Skip to main content

otf_pixels_core/
tile.rs

1//! Tiles: strided views over a rectangular region of pixels.
2//!
3//! A tile is the unit of work that moves through the graph. In M1 the naive
4//! evaluator uses one whole-image tile per node; in M2 the scheduler will
5//! subdivide into negotiated strips and squares (ADR-0003). Nothing in this
6//! module assumes either shape — a tile is defined purely by its [`Region`],
7//! [`PixelFormat`] and row stride.
8//!
9//! Rows are addressed in **image coordinates**, not tile-relative ones: a tile
10//! covering `Region::new(10, 20, 8, 8)` answers `row(20)` through `row(27)`.
11//! Ops translate between input and output coordinates through the regions they
12//! are handed, so absolute addressing removes a whole class of off-by-origin
13//! bugs from kernels.
14
15use crate::{ImageDescriptor, PixelFormat, PixelsError, Region, Result};
16
17/// Validate that `data` can back `region` at `stride`, returning the row length.
18///
19/// A buffer must hold every full row plus the packed bytes of the final row;
20/// trailing stride padding after the last row is not required.
21fn validate(region: Region, pixel: PixelFormat, stride: usize, data_len: usize) -> Result<usize> {
22    let row_bytes = (region.width as usize)
23        .checked_mul(pixel.bytes_per_pixel())
24        .ok_or_else(|| PixelsError::invalid_argument("region", "row byte length overflows"))?;
25    if stride < row_bytes {
26        return Err(PixelsError::invalid_argument(
27            "stride",
28            format!("stride {stride} is shorter than a {row_bytes}-byte row"),
29        ));
30    }
31    if region.is_empty() {
32        return Ok(row_bytes);
33    }
34    let needed = (region.height as usize - 1)
35        .checked_mul(stride)
36        .and_then(|full| full.checked_add(row_bytes))
37        .ok_or_else(|| PixelsError::invalid_argument("region", "tile byte length overflows"))?;
38    if data_len < needed {
39        return Err(PixelsError::invalid_argument(
40            "data",
41            format!("buffer of {data_len} bytes is too small for {region} (needs {needed})"),
42        ));
43    }
44    Ok(row_bytes)
45}
46
47/// Byte offset of the row at image coordinate `y`, if the tile covers it.
48fn row_offset(region: Region, stride: usize, y: u32) -> Option<usize> {
49    if y < region.y || u64::from(y) >= region.bottom() {
50        return None;
51    }
52    ((y - region.y) as usize).checked_mul(stride)
53}
54
55/// An immutable strided view over a region of pixels.
56#[derive(Debug, Clone, Copy)]
57pub struct Tile<'a> {
58    region: Region,
59    pixel: PixelFormat,
60    stride: usize,
61    row_bytes: usize,
62    data: &'a [u8],
63}
64
65impl<'a> Tile<'a> {
66    /// Wrap `data` as a tile covering `region`.
67    ///
68    /// `stride` is the distance in bytes between the starts of consecutive
69    /// rows, and must be at least `region.width * pixel.bytes_per_pixel()`.
70    ///
71    /// # Errors
72    ///
73    /// Returns [`PixelsError::InvalidArgument`] if `stride` is shorter than one
74    /// packed row, or if `data` is too small to cover `region` at `stride`.
75    pub fn new(region: Region, pixel: PixelFormat, stride: usize, data: &'a [u8]) -> Result<Self> {
76        let row_bytes = validate(region, pixel, stride, data.len())?;
77        Ok(Self {
78            region,
79            pixel,
80            stride,
81            row_bytes,
82            data,
83        })
84    }
85
86    /// The region of the image this tile covers.
87    #[must_use]
88    pub const fn region(&self) -> Region {
89        self.region
90    }
91
92    /// The pixel format of the samples in this tile.
93    #[must_use]
94    pub const fn pixel(&self) -> PixelFormat {
95        self.pixel
96    }
97
98    /// Distance in bytes between the starts of consecutive rows.
99    #[must_use]
100    pub const fn stride(&self) -> usize {
101        self.stride
102    }
103
104    /// Packed length in bytes of one row of this tile.
105    #[must_use]
106    pub const fn row_bytes(&self) -> usize {
107        self.row_bytes
108    }
109
110    /// The row at image coordinate `y`, or [`None`] if outside the region.
111    #[must_use]
112    pub fn row(&self, y: u32) -> Option<&'a [u8]> {
113        let offset = row_offset(self.region, self.stride, y)?;
114        self.data.get(offset..offset.checked_add(self.row_bytes)?)
115    }
116
117    /// The tile's rows, top to bottom.
118    pub fn rows(&self) -> impl Iterator<Item = &'a [u8]> + '_ {
119        let first = self.region.y;
120        (0..self.region.height).filter_map(move |i| self.row(first + i))
121    }
122}
123
124/// A mutable strided view over a region of pixels.
125///
126/// This is the output half of [`Op::compute`]: the evaluator hands the kernel a
127/// `TileMut` sized to the region it must fill.
128///
129/// [`Op::compute`]: crate::Op::compute
130#[derive(Debug)]
131pub struct TileMut<'a> {
132    region: Region,
133    pixel: PixelFormat,
134    stride: usize,
135    row_bytes: usize,
136    data: &'a mut [u8],
137}
138
139impl<'a> TileMut<'a> {
140    /// Wrap `data` as a mutable tile covering `region`.
141    ///
142    /// # Errors
143    ///
144    /// As [`Tile::new`].
145    pub fn new(
146        region: Region,
147        pixel: PixelFormat,
148        stride: usize,
149        data: &'a mut [u8],
150    ) -> Result<Self> {
151        let row_bytes = validate(region, pixel, stride, data.len())?;
152        Ok(Self {
153            region,
154            pixel,
155            stride,
156            row_bytes,
157            data,
158        })
159    }
160
161    /// The region of the image this tile covers.
162    #[must_use]
163    pub const fn region(&self) -> Region {
164        self.region
165    }
166
167    /// The pixel format of the samples in this tile.
168    #[must_use]
169    pub const fn pixel(&self) -> PixelFormat {
170        self.pixel
171    }
172
173    /// Distance in bytes between the starts of consecutive rows.
174    #[must_use]
175    pub const fn stride(&self) -> usize {
176        self.stride
177    }
178
179    /// Packed length in bytes of one row of this tile.
180    #[must_use]
181    pub const fn row_bytes(&self) -> usize {
182        self.row_bytes
183    }
184
185    /// The row at image coordinate `y`, or [`None`] if outside the region.
186    #[must_use]
187    pub fn row_mut(&mut self, y: u32) -> Option<&mut [u8]> {
188        let offset = row_offset(self.region, self.stride, y)?;
189        let end = offset.checked_add(self.row_bytes)?;
190        self.data.get_mut(offset..end)
191    }
192
193    /// The tile's rows, top to bottom, mutably.
194    pub fn rows_mut(&mut self) -> impl Iterator<Item = &mut [u8]> {
195        let (stride, row_bytes, height) =
196            (self.stride, self.row_bytes, self.region.height as usize);
197        self.data
198            .chunks_mut(stride)
199            .take(height)
200            .filter_map(move |chunk| chunk.get_mut(..row_bytes))
201    }
202
203    /// Reborrow as an immutable tile.
204    #[must_use]
205    pub fn as_ref(&self) -> Tile<'_> {
206        Tile {
207            region: self.region,
208            pixel: self.pixel,
209            stride: self.stride,
210            row_bytes: self.row_bytes,
211            data: self.data,
212        }
213    }
214}
215
216/// An owned, densely packed buffer of pixels for one region.
217///
218/// This is what the M1 evaluator materializes per node. It is deliberately
219/// simple: M2 replaces most uses with scheduler-owned tile memory, but the
220/// buffer's view API ([`TileBuf::as_tile`], [`TileBuf::as_tile_mut`]) is the
221/// same one kernels already program against, so kernels do not change.
222#[derive(Debug, Clone)]
223pub struct TileBuf {
224    region: Region,
225    pixel: PixelFormat,
226    stride: usize,
227    data: Vec<u8>,
228}
229
230impl TileBuf {
231    /// Allocate a zeroed, densely packed buffer covering `region`.
232    ///
233    /// # Errors
234    ///
235    /// Returns [`PixelsError::InvalidArgument`] if the region's byte length
236    /// overflows `usize` on this platform.
237    pub fn zeroed(region: Region, pixel: PixelFormat) -> Result<Self> {
238        let stride = (region.width as usize)
239            .checked_mul(pixel.bytes_per_pixel())
240            .ok_or_else(|| PixelsError::invalid_argument("region", "row length overflows"))?;
241        let len = (region.height as usize)
242            .checked_mul(stride)
243            .ok_or_else(|| PixelsError::invalid_argument("region", "tile length overflows"))?;
244        Ok(Self {
245            region,
246            pixel,
247            stride,
248            data: vec![0; len],
249        })
250    }
251
252    /// Allocate a zeroed buffer covering the whole of `desc`.
253    ///
254    /// # Errors
255    ///
256    /// As [`TileBuf::zeroed`].
257    pub fn for_image(desc: &ImageDescriptor) -> Result<Self> {
258        Self::zeroed(desc.region(), desc.pixel)
259    }
260
261    /// Take ownership of `data` as the pixels of `region`, densely packed.
262    ///
263    /// # Errors
264    ///
265    /// Returns [`PixelsError::InvalidArgument`] if `data` is not exactly the
266    /// packed byte length of `region`.
267    pub fn from_vec(region: Region, pixel: PixelFormat, data: Vec<u8>) -> Result<Self> {
268        let stride = (region.width as usize)
269            .checked_mul(pixel.bytes_per_pixel())
270            .ok_or_else(|| PixelsError::invalid_argument("region", "row length overflows"))?;
271        let expected = (region.height as usize)
272            .checked_mul(stride)
273            .ok_or_else(|| PixelsError::invalid_argument("region", "tile length overflows"))?;
274        if data.len() != expected {
275            return Err(PixelsError::invalid_argument(
276                "data",
277                format!(
278                    "expected exactly {expected} packed bytes, got {}",
279                    data.len()
280                ),
281            ));
282        }
283        Ok(Self {
284            region,
285            pixel,
286            stride,
287            data,
288        })
289    }
290
291    /// The region this buffer covers.
292    #[must_use]
293    pub const fn region(&self) -> Region {
294        self.region
295    }
296
297    /// The pixel format of the samples in this buffer.
298    #[must_use]
299    pub const fn pixel(&self) -> PixelFormat {
300        self.pixel
301    }
302
303    /// The packed bytes of this buffer.
304    #[must_use]
305    pub fn bytes(&self) -> &[u8] {
306        &self.data
307    }
308
309    /// Consume the buffer, returning its packed bytes.
310    #[must_use]
311    pub fn into_bytes(self) -> Vec<u8> {
312        self.data
313    }
314
315    /// Borrow the buffer as an immutable tile.
316    ///
317    /// # Errors
318    ///
319    /// Only if the buffer's own invariants were violated, which construction
320    /// prevents.
321    pub fn as_tile(&self) -> Result<Tile<'_>> {
322        Tile::new(self.region, self.pixel, self.stride, &self.data)
323    }
324
325    /// Borrow the buffer as a mutable tile.
326    ///
327    /// # Errors
328    ///
329    /// As [`TileBuf::as_tile`].
330    pub fn as_tile_mut(&mut self) -> Result<TileMut<'_>> {
331        TileMut::new(self.region, self.pixel, self.stride, &mut self.data)
332    }
333}
334
335/// Copy the pixels of `region` from `src` into `dst`.
336///
337/// Both tiles address rows in image coordinates, so this is the general
338/// "move a rectangle between two views of the same image space" primitive:
339/// it handles differing origins and strides without either side knowing the
340/// other's layout.
341///
342/// # Errors
343///
344/// Returns [`PixelsError::InvalidArgument`] if the tiles disagree on pixel
345/// format, or if `region` is not covered by both tiles.
346pub fn copy_region(src: &Tile<'_>, dst: &mut TileMut<'_>, region: Region) -> Result<()> {
347    if src.pixel() != dst.pixel() {
348        return Err(PixelsError::invalid_argument(
349            "pixel",
350            format!(
351                "cannot copy {} pixels into a {} tile",
352                src.pixel(),
353                dst.pixel()
354            ),
355        ));
356    }
357    if !src.region().contains(region) {
358        return Err(PixelsError::invalid_argument(
359            "region",
360            format!("source tile {} does not cover {region}", src.region()),
361        ));
362    }
363    if !dst.region().contains(region) {
364        return Err(PixelsError::invalid_argument(
365            "region",
366            format!("destination tile {} does not cover {region}", dst.region()),
367        ));
368    }
369    if region.is_empty() {
370        return Ok(());
371    }
372    let bpp = src.pixel().bytes_per_pixel();
373    let span = region.width as usize * bpp;
374    let src_offset = (region.x - src.region().x) as usize * bpp;
375    let dst_offset = (region.x - dst.region().x) as usize * bpp;
376    for y in region.y..region.y.saturating_add(region.height) {
377        let (Some(src_row), Some(dst_row)) = (src.row(y), dst.row_mut(y)) else {
378            return Err(PixelsError::invalid_argument(
379                "region",
380                format!("row {y} is out of range"),
381            ));
382        };
383        let (Some(from), Some(into)) = (
384            src_row.get(src_offset..src_offset + span),
385            dst_row.get_mut(dst_offset..dst_offset + span),
386        ) else {
387            return Err(PixelsError::invalid_argument(
388                "region",
389                format!("row {y} is too short for {region}"),
390            ));
391        };
392        into.copy_from_slice(from);
393    }
394    Ok(())
395}
396
397#[cfg(test)]
398#[allow(
399    clippy::unwrap_used,
400    clippy::indexing_slicing,
401    reason = "tests operate on known-good values and assert shapes directly"
402)]
403mod tests {
404    use super::*;
405
406    fn buf(width: u32, height: u32) -> TileBuf {
407        TileBuf::zeroed(Region::from_size(width, height), PixelFormat::Rgb8).unwrap()
408    }
409
410    #[test]
411    fn rows_are_addressed_in_image_coordinates() {
412        let region = Region::new(10, 20, 2, 3);
413        let data = vec![7_u8; 2 * 3 * 3];
414        let tile = Tile::new(region, PixelFormat::Rgb8, 6, &data).unwrap();
415        assert!(tile.row(19).is_none(), "above the tile");
416        assert!(tile.row(20).is_some(), "first row");
417        assert!(tile.row(22).is_some(), "last row");
418        assert!(tile.row(23).is_none(), "below the tile");
419        assert_eq!(tile.rows().count(), 3);
420        assert_eq!(tile.row(20).unwrap().len(), 6);
421    }
422
423    #[test]
424    fn stride_padding_is_skipped_by_row_views() {
425        // 2px RGB8 rows (6 bytes) padded to a stride of 8.
426        let data: Vec<u8> = (0..16).collect();
427        let tile = Tile::new(Region::from_size(2, 2), PixelFormat::Rgb8, 8, &data).unwrap();
428        assert_eq!(tile.row(0).unwrap(), &[0, 1, 2, 3, 4, 5]);
429        assert_eq!(tile.row(1).unwrap(), &[8, 9, 10, 11, 12, 13]);
430        assert_eq!(tile.stride(), 8);
431        assert_eq!(tile.row_bytes(), 6);
432    }
433
434    #[test]
435    fn a_buffer_one_byte_short_is_an_error_not_a_panic() {
436        let data = vec![0_u8; 6 * 3 - 1];
437        let err = Tile::new(Region::from_size(6, 3), PixelFormat::Gray8, 6, &data).unwrap_err();
438        assert_eq!(err.code(), crate::ErrorCode::InvalidArgument);
439    }
440
441    #[test]
442    fn stride_shorter_than_a_row_is_rejected() {
443        let data = vec![0_u8; 64];
444        let err = Tile::new(Region::from_size(4, 2), PixelFormat::Rgb8, 11, &data).unwrap_err();
445        assert_eq!(err.code(), crate::ErrorCode::InvalidArgument);
446    }
447
448    #[test]
449    fn trailing_stride_padding_is_not_required() {
450        // 3 rows at stride 8 but only 6 packed bytes needed in the last row.
451        let data = vec![0_u8; 8 * 2 + 6];
452        assert!(Tile::new(Region::from_size(2, 3), PixelFormat::Rgb8, 8, &data).is_ok());
453    }
454
455    #[test]
456    fn mutable_rows_write_through_to_the_buffer() {
457        let mut b = buf(2, 2);
458        {
459            let mut tile = b.as_tile_mut().unwrap();
460            for (i, row) in tile.rows_mut().enumerate() {
461                row.fill(i as u8 + 1);
462            }
463        }
464        assert_eq!(b.bytes(), &[1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2]);
465    }
466
467    #[test]
468    fn row_mut_addresses_absolute_coordinates() {
469        let mut b = TileBuf::zeroed(Region::new(0, 5, 1, 2), PixelFormat::Gray8).unwrap();
470        let mut tile = b.as_tile_mut().unwrap();
471        assert!(tile.row_mut(4).is_none());
472        tile.row_mut(5).unwrap().fill(9);
473        tile.row_mut(6).unwrap().fill(8);
474        assert!(tile.row_mut(7).is_none());
475        assert_eq!(b.bytes(), &[9, 8]);
476    }
477
478    #[test]
479    fn from_vec_requires_an_exactly_packed_buffer() {
480        let region = Region::from_size(2, 2);
481        assert!(TileBuf::from_vec(region, PixelFormat::Gray8, vec![0; 4]).is_ok());
482        assert!(TileBuf::from_vec(region, PixelFormat::Gray8, vec![0; 3]).is_err());
483        assert!(TileBuf::from_vec(region, PixelFormat::Gray8, vec![0; 5]).is_err());
484    }
485
486    #[test]
487    fn empty_regions_are_representable() {
488        let tile = Tile::new(Region::EMPTY, PixelFormat::Rgb8, 0, &[]).unwrap();
489        assert_eq!(tile.rows().count(), 0);
490        assert!(tile.row(0).is_none());
491        assert_eq!(
492            TileBuf::zeroed(Region::EMPTY, PixelFormat::Rgb8)
493                .unwrap()
494                .bytes()
495                .len(),
496            0
497        );
498    }
499
500    #[test]
501    fn copy_region_moves_a_rectangle_between_differing_origins() {
502        // Source: 4x4 gray, value = y*10 + x.
503        let mut src_buf = TileBuf::zeroed(Region::from_size(4, 4), PixelFormat::Gray8).unwrap();
504        {
505            let mut tile = src_buf.as_tile_mut().unwrap();
506            for y in 0..4_u32 {
507                let row = tile.row_mut(y).unwrap();
508                for (x, cell) in row.iter_mut().enumerate() {
509                    *cell = (y as u8) * 10 + x as u8;
510                }
511            }
512        }
513        // Destination covers only the 2x2 rectangle at (1,1).
514        let mut dst_buf = TileBuf::zeroed(Region::new(1, 1, 2, 2), PixelFormat::Gray8).unwrap();
515        let src = src_buf.as_tile().unwrap();
516        let mut dst = dst_buf.as_tile_mut().unwrap();
517        copy_region(&src, &mut dst, Region::new(1, 1, 2, 2)).unwrap();
518        assert_eq!(dst_buf.bytes(), &[11, 12, 21, 22]);
519    }
520
521    #[test]
522    fn copy_region_rejects_uncovered_or_mismatched_requests() {
523        let src_buf = TileBuf::zeroed(Region::from_size(4, 4), PixelFormat::Gray8).unwrap();
524        let mut dst_buf = TileBuf::zeroed(Region::from_size(4, 4), PixelFormat::Gray8).unwrap();
525        let src = src_buf.as_tile().unwrap();
526        {
527            let mut dst = dst_buf.as_tile_mut().unwrap();
528            // Region extends past the source.
529            let err = copy_region(&src, &mut dst, Region::new(2, 2, 4, 4)).unwrap_err();
530            assert_eq!(err.code(), crate::ErrorCode::InvalidArgument);
531            // Empty regions are a no-op, not an error.
532            copy_region(&src, &mut dst, Region::EMPTY).unwrap();
533        }
534        // Mismatched pixel formats.
535        let mut rgb = TileBuf::zeroed(Region::from_size(4, 4), PixelFormat::Rgb8).unwrap();
536        let src2 = src_buf.as_tile().unwrap();
537        let mut dst2 = rgb.as_tile_mut().unwrap();
538        let err = copy_region(&src2, &mut dst2, Region::from_size(4, 4)).unwrap_err();
539        assert_eq!(err.code(), crate::ErrorCode::InvalidArgument);
540    }
541
542    #[test]
543    fn copy_region_survives_stride_padding_on_both_sides() {
544        let src_data: Vec<u8> = (0..16).collect();
545        let src = Tile::new(Region::from_size(2, 4), PixelFormat::Gray8, 4, &src_data).unwrap();
546        let mut dst_data = vec![0_u8; 16];
547        let mut dst = TileMut::new(
548            Region::from_size(2, 4),
549            PixelFormat::Gray8,
550            4,
551            &mut dst_data,
552        )
553        .unwrap();
554        copy_region(&src, &mut dst, Region::from_size(2, 4)).unwrap();
555        // Packed pixels copied; stride padding left untouched.
556        assert_eq!(&dst_data[0..2], &[0, 1]);
557        assert_eq!(&dst_data[4..6], &[4, 5]);
558        assert_eq!(&dst_data[2..4], &[0, 0], "padding is not written");
559    }
560
561    #[test]
562    fn as_ref_preserves_the_view() {
563        let mut b = buf(2, 2);
564        let tile = b.as_tile_mut().unwrap();
565        let view = tile.as_ref();
566        assert_eq!(view.region(), Region::from_size(2, 2));
567        assert_eq!(view.pixel(), PixelFormat::Rgb8);
568        assert_eq!(view.stride(), 6);
569    }
570}