Add M3: ROIs, save/export, undo, and morphological operations
- ri-roi: Full ROI system (Rectangle, Ellipse, Line, Point, Polygon, Freehand) with RoiManager, containment tests, pixel iteration, and Bresenham line sampling - ri-morph: Morphological operations (dilate, erode, open, close) with configurable structuring elements (Disk, Square, Cross) - ri-io: save_image() supporting PNG, JPEG, TIFF, BMP with channel re-interleaving - ri-measure: ROI-based stats/histogram and line intensity profile - ri-store: Parent tracking for undo, per-image RoiManager - ri-ipc: 12 new commands (save, undo, 4 morph ops, 6 ROI commands) - Frontend: ROI drawing overlay (SVG), ROI toolbar, measurement panel, line profile chart, save dialog, undo button, morphology in Process menu 57 tests pass, frontend builds clean. Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
bdbfe49310
commit
b35ef361ac
@@ -4,6 +4,134 @@ use image::{ColorType, GenericImageView};
|
||||
use ri_core::{Hyperstack, TypedBuffer};
|
||||
use ri_types::{ImageId, ImageMeta, PixelType, RiError};
|
||||
|
||||
/// Save a hyperstack to a file. Format is detected from the extension.
|
||||
/// Saves a single plane (specified slice/frame). Multi-channel images are interleaved.
|
||||
pub fn save_image(
|
||||
hyperstack: &Hyperstack,
|
||||
path: &Path,
|
||||
slice: u32,
|
||||
frame: u32,
|
||||
) -> Result<(), RiError> {
|
||||
let w = hyperstack.meta.width;
|
||||
let h = hyperstack.meta.height;
|
||||
let channels = hyperstack.meta.channels;
|
||||
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build RGBA pixel data
|
||||
let npix = (w * h) as usize;
|
||||
let mut rgba = vec![0u8; npix * 4];
|
||||
|
||||
if channels == 1 {
|
||||
let plane = hyperstack
|
||||
.get_plane(0, slice, frame)
|
||||
.ok_or_else(|| RiError::InvalidArgs("plane not found".into()))?;
|
||||
for i in 0..npix {
|
||||
let v = to_u8(plane, i);
|
||||
rgba[i * 4] = v;
|
||||
rgba[i * 4 + 1] = v;
|
||||
rgba[i * 4 + 2] = v;
|
||||
rgba[i * 4 + 3] = 255;
|
||||
}
|
||||
} else if channels == 3 {
|
||||
let r = hyperstack.get_plane(0, slice, frame);
|
||||
let g = hyperstack.get_plane(1, slice, frame);
|
||||
let b = hyperstack.get_plane(2, slice, frame);
|
||||
if r.is_none() || g.is_none() || b.is_none() {
|
||||
return Err(RiError::InvalidArgs("missing channel plane".into()));
|
||||
}
|
||||
let (r, g, b) = (r.unwrap(), g.unwrap(), b.unwrap());
|
||||
for i in 0..npix {
|
||||
rgba[i * 4] = to_u8(r, i);
|
||||
rgba[i * 4 + 1] = to_u8(g, i);
|
||||
rgba[i * 4 + 2] = to_u8(b, i);
|
||||
rgba[i * 4 + 3] = 255;
|
||||
}
|
||||
} else if channels >= 4 {
|
||||
let r = hyperstack.get_plane(0, slice, frame);
|
||||
let g = hyperstack.get_plane(1, slice, frame);
|
||||
let b = hyperstack.get_plane(2, slice, frame);
|
||||
let a = hyperstack.get_plane(3, slice, frame);
|
||||
if r.is_none() || g.is_none() || b.is_none() || a.is_none() {
|
||||
return Err(RiError::InvalidArgs("missing channel plane".into()));
|
||||
}
|
||||
let (r, g, b, a) = (r.unwrap(), g.unwrap(), b.unwrap(), a.unwrap());
|
||||
for i in 0..npix {
|
||||
rgba[i * 4] = to_u8(r, i);
|
||||
rgba[i * 4 + 1] = to_u8(g, i);
|
||||
rgba[i * 4 + 2] = to_u8(b, i);
|
||||
rgba[i * 4 + 3] = to_u8(a, i);
|
||||
}
|
||||
} else {
|
||||
// 2 channels: treat as gray + alpha
|
||||
let c0 = hyperstack
|
||||
.get_plane(0, slice, frame)
|
||||
.ok_or_else(|| RiError::InvalidArgs("plane not found".into()))?;
|
||||
let c1 = hyperstack
|
||||
.get_plane(1, slice, frame)
|
||||
.ok_or_else(|| RiError::InvalidArgs("plane not found".into()))?;
|
||||
for i in 0..npix {
|
||||
let v = to_u8(c0, i);
|
||||
rgba[i * 4] = v;
|
||||
rgba[i * 4 + 1] = v;
|
||||
rgba[i * 4 + 2] = v;
|
||||
rgba[i * 4 + 3] = to_u8(c1, i);
|
||||
}
|
||||
}
|
||||
|
||||
match ext.as_str() {
|
||||
"png" => {
|
||||
let file = std::fs::File::create(path)?;
|
||||
let mut encoder = png::Encoder::new(file, w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder
|
||||
.write_header()
|
||||
.map_err(|e| RiError::ImageDecode(e.to_string()))?;
|
||||
writer
|
||||
.write_image_data(&rgba)
|
||||
.map_err(|e| RiError::ImageDecode(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
"jpg" | "jpeg" => {
|
||||
// Convert RGBA to RGB for JPEG
|
||||
let mut rgb = vec![0u8; npix * 3];
|
||||
for i in 0..npix {
|
||||
rgb[i * 3] = rgba[i * 4];
|
||||
rgb[i * 3 + 1] = rgba[i * 4 + 1];
|
||||
rgb[i * 3 + 2] = rgba[i * 4 + 2];
|
||||
}
|
||||
let img_buf = image::RgbImage::from_raw(w, h, rgb)
|
||||
.ok_or_else(|| RiError::ImageDecode("failed to create image buffer".into()))?;
|
||||
img_buf
|
||||
.save(path)
|
||||
.map_err(|e| RiError::ImageDecode(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
// TIFF, BMP, etc. — use image crate
|
||||
let img_buf = image::RgbaImage::from_raw(w, h, rgba)
|
||||
.ok_or_else(|| RiError::ImageDecode("failed to create image buffer".into()))?;
|
||||
img_buf
|
||||
.save(path)
|
||||
.map_err(|e| RiError::ImageDecode(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_u8(buf: &TypedBuffer, idx: usize) -> u8 {
|
||||
match buf {
|
||||
TypedBuffer::U8(v) => v.get(idx).copied().unwrap_or(0),
|
||||
TypedBuffer::U16(v) => v.get(idx).map(|&x| (x >> 8) as u8).unwrap_or(0),
|
||||
TypedBuffer::F32(v) => v.get(idx).map(|&x| (x.clamp(0.0, 1.0) * 255.0) as u8).unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load an image file into a Hyperstack.
|
||||
///
|
||||
/// Preserves channel structure: grayscale → 1ch, RGB → 3ch, RGBA → 4ch.
|
||||
|
||||
Reference in New Issue
Block a user