mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
docs/parity-checklist.md: header keeps 2026-10-21 and lists both sides' change notes
This commit is contained in:
+43
-19
@@ -13,13 +13,49 @@ use crate::ransac::{self, Model};
|
||||
/// Long edge of the images features are detected on.
|
||||
pub const WORK_EDGE: usize = 1200;
|
||||
|
||||
/// The luminance merges align and weigh by (camera or linear RGB; not a colorimetric one).
|
||||
#[inline]
|
||||
pub(crate) fn luma(c: [f32; 3]) -> f32 {
|
||||
0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]
|
||||
}
|
||||
|
||||
/// The motion models a fit chooses among, simplest first: (model, name, fewest inliers).
|
||||
pub type Models = [(Model, &'static str, usize)];
|
||||
|
||||
/// Of `models` fitted by RANSAC to `a` → `b` (`thresh` px; at least `share` of the points and each
|
||||
/// model's minimum as inliers; plausible for a `w × h` frame), the simplest that explains (almost) as
|
||||
/// many points as the one explaining the most: extra degrees of freedom fitted to clustered points
|
||||
/// extrapolate badly.
|
||||
pub(crate) fn choose_model(
|
||||
models: &Models,
|
||||
a: &[Point],
|
||||
b: &[Point],
|
||||
thresh: f64,
|
||||
share: f64,
|
||||
(w, h): (f64, f64),
|
||||
seed: u64,
|
||||
) -> Option<(&'static str, ransac::Fit)> {
|
||||
let mut fits: Vec<(&'static str, ransac::Fit)> = Vec::new();
|
||||
for &(model, name, min) in models {
|
||||
let need = ((a.len() as f64 * share).ceil() as usize).max(min);
|
||||
if let Some(f) = ransac::ransac(model, a, b, thresh, need, seed)
|
||||
&& plausible(&f.h, w, h, 0.12)
|
||||
{
|
||||
fits.push((name, f));
|
||||
}
|
||||
}
|
||||
let most = fits.iter().map(|(_, f)| f.inliers.len()).max()?;
|
||||
// the fit with `most` inliers always qualifies
|
||||
fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97)
|
||||
}
|
||||
|
||||
/// A grey, perceptually encoded work image: `(luminance · gain)^(1/2.2)`, clamped to 0..1, and
|
||||
/// the scale from full-resolution pixels to work pixels.
|
||||
pub fn work_image(img: &Rgb32f, gain: f32, max_edge: usize) -> (Plane, f64) {
|
||||
let small = if img.width.max(img.height) > max_edge { fit(img, max_edge, max_edge, Filter::Box) } else { img.clone() };
|
||||
let scale = small.width as f64 / img.width as f64;
|
||||
let p = small.map(|c| {
|
||||
let y = (0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]) * gain;
|
||||
let y = luma(c) * gain;
|
||||
y.clamp(0.0, 1.0).powf(1.0 / 2.2)
|
||||
});
|
||||
(p, scale)
|
||||
@@ -29,8 +65,7 @@ pub fn work_image(img: &Rgb32f, gain: f32, max_edge: usize) -> (Plane, f64) {
|
||||
/// without EXIF.
|
||||
pub fn median_luma(img: &Rgb32f, clip: f32) -> f32 {
|
||||
let step = (img.data.len() / 100_000).max(1);
|
||||
let mut v: Vec<f32> =
|
||||
img.data.iter().step_by(step).filter(|p| p[0].max(p[1]).max(p[2]) < clip * 0.98).map(|c| 0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]).collect();
|
||||
let mut v: Vec<f32> = img.data.iter().step_by(step).filter(|p| p[0].max(p[1]).max(p[2]) < clip * 0.98).map(|c| luma(*c)).collect();
|
||||
if v.is_empty() {
|
||||
return clip;
|
||||
}
|
||||
@@ -86,24 +121,13 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz
|
||||
let a: Vec<Point> = m.iter().map(|(i, _)| Point::new(ref_feat.points[*i].x as f64, ref_feat.points[*i].y as f64)).collect();
|
||||
let b: Vec<Point> = m.iter().map(|(_, j)| Point::new(feat.points[*j].x as f64, feat.points[*j].y as f64)).collect();
|
||||
let (ww, wh) = (full_w as f64 * scale, full_h as f64 * scale);
|
||||
// Fit every model; keep the simplest one that explains (almost) as many matches as the most
|
||||
// general one — extra degrees of freedom fitted to clustered points extrapolate badly.
|
||||
let mut fits: Vec<(&'static str, ransac::Fit)> = Vec::new();
|
||||
for (model, name, min) in [(Model::Translation, "translation", 5), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 16)] {
|
||||
if let Some(f) = ransac::ransac(model, &a, &b, 1.5, min, seed)
|
||||
&& plausible(&f.h, ww, wh, 0.12)
|
||||
{
|
||||
fits.push((name, f));
|
||||
}
|
||||
}
|
||||
let Some(most) = fits.iter().map(|(_, f)| f.inliers.len()).max() else { return Alignment::identity() };
|
||||
// the fit with `most` inliers always qualifies
|
||||
let Some((name, f)) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97) else { return Alignment::identity() };
|
||||
let models = [(Model::Translation, "translation", 5), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 16)];
|
||||
let Some((name, f)) = choose_model(&models, &a, &b, 1.5, 0.0, (ww, wh), seed) else { return Alignment::identity() };
|
||||
Alignment { h: scale_homography(&f.h, scale), model: name, inliers: f.inliers.len(), rms: f.rms / scale }
|
||||
}
|
||||
|
||||
/// Resample `img` into the reference geometry: `out(p) = img(h(p))`. Returns the warped image and
|
||||
/// a validity mask (false where `h(p)` falls outside `img`).
|
||||
/// Resample `img` into the reference geometry: `out(p) = img(h(p))` (Lanczos-3). Returns the warped
|
||||
/// image and a validity mask (false where `h(p)` falls outside `img`).
|
||||
pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec<bool>) {
|
||||
let (w, ht) = (img.width, img.height);
|
||||
if *h == Homography::IDENTITY {
|
||||
@@ -116,7 +140,7 @@ pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec<bool>) {
|
||||
let p = h.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
|
||||
let (sx, sy) = (p.x as f32, p.y as f32);
|
||||
if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 {
|
||||
row[x] = img.sample_bilinear(sx, sy);
|
||||
row[x] = img.sample_lanczos3(sx, sy);
|
||||
vrow[x] = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ fn stretch(img: &Rgb32f, alpha: &Plane, amount: f64, vertical: bool) -> (Rgb32f,
|
||||
let src = lo[i] + t * (hi[i] - lo[i]);
|
||||
let s = (1.0 - amount) * (k as f64 + 0.5) + amount * src;
|
||||
let (sx, sy) = if vertical { (x as f32 + 0.5, s as f32) } else { (s as f32, y as f32 + 0.5) };
|
||||
row[x] = img.sample_bilinear(sx, sy);
|
||||
row[x] = img.sample_lanczos3(sx, sy);
|
||||
arow[x] = crate::features::bilinear(alpha, sx, sy);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -298,7 +298,7 @@ pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -
|
||||
let wx = 1.0 - (2.0 * px / fw - 1.0).abs();
|
||||
let wy = 1.0 - (2.0 * py / fh - 1.0).abs();
|
||||
let w = (wx * wy).max(1e-6) as f32;
|
||||
row[tx] = fr.image.sample_bilinear(px as f32, py as f32);
|
||||
row[tx] = fr.image.sample_lanczos3(px as f32, py as f32);
|
||||
wrow[tx] = w;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! - [`Image<T>`]: interleaved, row-major pixels (`Rgb32f = Image<[f32; 3]>`, `Rgba8 = Image<[u8; 4]>`,
|
||||
//! `Plane = Image<f32>`).
|
||||
//! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel.
|
||||
//! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel; point sampling for warps
|
||||
//! ([`sample`]: Lanczos-3 held to its neighbours' range).
|
||||
//! - Blur ([`blur`]): separable Gaussian via repeated box filters (O(1) per pixel in the radius).
|
||||
//! - Morphology ([`morph`]): square min / max filters (van Herk / Gil-Werman, O(1) per pixel in the radius).
|
||||
//! - [`Histogram`] of display-encoded RGB + luminance.
|
||||
@@ -13,6 +14,7 @@ pub mod blur;
|
||||
pub mod histogram;
|
||||
pub mod morph;
|
||||
pub mod resample;
|
||||
pub mod sample;
|
||||
|
||||
pub use histogram::{HdrBins, Histogram};
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ impl Filter {
|
||||
Filter::Lanczos3 => 3.0,
|
||||
}
|
||||
}
|
||||
fn eval(self, x: f32) -> f32 {
|
||||
/// The filter's weight at distance `x` (pixels of the source).
|
||||
pub(crate) fn eval(self, x: f32) -> f32 {
|
||||
let x = x.abs();
|
||||
match self {
|
||||
Filter::Box => {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Point sampling at continuous coordinates for geometric warps (alignment, projections).
|
||||
//!
|
||||
//! [`Rgb32f::sample_lanczos3`] interpolates with a 6 × 6 Lanczos-3 kernel. Bilinear sampling (the
|
||||
//! [`Rgb32f::sample_bilinear`] next to it) averages two pixels at a half-pixel offset: a [½ ½]
|
||||
//! filter that passes nothing at the Nyquist frequency, so a frame warped by a fraction of a pixel
|
||||
//! loses its finest detail. Lanczos-3 keeps most of it. Its negative lobes would overshoot next to
|
||||
//! a hard edge (a clipped highlight, a star), so each sample is held to the range of the 4 × 4
|
||||
//! pixels around it, channel by channel: in smooth areas and fine texture that range holds the
|
||||
//! interpolated value anyway (a 2 × 2 range would cut the peaks of texture near Nyquist that fall
|
||||
//! between pixels), and at an edge the overshoot goes.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::Rgb32f;
|
||||
use crate::resample::Filter;
|
||||
|
||||
/// Phases of a pixel the kernel's taps are tabulated for.
|
||||
const PHASES: usize = 1024;
|
||||
|
||||
/// The six Lanczos-3 weights (summing to 1) for a sample `t` (0..1) past the third of the six
|
||||
/// pixels it reads: pixel `k` (0..6) is at distance `t + 2 − k`.
|
||||
pub fn lanczos3_taps(t: f32) -> [f32; 6] {
|
||||
static TABLE: OnceLock<Vec<[f32; 6]>> = OnceLock::new();
|
||||
let table = TABLE.get_or_init(|| {
|
||||
(0..=PHASES)
|
||||
.map(|i| {
|
||||
let t = i as f32 / PHASES as f32;
|
||||
let w: [f32; 6] = std::array::from_fn(|k| Filter::Lanczos3.eval(t + 2.0 - k as f32));
|
||||
let s: f32 = w.iter().sum();
|
||||
w.map(|v| v / s)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let i = if t.is_finite() { (t.clamp(0.0, 1.0) * PHASES as f32).round() as usize } else { 0 };
|
||||
table.get(i).copied().unwrap_or([0.0, 0.0, 1.0, 0.0, 0.0, 0.0])
|
||||
}
|
||||
|
||||
impl Rgb32f {
|
||||
/// Lanczos-3 sample at continuous pixel coordinates (pixel centres at +0.5), held to the range
|
||||
/// of the 4 × 4 pixels around it (see the module docs). Past the edges the edge pixels repeat;
|
||||
/// a coordinate that isn't finite reads the nearest edge, as [`Rgb32f::sample_bilinear`] does.
|
||||
pub fn sample_lanczos3(&self, x: f32, y: f32) -> [f32; 3] {
|
||||
let (w, h) = (self.width, self.height);
|
||||
if w == 0 || h == 0 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
let fx = x.clamp(-1.0, w as f32 + 1.0) - 0.5;
|
||||
let fy = y.clamp(-1.0, h as f32 + 1.0) - 0.5;
|
||||
let (fx, fy) = (if fx.is_finite() { fx } else { 0.0 }, if fy.is_finite() { fy } else { 0.0 });
|
||||
let (x0, y0) = (fx.floor(), fy.floor());
|
||||
let (wx, wy) = (lanczos3_taps(fx - x0), lanczos3_taps(fy - y0));
|
||||
let (x0, y0) = (x0 as isize, y0 as isize);
|
||||
let col: [usize; 6] = std::array::from_fn(|k| (x0 + k as isize - 2).clamp(0, w as isize - 1) as usize);
|
||||
let row: [usize; 6] = std::array::from_fn(|k| (y0 + k as isize - 2).clamp(0, h as isize - 1) as usize * w);
|
||||
let px = |r: usize, c: usize| self.data.get(r + c).copied().unwrap_or_default();
|
||||
let mut acc = [0f32; 3];
|
||||
// and the range of the 4 × 4 pixels around the sample (taps 1 to 4 on each axis)
|
||||
let mut lo = [f32::INFINITY; 3];
|
||||
let mut hi = [f32::NEG_INFINITY; 3];
|
||||
for (j, (r, &wr)) in row.iter().zip(&wy).enumerate() {
|
||||
let mut line = [0f32; 3];
|
||||
for (i, (&c, &wc)) in col.iter().zip(&wx).enumerate() {
|
||||
let p = px(*r, c);
|
||||
for ch in 0..3 {
|
||||
line[ch] += wc * p[ch];
|
||||
}
|
||||
if (1..5).contains(&i) && (1..5).contains(&j) {
|
||||
for ch in 0..3 {
|
||||
lo[ch] = lo[ch].min(p[ch]);
|
||||
hi[ch] = hi[ch].max(p[ch]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for ch in 0..3 {
|
||||
acc[ch] += wr * line[ch];
|
||||
}
|
||||
}
|
||||
// no finite pixel in the 4 × 4 range (NaN all around): nothing to hold the sample to
|
||||
std::array::from_fn(|ch| if lo[ch] <= hi[ch] { acc[ch].clamp(lo[ch], hi[ch]) } else { acc[ch] })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// High-pass energy of a row-wise pattern: the mean squared second difference.
|
||||
fn hp(img: &Rgb32f) -> f64 {
|
||||
let w = img.width;
|
||||
let mut s = 0.0;
|
||||
let mut n = 0;
|
||||
for y in 4..img.height - 4 {
|
||||
for x in 4..w - 4 {
|
||||
let v = |dx: isize| img.get((x as isize + dx) as usize, y)[1] as f64;
|
||||
s += (2.0 * v(0) - v(-1) - v(1)).powi(2);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
s / n as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taps_sum_to_one_and_hit_pixels_exactly() {
|
||||
for t in [0.0, 0.1, 0.5, 0.77, 1.0] {
|
||||
let w = lanczos3_taps(t);
|
||||
assert!((w.iter().sum::<f32>() - 1.0).abs() < 1e-5);
|
||||
}
|
||||
let w = lanczos3_taps(0.0);
|
||||
assert!((w[2] - 1.0).abs() < 1e-5 && w.iter().enumerate().all(|(k, v)| k == 2 || v.abs() < 1e-5));
|
||||
assert_eq!(lanczos3_taps(f32::NAN), lanczos3_taps(0.0));
|
||||
}
|
||||
|
||||
/// A fine pattern moved by half a pixel: bilinear keeps cos²(π f) of its high-pass energy (a
|
||||
/// third at 0.3 cycles per pixel), Lanczos-3 twice that, and nearly all of it at 0.2.
|
||||
#[test]
|
||||
fn a_half_pixel_shift_keeps_fine_detail() {
|
||||
for (f, lanczos_min, over_bilinear) in [(0.2f32, 0.9, 1.3), (0.3, 0.65, 1.9)] {
|
||||
let img = Rgb32f::from_fn(64, 16, |x, _| [0.5 + 0.4 * (x as f32 * f * std::f32::consts::TAU).sin(); 3]);
|
||||
let shifted = |s: &dyn Fn(f32, f32) -> [f32; 3]| Rgb32f::from_fn(64, 16, |x, y| s(x as f32 + 1.0, y as f32 + 0.5));
|
||||
let bil = shifted(&|x, y| img.sample_bilinear(x, y));
|
||||
let lan = shifted(&|x, y| img.sample_lanczos3(x, y));
|
||||
let (e0, eb, el) = (hp(&img), hp(&bil), hp(&lan));
|
||||
assert!(el > lanczos_min * e0, "{f} c/px: lanczos {el} vs {e0}");
|
||||
assert!(el > over_bilinear * eb, "{f} c/px: lanczos {el} vs bilinear {eb}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Next to a hard edge Lanczos-3 would overshoot; the sample stays within its neighbours.
|
||||
#[test]
|
||||
fn no_overshoot_at_a_hard_edge() {
|
||||
let img = Rgb32f::from_fn(16, 4, |x, _| if x < 8 { [0.0; 3] } else { [1.0; 3] });
|
||||
for i in 0..160 {
|
||||
let v = img.sample_lanczos3(i as f32 * 0.1, 2.0);
|
||||
assert!((0.0..=1.0).contains(&v[0]), "{v:?} at {}", i as f32 * 0.1);
|
||||
}
|
||||
// pixel centres come back exactly
|
||||
assert_eq!(img.sample_lanczos3(8.5, 1.5), [1.0; 3]);
|
||||
assert_eq!(img.sample_lanczos3(7.5, 1.5), [0.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_and_odd_coordinates_never_panic() {
|
||||
let img = Rgb32f::from_fn(3, 2, |x, y| [x as f32, y as f32, 1.0]);
|
||||
for (x, y) in [(f32::INFINITY, f32::INFINITY), (f32::NEG_INFINITY, 0.0), (f32::NAN, f32::NAN), (f32::MAX, -1e30), (-5.0, 9.0)] {
|
||||
let v = img.sample_lanczos3(x, y);
|
||||
assert!(v.iter().all(|c| c.is_finite()), "{v:?}");
|
||||
}
|
||||
assert_eq!(Rgb32f::new(0, 0).sample_lanczos3(1.0, 1.0), [0.0; 3]);
|
||||
// pixels that are all NaN (a damaged float file) give a NaN sample, never a panic
|
||||
let nan = Rgb32f::from_fn(8, 8, |_, _| [f32::NAN; 3]);
|
||||
assert!(nan.sample_lanczos3(4.3, 4.6).iter().all(|v| v.is_nan()));
|
||||
assert_eq!(img.sample_lanczos3(0.5, 0.5), [0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -27,7 +27,9 @@ All algorithms are our own implementations from published papers (cited in the m
|
||||
|
||||
**HDR** (`hdr.rs`): frames ordered by EXIF exposure (or median luminance); the middle one is the
|
||||
reference. Auto Align: features on exposure-normalised luminance, RANSAC translation / similarity /
|
||||
homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured
|
||||
homography (the simplest model that explains ≥ 97 % of the matches). Frames are resampled with Lanczos-3, held to
|
||||
the range of their 4 × 4 neighbours so edges don't ring (`lightcraft_raster::sample`); bilinear lost a third of the
|
||||
finest detail. Exposure ratios are measured
|
||||
(median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback.
|
||||
Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max
|
||||
channel within 1 px))`: a pixel next to clipped photosites was demosaiced from them and reads low in the clipped
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Lightroom parity checklist
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
|
||||
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
|
||||
@@ -352,8 +352,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
|
||||
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 |
|
||||
| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap |
|
||||
| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align (Lanczos-3 warp), deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 |
|
||||
| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto (projected with Lanczos-3), boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap |
|
||||
| LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF |
|
||||
| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time), kept in the library's prefs.json so they survive a restart; the HDR / Panorama / HDR Panorama dialogs open with them too (`merge_dialog_remembers_the_last_settings_per_kind`). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` |
|
||||
|
||||
@@ -900,3 +900,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. |
|
||||
| 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) |
|
||||
| 2026-10-16 | patch | LR-META-XMP: a TIFF or JPEG imported beside a raw of its stem reads its own `<file>.xmp` and takes nothing from the raw's `IMG_0001.xmp`, whether it arrives with the raw or after it (a stem sidecar belongs to one file: a raw first, then the first by name); status unchanged (✅) |
|
||||
| 2026-10-21 | minor | LR-MERGE-HDR / LR-MERGE-PANO: HDR alignment warps, panorama projection and Boundary Warp sample Lanczos-3 instead of bilinear (`lightcraft_raster::sample`); status unchanged |
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# What's new in LightCraft
|
||||
|
||||
## 2026-10-21
|
||||
|
||||
### Sharper resampling in HDR and panorama merges
|
||||
- Merged photos (HDR and panorama) are resampled with a sharper filter (Lanczos-3 instead of bilinear). Moving a
|
||||
photo by half a pixel used to lose a third of its finest detail; now it keeps most of it, without halos around
|
||||
hard edges such as clipped highlights.
|
||||
|
||||
## 2026-10-19
|
||||
|
||||
### Hidden Local locations come back one at a time
|
||||
|
||||
Reference in New Issue
Block a user