Skip to main content

edgefirst_image/
tiling.rs

1// SPDX-FileCopyrightText: Copyright 2026 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! SAHI-style input tiling: cover a high-resolution frame with a uniform
5//! overlapping grid of model-input-sized tiles and preprocess them zero-copy.
6//!
7//! The grid uses the **EvenDist** spacing (ported from the adis-uav-model
8//! `sahi()` reference): `overlap_ratio` is treated as a *minimum*; the actual
9//! overlap is redistributed evenly so every tile is full-size and the last tile
10//! lands exactly on the far edge. This is the single spacing method — there is
11//! no flush-to-edge/clip configurability.
12//!
13//! The per-tile detections produced downstream are lifted to full-frame
14//! coordinates and merged by `edgefirst_decoder::tiling`; the shared
15//! [`TilePlacement`] is produced here and consumed there.
16
17use crate::Error;
18use crate::{Crop, Fit, ImageProcessor, ImageProcessorTrait, Result};
19use edgefirst_tensor::{CpuAccess, DType, PixelFormat, Region, TensorDyn, TensorMemory};
20
21pub use edgefirst_decoder_abi::TilePlacement;
22
23/// One tile's native-frame crop rectangle and its grid coordinates.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct TileSpec {
26    /// Native crop in full-frame pixels: `(x=ox, y=oy, width=cw, height=ch)`.
27    pub source: Region,
28    /// Row-major flat index, `0..count`.
29    pub index: usize,
30    /// Grid row.
31    pub row: usize,
32    /// Grid column.
33    pub col: usize,
34}
35
36/// Static tiling configuration for one model. Independent of frame size.
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct TilingConfig {
39    /// Tile width = model input width.
40    pub tile_w: usize,
41    /// Tile height = model input height.
42    pub tile_h: usize,
43    /// Minimum overlap ratio between adjacent tiles, `0.0 <= r < 1.0`
44    /// (default 0.2). The realized overlap is redistributed evenly and is
45    /// always `>=` this.
46    pub overlap_ratio: f32,
47    /// Letterbox pad colour (RGBA), used only when `fit == Fit::Letterbox`.
48    pub pad: [u8; 4],
49    /// How a tile crop is fit into the model input. Defaults to
50    /// [`Fit::Stretch`], which is identity for the full square tiles the grid
51    /// produces (`crop == model input`).
52    pub fit: Fit,
53}
54
55impl TilingConfig {
56    /// Tile size = model input; deploy defaults (overlap 0.2, stretch fit,
57    /// `[114,114,114,255]` pad).
58    pub fn new(tile_w: usize, tile_h: usize) -> Self {
59        Self {
60            tile_w,
61            tile_h,
62            overlap_ratio: 0.2,
63            pad: [114, 114, 114, 255],
64            fit: Fit::Stretch,
65        }
66    }
67
68    /// Set the minimum overlap ratio (builder).
69    pub fn with_overlap(mut self, overlap_ratio: f32) -> Self {
70        self.overlap_ratio = overlap_ratio;
71        self
72    }
73
74    /// Set the fit mode (builder). When switching to [`Fit::Letterbox`], also
75    /// copies that variant's pad into [`Self::pad`] so the two stay in sync.
76    pub fn with_fit(mut self, fit: Fit) -> Self {
77        if let Fit::Letterbox { pad } = fit {
78            self.pad = pad;
79        }
80        self.fit = fit;
81        self
82    }
83
84    /// Validate the config: `overlap_ratio` in `[0.0, 1.0)` and non-zero tile
85    /// size. Called by every tiling method, and exposed so callers (e.g. the
86    /// language bindings) can reject bad input before generating a grid.
87    ///
88    /// # Errors
89    /// Returns [`Error::CropInvalid`] if the overlap is out of range or a tile
90    /// dimension is zero.
91    pub fn validate(&self) -> Result<()> {
92        if !(self.overlap_ratio >= 0.0 && self.overlap_ratio < 1.0) {
93            return Err(Error::CropInvalid(format!(
94                "tiling overlap_ratio must be in [0.0, 1.0), got {}",
95                self.overlap_ratio
96            )));
97        }
98        if self.tile_w == 0 || self.tile_h == 0 {
99            return Err(Error::CropInvalid(
100                "tiling tile size must be non-zero".into(),
101            ));
102        }
103        Ok(())
104    }
105}
106
107/// EvenDist 1-D tile origins along one axis. Mirrors adis-uav-model `sahi()`:
108/// `overlap` is the *minimum*; origins are redistributed evenly so the first
109/// is `0`, the last is `frame - tile`, and the realized step is
110/// `<= floor(tile * (1 - overlap))` (realized overlap `>=` requested).
111/// Returns `[0]` when the frame is no larger than the tile.
112fn axis_origins(frame: usize, tile: usize, overlap: f64) -> Vec<usize> {
113    if frame <= tile {
114        return vec![0];
115    }
116    let last = frame - tile;
117    // Use floor(tile*(1-overlap)), not tile-floor(overlap*tile): the latter can
118    // be 1px larger than tile*(1-overlap) for non-integer ratios (e.g. 0.333),
119    // which would under-count tiles and realize less than the requested overlap.
120    // Clamp to >= 1 so a pathological overlap never div_ceil(0) (config
121    // validation already rejects overlap >= 1.0 up front).
122    let max_step = ((1.0 - overlap) * tile as f64).floor().max(1.0) as usize;
123    let total = last.div_ceil(max_step);
124    (0..=total)
125        .map(|i| (i as f32 * last as f32 / total as f32).round() as usize)
126        .collect()
127}
128
129/// Uniform overlapping EvenDist tile grid covering a `frame_h`×`frame_w` frame.
130/// Row-major (all columns of row 0, then row 1, …). Every tile is full-size
131/// (`tile_w`×`tile_h`) unless the frame is smaller than the tile on an axis, in
132/// which case that axis yields a single whole-frame crop.
133pub fn tile_grid(
134    frame_h: usize,
135    frame_w: usize,
136    tile_h: usize,
137    tile_w: usize,
138    overlap_ratio: f32,
139) -> Vec<TileSpec> {
140    let cw = tile_w.min(frame_w);
141    let ch = tile_h.min(frame_h);
142    let xs = axis_origins(frame_w, tile_w, overlap_ratio as f64);
143    let ys = axis_origins(frame_h, tile_h, overlap_ratio as f64);
144    let mut tiles = Vec::with_capacity(xs.len() * ys.len());
145    let mut index = 0;
146    for (row, &oy) in ys.iter().enumerate() {
147        for (col, &ox) in xs.iter().enumerate() {
148            debug_assert!(
149                ox + cw <= frame_w && oy + ch <= frame_h,
150                "tile out of bounds"
151            );
152            tiles.push(TileSpec {
153                source: Region::new(ox, oy, cw, ch),
154                index,
155                row,
156                col,
157            });
158            index += 1;
159        }
160    }
161    tiles
162}
163
164/// Normalize a resolved letterbox dst-rect to `[lx0, ly0, lx1, ly1]` on the
165/// model input, or `None` for a stretched (whole-destination) placement.
166fn placement_letterbox(
167    crop: &Crop,
168    src_w: usize,
169    src_h: usize,
170    tile_w: usize,
171    tile_h: usize,
172) -> Option<[f32; 4]> {
173    let resolved = crop
174        .resolve(
175            src_w,
176            src_h,
177            tile_w,
178            tile_h,
179            crate::LetterboxPlacement::Center,
180        )
181        .ok()?;
182    resolved.dst_rect.map(|r| {
183        let (dw, dh) = (tile_w as f32, tile_h as f32);
184        [
185            r.left as f32 / dw,
186            r.top as f32 / dh,
187            (r.left + r.width) as f32 / dw,
188            (r.top + r.height) as f32 / dh,
189        ]
190    })
191}
192
193impl ImageProcessor {
194    /// Allocate the tall packed batch destination `[tile_w, n*tile_h]` — a
195    /// single GL-importable parent that stacks `n` tiles vertically. Uses
196    /// [`ImageProcessor::create_image`] so DMA pitch alignment and
197    /// `Dma>Pbo>Mem` selection apply. Caller-owned for pool reuse.
198    ///
199    /// `access` declares planned CPU involvement (see
200    /// [`edgefirst_tensor::CpuAccess`]); use `None` for NPU-bound tile
201    /// batches and `ReadWrite` when the batch will be CPU-mapped. As for
202    /// `create_image`, what this processor's own convert may do on the CPU
203    /// is added; tiles are written through views, so any predicted CPU
204    /// write is added as `ReadWrite`.
205    ///
206    /// # Errors
207    /// Returns an error if `cfg` is invalid (overlap not in `[0,1)`, or zero
208    /// tile size) or if the underlying allocation fails.
209    pub fn alloc_tile_batch(
210        &self,
211        n: usize,
212        cfg: &TilingConfig,
213        format: PixelFormat,
214        dtype: DType,
215        memory: Option<TensorMemory>,
216        access: CpuAccess,
217    ) -> Result<TensorDyn> {
218        cfg.validate()?;
219        self.create_image_with(
220            cfg.tile_w,
221            n.saturating_mul(cfg.tile_h),
222            format,
223            dtype,
224            memory,
225            access,
226            true,
227        )
228    }
229
230    /// Compute the tile grid and per-tile [`TilePlacement`] metadata for a
231    /// `src_w`×`src_h` frame **without touching the GPU**. A pipelined runtime
232    /// calls this once per frame to size pools and drive its tile stream.
233    ///
234    /// The realized tile count can exceed the naive `ceil(frame / tile)`: the
235    /// grid treats `cfg.overlap_ratio` as a *minimum* and redistributes the
236    /// surplus evenly, so allocate from `len()` rather than estimating.
237    ///
238    /// The returned placements are consumed by
239    /// `edgefirst_decoder::tiling` to lift per-tile detections back to
240    /// full-frame coordinates.
241    ///
242    /// # Errors
243    /// Returns an error if `cfg` is invalid (overlap not in `[0,1)`, or zero
244    /// tile size).
245    pub fn plan_tiles(
246        &self,
247        src_w: usize,
248        src_h: usize,
249        cfg: &TilingConfig,
250    ) -> Result<Vec<TilePlacement>> {
251        let span = tracing::trace_span!(
252            "image.plan_tiles",
253            tiles = tracing::field::Empty,
254            overlap = cfg.overlap_ratio,
255        );
256        let _s = span.enter();
257        cfg.validate()?;
258        let grid = tile_grid(src_h, src_w, cfg.tile_h, cfg.tile_w, cfg.overlap_ratio);
259        let count = grid.len();
260        span.record("tiles", count);
261        let crop = Crop::default().with_fit(cfg.fit);
262        Ok(grid
263            .iter()
264            .map(|t| {
265                let lb = placement_letterbox(
266                    &crop.with_source(Some(t.source)),
267                    t.source.width,
268                    t.source.height,
269                    cfg.tile_w,
270                    cfg.tile_h,
271                );
272                TilePlacement {
273                    index: t.index,
274                    count,
275                    origin: (t.source.x as f32, t.source.y as f32),
276                    crop_size: (t.source.width as f32, t.source.height as f32),
277                    letterbox: lb,
278                    frame_dims: (src_w as f32, src_h as f32),
279                }
280            })
281            .collect())
282    }
283
284    /// Render every tile of `src` into `dst_batched` (a tall packed parent from
285    /// [`Self::alloc_tile_batch`]), one deferred convert per tile, then a single
286    /// `flush`. Returns the per-tile [`TilePlacement`] in tile-index order.
287    ///
288    /// The source crop is selected via [`Crop::with_source`] (a sampled
289    /// sub-rect of the whole-frame `src`) — **not** `src.view()` — so all tiles
290    /// share one source import. Each destination tile is a `view` row-band of
291    /// the shared parent.
292    ///
293    /// # Examples
294    ///
295    /// ```rust,no_run
296    /// # use edgefirst_image::{ImageProcessor, TilingConfig};
297    /// # use edgefirst_tensor::{CpuAccess, DType, PixelFormat, TensorDyn};
298    /// # fn run(src: &TensorDyn) -> Result<(), edgefirst_image::Error> {
299    /// let mut processor = ImageProcessor::new()?;
300    /// let cfg = TilingConfig::new(640, 640).with_overlap(0.2);
301    ///
302    /// // Pure geometry — size the batch before touching the GPU.
303    /// let planned = processor.plan_tiles(3840, 2160, &cfg)?;
304    /// let mut batch = processor.alloc_tile_batch(
305    ///     planned.len(), &cfg, PixelFormat::Rgba, DType::U8, None, CpuAccess::None,
306    /// )?;
307    ///
308    /// // One import, one flush, N tiles.
309    /// let placements = processor.tile_into(src, &mut batch, &cfg)?;
310    /// assert_eq!(placements.len(), planned.len());
311    /// # Ok(())
312    /// # }
313    /// ```
314    ///
315    /// # Backend note
316    ///
317    /// The GL band lowering covers single-pass geometry only. A zero-copy
318    /// (DMA) `dst_batched` in packed `Rgb` or a planar layout reinterprets the
319    /// render-target shape, which the band path does not yet compute, so GL
320    /// declines and each tile falls back to CPU. `Rgba`, `Bgra`, and `Grey`
321    /// batches stay on the batched GPU path.
322    ///
323    /// # Errors
324    /// Returns an error if `cfg` is invalid, if `src`/`dst_batched` are not
325    /// images, if `dst_batched` is shorter than `count * tile_h`, or if a tile
326    /// convert fails.
327    pub fn tile_into(
328        &mut self,
329        src: &TensorDyn,
330        dst_batched: &mut TensorDyn,
331        cfg: &TilingConfig,
332    ) -> Result<Vec<TilePlacement>> {
333        let span = tracing::trace_span!("image.tile_into", tiles = tracing::field::Empty);
334        let _s = span.enter();
335        cfg.validate()?;
336        let (src_w, src_h) = (
337            src.width().ok_or(Error::NotAnImage)?,
338            src.height().ok_or(Error::NotAnImage)?,
339        );
340        let placements = self.plan_tiles(src_w, src_h, cfg)?;
341        let count = placements.len();
342        span.record("tiles", count);
343
344        let dst_h = dst_batched.height().ok_or(Error::NotAnImage)?;
345        let required_h = count.saturating_mul(cfg.tile_h);
346        if dst_h < required_h {
347            return Err(Error::InvalidShape(format!(
348                "tile_into dst height {dst_h} < count*tile_h {required_h}"
349            )));
350        }
351
352        for p in &placements {
353            self.render_tile(src, dst_batched, p, cfg)?;
354        }
355        self.flush()?;
356        Ok(placements)
357    }
358
359    /// Render exactly one tile of `src` into `dst_slot` (a single model-input
360    /// sized destination, e.g. a slot of a caller-owned ring). Deferred — the
361    /// caller flushes on its own cadence so tiles overlap with inference. Holds
362    /// no frame-wide state; all geometry rides in `placement` (from
363    /// [`Self::plan_tiles`]).
364    ///
365    /// A deferred tile is not CPU/CUDA-readable until [`ImageProcessorTrait::flush`]
366    /// (mirrors [`ImageProcessorTrait::convert_deferred`]).
367    ///
368    /// # Errors
369    /// Returns an error if `cfg` is invalid or the tile convert fails.
370    pub fn tile_one(
371        &mut self,
372        src: &TensorDyn,
373        dst_slot: &mut TensorDyn,
374        placement: &TilePlacement,
375        cfg: &TilingConfig,
376    ) -> Result<()> {
377        let _s = tracing::trace_span!(
378            "image.tile_one",
379            index = placement.index,
380            count = placement.count,
381        )
382        .entered();
383        cfg.validate()?;
384        self.render_tile(src, dst_slot, placement, cfg)
385    }
386
387    /// Issue one deferred convert sampling `placement.source` from `src` into
388    /// `dst` (a tall-parent band or a single slot).
389    fn render_tile(
390        &mut self,
391        src: &TensorDyn,
392        dst: &mut TensorDyn,
393        placement: &TilePlacement,
394        cfg: &TilingConfig,
395    ) -> Result<()> {
396        let source = placement_to_source_region(placement)?;
397        let crop = Crop::default().with_source(Some(source)).with_fit(cfg.fit);
398
399        // Destination is a row-band of the tall parent (packed) or the slot
400        // itself. Decide batched vs slot from capacity vs. `placement.count`,
401        // not merely `dst_h > tile_h` — a padded `tile_one` slot can be taller
402        // than `tile_h` without being a tall parent.
403        let dst_h = dst.height().ok_or(Error::NotAnImage)?;
404        let batched = dst_h >= placement.count.saturating_mul(cfg.tile_h);
405        if batched {
406            let mut band = dst.view(Region::new(
407                0,
408                placement.index * cfg.tile_h,
409                cfg.tile_w,
410                cfg.tile_h,
411            ))?;
412            self.convert_deferred(
413                src,
414                &mut band,
415                Some(crate::ConvertOptions::new().crop(crop)),
416            )?;
417        } else {
418            self.convert_deferred(src, dst, Some(crate::ConvertOptions::new().crop(crop)))?;
419        }
420        Ok(())
421    }
422}
423
424/// Validate a [`TilePlacement`]'s origin/crop and convert to a pixel [`Region`].
425///
426/// Rejects negative, non-finite, or non-integral (after truncation) geometry
427/// that Python/C callers could otherwise smuggle in via the public placement
428/// constructors.
429fn placement_to_source_region(placement: &TilePlacement) -> Result<Region> {
430    let (ox, oy) = placement.origin;
431    let (cw, ch) = placement.crop_size;
432    for (name, v) in [
433        ("origin.x", ox),
434        ("origin.y", oy),
435        ("crop.w", cw),
436        ("crop.h", ch),
437    ] {
438        if !v.is_finite() || v < 0.0 {
439            return Err(Error::CropInvalid(format!(
440                "tile placement {name} must be finite and non-negative, got {v}"
441            )));
442        }
443    }
444    let (ox_i, oy_i, cw_i, ch_i) = (ox as usize, oy as usize, cw as usize, ch as usize);
445    // Reject fractional values that would silently truncate (e.g. origin.x=1.5).
446    for (name, f, i) in [
447        ("origin.x", ox, ox_i),
448        ("origin.y", oy, oy_i),
449        ("crop.w", cw, cw_i),
450        ("crop.h", ch, ch_i),
451    ] {
452        if (f - i as f32).abs() > f32::EPSILON {
453            return Err(Error::CropInvalid(format!(
454                "tile placement {name} must be an integral pixel value, got {f}"
455            )));
456        }
457    }
458    if cw_i == 0 || ch_i == 0 {
459        return Err(Error::CropInvalid(
460            "tile placement crop size must be non-zero".into(),
461        ));
462    }
463    Ok(Region::new(ox_i, oy_i, cw_i, ch_i))
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    // --- EvenDist geometry (pure, no GPU) ---------------------------------
471
472    #[test]
473    fn axis_origins_matches_adis_worked_example() {
474        // adis-uav-model sahi(1920, 640, 0.1) => [0, 427, 853, 1280]
475        assert_eq!(axis_origins(1920, 640, 0.1), vec![0, 427, 853, 1280]);
476    }
477
478    #[test]
479    fn axis_origins_4k_axes() {
480        // 3840 / 640 / 0.2 => 8 full tiles, last at frame-tile.
481        let xs = axis_origins(3840, 640, 0.2);
482        assert_eq!(xs, vec![0, 457, 914, 1371, 1829, 2286, 2743, 3200]);
483        // 2160 / 640 / 0.2 => 4 tiles.
484        let ys = axis_origins(2160, 640, 0.2);
485        assert_eq!(ys, vec![0, 507, 1013, 1520]);
486        assert_eq!(*xs.last().unwrap(), 3840 - 640);
487        assert_eq!(*ys.last().unwrap(), 2160 - 640);
488        assert_eq!(xs[0], 0);
489    }
490
491    #[test]
492    fn axis_origins_frame_le_tile_single() {
493        assert_eq!(axis_origins(640, 640, 0.2), vec![0]); // == tile
494        assert_eq!(axis_origins(400, 640, 0.2), vec![0]); // < tile
495    }
496
497    #[test]
498    fn axis_origins_frame_tile_plus_one() {
499        // Smallest 2-tile grid; div_ceil boundary.
500        assert_eq!(axis_origins(641, 640, 0.2), vec![0, 1]);
501    }
502
503    #[test]
504    fn axis_origins_overlap_zero_is_exact_tiling() {
505        // overlap 0 => step == tile, no overlap.
506        assert_eq!(axis_origins(1920, 640, 0.0), vec![0, 640, 1280]);
507    }
508
509    #[test]
510    fn axis_origins_overlap_near_one_no_panic() {
511        // overlap 0.99 must not panic (max_step clamps to >= 1).
512        let xs = axis_origins(1920, 640, 0.99);
513        assert_eq!(xs[0], 0);
514        assert_eq!(*xs.last().unwrap(), 1280);
515        // monotone non-decreasing
516        assert!(xs.windows(2).all(|w| w[0] <= w[1]));
517        assert!(xs.len() > 8); // heavy overlap => many tiles
518    }
519
520    #[test]
521    fn axis_origins_8k_no_overflow() {
522        // Large frame: f32 rounding path, no overflow.
523        let xs = axis_origins(7680, 640, 0.2);
524        assert_eq!(xs[0], 0);
525        assert_eq!(*xs.last().unwrap(), 7680 - 640);
526        assert!(xs.windows(2).all(|w| w[0] <= w[1]));
527    }
528
529    #[test]
530    fn tile_grid_4k_all_full_size_and_in_bounds() {
531        let grid = tile_grid(2160, 3840, 640, 640, 0.2);
532        assert_eq!(grid.len(), 8 * 4); // 32 tiles
533        for t in &grid {
534            assert_eq!(t.source.width, 640);
535            assert_eq!(t.source.height, 640);
536            assert!(t.source.x + 640 <= 3840);
537            assert!(t.source.y + 640 <= 2160);
538        }
539        // row-major: index increments by column then row
540        assert_eq!(grid[0].source, Region::new(0, 0, 640, 640));
541        assert_eq!(grid[8].source, Region::new(0, 507, 640, 640)); // start of row 1
542    }
543
544    #[test]
545    fn tile_grid_realized_overlap_at_least_requested() {
546        let grid = tile_grid(2160, 3840, 640, 640, 0.2);
547        // realized step between first two columns
548        let step = grid[1].source.x - grid[0].source.x;
549        let realized_overlap = 1.0 - (step as f64 / 640.0);
550        assert!(
551            realized_overlap >= 0.2 - 1e-9,
552            "realized {realized_overlap} < 0.2"
553        );
554    }
555
556    #[test]
557    fn tile_grid_frame_smaller_than_tile_single_whole_frame() {
558        let grid = tile_grid(400, 500, 640, 640, 0.2);
559        assert_eq!(grid.len(), 1);
560        assert_eq!(grid[0].source, Region::new(0, 0, 500, 400));
561    }
562
563    #[test]
564    fn rounding_uses_f32_path() {
565        // overlap 0.333 on a large frame: exercise the f32 round() path AND
566        // the floor(tile*(1-overlap)) minimum-overlap contract (max_step must
567        // be 426, not 427 — tile - floor(overlap*tile) would under-count).
568        let xs = axis_origins(4000, 640, 0.333);
569        assert_eq!(xs[0], 0);
570        assert_eq!(*xs.last().unwrap(), 4000 - 640);
571        assert!(xs.windows(2).all(|w| w[0] <= w[1]));
572        let max_step = xs.windows(2).map(|w| w[1] - w[0]).max().unwrap();
573        let realized = 1.0 - (max_step as f64 / 640.0);
574        assert!(
575            realized >= 0.333 - 1e-9,
576            "realized overlap {realized} < 0.333 (max_step={max_step})"
577        );
578    }
579
580    #[test]
581    fn with_fit_letterbox_syncs_pad() {
582        let pad = [1, 2, 3, 4];
583        let cfg = TilingConfig::new(640, 640).with_fit(Fit::Letterbox { pad });
584        assert_eq!(cfg.fit, Fit::Letterbox { pad });
585        assert_eq!(cfg.pad, pad);
586    }
587
588    #[test]
589    fn placement_to_source_region_rejects_invalid() {
590        let good = TilePlacement {
591            index: 0,
592            count: 1,
593            origin: (10.0, 20.0),
594            crop_size: (640.0, 640.0),
595            letterbox: None,
596            frame_dims: (3840.0, 2160.0),
597        };
598        assert!(placement_to_source_region(&good).is_ok());
599
600        let mut bad = good;
601        bad.origin.0 = -1.0;
602        assert!(placement_to_source_region(&bad).is_err());
603        bad = good;
604        bad.origin.0 = f32::NAN;
605        assert!(placement_to_source_region(&bad).is_err());
606        bad = good;
607        bad.origin.0 = 1.5;
608        assert!(placement_to_source_region(&bad).is_err());
609        bad = good;
610        bad.crop_size.0 = 0.0;
611        assert!(placement_to_source_region(&bad).is_err());
612    }
613
614    #[test]
615    fn tiling_config_validate_rejects_bad_overlap() {
616        assert!(TilingConfig::new(640, 640)
617            .with_overlap(1.0)
618            .validate()
619            .is_err());
620        assert!(TilingConfig::new(640, 640)
621            .with_overlap(-0.1)
622            .validate()
623            .is_err());
624        assert!(TilingConfig::new(640, 640)
625            .with_overlap(0.2)
626            .validate()
627            .is_ok());
628    }
629
630    #[test]
631    fn tiling_config_validate_rejects_zero_tile_size() {
632        assert!(TilingConfig::new(0, 640).validate().is_err());
633        assert!(TilingConfig::new(640, 0).validate().is_err());
634    }
635
636    // --- span instrumentation ----------------------------------------------
637
638    /// Minimal `tracing::Subscriber` that records every span's name, in
639    /// creation order, into a shared `Vec`. Enough to assert *which* spans
640    /// fired without pulling in `tracing-subscriber` (not a dependency of
641    /// this crate) or `tracing-test`.
642    struct SpanNameCapture {
643        names: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
644        next_id: std::sync::atomic::AtomicU64,
645    }
646
647    impl tracing::Subscriber for SpanNameCapture {
648        fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
649            true
650        }
651
652        fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
653            self.names
654                .lock()
655                .unwrap()
656                .push(span.metadata().name().to_string());
657            let id = self
658                .next_id
659                .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
660                + 1;
661            tracing::span::Id::from_u64(id)
662        }
663
664        fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
665        fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
666        fn event(&self, _event: &tracing::Event<'_>) {}
667        fn enter(&self, _span: &tracing::span::Id) {}
668        fn exit(&self, _span: &tracing::span::Id) {}
669    }
670
671    #[test]
672    fn tiling_emits_spans() {
673        let names = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
674        let subscriber = SpanNameCapture {
675            names: names.clone(),
676            next_id: std::sync::atomic::AtomicU64::new(0),
677        };
678
679        tracing::subscriber::with_default(subscriber, || {
680            let mut proc = ImageProcessor::new().expect("processor");
681            let cfg = TilingConfig::new(320, 240);
682            let placements = proc.plan_tiles(640, 480, &cfg).expect("plan_tiles");
683
684            let src = TensorDyn::image(
685                640,
686                480,
687                PixelFormat::Nv12,
688                DType::U8,
689                Some(TensorMemory::Mem),
690                CpuAccess::ReadWrite,
691            )
692            .expect("src");
693            let mut dst = TensorDyn::image(
694                320,
695                240,
696                PixelFormat::Rgb,
697                DType::U8,
698                Some(TensorMemory::Mem),
699                CpuAccess::ReadWrite,
700            )
701            .expect("dst");
702
703            proc.tile_one(&src, &mut dst, &placements[0], &cfg)
704                .expect("tile_one");
705            let _ = proc.flush();
706        });
707
708        let names = names.lock().unwrap();
709        assert!(
710            names.iter().any(|n| n == "image.plan_tiles"),
711            "expected image.plan_tiles in {names:?}"
712        );
713        assert!(
714            names.iter().any(|n| n == "image.tile_one"),
715            "expected image.tile_one in {names:?}"
716        );
717    }
718}