From 0105968a936faac3e2a33c42523a43280783dbae Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sat, 18 Jul 2026 11:56:57 -0400 Subject: [PATCH 1/2] chore(deps): bump gamut pin b08a1a2 -> dde9f64 Picks up justin13888/gamut#295 (TransferCharacteristics::Linear, CICP code point 8, plus SourceTransfer::Linear and SourceProfile::LINEAR_SRGB), landed via justin13888/gamut#297. This is the gate for the ColorDescription slice of the core-primitives migration: the linear-sRGB working space is now expressible as a CICP pair. Per the README pin-bump procedure: this commit only moves the hash (plus Cargo.lock). Full workspace test run passes (345 tests). Benchmarks are unaffected and were not re-baselined: at this commit no rawshift code path consumes gamut yet (gamut-core is dependency-wired only), so the bump cannot change rawshift behaviour. No CHANGELOG entry for the same reason. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac337e7..5e66a58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -816,7 +816,7 @@ checksum = "795cbfc56d419a7ce47ccbb7504dd9a5b7c484c083c356e797de08bd988d9629" [[package]] name = "gamut-core" version = "2.0.0" -source = "git+https://github.com/justin13888/gamut?rev=b08a1a223db3f010997d4918ddb41723b7266a4c#b08a1a223db3f010997d4918ddb41723b7266a4c" +source = "git+https://github.com/justin13888/gamut?rev=dde9f640ab02ec9c3437c3f1181164f6e7c60151#dde9f640ab02ec9c3437c3f1181164f6e7c60151" dependencies = [ "thiserror", ] diff --git a/Cargo.toml b/Cargo.toml index 49c3e7c..ebf1923 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ homepage = "https://github.com/justin13888/rawshift" # # Bumping this pin is a deliberate, reviewed change — see README "Bumping the # gamut pin" for the required procedure. -gamut-core = { git = "https://github.com/justin13888/gamut", rev = "b08a1a223db3f010997d4918ddb41723b7266a4c" } +gamut-core = { git = "https://github.com/justin13888/gamut", rev = "dde9f640ab02ec9c3437c3f1181164f6e7c60151" } # Shared infrastructure dependencies (used across multiple workspace crates). thiserror = "2.0" From fc5d69369852f2fd3487ec6e285ea7bee75cdb7a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sat, 18 Jul 2026 12:18:05 -0400 Subject: [PATCH 2/2] feat(core)!: replace generic primitives with gamut re-exports, keep sensor types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rawshift-core now re-exports gamut's generic image vocabulary instead of defining its own, keeping only what gamut deliberately does not model: the sensor set (RawImage + builder, CfaPattern, XTransPattern, white_level_from_bit_depth), codec descriptors, the typed ImageMetadata model, and URational/SRational. Deleted -> replaced: - `Size` -> `gamut_core::Dimensions` re-export. Construction rule: struct literals preserve the old infallible zero-permitting semantics at existing call sites; `Dimensions::new` (fallible, rejects zero) is available where strictness is wanted. `Rect` now composes `Dimensions` with hand-written serde (four flat integers) since gamut has no serde yet (gamut#257); a `dimensions_serde` with-module serves downstream struct fields. - `pixel.rs` (Sample/FromF32/Rgb/Rgba + aliases) -> gamut-core's sealed `Pixel`/`Sample` traits and marker types (Rgb16, Rgba8, ...), plus ImageBuf/ImageRef/PixelFormat/ColorModel. The old module was confirmed dead: zero call sites beyond a prelude re-export. f32 stays transform-internal — gamut's Sample is sealed over u8/u16 and rawshift adds no pixel formats. - `BitDepth` -> `gamut_color::BitDepth` re-export (variants and semantics are identical; gamut#260 provided Sixteen). It is #[non_exhaustive] and carries no Default/serde upstream, so a `bit_depth_serde` with-module covers wire use and containing types hand-write their Defaults. - `ColorSpace` -> new `ColorDescription`, a CICP (H.273) code-point pair with consts SRGB, LINEAR_SRGB (working space, Default), DISPLAY_P3, REC2020, and UNSPECIFIED. LINEAR_SRGB is expressible because gamut#295 added TransferCharacteristics::Linear (code point 8) — found missing during this migration, filed, and landed first per the upstream-first policy. ICC-authoritative spaces (Adobe RGB, ProPhoto RGB) map to UNSPECIFIED: the pair never lies about the samples, and the preserved ICC profile is the authority. Manual serde uses the numeric code points as the wire form. The old enum's wide-gamut variants were never constructed anywhere, so no behaviour changes. - Core `RgbImage` -> new wrapper in rawshift-image over `gamut_core::ImageBuf` carrying ColorDescription + baseline exposure + default crop. The public `data` field becomes `data()`/`data_mut()`, and the buffer length invariant (len == w*h*3) is enforced at every construction: `new`/`with_color` are fallible, and the mutate-then-set_size two-step is replaced by an atomic validated `replace_data`. Misuse that previously produced silently inconsistent images is now a compile- or construction-time error. - `MetadataExtractor` -> `ExtractMetadata` (rename, same contract). New error surface: `RawError::Gamut { context, source }` wraps `gamut_core::Error` with the rawshift operation name (context stays ours until gamut#254 lands structured diagnostics). Carve-out: the `IccProfile` deletion rides with the metadata migration (#19). gamut-icc has no sRGB-profile synthesis helper, and the type is fully entangled with the img-parts embed paths #19 replaces wholesale — removing it here would create the dual path the epic forbids. All ~49 files of call sites across formats/, processing/, transforms/, codecs/, examples/, tests/, and benches migrated. Behavioural parity is preserved throughout: infallible construction sites keep their semantics via struct literals, and fallible RgbImage construction is `?`-propagated in Result contexts or `.expect`ed where the buffer is correct by construction. BREAKING CHANGE: 0.x break, accepted by the epic. Size/ColorSpace/BitDepth (local)/pixel module/RgbImage.data field/MetadataExtractor are gone from the public API; RgbImage construction is fallible. --- Cargo.lock | 10 + Cargo.toml | 1 + crates/rawshift-core/Cargo.toml | 4 +- crates/rawshift-core/src/color.rs | 285 +++++++++++---- crates/rawshift-core/src/image.rs | 345 ++++++++---------- crates/rawshift-core/src/lib.rs | 41 ++- crates/rawshift-core/src/metadata.rs | 2 +- crates/rawshift-core/src/pixel.rs | 294 --------------- crates/rawshift-image/Cargo.toml | 1 + crates/rawshift-image/benches/decode.rs | 12 +- crates/rawshift-image/benches/demosaic.rs | 4 +- crates/rawshift-image/benches/pipeline.rs | 7 +- .../examples/decode_standard.rs | 4 +- .../examples/encode_in_memory.rs | 2 +- .../examples/generate_test_fixtures.rs | 14 +- crates/rawshift-image/src/core/mod.rs | 13 +- crates/rawshift-image/src/core/rgb_image.rs | 249 +++++++++++++ crates/rawshift-image/src/error.rs | 22 ++ crates/rawshift-image/src/formats/arw.rs | 13 +- crates/rawshift-image/src/formats/cr2.rs | 18 +- crates/rawshift-image/src/formats/cr3.rs | 31 +- crates/rawshift-image/src/formats/crw.rs | 18 +- crates/rawshift-image/src/formats/dng.rs | 15 +- .../rawshift-image/src/formats/dng_export.rs | 8 +- crates/rawshift-image/src/formats/encode.rs | 20 +- crates/rawshift-image/src/formats/export.rs | 17 +- crates/rawshift-image/src/formats/heic.rs | 8 +- crates/rawshift-image/src/formats/mod.rs | 29 +- crates/rawshift-image/src/formats/nef.rs | 18 +- crates/rawshift-image/src/formats/raf.rs | 18 +- crates/rawshift-image/src/formats/standard.rs | 174 +++++---- crates/rawshift-image/src/prelude.rs | 22 +- crates/rawshift-image/src/processing/color.rs | 65 ++-- .../src/processing/demosaic/bayer.rs | 65 ++-- .../src/processing/demosaic/bilinear.rs | 44 ++- .../src/processing/demosaic/mod.rs | 5 +- .../src/processing/demosaic/xtrans.rs | 9 +- .../src/transforms/bad_pixel.rs | 4 +- .../src/transforms/black_level.rs | 4 +- .../src/transforms/ca_correction.rs | 25 +- crates/rawshift-image/src/transforms/cfa.rs | 3 +- crates/rawshift-image/src/transforms/color.rs | 102 +++--- .../rawshift-image/src/transforms/denoise.rs | 34 +- .../src/transforms/lens_correction.rs | 29 +- .../rawshift-image/src/transforms/opcodes.rs | 13 +- .../src/transforms/orientation.rs | 102 ++++-- .../rawshift-image/src/transforms/tonemap.rs | 52 +-- .../tests/export_format_tests.rs | 19 +- crates/rawshift-image/tests/heic_aux.rs | 2 +- .../tests/standard_decode_fixtures.rs | 20 +- 50 files changed, 1296 insertions(+), 1020 deletions(-) delete mode 100644 crates/rawshift-core/src/pixel.rs create mode 100644 crates/rawshift-image/src/core/rgb_image.rs diff --git a/Cargo.lock b/Cargo.lock index 5e66a58..8e4bc00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -813,6 +813,14 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "795cbfc56d419a7ce47ccbb7504dd9a5b7c484c083c356e797de08bd988d9629" +[[package]] +name = "gamut-color" +version = "1.1.0" +source = "git+https://github.com/justin13888/gamut?rev=dde9f640ab02ec9c3437c3f1181164f6e7c60151#dde9f640ab02ec9c3437c3f1181164f6e7c60151" +dependencies = [ + "gamut-core", +] + [[package]] name = "gamut-core" version = "2.0.0" @@ -1827,6 +1835,7 @@ dependencies = [ name = "rawshift-core" version = "0.1.1" dependencies = [ + "gamut-color", "gamut-core", "serde", "serde_json", @@ -1844,6 +1853,7 @@ dependencies = [ "cmake", "criterion", "eyre", + "gamut-core", "gif", "image", "img-parts", diff --git a/Cargo.toml b/Cargo.toml index ebf1923..3ecbfaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ homepage = "https://github.com/justin13888/rawshift" # Bumping this pin is a deliberate, reviewed change — see README "Bumping the # gamut pin" for the required procedure. gamut-core = { git = "https://github.com/justin13888/gamut", rev = "dde9f640ab02ec9c3437c3f1181164f6e7c60151" } +gamut-color = { git = "https://github.com/justin13888/gamut", rev = "dde9f640ab02ec9c3437c3f1181164f6e7c60151" } # Shared infrastructure dependencies (used across multiple workspace crates). thiserror = "2.0" diff --git a/crates/rawshift-core/Cargo.toml b/crates/rawshift-core/Cargo.toml index 1a813cd..6c5a17e 100644 --- a/crates/rawshift-core/Cargo.toml +++ b/crates/rawshift-core/Cargo.toml @@ -13,10 +13,8 @@ categories = ["multimedia::images"] readme = "README.md" [dependencies] -# First lazily-added gamut crate. rawshift-core's primitives migrate onto it in -# the core-primitives issue; it is declared here now so the pinned dependency is -# resolved, fetched, and compiled by CI. gamut-core = { workspace = true } +gamut-color = { workspace = true } serde = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/rawshift-core/src/color.rs b/crates/rawshift-core/src/color.rs index 4ee2586..ab8f51d 100644 --- a/crates/rawshift-core/src/color.rs +++ b/crates/rawshift-core/src/color.rs @@ -1,92 +1,175 @@ -//! Color space and bit-depth descriptors shared across image and video. +//! Color descriptors shared across image and video. //! -//! These are lightweight *tags*, not a color-management engine. A [`ColorSpace`] -//! records *which* space a buffer of samples is in, so callers (and the optional -//! sRGB conversion in `rawshift-image`) can tell whether a transform is needed. -//! The precise source ICC profile, when one exists, is preserved separately as -//! raw bytes in [`ImageMetadata::icc_profile`](crate::metadata::ImageMetadata). +//! These are lightweight *tags*, not a color-management engine. A +//! [`ColorDescription`] records *which* space a buffer of samples is in — as a +//! CICP (ITU-T H.273) code-point pair — so callers (and the optional sRGB +//! conversion in `rawshift-image`) can tell whether a transform is needed. The +//! precise source ICC profile, when one exists, is preserved separately as raw +//! bytes in [`ImageMetadata::icc_profile`](crate::metadata::ImageMetadata) and +//! is authoritative for spaces CICP cannot express (e.g. Adobe RGB). +//! +//! [`BitDepth`] is gamut's encode-side depth descriptor, re-exported so the +//! whole workspace shares one vocabulary. + +/// Bits per pixel sample of an encoded image (re-exported from `gamut-color`). +/// +/// `#[non_exhaustive]` upstream. `Ten` and `Twelve` are honoured by the +/// HDR-capable encoder backends; the 8-bit/16-bit backends reject them as +/// unsupported. Note the sensor-side depth on +/// [`RawImage`](crate::image::RawImage) stays a raw `u8` (12/14-bit sensors +/// have no encode-side equivalent here). +pub use gamut_color::BitDepth; -use crate::image::white_level_from_bit_depth; +pub use gamut_color::cicp::{ColourPrimaries, TransferCharacteristics}; -/// The color space a set of RGB samples is encoded in. +/// The color space a set of RGB samples is encoded in, as a CICP pair. /// -/// A coarse, `Copy` tag — deliberately not a full ICC profile. It is -/// `#[non_exhaustive]`: more spaces may be added without a breaking change. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[non_exhaustive] -pub enum ColorSpace { - /// sRGB primaries with the sRGB transfer function — standard display RGB. - Srgb, - /// sRGB primaries with a linear transfer function. +/// A coarse, `Copy` tag — deliberately not a full ICC profile. Spaces CICP +/// cannot express (Adobe RGB, ProPhoto RGB) are carried as +/// [`UNSPECIFIED`](Self::UNSPECIFIED) with the ICC profile in +/// [`ImageMetadata::icc_profile`](crate::metadata::ImageMetadata) as the +/// authority — the pair deliberately never lies about what the samples are. +/// +/// Both fields are `#[non_exhaustive]` enums upstream, so more code points may +/// appear without a breaking change here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ColorDescription { + /// CICP `ColourPrimaries` code point. + pub primaries: ColourPrimaries, + /// CICP `TransferCharacteristics` code point. + pub transfer: TransferCharacteristics, +} + +impl ColorDescription { + /// sRGB: BT.709 primaries + sRGB transfer — standard display RGB. + pub const SRGB: Self = Self { + primaries: ColourPrimaries::Bt709, + transfer: TransferCharacteristics::Srgb, + }; + + /// Linear sRGB: BT.709 primaries + linear transfer. /// /// The working space of the RAW development pipeline and the documented /// input of the encode functions. - #[default] - LinearSrgb, - /// Display P3: DCI-P3 primaries with the sRGB transfer function. - DisplayP3, - /// ITU-R BT.2020 primaries. - Rec2020, - /// Adobe RGB (1998). - AdobeRgb, - /// ROMM RGB / ProPhoto RGB (wide gamut). - ProPhotoRgb, - /// The color space could not be determined. + pub const LINEAR_SRGB: Self = Self { + primaries: ColourPrimaries::Bt709, + transfer: TransferCharacteristics::Linear, + }; + + /// Display P3: DCI-P3 primaries + sRGB transfer. + pub const DISPLAY_P3: Self = Self { + primaries: ColourPrimaries::DisplayP3, + transfer: TransferCharacteristics::Srgb, + }; + + /// ITU-R BT.2020 primaries, transfer unspecified. + pub const REC2020: Self = Self { + primaries: ColourPrimaries::Bt2020, + transfer: TransferCharacteristics::Unspecified, + }; + + /// The color space could not be determined, or has no CICP expression. /// - /// Conversions treat this as [`Srgb`](Self::Srgb) on a best-effort basis. - Unknown, -} + /// Conversions treat this as [`SRGB`](Self::SRGB) on a best-effort basis. + /// ICC-authoritative spaces (Adobe RGB, ProPhoto RGB) are tagged with this + /// value: their truth lives in the preserved ICC profile, and inventing a + /// wrong code-point pair for them would misdescribe the samples. + pub const UNSPECIFIED: Self = Self { + primaries: ColourPrimaries::Unspecified, + transfer: TransferCharacteristics::Unspecified, + }; -impl ColorSpace { - /// A short human-readable name, e.g. for logging or UI pickers. + /// A short human-readable name for the well-known pairs, e.g. for logging. pub fn name(self) -> &'static str { match self { - ColorSpace::Srgb => "sRGB", - ColorSpace::LinearSrgb => "Linear sRGB", - ColorSpace::DisplayP3 => "Display P3", - ColorSpace::Rec2020 => "Rec. 2020", - ColorSpace::AdobeRgb => "Adobe RGB", - ColorSpace::ProPhotoRgb => "ProPhoto RGB", - ColorSpace::Unknown => "Unknown", + Self::SRGB => "sRGB", + Self::LINEAR_SRGB => "Linear sRGB", + Self::DISPLAY_P3 => "Display P3", + Self::REC2020 => "Rec. 2020", + Self::UNSPECIFIED => "Unspecified", + _ => "CICP", } } + + /// The CICP code-point pair `(primaries, transfer)`. + /// + /// The wire form used by the manual serde implementation and by container + /// `colr`/nclx boxes. + pub fn code_points(self) -> (u16, u16) { + (self.primaries.code_point(), self.transfer.code_point()) + } + + /// Reconstruct from CICP code points, `None` if either point is not + /// modelled by gamut-color (a later gamut release may turn a `None` into a + /// `Some`). + pub fn from_code_points(primaries: u16, transfer: u16) -> Option { + Some(Self { + primaries: ColourPrimaries::from_code_point(primaries)?, + transfer: TransferCharacteristics::from_code_point(transfer)?, + }) + } } -/// Bits per pixel sample of an encoded image. -/// -/// `#[non_exhaustive]`: further variants can be added without a breaking change. -/// `Ten` and `Twelve` are honoured by the HDR-capable encoder backends (e.g. -/// libaom AVIF); the 8-bit/16-bit backends reject them as unsupported. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[non_exhaustive] -pub enum BitDepth { - /// 8 bits per sample. - Eight, - /// 10 bits per sample. High-bit-depth output (e.g. AV1/AVIF). - Ten, - /// 12 bits per sample. High-bit-depth output (e.g. AV1/AVIF). - Twelve, - /// 16 bits per sample. - #[default] - Sixteen, +impl Default for ColorDescription { + /// [`LINEAR_SRGB`](Self::LINEAR_SRGB) — the pipeline working space. + fn default() -> Self { + Self::LINEAR_SRGB + } } -impl BitDepth { - /// Number of bits per sample. - pub fn bits(self) -> u8 { - match self { - BitDepth::Eight => 8, - BitDepth::Ten => 10, - BitDepth::Twelve => 12, - BitDepth::Sixteen => 16, +// Manual serde over the CICP code points: the gamut enums do not (yet) derive +// serde (justin13888/gamut#257); the numeric pair is also the stable wire form, +// so this representation survives that issue landing. +#[cfg(feature = "serde")] +impl serde::Serialize for ColorDescription { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let (primaries, transfer) = self.code_points(); + let mut s = serializer.serialize_struct("ColorDescription", 2)?; + s.serialize_field("primaries", &primaries)?; + s.serialize_field("transfer", &transfer)?; + s.end() + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for ColorDescription { + fn deserialize>(deserializer: D) -> Result { + #[derive(serde::Deserialize)] + struct Wire { + primaries: u16, + transfer: u16, } + let w = Wire::deserialize(deserializer)?; + ColorDescription::from_code_points(w.primaries, w.transfer).ok_or_else(|| { + serde::de::Error::custom(format!( + "unmodelled CICP code points: primaries {} / transfer {}", + w.primaries, w.transfer + )) + }) + } +} + +/// Serde adapter for the re-exported [`BitDepth`], which carries no serde +/// derives upstream (justin13888/gamut#257). Serializes as the bit count. +/// +/// Use on struct fields: +/// `#[cfg_attr(feature = "serde", serde(with = "rawshift_core::color::bit_depth_serde"))]` +#[cfg(feature = "serde")] +pub mod bit_depth_serde { + use super::BitDepth; + use serde::Deserialize; + + /// Serialize as the number of bits (`8`, `10`, `12`, `16`). + pub fn serialize(v: &BitDepth, s: S) -> Result { + s.serialize_u8(v.bits()) } - /// Maximum representable sample value, clamped to `u16`. - pub fn max_value(self) -> u16 { - white_level_from_bit_depth(self.bits()) + /// Deserialize from the number of bits; rejects unmodelled depths. + pub fn deserialize<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let bits = u8::deserialize(d)?; + BitDepth::from_bits(u32::from(bits)) + .ok_or_else(|| serde::de::Error::custom(format!("unsupported bit depth: {bits}"))) } } @@ -104,13 +187,67 @@ mod tests { assert_eq!(BitDepth::Ten.max_value(), 1023); assert_eq!(BitDepth::Twelve.max_value(), 4095); assert_eq!(BitDepth::Sixteen.max_value(), u16::MAX); - assert_eq!(BitDepth::default(), BitDepth::Sixteen); } #[test] - fn color_space_defaults_and_names() { - assert_eq!(ColorSpace::default(), ColorSpace::LinearSrgb); - assert_eq!(ColorSpace::Srgb.name(), "sRGB"); - assert_eq!(ColorSpace::Unknown.name(), "Unknown"); + fn color_description_defaults_names_and_code_points() { + assert_eq!(ColorDescription::default(), ColorDescription::LINEAR_SRGB); + assert_eq!(ColorDescription::SRGB.name(), "sRGB"); + assert_eq!(ColorDescription::LINEAR_SRGB.name(), "Linear sRGB"); + assert_eq!(ColorDescription::UNSPECIFIED.name(), "Unspecified"); + // The pairs are the H.273 code points. + assert_eq!(ColorDescription::SRGB.code_points(), (1, 13)); + assert_eq!(ColorDescription::LINEAR_SRGB.code_points(), (1, 8)); + assert_eq!(ColorDescription::DISPLAY_P3.code_points(), (12, 13)); + assert_eq!(ColorDescription::REC2020.code_points(), (9, 2)); + assert_eq!(ColorDescription::UNSPECIFIED.code_points(), (2, 2)); + } + + #[test] + fn color_description_code_point_round_trip() { + for desc in [ + ColorDescription::SRGB, + ColorDescription::LINEAR_SRGB, + ColorDescription::DISPLAY_P3, + ColorDescription::REC2020, + ColorDescription::UNSPECIFIED, + ] { + let (p, t) = desc.code_points(); + assert_eq!(ColorDescription::from_code_points(p, t), Some(desc)); + } + // Unmodelled points refuse to construct rather than guessing. + assert_eq!(ColorDescription::from_code_points(3, 13), None); + assert_eq!(ColorDescription::from_code_points(1, 3), None); + } + + #[cfg(feature = "serde")] + #[test] + fn color_description_serde_round_trip() { + let json = serde_json::to_string(&ColorDescription::LINEAR_SRGB).unwrap(); + assert_eq!(json, r#"{"primaries":1,"transfer":8}"#); + let back: ColorDescription = serde_json::from_str(&json).unwrap(); + assert_eq!(back, ColorDescription::LINEAR_SRGB); + // Unmodelled code points are a deserialization error, not a guess. + assert!( + serde_json::from_str::(r#"{"primaries":3,"transfer":13}"#).is_err() + ); + } + + #[cfg(feature = "serde")] + #[test] + fn bit_depth_serde_round_trip() { + #[derive(serde::Serialize, serde::Deserialize)] + struct Holder { + #[serde(with = "super::bit_depth_serde")] + depth: BitDepth, + } + let json = serde_json::to_string(&Holder { + depth: BitDepth::Twelve, + }) + .unwrap(); + assert_eq!(json, r#"{"depth":12}"#); + let back: Holder = serde_json::from_str(&json).unwrap(); + assert_eq!(back.depth, BitDepth::Twelve); + assert!(serde_json::from_str::(r#"{"depth":13}"#).is_err()); } } diff --git a/crates/rawshift-core/src/image.rs b/crates/rawshift-core/src/image.rs index 174c055..e54a64d 100644 --- a/crates/rawshift-core/src/image.rs +++ b/crates/rawshift-core/src/image.rs @@ -1,9 +1,17 @@ //! Core image structures and types. //! -//! This module defines the fundamental structures for representing -//! image dimensions, coordinates, and raw image data. +//! This module defines the fundamental structures for representing image +//! dimensions, coordinates, and raw sensor data. Pixel dimensions are gamut's +//! [`Dimensions`]; the sensor-specific vocabulary ([`RawImage`], +//! [`CfaPattern`], [`XTransPattern`]) is rawshift's own — a Bayer/X-Trans +//! mosaic has no gamut equivalent. -use crate::color::ColorSpace; +/// Image dimensions in pixels (re-exported from `gamut-core`). +/// +/// Fields are public `u32`s; [`Dimensions::new`] is fallible and rejects +/// zero-sized images, while struct-literal construction is unvalidated for +/// call sites that permit zero (e.g. probes of degenerate headers). +pub use gamut_core::Dimensions; /// Compute the maximum pixel value (white level) for a given bit depth, clamped to `u16`. /// @@ -19,31 +27,15 @@ pub fn white_level_from_bit_depth(bit_depth: u8) -> u16 { } } -/// Image dimensions. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Size { - /// Width in pixels - pub width: u32, - /// Height in pixels - pub height: u32, -} - -impl Size { - /// Create a new Size. - pub fn new(width: u32, height: u32) -> Self { - Self { width, height } - } - - /// Check if dimensions are valid (non-zero). - pub fn is_valid(&self) -> bool { - self.width > 0 && self.height > 0 - } - - /// Total number of pixels. - pub fn pixel_count(&self) -> u64 { - self.width as u64 * self.height as u64 - } +/// Number of pixels in `dims` as a `usize`, for buffer allocation. +/// +/// Zero-sized dimensions yield 0. Panics if the product overflows `usize` +/// (impossible on 64-bit targets; on 32-bit it means an allocation that could +/// never succeed anyway). +#[inline] +pub(crate) fn pixel_count(dims: Dimensions) -> usize { + dims.num_pixels() + .expect("pixel count overflows usize on this target") } /// A point in image coordinates. @@ -66,19 +58,18 @@ impl Point { pub const ORIGIN: Point = Point { x: 0, y: 0 }; } -/// A rectangular region. +/// A rectangular region: an origin plus [`Dimensions`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Rect { /// Origin (top-left corner) pub origin: Point, /// Size of the rectangle - pub size: Size, + pub size: Dimensions, } impl Rect { /// Create a new Rect. - pub fn new(origin: Point, size: Size) -> Self { + pub fn new(origin: Point, size: Dimensions) -> Self { Self { origin, size } } @@ -86,7 +77,7 @@ impl Rect { pub fn from_coords(x: u32, y: u32, width: u32, height: u32) -> Self { Self { origin: Point::new(x, y), - size: Size::new(width, height), + size: Dimensions { width, height }, } } @@ -101,6 +92,72 @@ impl Rect { } } +// Manual serde: `Dimensions` is a gamut type without serde derives +// (justin13888/gamut#257), so `Rect` flattens to four integers on the wire — +// which is also the more natural stable form. +#[cfg(feature = "serde")] +impl serde::Serialize for Rect { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut s = serializer.serialize_struct("Rect", 4)?; + s.serialize_field("x", &self.origin.x)?; + s.serialize_field("y", &self.origin.y)?; + s.serialize_field("width", &self.size.width)?; + s.serialize_field("height", &self.size.height)?; + s.end() + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Rect { + fn deserialize>(deserializer: D) -> Result { + #[derive(serde::Deserialize)] + struct Wire { + x: u32, + y: u32, + width: u32, + height: u32, + } + let w = Wire::deserialize(deserializer)?; + Ok(Rect::from_coords(w.x, w.y, w.width, w.height)) + } +} + +/// Serde adapter for the re-exported [`Dimensions`], which carries no serde +/// derives upstream (justin13888/gamut#257). +/// +/// Use on struct fields: +/// `#[cfg_attr(feature = "serde", serde(with = "rawshift_core::image::dimensions_serde"))]` +#[cfg(feature = "serde")] +pub mod dimensions_serde { + use super::Dimensions; + use serde::Deserialize; + + /// Serialize as `{ "width": u32, "height": u32 }`. + pub fn serialize(v: &Dimensions, s: S) -> Result { + use serde::ser::SerializeStruct; + let mut st = s.serialize_struct("Dimensions", 2)?; + st.serialize_field("width", &v.width)?; + st.serialize_field("height", &v.height)?; + st.end() + } + + /// Deserialize from `{ "width": u32, "height": u32 }` (zero permitted, as + /// with struct-literal construction). + pub fn deserialize<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + #[derive(serde::Deserialize)] + struct Wire { + width: u32, + height: u32, + } + let w = Wire::deserialize(d)?; + Ok(Dimensions { + width: w.width, + height: w.height, + }) + } +} + /// CFA (Color Filter Array) pattern. /// /// Represents the Bayer pattern used in the camera's sensor. @@ -193,7 +250,7 @@ impl XTransPattern { /// Use [`RawImageBuilder`] to construct new instances. #[derive(Debug, Clone)] pub struct RawImage { - size: Size, + size: Dimensions, active_area: Rect, bit_depth: u8, cfa_pattern: CfaPattern, @@ -209,8 +266,12 @@ pub struct RawImage { impl RawImage { /// Create a new empty RawImage with the given parameters. - pub fn new(size: Size, active_area: Rect, bit_depth: u8, cfa_pattern: CfaPattern) -> Self { - let pixel_count = size.pixel_count() as usize; + pub fn new( + size: Dimensions, + active_area: Rect, + bit_depth: u8, + cfa_pattern: CfaPattern, + ) -> Self { Self { size, active_area, @@ -219,7 +280,7 @@ impl RawImage { xtrans_pattern: None, black_levels: [0; 4], white_level: white_level_from_bit_depth(bit_depth), - data: vec![0u16; pixel_count], + data: vec![0u16; pixel_count(size)], baseline_exposure: None, default_crop: None, } @@ -227,7 +288,7 @@ impl RawImage { /// Create a builder for constructing a RawImage. pub fn builder( - size: Size, + size: Dimensions, active_area: Rect, bit_depth: u8, cfa_pattern: CfaPattern, @@ -249,7 +310,7 @@ impl RawImage { // ── Read accessors ─────────────────────────────────────────────────── /// Full sensor dimensions. - pub fn size(&self) -> Size { + pub fn size(&self) -> Dimensions { self.size } @@ -358,7 +419,7 @@ impl RawImage { /// Builder for constructing [`RawImage`] instances. pub struct RawImageBuilder { - size: Size, + size: Dimensions, active_area: Rect, bit_depth: u8, cfa_pattern: CfaPattern, @@ -411,7 +472,7 @@ impl RawImageBuilder { pub fn build(self) -> RawImage { let data = self .data - .unwrap_or_else(|| vec![0u16; self.size.pixel_count() as usize]); + .unwrap_or_else(|| vec![0u16; pixel_count(self.size)]); RawImage { size: self.size, active_area: self.active_area, @@ -427,103 +488,6 @@ impl RawImageBuilder { } } -/// A simple container for RGB image data. -#[derive(Debug, Clone)] -pub struct RgbImage { - size: Size, - /// Interleaved RGB data (R, G, B, R, G, B...) - pub data: Vec, - baseline_exposure: Option, - default_crop: Option, - color_space: ColorSpace, -} - -impl RgbImage { - /// Create a new RgbImage with an unknown color space. - /// - /// Use [`with_color_space`](Self::with_color_space) or - /// [`set_color_space`](Self::set_color_space) when the space is known. - pub fn new(width: u32, height: u32, data: Vec) -> Self { - Self { - size: Size::new(width, height), - data, - baseline_exposure: None, - default_crop: None, - color_space: ColorSpace::Unknown, - } - } - - /// Create a new RgbImage tagged with a known color space. - pub fn with_color_space( - width: u32, - height: u32, - data: Vec, - color_space: ColorSpace, - ) -> Self { - Self { - size: Size::new(width, height), - data, - baseline_exposure: None, - default_crop: None, - color_space, - } - } - - // ── Read accessors ─────────────────────────────────────────────────── - - /// Image dimensions. - pub fn size(&self) -> Size { - self.size - } - - /// Image width in pixels. - pub fn width(&self) -> u32 { - self.size.width - } - - /// Image height in pixels. - pub fn height(&self) -> u32 { - self.size.height - } - - /// Baseline exposure offset in EV. - pub fn baseline_exposure(&self) -> Option { - self.baseline_exposure - } - - /// Default crop rectangle. - pub fn default_crop(&self) -> Option { - self.default_crop - } - - /// The color space the RGB samples are in. - pub fn color_space(&self) -> ColorSpace { - self.color_space - } - - // ── Write accessors ────────────────────────────────────────────────── - - /// Set baseline exposure offset. - pub fn set_baseline_exposure(&mut self, ev: Option) { - self.baseline_exposure = ev; - } - - /// Set the color space tag for the RGB samples. - pub fn set_color_space(&mut self, color_space: ColorSpace) { - self.color_space = color_space; - } - - /// Set default crop rectangle. - pub fn set_default_crop(&mut self, crop: Option) { - self.default_crop = crop; - } - - /// Set image dimensions (used by orientation transforms). - pub fn set_size(&mut self, size: Size) { - self.size = size; - } -} - #[cfg(test)] mod tests { use super::*; @@ -543,13 +507,24 @@ mod tests { } #[test] - fn test_size() { - let size = Size::new(100, 200); - assert_eq!(size.pixel_count(), 20000); - assert!(size.is_valid()); - - let empty = Size::new(0, 100); - assert!(!empty.is_valid()); + fn test_dimensions() { + let size = Dimensions { + width: 100, + height: 200, + }; + assert_eq!(pixel_count(size), 20000); + assert!(!size.is_empty()); + + let empty = Dimensions { + width: 0, + height: 100, + }; + assert!(empty.is_empty()); + assert_eq!(pixel_count(empty), 0); + + // The validating constructor rejects zero sizes. + assert!(Dimensions::new(0, 100).is_err()); + assert!(Dimensions::new(100, 200).is_ok()); } #[test] @@ -561,7 +536,10 @@ mod tests { #[test] fn test_raw_image() { - let size = Size::new(10, 10); + let size = Dimensions { + width: 10, + height: 10, + }; let active = Rect::from_coords(0, 0, 10, 10); let mut img = RawImage::new(size, active, 14, CfaPattern::Rggb); @@ -572,7 +550,10 @@ mod tests { #[test] fn test_raw_image_pixel_access() { - let size = Size::new(4, 4); + let size = Dimensions { + width: 4, + height: 4, + }; let active = Rect::from_coords(0, 0, 4, 4); let mut img = RawImage::new(size, active, 14, CfaPattern::Rggb); @@ -595,41 +576,6 @@ mod tests { assert_eq!(img.get_pixel(u32::MAX, u32::MAX), None); } - #[test] - fn test_rgb_image_indexing() { - // RgbImage stores interleaved RGB: R G B R G B ... - let data = vec![ - 100u16, 200, 300, // pixel 0: R=100, G=200, B=300 - 400, 500, 600, // pixel 1: R=400, G=500, B=600 - ]; - let img = RgbImage::new(2, 1, data.clone()); - - assert_eq!(img.data[0], 100, "pixel 0 R"); - assert_eq!(img.data[1], 200, "pixel 0 G"); - assert_eq!(img.data[2], 300, "pixel 0 B"); - assert_eq!(img.data[3], 400, "pixel 1 R"); - assert_eq!(img.data[4], 500, "pixel 1 G"); - assert_eq!(img.data[5], 600, "pixel 1 B"); - - assert_eq!(img.width(), 2); - assert_eq!(img.height(), 1); - assert_eq!(img.data.len(), 6); - } - - #[test] - fn test_size_pixel_count() { - let s = Size::new(1920, 1080); - assert_eq!(s.pixel_count(), 1920 * 1080); - - // Zero dimension - let s = Size::new(0, 100); - assert_eq!(s.pixel_count(), 0); - - // Large dimensions (check u64 doesn't overflow) - let s = Size::new(10000, 10000); - assert_eq!(s.pixel_count(), 100_000_000u64); - } - #[test] fn test_rect_dimensions() { let r = Rect::from_coords(10, 20, 100, 200); @@ -643,7 +589,10 @@ mod tests { #[test] fn test_raw_image_builder() { - let size = Size::new(10, 10); + let size = Dimensions { + width: 10, + height: 10, + }; let active = Rect::from_coords(0, 0, 10, 10); let img = RawImage::builder(size, active, 14, CfaPattern::Rggb) .black_levels([100, 100, 100, 100]) @@ -661,7 +610,10 @@ mod tests { #[test] fn test_raw_image_builder_with_data() { - let size = Size::new(2, 2); + let size = Dimensions { + width: 2, + height: 2, + }; let active = Rect::from_coords(0, 0, 2, 2); let img = RawImage::builder(size, active, 14, CfaPattern::Rggb) .data(vec![1000, 2000, 3000, 4000]) @@ -670,19 +622,12 @@ mod tests { assert_eq!(img.data, vec![1000, 2000, 3000, 4000]); } - #[test] - fn test_rgb_image_accessors() { - let img = RgbImage::new(100, 200, vec![0u16; 100 * 200 * 3]); - assert_eq!(img.width(), 100); - assert_eq!(img.height(), 200); - assert_eq!(img.size(), Size::new(100, 200)); - assert_eq!(img.baseline_exposure(), None); - assert_eq!(img.default_crop(), None); - } - #[test] fn test_raw_image_setters() { - let size = Size::new(4, 4); + let size = Dimensions { + width: 4, + height: 4, + }; let active = Rect::from_coords(0, 0, 4, 4); let mut img = RawImage::new(size, active, 14, CfaPattern::Rggb); @@ -702,4 +647,14 @@ mod tests { img.set_xtrans_pattern(Some(XTransPattern::standard())); assert!(img.xtrans_pattern().is_some()); } + + #[cfg(feature = "serde")] + #[test] + fn rect_serde_round_trip() { + let r = Rect::from_coords(10, 20, 100, 200); + let json = serde_json::to_string(&r).unwrap(); + assert_eq!(json, r#"{"x":10,"y":20,"width":100,"height":200}"#); + let back: Rect = serde_json::from_str(&json).unwrap(); + assert_eq!(back, r); + } } diff --git a/crates/rawshift-core/src/lib.rs b/crates/rawshift-core/src/lib.rs index efe1a0d..c409a95 100644 --- a/crates/rawshift-core/src/lib.rs +++ b/crates/rawshift-core/src/lib.rs @@ -1,9 +1,17 @@ //! Shared core types for the rawshift image/video processing libraries. //! //! This crate holds pure, stateless data structures with no decoding logic: -//! geometry ([`image::Size`], [`image::Point`], [`image::Rect`]), pixel sample -//! types ([`pixel`]), CFA patterns, the raw/RGB image containers, and the -//! format-agnostic [`metadata`] model. +//! geometry ([`image::Dimensions`], [`image::Point`], [`image::Rect`]), pixel +//! vocabulary (re-exported from `gamut-core`), CFA patterns, the raw sensor +//! container, and the format-agnostic [`metadata`] model. +//! +//! Generic image primitives come from [gamut](https://github.com/justin13888/gamut) +//! and are re-exported here rather than reimplemented (see the workspace +//! upstream-first policy): [`Dimensions`], the sealed [`Pixel`]/[`Sample`] +//! traits with their marker types ([`Rgb16`], [`Rgba8`], …), the [`ImageBuf`]/ +//! [`ImageRef`] containers, [`BitDepth`], and the CICP code-point enums behind +//! [`ColorDescription`]. rawshift-core adds only what gamut deliberately does +//! not model: the sensor vocabulary and the metadata model. //! //! # Charter //! @@ -20,7 +28,7 @@ //! **Genuinely video-shared** — media-agnostic, and video will consume these //! as-is: //! -//! - Geometry — [`image::Point`], [`image::Rect`], and frame dimensions. +//! - Geometry — [`image::Dimensions`], [`image::Point`], [`image::Rect`]. //! - Codec descriptors — [`codec::CodecId`], [`codec::CodecInfo`], //! [`codec::CodecDirection`]. A codec registry spans stills and video. //! - Metadata model — [`metadata::ImageMetadata`] and its @@ -29,8 +37,9 @@ //! same for a video file as for a still. //! - Rationals — [`metadata::URational`], [`metadata::SRational`]. These are the //! EXIF/TIFF wire representation, shared with the metadata model above. -//! - Color and bit depth descriptors, which describe a decoded frame regardless -//! of whether it came from a still or a video track. +//! - Color and bit depth descriptors ([`ColorDescription`], [`BitDepth`]), +//! which describe a decoded frame regardless of whether it came from a still +//! or a video track. //! //! **Stills-only** — present here for historical reasons, and not part of the //! shared vocabulary. These describe a Bayer/X-Trans sensor mosaic, a concept @@ -39,19 +48,27 @@ //! - [`image::RawImage`] and [`image::RawImageBuilder`]. //! - [`image::CfaPattern`], [`image::XTransPattern`], //! [`image::white_level_from_bit_depth`]. -//! - The RGB image container and the [`pixel`] sample types. #![forbid(unsafe_code)] pub mod codec; pub mod color; pub mod image; pub mod metadata; -pub mod pixel; pub use codec::{CodecDirection, CodecId, CodecInfo, MetadataEmbedOptions}; -pub use color::{BitDepth, ColorSpace}; -pub use image::XTransPattern; +pub use color::{BitDepth, ColorDescription, ColourPrimaries, TransferCharacteristics}; +pub use image::{ + CfaPattern, Dimensions, Point, RawImage, RawImageBuilder, Rect, XTransPattern, + white_level_from_bit_depth, +}; pub use metadata::{ - ImageMetadata, MetadataEntry, MetadataExtractor, MetadataKey, MetadataNamespace, MetadataValue, + ExtractMetadata, ImageMetadata, MetadataEntry, MetadataKey, MetadataNamespace, MetadataValue, +}; + +// Pixel vocabulary, re-exported from gamut-core. `Pixel` and `Sample` are +// sealed upstream: rawshift cannot (and should not) add pixel formats — an f32 +// working format stays transform-internal in rawshift-image. +pub use gamut_core::{ + Bilevel, Cmyk8, ColorModel, Gray8, Gray16, GrayAlpha8, GrayAlpha16, ImageBuf, ImageRef, + Indexed8, Pixel, PixelFormat, Rgb8, Rgb16, Rgba8, Rgba16, Sample, }; -pub use pixel::{FromF32, Rgb, Rgb8, Rgb16, RgbF32, Rgba, Rgba8, Rgba16, RgbaF32, Sample}; diff --git a/crates/rawshift-core/src/metadata.rs b/crates/rawshift-core/src/metadata.rs index 4645a88..21d2f5d 100644 --- a/crates/rawshift-core/src/metadata.rs +++ b/crates/rawshift-core/src/metadata.rs @@ -376,7 +376,7 @@ impl ImageMetadata { /// /// Implementors MUST provide metadata extraction for their format. /// The compiler enforces implementation; incomplete data is handled via Option. -pub trait MetadataExtractor { +pub trait ExtractMetadata { /// Extract unified metadata from the format-specific representation. fn extract_metadata(&self) -> ImageMetadata; } diff --git a/crates/rawshift-core/src/pixel.rs b/crates/rawshift-core/src/pixel.rs deleted file mode 100644 index ac7e0cf..0000000 --- a/crates/rawshift-core/src/pixel.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! Pixel type system for generic image processing. -//! -//! Provides traits and type aliases for working with different pixel -//! representations (u8, u16, f32) in a uniform way. - -/// Trait for scalar sample values used in image processing. -/// -/// Implemented for `u8`, `u16`, and `f32` — the common pixel sample types -/// in raw image processing pipelines. -pub trait Sample: - Copy + Clone + PartialOrd + Default + Send + Sync + 'static + Into + FromF32 -{ - /// The maximum representable value for this sample type. - const MAX: Self; - - /// The minimum representable value for this sample type. - const MIN: Self; - - /// The number of bits used to represent this sample. - const BIT_DEPTH: u8; - - /// Clamp a value to the valid range. - fn clamp_sample(self) -> Self; - - /// Convert from a normalized f32 value in [0.0, 1.0] to this sample type. - fn from_normalized(v: f32) -> Self; - - /// Convert this sample to a normalized f32 value in [0.0, 1.0]. - fn to_normalized(self) -> f32; -} - -/// Conversion trait from f32 to a sample type. -pub trait FromF32 { - fn from_f32(v: f32) -> Self; -} - -impl FromF32 for u8 { - #[inline] - fn from_f32(v: f32) -> Self { - v.round().clamp(0.0, 255.0) as u8 - } -} - -impl FromF32 for u16 { - #[inline] - fn from_f32(v: f32) -> Self { - v.round().clamp(0.0, 65535.0) as u16 - } -} - -impl FromF32 for f32 { - #[inline] - fn from_f32(v: f32) -> Self { - v - } -} - -impl Sample for u8 { - const MAX: Self = 255; - const MIN: Self = 0; - const BIT_DEPTH: u8 = 8; - - #[inline] - fn clamp_sample(self) -> Self { - self // u8 is always in range - } - - #[inline] - fn from_normalized(v: f32) -> Self { - (v * 255.0).round().clamp(0.0, 255.0) as u8 - } - - #[inline] - fn to_normalized(self) -> f32 { - self as f32 / 255.0 - } -} - -impl Sample for u16 { - const MAX: Self = 65535; - const MIN: Self = 0; - const BIT_DEPTH: u8 = 16; - - #[inline] - fn clamp_sample(self) -> Self { - self // u16 is always in range - } - - #[inline] - fn from_normalized(v: f32) -> Self { - (v * 65535.0).round().clamp(0.0, 65535.0) as u16 - } - - #[inline] - fn to_normalized(self) -> f32 { - self as f32 / 65535.0 - } -} - -impl Sample for f32 { - const MAX: Self = 1.0; - const MIN: Self = 0.0; - const BIT_DEPTH: u8 = 32; - - #[inline] - fn clamp_sample(self) -> Self { - self.clamp(0.0, 1.0) - } - - #[inline] - fn from_normalized(v: f32) -> Self { - v - } - - #[inline] - fn to_normalized(self) -> f32 { - self - } -} - -/// An RGB pixel with three components. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Rgb { - pub r: S, - pub g: S, - pub b: S, -} - -impl Rgb { - /// Create a new RGB pixel. - #[inline] - pub fn new(r: S, g: S, b: S) -> Self { - Self { r, g, b } - } - - /// Convert to normalized f32 RGB. - #[inline] - pub fn to_f32(self) -> Rgb { - Rgb { - r: self.r.to_normalized(), - g: self.g.to_normalized(), - b: self.b.to_normalized(), - } - } - - /// Convert from normalized f32 RGB. - #[inline] - pub fn from_f32(src: Rgb) -> Self { - Rgb { - r: S::from_normalized(src.r), - g: S::from_normalized(src.g), - b: S::from_normalized(src.b), - } - } -} - -impl Default for Rgb { - fn default() -> Self { - Self { - r: S::default(), - g: S::default(), - b: S::default(), - } - } -} - -/// An RGBA pixel with four components. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Rgba { - pub r: S, - pub g: S, - pub b: S, - pub a: S, -} - -impl Rgba { - /// Create a new RGBA pixel. - #[inline] - pub fn new(r: S, g: S, b: S, a: S) -> Self { - Self { r, g, b, a } - } - - /// Convert to RGB, discarding the alpha channel. - #[inline] - pub fn to_rgb(self) -> Rgb { - Rgb::new(self.r, self.g, self.b) - } -} - -impl Default for Rgba { - fn default() -> Self { - Self { - r: S::default(), - g: S::default(), - b: S::default(), - a: S::MAX, - } - } -} - -/// Convenience type aliases for common pixel representations. -pub type Rgb8 = Rgb; -pub type Rgb16 = Rgb; -pub type RgbF32 = Rgb; -pub type Rgba8 = Rgba; -pub type Rgba16 = Rgba; -pub type RgbaF32 = Rgba; - -/// Convert a slice of interleaved u16 RGB data to a Vec of Rgb16 pixels. -pub fn rgb16_from_interleaved(data: &[u16]) -> Vec { - debug_assert!(data.len().is_multiple_of(3)); - data.chunks_exact(3) - .map(|c| Rgb16::new(c[0], c[1], c[2])) - .collect() -} - -/// Convert a slice of Rgb16 pixels to interleaved u16 data. -pub fn rgb16_to_interleaved(pixels: &[Rgb16]) -> Vec { - let mut data = Vec::with_capacity(pixels.len() * 3); - for p in pixels { - data.push(p.r); - data.push(p.g); - data.push(p.b); - } - data -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_u8_sample() { - assert_eq!(u8::MAX, 255); - assert_eq!(u8::from_normalized(1.0), 255); - assert_eq!(u8::from_normalized(0.0), 0); - assert_eq!(u8::from_normalized(0.5), 128); - assert!((128u8.to_normalized() - 0.502).abs() < 0.01); - } - - #[test] - fn test_u16_sample() { - assert_eq!(u16::MAX, 65535); - assert_eq!(u16::from_normalized(1.0), 65535); - assert_eq!(u16::from_normalized(0.0), 0); - let half = u16::from_normalized(0.5); - assert!((half as i32 - 32768).abs() <= 1); - } - - #[test] - fn test_f32_sample() { - assert_eq!(f32::from_normalized(0.5), 0.5); - assert_eq!((0.5f32).to_normalized(), 0.5); - assert_eq!((1.5f32).clamp_sample(), 1.0); - assert_eq!((-0.5f32).clamp_sample(), 0.0); - } - - #[test] - fn test_rgb_pixel() { - let p = Rgb16::new(1000, 2000, 3000); - assert_eq!(p.r, 1000); - assert_eq!(p.g, 2000); - assert_eq!(p.b, 3000); - } - - #[test] - fn test_rgb_roundtrip() { - let p = Rgb16::new(1000, 2000, 3000); - let f = p.to_f32(); - let back = Rgb16::from_f32(f); - assert!((back.r as i32 - 1000).abs() <= 1); - assert!((back.g as i32 - 2000).abs() <= 1); - assert!((back.b as i32 - 3000).abs() <= 1); - } - - #[test] - fn test_rgba_default() { - let p = Rgba16::default(); - assert_eq!(p.r, 0); - assert_eq!(p.g, 0); - assert_eq!(p.b, 0); - assert_eq!(p.a, 65535); - } - - #[test] - fn test_interleaved_roundtrip() { - let data = vec![100u16, 200, 300, 400, 500, 600]; - let pixels = rgb16_from_interleaved(&data); - assert_eq!(pixels.len(), 2); - assert_eq!(pixels[0], Rgb16::new(100, 200, 300)); - let back = rgb16_to_interleaved(&pixels); - assert_eq!(back, data); - } -} diff --git a/crates/rawshift-image/Cargo.toml b/crates/rawshift-image/Cargo.toml index 2073b4d..461f17d 100644 --- a/crates/rawshift-image/Cargo.toml +++ b/crates/rawshift-image/Cargo.toml @@ -22,6 +22,7 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] rawshift-core = { workspace = true } +gamut-core = { workspace = true } binrw = { version = "0.15", optional = true } libheif-rs = { version = "2.7", optional = true } libwebp-sys = { version = "0.14", optional = true } diff --git a/crates/rawshift-image/benches/decode.rs b/crates/rawshift-image/benches/decode.rs index 4844896..1bdf0cd 100644 --- a/crates/rawshift-image/benches/decode.rs +++ b/crates/rawshift-image/benches/decode.rs @@ -1,7 +1,7 @@ //! Benchmarks for RAW image data structure creation and basic operations. use criterion::{Criterion, criterion_group, criterion_main}; -use rawshift_image::core::image::{CfaPattern, Point, RawImage, Rect, Size}; +use rawshift_image::core::image::{CfaPattern, Dimensions, Point, RawImage, Rect}; /// Benchmark creating a RawImage (allocation + init). fn bench_raw_image_creation(c: &mut Criterion) { @@ -10,7 +10,10 @@ fn bench_raw_image_creation(c: &mut Criterion) { for &(w, h) in &[(1000, 1000), (4000, 3000), (8000, 6000)] { group.bench_function(format!("{}x{}", w, h), |b| { b.iter(|| { - let size = Size::new(w, h); + let size = Dimensions { + width: w, + height: h, + }; let area = Rect::new(Point::ORIGIN, size); RawImage::new(size, area, 14, CfaPattern::Rggb) }); @@ -22,7 +25,10 @@ fn bench_raw_image_creation(c: &mut Criterion) { /// Benchmark pixel access patterns. fn bench_pixel_access(c: &mut Criterion) { - let size = Size::new(4000, 3000); + let size = Dimensions { + width: 4000, + height: 3000, + }; let area = Rect::new(Point::ORIGIN, size); let raw = RawImage::new(size, area, 14, CfaPattern::Rggb); diff --git a/crates/rawshift-image/benches/demosaic.rs b/crates/rawshift-image/benches/demosaic.rs index 3970a00..6459fb5 100644 --- a/crates/rawshift-image/benches/demosaic.rs +++ b/crates/rawshift-image/benches/demosaic.rs @@ -1,11 +1,11 @@ //! Benchmarks for demosaicing algorithms. use criterion::{Criterion, criterion_group, criterion_main}; -use rawshift_image::core::image::{CfaPattern, Point, RawImage, Rect, Size}; +use rawshift_image::core::image::{CfaPattern, Dimensions, Point, RawImage, Rect}; use rawshift_image::processing::demosaic::{Bilinear, Demosaic, bayer::Amaze}; fn create_test_raw(width: u32, height: u32) -> RawImage { - let size = Size::new(width, height); + let size = Dimensions { width, height }; let area = Rect::new(Point::ORIGIN, size); let pixel_count = (width * height) as usize; let mut data = vec![0u16; pixel_count]; diff --git a/crates/rawshift-image/benches/pipeline.rs b/crates/rawshift-image/benches/pipeline.rs index 5513502..a6227fb 100644 --- a/crates/rawshift-image/benches/pipeline.rs +++ b/crates/rawshift-image/benches/pipeline.rs @@ -1,14 +1,15 @@ //! Benchmarks for the processing pipeline stages. use criterion::{Criterion, criterion_group, criterion_main}; -use rawshift_image::core::image::{CfaPattern, Point, RawImage, Rect, RgbImage, Size}; +use rawshift_image::core::RgbImage; +use rawshift_image::core::image::{CfaPattern, Dimensions, Point, RawImage, Rect}; use rawshift_image::processing::color::{apply_color_matrix, apply_white_balance}; use rawshift_image::transforms::black_level::apply_black_level; use rawshift_image::transforms::color::compute_camera_to_srgb; use rawshift_image::transforms::tonemap::apply_tone_reproduction; fn create_test_raw(width: u32, height: u32) -> RawImage { - let size = Size::new(width, height); + let size = Dimensions { width, height }; let area = Rect::new(Point::ORIGIN, size); let pixel_count = (width * height) as usize; let data = vec![5000u16; pixel_count]; @@ -22,7 +23,7 @@ fn create_test_raw(width: u32, height: u32) -> RawImage { fn create_test_rgb(width: u32, height: u32) -> RgbImage { let data = vec![5000u16; (width * height * 3) as usize]; - RgbImage::new(width, height, data) + RgbImage::new(width, height, data).expect("valid RGB buffer") } fn bench_black_level(c: &mut Criterion) { diff --git a/crates/rawshift-image/examples/decode_standard.rs b/crates/rawshift-image/examples/decode_standard.rs index 49dabf3..c397dff 100644 --- a/crates/rawshift-image/examples/decode_standard.rs +++ b/crates/rawshift-image/examples/decode_standard.rs @@ -72,11 +72,11 @@ fn main() -> Result<(), Box> { image.height(), image.width() as u64 * image.height() as u64 ); - println!("Pixel data length: {} u16 values", image.data.len()); + println!("Pixel data length: {} u16 values", image.data().len()); if let Some(out_path) = save_raw { // Convert u16 to u8 bytes (little-endian) and write - let bytes: Vec = image.data.iter().flat_map(|&v| v.to_le_bytes()).collect(); + let bytes: Vec = image.data().iter().flat_map(|&v| v.to_le_bytes()).collect(); std::fs::write(&out_path, &bytes)?; println!( "Saved {} bytes of raw pixel data to {:?}", diff --git a/crates/rawshift-image/examples/encode_in_memory.rs b/crates/rawshift-image/examples/encode_in_memory.rs index 36bfa68..973f452 100644 --- a/crates/rawshift-image/examples/encode_in_memory.rs +++ b/crates/rawshift-image/examples/encode_in_memory.rs @@ -19,7 +19,7 @@ fn main() -> Result<(), Box> { data.extend_from_slice(&[r, g, 32768]); } } - let image = RgbImage::new(width, height, data); + let image = RgbImage::new(width, height, data).expect("valid RGB buffer"); let metadata = ImageMetadata::default(); println!("Encoders compiled into this build:"); diff --git a/crates/rawshift-image/examples/generate_test_fixtures.rs b/crates/rawshift-image/examples/generate_test_fixtures.rs index 16d27d9..fbd3675 100644 --- a/crates/rawshift-image/examples/generate_test_fixtures.rs +++ b/crates/rawshift-image/examples/generate_test_fixtures.rs @@ -13,7 +13,7 @@ use std::fs; use std::io::Cursor; use std::path::{Path, PathBuf}; -use rawshift_image::core::image::RgbImage; +use rawshift_image::core::RgbImage; use rawshift_image::core::metadata::{ CameraInfo, DateTimeInfo, ExifInfo, GpsInfo, ImageInfo, ImageMetadata, SRational, URational, }; @@ -203,7 +203,7 @@ fn generate_jpeg(data_dir: &Path, fixture_dir: &Path) { let (w, h, pixels_u8) = reference_pixels_u8(); let pixels_u16 = pixels_u8_to_u16(&pixels_u8); - let img = RgbImage::new(w, h, pixels_u16); + let img = RgbImage::new(w, h, pixels_u16).expect("valid RGB buffer"); let name = "test_8x8.jpg"; encode_rgb_image( @@ -226,7 +226,7 @@ fn generate_png(data_dir: &Path, fixture_dir: &Path) { let (w, h, pixels_u8) = reference_pixels_u8(); let pixels_u16 = pixels_u8_to_u16(&pixels_u8); - let img = RgbImage::new(w, h, pixels_u16); + let img = RgbImage::new(w, h, pixels_u16).expect("valid RGB buffer"); let name = "test_8x8.png"; encode_rgb_image( @@ -309,7 +309,7 @@ fn generate_webp(data_dir: &Path, fixture_dir: &Path) { let (w, h, pixels_u8) = reference_pixels_u8(); let pixels_u16 = pixels_u8_to_u16(&pixels_u8); - let img = RgbImage::new(w, h, pixels_u16); + let img = RgbImage::new(w, h, pixels_u16).expect("valid RGB buffer"); let name = "test_8x8.webp"; encode_rgb_image( @@ -352,7 +352,7 @@ fn generate_avif(data_dir: &Path, fixture_dir: &Path) { let (w, h, pixels_u8) = reference_pixels_u8(); let pixels_u16 = pixels_u8_to_u16(&pixels_u8); - let img = RgbImage::new(w, h, pixels_u16); + let img = RgbImage::new(w, h, pixels_u16).expect("valid RGB buffer"); let name = "test_8x8.avif"; encode_rgb_image( @@ -376,7 +376,7 @@ fn generate_avif_libaom(data_dir: &Path, fixture_dir: &Path) { let (w, h, pixels_u8) = reference_pixels_u8(); let pixels_u16 = pixels_u8_to_u16(&pixels_u8); - let img = RgbImage::new(w, h, pixels_u16); + let img = RgbImage::new(w, h, pixels_u16).expect("valid RGB buffer"); // Default libaom config: 10-bit, 4:4:4. let name = "test_8x8.avif"; @@ -401,7 +401,7 @@ fn generate_jxl(data_dir: &Path, fixture_dir: &Path) { let (w, h, pixels_u8) = reference_pixels_u8(); let pixels_u16 = pixels_u8_to_u16(&pixels_u8); - let img = RgbImage::new(w, h, pixels_u16); + let img = RgbImage::new(w, h, pixels_u16).expect("valid RGB buffer"); let name = "test_8x8.jxl"; encode_rgb_image( diff --git a/crates/rawshift-image/src/core/mod.rs b/crates/rawshift-image/src/core/mod.rs index 4ff66c2..04a8580 100644 --- a/crates/rawshift-image/src/core/mod.rs +++ b/crates/rawshift-image/src/core/mod.rs @@ -1,12 +1,19 @@ //! Core types and traits for image processing. //! -//! These types live in the [`rawshift_core`] crate and are re-exported here so +//! Most types live in the [`rawshift_core`] crate and are re-exported here so //! that `rawshift`'s public path `rawshift::core::…` (and internal -//! `crate::core::…` paths) stay stable. [`IccProfile`] is additionally surfaced -//! here from the internal `metadata` module. +//! `crate::core::…` paths) stay stable. [`RgbImage`] is defined here — it is a +//! stills-only container (see the rawshift-core charter) wrapping gamut's +//! validated `ImageBuf`. [`IccProfile`] is additionally surfaced here +//! from the internal `metadata` module. + +mod rgb_image; pub use rawshift_core::*; +pub use rgb_image::RgbImage; // Re-export IccProfile from the internal metadata module so it remains // publicly accessible under `core` as before the workspace split. +// The type is replaced by `gamut_icc::IccProfile` in the metadata-stack +// migration (#19), which owns the icc.rs internals wholesale. pub use crate::metadata::icc::IccProfile; diff --git a/crates/rawshift-image/src/core/rgb_image.rs b/crates/rawshift-image/src/core/rgb_image.rs new file mode 100644 index 0000000..529bce5 --- /dev/null +++ b/crates/rawshift-image/src/core/rgb_image.rs @@ -0,0 +1,249 @@ +//! The RGB image container for decoded and developed images. +//! +//! [`RgbImage`] wraps gamut's validated [`ImageBuf`] and adds the +//! rawshift-specific carry-alongs: the [`ColorDescription`] tag, baseline +//! exposure, and the default crop. The buffer invariant (`data.len() == +//! width * height * 3`) is enforced by gamut at every construction and +//! mutation point — there is no way to hold an `RgbImage` whose data length +//! disagrees with its dimensions. + +use rawshift_core::{ColorDescription, Dimensions, ImageBuf, Rgb16}; + +use crate::core::Rect; +use crate::error::{RawError, RawResult}; + +/// A container for interleaved 16-bit RGB image data (R, G, B, R, G, B, …). +/// +/// Backed by [`ImageBuf`], which validates the length invariant on +/// construction. Pixel data is reached through [`data`](Self::data) / +/// [`data_mut`](Self::data_mut); dimension changes go through +/// [`replace_data`](Self::replace_data), which revalidates atomically. +#[derive(Debug, Clone)] +pub struct RgbImage { + buf: ImageBuf, + color: ColorDescription, + baseline_exposure: Option, + default_crop: Option, +} + +impl RgbImage { + /// Create a new `RgbImage` with an + /// [`UNSPECIFIED`](ColorDescription::UNSPECIFIED) color description. + /// + /// Use [`with_color`](Self::with_color) or + /// [`set_color`](Self::set_color) when the space is known. + /// + /// # Errors + /// Returns [`RawError::Gamut`] when `data.len() != width * height * 3` or + /// either dimension is zero. + pub fn new(width: u32, height: u32, data: Vec) -> RawResult { + Self::with_color(width, height, data, ColorDescription::UNSPECIFIED) + } + + /// Create a new `RgbImage` tagged with a known color description. + /// + /// # Errors + /// Returns [`RawError::Gamut`] when `data.len() != width * height * 3` or + /// either dimension is zero. + pub fn with_color( + width: u32, + height: u32, + data: Vec, + color: ColorDescription, + ) -> RawResult { + let dims = Dimensions::new(width, height) + .map_err(|e| RawError::gamut("RgbImage dimensions", e))?; + let buf = ImageBuf::::new(data, dims) + .map_err(|e| RawError::gamut("RgbImage buffer", e))?; + Ok(Self::from_buf(buf, color)) + } + + /// Wrap an already-validated gamut buffer. + pub fn from_buf(buf: ImageBuf, color: ColorDescription) -> Self { + Self { + buf, + color, + baseline_exposure: None, + default_crop: None, + } + } + + // ── Read accessors ─────────────────────────────────────────────────── + + /// Image dimensions. + pub fn size(&self) -> Dimensions { + self.buf.dimensions() + } + + /// Image width in pixels. + pub fn width(&self) -> u32 { + self.buf.width() + } + + /// Image height in pixels. + pub fn height(&self) -> u32 { + self.buf.height() + } + + /// Interleaved RGB samples (R, G, B, R, G, B, …), row-major. + pub fn data(&self) -> &[u16] { + self.buf.as_samples() + } + + /// Mutable interleaved RGB samples. + /// + /// The slice length is fixed by the dimensions; to change both together + /// use [`replace_data`](Self::replace_data). + pub fn data_mut(&mut self) -> &mut [u16] { + self.buf.as_mut_samples() + } + + /// The underlying gamut buffer. + pub fn as_buf(&self) -> &ImageBuf { + &self.buf + } + + /// Consume into the underlying gamut buffer (for hand-off to gamut + /// encoders). + pub fn into_buf(self) -> ImageBuf { + self.buf + } + + /// Consume into the raw sample vector. + pub fn into_data(self) -> Vec { + self.buf.into_samples() + } + + /// Baseline exposure offset in EV. + pub fn baseline_exposure(&self) -> Option { + self.baseline_exposure + } + + /// Default crop rectangle. + pub fn default_crop(&self) -> Option { + self.default_crop + } + + /// The color description the RGB samples are in. + pub fn color(&self) -> ColorDescription { + self.color + } + + // ── Write accessors ────────────────────────────────────────────────── + + /// Set baseline exposure offset. + pub fn set_baseline_exposure(&mut self, ev: Option) { + self.baseline_exposure = ev; + } + + /// Set the color description tag for the RGB samples. + pub fn set_color(&mut self, color: ColorDescription) { + self.color = color; + } + + /// Set default crop rectangle. + pub fn set_default_crop(&mut self, crop: Option) { + self.default_crop = crop; + } + + /// Replace dimensions and data together (used by orientation transforms + /// and crops, which change both). + /// + /// Atomic: on error the image is left unchanged. The color tag, baseline + /// exposure, and default crop are preserved. + /// + /// # Errors + /// Returns [`RawError::Gamut`] when `data.len() != width * height * 3` or + /// either dimension is zero. + pub fn replace_data(&mut self, width: u32, height: u32, data: Vec) -> RawResult<()> { + let dims = Dimensions::new(width, height) + .map_err(|e| RawError::gamut("RgbImage dimensions", e))?; + let buf = ImageBuf::::new(data, dims) + .map_err(|e| RawError::gamut("RgbImage buffer", e))?; + self.buf = buf; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rgb_image_indexing() { + // RgbImage stores interleaved RGB: R G B R G B ... + let data = vec![ + 100u16, 200, 300, // pixel 0: R=100, G=200, B=300 + 400, 500, 600, // pixel 1: R=400, G=500, B=600 + ]; + let img = RgbImage::new(2, 1, data).expect("valid buffer"); + + assert_eq!(img.data()[0], 100, "pixel 0 R"); + assert_eq!(img.data()[1], 200, "pixel 0 G"); + assert_eq!(img.data()[2], 300, "pixel 0 B"); + assert_eq!(img.data()[3], 400, "pixel 1 R"); + assert_eq!(img.data()[4], 500, "pixel 1 G"); + assert_eq!(img.data()[5], 600, "pixel 1 B"); + + assert_eq!(img.width(), 2); + assert_eq!(img.height(), 1); + assert_eq!(img.data().len(), 6); + } + + #[test] + fn rgb_image_accessors() { + let img = RgbImage::new(100, 200, vec![0u16; 100 * 200 * 3]).expect("valid buffer"); + assert_eq!(img.width(), 100); + assert_eq!(img.height(), 200); + assert_eq!( + img.size(), + Dimensions { + width: 100, + height: 200 + } + ); + assert_eq!(img.baseline_exposure(), None); + assert_eq!(img.default_crop(), None); + assert_eq!(img.color(), ColorDescription::UNSPECIFIED); + } + + #[test] + fn length_invariant_is_enforced() { + // Wrong length: 2x1 RGB needs 6 samples. + assert!(matches!( + RgbImage::new(2, 1, vec![0u16; 5]), + Err(RawError::Gamut { .. }) + )); + // Zero dimension. + assert!(matches!( + RgbImage::new(0, 1, vec![]), + Err(RawError::Gamut { .. }) + )); + } + + #[test] + fn replace_data_is_atomic() { + let mut img = + RgbImage::with_color(2, 1, vec![0u16; 6], ColorDescription::LINEAR_SRGB).unwrap(); + img.set_baseline_exposure(Some(0.5)); + + // A failed replace leaves everything unchanged. + assert!(img.replace_data(3, 1, vec![0u16; 5]).is_err()); + assert_eq!(img.width(), 2); + assert_eq!(img.data().len(), 6); + + // A successful replace swaps dims+data and preserves the carry-alongs. + img.replace_data(1, 2, vec![1u16; 6]).unwrap(); + assert_eq!(img.width(), 1); + assert_eq!(img.height(), 2); + assert_eq!(img.color(), ColorDescription::LINEAR_SRGB); + assert_eq!(img.baseline_exposure(), Some(0.5)); + } + + #[test] + fn data_mut_edits_in_place() { + let mut img = RgbImage::new(1, 1, vec![1, 2, 3]).unwrap(); + img.data_mut()[1] = 42; + assert_eq!(img.data(), &[1, 42, 3]); + } +} diff --git a/crates/rawshift-image/src/error.rs b/crates/rawshift-image/src/error.rs index e4f97df..c839791 100644 --- a/crates/rawshift-image/src/error.rs +++ b/crates/rawshift-image/src/error.rs @@ -43,6 +43,28 @@ pub enum RawError { /// Feature not yet implemented. #[error("Unsupported: {0}")] Unsupported(String), + + /// Error surfaced by a gamut primitive (buffer/dimension validation, + /// codec-independent invariants). + /// + /// `context` names the rawshift operation that invoked gamut, since the + /// upstream error alone rarely identifies the call site (structured + /// diagnostic context upstream is justin13888/gamut#254). + #[error("{context}: {source}")] + Gamut { + /// The rawshift operation that invoked gamut. + context: &'static str, + /// The underlying gamut error. + #[source] + source: gamut_core::Error, + }, +} + +impl RawError { + /// Wrap a gamut error with the rawshift operation it occurred in. + pub fn gamut(context: &'static str, source: gamut_core::Error) -> Self { + RawError::Gamut { context, source } + } } /// TIFF and binary parse errors. diff --git a/crates/rawshift-image/src/formats/arw.rs b/crates/rawshift-image/src/formats/arw.rs index a15ba08..db3e1ae 100644 --- a/crates/rawshift-image/src/formats/arw.rs +++ b/crates/rawshift-image/src/formats/arw.rs @@ -5,7 +5,7 @@ use std::io::{Read, Seek}; -use crate::core::image::{CfaPattern, RawImage, Rect, Size, white_level_from_bit_depth}; +use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth}; use crate::error::{FormatError, ParseError, RawError, RawResult}; use crate::tiff::{Ifd, TiffParser, TiffTag, TiffValue}; @@ -17,7 +17,7 @@ pub struct ArwMetadata { /// Camera model (e.g., "ILCE-6700") pub model: String, /// Full sensor dimensions - pub sensor_size: Size, + pub sensor_size: Dimensions, /// Active/crop area pub active_area: Rect, /// Bits per sample (typically 12 or 14) @@ -203,7 +203,7 @@ impl ArwFile { TiffTag::ImageLength, )))?; - let sensor_size = Size::new(width, height); + let sensor_size = Dimensions { width, height }; // Extract bit depth let bit_depth = if let Some(entry) = raw_ifd.get(TiffTag::BitsPerSample) { @@ -809,7 +809,10 @@ impl ArwFile { output = decoder.decode(&data)?; } - let expected_pixels = metadata.sensor_size.pixel_count() as usize; + let expected_pixels = metadata + .sensor_size + .num_pixels() + .expect("sensor pixel count overflows usize"); if output.len() != expected_pixels { return Err(RawError::Format(FormatError::Decompression(format!( "Decoded {} pixels, expected {}", @@ -843,7 +846,7 @@ impl ArwFile { } } -impl crate::core::MetadataExtractor for ArwFile { +impl crate::core::ExtractMetadata for ArwFile { fn extract_metadata(&self) -> crate::core::ImageMetadata { use crate::core::metadata::*; diff --git a/crates/rawshift-image/src/formats/cr2.rs b/crates/rawshift-image/src/formats/cr2.rs index 85837ad..520742e 100644 --- a/crates/rawshift-image/src/formats/cr2.rs +++ b/crates/rawshift-image/src/formats/cr2.rs @@ -18,7 +18,7 @@ use std::io::{Read, Seek}; -use crate::core::image::{CfaPattern, RawImage, Rect, Size, white_level_from_bit_depth}; +use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth}; use crate::error::{FormatError, ParseError, RawError, RawResult}; use crate::tiff::{Ifd, TiffParser, TiffTag, TiffValue}; @@ -42,7 +42,7 @@ pub struct Cr2Metadata { /// Camera model (e.g., "Canon EOS 5D Mark III") pub model: String, /// Full sensor dimensions - pub sensor_size: Size, + pub sensor_size: Dimensions, /// Active/crop area (full sensor size if no ActiveArea tag) pub active_area: Rect, /// Bits per sample (typically 14) @@ -223,7 +223,7 @@ impl Cr2File { TiffTag::ImageLength, )))?; - let sensor_size = Size::new(width, height); + let sensor_size = Dimensions { width, height }; // Extract bit depth let bit_depth = if let Some(entry) = raw_ifd.get(TiffTag::BitsPerSample) { @@ -367,7 +367,10 @@ impl Cr2File { let pixels = decoder.decode(&data)?; - let expected = metadata.sensor_size.pixel_count() as usize; + let expected = metadata + .sensor_size + .num_pixels() + .expect("sensor pixel count overflows usize"); if pixels.len() != expected { return Err(RawError::Format(FormatError::Cr2(format!( "Decoded {} pixels, expected {} ({}x{})", @@ -414,7 +417,7 @@ pub fn is_cr2(data: &[u8]) -> bool { && data[CR2_MAGIC_OFFSET + 2] == CR2_MAGIC[2] } -impl crate::core::MetadataExtractor for Cr2File { +impl crate::core::ExtractMetadata for Cr2File { fn extract_metadata(&self) -> crate::core::ImageMetadata { use crate::core::metadata::*; @@ -544,7 +547,10 @@ mod tests { let meta = Cr2Metadata { make: "Canon".to_string(), model: "Canon EOS 5D Mark III".to_string(), - sensor_size: Size::new(5760, 3840), + sensor_size: Dimensions { + width: 5760, + height: 3840, + }, active_area: Rect::from_coords(0, 0, 5760, 3840), bit_depth: 14, cfa_pattern: CfaPattern::Rggb, diff --git a/crates/rawshift-image/src/formats/cr3.rs b/crates/rawshift-image/src/formats/cr3.rs index 1d68d92..de347fe 100644 --- a/crates/rawshift-image/src/formats/cr3.rs +++ b/crates/rawshift-image/src/formats/cr3.rs @@ -23,7 +23,7 @@ use std::io::{Cursor, Read, Seek, SeekFrom}; use tracing::instrument; -use crate::core::image::{CfaPattern, RawImage, Rect, Size, white_level_from_bit_depth}; +use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth}; use crate::error::{FormatError, RawError, RawResult}; use crate::tiff::{TiffParser, TiffTag, TiffValue}; @@ -62,7 +62,7 @@ pub struct Cr3Metadata { /// Camera model (e.g., "Canon EOS R5") pub model: String, /// Full sensor dimensions - pub sensor_size: Size, + pub sensor_size: Dimensions, /// Active/crop area (full sensor size if unavailable) pub active_area: Rect, /// Bits per sample (typically 14) @@ -297,7 +297,10 @@ impl Cr3File { let bit_depth: u8 = 14; let white_level = white_level_from_bit_depth(bit_depth); - let sensor_size = sensor_size_opt.unwrap_or(Size::new(0, 0)); + let sensor_size = sensor_size_opt.unwrap_or(Dimensions { + width: 0, + height: 0, + }); let active_area = Rect::from_coords(0, 0, sensor_size.width, sensor_size.height); let cfa_pattern = cfa_pattern_opt.unwrap_or(CfaPattern::Rggb); @@ -410,10 +413,10 @@ impl Cr3File { fn parse_trak_boxes( &mut self, moov_boxes: &[IsobmffBox], - ) -> RawResult<(u64, u64, Option)> { + ) -> RawResult<(u64, u64, Option)> { let mut best_offset: u64 = 0; let mut best_size: u64 = 0; - let mut best_sensor_size: Option = None; + let mut best_sensor_size: Option = None; for b in moov_boxes { if b.box_type != BOX_TRAK { @@ -433,7 +436,10 @@ impl Cr3File { } /// Parse a single `trak` box and extract raw data location. - fn parse_single_trak(&mut self, trak: &IsobmffBox) -> RawResult<(u64, u64, Option)> { + fn parse_single_trak( + &mut self, + trak: &IsobmffBox, + ) -> RawResult<(u64, u64, Option)> { self.reader.seek(SeekFrom::Start(trak.payload_offset))?; let trak_boxes = read_boxes(&mut self.reader, trak.payload_size)?; @@ -487,7 +493,7 @@ impl Cr3File { } /// Try to extract image dimensions from the `stsd` (sample description) box. - fn parse_stsd_for_size(&mut self, stbl_boxes: &[IsobmffBox]) -> Option { + fn parse_stsd_for_size(&mut self, stbl_boxes: &[IsobmffBox]) -> Option { let stsd = stbl_boxes.iter().find(|b| b.box_type == BOX_STSD)?; // stsd layout: version(1) + flags(3) + entry_count(4) + entries… @@ -544,7 +550,7 @@ impl Cr3File { .seek(SeekFrom::Start(entry_start + entry_size as u64)); if width > 0 && height > 0 { - Some(Size::new(width, height)) + Some(Dimensions { width, height }) } else { None } @@ -616,9 +622,9 @@ impl Cr3File { } } -// ── MetadataExtractor trait ─────────────────────────────────────────────────── +// ── ExtractMetadata trait ───────────────────────────────────────────────────── -impl crate::core::MetadataExtractor for Cr3File { +impl crate::core::ExtractMetadata for Cr3File { fn extract_metadata(&self) -> crate::core::ImageMetadata { use crate::core::metadata::*; @@ -749,7 +755,10 @@ mod tests { let meta = Cr3Metadata { make: "Canon".to_string(), model: "Canon EOS R5".to_string(), - sensor_size: Size::new(8192, 5464), + sensor_size: Dimensions { + width: 8192, + height: 5464, + }, active_area: Rect::from_coords(0, 0, 8192, 5464), bit_depth: 14, cfa_pattern: CfaPattern::Rggb, diff --git a/crates/rawshift-image/src/formats/crw.rs b/crates/rawshift-image/src/formats/crw.rs index 3d7ebb1..03d21fb 100644 --- a/crates/rawshift-image/src/formats/crw.rs +++ b/crates/rawshift-image/src/formats/crw.rs @@ -18,7 +18,7 @@ use std::io::{Read, Seek}; -use crate::core::image::{CfaPattern, RawImage, Rect, Size, white_level_from_bit_depth}; +use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth}; use crate::error::{FormatError, RawError, RawResult}; // ── CIFF signature ──────────────────────────────────────────────────────────── @@ -39,7 +39,7 @@ pub struct CrwMetadata { /// Camera model string (e.g. "Canon PowerShot G2"). pub model: String, /// Full sensor dimensions. - pub sensor_size: Size, + pub sensor_size: Dimensions, /// Active / crop area. pub active_area: Rect, /// Bits per sample (typically 12 for CRW). @@ -127,7 +127,10 @@ impl CrwFile { // Full CIFF heap parsing is not yet implemented. // Provide default metadata: Canon, typical 20 MP 5D-era sensor, 12-bit, RGGB. - let sensor_size = Size::new(5616, 3744); + let sensor_size = Dimensions { + width: 5616, + height: 3744, + }; let active_area = Rect::from_coords(0, 0, 5616, 3744); let bit_depth: u8 = 12; let white_level: u16 = white_level_from_bit_depth(bit_depth); @@ -212,9 +215,9 @@ pub fn is_crw(data: &[u8]) -> bool { &data[6..14] == CIFF_SIGNATURE } -// ── MetadataExtractor impl ──────────────────────────────────────────────────── +// ── ExtractMetadata impl ────────────────────────────────────────────────────── -impl crate::core::MetadataExtractor for CrwFile { +impl crate::core::ExtractMetadata for CrwFile { fn extract_metadata(&self) -> crate::core::ImageMetadata { use crate::core::metadata::*; @@ -348,7 +351,10 @@ mod tests { let meta = CrwMetadata { make: "Canon".to_string(), model: "Canon PowerShot G2".to_string(), - sensor_size: Size::new(2272, 1704), + sensor_size: Dimensions { + width: 2272, + height: 1704, + }, active_area: Rect::from_coords(0, 0, 2272, 1704), bit_depth: 12, cfa_pattern: CfaPattern::Rggb, diff --git a/crates/rawshift-image/src/formats/dng.rs b/crates/rawshift-image/src/formats/dng.rs index 9f1a021..af4c1a8 100644 --- a/crates/rawshift-image/src/formats/dng.rs +++ b/crates/rawshift-image/src/formats/dng.rs @@ -6,7 +6,8 @@ use std::io::{Read, Seek}; use crate::codecs::jxl::JxlDecoder; -use crate::core::image::{CfaPattern, RawImage, Rect, RgbImage, Size}; +use crate::core::RgbImage; +use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect}; use crate::error::{ParseError, RawError, RawResult}; use crate::tiff::{ByteOrder, Ifd, TiffParser, TiffTag, TiffValue}; @@ -22,7 +23,7 @@ pub struct DngMetadata { /// Unique camera model identifier pub unique_camera_model: String, /// Full sensor dimensions - pub sensor_size: Size, + pub sensor_size: Dimensions, /// Active/crop area (if different from sensor size) pub active_area: Option, /// Default crop origin @@ -306,7 +307,7 @@ impl DngFile { TiffTag::ImageLength, )))?; - let sensor_size = Size::new(width, height); + let sensor_size = Dimensions { width, height }; // Extract bit depth (BitsPerSample may be array for LinearRaw) let bit_depth = if let Some(entry) = raw_ifd.get(TiffTag::BitsPerSample) { @@ -1279,7 +1280,7 @@ impl DngFile { } } - let mut image = RgbImage::new(out_width as u32, out_height as u32, output); + let mut image = RgbImage::new(out_width as u32, out_height as u32, output)?; image.set_baseline_exposure(metadata.baseline_exposure); image.set_default_crop( if let (Some(origin), Some(size)) = @@ -1302,7 +1303,7 @@ impl DngFile { } } -impl crate::core::MetadataExtractor for DngFile { +impl crate::core::ExtractMetadata for DngFile { fn extract_metadata(&self) -> crate::core::ImageMetadata { use crate::core::metadata::*; @@ -1620,10 +1621,10 @@ mod tests { // Validate data size (width * height * 3 channels) let expected_size = 8064 * 6048 * 3; - assert_eq!(rgb_image.data.len(), expected_size); + assert_eq!(rgb_image.data().len(), expected_size); // Check that we got some non-zero pixel data - let non_zero_count = rgb_image.data.iter().filter(|&&v| v > 0).count(); + let non_zero_count = rgb_image.data().iter().filter(|&&v| v > 0).count(); assert!(non_zero_count > 0, "Should have non-zero pixel values"); } } diff --git a/crates/rawshift-image/src/formats/dng_export.rs b/crates/rawshift-image/src/formats/dng_export.rs index 1a8a2f4..4aea08e 100644 --- a/crates/rawshift-image/src/formats/dng_export.rs +++ b/crates/rawshift-image/src/formats/dng_export.rs @@ -7,7 +7,7 @@ use std::fs::File; use std::io::{BufWriter, Seek, Write}; use std::path::Path; -use crate::core::image::RgbImage; +use crate::core::RgbImage; use crate::core::metadata::ImageMetadata; use crate::error::RawResult; use crate::tiff::writer::{IfdEntry, TiffWriter}; @@ -58,7 +58,7 @@ pub fn export_dng_to_writer( writer.write_header()?; // Write image data first to get offset - let (strip_offset, strip_bytes) = writer.write_image_strip_rgb16(&image.data)?; + let (strip_offset, strip_bytes) = writer.write_image_strip_rgb16(image.data())?; // Build IFD entries let mut entries = build_dng_ifd(image, metadata, config, strip_offset, strip_bytes); @@ -246,11 +246,11 @@ fn build_dng_ifd( #[cfg(test)] mod tests { use super::*; - use crate::core::image::RgbImage; + use crate::core::RgbImage; #[test] fn test_build_dng_ifd() { - let image = RgbImage::new(100, 50, vec![0u16; 100 * 50 * 3]); + let image = RgbImage::new(100, 50, vec![0u16; 100 * 50 * 3]).expect("valid RGB buffer"); let metadata = ImageMetadata::default(); let config = DngExportConfig::archival(); diff --git a/crates/rawshift-image/src/formats/encode.rs b/crates/rawshift-image/src/formats/encode.rs index 801c42e..4d2afa1 100644 --- a/crates/rawshift-image/src/formats/encode.rs +++ b/crates/rawshift-image/src/formats/encode.rs @@ -11,7 +11,7 @@ use std::path::Path; #[cfg(any_standard_encode)] use crate::core::BitDepth; -use crate::core::image::RgbImage; +use crate::core::RgbImage; use crate::core::metadata::ImageMetadata; #[cfg(any_standard_encode)] use crate::error::EncodeError; @@ -98,7 +98,7 @@ pub fn encode_rgb_image( #[cfg(any_standard_encode)] #[allow(dead_code)] // unused when only `dng-encode` is enabled fn pack_rgb8(image: &RgbImage) -> Vec { - image.data.iter().map(|&p| (p >> 8) as u8).collect() + image.data().iter().map(|&p| (p >> 8) as u8).collect() } /// Validate a bit-depth request for a backend that only emits 8-bit output. @@ -133,8 +133,8 @@ fn encode_png( let (data_bytes, depth) = match cfg.common.bit_depth { BitDepth::Eight => (pack_rgb8(image), zune_core::bit_depth::BitDepth::Eight), BitDepth::Sixteen => { - let mut bytes = Vec::with_capacity(image.data.len() * 2); - for &pixel in &image.data { + let mut bytes = Vec::with_capacity(image.data().len() * 2); + for &pixel in image.data() { bytes.extend_from_slice(&pixel.to_be_bytes()); } (bytes, zune_core::bit_depth::BitDepth::Sixteen) @@ -284,8 +284,8 @@ fn encode_jpeg_jpegli( let (samples, bits_per_sample) = match cfg.common.bit_depth { BitDepth::Eight => (pack_rgb8(image), 8u32), BitDepth::Sixteen => { - let mut bytes = Vec::with_capacity(image.data.len() * 2); - for &sample in &image.data { + let mut bytes = Vec::with_capacity(image.data().len() * 2); + for &sample in image.data() { bytes.extend_from_slice(&sample.to_ne_bytes()); } (bytes, 16u32) @@ -417,7 +417,7 @@ fn encode_avif( check_8bit_backend(cfg.common.bit_depth, "AVIF")?; let rgba_data: Vec = image - .data + .data() .chunks_exact(3) .map(|rgb| { RGBA8::new( @@ -512,7 +512,7 @@ fn encode_avif_libaom( }; let mut avif_bytes = - avif_libaom::encode(&image.data, image.width(), image.height(), depth, ¶ms).map_err( + avif_libaom::encode(image.data(), image.width(), image.height(), depth, ¶ms).map_err( |e| { RawError::Encode(EncodeError::Encoding { format: "AVIF", @@ -630,8 +630,8 @@ fn encode_jxl_libjxl( let (samples, bits_per_sample) = match cfg.common.bit_depth { BitDepth::Eight => (pack_rgb8(image), 8u32), BitDepth::Sixteen => { - let mut bytes = Vec::with_capacity(image.data.len() * 2); - for &sample in &image.data { + let mut bytes = Vec::with_capacity(image.data().len() * 2); + for &sample in image.data() { bytes.extend_from_slice(&sample.to_ne_bytes()); } (bytes, 16u32) diff --git a/crates/rawshift-image/src/formats/export.rs b/crates/rawshift-image/src/formats/export.rs index 5d6b1d3..ed1d727 100644 --- a/crates/rawshift-image/src/formats/export.rs +++ b/crates/rawshift-image/src/formats/export.rs @@ -75,7 +75,7 @@ impl OutputFormat { /// Embedded as the `common` field of each per-implementation config struct so /// that metadata-embedding and output bit-depth are configured uniformly, /// independent of the chosen backend. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct CommonEncodeOptions { /// Which metadata blocks to embed in the output container. @@ -85,9 +85,24 @@ pub struct CommonEncodeOptions { /// Encoders that cannot honour the request return /// [`EncodeError::UnsupportedBitDepth`](crate::error::EncodeError::UnsupportedBitDepth) /// rather than silently degrading. + #[cfg_attr( + feature = "serde", + serde(with = "rawshift_core::color::bit_depth_serde") + )] pub bit_depth: BitDepth, } +impl Default for CommonEncodeOptions { + /// Defaults to 16-bit output ([`BitDepth::Sixteen`]) with default metadata + /// embedding options. + fn default() -> Self { + Self { + metadata: MetadataEmbedOptions::default(), + bit_depth: BitDepth::Sixteen, + } + } +} + /// WebP encoding mode. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/crates/rawshift-image/src/formats/heic.rs b/crates/rawshift-image/src/formats/heic.rs index 03ad198..c7ce43b 100644 --- a/crates/rawshift-image/src/formats/heic.rs +++ b/crates/rawshift-image/src/formats/heic.rs @@ -9,7 +9,7 @@ //! [`HeicFile`] when you need the auxiliary images or richer metadata. use crate::codecs::heic; -use crate::core::image::RgbImage; +use crate::core::RgbImage; use crate::core::metadata::{ImageMetadata, MetadataKey, MetadataNamespace, MetadataValue}; use crate::error::{FormatError, RawError, RawResult}; use crate::metadata::exif::ExifParser; @@ -91,7 +91,7 @@ impl HeicFile { /// range. pub fn decode_primary(&self) -> RawResult { let decoded = heic::decode_primary(&self.data).map_err(heic_err)?; - Ok(RgbImage::new(decoded.width, decoded.height, decoded.rgb)) + RgbImage::new(decoded.width, decoded.height, decoded.rgb) } /// Extract embedded EXIF/XMP/ICC and full typed metadata. @@ -110,7 +110,7 @@ impl HeicFile { /// grayscale-expanded RGB. pub fn decode_aux(&self, aux: &HeicAuxImage) -> RawResult { let decoded = heic::decode_aux(&self.data, aux.item_id).map_err(heic_err)?; - Ok(RgbImage::new(decoded.width, decoded.height, decoded.rgb)) + RgbImage::new(decoded.width, decoded.height, decoded.rgb) } /// Decode the embedded thumbnail, if the file carries one. @@ -207,7 +207,7 @@ mod tests { let primary = file.decode_primary().expect("decode primary"); assert!(primary.width() > 0 && primary.height() > 0); assert_eq!( - primary.data.len(), + primary.data().len(), primary.width() as usize * primary.height() as usize * 3 ); diff --git a/crates/rawshift-image/src/formats/mod.rs b/crates/rawshift-image/src/formats/mod.rs index d405702..f08df0b 100644 --- a/crates/rawshift-image/src/formats/mod.rs +++ b/crates/rawshift-image/src/formats/mod.rs @@ -47,7 +47,8 @@ use crate::tiff::{TiffParser, TiffTag}; #[cfg(any_raw)] use { - crate::core::image::{RawImage, RgbImage}, + crate::core::RgbImage, + crate::core::image::RawImage, crate::error::{RawError, RawResult}, crate::processing::ProcessingOptions, crate::transforms::{ @@ -218,7 +219,7 @@ impl RawFile { /// /// This provides format-agnostic access to all available metadata. pub fn metadata(&self) -> crate::core::ImageMetadata { - use crate::core::MetadataExtractor; + use crate::core::ExtractMetadata; raw_format_dispatch!(self, inner => inner.extract_metadata()) } @@ -288,7 +289,7 @@ impl RawFile { bit_depth, shift ); - for pixel in &mut image.data { + for pixel in image.data_mut() { let val = (*pixel as u32) << shift; *pixel = val.min(65535) as u16; } @@ -473,7 +474,7 @@ impl RawFile { } // The pipeline emits display-referred sRGB after tone reproduction. - rgb_image.set_color_space(crate::core::ColorSpace::Srgb); + rgb_image.set_color(crate::core::ColorDescription::SRGB); Ok(rgb_image) } @@ -860,8 +861,8 @@ mod tests { // Tests for orientation transforms (via transforms::orientation module) // ------------------------------------------------------------------------- - fn make_test_rgb(width: u32, height: u32, data: Vec) -> crate::core::image::RgbImage { - crate::core::image::RgbImage::new(width, height, data) + fn make_test_rgb(width: u32, height: u32, data: Vec) -> crate::core::RgbImage { + crate::core::RgbImage::new(width, height, data).expect("valid RGB buffer") } #[test] @@ -869,7 +870,7 @@ mod tests { use crate::transforms::orientation::flip_horizontal; let mut img = make_test_rgb(2, 1, vec![10, 11, 12, 20, 21, 22]); flip_horizontal(&mut img); - assert_eq!(img.data, vec![20, 21, 22, 10, 11, 12]); + assert_eq!(img.data(), vec![20, 21, 22, 10, 11, 12]); } #[test] @@ -877,7 +878,7 @@ mod tests { use crate::transforms::orientation::rotate_180; let mut img = make_test_rgb(2, 1, vec![1, 2, 3, 4, 5, 6]); rotate_180(&mut img); - assert_eq!(img.data, vec![4, 5, 6, 1, 2, 3]); + assert_eq!(img.data(), vec![4, 5, 6, 1, 2, 3]); } #[test] @@ -887,7 +888,7 @@ mod tests { rotate_90_cw(&mut img); assert_eq!(img.width(), 2); assert_eq!(img.height(), 1); - assert_eq!(img.data, vec![4, 5, 6, 1, 2, 3]); + assert_eq!(img.data(), vec![4, 5, 6, 1, 2, 3]); } #[test] @@ -897,16 +898,16 @@ mod tests { rotate_90_ccw(&mut img); assert_eq!(img.width(), 1); assert_eq!(img.height(), 2); - assert_eq!(img.data, vec![4, 5, 6, 1, 2, 3]); + assert_eq!(img.data(), vec![4, 5, 6, 1, 2, 3]); } #[test] fn test_orientation_identity() { use crate::transforms::orientation::apply_orientation; let mut img = make_test_rgb(2, 2, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); - let original = img.data.clone(); + let original = img.data().to_vec(); apply_orientation(&mut img, 1); - assert_eq!(img.data, original); + assert_eq!(img.data(), original); } #[test] @@ -919,14 +920,14 @@ mod tests { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, ], ); - let original_data = img.data.clone(); + let original_data = img.data().to_vec(); let original_w = img.width(); let original_h = img.height(); apply_orientation(&mut img, 6); // 90° CW apply_orientation(&mut img, 8); // 90° CCW (should undo it) assert_eq!(img.width(), original_w); assert_eq!(img.height(), original_h); - assert_eq!(img.data, original_data); + assert_eq!(img.data(), original_data); } #[cfg(any_raw)] diff --git a/crates/rawshift-image/src/formats/nef.rs b/crates/rawshift-image/src/formats/nef.rs index 4371d05..a1b8399 100644 --- a/crates/rawshift-image/src/formats/nef.rs +++ b/crates/rawshift-image/src/formats/nef.rs @@ -5,7 +5,7 @@ use std::io::{Read, Seek}; -use crate::core::image::{CfaPattern, RawImage, Rect, Size, white_level_from_bit_depth}; +use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth}; use crate::error::{FormatError, ParseError, RawError, RawResult}; use crate::tiff::{Ifd, TiffParser, TiffTag, TiffValue}; @@ -17,7 +17,7 @@ pub struct NefMetadata { /// Camera model (e.g., "NIKON Z8") pub model: String, /// Full sensor dimensions - pub sensor_size: Size, + pub sensor_size: Dimensions, /// Active/crop area pub active_area: Rect, /// Bits per sample (typically 12 or 14) @@ -186,7 +186,7 @@ impl NefFile { TiffTag::ImageLength, )))?; - let sensor_size = Size::new(width, height); + let sensor_size = Dimensions { width, height }; // Extract bit depth let bit_depth = if let Some(entry) = raw_ifd.get(TiffTag::BitsPerSample) { @@ -406,7 +406,10 @@ impl NefFile { decoder.set_dimensions(metadata.sensor_size.width, metadata.sensor_size.height); let output = decoder.decode(&data)?; - let expected_pixels = metadata.sensor_size.pixel_count() as usize; + let expected_pixels = metadata + .sensor_size + .num_pixels() + .expect("sensor pixel count overflows usize"); if output.len() != expected_pixels { return Err(RawError::Format(FormatError::Decompression(format!( "LJPEG decoded {} pixels, expected {}", @@ -435,7 +438,7 @@ impl NefFile { } } -impl crate::core::MetadataExtractor for NefFile { +impl crate::core::ExtractMetadata for NefFile { fn extract_metadata(&self) -> crate::core::ImageMetadata { use crate::core::metadata::*; @@ -531,7 +534,10 @@ mod tests { let meta = NefMetadata { make: "NIKON CORPORATION".to_string(), model: "NIKON Z8".to_string(), - sensor_size: Size::new(8256, 5504), + sensor_size: Dimensions { + width: 8256, + height: 5504, + }, active_area: Rect::from_coords(0, 0, 8256, 5504), bit_depth: 14, cfa_pattern: CfaPattern::Rggb, diff --git a/crates/rawshift-image/src/formats/raf.rs b/crates/rawshift-image/src/formats/raf.rs index 5f76fc8..c2fc841 100644 --- a/crates/rawshift-image/src/formats/raf.rs +++ b/crates/rawshift-image/src/formats/raf.rs @@ -14,7 +14,7 @@ use std::io::{Read, Seek, SeekFrom}; -use crate::core::image::{CfaPattern, RawImage, Rect, Size, XTransPattern}; +use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect, XTransPattern}; use crate::error::{FormatError, RawError, RawResult}; use tracing::instrument; @@ -53,7 +53,7 @@ pub struct RafMetadata { /// Camera model (e.g., "X-T5") pub model: String, /// Full sensor dimensions - pub sensor_size: Size, + pub sensor_size: Dimensions, /// Active/crop area (full sensor size as RAF does not provide a sub-area) pub active_area: Rect, /// Bits per sample (12 or 14) @@ -139,7 +139,7 @@ impl RafFile { (DEFAULT_WIDTH, DEFAULT_HEIGHT) }; - let sensor_size = Size::new(width, height); + let sensor_size = Dimensions { width, height }; let active_area = Rect::from_coords(0, 0, width, height); // Fujifilm default calibration values @@ -238,7 +238,10 @@ impl RafFile { // Unpack big-endian 16-bit pixel values let pixels = unpack_raw_16bit(pixel_bytes); - let expected = metadata.sensor_size.pixel_count() as usize; + let expected = metadata + .sensor_size + .num_pixels() + .expect("sensor pixel count overflows usize"); if pixels.len() != expected { return Err(RawError::Format(FormatError::Raf(format!( "Pixel count mismatch: got {} pixels, expected {} ({}×{})", @@ -267,7 +270,7 @@ impl RafFile { } } -impl crate::core::MetadataExtractor for RafFile { +impl crate::core::ExtractMetadata for RafFile { fn extract_metadata(&self) -> crate::core::ImageMetadata { use crate::core::metadata::*; @@ -595,7 +598,10 @@ mod tests { let meta = RafMetadata { make: "FUJIFILM".to_string(), model: "X-T5".to_string(), - sensor_size: Size::new(6240, 4168), + sensor_size: Dimensions { + width: 6240, + height: 4168, + }, active_area: Rect::from_coords(0, 0, 6240, 4168), bit_depth: 14, cfa_pattern: CfaPattern::Rggb, diff --git a/crates/rawshift-image/src/formats/standard.rs b/crates/rawshift-image/src/formats/standard.rs index a052e07..7f1f98e 100644 --- a/crates/rawshift-image/src/formats/standard.rs +++ b/crates/rawshift-image/src/formats/standard.rs @@ -16,7 +16,7 @@ use zune_core::options::DecoderOptions; use zune_core::result::DecodingResult; use crate::core::CodecId; -use crate::core::image::{RgbImage, Size}; +use crate::core::{Dimensions, RgbImage}; use crate::error::{FormatError, RawError, RawResult}; /// Supported standard (non-RAW) image formats. @@ -340,7 +340,7 @@ fn decode_gif(data: &[u8]) -> RawResult { } } - Ok(RgbImage::new(canvas_width, canvas_height, out)) + RgbImage::new(canvas_width, canvas_height, out) } // ── JPEG ───────────────────────────────────────────────────────────────────── @@ -379,7 +379,7 @@ fn decode_jpeg(data: &[u8], cfg: &ZuneJpegDecodeConfig) -> RawResult { // pixels is Vec, RGB interleaved — scale to u16 let data_u16: Vec = pixels.iter().map(|&v| u8_to_u16(v)).collect(); - Ok(RgbImage::new(w, h, data_u16)) + RgbImage::new(w, h, data_u16) } // ── PNG ────────────────────────────────────────────────────────────────────── @@ -463,7 +463,7 @@ fn decode_png(data: &[u8], cfg: &ZunePngDecodeConfig) -> RawResult { } }; - Ok(RgbImage::new(w, h, data_u16)) + RgbImage::new(w, h, data_u16) } // ── WebP ───────────────────────────────────────────────────────────────────── @@ -479,7 +479,7 @@ fn decode_webp(data: &[u8]) -> RawResult { let data_u16: Vec = rgb.iter().map(|&v| u8_to_u16(v)).collect(); - Ok(RgbImage::new(w, h, data_u16)) + RgbImage::new(w, h, data_u16) } // ── JXL ────────────────────────────────────────────────────────────────────── @@ -559,7 +559,7 @@ fn jxl_render_to_rgb( } }; - Ok(RgbImage::new(w, h, data_u16)) + RgbImage::new(w, h, data_u16) } /// Decode a JPEG XL stream that may be **truncated**, returning the best @@ -570,7 +570,7 @@ fn jxl_render_to_rgb( /// mid-frame — renders the partially-decoded frame. The returned `bool` is /// `true` when a complete keyframe was decoded and `false` for a partial render. /// -/// The returned image is tagged [`ColorSpace::Srgb`](crate::core::ColorSpace). +/// The returned image is tagged [`ColorDescription::SRGB`](crate::core::ColorDescription). /// /// # Errors /// Returns an error only when the stream is too short to even parse the image @@ -724,7 +724,7 @@ fn decode_tiff(data: &[u8]) -> RawResult { } }; - Ok(RgbImage::new(w, h, data_u16)) + RgbImage::new(w, h, data_u16) } // ── AVIF ───────────────────────────────────────────────────────────────────── @@ -751,7 +751,7 @@ fn decode_avif(data: &[u8]) -> RawResult { let rgb = img.into_rgb16(); let w = rgb.width(); let h = rgb.height(); - Ok(RgbImage::new(w, h, rgb.into_raw())) + RgbImage::new(w, h, rgb.into_raw()) } #[cfg(not(feature = "avif-decode"))] @@ -772,7 +772,7 @@ fn decode_heic(data: &[u8]) -> RawResult { message, }) })?; - Ok(RgbImage::new(decoded.width, decoded.height, decoded.rgb)) + RgbImage::new(decoded.width, decoded.height, decoded.rgb) } #[cfg(not(feature = "heic-decode"))] @@ -826,7 +826,7 @@ fn decode_svg(data: &[u8], cfg: &ResvgDecodeConfig) -> RawResult { }) .collect(); - Ok(RgbImage::new(width, height, data_u16)) + RgbImage::new(width, height, data_u16) } #[cfg(not(feature = "svg-decode"))] @@ -913,7 +913,7 @@ fn decode_ppm(data: &[u8], _cfg: &ZunePpmDecodeConfig) -> RawResult { } }; - Ok(RgbImage::new(w, h, data_u16)) + RgbImage::new(w, h, data_u16) } // ── Decoder implementation selection ────────────────────────────────────────── @@ -1218,17 +1218,17 @@ pub fn decode_standard_image_with(data: &[u8], options: &DecodeOptions) -> RawRe decoded.map(tag_srgb) } -/// Tag a freshly-decoded standard image with its color space. +/// Tag a freshly-decoded standard image with its color description. /// /// Every standard decoder produces display-referred, sRGB-encoded RGB, so the -/// result is tagged [`ColorSpace::Srgb`](crate::core::ColorSpace::Srgb). When -/// the source carried a non-sRGB ICC profile the pixels are *not* converted — -/// the precise profile is preserved in +/// result is tagged [`ColorDescription::SRGB`](crate::core::ColorDescription::SRGB). +/// When the source carried a non-sRGB ICC profile the pixels are *not* +/// converted — the precise profile is preserved in /// [`ImageMetadata::icc_profile`](crate::core::ImageMetadata) by /// [`read_standard_image_metadata`], and a caller wanting true sRGB pixels can /// apply [`convert_to_srgb`](crate::transforms::convert_to_srgb). fn tag_srgb(mut image: RgbImage) -> RgbImage { - image.set_color_space(crate::core::ColorSpace::Srgb); + image.set_color(crate::core::ColorDescription::SRGB); image } @@ -1245,11 +1245,15 @@ pub struct ImageProbe { /// The detected image format. pub format: StandardFormat, /// Pixel dimensions read from the format header. - pub size: Size, + #[cfg_attr( + feature = "serde", + serde(with = "rawshift_core::image::dimensions_serde") + )] + pub size: Dimensions, /// Bits per channel, when the header exposes it cheaply (`None` otherwise). pub bit_depth: Option, - /// Best-effort color space — see [`decode_standard_image`] for the caveats. - pub color_space: crate::core::ColorSpace, + /// Best-effort color description — see [`decode_standard_image`] for the caveats. + pub color_space: crate::core::ColorDescription, } fn probe_err(format: &'static str, msg: impl Into) -> RawError { @@ -1306,22 +1310,22 @@ pub fn probe_standard_image(data: &[u8]) -> RawResult { format, size, bit_depth, - color_space: crate::core::ColorSpace::Srgb, + color_space: crate::core::ColorDescription::SRGB, }) } /// PNG: dimensions and bit depth live in the fixed-offset IHDR chunk. -fn probe_png(data: &[u8]) -> RawResult<(Size, Option)> { +fn probe_png(data: &[u8]) -> RawResult<(Dimensions, Option)> { if data.len() < 26 || &data[12..16] != b"IHDR" { return Err(probe_err("PNG", "missing IHDR chunk")); } let width = u32::from_be_bytes([data[16], data[17], data[18], data[19]]); let height = u32::from_be_bytes([data[20], data[21], data[22], data[23]]); - Ok((Size::new(width, height), Some(data[24]))) + Ok((Dimensions { width, height }, Some(data[24]))) } /// JPEG: scan marker segments for a Start-Of-Frame (SOFn) marker. -fn probe_jpeg(data: &[u8]) -> RawResult<(Size, Option)> { +fn probe_jpeg(data: &[u8]) -> RawResult<(Dimensions, Option)> { let mut i = 2; // skip the SOI marker while i + 1 < data.len() { if data[i] != 0xFF { @@ -1346,7 +1350,7 @@ fn probe_jpeg(data: &[u8]) -> RawResult<(Size, Option)> { let precision = data[i + 4]; let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32; let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32; - return Ok((Size::new(width, height), Some(precision))); + return Ok((Dimensions { width, height }, Some(precision))); } // Any other marker carries a big-endian u16 length (incl. the 2 length // bytes) — skip past it. @@ -1360,17 +1364,17 @@ fn probe_jpeg(data: &[u8]) -> RawResult<(Size, Option)> { } /// GIF: the Logical Screen Descriptor follows the 6-byte signature. -fn probe_gif(data: &[u8]) -> RawResult<(Size, Option)> { +fn probe_gif(data: &[u8]) -> RawResult<(Dimensions, Option)> { if data.len() < 10 { return Err(probe_err("GIF", "truncated header")); } let width = u16::from_le_bytes([data[6], data[7]]) as u32; let height = u16::from_le_bytes([data[8], data[9]]) as u32; - Ok((Size::new(width, height), Some(8))) + Ok((Dimensions { width, height }, Some(8))) } /// WebP: dimensions live in the first RIFF chunk (`VP8X`, `VP8 ` or `VP8L`). -fn probe_webp(data: &[u8]) -> RawResult<(Size, Option)> { +fn probe_webp(data: &[u8]) -> RawResult<(Dimensions, Option)> { if data.len() < 30 || &data[8..12] != b"WEBP" { return Err(probe_err("WebP", "not a RIFF/WEBP container")); } @@ -1379,27 +1383,27 @@ fn probe_webp(data: &[u8]) -> RawResult<(Size, Option)> { b"VP8X" => { let width = 1 + u32::from_le_bytes([data[24], data[25], data[26], 0]); let height = 1 + u32::from_le_bytes([data[27], data[28], data[29], 0]); - Ok((Size::new(width, height), Some(8))) + Ok((Dimensions { width, height }, Some(8))) } b"VP8 " => { // Lossy: 3-byte frame tag, 3-byte start code, then 14-bit w/h. let width = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF; let height = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF; - Ok((Size::new(width, height), Some(8))) + Ok((Dimensions { width, height }, Some(8))) } b"VP8L" => { // Lossless: 1 signature byte, then 14-bit (w-1) and 14-bit (h-1). let bits = u32::from_le_bytes([data[21], data[22], data[23], data[24]]); let width = (bits & 0x3FFF) + 1; let height = ((bits >> 14) & 0x3FFF) + 1; - Ok((Size::new(width, height), Some(8))) + Ok((Dimensions { width, height }, Some(8))) } _ => Err(probe_err("WebP", "unrecognized WebP chunk")), } } /// TIFF: read `ImageWidth`/`ImageLength` from the first IFD. -fn probe_tiff(data: &[u8]) -> RawResult<(Size, Option)> { +fn probe_tiff(data: &[u8]) -> RawResult<(Dimensions, Option)> { if data.len() < 8 { return Err(probe_err("TIFF", "truncated header")); } @@ -1449,14 +1453,20 @@ fn probe_tiff(data: &[u8]) -> RawResult<(Size, Option)> { } } match (width, height) { - (Some(w), Some(h)) => Ok((Size::new(w, h), None)), + (Some(w), Some(h)) => Ok(( + Dimensions { + width: w, + height: h, + }, + None, + )), _ => Err(probe_err("TIFF", "ImageWidth/ImageLength not found")), } } /// AVIF / HEIC: locate the ISOBMFF `ispe` (image spatial extents) box. Several /// may exist (thumbnails, alpha planes) — the largest is taken as the primary. -fn probe_isobmff(data: &[u8], format: &'static str) -> RawResult<(Size, Option)> { +fn probe_isobmff(data: &[u8], format: &'static str) -> RawResult<(Dimensions, Option)> { let mut best: Option<(u32, u32)> = None; let mut i = 0; while i + 16 <= data.len() { @@ -1473,13 +1483,19 @@ fn probe_isobmff(data: &[u8], format: &'static str) -> RawResult<(Size, Option Ok((Size::new(w, h), None)), + Some((w, h)) => Ok(( + Dimensions { + width: w, + height: h, + }, + None, + )), None => Err(probe_err(format, "no `ispe` box found")), } } /// PPM / PGM / PBM (Netpbm): a whitespace-separated ASCII header. -fn probe_ppm(data: &[u8]) -> RawResult<(Size, Option)> { +fn probe_ppm(data: &[u8]) -> RawResult<(Dimensions, Option)> { // After the 2-byte magic ("P1".."P6"), read ASCII tokens separated by // whitespace: width, height, and (except for bitmaps) maxval. A '#' starts // a comment that runs to end-of-line. @@ -1513,7 +1529,13 @@ fn probe_ppm(data: &[u8]) -> RawResult<(Size, Option)> { .get(2) .and_then(|&t| parse(t)) .map(|maxval| if maxval > 255 { 16u8 } else { 8 }); - Ok((Size::new(w, h), bits)) + Ok(( + Dimensions { + width: w, + height: h, + }, + bits, + )) } _ => Err(probe_err("PPM", "could not read width/height")), } @@ -1521,7 +1543,7 @@ fn probe_ppm(data: &[u8]) -> RawResult<(Size, Option)> { /// JXL: parse just enough of the codestream to read the image header. #[cfg(feature = "jxl-decode")] -fn probe_jxl(data: &[u8]) -> RawResult<(Size, Option)> { +fn probe_jxl(data: &[u8]) -> RawResult<(Dimensions, Option)> { use jxl_oxide::{InitializeResult, JxlImage}; let mut uninit = JxlImage::builder().build_uninit(); @@ -1532,7 +1554,13 @@ fn probe_jxl(data: &[u8]) -> RawResult<(Size, Option)> { .try_init() .map_err(|e| probe_err("JXL", e.to_string()))? { - InitializeResult::Initialized(img) => Ok((Size::new(img.width(), img.height()), None)), + InitializeResult::Initialized(img) => Ok(( + Dimensions { + width: img.width(), + height: img.height(), + }, + None, + )), InitializeResult::NeedMoreData(_) => Err(probe_err( "JXL", "stream too short to read the image header", @@ -1555,7 +1583,13 @@ mod probe_tests { png.extend_from_slice(&[8, 2, 0, 0, 0]); // bit depth 8, color type 2 (RGB) let probe = probe_standard_image(&png).expect("probe PNG"); assert_eq!(probe.format, StandardFormat::Png); - assert_eq!(probe.size, Size::new(640, 480)); + assert_eq!( + probe.size, + Dimensions { + width: 640, + height: 480 + } + ); assert_eq!(probe.bit_depth, Some(8)); } @@ -1566,7 +1600,13 @@ mod probe_tests { gif.extend_from_slice(&200u16.to_le_bytes()); gif.extend_from_slice(&[0, 0, 0]); let probe = probe_standard_image(&gif).expect("probe GIF"); - assert_eq!(probe.size, Size::new(320, 200)); + assert_eq!( + probe.size, + Dimensions { + width: 320, + height: 200 + } + ); } #[test] @@ -1797,7 +1837,7 @@ mod tests { assert_eq!(decoded.width(), W as u32); assert_eq!(decoded.height(), H as u32); - assert_eq!(decoded.data.len(), W as usize * H as usize * 3); + assert_eq!(decoded.data().len(), W as usize * H as usize * 3); } // ── PNG roundtrip ───────────────────────────────────────────────────── @@ -1823,9 +1863,9 @@ mod tests { assert_eq!(decoded.width(), W as u32); assert_eq!(decoded.height(), H as u32); - assert_eq!(decoded.data.len(), W * H * 3); + assert_eq!(decoded.data().len(), W * H * 3); // Each u8 value should have been scaled to u16 - assert_eq!(decoded.data[0], u8_to_u16(pixels_u8[0])); + assert_eq!(decoded.data()[0], u8_to_u16(pixels_u8[0])); } // ── DecodeOptions / decode_standard_image_with ──────────────────────── @@ -1872,7 +1912,7 @@ mod tests { assert_eq!(via_with.width(), W as u32); assert_eq!(via_with.height(), H as u32); - assert_eq!(via_with.data, via_default.data); + assert_eq!(via_with.data(), via_default.data()); } // ── detect + decode consistency ─────────────────────────────────────── @@ -1932,10 +1972,10 @@ mod tests { let decoded = decode_standard_image(&file, StandardFormat::Ppm).expect("PPM decode failed"); assert_eq!(decoded.width(), 2); assert_eq!(decoded.height(), 2); - assert_eq!(decoded.data.len(), 2 * 2 * 3); + assert_eq!(decoded.data().len(), 2 * 2 * 3); // 8-bit samples must have been scaled to 16-bit. - assert_eq!(decoded.data[0], u8_to_u16(pixels[0])); - assert_eq!(decoded.data[11], u8_to_u16(pixels[11])); + assert_eq!(decoded.data()[0], u8_to_u16(pixels[0])); + assert_eq!(decoded.data()[11], u8_to_u16(pixels[11])); } // ── GIF decode ──────────────────────────────────────────────────────── @@ -1987,7 +2027,7 @@ mod tests { assert_eq!(img.width(), 2, "decoded width must be 2"); assert_eq!(img.height(), 2, "decoded height must be 2"); assert_eq!( - img.data.len(), + img.data().len(), 2 * 2 * 3, "must have 12 u16 samples (2×2×3)" ); @@ -2012,9 +2052,9 @@ mod tests { let gif_data = make_minimal_gif(); let img = decode_standard_image(&gif_data, StandardFormat::Gif).unwrap(); // Index 0 → red (255, 0, 0) → scaled to u16: (255*257, 0, 0) - assert_eq!(img.data[0], u8_to_u16(255), "R of top-left pixel"); - assert_eq!(img.data[1], u8_to_u16(0), "G of top-left pixel"); - assert_eq!(img.data[2], u8_to_u16(0), "B of top-left pixel"); + assert_eq!(img.data()[0], u8_to_u16(255), "R of top-left pixel"); + assert_eq!(img.data()[1], u8_to_u16(0), "G of top-left pixel"); + assert_eq!(img.data()[2], u8_to_u16(0), "B of top-left pixel"); } #[test] @@ -2059,16 +2099,16 @@ mod tests { .expect("TIFF decode must succeed"); assert_eq!(img.width(), 2); assert_eq!(img.height(), 2); - assert_eq!(img.data.len(), 2 * 2 * 3); + assert_eq!(img.data().len(), 2 * 2 * 3); } #[test] fn tiff_decode_first_pixel_is_red() { let tiff_data = make_minimal_tiff_rgb8(); let img = decode_standard_image(&tiff_data, StandardFormat::Tiff).unwrap(); - assert_eq!(img.data[0], u8_to_u16(255), "R of top-left pixel"); - assert_eq!(img.data[1], u8_to_u16(0), "G of top-left pixel"); - assert_eq!(img.data[2], u8_to_u16(0), "B of top-left pixel"); + assert_eq!(img.data()[0], u8_to_u16(255), "R of top-left pixel"); + assert_eq!(img.data()[1], u8_to_u16(0), "G of top-left pixel"); + assert_eq!(img.data()[2], u8_to_u16(0), "B of top-left pixel"); } #[test] @@ -2100,9 +2140,9 @@ mod tests { let img = decode_standard_image(&tiff_data, StandardFormat::Tiff).unwrap(); assert_eq!(img.width(), 4); assert_eq!(img.height(), 4); - assert_eq!(img.data.len(), 4 * 4 * 3); + assert_eq!(img.data().len(), 4 * 4 * 3); // Grayscale: R == G == B for each pixel - for px in img.data.chunks_exact(3) { + for px in img.data().chunks_exact(3) { assert_eq!(px[0], px[1]); assert_eq!(px[1], px[2]); } @@ -2133,11 +2173,11 @@ mod tests { assert_eq!(img.width(), 2); assert_eq!(img.height(), 2); // Should be RGB only (alpha dropped) - assert_eq!(img.data.len(), 2 * 2 * 3); + assert_eq!(img.data().len(), 2 * 2 * 3); // First pixel should be red - assert_eq!(img.data[0], u8_to_u16(255)); - assert_eq!(img.data[1], u8_to_u16(0)); - assert_eq!(img.data[2], u8_to_u16(0)); + assert_eq!(img.data()[0], u8_to_u16(255)); + assert_eq!(img.data()[1], u8_to_u16(0)); + assert_eq!(img.data()[2], u8_to_u16(0)); } /// Build a 16-bit RGB TIFF (2×2) in memory. @@ -2165,9 +2205,9 @@ mod tests { assert_eq!(img.width(), 2); assert_eq!(img.height(), 2); // 16-bit values should be preserved exactly - assert_eq!(img.data[0], 65535); // R of red pixel - assert_eq!(img.data[1], 0); // G of red pixel - assert_eq!(img.data[2], 0); // B of red pixel + assert_eq!(img.data()[0], 65535); // R of red pixel + assert_eq!(img.data()[1], 0); // G of red pixel + assert_eq!(img.data()[2], 0); // B of red pixel } #[cfg(not(feature = "avif-decode"))] @@ -2493,7 +2533,7 @@ mod tests { let img = result.unwrap(); assert_eq!(img.width(), 4); assert_eq!(img.height(), 4); - assert_eq!(img.data.len(), 4 * 4 * 3); + assert_eq!(img.data().len(), 4 * 4 * 3); } // ── read_standard_image_metadata ───────────────────────────────────── @@ -2526,7 +2566,7 @@ mod tests { // Build a 2×2 synthetic image (solid red). let data: Vec = vec![65535, 0, 0, 65535, 0, 0, 65535, 0, 0, 65535, 0, 0]; - let rgb = RgbImage::new(2, 2, data); + let rgb = RgbImage::new(2, 2, data).expect("valid RGB buffer"); // Build metadata with known EXIF values. let md = ImageMetadata { diff --git a/crates/rawshift-image/src/prelude.rs b/crates/rawshift-image/src/prelude.rs index f5c3c69..ed1674f 100644 --- a/crates/rawshift-image/src/prelude.rs +++ b/crates/rawshift-image/src/prelude.rs @@ -4,10 +4,10 @@ //! //! # Contents //! -//! - **`core`** — `RawImage`, `RgbImage`, `Size`, `Rect`, `Point`, `CfaPattern`, -//! `ImageMetadata`, `ColorSpace`, `BitDepth`, `CodecInfo`, the generic metadata -//! model (`MetadataValue`, `MetadataKey`, `MetadataNamespace`, `MetadataEntry`), -//! and related structs. +//! - **`core`** — `RawImage`, `RgbImage`, `Dimensions`, `Rect`, `Point`, +//! `CfaPattern`, `ImageMetadata`, `ColorDescription`, `BitDepth`, `CodecInfo`, +//! the generic metadata model (`MetadataValue`, `MetadataKey`, +//! `MetadataNamespace`, `MetadataEntry`), and related structs. //! - **`data`** — Camera color-calibration database (`CameraCalibration`, //! `get_camera_calibration`, `all_cameras`). //! - **`error`** — `RawError`, `ParseError`, `FormatError`, `ProcessingError`, @@ -23,16 +23,16 @@ //! `apply_tonemap`, `compute_camera_to_srgb`, `ColorSpaceTransform`, and more. // core -pub use crate::core::image::{CfaPattern, RawImage, Rect, RgbImage, Size, XTransPattern}; +pub use crate::core::image::{CfaPattern, RawImage, Rect, XTransPattern}; pub use crate::core::metadata::{ - CameraInfo, DateTimeInfo, DngCalibrationInfo, DngColorInfo, DngProfileInfo, ExifInfo, GpsInfo, - ImageInfo, ImageMetadata, MetadataEntry, MetadataExtractor, MetadataKey, MetadataNamespace, - MetadataValue, + CameraInfo, DateTimeInfo, DngCalibrationInfo, DngColorInfo, DngProfileInfo, ExifInfo, + ExtractMetadata, GpsInfo, ImageInfo, ImageMetadata, MetadataEntry, MetadataKey, + MetadataNamespace, MetadataValue, }; -pub use crate::core::pixel::{ - FromF32, Rgb, Rgb8, Rgb16, RgbF32, Rgba, Rgba8, Rgba16, RgbaF32, Sample, +pub use crate::core::{ + CodecDirection, CodecId, CodecInfo, ColorDescription, Dimensions, IccProfile, RgbImage, }; -pub use crate::core::{CodecDirection, CodecId, CodecInfo, ColorSpace, IccProfile}; +pub use crate::core::{Pixel, Rgb8, Rgb16, Rgba8, Rgba16, Sample}; // data pub use crate::data::cameras::find_camera_calibration; diff --git a/crates/rawshift-image/src/processing/color.rs b/crates/rawshift-image/src/processing/color.rs index c26070f..3ebcc24 100644 --- a/crates/rawshift-image/src/processing/color.rs +++ b/crates/rawshift-image/src/processing/color.rs @@ -7,7 +7,8 @@ //! //! All functions operate on 16-bit RGB data in the range [0, 65535]. -use crate::core::image::{RawImage, RgbImage}; +use crate::core::RgbImage; +use crate::core::image::RawImage; /// Apply white balance to a raw Bayer CFA image. /// @@ -66,7 +67,7 @@ pub fn apply_white_balance(image: &mut RgbImage, coeffs: (f32, f32, f32)) { let (r_scale, g_scale, b_scale) = coeffs; // Process pixel triplets - for chunk in image.data.chunks_exact_mut(3) { + for chunk in image.data_mut().chunks_exact_mut(3) { // Red let r = chunk[0] as f32 * r_scale; chunk[0] = clamp_u16(r); @@ -96,7 +97,7 @@ pub fn apply_white_balance(image: &mut RgbImage, coeffs: (f32, f32, f32)) { /// * `image` - The image to modify in place /// * `matrix` - 3x3 row-major color transformation matrix pub fn apply_color_matrix(image: &mut RgbImage, matrix: &[f32; 9]) { - for chunk in image.data.chunks_exact_mut(3) { + for chunk in image.data_mut().chunks_exact_mut(3) { let r = chunk[0] as f32; let g = chunk[1] as f32; let b = chunk[2] as f32; @@ -154,7 +155,7 @@ impl GammaLut { /// Apply gamma correction using the cached lookup table. pub fn apply(&self, image: &mut RgbImage) { - for pixel in &mut image.data { + for pixel in image.data_mut() { *pixel = self.table[*pixel as usize]; } } @@ -196,10 +197,10 @@ pub fn clamp_u16(val: f32) -> u16 { #[cfg(test)] mod tests { use super::*; - use crate::core::image::{CfaPattern, Rect, Size}; + use crate::core::image::{CfaPattern, Dimensions, Rect}; fn create_test_raw_image(width: u32, height: u32, pattern: CfaPattern) -> RawImage { - let size = Size::new(width, height); + let size = Dimensions { width, height }; let active = Rect::from_coords(0, 0, width, height); RawImage::new(size, active, 14, pattern) } @@ -308,7 +309,7 @@ mod tests { data.push(g); data.push(b); } - RgbImage::new(width, height, data) + RgbImage::new(width, height, data).expect("valid RGB buffer") } #[test] @@ -327,9 +328,9 @@ mod tests { // Identity transform should leave values unchanged for i in 0..4 { - assert_eq!(image.data[i * 3], 1000); - assert_eq!(image.data[i * 3 + 1], 2000); - assert_eq!(image.data[i * 3 + 2], 3000); + assert_eq!(image.data()[i * 3], 1000); + assert_eq!(image.data()[i * 3 + 1], 2000); + assert_eq!(image.data()[i * 3 + 2], 3000); } } @@ -339,9 +340,9 @@ mod tests { apply_white_balance(&mut image, (2.0, 1.0, 0.5)); for i in 0..4 { - assert_eq!(image.data[i * 3], 2000); // R * 2.0 - assert_eq!(image.data[i * 3 + 1], 2000); // G * 1.0 - assert_eq!(image.data[i * 3 + 2], 1500); // B * 0.5 + assert_eq!(image.data()[i * 3], 2000); // R * 2.0 + assert_eq!(image.data()[i * 3 + 1], 2000); // G * 1.0 + assert_eq!(image.data()[i * 3 + 2], 1500); // B * 0.5 } } @@ -350,9 +351,9 @@ mod tests { let mut image = create_test_image(1, 1, 60000, 30000, 1000); apply_white_balance(&mut image, (2.0, 2.0, 0.0)); - assert_eq!(image.data[0], 65535); // Clipped to max - assert_eq!(image.data[1], 60000); // 30000 * 2 - assert_eq!(image.data[2], 0); // Clipped to 0 + assert_eq!(image.data()[0], 65535); // Clipped to max + assert_eq!(image.data()[1], 60000); // 30000 * 2 + assert_eq!(image.data()[2], 0); // Clipped to 0 } #[test] @@ -363,9 +364,9 @@ mod tests { // Identity matrix should leave values unchanged for i in 0..4 { - assert_eq!(image.data[i * 3], 1000); - assert_eq!(image.data[i * 3 + 1], 2000); - assert_eq!(image.data[i * 3 + 2], 3000); + assert_eq!(image.data()[i * 3], 1000); + assert_eq!(image.data()[i * 3 + 1], 2000); + assert_eq!(image.data()[i * 3 + 2], 3000); } } @@ -376,19 +377,19 @@ mod tests { let mut image = create_test_image(1, 1, 1000, 2000, 3000); apply_color_matrix(&mut image, &swap_matrix); - assert_eq!(image.data[0], 3000); // R_out = B_in - assert_eq!(image.data[1], 2000); // G_out = G_in - assert_eq!(image.data[2], 1000); // B_out = R_in + assert_eq!(image.data()[0], 3000); // R_out = B_in + assert_eq!(image.data()[1], 2000); // G_out = G_in + assert_eq!(image.data()[2], 1000); // B_out = R_in } #[test] fn test_gamma_identity() { let mut image = create_test_image(2, 2, 1000, 2000, 3000); - let original = image.data.clone(); + let original = image.data().to_vec(); apply_gamma(&mut image, 1.0); // Gamma 1.0 should be identity (fast path) - assert_eq!(image.data, original); + assert_eq!(image.data(), original); } #[test] @@ -397,14 +398,14 @@ mod tests { apply_gamma(&mut image, 2.2); // Black should stay black - assert_eq!(image.data[0], 0); + assert_eq!(image.data()[0], 0); // White should stay white - assert_eq!(image.data[2], 65535); + assert_eq!(image.data()[2], 65535); // Mid-tone should be brighter (gamma correction raises values) assert!( - image.data[1] > 32768, + image.data()[1] > 32768, "Mid-tone {} should be > 32768", - image.data[1] + image.data()[1] ); } @@ -420,10 +421,10 @@ mod tests { let mut image = create_test_image(1, 1, 0, 32768, 65535); lut.apply(&mut image); - assert_eq!(image.data[0], 0); - assert_eq!(image.data[2], 65535); + assert_eq!(image.data()[0], 0); + assert_eq!(image.data()[2], 65535); assert!( - image.data[1] > 32768, + image.data()[1] > 32768, "Mid-tone should be brighter after gamma" ); } @@ -439,6 +440,6 @@ mod tests { lut.apply(&mut image1); lut.apply(&mut image2); - assert_eq!(image1.data, image2.data); + assert_eq!(image1.data(), image2.data()); } } diff --git a/crates/rawshift-image/src/processing/demosaic/bayer.rs b/crates/rawshift-image/src/processing/demosaic/bayer.rs index 56ca90f..2b21861 100644 --- a/crates/rawshift-image/src/processing/demosaic/bayer.rs +++ b/crates/rawshift-image/src/processing/demosaic/bayer.rs @@ -1044,10 +1044,10 @@ impl Demosaic for Rcd { #[cfg(test)] mod tests { use super::*; - use crate::core::image::{Point, Rect, Size}; + use crate::core::image::{Dimensions, Point, Rect}; fn create_test_raw(width: u32, height: u32, pattern: CfaPattern, value: u16) -> RawImage { - let size = Size::new(width, height); + let size = Dimensions { width, height }; let active_area = Rect::new(Point::ORIGIN, size); RawImage::builder(size, active_area, 14, pattern) .white_level(16383) @@ -1056,7 +1056,7 @@ mod tests { } fn create_gradient_raw(width: u32, height: u32, pattern: CfaPattern) -> RawImage { - let size = Size::new(width, height); + let size = Dimensions { width, height }; let active_area = Rect::new(Point::ORIGIN, size); let mut data = vec![0u16; (width * height) as usize]; for y in 0..height { @@ -1110,7 +1110,7 @@ mod tests { for x in 4..16 { let idx = (y * 20 + x) * 3; for c in 0..3 { - let val = rgb.data[idx + c]; + let val = rgb.data()[idx + c]; assert!( (val as i32 - 5000).abs() < 500, "pixel ({},{}) ch {} = {}, expected ~5000", @@ -1136,10 +1136,10 @@ mod tests { let rgb = Amaze.demosaic(&raw); assert_eq!(rgb.width(), 20); assert_eq!(rgb.height(), 20); - assert_eq!(rgb.data.len(), 20 * 20 * 3); + assert_eq!(rgb.data().len(), 20 * 20 * 3); // All values should be non-negative and bounded - for val in &rgb.data { + for val in rgb.data() { assert!( *val <= 16383, "pattern {:?}: value {} too high", @@ -1163,8 +1163,10 @@ mod tests { let idx_down = ((y + 1) * 40 + x) * 3; for c in 0..3 { - let diff_h = (rgb.data[idx + c] as i32 - rgb.data[idx_right + c] as i32).abs(); - let diff_v = (rgb.data[idx + c] as i32 - rgb.data[idx_down + c] as i32).abs(); + let diff_h = + (rgb.data()[idx + c] as i32 - rgb.data()[idx_right + c] as i32).abs(); + let diff_v = + (rgb.data()[idx + c] as i32 - rgb.data()[idx_down + c] as i32).abs(); assert!( diff_h < 1000, "horizontal jump at ({},{}) ch {}: {}", @@ -1195,7 +1197,7 @@ mod tests { let rgb = Amaze.demosaic(&raw); // Green channel at (1,0) should be exactly 7000 // pixel (1, 0): row=0, col=1, so index = 1 * 3 + 1 = 4 - let g = rgb.data[3 + 1]; + let g = rgb.data()[3 + 1]; assert_eq!( g, 7000, "green pixel should be preserved exactly, got {}", @@ -1205,7 +1207,10 @@ mod tests { #[test] fn test_amaze_with_active_area() { - let size = Size::new(30, 30); + let size = Dimensions { + width: 30, + height: 30, + }; let active_area = Rect::from_coords(5, 5, 20, 20); let raw = RawImage::builder(size, active_area, 14, CfaPattern::Rggb) .white_level(16383) @@ -1214,7 +1219,7 @@ mod tests { let rgb = Amaze.demosaic(&raw); assert_eq!(rgb.width(), 20); assert_eq!(rgb.height(), 20); - assert_eq!(rgb.data.len(), 20 * 20 * 3); + assert_eq!(rgb.data().len(), 20 * 20 * 3); } #[test] @@ -1273,7 +1278,7 @@ mod tests { for x in 4..16 { let idx = (y * 20 + x) * 3; for c in 0..3 { - let val = rgb.data[idx + c]; + let val = rgb.data()[idx + c]; assert!( (val as i32 - 5000).abs() < 500, "LMMSE pixel ({},{}) ch {} = {}, expected ~5000", @@ -1299,9 +1304,9 @@ mod tests { let rgb = Lmmse.demosaic(&raw); assert_eq!(rgb.width(), 20); assert_eq!(rgb.height(), 20); - assert_eq!(rgb.data.len(), 20 * 20 * 3); + assert_eq!(rgb.data().len(), 20 * 20 * 3); - for val in &rgb.data { + for val in rgb.data() { assert!( *val <= 16383, "LMMSE pattern {:?}: value {} too high", @@ -1324,8 +1329,10 @@ mod tests { let idx_down = ((y + 1) * 40 + x) * 3; for c in 0..3 { - let diff_h = (rgb.data[idx + c] as i32 - rgb.data[idx_right + c] as i32).abs(); - let diff_v = (rgb.data[idx + c] as i32 - rgb.data[idx_down + c] as i32).abs(); + let diff_h = + (rgb.data()[idx + c] as i32 - rgb.data()[idx_right + c] as i32).abs(); + let diff_v = + (rgb.data()[idx + c] as i32 - rgb.data()[idx_down + c] as i32).abs(); assert!( diff_h < 1000, "LMMSE horizontal jump at ({},{}) ch {}: {}", @@ -1349,7 +1356,10 @@ mod tests { #[test] fn test_lmmse_with_active_area() { - let size = Size::new(30, 30); + let size = Dimensions { + width: 30, + height: 30, + }; let active_area = Rect::from_coords(5, 5, 20, 20); let raw = RawImage::builder(size, active_area, 14, CfaPattern::Rggb) .white_level(16383) @@ -1358,7 +1368,7 @@ mod tests { let rgb = Lmmse.demosaic(&raw); assert_eq!(rgb.width(), 20); assert_eq!(rgb.height(), 20); - assert_eq!(rgb.data.len(), 20 * 20 * 3); + assert_eq!(rgb.data().len(), 20 * 20 * 3); } // ── RCD tests ───────────────────────────────────────────────────────────── @@ -1399,7 +1409,7 @@ mod tests { for x in 4..16 { let idx = (y * 20 + x) * 3; for c in 0..3 { - let val = rgb.data[idx + c]; + let val = rgb.data()[idx + c]; assert!( (val as i32 - 5000).abs() < 500, "RCD pixel ({},{}) ch {} = {}, expected ~5000", @@ -1425,9 +1435,9 @@ mod tests { let rgb = Rcd.demosaic(&raw); assert_eq!(rgb.width(), 20); assert_eq!(rgb.height(), 20); - assert_eq!(rgb.data.len(), 20 * 20 * 3); + assert_eq!(rgb.data().len(), 20 * 20 * 3); - for val in &rgb.data { + for val in rgb.data() { assert!( *val <= 16383, "RCD pattern {:?}: value {} too high", @@ -1450,8 +1460,10 @@ mod tests { let idx_down = ((y + 1) * 40 + x) * 3; for c in 0..3 { - let diff_h = (rgb.data[idx + c] as i32 - rgb.data[idx_right + c] as i32).abs(); - let diff_v = (rgb.data[idx + c] as i32 - rgb.data[idx_down + c] as i32).abs(); + let diff_h = + (rgb.data()[idx + c] as i32 - rgb.data()[idx_right + c] as i32).abs(); + let diff_v = + (rgb.data()[idx + c] as i32 - rgb.data()[idx_down + c] as i32).abs(); assert!( diff_h < 1000, "RCD horizontal jump at ({},{}) ch {}: {}", @@ -1475,7 +1487,10 @@ mod tests { #[test] fn test_rcd_with_active_area() { - let size = Size::new(30, 30); + let size = Dimensions { + width: 30, + height: 30, + }; let active_area = Rect::from_coords(5, 5, 20, 20); let raw = RawImage::builder(size, active_area, 14, CfaPattern::Rggb) .white_level(16383) @@ -1484,6 +1499,6 @@ mod tests { let rgb = Rcd.demosaic(&raw); assert_eq!(rgb.width(), 20); assert_eq!(rgb.height(), 20); - assert_eq!(rgb.data.len(), 20 * 20 * 3); + assert_eq!(rgb.data().len(), 20 * 20 * 3); } } diff --git a/crates/rawshift-image/src/processing/demosaic/bilinear.rs b/crates/rawshift-image/src/processing/demosaic/bilinear.rs index 212cca6..aebc453 100644 --- a/crates/rawshift-image/src/processing/demosaic/bilinear.rs +++ b/crates/rawshift-image/src/processing/demosaic/bilinear.rs @@ -232,11 +232,11 @@ fn avg4(a: u16, b: u16, c: u16, d: u16) -> u16 { #[cfg(test)] mod tests { use super::*; - use crate::core::image::{Point, Rect, Size}; + use crate::core::image::{Dimensions, Point, Rect}; /// Create a test raw image with given dimensions and CFA pattern. fn create_test_raw(width: u32, height: u32, pattern: CfaPattern, value: u16) -> RawImage { - let size = Size::new(width, height); + let size = Dimensions { width, height }; let active_area = Rect::new(Point::ORIGIN, size); let pixel_count = (width * height) as usize; RawImage::builder(size, active_area, 14, pattern) @@ -287,7 +287,7 @@ mod tests { assert_eq!(rgb.width(), 10); assert_eq!(rgb.height(), 10); - assert_eq!(rgb.data.len(), 10 * 10 * 3); + assert_eq!(rgb.data().len(), 10 * 10 * 3); // Interior pixels (not on edge) should be close to input value // Edge pixels may have lower values due to boundary handling @@ -295,7 +295,7 @@ mod tests { for x in 1..9 { let idx = ((y * 10 + x) * 3) as usize; for c in 0..3 { - let pixel = rgb.data[idx + c]; + let pixel = rgb.data()[idx + c]; assert!( (4000..=5500).contains(&pixel), "Interior pixel at ({},{}) channel {} value {} out of range", @@ -324,10 +324,10 @@ mod tests { assert_eq!(rgb.width(), 8); assert_eq!(rgb.height(), 8); - assert_eq!(rgb.data.len(), 8 * 8 * 3); + assert_eq!(rgb.data().len(), 8 * 8 * 3); // Verify all pixels have reasonable values - for pixel in &rgb.data { + for pixel in rgb.data() { assert!( *pixel <= 3000, "Pattern {:?}: pixel value {} too high", @@ -350,9 +350,9 @@ mod tests { assert_eq!(rgb.height(), 2); // First pixel (0,0) - this is a Red position in RGGB - let r = rgb.data[0]; - let g = rgb.data[1]; - let _b = rgb.data[2]; + let r = rgb.data()[0]; + let g = rgb.data()[1]; + let _b = rgb.data()[2]; // Red should be the original value (1000) assert_eq!(r, 1000, "Red at RGGB position (0,0) should be 1000"); @@ -366,7 +366,10 @@ mod tests { fn test_demosaic_with_active_area() { // Test that active_area is respected let raw = { - let size = Size::new(10, 10); + let size = Dimensions { + width: 10, + height: 10, + }; let active_area = Rect::from_coords(3, 3, 4, 4); RawImage::builder(size, active_area, 14, CfaPattern::Rggb) .white_level(16383) @@ -379,7 +382,7 @@ mod tests { // Output dimensions should match active area assert_eq!(rgb.width(), 4); assert_eq!(rgb.height(), 4); - assert_eq!(rgb.data.len(), 4 * 4 * 3); + assert_eq!(rgb.data().len(), 4 * 4 * 3); } #[test] @@ -414,11 +417,11 @@ mod tests { assert_eq!(rgb.width(), 6, "width for {:?}", pattern); assert_eq!(rgb.height(), 6, "height for {:?}", pattern); - assert_eq!(rgb.data.len(), 6 * 6 * 3, "data length for {:?}", pattern); + assert_eq!(rgb.data().len(), 6 * 6 * 3, "data length for {:?}", pattern); // All output pixels must be in valid u16 range (which they always are, // but also check that at least some pixels are non-zero for a non-zero input) - let non_zero = rgb.data.iter().any(|&v| v > 0); + let non_zero = rgb.data().iter().any(|&v| v > 0); assert!( non_zero, "Output for {:?} should have non-zero pixels", @@ -431,7 +434,10 @@ mod tests { fn test_bilinear_with_active_area() { // Test various active area offsets let raw = { - let size = Size::new(12, 12); + let size = Dimensions { + width: 12, + height: 12, + }; let active_area = Rect::from_coords(2, 4, 6, 6); RawImage::builder(size, active_area, 14, CfaPattern::Rggb) .white_level(16383) @@ -452,13 +458,13 @@ mod tests { "output height should match active area height" ); assert_eq!( - rgb.data.len(), + rgb.data().len(), 6 * 6 * 3, "output should have correct data length" ); // Output values are u16, so always in [0, 65535] by definition - assert!(!rgb.data.is_empty(), "output should have pixel data"); + assert!(!rgb.data().is_empty(), "output should have pixel data"); } #[test] @@ -467,7 +473,7 @@ mod tests { // green channel in the output, since bilinear averages neighbors. let width = 8u32; let height = 4u32; - let size = Size::new(width, height); + let size = Dimensions { width, height }; let active_area = Rect::new(Point::ORIGIN, size); // Fill with a horizontal gradient: pixel value increases with x @@ -493,8 +499,8 @@ mod tests { // Compare interior pixels: pixel at x+1 should have green >= pixel at x // (with tolerance for boundary effects) for x in 1..(width as usize - 2) { - let g_left = rgb.data[row_start + (x - 1) * 3 + 1]; - let g_right = rgb.data[row_start + (x + 1) * 3 + 1]; + let g_left = rgb.data()[row_start + (x - 1) * 3 + 1]; + let g_right = rgb.data()[row_start + (x + 1) * 3 + 1]; assert!( g_right >= g_left || (g_right as i32 - g_left as i32).abs() < 2000, "gradient smoothness: g[{}]={} should not greatly exceed g[{}]={} in row {}", diff --git a/crates/rawshift-image/src/processing/demosaic/mod.rs b/crates/rawshift-image/src/processing/demosaic/mod.rs index 41547ea..ace899a 100644 --- a/crates/rawshift-image/src/processing/demosaic/mod.rs +++ b/crates/rawshift-image/src/processing/demosaic/mod.rs @@ -1,4 +1,5 @@ -use crate::core::image::{RawImage, RgbImage}; +use crate::core::RgbImage; +use crate::core::image::RawImage; /// Error type for demosaicing operations. #[derive(Debug, Clone)] @@ -205,7 +206,7 @@ pub trait Demosaic { let mut data = vec![0u16; (width as usize) * (height as usize) * 3]; self.demosaic_into(raw, &mut data) .expect("demosaic_into failed with correctly sized buffer"); - RgbImage::new(width, height, data) + RgbImage::new(width, height, data).expect("width*height*3 buffer allocated above") } } diff --git a/crates/rawshift-image/src/processing/demosaic/xtrans.rs b/crates/rawshift-image/src/processing/demosaic/xtrans.rs index 3eddc6b..cc095c2 100644 --- a/crates/rawshift-image/src/processing/demosaic/xtrans.rs +++ b/crates/rawshift-image/src/processing/demosaic/xtrans.rs @@ -439,7 +439,7 @@ impl Demosaic for XTransFast { #[cfg(test)] mod tests { use super::*; - use crate::core::image::{CfaPattern, Point, Rect, Size}; + use crate::core::image::{CfaPattern, Dimensions, Point, Rect}; /// Create a minimal X-Trans RawImage for testing. /// @@ -447,7 +447,7 @@ mod tests { /// standard Fujifilm pattern so the Markesteijn algorithm can identify /// which colour each sensor site carries. fn create_xtrans_raw(width: u32, height: u32, value: u16) -> RawImage { - let size = Size::new(width, height); + let size = Dimensions { width, height }; let active_area = Rect::new(Point::ORIGIN, size); let pixel_count = (width * height) as usize; RawImage::builder(size, active_area, 14, CfaPattern::Rggb) @@ -577,7 +577,10 @@ mod tests { fn markesteijn_uses_default_pattern_when_xtrans_is_none() { // When xtrans_pattern is None the algorithm must still succeed // (falling back to XTransPattern::standard()). - let size = Size::new(12, 12); + let size = Dimensions { + width: 12, + height: 12, + }; let active_area = Rect::new(Point::ORIGIN, size); // xtrans_pattern deliberately absent — algorithm should fall back to standard() let raw = RawImage::builder(size, active_area, 14, CfaPattern::Rggb) diff --git a/crates/rawshift-image/src/transforms/bad_pixel.rs b/crates/rawshift-image/src/transforms/bad_pixel.rs index 03bd21b..b09dc54 100644 --- a/crates/rawshift-image/src/transforms/bad_pixel.rs +++ b/crates/rawshift-image/src/transforms/bad_pixel.rs @@ -193,11 +193,11 @@ pub fn apply_bad_pixel_correction( #[cfg(test)] mod tests { use super::*; - use crate::core::image::{Rect, Size}; + use crate::core::image::{Dimensions, Rect}; /// Build a minimal RawImage filled with a uniform value. fn make_raw(width: u32, height: u32, fill: u16) -> RawImage { - let size = Size::new(width, height); + let size = Dimensions { width, height }; let active = Rect::from_coords(0, 0, width, height); let mut img = RawImage::new(size, active, 14, CfaPattern::Rggb); for v in img.data.iter_mut() { diff --git a/crates/rawshift-image/src/transforms/black_level.rs b/crates/rawshift-image/src/transforms/black_level.rs index 6a897ba..48fb5ca 100644 --- a/crates/rawshift-image/src/transforms/black_level.rs +++ b/crates/rawshift-image/src/transforms/black_level.rs @@ -29,10 +29,10 @@ pub fn apply_black_level(raw: &mut RawImage) { #[cfg(test)] mod tests { use super::*; - use crate::core::image::{CfaPattern, Rect, Size}; + use crate::core::image::{CfaPattern, Dimensions, Rect}; fn make_raw(width: u32, height: u32, data: Vec, black_levels: [u16; 4]) -> RawImage { - let size = Size::new(width, height); + let size = Dimensions { width, height }; let active = Rect::from_coords(0, 0, width, height); let mut raw = RawImage::new(size, active, 14, CfaPattern::Rggb); raw.data = data; diff --git a/crates/rawshift-image/src/transforms/ca_correction.rs b/crates/rawshift-image/src/transforms/ca_correction.rs index 9109312..d8b91f6 100644 --- a/crates/rawshift-image/src/transforms/ca_correction.rs +++ b/crates/rawshift-image/src/transforms/ca_correction.rs @@ -6,7 +6,7 @@ //! independently rescaling the R and B channels relative to the image centre, //! using bilinear interpolation to sample the shifted source positions. -use crate::core::image::RgbImage; +use crate::core::RgbImage; /// Apply lateral chromatic aberration correction by rescaling colour channels. /// @@ -40,7 +40,8 @@ pub fn apply_ca_correction(image: &mut RgbImage, red_scale: f32, blue_scale: f32 let cx = (width as f32 - 1.0) * 0.5; let cy = (height as f32 - 1.0) * 0.5; - let input = image.data.clone(); + let input = image.data().to_vec(); + let data = image.data_mut(); let scales = [(0usize, red_scale), (2usize, blue_scale)]; @@ -57,7 +58,7 @@ pub fn apply_ca_correction(image: &mut RgbImage, red_scale: f32, blue_scale: f32 let sy = cy + (y as f32 - cy) * inv_scale; let value = bilinear_sample(&input, width, height, channel, sx, sy); - image.data[(y * width + x) * 3 + channel] = value; + data[(y * width + x) * 3 + channel] = value; } } } @@ -106,7 +107,7 @@ mod tests { fn make_rgb(width: u32, height: u32, fill: u16) -> RgbImage { let n = (width as usize) * (height as usize) * 3; - RgbImage::new(width, height, vec![fill; n]) + RgbImage::new(width, height, vec![fill; n]).expect("valid RGB buffer") } #[test] @@ -117,9 +118,9 @@ mod tests { let n = (w as usize) * (h as usize) * 3; // Use a non-trivial pattern so any change would be visible. let data: Vec = (0..n).map(|i| (i as u16).wrapping_mul(7)).collect(); - let mut img = RgbImage::new(w, h, data.clone()); + let mut img = RgbImage::new(w, h, data.clone()).expect("valid RGB buffer"); apply_ca_correction(&mut img, 1.0, 1.0); - assert_eq!(img.data, data, "scale 1.0 should leave image unchanged"); + assert_eq!(img.data(), data, "scale 1.0 should leave image unchanged"); } #[test] @@ -140,7 +141,7 @@ mod tests { apply_ca_correction(&mut img, 1.002, 0.998); assert_eq!(img.width(), w, "width must not change"); assert_eq!(img.height(), h, "height must not change"); - assert_eq!(img.data.len(), (w as usize) * (h as usize) * 3); + assert_eq!(img.data().len(), (w as usize) * (h as usize) * 3); } #[test] @@ -148,7 +149,7 @@ mod tests { // A flat image should remain flat regardless of scale. let mut img = make_rgb(12, 12, 8000); apply_ca_correction(&mut img, 1.005, 0.995); - assert!(img.data.iter().all(|&v| v == 8000)); + assert!(img.data().iter().all(|&v| v == 8000)); } #[test] @@ -159,9 +160,9 @@ mod tests { let n = (w as usize) * (h as usize) * 3; let data: Vec = (0..n).map(|i| i as u16).collect(); let original_green: Vec = data.chunks_exact(3).map(|px| px[1]).collect(); - let mut img = RgbImage::new(w, h, data); + let mut img = RgbImage::new(w, h, data).expect("valid RGB buffer"); apply_ca_correction(&mut img, 1.005, 0.995); - let corrected_green: Vec = img.data.chunks_exact(3).map(|px| px[1]).collect(); + let corrected_green: Vec = img.data().chunks_exact(3).map(|px| px[1]).collect(); assert_eq!( original_green, corrected_green, "G channel must not be modified" @@ -173,7 +174,7 @@ mod tests { let mut img = make_rgb(1, 1, 1234); apply_ca_correction(&mut img, 1.01, 0.99); // Only one pixel; it should remain at the clamped bilinear sample of itself. - assert_eq!(img.data[0], 1234); - assert_eq!(img.data[2], 1234); + assert_eq!(img.data()[0], 1234); + assert_eq!(img.data()[2], 1234); } } diff --git a/crates/rawshift-image/src/transforms/cfa.rs b/crates/rawshift-image/src/transforms/cfa.rs index 077dea8..30e5e73 100644 --- a/crates/rawshift-image/src/transforms/cfa.rs +++ b/crates/rawshift-image/src/transforms/cfa.rs @@ -4,7 +4,8 @@ //! defined in [`crate::processing::demosaic`]. It handles the selection of the appropriate //! algorithm and manages the conversion from raw sensor data to RGB image buffers. -use crate::core::image::{RawImage, RgbImage}; +use crate::core::RgbImage; +use crate::core::image::RawImage; use crate::error::RawResult; use crate::processing::demosaic::DemosaicMethod; diff --git a/crates/rawshift-image/src/transforms/color.rs b/crates/rawshift-image/src/transforms/color.rs index 4ca4d3f..7746068 100644 --- a/crates/rawshift-image/src/transforms/color.rs +++ b/crates/rawshift-image/src/transforms/color.rs @@ -7,7 +7,7 @@ //! It re-exports the optimized primitives from [`crate::processing::color`] and //! provides the [`ColorSpaceTransform`] struct for bundled pipeline steps. -use crate::core::image::RgbImage; +use crate::core::RgbImage; use crate::error::{RawError, RawResult}; // Re-export processing primitives as canonical transform entry points. @@ -188,67 +188,73 @@ pub fn estimate_cct_from_as_shot_neutral(as_shot_neutral: [f64; 3]) -> ColorTemp /// Convert an [`RgbImage`] into sRGB-encoded color space, in place. /// -/// Behaviour depends on the image's current [`ColorSpace`](crate::core::ColorSpace): -/// - `Srgb` / `Unknown` — no-op (`Unknown` is assumed to be sRGB already). -/// - `LinearSrgb` — applies the sRGB transfer function (OETF). -/// - `DisplayP3` / `Rec2020` / `AdobeRgb` / `ProPhotoRgb` — not yet supported; +/// Behaviour depends on the image's current [`ColorDescription`](crate::core::ColorDescription): +/// - `SRGB` / `UNSPECIFIED` — no-op (`UNSPECIFIED` is assumed to be sRGB already). +/// - `LINEAR_SRGB` — applies the sRGB transfer function (OETF). +/// - `DISPLAY_P3` / `REC2020` and other descriptions — not yet supported; /// returns [`RawError::Unsupported`]. Wide-gamut conversion needs a /// color-management engine, which is planned follow-up work. /// -/// On success the image's color-space tag is updated to `Srgb`. +/// On success the image's color description is updated to `SRGB`. pub fn convert_to_srgb(image: &mut RgbImage) -> RawResult<()> { - use crate::core::ColorSpace; + use crate::core::ColorDescription; use crate::transforms::tonemap::srgb_encode; - match image.color_space() { - ColorSpace::Srgb | ColorSpace::Unknown => {} - ColorSpace::LinearSrgb => { - for sample in &mut image.data { - let linear = *sample as f32 / 65535.0; - *sample = (srgb_encode(linear) * 65535.0 + 0.5) as u16; - } - } - other => { - return Err(RawError::Unsupported(format!( - "conversion from {} to sRGB requires a color-management engine \ - (not yet implemented)", - other.name() - ))); + let color = image.color(); + if color == ColorDescription::SRGB || color == ColorDescription::UNSPECIFIED { + // Already sRGB (or assumed to be) — nothing to do. + } else if color == ColorDescription::LINEAR_SRGB { + for sample in image.data_mut() { + let linear = *sample as f32 / 65535.0; + *sample = (srgb_encode(linear) * 65535.0 + 0.5) as u16; } + } else { + return Err(RawError::Unsupported(format!( + "conversion from {} to sRGB requires a color-management engine \ + (not yet implemented)", + color.name() + ))); } - image.set_color_space(ColorSpace::Srgb); + image.set_color(ColorDescription::SRGB); Ok(()) } #[cfg(test)] mod convert_srgb_tests { use super::*; - use crate::core::ColorSpace; + use crate::core::ColorDescription; #[test] fn linear_srgb_is_oetf_encoded() { // The sRGB OETF lifts linear mid-grey above 0.5. - let mut img = - RgbImage::with_color_space(1, 1, vec![32768, 32768, 32768], ColorSpace::LinearSrgb); + let mut img = RgbImage::with_color( + 1, + 1, + vec![32768, 32768, 32768], + ColorDescription::LINEAR_SRGB, + ) + .expect("valid RGB buffer"); convert_to_srgb(&mut img).expect("LinearSrgb conversion"); - assert_eq!(img.color_space(), ColorSpace::Srgb); - assert!(img.data.iter().all(|&v| v > 32768)); + assert_eq!(img.color(), ColorDescription::SRGB); + assert!(img.data().iter().all(|&v| v > 32768)); } #[test] fn srgb_and_unknown_are_noops() { - for cs in [ColorSpace::Srgb, ColorSpace::Unknown] { + for cs in [ColorDescription::SRGB, ColorDescription::UNSPECIFIED] { let original = vec![100u16, 200, 300]; - let mut img = RgbImage::with_color_space(1, 1, original.clone(), cs); + let mut img = + RgbImage::with_color(1, 1, original.clone(), cs).expect("valid RGB buffer"); convert_to_srgb(&mut img).expect("no-op conversion"); - assert_eq!(img.data, original); - assert_eq!(img.color_space(), ColorSpace::Srgb); + assert_eq!(img.data(), original); + assert_eq!(img.color(), ColorDescription::SRGB); } } #[test] fn wide_gamut_is_rejected() { - let mut img = RgbImage::with_color_space(1, 1, vec![0, 0, 0], ColorSpace::DisplayP3); + let mut img = RgbImage::with_color(1, 1, vec![0, 0, 0], ColorDescription::DISPLAY_P3) + .expect("valid RGB buffer"); assert!(convert_to_srgb(&mut img).is_err()); } } @@ -359,15 +365,15 @@ mod tests { #[test] fn test_apply_white_balance_clamps_at_white_level() { - use crate::core::image::RgbImage; + use crate::core::RgbImage; use crate::processing::color::apply_white_balance; // Pixel near max with a large gain should clamp at 65535 - let mut img = RgbImage::new(1, 1, vec![60000u16, 60000, 60000]); + let mut img = RgbImage::new(1, 1, vec![60000u16, 60000, 60000]).expect("valid RGB buffer"); apply_white_balance(&mut img, (3.0, 3.0, 3.0)); - assert_eq!(img.data[0], 65535, "R should clamp at 65535"); - assert_eq!(img.data[1], 65535, "G should clamp at 65535"); - assert_eq!(img.data[2], 65535, "B should clamp at 65535"); + assert_eq!(img.data()[0], 65535, "R should clamp at 65535"); + assert_eq!(img.data()[1], 65535, "G should clamp at 65535"); + assert_eq!(img.data()[2], 65535, "B should clamp at 65535"); } #[test] @@ -391,20 +397,20 @@ mod tests { #[test] fn test_apply_color_matrix_zero_input() { - use crate::core::image::RgbImage; + use crate::core::RgbImage; use crate::processing::color::apply_color_matrix; let any_matrix: [f32; 9] = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; - let mut img = RgbImage::new(2, 1, vec![0u16; 6]); + let mut img = RgbImage::new(2, 1, vec![0u16; 6]).expect("valid RGB buffer"); apply_color_matrix(&mut img, &any_matrix); - for v in &img.data { + for v in img.data() { assert_eq!(*v, 0, "Zero input should produce zero output"); } } #[test] fn test_apply_color_matrix_roundtrip() { - use crate::core::image::RgbImage; + use crate::core::RgbImage; use crate::processing::color::apply_color_matrix; // Use a known camera matrix and its inverse for a round-trip test. @@ -417,13 +423,13 @@ mod tests { let inv: [f32; 9] = inv_f64.map(|v| v as f32); let original = vec![10000u16, 20000, 30000]; - let mut img = RgbImage::new(1, 1, original.clone()); + let mut img = RgbImage::new(1, 1, original.clone()).expect("valid RGB buffer"); apply_color_matrix(&mut img, &cm); apply_color_matrix(&mut img, &inv); // After applying matrix then its inverse, values should be close to original - for (i, (&got, &expected)) in img.data.iter().zip(original.iter()).enumerate() { + for (i, (&got, &expected)) in img.data().iter().zip(original.iter()).enumerate() { let diff = (got as i32 - expected as i32).abs(); assert!( diff < 500, @@ -442,12 +448,12 @@ mod tests { let lut = GammaLut::new(2.2); // Create a minimal RgbImage with 0 and 65535 - use crate::core::image::RgbImage; - let mut img = RgbImage::new(1, 1, vec![0u16, 0, 65535]); + use crate::core::RgbImage; + let mut img = RgbImage::new(1, 1, vec![0u16, 0, 65535]).expect("valid RGB buffer"); lut.apply(&mut img); - assert_eq!(img.data[0], 0, "0 should map to 0"); - assert_eq!(img.data[1], 0, "0 should map to 0"); - assert_eq!(img.data[2], 65535, "65535 should map to 65535"); + assert_eq!(img.data()[0], 0, "0 should map to 0"); + assert_eq!(img.data()[1], 0, "0 should map to 0"); + assert_eq!(img.data()[2], 65535, "65535 should map to 65535"); } // ------------------------------------------------------------------------- diff --git a/crates/rawshift-image/src/transforms/denoise.rs b/crates/rawshift-image/src/transforms/denoise.rs index 643f258..03cc21a 100644 --- a/crates/rawshift-image/src/transforms/denoise.rs +++ b/crates/rawshift-image/src/transforms/denoise.rs @@ -5,7 +5,7 @@ //! preserves edges while smoothing noise by weighting contributions from both //! spatial proximity and intensity similarity. -use crate::core::image::RgbImage; +use crate::core::RgbImage; /// Apply bilateral noise reduction filter to an RGB image. /// @@ -54,7 +54,8 @@ pub fn apply_bilateral_filter( } } - let input = image.data.clone(); + let input = image.data().to_vec(); + let data = image.data_mut(); for y in 0..height { for x in 0..width { @@ -92,7 +93,7 @@ pub fn apply_bilateral_filter( input[center_idx + c] }; - image.data[center_idx + c] = result; + data[center_idx + c] = result; } } } @@ -132,7 +133,8 @@ pub fn apply_gaussian_blur(image: &mut RgbImage, sigma: f32, radius: u32) { } // Horizontal pass. - let mut tmp = image.data.clone(); + let data = image.data_mut(); + let mut tmp = data.to_vec(); for y in 0..height { for x in 0..width { for c in 0..3usize { @@ -143,13 +145,13 @@ pub fn apply_gaussian_blur(image: &mut RgbImage, sigma: f32, radius: u32) { for nx in x_min..=x_max { let ki = (nx as isize - x as isize + r as isize) as usize; let w = kernel[ki]; - acc += image.data[(y * width + nx) * 3 + c] as f32 * w; + acc += data[(y * width + nx) * 3 + c] as f32 * w; wsum += w; } tmp[(y * width + x) * 3 + c] = if wsum > 0.0 { (acc / wsum).round() as u16 } else { - image.data[(y * width + x) * 3 + c] + data[(y * width + x) * 3 + c] }; } } @@ -169,7 +171,7 @@ pub fn apply_gaussian_blur(image: &mut RgbImage, sigma: f32, radius: u32) { acc += tmp[(ny * width + x) * 3 + c] as f32 * w; wsum += w; } - image.data[(y * width + x) * 3 + c] = if wsum > 0.0 { + data[(y * width + x) * 3 + c] = if wsum > 0.0 { (acc / wsum).round() as u16 } else { tmp[(y * width + x) * 3 + c] @@ -186,13 +188,13 @@ mod tests { /// Build a uniform RGB image. fn make_uniform(width: u32, height: u32, value: u16) -> RgbImage { let n = (width as usize) * (height as usize) * 3; - RgbImage::new(width, height, vec![value; n]) + RgbImage::new(width, height, vec![value; n]).expect("valid RGB buffer") } /// Compute the variance of the values in the given channel (0, 1, or 2). fn channel_variance(image: &RgbImage, channel: usize) -> f64 { let vals: Vec = image - .data + .data() .chunks_exact(3) .map(|px| px[channel] as f64) .collect(); @@ -205,7 +207,7 @@ mod tests { // A perfectly uniform image should remain unchanged. let mut img = make_uniform(8, 8, 1000); apply_bilateral_filter(&mut img, 2.0, 2000.0, 2); - assert!(img.data.iter().all(|&v| v == 1000)); + assert!(img.data().iter().all(|&v| v == 1000)); } #[test] @@ -219,7 +221,7 @@ mod tests { let v = if i % 2 == 0 { 1000u16 } else { 2000u16 }; data.extend_from_slice(&[v, v, v]); } - let mut img = RgbImage::new(w, h, data.clone()); + let mut img = RgbImage::new(w, h, data.clone()).expect("valid RGB buffer"); let var_before = channel_variance(&img, 0); apply_bilateral_filter(&mut img, 3.0, 5000.0, 3); let var_after = channel_variance(&img, 0); @@ -243,13 +245,13 @@ mod tests { data.extend_from_slice(&[v, v, v]); } } - let mut img = RgbImage::new(w, h, data); + let mut img = RgbImage::new(w, h, data).expect("valid RGB buffer"); apply_bilateral_filter(&mut img, 2.0, 1000.0, 2); // Pixel in the left half should stay dark. - let left_px = img.data[((4 * w as usize) + 2) * 3]; + let left_px = img.data()[((4 * w as usize) + 2) * 3]; // Pixel in the right half should stay bright. - let right_px = img.data[((4 * w as usize) + 13) * 3]; + let right_px = img.data()[((4 * w as usize) + 13) * 3]; assert!(left_px < 10000, "left edge should stay dark, got {left_px}"); assert!( right_px > 50000, @@ -262,7 +264,7 @@ mod tests { let mut img = make_uniform(8, 8, 5000); apply_gaussian_blur(&mut img, 1.5, 2); // A uniform image blurred with any kernel is still uniform. - assert!(img.data.iter().all(|&v| v == 5000)); + assert!(img.data().iter().all(|&v| v == 5000)); } #[test] @@ -285,7 +287,7 @@ mod tests { let v: u16 = if i % 2 == 0 { 1000 } else { 3000 }; data.extend_from_slice(&[v, v, v]); } - let mut img = RgbImage::new(w, h, data); + let mut img = RgbImage::new(w, h, data).expect("valid RGB buffer"); let var_before = channel_variance(&img, 0); apply_gaussian_blur(&mut img, 2.0, 3); let var_after = channel_variance(&img, 0); diff --git a/crates/rawshift-image/src/transforms/lens_correction.rs b/crates/rawshift-image/src/transforms/lens_correction.rs index 5d70676..c5cebc1 100644 --- a/crates/rawshift-image/src/transforms/lens_correction.rs +++ b/crates/rawshift-image/src/transforms/lens_correction.rs @@ -6,7 +6,7 @@ //! output pixel, the corresponding distorted source position and sampling it //! with bilinear interpolation. -use crate::core::image::RgbImage; +use crate::core::RgbImage; // ── Public API ──────────────────────────────────────────────────────────────── @@ -73,7 +73,8 @@ pub fn apply_warp_rectilinear_tangential( let cy_px = cy * height as f64; // Snapshot the source data before modifying in-place. - let src = image.data.clone(); + let src = image.data().to_vec(); + let data = image.data_mut(); for y in 0..height { for x in 0..width { @@ -100,7 +101,7 @@ pub fn apply_warp_rectilinear_tangential( // Write bilinear sample for each of R, G, B. let dst = (y * width + x) * 3; for ch in 0..3usize { - image.data[dst + ch] = + data[dst + ch] = bilinear_sample(&src, width, height, ch, src_x as f32, src_y as f32); } } @@ -153,13 +154,13 @@ mod tests { fn make_rgb(width: u32, height: u32, fill: u16) -> RgbImage { let n = (width as usize) * (height as usize) * 3; - RgbImage::new(width, height, vec![fill; n]) + RgbImage::new(width, height, vec![fill; n]).expect("valid RGB buffer") } fn make_gradient(width: u32, height: u32) -> RgbImage { let n = (width as usize) * (height as usize) * 3; let data: Vec = (0..n).map(|i| (i as u16).wrapping_mul(7)).collect(); - RgbImage::new(width, height, data) + RgbImage::new(width, height, data).expect("valid RGB buffer") } // ── apply_warp_rectilinear ──────────────────────────────────────────── @@ -176,7 +177,8 @@ mod tests { apply_warp_rectilinear(&mut img, [0.0; 4], 0.5, 0.5); assert_eq!( - img.data, original.data, + img.data(), + original.data(), "zero coefficients must leave image unchanged" ); } @@ -191,7 +193,7 @@ mod tests { assert_eq!(img.width(), w, "width must not change after warp"); assert_eq!(img.height(), h, "height must not change after warp"); - assert_eq!(img.data.len(), (w as usize) * (h as usize) * 3); + assert_eq!(img.data().len(), (w as usize) * (h as usize) * 3); } #[test] @@ -209,9 +211,9 @@ mod tests { apply_warp_rectilinear(&mut img, [-0.05, 0.002, 0.0, 0.0], 0.5, 0.5); // The centre pixel of a uniform image is always 32768 regardless of distortion. - assert_eq!(img.data[cx_idx * 3], 32768, "R of centre pixel"); - assert_eq!(img.data[cx_idx * 3 + 1], 32768, "G of centre pixel"); - assert_eq!(img.data[cx_idx * 3 + 2], 32768, "B of centre pixel"); + assert_eq!(img.data()[cx_idx * 3], 32768, "R of centre pixel"); + assert_eq!(img.data()[cx_idx * 3 + 1], 32768, "G of centre pixel"); + assert_eq!(img.data()[cx_idx * 3 + 2], 32768, "B of centre pixel"); } #[test] @@ -227,7 +229,7 @@ mod tests { fn test_warp_no_crash_1x1_image() { let mut img = make_rgb(1, 1, 5000); apply_warp_rectilinear(&mut img, [-0.1, 0.0, 0.0, 0.0], 0.5, 0.5); - assert_eq!(img.data[0], 5000); + assert_eq!(img.data()[0], 5000); } // ── apply_warp_rectilinear_tangential ───────────────────────────────── @@ -240,7 +242,8 @@ mod tests { apply_warp_rectilinear_tangential(&mut img, [0.0; 4], [0.0; 2], 0.5, 0.5); assert_eq!( - img.data, original.data, + img.data(), + original.data(), "all-zero tangential coefficients must leave image unchanged" ); } @@ -270,7 +273,7 @@ mod tests { let mut img = make_rgb(10, 10, 8000); apply_warp_rectilinear(&mut img, [-0.05, 0.02, -0.005, 0.001], 0.5, 0.5); assert!( - img.data.iter().all(|&v| v == 8000), + img.data().iter().all(|&v| v == 8000), "uniform image must remain uniform" ); } diff --git a/crates/rawshift-image/src/transforms/opcodes.rs b/crates/rawshift-image/src/transforms/opcodes.rs index 28cb9be..703a9cf 100644 --- a/crates/rawshift-image/src/transforms/opcodes.rs +++ b/crates/rawshift-image/src/transforms/opcodes.rs @@ -19,7 +19,7 @@ //! 2. `FixBadPixelsList` (ID 5) — replace specific known bad pixels //! 3. `GainMap` (ID 9) — spatially-varying lens-shading correction (critical for ProRAW) -use crate::core::image::RgbImage; +use crate::core::RgbImage; // ============================================================================ // Opcode data structures @@ -90,6 +90,8 @@ impl GainMap { // (1 = shared gain applied to all `planes` output channels). let planes_count = (self.planes as usize).min(3); + let data = image.data_mut(); + for y in 0..img_h { // Normalised image coordinate in [0, 1] let norm_v = if img_h > 1 { @@ -138,9 +140,8 @@ impl GainMap { + g10 * dr * (1.0 - dc) + g11 * dr * dc; - let val = image.data[pixel_base + channel]; - image.data[pixel_base + channel] = - (val as f64 * gain).clamp(0.0, 65535.0) as u16; + let val = data[pixel_base + channel]; + data[pixel_base + channel] = (val as f64 * gain).clamp(0.0, 65535.0) as u16; } } } @@ -511,11 +512,11 @@ mod tests { let data = build_gain_map_opcode_list(2.0); let list = OpcodeList::parse(&data); - let mut img = RgbImage::new(2, 2, vec![1000u16; 12]); + let mut img = RgbImage::new(2, 2, vec![1000u16; 12]).expect("valid RGB buffer"); list.apply_to_rgb(&mut img); // Uniform gain of 2.0 should double all pixels - for &v in &img.data { + for &v in img.data() { assert_eq!(v, 2000, "Expected pixel value 2000, got {v}"); } } diff --git a/crates/rawshift-image/src/transforms/orientation.rs b/crates/rawshift-image/src/transforms/orientation.rs index ae70a73..6a88860 100644 --- a/crates/rawshift-image/src/transforms/orientation.rs +++ b/crates/rawshift-image/src/transforms/orientation.rs @@ -3,7 +3,8 @@ //! Applies EXIF orientation tags and rectangular crop regions to //! fully demosaiced RGB images. -use crate::core::image::{Rect, RgbImage, Size}; +use crate::core::RgbImage; +use crate::core::image::Rect; /// Apply EXIF orientation transform to correct image display. /// @@ -49,13 +50,14 @@ pub fn apply_orientation(image: &mut RgbImage, orientation: u16) { pub fn flip_horizontal(image: &mut RgbImage) { let w = image.width() as usize; let h = image.height() as usize; + let data = image.data_mut(); for row in 0..h { for col in 0..w / 2 { let a = (row * w + col) * 3; let b = (row * w + (w - 1 - col)) * 3; - image.data.swap(a, b); - image.data.swap(a + 1, b + 1); - image.data.swap(a + 2, b + 2); + data.swap(a, b); + data.swap(a + 1, b + 1); + data.swap(a + 2, b + 2); } } } @@ -64,26 +66,28 @@ pub fn flip_horizontal(image: &mut RgbImage) { pub fn flip_vertical(image: &mut RgbImage) { let w = image.width() as usize; let h = image.height() as usize; + let data = image.data_mut(); for row in 0..h / 2 { for col in 0..w { let a = (row * w + col) * 3; let b = ((h - 1 - row) * w + col) * 3; - image.data.swap(a, b); - image.data.swap(a + 1, b + 1); - image.data.swap(a + 2, b + 2); + data.swap(a, b); + data.swap(a + 1, b + 1); + data.swap(a + 2, b + 2); } } } /// Rotate image 180°. pub fn rotate_180(image: &mut RgbImage) { - let n = image.data.len(); + let data = image.data_mut(); + let n = data.len(); let mut i = 0; let mut j = n - 3; while i < j { - image.data.swap(i, j); - image.data.swap(i + 1, j + 1); - image.data.swap(i + 2, j + 2); + data.swap(i, j); + data.swap(i + 1, j + 1); + data.swap(i + 2, j + 2); i += 3; j -= 3; } @@ -98,19 +102,21 @@ pub fn rotate_90_cw(image: &mut RgbImage) { let new_w = old_h; let new_h = old_w; let mut new_data = vec![0u16; new_w * new_h * 3]; + let data = image.data(); for old_row in 0..old_h { for old_col in 0..old_w { let new_row = old_col; let new_col = old_h - 1 - old_row; let src = (old_row * old_w + old_col) * 3; let dst = (new_row * new_w + new_col) * 3; - new_data[dst] = image.data[src]; - new_data[dst + 1] = image.data[src + 1]; - new_data[dst + 2] = image.data[src + 2]; + new_data[dst] = data[src]; + new_data[dst + 1] = data[src + 1]; + new_data[dst + 2] = data[src + 2]; } } - image.data = new_data; - image.set_size(Size::new(new_w as u32, new_h as u32)); + image + .replace_data(new_w as u32, new_h as u32, new_data) + .expect("rotation preserves sample count"); } /// Rotate image 90° counter-clockwise. @@ -122,19 +128,21 @@ pub fn rotate_90_ccw(image: &mut RgbImage) { let new_w = old_h; let new_h = old_w; let mut new_data = vec![0u16; new_w * new_h * 3]; + let data = image.data(); for old_row in 0..old_h { for old_col in 0..old_w { let new_row = old_w - 1 - old_col; let new_col = old_row; let src = (old_row * old_w + old_col) * 3; let dst = (new_row * new_w + new_col) * 3; - new_data[dst] = image.data[src]; - new_data[dst + 1] = image.data[src + 1]; - new_data[dst + 2] = image.data[src + 2]; + new_data[dst] = data[src]; + new_data[dst + 1] = data[src + 1]; + new_data[dst + 2] = data[src + 2]; } } - image.data = new_data; - image.set_size(Size::new(new_w as u32, new_h as u32)); + image + .replace_data(new_w as u32, new_h as u32, new_data) + .expect("rotation preserves sample count"); } /// Crop an RGB image to the given rectangle. @@ -150,12 +158,14 @@ pub fn apply_crop(image: &mut RgbImage, crop: Rect) { if x + w <= image.width() as usize && y + h <= image.height() as usize { let img_width = image.width() as usize; let mut new_data = Vec::with_capacity(w * h * 3); + let data = image.data(); for row in 0..h { let src_base = ((y + row) * img_width + x) * 3; - new_data.extend_from_slice(&image.data[src_base..src_base + w * 3]); + new_data.extend_from_slice(&data[src_base..src_base + w * 3]); } - image.set_size(Size::new(w as u32, h as u32)); - image.data = new_data; + image + .replace_data(w as u32, h as u32, new_data) + .expect("crop copies w*h*3 samples from a bounds-checked region"); } else { tracing::warn!( "Crop region out of bounds: {:?} vs {}x{}", @@ -169,10 +179,10 @@ pub fn apply_crop(image: &mut RgbImage, crop: Rect) { #[cfg(test)] mod tests { use super::*; - use crate::core::image::{Point, Size}; + use crate::core::image::{Dimensions, Point}; fn make_image(w: u32, h: u32, data: Vec) -> RgbImage { - RgbImage::new(w, h, data) + RgbImage::new(w, h, data).expect("valid RGB buffer") } #[test] @@ -180,21 +190,21 @@ mod tests { // 2x2 image: [R0,G0,B0, R1,G1,B1, R2,G2,B2, R3,G3,B3] let mut img = make_image(2, 2, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); flip_horizontal(&mut img); - assert_eq!(img.data, vec![4, 5, 6, 1, 2, 3, 10, 11, 12, 7, 8, 9]); + assert_eq!(img.data(), vec![4, 5, 6, 1, 2, 3, 10, 11, 12, 7, 8, 9]); } #[test] fn test_flip_vertical_2x2() { let mut img = make_image(2, 2, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); flip_vertical(&mut img); - assert_eq!(img.data, vec![7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5, 6]); + assert_eq!(img.data(), vec![7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5, 6]); } #[test] fn test_rotate_180_2x2() { let mut img = make_image(2, 2, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); rotate_180(&mut img); - assert_eq!(img.data, vec![10, 11, 12, 7, 8, 9, 4, 5, 6, 1, 2, 3]); + assert_eq!(img.data(), vec![10, 11, 12, 7, 8, 9, 4, 5, 6, 1, 2, 3]); } #[test] @@ -205,7 +215,7 @@ mod tests { rotate_90_cw(&mut img); assert_eq!(img.width(), 2); assert_eq!(img.height(), 2); - assert_eq!(img.data, vec![7, 8, 9, 1, 2, 3, 10, 11, 12, 4, 5, 6]); + assert_eq!(img.data(), vec![7, 8, 9, 1, 2, 3, 10, 11, 12, 4, 5, 6]); } #[test] @@ -216,7 +226,7 @@ mod tests { rotate_90_ccw(&mut img); assert_eq!(img.width(), 2); assert_eq!(img.height(), 2); - assert_eq!(img.data, vec![4, 5, 6, 10, 11, 12, 1, 2, 3, 7, 8, 9]); + assert_eq!(img.data(), vec![4, 5, 6, 10, 11, 12, 1, 2, 3, 7, 8, 9]); } #[test] @@ -235,9 +245,9 @@ mod tests { #[test] fn test_apply_orientation_identity() { let mut img = make_image(2, 2, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); - let original = img.data.clone(); + let original = img.data().to_vec(); apply_orientation(&mut img, 1); - assert_eq!(img.data, original); + assert_eq!(img.data(), original); } #[test] @@ -250,19 +260,37 @@ mod tests { data.push(0); } let mut img = make_image(4, 4, data); - apply_crop(&mut img, Rect::new(Point::new(1, 1), Size::new(2, 2))); + apply_crop( + &mut img, + Rect::new( + Point::new(1, 1), + Dimensions { + width: 2, + height: 2, + }, + ), + ); assert_eq!(img.width(), 2); assert_eq!(img.height(), 2); // Row 1 of original: pixels 4,5,6,7 → crop cols 1..3 → pixels 5,6 - assert_eq!(img.data[0], 5); // pixel(1,1).r - assert_eq!(img.data[3], 6); // pixel(2,1).r + assert_eq!(img.data()[0], 5); // pixel(1,1).r + assert_eq!(img.data()[3], 6); // pixel(2,1).r } #[test] fn test_crop_out_of_bounds() { let mut img = make_image(4, 4, vec![0u16; 4 * 4 * 3]); let original_size = img.size(); - apply_crop(&mut img, Rect::new(Point::new(3, 3), Size::new(2, 2))); + apply_crop( + &mut img, + Rect::new( + Point::new(3, 3), + Dimensions { + width: 2, + height: 2, + }, + ), + ); // Should be unchanged assert_eq!(img.size(), original_size); } diff --git a/crates/rawshift-image/src/transforms/tonemap.rs b/crates/rawshift-image/src/transforms/tonemap.rs index dccb944..f961564 100644 --- a/crates/rawshift-image/src/transforms/tonemap.rs +++ b/crates/rawshift-image/src/transforms/tonemap.rs @@ -12,7 +12,7 @@ //! Output is comparable to dcraw/libraw defaults and correctly handles both //! negative BaselineExposure (e.g. iPhone ProRAW at -0.83 EV) and positive values. -use crate::core::image::RgbImage; +use crate::core::RgbImage; use crate::processing::color::apply_gamma; /// Apply tone reproduction to an RGB image. @@ -43,7 +43,7 @@ pub fn apply_tone_reproduction(image: &mut RgbImage, custom_gamma: Option) pub fn apply_tonemap(image: &mut RgbImage, baseline_exposure: Option) { let gain = baseline_exposure.map(|ev| 2.0f32.powf(ev)).unwrap_or(1.0); let lut = build_lut(gain); - for pixel in &mut image.data { + for pixel in image.data_mut() { *pixel = lut[*pixel as usize]; } } @@ -101,20 +101,20 @@ pub(crate) fn srgb_encode(linear: f32) -> f32 { #[cfg(test)] mod tests { use super::*; - use crate::core::image::RgbImage; + use crate::core::RgbImage; fn make_image(values: &[u16]) -> RgbImage { let n = values.len() as u32 / 3; - RgbImage::new(n, 1, values.to_vec()) + RgbImage::new(n, 1, values.to_vec()).expect("valid RGB buffer") } #[test] fn black_stays_black() { let mut img = make_image(&[0, 0, 0]); apply_tonemap(&mut img, None); - assert_eq!(img.data[0], 0); - assert_eq!(img.data[1], 0); - assert_eq!(img.data[2], 0); + assert_eq!(img.data()[0], 0); + assert_eq!(img.data()[1], 0); + assert_eq!(img.data()[2], 0); } #[test] @@ -122,7 +122,7 @@ mod tests { // Sensor max (65535) with no baseline exposure should map to display white. let mut img = make_image(&[65535, 65535, 65535]); apply_tonemap(&mut img, None); - assert_eq!(img.data[0], 65535); + assert_eq!(img.data()[0], 65535); } #[test] @@ -131,7 +131,7 @@ mod tests { // Sensor max should still map to display white (the curve remaps the range). let mut img = make_image(&[65535, 65535, 65535]); apply_tonemap(&mut img, Some(-0.83)); - assert_eq!(img.data[0], 65535); + assert_eq!(img.data()[0], 65535); } #[test] @@ -143,10 +143,10 @@ mod tests { apply_tonemap(&mut img_no_exp, None); apply_tonemap(&mut img_neg_exp, Some(-0.83)); assert!( - img_neg_exp.data[0] < img_no_exp.data[0], + img_neg_exp.data()[0] < img_no_exp.data()[0], "negative EV {} should darken mid-grey {}", - img_neg_exp.data[0], - img_no_exp.data[0] + img_neg_exp.data()[0], + img_no_exp.data()[0] ); } @@ -157,10 +157,10 @@ mod tests { apply_tonemap(&mut img_no_exp, None); apply_tonemap(&mut img_pos_exp, Some(0.5)); assert!( - img_pos_exp.data[0] > img_no_exp.data[0], + img_pos_exp.data()[0] > img_no_exp.data()[0], "positive EV {} should brighten {}", - img_pos_exp.data[0], - img_no_exp.data[0] + img_pos_exp.data()[0], + img_no_exp.data()[0] ); } @@ -177,7 +177,7 @@ mod tests { apply_tone_reproduction(&mut img_gamma, Some(2.2)); apply_tone_reproduction(&mut img_filmic, None); // They should produce different results - assert_ne!(img_gamma.data[0], img_filmic.data[0]); + assert_ne!(img_gamma.data()[0], img_filmic.data()[0]); } #[test] @@ -186,7 +186,7 @@ mod tests { let mut img_filmic = make_image(&[32768, 32768, 32768]); apply_tone_reproduction(&mut img_repro, None); apply_tonemap(&mut img_filmic, None); - assert_eq!(img_repro.data[0], img_filmic.data[0]); + assert_eq!(img_repro.data()[0], img_filmic.data()[0]); } #[test] @@ -194,9 +194,13 @@ mod tests { // Values at white_level (65535) should map to display white (65535) let mut img = make_image(&[65535, 65535, 65535]); apply_tone_reproduction(&mut img, None); - assert_eq!(img.data[0], 65535, "white should stay at max after tonemap"); - assert_eq!(img.data[1], 65535); - assert_eq!(img.data[2], 65535); + assert_eq!( + img.data()[0], + 65535, + "white should stay at max after tonemap" + ); + assert_eq!(img.data()[1], 65535); + assert_eq!(img.data()[2], 65535); } #[test] @@ -207,9 +211,9 @@ mod tests { let mut img = make_image(&values); apply_tone_reproduction(&mut img, Some(1.0)); // gamma=1.0 is identity - values should be unchanged - assert_eq!(img.data[0], values[0]); - assert_eq!(img.data[1], values[1]); - assert_eq!(img.data[2], values[2]); + assert_eq!(img.data()[0], values[0]); + assert_eq!(img.data()[1], values[1]); + assert_eq!(img.data()[2], values[2]); } #[test] @@ -221,7 +225,7 @@ mod tests { apply_tonemap(&mut img, Some(ev)); // u16 is always in range [0, 65535] by definition assert!( - !img.data.is_empty(), + !img.data().is_empty(), "EV={}, input={}: output should not be empty", ev, val diff --git a/crates/rawshift-image/tests/export_format_tests.rs b/crates/rawshift-image/tests/export_format_tests.rs index 42d4121..60429da 100644 --- a/crates/rawshift-image/tests/export_format_tests.rs +++ b/crates/rawshift-image/tests/export_format_tests.rs @@ -5,7 +5,7 @@ //! that the decode-side color/probe APIs behave as documented. They use a tiny //! synthetic RGB image to avoid the expensive full RAW decode pipeline. -use rawshift_image::core::image::RgbImage; +use rawshift_image::core::RgbImage; use rawshift_image::core::metadata::ImageMetadata; use rawshift_image::formats::export::{ BitDepth, CommonEncodeOptions, EncodeOptions, JpegEncEncodeConfig, LibwebpEncodeConfig, @@ -17,7 +17,7 @@ use std::path::PathBuf; /// 4×4 grey synthetic RGB image (16-bit, tone-mapped already). fn synthetic_image() -> RgbImage { - RgbImage::new(4, 4, vec![32768u16; 4 * 4 * 3]) + RgbImage::new(4, 4, vec![32768u16; 4 * 4 * 3]).expect("valid RGB buffer") } /// Get a temporary file path for test output. @@ -197,7 +197,7 @@ mod jpeg_tests { let data: Vec = (0..64 * 64 * 3) .map(|i| ((i * 997) % 65536) as u16) .collect(); - let img = RgbImage::new(64, 64, data); + let img = RgbImage::new(64, 64, data).expect("valid RGB buffer"); let low = encode_rgb_image_to_vec(&img, &ImageMetadata::default(), &jpeg(30, false, false)) .expect("Export low quality"); @@ -338,7 +338,7 @@ mod webp_tests { let data: Vec = (0..64 * 64 * 3) .map(|i| ((i * 997) % 65536) as u16) .collect(); - let img = RgbImage::new(64, 64, data); + let img = RgbImage::new(64, 64, data).expect("valid RGB buffer"); let mut low = webp(false, false, false); low.quality = 10.0; @@ -656,7 +656,7 @@ mod libjxl_tests { let data: Vec = (0..4 * 4 * 3) .map(|i| ((i as u32 * 4099) % 65536) as u16) .collect(); - RgbImage::new(4, 4, data) + RgbImage::new(4, 4, data).expect("valid RGB buffer") } #[test] @@ -690,7 +690,7 @@ mod libjxl_tests { #[test] fn libjxl_lossless_16bit_is_exact() { let img = distinct_16bit(); - let want = img.data.clone(); + let want = img.data().to_vec(); let opts = EncodeOptions::JxlLibjxl(LibjxlEncodeConfig { common: common(false, false, false), distance: 0.0, @@ -701,7 +701,8 @@ mod libjxl_tests { .expect("encode lossless JXL"); let decoded = decode_standard_image(&bytes, StandardFormat::Jxl).expect("decode JXL"); assert_eq!( - decoded.data, want, + decoded.data(), + want, "lossless 16-bit libjxl round-trip must be exact" ); } @@ -984,7 +985,7 @@ mod in_memory_tests { #[test] fn decoded_png_is_tagged_srgb() { - use rawshift_image::core::ColorSpace; + use rawshift_image::core::ColorDescription; let bytes = encode_rgb_image_to_vec( &synthetic_image(), &ImageMetadata::default(), @@ -992,7 +993,7 @@ mod in_memory_tests { ) .expect("encode PNG"); let decoded = decode_standard_image(&bytes, StandardFormat::Png).expect("decode PNG"); - assert_eq!(decoded.color_space(), ColorSpace::Srgb); + assert_eq!(decoded.color(), ColorDescription::SRGB); } #[test] diff --git a/crates/rawshift-image/tests/heic_aux.rs b/crates/rawshift-image/tests/heic_aux.rs index 9fcf831..48b6668 100644 --- a/crates/rawshift-image/tests/heic_aux.rs +++ b/crates/rawshift-image/tests/heic_aux.rs @@ -62,7 +62,7 @@ fn heic_primary_decodes() { let img = file.decode_primary().expect("decode primary"); assert!(img.width() > 0 && img.height() > 0); assert_eq!( - img.data.len(), + img.data().len(), img.width() as usize * img.height() as usize * 3, "primary RGB buffer must be width*height*3" ); diff --git a/crates/rawshift-image/tests/standard_decode_fixtures.rs b/crates/rawshift-image/tests/standard_decode_fixtures.rs index fbd9c1d..33aee61 100644 --- a/crates/rawshift-image/tests/standard_decode_fixtures.rs +++ b/crates/rawshift-image/tests/standard_decode_fixtures.rs @@ -211,7 +211,7 @@ fn assert_decode_dimensions(format_dir: &str, expected_format: StandardFormat) { img.height() ); assert_eq!( - img.data.len(), + img.data().len(), (gt.width * gt.height * gt.channels) as usize, "{} pixel data length mismatch", gt.format @@ -351,9 +351,9 @@ fn decode_png_pixel_values_from_file() { // PNG is lossless, so first pixel (red: 255,0,0) should be exact after u8->u16 scaling. // u8 255 -> u16 65535 (255 * 257) - assert_eq!(img.data[0], 65535, "PNG first pixel R should be 65535"); - assert_eq!(img.data[1], 0, "PNG first pixel G should be 0"); - assert_eq!(img.data[2], 0, "PNG first pixel B should be 0"); + assert_eq!(img.data()[0], 65535, "PNG first pixel R should be 65535"); + assert_eq!(img.data()[1], 0, "PNG first pixel G should be 0"); + assert_eq!(img.data()[2], 0, "PNG first pixel B should be 0"); } #[test] @@ -369,9 +369,9 @@ fn decode_tiff_pixel_values_from_file() { let img = decode_standard_image(&data, StandardFormat::Tiff).unwrap(); // TIFF is lossless, first pixel (red: 255,0,0) should be exact. - assert_eq!(img.data[0], 65535, "TIFF first pixel R should be 65535"); - assert_eq!(img.data[1], 0, "TIFF first pixel G should be 0"); - assert_eq!(img.data[2], 0, "TIFF first pixel B should be 0"); + assert_eq!(img.data()[0], 65535, "TIFF first pixel R should be 65535"); + assert_eq!(img.data()[1], 0, "TIFF first pixel G should be 0"); + assert_eq!(img.data()[2], 0, "TIFF first pixel B should be 0"); } #[test] @@ -387,9 +387,9 @@ fn decode_gif_first_pixel_from_file() { let img = decode_standard_image(&data, StandardFormat::Gif).unwrap(); // GIF palette index 0 = red (255, 0, 0) -> u16: (65535, 0, 0) - assert_eq!(img.data[0], 65535, "GIF first pixel R should be 65535"); - assert_eq!(img.data[1], 0, "GIF first pixel G should be 0"); - assert_eq!(img.data[2], 0, "GIF first pixel B should be 0"); + assert_eq!(img.data()[0], 65535, "GIF first pixel R should be 65535"); + assert_eq!(img.data()[1], 0, "GIF first pixel G should be 0"); + assert_eq!(img.data()[2], 0, "GIF first pixel B should be 0"); } // ============================================================================