LR-MERGE-HDR: merge warps sample Lanczos-3 instead of bilinear

Split out of #1887 (part 1 of 3). Every warp in the merge crate (HDR alignment,
panorama projection, Boundary Warp) sampled bilinearly: at a half-pixel offset
that is a [½ ½] filter, which keeps cos²(πf) of a pattern's fine detail (a third
at 0.3 cycles per pixel).

- lightcraft_raster::sample: Rgb32f::sample_lanczos3, a 6 × 6 Lanczos-3 point
  sampler with tabulated taps, each sample held to the range of the 4 × 4 pixels
  around it so hard edges (clipped highlights, stars) don't ring.
- align::warp, the panorama projection and Boundary Warp use it.
- align.rs: the merge luminance (`luma`) and the simplest-model choice among
  RANSAC fits (`choose_model`) are shared functions, for the full-size
  refinement that builds on them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Matthew James Sivak
2026-10-21 18:52:24 +02:00
co-authored by Claude Opus 5.5
parent abb49bdd76
commit d87a8714ad
9 changed files with 218 additions and 27 deletions
+43 -19
View File
@@ -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;
}
}
+1 -1
View File
@@ -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);
}
});
+1 -1
View File
@@ -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;
}
});
+3 -1
View File
@@ -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};
+2 -1
View File
@@ -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 => {
+154
View File
@@ -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
View File
@@ -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
+4 -3
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **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; LRC-PRINT-PAGE: Print Page Options) · **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…` |
@@ -891,3 +891,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-13 | minor | LRC-LIB-CATALOG-IMPORT review: offline Local promotion and result dismissal across library switches covered; redundant operation/settings copies removed. Status and summary counts unchanged. |
| 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-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 |
+7
View File
@@ -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-14
### Model downloads time out on a name that doesn't resolve