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::{Crop, Fit, ImageProcessor, ImageProcessorTrait, Result};
18use crate::{Error, Flip, Rotation};
19use edgefirst_tensor::{CpuAccess, DType, PixelFormat, Region, TensorDyn, TensorMemory};
20
21pub use edgefirst_decoder::tiling::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.resolve(src_w, src_h, tile_w, tile_h).ok()?;
174    resolved.dst_rect.map(|r| {
175        let (dw, dh) = (tile_w as f32, tile_h as f32);
176        [
177            r.left as f32 / dw,
178            r.top as f32 / dh,
179            (r.left + r.width) as f32 / dw,
180            (r.top + r.height) as f32 / dh,
181        ]
182    })
183}
184
185impl ImageProcessor {
186    /// Allocate the tall packed batch destination `[tile_w, n*tile_h]` — a
187    /// single GL-importable parent that stacks `n` tiles vertically. Uses
188    /// [`ImageProcessor::create_image`] so DMA pitch alignment and
189    /// `Dma>Pbo>Mem` selection apply. Caller-owned for pool reuse.
190    ///
191    /// `access` declares planned CPU involvement (see
192    /// [`edgefirst_tensor::CpuAccess`]); use `None` for NPU-bound tile
193    /// batches and `ReadWrite` when the batch will be CPU-mapped.
194    ///
195    /// # Errors
196    /// Returns an error if `cfg` is invalid (overlap not in `[0,1)`, or zero
197    /// tile size) or if the underlying allocation fails.
198    pub fn alloc_tile_batch(
199        &self,
200        n: usize,
201        cfg: &TilingConfig,
202        format: PixelFormat,
203        dtype: DType,
204        memory: Option<TensorMemory>,
205        access: CpuAccess,
206    ) -> Result<TensorDyn> {
207        cfg.validate()?;
208        self.create_image(
209            cfg.tile_w,
210            n.saturating_mul(cfg.tile_h),
211            format,
212            dtype,
213            memory,
214            access,
215        )
216    }
217
218    /// Compute the tile grid and per-tile [`TilePlacement`] metadata for a
219    /// `src_w`×`src_h` frame **without touching the GPU**. A pipelined runtime
220    /// calls this once per frame to size pools and drive its tile stream.
221    ///
222    /// # Errors
223    /// Returns an error if `cfg` is invalid (overlap not in `[0,1)`, or zero
224    /// tile size).
225    pub fn plan_tiles(
226        &self,
227        src_w: usize,
228        src_h: usize,
229        cfg: &TilingConfig,
230    ) -> Result<Vec<TilePlacement>> {
231        cfg.validate()?;
232        let grid = tile_grid(src_h, src_w, cfg.tile_h, cfg.tile_w, cfg.overlap_ratio);
233        let count = grid.len();
234        let crop = Crop::default().with_fit(cfg.fit);
235        Ok(grid
236            .iter()
237            .map(|t| {
238                let lb = placement_letterbox(
239                    &crop.with_source(Some(t.source)),
240                    t.source.width,
241                    t.source.height,
242                    cfg.tile_w,
243                    cfg.tile_h,
244                );
245                TilePlacement {
246                    index: t.index,
247                    count,
248                    origin: (t.source.x as f32, t.source.y as f32),
249                    crop_size: (t.source.width as f32, t.source.height as f32),
250                    letterbox: lb,
251                    frame_dims: (src_w as f32, src_h as f32),
252                }
253            })
254            .collect())
255    }
256
257    /// Render every tile of `src` into `dst_batched` (a tall packed parent from
258    /// [`Self::alloc_tile_batch`]), one deferred convert per tile, then a single
259    /// `flush`. Returns the per-tile [`TilePlacement`] in tile-index order.
260    ///
261    /// The source crop is selected via [`Crop::with_source`] (a sampled
262    /// sub-rect of the whole-frame `src`) — **not** `src.view()` — so all tiles
263    /// share one source import. Each destination tile is a `view` row-band of
264    /// the shared parent.
265    ///
266    /// # Errors
267    /// Returns an error if `cfg` is invalid, if `src`/`dst_batched` are not
268    /// images, if `dst_batched` is shorter than `count * tile_h`, or if a tile
269    /// convert fails.
270    pub fn tile_into(
271        &mut self,
272        src: &TensorDyn,
273        dst_batched: &mut TensorDyn,
274        cfg: &TilingConfig,
275    ) -> Result<Vec<TilePlacement>> {
276        cfg.validate()?;
277        let (src_w, src_h) = (
278            src.width().ok_or(Error::NotAnImage)?,
279            src.height().ok_or(Error::NotAnImage)?,
280        );
281        let placements = self.plan_tiles(src_w, src_h, cfg)?;
282        let count = placements.len();
283
284        let dst_h = dst_batched.height().ok_or(Error::NotAnImage)?;
285        let required_h = count.saturating_mul(cfg.tile_h);
286        if dst_h < required_h {
287            return Err(Error::InvalidShape(format!(
288                "tile_into dst height {dst_h} < count*tile_h {required_h}"
289            )));
290        }
291
292        for p in &placements {
293            self.render_tile(src, dst_batched, p, cfg)?;
294        }
295        self.flush()?;
296        Ok(placements)
297    }
298
299    /// Render exactly one tile of `src` into `dst_slot` (a single model-input
300    /// sized destination, e.g. a slot of a caller-owned ring). Deferred — the
301    /// caller flushes on its own cadence so tiles overlap with inference. Holds
302    /// no frame-wide state; all geometry rides in `placement` (from
303    /// [`Self::plan_tiles`]).
304    ///
305    /// A deferred tile is not CPU/CUDA-readable until [`ImageProcessorTrait::flush`]
306    /// (mirrors [`ImageProcessorTrait::convert_deferred`]).
307    ///
308    /// # Errors
309    /// Returns an error if `cfg` is invalid or the tile convert fails.
310    pub fn tile_one(
311        &mut self,
312        src: &TensorDyn,
313        dst_slot: &mut TensorDyn,
314        placement: &TilePlacement,
315        cfg: &TilingConfig,
316    ) -> Result<()> {
317        cfg.validate()?;
318        self.render_tile(src, dst_slot, placement, cfg)
319    }
320
321    /// Issue one deferred convert sampling `placement.source` from `src` into
322    /// `dst` (a tall-parent band or a single slot).
323    fn render_tile(
324        &mut self,
325        src: &TensorDyn,
326        dst: &mut TensorDyn,
327        placement: &TilePlacement,
328        cfg: &TilingConfig,
329    ) -> Result<()> {
330        let source = placement_to_source_region(placement)?;
331        let crop = Crop::default().with_source(Some(source)).with_fit(cfg.fit);
332
333        // Destination is a row-band of the tall parent (packed) or the slot
334        // itself. Decide batched vs slot from capacity vs. `placement.count`,
335        // not merely `dst_h > tile_h` — a padded `tile_one` slot can be taller
336        // than `tile_h` without being a tall parent.
337        let dst_h = dst.height().ok_or(Error::NotAnImage)?;
338        let batched = dst_h >= placement.count.saturating_mul(cfg.tile_h);
339        if batched {
340            let mut band = dst.view(Region::new(
341                0,
342                placement.index * cfg.tile_h,
343                cfg.tile_w,
344                cfg.tile_h,
345            ))?;
346            self.convert_deferred(src, &mut band, Rotation::None, Flip::None, crop)?;
347        } else {
348            self.convert_deferred(src, dst, Rotation::None, Flip::None, crop)?;
349        }
350        Ok(())
351    }
352}
353
354/// Validate a [`TilePlacement`]'s origin/crop and convert to a pixel [`Region`].
355///
356/// Rejects negative, non-finite, or non-integral (after truncation) geometry
357/// that Python/C callers could otherwise smuggle in via the public placement
358/// constructors.
359fn placement_to_source_region(placement: &TilePlacement) -> Result<Region> {
360    let (ox, oy) = placement.origin;
361    let (cw, ch) = placement.crop_size;
362    for (name, v) in [
363        ("origin.x", ox),
364        ("origin.y", oy),
365        ("crop.w", cw),
366        ("crop.h", ch),
367    ] {
368        if !v.is_finite() || v < 0.0 {
369            return Err(Error::CropInvalid(format!(
370                "tile placement {name} must be finite and non-negative, got {v}"
371            )));
372        }
373    }
374    let (ox_i, oy_i, cw_i, ch_i) = (ox as usize, oy as usize, cw as usize, ch as usize);
375    // Reject fractional values that would silently truncate (e.g. origin.x=1.5).
376    for (name, f, i) in [
377        ("origin.x", ox, ox_i),
378        ("origin.y", oy, oy_i),
379        ("crop.w", cw, cw_i),
380        ("crop.h", ch, ch_i),
381    ] {
382        if (f - i as f32).abs() > f32::EPSILON {
383            return Err(Error::CropInvalid(format!(
384                "tile placement {name} must be an integral pixel value, got {f}"
385            )));
386        }
387    }
388    if cw_i == 0 || ch_i == 0 {
389        return Err(Error::CropInvalid(
390            "tile placement crop size must be non-zero".into(),
391        ));
392    }
393    Ok(Region::new(ox_i, oy_i, cw_i, ch_i))
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    // --- EvenDist geometry (pure, no GPU) ---------------------------------
401
402    #[test]
403    fn axis_origins_matches_adis_worked_example() {
404        // adis-uav-model sahi(1920, 640, 0.1) => [0, 427, 853, 1280]
405        assert_eq!(axis_origins(1920, 640, 0.1), vec![0, 427, 853, 1280]);
406    }
407
408    #[test]
409    fn axis_origins_4k_axes() {
410        // 3840 / 640 / 0.2 => 8 full tiles, last at frame-tile.
411        let xs = axis_origins(3840, 640, 0.2);
412        assert_eq!(xs, vec![0, 457, 914, 1371, 1829, 2286, 2743, 3200]);
413        // 2160 / 640 / 0.2 => 4 tiles.
414        let ys = axis_origins(2160, 640, 0.2);
415        assert_eq!(ys, vec![0, 507, 1013, 1520]);
416        assert_eq!(*xs.last().unwrap(), 3840 - 640);
417        assert_eq!(*ys.last().unwrap(), 2160 - 640);
418        assert_eq!(xs[0], 0);
419    }
420
421    #[test]
422    fn axis_origins_frame_le_tile_single() {
423        assert_eq!(axis_origins(640, 640, 0.2), vec![0]); // == tile
424        assert_eq!(axis_origins(400, 640, 0.2), vec![0]); // < tile
425    }
426
427    #[test]
428    fn axis_origins_frame_tile_plus_one() {
429        // Smallest 2-tile grid; div_ceil boundary.
430        assert_eq!(axis_origins(641, 640, 0.2), vec![0, 1]);
431    }
432
433    #[test]
434    fn axis_origins_overlap_zero_is_exact_tiling() {
435        // overlap 0 => step == tile, no overlap.
436        assert_eq!(axis_origins(1920, 640, 0.0), vec![0, 640, 1280]);
437    }
438
439    #[test]
440    fn axis_origins_overlap_near_one_no_panic() {
441        // overlap 0.99 must not panic (max_step clamps to >= 1).
442        let xs = axis_origins(1920, 640, 0.99);
443        assert_eq!(xs[0], 0);
444        assert_eq!(*xs.last().unwrap(), 1280);
445        // monotone non-decreasing
446        assert!(xs.windows(2).all(|w| w[0] <= w[1]));
447        assert!(xs.len() > 8); // heavy overlap => many tiles
448    }
449
450    #[test]
451    fn axis_origins_8k_no_overflow() {
452        // Large frame: f32 rounding path, no overflow.
453        let xs = axis_origins(7680, 640, 0.2);
454        assert_eq!(xs[0], 0);
455        assert_eq!(*xs.last().unwrap(), 7680 - 640);
456        assert!(xs.windows(2).all(|w| w[0] <= w[1]));
457    }
458
459    #[test]
460    fn tile_grid_4k_all_full_size_and_in_bounds() {
461        let grid = tile_grid(2160, 3840, 640, 640, 0.2);
462        assert_eq!(grid.len(), 8 * 4); // 32 tiles
463        for t in &grid {
464            assert_eq!(t.source.width, 640);
465            assert_eq!(t.source.height, 640);
466            assert!(t.source.x + 640 <= 3840);
467            assert!(t.source.y + 640 <= 2160);
468        }
469        // row-major: index increments by column then row
470        assert_eq!(grid[0].source, Region::new(0, 0, 640, 640));
471        assert_eq!(grid[8].source, Region::new(0, 507, 640, 640)); // start of row 1
472    }
473
474    #[test]
475    fn tile_grid_realized_overlap_at_least_requested() {
476        let grid = tile_grid(2160, 3840, 640, 640, 0.2);
477        // realized step between first two columns
478        let step = grid[1].source.x - grid[0].source.x;
479        let realized_overlap = 1.0 - (step as f64 / 640.0);
480        assert!(
481            realized_overlap >= 0.2 - 1e-9,
482            "realized {realized_overlap} < 0.2"
483        );
484    }
485
486    #[test]
487    fn tile_grid_frame_smaller_than_tile_single_whole_frame() {
488        let grid = tile_grid(400, 500, 640, 640, 0.2);
489        assert_eq!(grid.len(), 1);
490        assert_eq!(grid[0].source, Region::new(0, 0, 500, 400));
491    }
492
493    #[test]
494    fn rounding_uses_f32_path() {
495        // overlap 0.333 on a large frame: exercise the f32 round() path AND
496        // the floor(tile*(1-overlap)) minimum-overlap contract (max_step must
497        // be 426, not 427 — tile - floor(overlap*tile) would under-count).
498        let xs = axis_origins(4000, 640, 0.333);
499        assert_eq!(xs[0], 0);
500        assert_eq!(*xs.last().unwrap(), 4000 - 640);
501        assert!(xs.windows(2).all(|w| w[0] <= w[1]));
502        let max_step = xs.windows(2).map(|w| w[1] - w[0]).max().unwrap();
503        let realized = 1.0 - (max_step as f64 / 640.0);
504        assert!(
505            realized >= 0.333 - 1e-9,
506            "realized overlap {realized} < 0.333 (max_step={max_step})"
507        );
508    }
509
510    #[test]
511    fn with_fit_letterbox_syncs_pad() {
512        let pad = [1, 2, 3, 4];
513        let cfg = TilingConfig::new(640, 640).with_fit(Fit::Letterbox { pad });
514        assert_eq!(cfg.fit, Fit::Letterbox { pad });
515        assert_eq!(cfg.pad, pad);
516    }
517
518    #[test]
519    fn placement_to_source_region_rejects_invalid() {
520        let good = TilePlacement {
521            index: 0,
522            count: 1,
523            origin: (10.0, 20.0),
524            crop_size: (640.0, 640.0),
525            letterbox: None,
526            frame_dims: (3840.0, 2160.0),
527        };
528        assert!(placement_to_source_region(&good).is_ok());
529
530        let mut bad = good;
531        bad.origin.0 = -1.0;
532        assert!(placement_to_source_region(&bad).is_err());
533        bad = good;
534        bad.origin.0 = f32::NAN;
535        assert!(placement_to_source_region(&bad).is_err());
536        bad = good;
537        bad.origin.0 = 1.5;
538        assert!(placement_to_source_region(&bad).is_err());
539        bad = good;
540        bad.crop_size.0 = 0.0;
541        assert!(placement_to_source_region(&bad).is_err());
542    }
543
544    #[test]
545    fn tiling_config_validate_rejects_bad_overlap() {
546        assert!(TilingConfig::new(640, 640)
547            .with_overlap(1.0)
548            .validate()
549            .is_err());
550        assert!(TilingConfig::new(640, 640)
551            .with_overlap(-0.1)
552            .validate()
553            .is_err());
554        assert!(TilingConfig::new(640, 640)
555            .with_overlap(0.2)
556            .validate()
557            .is_ok());
558    }
559
560    #[test]
561    fn tiling_config_validate_rejects_zero_tile_size() {
562        assert!(TilingConfig::new(0, 640).validate().is_err());
563        assert!(TilingConfig::new(640, 0).validate().is_err());
564    }
565}