Skip to main content

oximedia_scaling/
chroma_scale.rs

1//! Chroma subsampling-aware scaling with phase-aligned bilinear resampling.
2//!
3//! When scaling YCbCr video the chroma planes often have different
4//! dimensions to the luma plane (e.g. 4:2:0 is half width and half height).
5//! This module computes correct dimensions and offsets and provides pixel
6//! resampling for chroma planes so that subsampling alignment is maintained.
7
8#![allow(dead_code)]
9#![allow(clippy::cast_precision_loss)]
10#![allow(clippy::cast_possible_truncation)]
11#![allow(clippy::cast_sign_loss)]
12
13use serde::{Deserialize, Serialize};
14
15/// Common chroma subsampling formats.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum ChromaSubsampling {
18    /// 4:4:4 - no subsampling.
19    Yuv444,
20    /// 4:2:2 - chroma is half width, full height.
21    Yuv422,
22    /// 4:2:0 - chroma is half width and half height.
23    Yuv420,
24    /// 4:1:1 - chroma is quarter width, full height.
25    Yuv411,
26}
27
28impl ChromaSubsampling {
29    /// Horizontal subsampling factor (1 = no subsampling).
30    pub fn h_factor(&self) -> u32 {
31        match self {
32            Self::Yuv444 => 1,
33            Self::Yuv422 | Self::Yuv420 => 2,
34            Self::Yuv411 => 4,
35        }
36    }
37
38    /// Vertical subsampling factor (1 = no subsampling).
39    pub fn v_factor(&self) -> u32 {
40        match self {
41            Self::Yuv444 | Self::Yuv422 | Self::Yuv411 => 1,
42            Self::Yuv420 => 2,
43        }
44    }
45
46    /// Compute the chroma plane width for a given luma width.
47    pub fn chroma_width(&self, luma_width: u32) -> u32 {
48        (luma_width + self.h_factor() - 1) / self.h_factor()
49    }
50
51    /// Compute the chroma plane height for a given luma height.
52    pub fn chroma_height(&self, luma_height: u32) -> u32 {
53        (luma_height + self.v_factor() - 1) / self.v_factor()
54    }
55}
56
57impl std::fmt::Display for ChromaSubsampling {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            Self::Yuv444 => write!(f, "4:4:4"),
61            Self::Yuv422 => write!(f, "4:2:2"),
62            Self::Yuv420 => write!(f, "4:2:0"),
63            Self::Yuv411 => write!(f, "4:1:1"),
64        }
65    }
66}
67
68/// Chroma sample siting location within the luma grid.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub enum ChromaLocation {
71    /// Chroma is cosited with the left luma sample (MPEG-2 / H.264).
72    Left,
73    /// Chroma is centred between two luma samples (MPEG-1 / JPEG).
74    Center,
75    /// Top-left co-sited (used in some DV formats).
76    TopLeft,
77}
78
79impl ChromaLocation {
80    /// Horizontal offset of chroma relative to the first luma sample.
81    pub fn h_offset(&self) -> f64 {
82        match self {
83            Self::Left | Self::TopLeft => 0.0,
84            Self::Center => 0.5,
85        }
86    }
87
88    /// Vertical offset of chroma relative to the first luma sample.
89    pub fn v_offset(&self) -> f64 {
90        match self {
91            Self::Left | Self::Center => 0.5,
92            Self::TopLeft => 0.0,
93        }
94    }
95}
96
97/// Result of computing chroma-aware scaled dimensions.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ChromaScaleResult {
100    /// Scaled luma width.
101    pub luma_width: u32,
102    /// Scaled luma height.
103    pub luma_height: u32,
104    /// Scaled chroma width.
105    pub chroma_width: u32,
106    /// Scaled chroma height.
107    pub chroma_height: u32,
108    /// Whether the luma dimensions were adjusted for chroma alignment.
109    pub adjusted: bool,
110}
111
112/// Computes subsampling-correct dimensions for scaling operations.
113#[derive(Debug, Clone, Copy)]
114pub struct ChromaScaler {
115    /// Subsampling format.
116    pub subsampling: ChromaSubsampling,
117    /// Chroma sample location.
118    pub location: ChromaLocation,
119}
120
121impl ChromaScaler {
122    /// Create a new chroma-aware scaler.
123    pub fn new(subsampling: ChromaSubsampling, location: ChromaLocation) -> Self {
124        Self {
125            subsampling,
126            location,
127        }
128    }
129
130    /// Align a dimension to the subsampling factor (round up).
131    pub fn align_to_subsampling(&self, dim: u32, factor: u32) -> u32 {
132        if factor <= 1 {
133            return dim;
134        }
135        ((dim + factor - 1) / factor) * factor
136    }
137
138    /// Compute chroma-correct scaled dimensions.
139    pub fn compute_scaled_dims(
140        &self,
141        _src_w: u32,
142        _src_h: u32,
143        dst_w: u32,
144        dst_h: u32,
145    ) -> ChromaScaleResult {
146        let h_fac = self.subsampling.h_factor();
147        let v_fac = self.subsampling.v_factor();
148
149        let aligned_w = self.align_to_subsampling(dst_w, h_fac);
150        let aligned_h = self.align_to_subsampling(dst_h, v_fac);
151        let adjusted = aligned_w != dst_w || aligned_h != dst_h;
152
153        ChromaScaleResult {
154            luma_width: aligned_w,
155            luma_height: aligned_h,
156            chroma_width: self.subsampling.chroma_width(aligned_w),
157            chroma_height: self.subsampling.chroma_height(aligned_h),
158            adjusted,
159        }
160    }
161
162    /// Total number of samples (Y + Cb + Cr) for a frame of given luma dimensions.
163    pub fn total_samples(&self, luma_w: u32, luma_h: u32) -> u64 {
164        let luma = luma_w as u64 * luma_h as u64;
165        let cw = self.subsampling.chroma_width(luma_w) as u64;
166        let ch = self.subsampling.chroma_height(luma_h) as u64;
167        luma + 2 * cw * ch
168    }
169
170    /// Compute the chroma-to-luma sample ratio (total chroma / luma).
171    pub fn chroma_ratio(&self) -> f64 {
172        let h = self.subsampling.h_factor() as f64;
173        let v = self.subsampling.v_factor() as f64;
174        2.0 / (h * v)
175    }
176}
177
178// -- Phase-aligned chroma plane resampler ------------------------------------
179
180/// Phase-aligned bilinear resampler for a single chroma plane.
181///
182/// Incorporates the `ChromaLocation` phase offset when mapping destination
183/// chroma coordinates back to the source chroma grid so that the scaled chroma
184/// plane is correctly aligned with the scaled luma plane.
185#[derive(Debug, Clone)]
186pub struct ChromaPlaneResampler {
187    /// Subsampling format.
188    pub subsampling: ChromaSubsampling,
189    /// Siting of chroma samples in the source frame.
190    pub src_location: ChromaLocation,
191    /// Siting of chroma samples in the destination frame.
192    pub dst_location: ChromaLocation,
193}
194
195impl ChromaPlaneResampler {
196    /// Create a resampler with the same siting for source and destination.
197    pub fn same_siting(subsampling: ChromaSubsampling, location: ChromaLocation) -> Self {
198        Self {
199            subsampling,
200            src_location: location,
201            dst_location: location,
202        }
203    }
204
205    /// Create a resampler with explicit source and destination siting.
206    pub fn new(
207        subsampling: ChromaSubsampling,
208        src_location: ChromaLocation,
209        dst_location: ChromaLocation,
210    ) -> Self {
211        Self {
212            subsampling,
213            src_location,
214            dst_location,
215        }
216    }
217
218    /// Resample a single chroma plane with phase-correct bilinear interpolation.
219    pub fn resample_plane(
220        &self,
221        src: &[u8],
222        src_luma_w: u32,
223        src_luma_h: u32,
224        dst_luma_w: u32,
225        dst_luma_h: u32,
226    ) -> Vec<u8> {
227        let src_cw = self.subsampling.chroma_width(src_luma_w) as usize;
228        let src_ch = self.subsampling.chroma_height(src_luma_h) as usize;
229        let dst_cw = self.subsampling.chroma_width(dst_luma_w) as usize;
230        let dst_ch = self.subsampling.chroma_height(dst_luma_h) as usize;
231
232        if src.is_empty() || src_cw == 0 || src_ch == 0 || dst_cw == 0 || dst_ch == 0 {
233            return vec![0u8; dst_cw * dst_ch];
234        }
235
236        let scale_x = src_cw as f64 / dst_cw as f64;
237        let scale_y = src_ch as f64 / dst_ch as f64;
238
239        let hf = self.subsampling.h_factor() as f64;
240        let vf = self.subsampling.v_factor() as f64;
241
242        let src_ph = self.src_location.h_offset() / hf;
243        let src_pv = self.src_location.v_offset() / vf;
244        let dst_ph = self.dst_location.h_offset() / hf;
245        let dst_pv = self.dst_location.v_offset() / vf;
246
247        let mut dst = vec![0u8; dst_cw * dst_ch];
248
249        for cy in 0..dst_ch {
250            let sy_raw = (cy as f64 + dst_pv) * scale_y - src_pv;
251            let sy = sy_raw.clamp(0.0, (src_ch - 1) as f64);
252            let sy0 = sy.floor() as usize;
253            let sy1 = (sy0 + 1).min(src_ch - 1);
254            let fy = sy - sy.floor();
255
256            for cx in 0..dst_cw {
257                let sx_raw = (cx as f64 + dst_ph) * scale_x - src_ph;
258                let sx = sx_raw.clamp(0.0, (src_cw - 1) as f64);
259                let sx0 = sx.floor() as usize;
260                let sx1 = (sx0 + 1).min(src_cw - 1);
261                let fx = sx - sx.floor();
262
263                let p00 = src[sy0 * src_cw + sx0] as f64;
264                let p01 = src[sy0 * src_cw + sx1] as f64;
265                let p10 = src[sy1 * src_cw + sx0] as f64;
266                let p11 = src[sy1 * src_cw + sx1] as f64;
267
268                let val = p00 * (1.0 - fx) * (1.0 - fy)
269                    + p01 * fx * (1.0 - fy)
270                    + p10 * (1.0 - fx) * fy
271                    + p11 * fx * fy;
272
273                dst[cy * dst_cw + cx] = val.round().clamp(0.0, 255.0) as u8;
274            }
275        }
276
277        dst
278    }
279
280    /// Resample both Cb and Cr planes.
281    pub fn resample_both_planes(
282        &self,
283        cb_src: &[u8],
284        cr_src: &[u8],
285        src_luma_w: u32,
286        src_luma_h: u32,
287        dst_luma_w: u32,
288        dst_luma_h: u32,
289    ) -> (Vec<u8>, Vec<u8>) {
290        let cb = self.resample_plane(cb_src, src_luma_w, src_luma_h, dst_luma_w, dst_luma_h);
291        let cr = self.resample_plane(cr_src, src_luma_w, src_luma_h, dst_luma_w, dst_luma_h);
292        (cb, cr)
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    fn scaler_420() -> ChromaScaler {
301        ChromaScaler::new(ChromaSubsampling::Yuv420, ChromaLocation::Left)
302    }
303
304    fn scaler_422() -> ChromaScaler {
305        ChromaScaler::new(ChromaSubsampling::Yuv422, ChromaLocation::Left)
306    }
307
308    #[test]
309    fn test_chroma_width_420() {
310        assert_eq!(ChromaSubsampling::Yuv420.chroma_width(1920), 960);
311    }
312
313    #[test]
314    fn test_chroma_height_420() {
315        assert_eq!(ChromaSubsampling::Yuv420.chroma_height(1080), 540);
316    }
317
318    #[test]
319    fn test_chroma_width_422() {
320        assert_eq!(ChromaSubsampling::Yuv422.chroma_width(1920), 960);
321    }
322
323    #[test]
324    fn test_chroma_height_422_full() {
325        assert_eq!(ChromaSubsampling::Yuv422.chroma_height(1080), 1080);
326    }
327
328    #[test]
329    fn test_chroma_444_no_subsampling() {
330        assert_eq!(ChromaSubsampling::Yuv444.chroma_width(1920), 1920);
331        assert_eq!(ChromaSubsampling::Yuv444.chroma_height(1080), 1080);
332    }
333
334    #[test]
335    fn test_chroma_411_quarter_width() {
336        assert_eq!(ChromaSubsampling::Yuv411.chroma_width(1920), 480);
337        assert_eq!(ChromaSubsampling::Yuv411.chroma_height(1080), 1080);
338    }
339
340    #[test]
341    fn test_scaled_dims_420_aligned() {
342        let s = scaler_420();
343        let r = s.compute_scaled_dims(1920, 1080, 1280, 720);
344        assert_eq!(r.luma_width, 1280);
345        assert_eq!(r.luma_height, 720);
346        assert_eq!(r.chroma_width, 640);
347        assert_eq!(r.chroma_height, 360);
348        assert!(!r.adjusted);
349    }
350
351    #[test]
352    fn test_scaled_dims_420_needs_alignment() {
353        let s = scaler_420();
354        let r = s.compute_scaled_dims(1920, 1080, 1281, 721);
355        assert_eq!(r.luma_width, 1282);
356        assert_eq!(r.luma_height, 722);
357        assert!(r.adjusted);
358    }
359
360    #[test]
361    fn test_scaled_dims_444_no_alignment() {
362        let s = ChromaScaler::new(ChromaSubsampling::Yuv444, ChromaLocation::Center);
363        let r = s.compute_scaled_dims(1920, 1080, 1281, 721);
364        assert_eq!(r.luma_width, 1281);
365        assert_eq!(r.luma_height, 721);
366        assert!(!r.adjusted);
367    }
368
369    #[test]
370    fn test_total_samples_420() {
371        let s = scaler_420();
372        assert_eq!(s.total_samples(1920, 1080), 3_110_400);
373    }
374
375    #[test]
376    fn test_total_samples_444() {
377        let s = ChromaScaler::new(ChromaSubsampling::Yuv444, ChromaLocation::Left);
378        assert_eq!(s.total_samples(1920, 1080), 6_220_800);
379    }
380
381    #[test]
382    fn test_chroma_ratio_420() {
383        let s = scaler_420();
384        assert!((s.chroma_ratio() - 0.5).abs() < 1e-6);
385    }
386
387    #[test]
388    fn test_chroma_ratio_422() {
389        let s = scaler_422();
390        assert!((s.chroma_ratio() - 1.0).abs() < 1e-6);
391    }
392
393    #[test]
394    fn test_chroma_location_offsets() {
395        assert!((ChromaLocation::Left.h_offset() - 0.0).abs() < 1e-6);
396        assert!((ChromaLocation::Center.h_offset() - 0.5).abs() < 1e-6);
397        assert!((ChromaLocation::TopLeft.v_offset() - 0.0).abs() < 1e-6);
398    }
399
400    #[test]
401    fn test_subsampling_display() {
402        assert_eq!(ChromaSubsampling::Yuv420.to_string(), "4:2:0");
403        assert_eq!(ChromaSubsampling::Yuv422.to_string(), "4:2:2");
404        assert_eq!(ChromaSubsampling::Yuv444.to_string(), "4:4:4");
405        assert_eq!(ChromaSubsampling::Yuv411.to_string(), "4:1:1");
406    }
407
408    #[test]
409    fn test_align_to_subsampling() {
410        let s = scaler_420();
411        assert_eq!(s.align_to_subsampling(1920, 2), 1920);
412        assert_eq!(s.align_to_subsampling(1921, 2), 1922);
413        assert_eq!(s.align_to_subsampling(100, 4), 100);
414        assert_eq!(s.align_to_subsampling(101, 4), 104);
415    }
416
417    #[test]
418    fn test_chroma_odd_dimension_rounds_up() {
419        assert_eq!(ChromaSubsampling::Yuv420.chroma_width(1921), 961);
420    }
421
422    // -- ChromaPlaneResampler tests ------------------------------------------
423
424    #[test]
425    fn test_resample_plane_420_downscale_output_size() {
426        let r = ChromaPlaneResampler::same_siting(ChromaSubsampling::Yuv420, ChromaLocation::Left);
427        let src = vec![128u8; 960 * 540];
428        let dst = r.resample_plane(&src, 1920, 1080, 960, 540);
429        assert_eq!(dst.len(), 480 * 270);
430    }
431
432    #[test]
433    fn test_resample_plane_420_flat_field_preserves_value() {
434        let r = ChromaPlaneResampler::same_siting(ChromaSubsampling::Yuv420, ChromaLocation::Left);
435        let src = vec![200u8; 8 * 4];
436        let dst = r.resample_plane(&src, 16, 8, 8, 4);
437        for &v in &dst {
438            assert_eq!(v, 200);
439        }
440    }
441
442    #[test]
443    fn test_resample_plane_444_identity_size() {
444        let r = ChromaPlaneResampler::same_siting(ChromaSubsampling::Yuv444, ChromaLocation::Left);
445        let src = vec![100u8; 16 * 8];
446        let dst = r.resample_plane(&src, 16, 8, 8, 4);
447        assert_eq!(dst.len(), 8 * 4);
448    }
449
450    #[test]
451    fn test_resample_plane_422_height_preserved() {
452        let r = ChromaPlaneResampler::same_siting(ChromaSubsampling::Yuv422, ChromaLocation::Left);
453        let src = vec![150u8; 8 * 8];
454        let dst = r.resample_plane(&src, 16, 8, 8, 4);
455        assert_eq!(dst.len(), 4 * 4);
456    }
457
458    #[test]
459    fn test_resample_plane_empty_source_returns_zeros() {
460        let r = ChromaPlaneResampler::same_siting(ChromaSubsampling::Yuv420, ChromaLocation::Left);
461        let dst = r.resample_plane(&[], 16, 8, 8, 4);
462        let dst_cw = ChromaSubsampling::Yuv420.chroma_width(8) as usize;
463        let dst_ch = ChromaSubsampling::Yuv420.chroma_height(4) as usize;
464        assert_eq!(dst.len(), dst_cw * dst_ch);
465        assert!(dst.iter().all(|&v| v == 0));
466    }
467
468    #[test]
469    fn test_resample_plane_center_vs_left_siting_differs() {
470        let r_left =
471            ChromaPlaneResampler::same_siting(ChromaSubsampling::Yuv420, ChromaLocation::Left);
472        let r_center =
473            ChromaPlaneResampler::same_siting(ChromaSubsampling::Yuv420, ChromaLocation::Center);
474
475        let src_cw = ChromaSubsampling::Yuv420.chroma_width(32) as usize;
476        let src_ch = ChromaSubsampling::Yuv420.chroma_height(16) as usize;
477        let src: Vec<u8> = (0..src_cw * src_ch)
478            .map(|i| ((i * 3) % 200) as u8)
479            .collect();
480
481        let dst_left = r_left.resample_plane(&src, 32, 16, 16, 8);
482        let dst_center = r_center.resample_plane(&src, 32, 16, 16, 8);
483
484        assert_eq!(dst_left.len(), dst_center.len());
485        let differs = dst_left.iter().zip(dst_center.iter()).any(|(a, b)| a != b);
486        assert!(differs, "different siting should produce different results");
487    }
488
489    #[test]
490    fn test_resample_both_planes_returns_correct_sizes() {
491        let r = ChromaPlaneResampler::same_siting(ChromaSubsampling::Yuv420, ChromaLocation::Left);
492        let src_cw = ChromaSubsampling::Yuv420.chroma_width(16) as usize;
493        let src_ch = ChromaSubsampling::Yuv420.chroma_height(8) as usize;
494        let cb_src = vec![100u8; src_cw * src_ch];
495        let cr_src = vec![150u8; src_cw * src_ch];
496        let (cb_dst, cr_dst) = r.resample_both_planes(&cb_src, &cr_src, 16, 8, 8, 4);
497        let dst_cw = ChromaSubsampling::Yuv420.chroma_width(8) as usize;
498        let dst_ch = ChromaSubsampling::Yuv420.chroma_height(4) as usize;
499        assert_eq!(cb_dst.len(), dst_cw * dst_ch);
500        assert_eq!(cr_dst.len(), dst_cw * dst_ch);
501    }
502
503    #[test]
504    fn test_resample_plane_420_upscale_output_size() {
505        let r = ChromaPlaneResampler::same_siting(ChromaSubsampling::Yuv420, ChromaLocation::Left);
506        let src_cw = ChromaSubsampling::Yuv420.chroma_width(16) as usize;
507        let src_ch = ChromaSubsampling::Yuv420.chroma_height(8) as usize;
508        let src = vec![64u8; src_cw * src_ch];
509        let dst = r.resample_plane(&src, 16, 8, 32, 16);
510        let exp_cw = ChromaSubsampling::Yuv420.chroma_width(32) as usize;
511        let exp_ch = ChromaSubsampling::Yuv420.chroma_height(16) as usize;
512        assert_eq!(dst.len(), exp_cw * exp_ch);
513    }
514
515    #[test]
516    fn test_resampler_cross_siting_constructs() {
517        let r = ChromaPlaneResampler::new(
518            ChromaSubsampling::Yuv420,
519            ChromaLocation::Left,
520            ChromaLocation::Center,
521        );
522        assert_eq!(r.subsampling, ChromaSubsampling::Yuv420);
523    }
524}