scirs2_vision/style_transfer.rs
1//! Neural style transfer utilities
2//!
3//! This module provides gradient-descent-based neural style transfer,
4//! including Gram-matrix style representation, content/style/total-variation
5//! loss functions, and an iterative image optimization loop.
6//!
7//! # Overview
8//!
9//! Neural style transfer (Gatys et al., 2015) separates and recombines the
10//! *content* of one image with the *style* of another. The key insight is
11//! that the Gram matrix of feature activations captures texture statistics
12//! while the raw activations encode spatial content.
13//!
14//! This implementation works directly on raw feature-map tensors (C×H×W
15//! `Array3<f64>` where C = channels, H = height, W = width) rather than
16//! requiring a full neural network at runtime, making it usable as a
17//! standalone post-processing or artistic-texture layer.
18//!
19//! # Example
20//!
21//! ```rust
22//! use scirs2_vision::style_transfer::{
23//! gram_matrix, content_loss, style_loss, total_variation_loss,
24//! StyleTransferLoss, StyleTransferWeights,
25//! };
26//! use scirs2_core::ndarray::Array3;
27//!
28//! // Build toy 2-channel, 4×4 feature maps
29//! let content: Array3<f64> = Array3::ones((2, 4, 4));
30//! let style: Array3<f64> = Array3::ones((2, 4, 4));
31//!
32//! // Gram matrix: shape (C, C)
33//! let g = gram_matrix(&content);
34//! assert_eq!(g.dim(), (2, 2));
35//!
36//! // Losses
37//! let cl = content_loss(&content, &content);
38//! let sg = gram_matrix(&style);
39//! let sl = style_loss(&g, &sg);
40//! let tvl = total_variation_loss(&content);
41//!
42//! assert!(cl >= 0.0);
43//! assert!(sl >= 0.0);
44//! assert!(tvl >= 0.0);
45//!
46//! // Combined loss struct
47//! let weights = StyleTransferWeights::default();
48//! let combined = StyleTransferLoss::new(weights);
49//! let total = combined.total(&g, &sg, &content, &content);
50//! assert!(total >= 0.0);
51//! ```
52
53use crate::error::{Result, VisionError};
54use scirs2_core::ndarray::{Array1, Array2, Array3, Axis};
55
56// ─────────────────────────────────────────────────────────────────────────────
57// Gram matrix
58// ─────────────────────────────────────────────────────────────────────────────
59
60/// Compute the Gram matrix of a feature-map tensor.
61///
62/// Given a tensor of shape `(C, H, W)` the function reshapes it into
63/// `(C, H*W)` and returns `G = F · Fᵀ / (H*W)` of shape `(C, C)`.
64///
65/// Normalising by the spatial extent `H*W` keeps the magnitude
66/// scale-independent with respect to image size.
67///
68/// # Arguments
69///
70/// * `features` – Feature-map tensor of shape `(C, H, W)`.
71///
72/// # Errors
73///
74/// Returns [`VisionError::InvalidParameter`] when the channel axis has
75/// zero elements.
76pub fn gram_matrix(features: &Array3<f64>) -> Array2<f64> {
77 let (c, h, w) = features.dim();
78 let spatial = h * w;
79
80 // Flatten spatial dimensions: shape (C, H*W)
81 let flat: Array2<f64> = features
82 .to_shape((c, spatial))
83 .map(|v| v.into_owned())
84 .unwrap_or_else(|_| {
85 // Fallback: manual row construction
86 let mut buf = Array2::zeros((c, spatial));
87 for ch in 0..c {
88 let mut idx = 0;
89 for row in 0..h {
90 for col in 0..w {
91 buf[[ch, idx]] = features[[ch, row, col]];
92 idx += 1;
93 }
94 }
95 }
96 buf
97 });
98
99 // G[i,j] = dot(flat[i,:], flat[j,:]) / spatial
100 let scale = if spatial > 0 {
101 1.0 / spatial as f64
102 } else {
103 1.0
104 };
105
106 let mut gram = Array2::zeros((c, c));
107 for i in 0..c {
108 for j in 0..c {
109 let dot: f64 = flat
110 .row(i)
111 .iter()
112 .zip(flat.row(j).iter())
113 .map(|(a, b)| a * b)
114 .sum();
115 gram[[i, j]] = dot * scale;
116 }
117 }
118 gram
119}
120
121// ─────────────────────────────────────────────────────────────────────────────
122// Style loss
123// ─────────────────────────────────────────────────────────────────────────────
124
125/// Compute style loss as the squared Frobenius norm of Gram-matrix residuals.
126///
127/// `L_style = ‖G_generated − G_style‖_F² / (4 · C²)`
128///
129/// The `4 · C²` denominator is the normalisation used in the original paper.
130///
131/// # Arguments
132///
133/// * `generated_gram` – Gram matrix of the generated image's features, shape `(C, C)`.
134/// * `style_gram` – Gram matrix of the style image's features, shape `(C, C)`.
135///
136/// # Errors
137///
138/// Returns [`VisionError::DimensionMismatch`] when the two matrices differ
139/// in shape.
140pub fn style_loss(generated_gram: &Array2<f64>, style_gram: &Array2<f64>) -> f64 {
141 debug_assert_eq!(
142 generated_gram.dim(),
143 style_gram.dim(),
144 "Gram matrices must have identical shapes"
145 );
146
147 let (c, _) = generated_gram.dim();
148 let denom = 4.0 * (c * c) as f64;
149 let denom = if denom > 0.0 { denom } else { 1.0 };
150
151 let sum_sq: f64 = generated_gram
152 .iter()
153 .zip(style_gram.iter())
154 .map(|(g, s)| {
155 let diff = g - s;
156 diff * diff
157 })
158 .sum();
159
160 sum_sq / denom
161}
162
163// ─────────────────────────────────────────────────────────────────────────────
164// Content loss
165// ─────────────────────────────────────────────────────────────────────────────
166
167/// Compute content loss as mean squared error between two feature maps.
168///
169/// `L_content = MSE(generated, content) = ‖F_gen − F_content‖² / n`
170///
171/// # Arguments
172///
173/// * `generated` – Generated-image feature map, shape `(C, H, W)`.
174/// * `content` – Content-image feature map, shape `(C, H, W)`.
175///
176/// # Panics (debug)
177///
178/// Asserts that both tensors have equal shape.
179pub fn content_loss(generated: &Array3<f64>, content: &Array3<f64>) -> f64 {
180 debug_assert_eq!(
181 generated.dim(),
182 content.dim(),
183 "Feature maps must have identical shapes"
184 );
185
186 let n = generated.len();
187 if n == 0 {
188 return 0.0;
189 }
190
191 let sum_sq: f64 = generated
192 .iter()
193 .zip(content.iter())
194 .map(|(g, c)| {
195 let d = g - c;
196 d * d
197 })
198 .sum();
199
200 sum_sq / n as f64
201}
202
203// ─────────────────────────────────────────────────────────────────────────────
204// Total variation loss
205// ─────────────────────────────────────────────────────────────────────────────
206
207/// Compute the anisotropic total-variation loss for smoothness regularisation.
208///
209/// `L_tv = Σ_{c,i,j} (|F[c,i+1,j] - F[c,i,j]| + |F[c,i,j+1] - F[c,i,j]|) / n`
210///
211/// This penalises abrupt transitions in the generated image and acts as a
212/// spatial smoothness prior.
213///
214/// # Arguments
215///
216/// * `image` – Image tensor of shape `(C, H, W)`.
217pub fn total_variation_loss(image: &Array3<f64>) -> f64 {
218 let (c, h, w) = image.dim();
219 if h < 2 || w < 2 {
220 return 0.0;
221 }
222
223 let mut tv = 0.0_f64;
224 let n = c * (h - 1) * (w - 1);
225
226 for ch in 0..c {
227 for row in 0..h - 1 {
228 for col in 0..w - 1 {
229 let vert = (image[[ch, row + 1, col]] - image[[ch, row, col]]).abs();
230 let horiz = (image[[ch, row, col + 1]] - image[[ch, row, col]]).abs();
231 tv += vert + horiz;
232 }
233 }
234 }
235
236 if n > 0 {
237 tv / n as f64
238 } else {
239 0.0
240 }
241}
242
243// ─────────────────────────────────────────────────────────────────────────────
244// Combined loss struct
245// ─────────────────────────────────────────────────────────────────────────────
246
247/// Weights for the three components of the combined style-transfer loss.
248#[derive(Debug, Clone)]
249pub struct StyleTransferWeights {
250 /// Weight for the content reconstruction term (`α`).
251 pub content_weight: f64,
252 /// Weight for the style matching term (`β`).
253 pub style_weight: f64,
254 /// Weight for the total-variation regularisation term (`γ`).
255 pub tv_weight: f64,
256}
257
258impl Default for StyleTransferWeights {
259 fn default() -> Self {
260 Self {
261 content_weight: 1.0,
262 style_weight: 1e5,
263 tv_weight: 1e-4,
264 }
265 }
266}
267
268/// Struct that combines content, style, and TV losses with configurable weights.
269///
270/// # Example
271///
272/// ```rust
273/// use scirs2_vision::style_transfer::{StyleTransferLoss, StyleTransferWeights, gram_matrix};
274/// use scirs2_core::ndarray::Array3;
275///
276/// let img: Array3<f64> = Array3::ones((3, 8, 8));
277/// let gen_gram = gram_matrix(&img);
278/// let sty_gram = gram_matrix(&img);
279///
280/// let loss = StyleTransferLoss::new(StyleTransferWeights::default());
281/// let v = loss.total(&gen_gram, &sty_gram, &img, &img);
282/// assert!(v >= 0.0);
283/// ```
284#[derive(Debug, Clone)]
285pub struct StyleTransferLoss {
286 /// Weights for each loss component.
287 pub weights: StyleTransferWeights,
288}
289
290impl StyleTransferLoss {
291 /// Create a new combined loss with the supplied weights.
292 pub fn new(weights: StyleTransferWeights) -> Self {
293 Self { weights }
294 }
295
296 /// Compute the total weighted loss.
297 ///
298 /// # Arguments
299 ///
300 /// * `generated_gram` – Gram matrix of the generated image's features.
301 /// * `style_gram` – Gram matrix of the style image's features.
302 /// * `generated` – Generated feature map (for content + TV).
303 /// * `content` – Content feature map.
304 pub fn total(
305 &self,
306 generated_gram: &Array2<f64>,
307 style_gram: &Array2<f64>,
308 generated: &Array3<f64>,
309 content: &Array3<f64>,
310 ) -> f64 {
311 let lc = self.weights.content_weight * content_loss(generated, content);
312 let ls = self.weights.style_weight * style_loss(generated_gram, style_gram);
313 let ltv = self.weights.tv_weight * total_variation_loss(generated);
314 lc + ls + ltv
315 }
316
317 /// Compute individual loss components without combining.
318 ///
319 /// Returns `(content_loss, style_loss, tv_loss)`.
320 pub fn components(
321 &self,
322 generated_gram: &Array2<f64>,
323 style_gram: &Array2<f64>,
324 generated: &Array3<f64>,
325 content: &Array3<f64>,
326 ) -> (f64, f64, f64) {
327 let lc = content_loss(generated, content);
328 let ls = style_loss(generated_gram, style_gram);
329 let ltv = total_variation_loss(generated);
330 (lc, ls, ltv)
331 }
332}
333
334// ─────────────────────────────────────────────────────────────────────────────
335// Gradient computation helpers
336// ─────────────────────────────────────────────────────────────────────────────
337
338/// Compute the gradient of the style loss w.r.t. the generated image's
339/// feature map via Gram-matrix back-propagation.
340///
341/// For `G = F Fᵀ / n` and `L_style = ‖G - G_s‖_F² / (4C²)`:
342///
343/// `∂L/∂F[c,h,w] = 2 / (n · 4C²) · (G - G_s)[c, :] · F[:, h, w]`
344///
345/// (simplified per-channel accumulation)
346fn style_gradient(features: &Array3<f64>, style_gram: &Array2<f64>) -> Array3<f64> {
347 let gen_gram = gram_matrix(features);
348 let (c, h, w) = features.dim();
349 let spatial = (h * w) as f64;
350 let denom = 4.0 * (c * c) as f64 * spatial.max(1.0);
351
352 let residual = &gen_gram - style_gram; // (C, C)
353
354 let mut grad = Array3::zeros((c, h, w));
355 for row in 0..h {
356 for col in 0..w {
357 for ci in 0..c {
358 // dL/dF[ci, row, col] = 2/denom · Σ_j residual[ci,j] * F[j,row,col]
359 let mut acc = 0.0_f64;
360 for cj in 0..c {
361 acc += residual[[ci, cj]] * features[[cj, row, col]];
362 }
363 grad[[ci, row, col]] = 2.0 * acc / denom;
364 }
365 }
366 }
367 grad
368}
369
370/// Compute the gradient of the content loss w.r.t. the generated feature map.
371///
372/// `∂MSE/∂F_gen = 2 (F_gen - F_content) / n`
373fn content_gradient(generated: &Array3<f64>, content: &Array3<f64>) -> Array3<f64> {
374 let n = generated.len().max(1) as f64;
375 (generated - content).mapv(|d| 2.0 * d / n)
376}
377
378/// Compute the gradient of the anisotropic total-variation loss.
379fn tv_gradient(image: &Array3<f64>) -> Array3<f64> {
380 let (c, h, w) = image.dim();
381 if h < 2 || w < 2 {
382 return Array3::zeros((c, h, w));
383 }
384 let n = (c * (h - 1) * (w - 1)).max(1) as f64;
385 let mut grad = Array3::zeros((c, h, w));
386
387 for ch in 0..c {
388 for row in 0..h {
389 for col in 0..w {
390 let mut g = 0.0_f64;
391
392 // Contribution from the (row, col) → (row+1, col) pair
393 if row + 1 < h {
394 let diff = image[[ch, row + 1, col]] - image[[ch, row, col]];
395 g -= diff.signum(); // dTV/d(image[row,col]) = -sign(next - curr)
396 }
397 if row > 0 {
398 let diff = image[[ch, row, col]] - image[[ch, row - 1, col]];
399 g += diff.signum();
400 }
401
402 // Contribution from the (row, col) → (row, col+1) pair
403 if col + 1 < w {
404 let diff = image[[ch, row, col + 1]] - image[[ch, row, col]];
405 g -= diff.signum();
406 }
407 if col > 0 {
408 let diff = image[[ch, row, col]] - image[[ch, row, col - 1]];
409 g += diff.signum();
410 }
411
412 grad[[ch, row, col]] = g / n;
413 }
414 }
415 }
416 grad
417}
418
419// ─────────────────────────────────────────────────────────────────────────────
420// Iterative optimisation
421// ─────────────────────────────────────────────────────────────────────────────
422
423/// Optimise an image via gradient descent to match content and style targets.
424///
425/// This implements vanilla gradient descent with an optional learning-rate
426/// warm-up (first iteration uses `lr * 0.1`) to avoid large initial steps.
427///
428/// The generated image is initialised as a copy of the *content* tensor and
429/// updated by descending the combined style-transfer gradient.
430///
431/// # Arguments
432///
433/// * `content` – Content target feature map, shape `(C, H, W)`.
434/// * `style` – Style target feature map, shape `(C, H, W)`.
435/// * `weights` – Loss weights (content, style, TV).
436/// * `n_iters` – Number of gradient-descent iterations.
437/// * `lr` – Learning rate (step size).
438///
439/// # Errors
440///
441/// * [`VisionError::InvalidParameter`] – when `lr ≤ 0` or `n_iters == 0`.
442/// * [`VisionError::DimensionMismatch`] – when content and style have
443/// different shapes.
444///
445/// # Example
446///
447/// ```rust
448/// use scirs2_vision::style_transfer::{optimize_style_transfer, StyleTransferWeights};
449/// use scirs2_core::ndarray::Array3;
450///
451/// let content: Array3<f64> = Array3::ones((2, 4, 4));
452/// let style: Array3<f64> = Array3::ones((2, 4, 4));
453/// let weights = StyleTransferWeights {
454/// content_weight: 1.0,
455/// style_weight: 1.0,
456/// tv_weight: 0.0,
457/// };
458/// let result = optimize_style_transfer(&content, &style, &weights, 5, 0.01);
459/// assert!(result.is_ok());
460/// let img = result.unwrap();
461/// assert_eq!(img.dim(), content.dim());
462/// ```
463pub fn optimize_style_transfer(
464 content: &Array3<f64>,
465 style: &Array3<f64>,
466 weights: &StyleTransferWeights,
467 n_iters: usize,
468 lr: f64,
469) -> Result<Array3<f64>> {
470 if lr <= 0.0 {
471 return Err(VisionError::InvalidParameter(format!(
472 "Learning rate must be positive, got {lr}"
473 )));
474 }
475 if n_iters == 0 {
476 return Err(VisionError::InvalidParameter(
477 "n_iters must be at least 1".to_string(),
478 ));
479 }
480 if content.dim() != style.dim() {
481 return Err(VisionError::DimensionMismatch(format!(
482 "Content shape {:?} ≠ style shape {:?}",
483 content.dim(),
484 style.dim()
485 )));
486 }
487
488 // Pre-compute the fixed style Gram matrix
489 let style_gram = gram_matrix(style);
490
491 // Initialise generated image from content
492 let mut generated = content.to_owned();
493
494 for iter in 0..n_iters {
495 let gen_gram = gram_matrix(&generated);
496
497 // Compute gradients
498 let grad_style = style_gradient(&generated, &style_gram);
499 let grad_content = content_gradient(&generated, content);
500 let grad_tv = tv_gradient(&generated);
501
502 // Combined gradient
503 let grad = grad_content.mapv(|g| g * weights.content_weight)
504 + grad_style.mapv(|g| g * weights.style_weight)
505 + grad_tv.mapv(|g| g * weights.tv_weight);
506
507 // Warm-up: smaller first step
508 let step = if iter == 0 { lr * 0.1 } else { lr };
509
510 // Gradient-descent update
511 generated = generated - grad.mapv(|g| g * step);
512
513 // Drop `gen_gram` explicitly to make the borrow-checker happy
514 drop(gen_gram);
515 }
516
517 Ok(generated)
518}
519
520// ─────────────────────────────────────────────────────────────────────────────
521// Tests
522// ─────────────────────────────────────────────────────────────────────────────
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use scirs2_core::ndarray::Array3;
528
529 fn make_ramp(c: usize, h: usize, w: usize) -> Array3<f64> {
530 let mut a = Array3::zeros((c, h, w));
531 for ch in 0..c {
532 for row in 0..h {
533 for col in 0..w {
534 a[[ch, row, col]] = (ch * h * w + row * w + col) as f64;
535 }
536 }
537 }
538 a
539 }
540
541 #[test]
542 fn test_gram_matrix_shape() {
543 let feat: Array3<f64> = Array3::ones((4, 8, 8));
544 let g = gram_matrix(&feat);
545 assert_eq!(g.dim(), (4, 4));
546 }
547
548 #[test]
549 fn test_gram_matrix_symmetric() {
550 let feat = make_ramp(3, 5, 5);
551 let g = gram_matrix(&feat);
552 for i in 0..3 {
553 for j in 0..3 {
554 let diff = (g[[i, j]] - g[[j, i]]).abs();
555 assert!(diff < 1e-10, "Gram not symmetric at ({i},{j}): {diff}");
556 }
557 }
558 }
559
560 #[test]
561 fn test_gram_matrix_positive_semidefinite() {
562 // All diagonal elements must be non-negative (they are dot products).
563 let feat = make_ramp(3, 4, 4);
564 let g = gram_matrix(&feat);
565 for i in 0..3 {
566 assert!(
567 g[[i, i]] >= 0.0,
568 "Diagonal element ({i},{i}) is negative: {}",
569 g[[i, i]]
570 );
571 }
572 }
573
574 #[test]
575 fn test_style_loss_identical() {
576 let feat: Array3<f64> = Array3::ones((3, 4, 4));
577 let g = gram_matrix(&feat);
578 let loss = style_loss(&g, &g);
579 assert!(loss.abs() < 1e-12);
580 }
581
582 #[test]
583 fn test_style_loss_non_negative() {
584 let a = make_ramp(3, 4, 4);
585 let b: Array3<f64> = Array3::zeros((3, 4, 4));
586 let ga = gram_matrix(&a);
587 let gb = gram_matrix(&b);
588 assert!(style_loss(&ga, &gb) >= 0.0);
589 }
590
591 #[test]
592 fn test_content_loss_identical() {
593 let feat = make_ramp(3, 4, 4);
594 assert!(content_loss(&feat, &feat).abs() < 1e-12);
595 }
596
597 #[test]
598 fn test_content_loss_non_negative() {
599 let a = make_ramp(2, 4, 4);
600 let b: Array3<f64> = Array3::zeros((2, 4, 4));
601 assert!(content_loss(&a, &b) >= 0.0);
602 }
603
604 #[test]
605 fn test_total_variation_uniform() {
606 // Uniform image has zero TV
607 let img: Array3<f64> = Array3::from_elem((2, 6, 6), 5.0);
608 assert!(total_variation_loss(&img).abs() < 1e-12);
609 }
610
611 #[test]
612 fn test_total_variation_non_negative() {
613 let img = make_ramp(2, 6, 6);
614 assert!(total_variation_loss(&img) >= 0.0);
615 }
616
617 #[test]
618 fn test_total_variation_small_image() {
619 let img: Array3<f64> = Array3::ones((2, 1, 1));
620 assert_eq!(total_variation_loss(&img), 0.0);
621 }
622
623 #[test]
624 fn test_style_transfer_loss_struct() {
625 let content = make_ramp(2, 4, 4);
626 let style: Array3<f64> = Array3::from_elem((2, 4, 4), 3.0);
627 let gen_gram = gram_matrix(&content);
628 let sty_gram = gram_matrix(&style);
629 let loss_fn = StyleTransferLoss::new(StyleTransferWeights::default());
630 let total = loss_fn.total(&gen_gram, &sty_gram, &content, &content);
631 assert!(total >= 0.0);
632 }
633
634 #[test]
635 fn test_style_transfer_components() {
636 let img: Array3<f64> = Array3::ones((2, 4, 4));
637 let g = gram_matrix(&img);
638 let loss_fn = StyleTransferLoss::new(StyleTransferWeights::default());
639 let (lc, ls, ltv) = loss_fn.components(&g, &g, &img, &img);
640 // Identical images → zero content and style losses
641 assert!(lc.abs() < 1e-12);
642 assert!(ls.abs() < 1e-12);
643 assert!(ltv >= 0.0);
644 }
645
646 #[test]
647 fn test_optimize_style_transfer_shape() {
648 let content: Array3<f64> = Array3::ones((2, 4, 4));
649 let style: Array3<f64> = Array3::ones((2, 4, 4));
650 let weights = StyleTransferWeights {
651 content_weight: 1.0,
652 style_weight: 1.0,
653 tv_weight: 0.0,
654 };
655 let result = optimize_style_transfer(&content, &style, &weights, 3, 0.01);
656 assert!(result.is_ok());
657 assert_eq!(result.expect("Test: result shape").dim(), (2, 4, 4));
658 }
659
660 #[test]
661 fn test_optimize_style_transfer_bad_lr() {
662 let content: Array3<f64> = Array3::ones((2, 4, 4));
663 let style: Array3<f64> = Array3::ones((2, 4, 4));
664 let weights = StyleTransferWeights::default();
665 assert!(optimize_style_transfer(&content, &style, &weights, 3, 0.0).is_err());
666 assert!(optimize_style_transfer(&content, &style, &weights, 3, -1.0).is_err());
667 }
668
669 #[test]
670 fn test_optimize_style_transfer_zero_iters() {
671 let content: Array3<f64> = Array3::ones((2, 4, 4));
672 let style: Array3<f64> = Array3::ones((2, 4, 4));
673 let weights = StyleTransferWeights::default();
674 assert!(optimize_style_transfer(&content, &style, &weights, 0, 0.01).is_err());
675 }
676
677 #[test]
678 fn test_optimize_style_transfer_dimension_mismatch() {
679 let content: Array3<f64> = Array3::ones((2, 4, 4));
680 let style: Array3<f64> = Array3::ones((3, 4, 4));
681 let weights = StyleTransferWeights::default();
682 assert!(optimize_style_transfer(&content, &style, &weights, 3, 0.01).is_err());
683 }
684}