diff --git a/README.md b/README.md index 834a0f2..584ea7c 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ cargo run --release --bin svcache -- build sample.svs --out sample.svs.svcache | Input family | Typical paths | | --- | --- | -| TIFF-family WSI | `.svs`, `.tif`, `.tiff`, `.ndpi`, `.scn`, `.bif` | +| TIFF-family WSI and uncompressed RGB TIFF | `.svs`, `.tif`, `.tiff`, `.ndpi`, `.scn`, `.bif` | | DICOM VL WSI | `.dcm` files or a DICOM series directory | | Zeiss | `.czi`, `.zvi` | | MIRAX | `.mrxs` plus sibling data files | @@ -83,6 +83,11 @@ cargo run --release --bin svcache -- build sample.svs --out sample.svs.svcache | Raw JPEG 2000 codestream | `.j2k`, `.j2c` | | `.svcache` | `.svcache` | +Generic strip-based TIFF support is intentionally limited to one top-level, +uncompressed 8-bit RGB image with top-left orientation, no predictor, and +either interleaved or separate sample planes. Other ordinary TIFF variants +remain unsupported unless they use a registered WSI layout. + ## Features | Feature | Default | Description | diff --git a/src/formats/tiff_family/container/model.rs b/src/formats/tiff_family/container/model.rs index c6a2067..4cc61ac 100644 --- a/src/formats/tiff_family/container/model.rs +++ b/src/formats/tiff_family/container/model.rs @@ -22,9 +22,11 @@ pub(crate) mod tags { pub const PHOTOMETRIC: u16 = 262; pub const IMAGE_DESCRIPTION: u16 = 270; pub const STRIP_OFFSETS: u16 = 273; + pub const ORIENTATION: u16 = 274; pub const SAMPLES_PER_PIXEL: u16 = 277; pub const ROWS_PER_STRIP: u16 = 278; pub const STRIP_BYTE_COUNTS: u16 = 279; + pub const PLANAR_CONFIGURATION: u16 = 284; #[cfg(test)] pub const SUB_IFDS: u16 = 330; pub const TILE_WIDTH: u16 = 322; @@ -35,6 +37,7 @@ pub(crate) mod tags { pub const Y_RESOLUTION: u16 = 283; pub const RESOLUTION_UNIT: u16 = 296; pub const PREDICTOR: u16 = 317; + pub const SAMPLE_FORMAT: u16 = 339; pub const JPEG_TABLES: u16 = 347; pub const XMP: u16 = 700; pub const ICC_PROFILE: u16 = 34675; diff --git a/src/formats/tiff_family/layout/generic.rs b/src/formats/tiff_family/layout/generic.rs index 580f4b4..ef10564 100644 --- a/src/formats/tiff_family/layout/generic.rs +++ b/src/formats/tiff_family/layout/generic.rs @@ -1,11 +1,13 @@ //! Generic TIFF layout interpreter. //! -//! Fallback interpreter for any tiled TIFF that is not claimed by a -//! vendor-specific interpreter. Registered last in the interpreter chain -//! so it only fires when all specific vendors decline. +//! Fallback interpreter for tiled TIFFs and a narrow set of strip-based RGB +//! TIFFs that are not claimed by a vendor-specific interpreter. Registered +//! last in the interpreter chain so it only fires when all specific vendors +//! decline. use std::collections::HashMap; +use crate::core::limits::MAX_DECODED_IMAGE_BYTES; use crate::core::types::*; use crate::formats::tiff_family::container::{tags, TiffContainer}; use crate::formats::tiff_family::error::{IfdId, TiffParseError}; @@ -18,6 +20,84 @@ use super::{ // ── Helpers ────────────────────────────────────────────────────────── +const STRIPPED_LEVEL_TILE_SIZE: u32 = 256; + +fn is_supported_stripped_rgb_ifd(container: &TiffContainer, ifd_id: IfdId) -> bool { + let Ok(width) = container.get_u64(ifd_id, tags::IMAGE_WIDTH) else { + return false; + }; + let Ok(height) = container.get_u64(ifd_id, tags::IMAGE_LENGTH) else { + return false; + }; + if width == 0 || height == 0 || width > u64::from(u32::MAX) || height > u64::from(u32::MAX) { + return false; + } + let Some(decoded_bytes) = width + .checked_mul(height) + .and_then(|pixels| pixels.checked_mul(3)) + else { + return false; + }; + if decoded_bytes > MAX_DECODED_IMAGE_BYTES { + return false; + } + + if container.get_u32(ifd_id, tags::COMPRESSION).unwrap_or(1) != 1 + || container.get_u32(ifd_id, tags::PHOTOMETRIC).unwrap_or(0) != 2 + || container + .get_u32(ifd_id, tags::SAMPLES_PER_PIXEL) + .unwrap_or(1) + != 3 + || container.get_u32(ifd_id, tags::ORIENTATION).unwrap_or(1) != 1 + || container.get_u32(ifd_id, tags::PREDICTOR).unwrap_or(1) != 1 + { + return false; + } + let planar = container + .get_u32(ifd_id, tags::PLANAR_CONFIGURATION) + .unwrap_or(1); + if !matches!(planar, 1 | 2) { + return false; + } + if !container + .get_u64_array(ifd_id, tags::BITS_PER_SAMPLE) + .is_ok_and(|values| !values.is_empty() && values.iter().all(|&value| value == 8)) + { + return false; + } + if container + .get_u64_array(ifd_id, tags::SAMPLE_FORMAT) + .is_ok_and(|values| values.iter().any(|&value| value != 1)) + { + return false; + } + + let rows_per_strip = u64::from( + container + .get_u32(ifd_id, tags::ROWS_PER_STRIP) + .unwrap_or(height as u32), + ); + if rows_per_strip == 0 { + return false; + } + let strips_per_plane = height.div_ceil(rows_per_strip); + let expected_strips = strips_per_plane * if planar == 2 { 3 } else { 1 }; + let Ok(strip_offsets) = container.get_u64_array(ifd_id, tags::STRIP_OFFSETS) else { + return false; + }; + let Ok(strip_byte_counts) = container.get_u64_array(ifd_id, tags::STRIP_BYTE_COUNTS) else { + return false; + }; + let total_strip_bytes = strip_byte_counts + .iter() + .try_fold(0u64, |total, &count| total.checked_add(count)); + strip_offsets.len() as u64 == expected_strips + && strip_byte_counts.len() as u64 == expected_strips + && strip_offsets.iter().all(|&offset| offset > 0) + && strip_byte_counts.iter().all(|&count| count > 0) + && total_strip_bytes == Some(decoded_bytes) +} + // ── Interpreter ────────────────────────────────────────────────────── pub(crate) struct GenericTiffInterpreter; @@ -44,13 +124,16 @@ impl TiffLayoutInterpreter for GenericTiffInterpreter { } } - // Accept if at least one top-level IFD has TILE_WIDTH. - container.top_ifds().iter().any(|&ifd_id| { + // Accept any tiled TIFF, or one unambiguous strip-based RGB image. + let has_tiled_ifd = container.top_ifds().iter().any(|&ifd_id| { container .ifd_by_id(ifd_id) .map(|ifd| ifd.tags.contains_key(&tags::TILE_WIDTH)) .unwrap_or(false) - }) + }); + has_tiled_ifd + || (container.top_ifds().len() == 1 + && is_supported_stripped_rgb_ifd(container, container.top_ifds()[0])) } fn interpret(&self, container: &TiffContainer) -> Result { @@ -110,9 +193,13 @@ impl TiffLayoutInterpreter for GenericTiffInterpreter { } } - if tiled_ifds.is_empty() { + let stripped_level_index = (tiled_ifds.is_empty() + && stripped_ifds.len() == 1 + && is_supported_stripped_rgb_ifd(container, stripped_ifds[0].ifd_id)) + .then_some(0usize); + if tiled_ifds.is_empty() && stripped_level_index.is_none() { return Err(TiffParseError::Structure( - "No tiled IFDs found in generic TIFF".into(), + "No tiled IFDs or supported stripped RGB image found in generic TIFF".into(), )); } @@ -123,14 +210,20 @@ impl TiffLayoutInterpreter for GenericTiffInterpreter { area_b.cmp(&area_a) }); - let base_w = tiled_ifds[0].width; - let base_h = tiled_ifds[0].height; + let (base_w, base_h) = if let Some(first) = tiled_ifds.first() { + (first.width, first.height) + } else { + let stripped = &stripped_ifds[stripped_level_index.unwrap()]; + (stripped.width, stripped.height) + }; // Phase 3: JPEG tables from tag 347 on first tiled IFD if present. - let jpeg_tables: Option> = container - .get_bytes(tiled_ifds[0].ifd_id, tags::JPEG_TABLES) - .ok() - .map(|b| b.to_vec()); + let jpeg_tables: Option> = tiled_ifds.first().and_then(|first| { + container + .get_bytes(first.ifd_id, tags::JPEG_TABLES) + .ok() + .map(|bytes| bytes.to_vec()) + }); // Phase 4: Build levels and tile sources. let mut levels = Vec::with_capacity(tiled_ifds.len()); @@ -172,11 +265,42 @@ impl TiffLayoutInterpreter for GenericTiffInterpreter { ); } + if let Some(index) = stripped_level_index { + let stripped = &stripped_ifds[index]; + levels.push(regular_tiff_level( + "Generic stripped TIFF", + stripped.width, + stripped.height, + STRIPPED_LEVEL_TILE_SIZE, + STRIPPED_LEVEL_TILE_SIZE, + 1.0, + )?); + tile_sources.insert( + TileSourceKey { + scene: 0, + series: 0, + level: 0, + z: 0, + c: 0, + t: 0, + }, + TileSource::StrippedLevel { + ifd_id: stripped.ifd_id, + compression: stripped.compression, + strip_offsets: stripped.strip_offsets.clone(), + strip_byte_counts: stripped.strip_byte_counts.clone(), + }, + ); + } + // Phase 5: Build associated images from stripped IFDs. let mut associated_images: HashMap = HashMap::new(); let mut associated_sources: HashMap = HashMap::new(); for (i, sifd) in stripped_ifds.iter().enumerate() { + if stripped_level_index == Some(i) { + continue; + } let name = format!("image_{}", i); associated_images.insert( name.clone(), @@ -239,9 +363,18 @@ impl TiffLayoutInterpreter for GenericTiffInterpreter { .first() .ok_or_else(|| TiffParseError::Structure("No IFDs in generic TIFF container".into()))?; // Phase 8: Assemble Dataset with single Scene, single Series. + let (lowest_resolution_ifd, source_icc_ifds) = if let Some(index) = stripped_level_index { + let ifd_id = stripped_ifds[index].ifd_id; + (ifd_id, vec![ifd_id]) + } else { + ( + tiled_ifds.last().unwrap().ifd_id, + tiled_ifds.iter().map(|ifd| ifd.ifd_id).collect(), + ) + }; finish_single_scene_uint8_tiff_layout( container, - tiled_ifds.last().unwrap().ifd_id, + lowest_resolution_ifd, property_ifd, AxesShape::default(), levels, @@ -249,7 +382,7 @@ impl TiffLayoutInterpreter for GenericTiffInterpreter { properties, tile_sources, associated_sources, - tiled_ifds.iter().map(|ifd| ifd.ifd_id), + source_icc_ifds, ) } } diff --git a/src/formats/tiff_family/layout/mod.rs b/src/formats/tiff_family/layout/mod.rs index 49135a3..29e9d64 100644 --- a/src/formats/tiff_family/layout/mod.rs +++ b/src/formats/tiff_family/layout/mod.rs @@ -275,6 +275,14 @@ pub(crate) enum TileSource { strip_byte_counts: Vec, }, + /// Generic strip-based TIFF exposed as a synthetic regular tile grid. + StrippedLevel { + ifd_id: IfdId, + compression: Compression, + strip_offsets: Vec, + strip_byte_counts: Vec, + }, + /// Associated image stored as an external JPEG sidecar file. ExternalJpeg { path: PathBuf }, } diff --git a/src/formats/tiff_family/mod.rs b/src/formats/tiff_family/mod.rs index d7b8f96..c5c4e51 100644 --- a/src/formats/tiff_family/mod.rs +++ b/src/formats/tiff_family/mod.rs @@ -566,6 +566,92 @@ mod tests { file } + /// Build an uncompressed RGB TIFF with separate sample planes and no native tiles. + fn build_planar_stripped_rgb_tiff( + width: u32, + height: u32, + rows_per_strip: u32, + ) -> NamedTempFile { + let mut buf = Vec::new(); + buf.extend_from_slice(b"II"); + buf.extend_from_slice(&42u16.to_le_bytes()); + let first_ifd_pos = buf.len(); + buf.extend_from_slice(&0u32.to_le_bytes()); + + let mut strip_offsets = Vec::new(); + let mut strip_byte_counts = Vec::new(); + for channel in 0..3 { + for strip_y in (0..height).step_by(rows_per_strip as usize) { + strip_offsets.push(buf.len() as u32); + let strip_height = rows_per_strip.min(height - strip_y); + for y in strip_y..strip_y + strip_height { + for x in 0..width { + let sample = match channel { + 0 => x as u8, + 1 => y as u8, + _ => x.wrapping_add(y) as u8, + }; + buf.push(sample); + } + } + strip_byte_counts.push(width * strip_height); + } + } + + let bits_per_sample_offset = buf.len() as u32; + for bits in [8u16; 3] { + buf.extend_from_slice(&bits.to_le_bytes()); + } + let strip_offsets_offset = buf.len() as u32; + for offset in &strip_offsets { + buf.extend_from_slice(&offset.to_le_bytes()); + } + let strip_byte_counts_offset = buf.len() as u32; + for byte_count in &strip_byte_counts { + buf.extend_from_slice(&byte_count.to_le_bytes()); + } + + let ifd_offset = buf.len() as u32; + buf[first_ifd_pos..first_ifd_pos + 4].copy_from_slice(&ifd_offset.to_le_bytes()); + let mut tags: Vec<(u16, u16, u32, [u8; 4])> = vec![ + (256, 4, 1, width.to_le_bytes()), + (257, 4, 1, height.to_le_bytes()), + (258, 3, 3, bits_per_sample_offset.to_le_bytes()), + (259, 3, 1, to_short_in_long(1, false)), + (262, 3, 1, to_short_in_long(2, false)), + ( + 273, + 4, + strip_offsets.len() as u32, + strip_offsets_offset.to_le_bytes(), + ), + (274, 3, 1, to_short_in_long(1, false)), + (277, 3, 1, to_short_in_long(3, false)), + (278, 4, 1, rows_per_strip.to_le_bytes()), + ( + 279, + 4, + strip_byte_counts.len() as u32, + strip_byte_counts_offset.to_le_bytes(), + ), + (284, 3, 1, to_short_in_long(2, false)), + ]; + tags.sort_by_key(|tag| tag.0); + buf.extend_from_slice(&(tags.len() as u16).to_le_bytes()); + for (tag, typ, count, value) in tags { + buf.extend_from_slice(&tag.to_le_bytes()); + buf.extend_from_slice(&typ.to_le_bytes()); + buf.extend_from_slice(&count.to_le_bytes()); + buf.extend_from_slice(&value); + } + buf.extend_from_slice(&0u32.to_le_bytes()); + + let mut file = NamedTempFile::new().unwrap(); + file.write_all(&buf).unwrap(); + file.flush().unwrap(); + file + } + #[test] fn generic_tiff_detected_as_fallback() { let file = build_generic_tiled_tiff(256, 256); @@ -599,6 +685,46 @@ mod tests { assert_eq!(tile.channels, 3); } + #[test] + fn generic_planar_stripped_rgb_tiff_opens_and_reads_synthetic_edge_tile() { + let file = build_planar_stripped_rgb_tiff(260, 258, 128); + let backend = TiffFamilyBackend::new(); + + let probe = backend.probe(file.path()).unwrap(); + assert!(probe.detected); + assert_eq!(probe.vendor, "generic-tiff"); + + let source = backend.open(file.path()).unwrap(); + let level = &source.dataset().scenes[0].series[0].levels[0]; + assert_eq!(level.dimensions, (260, 258)); + assert!(matches!( + level.tile_layout, + crate::core::types::TileLayout::Regular { + tile_width: 256, + tile_height: 256, + tiles_across: 2, + tiles_down: 2, + } + )); + + let tile = source + .read_tile_cpu(&crate::core::types::TileRequest { + scene: 0usize.into(), + series: 0usize.into(), + level: 0u32.into(), + plane: crate::core::types::PlaneSelection::default().into(), + col: 1, + row: 1, + }) + .unwrap(); + assert_eq!((tile.width, tile.height), (4, 2)); + assert_eq!(tile.channels, 3); + assert_eq!(tile.color_space, ColorSpace::Rgb); + let pixels = tile.data.as_u8().unwrap(); + assert_eq!(&pixels[..3], &[0, 0, 0]); + assert_eq!(&pixels[pixels.len() - 3..], &[3, 1, 4]); + } + // ── Review finding tests ───────────────────────────────────── /// Build a tiled TIFF with uncompressed RGB data (compression=1). diff --git a/src/formats/tiff_family/pixel_access/dispatch.rs b/src/formats/tiff_family/pixel_access/dispatch.rs index c7441b4..07e06c7 100644 --- a/src/formats/tiff_family/pixel_access/dispatch.rs +++ b/src/formats/tiff_family/pixel_access/dispatch.rs @@ -242,7 +242,9 @@ impl SlideReader for TiffPixelReader { TileSource::SyntheticDownsample { .. } => Err(WsiError::Unsupported { reason: "JPEG passthrough is not available for synthetic downsample levels".into(), }), - TileSource::Stripped { .. } | TileSource::ExternalJpeg { .. } => Err(WsiError::Unsupported { + TileSource::Stripped { .. } + | TileSource::StrippedLevel { .. } + | TileSource::ExternalJpeg { .. } => Err(WsiError::Unsupported { reason: "JPEG passthrough is only available for tiled image levels".into(), }), } @@ -428,8 +430,20 @@ impl SlideReader for TiffPixelReader { .as_ref() .clone()) } + TileSource::StrippedLevel { + ifd_id, + compression, + strip_offsets, + strip_byte_counts, + } => self.read_stripped_level_tile( + req, + *ifd_id, + *compression, + strip_offsets, + strip_byte_counts, + ), TileSource::Stripped { .. } => Err(WsiError::UnsupportedFormat( - "Stripped pixel access via read_tile not supported; use read_associated()".into(), + "Associated stripped images cannot be read via read_tile()".into(), )), TileSource::ExternalJpeg { .. } => Err(WsiError::UnsupportedFormat( "External JPEG associated images cannot be read via read_tile()".into(), diff --git a/src/formats/tiff_family/pixel_access/mod.rs b/src/formats/tiff_family/pixel_access/mod.rs index e99ae08..5a6430c 100644 --- a/src/formats/tiff_family/pixel_access/mod.rs +++ b/src/formats/tiff_family/pixel_access/mod.rs @@ -52,6 +52,7 @@ mod ndpi_core; mod ndpi_retile; mod ndpi_tiles; mod reader; +mod stripped_level; mod synthetic; mod tiled_ifd; diff --git a/src/formats/tiff_family/pixel_access/stripped_level.rs b/src/formats/tiff_family/pixel_access/stripped_level.rs new file mode 100644 index 0000000..f0d4510 --- /dev/null +++ b/src/formats/tiff_family/pixel_access/stripped_level.rs @@ -0,0 +1,180 @@ +use super::*; + +impl TiffPixelReader { + fn decode_stripped_level_image( + &self, + ifd_id: IfdId, + compression: Compression, + dimensions: (u32, u32), + strip_offsets: &[u64], + strip_byte_counts: &[u64], + ) -> Result { + if compression != Compression::None { + return Err(WsiError::UnsupportedFormat(format!( + "generic stripped TIFF levels require uncompressed RGB data, got {compression:?}" + ))); + } + let data = + self.read_stripped_data("generic TIFF level", strip_offsets, strip_byte_counts)?; + let planar = self + .container + .get_u32(ifd_id, tags::PLANAR_CONFIGURATION) + .unwrap_or(1); + if planar != 2 { + return self.decode_uncompressed_tile(ifd_id, &data, dimensions.0, dimensions.1); + } + + let samples_per_pixel = self + .container + .get_u32(ifd_id, tags::SAMPLES_PER_PIXEL) + .unwrap_or(1); + let bits_per_sample = self + .container + .get_u64_array(ifd_id, tags::BITS_PER_SAMPLE) + .map_err(|err| err.into_wsi_error(self.container.path()))?; + let photometric = self + .container + .get_u32(ifd_id, tags::PHOTOMETRIC) + .unwrap_or(0); + let sample_format = self + .container + .get_u64_array(ifd_id, tags::SAMPLE_FORMAT) + .ok(); + if samples_per_pixel != 3 + || bits_per_sample.is_empty() + || bits_per_sample.iter().any(|&bits| bits != 8) + || photometric != 2 + || sample_format.is_some_and(|formats| formats.iter().any(|&format| format != 1)) + { + return Err(WsiError::UnsupportedFormat( + "generic planar stripped TIFF levels require unsigned 8-bit RGB samples".into(), + )); + } + + let plane_len = checked_product_to_usize( + &[u64::from(dimensions.0), u64::from(dimensions.1)], + MAX_DECODED_IMAGE_BYTES, + "generic planar stripped TIFF sample plane", + ) + .map_err(WsiError::DisplayConversion)?; + let expected_len = plane_len.checked_mul(3).ok_or_else(|| { + WsiError::DisplayConversion( + "generic planar stripped TIFF RGB byte count overflow".into(), + ) + })?; + if data.len() != expected_len { + return Err(WsiError::UnsupportedFormat(format!( + "generic planar stripped TIFF has {} decoded bytes, expected {expected_len}", + data.len() + ))); + } + + let mut interleaved = vec![0u8; expected_len]; + for pixel in 0..plane_len { + interleaved[pixel * 3] = data[pixel]; + interleaved[pixel * 3 + 1] = data[plane_len + pixel]; + interleaved[pixel * 3 + 2] = data[plane_len * 2 + pixel]; + } + Ok(CpuTile { + width: dimensions.0, + height: dimensions.1, + channels: 3, + color_space: ColorSpace::Rgb, + layout: CpuTileLayout::Interleaved, + data: CpuTileData::u8(interleaved), + }) + } + + fn get_or_decode_stripped_level_image( + &self, + ifd_id: IfdId, + compression: Compression, + dimensions: (u32, u32), + strip_offsets: &[u64], + strip_byte_counts: &[u64], + ) -> Result, WsiError> { + if let Some(image) = self + .full_decode_cache + .lock() + .unwrap_or_else(|err| err.into_inner()) + .get(&ifd_id) + { + return Ok(image); + } + + let image = Arc::new(self.decode_stripped_level_image( + ifd_id, + compression, + dimensions, + strip_offsets, + strip_byte_counts, + )?); + self.full_decode_cache + .lock() + .unwrap_or_else(|err| err.into_inner()) + .put(ifd_id, image.clone()); + Ok(image) + } + + pub(super) fn read_stripped_level_tile( + &self, + req: &TileRequest, + ifd_id: IfdId, + compression: Compression, + strip_offsets: &[u64], + strip_byte_counts: &[u64], + ) -> Result { + let level = &self.layout.dataset.scenes[req.scene.get()].series[req.series.get()].levels + [req.level.get() as usize]; + let TileLayout::Regular { + tile_width, + tile_height, + tiles_across, + tiles_down, + } = level.tile_layout + else { + return Err(WsiError::TileRead { + col: req.col, + row: req.row, + level: req.level.get(), + reason: "generic stripped TIFF level expects a regular tile layout".into(), + }); + }; + let (col, row) = validate_tile_coords(req.col, req.row, req.level.get())?; + if u64::from(col) >= tiles_across || u64::from(row) >= tiles_down { + return Err(WsiError::TileRead { + col: req.col, + row: req.row, + level: req.level.get(), + reason: format!("tile is outside the {tiles_across}x{tiles_down} grid"), + }); + } + + let dimensions = ( + u32::try_from(level.dimensions.0).map_err(|_| WsiError::TileRead { + col: req.col, + row: req.row, + level: req.level.get(), + reason: "generic stripped TIFF width exceeds u32".into(), + })?, + u32::try_from(level.dimensions.1).map_err(|_| WsiError::TileRead { + col: req.col, + row: req.row, + level: req.level.get(), + reason: "generic stripped TIFF height exceeds u32".into(), + })?, + ); + let src_x = col * tile_width; + let src_y = row * tile_height; + let width = tile_width.min(dimensions.0 - src_x); + let height = tile_height.min(dimensions.1 - src_y); + let image = self.get_or_decode_stripped_level_image( + ifd_id, + compression, + dimensions, + strip_offsets, + strip_byte_counts, + )?; + crop_rgb_interleaved_u8_buffer(image.as_ref(), src_x, src_y, width, height) + } +}