From f6257fc03f5df3e64ba5c56fbf711ced75284edf Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Thu, 10 Sep 2026 14:57:10 +0300 Subject: [PATCH 1/8] feat(converter)!: split what the input is from what the stream should be `ColorSpace` was an enum of pipelines, not of colour spaces. Each variant bundled three separate decisions under one name: what the input already is, what the output should be, and which YUV matrix to use. Two of the four were passthroughs, and for those the library could not say what the output transfer function was: `ColorSpace::Bt2020` means "already BT.2020-encoded, write it through", which covers PQ, HLG and SDR BT.2020 alike. That is why the range and matrix decisions had to be made twice, once on `ColorConverterConfig` and once on `ColorDescription`, with nothing linking them. Neither could be derived from the other. `ColorDescription` to `ColorSpace` was ambiguous because the BT.2020 NCL matrix matched three variants; `ColorSpace` to `ColorDescription` was ambiguous because `Bt2020` could not fill in the transfer characteristics. A setter bridging them would have had to guess, and it is better not to have one than to have one that guesses. So the input and the output are now described separately: let conv = ColorConverterConfig::new(w, h, input_format, output_format) .with_source(SourceColor::scrgb()) .with_target(TargetColor::Bt2020Pq) .with_range(ColorRange::Full); let enc = EncodeConfig::h265(w, h) .with_color_description(conv.color_description()); `SourceColor` says what the pixels are; `TargetColor` says what the stream is and picks the matrix; `ColorRange` replaces the `full_range` bool, which reads better at a call site than `true` did. Because `TargetColor` fixes primaries, transfer and matrix together, `color_description()` derives the encoder's declaration from the conversion itself, with nothing left to guess. The two structs can still disagree if you insist on building the description by hand, but the drift is no longer the path of least effort, and full-range samples tagged limited get expanded twice on playback. Two more things fall out of the split: The reference white now lives on the source variants, because that is whose property it is: it answers what luminance a sample value of 1.0 stands for. `SourceColor::srgb()` carries BT.2408's 203 nits and `SourceColor::scrgb()` carries IEC 61966-2-2's 80, so the correct value follows from the source instead of one 203 nit default serving both. Getting that wrong maps scRGB white about two and a half times too bright, and it was a real downstream bug, not a hypothetical one. `SourceColor::Bt2020Pq` has no such field, PQ being absolute already. And unsupported conversions are now refused. `Bt2020Linear` is newly expressible and implemented, so every source reaches `Bt2020Pq`; only `Srgb` reaches `Bt709`, since the others would need a forward gamma encode or tone mapping that the shader does not do. Those three pairs error out of `ColorConverter::new` and `set_source`/`set_target` instead of silently writing samples that do not match their label. Breaking, hence the `!`: `ColorSpace` is gone, and the three colour fields on `ColorConverterConfig` are replaced. The push constant block grows to 8 u32s and the shader takes `source`, `target` and `range` in place of `color_space` and `full_range`. --- README.md | 59 +++- shader/color_convert.comp | 62 +++-- shader/color_convert.spv | Bin 28580 -> 25968 bytes src/converter/mod.rs | 548 +++++++++++++++++++++++++++++++------- src/converter/pipeline.rs | 4 +- src/lib.rs | 64 ++++- 6 files changed, 590 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 9ed6893..9f28662 100644 --- a/README.md +++ b/README.md @@ -185,30 +185,65 @@ CPU is the consumer's job; `examples/common` shows one way. ### Color Conversion (RGB → YUV) -PixelForge includes a GPU compute shader for converting RGB input to YUV output, supporting multiple color spaces: - -| Color Space | Description | -|-------------|-------------| -| `Bt709` | Standard SDR (BT.709 coefficients) | -| `Bt2020` | HDR passthrough (BT.2020 coefficients, PQ-encoded input) | -| `SrgbToBt2020Pq` | SDR-in-HDR (sRGB → linear → BT.2020 gamut → PQ OETF) | -| `Bt709LinearToBt2020Pq` | scRGB HDR (linear BT.709 → BT.2020 gamut → PQ OETF). `sdr_reference_white_nits` sets the interpretation of 1.0; per the scRGB spec (IEC 61966-2-2), 80 nits. | +PixelForge includes a GPU compute shader for converting RGB input to YUV +output. The conversion is two decisions: what the input already is, and +what the encoded stream should be. + +| `SourceColor` | What the input pixels are | +|---------------|---------------------------| +| `Srgb` | BT.709 primaries, sRGB transfer. Ordinary SDR content. | +| `Bt709Linear` | Linear BT.709. This is scRGB, from an `EXTENDED_SRGB_LINEAR_EXT` swapchain. | +| `Bt2020Linear` | Linear BT.2020. | +| `Bt2020Pq` | BT.2020 primaries, already PQ-encoded. | + +| `TargetColor` | What the stream is | +|---------------|--------------------| +| `Bt709` | BT.709 primaries, transfer and matrix. SDR. | +| `Bt2020Pq` | BT.2020 primaries, PQ transfer, BT.2020 NCL matrix. HDR10. | + +The relative sources carry the luminance that a sample value of 1.0 stands +for, since that is a property of the source and not of the target: 203 nits +for `Srgb` per ITU-R BT.2408, and 80 for scRGB per IEC 61966-2-2. It is read +only on the way to `Bt2020Pq`, where the PQ encode needs it to be absolute. + +Every source reaches `Bt2020Pq`. Only `Srgb` reaches `Bt709`, because the +others would need a forward gamma encode or tone mapping; those pairings are +rejected by [`ColorConverter::new`] rather than quietly passed through. Supported input formats: BGRx, RGBx, BGRA, RGBA, ABGR2101010 (10-bit packed), RGBA16F (FP16). Supported output formats: NV12 (8-bit), I420 (8-bit), YUV444 (8-bit), P010 (10-bit), YUV444P10 (10-bit). +Because the target and the range fully determine the stream's colour +signalling, [`ColorConverterConfig::color_description`] derives the +encoder's VUI declaration from the conversion itself. Use it rather than +declaring the same thing twice: full-range samples tagged as limited are +expanded a second time on playback, and the API cannot catch that if the two +are set independently. + ```rust -use pixelforge::{ColorConverter, ColorConverterConfig, ColorSpace, InputFormat, OutputFormat, VideoContextBuilder}; +use pixelforge::{ + Codec, ColorConverter, ColorConverterConfig, ColorRange, EncodeConfig, Encoder, + InputFormat, OutputFormat, SourceColor, TargetColor, VideoContextBuilder, +}; let context = VideoContextBuilder::new() .app_name("Color Converter") + .require_encode(Codec::H265) .build()?; -let mut config = ColorConverterConfig::new(1920, 1080, InputFormat::BGRx, OutputFormat::NV12); -config.color_space = ColorSpace::SrgbToBt2020Pq; +// SDR desktop content, encoded as HDR10 for an HDR streaming session. +let config = ColorConverterConfig::new(1920, 1080, InputFormat::BGRx, OutputFormat::P010) + .with_source(SourceColor::srgb()) + .with_target(TargetColor::Bt2020Pq) + .with_range(ColorRange::Full); + +// The encoder declares exactly what the shader wrote. +let encode_config = EncodeConfig::h265(1920, 1080) + .with_color_description(config.color_description()); let mut converter = ColorConverter::new(context.clone(), config)?; -// converter.convert(input_image, output_buffer)?; +let mut encoder = Encoder::new(context, encode_config)?; +// converter.convert(input_image, layout, encoder.input_image())?; ``` ## Benchmarking diff --git a/shader/color_convert.comp b/shader/color_convert.comp index f726ef8..0c31b9f 100644 --- a/shader/color_convert.comp +++ b/shader/color_convert.comp @@ -7,11 +7,22 @@ layout(push_constant) uniform PushConstants { uint height; uint input_format; // 0=BGRx, 1=RGBx, 2=BGRA, 3=RGBA, 4=ABGR2101010, 5=RGBA16F uint output_format; // 0=NV12, 1=I420, 2=YUV444, 3=P010, 4=YUV444P10 - uint color_space; // 0=BT.709, 1=BT.2020, 2=sRGB→BT.2020+PQ, 3=scRGB linear→BT.2020+PQ - uint full_range; // 0=limited/studio range, 1=full range - float sdr_white_nits; // SDR reference white (nits), used for sRGB and scRGB → BT.2020+PQ + uint source; // what the input is: 0=sRGB, 1=BT.709 linear, 2=BT.2020 linear, 3=BT.2020 PQ + uint target; // what to produce: 0=BT.709 SDR, 1=BT.2020 PQ (HDR10) + uint range; // 0=limited/studio range, 1=full range + float reference_white_nits; // luminance of source value 1.0; unused when the source is PQ } params; +// `source` values. +#define SOURCE_SRGB 0u +#define SOURCE_BT709_LINEAR 1u +#define SOURCE_BT2020_LINEAR 2u +#define SOURCE_BT2020_PQ 3u + +// `target` values. +#define TARGET_BT709 0u +#define TARGET_BT2020_PQ 1u + // Source image sampled directly — eliminates the image-to-buffer copy. layout(binding = 0) uniform sampler2D inputImage; @@ -89,25 +100,26 @@ vec3 bt709_to_bt2020(vec3 rgb709) { // - PQ surfaces → PQ-encoded values (even in FP16) // - sRGB surfaces → sRGB gamma-encoded values (even in FP16) // -// color_space 0 (BT.709) and 1 (BT.2020): passthrough — data is already encoded. -// color_space 2 (sRGB→BT.2020+PQ): decode sRGB, convert gamut, apply PQ. -// color_space 3 (scRGB→BT.2020+PQ): convert gamut, apply PQ. No EOTF — input is linear. +// which is what `source` describes. vec3 read_rgb(ivec2 coord) { vec4 rgba = texelFetch(inputImage, coord, 0); - if (params.color_space == 2u) { - // sRGB→BT.2020+PQ: decode sRGB gamma → linear BT.709 → BT.2020 gamut → PQ. - vec3 linear_709 = srgb_to_linear(rgba.rgb); - vec3 linear_2020 = bt709_to_bt2020(linear_709); - // SDR reference white (configurable, default 203 nits per ITU-R BT.2408) - // normalized to PQ's 10000 nit scale. - return linear_to_pq(linear_2020 * (params.sdr_white_nits / 10000.0)); - } - if (params.color_space == 3u) { - // scRGB→BT.2020+PQ: BT.709 linear → BT.2020 gamut → PQ (no EOTF, input is already linear). - vec3 linear_2020 = bt709_to_bt2020(rgba.rgb); - return linear_to_pq(linear_2020 * (params.sdr_white_nits / 10000.0)); + + if (params.target == TARGET_BT2020_PQ) { + // Already PQ in the right gamut: nothing to do. + if (params.source == SOURCE_BT2020_PQ) return rgba.rgb; + + // Get to linear light, then into BT.2020, then encode PQ. The + // reference white says what luminance source 1.0 stands for, which is + // what makes the PQ encode absolute. + vec3 linear = rgba.rgb; + if (params.source == SOURCE_SRGB) linear = srgb_to_linear(linear); + if (params.source != SOURCE_BT2020_LINEAR) linear = bt709_to_bt2020(linear); + return linear_to_pq(linear * (params.reference_white_nits / 10000.0)); } - // BT.709 or BT.2020 passthrough: values are already properly encoded. + + // TARGET_BT709: the only source that reaches it is already SDR-encoded in + // the right gamut, so this is a passthrough. Every other source is + // rejected on the CPU side before the shader ever runs. return rgba.rgb; } @@ -115,8 +127,8 @@ vec3 read_rgb(ivec2 coord) { // Returns Y in [0, 1], U and V in [0, 1] centered at 0.5. vec3 rgb_to_yuv(vec3 rgb) { float yr, yg, yb, ur, ug, ub, vr, vg, vb; - if (params.color_space >= 1u) { - // BT.2020 (also used for sRGB→BT.2020+PQ, since read_rgb already converted). + if (params.target == TARGET_BT2020_PQ) { + // BT.2020 NCL. read_rgb has already put the samples in that gamut. yr = BT2020_Y_R; yg = BT2020_Y_G; yb = BT2020_Y_B; ur = BT2020_U_R; ug = BT2020_U_G; ub = BT2020_U_B; vr = BT2020_V_R; vg = BT2020_V_G; vb = BT2020_V_B; @@ -135,12 +147,12 @@ vec3 rgb_to_yuv(vec3 rgb) { // --- 8-bit quantization --- uint q8_y(float y) { - if (params.full_range == 0u) return uint(round(clamp(y * 219.0 + 16.0, 0.0, 255.0))); + if (params.range == 0u) return uint(round(clamp(y * 219.0 + 16.0, 0.0, 255.0))); return uint(round(clamp(y * 255.0, 0.0, 255.0))); } uint q8_c(float c) { - if (params.full_range == 0u) return uint(round(clamp((c - 0.5) * 224.0 + 128.0, 0.0, 255.0))); + if (params.range == 0u) return uint(round(clamp((c - 0.5) * 224.0 + 128.0, 0.0, 255.0))); return uint(round(clamp(c * 255.0, 0.0, 255.0))); } @@ -148,14 +160,14 @@ uint q8_c(float c) { uint q10_y(float y) { uint val; - if (params.full_range == 0u) val = uint(round(clamp(y * 876.0 + 64.0, 0.0, 1023.0))); + if (params.range == 0u) val = uint(round(clamp(y * 876.0 + 64.0, 0.0, 1023.0))); else val = uint(round(clamp(y * 1023.0, 0.0, 1023.0))); return (val << 6u) & 0xFFC0u; } uint q10_c(float c) { uint val; - if (params.full_range == 0u) val = uint(round(clamp((c - 0.5) * 896.0 + 512.0, 0.0, 1023.0))); + if (params.range == 0u) val = uint(round(clamp((c - 0.5) * 896.0 + 512.0, 0.0, 1023.0))); else val = uint(round(clamp(c * 1023.0, 0.0, 1023.0))); return (val << 6u) & 0xFFC0u; } diff --git a/shader/color_convert.spv b/shader/color_convert.spv index f0ad72cc4134c8b6c1aac9892d67a2c73da3d46e..a9436d4c9ba3b188c333501203900f56140e8329 100644 GIT binary patch literal 25968 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 diff --git a/src/converter/mod.rs b/src/converter/mod.rs index 770a894..1155fea 100644 --- a/src/converter/mod.rs +++ b/src/converter/mod.rs @@ -8,27 +8,161 @@ mod pipeline; +use crate::encoder::ColorDescription; use crate::error::{PixelForgeError, Result}; use crate::vulkan::VideoContext; use ash::vk; use tracing::debug; -/// Color space for RGB→YUV conversion matrix selection. +/// What the source pixels already are. +/// +/// This describes the *input* to the conversion: its primaries, its transfer +/// function, and, where the transfer function is relative rather than +/// absolute, what luminance a sample value of 1.0 stands for. It says nothing +/// about what the encoded stream will be; that is [`TargetColor`]. +/// +/// The reference white only matters on the way to an absolute transfer +/// function, so it is carried by the relative sources and read only when the +/// target is [`TargetColor::Bt2020Pq`]. [`SourceColor::Bt2020Pq`] has none: +/// PQ is already absolute. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SourceColor { + /// BT.709 primaries, sRGB transfer function. Ordinary SDR desktop content. + Srgb { + /// Luminance that a sample value of 1.0 represents, in nits. + reference_white_nits: f32, + }, + /// BT.709 primaries, linear light. This is scRGB, what a + /// `VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT` swapchain produces. + Bt709Linear { + /// Luminance that a sample value of 1.0 represents, in nits. + reference_white_nits: f32, + }, + /// BT.2020 primaries, linear light. + Bt2020Linear { + /// Luminance that a sample value of 1.0 represents, in nits. + reference_white_nits: f32, + }, + /// BT.2020 primaries, PQ (ST 2084) transfer function. Already HDR-encoded, + /// as a `VK_COLOR_SPACE_HDR10_ST2084_EXT` surface or a gamescope PQ + /// surface is. + Bt2020Pq, +} + +impl Default for SourceColor { + /// [`SourceColor::srgb`]. + fn default() -> Self { + Self::srgb() + } +} + +impl SourceColor { + /// sRGB content at the 203 nit reference white of ITU-R BT.2408. + pub fn srgb() -> Self { + Self::Srgb { + reference_white_nits: 203.0, + } + } + + /// scRGB: linear BT.709 where 1.0 is 80 cd/m², per IEC 61966-2-2. + /// + /// This is the pairing for an `EXTENDED_SRGB_LINEAR_EXT` swapchain, and + /// the 80 is why: taking the 203 that SDR content wants would map scRGB + /// white about two and a half times too bright. + pub fn scrgb() -> Self { + Self::Bt709Linear { + reference_white_nits: 80.0, + } + } + + /// Linear BT.2020 at the 203 nit reference white of ITU-R BT.2408. + pub fn bt2020_linear() -> Self { + Self::Bt2020Linear { + reference_white_nits: 203.0, + } + } + + /// Already PQ-encoded BT.2020. + pub fn bt2020_pq() -> Self { + Self::Bt2020Pq + } + + /// Override the luminance that a sample value of 1.0 represents. + /// + /// No effect on [`SourceColor::Bt2020Pq`], which is already absolute. + pub fn with_reference_white_nits(self, nits: f32) -> Self { + match self { + Self::Srgb { .. } => Self::Srgb { + reference_white_nits: nits, + }, + Self::Bt709Linear { .. } => Self::Bt709Linear { + reference_white_nits: nits, + }, + Self::Bt2020Linear { .. } => Self::Bt2020Linear { + reference_white_nits: nits, + }, + Self::Bt2020Pq => Self::Bt2020Pq, + } + } + + /// The luminance a sample value of 1.0 represents, where that is a + /// meaningful question to ask. + pub fn reference_white_nits(&self) -> Option { + match *self { + Self::Srgb { + reference_white_nits, + } + | Self::Bt709Linear { + reference_white_nits, + } + | Self::Bt2020Linear { + reference_white_nits, + } => Some(reference_white_nits), + Self::Bt2020Pq => None, + } + } + + /// Discriminant handed to the shader. + fn shader_id(&self) -> u32 { + match self { + Self::Srgb { .. } => 0, + Self::Bt709Linear { .. } => 1, + Self::Bt2020Linear { .. } => 2, + Self::Bt2020Pq => 3, + } + } +} + +/// What the encoded stream is. +/// +/// Each variant fixes all three of the primaries, the transfer function and +/// the YUV matrix, which is what lets [`ColorConverterConfig::color_description`] +/// hand the encoder a complete and truthful declaration. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ColorSpace { - /// BT.709 (standard SDR). Used for all HD/UHD SDR content. +pub enum TargetColor { + /// BT.709 primaries, BT.709 transfer function, BT.709 NCL matrix. SDR. #[default] Bt709, - /// BT.2020 (HDR / wide color gamut). - Bt2020, - /// sRGB input converted to BT.2020 + PQ output. - /// Used for SDR content during HDR streaming sessions. - /// Applies: inverse sRGB EOTF → BT.709→BT.2020 gamut mapping → PQ OETF. - SrgbToBt2020Pq, - /// scRGB input (linear BT.709, FP16) converted to BT.2020 + PQ output. - /// Used for HDR games submitting a `VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT` swapchain. - /// Applies: BT.709→BT.2020 gamut mapping → PQ OETF. No EOTF decode — input is already linear. - Bt709LinearToBt2020Pq, + /// BT.2020 primaries, PQ (ST 2084) transfer function, BT.2020 NCL matrix. + /// This is HDR10. + Bt2020Pq, +} + +/// Luma and chroma quantization range. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ColorRange { + /// Studio range: luma 16..235, chroma 16..240 at 8-bit. + #[default] + Limited, + /// Full range: 0..255 at 8-bit. + Full, +} + +impl ColorRange { + /// Whether this is [`ColorRange::Full`]. + pub fn is_full(&self) -> bool { + matches!(self, Self::Full) + } } /// Supported input pixel formats for color conversion. @@ -145,6 +279,13 @@ impl OutputFormat { } /// Configuration for the color converter. +/// +/// The colour side of this is deliberately two decisions rather than one: +/// [`source`](Self::source) says what the input pixels are, and +/// [`target`](Self::target) says what the encoded stream should be. Only the +/// target is a property of the output, which is why +/// [`color_description`](Self::color_description) can derive the encoder's +/// declaration from it with nothing left to guess. #[derive(Clone, Debug)] #[non_exhaustive] pub struct ColorConverterConfig { @@ -156,22 +297,20 @@ pub struct ColorConverterConfig { pub input_format: InputFormat, /// Output YUV format. pub output_format: OutputFormat, - /// Color space for the RGB→YUV matrix. - /// Use Bt709 for SDR, Bt2020 for HDR. - pub color_space: ColorSpace, - /// Full range (0-255 luma) or limited/studio range (16-235 luma). - /// Must match the `full_range` flag in `ColorDescription` for correct playback. - pub full_range: bool, - /// SDR reference white level in nits for the sRGB→BT.2020+PQ conversion. + /// What the input pixels already are. + pub source: SourceColor, + /// What the encoded stream should be. Also decides the YUV matrix. + pub target: TargetColor, + /// Quantization range the shader writes. /// - /// Per ITU-R BT.2408 the standard value is 203 nits. - /// Only used when `color_space` is `SrgbToBt2020Pq` or `Bt709LinearToBt2020Pq` - /// (for the latter, the scRGB spec defines 1.0 == 80 cd/m²). - pub sdr_reference_white_nits: f32, + /// Pass [`color_description`](Self::color_description) to the encoder and + /// this reaches the stream's VUI as well, which it has to: full-range + /// samples tagged as limited get expanded a second time on playback. + pub range: ColorRange, } impl ColorConverterConfig { - /// Create a new configuration with BT.709 color space and limited range. + /// Create a new configuration: sRGB in, SDR BT.709 out, limited range. pub fn new( width: u32, height: u32, @@ -183,11 +322,94 @@ impl ColorConverterConfig { height, input_format, output_format, - color_space: ColorSpace::Bt709, - full_range: false, - sdr_reference_white_nits: 203.0, + source: SourceColor::default(), + target: TargetColor::default(), + range: ColorRange::default(), + } + } + + /// Set what the input pixels already are. + pub fn with_source(mut self, source: SourceColor) -> Self { + self.source = source; + self + } + + /// Set what the encoded stream should be. + pub fn with_target(mut self, target: TargetColor) -> Self { + self.target = target; + self + } + + /// Set the quantization range. + pub fn with_range(mut self, range: ColorRange) -> Self { + self.range = range; + self + } + + /// The description of what this conversion actually produces, for + /// [`EncodeConfig::with_color_description`](crate::EncodeConfig::with_color_description). + /// + /// Derived from [`target`](Self::target) and [`range`](Self::range), so the + /// encoder's VUI cannot end up describing a different signal from the one + /// the shader wrote: + /// + /// ```no_run + /// use pixelforge::{ + /// ColorConverterConfig, ColorRange, EncodeConfig, InputFormat, OutputFormat, + /// SourceColor, TargetColor, + /// }; + /// + /// let conv = ColorConverterConfig::new(1920, 1080, InputFormat::BGRA, OutputFormat::P010) + /// .with_source(SourceColor::scrgb()) + /// .with_target(TargetColor::Bt2020Pq) + /// .with_range(ColorRange::Full); + /// + /// let enc = EncodeConfig::h265(1920, 1080) + /// .with_color_description(conv.color_description()); + /// ``` + pub fn color_description(&self) -> ColorDescription { + let description = match self.target { + TargetColor::Bt709 => ColorDescription::bt709(), + TargetColor::Bt2020Pq => ColorDescription::bt2020_pq(), + }; + description.with_full_range(self.range.is_full()) + } + + /// Whether the shader can get from this source to this target. + /// + /// Anything that would need tone mapping or a forward gamma encode is not + /// implemented, and is rejected rather than silently passed through. + fn conversion_supported(&self) -> bool { + match (self.source, self.target) { + // Already encoded for the target: write it through. + (SourceColor::Srgb { .. }, TargetColor::Bt709) => true, + (SourceColor::Bt2020Pq, TargetColor::Bt2020Pq) => true, + // Encode to PQ, with a gamut hop for the BT.709 sources. + (SourceColor::Srgb { .. }, TargetColor::Bt2020Pq) => true, + (SourceColor::Bt709Linear { .. }, TargetColor::Bt2020Pq) => true, + (SourceColor::Bt2020Linear { .. }, TargetColor::Bt2020Pq) => true, + // Would need an SDR gamma encode. + (SourceColor::Bt709Linear { .. }, TargetColor::Bt709) => false, + (SourceColor::Bt2020Linear { .. }, TargetColor::Bt709) => false, + // Would need tone mapping. + (SourceColor::Bt2020Pq, TargetColor::Bt709) => false, } } + + /// Error explaining why [`Self::conversion_supported`] said no. + fn unsupported_conversion(&self) -> PixelForgeError { + PixelForgeError::InvalidInput(format!( + "no conversion from {:?} to {:?}: {}", + self.source, + self.target, + match self.target { + TargetColor::Bt709 => + "encoding SDR from a linear or PQ source would need \ + a gamma encode or tone mapping, neither of which the shader does", + TargetColor::Bt2020Pq => "unreachable", + } + )) + } } /// GPU-based color format converter. @@ -241,7 +463,14 @@ pub struct ColorConverter { impl ColorConverter { /// Create a new color converter. + /// + /// Fails if the shader has no path from the configured + /// [`source`](ColorConverterConfig::source) to its + /// [`target`](ColorConverterConfig::target). pub fn new(context: VideoContext, config: ColorConverterConfig) -> Result { + if !config.conversion_supported() { + return Err(config.unsupported_conversion()); + } pipeline::create_converter(context, config) } @@ -265,29 +494,54 @@ impl ColorConverter { self.output_buffer } - /// Set the color space for subsequent conversions. + /// Change what the input pixels are, for subsequent conversions. + /// + /// Takes effect on the next `convert()` without recreating the pipeline, + /// since the source is passed via push constants. /// - /// This takes effect on the next `convert()` call without recreating the pipeline, - /// since the color space is passed via push constants. - pub fn set_color_space(&mut self, color_space: ColorSpace) { - self.config.color_space = color_space; + /// Returns an error, and leaves the converter untouched, if the new source + /// cannot reach the configured target. + pub fn set_source(&mut self, source: SourceColor) -> Result<()> { + let candidate = ColorConverterConfig { + source, + ..self.config.clone() + }; + if !candidate.conversion_supported() { + return Err(candidate.unsupported_conversion()); + } + self.config.source = source; + Ok(()) } - /// Set the full-range flag for subsequent conversions. + /// Change what the encoded stream should be, for subsequent conversions. + /// + /// Takes effect on the next `convert()` without recreating the pipeline, + /// since the target is passed via push constants. /// - /// This takes effect on the next `convert()` call without recreating the pipeline, - /// since the full-range flag is passed via push constants. - pub fn set_full_range(&mut self, full_range: bool) { - self.config.full_range = full_range; + /// Returns an error, and leaves the converter untouched, if the configured + /// source cannot reach the new target. Callers that pass + /// [`ColorConverterConfig::color_description`] to the encoder need to + /// re-declare it after this: the target is half of what that derives from, + /// and an encoder's session parameters were fixed when it was built. + pub fn set_target(&mut self, target: TargetColor) -> Result<()> { + let candidate = ColorConverterConfig { + target, + ..self.config.clone() + }; + if !candidate.conversion_supported() { + return Err(candidate.unsupported_conversion()); + } + self.config.target = target; + Ok(()) } - /// Set the SDR reference white level for subsequent conversions. + /// Change the quantization range for subsequent conversions. /// - /// This takes effect on the next `convert()` call without recreating the pipeline, - /// since the value is passed via push constants. Only consumed by the - /// `SrgbToBt2020Pq` and `Bt709LinearToBt2020Pq` color spaces. - pub fn set_sdr_reference_white_nits(&mut self, nits: f32) { - self.config.sdr_reference_white_nits = nits; + /// Takes effect on the next `convert()` without recreating the pipeline, + /// since the range is passed via push constants. The same re-declaration + /// caveat as [`Self::set_target`] applies. + pub fn set_range(&mut self, range: ColorRange) { + self.config.range = range; } /// Build buffer-to-image copy regions for multi-planar YUV formats. @@ -740,15 +994,21 @@ impl ColorConverter { &offsets, ); - // Push constants: width, height, input_format, output_format, color_space, full_range, sdr_white_nits. - let push_constants: [u32; 7] = [ + // Push constants: width, height, input_format, output_format, + // source, target, range, reference_white_nits. + let push_constants: [u32; 8] = [ self.config.width, self.config.height, self.config.input_format as u32, self.config.output_format as u32, - self.config.color_space as u32, - self.config.full_range as u32, - self.config.sdr_reference_white_nits.to_bits(), + self.config.source.shader_id(), + self.config.target as u32, + self.config.range.is_full() as u32, + self.config + .source + .reference_white_nits() + .unwrap_or(0.0) + .to_bits(), ]; let push_constants_bytes: &[u8] = std::slice::from_raw_parts( push_constants.as_ptr() as *const u8, @@ -1010,17 +1270,143 @@ mod tests { } // ======================== - // ColorSpace tests. + // SourceColor / TargetColor / ColorRange tests. + // ======================== + + #[test] + fn source_discriminants_match_the_shader() { + // The shader's SOURCE_* defines. + assert_eq!(SourceColor::srgb().shader_id(), 0); + assert_eq!(SourceColor::scrgb().shader_id(), 1); + assert_eq!(SourceColor::bt2020_linear().shader_id(), 2); + assert_eq!(SourceColor::bt2020_pq().shader_id(), 3); + } + + #[test] + fn target_and_range_discriminants_match_the_shader() { + // The shader's TARGET_* defines, and its `range == 0u` test. + assert_eq!(TargetColor::Bt709 as u32, 0); + assert_eq!(TargetColor::Bt2020Pq as u32, 1); + assert!(!ColorRange::Limited.is_full()); + assert!(ColorRange::Full.is_full()); + } + + #[test] + fn scrgb_white_is_not_the_srgb_reference() { + // IEC 61966-2-2 puts scRGB 1.0 at 80 cd/m²; BT.2408 puts SDR white at + // 203. Taking the 203 for scRGB is the mistake this pairing prevents. + assert_eq!(SourceColor::scrgb().reference_white_nits(), Some(80.0)); + assert_eq!(SourceColor::srgb().reference_white_nits(), Some(203.0)); + assert_eq!( + SourceColor::bt2020_linear().reference_white_nits(), + Some(203.0) + ); + } + + #[test] + fn pq_source_has_no_reference_white_to_set() { + // PQ is absolute, so there is no such quantity, and overriding it is + // a no-op rather than a silently stored value. + assert_eq!(SourceColor::bt2020_pq().reference_white_nits(), None); + let overridden = SourceColor::bt2020_pq().with_reference_white_nits(500.0); + assert_eq!(overridden, SourceColor::Bt2020Pq); + assert_eq!(overridden.reference_white_nits(), None); + } + + #[test] + fn reference_white_override_keeps_the_source_kind() { + let source = SourceColor::scrgb().with_reference_white_nits(203.0); + assert_eq!(source.shader_id(), SourceColor::scrgb().shader_id()); + assert_eq!(source.reference_white_nits(), Some(203.0)); + } + + // ======================== + // color_description() tests. + // ======================== + + fn config_for(target: TargetColor, range: ColorRange) -> ColorConverterConfig { + ColorConverterConfig::new(64, 64, InputFormat::BGRA, OutputFormat::NV12) + .with_target(target) + .with_range(range) + } + + #[test] + fn description_follows_the_target() { + let sdr = config_for(TargetColor::Bt709, ColorRange::Limited).color_description(); + assert_eq!(sdr, ColorDescription::bt709()); + assert!(!sdr.is_hdr()); + + let hdr = config_for(TargetColor::Bt2020Pq, ColorRange::Limited).color_description(); + assert_eq!(hdr, ColorDescription::bt2020_pq()); + assert!(hdr.is_hdr()); + } + + #[test] + fn description_follows_the_range() { + // The drift this whole split exists to prevent: what the shader + // quantizes to and what the VUI claims are now one decision. + for target in [TargetColor::Bt709, TargetColor::Bt2020Pq] { + for range in [ColorRange::Limited, ColorRange::Full] { + let config = config_for(target, range); + assert_eq!( + config.color_description().full_range, + config.range.is_full(), + "{target:?} / {range:?}" + ); + } + } + } + + #[test] + fn description_ignores_the_source() { + // The source says nothing about the encoded stream, so it must not + // reach the declaration. + let base = ColorConverterConfig::new(64, 64, InputFormat::BGRA, OutputFormat::P010) + .with_target(TargetColor::Bt2020Pq) + .with_range(ColorRange::Full); + let from_srgb = base.clone().with_source(SourceColor::srgb()); + let from_scrgb = base.clone().with_source(SourceColor::scrgb()); + let from_pq = base.with_source(SourceColor::bt2020_pq()); + assert_eq!( + from_srgb.color_description(), + from_scrgb.color_description() + ); + assert_eq!(from_srgb.color_description(), from_pq.color_description()); + } + + // ======================== + // Supported conversion tests. // ======================== #[test] - fn test_color_space_enum_values() { - // Verify enum values match shader push constant expectations: - // 0=BT.709, 1=BT.2020, 2=sRGB→BT.2020+PQ, 3=scRGB (BT.709 linear)→BT.2020+PQ. - assert_eq!(ColorSpace::Bt709 as u32, 0); - assert_eq!(ColorSpace::Bt2020 as u32, 1); - assert_eq!(ColorSpace::SrgbToBt2020Pq as u32, 2); - assert_eq!(ColorSpace::Bt709LinearToBt2020Pq as u32, 3); + fn every_source_can_reach_pq() { + for source in [ + SourceColor::srgb(), + SourceColor::scrgb(), + SourceColor::bt2020_linear(), + SourceColor::bt2020_pq(), + ] { + let config = config_for(TargetColor::Bt2020Pq, ColorRange::Full).with_source(source); + assert!(config.conversion_supported(), "{source:?}"); + } + } + + #[test] + fn only_srgb_can_reach_sdr() { + // Everything else would need a forward gamma encode or tone mapping, + // and the shader does neither. Rejected beats silently passed through. + let ok = + config_for(TargetColor::Bt709, ColorRange::Limited).with_source(SourceColor::srgb()); + assert!(ok.conversion_supported()); + + for source in [ + SourceColor::scrgb(), + SourceColor::bt2020_linear(), + SourceColor::bt2020_pq(), + ] { + let config = config_for(TargetColor::Bt709, ColorRange::Limited).with_source(source); + assert!(!config.conversion_supported(), "{source:?}"); + } } // ======================== @@ -1103,36 +1489,22 @@ mod tests { #[test] fn test_config_clone() { - let config = ColorConverterConfig { - width: 1920, - height: 1080, - input_format: InputFormat::BGRx, - output_format: OutputFormat::NV12, - color_space: ColorSpace::Bt709, - full_range: true, - sdr_reference_white_nits: 203.0, - }; + let config = ColorConverterConfig::new(1920, 1080, InputFormat::BGRx, OutputFormat::NV12) + .with_range(ColorRange::Full); let cloned = config.clone(); assert_eq!(cloned.width, 1920); assert_eq!(cloned.height, 1080); assert_eq!(cloned.input_format, InputFormat::BGRx); assert_eq!(cloned.output_format, OutputFormat::NV12); - assert_eq!(cloned.color_space, ColorSpace::Bt709); - assert!(cloned.full_range); + assert_eq!(cloned.target, TargetColor::Bt709); + assert!(cloned.range.is_full()); } #[test] fn test_config_debug() { - let config = ColorConverterConfig { - width: 640, - height: 480, - input_format: InputFormat::RGBA, - output_format: OutputFormat::I420, - color_space: ColorSpace::Bt709, - full_range: true, - sdr_reference_white_nits: 203.0, - }; + let config = ColorConverterConfig::new(640, 480, InputFormat::RGBA, OutputFormat::I420) + .with_range(ColorRange::Full); let debug_str = format!("{:?}", config); assert!(debug_str.contains("640")); @@ -1166,15 +1538,8 @@ mod tests { return; }; - let config = ColorConverterConfig { - width: 64, - height: 64, - input_format: InputFormat::BGRx, - output_format: OutputFormat::NV12, - color_space: ColorSpace::Bt709, - full_range: true, - sdr_reference_white_nits: 203.0, - }; + let config = ColorConverterConfig::new(64, 64, InputFormat::BGRx, OutputFormat::NV12) + .with_range(ColorRange::Full); let result = ColorConverter::new(context, config); assert!( @@ -1201,15 +1566,8 @@ mod tests { for input_format in &input_formats { for output_format in &output_formats { - let config = ColorConverterConfig { - width: 32, - height: 32, - input_format: *input_format, - output_format: *output_format, - color_space: ColorSpace::Bt709, - full_range: true, - sdr_reference_white_nits: 203.0, - }; + let config = ColorConverterConfig::new(32, 32, *input_format, *output_format) + .with_range(ColorRange::Full); let result = ColorConverter::new(context.clone(), config); assert!( diff --git a/src/converter/pipeline.rs b/src/converter/pipeline.rs index fe135dc..24ce281 100644 --- a/src/converter/pipeline.rs +++ b/src/converter/pipeline.rs @@ -16,7 +16,7 @@ const COLOR_CONVERT_SPIRV_BYTES: &[u8] = include_bytes!("../../shader/color_conv /// Get the SPIR-V bytecode for the color conversion shader. /// /// The shader expects: -/// - Push constants: width, height, input_format, output_format, color_space, full_range (6 × u32) +/// - Push constants: width, height, input_format, output_format, source, target, range, reference_white_nits (8 × u32) /// - Binding 0: Input image (sampler2D) /// - Binding 1: Output buffer (YUV data) /// @@ -73,7 +73,7 @@ pub fn create_converter( let push_constant_range = vk::PushConstantRange::default() .stage_flags(vk::ShaderStageFlags::COMPUTE) .offset(0) - .size(28); // 7 x u32: width, height, input_format, output_format, color_space, full_range, sdr_white_nits(f32) + .size(32); // 8 x u32: width, height, input_format, output_format, source, target, range, reference_white_nits(f32) let pipeline_layout_info = vk::PipelineLayoutCreateInfo::default() .set_layouts(std::slice::from_ref(&descriptor_set_layout)) diff --git a/src/lib.rs b/src/lib.rs index f65439b..2e1778f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -179,31 +179,66 @@ //! //! ## Color Conversion (RGB → YUV) //! -//! PixelForge includes a GPU compute shader for converting RGB input to YUV output, supporting multiple color spaces: -//! -//! | Color Space | Description | -//! |-------------|-------------| -//! | `Bt709` | Standard SDR (BT.709 coefficients) | -//! | `Bt2020` | HDR passthrough (BT.2020 coefficients, PQ-encoded input) | -//! | `SrgbToBt2020Pq` | SDR-in-HDR (sRGB → linear → BT.2020 gamut → PQ OETF) | -//! | `Bt709LinearToBt2020Pq` | scRGB HDR (linear BT.709 → BT.2020 gamut → PQ OETF). `sdr_reference_white_nits` sets the interpretation of 1.0; per the scRGB spec (IEC 61966-2-2), 80 nits. | +//! PixelForge includes a GPU compute shader for converting RGB input to YUV +//! output. The conversion is two decisions: what the input already is, and +//! what the encoded stream should be. +//! +//! | `SourceColor` | What the input pixels are | +//! |---------------|---------------------------| +//! | `Srgb` | BT.709 primaries, sRGB transfer. Ordinary SDR content. | +//! | `Bt709Linear` | Linear BT.709. This is scRGB, from an `EXTENDED_SRGB_LINEAR_EXT` swapchain. | +//! | `Bt2020Linear` | Linear BT.2020. | +//! | `Bt2020Pq` | BT.2020 primaries, already PQ-encoded. | +//! +//! | `TargetColor` | What the stream is | +//! |---------------|--------------------| +//! | `Bt709` | BT.709 primaries, transfer and matrix. SDR. | +//! | `Bt2020Pq` | BT.2020 primaries, PQ transfer, BT.2020 NCL matrix. HDR10. | +//! +//! The relative sources carry the luminance that a sample value of 1.0 stands +//! for, since that is a property of the source and not of the target: 203 nits +//! for `Srgb` per ITU-R BT.2408, and 80 for scRGB per IEC 61966-2-2. It is read +//! only on the way to `Bt2020Pq`, where the PQ encode needs it to be absolute. +//! +//! Every source reaches `Bt2020Pq`. Only `Srgb` reaches `Bt709`, because the +//! others would need a forward gamma encode or tone mapping; those pairings are +//! rejected by [`ColorConverter::new`] rather than quietly passed through. //! //! Supported input formats: BGRx, RGBx, BGRA, RGBA, ABGR2101010 (10-bit packed), RGBA16F (FP16). //! Supported output formats: NV12 (8-bit), I420 (8-bit), YUV444 (8-bit), P010 (10-bit), YUV444P10 (10-bit). //! +//! Because the target and the range fully determine the stream's colour +//! signalling, [`ColorConverterConfig::color_description`] derives the +//! encoder's VUI declaration from the conversion itself. Use it rather than +//! declaring the same thing twice: full-range samples tagged as limited are +//! expanded a second time on playback, and the API cannot catch that if the two +//! are set independently. +//! //! ```rust,no_run -//! use pixelforge::{ColorConverter, ColorConverterConfig, ColorSpace, InputFormat, OutputFormat, VideoContextBuilder}; +//! use pixelforge::{ +//! Codec, ColorConverter, ColorConverterConfig, ColorRange, EncodeConfig, Encoder, +//! InputFormat, OutputFormat, SourceColor, TargetColor, VideoContextBuilder, +//! }; //! //! # fn main() -> Result<(), Box> { //! let context = VideoContextBuilder::new() //! .app_name("Color Converter") +//! .require_encode(Codec::H265) //! .build()?; //! -//! let mut config = ColorConverterConfig::new(1920, 1080, InputFormat::BGRx, OutputFormat::NV12); -//! config.color_space = ColorSpace::SrgbToBt2020Pq; +//! // SDR desktop content, encoded as HDR10 for an HDR streaming session. +//! let config = ColorConverterConfig::new(1920, 1080, InputFormat::BGRx, OutputFormat::P010) +//! .with_source(SourceColor::srgb()) +//! .with_target(TargetColor::Bt2020Pq) +//! .with_range(ColorRange::Full); +//! +//! // The encoder declares exactly what the shader wrote. +//! let encode_config = EncodeConfig::h265(1920, 1080) +//! .with_color_description(config.color_description()); //! //! let mut converter = ColorConverter::new(context.clone(), config)?; -//! // converter.convert(input_image, output_buffer)?; +//! let mut encoder = Encoder::new(context, encode_config)?; +//! // converter.convert(input_image, layout, encoder.input_image())?; //! # Ok(()) //! # } //! ``` @@ -282,7 +317,10 @@ pub(crate) const fn align4(size: usize) -> usize { (size + 3) & !3 } -pub use converter::{ColorConverter, ColorConverterConfig, ColorSpace, InputFormat, OutputFormat}; +pub use converter::{ + ColorConverter, ColorConverterConfig, ColorRange, InputFormat, OutputFormat, SourceColor, + TargetColor, +}; pub use decoder::{ DecodeConfig, DecodeSink, DecodeSource, DecodeStatus, DecodedFrame, Decoder, FramePoll, Framing, }; From 3f61b7090f5a7d2c52f2e248af973fec0570ddd3 Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Thu, 10 Sep 2026 14:57:35 +0300 Subject: [PATCH 2/8] feat(examples): check every conversion path against a model of itself The split in the previous commit rearranged which shader stages run for which source and target. Getting one of those wrong does not fail loudly: it produces a plausible picture in the wrong colour, and the only way to catch it is to compute the same thing twice by different means. `convert_color_paths` reimplements the pipeline on the CPU stage for stage, selecting the stages from the source and target rather than from the shader's branches, and compares the luma plane sample by sample. All five supported pairs at 8-bit and 10-bit agree to within one code value, which is the slack the GPU needs to order and fuse its f32 arithmetic differently: Srgb -> Bt709 worst 1.0, mean 0.0002 VUI 1/1/1 Srgb -> Bt2020Pq worst 1.0, mean 0.0005 VUI 9/16/9 Bt709Linear -> Bt2020Pq worst 1.0, mean 0.0003 VUI 9/16/9 Bt2020Linear -> Bt2020Pq worst 1.0, mean 0.0005 VUI 9/16/9 Bt2020Pq -> Bt2020Pq worst 0.0, mean 0.0000 VUI 9/16/9 A passing run means something because the comparison is sensitive to the things it is checking. Modelling scRGB at 203 nits instead of 80 takes the mean to 22.5; dropping the BT.709 to BT.2020 gamut hop takes the worst case to 55. Both were confirmed by hand before this landed. It prints the derived `color_description()` alongside each result, so the VUI code points are visible next to the pixels they describe, and it asserts that the three unsupported pairs are refused rather than quietly converted. Same measurement discipline as `convert_quantization`, and luma only for the same reason: chroma is a 2x2 average, which would mix averaging error in. --- examples/convert_color_paths.rs | 481 ++++++++++++++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 examples/convert_color_paths.rs diff --git a/examples/convert_color_paths.rs b/examples/convert_color_paths.rs new file mode 100644 index 0000000..189f98f --- /dev/null +++ b/examples/convert_color_paths.rs @@ -0,0 +1,481 @@ +//! Example: Check every conversion path against a model of itself +//! +//! [`ColorConverterConfig`] takes two colour decisions: a [`SourceColor`] for +//! what the input pixels already are, and a [`TargetColor`] for what the +//! encoded stream should be. Between them they select which of the shader's +//! stages run: an sRGB decode, a BT.709 to BT.2020 gamut hop, a PQ encode, or +//! none of them for a passthrough. +//! +//! Getting one of those stages wrong does not fail loudly. It produces a +//! plausible picture with the wrong colour, which is why this reimplements the +//! same pipeline on the CPU, stage for stage, and compares. Every supported +//! pair is checked at 8-bit and 10-bit, in both cases against a model built +//! from the source and target rather than from the shader's branches, so a +//! branch taken wrongly shows up as a large disagreement. +//! +//! It also asserts the refusals. Only [`SourceColor::Srgb`] can reach +//! [`TargetColor::Bt709`]; a linear or PQ source would need a forward gamma +//! encode or tone mapping, and the converter rejects those pairs rather than +//! passing the samples through and mislabelling them. +//! +//! Luma only, for the same reason as `convert_quantization`: chroma is a 2x2 +//! average in NV12 and P010, which would mix averaging error into the result. +//! +//! ```text +//! cargo run --example convert_color_paths +//! ``` + +use ash::vk; +use pixelforge::{ + Codec, ColorConverter, ColorConverterConfig, ColorRange, EncodeBitDepth, EncodeConfig, Encoder, + InputFormat, OutputFormat, RateControlMode, SourceColor, TargetColor, VideoContext, + VideoContextBuilder, +}; + +const WIDTH: u32 = 256; +const HEIGHT: u32 = 64; + +/// Deterministic colours that are never grey, so every channel differs and the +/// luma lands between code values often enough to be worth measuring. +fn colour_at(x: u32, y: u32) -> [u8; 3] { + [ + (x ^ y.wrapping_mul(37)) as u8, + x.wrapping_mul(7).wrapping_add(y.wrapping_mul(11)) as u8, + x.wrapping_mul(13).wrapping_add(y.wrapping_mul(3)) as u8, + ] +} + +fn make_frame_bgra() -> Vec { + let mut data = Vec::with_capacity((WIDTH * HEIGHT * 4) as usize); + for y in 0..HEIGHT { + for x in 0..WIDTH { + let [r, g, b] = colour_at(x, y); + data.extend_from_slice(&[b, g, r, 255]); + } + } + data +} + +// --- CPU model of each shader stage, same order, same constants. --- + +fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +fn bt709_to_bt2020(rgb: [f32; 3]) -> [f32; 3] { + let [r, g, b] = rgb; + [ + 0.6274 * r + 0.3293 * g + 0.0433 * b, + 0.0691 * r + 0.9195 * g + 0.0114 * b, + 0.0164 * r + 0.0880 * g + 0.8956 * b, + ] +} + +fn linear_to_pq(l: f32) -> f32 { + // Computed in f64. This is the reference; the shader's own f32 rounding is + // what the one-code-value tolerance covers. + const M1: f64 = 0.1593017578125; + const M2: f64 = 78.84375; + const C1: f64 = 0.8359375; + const C2: f64 = 18.8515625; + const C3: f64 = 18.6875; + let lm1 = (l.max(0.0) as f64).powf(M1); + (((C1 + C2 * lm1) / (1.0 + C3 * lm1)).powf(M2)) as f32 +} + +/// The signal the shader should arrive at for this source and target, then its +/// luma, then its code value. Mirrors `read_rgb` and `rgb_to_yuv`. +fn expected_code( + rgb: [u8; 3], + source: SourceColor, + target: TargetColor, + ten_bit: bool, + full: bool, +) -> f32 { + let mut v = [ + rgb[0] as f32 / 255.0, + rgb[1] as f32 / 255.0, + rgb[2] as f32 / 255.0, + ]; + if target == TargetColor::Bt2020Pq && source != SourceColor::Bt2020Pq { + if matches!(source, SourceColor::Srgb { .. }) { + v = [ + srgb_to_linear(v[0]), + srgb_to_linear(v[1]), + srgb_to_linear(v[2]), + ]; + } + if !matches!(source, SourceColor::Bt2020Linear { .. }) { + v = bt709_to_bt2020(v); + } + let nits = source.reference_white_nits().unwrap(); + v = [ + linear_to_pq(v[0] * (nits / 10000.0)), + linear_to_pq(v[1] * (nits / 10000.0)), + linear_to_pq(v[2] * (nits / 10000.0)), + ]; + } + let y = if target == TargetColor::Bt2020Pq { + 0.2627f32 * v[0] + 0.6780f32 * v[1] + 0.0593f32 * v[2] + } else { + 0.2126f32 * v[0] + 0.7152f32 * v[1] + 0.0722f32 * v[2] + } + .clamp(0.0, 1.0); + match (ten_bit, full) { + (false, true) => (y * 255.0).clamp(0.0, 255.0), + (false, false) => (y * 219.0 + 16.0).clamp(0.0, 255.0), + (true, true) => (y * 1023.0).clamp(0.0, 1023.0), + (true, false) => (y * 876.0 + 64.0).clamp(0.0, 1023.0), + } +} + +struct SrcImage { + image: vk::Image, + memory: vk::DeviceMemory, +} + +unsafe fn one_shot( + context: &VideoContext, + record: F, +) -> Result<(), Box> { + let device = context.device(); + let pool_info = vk::CommandPoolCreateInfo::default() + .queue_family_index(context.transfer_queue_family()) + .flags(vk::CommandPoolCreateFlags::TRANSIENT); + let pool = unsafe { device.create_command_pool(&pool_info, None) }?; + let cb_info = vk::CommandBufferAllocateInfo::default() + .command_pool(pool) + .level(vk::CommandBufferLevel::PRIMARY) + .command_buffer_count(1); + let cb = unsafe { device.allocate_command_buffers(&cb_info) }?[0]; + unsafe { + device.begin_command_buffer( + cb, + &vk::CommandBufferBeginInfo::default() + .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT), + )?; + record(cb); + device.end_command_buffer(cb)?; + let cbs = [cb]; + let submit = vk::SubmitInfo::default().command_buffers(&cbs); + device.queue_submit(context.transfer_queue(), &[submit], vk::Fence::null())?; + device.queue_wait_idle(context.transfer_queue())?; + device.destroy_command_pool(pool, None); + } + Ok(()) +} + +unsafe fn host_buffer( + context: &VideoContext, + size: u64, + usage: vk::BufferUsageFlags, +) -> Result<(vk::Buffer, vk::DeviceMemory), Box> { + let device = context.device(); + let info = vk::BufferCreateInfo::default() + .size(size) + .usage(usage) + .sharing_mode(vk::SharingMode::EXCLUSIVE); + let buffer = unsafe { device.create_buffer(&info, None) }?; + let reqs = unsafe { device.get_buffer_memory_requirements(buffer) }; + let mem_type = context + .find_memory_type( + reqs.memory_type_bits, + vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT, + ) + .ok_or("no host visible memory")?; + let alloc = vk::MemoryAllocateInfo::default() + .allocation_size(reqs.size) + .memory_type_index(mem_type); + let memory = unsafe { device.allocate_memory(&alloc, None) }?; + unsafe { device.bind_buffer_memory(buffer, memory, 0) }?; + Ok((buffer, memory)) +} + +unsafe fn create_src_image(context: &VideoContext) -> Result> { + let device = context.device(); + let info = vk::ImageCreateInfo::default() + .image_type(vk::ImageType::TYPE_2D) + .format(vk::Format::B8G8R8A8_UNORM) + .extent(vk::Extent3D { + width: WIDTH, + height: HEIGHT, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST) + .sharing_mode(vk::SharingMode::EXCLUSIVE) + .initial_layout(vk::ImageLayout::UNDEFINED); + let image = unsafe { device.create_image(&info, None) }?; + let reqs = unsafe { device.get_image_memory_requirements(image) }; + let mem_type = context + .find_memory_type(reqs.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL) + .ok_or("no device local memory")?; + let alloc = vk::MemoryAllocateInfo::default() + .allocation_size(reqs.size) + .memory_type_index(mem_type); + let memory = unsafe { device.allocate_memory(&alloc, None) }?; + unsafe { device.bind_image_memory(image, memory, 0) }?; + + let pixels = make_frame_bgra(); + let (staging, staging_mem) = unsafe { + host_buffer( + context, + pixels.len() as u64, + vk::BufferUsageFlags::TRANSFER_SRC, + )? + }; + unsafe { + let ptr = device.map_memory( + staging_mem, + 0, + pixels.len() as u64, + vk::MemoryMapFlags::empty(), + )?; + std::ptr::copy_nonoverlapping(pixels.as_ptr(), ptr as *mut u8, pixels.len()); + device.unmap_memory(staging_mem); + } + + let range = vk::ImageSubresourceRange { + aspect_mask: vk::ImageAspectFlags::COLOR, + base_mip_level: 0, + level_count: 1, + base_array_layer: 0, + layer_count: 1, + }; + unsafe { + one_shot(context, |cb| { + let to_dst = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::UNDEFINED) + .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .image(image) + .subresource_range(range) + .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE); + device.cmd_pipeline_barrier( + cb, + vk::PipelineStageFlags::TOP_OF_PIPE, + vk::PipelineStageFlags::TRANSFER, + vk::DependencyFlags::empty(), + &[], + &[], + &[to_dst], + ); + let copy = vk::BufferImageCopy::default() + .image_subresource(vk::ImageSubresourceLayers { + aspect_mask: vk::ImageAspectFlags::COLOR, + mip_level: 0, + base_array_layer: 0, + layer_count: 1, + }) + .image_extent(vk::Extent3D { + width: WIDTH, + height: HEIGHT, + depth: 1, + }); + device.cmd_copy_buffer_to_image( + cb, + staging, + image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + &[copy], + ); + let to_read = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .image(image) + .subresource_range(range) + .src_access_mask(vk::AccessFlags::TRANSFER_WRITE) + .dst_access_mask(vk::AccessFlags::SHADER_READ); + device.cmd_pipeline_barrier( + cb, + vk::PipelineStageFlags::TRANSFER, + vk::PipelineStageFlags::ALL_COMMANDS, + vk::DependencyFlags::empty(), + &[], + &[], + &[to_read], + ); + })?; + device.destroy_buffer(staging, None); + device.free_memory(staging_mem, None); + } + Ok(SrcImage { image, memory }) +} + +/// Read the converter's luma plane back as code values. +fn read_luma( + context: &VideoContext, + converter: &ColorConverter, + output_format: OutputFormat, +) -> Result, Box> { + let sample_bytes = output_format.bytes_per_sample() as u64; + let plane_bytes = (WIDTH * HEIGHT) as u64 * sample_bytes; + let device = context.device(); + let (readback, readback_mem) = + unsafe { host_buffer(context, plane_bytes, vk::BufferUsageFlags::TRANSFER_DST)? }; + unsafe { + one_shot(context, |cb| { + let copy = vk::BufferCopy::default().size(plane_bytes); + device.cmd_copy_buffer(cb, converter.output_buffer(), readback, &[copy]); + })?; + } + let mut bytes = vec![0u8; plane_bytes as usize]; + unsafe { + let ptr = device.map_memory(readback_mem, 0, plane_bytes, vk::MemoryMapFlags::empty())?; + std::ptr::copy_nonoverlapping(ptr as *const u8, bytes.as_mut_ptr(), bytes.len()); + device.unmap_memory(readback_mem); + device.destroy_buffer(readback, None); + device.free_memory(readback_mem, None); + } + Ok(if sample_bytes == 2 { + bytes + .as_chunks::<2>() + .0 + .iter() + .map(|w| (u16::from_le_bytes(*w) >> 6) as u32) + .collect() + } else { + bytes.iter().map(|&b| b as u32).collect() + }) +} + +fn check( + context: &VideoContext, + src: &SrcImage, + encoder: &Encoder, + source: SourceColor, + target: TargetColor, + output_format: OutputFormat, +) -> Result> { + let ten_bit = output_format.bytes_per_sample() == 2; + let config = ColorConverterConfig::new(WIDTH, HEIGHT, InputFormat::BGRA, output_format) + .with_source(source) + .with_target(target) + .with_range(ColorRange::Full); + let description = config.color_description(); + let mut converter = ColorConverter::new(context.clone(), config)?; + converter.convert( + src.image, + vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, + encoder.input_image(), + )?; + let codes = read_luma(context, &converter, output_format)?; + + let mut worst = 0f64; + let mut sum = 0f64; + for y in 0..HEIGHT { + for x in 0..WIDTH { + let got = codes[(y * WIDTH + x) as usize] as f64; + let want = expected_code(colour_at(x, y), source, target, ten_bit, true).round() as f64; + let d = (got - want).abs(); + worst = worst.max(d); + sum += d; + } + } + let mean = sum / (WIDTH * HEIGHT) as f64; + // One code value of slack: the GPU is free to order and fuse these f32 + // operations differently, which moves samples that land on a tie. + let ok = worst <= 1.0; + println!( + "{:<34} -> {:<10} {:<6} worst {worst:>5.1}, mean {mean:.4} VUI {}/{}/{} {} {}", + format!("{source:?}"), + format!("{target:?}"), + if ten_bit { "P010" } else { "NV12" }, + description.color_primaries, + description.transfer_characteristics, + description.matrix_coefficients, + if description.full_range { "pc" } else { "tv" }, + if ok { "ok" } else { "MISMATCH" } + ); + Ok(ok) +} + +fn main() -> Result<(), Box> { + let context = VideoContextBuilder::new() + .app_name("path probe") + .require_encode(Codec::H264) + .build()?; + let src = unsafe { create_src_image(&context)? }; + let encoder_8 = Encoder::new( + context.clone(), + EncodeConfig::h264(WIDTH, HEIGHT) + .with_rate_control(RateControlMode::Cqp) + .with_frame_rate(30, 1) + .with_b_frames(0), + )?; + let encoder_10 = Encoder::new( + context.clone(), + EncodeConfig::h265(WIDTH, HEIGHT) + .with_bit_depth(EncodeBitDepth::Ten) + .with_rate_control(RateControlMode::Cqp) + .with_frame_rate(30, 1) + .with_b_frames(0), + )?; + + let supported = [ + (SourceColor::srgb(), TargetColor::Bt709), + (SourceColor::srgb(), TargetColor::Bt2020Pq), + (SourceColor::scrgb(), TargetColor::Bt2020Pq), + (SourceColor::bt2020_linear(), TargetColor::Bt2020Pq), + (SourceColor::bt2020_pq(), TargetColor::Bt2020Pq), + ]; + let mut all_ok = true; + for (source, target) in supported { + all_ok &= check( + &context, + &src, + &encoder_8, + source, + target, + OutputFormat::NV12, + )?; + all_ok &= check( + &context, + &src, + &encoder_10, + source, + target, + OutputFormat::P010, + )?; + } + + println!("\n--- these must be refused ---"); + for source in [ + SourceColor::scrgb(), + SourceColor::bt2020_linear(), + SourceColor::bt2020_pq(), + ] { + let config = + ColorConverterConfig::new(WIDTH, HEIGHT, InputFormat::BGRA, OutputFormat::NV12) + .with_source(source) + .with_target(TargetColor::Bt709); + match ColorConverter::new(context.clone(), config) { + Ok(_) => { + println!("{source:?} -> Bt709: ACCEPTED, should not be"); + all_ok = false; + } + Err(e) => println!("{source:?} -> Bt709: refused ({e})"), + } + } + + unsafe { + let device = context.device(); + device.destroy_image(src.image, None); + device.free_memory(src.memory, None); + } + if all_ok { + Ok(()) + } else { + Err("at least one path disagrees with the model".into()) + } +} From f3eddbaa92f8faf824b9d57537bcb5ac12fbc37a Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Fri, 18 Sep 2026 18:27:54 +0300 Subject: [PATCH 3/8] refactor(converter)!: unify the two colour types into ColorSpec Addresses hgaiser's review on #38. His objection to the source/target split was that the names stop meaning anything if conversion ever runs the other way, and he is right. libavcodec is not really the counterexample he reached for, since its colour fields describe one image and swscale takes a source frame and a destination frame each carrying their own set, so it has a source and a target too, expressed as two instances of one type. That is the choice: one symmetric type used at both ends, or two asymmetric ones. Taking the symmetric option turns out to remove a type rather than add one. `TargetColor::Bt709` was the same colour space as `SourceColor::Srgb`, and `TargetColor::Bt2020Pq` the same as `SourceColor::Bt2020Pq`; the only thing separating them was the direction they were used in, which belongs to the slot and not to the value. So `TargetColor` is gone and `SourceColor` is renamed: ColorConverterConfig::new(w, h, input, output, ColorSpec::scrgb(), // source slot ColorSpec::Bt2020Pq, // target slot ColorRange::Full) Not every space can be a target. There are no H.273 code points for linear light, so `Bt709Linear` and `Bt2020Linear` are source-only, which `ColorSpec::is_encodable` answers and `ColorConverter::new` enforces. That makes `ColorConverterConfig::color_description` partial, returning `Option`. The partiality is confined: `ColorConverter::color_description` is infallible, because a converter only exists if its target passed that check, and callers doing the normal thing never see the `Option`. Also in this round, from the rest of the review: The `RGBA16F` doc still explained itself in terms of the old `ColorSpace` variants. It now says the `ColorSpec` decides the interpretation, not the pixel format. `set_target` claimed an encoder's session parameters were fixed when it was built. They are not: `Encoder::set_color_description` drains in-flight encodes, rebuilds them, invalidates the header cache and requests an IDR. The doc points there instead of implying the caller is stuck. Same on `set_range`. The module prose takes hgaiser's suggested wording, and two doctests that the constructor change had broken now pass. One bug found on the way: `unsupported_conversion` had a match arm returning the string "unreachable" for the PQ target. An edit meant to remove it in the previous round silently did not apply, and I never checked. With the unified type there are two real reasons a pair can be refused, and both now say which. Verified on an Arc A310. All ten supported source, target and format combinations still agree with the CPU model to within one code value, the quantizer example still reports the same four rows, and the refusals now cover both kinds: three SDR-target pairs and two unencodable targets. Co-Authored-By: Claude Opus 5 --- README.md | 79 +++--- examples/convert_color_paths.rs | 93 ++++--- shader/color_convert.comp | 31 ++- shader/color_convert.spv | Bin 25968 -> 25968 bytes src/converter/mod.rs | 412 ++++++++++++++++++++------------ src/lib.rs | 82 ++++--- 6 files changed, 419 insertions(+), 278 deletions(-) diff --git a/README.md b/README.md index 9f28662..a772548 100644 --- a/README.md +++ b/README.md @@ -186,44 +186,45 @@ CPU is the consumer's job; `examples/common` shows one way. ### Color Conversion (RGB → YUV) PixelForge includes a GPU compute shader for converting RGB input to YUV -output. The conversion is two decisions: what the input already is, and -what the encoded stream should be. - -| `SourceColor` | What the input pixels are | -|---------------|---------------------------| -| `Srgb` | BT.709 primaries, sRGB transfer. Ordinary SDR content. | -| `Bt709Linear` | Linear BT.709. This is scRGB, from an `EXTENDED_SRGB_LINEAR_EXT` swapchain. | -| `Bt2020Linear` | Linear BT.2020. | -| `Bt2020Pq` | BT.2020 primaries, already PQ-encoded. | - -| `TargetColor` | What the stream is | -|---------------|--------------------| -| `Bt709` | BT.709 primaries, transfer and matrix. SDR. | -| `Bt2020Pq` | BT.2020 primaries, PQ transfer, BT.2020 NCL matrix. HDR10. | - -The relative sources carry the luminance that a sample value of 1.0 stands -for, since that is a property of the source and not of the target: 203 nits -for `Srgb` per ITU-R BT.2408, and 80 for scRGB per IEC 61966-2-2. It is read -only on the way to `Bt2020Pq`, where the PQ encode needs it to be absolute. - -Every source reaches `Bt2020Pq`. Only `Srgb` reaches `Bt709`, because the -others would need a forward gamma encode or tone mapping; those pairings are -rejected by [`ColorConverter::new`] rather than quietly passed through. +output. The source color describes the input. The target color describes the stream. +Both are a [`ColorSpec`]; which end one describes comes from the slot it is +in, not from the value. + +| `ColorSpec` | Space | Usable as a target | +|-------------|-------|--------------------| +| `Srgb` | BT.709 primaries, sRGB transfer. Ordinary SDR content. | yes, declared as BT.709 | +| `Bt709Linear` | Linear BT.709. This is scRGB, from an `EXTENDED_SRGB_LINEAR_EXT` swapchain. | no | +| `Bt2020Linear` | Linear BT.2020. | no | +| `Bt2020Pq` | BT.2020 primaries, PQ transfer. HDR10. | yes | + +The two linear specs are source-only because H.273 has no code points for +linear light, so a decoder cannot be told a stream is in one. +[`ColorSpec::is_encodable`] is the question, and [`ColorConverter::new`] +refuses the rest. + +The relative specs carry the luminance that a sample value of 1.0 stands +for: 203 nits for `Srgb` per ITU-R BT.2408, and 80 for scRGB per IEC +61966-2-2. It is read only on the way to `Bt2020Pq`, where the PQ encode +needs it to be absolute, and a spec used as a target ignores its own. + +Every source reaches `Bt2020Pq`. Only `Srgb` reaches an SDR target, because +the others would need a forward gamma encode or tone mapping; those pairings +are rejected rather than quietly passed through. Supported input formats: BGRx, RGBx, BGRA, RGBA, ABGR2101010 (10-bit packed), RGBA16F (FP16). Supported output formats: NV12 (8-bit), I420 (8-bit), YUV444 (8-bit), P010 (10-bit), YUV444P10 (10-bit). Because the target and the range fully determine the stream's colour -signalling, [`ColorConverterConfig::color_description`] derives the -encoder's VUI declaration from the conversion itself. Use it rather than -declaring the same thing twice: full-range samples tagged as limited are -expanded a second time on playback, and the API cannot catch that if the two -are set independently. +signalling, [`ColorConverter::color_description`] derives the encoder's VUI +declaration from the conversion itself. Use it rather than declaring the +same thing twice: full-range samples tagged as limited are expanded a second +time on playback, and the API cannot catch that if the two are set +independently. ```rust use pixelforge::{ - Codec, ColorConverter, ColorConverterConfig, ColorRange, EncodeConfig, Encoder, - InputFormat, OutputFormat, SourceColor, TargetColor, VideoContextBuilder, + Codec, ColorConverter, ColorConverterConfig, ColorRange, ColorSpec, EncodeConfig, + Encoder, InputFormat, OutputFormat, VideoContextBuilder, }; let context = VideoContextBuilder::new() @@ -232,16 +233,22 @@ let context = VideoContextBuilder::new() .build()?; // SDR desktop content, encoded as HDR10 for an HDR streaming session. -let config = ColorConverterConfig::new(1920, 1080, InputFormat::BGRx, OutputFormat::P010) - .with_source(SourceColor::srgb()) - .with_target(TargetColor::Bt2020Pq) - .with_range(ColorRange::Full); +let config = ColorConverterConfig::new( + 1920, + 1080, + InputFormat::BGRx, + OutputFormat::P010, + ColorSpec::srgb(), + ColorSpec::Bt2020Pq, + ColorRange::Full, +); + +let mut converter = ColorConverter::new(context.clone(), config)?; // The encoder declares exactly what the shader wrote. let encode_config = EncodeConfig::h265(1920, 1080) - .with_color_description(config.color_description()); + .with_color_description(converter.color_description()); -let mut converter = ColorConverter::new(context.clone(), config)?; let mut encoder = Encoder::new(context, encode_config)?; // converter.convert(input_image, layout, encoder.input_image())?; ``` diff --git a/examples/convert_color_paths.rs b/examples/convert_color_paths.rs index 189f98f..ea66d33 100644 --- a/examples/convert_color_paths.rs +++ b/examples/convert_color_paths.rs @@ -1,7 +1,7 @@ //! Example: Check every conversion path against a model of itself //! -//! [`ColorConverterConfig`] takes two colour decisions: a [`SourceColor`] for -//! what the input pixels already are, and a [`TargetColor`] for what the +//! [`ColorConverterConfig`] takes two colour decisions: a [`ColorSpec`] for +//! what the input pixels already are, and a [`ColorSpec`] for what the //! encoded stream should be. Between them they select which of the shader's //! stages run: an sRGB decode, a BT.709 to BT.2020 gamut hop, a PQ encode, or //! none of them for a passthrough. @@ -13,8 +13,8 @@ //! from the source and target rather than from the shader's branches, so a //! branch taken wrongly shows up as a large disagreement. //! -//! It also asserts the refusals. Only [`SourceColor::Srgb`] can reach -//! [`TargetColor::Bt709`]; a linear or PQ source would need a forward gamma +//! It also asserts the refusals. Only [`ColorSpec::Srgb`] can reach +//! [`ColorSpec::srgb()`]; a linear or PQ source would need a forward gamma //! encode or tone mapping, and the converter rejects those pairs rather than //! passing the samples through and mislabelling them. //! @@ -27,8 +27,8 @@ use ash::vk; use pixelforge::{ - Codec, ColorConverter, ColorConverterConfig, ColorRange, EncodeBitDepth, EncodeConfig, Encoder, - InputFormat, OutputFormat, RateControlMode, SourceColor, TargetColor, VideoContext, + Codec, ColorConverter, ColorConverterConfig, ColorRange, ColorSpec, EncodeBitDepth, + EncodeConfig, Encoder, InputFormat, OutputFormat, RateControlMode, VideoContext, VideoContextBuilder, }; @@ -91,8 +91,8 @@ fn linear_to_pq(l: f32) -> f32 { /// luma, then its code value. Mirrors `read_rgb` and `rgb_to_yuv`. fn expected_code( rgb: [u8; 3], - source: SourceColor, - target: TargetColor, + source: ColorSpec, + target: ColorSpec, ten_bit: bool, full: bool, ) -> f32 { @@ -101,15 +101,15 @@ fn expected_code( rgb[1] as f32 / 255.0, rgb[2] as f32 / 255.0, ]; - if target == TargetColor::Bt2020Pq && source != SourceColor::Bt2020Pq { - if matches!(source, SourceColor::Srgb { .. }) { + if target == ColorSpec::Bt2020Pq && source != ColorSpec::Bt2020Pq { + if matches!(source, ColorSpec::Srgb { .. }) { v = [ srgb_to_linear(v[0]), srgb_to_linear(v[1]), srgb_to_linear(v[2]), ]; } - if !matches!(source, SourceColor::Bt2020Linear { .. }) { + if !matches!(source, ColorSpec::Bt2020Linear { .. }) { v = bt709_to_bt2020(v); } let nits = source.reference_white_nits().unwrap(); @@ -119,7 +119,7 @@ fn expected_code( linear_to_pq(v[2] * (nits / 10000.0)), ]; } - let y = if target == TargetColor::Bt2020Pq { + let y = if target == ColorSpec::Bt2020Pq { 0.2627f32 * v[0] + 0.6780f32 * v[1] + 0.0593f32 * v[2] } else { 0.2126f32 * v[0] + 0.7152f32 * v[1] + 0.0722f32 * v[2] @@ -353,17 +353,22 @@ fn check( context: &VideoContext, src: &SrcImage, encoder: &Encoder, - source: SourceColor, - target: TargetColor, + source: ColorSpec, + target: ColorSpec, output_format: OutputFormat, ) -> Result> { let ten_bit = output_format.bytes_per_sample() == 2; - let config = ColorConverterConfig::new(WIDTH, HEIGHT, InputFormat::BGRA, output_format) - .with_source(source) - .with_target(target) - .with_range(ColorRange::Full); - let description = config.color_description(); + let config = ColorConverterConfig::new( + WIDTH, + HEIGHT, + InputFormat::BGRA, + output_format, + source, + target, + ColorRange::Full, + ); let mut converter = ColorConverter::new(context.clone(), config)?; + let description = converter.color_description(); converter.convert( src.image, vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, @@ -423,11 +428,11 @@ fn main() -> Result<(), Box> { )?; let supported = [ - (SourceColor::srgb(), TargetColor::Bt709), - (SourceColor::srgb(), TargetColor::Bt2020Pq), - (SourceColor::scrgb(), TargetColor::Bt2020Pq), - (SourceColor::bt2020_linear(), TargetColor::Bt2020Pq), - (SourceColor::bt2020_pq(), TargetColor::Bt2020Pq), + (ColorSpec::srgb(), ColorSpec::srgb()), + (ColorSpec::srgb(), ColorSpec::Bt2020Pq), + (ColorSpec::scrgb(), ColorSpec::Bt2020Pq), + (ColorSpec::bt2020_linear(), ColorSpec::Bt2020Pq), + (ColorSpec::bt2020_pq(), ColorSpec::Bt2020Pq), ]; let mut all_ok = true; for (source, target) in supported { @@ -450,21 +455,39 @@ fn main() -> Result<(), Box> { } println!("\n--- these must be refused ---"); - for source in [ - SourceColor::scrgb(), - SourceColor::bt2020_linear(), - SourceColor::bt2020_pq(), - ] { - let config = - ColorConverterConfig::new(WIDTH, HEIGHT, InputFormat::BGRA, OutputFormat::NV12) - .with_source(source) - .with_target(TargetColor::Bt709); + let refused = [ + // An SDR target from something not already SDR-encoded: would need a + // forward gamma encode, or tone mapping from PQ. + (ColorSpec::scrgb(), ColorSpec::srgb()), + (ColorSpec::bt2020_linear(), ColorSpec::srgb()), + (ColorSpec::bt2020_pq(), ColorSpec::srgb()), + // A target no decoder can be told about: linear light has no VUI code + // points, so these specs are source-only. + (ColorSpec::srgb(), ColorSpec::scrgb()), + (ColorSpec::srgb(), ColorSpec::bt2020_linear()), + ]; + for (source, target) in refused { + // The description has to be absent for exactly the unencodable ones. + let config = ColorConverterConfig::new( + WIDTH, + HEIGHT, + InputFormat::BGRA, + OutputFormat::NV12, + source, + target, + ColorRange::Full, + ); + let described = config.color_description().is_some(); + if described != target.is_encodable() { + println!("{target:?}: color_description() and is_encodable() disagree"); + all_ok = false; + } match ColorConverter::new(context.clone(), config) { Ok(_) => { - println!("{source:?} -> Bt709: ACCEPTED, should not be"); + println!("{source:?} -> {target:?}: ACCEPTED, should not be"); all_ok = false; } - Err(e) => println!("{source:?} -> Bt709: refused ({e})"), + Err(e) => println!("{source:?} -> {target:?}: refused ({e})"), } } diff --git a/shader/color_convert.comp b/shader/color_convert.comp index 0c31b9f..c9f2c92 100644 --- a/shader/color_convert.comp +++ b/shader/color_convert.comp @@ -7,21 +7,18 @@ layout(push_constant) uniform PushConstants { uint height; uint input_format; // 0=BGRx, 1=RGBx, 2=BGRA, 3=RGBA, 4=ABGR2101010, 5=RGBA16F uint output_format; // 0=NV12, 1=I420, 2=YUV444, 3=P010, 4=YUV444P10 - uint source; // what the input is: 0=sRGB, 1=BT.709 linear, 2=BT.2020 linear, 3=BT.2020 PQ - uint target; // what to produce: 0=BT.709 SDR, 1=BT.2020 PQ (HDR10) + uint source; // what the input is, as a ColorSpec discriminant + uint target; // what to produce, as a ColorSpec discriminant uint range; // 0=limited/studio range, 1=full range float reference_white_nits; // luminance of source value 1.0; unused when the source is PQ } params; -// `source` values. -#define SOURCE_SRGB 0u -#define SOURCE_BT709_LINEAR 1u -#define SOURCE_BT2020_LINEAR 2u -#define SOURCE_BT2020_PQ 3u - -// `target` values. -#define TARGET_BT709 0u -#define TARGET_BT2020_PQ 1u +// ColorSpec discriminants. One set: `source` and `target` are the same type, +// and which end a value describes comes from which push constant it is in. +#define SPEC_SRGB 0u +#define SPEC_BT709_LINEAR 1u +#define SPEC_BT2020_LINEAR 2u +#define SPEC_BT2020_PQ 3u // Source image sampled directly — eliminates the image-to-buffer copy. layout(binding = 0) uniform sampler2D inputImage; @@ -104,20 +101,20 @@ vec3 bt709_to_bt2020(vec3 rgb709) { vec3 read_rgb(ivec2 coord) { vec4 rgba = texelFetch(inputImage, coord, 0); - if (params.target == TARGET_BT2020_PQ) { + if (params.target == SPEC_BT2020_PQ) { // Already PQ in the right gamut: nothing to do. - if (params.source == SOURCE_BT2020_PQ) return rgba.rgb; + if (params.source == SPEC_BT2020_PQ) return rgba.rgb; // Get to linear light, then into BT.2020, then encode PQ. The // reference white says what luminance source 1.0 stands for, which is // what makes the PQ encode absolute. vec3 linear = rgba.rgb; - if (params.source == SOURCE_SRGB) linear = srgb_to_linear(linear); - if (params.source != SOURCE_BT2020_LINEAR) linear = bt709_to_bt2020(linear); + if (params.source == SPEC_SRGB) linear = srgb_to_linear(linear); + if (params.source != SPEC_BT2020_LINEAR) linear = bt709_to_bt2020(linear); return linear_to_pq(linear * (params.reference_white_nits / 10000.0)); } - // TARGET_BT709: the only source that reaches it is already SDR-encoded in + // An SDR target: the only source that reaches it is already SDR-encoded in // the right gamut, so this is a passthrough. Every other source is // rejected on the CPU side before the shader ever runs. return rgba.rgb; @@ -127,7 +124,7 @@ vec3 read_rgb(ivec2 coord) { // Returns Y in [0, 1], U and V in [0, 1] centered at 0.5. vec3 rgb_to_yuv(vec3 rgb) { float yr, yg, yb, ur, ug, ub, vr, vg, vb; - if (params.target == TARGET_BT2020_PQ) { + if (params.target == SPEC_BT2020_PQ) { // BT.2020 NCL. read_rgb has already put the samples in that gamut. yr = BT2020_Y_R; yg = BT2020_Y_G; yb = BT2020_Y_B; ur = BT2020_U_R; ug = BT2020_U_G; ub = BT2020_U_B; diff --git a/shader/color_convert.spv b/shader/color_convert.spv index a9436d4c9ba3b188c333501203900f56140e8329..6abab7f8d3e3d6f68c7a291ef25c52825d753979 100644 GIT binary patch delta 151 zcmexxit)oK#tm0kB;^D55YviU8GF&pFZ$%@?OjH@R{aw{_~ z-(1VB&IP2FiZ*isDNXr!7NAt8GDvEzayL7WW2p_|#A<^%FO5JPUSo59pp2^%h?D6A Vmig)l;z)XdIZFdUoRfjeg#o8cFDU>3 delta 151 zcmexxit)oK#tm0kB!w9m7*+u>3lM7qNjV_B7KoWQzhyCIW6YYY$ZgKJYH}pEGUM9K zwcP4lKx(OIGbfPJl#gctN_8rOq}D2TvjaJn+8|D>Hkk9$2*lwvHs=S*xH^G2nNDDt Uubv=|q!*a8G!Vo&8Ms^+0GVMgAOHXW diff --git a/src/converter/mod.rs b/src/converter/mod.rs index 1155fea..ed93d84 100644 --- a/src/converter/mod.rs +++ b/src/converter/mod.rs @@ -14,19 +14,28 @@ use crate::vulkan::VideoContext; use ash::vk; use tracing::debug; -/// What the source pixels already are. +/// A colour space: its primaries, its transfer function, and, where that +/// transfer function is relative rather than absolute, what luminance a sample +/// value of 1.0 stands for. /// -/// This describes the *input* to the conversion: its primaries, its transfer -/// function, and, where the transfer function is relative rather than -/// absolute, what luminance a sample value of 1.0 stands for. It says nothing -/// about what the encoded stream will be; that is [`TargetColor`]. +/// One type describes both ends of a conversion. Which end a given value is +/// describing comes from the slot it sits in, +/// [`ColorConverterConfig::source`] or [`ColorConverterConfig::target`], not +/// from the value itself, so nothing here needs renaming if conversion ever +/// runs the other way. +/// +/// Not every space can be a target. An encoded stream has to be something a +/// decoder can be told about, and there are no VUI code points for linear +/// light, so [`ColorSpec::Bt709Linear`] and [`ColorSpec::Bt2020Linear`] are +/// source-only. [`ColorSpec::is_encodable`] is the check, and +/// [`ColorConverter::new`] applies it. /// /// The reference white only matters on the way to an absolute transfer -/// function, so it is carried by the relative sources and read only when the -/// target is [`TargetColor::Bt2020Pq`]. [`SourceColor::Bt2020Pq`] has none: -/// PQ is already absolute. +/// function, so it is read only when the target is [`ColorSpec::Bt2020Pq`], +/// and a spec used as a target ignores its own. [`ColorSpec::Bt2020Pq`] has +/// none at all: PQ is already absolute. #[derive(Debug, Clone, Copy, PartialEq)] -pub enum SourceColor { +pub enum ColorSpec { /// BT.709 primaries, sRGB transfer function. Ordinary SDR desktop content. Srgb { /// Luminance that a sample value of 1.0 represents, in nits. @@ -49,14 +58,7 @@ pub enum SourceColor { Bt2020Pq, } -impl Default for SourceColor { - /// [`SourceColor::srgb`]. - fn default() -> Self { - Self::srgb() - } -} - -impl SourceColor { +impl ColorSpec { /// sRGB content at the 203 nit reference white of ITU-R BT.2408. pub fn srgb() -> Self { Self::Srgb { @@ -89,7 +91,7 @@ impl SourceColor { /// Override the luminance that a sample value of 1.0 represents. /// - /// No effect on [`SourceColor::Bt2020Pq`], which is already absolute. + /// No effect on [`ColorSpec::Bt2020Pq`], which is already absolute. pub fn with_reference_white_nits(self, nits: f32) -> Self { match self { Self::Srgb { .. } => Self::Srgb { @@ -122,6 +124,34 @@ impl SourceColor { } } + /// Whether a stream can be declared to be in this space. + /// + /// H.273 has code points for primaries, transfer characteristics and + /// matrix coefficients; it has none for linear light. The two linear specs + /// are therefore source-only. + pub fn is_encodable(&self) -> bool { + matches!(self, Self::Srgb { .. } | Self::Bt2020Pq) + } + + /// How a decoder should be told about a stream in this space, if it can be + /// told at all. + /// + /// `None` exactly when [`Self::is_encodable`] is false. The range is not + /// part of a colour space here, so this leaves it at the + /// [`ColorDescription`] default; [`ColorConverterConfig::color_description`] + /// is what fills it in. + pub fn color_description(&self) -> Option { + match self { + // The VUI says BT.709 transfer characteristics for SDR. sRGB's + // curve differs from BT.709's only in the toe, and every decoder + // and display treats the pair interchangeably; H.273's separate + // sRGB code point (13) is not what encoders emit here. + Self::Srgb { .. } => Some(ColorDescription::bt709()), + Self::Bt2020Pq => Some(ColorDescription::bt2020_pq()), + Self::Bt709Linear { .. } | Self::Bt2020Linear { .. } => None, + } + } + /// Discriminant handed to the shader. fn shader_id(&self) -> u32 { match self { @@ -133,26 +163,10 @@ impl SourceColor { } } -/// What the encoded stream is. -/// -/// Each variant fixes all three of the primaries, the transfer function and -/// the YUV matrix, which is what lets [`ColorConverterConfig::color_description`] -/// hand the encoder a complete and truthful declaration. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum TargetColor { - /// BT.709 primaries, BT.709 transfer function, BT.709 NCL matrix. SDR. - #[default] - Bt709, - /// BT.2020 primaries, PQ (ST 2084) transfer function, BT.2020 NCL matrix. - /// This is HDR10. - Bt2020Pq, -} - /// Luma and chroma quantization range. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ColorRange { /// Studio range: luma 16..235, chroma 16..240 at 8-bit. - #[default] Limited, /// Full range: 0..255 at 8-bit. Full, @@ -183,14 +197,11 @@ pub enum InputFormat { /// RGBA16F (64-bit, 16-bit float per channel). /// Maps to DRM_FORMAT_ABGR16161616F / VK_FORMAT_R16G16B16A16_SFLOAT. /// - /// The converter treats FP16 data the same as other formats: passthrough - /// for `ColorSpace::Bt709` / `Bt2020`, sRGB→BT.2020+PQ conversion for - /// `ColorSpace::SrgbToBt2020Pq`, and scRGB-linear→BT.2020+PQ for - /// `ColorSpace::Bt709LinearToBt2020Pq` (the natural pairing for FP16 input - /// from an `EXTENDED_SRGB_LINEAR_EXT` swapchain). No linear→PQ transfer - /// function is applied automatically; if the source is PQ-encoded - /// (e.g. via the gamescope WSI layer), use `ColorSpace::Bt2020` for - /// passthrough. + /// The converter treats FP16 data the same as other formats: the + /// [`ColorSpec`] says what the samples mean, not the pixel format. + /// [`ColorSpec::scrgb`] is the natural pairing for FP16 from an + /// `EXTENDED_SRGB_LINEAR_EXT` swapchain; FP16 that is already PQ-encoded + /// (via the gamescope WSI layer, say) is [`ColorSpec::Bt2020Pq`]. RGBA16F, } @@ -282,9 +293,10 @@ impl OutputFormat { /// /// The colour side of this is deliberately two decisions rather than one: /// [`source`](Self::source) says what the input pixels are, and -/// [`target`](Self::target) says what the encoded stream should be. Only the -/// target is a property of the output, which is why -/// [`color_description`](Self::color_description) can derive the encoder's +/// [`target`](Self::target) says what the encoded stream should be. Both are a +/// [`ColorSpec`], since they are the same kind of thing; only the slot says +/// which end is which. Only the target is a property of the output, which is +/// why [`color_description`](Self::color_description) can derive the encoder's /// declaration from it with nothing left to guess. #[derive(Clone, Debug)] #[non_exhaustive] @@ -298,9 +310,12 @@ pub struct ColorConverterConfig { /// Output YUV format. pub output_format: OutputFormat, /// What the input pixels already are. - pub source: SourceColor, + pub source: ColorSpec, /// What the encoded stream should be. Also decides the YUV matrix. - pub target: TargetColor, + /// + /// Must satisfy [`ColorSpec::is_encodable`]; [`ColorConverter::new`] + /// rejects the rest. + pub target: ColorSpec, /// Quantization range the shader writes. /// /// Pass [`color_description`](Self::color_description) to the encoder and @@ -310,32 +325,35 @@ pub struct ColorConverterConfig { } impl ColorConverterConfig { - /// Create a new configuration: sRGB in, SDR BT.709 out, limited range. + /// Create a new configuration. pub fn new( width: u32, height: u32, input_format: InputFormat, output_format: OutputFormat, + source_color: ColorSpec, + target_color: ColorSpec, + color_range: ColorRange, ) -> Self { Self { width, height, input_format, output_format, - source: SourceColor::default(), - target: TargetColor::default(), - range: ColorRange::default(), + source: source_color, + target: target_color, + range: color_range, } } /// Set what the input pixels already are. - pub fn with_source(mut self, source: SourceColor) -> Self { + pub fn with_source(mut self, source: ColorSpec) -> Self { self.source = source; self } /// Set what the encoded stream should be. - pub fn with_target(mut self, target: TargetColor) -> Self { + pub fn with_target(mut self, target: ColorSpec) -> Self { self.target = target; self } @@ -351,28 +369,40 @@ impl ColorConverterConfig { /// /// Derived from [`target`](Self::target) and [`range`](Self::range), so the /// encoder's VUI cannot end up describing a different signal from the one - /// the shader wrote: + /// the shader wrote. + /// + /// `None` when the target is not a space a stream can be declared to be in, + /// which [`ColorConverter::new`] refuses to build anyway. Given a converter + /// that exists, [`ColorConverter::color_description`] answers the same + /// question without the `Option`. /// /// ```no_run /// use pixelforge::{ - /// ColorConverterConfig, ColorRange, EncodeConfig, InputFormat, OutputFormat, - /// SourceColor, TargetColor, + /// ColorConverterConfig, ColorRange, ColorSpec, EncodeConfig, InputFormat, OutputFormat, /// }; /// - /// let conv = ColorConverterConfig::new(1920, 1080, InputFormat::BGRA, OutputFormat::P010) - /// .with_source(SourceColor::scrgb()) - /// .with_target(TargetColor::Bt2020Pq) - /// .with_range(ColorRange::Full); + /// # fn main() -> Result<(), Box> { + /// let conv = ColorConverterConfig::new( + /// 1920, + /// 1080, + /// InputFormat::BGRA, + /// OutputFormat::P010, + /// ColorSpec::scrgb(), + /// ColorSpec::Bt2020Pq, + /// ColorRange::Full, + /// ); /// /// let enc = EncodeConfig::h265(1920, 1080) - /// .with_color_description(conv.color_description()); + /// .with_color_description(conv.color_description().expect("PQ is encodable")); + /// # Ok(()) + /// # } /// ``` - pub fn color_description(&self) -> ColorDescription { - let description = match self.target { - TargetColor::Bt709 => ColorDescription::bt709(), - TargetColor::Bt2020Pq => ColorDescription::bt2020_pq(), - }; - description.with_full_range(self.range.is_full()) + pub fn color_description(&self) -> Option { + Some( + self.target + .color_description()? + .with_full_range(self.range.is_full()), + ) } /// Whether the shader can get from this source to this target. @@ -380,34 +410,29 @@ impl ColorConverterConfig { /// Anything that would need tone mapping or a forward gamma encode is not /// implemented, and is rejected rather than silently passed through. fn conversion_supported(&self) -> bool { - match (self.source, self.target) { - // Already encoded for the target: write it through. - (SourceColor::Srgb { .. }, TargetColor::Bt709) => true, - (SourceColor::Bt2020Pq, TargetColor::Bt2020Pq) => true, - // Encode to PQ, with a gamut hop for the BT.709 sources. - (SourceColor::Srgb { .. }, TargetColor::Bt2020Pq) => true, - (SourceColor::Bt709Linear { .. }, TargetColor::Bt2020Pq) => true, - (SourceColor::Bt2020Linear { .. }, TargetColor::Bt2020Pq) => true, - // Would need an SDR gamma encode. - (SourceColor::Bt709Linear { .. }, TargetColor::Bt709) => false, - (SourceColor::Bt2020Linear { .. }, TargetColor::Bt709) => false, - // Would need tone mapping. - (SourceColor::Bt2020Pq, TargetColor::Bt709) => false, + match self.target { + // Every source reaches PQ: decode what needs decoding, hop the + // gamut if it is not already BT.2020, encode PQ. + ColorSpec::Bt2020Pq => true, + // Only something already SDR-encoded reaches an SDR target. + // Anything else would need a forward gamma encode or tone mapping. + ColorSpec::Srgb { .. } => matches!(self.source, ColorSpec::Srgb { .. }), + // Not encodable at all; see `ColorSpec::is_encodable`. + ColorSpec::Bt709Linear { .. } | ColorSpec::Bt2020Linear { .. } => false, } } /// Error explaining why [`Self::conversion_supported`] said no. fn unsupported_conversion(&self) -> PixelForgeError { + let reason = if !self.target.is_encodable() { + "no decoder can be told a stream is in linear light, so that space is source-only" + } else { + "reaching an SDR target from a linear or PQ source would need a forward gamma \ + encode or tone mapping, and the conversion shader does neither" + }; PixelForgeError::InvalidInput(format!( - "no conversion from {:?} to {:?}: {}", - self.source, - self.target, - match self.target { - TargetColor::Bt709 => - "encoding SDR from a linear or PQ source would need \ - a gamma encode or tone mapping, neither of which the shader does", - TargetColor::Bt2020Pq => "unreachable", - } + "no conversion from {:?} to {:?}: {reason}", + self.source, self.target )) } } @@ -481,6 +506,18 @@ impl ColorConverter { &self.context } + /// What this converter's output should be declared as, for + /// [`EncodeConfig::with_color_description`](crate::EncodeConfig::with_color_description). + /// + /// The same as [`ColorConverterConfig::color_description`], minus the + /// `Option`: a converter only exists if its target is encodable, so the + /// answer is always there. + pub fn color_description(&self) -> ColorDescription { + self.config + .color_description() + .expect("a converter cannot be built with a target that has no description") + } + /// Get the converter configuration. pub fn config(&self) -> &ColorConverterConfig { &self.config @@ -501,7 +538,7 @@ impl ColorConverter { /// /// Returns an error, and leaves the converter untouched, if the new source /// cannot reach the configured target. - pub fn set_source(&mut self, source: SourceColor) -> Result<()> { + pub fn set_source(&mut self, source: ColorSpec) -> Result<()> { let candidate = ColorConverterConfig { source, ..self.config.clone() @@ -519,11 +556,15 @@ impl ColorConverter { /// since the target is passed via push constants. /// /// Returns an error, and leaves the converter untouched, if the configured - /// source cannot reach the new target. Callers that pass - /// [`ColorConverterConfig::color_description`] to the encoder need to - /// re-declare it after this: the target is half of what that derives from, - /// and an encoder's session parameters were fixed when it was built. - pub fn set_target(&mut self, target: TargetColor) -> Result<()> { + /// source cannot reach the new target. + /// + /// The target is half of what [`ColorConverterConfig::color_description`] + /// derives from, so an encoder told about the old one is now describing the + /// wrong signal. Pass the new description to + /// [`Encoder::set_color_description`](crate::Encoder::set_color_description), + /// which rebuilds the session parameters and makes the next frame an IDR + /// carrying the updated header. + pub fn set_target(&mut self, target: ColorSpec) -> Result<()> { let candidate = ColorConverterConfig { target, ..self.config.clone() @@ -538,8 +579,9 @@ impl ColorConverter { /// Change the quantization range for subsequent conversions. /// /// Takes effect on the next `convert()` without recreating the pipeline, - /// since the range is passed via push constants. The same re-declaration - /// caveat as [`Self::set_target`] applies. + /// since the range is passed via push constants. The range is the other + /// half of [`ColorConverterConfig::color_description`], so the same + /// re-declaration as [`Self::set_target`] applies. pub fn set_range(&mut self, range: ColorRange) { self.config.range = range; } @@ -1002,7 +1044,7 @@ impl ColorConverter { self.config.input_format as u32, self.config.output_format as u32, self.config.source.shader_id(), - self.config.target as u32, + self.config.target.shader_id(), self.config.range.is_full() as u32, self.config .source @@ -1270,35 +1312,55 @@ mod tests { } // ======================== - // SourceColor / TargetColor / ColorRange tests. + // ColorSpec / ColorSpec / ColorRange tests. // ======================== #[test] - fn source_discriminants_match_the_shader() { - // The shader's SOURCE_* defines. - assert_eq!(SourceColor::srgb().shader_id(), 0); - assert_eq!(SourceColor::scrgb().shader_id(), 1); - assert_eq!(SourceColor::bt2020_linear().shader_id(), 2); - assert_eq!(SourceColor::bt2020_pq().shader_id(), 3); + fn spec_discriminants_match_the_shader() { + // The shader's SPEC_* defines. One set, used for both push constants. + assert_eq!(ColorSpec::srgb().shader_id(), 0); + assert_eq!(ColorSpec::scrgb().shader_id(), 1); + assert_eq!(ColorSpec::bt2020_linear().shader_id(), 2); + assert_eq!(ColorSpec::bt2020_pq().shader_id(), 3); } #[test] - fn target_and_range_discriminants_match_the_shader() { - // The shader's TARGET_* defines, and its `range == 0u` test. - assert_eq!(TargetColor::Bt709 as u32, 0); - assert_eq!(TargetColor::Bt2020Pq as u32, 1); + fn range_matches_the_shader() { + // The shader's `range == 0u` test. assert!(!ColorRange::Limited.is_full()); assert!(ColorRange::Full.is_full()); } + #[test] + fn only_encodable_specs_describe_a_stream() { + // A decoder can be told about these two. + assert_eq!( + ColorSpec::srgb().color_description(), + Some(ColorDescription::bt709()) + ); + assert_eq!( + ColorSpec::bt2020_pq().color_description(), + Some(ColorDescription::bt2020_pq()) + ); + assert!(ColorSpec::srgb().is_encodable()); + assert!(ColorSpec::bt2020_pq().is_encodable()); + + // There are no VUI code points for linear light, so these are + // source-only and say so. + for spec in [ColorSpec::scrgb(), ColorSpec::bt2020_linear()] { + assert!(!spec.is_encodable(), "{spec:?}"); + assert_eq!(spec.color_description(), None, "{spec:?}"); + } + } + #[test] fn scrgb_white_is_not_the_srgb_reference() { // IEC 61966-2-2 puts scRGB 1.0 at 80 cd/m²; BT.2408 puts SDR white at // 203. Taking the 203 for scRGB is the mistake this pairing prevents. - assert_eq!(SourceColor::scrgb().reference_white_nits(), Some(80.0)); - assert_eq!(SourceColor::srgb().reference_white_nits(), Some(203.0)); + assert_eq!(ColorSpec::scrgb().reference_white_nits(), Some(80.0)); + assert_eq!(ColorSpec::srgb().reference_white_nits(), Some(203.0)); assert_eq!( - SourceColor::bt2020_linear().reference_white_nits(), + ColorSpec::bt2020_linear().reference_white_nits(), Some(203.0) ); } @@ -1307,16 +1369,16 @@ mod tests { fn pq_source_has_no_reference_white_to_set() { // PQ is absolute, so there is no such quantity, and overriding it is // a no-op rather than a silently stored value. - assert_eq!(SourceColor::bt2020_pq().reference_white_nits(), None); - let overridden = SourceColor::bt2020_pq().with_reference_white_nits(500.0); - assert_eq!(overridden, SourceColor::Bt2020Pq); + assert_eq!(ColorSpec::bt2020_pq().reference_white_nits(), None); + let overridden = ColorSpec::bt2020_pq().with_reference_white_nits(500.0); + assert_eq!(overridden, ColorSpec::Bt2020Pq); assert_eq!(overridden.reference_white_nits(), None); } #[test] fn reference_white_override_keeps_the_source_kind() { - let source = SourceColor::scrgb().with_reference_white_nits(203.0); - assert_eq!(source.shader_id(), SourceColor::scrgb().shader_id()); + let source = ColorSpec::scrgb().with_reference_white_nits(203.0); + assert_eq!(source.shader_id(), ColorSpec::scrgb().shader_id()); assert_eq!(source.reference_white_nits(), Some(203.0)); } @@ -1324,19 +1386,29 @@ mod tests { // color_description() tests. // ======================== - fn config_for(target: TargetColor, range: ColorRange) -> ColorConverterConfig { - ColorConverterConfig::new(64, 64, InputFormat::BGRA, OutputFormat::NV12) - .with_target(target) - .with_range(range) + fn config_for(target: ColorSpec, range: ColorRange) -> ColorConverterConfig { + ColorConverterConfig::new( + 64, + 64, + InputFormat::BGRA, + OutputFormat::NV12, + ColorSpec::srgb(), + target, + range, + ) } #[test] fn description_follows_the_target() { - let sdr = config_for(TargetColor::Bt709, ColorRange::Limited).color_description(); + let sdr = config_for(ColorSpec::srgb(), ColorRange::Limited) + .color_description() + .expect("sRGB is encodable"); assert_eq!(sdr, ColorDescription::bt709()); assert!(!sdr.is_hdr()); - let hdr = config_for(TargetColor::Bt2020Pq, ColorRange::Limited).color_description(); + let hdr = config_for(ColorSpec::Bt2020Pq, ColorRange::Limited) + .color_description() + .expect("PQ is encodable"); assert_eq!(hdr, ColorDescription::bt2020_pq()); assert!(hdr.is_hdr()); } @@ -1345,11 +1417,14 @@ mod tests { fn description_follows_the_range() { // The drift this whole split exists to prevent: what the shader // quantizes to and what the VUI claims are now one decision. - for target in [TargetColor::Bt709, TargetColor::Bt2020Pq] { + for target in [ColorSpec::srgb(), ColorSpec::Bt2020Pq] { for range in [ColorRange::Limited, ColorRange::Full] { let config = config_for(target, range); assert_eq!( - config.color_description().full_range, + config + .color_description() + .expect("both targets are encodable") + .full_range, config.range.is_full(), "{target:?} / {range:?}" ); @@ -1361,12 +1436,18 @@ mod tests { fn description_ignores_the_source() { // The source says nothing about the encoded stream, so it must not // reach the declaration. - let base = ColorConverterConfig::new(64, 64, InputFormat::BGRA, OutputFormat::P010) - .with_target(TargetColor::Bt2020Pq) - .with_range(ColorRange::Full); - let from_srgb = base.clone().with_source(SourceColor::srgb()); - let from_scrgb = base.clone().with_source(SourceColor::scrgb()); - let from_pq = base.with_source(SourceColor::bt2020_pq()); + let base = ColorConverterConfig::new( + 64, + 64, + InputFormat::BGRA, + OutputFormat::P010, + ColorSpec::srgb(), + ColorSpec::Bt2020Pq, + ColorRange::Full, + ); + let from_srgb = base.clone().with_source(ColorSpec::srgb()); + let from_scrgb = base.clone().with_source(ColorSpec::scrgb()); + let from_pq = base.with_source(ColorSpec::bt2020_pq()); assert_eq!( from_srgb.color_description(), from_scrgb.color_description() @@ -1381,12 +1462,12 @@ mod tests { #[test] fn every_source_can_reach_pq() { for source in [ - SourceColor::srgb(), - SourceColor::scrgb(), - SourceColor::bt2020_linear(), - SourceColor::bt2020_pq(), + ColorSpec::srgb(), + ColorSpec::scrgb(), + ColorSpec::bt2020_linear(), + ColorSpec::bt2020_pq(), ] { - let config = config_for(TargetColor::Bt2020Pq, ColorRange::Full).with_source(source); + let config = config_for(ColorSpec::Bt2020Pq, ColorRange::Full).with_source(source); assert!(config.conversion_supported(), "{source:?}"); } } @@ -1395,16 +1476,15 @@ mod tests { fn only_srgb_can_reach_sdr() { // Everything else would need a forward gamma encode or tone mapping, // and the shader does neither. Rejected beats silently passed through. - let ok = - config_for(TargetColor::Bt709, ColorRange::Limited).with_source(SourceColor::srgb()); + let ok = config_for(ColorSpec::srgb(), ColorRange::Limited).with_source(ColorSpec::srgb()); assert!(ok.conversion_supported()); for source in [ - SourceColor::scrgb(), - SourceColor::bt2020_linear(), - SourceColor::bt2020_pq(), + ColorSpec::scrgb(), + ColorSpec::bt2020_linear(), + ColorSpec::bt2020_pq(), ] { - let config = config_for(TargetColor::Bt709, ColorRange::Limited).with_source(source); + let config = config_for(ColorSpec::srgb(), ColorRange::Limited).with_source(source); assert!(!config.conversion_supported(), "{source:?}"); } } @@ -1489,22 +1569,36 @@ mod tests { #[test] fn test_config_clone() { - let config = ColorConverterConfig::new(1920, 1080, InputFormat::BGRx, OutputFormat::NV12) - .with_range(ColorRange::Full); + let config = ColorConverterConfig::new( + 1920, + 1080, + InputFormat::BGRx, + OutputFormat::NV12, + ColorSpec::srgb(), + ColorSpec::srgb(), + ColorRange::Full, + ); let cloned = config.clone(); assert_eq!(cloned.width, 1920); assert_eq!(cloned.height, 1080); assert_eq!(cloned.input_format, InputFormat::BGRx); assert_eq!(cloned.output_format, OutputFormat::NV12); - assert_eq!(cloned.target, TargetColor::Bt709); + assert_eq!(cloned.target, ColorSpec::srgb()); assert!(cloned.range.is_full()); } #[test] fn test_config_debug() { - let config = ColorConverterConfig::new(640, 480, InputFormat::RGBA, OutputFormat::I420) - .with_range(ColorRange::Full); + let config = ColorConverterConfig::new( + 640, + 480, + InputFormat::RGBA, + OutputFormat::I420, + ColorSpec::srgb(), + ColorSpec::srgb(), + ColorRange::Full, + ); let debug_str = format!("{:?}", config); assert!(debug_str.contains("640")); @@ -1538,8 +1632,15 @@ mod tests { return; }; - let config = ColorConverterConfig::new(64, 64, InputFormat::BGRx, OutputFormat::NV12) - .with_range(ColorRange::Full); + let config = ColorConverterConfig::new( + 64, + 64, + InputFormat::BGRx, + OutputFormat::NV12, + ColorSpec::srgb(), + ColorSpec::srgb(), + ColorRange::Full, + ); let result = ColorConverter::new(context, config); assert!( @@ -1566,8 +1667,15 @@ mod tests { for input_format in &input_formats { for output_format in &output_formats { - let config = ColorConverterConfig::new(32, 32, *input_format, *output_format) - .with_range(ColorRange::Full); + let config = ColorConverterConfig::new( + 32, + 32, + *input_format, + *output_format, + ColorSpec::srgb(), + ColorSpec::srgb(), + ColorRange::Full, + ); let result = ColorConverter::new(context.clone(), config); assert!( diff --git a/src/lib.rs b/src/lib.rs index 2e1778f..e958177 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -180,44 +180,45 @@ //! ## Color Conversion (RGB → YUV) //! //! PixelForge includes a GPU compute shader for converting RGB input to YUV -//! output. The conversion is two decisions: what the input already is, and -//! what the encoded stream should be. -//! -//! | `SourceColor` | What the input pixels are | -//! |---------------|---------------------------| -//! | `Srgb` | BT.709 primaries, sRGB transfer. Ordinary SDR content. | -//! | `Bt709Linear` | Linear BT.709. This is scRGB, from an `EXTENDED_SRGB_LINEAR_EXT` swapchain. | -//! | `Bt2020Linear` | Linear BT.2020. | -//! | `Bt2020Pq` | BT.2020 primaries, already PQ-encoded. | -//! -//! | `TargetColor` | What the stream is | -//! |---------------|--------------------| -//! | `Bt709` | BT.709 primaries, transfer and matrix. SDR. | -//! | `Bt2020Pq` | BT.2020 primaries, PQ transfer, BT.2020 NCL matrix. HDR10. | -//! -//! The relative sources carry the luminance that a sample value of 1.0 stands -//! for, since that is a property of the source and not of the target: 203 nits -//! for `Srgb` per ITU-R BT.2408, and 80 for scRGB per IEC 61966-2-2. It is read -//! only on the way to `Bt2020Pq`, where the PQ encode needs it to be absolute. -//! -//! Every source reaches `Bt2020Pq`. Only `Srgb` reaches `Bt709`, because the -//! others would need a forward gamma encode or tone mapping; those pairings are -//! rejected by [`ColorConverter::new`] rather than quietly passed through. +//! output. The source color describes the input. The target color describes the stream. +//! Both are a [`ColorSpec`]; which end one describes comes from the slot it is +//! in, not from the value. +//! +//! | `ColorSpec` | Space | Usable as a target | +//! |-------------|-------|--------------------| +//! | `Srgb` | BT.709 primaries, sRGB transfer. Ordinary SDR content. | yes, declared as BT.709 | +//! | `Bt709Linear` | Linear BT.709. This is scRGB, from an `EXTENDED_SRGB_LINEAR_EXT` swapchain. | no | +//! | `Bt2020Linear` | Linear BT.2020. | no | +//! | `Bt2020Pq` | BT.2020 primaries, PQ transfer. HDR10. | yes | +//! +//! The two linear specs are source-only because H.273 has no code points for +//! linear light, so a decoder cannot be told a stream is in one. +//! [`ColorSpec::is_encodable`] is the question, and [`ColorConverter::new`] +//! refuses the rest. +//! +//! The relative specs carry the luminance that a sample value of 1.0 stands +//! for: 203 nits for `Srgb` per ITU-R BT.2408, and 80 for scRGB per IEC +//! 61966-2-2. It is read only on the way to `Bt2020Pq`, where the PQ encode +//! needs it to be absolute, and a spec used as a target ignores its own. +//! +//! Every source reaches `Bt2020Pq`. Only `Srgb` reaches an SDR target, because +//! the others would need a forward gamma encode or tone mapping; those pairings +//! are rejected rather than quietly passed through. //! //! Supported input formats: BGRx, RGBx, BGRA, RGBA, ABGR2101010 (10-bit packed), RGBA16F (FP16). //! Supported output formats: NV12 (8-bit), I420 (8-bit), YUV444 (8-bit), P010 (10-bit), YUV444P10 (10-bit). //! //! Because the target and the range fully determine the stream's colour -//! signalling, [`ColorConverterConfig::color_description`] derives the -//! encoder's VUI declaration from the conversion itself. Use it rather than -//! declaring the same thing twice: full-range samples tagged as limited are -//! expanded a second time on playback, and the API cannot catch that if the two -//! are set independently. +//! signalling, [`ColorConverter::color_description`] derives the encoder's VUI +//! declaration from the conversion itself. Use it rather than declaring the +//! same thing twice: full-range samples tagged as limited are expanded a second +//! time on playback, and the API cannot catch that if the two are set +//! independently. //! //! ```rust,no_run //! use pixelforge::{ -//! Codec, ColorConverter, ColorConverterConfig, ColorRange, EncodeConfig, Encoder, -//! InputFormat, OutputFormat, SourceColor, TargetColor, VideoContextBuilder, +//! Codec, ColorConverter, ColorConverterConfig, ColorRange, ColorSpec, EncodeConfig, +//! Encoder, InputFormat, OutputFormat, VideoContextBuilder, //! }; //! //! # fn main() -> Result<(), Box> { @@ -227,16 +228,22 @@ //! .build()?; //! //! // SDR desktop content, encoded as HDR10 for an HDR streaming session. -//! let config = ColorConverterConfig::new(1920, 1080, InputFormat::BGRx, OutputFormat::P010) -//! .with_source(SourceColor::srgb()) -//! .with_target(TargetColor::Bt2020Pq) -//! .with_range(ColorRange::Full); +//! let config = ColorConverterConfig::new( +//! 1920, +//! 1080, +//! InputFormat::BGRx, +//! OutputFormat::P010, +//! ColorSpec::srgb(), +//! ColorSpec::Bt2020Pq, +//! ColorRange::Full, +//! ); +//! +//! let mut converter = ColorConverter::new(context.clone(), config)?; //! //! // The encoder declares exactly what the shader wrote. //! let encode_config = EncodeConfig::h265(1920, 1080) -//! .with_color_description(config.color_description()); +//! .with_color_description(converter.color_description()); //! -//! let mut converter = ColorConverter::new(context.clone(), config)?; //! let mut encoder = Encoder::new(context, encode_config)?; //! // converter.convert(input_image, layout, encoder.input_image())?; //! # Ok(()) @@ -318,8 +325,7 @@ pub(crate) const fn align4(size: usize) -> usize { } pub use converter::{ - ColorConverter, ColorConverterConfig, ColorRange, InputFormat, OutputFormat, SourceColor, - TargetColor, + ColorConverter, ColorConverterConfig, ColorRange, ColorSpec, InputFormat, OutputFormat, }; pub use decoder::{ DecodeConfig, DecodeSink, DecodeSource, DecodeStatus, DecodedFrame, Decoder, FramePoll, Framing, From 373607a3fecb86f2976e0b058c0fe01ac91e0d7b Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Fri, 18 Sep 2026 18:36:05 +0300 Subject: [PATCH 4/8] refactor(converter)!: take the reference white out of ColorSpec Carrying nits in the variants forced a constructor per space, and the call sites ended up mixing two spellings of the same thing: ColorSpec::scrgb(), // a method ColorSpec::Bt2020Pq, // a variant Keeping the data and dropping the constructors is not an option on stable: default values on enum fields are still unstable (rust-lang#132162), so every call site would have to spell out `ColorSpec::Bt709Linear { reference_white_nits: 80.0 }`, which is worse than the constructors were. So the data comes out and `ColorSpec` is field-less. Every space is now named one way: ColorSpec::Bt709Linear, ColorSpec::Bt2020Pq, The reference white becomes something a space knows rather than carries. `ColorSpec::reference_white_nits()` returns the standard figure, 203 for the SDR-referred spaces per BT.2408 and 80 for scRGB per IEC 61966-2-2, so the right value still follows from the source without anyone choosing it. `ColorConverterConfig::with_reference_white_nits` overrides it for the rare conversion that needs a different one, which puts the override on the conversion where it belongs rather than on the colour space, where it never made sense. One property is lost and worth naming: `scrgb()` made it impossible to build scRGB with the wrong white, and now an override can set it to 203. That is a deliberate call rather than a default, which is the trade for uniform call sites. Field-less also means `Eq` and `Hash` derive, and the discriminant is the shader's, so `as u32` replaces the private `shader_id()` helper. Verified on an Arc A310: all ten supported combinations give the same numbers as before, in particular `Bt709Linear -> Bt2020Pq` still at 80 nits, so the standard figure reaches the shader. Both refusal kinds still fire, and the quantizer example is unchanged. Co-Authored-By: Claude Opus 5 --- README.md | 12 +- examples/convert_color_paths.rs | 26 +-- src/converter/mod.rs | 285 +++++++++++++++----------------- src/lib.rs | 12 +- 4 files changed, 161 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index a772548..6914d94 100644 --- a/README.md +++ b/README.md @@ -202,10 +202,12 @@ linear light, so a decoder cannot be told a stream is in one. [`ColorSpec::is_encodable`] is the question, and [`ColorConverter::new`] refuses the rest. -The relative specs carry the luminance that a sample value of 1.0 stands -for: 203 nits for `Srgb` per ITU-R BT.2408, and 80 for scRGB per IEC -61966-2-2. It is read only on the way to `Bt2020Pq`, where the PQ encode -needs it to be absolute, and a spec used as a target ignores its own. +Each space knows the luminance a sample value of 1.0 represents, see +[`ColorSpec::reference_white_nits`]: 203 nits for the SDR-referred spaces +per ITU-R BT.2408, and 80 for scRGB per IEC 61966-2-2. It is read from the +source only, and only on the way to `Bt2020Pq`, where the PQ encode needs an +absolute reference. A conversion that needs a different figure overrides it +with [`ColorConverterConfig::with_reference_white_nits`]. Every source reaches `Bt2020Pq`. Only `Srgb` reaches an SDR target, because the others would need a forward gamma encode or tone mapping; those pairings @@ -238,7 +240,7 @@ let config = ColorConverterConfig::new( 1080, InputFormat::BGRx, OutputFormat::P010, - ColorSpec::srgb(), + ColorSpec::Srgb, ColorSpec::Bt2020Pq, ColorRange::Full, ); diff --git a/examples/convert_color_paths.rs b/examples/convert_color_paths.rs index ea66d33..9fca6b4 100644 --- a/examples/convert_color_paths.rs +++ b/examples/convert_color_paths.rs @@ -14,7 +14,7 @@ //! branch taken wrongly shows up as a large disagreement. //! //! It also asserts the refusals. Only [`ColorSpec::Srgb`] can reach -//! [`ColorSpec::srgb()`]; a linear or PQ source would need a forward gamma +//! [`ColorSpec::Srgb`]; a linear or PQ source would need a forward gamma //! encode or tone mapping, and the converter rejects those pairs rather than //! passing the samples through and mislabelling them. //! @@ -102,14 +102,14 @@ fn expected_code( rgb[2] as f32 / 255.0, ]; if target == ColorSpec::Bt2020Pq && source != ColorSpec::Bt2020Pq { - if matches!(source, ColorSpec::Srgb { .. }) { + if matches!(source, ColorSpec::Srgb) { v = [ srgb_to_linear(v[0]), srgb_to_linear(v[1]), srgb_to_linear(v[2]), ]; } - if !matches!(source, ColorSpec::Bt2020Linear { .. }) { + if !matches!(source, ColorSpec::Bt2020Linear) { v = bt709_to_bt2020(v); } let nits = source.reference_white_nits().unwrap(); @@ -428,11 +428,11 @@ fn main() -> Result<(), Box> { )?; let supported = [ - (ColorSpec::srgb(), ColorSpec::srgb()), - (ColorSpec::srgb(), ColorSpec::Bt2020Pq), - (ColorSpec::scrgb(), ColorSpec::Bt2020Pq), - (ColorSpec::bt2020_linear(), ColorSpec::Bt2020Pq), - (ColorSpec::bt2020_pq(), ColorSpec::Bt2020Pq), + (ColorSpec::Srgb, ColorSpec::Srgb), + (ColorSpec::Srgb, ColorSpec::Bt2020Pq), + (ColorSpec::Bt709Linear, ColorSpec::Bt2020Pq), + (ColorSpec::Bt2020Linear, ColorSpec::Bt2020Pq), + (ColorSpec::Bt2020Pq, ColorSpec::Bt2020Pq), ]; let mut all_ok = true; for (source, target) in supported { @@ -458,13 +458,13 @@ fn main() -> Result<(), Box> { let refused = [ // An SDR target from something not already SDR-encoded: would need a // forward gamma encode, or tone mapping from PQ. - (ColorSpec::scrgb(), ColorSpec::srgb()), - (ColorSpec::bt2020_linear(), ColorSpec::srgb()), - (ColorSpec::bt2020_pq(), ColorSpec::srgb()), + (ColorSpec::Bt709Linear, ColorSpec::Srgb), + (ColorSpec::Bt2020Linear, ColorSpec::Srgb), + (ColorSpec::Bt2020Pq, ColorSpec::Srgb), // A target no decoder can be told about: linear light has no VUI code // points, so these specs are source-only. - (ColorSpec::srgb(), ColorSpec::scrgb()), - (ColorSpec::srgb(), ColorSpec::bt2020_linear()), + (ColorSpec::Srgb, ColorSpec::Bt709Linear), + (ColorSpec::Srgb, ColorSpec::Bt2020Linear), ]; for (source, target) in refused { // The description has to be absent for exactly the unencodable ones. diff --git a/src/converter/mod.rs b/src/converter/mod.rs index ed93d84..7deeb0f 100644 --- a/src/converter/mod.rs +++ b/src/converter/mod.rs @@ -30,96 +30,43 @@ use tracing::debug; /// source-only. [`ColorSpec::is_encodable`] is the check, and /// [`ColorConverter::new`] applies it. /// -/// The reference white only matters on the way to an absolute transfer -/// function, so it is read only when the target is [`ColorSpec::Bt2020Pq`], -/// and a spec used as a target ignores its own. [`ColorSpec::Bt2020Pq`] has -/// none at all: PQ is already absolute. -#[derive(Debug, Clone, Copy, PartialEq)] +/// Each space knows the luminance its sample value of 1.0 represents, see +/// [`ColorSpec::reference_white_nits`]. That only matters on the way to an +/// absolute transfer function, so it is read only when the target is +/// [`ColorSpec::Bt2020Pq`], and only from the source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u32)] pub enum ColorSpec { /// BT.709 primaries, sRGB transfer function. Ordinary SDR desktop content. - Srgb { - /// Luminance that a sample value of 1.0 represents, in nits. - reference_white_nits: f32, - }, + Srgb = 0, /// BT.709 primaries, linear light. This is scRGB, what a /// `VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT` swapchain produces. - Bt709Linear { - /// Luminance that a sample value of 1.0 represents, in nits. - reference_white_nits: f32, - }, + Bt709Linear = 1, /// BT.2020 primaries, linear light. - Bt2020Linear { - /// Luminance that a sample value of 1.0 represents, in nits. - reference_white_nits: f32, - }, + Bt2020Linear = 2, /// BT.2020 primaries, PQ (ST 2084) transfer function. Already HDR-encoded, /// as a `VK_COLOR_SPACE_HDR10_ST2084_EXT` surface or a gamescope PQ /// surface is. - Bt2020Pq, + Bt2020Pq = 3, } impl ColorSpec { - /// sRGB content at the 203 nit reference white of ITU-R BT.2408. - pub fn srgb() -> Self { - Self::Srgb { - reference_white_nits: 203.0, - } - } - - /// scRGB: linear BT.709 where 1.0 is 80 cd/m², per IEC 61966-2-2. + /// The luminance a sample value of 1.0 represents in this space, in nits, + /// where that is a meaningful question to ask. /// - /// This is the pairing for an `EXTENDED_SRGB_LINEAR_EXT` swapchain, and - /// the 80 is why: taking the 203 that SDR content wants would map scRGB - /// white about two and a half times too bright. - pub fn scrgb() -> Self { - Self::Bt709Linear { - reference_white_nits: 80.0, - } - } - - /// Linear BT.2020 at the 203 nit reference white of ITU-R BT.2408. - pub fn bt2020_linear() -> Self { - Self::Bt2020Linear { - reference_white_nits: 203.0, - } - } - - /// Already PQ-encoded BT.2020. - pub fn bt2020_pq() -> Self { - Self::Bt2020Pq - } - - /// Override the luminance that a sample value of 1.0 represents. + /// This is the standard figure for the space, not a setting: 203 nits for + /// the SDR-referred spaces per ITU-R BT.2408, and 80 for scRGB per IEC + /// 61966-2-2. The 80 is why `Bt709Linear` is worth having as its own + /// variant: reading an `EXTENDED_SRGB_LINEAR_EXT` swapchain at the 203 that + /// SDR content wants maps its white about two and a half times too bright. /// - /// No effect on [`ColorSpec::Bt2020Pq`], which is already absolute. - pub fn with_reference_white_nits(self, nits: f32) -> Self { - match self { - Self::Srgb { .. } => Self::Srgb { - reference_white_nits: nits, - }, - Self::Bt709Linear { .. } => Self::Bt709Linear { - reference_white_nits: nits, - }, - Self::Bt2020Linear { .. } => Self::Bt2020Linear { - reference_white_nits: nits, - }, - Self::Bt2020Pq => Self::Bt2020Pq, - } - } - - /// The luminance a sample value of 1.0 represents, where that is a - /// meaningful question to ask. + /// `None` for [`ColorSpec::Bt2020Pq`], PQ being absolute already. + /// [`ColorConverterConfig::with_reference_white_nits`] overrides it for a + /// conversion that needs a different figure. pub fn reference_white_nits(&self) -> Option { - match *self { - Self::Srgb { - reference_white_nits, - } - | Self::Bt709Linear { - reference_white_nits, - } - | Self::Bt2020Linear { - reference_white_nits, - } => Some(reference_white_nits), + match self { + Self::Srgb | Self::Bt2020Linear => Some(203.0), + Self::Bt709Linear => Some(80.0), Self::Bt2020Pq => None, } } @@ -130,7 +77,7 @@ impl ColorSpec { /// matrix coefficients; it has none for linear light. The two linear specs /// are therefore source-only. pub fn is_encodable(&self) -> bool { - matches!(self, Self::Srgb { .. } | Self::Bt2020Pq) + matches!(self, Self::Srgb | Self::Bt2020Pq) } /// How a decoder should be told about a stream in this space, if it can be @@ -146,19 +93,9 @@ impl ColorSpec { // curve differs from BT.709's only in the toe, and every decoder // and display treats the pair interchangeably; H.273's separate // sRGB code point (13) is not what encoders emit here. - Self::Srgb { .. } => Some(ColorDescription::bt709()), + Self::Srgb => Some(ColorDescription::bt709()), Self::Bt2020Pq => Some(ColorDescription::bt2020_pq()), - Self::Bt709Linear { .. } | Self::Bt2020Linear { .. } => None, - } - } - - /// Discriminant handed to the shader. - fn shader_id(&self) -> u32 { - match self { - Self::Srgb { .. } => 0, - Self::Bt709Linear { .. } => 1, - Self::Bt2020Linear { .. } => 2, - Self::Bt2020Pq => 3, + Self::Bt709Linear | Self::Bt2020Linear => None, } } } @@ -199,7 +136,7 @@ pub enum InputFormat { /// /// The converter treats FP16 data the same as other formats: the /// [`ColorSpec`] says what the samples mean, not the pixel format. - /// [`ColorSpec::scrgb`] is the natural pairing for FP16 from an + /// [`ColorSpec::Bt709Linear`] is the natural pairing for FP16 from an /// `EXTENDED_SRGB_LINEAR_EXT` swapchain; FP16 that is already PQ-encoded /// (via the gamescope WSI layer, say) is [`ColorSpec::Bt2020Pq`]. RGBA16F, @@ -316,6 +253,14 @@ pub struct ColorConverterConfig { /// Must satisfy [`ColorSpec::is_encodable`]; [`ColorConverter::new`] /// rejects the rest. pub target: ColorSpec, + /// Luminance that a source sample value of 1.0 represents, in nits, + /// overriding [`ColorSpec::reference_white_nits`]. + /// + /// `None`, the default, takes the standard figure for the source space, + /// which is what almost every caller wants. Only read on the way to + /// [`ColorSpec::Bt2020Pq`], where the PQ encode needs an absolute + /// reference; ignored otherwise. + pub reference_white_nits: Option, /// Quantization range the shader writes. /// /// Pass [`color_description`](Self::color_description) to the encoder and @@ -343,6 +288,7 @@ impl ColorConverterConfig { source: source_color, target: target_color, range: color_range, + reference_white_nits: None, } } @@ -358,6 +304,23 @@ impl ColorConverterConfig { self } + /// Override the luminance a source sample value of 1.0 represents. + /// + /// Rarely needed: [`ColorSpec::reference_white_nits`] already gives the + /// standard figure for each space, and picking the wrong one is a visible + /// brightness error rather than a subtle one. + pub fn with_reference_white_nits(mut self, nits: f32) -> Self { + self.reference_white_nits = Some(nits); + self + } + + /// The reference white this conversion will actually use. + fn effective_reference_white_nits(&self) -> f32 { + self.reference_white_nits + .or_else(|| self.source.reference_white_nits()) + .unwrap_or(0.0) + } + /// Set the quantization range. pub fn with_range(mut self, range: ColorRange) -> Self { self.range = range; @@ -387,7 +350,7 @@ impl ColorConverterConfig { /// 1080, /// InputFormat::BGRA, /// OutputFormat::P010, - /// ColorSpec::scrgb(), + /// ColorSpec::Bt709Linear, /// ColorSpec::Bt2020Pq, /// ColorRange::Full, /// ); @@ -416,9 +379,9 @@ impl ColorConverterConfig { ColorSpec::Bt2020Pq => true, // Only something already SDR-encoded reaches an SDR target. // Anything else would need a forward gamma encode or tone mapping. - ColorSpec::Srgb { .. } => matches!(self.source, ColorSpec::Srgb { .. }), + ColorSpec::Srgb => matches!(self.source, ColorSpec::Srgb), // Not encodable at all; see `ColorSpec::is_encodable`. - ColorSpec::Bt709Linear { .. } | ColorSpec::Bt2020Linear { .. } => false, + ColorSpec::Bt709Linear | ColorSpec::Bt2020Linear => false, } } @@ -1043,14 +1006,10 @@ impl ColorConverter { self.config.height, self.config.input_format as u32, self.config.output_format as u32, - self.config.source.shader_id(), - self.config.target.shader_id(), + self.config.source as u32, + self.config.target as u32, self.config.range.is_full() as u32, - self.config - .source - .reference_white_nits() - .unwrap_or(0.0) - .to_bits(), + self.config.effective_reference_white_nits().to_bits(), ]; let push_constants_bytes: &[u8] = std::slice::from_raw_parts( push_constants.as_ptr() as *const u8, @@ -1318,10 +1277,10 @@ mod tests { #[test] fn spec_discriminants_match_the_shader() { // The shader's SPEC_* defines. One set, used for both push constants. - assert_eq!(ColorSpec::srgb().shader_id(), 0); - assert_eq!(ColorSpec::scrgb().shader_id(), 1); - assert_eq!(ColorSpec::bt2020_linear().shader_id(), 2); - assert_eq!(ColorSpec::bt2020_pq().shader_id(), 3); + assert_eq!(ColorSpec::Srgb as u32, 0); + assert_eq!(ColorSpec::Bt709Linear as u32, 1); + assert_eq!(ColorSpec::Bt2020Linear as u32, 2); + assert_eq!(ColorSpec::Bt2020Pq as u32, 3); } #[test] @@ -1335,19 +1294,19 @@ mod tests { fn only_encodable_specs_describe_a_stream() { // A decoder can be told about these two. assert_eq!( - ColorSpec::srgb().color_description(), + ColorSpec::Srgb.color_description(), Some(ColorDescription::bt709()) ); assert_eq!( - ColorSpec::bt2020_pq().color_description(), + ColorSpec::Bt2020Pq.color_description(), Some(ColorDescription::bt2020_pq()) ); - assert!(ColorSpec::srgb().is_encodable()); - assert!(ColorSpec::bt2020_pq().is_encodable()); + assert!(ColorSpec::Srgb.is_encodable()); + assert!(ColorSpec::Bt2020Pq.is_encodable()); // There are no VUI code points for linear light, so these are // source-only and say so. - for spec in [ColorSpec::scrgb(), ColorSpec::bt2020_linear()] { + for spec in [ColorSpec::Bt709Linear, ColorSpec::Bt2020Linear] { assert!(!spec.is_encodable(), "{spec:?}"); assert_eq!(spec.color_description(), None, "{spec:?}"); } @@ -1357,29 +1316,53 @@ mod tests { fn scrgb_white_is_not_the_srgb_reference() { // IEC 61966-2-2 puts scRGB 1.0 at 80 cd/m²; BT.2408 puts SDR white at // 203. Taking the 203 for scRGB is the mistake this pairing prevents. - assert_eq!(ColorSpec::scrgb().reference_white_nits(), Some(80.0)); - assert_eq!(ColorSpec::srgb().reference_white_nits(), Some(203.0)); - assert_eq!( - ColorSpec::bt2020_linear().reference_white_nits(), - Some(203.0) - ); + assert_eq!(ColorSpec::Bt709Linear.reference_white_nits(), Some(80.0)); + assert_eq!(ColorSpec::Srgb.reference_white_nits(), Some(203.0)); + assert_eq!(ColorSpec::Bt2020Linear.reference_white_nits(), Some(203.0)); + } + + #[test] + fn pq_has_no_reference_white() { + // PQ is absolute, so there is no such quantity to ask about. + assert_eq!(ColorSpec::Bt2020Pq.reference_white_nits(), None); } #[test] - fn pq_source_has_no_reference_white_to_set() { - // PQ is absolute, so there is no such quantity, and overriding it is - // a no-op rather than a silently stored value. - assert_eq!(ColorSpec::bt2020_pq().reference_white_nits(), None); - let overridden = ColorSpec::bt2020_pq().with_reference_white_nits(500.0); - assert_eq!(overridden, ColorSpec::Bt2020Pq); - assert_eq!(overridden.reference_white_nits(), None); + fn the_override_replaces_the_standard_figure() { + let base = ColorConverterConfig::new( + 64, + 64, + InputFormat::BGRA, + OutputFormat::P010, + ColorSpec::Bt709Linear, + ColorSpec::Bt2020Pq, + ColorRange::Full, + ); + // Unset, the source's own figure is what the shader gets. + assert_eq!(base.reference_white_nits, None); + assert_eq!(base.effective_reference_white_nits(), 80.0); + + let overridden = base.clone().with_reference_white_nits(203.0); + assert_eq!(overridden.effective_reference_white_nits(), 203.0); + // The space itself is untouched; the override lives on the conversion. + assert_eq!(overridden.source, ColorSpec::Bt709Linear); + assert_eq!(overridden.source.reference_white_nits(), Some(80.0)); } #[test] - fn reference_white_override_keeps_the_source_kind() { - let source = ColorSpec::scrgb().with_reference_white_nits(203.0); - assert_eq!(source.shader_id(), ColorSpec::scrgb().shader_id()); - assert_eq!(source.reference_white_nits(), Some(203.0)); + fn a_pq_source_needs_no_reference_white() { + // Nothing reads it, so an absent figure has to be harmless rather than + // a panic or a NaN reaching the shader. + let config = ColorConverterConfig::new( + 64, + 64, + InputFormat::BGRA, + OutputFormat::P010, + ColorSpec::Bt2020Pq, + ColorSpec::Bt2020Pq, + ColorRange::Full, + ); + assert_eq!(config.effective_reference_white_nits(), 0.0); } // ======================== @@ -1392,7 +1375,7 @@ mod tests { 64, InputFormat::BGRA, OutputFormat::NV12, - ColorSpec::srgb(), + ColorSpec::Srgb, target, range, ) @@ -1400,7 +1383,7 @@ mod tests { #[test] fn description_follows_the_target() { - let sdr = config_for(ColorSpec::srgb(), ColorRange::Limited) + let sdr = config_for(ColorSpec::Srgb, ColorRange::Limited) .color_description() .expect("sRGB is encodable"); assert_eq!(sdr, ColorDescription::bt709()); @@ -1417,7 +1400,7 @@ mod tests { fn description_follows_the_range() { // The drift this whole split exists to prevent: what the shader // quantizes to and what the VUI claims are now one decision. - for target in [ColorSpec::srgb(), ColorSpec::Bt2020Pq] { + for target in [ColorSpec::Srgb, ColorSpec::Bt2020Pq] { for range in [ColorRange::Limited, ColorRange::Full] { let config = config_for(target, range); assert_eq!( @@ -1441,13 +1424,13 @@ mod tests { 64, InputFormat::BGRA, OutputFormat::P010, - ColorSpec::srgb(), + ColorSpec::Srgb, ColorSpec::Bt2020Pq, ColorRange::Full, ); - let from_srgb = base.clone().with_source(ColorSpec::srgb()); - let from_scrgb = base.clone().with_source(ColorSpec::scrgb()); - let from_pq = base.with_source(ColorSpec::bt2020_pq()); + let from_srgb = base.clone().with_source(ColorSpec::Srgb); + let from_scrgb = base.clone().with_source(ColorSpec::Bt709Linear); + let from_pq = base.with_source(ColorSpec::Bt2020Pq); assert_eq!( from_srgb.color_description(), from_scrgb.color_description() @@ -1462,10 +1445,10 @@ mod tests { #[test] fn every_source_can_reach_pq() { for source in [ - ColorSpec::srgb(), - ColorSpec::scrgb(), - ColorSpec::bt2020_linear(), - ColorSpec::bt2020_pq(), + ColorSpec::Srgb, + ColorSpec::Bt709Linear, + ColorSpec::Bt2020Linear, + ColorSpec::Bt2020Pq, ] { let config = config_for(ColorSpec::Bt2020Pq, ColorRange::Full).with_source(source); assert!(config.conversion_supported(), "{source:?}"); @@ -1476,15 +1459,15 @@ mod tests { fn only_srgb_can_reach_sdr() { // Everything else would need a forward gamma encode or tone mapping, // and the shader does neither. Rejected beats silently passed through. - let ok = config_for(ColorSpec::srgb(), ColorRange::Limited).with_source(ColorSpec::srgb()); + let ok = config_for(ColorSpec::Srgb, ColorRange::Limited).with_source(ColorSpec::Srgb); assert!(ok.conversion_supported()); for source in [ - ColorSpec::scrgb(), - ColorSpec::bt2020_linear(), - ColorSpec::bt2020_pq(), + ColorSpec::Bt709Linear, + ColorSpec::Bt2020Linear, + ColorSpec::Bt2020Pq, ] { - let config = config_for(ColorSpec::srgb(), ColorRange::Limited).with_source(source); + let config = config_for(ColorSpec::Srgb, ColorRange::Limited).with_source(source); assert!(!config.conversion_supported(), "{source:?}"); } } @@ -1574,8 +1557,8 @@ mod tests { 1080, InputFormat::BGRx, OutputFormat::NV12, - ColorSpec::srgb(), - ColorSpec::srgb(), + ColorSpec::Srgb, + ColorSpec::Srgb, ColorRange::Full, ); @@ -1584,7 +1567,7 @@ mod tests { assert_eq!(cloned.height, 1080); assert_eq!(cloned.input_format, InputFormat::BGRx); assert_eq!(cloned.output_format, OutputFormat::NV12); - assert_eq!(cloned.target, ColorSpec::srgb()); + assert_eq!(cloned.target, ColorSpec::Srgb); assert!(cloned.range.is_full()); } @@ -1595,8 +1578,8 @@ mod tests { 480, InputFormat::RGBA, OutputFormat::I420, - ColorSpec::srgb(), - ColorSpec::srgb(), + ColorSpec::Srgb, + ColorSpec::Srgb, ColorRange::Full, ); @@ -1637,8 +1620,8 @@ mod tests { 64, InputFormat::BGRx, OutputFormat::NV12, - ColorSpec::srgb(), - ColorSpec::srgb(), + ColorSpec::Srgb, + ColorSpec::Srgb, ColorRange::Full, ); @@ -1672,8 +1655,8 @@ mod tests { 32, *input_format, *output_format, - ColorSpec::srgb(), - ColorSpec::srgb(), + ColorSpec::Srgb, + ColorSpec::Srgb, ColorRange::Full, ); diff --git a/src/lib.rs b/src/lib.rs index e958177..12aa378 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -196,10 +196,12 @@ //! [`ColorSpec::is_encodable`] is the question, and [`ColorConverter::new`] //! refuses the rest. //! -//! The relative specs carry the luminance that a sample value of 1.0 stands -//! for: 203 nits for `Srgb` per ITU-R BT.2408, and 80 for scRGB per IEC -//! 61966-2-2. It is read only on the way to `Bt2020Pq`, where the PQ encode -//! needs it to be absolute, and a spec used as a target ignores its own. +//! Each space knows the luminance a sample value of 1.0 represents, see +//! [`ColorSpec::reference_white_nits`]: 203 nits for the SDR-referred spaces +//! per ITU-R BT.2408, and 80 for scRGB per IEC 61966-2-2. It is read from the +//! source only, and only on the way to `Bt2020Pq`, where the PQ encode needs an +//! absolute reference. A conversion that needs a different figure overrides it +//! with [`ColorConverterConfig::with_reference_white_nits`]. //! //! Every source reaches `Bt2020Pq`. Only `Srgb` reaches an SDR target, because //! the others would need a forward gamma encode or tone mapping; those pairings @@ -233,7 +235,7 @@ //! 1080, //! InputFormat::BGRx, //! OutputFormat::P010, -//! ColorSpec::srgb(), +//! ColorSpec::Srgb, //! ColorSpec::Bt2020Pq, //! ColorRange::Full, //! ); From 55a5c560bac5260e00f092d5c18de610564495cb Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Fri, 18 Sep 2026 18:59:03 +0300 Subject: [PATCH 5/8] refactor(converter)!: drop the redundant config builders `ColorConverterConfig::new` takes the source, target and range, so `with_source`, `with_target` and `with_range` could only ever restate what the constructor had just been told. Two ways to say one thing, and the builder form read as though the constructor's arguments were defaults that could be left out, which they are not. `with_reference_white_nits` stays. It is the one colour setting `new` does not take, because the spec's own figure is right nearly always and an override is worth making deliberate. The unit tests used the builders to vary one slot at a time; they take a helper that passes both slots instead. Co-Authored-By: Claude Opus 5 --- src/converter/mod.rs | 47 +++++++++++++++----------------------------- 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/src/converter/mod.rs b/src/converter/mod.rs index 7deeb0f..9b86d02 100644 --- a/src/converter/mod.rs +++ b/src/converter/mod.rs @@ -292,18 +292,6 @@ impl ColorConverterConfig { } } - /// Set what the input pixels already are. - pub fn with_source(mut self, source: ColorSpec) -> Self { - self.source = source; - self - } - - /// Set what the encoded stream should be. - pub fn with_target(mut self, target: ColorSpec) -> Self { - self.target = target; - self - } - /// Override the luminance a source sample value of 1.0 represents. /// /// Rarely needed: [`ColorSpec::reference_white_nits`] already gives the @@ -321,12 +309,6 @@ impl ColorConverterConfig { .unwrap_or(0.0) } - /// Set the quantization range. - pub fn with_range(mut self, range: ColorRange) -> Self { - self.range = range; - self - } - /// The description of what this conversion actually produces, for /// [`EncodeConfig::with_color_description`](crate::EncodeConfig::with_color_description). /// @@ -1370,12 +1352,20 @@ mod tests { // ======================== fn config_for(target: ColorSpec, range: ColorRange) -> ColorConverterConfig { + config_from(ColorSpec::Srgb, target, range) + } + + fn config_from( + source: ColorSpec, + target: ColorSpec, + range: ColorRange, + ) -> ColorConverterConfig { ColorConverterConfig::new( 64, 64, InputFormat::BGRA, OutputFormat::NV12, - ColorSpec::Srgb, + source, target, range, ) @@ -1419,18 +1409,13 @@ mod tests { fn description_ignores_the_source() { // The source says nothing about the encoded stream, so it must not // reach the declaration. - let base = ColorConverterConfig::new( - 64, - 64, - InputFormat::BGRA, - OutputFormat::P010, - ColorSpec::Srgb, + let from_srgb = config_from(ColorSpec::Srgb, ColorSpec::Bt2020Pq, ColorRange::Full); + let from_scrgb = config_from( + ColorSpec::Bt709Linear, ColorSpec::Bt2020Pq, ColorRange::Full, ); - let from_srgb = base.clone().with_source(ColorSpec::Srgb); - let from_scrgb = base.clone().with_source(ColorSpec::Bt709Linear); - let from_pq = base.with_source(ColorSpec::Bt2020Pq); + let from_pq = config_from(ColorSpec::Bt2020Pq, ColorSpec::Bt2020Pq, ColorRange::Full); assert_eq!( from_srgb.color_description(), from_scrgb.color_description() @@ -1450,7 +1435,7 @@ mod tests { ColorSpec::Bt2020Linear, ColorSpec::Bt2020Pq, ] { - let config = config_for(ColorSpec::Bt2020Pq, ColorRange::Full).with_source(source); + let config = config_from(source, ColorSpec::Bt2020Pq, ColorRange::Full); assert!(config.conversion_supported(), "{source:?}"); } } @@ -1459,7 +1444,7 @@ mod tests { fn only_srgb_can_reach_sdr() { // Everything else would need a forward gamma encode or tone mapping, // and the shader does neither. Rejected beats silently passed through. - let ok = config_for(ColorSpec::Srgb, ColorRange::Limited).with_source(ColorSpec::Srgb); + let ok = config_from(ColorSpec::Srgb, ColorSpec::Srgb, ColorRange::Limited); assert!(ok.conversion_supported()); for source in [ @@ -1467,7 +1452,7 @@ mod tests { ColorSpec::Bt2020Linear, ColorSpec::Bt2020Pq, ] { - let config = config_for(ColorSpec::Srgb, ColorRange::Limited).with_source(source); + let config = config_from(source, ColorSpec::Srgb, ColorRange::Limited); assert!(!config.conversion_supported(), "{source:?}"); } } From 9c743848ec5e4d71b3250cc190fada8dcafab154 Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Fri, 18 Sep 2026 18:59:03 +0300 Subject: [PATCH 6/8] test: move the colour conversion checks out of examples Two things hgaiser asked for on #38, and the repo restructure in 936d412 made the second one easy to answer. The checks are tests, not examples. They demonstrate nothing a caller wants to copy; they compute what the converter should produce and compare. As examples they ran only when someone deliberately ran them, which is approximately never. As integration tests `cargo test -- --ignored` runs them on any machine with a video device, alongside `roundtrip` and `verify_all`. `convert_quantization` and `convert_color_paths` become one file, since they shared about two hundred lines of Vulkan scaffolding and a synthetic frame. Three tests come out of it: - `quantizers_round_rather_than_truncate`, over NV12 and P010 in both ranges - `every_conversion_matches_the_model`, the five supported ColorSpec pairs at both bit depths, which also asserts the derived declaration describes the target - `unsupported_conversions_are_refused`, both refusal kinds, which also pins `color_description()` and `is_encodable()` to the same answer The examples printed their findings and returned an error; tests assert. The quantization one now fails on either evidence of truncation, a rounding hypothesis that fits worse than a truncating one or a mean signed error above 0.05, rather than only on the first. Confirmed by making the shader truncate again and watching it fail, then restoring it and checking the SPIR-V came back byte-identical. `SrcImage` gained a `Drop`, since a test leaves scope without the explicit teardown an example's `main` had. Measured on an Arc A310, unchanged from the examples: all ten source, target and format combinations within one code value of the model, quantizer bias at -0.0006 and +0.0017 for NV12, -0.0012 and +0.0040 for P010. Co-Authored-By: Claude Opus 5 --- .../color_conversion.rs | 349 ++++++++++++------ 1 file changed, 231 insertions(+), 118 deletions(-) rename examples/convert_color_paths.rs => tests/color_conversion.rs (60%) diff --git a/examples/convert_color_paths.rs b/tests/color_conversion.rs similarity index 60% rename from examples/convert_color_paths.rs rename to tests/color_conversion.rs index 9fca6b4..9da00ca 100644 --- a/examples/convert_color_paths.rs +++ b/tests/color_conversion.rs @@ -1,35 +1,38 @@ -//! Example: Check every conversion path against a model of itself +//! What the colour converter writes, checked against a model of itself. //! -//! [`ColorConverterConfig`] takes two colour decisions: a [`ColorSpec`] for -//! what the input pixels already are, and a [`ColorSpec`] for what the -//! encoded stream should be. Between them they select which of the shader's -//! stages run: an sRGB decode, a BT.709 to BT.2020 gamut hop, a PQ encode, or -//! none of them for a passthrough. +//! Two things are hard to see by looking at a picture and easy to see by +//! computing the same thing twice. Both are checked here, off one synthetic +//! frame and one readback path. //! -//! Getting one of those stages wrong does not fail loudly. It produces a -//! plausible picture with the wrong colour, which is why this reimplements the -//! same pipeline on the CPU, stage for stage, and compares. Every supported -//! pair is checked at 8-bit and 10-bit, in both cases against a model built -//! from the source and target rather than from the shader's branches, so a -//! branch taken wrongly shows up as a large disagreement. +//! **Quantization.** The shader turns floating-point YUV into integer code +//! values. Truncating instead of rounding is not a visible failure, it is a +//! uniform half-code darkening, so each sample is scored against two +//! hypotheses: that the shader rounds, and that it truncates. A correct shader +//! matches the rounding prediction and shows a mean signed error near zero. //! -//! It also asserts the refusals. Only [`ColorSpec::Srgb`] can reach -//! [`ColorSpec::Srgb`]; a linear or PQ source would need a forward gamma -//! encode or tone mapping, and the converter rejects those pairs rather than -//! passing the samples through and mislabelling them. +//! **Conversion paths.** A [`ColorSpec`] pair selects which shader stages run: +//! an sRGB decode, a BT.709 to BT.2020 gamut hop, a PQ encode, or none of them +//! for a passthrough. Getting one wrong produces a plausible picture in the +//! wrong colour, so the pipeline is reimplemented on the CPU stage for stage, +//! with the stages selected from the source and target rather than from the +//! shader's branches, and compared sample by sample. //! -//! Luma only, for the same reason as `convert_quantization`: chroma is a 2x2 -//! average in NV12 and P010, which would mix averaging error into the result. +//! Luma only. It is one sample per pixel, whereas NV12 and P010 chroma is a 2x2 +//! average, which would fold averaging error into the measurement. The frame is +//! deliberately not grey, because grey converts to exact code values under +//! BT.709 and cannot tell any of these hypotheses apart. //! -//! ```text -//! cargo run --example convert_color_paths -//! ``` +//! Ignored by default: requires a Vulkan Video device. Run with +//! `cargo test -- --ignored`. + +#[allow(dead_code)] +mod common; use ash::vk; use pixelforge::{ - Codec, ColorConverter, ColorConverterConfig, ColorRange, ColorSpec, EncodeBitDepth, - EncodeConfig, Encoder, InputFormat, OutputFormat, RateControlMode, VideoContext, - VideoContextBuilder, + Codec, ColorConverter, ColorConverterConfig, ColorDescription, ColorRange, ColorSpec, + EncodeBitDepth, EncodeConfig, Encoder, InputFormat, OutputFormat, RateControlMode, + VideoContext, VideoContextBuilder, }; const WIDTH: u32 = 256; @@ -133,11 +136,24 @@ fn expected_code( } } +/// The synthetic frame, on the GPU, cleaned up when the test drops it. struct SrcImage { + /// Keeps the device alive until after the image is destroyed. + context: VideoContext, image: vk::Image, memory: vk::DeviceMemory, } +impl Drop for SrcImage { + fn drop(&mut self) { + let device = self.context.device(); + unsafe { + device.destroy_image(self.image, None); + device.free_memory(self.memory, None); + } + } +} + unsafe fn one_shot( context: &VideoContext, record: F, @@ -309,7 +325,11 @@ unsafe fn create_src_image(context: &VideoContext) -> Result Result<(Encoder, Option), Box> { + let eight = Encoder::new( + context.clone(), + EncodeConfig::h264(WIDTH, HEIGHT) + .with_rate_control(RateControlMode::Cqp) + .with_frame_rate(30, 1) + .with_b_frames(0), + )?; + let ten = Encoder::new( + context.clone(), + EncodeConfig::h265(WIDTH, HEIGHT) + .with_bit_depth(EncodeBitDepth::Ten) + .with_rate_control(RateControlMode::Cqp) + .with_frame_rate(30, 1) + .with_b_frames(0), + ) + .ok(); + if ten.is_none() { + println!("no 10-bit H.265 encoder on this device: P010 cases skipped"); + } + Ok((eight, ten)) +} + +fn context() -> Result> { + Ok(VideoContextBuilder::new() + .app_name("pixelforge-color-conversion") + .require_encode(Codec::H264) + .enable_validation(std::env::var("PIXELFORGE_VALIDATION").is_ok()) + .build()?) +} + +/// Convert one frame and read its luma plane back as code values. +fn convert( context: &VideoContext, src: &SrcImage, encoder: &Encoder, source: ColorSpec, target: ColorSpec, output_format: OutputFormat, -) -> Result> { - let ten_bit = output_format.bytes_per_sample() == 2; + range: ColorRange, +) -> Result<(Vec, ColorDescription), Box> { let config = ColorConverterConfig::new( WIDTH, HEIGHT, @@ -365,7 +423,7 @@ fn check( output_format, source, target, - ColorRange::Full, + range, ); let mut converter = ColorConverter::new(context.clone(), config)?; let description = converter.color_description(); @@ -374,58 +432,85 @@ fn check( vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, encoder.input_image(), )?; - let codes = read_luma(context, &converter, output_format)?; + Ok((read_luma(context, &converter, output_format)?, description)) +} - let mut worst = 0f64; - let mut sum = 0f64; - for y in 0..HEIGHT { - for x in 0..WIDTH { - let got = codes[(y * WIDTH + x) as usize] as f64; - let want = expected_code(colour_at(x, y), source, target, ten_bit, true).round() as f64; - let d = (got - want).abs(); - worst = worst.max(d); - sum += d; +#[test] +#[ignore = "requires a Vulkan Video device"] +fn quantizers_round_rather_than_truncate() -> Result<(), Box> { + let context = context()?; + let src = unsafe { create_src_image(&context)? }; + let (eight, ten) = encoders(&context)?; + + for (output_format, encoder) in [ + (OutputFormat::NV12, Some(&eight)), + (OutputFormat::P010, ten.as_ref()), + ] { + let Some(encoder) = encoder else { continue }; + let ten_bit = output_format.bytes_per_sample() == 2; + for range in [ColorRange::Limited, ColorRange::Full] { + let full = range.is_full(); + let (codes, _) = convert( + &context, + &src, + encoder, + ColorSpec::Srgb, + ColorSpec::Srgb, + output_format, + range, + )?; + + let mut if_truncating = 0usize; + let mut if_rounding = 0usize; + let mut signed_error = 0f64; + for y in 0..HEIGHT { + for x in 0..WIDTH { + let got = codes[(y * WIDTH + x) as usize]; + let ideal = expected_code( + colour_at(x, y), + ColorSpec::Srgb, + ColorSpec::Srgb, + ten_bit, + full, + ); + if got != ideal as u32 { + if_truncating += 1; + } + if got != ideal.round() as u32 { + if_rounding += 1; + } + signed_error += got as f64 - ideal as f64; + } + } + let samples = (WIDTH * HEIGHT) as f64; + let bias = signed_error / samples; + println!( + "{output_format:?} {range:?}: {if_truncating} mismatches if truncating, \ + {if_rounding} if rounding, mean signed error {bias:+.4}" + ); + + // Truncation biases every sample low by about half a code value, + // which is what this is really watching for. The tie tolerance is + // for the GPU ordering its f32 arithmetic differently from us. + assert!( + if_rounding < if_truncating, + "{output_format:?} {range:?}: looks like it truncates" + ); + assert!( + bias.abs() < 0.05, + "{output_format:?} {range:?}: mean signed error {bias:+.4} is a bias, not noise" + ); } } - let mean = sum / (WIDTH * HEIGHT) as f64; - // One code value of slack: the GPU is free to order and fuse these f32 - // operations differently, which moves samples that land on a tie. - let ok = worst <= 1.0; - println!( - "{:<34} -> {:<10} {:<6} worst {worst:>5.1}, mean {mean:.4} VUI {}/{}/{} {} {}", - format!("{source:?}"), - format!("{target:?}"), - if ten_bit { "P010" } else { "NV12" }, - description.color_primaries, - description.transfer_characteristics, - description.matrix_coefficients, - if description.full_range { "pc" } else { "tv" }, - if ok { "ok" } else { "MISMATCH" } - ); - Ok(ok) + Ok(()) } -fn main() -> Result<(), Box> { - let context = VideoContextBuilder::new() - .app_name("path probe") - .require_encode(Codec::H264) - .build()?; +#[test] +#[ignore = "requires a Vulkan Video device"] +fn every_conversion_matches_the_model() -> Result<(), Box> { + let context = context()?; let src = unsafe { create_src_image(&context)? }; - let encoder_8 = Encoder::new( - context.clone(), - EncodeConfig::h264(WIDTH, HEIGHT) - .with_rate_control(RateControlMode::Cqp) - .with_frame_rate(30, 1) - .with_b_frames(0), - )?; - let encoder_10 = Encoder::new( - context.clone(), - EncodeConfig::h265(WIDTH, HEIGHT) - .with_bit_depth(EncodeBitDepth::Ten) - .with_rate_control(RateControlMode::Cqp) - .with_frame_rate(30, 1) - .with_b_frames(0), - )?; + let (eight, ten) = encoders(&context)?; let supported = [ (ColorSpec::Srgb, ColorSpec::Srgb), @@ -434,27 +519,68 @@ fn main() -> Result<(), Box> { (ColorSpec::Bt2020Linear, ColorSpec::Bt2020Pq), (ColorSpec::Bt2020Pq, ColorSpec::Bt2020Pq), ]; - let mut all_ok = true; + for (source, target) in supported { - all_ok &= check( - &context, - &src, - &encoder_8, - source, - target, - OutputFormat::NV12, - )?; - all_ok &= check( - &context, - &src, - &encoder_10, - source, - target, - OutputFormat::P010, - )?; + for (output_format, encoder) in [ + (OutputFormat::NV12, Some(&eight)), + (OutputFormat::P010, ten.as_ref()), + ] { + let Some(encoder) = encoder else { continue }; + let ten_bit = output_format.bytes_per_sample() == 2; + let (codes, description) = convert( + &context, + &src, + encoder, + source, + target, + output_format, + ColorRange::Full, + )?; + + let mut worst = 0f64; + let mut sum = 0f64; + for y in 0..HEIGHT { + for x in 0..WIDTH { + let got = codes[(y * WIDTH + x) as usize] as f64; + let want = expected_code(colour_at(x, y), source, target, ten_bit, true).round() + as f64; + let d = (got - want).abs(); + worst = worst.max(d); + sum += d; + } + } + let mean = sum / (WIDTH * HEIGHT) as f64; + println!( + "{source:?} -> {target:?} {output_format:?}: worst {worst:.1}, mean {mean:.4}, \ + VUI {}/{}/{} {}", + description.color_primaries, + description.transfer_characteristics, + description.matrix_coefficients, + if description.full_range { "pc" } else { "tv" }, + ); + + // The declaration has to describe the target, not the source. + assert_eq!( + Some(description.with_full_range(false)), + target.color_description(), + "{source:?} -> {target:?}: declared the wrong space" + ); + // One code value of slack: the GPU is free to order and fuse these + // f32 operations differently, which moves samples landing on a tie. + assert!( + worst <= 1.0, + "{source:?} -> {target:?} {output_format:?}: worst {worst:.1} code values off" + ); + } } + Ok(()) +} + +#[test] +#[ignore = "requires a Vulkan Video device"] +fn unsupported_conversions_are_refused() -> Result<(), Box> { + let context = context()?; - println!("\n--- these must be refused ---"); let refused = [ // An SDR target from something not already SDR-encoded: would need a // forward gamma encode, or tone mapping from PQ. @@ -462,12 +588,12 @@ fn main() -> Result<(), Box> { (ColorSpec::Bt2020Linear, ColorSpec::Srgb), (ColorSpec::Bt2020Pq, ColorSpec::Srgb), // A target no decoder can be told about: linear light has no VUI code - // points, so these specs are source-only. + // points, so those specs are source-only. (ColorSpec::Srgb, ColorSpec::Bt709Linear), (ColorSpec::Srgb, ColorSpec::Bt2020Linear), ]; + for (source, target) in refused { - // The description has to be absent for exactly the unencodable ones. let config = ColorConverterConfig::new( WIDTH, HEIGHT, @@ -477,28 +603,15 @@ fn main() -> Result<(), Box> { target, ColorRange::Full, ); - let described = config.color_description().is_some(); - if described != target.is_encodable() { - println!("{target:?}: color_description() and is_encodable() disagree"); - all_ok = false; - } - match ColorConverter::new(context.clone(), config) { - Ok(_) => { - println!("{source:?} -> {target:?}: ACCEPTED, should not be"); - all_ok = false; - } - Err(e) => println!("{source:?} -> {target:?}: refused ({e})"), - } - } - - unsafe { - let device = context.device(); - device.destroy_image(src.image, None); - device.free_memory(src.memory, None); - } - if all_ok { - Ok(()) - } else { - Err("at least one path disagrees with the model".into()) + assert_eq!( + config.color_description().is_some(), + target.is_encodable(), + "{target:?}: color_description() and is_encodable() disagree" + ); + let error = ColorConverter::new(context.clone(), config) + .err() + .unwrap_or_else(|| panic!("{source:?} -> {target:?} was accepted")); + println!("{source:?} -> {target:?}: {error}"); } + Ok(()) } From 923d3696bf708f2b034fe4a74be3c219b92980eb Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Fri, 18 Sep 2026 19:05:27 +0300 Subject: [PATCH 7/8] docs(converter): say it plainly The colour docs were written for someone who already knew the standards. They named specs (H.273, ITU-R BT.2408, IEC 61966-2-2) without saying what those mean, described "the luminance a sample value of 1.0 represents" where "how bright white is" would do, and explained the design's reasoning where a caller only wanted to know what to pass. Same facts, fewer words, no citations standing in for explanations: before: The two linear specs are source-only because H.273 has no code points for linear light, so a decoder cannot be told a stream is in one. after: The linear spaces cannot be a target, because a video file has no way to record that it holds linear light. before: The luminance a sample value of 1.0 represents in this space, in nits, where that is a meaningful question to ask. after: How bright this space's white is, in nits. Error messages got the same treatment, since they are read by people who hit them rather than people studying the API. The variant docs now say what each space is for rather than reciting its primaries and transfer function. About sixty lines shorter overall, with nothing dropped that a caller needs. Co-Authored-By: Claude Opus 5 --- README.md | 54 ++++++------- src/converter/mod.rs | 164 +++++++++++++++----------------------- src/lib.rs | 54 ++++++------- tests/color_conversion.rs | 40 ++++------ 4 files changed, 126 insertions(+), 186 deletions(-) diff --git a/README.md b/README.md index 6914d94..c4b2e23 100644 --- a/README.md +++ b/README.md @@ -187,41 +187,33 @@ CPU is the consumer's job; `examples/common` shows one way. PixelForge includes a GPU compute shader for converting RGB input to YUV output. The source color describes the input. The target color describes the stream. -Both are a [`ColorSpec`]; which end one describes comes from the slot it is -in, not from the value. - -| `ColorSpec` | Space | Usable as a target | -|-------------|-------|--------------------| -| `Srgb` | BT.709 primaries, sRGB transfer. Ordinary SDR content. | yes, declared as BT.709 | -| `Bt709Linear` | Linear BT.709. This is scRGB, from an `EXTENDED_SRGB_LINEAR_EXT` swapchain. | no | -| `Bt2020Linear` | Linear BT.2020. | no | -| `Bt2020Pq` | BT.2020 primaries, PQ transfer. HDR10. | yes | - -The two linear specs are source-only because H.273 has no code points for -linear light, so a decoder cannot be told a stream is in one. -[`ColorSpec::is_encodable`] is the question, and [`ColorConverter::new`] -refuses the rest. - -Each space knows the luminance a sample value of 1.0 represents, see -[`ColorSpec::reference_white_nits`]: 203 nits for the SDR-referred spaces -per ITU-R BT.2408, and 80 for scRGB per IEC 61966-2-2. It is read from the -source only, and only on the way to `Bt2020Pq`, where the PQ encode needs an -absolute reference. A conversion that needs a different figure overrides it -with [`ColorConverterConfig::with_reference_white_nits`]. - -Every source reaches `Bt2020Pq`. Only `Srgb` reaches an SDR target, because -the others would need a forward gamma encode or tone mapping; those pairings -are rejected rather than quietly passed through. +Both are a [`ColorSpec`]. + +| `ColorSpec` | | Can be a target | +|-------------|-|-----------------| +| `Srgb` | Ordinary SDR content | yes | +| `Bt709Linear` | scRGB, from an `EXTENDED_SRGB_LINEAR_EXT` swapchain | no | +| `Bt2020Linear` | Linear light, wide gamut | no | +| `Bt2020Pq` | HDR10, from an `HDR10_ST2084_EXT` swapchain | yes | + +The linear spaces cannot be a target, because a video file has no way to +record that it holds linear light. Any source can be converted to +`Bt2020Pq`. Only `Srgb` can be converted to `Srgb`; the others would need +tone mapping or a gamma curve applied, which the shader does not do. +[`ColorConverter::new`] rejects the combinations it cannot do. + +Encoding to HDR needs to know how bright the source's white is, since HDR +carries real brightness values and SDR does not. Each space has a sensible +default, see [`ColorSpec::reference_white_nits`], overridable with +[`ColorConverterConfig::with_reference_white_nits`]. Supported input formats: BGRx, RGBx, BGRA, RGBA, ABGR2101010 (10-bit packed), RGBA16F (FP16). Supported output formats: NV12 (8-bit), I420 (8-bit), YUV444 (8-bit), P010 (10-bit), YUV444P10 (10-bit). -Because the target and the range fully determine the stream's colour -signalling, [`ColorConverter::color_description`] derives the encoder's VUI -declaration from the conversion itself. Use it rather than declaring the -same thing twice: full-range samples tagged as limited are expanded a second -time on playback, and the API cannot catch that if the two are set -independently. +Pass [`ColorConverter::color_description`] to the encoder rather than +describing the colours a second time by hand. The two have to agree: if the +converter writes full-range pixels and the stream says limited, players +stretch the range again and the picture comes out wrong. ```rust use pixelforge::{ diff --git a/src/converter/mod.rs b/src/converter/mod.rs index 9b86d02..babebf6 100644 --- a/src/converter/mod.rs +++ b/src/converter/mod.rs @@ -14,55 +14,39 @@ use crate::vulkan::VideoContext; use ash::vk; use tracing::debug; -/// A colour space: its primaries, its transfer function, and, where that -/// transfer function is relative rather than absolute, what luminance a sample -/// value of 1.0 stands for. +/// A colour space, used for both ends of a conversion: +/// [`ColorConverterConfig::source`] for the input and +/// [`ColorConverterConfig::target`] for the encoded output. /// -/// One type describes both ends of a conversion. Which end a given value is -/// describing comes from the slot it sits in, -/// [`ColorConverterConfig::source`] or [`ColorConverterConfig::target`], not -/// from the value itself, so nothing here needs renaming if conversion ever -/// runs the other way. -/// -/// Not every space can be a target. An encoded stream has to be something a -/// decoder can be told about, and there are no VUI code points for linear -/// light, so [`ColorSpec::Bt709Linear`] and [`ColorSpec::Bt2020Linear`] are -/// source-only. [`ColorSpec::is_encodable`] is the check, and -/// [`ColorConverter::new`] applies it. -/// -/// Each space knows the luminance its sample value of 1.0 represents, see -/// [`ColorSpec::reference_white_nits`]. That only matters on the way to an -/// absolute transfer function, so it is read only when the target is -/// [`ColorSpec::Bt2020Pq`], and only from the source. +/// The two linear spaces can only be an input. Video files have no way to say +/// "linear", so nothing could be decoded correctly; see +/// [`ColorSpec::is_encodable`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(u32)] pub enum ColorSpec { - /// BT.709 primaries, sRGB transfer function. Ordinary SDR desktop content. + /// Ordinary SDR content, the usual desktop and game output. Srgb = 0, - /// BT.709 primaries, linear light. This is scRGB, what a - /// `VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT` swapchain produces. + /// scRGB: like `Srgb` but in linear light. What a + /// `VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT` swapchain gives you. Bt709Linear = 1, - /// BT.2020 primaries, linear light. + /// Linear light in the wide HDR gamut. Bt2020Linear = 2, - /// BT.2020 primaries, PQ (ST 2084) transfer function. Already HDR-encoded, - /// as a `VK_COLOR_SPACE_HDR10_ST2084_EXT` surface or a gamescope PQ - /// surface is. + /// HDR10: wide gamut, PQ encoded. What a + /// `VK_COLOR_SPACE_HDR10_ST2084_EXT` swapchain gives you. Bt2020Pq = 3, } impl ColorSpec { - /// The luminance a sample value of 1.0 represents in this space, in nits, - /// where that is a meaningful question to ask. + /// How bright this space's white is, in nits. /// - /// This is the standard figure for the space, not a setting: 203 nits for - /// the SDR-referred spaces per ITU-R BT.2408, and 80 for scRGB per IEC - /// 61966-2-2. The 80 is why `Bt709Linear` is worth having as its own - /// variant: reading an `EXTENDED_SRGB_LINEAR_EXT` swapchain at the 203 that - /// SDR content wants maps its white about two and a half times too bright. + /// Only used when encoding to HDR, which needs real brightness values + /// rather than the relative ones SDR content carries. Getting it wrong + /// makes the picture too bright or too dim, so the spaces differ here: + /// scRGB defines its white as 80 nits, the others use 203. /// - /// `None` for [`ColorSpec::Bt2020Pq`], PQ being absolute already. - /// [`ColorConverterConfig::with_reference_white_nits`] overrides it for a - /// conversion that needs a different figure. + /// `None` for `Bt2020Pq`, which already carries real brightness values. + /// Override it with + /// [`ColorConverterConfig::with_reference_white_nits`]. pub fn reference_white_nits(&self) -> Option { match self { Self::Srgb | Self::Bt2020Linear => Some(203.0), @@ -71,28 +55,23 @@ impl ColorSpec { } } - /// Whether a stream can be declared to be in this space. + /// Whether video can be encoded in this space. /// - /// H.273 has code points for primaries, transfer characteristics and - /// matrix coefficients; it has none for linear light. The two linear specs - /// are therefore source-only. + /// False for the two linear spaces: a video file has no way to record that + /// it holds linear light, so a player would get it wrong. pub fn is_encodable(&self) -> bool { matches!(self, Self::Srgb | Self::Bt2020Pq) } - /// How a decoder should be told about a stream in this space, if it can be - /// told at all. + /// What to tell a player about video in this space. /// - /// `None` exactly when [`Self::is_encodable`] is false. The range is not - /// part of a colour space here, so this leaves it at the - /// [`ColorDescription`] default; [`ColorConverterConfig::color_description`] - /// is what fills it in. + /// `None` when [`Self::is_encodable`] is false. The range is set + /// separately, by [`ColorConverterConfig::color_description`]. pub fn color_description(&self) -> Option { match self { - // The VUI says BT.709 transfer characteristics for SDR. sRGB's - // curve differs from BT.709's only in the toe, and every decoder - // and display treats the pair interchangeably; H.273's separate - // sRGB code point (13) is not what encoders emit here. + // sRGB and BT.709 differ slightly in their curves, but video + // always labels this case BT.709 and players treat the two the + // same, so labelling it anything else would be surprising. Self::Srgb => Some(ColorDescription::bt709()), Self::Bt2020Pq => Some(ColorDescription::bt2020_pq()), Self::Bt709Linear | Self::Bt2020Linear => None, @@ -228,13 +207,8 @@ impl OutputFormat { /// Configuration for the color converter. /// -/// The colour side of this is deliberately two decisions rather than one: -/// [`source`](Self::source) says what the input pixels are, and -/// [`target`](Self::target) says what the encoded stream should be. Both are a -/// [`ColorSpec`], since they are the same kind of thing; only the slot says -/// which end is which. Only the target is a property of the output, which is -/// why [`color_description`](Self::color_description) can derive the encoder's -/// declaration from it with nothing left to guess. +/// Colour is two decisions: [`source`](Self::source) for what the input pixels +/// are, [`target`](Self::target) for what the encoded stream should be. #[derive(Clone, Debug)] #[non_exhaustive] pub struct ColorConverterConfig { @@ -248,24 +222,18 @@ pub struct ColorConverterConfig { pub output_format: OutputFormat, /// What the input pixels already are. pub source: ColorSpec, - /// What the encoded stream should be. Also decides the YUV matrix. + /// What the encoded stream should be. Also picks the YUV matrix. /// - /// Must satisfy [`ColorSpec::is_encodable`]; [`ColorConverter::new`] - /// rejects the rest. + /// Must be a space video can be encoded in, see + /// [`ColorSpec::is_encodable`]. pub target: ColorSpec, - /// Luminance that a source sample value of 1.0 represents, in nits, - /// overriding [`ColorSpec::reference_white_nits`]. - /// - /// `None`, the default, takes the standard figure for the source space, - /// which is what almost every caller wants. Only read on the way to - /// [`ColorSpec::Bt2020Pq`], where the PQ encode needs an absolute - /// reference; ignored otherwise. + /// How bright the source's white is, in nits, overriding the source + /// space's default. Only used when encoding to HDR. pub reference_white_nits: Option, - /// Quantization range the shader writes. + /// Whether the shader writes full or limited range. /// - /// Pass [`color_description`](Self::color_description) to the encoder and - /// this reaches the stream's VUI as well, which it has to: full-range - /// samples tagged as limited get expanded a second time on playback. + /// The stream has to say the same thing, which is what + /// [`color_description`](Self::color_description) is for. pub range: ColorRange, } @@ -292,34 +260,32 @@ impl ColorConverterConfig { } } - /// Override the luminance a source sample value of 1.0 represents. + /// Override how bright the source's white is, in nits. /// - /// Rarely needed: [`ColorSpec::reference_white_nits`] already gives the - /// standard figure for each space, and picking the wrong one is a visible - /// brightness error rather than a subtle one. + /// Rarely needed; each space already has a sensible default, see + /// [`ColorSpec::reference_white_nits`]. pub fn with_reference_white_nits(mut self, nits: f32) -> Self { self.reference_white_nits = Some(nits); self } - /// The reference white this conversion will actually use. + /// The value the shader gets: the override, or the source's default. fn effective_reference_white_nits(&self) -> f32 { self.reference_white_nits .or_else(|| self.source.reference_white_nits()) .unwrap_or(0.0) } - /// The description of what this conversion actually produces, for + /// What to tell the encoder about the colours this conversion produces, + /// for /// [`EncodeConfig::with_color_description`](crate::EncodeConfig::with_color_description). /// - /// Derived from [`target`](Self::target) and [`range`](Self::range), so the - /// encoder's VUI cannot end up describing a different signal from the one - /// the shader wrote. + /// Taken from [`target`](Self::target) and [`range`](Self::range), so the + /// stream cannot end up describing something the shader did not write. /// - /// `None` when the target is not a space a stream can be declared to be in, - /// which [`ColorConverter::new`] refuses to build anyway. Given a converter - /// that exists, [`ColorConverter::color_description`] answers the same - /// question without the `Option`. + /// `None` when the target is one [`ColorConverter::new`] would reject + /// anyway. [`ColorConverter::color_description`] gives the same answer + /// without the `Option`. /// /// ```no_run /// use pixelforge::{ @@ -356,13 +322,11 @@ impl ColorConverterConfig { /// implemented, and is rejected rather than silently passed through. fn conversion_supported(&self) -> bool { match self.target { - // Every source reaches PQ: decode what needs decoding, hop the - // gamut if it is not already BT.2020, encode PQ. + // Anything can be converted to HDR. ColorSpec::Bt2020Pq => true, - // Only something already SDR-encoded reaches an SDR target. - // Anything else would need a forward gamma encode or tone mapping. + // Converting to SDR would need tone mapping or a gamma curve. ColorSpec::Srgb => matches!(self.source, ColorSpec::Srgb), - // Not encodable at all; see `ColorSpec::is_encodable`. + // Cannot be encoded at all, see `ColorSpec::is_encodable`. ColorSpec::Bt709Linear | ColorSpec::Bt2020Linear => false, } } @@ -370,10 +334,10 @@ impl ColorConverterConfig { /// Error explaining why [`Self::conversion_supported`] said no. fn unsupported_conversion(&self) -> PixelForgeError { let reason = if !self.target.is_encodable() { - "no decoder can be told a stream is in linear light, so that space is source-only" + "video cannot be encoded in a linear space, so it can only be a source" } else { - "reaching an SDR target from a linear or PQ source would need a forward gamma \ - encode or tone mapping, and the conversion shader does neither" + "converting to SDR would need tone mapping or a gamma curve applied, \ + which the shader does not do" }; PixelForgeError::InvalidInput(format!( "no conversion from {:?} to {:?}: {reason}", @@ -451,12 +415,11 @@ impl ColorConverter { &self.context } - /// What this converter's output should be declared as, for + /// What to tell the encoder about the colours this converter produces, for /// [`EncodeConfig::with_color_description`](crate::EncodeConfig::with_color_description). /// - /// The same as [`ColorConverterConfig::color_description`], minus the - /// `Option`: a converter only exists if its target is encodable, so the - /// answer is always there. + /// [`ColorConverterConfig::color_description`] without the `Option`: this + /// converter exists, so its target was already checked. pub fn color_description(&self) -> ColorDescription { self.config .color_description() @@ -1286,8 +1249,7 @@ mod tests { assert!(ColorSpec::Srgb.is_encodable()); assert!(ColorSpec::Bt2020Pq.is_encodable()); - // There are no VUI code points for linear light, so these are - // source-only and say so. + // Linear light cannot be encoded, and these say so. for spec in [ColorSpec::Bt709Linear, ColorSpec::Bt2020Linear] { assert!(!spec.is_encodable(), "{spec:?}"); assert_eq!(spec.color_description(), None, "{spec:?}"); @@ -1296,8 +1258,8 @@ mod tests { #[test] fn scrgb_white_is_not_the_srgb_reference() { - // IEC 61966-2-2 puts scRGB 1.0 at 80 cd/m²; BT.2408 puts SDR white at - // 203. Taking the 203 for scRGB is the mistake this pairing prevents. + // scRGB's white is 80 nits, not the 203 the other spaces use. Using + // 203 for it makes HDR output far too bright. assert_eq!(ColorSpec::Bt709Linear.reference_white_nits(), Some(80.0)); assert_eq!(ColorSpec::Srgb.reference_white_nits(), Some(203.0)); assert_eq!(ColorSpec::Bt2020Linear.reference_white_nits(), Some(203.0)); @@ -1305,7 +1267,7 @@ mod tests { #[test] fn pq_has_no_reference_white() { - // PQ is absolute, so there is no such quantity to ask about. + // Already carries real brightness values, so there is nothing to ask. assert_eq!(ColorSpec::Bt2020Pq.reference_white_nits(), None); } diff --git a/src/lib.rs b/src/lib.rs index 12aa378..c6598ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -181,41 +181,33 @@ //! //! PixelForge includes a GPU compute shader for converting RGB input to YUV //! output. The source color describes the input. The target color describes the stream. -//! Both are a [`ColorSpec`]; which end one describes comes from the slot it is -//! in, not from the value. -//! -//! | `ColorSpec` | Space | Usable as a target | -//! |-------------|-------|--------------------| -//! | `Srgb` | BT.709 primaries, sRGB transfer. Ordinary SDR content. | yes, declared as BT.709 | -//! | `Bt709Linear` | Linear BT.709. This is scRGB, from an `EXTENDED_SRGB_LINEAR_EXT` swapchain. | no | -//! | `Bt2020Linear` | Linear BT.2020. | no | -//! | `Bt2020Pq` | BT.2020 primaries, PQ transfer. HDR10. | yes | -//! -//! The two linear specs are source-only because H.273 has no code points for -//! linear light, so a decoder cannot be told a stream is in one. -//! [`ColorSpec::is_encodable`] is the question, and [`ColorConverter::new`] -//! refuses the rest. -//! -//! Each space knows the luminance a sample value of 1.0 represents, see -//! [`ColorSpec::reference_white_nits`]: 203 nits for the SDR-referred spaces -//! per ITU-R BT.2408, and 80 for scRGB per IEC 61966-2-2. It is read from the -//! source only, and only on the way to `Bt2020Pq`, where the PQ encode needs an -//! absolute reference. A conversion that needs a different figure overrides it -//! with [`ColorConverterConfig::with_reference_white_nits`]. -//! -//! Every source reaches `Bt2020Pq`. Only `Srgb` reaches an SDR target, because -//! the others would need a forward gamma encode or tone mapping; those pairings -//! are rejected rather than quietly passed through. +//! Both are a [`ColorSpec`]. +//! +//! | `ColorSpec` | | Can be a target | +//! |-------------|-|-----------------| +//! | `Srgb` | Ordinary SDR content | yes | +//! | `Bt709Linear` | scRGB, from an `EXTENDED_SRGB_LINEAR_EXT` swapchain | no | +//! | `Bt2020Linear` | Linear light, wide gamut | no | +//! | `Bt2020Pq` | HDR10, from an `HDR10_ST2084_EXT` swapchain | yes | +//! +//! The linear spaces cannot be a target, because a video file has no way to +//! record that it holds linear light. Any source can be converted to +//! `Bt2020Pq`. Only `Srgb` can be converted to `Srgb`; the others would need +//! tone mapping or a gamma curve applied, which the shader does not do. +//! [`ColorConverter::new`] rejects the combinations it cannot do. +//! +//! Encoding to HDR needs to know how bright the source's white is, since HDR +//! carries real brightness values and SDR does not. Each space has a sensible +//! default, see [`ColorSpec::reference_white_nits`], overridable with +//! [`ColorConverterConfig::with_reference_white_nits`]. //! //! Supported input formats: BGRx, RGBx, BGRA, RGBA, ABGR2101010 (10-bit packed), RGBA16F (FP16). //! Supported output formats: NV12 (8-bit), I420 (8-bit), YUV444 (8-bit), P010 (10-bit), YUV444P10 (10-bit). //! -//! Because the target and the range fully determine the stream's colour -//! signalling, [`ColorConverter::color_description`] derives the encoder's VUI -//! declaration from the conversion itself. Use it rather than declaring the -//! same thing twice: full-range samples tagged as limited are expanded a second -//! time on playback, and the API cannot catch that if the two are set -//! independently. +//! Pass [`ColorConverter::color_description`] to the encoder rather than +//! describing the colours a second time by hand. The two have to agree: if the +//! converter writes full-range pixels and the stream says limited, players +//! stretch the range again and the picture comes out wrong. //! //! ```rust,no_run //! use pixelforge::{ diff --git a/tests/color_conversion.rs b/tests/color_conversion.rs index 9da00ca..362a117 100644 --- a/tests/color_conversion.rs +++ b/tests/color_conversion.rs @@ -1,26 +1,22 @@ -//! What the colour converter writes, checked against a model of itself. +//! What the colour converter writes, checked against the same maths on the CPU. //! -//! Two things are hard to see by looking at a picture and easy to see by -//! computing the same thing twice. Both are checked here, off one synthetic -//! frame and one readback path. +//! Colour bugs here do not look like failures. They look like a normal picture +//! that is slightly too dark, or the wrong shade. So rather than eyeball it, +//! these tests compute what each conversion should produce and compare. //! -//! **Quantization.** The shader turns floating-point YUV into integer code -//! values. Truncating instead of rounding is not a visible failure, it is a -//! uniform half-code darkening, so each sample is scored against two -//! hypotheses: that the shader rounds, and that it truncates. A correct shader -//! matches the rounding prediction and shows a mean signed error near zero. +//! **Quantization.** The shader turns floating-point YUV into integer values. +//! If it truncates instead of rounding, every pixel comes out up to one step +//! too dark. Each sample is scored against both possibilities. //! -//! **Conversion paths.** A [`ColorSpec`] pair selects which shader stages run: -//! an sRGB decode, a BT.709 to BT.2020 gamut hop, a PQ encode, or none of them -//! for a passthrough. Getting one wrong produces a plausible picture in the -//! wrong colour, so the pipeline is reimplemented on the CPU stage for stage, -//! with the stages selected from the source and target rather than from the -//! shader's branches, and compared sample by sample. +//! **Conversion paths.** Which [`ColorSpec`] pair you pick decides which steps +//! the shader runs: an sRGB decode, a gamut conversion, a PQ encode, or none of +//! them. The same steps are redone on the CPU, picked from the source and +//! target rather than from the shader's branches, so a wrong branch shows up as +//! a large disagreement. //! -//! Luma only. It is one sample per pixel, whereas NV12 and P010 chroma is a 2x2 -//! average, which would fold averaging error into the measurement. The frame is -//! deliberately not grey, because grey converts to exact code values under -//! BT.709 and cannot tell any of these hypotheses apart. +//! Luma only, since chroma is averaged over 2x2 blocks and that averaging would +//! muddy the comparison. The test frame is deliberately not grey: grey converts +//! to exact values and would hide the very errors being looked for. //! //! Ignored by default: requires a Vulkan Video device. Run with //! `cargo test -- --ignored`. @@ -582,13 +578,11 @@ fn unsupported_conversions_are_refused() -> Result<(), Box Date: Fri, 18 Sep 2026 19:26:32 +0300 Subject: [PATCH 8/8] docs(converter): say why the linear sRGB variant is called Bt709Linear `Srgb` and `Bt709Linear` are the same colours, differing only in the curve, but nothing said so. The `Srgb` variant never mentioned BT.709 at all, so the two read as unrelated families and the name looked like a mistake. Also notes that scRGB values above 1.0 are allowed and mean brighter than SDR white, since that is the whole reason the space is useful here and is not guessable from "linear light". Co-Authored-By: Claude Opus 5 --- src/converter/mod.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/converter/mod.rs b/src/converter/mod.rs index babebf6..dbabfea 100644 --- a/src/converter/mod.rs +++ b/src/converter/mod.rs @@ -25,11 +25,18 @@ use tracing::debug; #[repr(u32)] pub enum ColorSpec { /// Ordinary SDR content, the usual desktop and game output. + /// + /// sRGB and BT.709 cover the same colours and differ only in the curve + /// applied to them, which is why the linear one below is named for BT.709 + /// rather than for sRGB. Srgb = 0, - /// scRGB: like `Srgb` but in linear light. What a - /// `VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT` swapchain gives you. + /// scRGB: the same colours as `Srgb`, in linear light. + /// + /// Values above 1.0 are allowed and mean brighter than SDR white, which is + /// how this carries HDR. What a `VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT` + /// swapchain gives you. Bt709Linear = 1, - /// Linear light in the wide HDR gamut. + /// Like `Bt709Linear`, but with the wider HDR colours. Bt2020Linear = 2, /// HDR10: wide gamut, PQ encoded. What a /// `VK_COLOR_SPACE_HDR10_ST2084_EXT` swapchain gives you.