Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_graphics/src/hevc/colour.rs

6.8 KiB, 9 runs

created by r1870400018:20559, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Turning a decoded picture into something that can be looked at.
2//!
3//! A coded picture is three planes of brightness and colour difference, the colour ones at half the
4//! width and half the height, and every number in them is in a *studio* range rather than a full
5//! one: brightness runs from 16 to 235 and colour from 16 to 240, with the room at each end left
6//! for signals that overshoot. Getting that range wrong is the commonest fault in a hand-written
7//! conversion, and it looks like a photograph with no real black in it.
8//!
9//! The matrix is the other half. Two are in wide use -- the older one from standard-definition
10//! television and the one from high definition -- and a photograph out of a phone is coded against
11//! the second. They differ by a few per cent in the green channel, which is enough to shift a face.
12//!
13//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
14//! Anthropic Claude
15
16use crate::{
17 hevc::decode::Picture,
18 pixmap::Pixmap,
19};
20
21use oxedyne_fe2o3_core::prelude::*;
22
23/// Which set of weights turns colour difference back into red, green and blue.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum Matrix {
26 Sd, // Rec. 601, from standard-definition television
27 Hd, // Rec. 709, what a photograph out of a phone is coded against
28}
29
30impl Matrix {
31
32 /// The two weights the conversion is built from: how much of the luminance is red, and how much
33 /// is blue. Green is what is left.
34 fn weights(self) -> (f32, f32) {
35 match self {
36 Self::Sd => (0.299, 0.114),
37 Self::Hd => (0.2126, 0.0722),
38 }
39 }
40}
41
42/// The colour planes are half size, so each of their samples covers four of the brightness plane's;
43/// they are stretched by **bilinear interpolation between sample centres**, which for 4:2:0 means
44/// the chroma sample sits a quarter of a pixel up and to the left of the luma one it is named for.
45/// Repeating each sample four times instead is visibly blockier along a hard colour edge -- a red
46/// jumper against a white wall is where it shows.
47pub fn rgb(pic: &Picture, matrix: Matrix, full_range: bool) -> Outcome<Pixmap> {
48 let (w, h) = (pic.y.w, pic.y.h);
49 if w == 0 || h == 0 {
50 return Err(err!("A picture of {} by {} has nothing in it.", w, h; Invalid, Input));
51 }
52 let (kr, kb) = matrix.weights();
53 let kg = 1.0 - kr - kb;
54 // From colour difference back to the two channels that carry it, and thence to green.
55 let (vr, ub) = (2.0 * (1.0 - kr), 2.0 * (1.0 - kb));
56 let top = ((1u32 << pic.depth) - 1) as f32;
57 // The studio range, scaled to whatever depth the picture is coded at.
58 let (y_low, y_span, c_span) = if full_range {
59 (0.0, top, top)
60 } else {
61 let one = (1u32 << (pic.depth - 8)) as f32;
62 (16.0 * one, 219.0 * one, 224.0 * one)
63 };
64 let c_mid = ((1u32 << (pic.depth - 1)) as f32).floor();
65
66 let mut out = vec![0u8; w * h * 4];
67 for y in 0..h {
68 for x in 0..w {
69 let luma = match pic.y.at(x, y) {
70 Some(v) => v as f32,
71 None => continue,
72 };
73 let (cb, cr) = chroma_at(pic, x, y);
74 let l = ((luma - y_low) / y_span).clamp(0.0, 1.0);
75 let u = (cb - c_mid) / c_span;
76 let v = (cr - c_mid) / c_span;
77 let r = l + vr * v;
78 let b = l + ub * u;
79 let g = (l - kr * r - kb * b) / kg;
80 let at = (y * w + x) * 4;
81 out[at] = (r.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
82 out[at + 1] = (g.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
83 out[at + 2] = (b.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
84 out[at + 3] = 255;
85 }
86 }
87 Pixmap::from_data(w, h, out)
88}
89
90/// The colour difference at one brightness sample, interpolated between the four around it.
91///
92/// In 4:2:0 a colour sample sits between brightness samples both ways, which is why the offsets are
93/// halves rather than whole steps.
94fn chroma_at(pic: &Picture, x: usize, y: usize) -> (f32, f32) {
95 let fx = (x as f32 - 0.5) / 2.0;
96 let fy = (y as f32 - 0.5) / 2.0;
97 let (x0, y0) = (fx.floor().max(0.0) as usize, fy.floor().max(0.0) as usize);
98 let (dx, dy) = ((fx - x0 as f32).clamp(0.0, 1.0), (fy - y0 as f32).clamp(0.0, 1.0));
99 let (x1, y1) = ((x0 + 1).min(pic.cb.w.saturating_sub(1)), (y0 + 1).min(pic.cb.h.saturating_sub(1)));
100 let take = |p: &crate::hevc::decode::Plane| {
101 let a = p.at(x0, y0).unwrap_or(0) as f32;
102 let b = p.at(x1, y0).unwrap_or(a as u16) as f32;
103 let c = p.at(x0, y1).unwrap_or(a as u16) as f32;
104 let d = p.at(x1, y1).unwrap_or(a as u16) as f32;
105 (a * (1.0 - dx) + b * dx) * (1.0 - dy) + (c * (1.0 - dx) + d * dx) * dy
106 };
107 (take(&pic.cb), take(&pic.cr))
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use crate::hevc::decode::Plane;
114
115 /// A picture of one colour throughout.
116 fn flat(w: usize, h: usize, y: u16, cb: u16, cr: u16) -> Picture {
117 let plane = |w: usize, h: usize, v: u16| Plane { w, h, px: vec![v; w * h] };
118 Picture {
119 y: plane(w, h, y),
120 cb: plane(w / 2, h / 2, cb),
121 cr: plane(w / 2, h / 2, cr),
122 depth: 8,
123 }
124 }
125
126 #[test]
127 fn test_the_studio_range_reaches_black_and_white_00() -> Outcome<()> {
128 // The fault a hand-written conversion nearly always has: a picture whose darkest sample is
129 // 16 must come out as nought and not as 16, or nothing in the library has a real black in
130 // it. The same at the top, where 235 is white.
131 let black = res!(rgb(&flat(4, 4, 16, 128, 128), Matrix::Hd, false));
132 req!(black.data()[0], 0u8, "the darkest studio level is not black");
133 let white = res!(rgb(&flat(4, 4, 235, 128, 128), Matrix::Hd, false));
134 req!(white.data()[0], 255u8, "the brightest studio level is not white");
135 // And with a full-range picture the same numbers mean what they say.
136 let full = res!(rgb(&flat(4, 4, 16, 128, 128), Matrix::Hd, true));
137 req!(full.data()[0], 16u8, "a full-range picture was stretched anyway");
138 Ok(())
139 }
140
141 #[test]
142 fn test_no_colour_difference_is_a_grey_01() -> Outcome<()> {
143 // With both colour planes at their middle, the three channels must agree exactly, whatever
144 // the matrix. A conversion with a sign or a weight wrong shows here as a green or magenta
145 // cast over the whole library.
146 for matrix in [Matrix::Sd, Matrix::Hd] {
147 for level in [16u16, 60, 128, 200, 235] {
148 let grey = res!(rgb(&flat(4, 4, level, 128, 128), matrix, false));
149 let px = grey.data();
150 req!(px[0], px[1], "{:?} at {} is not grey", matrix, level);
151 req!(px[1], px[2], "{:?} at {} is not grey", matrix, level);
152 }
153 }
154 Ok(())
155 }
156
157 #[test]
158 fn test_the_colour_difference_axes_point_the_right_way_02() -> Outcome<()> {
159 // Cr carries red and Cb carries blue, and swapping the two is a mistake that survives every
160 // test written in greys. A picture with Cr well above the middle must come out red.
161 let red = res!(rgb(&flat(4, 4, 128, 128, 240), Matrix::Hd, false));
162 let px = red.data();
163 let reddest = px[0] > px[1] && px[0] > px[2];
164 req!(reddest, true, "high Cr gave {:?} rather than a red", &px[..3]);
165 let blue = res!(rgb(&flat(4, 4, 128, 240, 128), Matrix::Hd, false));
166 let px = blue.data();
167 let bluest = px[2] > px[0] && px[2] > px[1];
168 req!(bluest, true, "high Cb gave {:?} rather than a blue", &px[..3]);
169 Ok(())
170 }
171}