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 | |
| 16 | use crate::{ |
| 17 | hevc::decode::Picture, |
| 18 | pixmap::Pixmap, |
| 19 | }; |
| 20 | |
| 21 | use 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)] |
| 25 | pub 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 | |
| 30 | impl 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. |
| 47 | pub 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. |
| 94 | fn 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)] |
| 111 | mod 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 | } |