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:
Omar Sobh
2026-03-09 21:49:20 -07:00
co-authored by Claude Opus 4.6
parent bdbfe49310
commit b35ef361ac
25 changed files with 1920 additions and 46 deletions
+128
View File
@@ -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.