Skip to main content

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}