Meta: read MWG-RS face/pet/focus regions from XMP (read-only)

Adds Metadata::regions: Vec<Region>, 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 <noreply@anthropic.com>
This commit is contained in:
Daniel Voshart 2026-10-05 12:23:27 -04:00
parent 5cee0c08d0
commit f29436a1f6
2 changed files with 217 additions and 2 deletions

View File

@ -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<String>,
pub description: Option<String>,
}
/// `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<i8>,
/// Colour label name (e.g. "Red").
pub label: Option<String>,
/// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only.
pub regions: Vec<Region>,
}
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″`.

View File

@ -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<f64> {
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<Region> {
// `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<Region> {
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<XmpData, XmpError> {
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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#"
xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:AppliedToDimensions rdf:parseType="Resource" stDim:w="4000" stDim:h="3000" stDim:unit="pixel"/>
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li rdf:parseType="Resource">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.4" stArea:w="0.2" stArea:h="0.3" stArea:unit="normalized"/>
<mwg-rs:Name>Jane Doe</mwg-rs:Name>
<mwg-rs:Type>Face</mwg-rs:Type>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.1" stArea:y="0.1" stArea:w="0.05" stArea:h="0.05" stArea:unit="normalized"/>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.8" stArea:y="0.2" stArea:w="0.1" stArea:h="0.1" stArea:unit="normalized"/>
<mwg-rs:Type>Pet</mwg-rs:Type>
<mwg-rs:Name>Rex</mwg-rs:Name>
<mwg-rs:Description>Good boy</mwg-rs:Description>
</rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li>
<rdf:Description mwg-rs:Type="BarCode">
<mwg-rs:Area stArea:x="0.3" stArea:y="0.3" stArea:w="0.1" stArea:h="0.1"/>
</rdf:Description>
</rdf:li>
<rdf:li>
<rdf:Description mwg-rs:Type="Sticker">
<mwg-rs:Area stArea:x="0.6" stArea:y="0.6" stArea:w="0.1" stArea:h="0.1"/>
</rdf:Description>
</rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF>"#;
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li rdf:parseType="Resource"><mwg-rs:Name>No Area</mwg-rs:Name></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="NaN" stArea:y="0.1" stArea:w="0.1" stArea:h="0.1"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="1e300" stArea:y="0.1" stArea:w="0.1" stArea:h="0.1"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.5" stArea:w="0" stArea:h="0.1"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.5" stArea:w="-0.1" stArea:h="0.1"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="100" stArea:y="100" stArea:w="50" stArea:h="50" stArea:unit="pixel"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.5" stArea:w="0.2" stArea:h="0.2"/><mwg-rs:Type>Face</mwg-rs:Type></rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF>"#;
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]);
}
}