From f29436a1f6710361ca76d0ac22c964b6edd786bd Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:23:27 -0400 Subject: [PATCH] Meta: read MWG-RS face/pet/focus regions from XMP (read-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Metadata::regions: Vec, populated by parse_xmp from mwg-rs:Regions (MWG Region Guidelines v2.0) — the format Lightroom, digiKam and Picasa write for face/pet/focus/barcode regions. Read-only: LightCraft does not write regions yet (blocked on #92, which would otherwise clobber existing sidecars). - Canonicalises the mwg-rs/stArea/stDim namespaces so any prefix a writer picks resolves consistently, reusing the existing generic struct/array XMP machinery (handles both the element and attribute encodings transparently). - Area center-coordinates are converted to lightcraft_geom::Rect via Rect::from_center. Normalized units (the default, and the only unit we've seen in practice) need no AppliedToDimensions cross-reference; the rare pixel-unit region is converted when AppliedToDimensions is present, and dropped (not stored as a nonsense fraction) when it isn't. - Hostile/malformed regions (missing Area, NaN/absurd/zero/negative extents) are skipped individually rather than failing the whole list or panicking. - regions merges like keywords in fill_missing/overlay_user_fields, since it only ever comes from XMP today. Not included: nothing yet reads Metadata::regions (no catalog field, no UI, no command) — this is plumbing for a later PR, with no user-visible effect and no docs/parity.md row to update. cargo xtask ci passes (fmt, clippy -D warnings, test, parity, layers, assets, wasm). Co-Authored-By: Claude Sonnet 5 --- crates/meta/src/lib.rs | 35 +++++++- crates/meta/src/xmp.rs | 184 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 217 insertions(+), 2 deletions(-) diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 3408aed7..467a89af 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -24,7 +24,7 @@ pub use datetime::DateTime; pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif}; pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx}; pub use iptc::parse_iptc; -pub use lightcraft_geom::Orientation; +pub use lightcraft_geom::{Orientation, Rect}; pub use tags::{TagRow, file_tag_rows, tag_rows}; pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, XmpValue, parse_xmp, write_xmp, write_xmp_lc}; pub use xmp_merge::{MergeRules, merge_xmp}; @@ -48,6 +48,31 @@ pub struct Flash { pub raw: u16, } +/// A named region of interest on a photo (MWG Region Guidelines, `mwg-rs:Regions`): most often a face, +/// drawn by Lightroom, digiKam, Picasa or similar tools. Read-only for now — LightCraft does not write +/// regions yet (see `docs/xmp-interop.md`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Region { + /// Normalized to the image's full, oriented frame (`Metadata::width`/`height`), y-down, 0..1 on + /// both axes — MWG's own convention, and what `AppliedToDimensions` areas are converted to. + pub rect: Rect, + pub kind: RegionKind, + /// `mwg-rs:Name` (e.g. the person's name). + pub name: Option, + pub description: Option, +} + +/// `mwg-rs:Type`. Unrecognised values are kept verbatim rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RegionKind { + Face, + Pet, + Focus, + BarCode, + /// `mwg-rs:Type` was missing, empty, or a value we don't recognise. + Other(String), +} + /// Everything LightCraft shows or searches about a photo. All fields are optional; unknown = `None`/empty. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Metadata { @@ -113,6 +138,8 @@ pub struct Metadata { pub rating: Option, /// Colour label name (e.g. "Red"). pub label: Option, + /// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only. + pub regions: Vec, } macro_rules! fill { @@ -173,6 +200,9 @@ impl Metadata { if self.hierarchical_keywords.is_empty() { self.hierarchical_keywords = other.hierarchical_keywords.clone(); } + if self.regions.is_empty() { + self.regions = other.regions.clone(); + } } /// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with @@ -185,6 +215,9 @@ impl Metadata { if !other.hierarchical_keywords.is_empty() { self.hierarchical_keywords = other.hierarchical_keywords.clone(); } + if !other.regions.is_empty() { + self.regions = other.regions.clone(); + } } /// Exposure time formatted like cameras show it: `1/250`, `0.5`, `2″`. diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 07373e62..225cb048 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -8,7 +8,8 @@ //! Writing emits the interchange subset in standard namespaces (dc, xmp, photoshop, exif, exifEX, tiff, lr) and //! LightCraft's full develop settings as an opaque JSON string in `lc:settings`. -use crate::{DateTime, Flash, Gps, Metadata, Orientation, parse_number}; +use crate::{DateTime, Flash, Gps, Metadata, Orientation, Region, RegionKind, parse_number}; +use lightcraft_geom::{Point, Rect}; use quick_xml::escape::{escape, partial_escape}; use quick_xml::events::Event; use std::collections::BTreeMap; @@ -32,6 +33,11 @@ const NAMESPACES: &[(&str, &str)] = &[ ("lc", LC_NS), // Read-only: develop settings written by other raw developers (interchange; see docs/xmp-interop.md). ("crs", CRS_NS), + // Read-only: face/pet/focus regions written by Lightroom, digiKam, Picasa and others (MWG Region + // Guidelines v2.0). Canonicalising these three lets a packet use any prefix for them. + ("mwg-rs", "http://www.metadataworkinggroup.com/schemas/regions/"), + ("stArea", "http://ns.adobe.com/xmp/sType/Area#"), + ("stDim", "http://ns.adobe.com/xap/1.0/sType/Dimensions#"), ]; /// The camera-raw-settings namespace URI (prefix `crs`), read for interchange only. @@ -374,6 +380,52 @@ fn parse_gps_coord(s: &str) -> Option { v.is_finite().then_some(v) } +/// `mwg-rs:Regions` → every region in its `RegionList`, converted to the full-image normalized frame. +/// Malformed or out-of-range entries are skipped rather than rejecting the whole list: one bad region +/// (from Lightroom, digiKam, Picasa, or a hand-edited file) shouldn't hide the rest. +fn parse_regions(regions: &XmpValue) -> Vec { + // `AppliedToDimensions` documents the pixel size regions were authored against. It's only consulted + // for the rare region whose own Area is in pixel units instead of MWG's normalized default below. + let px_dims = regions.field("mwg-rs:AppliedToDimensions").and_then(|d| { + let w = d.field("stDim:w").and_then(XmpValue::text).and_then(parse_number)?; + let h = d.field("stDim:h").and_then(XmpValue::text).and_then(parse_number)?; + (w > 0.0 && h > 0.0).then_some((w, h)) + }); + let Some(list) = regions.field("mwg-rs:RegionList") else { return Vec::new() }; + list.items().iter().filter_map(|item| parse_one_region(item, px_dims)).collect() +} + +fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option { + let area = item.field("mwg-rs:Area")?; + let num_attr = |k: &str| area.field(k).and_then(XmpValue::text).and_then(parse_number); + // stArea:x/y are the region's CENTER, not its top-left corner (MWG Region Guidelines §Area). + let (cx, cy, w, h) = (num_attr("stArea:x")?, num_attr("stArea:y")?, num_attr("stArea:w")?, num_attr("stArea:h")?); + let finite_and_sane = [cx, cy, w, h].iter().all(|v| v.is_finite() && v.abs() <= 1.0e6); + if !finite_and_sane || w <= 0.0 || h <= 0.0 { + return None; + } + // `stArea:unit` is "normalized" (a fraction of AppliedToDimensions) in every writer we know of, and + // that needs no cross-referencing at all. Only a region that explicitly says "pixel" gets divided + // down, and only if AppliedToDimensions is there to divide by; otherwise it's dropped rather than + // stored as a nonsense fraction. + let is_pixels = area.field("stArea:unit").and_then(XmpValue::text).is_some_and(|u| u.eq_ignore_ascii_case("pixel")); + let (cx, cy, w, h) = match (is_pixels, px_dims) { + (false, _) => (cx, cy, w, h), + (true, Some((dw, dh))) => (cx / dw, cy / dh, w / dw, h / dh), + (true, None) => return None, + }; + let name = item.field("mwg-rs:Name").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty()); + let description = item.field("mwg-rs:Description").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty()); + let kind = match item.field("mwg-rs:Type").and_then(XmpValue::text).unwrap_or("") { + t if t.eq_ignore_ascii_case("face") => RegionKind::Face, + t if t.eq_ignore_ascii_case("pet") => RegionKind::Pet, + t if t.eq_ignore_ascii_case("focus") => RegionKind::Focus, + t if t.eq_ignore_ascii_case("barcode") => RegionKind::BarCode, + t => RegionKind::Other(t.to_string()), + }; + Some(Region { rect: Rect::from_center(Point::new(cx, cy), w, h), kind, name, description }) +} + fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String { let a = v.abs(); let d = a.trunc(); @@ -470,6 +522,7 @@ pub fn parse_xmp(s: &str) -> Result { let altitude = num("exif:GPSAltitude").map(|a| if first("exif:GPSAltitudeRef").as_deref() == Some("1") { -a } else { a }); m.gps = Some(Gps { latitude: lat, longitude: lon, altitude }); } + m.regions = values.get("mwg-rs:Regions").map(parse_regions).unwrap_or_default(); let lc_settings = props.get("lc:settings").and_then(|v| v.first()).cloned(); Ok(XmpData { metadata: m, lc_settings, properties: props, values }) } @@ -610,6 +663,9 @@ mod tests { fn full() -> Metadata { Metadata { + // write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the + // roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it. + regions: Vec::new(), make: Some("Maker & Sons".into()), model: Some("X <1>".into()), serial_number: Some("SN1".into()), @@ -764,4 +820,130 @@ mod tests { assert_eq!(parse_gps_coord("é"), None); assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); } + + /// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how + /// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one + /// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas + /// (the default, and the only unit real files use) don't need it. + #[test] + fn mwg_regions_element_form() { + let x = r#" + + + + + + + + Jane Doe + Face + + + + + + + Pet + Rex + Good boy + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 3, "{regions:?}"); + + assert_eq!(regions[0].kind, RegionKind::Face); + assert_eq!(regions[0].name.as_deref(), Some("Jane Doe")); + assert_eq!(regions[0].description, None); + let r = regions[0].rect; + assert!((r.x0 - 0.4).abs() < 1e-9 && (r.x1 - 0.6).abs() < 1e-9, "{r:?}"); + assert!((r.y0 - 0.25).abs() < 1e-9 && (r.y1 - 0.55).abs() < 1e-9, "{r:?}"); + + // No mwg-rs:Type or mwg-rs:Name at all: still a usable region, kind falls back to `Other("")`. + assert_eq!(regions[1].kind, RegionKind::Other(String::new())); + assert_eq!(regions[1].name, None); + + assert_eq!(regions[2].kind, RegionKind::Pet); + assert_eq!(regions[2].name.as_deref(), Some("Rex")); + assert_eq!(regions[2].description.as_deref(), Some("Good boy")); + } + + /// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an + /// `rdf:li rdf:parseType="Resource"`), `mwg-rs:Type` as an attribute instead of an element, and an + /// unrecognised `mwg-rs:Type` value kept verbatim. + #[test] + fn mwg_regions_attribute_form_and_unknown_type() { + let x = r#" + + + + + + + + + + + + + + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 2, "{regions:?}"); + assert_eq!(regions[0].kind, RegionKind::BarCode); + assert_eq!(regions[1].kind, RegionKind::Other("Sticker".to_string())); + } + + /// Hostile/malformed regions are dropped individually, never panic, and never poison the rest of + /// the list: missing Area, non-finite or absurd numbers, zero/negative size, and a pixel-unit area + /// with no `AppliedToDimensions` to convert it (so it can't be stored as a nonsense fraction). + #[test] + fn mwg_regions_hostile_inputs_are_dropped_not_panicking() { + let x = r#" + + + + + No Area + + + + + + Face + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 1, "only the last, well-formed region should survive: {regions:?}"); + assert_eq!(regions[0].kind, RegionKind::Face); + } + + /// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords). + #[test] + fn regions_merge_like_keywords() { + let with_region = Region { rect: Rect::from_center(Point::new(0.5, 0.5), 0.2, 0.2), kind: RegionKind::Face, name: None, description: None }; + let mut a = Metadata::default(); + let b = Metadata { regions: vec![with_region.clone()], ..Default::default() }; + a.fill_missing(&b); + assert_eq!(a.regions, vec![with_region.clone()]); + + let mut a = Metadata::default(); + a.overlay_user_fields(&b); + assert_eq!(a.regions, vec![with_region]); + } }