Files
lightcraft/crates/raw/src/semantic.rs
T
Exalted5373andClaude Opus 5.5 ed59d78e83 LR-MASK-SKY: AI masks use DNG semantic mattes (iPhone ProRAW sky matte)
Read DNG 1.6 semantic masks (SemanticName, SemanticInstanceID,
MaskSubArea; any coding the tile/strip reader decodes, e.g. ProRAW's
lossy-JPEG matte) with lightcraft_raw::semantic_masks, map them onto the
developed image, and carry Apple's sky / portrait / skin / hair / teeth /
glasses mattes in SourceInfo::mattes (SourceInfo is no longer Copy).
Sky, Subject, Background and People components sample them through
orientation, warp (Warp::frame, as geometry.rs) and crop like the image,
so preview = export, instead of the heuristics; files without mattes
render as before. Malformed mask IFDs are skipped, and only the first
MAX_MASKS mask IFDs are decoded, so a file full of them costs bounded
work. The render cache version is bumped once, in the white-balance commit.

The corpus test reads corpus/raw/dng-apple-iphone12pro-proraw.dng, the
name `cargo xtask corpus` downloads it under.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:18:54 -07:00

137 lines
6.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! DNG semantic masks (DNG 1.6+, chapter "Semantic Masks"): single-channel segmentation mattes
//! stored in their own IFDs (NewSubFileType 10004.H, PhotometricInterpretation 52527) with a
//! `SemanticName`, an optional `SemanticInstanceID` and an optional `MaskSubArea`. iPhone ProRAW
//! stores e.g. a sky matte this way.
//!
//! [`semantic_masks`] reads them separately from [`crate::decode`] (only callers that use them
//! pay for them); malformed or unsupported mask IFDs are skipped.
use crate::Rect;
use crate::tiffraw::{Packing, read_image};
use crate::{RawData, Result};
use lightcraft_raster::Image;
use lightcraft_tiff::Tiff;
use lightcraft_tiff::tags::{self as t, photometric};
/// At most this many mask IFDs are read from one file.
pub const MAX_MASKS: usize = 16;
/// Masks with more samples than this are skipped (a mask is a low-resolution matte; this is
/// 8192 × 4096).
pub const MAX_MASK_SAMPLES: usize = 1 << 25;
/// Where a cropped mask sits in the uncropped mask (DNG `MaskSubArea`). The uncropped mask
/// (`full_width × full_height`) spans the main image's active area.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MaskSubArea {
pub top: u32,
pub left: u32,
pub full_width: u32,
pub full_height: u32,
}
/// One semantic mask: `width × height` values, 0 = not part of the attribute, 65535 = fully part.
#[derive(Clone, Debug, PartialEq)]
pub struct SemanticMask {
/// `SemanticName`, e.g. `urn:com:apple:photo:2020:aux:semanticskymatte`.
pub name: String,
pub instance_id: Option<String>,
pub width: usize,
pub height: usize,
/// `None`: the mask covers the whole active area.
pub sub_area: Option<MaskSubArea>,
pub data: Vec<u16>,
}
/// The semantic masks of a DNG, in file order (none for other files). Only the first
/// [`MAX_MASKS`] mask IFDs are looked at, malformed ones included, so a file full of mask IFDs
/// costs at most that many decodes.
pub fn semantic_masks(bytes: &[u8]) -> Vec<SemanticMask> {
let Ok(tiff) = Tiff::parse(bytes) else { return Vec::new() };
tiff.all_ifds()
.into_iter()
.filter(|i| i.u16(t::PHOTOMETRIC) == Some(photometric::MASK) || i.u32(t::NEW_SUBFILE_TYPE) == Some(t::SUBFILE_SEMANTIC_MASK))
.take(MAX_MASKS)
.filter_map(|ifd| read_mask(bytes, &tiff, ifd).ok().flatten())
.collect()
}
fn read_mask(bytes: &[u8], tiff: &Tiff, ifd: &lightcraft_tiff::Ifd) -> Result<Option<SemanticMask>> {
let Some(name) = ifd.string(t::SEMANTIC_NAME).filter(|n| !n.is_empty()) else { return Ok(None) };
let info = ifd.image()?;
let (w, h) = (info.width as usize, info.height as usize);
if info.samples_per_pixel != 1 || w.checked_mul(h).is_none_or(|n| n > MAX_MASK_SAMPLES) {
return Ok(None);
}
let bits = info.bits() as u32;
let data: Vec<u16> = match read_image(bytes, &info, tiff.order, Packing::Msb)? {
RawData::U16(v) if (1..=16).contains(&bits) => {
let max = ((1u32 << bits) - 1) as f32;
if bits == 16 { v } else { v.into_iter().map(|s| (s as f32 / max * 65535.0).round().min(65535.0) as u16).collect() }
}
RawData::F32(v) => v.into_iter().map(|s| if s.is_finite() { (s.clamp(0.0, 1.0) * 65535.0).round() as u16 } else { 0 }).collect(),
RawData::U16(_) => return Ok(None),
};
if data.len() != w * h {
return Ok(None);
}
// the tag is ignored when the stored mask does not fit inside the uncropped one
let sub_area = match ifd.u64s(t::MASK_SUB_AREA).as_deref() {
Some(&[top, left, fw, fh]) => {
let fits = fw > 0 && fh > 0 && left.saturating_add(w as u64) <= fw && top.saturating_add(h as u64) <= fh && fw.max(fh) <= u32::MAX as u64;
fits.then_some(MaskSubArea { top: top as u32, left: left as u32, full_width: fw as u32, full_height: fh as u32 })
}
_ => None,
};
Ok(Some(SemanticMask { name, instance_id: ifd.string(t::SEMANTIC_INSTANCE_ID).filter(|s| !s.is_empty()), width: w, height: h, sub_area, data }))
}
impl SemanticMask {
/// The mask value (0..1) at a continuous position of the uncropped mask (pixel centres at
/// +0.5); 0 outside the stored (cropped) mask.
pub fn value_at(&self, x: f64, y: f64) -> f32 {
let (left, top) = self.sub_area.map_or((0.0, 0.0), |a| (a.left as f64, a.top as f64));
let (x, y) = (x - left, y - top);
if self.width == 0 || self.height == 0 || !(x >= 0.0 && y >= 0.0 && x <= self.width as f64 && y <= self.height as f64) {
return 0.0;
}
let (fx, fy) = ((x - 0.5).max(0.0), (y - 0.5).max(0.0));
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32);
let at = |x: usize, y: usize| {
let (x, y) = (x.min(self.width - 1), y.min(self.height - 1));
self.data.get(y * self.width + x).copied().unwrap_or(0) as f32 / 65535.0
};
let top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * tx;
let bottom = at(x0, y0 + 1) + (at(x0 + 1, y0 + 1) - at(x0, y0 + 1)) * tx;
top + (bottom - top) * ty
}
/// The mask over the developed image ([`crate::RawImage::develop`]: the default `crop` of the
/// `active_area`, before orientation), at the mask's own resolution, as 0..255 alpha. `None`
/// for an empty crop.
pub fn developed(&self, active_area: Rect, crop: Rect) -> Option<Image<u8>> {
let c = crop.clipped(active_area.width, active_area.height);
if c.width == 0 || c.height == 0 || self.width == 0 || self.height == 0 {
return None;
}
let (fw, fh) = self.sub_area.map_or((self.width, self.height), |a| (a.full_width as usize, a.full_height as usize));
// uncropped-mask px per active-area px
let (sx, sy) = (fw as f64 / active_area.width as f64, fh as f64 / active_area.height as f64);
let ow = ((c.width as f64 * sx).round() as usize).clamp(1, self.width.max(fw));
let oh = ((c.height as f64 * sy).round() as usize).clamp(1, self.height.max(fh));
if ow.checked_mul(oh).is_none_or(|n| n > MAX_MASK_SAMPLES) {
return None;
}
let (kx, ky) = (c.width as f64 / ow as f64, c.height as f64 / oh as f64);
let mut out = Image::new(ow, oh);
lightcraft_raster::par_rows(&mut out.data, ow, |y, row| {
let ay = c.y as f64 + (y as f64 + 0.5) * ky;
for (x, v) in row.iter_mut().enumerate() {
let ax = c.x as f64 + (x as f64 + 0.5) * kx;
*v = (self.value_at(ax * sx, ay * sy) * 255.0).round() as u8;
}
});
Some(out)
}
}