Skip to main content

cu_sensor_payloads/
image.rs

1use bincode::de::Decoder;
2use bincode::error::DecodeError;
3use bincode::{Decode, Encode};
4use core::fmt::Debug;
5use core::ops::Range;
6use cu29::prelude::*;
7
8#[cfg(feature = "image")]
9use image::{ImageBuffer, Pixel};
10#[cfg(feature = "kornia")]
11use kornia_image::Image;
12#[cfg(feature = "kornia")]
13use kornia_tensor::MemoryDomain;
14use serde::{Deserialize, Serialize, Serializer};
15
16#[derive(Default, Debug, Encode, Decode, Clone, Copy, Serialize, Deserialize, Reflect)]
17#[bincode(describe)]
18pub struct CuImageBufferFormat {
19    pub width: u32,
20    pub height: u32,
21    pub stride: u32,
22    pub pixel_format: [u8; 4],
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct CuImagePlaneLayout {
27    pub offset_bytes: usize,
28    pub row_bytes: u32,
29    pub stride_bytes: u32,
30    pub height: u32,
31}
32
33impl CuImagePlaneLayout {
34    pub fn byte_len(&self) -> usize {
35        self.stride_bytes as usize * self.height as usize
36    }
37
38    pub fn byte_range(&self) -> Range<usize> {
39        self.offset_bytes..self.offset_bytes + self.byte_len()
40    }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44enum CuImageMemoryLayout {
45    Packed { bytes_per_pixel: u32 },
46    SemiPlanar420,
47    Planar420,
48    SinglePlane,
49}
50
51impl CuImageBufferFormat {
52    fn memory_layout(&self) -> CuImageMemoryLayout {
53        match &self.pixel_format {
54            b"GRAY" | b"Y800" => CuImageMemoryLayout::Packed { bytes_per_pixel: 1 },
55            b"YUYV" | b"UYVY" => CuImageMemoryLayout::Packed { bytes_per_pixel: 2 },
56            b"RGB3" | b"BGR3" | b"RGB " | b"BGR " => {
57                CuImageMemoryLayout::Packed { bytes_per_pixel: 3 }
58            }
59            b"RGBA" | b"BGRA" => CuImageMemoryLayout::Packed { bytes_per_pixel: 4 },
60            b"NV12" | b"NV21" => CuImageMemoryLayout::SemiPlanar420,
61            b"I420" | b"YV12" => CuImageMemoryLayout::Planar420,
62            _ => CuImageMemoryLayout::SinglePlane,
63        }
64    }
65
66    pub fn plane_count(&self) -> usize {
67        match self.memory_layout() {
68            CuImageMemoryLayout::Planar420 => 3,
69            CuImageMemoryLayout::SemiPlanar420 => 2,
70            CuImageMemoryLayout::Packed { .. } | CuImageMemoryLayout::SinglePlane => 1,
71        }
72    }
73
74    pub fn is_packed(&self) -> bool {
75        matches!(self.memory_layout(), CuImageMemoryLayout::Packed { .. })
76    }
77
78    pub fn packed_row_bytes(&self) -> Option<u32> {
79        match self.memory_layout() {
80            CuImageMemoryLayout::Packed { bytes_per_pixel } => Some(self.width * bytes_per_pixel),
81            CuImageMemoryLayout::SemiPlanar420
82            | CuImageMemoryLayout::Planar420
83            | CuImageMemoryLayout::SinglePlane => None,
84        }
85    }
86
87    pub fn plane(&self, index: usize) -> Option<CuImagePlaneLayout> {
88        let y_plane_bytes = self.stride as usize * self.height as usize;
89        let chroma_height = self.height.div_ceil(2);
90
91        match self.memory_layout() {
92            CuImageMemoryLayout::Packed { bytes_per_pixel } if index == 0 => {
93                Some(CuImagePlaneLayout {
94                    offset_bytes: 0,
95                    row_bytes: self.width * bytes_per_pixel,
96                    stride_bytes: self.stride,
97                    height: self.height,
98                })
99            }
100            CuImageMemoryLayout::SinglePlane if index == 0 => Some(CuImagePlaneLayout {
101                offset_bytes: 0,
102                row_bytes: self.stride,
103                stride_bytes: self.stride,
104                height: self.height,
105            }),
106            CuImageMemoryLayout::SemiPlanar420 if index == 0 => Some(CuImagePlaneLayout {
107                offset_bytes: 0,
108                row_bytes: self.width,
109                stride_bytes: self.stride,
110                height: self.height,
111            }),
112            CuImageMemoryLayout::SemiPlanar420 if index == 1 => Some(CuImagePlaneLayout {
113                offset_bytes: y_plane_bytes,
114                row_bytes: self.width.div_ceil(2) * 2,
115                stride_bytes: self.stride,
116                height: chroma_height,
117            }),
118            CuImageMemoryLayout::Planar420 if index == 0 => Some(CuImagePlaneLayout {
119                offset_bytes: 0,
120                row_bytes: self.width,
121                stride_bytes: self.stride,
122                height: self.height,
123            }),
124            CuImageMemoryLayout::Planar420 if index == 1 => Some({
125                let chroma_stride = self.stride.div_ceil(2);
126                CuImagePlaneLayout {
127                    offset_bytes: y_plane_bytes,
128                    row_bytes: self.width.div_ceil(2),
129                    stride_bytes: chroma_stride,
130                    height: chroma_height,
131                }
132            }),
133            CuImageMemoryLayout::Planar420 if index == 2 => Some({
134                let chroma_stride = self.stride.div_ceil(2);
135                let chroma_plane_bytes = chroma_stride as usize * chroma_height as usize;
136                CuImagePlaneLayout {
137                    offset_bytes: y_plane_bytes + chroma_plane_bytes,
138                    row_bytes: self.width.div_ceil(2),
139                    stride_bytes: chroma_stride,
140                    height: chroma_height,
141                }
142            }),
143            _ => None,
144        }
145    }
146
147    pub fn is_valid(&self) -> bool {
148        (0..self.plane_count()).all(|index| {
149            self.plane(index)
150                .map(|plane| plane.row_bytes <= plane.stride_bytes)
151                .unwrap_or(false)
152        })
153    }
154
155    pub fn required_bytes(&self) -> usize {
156        self.plane(self.plane_count().saturating_sub(1))
157            .map(|plane| plane.offset_bytes + plane.byte_len())
158            .unwrap_or(0)
159    }
160
161    pub fn byte_size(&self) -> usize {
162        self.required_bytes()
163    }
164}
165
166#[derive(Debug, Encode, Reflect)]
167#[reflect(from_reflect = false, no_field_bounds, type_path = false)]
168#[bincode(describe)]
169pub struct CuImage<A>
170where
171    A: ArrayLike<Element = u8> + Send + Sync + 'static,
172{
173    pub seq: u64,
174    pub format: CuImageBufferFormat,
175    pub buffer_handle: CuHandle<A>,
176}
177
178// Cloning an image clones its handle, never the storage behind it. Shared-memory
179// buffers deliberately do not implement Clone or Default.
180impl<A> Clone for CuImage<A>
181where
182    A: ArrayLike<Element = u8> + Send + Sync + 'static,
183{
184    fn clone(&self) -> Self {
185        Self {
186            seq: self.seq,
187            format: self.format,
188            buffer_handle: self.buffer_handle.clone(),
189        }
190    }
191}
192
193impl<A> Default for CuImage<A>
194where
195    A: ArrayLike<Element = u8> + Send + Sync + 'static,
196{
197    fn default() -> Self {
198        Self {
199            seq: 0,
200            format: CuImageBufferFormat::default(),
201            buffer_handle: CuHandle::default(),
202        }
203    }
204}
205
206impl<A> TypePath for CuImage<A>
207where
208    A: ArrayLike<Element = u8> + Send + Sync + 'static,
209{
210    fn type_path() -> &'static str {
211        "cu_sensor_payloads::CuImage"
212    }
213
214    fn short_type_path() -> &'static str {
215        "CuImage"
216    }
217
218    fn type_ident() -> Option<&'static str> {
219        Some("CuImage")
220    }
221
222    fn crate_name() -> Option<&'static str> {
223        Some("cu_sensor_payloads")
224    }
225
226    fn module_path() -> Option<&'static str> {
227        Some("cu_sensor_payloads")
228    }
229}
230
231impl<A> Decode<()> for CuImage<A>
232where
233    A: ArrayLike<Element = u8> + Send + Sync + 'static,
234    CuHandle<A>: Decode<()>,
235{
236    fn decode<D: Decoder<Context = ()>>(decoder: &mut D) -> Result<Self, DecodeError> {
237        let seq: u64 = Decode::decode(decoder)?;
238        let format: CuImageBufferFormat = Decode::decode(decoder)?;
239        let buffer_handle: CuHandle<A> = Decode::decode(decoder)?;
240
241        Ok(Self {
242            seq,
243            format,
244            buffer_handle,
245        })
246    }
247}
248
249impl<'de, A> Deserialize<'de> for CuImage<A>
250where
251    A: ArrayLike<Element = u8> + Send + Sync + 'static,
252    CuHandle<A>: Deserialize<'de>,
253{
254    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
255    where
256        D: serde::Deserializer<'de>,
257    {
258        #[derive(Deserialize)]
259        struct CuImageWire<H> {
260            seq: u64,
261            format: CuImageBufferFormat,
262            handle: H,
263        }
264
265        let wire = CuImageWire::<CuHandle<A>>::deserialize(deserializer)?;
266        Ok(Self {
267            seq: wire.seq,
268            format: wire.format,
269            buffer_handle: wire.handle,
270        })
271    }
272}
273
274impl<A> Serialize for CuImage<A>
275where
276    A: ArrayLike<Element = u8> + Send + Sync + 'static,
277    CuHandle<A>: Serialize,
278{
279    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
280    where
281        S: Serializer,
282    {
283        use serde::ser::SerializeStruct;
284        let mut struct_ = serializer.serialize_struct("CuImage", 3)?;
285        struct_.serialize_field("seq", &self.seq)?;
286        struct_.serialize_field("format", &self.format)?;
287        struct_.serialize_field("handle", &self.buffer_handle)?;
288        struct_.end()
289    }
290}
291
292impl<A> CuImage<A>
293where
294    A: ArrayLike<Element = u8> + Send + Sync + 'static,
295{
296    pub fn new(format: CuImageBufferFormat, buffer_handle: CuHandle<A>) -> Self {
297        assert!(
298            format.is_valid(),
299            "Image format layout is invalid for the declared stride."
300        );
301        assert!(
302            format.required_bytes() <= buffer_handle.with_inner(|i| i.len()),
303            "Buffer size must at least match the format."
304        );
305        CuImage {
306            seq: 0,
307            format,
308            buffer_handle,
309        }
310    }
311}
312
313// Codegen gate; the inherent forwards below are what actually propagate the policy.
314impl<A> cu29::pool::HandleContentAware for CuImage<A> where
315    A: ArrayLike<Element = u8> + Send + Sync + 'static
316{
317}
318
319impl<A> CuImage<A>
320where
321    A: ArrayLike<Element = u8> + Send + Sync + 'static,
322{
323    /// Forward of [`CuHandle::payload_should_log`]. The unified-log encoder resolves
324    /// to this inherent method (via autoref-specialization) when the payload type is
325    /// `CuImage<A>`, so the configured `HandleContent` policy on the inner handle
326    /// drives whether the image bytes are written to the log.
327    pub fn payload_should_log(&self) -> bool {
328        self.buffer_handle.payload_should_log()
329    }
330
331    /// Forward of [`CuHandle::apply_handle_content_policy`]. The runtime calls this
332    /// (via autoref-specialization) on every source-produced `CuImage` payload to
333    /// stamp it with the source's configured `NodeLogging.handle_content` mode
334    /// before downstream consumers see it.
335    pub fn apply_handle_content_policy(&self, mode: cu29::pool::HandleContent) {
336        self.buffer_handle.apply_handle_content_policy(mode);
337    }
338
339    /// Consumer-side convenience: mark the underlying buffer as read. The unified-log
340    /// encoder records the full payload for this frame (when the source uses
341    /// `HandleContent::TouchedOnly`); without this call the payload is skipped.
342    pub fn mark_touched(&self) {
343        self.buffer_handle.mark_touched();
344    }
345
346    pub fn with_plane_bytes<R>(
347        &self,
348        plane_index: usize,
349        f: impl FnOnce(&[u8], CuImagePlaneLayout) -> R,
350    ) -> CuResult<R> {
351        let plane = self
352            .format
353            .plane(plane_index)
354            .ok_or_else(|| CuError::from(format!("Invalid image plane index {plane_index}")))?;
355        Ok(self.buffer_handle.with_inner(|inner| {
356            let range = plane.byte_range();
357            f(&inner[range], plane)
358        }))
359    }
360
361    pub fn with_plane_bytes_mut<R>(
362        &mut self,
363        plane_index: usize,
364        f: impl FnOnce(&mut [u8], CuImagePlaneLayout) -> R,
365    ) -> CuResult<R> {
366        let plane = self
367            .format
368            .plane(plane_index)
369            .ok_or_else(|| CuError::from(format!("Invalid image plane index {plane_index}")))?;
370        Ok(self.buffer_handle.with_inner_mut(|inner| {
371            let range = plane.byte_range();
372            f(&mut inner[range], plane)
373        }))
374    }
375
376    /// Builds an ImageBuffer from the image crate backed by the CuImage's pixel data.
377    #[cfg(feature = "image")]
378    pub fn as_image_buffer<P: Pixel>(&self) -> CuResult<ImageBuffer<P, &[P::Subpixel]>> {
379        let width = self.format.width;
380        let height = self.format.height;
381        let plane = self
382            .format
383            .plane(0)
384            .ok_or_else(|| CuError::from("Image format has no addressable planes"))?;
385        if self.format.plane_count() != 1 {
386            return Err(CuError::from(
387                "ImageBuffer compatibility requires a single-plane packed image.",
388            ));
389        }
390        if plane.row_bytes != plane.stride_bytes {
391            return Err(CuError::from(
392                "ImageBuffer compatibility requires tightly packed rows without padding.",
393            ));
394        }
395
396        self.with_plane_bytes(0, |data, _| {
397            let raw_pixels: &[P::Subpixel] = unsafe {
398                core::slice::from_raw_parts(
399                    data.as_ptr() as *const P::Subpixel,
400                    data.len() / core::mem::size_of::<P::Subpixel>(),
401                )
402            };
403            ImageBuffer::from_raw(width, height, raw_pixels)
404                .ok_or("Could not create the image:: buffer".into())
405        })?
406    }
407
408    /// Wraps plane 0 as a read-only kornia [`Image`] without copying.
409    ///
410    /// The image holds a clone of this `CuImage`'s handle, so the buffer stays alive (and a
411    /// pooled buffer is not recycled) for as long as the image does. Writing to the buffer
412    /// through another handle clone while the image is alive is a data race; don't.
413    #[cfg(feature = "kornia")]
414    pub fn as_kornia_image<T: Clone, const C: usize>(&self) -> CuResult<Image<T, C>> {
415        let width = self.format.width as usize;
416        let height = self.format.height as usize;
417        let plane = self
418            .format
419            .plane(0)
420            .ok_or_else(|| CuError::from("Image format has no addressable planes"))?;
421        if self.format.plane_count() != 1 {
422            return Err(CuError::from(
423                "Kornia compatibility requires a single-plane packed image.",
424            ));
425        }
426        if plane.row_bytes != plane.stride_bytes {
427            return Err(CuError::from(
428                "Kornia compatibility requires tightly packed rows without padding.",
429            ));
430        }
431
432        let len_bytes = width * height * C * core::mem::size_of::<T>();
433        let data = self.with_plane_bytes(0, |data, _| {
434            if data.len() < len_bytes {
435                return Err(CuError::from(
436                    "Image plane is smaller than the requested Kornia image.",
437                ));
438            }
439            if !(data.as_ptr() as usize).is_multiple_of(core::mem::align_of::<T>()) {
440                return Err(CuError::from(
441                    "Image plane is not aligned for the requested Kornia element type.",
442                ));
443            }
444            Ok(data.as_ptr() as *const T)
445        })??;
446
447        // SAFETY: `data` points at `len_bytes` readable, aligned bytes inside the handle's
448        // buffer, and the keepalive is a clone of that handle, so the buffer outlives the image.
449        unsafe {
450            Image::from_borrowed_readonly(
451                [height, width].into(),
452                data,
453                MemoryDomain::Host,
454                std::sync::Arc::new(self.buffer_handle.clone()),
455            )
456        }
457        .map_err(|e| CuError::new_with_cause("Could not create a Kornia Image", e))
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::{CuImageBufferFormat, CuImagePlaneLayout};
464    use cu29_value::decode::{ValueDecodeDescription, ValueDecodeLimits};
465
466    #[cfg(feature = "std")]
467    #[test]
468    fn test_shared_memory_image_clones_its_handle_and_satisfies_payload_contract() {
469        use super::CuImage;
470        use cu29::pool::CuPool;
471        use cu29::pool::CuSharedMemoryBuffer;
472        use cu29::pool::CuSharedMemoryPool;
473        use cu29::prelude::*;
474
475        fn assert_payload<T: CuMsgPayload>() {}
476        assert_payload::<CuImage<CuSharedMemoryBuffer<u8>>>();
477
478        let pool = CuSharedMemoryPool::new("image-clone-test", 1, 3).unwrap();
479        let handle = pool.acquire().unwrap();
480        handle.with_inner_mut(|bytes| bytes.copy_from_slice(&[12, 34, 56]));
481        let mut image = CuImage::new(
482            CuImageBufferFormat {
483                width: 1,
484                height: 1,
485                stride: 3,
486                pixel_format: *b"RGB3",
487            },
488            handle,
489        );
490        image.seq = 42;
491        let cloned = image.clone();
492        assert_eq!(cloned.seq, 42);
493        assert_eq!(cloned.format.pixel_format, *b"RGB3");
494        assert_eq!(
495            cloned.buffer_handle.storage_id(),
496            image.buffer_handle.storage_id()
497        );
498        drop(image);
499        assert!(pool.acquire().is_none());
500        assert_eq!(
501            cloned
502                .with_plane_bytes(0, |bytes, _| bytes.to_vec())
503                .unwrap(),
504            [12, 34, 56]
505        );
506
507        let config = bincode::config::standard();
508        let encoded = bincode::encode_to_vec(&cloned, config).unwrap();
509        drop(cloned);
510        assert!(pool.acquire().is_some());
511        let (decoded, used): (CuImage<CuSharedMemoryBuffer<u8>>, usize) =
512            bincode::decode_from_slice(&encoded, config).unwrap();
513        assert_eq!(used, encoded.len());
514        assert_eq!(decoded.seq, 42);
515        assert_eq!(
516            decoded
517                .with_plane_bytes(0, |bytes, _| bytes.to_vec())
518                .unwrap(),
519            [12, 34, 56]
520        );
521    }
522
523    #[test]
524    fn test_catalog_decodes_native_image() {
525        use super::CuImage;
526        use cu29::prelude::*;
527
528        let mut sample = CuImage::new(
529            CuImageBufferFormat {
530                width: 1,
531                height: 1,
532                stride: 3,
533                pixel_format: *b"RGB3",
534            },
535            CuHandle::new_detached(vec![1u8, 2, 255]),
536        );
537        sample.seq = 42;
538        let config = bincode::config::standard();
539        let bytes = bincode::encode_to_vec(&sample, config).unwrap();
540        let description = ValueDecodeDescription::from_type::<CuImage<Vec<u8>>>().unwrap();
541        let (decoded, used) = description
542            .decode(&bytes, config, ValueDecodeLimits::default())
543            .unwrap();
544        assert_eq!(used, bytes.len());
545        let Value::Map(fields) = decoded else {
546            panic!("expected named image fields")
547        };
548        assert_eq!(fields[&Value::String("seq".into())], Value::U64(42));
549        assert_eq!(
550            fields[&Value::String("format".into())],
551            to_value(sample.format).unwrap()
552        );
553        assert_eq!(
554            fields[&Value::String("buffer_handle".into())],
555            Value::Seq(vec![Value::U8(1), Value::U8(2), Value::U8(255)])
556        );
557    }
558
559    fn assert_plane(
560        plane: Option<CuImagePlaneLayout>,
561        offset_bytes: usize,
562        row_bytes: u32,
563        stride_bytes: u32,
564        height: u32,
565    ) {
566        assert_eq!(
567            plane,
568            Some(CuImagePlaneLayout {
569                offset_bytes,
570                row_bytes,
571                stride_bytes,
572                height,
573            })
574        );
575    }
576
577    #[test]
578    fn packed_rgb3_layout_uses_single_plane() {
579        let format = CuImageBufferFormat {
580            width: 4,
581            height: 2,
582            stride: 12,
583            pixel_format: *b"RGB3",
584        };
585
586        assert!(format.is_packed());
587        assert_eq!(format.plane_count(), 1);
588        assert_eq!(format.packed_row_bytes(), Some(12));
589        assert_plane(format.plane(0), 0, 12, 12, 2);
590        assert_eq!(format.required_bytes(), 24);
591        assert!(format.is_valid());
592    }
593
594    #[test]
595    fn nv12_layout_exposes_two_planes() {
596        let format = CuImageBufferFormat {
597            width: 640,
598            height: 360,
599            stride: 640,
600            pixel_format: *b"NV12",
601        };
602
603        assert!(!format.is_packed());
604        assert_eq!(format.plane_count(), 2);
605        assert_plane(format.plane(0), 0, 640, 640, 360);
606        assert_plane(format.plane(1), 230_400, 640, 640, 180);
607        assert_eq!(format.required_bytes(), 345_600);
608        assert!(format.is_valid());
609    }
610
611    #[test]
612    fn i420_layout_exposes_three_planes() {
613        let format = CuImageBufferFormat {
614            width: 640,
615            height: 360,
616            stride: 640,
617            pixel_format: *b"I420",
618        };
619
620        assert_eq!(format.plane_count(), 3);
621        assert_plane(format.plane(0), 0, 640, 640, 360);
622        assert_plane(format.plane(1), 230_400, 320, 320, 180);
623        assert_plane(format.plane(2), 288_000, 320, 320, 180);
624        assert_eq!(format.required_bytes(), 345_600);
625        assert!(format.is_valid());
626    }
627
628    #[test]
629    fn invalid_stride_is_detected_for_packed_formats() {
630        let format = CuImageBufferFormat {
631            width: 4,
632            height: 2,
633            stride: 4,
634            pixel_format: *b"RGB3",
635        };
636
637        assert!(!format.is_valid());
638        assert_eq!(format.packed_row_bytes(), Some(12));
639    }
640
641    #[test]
642    fn byte_size_for_packed_formats_is_stride_times_height() {
643        let format = CuImageBufferFormat {
644            width: 4,
645            height: 3,
646            stride: 16,
647            pixel_format: *b"BGRA",
648        };
649
650        assert_eq!(format.byte_size(), 48);
651    }
652
653    #[test]
654    fn byte_size_for_nv12_includes_uv_plane() {
655        let format = CuImageBufferFormat {
656            width: 1280,
657            height: 720,
658            stride: 1280,
659            pixel_format: *b"NV12",
660        };
661
662        assert_eq!(format.byte_size(), 1_382_400);
663    }
664
665    #[test]
666    fn byte_size_for_i420_includes_chroma_planes() {
667        let format = CuImageBufferFormat {
668            width: 640,
669            height: 480,
670            stride: 640,
671            pixel_format: *b"I420",
672        };
673
674        assert_eq!(format.byte_size(), 460_800);
675    }
676
677    // ---- only-log-what-you-use: end-to-end encode behavior on CuImage ----
678    //
679    // These tests stand in for the codegen-emitted per-slot encode block. They run
680    // the same logic the cu29-derive macro emits at each output slot:
681    //
682    //   1. Stamp the source's configured policy on the payload's CuHandle(s) via
683    //      `apply_handle_content_policy(mode)`.
684    //   2. Ask the (now-policy-aware) payload whether to log via `payload_should_log()`.
685    //   3. Either call the normal encoder (full payload) or `encode_metadata_only`
686    //      (presence tag 0u8 + tov + metadata, no payload bytes).
687    //
688    // Both step 1 and step 2 require concrete-type method dispatch (autoref
689    // specialization). The codegen-emitted block has concrete types; the generic
690    // `Encode for CuStampedData` impl does not, which is exactly why the policy
691    // check lives in codegen and not in the generic encoder.
692    //
693    // Running these tests against the real `encode_metadata_only` helper proves the
694    // full chain end-to-end without needing to spin up a `copper_runtime!` graph.
695    #[cfg(feature = "kornia")]
696    mod kornia {
697        use super::super::{CuImage, CuImageBufferFormat};
698        use cu29::pool::CuHandle;
699
700        const RGB_2X2: CuImageBufferFormat = CuImageBufferFormat {
701            width: 2,
702            height: 2,
703            stride: 6,
704            pixel_format: *b"RGB3",
705        };
706
707        fn rgb_image() -> CuImage<Vec<u8>> {
708            CuImage::new(RGB_2X2, CuHandle::new_detached((0u8..12).collect()))
709        }
710
711        #[test]
712        fn as_kornia_image_wraps_the_plane() {
713            // The source is a temporary, dropped before the image is read: the image must keep
714            // the buffer alive on its own.
715            let image = rgb_image()
716                .as_kornia_image::<u8, 3>()
717                .expect("a packed RGB plane converts");
718            assert_eq!((image.width(), image.height()), (2, 2));
719            assert_eq!(image.as_slice(), (0u8..12).collect::<Vec<_>>().as_slice());
720        }
721
722        #[test]
723        fn as_kornia_image_refuses_a_plane_too_small_for_the_request() {
724            // 2x2x4 bytes requested from a 12-byte RGB plane.
725            assert!(rgb_image().as_kornia_image::<u8, 4>().is_err());
726        }
727    }
728
729    mod only_log_what_you_use {
730        use crate::CuImage;
731        use bincode::config;
732        use cu29::config::HandleContent;
733        use cu29::cutask::{CuMsg, encode_metadata_only};
734        use cu29::pool::CuHandle;
735        // No trait import needed here: `CuImage` defines inherent
736        // `apply_handle_content_policy` and `payload_should_log` methods that win
737        // method resolution. Codegen brings the fallback traits into scope because
738        // it doesn't know the payload's concrete type up front — for these tests
739        // the type is `CuImage<Vec<u8>>` so the inherent path always resolves.
740
741        const FORMAT: super::super::CuImageBufferFormat = super::super::CuImageBufferFormat {
742            width: 2,
743            height: 2,
744            stride: 2,
745            pixel_format: *b"GRAY",
746        };
747
748        fn make_image() -> CuImage<Vec<u8>> {
749            // Source-side default: handles are minted with HandleContent::All — only
750            // the codegen-emitted prelude flips them to the configured policy.
751            let handle = CuHandle::new_detached(vec![0xDE, 0xAD, 0xBE, 0xEF]);
752            CuImage::new(FORMAT, handle)
753        }
754
755        /// Mirrors `build_per_slot_encode_block`: stamp the policy, ask the payload,
756        /// either run the normal Encode or the metadata-only helper. Returns the
757        /// serialized byte stream — exactly what codegen would write for this slot.
758        ///
759        /// The `PayloadDefault*` trait imports at the top of this module bring the
760        /// autoref-specialization fallbacks into scope so method resolution picks
761        /// `CuImage`'s inherent overrides at this concrete-type call site (the same
762        /// trick codegen uses inside `build_per_slot_encode_block`).
763        fn encode_with_policy(msg: &CuMsg<CuImage<Vec<u8>>>, mode: HandleContent) -> Vec<u8> {
764            let should_log = match msg.payload() {
765                Some(p) => {
766                    p.apply_handle_content_policy(mode);
767                    p.payload_should_log()
768                }
769                None => false,
770            };
771
772            // Use an in-memory writer that grows on demand.
773            use bincode::enc::write::Writer;
774            struct VecWriter(Vec<u8>);
775            impl Writer for VecWriter {
776                fn write(&mut self, bytes: &[u8]) -> Result<(), bincode::error::EncodeError> {
777                    self.0.extend_from_slice(bytes);
778                    Ok(())
779                }
780            }
781            let mut encoder =
782                bincode::enc::EncoderImpl::new(VecWriter(Vec::new()), config::standard());
783            use bincode::enc::Encode as _;
784            if should_log {
785                msg.encode(&mut encoder).expect("encode");
786            } else {
787                encode_metadata_only(msg, &mut encoder).expect("encode metadata-only");
788            }
789            encoder.into_writer().0
790        }
791
792        /// Untouched + TouchedOnly → encoder writes the no-payload tag.
793        #[test]
794        fn touched_only_untouched_skips_payload() {
795            let image = make_image();
796            let msg: CuMsg<CuImage<Vec<u8>>> = CuMsg::new(Some(image));
797            let bytes = encode_with_policy(&msg, HandleContent::TouchedOnly);
798            assert_eq!(
799                bytes.first().copied(),
800                Some(0u8),
801                "untouched TouchedOnly must emit no-payload tag"
802            );
803        }
804
805        /// Touched + TouchedOnly → encoder writes the full payload.
806        #[test]
807        fn touched_only_touched_keeps_payload() {
808            let image = make_image();
809            image.mark_touched();
810            let msg: CuMsg<CuImage<Vec<u8>>> = CuMsg::new(Some(image));
811            let bytes = encode_with_policy(&msg, HandleContent::TouchedOnly);
812            assert_eq!(bytes.first().copied(), Some(1u8));
813            assert!(bytes.len() > 8, "payload bytes must follow the present tag");
814        }
815
816        /// `HandleContent::None` strips the payload regardless of touch state.
817        #[test]
818        fn none_always_skips_payload() {
819            let image = make_image();
820            image.mark_touched();
821            let msg: CuMsg<CuImage<Vec<u8>>> = CuMsg::new(Some(image));
822            let bytes = encode_with_policy(&msg, HandleContent::None);
823            assert_eq!(bytes.first().copied(), Some(0u8));
824        }
825
826        /// `HandleContent::All` always keeps the payload — codegen's zero-cost path
827        /// for the common case (it emits no prelude at all, but the result is the
828        /// same as calling apply_handle_content_policy(All)).
829        #[test]
830        fn all_mode_keeps_payload_with_or_without_touch() {
831            for touched in [false, true] {
832                let image = make_image();
833                if touched {
834                    image.mark_touched();
835                }
836                let msg: CuMsg<CuImage<Vec<u8>>> = CuMsg::new(Some(image));
837                let bytes = encode_with_policy(&msg, HandleContent::All);
838                assert_eq!(
839                    bytes.first().copied(),
840                    Some(1u8),
841                    "All mode must keep payload (touched={touched})"
842                );
843            }
844        }
845
846        /// A consumer that holds a cloned handle and marks it touched is observed by
847        /// the encoder, which reads the original. This is the multi-task case.
848        #[test]
849        fn touched_flag_is_shared_across_clones() {
850            let image = make_image();
851            image.apply_handle_content_policy(HandleContent::TouchedOnly);
852            let consumer_view = image.buffer_handle.clone();
853            let _ = consumer_view.with_touched_inner(|inner| inner.as_ref()[0]);
854            assert!(image.payload_should_log());
855        }
856    }
857}