M20: Deep learning, large images, GPU shaders, parallelism, interoperability (638 tests)

1. Neural Net Inference (new ri-nn crate): Conv2D, ReLU, Sigmoid, Softmax,
   MaxPool2D, BatchNorm, Dense, Upsample, U-Net architecture, StarDist-like
   detector — all from scratch, no ML deps
2. Virtual Stacks (new ri-virtual crate): VirtualStack with lazy loading +
   LRU cache, TiledImage for large 2D, multi-resolution pyramid
3. GPU Shaders: 7 new wgsl shaders (GLCM, optical flow, EDT, connected
   components, color deconvolution, separable convolution, 2D histogram)
4. Image Sequences: load/save numbered files, natural sorting, MJPEG AVI,
   Y4M export with ffmpeg command generation
5. ImageJ Interop: .roi binary format read/write, ROI zip sets, .lut format,
   Results Table CSV import/export
6. Parallel Processing: rayon for bilateral, median, EDT, connected components,
   particle analysis, ML features, random forest — set_parallelism config
7. Quality Metrics: PSNR, SSIM, MS-SSIM, NRMSE, UQI, VIF, noise estimation,
   SNR, contrast-to-noise ratio
8. Integration Tests (new ri-integration-tests crate): 15 end-to-end tests
   covering full pipelines across multiple crates
This commit is contained in:
osobh
2026-03-14 15:16:13 -07:00
parent 35043e3052
commit 1c4de8656b
28 changed files with 5941 additions and 0 deletions
+480
View File
@@ -0,0 +1,480 @@
//! Image quality metrics for comparing reference and test images.
//!
//! Includes PSNR, SSIM, MS-SSIM, NRMSE, UQI, VIF, noise estimation, SNR, and CNR.
use ri_core::TypedBuffer;
fn to_f64_vec(buf: &TypedBuffer) -> Vec<f64> {
match buf {
TypedBuffer::U8(v) => v.iter().map(|&x| x as f64).collect(),
TypedBuffer::U16(v) => v.iter().map(|&x| x as f64).collect(),
TypedBuffer::F32(v) => v.iter().map(|&x| x as f64).collect(),
}
}
fn max_pixel_value(buf: &TypedBuffer) -> f64 {
match buf {
TypedBuffer::U8(_) => 255.0,
TypedBuffer::U16(_) => 65535.0,
TypedBuffer::F32(_) => 1.0,
}
}
/// Peak Signal-to-Noise Ratio (PSNR) in dB.
pub fn psnr(reference: &TypedBuffer, test: &TypedBuffer) -> f64 {
let ref_data = to_f64_vec(reference);
let test_data = to_f64_vec(test);
assert_eq!(ref_data.len(), test_data.len());
let mse: f64 = ref_data
.iter()
.zip(test_data.iter())
.map(|(r, t)| (r - t) * (r - t))
.sum::<f64>()
/ ref_data.len() as f64;
if mse == 0.0 {
return f64::INFINITY;
}
let max_val = max_pixel_value(reference);
10.0 * (max_val * max_val / mse).log10()
}
/// Structural Similarity Index (SSIM).
/// Returns mean SSIM over all local windows.
pub fn ssim(reference: &TypedBuffer, test: &TypedBuffer, width: u32, height: u32) -> f64 {
ssim_map(reference, test, width, height).0
}
/// Compute SSIM returning (mean_ssim, ssim_map).
fn ssim_map(reference: &TypedBuffer, test: &TypedBuffer, width: u32, height: u32) -> (f64, Vec<f64>) {
let ref_data = to_f64_vec(reference);
let test_data = to_f64_vec(test);
let w = width as usize;
let h = height as usize;
let max_val = max_pixel_value(reference);
let c1 = (0.01 * max_val) * (0.01 * max_val);
let c2 = (0.03 * max_val) * (0.03 * max_val);
let window_size = 7usize;
let half = window_size / 2;
let mut ssim_values = Vec::new();
for y in half..h.saturating_sub(half) {
for x in half..w.saturating_sub(half) {
let mut sum_r = 0.0;
let mut sum_t = 0.0;
let mut sum_r2 = 0.0;
let mut sum_t2 = 0.0;
let mut sum_rt = 0.0;
let mut count = 0.0;
for wy in 0..window_size {
for wx in 0..window_size {
let py = y - half + wy;
let px = x - half + wx;
if py < h && px < w {
let r = ref_data[py * w + px];
let t = test_data[py * w + px];
sum_r += r;
sum_t += t;
sum_r2 += r * r;
sum_t2 += t * t;
sum_rt += r * t;
count += 1.0;
}
}
}
let mu_r = sum_r / count;
let mu_t = sum_t / count;
let sigma_r2 = sum_r2 / count - mu_r * mu_r;
let sigma_t2 = sum_t2 / count - mu_t * mu_t;
let sigma_rt = sum_rt / count - mu_r * mu_t;
let numerator = (2.0 * mu_r * mu_t + c1) * (2.0 * sigma_rt + c2);
let denominator = (mu_r * mu_r + mu_t * mu_t + c1) * (sigma_r2 + sigma_t2 + c2);
ssim_values.push(numerator / denominator);
}
}
let mean = if ssim_values.is_empty() {
1.0
} else {
ssim_values.iter().sum::<f64>() / ssim_values.len() as f64
};
(mean, ssim_values)
}
/// Multi-Scale SSIM.
pub fn ms_ssim(reference: &TypedBuffer, test: &TypedBuffer, width: u32, height: u32) -> f64 {
let weights = [0.0448, 0.2856, 0.3001, 0.2363, 0.1333];
let mut ref_data = to_f64_vec(reference);
let mut test_data = to_f64_vec(test);
let mut w = width as usize;
let mut h = height as usize;
let mut result = 1.0;
for (level, &weight) in weights.iter().enumerate() {
if w < 8 || h < 8 {
break;
}
let ref_buf = TypedBuffer::F32(ref_data.iter().map(|&v| v as f32).collect());
let test_buf = TypedBuffer::F32(test_data.iter().map(|&v| v as f32).collect());
let (s, _) = ssim_map(&ref_buf, &test_buf, w as u32, h as u32);
if level == weights.len() - 1 {
result *= s.powf(weight);
} else {
// Just use SSIM contrast/structure for intermediate scales
result *= s.max(0.0).powf(weight);
}
// Downsample by 2
let new_w = w / 2;
let new_h = h / 2;
if new_w == 0 || new_h == 0 { break; }
let mut new_ref = vec![0.0; new_w * new_h];
let mut new_test = vec![0.0; new_w * new_h];
for y in 0..new_h {
for x in 0..new_w {
let sy = y * 2;
let sx = x * 2;
new_ref[y * new_w + x] = (ref_data[sy * w + sx]
+ ref_data[sy * w + sx + 1]
+ ref_data[(sy + 1) * w + sx]
+ ref_data[(sy + 1) * w + sx + 1]) / 4.0;
new_test[y * new_w + x] = (test_data[sy * w + sx]
+ test_data[sy * w + sx + 1]
+ test_data[(sy + 1) * w + sx]
+ test_data[(sy + 1) * w + sx + 1]) / 4.0;
}
}
ref_data = new_ref;
test_data = new_test;
w = new_w;
h = new_h;
}
result
}
/// Normalized Root Mean Square Error.
pub fn nrmse(reference: &TypedBuffer, test: &TypedBuffer) -> f64 {
let ref_data = to_f64_vec(reference);
let test_data = to_f64_vec(test);
assert_eq!(ref_data.len(), test_data.len());
let mse: f64 = ref_data
.iter()
.zip(test_data.iter())
.map(|(r, t)| (r - t) * (r - t))
.sum::<f64>()
/ ref_data.len() as f64;
let rmse = mse.sqrt();
let ref_min = ref_data.iter().cloned().fold(f64::MAX, f64::min);
let ref_max = ref_data.iter().cloned().fold(f64::MIN, f64::max);
let range = ref_max - ref_min;
if range == 0.0 {
return 0.0;
}
rmse / range
}
/// Universal Quality Index (UQI).
pub fn uqi(reference: &TypedBuffer, test: &TypedBuffer) -> f64 {
let ref_data = to_f64_vec(reference);
let test_data = to_f64_vec(test);
let n = ref_data.len() as f64;
let mu_r = ref_data.iter().sum::<f64>() / n;
let mu_t = test_data.iter().sum::<f64>() / n;
let sigma_r2: f64 = ref_data.iter().map(|&r| (r - mu_r) * (r - mu_r)).sum::<f64>() / n;
let sigma_t2: f64 = test_data.iter().map(|&t| (t - mu_t) * (t - mu_t)).sum::<f64>() / n;
let sigma_rt: f64 = ref_data.iter().zip(test_data.iter())
.map(|(&r, &t)| (r - mu_r) * (t - mu_t)).sum::<f64>() / n;
let numerator = 4.0 * sigma_rt * mu_r * mu_t;
let denominator = (sigma_r2 + sigma_t2) * (mu_r * mu_r + mu_t * mu_t);
if denominator == 0.0 { 1.0 } else { numerator / denominator }
}
/// Simplified Visual Information Fidelity (VIF).
/// Uses a simple single-scale approach based on local statistics.
pub fn vif(reference: &TypedBuffer, test: &TypedBuffer, width: u32, height: u32) -> f64 {
let ref_data = to_f64_vec(reference);
let test_data = to_f64_vec(test);
let w = width as usize;
let h = height as usize;
let block = 3usize;
let mut num = 0.0;
let mut den = 0.0;
let sigma_nsq = 2.0; // noise variance estimate
for y in (0..h - block).step_by(block) {
for x in (0..w - block).step_by(block) {
let mut sum_r = 0.0;
let mut sum_t = 0.0;
let mut sum_r2 = 0.0;
let mut sum_t2 = 0.0;
let mut sum_rt = 0.0;
let n = (block * block) as f64;
for by in 0..block {
for bx in 0..block {
let r = ref_data[(y + by) * w + (x + bx)];
let t = test_data[(y + by) * w + (x + bx)];
sum_r += r;
sum_t += t;
sum_r2 += r * r;
sum_t2 += t * t;
sum_rt += r * t;
}
}
let mu_r = sum_r / n;
let mu_t = sum_t / n;
let sigma_r2 = (sum_r2 / n - mu_r * mu_r).max(0.0);
let sigma_t2 = (sum_t2 / n - mu_t * mu_t).max(0.0);
let sigma_rt = sum_rt / n - mu_r * mu_t;
let g = if sigma_r2 > 0.0 { sigma_rt / sigma_r2 } else { 0.0 };
let sigma_v2 = (sigma_t2 - g * sigma_rt).max(0.0);
num += (1.0 + (g * g * sigma_r2) / (sigma_v2 + sigma_nsq)).ln();
den += (1.0 + sigma_r2 / sigma_nsq).ln();
}
}
if den == 0.0 { 1.0 } else { num / den }
}
/// Estimate noise level using MAD (Median Absolute Deviation) of the Laplacian.
pub fn noise_estimation(buffer: &TypedBuffer, width: u32, height: u32) -> f64 {
let data = to_f64_vec(buffer);
let w = width as usize;
let h = height as usize;
// Apply Laplacian
let mut laplacian_values = Vec::with_capacity((w - 2) * (h - 2));
for y in 1..h - 1 {
for x in 1..w - 1 {
let lap = -data[(y - 1) * w + x]
- data[y * w + (x - 1)]
+ 4.0 * data[y * w + x]
- data[y * w + (x + 1)]
- data[(y + 1) * w + x];
laplacian_values.push(lap.abs());
}
}
laplacian_values.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = if laplacian_values.is_empty() {
0.0
} else {
laplacian_values[laplacian_values.len() / 2]
};
// MAD estimator: sigma = median / 0.6745 * sqrt(2) factor for Laplacian
median * 1.4826 / (2.0f64.sqrt())
}
/// Signal-to-Noise Ratio estimate (mean / std_dev).
pub fn snr(buffer: &TypedBuffer) -> f64 {
let data = to_f64_vec(buffer);
let n = data.len() as f64;
let mean = data.iter().sum::<f64>() / n;
let variance = data.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / n;
let std_dev = variance.sqrt();
if std_dev == 0.0 { f64::INFINITY } else { mean / std_dev }
}
/// Contrast-to-Noise Ratio between a signal region and a background region.
/// `signal_roi` and `background_roi` are lists of pixel indices.
pub fn contrast_to_noise(
buffer: &TypedBuffer,
signal_pixels: &[usize],
background_pixels: &[usize],
) -> f64 {
let data = to_f64_vec(buffer);
let signal_mean = signal_pixels.iter()
.map(|&i| data[i])
.sum::<f64>() / signal_pixels.len() as f64;
let bg_mean = background_pixels.iter()
.map(|&i| data[i])
.sum::<f64>() / background_pixels.len() as f64;
let bg_variance = background_pixels.iter()
.map(|&i| (data[i] - bg_mean) * (data[i] - bg_mean))
.sum::<f64>() / background_pixels.len() as f64;
let bg_std = bg_variance.sqrt();
if bg_std == 0.0 {
f64::INFINITY
} else {
(signal_mean - bg_mean).abs() / bg_std
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn psnr_identical() {
let buf = TypedBuffer::U8(vec![100; 64]);
let p = psnr(&buf, &buf);
assert!(p.is_infinite());
}
#[test]
fn psnr_different() {
let ref_buf = TypedBuffer::U8(vec![100; 64]);
let test_buf = TypedBuffer::U8(vec![110; 64]);
let p = psnr(&ref_buf, &test_buf);
assert!(p > 0.0);
assert!(p < 100.0);
// Expected: 10 * log10(255^2 / 100) ≈ 28.13
assert!((p - 28.13).abs() < 1.0);
}
#[test]
fn ssim_identical() {
let buf = TypedBuffer::U8(vec![128; 64]);
let s = ssim(&buf, &buf, 8, 8);
assert!((s - 1.0).abs() < 0.01);
}
#[test]
fn ssim_different() {
let ref_buf = TypedBuffer::U8(vec![100; 64]);
let test_buf = TypedBuffer::U8(vec![200; 64]);
let s = ssim(&ref_buf, &test_buf, 8, 8);
assert!(s < 1.0);
}
#[test]
fn ms_ssim_identical() {
let buf = TypedBuffer::U8(vec![128; 128 * 128]);
let s = ms_ssim(&buf, &buf, 128, 128);
assert!((s - 1.0).abs() < 0.01);
}
#[test]
fn nrmse_identical() {
let buf = TypedBuffer::U8(vec![100; 64]);
let n = nrmse(&buf, &buf);
assert!((n - 0.0).abs() < 1e-10);
}
#[test]
fn nrmse_different() {
let ref_buf = TypedBuffer::U8((0..64).map(|i| i as u8 * 4).collect());
let test_buf = TypedBuffer::U8((0..64).map(|i| (i as u8 * 4).wrapping_add(10)).collect());
let n = nrmse(&ref_buf, &test_buf);
assert!(n > 0.0);
assert!(n < 1.0);
}
#[test]
fn uqi_identical() {
let buf = TypedBuffer::U8(vec![100, 150, 200, 50]);
let q = uqi(&buf, &buf);
assert!((q - 1.0).abs() < 0.01);
}
#[test]
fn uqi_different() {
let ref_buf = TypedBuffer::U8(vec![100, 150, 200, 50]);
let test_buf = TypedBuffer::U8(vec![50, 200, 100, 150]);
let q = uqi(&ref_buf, &test_buf);
assert!(q < 1.0);
}
#[test]
fn vif_identical() {
let buf = TypedBuffer::F32((0..81).map(|i| i as f32 / 81.0).collect());
let v = vif(&buf, &buf, 9, 9);
assert!(v > 0.9);
}
#[test]
fn noise_estimation_uniform() {
let buf = TypedBuffer::U8(vec![128; 100]);
let n = noise_estimation(&buf, 10, 10);
assert!(n < 1.0); // Uniform image → near-zero noise
}
#[test]
fn noise_estimation_noisy() {
// Create a noisy image
let mut data: Vec<u8> = Vec::with_capacity(100);
for i in 0..100 {
data.push(if i % 2 == 0 { 100 } else { 200 });
}
let buf = TypedBuffer::U8(data);
let n = noise_estimation(&buf, 10, 10);
assert!(n > 0.0);
}
#[test]
fn snr_uniform() {
let buf = TypedBuffer::U8(vec![128; 100]);
let s = snr(&buf);
assert!(s.is_infinite()); // No variance → infinite SNR
}
#[test]
fn snr_variable() {
let buf = TypedBuffer::F32(vec![10.0, 12.0, 8.0, 11.0, 9.0]);
let s = snr(&buf);
assert!(s > 0.0);
assert!(s.is_finite());
}
#[test]
fn cnr_basic() {
let buf = TypedBuffer::F32(vec![
100.0, 102.0, 98.0, 101.0, // Signal region (indices 0-3)
10.0, 12.0, 8.0, 11.0, // Background region (indices 4-7)
]);
let signal = vec![0, 1, 2, 3];
let background = vec![4, 5, 6, 7];
let c = contrast_to_noise(&buf, &signal, &background);
assert!(c > 10.0); // Large contrast relative to background noise
}
#[test]
fn psnr_f32() {
let ref_buf = TypedBuffer::F32(vec![0.5; 64]);
let test_buf = TypedBuffer::F32(vec![0.6; 64]);
let p = psnr(&ref_buf, &test_buf);
assert!(p > 0.0);
}
#[test]
fn ssim_window_edge() {
// Very small image where SSIM window can barely fit
let buf = TypedBuffer::U8(vec![128; 49]);
let s = ssim(&buf, &buf, 7, 7);
// Should still produce a reasonable result
assert!(s >= 0.0);
}
}