Initial commit: RustyImage M0-M2
Scientific image viewer/processor built with Tauri + React + Rust.
M0: Workspace setup, Tauri window, PNG display via ritile:// protocol
M1a: Multi-image tabs, zoom/pan, brightness/contrast, native file dialog,
keyboard shortcuts, status bar
M1b: RGB/multichannel composite display with per-channel LUTs,
visibility toggles, additive blending
M2: Pixel inspector, histogram/statistics panel, image processing
operations (invert, gaussian blur, median filter, threshold,
histogram equalization), Z/T stack navigation support
Architecture: 14-crate Cargo workspace (ri-types, ri-core, ri-io,
ri-lut, ri-render, ri-store, ri-ops, ri-measure, ri-ipc, ri-app,
plus phase 2-4 placeholders). React frontend with hooks-based state
management and component architecture.
39 tests, all passing.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
use std::path::Path;
|
||||
|
||||
use ri_measure::{Histogram, ImageStats};
|
||||
use ri_store::ImageStore;
|
||||
use ri_types::{ChannelState, DisplayRange, ImageId, ImageMeta, PixelValue};
|
||||
|
||||
// ── Image management ──
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_image(path: String, store: tauri::State<'_, ImageStore>) -> Result<ImageMeta, String> {
|
||||
let hyperstack = ri_io::load_image(Path::new(&path)).map_err(|e| e.to_string())?;
|
||||
let meta = hyperstack.meta.clone();
|
||||
store.insert(hyperstack);
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_open_images(store: tauri::State<'_, ImageStore>) -> Vec<ImageMeta> {
|
||||
store.list()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn close_image(id: String, store: tauri::State<'_, ImageStore>) -> Result<(), String> {
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
store
|
||||
.remove(&image_id)
|
||||
.map(|_| ())
|
||||
.ok_or_else(|| format!("Image not found: {id}"))
|
||||
}
|
||||
|
||||
// ── Channel state ──
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_channel_states(
|
||||
id: String,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<Vec<ChannelState>, String> {
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
store
|
||||
.get_channel_states(&image_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_channel_display_range(
|
||||
id: String,
|
||||
channel: u32,
|
||||
min: f64,
|
||||
max: f64,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<(), String> {
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
store
|
||||
.set_channel_display_range(&image_id, channel, DisplayRange { min, max })
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_channel_visible(
|
||||
id: String,
|
||||
channel: u32,
|
||||
visible: bool,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<(), String> {
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
store
|
||||
.set_channel_visible(&image_id, channel, visible)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn auto_channel_display_range(
|
||||
id: String,
|
||||
channel: u32,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<DisplayRange, String> {
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
let range = store
|
||||
.with_image(&image_id, |hs| {
|
||||
ri_store::compute_channel_data_range(hs, channel)
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
store
|
||||
.set_channel_display_range(&image_id, channel, range.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(range)
|
||||
}
|
||||
|
||||
// Backward-compatible convenience commands (operate on channel 0)
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_display_range(
|
||||
id: String,
|
||||
min: f64,
|
||||
max: f64,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<(), String> {
|
||||
set_channel_display_range(id, 0, min, max, store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_display_range(
|
||||
id: String,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<DisplayRange, String> {
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
store
|
||||
.with_entry(&image_id, |entry| {
|
||||
entry
|
||||
.channel_states
|
||||
.first()
|
||||
.map(|cs| cs.display_range.clone())
|
||||
.unwrap_or(DisplayRange {
|
||||
min: 0.0,
|
||||
max: 255.0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn auto_display_range(
|
||||
id: String,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<DisplayRange, String> {
|
||||
auto_channel_display_range(id, 0, store)
|
||||
}
|
||||
|
||||
// ── Pixel inspector ──
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_pixel_value(
|
||||
id: String,
|
||||
x: u32,
|
||||
y: u32,
|
||||
slice: u32,
|
||||
frame: u32,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<PixelValue, String> {
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
store
|
||||
.with_image(&image_id, |hs| {
|
||||
let w = hs.width();
|
||||
let channels = hs.meta.channels;
|
||||
let idx = (y * w + x) as usize;
|
||||
let values: Vec<f64> = (0..channels)
|
||||
.map(|c| {
|
||||
hs.get_plane(c, slice, frame)
|
||||
.and_then(|plane| plane.get_as_f64(idx))
|
||||
.unwrap_or(0.0)
|
||||
})
|
||||
.collect();
|
||||
PixelValue { x, y, values }
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ── Statistics & histogram ──
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_image_stats(
|
||||
id: String,
|
||||
channel: u32,
|
||||
slice: u32,
|
||||
frame: u32,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<ImageStats, String> {
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
store
|
||||
.with_image(&image_id, |hs| {
|
||||
let plane = hs
|
||||
.get_plane(channel, slice, frame)
|
||||
.ok_or_else(|| format!("plane c={channel} z={slice} t={frame} not found"));
|
||||
plane.map(ri_measure::compute_stats)
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_histogram(
|
||||
id: String,
|
||||
channel: u32,
|
||||
slice: u32,
|
||||
frame: u32,
|
||||
bin_count: u32,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<Histogram, String> {
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
store
|
||||
.with_image(&image_id, |hs| {
|
||||
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_histogram(p, bin_count, None))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ── Image processing operations ──
|
||||
|
||||
fn run_op(
|
||||
id: &str,
|
||||
suffix: &str,
|
||||
store: &tauri::State<'_, ImageStore>,
|
||||
op: impl Fn(&ri_core::TypedBuffer) -> ri_core::TypedBuffer,
|
||||
) -> Result<ImageMeta, String> {
|
||||
let image_id: ImageId = id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||
let (new_hs, meta) = store
|
||||
.with_image(&image_id, |hs| {
|
||||
let mut new = hs.map_all_planes(&op);
|
||||
new.meta.id = ri_types::ImageId::new();
|
||||
new.meta.name = format!("{} {suffix}", hs.meta.name);
|
||||
let meta = new.meta.clone();
|
||||
(new, meta)
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
store.insert(new_hs);
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn op_invert(id: String, store: tauri::State<'_, ImageStore>) -> Result<ImageMeta, String> {
|
||||
run_op(&id, "[Invert]", &store, ri_ops::invert)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn op_gaussian_blur(
|
||||
id: String,
|
||||
sigma: f64,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<ImageMeta, String> {
|
||||
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!("[Gauss s={sigma:.1}]"), &store, |buf| {
|
||||
ri_ops::gaussian_blur(buf, w, h, sigma)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn op_median_filter(
|
||||
id: String,
|
||||
radius: u32,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<ImageMeta, String> {
|
||||
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!("[Median r={radius}]"), &store, |buf| {
|
||||
ri_ops::median_filter(buf, w, h, radius)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn op_threshold(
|
||||
id: String,
|
||||
value: f64,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<ImageMeta, String> {
|
||||
run_op(&id, &format!("[Thresh {value:.0}]"), &store, |buf| {
|
||||
ri_ops::threshold(buf, value)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn op_histogram_equalize(
|
||||
id: String,
|
||||
store: tauri::State<'_, ImageStore>,
|
||||
) -> Result<ImageMeta, String> {
|
||||
run_op(&id, "[Equalize]", &store, ri_ops::histogram_equalize)
|
||||
}
|
||||
Reference in New Issue
Block a user