edgefirst_codec/pixel.rs
1// SPDX-FileCopyrightText: Copyright 2026 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pixel type trait for image decoding.
5//!
6//! Types implementing [`ImagePixel`] can be used as the element type of a
7//! [`Tensor`](edgefirst_tensor::Tensor) passed to
8//! [`ImageLoad::load_image`](crate::ImageLoad::load_image).
9
10use edgefirst_tensor::DType;
11
12/// Marker trait for pixel element types supported by image decoding.
13///
14/// Provides conversions from decoded `u8` and `u16` pixel values into the
15/// tensor's native element type. The `from_u16` path is used for 16-bit PNG
16/// images; JPEG always decodes to `u8`.
17///
18/// ## Supported types and conversions
19///
20/// | Type | `from_u8` | `from_u16` | Notes |
21/// |------|-----------|------------|-------|
22/// | `u8` | identity | `>> 8` | |
23/// | `u16` | `* 257` | identity | 0..255 → 0..65535 |
24/// | `i8` | `XOR 0x80` | `(>> 8) XOR 0x80` | unsigned-to-signed via sign-bit flip |
25/// | `i16` | `* 257 XOR 0x8000` | `XOR 0x8000` | unsigned-to-signed via sign-bit flip |
26/// | `f32` | `/ 255.0` | `/ 65535.0` | normalised to `[0.0, 1.0]` |
27///
28/// `edgefirst_tensor::Element` is a supertrait: under the `dynamic` tensor
29/// backend, `Tensor<T>`'s own constructors need `T::DTYPE` at compile time
30/// (there is no per-variant enum to allocate through instead), so any `T`
31/// this crate builds a `Tensor<T>` over must already be one of the eleven
32/// element types `Element` is sealed to. Every `ImagePixel` impl below is
33/// one of those types, so this costs nothing real -- it just makes the
34/// existing constraint visible at the trait level instead of failing much
35/// later at a `Tensor<T>` call site.
36pub trait ImagePixel: edgefirst_tensor::Element {
37 /// Convert a `[0, 255]` byte value to this pixel type.
38 fn from_u8(v: u8) -> Self;
39
40 /// Convert a `[0, 65535]` 16-bit value to this pixel type.
41 ///
42 /// Used for 16-bit PNG images. The default implementation converts via
43 /// `from_u8(v >> 8)` which discards the low byte.
44 fn from_u16(v: u16) -> Self {
45 Self::from_u8((v >> 8) as u8)
46 }
47
48 /// The [`DType`] that corresponds to this Rust type.
49 fn dtype() -> DType;
50}
51
52impl ImagePixel for u8 {
53 #[inline]
54 fn from_u8(v: u8) -> Self {
55 v
56 }
57
58 #[inline]
59 fn from_u16(v: u16) -> Self {
60 (v >> 8) as u8
61 }
62
63 fn dtype() -> DType {
64 DType::U8
65 }
66}
67
68impl ImagePixel for u16 {
69 #[inline]
70 fn from_u8(v: u8) -> Self {
71 // Scale 0..255 → 0..65535 exactly: 0→0, 128→32896, 255→65535
72 v as u16 * 257
73 }
74
75 #[inline]
76 fn from_u16(v: u16) -> Self {
77 v
78 }
79
80 fn dtype() -> DType {
81 DType::U16
82 }
83}
84
85impl ImagePixel for i8 {
86 #[inline]
87 fn from_u8(v: u8) -> Self {
88 // XOR sign-bit flip: 0→-128, 128→0, 255→127
89 (v ^ 0x80) as i8
90 }
91
92 #[inline]
93 fn from_u16(v: u16) -> Self {
94 Self::from_u8((v >> 8) as u8)
95 }
96
97 fn dtype() -> DType {
98 DType::I8
99 }
100}
101
102impl ImagePixel for i16 {
103 #[inline]
104 fn from_u8(v: u8) -> Self {
105 // Scale to u16 range first, then XOR sign-bit flip
106 ((v as u16 * 257) ^ 0x8000) as i16
107 }
108
109 #[inline]
110 fn from_u16(v: u16) -> Self {
111 // XOR sign-bit flip: 0→-32768, 32768→0, 65535→32767
112 (v ^ 0x8000) as i16
113 }
114
115 fn dtype() -> DType {
116 DType::I16
117 }
118}
119
120impl ImagePixel for f32 {
121 #[inline]
122 fn from_u8(v: u8) -> Self {
123 v as f32 / 255.0
124 }
125
126 #[inline]
127 fn from_u16(v: u16) -> Self {
128 v as f32 / 65535.0
129 }
130
131 fn dtype() -> DType {
132 DType::F32
133 }
134}
135
136/// Map a pooled decode destination for CPU fill.
137///
138/// [`crate::ImageLoad::load_image`] first `configure_image`s the tensor to
139/// the current frame, so the mapped window is often shorter than the backing
140/// (a max-size pool). `map_write()` is tried first: it is the cheap,
141/// correct call everywhere except one case — a `CpuAccess::ReadWrite`
142/// -declared D3D11 texture tensor (`edgefirst-profiler`'s own decode pool is
143/// declared this way; see `orchestrator/buffers.rs::create_decode_source_pool`)
144/// has a staging texture, and unmapping a write-only *partial* window through
145/// it would publish the whole staging copy — including rows the caller never
146/// touched — back onto the real texture, so the tensor crate refuses it.
147///
148/// The tensor crate signals exactly that refusal, and no other, with
149/// [`Error::PartialWriteRequiresReadWrite`] — retrying only on that variant
150/// (not on any `map_write()` failure) means every other failure is reported
151/// as itself: an access declaration too narrow to write at all, a tensor
152/// already mapped elsewhere, or a D3D11 device/map error the retry could not
153/// somehow fix, all surface as `map_mut()`'s own error rather than the
154/// unrelated (and by then stale) "try `CpuAccess::ReadWrite`" advice.
155///
156/// Falling back only on that refusal, rather than requesting `ReadWrite`
157/// unconditionally, is also what keeps every other platform's cheap path
158/// cheap: a `CpuAccess::Write`-declared D3D11 tensor (no staging) and every
159/// Linux dma-buf decode succeed on the first try, so they never pay for the
160/// `ReadWrite` map's extra cache-invalidate work that the destination's own
161/// decoder-only write never needed.
162pub(crate) fn map_decode_dst<'a, T: ImagePixel>(
163 dst: &edgefirst_tensor::Tensor<T>,
164) -> Result<edgefirst_tensor::view::HostView<'a, T>, edgefirst_tensor::Error> {
165 use edgefirst_tensor::{Error, TensorTrait};
166 match dst.map_write() {
167 Err(Error::PartialWriteRequiresReadWrite(_)) => dst.map_mut(),
168 result => result,
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn u8_identity() {
178 assert_eq!(u8::from_u8(0), 0);
179 assert_eq!(u8::from_u8(128), 128);
180 assert_eq!(u8::from_u8(255), 255);
181 }
182
183 #[test]
184 fn u8_from_u16() {
185 assert_eq!(u8::from_u16(0), 0);
186 assert_eq!(u8::from_u16(32768), 128);
187 assert_eq!(u8::from_u16(65535), 255);
188 }
189
190 #[test]
191 fn u16_from_u8_scaling() {
192 assert_eq!(u16::from_u8(0), 0);
193 assert_eq!(u16::from_u8(1), 257);
194 assert_eq!(u16::from_u8(128), 32896);
195 assert_eq!(u16::from_u8(255), 65535);
196 }
197
198 #[test]
199 fn u16_identity() {
200 assert_eq!(u16::from_u16(0), 0);
201 assert_eq!(u16::from_u16(32768), 32768);
202 assert_eq!(u16::from_u16(65535), 65535);
203 }
204
205 #[test]
206 fn i8_xor_trick() {
207 // XOR with 0x80 flips the sign bit: u8 midpoint (128) maps to i8 zero
208 assert_eq!(i8::from_u8(0), -128);
209 assert_eq!(i8::from_u8(128), 0);
210 assert_eq!(i8::from_u8(255), 127);
211 assert_eq!(i8::from_u8(1), -127);
212 assert_eq!(i8::from_u8(127), -1);
213 }
214
215 #[test]
216 fn i16_xor_trick() {
217 // From u8: scale to u16 first, then XOR
218 assert_eq!(i16::from_u8(0), -32768);
219 assert_eq!(i16::from_u8(128), 128); // 32896 ^ 0x8000 = 128
220 assert_eq!(i16::from_u8(255), 32767);
221
222 // From u16: direct XOR
223 assert_eq!(i16::from_u16(0), -32768);
224 assert_eq!(i16::from_u16(32768), 0);
225 assert_eq!(i16::from_u16(65535), 32767);
226 }
227
228 #[test]
229 fn f32_normalised() {
230 assert!((f32::from_u8(0) - 0.0).abs() < f32::EPSILON);
231 assert!((f32::from_u8(255) - 1.0).abs() < f32::EPSILON);
232 assert!((f32::from_u8(128) - 128.0 / 255.0).abs() < f32::EPSILON);
233 }
234
235 #[test]
236 fn f32_from_u16_normalised() {
237 assert!((f32::from_u16(0) - 0.0).abs() < f32::EPSILON);
238 assert!((f32::from_u16(65535) - 1.0).abs() < 1e-5);
239 assert!((f32::from_u16(32768) - 32768.0 / 65535.0).abs() < 1e-5);
240 }
241
242 #[test]
243 fn dtype_matches() {
244 assert_eq!(u8::dtype(), DType::U8);
245 assert_eq!(u16::dtype(), DType::U16);
246 assert_eq!(i8::dtype(), DType::I8);
247 assert_eq!(i16::dtype(), DType::I16);
248 assert_eq!(f32::dtype(), DType::F32);
249 }
250}