Files
lightcraft/crates/merge/src/synth.rs
T
Claude 4c1dfc5b0f LR-MERGE-HDR: keep the reference frame's lens corrections in HDR merges
Merged HDR DNGs lost the camera's embedded lens correction: load_frame strips the lens opcodes from each
source (frames are merged uncorrected) and write_linear_dng wrote no OpcodeList3, so Optics ▸ Enable Lens
Corrections did nothing on the result (Sony ILCE-7M3/7RM4A, Panasonic/Leica, DNGs with their own opcodes).

Each raw frame now keeps the lens corrections it applies (the DNG 1.6 skip rule already applied),
re-expressed for the default crop develop keeps by the new Opcode::cropped: the centre moves into the crop
and coefficients are rescaled for the crop's farthest corner. HdrResult carries the reference frame's and the
DNG writes them as its OpcodeList3. Frames turned before merging (mixed orientations) and panoramas carry none.

Tests: cropped corrections give the crop of the corrected whole (WarpRectilinear, WarpRectilinear2 with and
without reciprocal, FixVignetteRadial); an HDR merge of cropped, corrected brackets, upright and vertical, gives
the same profile correction as its reference photo (fails without the fix). Real brackets not measured yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-13 01:05:26 +00:00

87 lines
4.1 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.
//! Synthetic merge inputs from procedural scenes (tests, demos, UI checks — no media files):
//! exposure brackets as linear DNGs and overlapping panorama views as PNGs.
use lightcraft_geom::Orientation;
use lightcraft_meta::Metadata;
use lightcraft_raster::Rgb32f;
use crate::frame::FrameColor;
use crate::linalg::{self, M3};
use crate::output::{DngSamples, write_linear_dng};
use crate::{MergeError, Result};
fn scene(kind: lightcraft_scenes::Kind) -> Result<lightcraft_scenes::Scene> {
lightcraft_scenes::demo_library()
.into_iter()
.find(|s| s.kind == kind)
.ok_or_else(|| MergeError::Mismatch(format!("no {kind:?} scene in the demo library")))
}
fn srgb_color() -> FrameColor {
FrameColor::Linear { to_xyz_d50: lightcraft_color::bradford(lightcraft_color::D65, lightcraft_color::D50).mul(&lightcraft_color::SRGB.to_xyz()) }
}
/// Exposure brackets (`evs`, e.g. `[-2, 0, 2]`) of a sunset scene as 16-bit linear DNGs, each
/// shifted by a few pixels (handheld) with EXIF exposure times; `w × h` pixels.
pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Result<Vec<Vec<u8>>> {
let m = 12usize;
let truth = scene(lightcraft_scenes::Kind::OceanSunset)?.render(w + 2 * m, h + 2 * m);
let mut l: Vec<f32> = truth.data.iter().map(|p| p[1]).collect();
l.sort_by(|a, b| a.total_cmp(b));
let s = 0.18 / l.get(l.len() / 2).copied().unwrap_or(0.0).max(1e-6);
let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32();
evs.iter()
.enumerate()
.map(|(i, ev)| {
let k = 2f32.powf(*ev as f32) * s;
let (dx, dy) = ((i as f32 * 2.3) % 5.0 - 2.0, (i as f32 * 1.7) % 4.0 - 1.5);
let img = Rgb32f::from_fn(w, h, |x, y| {
let p = truth.sample_bilinear(x as f32 + 0.5 + m as f32 - dx, y as f32 + 0.5 + m as f32 - dy);
let q = [0, 1, 2].map(|r| to_srgb[r][0] * p[0] + to_srgb[r][1] * p[1] + to_srgb[r][2] * p[2]);
q.map(|v| (v * k).clamp(0.0, 1.0))
});
let meta = Metadata {
make: Some("LightCraft".into()),
model: Some("Synthetic Bracket".into()),
exposure_time: Some(2f64.powf(*ev) / 125.0),
f_number: Some(8.0),
iso: Some(100),
..Default::default()
};
write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16, &[])
})
.collect()
}
/// `yaws.len()` overlapping views (degrees of yaw, small pitch/roll wobble) of a canyon scene used
/// as a 200° × 70° spherical environment, `w × h` pixels with focal length `f`, as linear sRGB.
pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Result<Vec<Rgb32f>> {
let env = scene(lightcraft_scenes::Kind::Canyon)?.render(2400, 840);
let mut l: Vec<f32> = env.data.iter().map(|p| p[1]).collect();
l.sort_by(|a, b| a.total_cmp(b));
let s = 0.25 / l.get(l.len() / 2).copied().unwrap_or(0.0).max(1e-6);
let (theta, phi) = (200.0f64, 70.0f64);
Ok(yaws
.iter()
.enumerate()
.map(|(i, yaw)| {
let wob = (i as f64 * 1.3).sin();
let ry = linalg::rodrigues([0.0, yaw.to_radians(), 0.0]);
let rx = linalg::rodrigues([(wob * 1.5).to_radians(), 0.0, 0.0]);
let rz = linalg::rodrigues([0.0, 0.0, (wob * 0.8).to_radians()]);
let c2w: M3 = linalg::mul3(&linalg::mul3(&ry, &rx), &rz);
Rgb32f::from_fn(w, h, |x, y| {
let d = linalg::apply3(&c2w, [(x as f64 + 0.5 - w as f64 / 2.0) / f, (y as f64 + 0.5 - h as f64 / 2.0) / f, 1.0]);
let t = d[0].atan2(d[2]).to_degrees();
let p = (d[1] / linalg::norm(d)).asin().to_degrees();
if t.abs() > theta / 2.0 || p.abs() > phi / 2.0 {
return [0.0; 3];
}
let px = (t / theta + 0.5) * env.width as f64;
let py = (p / phi + 0.5) * env.height as f64;
env.sample_bilinear(px as f32, py as f32).map(|v| (v * s).min(1.0))
})
})
.collect())
}