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:
Omar Sobh
2026-03-09 21:19:47 -07:00
co-authored by Claude Opus 4.6
commit bdbfe49310
75 changed files with 13278 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
use std::path::Path;
use image::{ColorType, GenericImageView};
use ri_core::{Hyperstack, TypedBuffer};
use ri_types::{ImageId, ImageMeta, PixelType, RiError};
/// Load an image file into a Hyperstack.
///
/// Preserves channel structure: grayscale → 1ch, RGB → 3ch, RGBA → 4ch.
/// Supports PNG, JPEG, TIFF, BMP, GIF via the `image` crate.
pub fn load_image(path: &Path) -> Result<Hyperstack, RiError> {
let img = image::open(path).map_err(|e| RiError::ImageDecode(e.to_string()))?;
let file_size_bytes = std::fs::metadata(path).map(|m| m.len()).ok();
let (w, h) = img.dimensions();
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "untitled".into());
match img.color() {
ColorType::Rgb8 | ColorType::Rgb16 | ColorType::Rgb32F => {
let rgb = img.to_rgb8();
let raw = rgb.into_raw();
let npix = (w * h) as usize;
let mut r = Vec::with_capacity(npix);
let mut g = Vec::with_capacity(npix);
let mut b = Vec::with_capacity(npix);
for i in 0..npix {
r.push(raw[i * 3]);
g.push(raw[i * 3 + 1]);
b.push(raw[i * 3 + 2]);
}
let meta = ImageMeta {
id: ImageId::new(),
name,
width: w,
height: h,
channels: 3,
slices: 1,
frames: 1,
pixel_type: PixelType::U8,
file_size_bytes,
};
Ok(Hyperstack::from_channel_planes(
meta,
vec![
TypedBuffer::U8(r),
TypedBuffer::U8(g),
TypedBuffer::U8(b),
],
))
}
ColorType::Rgba8 | ColorType::Rgba16 | ColorType::Rgba32F => {
let rgba = img.to_rgba8();
let raw = rgba.into_raw();
let npix = (w * h) as usize;
let mut r = Vec::with_capacity(npix);
let mut g = Vec::with_capacity(npix);
let mut b = Vec::with_capacity(npix);
let mut a = Vec::with_capacity(npix);
for i in 0..npix {
r.push(raw[i * 4]);
g.push(raw[i * 4 + 1]);
b.push(raw[i * 4 + 2]);
a.push(raw[i * 4 + 3]);
}
let meta = ImageMeta {
id: ImageId::new(),
name,
width: w,
height: h,
channels: 4,
slices: 1,
frames: 1,
pixel_type: PixelType::U8,
file_size_bytes,
};
Ok(Hyperstack::from_channel_planes(
meta,
vec![
TypedBuffer::U8(r),
TypedBuffer::U8(g),
TypedBuffer::U8(b),
TypedBuffer::U8(a),
],
))
}
_ => {
// Grayscale and other formats → single channel
let gray = img.to_luma8();
let pixels = gray.into_raw();
let meta = ImageMeta {
id: ImageId::new(),
name,
width: w,
height: h,
channels: 1,
slices: 1,
frames: 1,
pixel_type: PixelType::U8,
file_size_bytes,
};
Ok(Hyperstack::from_single_plane(
meta,
TypedBuffer::U8(pixels),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_nonexistent_file_returns_error() {
let result = load_image(Path::new("/nonexistent/file.png"));
assert!(result.is_err());
}
}