mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Merge origin/main into profile-gain-table-map (RENDER_CACHE_VERSION: main's 21 + 1 = 22)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
95d8aed986
28 files changed
+1539
-154
No files matched your search
@@ -93,25 +93,41 @@ pub fn install_panic_hook() {
|
||||
const TAB_LOCK: &str = "lightcraft-library";
|
||||
|
||||
/// Hold the library lock for as long as this page lives (Web Locks API). `Some(true)`: ours;
|
||||
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks).
|
||||
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks, or
|
||||
/// it refused the request).
|
||||
pub async fn acquire_tab_lock() -> Option<bool> {
|
||||
let nav = Reflect::get(&js_sys::global(), &"navigator".into()).ok()?;
|
||||
let locks = Reflect::get(&nav, &"locks".into()).ok().filter(|l| !l.is_undefined() && !l.is_null())?;
|
||||
let request: js_sys::Function = Reflect::get(&locks, &"request".into()).ok()?.dyn_into().ok()?;
|
||||
let opts = js_sys::Object::new();
|
||||
Reflect::set(&opts, &"ifAvailable".into(), &true.into()).ok()?;
|
||||
let granted = js_sys::Promise::new(&mut |resolve, _reject| {
|
||||
let granted = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let cb = Closure::once_into_js(move |lock: JsValue| -> JsValue {
|
||||
let ours = !lock.is_null() && !lock.is_undefined();
|
||||
let _ = resolve.call1(&JsValue::NULL, &ours.into());
|
||||
// keep the lock until the page goes away: a promise that never settles
|
||||
if ours { js_sys::Promise::new(&mut |_, _| {}).into() } else { JsValue::NULL }
|
||||
});
|
||||
if let Err(e) = request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) {
|
||||
log::warn!("Web Locks request failed: {e:?}");
|
||||
// a refused request never calls `cb`: settle with its error instead of waiting forever
|
||||
match request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) {
|
||||
Ok(pending) => {
|
||||
let catch = Reflect::get(&pending, &"catch".into()).ok().and_then(|c| c.dyn_into::<js_sys::Function>().ok());
|
||||
if let Some(catch) = catch {
|
||||
let _ = catch.call1(&pending, &reject);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &e);
|
||||
}
|
||||
}
|
||||
});
|
||||
JsFuture::from(granted).await.ok().map(|v| v.is_truthy())
|
||||
match JsFuture::from(granted).await {
|
||||
Ok(v) => Some(v.is_truthy()),
|
||||
Err(e) => {
|
||||
log::warn!("Web Locks request failed, starting without the one-tab guard: {e:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer a blob as a download named `name`.
|
||||
|
||||
@@ -52,8 +52,15 @@ struct App(LightcraftApp, PrefsWriter, #[cfg(target_os = "macos")] Option<native
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(m) = self.2.as_mut() {
|
||||
m.update(&mut self.0, ctx);
|
||||
if let Some(m) = self.2.as_mut()
|
||||
&& m.update(&mut self.0, ctx)
|
||||
&& lightcraft_ui_egui::panels::notices::may_close(&mut self.0)
|
||||
{
|
||||
// Quit from the menu bar: save exactly what closing the window saves (settings and
|
||||
// library, after the unsaved-changes check; otherwise its prompt shows), then end the
|
||||
// process instead of closing the viewport while AppKit is terminating (PR #444).
|
||||
eframe::App::on_exit(self);
|
||||
std::process::exit(0);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
self.1.tick(&mut self.0, ctx);
|
||||
@@ -76,11 +83,13 @@ impl eframe::App for App {
|
||||
|
||||
/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12
|
||||
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
|
||||
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12).
|
||||
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
|
||||
/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471).
|
||||
fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
let mut c = eframe::egui_wgpu::WgpuConfiguration::default();
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
|
||||
n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends();
|
||||
n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options();
|
||||
}
|
||||
c
|
||||
}
|
||||
@@ -898,6 +907,16 @@ mod tests {
|
||||
assert!(!b.is_empty());
|
||||
}
|
||||
|
||||
/// Issue #471: the window's DX12 shaders compile with FXC — wgpu's default loaded any
|
||||
/// `dxcompiler.dll` on the search path, and one without `dxil.dll` kept the window from opening.
|
||||
#[test]
|
||||
fn window_compiles_dx12_shaders_with_fxc() {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
|
||||
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
|
||||
assert!(matches!(n.instance_descriptor.backend_options.dx12.shader_compiler, eframe::wgpu::Dx12Compiler::Fxc));
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
|
||||
/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
|
||||
#[test]
|
||||
|
||||
@@ -355,12 +355,15 @@ impl NativeMenu {
|
||||
}
|
||||
|
||||
/// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus
|
||||
/// accelerators with the app state.
|
||||
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
/// accelerators with the app state. Returns whether Quit was chosen: the caller saves
|
||||
/// everything and exits the process itself (closing the viewport while AppKit terminates the
|
||||
/// app can crash in winit's teardown, PR #444).
|
||||
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) -> bool {
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
let mut quit = false;
|
||||
while let Ok(key) = self.rx.try_recv() {
|
||||
if key == QUIT {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
quit = true;
|
||||
continue;
|
||||
}
|
||||
if key == "app.about" || key == SETTINGS {
|
||||
@@ -375,7 +378,7 @@ impl NativeMenu {
|
||||
let bar = menu_bar(app);
|
||||
if structure_of(&bar) != self.structure {
|
||||
self.rebuild(app);
|
||||
return;
|
||||
return quit;
|
||||
}
|
||||
fn walk(nodes: &[MenuNode], items: &mut HashMap<String, Item>) {
|
||||
for n in nodes {
|
||||
@@ -429,6 +432,7 @@ impl NativeMenu {
|
||||
}
|
||||
self.publish_shortcuts(app);
|
||||
}
|
||||
quit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,5 +22,8 @@ real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.rock-meister]
|
||||
sync_github_name = true
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
@@ -10,6 +10,7 @@ use std::sync::{
|
||||
|
||||
use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue};
|
||||
use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source};
|
||||
use lightcraft_geom::{Affine, Orientation, Point, Rect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -477,17 +478,77 @@ fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
|
||||
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn mapped_settings(text: &str, raw: bool, aspect: f64) -> Result<(Value, Vec<String>), String> {
|
||||
fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation, stored_aspect: f64) -> Result<(Value, Vec<String>), String> {
|
||||
let mut root = crate::preset_import::parse_lua(text)?;
|
||||
let deferred = strip_lua_sentinels(&mut root);
|
||||
let (props, values) = crate::preset_import::lua_settings_props(&root)?;
|
||||
let (partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), Some(raw), aspect);
|
||||
let (mut props, values) = crate::preset_import::lua_settings_props(&root)?;
|
||||
// A catalog's develop settings carry the crop edges and angle but no `HasCrop` (XMP has it):
|
||||
// any crop field means the photo is cropped.
|
||||
const CROP: [&str; 5] = ["crs:CropLeft", "crs:CropTop", "crs:CropRight", "crs:CropBottom", "crs:CropAngle"];
|
||||
if !props.contains_key("crs:HasCrop") && CROP.iter().any(|k| props.contains_key(*k)) {
|
||||
props.insert("crs:HasCrop".into(), vec!["True".into()]);
|
||||
}
|
||||
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), Some(raw), aspect);
|
||||
catalog_crop(&mut partial, orientation, stored_aspect);
|
||||
if deferred > 0 {
|
||||
unknown.push("Deferred Lightroom adjustments (-999999)".into());
|
||||
}
|
||||
Ok((partial, unknown))
|
||||
}
|
||||
|
||||
/// Lightroom's `Adobe_images.orientation`: where the stored image's top-left and top-right corners
|
||||
/// land in the displayed photo, naming the displayed corners A, B, C, D clockwise from the top left
|
||||
/// (`AB` upright, `BC` a quarter turn clockwise, `DA` a quarter turn counter-clockwise, `BA` mirrored).
|
||||
fn lightroom_orientation(code: &str) -> Option<Orientation> {
|
||||
const ALL: [Orientation; 8] = [
|
||||
Orientation::Normal,
|
||||
Orientation::FlipH,
|
||||
Orientation::Rotate180,
|
||||
Orientation::FlipV,
|
||||
Orientation::Transpose,
|
||||
Orientation::Rotate90,
|
||||
Orientation::Transverse,
|
||||
Orientation::Rotate270,
|
||||
];
|
||||
let corner = |o: Orientation, x: f64, y: f64| match o.map(x, y, 1.0, 1.0) {
|
||||
(u, v) if u < 0.5 && v < 0.5 => 'A',
|
||||
(_, v) if v < 0.5 => 'B',
|
||||
(u, _) if u >= 0.5 => 'C',
|
||||
_ => 'D',
|
||||
};
|
||||
ALL.into_iter().find(|&o| code.chars().eq([corner(o, 0.0, 0.0), corner(o, 1.0, 0.0)]))
|
||||
}
|
||||
|
||||
/// Lightroom's crop in LightCraft's frame. `crs` gives the crop's top-left (`CropLeft`, `CropTop`)
|
||||
/// and bottom-right (`CropRight`, `CropBottom`) corners on the stored image (before orientation and
|
||||
/// straightening), normalized to its size, plus a straighten angle that turns the other way to
|
||||
/// LightCraft's. LightCraft's crop is a rectangle in the oriented image rotated by `angle` about its
|
||||
/// centre, normalized to the oriented image's size, so the corners go through the orientation and
|
||||
/// then the rotation; `stored_aspect` (width / height of the stored image) matters once the
|
||||
/// rectangle turns. On a real catalog this reproduced the frame aspect of all 223 straightened crops
|
||||
/// in Lightroom's exports within 1%.
|
||||
fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f64) {
|
||||
let Some(geometry) = partial.pointer_mut("/crop/geometry").and_then(Value::as_object_mut) else { return };
|
||||
let edge = |k: &str| geometry.get("rect").and_then(|r| r.get(k)).and_then(Value::as_f64);
|
||||
let (Some(x0), Some(y0), Some(x1), Some(y1)) = (edge("x0"), edge("y0"), edge("x1"), edge("y1")) else { return };
|
||||
let lightroom_angle = geometry.get("angle").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
if orientation == Orientation::Normal && lightroom_angle == 0.0 {
|
||||
return;
|
||||
}
|
||||
let w = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { 1.0 };
|
||||
let (ow, oh) = if orientation.swaps_axes() { (1.0, w) } else { (w, 1.0) };
|
||||
// a mirror turns the straighten the other way too
|
||||
let angle = if orientation.to_parts().0 { lightroom_angle } else { -lightroom_angle };
|
||||
let to_rotated = Affine::rotate_about(angle.to_radians(), Point::new(ow / 2.0, oh / 2.0));
|
||||
let corner = |x: f64, y: f64| {
|
||||
let (u, v) = orientation.map(x * w, y, w, 1.0);
|
||||
to_rotated.apply(Point::new(u, v))
|
||||
};
|
||||
let r = Rect::from_points(corner(x0, y0), corner(x1, y1));
|
||||
geometry.insert("rect".into(), json!({"x0": r.x0 / ow, "y0": r.y0 / oh, "x1": r.x1 / ow, "y1": r.y1 / oh}));
|
||||
geometry.insert("angle".into(), json!(angle));
|
||||
}
|
||||
|
||||
fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize {
|
||||
use crate::preset_import::Lua;
|
||||
match value {
|
||||
@@ -680,7 +741,15 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
p.meta.keywords = src.keywords.clone();
|
||||
}
|
||||
if !src.settings.is_empty() && !keep_develop {
|
||||
match mapped_settings(&src.settings, p.kind == lightcraft_catalog::MediaKind::Raw, p.width.max(1) as f64 / p.height.max(1) as f64) {
|
||||
let orientation = lightroom_orientation(text(&src.image, "orientation")).unwrap_or_default();
|
||||
let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64;
|
||||
match mapped_settings(
|
||||
&src.settings,
|
||||
p.kind == lightcraft_catalog::MediaKind::Raw,
|
||||
p.width.max(1) as f64 / p.height.max(1) as f64,
|
||||
orientation,
|
||||
stored_aspect,
|
||||
) {
|
||||
Ok((partial, unknown)) => {
|
||||
p.develop = Arc::new(lightcraft_develop::apply_partial(&p.develop, &partial, 1.0));
|
||||
p.edited = Some(now.clone());
|
||||
@@ -788,6 +857,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_geom::CropGeometry;
|
||||
fn row(v: Value) -> Row {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
@@ -866,7 +936,7 @@ mod tests {
|
||||
cyclic.collections[0].insert("parent".into(), json!(10));
|
||||
assert!(apply(&mut s, cyclic, false).is_err());
|
||||
assert_eq!(s.catalog.to_snapshot(), before);
|
||||
assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0).is_err());
|
||||
assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0, Orientation::Normal, 1.5).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn compressed_xmp_is_bounded_and_settings_are_data_only() {
|
||||
@@ -876,10 +946,156 @@ mod tests {
|
||||
assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet));
|
||||
encoded[0] = 0xff;
|
||||
assert!(xmp(Some(&json!(encoded))).is_err());
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5).unwrap();
|
||||
let (partial, _) =
|
||||
mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(partial["light"]["exposure"], 0.75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_crops_without_has_crop_are_imported() {
|
||||
// Lightroom Classic catalogs store crop edges and angle but never `HasCrop`
|
||||
let (partial, unknown) = mapped_settings(
|
||||
"s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }",
|
||||
true,
|
||||
1.5,
|
||||
Orientation::Normal,
|
||||
1.5,
|
||||
)
|
||||
.unwrap();
|
||||
let crop = &partial["crop"]["geometry"];
|
||||
assert_eq!(crop["rect"]["x0"], 0.042702);
|
||||
assert_eq!(crop["rect"]["y0"], 0.1);
|
||||
assert_eq!(crop["rect"]["x1"], 0.957298);
|
||||
assert_eq!(crop["rect"]["y1"], 1.0);
|
||||
assert_eq!(crop["angle"], 0.0);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}");
|
||||
// an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's)
|
||||
let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(partial["crop"]["geometry"]["angle"], 1.5);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}");
|
||||
// no crop fields: the crop stays untouched
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert!(partial.get("crop").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightroom_orientation_codes() {
|
||||
// the three seen in a real catalog, checked against each file's EXIF orientation (1, 6, 8)
|
||||
assert_eq!(lightroom_orientation("AB"), Some(Orientation::Normal));
|
||||
assert_eq!(lightroom_orientation("BC"), Some(Orientation::Rotate90));
|
||||
assert_eq!(lightroom_orientation("DA"), Some(Orientation::Rotate270));
|
||||
assert_eq!(lightroom_orientation("CD"), Some(Orientation::Rotate180));
|
||||
assert_eq!(lightroom_orientation("BA"), Some(Orientation::FlipH));
|
||||
assert_eq!(lightroom_orientation("DC"), Some(Orientation::FlipV));
|
||||
assert_eq!(lightroom_orientation("AD"), Some(Orientation::Transpose));
|
||||
assert_eq!(lightroom_orientation("CB"), Some(Orientation::Transverse));
|
||||
for code in ["", "A", "ABC", "AC", "ab", "XY"] {
|
||||
assert_eq!(lightroom_orientation(code), None, "{code:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_crops_follow_the_photo_orientation() {
|
||||
let crop = |o: Orientation| {
|
||||
let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }";
|
||||
let (partial, _) = mapped_settings(settings, true, 1.5, o, 1.5).unwrap();
|
||||
let g = &partial["crop"]["geometry"];
|
||||
let r = &g["rect"];
|
||||
[r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9)
|
||||
};
|
||||
// (x, y) of the stored image → (1 − y, x) a quarter turn clockwise, (y, 1 − x) counter-clockwise
|
||||
assert_eq!(crop(Orientation::Normal), [0.1, 0.2, 0.6, 0.9, 0.0]);
|
||||
assert_eq!(crop(Orientation::Rotate90), [0.1, 0.1, 0.8, 0.6, 0.0]);
|
||||
assert_eq!(crop(Orientation::Rotate270), [0.2, 0.4, 0.9, 0.9, 0.0]);
|
||||
assert_eq!(crop(Orientation::Rotate180), [0.4, 0.1, 0.9, 0.8, 0.0]);
|
||||
assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]);
|
||||
// a mirror turns the straighten the other way
|
||||
let angle = |o: Orientation| {
|
||||
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", true, 1.5, o, 1.5).unwrap();
|
||||
partial["crop"]["geometry"]["angle"].as_f64().unwrap()
|
||||
};
|
||||
assert_eq!(angle(Orientation::Rotate90), -2.0);
|
||||
assert_eq!(angle(Orientation::FlipH), 2.0);
|
||||
// no crop: nothing to orient
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Rotate90, 1.5).unwrap();
|
||||
assert!(partial.get("crop").is_none());
|
||||
}
|
||||
|
||||
/// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image
|
||||
/// of aspect `w` (height 1) shown with orientation `o`.
|
||||
fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String {
|
||||
let (ow, oh) = if o.swaps_axes() { (1.0, w) } else { (w, 1.0) };
|
||||
let to_source = Affine::rotate_about(-g.angle.to_radians(), Point::new(ow / 2.0, oh / 2.0));
|
||||
let stored = |x: f64, y: f64| {
|
||||
let p = to_source.apply(Point::new(x * ow, y * oh));
|
||||
o.inverse().map(p.x, p.y, ow, oh)
|
||||
};
|
||||
// the rectangle's corners that land top-left and bottom-right on the stored image
|
||||
let corners = [stored(g.rect.x0, g.rect.y0), stored(g.rect.x1, g.rect.y0), stored(g.rect.x1, g.rect.y1), stored(g.rect.x0, g.rect.y1)];
|
||||
let (tl, br) = (0..4).map(|i| (corners[i], corners[(i + 2) % 4])).find(|(a, b)| a.0 < b.0 && a.1 < b.1).unwrap();
|
||||
let angle = if o.to_parts().0 { g.angle } else { -g.angle };
|
||||
format!("s = {{ CropLeft = {}, CropTop = {}, CropRight = {}, CropBottom = {}, CropAngle = {angle} }}", tl.0 / w, tl.1, br.0 / w, br.1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straightened_catalog_crops_round_trip() {
|
||||
let g = CropGeometry { rect: Rect::new(0.2, 0.15, 0.7, 0.8), angle: 6.5 };
|
||||
for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] {
|
||||
for angle in [6.5, -6.5] {
|
||||
let g = CropGeometry { angle, ..g };
|
||||
let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), true, 1.5, o, 1.5).unwrap();
|
||||
let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap();
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
assert!(
|
||||
close(got.angle, g.angle)
|
||||
&& [(got.rect.x0, g.rect.x0), (got.rect.y0, g.rect.y0), (got.rect.x1, g.rect.x1), (got.rect.y1, g.rect.y1)]
|
||||
.iter()
|
||||
.all(|&(a, b)| close(a, b)),
|
||||
"{o:?} {angle}: {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straightened_catalog_crops_match_lightroom_exports() {
|
||||
// Settings and pixel sizes from a real Lightroom Classic catalog (9504 × 6336 stored) and the
|
||||
// JPEGs Lightroom exported from it at full size.
|
||||
for (settings, o, export) in [
|
||||
(
|
||||
"s = { CropAngle = -9.34444, CropBottom = 0.802034, CropLeft = 0.05499, CropRight = 0.94501, CropTop = 0.197966 }",
|
||||
Orientation::Normal,
|
||||
(7725.0, 5150.0),
|
||||
),
|
||||
(
|
||||
"s = { CropAngle = -1.48964, CropBottom = 0.962457, CropLeft = 0.010429, CropRight = 0.989571, CropTop = 0.037543 }",
|
||||
Orientation::Rotate90,
|
||||
(6100.0, 9150.0),
|
||||
),
|
||||
] {
|
||||
let (partial, _) = mapped_settings(settings, true, 1.5, o, 9504.0 / 6336.0).unwrap();
|
||||
let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap();
|
||||
let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) };
|
||||
let (w, h) = (r.width() * ow, r.height() * oh);
|
||||
assert!((w - export.0).abs() <= 3.0 && (h - export.1).abs() <= 3.0, "{o:?}: {w:.1} × {h:.1}, Lightroom exported {export:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_crops_use_the_catalog_orientation() {
|
||||
let mut data = sample();
|
||||
data.photos.truncate(1);
|
||||
data.photos[0].image.remove("masterImage");
|
||||
data.photos[0].image.insert("orientation".into(), json!("BC"));
|
||||
data.photos[0].settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }".into();
|
||||
data.photos[0].xmp = String::new();
|
||||
let mut s = crate::Session::new();
|
||||
apply(&mut s, data, false).unwrap();
|
||||
let g = s.catalog.photos().next().unwrap().develop.crop.geometry;
|
||||
let r = [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1].map(|v| (v * 1e9).round() / 1e9);
|
||||
assert_eq!(r, [0.1, 0.1, 0.8, 0.6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_auto_tone_sentinels_never_become_real_slider_values() {
|
||||
let mut data = sample();
|
||||
@@ -896,7 +1112,7 @@ mod tests {
|
||||
assert_eq!(p.develop.light.shadows, 65.0);
|
||||
assert_eq!(p.develop.light.blacks, -22.0);
|
||||
assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred"))));
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", true, 1.0).unwrap();
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", true, 1.0, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(partial["light"]["exposure"], -5.0);
|
||||
assert_eq!(partial["light"]["contrast"], -100.0);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 17;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 22;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
|
||||
@@ -121,6 +121,30 @@ pub fn window_backends() -> Backends {
|
||||
resolve(choice, default, wgpu::Instance::enabled_backend_features()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// The DX12 shader compiler: FXC (`d3dcompiler_47.dll`, part of Windows) unless
|
||||
/// `WGPU_DX12_COMPILER` (`wgpu`, wgpu's own variable: `fxc`, `dxc`, `auto`…) names another.
|
||||
///
|
||||
/// Issue #471: wgpu's default (`Auto`) loads whichever `dxcompiler.dll` the DLL search path finds
|
||||
/// first — LightCraft ships none, so it is some other program's (an SDK's, a folder on `PATH`). A
|
||||
/// copy without its `dxil.dll` beside it warns that the DXIL is unsigned; wgpu takes the warning for
|
||||
/// a compile error, its own validation pipelines fail, the device is lost and the window never
|
||||
/// opens. FXC is always there and compiles everything LightCraft's shaders need.
|
||||
pub fn dx12_compiler(env: Option<&str>) -> wgpu::Dx12Compiler {
|
||||
let Some(v) = env else { return wgpu::Dx12Compiler::Fxc };
|
||||
v.parse().unwrap_or_else(|e| {
|
||||
log::warn!("gpu: WGPU_DX12_COMPILER={v} ignored: {e}");
|
||||
wgpu::Dx12Compiler::Fxc
|
||||
})
|
||||
}
|
||||
|
||||
/// wgpu's backend options for every instance LightCraft creates — the window's and the compute
|
||||
/// devices': wgpu's environment variables, with the DX12 compiler from [`dx12_compiler`].
|
||||
pub fn backend_options() -> wgpu::BackendOptions {
|
||||
let mut o = wgpu::BackendOptions::from_env_or_default();
|
||||
o.dx12.shader_compiler = dx12_compiler(std::env::var("WGPU_DX12_COMPILER").ok().as_deref());
|
||||
o
|
||||
}
|
||||
|
||||
/// `LIGHTCRAFT_GPU_BACKEND=off`.
|
||||
pub(crate) fn env_off() -> bool {
|
||||
env_choice() == BackendChoice::Off
|
||||
@@ -211,6 +235,20 @@ mod tests {
|
||||
assert!(default_window_backends("linux").contains(Backends::VULKAN | Backends::GL));
|
||||
}
|
||||
|
||||
/// Issue #471: DX12 shaders compile with FXC, never a `dxcompiler.dll` found on the search path,
|
||||
/// unless `WGPU_DX12_COMPILER` asks for it.
|
||||
#[test]
|
||||
fn dx12_compiles_with_fxc_unless_asked() {
|
||||
assert!(matches!(dx12_compiler(None), wgpu::Dx12Compiler::Fxc));
|
||||
assert!(matches!(dx12_compiler(Some("bogus")), wgpu::Dx12Compiler::Fxc));
|
||||
assert!(matches!(dx12_compiler(Some("FXC")), wgpu::Dx12Compiler::Fxc));
|
||||
assert!(matches!(dx12_compiler(Some("dxc")), wgpu::Dx12Compiler::DynamicDxc { .. }));
|
||||
assert!(matches!(dx12_compiler(Some("auto")), wgpu::Dx12Compiler::Auto));
|
||||
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
|
||||
assert!(matches!(backend_options().dx12.shader_compiler, wgpu::Dx12Compiler::Fxc));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_against_the_build() {
|
||||
let compiled = Backends::DX12 | Backends::VULKAN;
|
||||
|
||||
@@ -282,6 +282,8 @@ impl Gpu {
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
// only these drivers are loaded (`crate::backend`: never Vulkan on Windows unless asked)
|
||||
desc.backends = backends;
|
||||
// DX12 shaders compile with FXC (issue #471)
|
||||
desc.backend_options = crate::backend::backend_options();
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let adapter = pollster::block_on(
|
||||
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
|
||||
|
||||
@@ -76,6 +76,8 @@ fn create_device() -> Result<Dev, String> {
|
||||
fn make_device(backends: wgpu::Backends) -> Result<Dev, String> {
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
desc.backends = backends;
|
||||
// DX12 shaders compile with FXC (issue #471)
|
||||
desc.backend_options = crate::backend::backend_options();
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let wanted = std::env::var("LIGHTCRAFT_GPU_ADAPTER").ok().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty());
|
||||
let adapter = match &wanted {
|
||||
|
||||
@@ -165,6 +165,10 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
|
||||
if make.starts_with("PENTAX") || make.starts_with("RICOH") {
|
||||
return Some(RawFormat::Pef);
|
||||
}
|
||||
// a Samsung-branded body built on a Pentax design writes a Pentax-style maker note (the note's own magic)
|
||||
if make.starts_with("SAMSUNG") && has_pentax_maker_note(&t, bytes) {
|
||||
return Some(RawFormat::Pef);
|
||||
}
|
||||
if make.starts_with("SAMSUNG") {
|
||||
return Some(RawFormat::Srw);
|
||||
}
|
||||
@@ -174,6 +178,12 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether the Exif maker note starts with the signature of the Pentax layouts (`AOC\0` or `PENTAX \0`).
|
||||
fn has_pentax_maker_note(t: &Tiff, bytes: &[u8]) -> bool {
|
||||
let Some(e) = t.exif().and_then(|e| e.get(lightcraft_tiff::tags::MAKER_NOTE)) else { return false };
|
||||
bytes.get(e.offset as usize..).is_some_and(|n| n.starts_with(b"AOC\0") || n.starts_with(b"PENTAX \0"))
|
||||
}
|
||||
|
||||
/// Whether some IFD is marked as raw: CFA photometric, or a raw-only compression value (99 is not a
|
||||
/// registered TIFF compression; Leaf MOS files use it for their tiled 16-bit lossless-JPEG raw).
|
||||
fn has_raw_ifd(t: &Tiff) -> bool {
|
||||
@@ -920,6 +930,23 @@ mod tests {
|
||||
assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff));
|
||||
}
|
||||
|
||||
/// A Samsung-branded body whose maker note has a Pentax layout is read by the Pentax reader; another
|
||||
/// Samsung file stays with the Samsung format.
|
||||
#[test]
|
||||
fn samsung_make_with_a_pentax_maker_note_is_pef() {
|
||||
let with_note = |note: &[u8]| {
|
||||
let mut ifd = rgb_ifd(16, 12);
|
||||
ifd.set(t::MAKE, Value::Ascii("SAMSUNG TECHWIN".into()));
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(t::MAKER_NOTE, Value::Undefined(note.to_vec()));
|
||||
ifd.set_child(t::EXIF_IFD, exif);
|
||||
write(&[ifd])
|
||||
};
|
||||
assert_eq!(probe(&with_note(b"AOC\0MM\0\x01\0\0\0\0\0\0")), Some(RawFormat::Pef));
|
||||
assert_eq!(probe(&with_note(b"PENTAX \0MM\0\0\0\0\0\0\0")), Some(RawFormat::Pef));
|
||||
assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0")), Some(RawFormat::Srw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_rejects_junk() {
|
||||
assert_eq!(probe(b""), None);
|
||||
|
||||
+70
-27
@@ -369,12 +369,25 @@ fn check_subsampled(h: &Header) -> Result<usize, RawError> {
|
||||
Ok(vertical)
|
||||
}
|
||||
|
||||
/// Sony M/S tiles: horizontal prediction, no restart markers. Keep subsampled planes separate;
|
||||
/// expanding them into a mosaic would silently corrupt the existing CFA callers.
|
||||
pub(crate) fn frame_info_subsampled(d: &[u8]) -> Result<(usize, usize), RawError> {
|
||||
/// Sony M/S tiles and Canon sRAW / mRAW: horizontal prediction, no restart markers. Keep subsampled planes
|
||||
/// separate; expanding them into a mosaic would silently corrupt the existing CFA callers.
|
||||
/// Returns (width, height, vertical subsampling of the chroma planes).
|
||||
pub(crate) fn frame_info_subsampled(d: &[u8]) -> Result<(usize, usize, usize), RawError> {
|
||||
let h = parse_header(d)?;
|
||||
check_subsampled(&h)?;
|
||||
Ok((h.width, h.height))
|
||||
let vertical = check_subsampled(&h)?;
|
||||
Ok((h.width, h.height, vertical))
|
||||
}
|
||||
|
||||
/// How the first-column prediction and the running predictor of a subsampled frame are chosen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Prediction {
|
||||
/// Sony: the left neighbour in the plane; at the first column the sample above, except that a top luma
|
||||
/// row of an MCU row reads the previous MCU row's top luma sample.
|
||||
Geometric,
|
||||
/// Canon: every component is its own one-dimensional chain in decoding order (the previous sample of the
|
||||
/// same component, so the second luma row of a 4:2:0 MCU continues from the end of the first); the first
|
||||
/// sample of each MCU row starts from the first sample of the previous MCU row.
|
||||
Sequential,
|
||||
}
|
||||
|
||||
pub(crate) struct FrameSubsampled {
|
||||
@@ -385,11 +398,13 @@ pub(crate) struct FrameSubsampled {
|
||||
pub planes: [Vec<u16>; 3],
|
||||
}
|
||||
|
||||
/// Sony's 4:2:0 / 4:2:2 LJ92 variants: T.81 MCU ordering and differences, modulo 2^16.
|
||||
/// At the first column, the top luma row predicts from the previous MCU row's top
|
||||
/// The 4:2:0 / 4:2:2 LJ92 variants of Sony (M/S ARW) and Canon (sRAW / mRAW): T.81 MCU ordering and differences,
|
||||
/// modulo 2^16. [`Prediction`] selects the predictor each maker uses.
|
||||
///
|
||||
/// Sony: at the first column, the top luma row predicts from the previous MCU row's top
|
||||
/// sample (two image rows above), and the bottom row from the current top sample.
|
||||
/// Observed black-box: using the immediately preceding image row introduces tile seams.
|
||||
pub(crate) fn decode_subsampled(d: &[u8], max_samples: usize) -> Result<FrameSubsampled, RawError> {
|
||||
pub(crate) fn decode_subsampled(d: &[u8], max_samples: usize, prediction: Prediction) -> Result<FrameSubsampled, RawError> {
|
||||
let h = parse_header(d)?;
|
||||
let vertical = check_subsampled(&h)?;
|
||||
let pixels = h.width.checked_mul(h.height).ok_or_else(|| err("frame too large"))?;
|
||||
@@ -410,6 +425,9 @@ pub(crate) fn decode_subsampled(d: &[u8], max_samples: usize) -> Result<FrameSub
|
||||
let mut planes = [vec![0u16; pixels], vec![0u16; chroma], vec![0u16; chroma]];
|
||||
let mut br = BitReader::new(entropy);
|
||||
let init = 1i32 << (h.precision - h.pt - 1);
|
||||
// Sequential prediction: the last sample of each component, and the first sample of its previous MCU row
|
||||
let mut last = [init; 3];
|
||||
let mut row_first = [init; 3];
|
||||
for my in 0..h.height / vertical {
|
||||
for mx in 0..h.width / 2 {
|
||||
for (c, plane) in planes.iter_mut().enumerate() {
|
||||
@@ -420,20 +438,24 @@ pub(crate) fn decode_subsampled(d: &[u8], max_samples: usize) -> Result<FrameSub
|
||||
for dx in 0..horizontal_factor {
|
||||
let (x, y) = (mx * horizontal_factor + dx, my * vertical_factor + dy);
|
||||
let i = y * width + x;
|
||||
let pred = if x > 0 {
|
||||
plane[i - 1] as i32
|
||||
} else if c == 0 && dy == 0 && y >= vertical {
|
||||
plane[i - vertical * width] as i32
|
||||
} else if y > 0 {
|
||||
plane[i - width] as i32
|
||||
} else {
|
||||
init
|
||||
let pred = match prediction {
|
||||
Prediction::Geometric if x > 0 => plane[i - 1] as i32,
|
||||
Prediction::Geometric if c == 0 && dy == 0 && y >= vertical => plane[i - vertical * width] as i32,
|
||||
Prediction::Geometric if y > 0 => plane[i - width] as i32,
|
||||
Prediction::Geometric => init,
|
||||
Prediction::Sequential if mx == 0 && dx == 0 && dy == 0 => row_first[c],
|
||||
Prediction::Sequential => last[c],
|
||||
};
|
||||
let ssss = tables[c].decode(&mut br)?;
|
||||
if ssss > 16 {
|
||||
return Err(err("invalid difference category"));
|
||||
}
|
||||
plane[i] = ((pred + diff_value(&mut br, ssss)) & 0xffff) as u16;
|
||||
let value = ((pred + diff_value(&mut br, ssss)) & 0xffff) as u16;
|
||||
plane[i] = value;
|
||||
last[c] = i32::from(value);
|
||||
if mx == 0 && dx == 0 && dy == 0 {
|
||||
row_first[c] = i32::from(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -816,11 +838,13 @@ pub(crate) mod tests {
|
||||
|
||||
/// Independent 4×4, 16-bit Sony 4:2:0 stream. Hand-specified differences in MCU order.
|
||||
pub(crate) fn fixture_420() -> Vec<u8> {
|
||||
fixture_subsampled(4, 0x22, &[-31768, 1, 1000, 1, -16384, -16284, 1, 1, 1, 1, 1, 2, 100, 1, 1000, 1, 100, 100, 1, 1, 1, 1, 1, 2])
|
||||
fixture_subsampled(4, 4, 0x22, &[-31768, 1, 1000, 1, -16384, -16284, 1, 1, 1, 1, 1, 2, 100, 1, 1000, 1, 100, 100, 1, 1, 1, 1, 1, 2])
|
||||
}
|
||||
|
||||
fn fixture_subsampled(height: u8, sampling: u8, differences: &[i32]) -> Vec<u8> {
|
||||
let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 17, 16, 0, height, 0, 4, 3, 1, sampling, 0, 2, 0x11, 0, 3, 0x11, 0];
|
||||
/// A 16-bit three-component frame of `width × height` pixels with the given luma `sampling` (`0x21` / `0x22`)
|
||||
/// and one shared 5-bit Huffman table, whose differences are written as given (MCU order).
|
||||
pub(crate) fn fixture_subsampled(width: u8, height: u8, sampling: u8, differences: &[i32]) -> Vec<u8> {
|
||||
let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 17, 16, 0, height, 0, width, 3, 1, sampling, 0, 2, 0x11, 0, 3, 0x11, 0];
|
||||
out.extend_from_slice(&[0xff, 0xc4, 0, 36, 0]);
|
||||
let mut counts = [0; 16];
|
||||
counts[4] = 17;
|
||||
@@ -844,28 +868,28 @@ pub(crate) mod tests {
|
||||
#[test]
|
||||
fn horizontal_only_subsampling_preserves_rows_and_chroma() {
|
||||
// Independent 4×2, 16-bit 4:2:2 stream: two luma samples, Cb, Cr per MCU.
|
||||
let enc = fixture_subsampled(2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]);
|
||||
let frame = decode_subsampled(&enc, 16).unwrap();
|
||||
let enc = fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]);
|
||||
let frame = decode_subsampled(&enc, 16, Prediction::Geometric).unwrap();
|
||||
assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 2, 1));
|
||||
assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 1100, 1101, 1102, 1103]);
|
||||
assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]);
|
||||
assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]);
|
||||
assert!(decode(&enc, 24).is_err());
|
||||
assert!(matches!(decode_subsampled(&enc, 15), Err(RawError::Limit(_))));
|
||||
assert!(decode_subsampled(&enc[..enc.len() - 6], 16).is_err());
|
||||
assert!(matches!(decode_subsampled(&enc, 15, Prediction::Geometric), Err(RawError::Limit(_))));
|
||||
assert!(decode_subsampled(&enc[..enc.len() - 6], 16, Prediction::Geometric).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subsampled_mcu_order_preserves_each_plane() {
|
||||
let enc = fixture_420();
|
||||
let frame = decode_subsampled(&enc, 24).unwrap();
|
||||
let frame = decode_subsampled(&enc, 24, Prediction::Geometric).unwrap();
|
||||
assert_eq!((frame.width, frame.height), (4, 4));
|
||||
assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 2000, 2001, 2002, 2003, 1100, 1101, 1102, 1103, 2100, 2101, 2102, 2103]);
|
||||
assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]);
|
||||
assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]);
|
||||
assert!(decode(&enc, 48).is_err()); // CFA API must keep rejecting subsampling.
|
||||
assert!(matches!(decode_subsampled(&enc, 23), Err(RawError::Limit(_))));
|
||||
assert!(decode_subsampled(&enc[..enc.len() - 6], 24).is_err());
|
||||
assert!(matches!(decode_subsampled(&enc, 23, Prediction::Geometric), Err(RawError::Limit(_))));
|
||||
assert!(decode_subsampled(&enc[..enc.len() - 6], 24, Prediction::Geometric).is_err());
|
||||
let mut bad = enc.clone();
|
||||
bad[10] = 3; // odd frame width
|
||||
assert!(frame_info_subsampled(&bad).is_err());
|
||||
@@ -875,6 +899,25 @@ pub(crate) mod tests {
|
||||
assert!(frame_info_subsampled(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_prediction_chains_each_component_in_decoding_order() {
|
||||
// Canon sRAW / mRAW: the same stream as `subsampled_mcu_order_preserves_each_plane`, but each component
|
||||
// continues from its previous sample in decoding order (the second luma row of an MCU follows the first),
|
||||
// and a new MCU row starts from the first sample of the previous one. Worked out by hand from init 32768.
|
||||
let enc = fixture_420();
|
||||
let frame = decode_subsampled(&enc, 24, Prediction::Sequential).unwrap();
|
||||
assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 4, 2));
|
||||
assert_eq!(frame.planes[0], [1000, 1001, 2003, 2004, 2001, 2002, 2005, 2006, 1100, 1101, 2103, 2104, 2101, 2102, 2105, 2106]);
|
||||
assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]);
|
||||
assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]);
|
||||
// 4:2:2 has one luma row per MCU row, so both predictions agree
|
||||
let enc = fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]);
|
||||
assert_eq!(
|
||||
decode_subsampled(&enc, 16, Prediction::Sequential).unwrap().planes,
|
||||
decode_subsampled(&enc, 16, Prediction::Geometric).unwrap().planes
|
||||
);
|
||||
}
|
||||
|
||||
fn noise(n: usize, bits: u32, seed: u64) -> Vec<u16> {
|
||||
let mut s = seed;
|
||||
(0..n)
|
||||
|
||||
Vendored
+39
-4
@@ -338,7 +338,7 @@ fn read_ycbcr_tiles(bytes: &[u8], info: &ImageInfo, raw: &Ifd, mode: Mode) -> Re
|
||||
.map(|c| {
|
||||
let src = chunk_bytes(bytes, c).ok_or_else(|| RawError::Corrupt("YCbCr tile outside file".into()))?;
|
||||
let limit = (c.width as usize).checked_mul(c.height as usize).and_then(|n| n.checked_mul(3)).ok_or(RawError::Limit("tile too large"))?;
|
||||
let f = ljpeg::decode_subsampled(src, limit)?;
|
||||
let f = ljpeg::decode_subsampled(src, limit, ljpeg::Prediction::Geometric)?;
|
||||
if f.width != c.width as usize || f.height != c.height as usize {
|
||||
return Err(RawError::Corrupt("Sony YCbCr tile dimensions".into()));
|
||||
}
|
||||
@@ -454,13 +454,17 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
let linear_rgb = info.compression == 7 && info.photometric == photometric::YCBCR;
|
||||
let chunks = info.chunks(bytes.len() as u64);
|
||||
let strip_len: u64 = chunks.iter().map(|c| c.len).sum();
|
||||
// The row-per-w-bytes (ARW2) layout stores exactly one byte per pixel in a single strip; all 108 such files in the test
|
||||
// archive measure exactly width x height. A200/A230/A350 files carry a strip 4-23 % larger than that (and A290/A390 one
|
||||
// smaller), which is a different packing, so they are refused rather than read with the wrong layout.
|
||||
let one_byte_per_sample = chunks.len() == 1 && strip_len == (w * h) as u64;
|
||||
let (data, out_bits) = match info.compression {
|
||||
7 if linear_rgb => (RawData::U16(read_ycbcr_tiles(bytes, &info, raw, mode)?), 14),
|
||||
32767 if chunks.len() == 1 && strip_len >= (w * h) as u64 && strip_len < (w * h) as u64 * 5 / 4 && mode == Mode::Header => {
|
||||
32767 if one_byte_per_sample && mode == Mode::Header => {
|
||||
chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?;
|
||||
(RawData::U16(Vec::new()), 14)
|
||||
}
|
||||
32767 if chunks.len() == 1 && strip_len >= (w * h) as u64 && strip_len < (w * h) as u64 * 5 / 4 => {
|
||||
32767 if one_byte_per_sample => {
|
||||
let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?;
|
||||
let curve = code_curve(&raw.u64s(TONE_CURVE).unwrap_or_else(|| vec![8000, 10400, 12900, 14100]));
|
||||
let mut data = vec![0u16; w * h];
|
||||
@@ -473,7 +477,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
});
|
||||
(RawData::U16(data), 14)
|
||||
}
|
||||
32767 => return Err(RawError::Unsupported("Sony ARW version 1 / packed compressed variant".into())),
|
||||
32767 => return Err(RawError::Unsupported("Sony ARW version 1 / packed compressed variant (raw strip is not one byte per pixel)".into())),
|
||||
7 if is_quad_tiled(bytes, &info) => match mode {
|
||||
Mode::Full => (RawData::U16(read_quad_tiles(bytes, &info)?), bits),
|
||||
Mode::Header => {
|
||||
@@ -595,6 +599,37 @@ mod tests {
|
||||
assert_eq!((developed.width, developed.height), (4, 4));
|
||||
}
|
||||
|
||||
/// A 32767-compressed ARW whose single strip is `strip` bytes for a 32 x 4 image.
|
||||
fn arw_with_strip(strip: usize) -> Vec<u8> {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::MAKE, Value::Ascii("SONY".into()));
|
||||
raw.set(t::MODEL, Value::Ascii("DSLR-A200".into()));
|
||||
raw.set(t::IMAGE_WIDTH, Value::Long(vec![32]));
|
||||
raw.set(t::IMAGE_LENGTH, Value::Long(vec![4]));
|
||||
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12]));
|
||||
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA]));
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![32767]));
|
||||
raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0u8; strip]] });
|
||||
TiffWriter::default().write(&[raw]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_larger_or_smaller_than_one_byte_per_pixel_is_the_packed_variant() {
|
||||
for strip in [32 * 4 + 8, 32 * 4 * 5 / 4 - 1, 32 * 4 - 1] {
|
||||
for mode in [Mode::Header, Mode::Full] {
|
||||
let err = decode(&arw_with_strip(strip), mode).unwrap_err();
|
||||
assert!(matches!(err, RawError::Unsupported(ref m) if m.contains("packed compressed variant")), "{strip} {mode:?}: {err:?}");
|
||||
}
|
||||
}
|
||||
for mode in [Mode::Header, Mode::Full] {
|
||||
let r = decode(&arw_with_strip(32 * 4), mode);
|
||||
assert!(!matches!(r, Err(RawError::Unsupported(ref m)) if m.contains("packed compressed variant")), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode one 16-value set as an ARW2 block (the paper's scheme, used here to test the decoder).
|
||||
pub(crate) fn encode_block(v: &[u16; 16]) -> [u8; 16] {
|
||||
let (imax, &max) = v.iter().enumerate().max_by_key(|(i, x)| (**x, usize::MAX - *i)).unwrap();
|
||||
|
||||
Vendored
+9
-3
@@ -17,7 +17,9 @@
|
||||
//! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known
|
||||
//! offsets and accept the first plausible quadruple. The array is 16-bit words; some models store it as UNDEFINED
|
||||
//! bytes (ColorData versions -3 and -4), which are paired up in the maker note's byte order first.
|
||||
//! - sRAW / mRAW (YCbCr, subsampled) are not supported yet.
|
||||
//! - sRAW / mRAW (raw IFD tag `0xc6c5` = 4) hold subsampled YCbCr instead of a mosaic: see [`sraw`].
|
||||
|
||||
mod sraw;
|
||||
|
||||
use super::{black_from_columns, white_from_data};
|
||||
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg};
|
||||
@@ -27,6 +29,8 @@ use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, makernote, tags as t};
|
||||
|
||||
const CR2_SLICE: u16 = 0xc640;
|
||||
const SRAW_TYPE: u16 = 0xc6c5;
|
||||
/// `SRAW_TYPE` value of the YCbCr frames of sRAW / mRAW (IFD2's preview image carries 3).
|
||||
const SRAW_YCC: u32 = 4;
|
||||
const CR2_CFA_PATTERN: u16 = 0xc5e0;
|
||||
const SENSOR_INFO: u16 = 0x00e0;
|
||||
const COLOR_BALANCE: u16 = 0x4001;
|
||||
@@ -79,8 +83,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
let tiff = Tiff::parse(bytes)?;
|
||||
let ifd0 = &tiff.ifds[0];
|
||||
let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("CR2 without raw IFD".into()))?;
|
||||
if raw.u32(SRAW_TYPE).is_some_and(|v| v != 1) {
|
||||
return Err(RawError::Unsupported("Canon sRAW/mRAW".into()));
|
||||
match raw.u32(SRAW_TYPE) {
|
||||
Some(SRAW_YCC) => return sraw::decode(bytes, &tiff, raw, mode),
|
||||
Some(v) if v != 1 => return Err(RawError::Unsupported(format!("Canon raw type {v}"))),
|
||||
_ => {}
|
||||
}
|
||||
let off = raw.u64(t::STRIP_OFFSETS).ok_or(RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(t::STRIP_OFFSETS)))?;
|
||||
let len = raw.u64(t::STRIP_BYTE_COUNTS).unwrap_or(bytes.len() as u64 - off.min(bytes.len() as u64));
|
||||
|
||||
Vendored
+578
@@ -0,0 +1,578 @@
|
||||
//! Canon sRAW / mRAW: reduced-resolution YCbCr "raw" frames in CR2 (raw IFD tag `0xc6c5` = 4).
|
||||
//!
|
||||
//! What the files contain (measured on the 31 CC0 sRAW / mRAW samples of 16 models, see the evidence below):
|
||||
//!
|
||||
//! - The raw strip is one lossless-JPEG frame with three components. Luma is sampled 2×1 (sRAW, 4:2:2, `0x21`) or
|
||||
//! 2×2 (mRAW, 4:2:0, `0x22`), both chroma components 1×1; precision 15, predictor 1, no restart markers.
|
||||
//! The frame's width and height are only a factorisation of the sample count: they are not the image size.
|
||||
//! - The decoded sample stream (MCU after MCU: `Y Y Cb Cr` or `Y Y Y Y Cb Cr`) is cut into vertical slices exactly
|
||||
//! like the CFA data (`cr2_slice = [n, w, last]`, widths counted in samples). A slice row is a whole number of
|
||||
//! MCUs, so the image is `Σw / MCU-size × 2` pixels wide, and one slice row is one pixel row (4:2:2) or two (4:2:0).
|
||||
//! - Prediction: each component is a one-dimensional chain in decoding order (so the second luma row of a 4:2:0 MCU
|
||||
//! continues from the end of the first), and the first sample of every frame row starts from the first sample of
|
||||
//! the previous one ([`ljpeg::Prediction::Sequential`]). The Sony prediction (geometric) gives vertical stripes on
|
||||
//! these files.
|
||||
//! - The samples are linear. Luma is the BT.601 luma (0.299, 0.587, 0.114; also the TIFF 6.0 default
|
||||
//! `YCbCrCoefficients`) of white-balanced camera RGB, about 7/8 of the black-subtracted sensor counts; chroma is
|
||||
//! centred on 16384. White balance is baked in (neutral surfaces have neutral chroma); which of the maker note's
|
||||
//! white-balance sets was used is not always the as-shot one, so it is not undone.
|
||||
//! - Chroma is stored as a scaled colour difference, `R − Y = a·(Cr − 16384)` and `B − Y = b·(Cb − 16384)`, with
|
||||
//! `G` from the luma equation. `a` and `b` are about 1.0 on bodies from 2012 on, but about 5.7 and 7.4 (the TIFF
|
||||
//! 6.0 YCbCr ratio 1.402 : 1.772, scaled by about 4.1) on the 2008–2010 bodies (5D Mark II, 50D, 7D, 60D, 1D Mark IV).
|
||||
//! Rather than keying a table on the model, the gains are measured per file against the 16-bit RGB image the
|
||||
//! camera stores in IFD2 for these files (see [`chroma_gains`]).
|
||||
//!
|
||||
//! Only these files were used as evidence: the sRAW samples themselves, the same cameras' CFA files of the same
|
||||
//! scenes (raw counts against the decoded samples), and the IFD2 image. The ExifTool tag documentation was used for
|
||||
//! tag names.
|
||||
|
||||
use super::{COLOR_BALANCE, CR2_SLICE, SENSOR_INFO, words};
|
||||
use crate::{BlackLevel, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg};
|
||||
use lightcraft_geom::Orientation;
|
||||
use lightcraft_tiff::image::{Chunk, chunk_bytes};
|
||||
use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote, tags as t};
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// Chroma samples are centred on this value.
|
||||
const CHROMA_CENTRE: f64 = 16384.0;
|
||||
/// BT.601 luma weights, the TIFF 6.0 default `YCbCrCoefficients`.
|
||||
const KR: f64 = 0.299;
|
||||
const KG: f64 = 0.587;
|
||||
const KB: f64 = 0.114;
|
||||
/// Decoded luma relative to black-subtracted sensor counts (white-balanced): 0.877, 0.877, 0.896, 0.880, 0.871, 0.851
|
||||
/// and 0.846 on the 7D Mark II, 5DS R, 6D Mark II, 5DS, 5D Mark II, 1D X Mark II and 50D (CFA and sRAW files of the
|
||||
/// same scene), so 7/8. It sets the white level, so that a file renders about as bright as the CFA file of the same
|
||||
/// scene.
|
||||
const LUMA_GAIN: f32 = 0.875;
|
||||
/// Full scale of a 14-bit sensor, the white level of a CFA CR2 without a saturation plateau.
|
||||
const SENSOR_FULL: f32 = 16383.0;
|
||||
/// Mid-tone limit (IFD2 units) of the pixels used for the chroma fit: above about a quarter of full scale the
|
||||
/// camera's own RGB image rolls off.
|
||||
const FIT_LIMIT: f64 = 4096.0;
|
||||
|
||||
/// How the decoded frame maps to the image.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) struct Layout {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
/// Vertical subsampling of the chroma planes: 1 (4:2:2, sRAW) or 2 (4:2:0, mRAW).
|
||||
pub vertical: usize,
|
||||
/// Slice widths in samples.
|
||||
widths: Vec<usize>,
|
||||
/// Slice height in MCU rows.
|
||||
rows: usize,
|
||||
/// Samples per MCU.
|
||||
mcu: usize,
|
||||
/// Frame size from the SOF3 header.
|
||||
frame_width: usize,
|
||||
frame_height: usize,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// The layout of a `frame_width × frame_height` frame with `vertical` chroma subsampling and `cr2_slice` tag
|
||||
/// values `slices` (`[n, w, last]`).
|
||||
pub(super) fn new(frame_width: usize, frame_height: usize, vertical: usize, slices: &[u64]) -> Result<Layout> {
|
||||
let &[n, w, last] = slices else { return Err(RawError::Corrupt("sRAW without slice layout".into())) };
|
||||
if n > 64 || w == 0 || last == 0 {
|
||||
return Err(RawError::Corrupt("sRAW slice layout".into()));
|
||||
}
|
||||
let mcu = 2 * vertical + 2;
|
||||
let mut widths = vec![w as usize; n as usize];
|
||||
widths.push(last as usize);
|
||||
if widths.iter().any(|w| w % mcu != 0) {
|
||||
return Err(RawError::Corrupt("sRAW slice is not a whole number of MCUs".into()));
|
||||
}
|
||||
let samples =
|
||||
frame_width.checked_mul(frame_height).and_then(|p| p.checked_add(p / vertical)).ok_or(RawError::Limit("sRAW frame too large"))?;
|
||||
let row = widths.iter().try_fold(0usize, |a, &w| a.checked_add(w)).ok_or(RawError::Limit("sRAW slice widths"))?;
|
||||
if samples == 0 || row == 0 || samples % row != 0 {
|
||||
return Err(RawError::Corrupt(format!("sRAW slices ({row} samples per row) do not divide the frame ({samples} samples)")));
|
||||
}
|
||||
let rows = samples / row;
|
||||
let (width, height) = (row / mcu * 2, rows * vertical);
|
||||
if width.checked_mul(height).and_then(|p| p.checked_mul(3)).is_none_or(|s| s > crate::MAX_SAMPLES) {
|
||||
return Err(RawError::Limit("sRAW image too large"));
|
||||
}
|
||||
Ok(Layout { width, height, vertical, widths, rows, mcu, frame_width, frame_height })
|
||||
}
|
||||
}
|
||||
|
||||
/// Planar YCbCr at image geometry: luma `width × height`, chroma `width/2 × height/vertical`.
|
||||
struct Planes {
|
||||
width: usize,
|
||||
height: usize,
|
||||
vertical: usize,
|
||||
y: Vec<u16>,
|
||||
cb: Vec<u16>,
|
||||
cr: Vec<u16>,
|
||||
}
|
||||
|
||||
impl Planes {
|
||||
fn chroma_width(&self) -> usize {
|
||||
self.width / 2
|
||||
}
|
||||
|
||||
/// Re-chop the decoded MCU stream into image planes.
|
||||
fn arrange(frame: &ljpeg::FrameSubsampled, layout: &Layout) -> Planes {
|
||||
let (w, h, v, m) = (layout.width, layout.height, layout.vertical, layout.mcu);
|
||||
let cw = w / 2;
|
||||
let mut p = Planes { width: w, height: h, vertical: v, y: vec![0; w * h], cb: vec![0; cw * (h / v)], cr: vec![0; cw * (h / v)] };
|
||||
let mcus_per_frame_row = layout.frame_width / 2;
|
||||
let mut x0 = 0;
|
||||
for &sw in &layout.widths {
|
||||
let per_row = sw / m;
|
||||
let first = layout.rows * (x0 / m);
|
||||
for r in 0..layout.rows {
|
||||
for k in 0..per_row {
|
||||
let q = first + r * per_row + k;
|
||||
let (qy, qx) = (q / mcus_per_frame_row, q % mcus_per_frame_row);
|
||||
let px = (x0 / m + k) * 2;
|
||||
for dy in 0..v {
|
||||
let src = (qy * v + dy) * layout.frame_width + qx * 2;
|
||||
let dst = (r * v + dy) * w + px;
|
||||
p.y[dst..dst + 2].copy_from_slice(&frame.planes[0][src..src + 2]);
|
||||
}
|
||||
p.cb[r * cw + px / 2] = frame.planes[1][qy * mcus_per_frame_row + qx];
|
||||
p.cr[r * cw + px / 2] = frame.planes[2][qy * mcus_per_frame_row + qx];
|
||||
}
|
||||
}
|
||||
x0 += sw;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Mean luma / Cb / Cr over the pixel window `[x0, x1) × [y0, y1)` (non-empty, inside the image).
|
||||
fn window_mean(&self, x0: usize, x1: usize, y0: usize, y1: usize) -> (f64, f64, f64) {
|
||||
let (mut sy, mut ny) = (0f64, 0f64);
|
||||
for y in y0..y1 {
|
||||
let row = &self.y[y * self.width + x0..y * self.width + x1];
|
||||
sy += row.iter().map(|&v| f64::from(v)).sum::<f64>();
|
||||
ny += row.len() as f64;
|
||||
}
|
||||
let (cx0, cx1) = (x0 / 2, x1.div_ceil(2).max(x0 / 2 + 1));
|
||||
let (cy0, cy1) = (y0 / self.vertical, y1.div_ceil(self.vertical).max(y0 / self.vertical + 1));
|
||||
let (mut sb, mut sr, mut nc) = (0f64, 0f64, 0f64);
|
||||
for y in cy0..cy1.min(self.height / self.vertical) {
|
||||
for x in cx0..cx1.min(self.chroma_width()) {
|
||||
sb += f64::from(self.cb[y * self.chroma_width() + x]);
|
||||
sr += f64::from(self.cr[y * self.chroma_width() + x]);
|
||||
nc += 1.0;
|
||||
}
|
||||
}
|
||||
let nc = nc.max(1.0);
|
||||
(sy / ny.max(1.0), sb / nc - CHROMA_CENTRE, sr / nc - CHROMA_CENTRE)
|
||||
}
|
||||
|
||||
/// Linear RGB (`width × height × 3`): `R = Y + a·Cr`, `B = Y + b·Cb`, `G` from the luma equation.
|
||||
fn to_rgb(&self, a: f64, b: f64) -> Vec<u16> {
|
||||
let (gr, gb) = ((KR / KG * a) as f32, (KB / KG * b) as f32);
|
||||
let (a, b) = (a as f32, b as f32);
|
||||
let cw = self.chroma_width();
|
||||
let centre = CHROMA_CENTRE as f32;
|
||||
let mut out = vec![0u16; self.width * self.height * 3];
|
||||
out.par_chunks_mut(self.width * 3).enumerate().for_each(|(y, row)| {
|
||||
let luma = &self.y[y * self.width..(y + 1) * self.width];
|
||||
let chroma = (y / self.vertical) * cw;
|
||||
for (x, px) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() {
|
||||
let (cb, cr) = (f32::from(self.cb[chroma + x / 2]) - centre, f32::from(self.cr[chroma + x / 2]) - centre);
|
||||
let l = f32::from(luma[x]);
|
||||
px[0] = (l + a * cr).round().clamp(0.0, 65535.0) as u16;
|
||||
px[1] = (l - gr * cr - gb * cb).round().clamp(0.0, 65535.0) as u16;
|
||||
px[2] = (l + b * cb).round().clamp(0.0, 65535.0) as u16;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The 16-bit RGB image in IFD2 (`SRawType` 3): the camera's own rendering of the same frame at a fraction of the
|
||||
/// size, white-balanced and linear below about a quarter of full scale.
|
||||
struct Preview {
|
||||
width: usize,
|
||||
height: usize,
|
||||
rgb: Vec<u16>,
|
||||
}
|
||||
|
||||
fn read_preview(bytes: &[u8], tiff: &Tiff) -> Option<Preview> {
|
||||
let info = tiff.ifds.get(2)?.image().ok()?;
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
if info.samples_per_pixel != 3 || info.bits_per_sample != [16, 16, 16] || info.compression != 1 || info.planar != 1 || info.sample_format != 1 {
|
||||
return None;
|
||||
}
|
||||
let n = w.checked_mul(h)?.checked_mul(3)?;
|
||||
if n == 0 || n > 1 << 24 {
|
||||
return None;
|
||||
}
|
||||
let mut raw: Vec<u8> = Vec::with_capacity(n * 2);
|
||||
for c in info.chunks(bytes.len() as u64) {
|
||||
raw.extend_from_slice(chunk_bytes(bytes, &c)?);
|
||||
if raw.len() >= n * 2 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if raw.len() < n * 2 {
|
||||
return None;
|
||||
}
|
||||
let order: ByteOrder = tiff.order;
|
||||
let rgb = raw.as_chunks::<2>().0.iter().take(n).map(|&c| order.u16(c)).collect();
|
||||
Some(Preview { width: w, height: h, rgb })
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Regression {
|
||||
n: f64,
|
||||
sx: f64,
|
||||
st: f64,
|
||||
sxx: f64,
|
||||
sxt: f64,
|
||||
stt: f64,
|
||||
}
|
||||
|
||||
impl Regression {
|
||||
fn add(&mut self, x: f64, t: f64) {
|
||||
self.n += 1.0;
|
||||
self.sx += x;
|
||||
self.st += t;
|
||||
self.sxx += x * x;
|
||||
self.sxt += x * t;
|
||||
self.stt += t * t;
|
||||
}
|
||||
|
||||
/// Least-squares slope of `t` on `x` (with intercept) and its residual sum of squares.
|
||||
fn slope(&self) -> Option<(f64, f64)> {
|
||||
let var = self.n * self.sxx - self.sx * self.sx;
|
||||
if self.n < 500.0 || var <= 1e-9 * self.n * self.n {
|
||||
return None;
|
||||
}
|
||||
let slope = (self.n * self.sxt - self.sx * self.st) / var;
|
||||
let icpt = (self.st - slope * self.sx) / self.n;
|
||||
let rss =
|
||||
self.stt - 2.0 * slope * self.sxt - 2.0 * icpt * self.st + slope * slope * self.sxx + 2.0 * slope * icpt * self.sx + self.n * icpt * icpt;
|
||||
Some((slope, rss.max(0.0)))
|
||||
}
|
||||
}
|
||||
|
||||
/// How the IFD2 image sits on the full-size planes: its pixel `(i, j)` covers the window starting at
|
||||
/// `(x0 + i·sx, y0 + j·sy)` and `sx × sy` pixels large.
|
||||
struct Grid {
|
||||
x0: f64,
|
||||
y0: f64,
|
||||
sx: f64,
|
||||
sy: f64,
|
||||
}
|
||||
|
||||
/// The chroma gains `(a, b)` of this file (`R − Y = a·Cr`, `B − Y = b·Cb`), by least squares of the IFD2 image
|
||||
/// against the planes averaged to its size. Where IFD2 sits on the frame depends on the model (the whole frame,
|
||||
/// the sensor's active area, or a whole or half-integer reduction of the frame that overhangs it by a few pixels),
|
||||
/// so these are tried and the best fit is kept. `None` when the file has no usable IFD2 or the fit is not well
|
||||
/// conditioned.
|
||||
fn chroma_gains(planes: &Planes, preview: &Preview, active: Rect) -> Option<(f64, f64)> {
|
||||
let (pw, ph) = (preview.width as f64, preview.height as f64);
|
||||
let (w, h) = (planes.width as f64, planes.height as f64);
|
||||
let grids = [
|
||||
Grid { x0: 0.0, y0: 0.0, sx: w / pw, sy: h / ph },
|
||||
Grid { x0: active.x as f64, y0: active.y as f64, sx: active.width as f64 / pw, sy: active.height as f64 / ph },
|
||||
Grid { x0: 0.0, y0: 0.0, sx: (2.0 * w / pw).round() / 2.0, sy: (2.0 * h / ph).round() / 2.0 },
|
||||
];
|
||||
let mut best: Option<(f64, f64, f64)> = None;
|
||||
for g in &grids {
|
||||
if !(g.sx >= 1.0 && g.sy >= 1.0) {
|
||||
continue;
|
||||
}
|
||||
let (mut fr, mut fb) = (Regression::default(), Regression::default());
|
||||
for j in 0..preview.height {
|
||||
let y0 = (g.y0 + j as f64 * g.sy) as usize;
|
||||
let y1 = ((g.y0 + (j + 1) as f64 * g.sy) as usize).min(planes.height);
|
||||
for i in 0..preview.width {
|
||||
let p = &preview.rgb[(j * preview.width + i) * 3..][..3];
|
||||
let [r, gr, b] = [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])];
|
||||
if r.max(gr).max(b) >= FIT_LIMIT || r.min(gr).min(b) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let x0 = (g.x0 + i as f64 * g.sx) as usize;
|
||||
let x1 = ((g.x0 + (i + 1) as f64 * g.sx) as usize).min(planes.width);
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
continue;
|
||||
}
|
||||
let (y, cb, cr) = planes.window_mean(x0, x1, y0, y1);
|
||||
if y < 150.0 {
|
||||
continue;
|
||||
}
|
||||
fr.add(cr, r - y);
|
||||
fb.add(cb, b - y);
|
||||
}
|
||||
}
|
||||
if let (Some((a, ra)), Some((b, rb))) = (fr.slope(), fb.slope()) {
|
||||
let cost = (ra + rb) / (fr.n + fb.n);
|
||||
if a.is_finite() && b.is_finite() && best.is_none_or(|(_, _, c)| cost < c) {
|
||||
best = Some((a, b, cost));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(a, b, _)| (a, b)).filter(|(a, b)| (0.25..=16.0).contains(a) && (0.25..=16.0).contains(b))
|
||||
}
|
||||
|
||||
/// Word index of `AverageBlackLevel` (four equal-ish values) in the maker note's `ColorData` by `ColorDataVersion`
|
||||
/// (word 0), located by searching the CC0 samples for the values ExifTool reports.
|
||||
fn average_black_at(version: i16) -> Option<usize> {
|
||||
Some(match version {
|
||||
6 | 7 | 9 => 231,
|
||||
10 | 11 => 276,
|
||||
12 | 13 | 15 => 326,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The sensor's black level in raw counts from `ColorData` (`None` for versions whose position is not known, and
|
||||
/// when the four values are not plausible and near-equal).
|
||||
fn colour_data_black(words: &[u64]) -> Option<f32> {
|
||||
let version = words.first().map(|&v| v as u16 as i16)?;
|
||||
let q = words.get(average_black_at(version)?..)?.get(..4)?;
|
||||
let (lo, hi) = (q.iter().min()?, q.iter().max()?);
|
||||
((256..=4096).contains(lo) && hi - lo <= 8).then(|| q.iter().sum::<u64>() as f32 / 4.0)
|
||||
}
|
||||
|
||||
/// Decode a Canon sRAW / mRAW CR2 into linear white-balanced camera RGB (`cpp` 3).
|
||||
pub(super) fn decode(bytes: &[u8], tiff: &Tiff, raw: &Ifd, mode: Mode) -> Result<RawImage> {
|
||||
let ifd0 = &tiff.ifds[0];
|
||||
let off = raw.u64(t::STRIP_OFFSETS).ok_or(RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(t::STRIP_OFFSETS)))?;
|
||||
let len = raw.u64(t::STRIP_BYTE_COUNTS).unwrap_or(bytes.len() as u64 - off.min(bytes.len() as u64));
|
||||
let chunk = Chunk { index: 0, x: 0, y: 0, width: 0, height: 0, plane: 0, offset: off, len };
|
||||
let src = chunk_bytes(bytes, &chunk).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?;
|
||||
let (fw, fh, vertical) = ljpeg::frame_info_subsampled(src)?;
|
||||
let slices = raw.u64s(CR2_SLICE).unwrap_or_default();
|
||||
let layout = Layout::new(fw, fh, vertical, &slices)?;
|
||||
|
||||
let make = ifd0.string(t::MAKE).unwrap_or_default();
|
||||
let mn =
|
||||
tiff.exif().and_then(|e| e.get(t::MAKER_NOTE)).and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make));
|
||||
let (w, h) = (layout.width, layout.height);
|
||||
let mut active = Rect::new(0, 0, w, h);
|
||||
if let Some(si) = mn.as_ref().and_then(|m| m.ifd.u64s(SENSOR_INFO)).filter(|v| v.len() >= 9) {
|
||||
let (l, tp, r, b) = (si[5] as usize, si[6] as usize, si[7] as usize, si[8] as usize);
|
||||
if r > l && b > tp && r < w && b < h {
|
||||
active = Rect::new(l, tp, r - l + 1, b - tp + 1);
|
||||
}
|
||||
}
|
||||
let colour_data = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order)));
|
||||
let black_counts = colour_data.as_deref().and_then(colour_data_black);
|
||||
// 1.0 = a CFA CR2 of the same body at full 14-bit scale; see LUMA_GAIN
|
||||
let white = LUMA_GAIN * (SENSOR_FULL - black_counts.unwrap_or(0.0));
|
||||
|
||||
let data = match mode {
|
||||
Mode::Header => Vec::new(),
|
||||
Mode::Full => {
|
||||
let samples = fw * fh + fw * fh / vertical;
|
||||
let frame = ljpeg::decode_subsampled(src, samples.min(crate::MAX_SAMPLES), ljpeg::Prediction::Sequential)?;
|
||||
let planes = Planes::arrange(&frame, &layout);
|
||||
drop(frame);
|
||||
let (a, b) = read_preview(bytes, tiff).and_then(|p| chroma_gains(&planes, &p, active)).unwrap_or((1.0, 1.0));
|
||||
planes.to_rgb(a, b)
|
||||
}
|
||||
};
|
||||
|
||||
let mut metadata = lightcraft_meta::from_tiff(tiff);
|
||||
metadata.width = Some(active.width as u32);
|
||||
metadata.height = Some(active.height as u32);
|
||||
let img = RawImage {
|
||||
format: RawFormat::Cr2,
|
||||
width: w,
|
||||
height: h,
|
||||
cpp: 3,
|
||||
data: RawData::U16(data),
|
||||
cfa: None,
|
||||
bits: 14,
|
||||
black: BlackLevel::uniform(0.0),
|
||||
white: vec![white],
|
||||
active_area: active,
|
||||
crop: Rect::new(0, 0, active.width, active.height),
|
||||
orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)),
|
||||
color: ColorData::default(),
|
||||
// the camera's white balance is already applied, as in Sony's linear YCbCr
|
||||
wb_multipliers: Some([1.0; 3]),
|
||||
linearized: false,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata,
|
||||
};
|
||||
img.validate_for(mode)?;
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
|
||||
|
||||
const SRAW_TYPE_TAG: u16 = 0xc6c5;
|
||||
|
||||
/// The 4:2:2 stream of `ljpeg`'s hand-coded fixture (4 × 2 frame, two MCUs per frame row) decodes to luma
|
||||
/// 1000 1001 / 1002 1003 / 1100 1101 / 1102 1103 in MCU order, Cb 16384 16385 16484 16485, Cr 16484 16486
|
||||
/// 16584 16586.
|
||||
fn stream_422() -> Vec<u8> {
|
||||
ljpeg::tests::fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2])
|
||||
}
|
||||
|
||||
/// A CR2 whose raw IFD holds `stream` cut into the slices `slices` (IFD2 = a 2 × 2 16-bit RGB image when
|
||||
/// `preview` is given).
|
||||
fn sraw_file(stream: Vec<u8>, slices: [u16; 3], sraw_type: u32, preview: Option<[u16; 12]>) -> Vec<u8> {
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("Canon".into()));
|
||||
ifd0.set(t::MODEL, Value::Ascii("Canon EOS Test".into()));
|
||||
let mut ifd2 = IfdBuilder::new();
|
||||
if let Some(p) = preview {
|
||||
ifd2.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
ifd2.set(t::PHOTOMETRIC, Value::Short(vec![2]));
|
||||
ifd2.set(t::IMAGE_WIDTH, Value::Long(vec![2]));
|
||||
ifd2.set(t::IMAGE_LENGTH, Value::Long(vec![2]));
|
||||
ifd2.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16]));
|
||||
ifd2.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
|
||||
ifd2.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![p.iter().flat_map(|v| v.to_le_bytes()).collect()] });
|
||||
} else {
|
||||
ifd2.set(1, Value::Short(vec![0]));
|
||||
}
|
||||
let mut ifd3 = IfdBuilder::new();
|
||||
ifd3.set(t::COMPRESSION, Value::Short(vec![6]));
|
||||
ifd3.set(SRAW_TYPE_TAG, Value::Long(vec![sraw_type]));
|
||||
ifd3.set(CR2_SLICE, Value::Short(slices.to_vec()));
|
||||
ifd3.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![stream] });
|
||||
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0, IfdBuilder::new().with(1, Value::Short(vec![0])), ifd2, ifd3]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slices_are_cut_back_into_image_columns_and_converted() {
|
||||
// slices [1, 4, 4]: two slices of one MCU per row; slice 0 holds MCUs 0 and 1 (image rows 0 and 1), slice 1
|
||||
// holds MCUs 2 and 3, so the image is 4 × 2 with luma 1000 1001 1100 1101 / 1002 1003 1102 1103.
|
||||
let bytes = sraw_file(stream_422(), [1, 4, 4], 4, None);
|
||||
let img = crate::decode(&bytes).unwrap();
|
||||
assert_eq!((img.width, img.height, img.cpp), (4, 2, 3));
|
||||
assert!(img.cfa.is_none());
|
||||
assert_eq!(img.wb_multipliers, Some([1.0; 3]));
|
||||
let RawData::U16(px) = &img.data else { panic!("integer data") };
|
||||
// no IFD2: chroma gains 1; R = Y + Cr', B = Y + Cb', G from the BT.601 luma
|
||||
let expect = |y: f64, cb: f64, cr: f64| {
|
||||
let (r, b) = (y + cr, y + cb);
|
||||
[r, (y - KR * r - KB * b) / KG, b].map(|v| v.round() as i32)
|
||||
};
|
||||
let cases = [
|
||||
((0, 0), expect(1000.0, 0.0, 100.0)),
|
||||
((1, 0), expect(1001.0, 0.0, 100.0)),
|
||||
((2, 0), expect(1100.0, 100.0, 200.0)),
|
||||
((3, 0), expect(1101.0, 100.0, 200.0)),
|
||||
((0, 1), expect(1002.0, 1.0, 102.0)),
|
||||
((3, 1), expect(1103.0, 101.0, 202.0)),
|
||||
];
|
||||
for ((x, y), want) in cases {
|
||||
let got: Vec<i32> = px[(y * 4 + x) * 3..][..3].iter().map(|&v| i32::from(v)).collect();
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "({x},{y}): {got:?} vs {want:?}");
|
||||
}
|
||||
// header-only decoding reports the same image
|
||||
assert_eq!(crate::probe_info(&bytes).unwrap(), img.info());
|
||||
assert!(img.develop(crate::Method::Bilinear).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_too_small_to_fit_leaves_the_gains_at_one() {
|
||||
// IFD2 of 2 × 2 pixels cannot support a fit (it needs hundreds of samples): the file still decodes
|
||||
let with = crate::decode(&sraw_file(stream_422(), [1, 4, 4], 4, Some([900; 12]))).unwrap();
|
||||
let without = crate::decode(&sraw_file(stream_422(), [1, 4, 4], 4, None)).unwrap();
|
||||
assert_eq!(with.data, without.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ycbcr_frames_take_this_path() {
|
||||
// other raw types (3 is the preview image's) stay unsupported, as before
|
||||
for kind in [0, 2, 3, 5] {
|
||||
let bytes = sraw_file(stream_422(), [1, 4, 4], kind, None);
|
||||
assert!(matches!(crate::decode(&bytes), Err(RawError::Unsupported(_))), "type {kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_layouts_must_fit_the_frame() {
|
||||
let ok = Layout::new(4, 2, 1, &[1, 4, 4]).unwrap();
|
||||
assert_eq!((ok.width, ok.height), (4, 2));
|
||||
// 4:2:0: six samples per MCU, one slice row is two image rows
|
||||
let l = Layout::new(8, 4, 2, &[1, 12, 12]).unwrap();
|
||||
assert_eq!((l.width, l.height), (8, 4));
|
||||
assert!(Layout::new(4, 2, 1, &[1, 3, 5]).is_err()); // not whole MCUs
|
||||
assert!(Layout::new(4, 2, 1, &[1, 4, 8]).is_err()); // rows do not divide the frame
|
||||
assert!(Layout::new(4, 2, 1, &[1, 0, 4]).is_err());
|
||||
assert!(Layout::new(4, 2, 1, &[4, 4]).is_err()); // not three values
|
||||
assert!(Layout::new(4, 2, 1, &[65, 4, 4]).is_err());
|
||||
// slice widths whose sum overflows (or wraps to zero) are rejected, not divided by
|
||||
assert!(Layout::new(4, 2, 1, &[3, 1 << 62, 1 << 62]).is_err());
|
||||
assert!(Layout::new(4, 2, 1, &[1, u64::MAX - 3, 4]).is_err());
|
||||
let bytes = sraw_file(stream_422(), [1, 4, 8], 4, None);
|
||||
assert!(crate::decode(&bytes).is_err());
|
||||
}
|
||||
|
||||
/// Textured luma and smooth chroma on `w × h` planes.
|
||||
fn test_planes(w: usize, h: usize, vertical: usize) -> Planes {
|
||||
let y: Vec<u16> = (0..w * h).map(|i| (600 + ((i % w) * 13 + (i / w) * 29) % 1500) as u16).collect();
|
||||
let cw = w / 2;
|
||||
let ch = h / vertical;
|
||||
let cb = (0..cw * ch).map(|i| (16384 + (i % cw) as i32 * 7 - (i / cw) as i32 * 3 - 150) as u16).collect();
|
||||
let cr = (0..cw * ch).map(|i| (16384 + (i / cw) as i32 * 5 + (i % cw) as i32 * 2 - 120) as u16).collect();
|
||||
Planes { width: w, height: h, vertical, y, cb, cr }
|
||||
}
|
||||
|
||||
/// An IFD2 image of `pw × ph` pixels at `scale` per pixel, built the way the files relate it to the planes.
|
||||
fn preview_of(p: &Planes, pw: usize, ph: usize, scale: usize, a: f64, b: f64, pedestal: f64) -> Preview {
|
||||
let mut rgb = Vec::new();
|
||||
for j in 0..ph {
|
||||
for i in 0..pw {
|
||||
let (y, cb, cr) = p.window_mean(i * scale, (i + 1) * scale, j * scale, (j + 1) * scale);
|
||||
let (r, bl) = (y + a * cr, y + b * cb);
|
||||
let g = (y - KR * r - KB * bl) / KG;
|
||||
rgb.extend([r, g, bl].map(|v| (v + pedestal).round().clamp(0.0, 65535.0) as u16));
|
||||
}
|
||||
}
|
||||
Preview { width: pw, height: ph, rgb }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chroma_gains_are_measured_against_the_preview() {
|
||||
for vertical in [1, 2] {
|
||||
for (a, b) in [(1.0, 1.0), (5.7, 7.4), (1.13, 1.12)] {
|
||||
let planes = test_planes(160, 96, vertical);
|
||||
// IFD2 is a plain 1/4 reduction, optionally with a black pedestal (some bodies add one)
|
||||
for pedestal in [0.0, 520.0] {
|
||||
let preview = preview_of(&planes, 40, 24, 4, a, b, pedestal);
|
||||
let (ga, gb) =
|
||||
chroma_gains(&planes, &preview, Rect::new(0, 0, 160, 96)).unwrap_or_else(|| panic!("no fit for {a}, {b}, {vertical}"));
|
||||
assert!((ga / a - 1.0).abs() < 0.02 && (gb / b - 1.0).abs() < 0.02, "vertical {vertical}: ({a}, {b}) measured as ({ga}, {gb})");
|
||||
}
|
||||
}
|
||||
}
|
||||
// the preview overhangs the frame by a few pixels: the whole-frame scale (4.1) is wrong, 4 is right
|
||||
let planes = test_planes(164, 96, 1);
|
||||
let mut preview = preview_of(&planes, 40, 24, 4, 5.7, 7.4, 0.0);
|
||||
let (ga, gb) = chroma_gains(&planes, &preview, Rect::new(0, 0, 164, 96)).unwrap();
|
||||
assert!((ga / 5.7 - 1.0).abs() < 0.02 && (gb / 7.4 - 1.0).abs() < 0.02, "({ga}, {gb})");
|
||||
// a preview of something else gives no usable fit rather than a wild gain
|
||||
preview.rgb.iter_mut().enumerate().for_each(|(i, v)| *v = (1000 + (i * 7919) % 3000) as u16);
|
||||
assert!(
|
||||
chroma_gains(&planes, &preview, Rect::new(0, 0, 164, 96)).is_none_or(|(a, b)| (0.25..=16.0).contains(&a) && (0.25..=16.0).contains(&b))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_data_black_by_version() {
|
||||
let mut w = vec![0u64; 400];
|
||||
w[0] = 7;
|
||||
w[231..235].copy_from_slice(&[2048, 2047, 2048, 2048]);
|
||||
assert_eq!(colour_data_black(&w), Some(2047.75));
|
||||
w[0] = 12;
|
||||
assert_eq!(colour_data_black(&w), None); // the black level of another version lives elsewhere
|
||||
w[326..330].copy_from_slice(&[512, 512, 511, 512]);
|
||||
assert_eq!(colour_data_black(&w), Some(511.75));
|
||||
w[326..330].copy_from_slice(&[512, 512, 100, 512]); // implausible
|
||||
assert_eq!(colour_data_black(&w), None);
|
||||
w[0] = 3;
|
||||
assert_eq!(colour_data_black(&w), None); // no position known: black 0
|
||||
w[0] = 65533; // -3
|
||||
assert_eq!(colour_data_black(&w), None);
|
||||
assert_eq!(colour_data_black(&[]), None);
|
||||
assert_eq!(colour_data_black(&[7, 1, 2]), None);
|
||||
}
|
||||
}
|
||||
Vendored
+91
-11
@@ -7,6 +7,15 @@
|
||||
//!
|
||||
//! `IAD1` stores the valid sensor area separately from the recommended image crop. Both use inclusive
|
||||
//! sensor offsets. The CFA is anchored at the full sensor's origin; cropping shifts its phase later.
|
||||
//!
|
||||
//! The in-camera aspect ratio (3:2, 4:3, 16:9, 1:1 ...) is not applied to the sensor data: the raw always
|
||||
//! holds the whole image. The maker note's `AspectInfo` (tag `0x009a`; ExifTool names `AspectRatio`,
|
||||
//! `CroppedImageWidth`, `CroppedImageHeight`, `CroppedImageLeft`, `CroppedImageTop`) holds the shot's rectangle
|
||||
//! as five 32-bit values, measured from the top-left of the recommended image crop (the "Canon image", whose
|
||||
//! size is ExifTool's `CanonImageWidth` x `CanonImageHeight`). That rectangle replaces the crop. Established
|
||||
//! on the CC0 PowerShot SX70 HS, EOS 250D, PowerShot G5 X Mark II and EOS M6 Mark II samples shot at 3:2, 4:3,
|
||||
//! 16:9 and 1:1: the rectangle is centred, has the shot's aspect, and files shot at the sensor's own aspect
|
||||
//! store the whole image.
|
||||
//! Unknown camera metadata versions are ignored rather than assigned a guessed white balance.
|
||||
|
||||
use super::{black_from_columns, crx, white_from_data};
|
||||
@@ -17,6 +26,7 @@ use lightcraft_tiff::{Ifd, Tiff};
|
||||
const SENSOR_INFO: u16 = 0x00e0;
|
||||
const COLOR_DATA: u16 = 0x4001;
|
||||
const LENS_MODEL: u16 = 0x0095;
|
||||
const ASPECT_INFO: u16 = 0x009a;
|
||||
|
||||
/// Pick the first largest raw track. Equal-sized later tracks can be the Dual Pixel delta, which
|
||||
/// must never replace the main sensor image. An invalid full-size descriptor is reported, not
|
||||
@@ -83,16 +93,26 @@ fn sensor_crop(maker: Option<&Ifd>, width: usize, height: usize) -> Option<Rect>
|
||||
)
|
||||
}
|
||||
|
||||
/// The shot's aspect-ratio rectangle from `AspectInfo`, placed inside `image` (the recommended crop). `None`
|
||||
/// when the tag is absent or malformed, or its rectangle doesn't fit inside `image`.
|
||||
fn aspect_crop(maker: Option<&Ifd>, image: Rect) -> Option<Rect> {
|
||||
let data = maker?.u64s(ASPECT_INFO)?;
|
||||
let [width, height, left, top] = [1usize, 2, 3, 4].map(|i| data.get(i).and_then(|&v| usize::try_from(v).ok()));
|
||||
let (width, height, left, top) = (width?, height?, left?, top?);
|
||||
if width < 2 || height < 2 || left.checked_add(width)? > image.width || top.checked_add(height)? > image.height {
|
||||
return None;
|
||||
}
|
||||
Some(Rect::new(image.x.checked_add(left)?, image.y.checked_add(top)?, width, height))
|
||||
}
|
||||
|
||||
fn geometry(area: Option<&Cr3ImageArea>, maker: Option<&Ifd>, width: usize, height: usize) -> (Rect, Rect) {
|
||||
let area = area.filter(|a| usize::from(a.width) == width && usize::from(a.height) == height);
|
||||
let sensor = sensor_crop(maker, width, height);
|
||||
let active = area.and_then(|a| a.active).and_then(|b| inclusive_rect(b, width, height)).or(sensor).unwrap_or(Rect::new(0, 0, width, height));
|
||||
let crop = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor).and_then(|c| relative_crop(c, active)).unwrap_or(Rect::new(
|
||||
0,
|
||||
0,
|
||||
active.width,
|
||||
active.height,
|
||||
));
|
||||
// The recommended crop, when the file gives a consistent one. `AspectInfo` is measured from its origin, so it
|
||||
// is only applied on top of that; without it (the crop doesn't fit the valid area) the whole active area stays.
|
||||
let recommended = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor).and_then(|c| relative_crop(c, active));
|
||||
let crop = recommended.map(|c| aspect_crop(maker, c).unwrap_or(c)).unwrap_or(Rect::new(0, 0, active.width, active.height));
|
||||
(active, crop)
|
||||
}
|
||||
|
||||
@@ -266,6 +286,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn synthetic_header() -> Vec<u8> {
|
||||
synthetic_header_with(None)
|
||||
}
|
||||
|
||||
fn synthetic_header_with(aspect: Option<[u32; 5]>) -> Vec<u8> {
|
||||
use lightcraft_tiff::{ByteOrder, TiffWriter, tags as t};
|
||||
let tiff = |b: IfdBuilder| TiffWriter::new(ByteOrder::Little, false).write(&[b]).unwrap();
|
||||
let cmt1 = tiff(
|
||||
@@ -275,11 +299,13 @@ mod tests {
|
||||
.with(t::ORIENTATION, Value::Short(vec![6])),
|
||||
);
|
||||
let cmt2 = tiff(IfdBuilder::new().with(t::ISO_SPEED, Value::Short(vec![800])));
|
||||
let cmt3 = tiff(
|
||||
IfdBuilder::new()
|
||||
.with(SENSOR_INFO, Value::Short(vec![34, 64, 48, 1, 1, 6, 4, 61, 45]))
|
||||
.with(LENS_MODEL, Value::Ascii("RF-S18-45mm F4.5-6.3 IS STM".into())),
|
||||
);
|
||||
let mut cmt3 = IfdBuilder::new()
|
||||
.with(SENSOR_INFO, Value::Short(vec![34, 64, 48, 1, 1, 6, 4, 61, 45]))
|
||||
.with(LENS_MODEL, Value::Ascii("RF-S18-45mm F4.5-6.3 IS STM".into()));
|
||||
if let Some(a) = aspect {
|
||||
cmt3 = cmt3.with(ASPECT_INFO, Value::Long(a.to_vec()));
|
||||
}
|
||||
let cmt3 = tiff(cmt3);
|
||||
let mut canon = vec![0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48];
|
||||
canon.extend([box_bytes(b"CMT1", &cmt1), box_bytes(b"CMT2", &cmt2), box_bytes(b"CMT3", &cmt3)].concat());
|
||||
let mut coding = vec![0u8; 52];
|
||||
@@ -374,6 +400,60 @@ mod tests {
|
||||
assert!(decode(&bytes[..4200], Mode::Header).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_info_replaces_the_crop() {
|
||||
// 16:9 inside the 56 x 42 recommended crop at (2, 2): 56 x 30, 6 rows down.
|
||||
let bytes = synthetic_header_with(Some([7, 56, 30, 0, 6]));
|
||||
let image = decode(&bytes, Mode::Header).unwrap();
|
||||
assert_eq!(image.active_area, Rect::new(4, 2, 60, 46));
|
||||
assert_eq!(image.crop, Rect::new(2, 8, 56, 30));
|
||||
assert_eq!((image.metadata.width, image.metadata.height), (Some(56), Some(30)));
|
||||
// 1:1 with a left offset.
|
||||
let image = decode(&synthetic_header_with(Some([1, 42, 42, 7, 0])), Mode::Header).unwrap();
|
||||
assert_eq!(image.crop, Rect::new(9, 2, 42, 42));
|
||||
// The whole image, as stored for a shot at the sensor's own aspect, changes nothing.
|
||||
let image = decode(&synthetic_header_with(Some([0, 56, 42, 0, 0])), Mode::Header).unwrap();
|
||||
assert_eq!(image.crop, Rect::new(2, 2, 56, 42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_info_that_does_not_fit_is_ignored() {
|
||||
for bad in [[7, 57, 30, 0, 6], [7, 56, 30, 0, 13], [7, 56, 30, 1, 6], [7, 1, 30, 0, 0], [7, 56, 0, 0, 0], [7, u32::MAX, 30, 0, 0]] {
|
||||
let image = decode(&synthetic_header_with(Some(bad)), Mode::Header).unwrap();
|
||||
assert_eq!(image.crop, Rect::new(2, 2, 56, 42), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Some crop-mode files (EOS R5 Mark II 7883) record a recommended crop that reaches past their valid area. The
|
||||
/// whole active area stays then, and `AspectInfo`, which is measured from the recommended crop, is not applied.
|
||||
#[test]
|
||||
fn aspect_info_needs_a_consistent_recommended_crop() {
|
||||
let aspect = lightcraft_tiff::IfdBuilder::new().with(ASPECT_INFO, Value::Long(vec![13, 5088, 3392, 0, 0]));
|
||||
let bytes = lightcraft_tiff::TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[aspect]).unwrap();
|
||||
let maker = Tiff::parse(&bytes).unwrap().ifds.remove(0);
|
||||
let area =
|
||||
|crop| Cr3ImageArea { width: 5376, height: 3574, crop, active: Some([132, 160, 5243, 3567]), masked_left: [0; 4], masked_top: None };
|
||||
// crop right edge 5359 > active right edge 5243: not a crop of the active area
|
||||
assert_eq!(geometry(Some(&area([272, 172, 5359, 3563])), Some(&maker), 5376, 3574).1, Rect::new(0, 0, 5112, 3408));
|
||||
// consistent crop: AspectInfo applies from its origin
|
||||
assert_eq!(geometry(Some(&area([140, 172, 5227, 3563])), Some(&maker), 5376, 3574).1, Rect::new(8, 12, 5088, 3392));
|
||||
}
|
||||
|
||||
/// `AspectInfo` as the SX70 HS 16:9 sample stores it, byte for byte (little-endian LONG x 5), read through
|
||||
/// the TIFF parser: AspectRatio 7, 5184 x 2912 at (0, 488).
|
||||
#[test]
|
||||
fn aspect_info_known_bytes() {
|
||||
let mut tiff = b"II* | ||||