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
@@ -1,6 +1,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use ri_measure::{Histogram, ImageStats};
|
||||
use ri_roi::{Roi, RoiEntry, RoiProperties};
|
||||
use ri_store::ImageStore;
|
||||
use ri_types::{ChannelState, DisplayRange, ImageId, ImageMeta, PixelValue};
|
||||
|
||||
@@ -216,7 +217,7 @@ fn run_op(
|
||||
(new, meta)
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
store.insert(new_hs);
|
||||
store.insert_with_parent(new_hs, Some(image_id), suffix.to_string());
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
@@ -273,3 +274,240 @@ pub fn op_histogram_equalize(
|
||||
) -> Result<ImageMeta, String> {
|
||||
run_op(&id, "[Equalize]", &store, ri_ops::histogram_equalize)
|
||||
}
|
||||
|
||||
// ── Save / Export ──
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_image(
|
||||
id: String,
|
||||
path: String,
|
||||
slice: u32,
|
||||
frame: u32,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<(), String> {
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
store
|
||||
.with_image(&image_id, |hs| {
|
||||
ri_io::save_image(hs, Path::new(&path), slice, frame)
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ── Undo ──
|
||||
|
||||
#[tauri::command]
|
||||
pub fn undo_operation(
|
||||
id: String,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
let parent_id = store
|
||||
.get_parent_id(&image_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(pid) = parent_id {
|
||||
store.remove(&image_id);
|
||||
Ok(Some(pid.to_string()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Morphological operations ──
|
||||
|
||||
#[tauri::command]
|
||||
pub fn op_dilate(
|
||||
id: String,
|
||||
radius: u32,
|
||||
shape: String,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<ImageMeta, String> {
|
||||
let se = parse_se_shape(&shape)?;
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
let (w, h) = store
|
||||
.with_image(&image_id, |hs| (hs.width(), hs.height()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
run_op(&id, &format!("[Dilate r={radius}]"), &store, |buf| {
|
||||
ri_ops::dilate(buf, w, h, radius, se)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn op_erode(
|
||||
id: String,
|
||||
radius: u32,
|
||||
shape: String,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<ImageMeta, String> {
|
||||
let se = parse_se_shape(&shape)?;
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
let (w, h) = store
|
||||
.with_image(&image_id, |hs| (hs.width(), hs.height()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
run_op(&id, &format!("[Erode r={radius}]"), &store, |buf| {
|
||||
ri_ops::erode(buf, w, h, radius, se)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn op_morph_open(
|
||||
id: String,
|
||||
radius: u32,
|
||||
shape: String,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<ImageMeta, String> {
|
||||
let se = parse_se_shape(&shape)?;
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
let (w, h) = store
|
||||
.with_image(&image_id, |hs| (hs.width(), hs.height()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
run_op(&id, &format!("[Open r={radius}]"), &store, |buf| {
|
||||
ri_ops::morph_open(buf, w, h, radius, se)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn op_morph_close(
|
||||
id: String,
|
||||
radius: u32,
|
||||
shape: String,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<ImageMeta, String> {
|
||||
let se = parse_se_shape(&shape)?;
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
let (w, h) = store
|
||||
.with_image(&image_id, |hs| (hs.width(), hs.height()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
run_op(&id, &format!("[Close r={radius}]"), &store, |buf| {
|
||||
ri_ops::morph_close(buf, w, h, radius, se)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_se_shape(s: &str) -> Result<ri_ops::SeShape, String> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"disk" => Ok(ri_ops::SeShape::Disk),
|
||||
"square" => Ok(ri_ops::SeShape::Square),
|
||||
"cross" => Ok(ri_ops::SeShape::Cross),
|
||||
_ => Err(format!("Unknown shape: {s}. Expected disk, square, or cross.")),
|
||||
}
|
||||
}
|
||||
|
||||
// ── ROI commands ──
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_roi(
|
||||
image_id: String,
|
||||
roi: Roi,
|
||||
name: String,
|
||||
color: String,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<RoiEntry, String> {
|
||||
let id: ImageId = image_id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
let props = RoiProperties {
|
||||
name,
|
||||
color,
|
||||
stroke_width: 1.0,
|
||||
};
|
||||
store
|
||||
.with_roi_manager_mut(&id, |mgr| mgr.add(roi, props))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_roi(
|
||||
image_id: String,
|
||||
roi_id: String,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<(), String> {
|
||||
let id: ImageId = image_id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
store
|
||||
.with_roi_manager_mut(&id, |mgr| {
|
||||
mgr.remove(&roi_id);
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_rois(
|
||||
image_id: String,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<Vec<RoiEntry>, String> {
|
||||
let id: ImageId = image_id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
store
|
||||
.with_roi_manager(&id, |mgr| mgr.list())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_roi(
|
||||
image_id: String,
|
||||
roi_id: String,
|
||||
roi: Roi,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<(), String> {
|
||||
let id: ImageId = image_id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
store
|
||||
.with_roi_manager_mut(&id, |mgr| mgr.update(&roi_id, roi))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn measure_roi(
|
||||
image_id: String,
|
||||
roi_id: String,
|
||||
channel: u32,
|
||||
slice: u32,
|
||||
frame: u32,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<ImageStats, String> {
|
||||
let id: ImageId = image_id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
|
||||
// Get the ROI
|
||||
let roi = store
|
||||
.with_roi_manager(&id, |mgr| mgr.get(&roi_id).cloned())
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("ROI not found: {roi_id}"))?;
|
||||
|
||||
// Get the plane and measure
|
||||
store
|
||||
.with_image(&id, |hs| {
|
||||
let w = hs.width();
|
||||
let h = hs.height();
|
||||
let plane = hs
|
||||
.get_plane(channel, slice, frame)
|
||||
.ok_or_else(|| format!("plane c={channel} z={slice} t={frame} not found"));
|
||||
plane.map(|p| ri_measure::compute_roi_stats(p, w, h, &roi.roi))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_line_profile(
|
||||
image_id: String,
|
||||
roi_id: String,
|
||||
channel: u32,
|
||||
slice: u32,
|
||||
frame: u32,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<Vec<(f64, f64)>, String> {
|
||||
let id: ImageId = image_id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
|
||||
let roi = store
|
||||
.with_roi_manager(&id, |mgr| mgr.get(&roi_id).cloned())
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("ROI not found: {roi_id}"))?;
|
||||
|
||||
store
|
||||
.with_image(&id, |hs| {
|
||||
let w = hs.width();
|
||||
let h = hs.height();
|
||||
let plane = hs
|
||||
.get_plane(channel, slice, frame)
|
||||
.ok_or_else(|| format!("plane not found"));
|
||||
plane.map(|p| ri_measure::line_profile(p, w, h, &roi.roi))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user