//! Learnable stiffness texture for MRE inverse problem //! //! The stiffness field mu(x,y) is represented as a 2D texture (grid) rather //! than a neural network. This allows for sharp discontinuities (tumor edges) //! that neural networks tend to over-smooth. //! //! Key features: //! - Bilinear interpolation for smooth sampling between grid points //! - Finite difference gradients (mu_x, mu_y) for physics loss //! - Direct gradient descent on texture pixels use crate::config::MreConfig; use anyhow::Result; use mre_shared::StiffnessField; use rtx_tensor::{Device, Tensor}; /// Learnable stiffness texture pub struct StiffnessTexture { /// Stiffness values [ny, nx] in non-dimensional units values: Tensor, /// Grid dimensions nx: usize, ny: usize, /// Physical domain bounds (non-dimensional: [0, 1] x [0, 1]) x_min: f32, x_max: f32, y_min: f32, y_max: f32, /// Minimum allowed stiffness (non-dimensional) min_stiffness: f32, /// Device device: Device, } impl StiffnessTexture { /// Create a new stiffness texture with uniform initial value pub fn new(config: &MreConfig, device: &Device) -> Result { let nx = config.stiffness_nx; let ny = config.stiffness_ny; // Initialize with uniform value = 1.0 (reference stiffness in non-dim) let init_value = 1.0; let n = nx * ny; let data = vec![init_value; n]; let values = Tensor::from_data(data, vec![ny, nx], device)?; Ok(Self { values, nx, ny, x_min: 0.0, x_max: 1.0, // Non-dimensional domain y_min: 0.0, y_max: 1.0, min_stiffness: config.min_stiffness, device: device.clone(), }) } /// Initialize from a ground truth stiffness field pub fn from_field(field: &StiffnessField, config: &MreConfig, device: &Device) -> Result { let (nx, ny) = field.resolution; // Convert to non-dimensional values let values_nondim: Vec = field .values .iter() .map(|&v| config.nondim.nondim_stiffness_kpa(v)) .collect(); let values = Tensor::from_data(values_nondim, vec![ny, nx], device)?; Ok(Self { values, nx, ny, x_min: 0.0, x_max: 1.0, y_min: 0.0, y_max: 1.0, min_stiffness: config.min_stiffness, device: device.clone(), }) } /// Sample stiffness at arbitrary points using bilinear interpolation /// /// # Arguments /// * `x` - x-coordinates [batch] (non-dimensional, in [0, 1]) /// * `y` - y-coordinates [batch] /// /// # Returns /// Tensor [batch] with interpolated stiffness values pub fn sample(&self, x: &Tensor, y: &Tensor) -> Result { let batch_size = x.shape().dims()[0]; let x_data = x.to_cpu()?; let y_data = y.to_cpu()?; let values_data = self.values.to_cpu()?; let mut result = Vec::with_capacity(batch_size); for i in 0..batch_size { let xi = x_data[i]; let yi = y_data[i]; let mu = self.bilinear_interpolate(&values_data, xi, yi); result.push(mu.max(self.min_stiffness)); } Ok(Tensor::from_data(result, vec![batch_size], &self.device)?) } /// Sample stiffness and its spatial gradients /// /// Uses finite differences on the texture for gradient computation. /// /// # Returns /// (mu, mu_x, mu_y) - stiffness and its gradients pub fn sample_with_gradients( &self, x: &Tensor, y: &Tensor, ) -> Result<(Tensor, Tensor, Tensor)> { let batch_size = x.shape().dims()[0]; let x_data = x.to_cpu()?; let y_data = y.to_cpu()?; let values_data = self.values.to_cpu()?; let dx = (self.x_max - self.x_min) / (self.nx - 1).max(1) as f32; let dy = (self.y_max - self.y_min) / (self.ny - 1).max(1) as f32; let mut mu_result = Vec::with_capacity(batch_size); let mut mu_x_result = Vec::with_capacity(batch_size); let mut mu_y_result = Vec::with_capacity(batch_size); for i in 0..batch_size { let xi = x_data[i]; let yi = y_data[i]; // Sample mu at current point let mu = self.bilinear_interpolate(&values_data, xi, yi); // Central difference for gradients let mu_xp = self.bilinear_interpolate(&values_data, xi + dx, yi); let mu_xm = self.bilinear_interpolate(&values_data, xi - dx, yi); let mu_yp = self.bilinear_interpolate(&values_data, xi, yi + dy); let mu_ym = self.bilinear_interpolate(&values_data, xi, yi - dy); let mu_x = (mu_xp - mu_xm) / (2.0 * dx); let mu_y = (mu_yp - mu_ym) / (2.0 * dy); mu_result.push(mu.max(self.min_stiffness)); mu_x_result.push(mu_x); mu_y_result.push(mu_y); } let mu = Tensor::from_data(mu_result, vec![batch_size], &self.device)?; let mu_x = Tensor::from_data(mu_x_result, vec![batch_size], &self.device)?; let mu_y = Tensor::from_data(mu_y_result, vec![batch_size], &self.device)?; Ok((mu, mu_x, mu_y)) } /// Bilinear interpolation helper fn bilinear_interpolate(&self, data: &[f32], x: f32, y: f32) -> f32 { // Convert to texture coordinates let tx = (x - self.x_min) / (self.x_max - self.x_min); let ty = (y - self.y_min) / (self.y_max - self.y_min); // Scale to grid indices let fx = tx * (self.nx - 1) as f32; let fy = ty * (self.ny - 1) as f32; // Floor indices let ix0 = (fx.floor() as usize).min(self.nx - 1); let iy0 = (fy.floor() as usize).min(self.ny - 1); let ix1 = (ix0 + 1).min(self.nx - 1); let iy1 = (iy0 + 1).min(self.ny - 1); // Fractional parts (weights) let wx1 = fx - ix0 as f32; let wy1 = fy - iy0 as f32; let wx0 = 1.0 - wx1; let wy0 = 1.0 - wy1; // Gather values at four corners let v00 = data[iy0 * self.nx + ix0]; let v01 = data[iy0 * self.nx + ix1]; let v10 = data[iy1 * self.nx + ix0]; let v11 = data[iy1 * self.nx + ix1]; // Bilinear interpolation v00 * wx0 * wy0 + v01 * wx1 * wy0 + v10 * wx0 * wy1 + v11 * wx1 * wy1 } /// Apply gradient update to texture values /// /// # Arguments /// * `grad` - Gradient tensor [ny, nx] /// * `lr` - Learning rate pub fn apply_gradient(&mut self, grad: &Tensor, lr: f32) -> Result<()> { // values = values - lr * grad let update = grad.mul_scalar(lr)?; self.values = self.values.sub(&update)?; // Clamp to minimum stiffness self.clamp_values()?; Ok(()) } /// Compute gradient of stiffness texture from physics residual /// /// Given the physics residual R and its gradient dR/dmu, compute /// the gradient dL/d(texture) by scattering residual contributions /// back to texture pixels. /// /// # Arguments /// * `x` - sample x-coordinates [batch] /// * `y` - sample y-coordinates [batch] /// * `grad_mu` - gradient dL/dmu at each sample point [batch] pub fn compute_texture_gradient( &self, x: &Tensor, y: &Tensor, grad_mu: &Tensor, ) -> Result { let batch_size = x.shape().dims()[0]; let x_data = x.to_cpu()?; let y_data = y.to_cpu()?; let grad_data = grad_mu.to_cpu()?; // Accumulate gradients using bilinear splatting (inverse of interpolation) let mut texture_grad = vec![0.0f32; self.ny * self.nx]; for i in 0..batch_size { let xi = x_data[i]; let yi = y_data[i]; let grad = grad_data[i]; // Convert to texture coordinates let tx = (xi - self.x_min) / (self.x_max - self.x_min); let ty = (yi - self.y_min) / (self.y_max - self.y_min); let fx = tx * (self.nx - 1) as f32; let fy = ty * (self.ny - 1) as f32; let ix0 = (fx.floor() as usize).min(self.nx - 1); let iy0 = (fy.floor() as usize).min(self.ny - 1); let ix1 = (ix0 + 1).min(self.nx - 1); let iy1 = (iy0 + 1).min(self.ny - 1); let wx1 = fx - ix0 as f32; let wy1 = fy - iy0 as f32; let wx0 = 1.0 - wx1; let wy0 = 1.0 - wy1; // Splat gradient to four corners (inverse bilinear) texture_grad[iy0 * self.nx + ix0] += grad * wx0 * wy0; texture_grad[iy0 * self.nx + ix1] += grad * wx1 * wy0; texture_grad[iy1 * self.nx + ix0] += grad * wx0 * wy1; texture_grad[iy1 * self.nx + ix1] += grad * wx1 * wy1; } Ok(Tensor::from_data( texture_grad, vec![self.ny, self.nx], &self.device, )?) } /// Clamp values to minimum stiffness fn clamp_values(&mut self) -> Result<()> { let data = self.values.to_cpu()?; let clamped: Vec = data.iter().map(|&v| v.max(self.min_stiffness)).collect(); self.values = Tensor::from_data(clamped, vec![self.ny, self.nx], &self.device)?; Ok(()) } /// Convert to StiffnessField for visualization pub fn to_field(&self, config: &MreConfig) -> Result { let data = self.values.to_cpu()?; // Convert from non-dim to kPa let values_kpa: Vec = data .iter() .map(|&v| config.nondim.dim_stiffness_kpa(v)) .collect(); Ok(StiffnessField { resolution: (self.nx, self.ny), values: values_kpa, bounds: ( config.nondim.dim_length(self.x_min), config.nondim.dim_length(self.x_max), config.nondim.dim_length(self.y_min), config.nondim.dim_length(self.y_max), ), }) } /// Get grid dimensions pub fn resolution(&self) -> (usize, usize) { (self.nx, self.ny) } /// Get raw values tensor pub fn values(&self) -> &Tensor { &self.values } /// Compute Total Variation (TV) regularization loss /// /// TV = sum(|mu(i+1,j) - mu(i,j)| + |mu(i,j+1) - mu(i,j)|) /// /// Encourages piecewise-constant stiffness maps (sharp edges) pub fn tv_loss(&self) -> Result { let data = self.values.to_cpu()?; let mut tv = 0.0f32; for j in 0..self.ny { for i in 0..self.nx { let idx = j * self.nx + i; let v = data[idx]; // Horizontal difference if i + 1 < self.nx { let v_right = data[idx + 1]; tv += (v_right - v).abs(); } // Vertical difference if j + 1 < self.ny { let v_below = data[idx + self.nx]; tv += (v_below - v).abs(); } } } Ok(tv) } } #[cfg(test)] mod tests { use super::*; fn get_test_config() -> MreConfig { MreConfig::fast() } #[test] fn test_texture_creation() { let config = get_test_config(); let device = Device::try_default().unwrap(); let texture = StiffnessTexture::new(&config, &device).unwrap(); assert_eq!( texture.resolution(), (config.stiffness_nx, config.stiffness_ny) ); } #[test] fn test_uniform_sampling() { let config = get_test_config(); let device = Device::try_default().unwrap(); let texture = StiffnessTexture::new(&config, &device).unwrap(); let x = Tensor::from_data(vec![0.25, 0.5, 0.75], vec![3], &device).unwrap(); let y = Tensor::from_data(vec![0.25, 0.5, 0.75], vec![3], &device).unwrap(); let mu = texture.sample(&x, &y).unwrap(); let mu_data = mu.to_cpu().unwrap(); // All should be 1.0 (initial uniform value) for val in &mu_data { assert!((*val - 1.0).abs() < 1e-5); } } #[test] fn test_bilinear_interpolation() { let config = MreConfig::fast().with_stiffness_resolution(3, 3); let device = Device::try_default().unwrap(); // Create texture with known pattern let mut texture = StiffnessTexture::new(&config, &device).unwrap(); // Set corners to different values // [0,0]=1, [0,2]=2, [2,0]=3, [2,2]=4 let values = vec![1.0, 1.5, 2.0, 2.0, 2.5, 3.0, 3.0, 3.5, 4.0]; texture.values = Tensor::from_data(values, vec![3, 3], &device).unwrap(); // Sample at center (0.5, 0.5) should interpolate let x = Tensor::from_data(vec![0.5], vec![1], &device).unwrap(); let y = Tensor::from_data(vec![0.5], vec![1], &device).unwrap(); let mu = texture.sample(&x, &y).unwrap(); let mu_val = mu.to_cpu().unwrap()[0]; // Center value should be 2.5 assert!((mu_val - 2.5).abs() < 1e-5); } #[test] fn test_gradient_computation() { let config = get_test_config(); let device = Device::try_default().unwrap(); let texture = StiffnessTexture::new(&config, &device).unwrap(); let x = Tensor::from_data(vec![0.5], vec![1], &device).unwrap(); let y = Tensor::from_data(vec![0.5], vec![1], &device).unwrap(); let (mu, mu_x, mu_y) = texture.sample_with_gradients(&x, &y).unwrap(); // For uniform texture, gradients should be ~0 let mu_x_val = mu_x.to_cpu().unwrap()[0]; let mu_y_val = mu_y.to_cpu().unwrap()[0]; assert!(mu_x_val.abs() < 1e-5); assert!(mu_y_val.abs() < 1e-5); } }