Files
lightcraft/crates/merge/src/refine.rs
T
Matthew James SivakandClaude Opus 5.5 2434525b53 LR-MERGE-HDR: full-size alignment refinement and local alignment (refine.rs, flow.rs)
Split out of #1887 (part 2 of 3, stacked on part 1). The modules land on their
own; the HDR merge starts using them in part 3.

- refine.rs: an alignment found on <= 1200 px versions refined at full size.
  One 48 px patch at the most textured spot (smaller structure-tensor
  eigenvalue) of each of ~24 cells along the long edge, matched by masked
  normalised cross-correlation over the pixels usable in both frames with a
  sub-pixel peak (ambiguous and textureless patches dropped), fitted by RANSAC
  at 0.75 px with the simplest of translation / similarity / homography. Also
  recovers a frame with no alignment, searching +-16 px.
- flow.rs: what one homography leaves (parallax, distortion), measured on ~96
  tiles along the long edge within +-5 px, outliers against their neighbours'
  median dropped, gaps filled, the field smoothed; flow::warp resamples once
  through the homography plus the local shift (Lanczos-3). align::warp is
  flow::warp with no shift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:11:14 +02:00

360 lines
16 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.
//! Full-resolution refinement of an alignment found on small versions of the frames.
//!
//! [`crate::align`] registers frames on versions of at most [`crate::align::WORK_EDGE`] pixels.
//! For a 24–60 MP photo that is 5–8× smaller, and a tenth of a work pixel off is most of a full
//! pixel off: merged frames that far apart blur the result. Here the motion is fitted again at
//! full size:
//!
//! 1. **Sites.** The reference is cut into a grid of cells (≈ [`CELLS`] along the long edge); in
//! each, the [`PATCH`]-pixel square with the most two-dimensional texture (the smaller
//! eigenvalue of its gradient structure tensor, so a straight edge, which only fixes one
//! direction, doesn't count) is a site.
//! 2. **Matching.** Each site's patch is compared with the frame resampled through the current
//! alignment, by normalised cross-correlation (immune to the frames' different brightness and
//! contrast) over integer offsets within a search radius, counting only pixels usable in both
//! (clipped ones aren't); the peak is refined to a fraction of a pixel by a parabola through its
//! neighbours on each axis. Patches less than half usable, with too little texture, a weak peak, a peak on the search border or a second peak almost
//! as strong (repeating texture) are dropped.
//! 3. **Fit.** The matches are fitted with RANSAC at [`THRESH`] pixels, choosing the simplest of
//! translation / similarity / homography that explains (nearly) as many as the most general
//! one, as [`crate::align::align_pair`] does. A second, narrower pass starts from that fit.
//!
//! When too few patches match, or the fit isn't a plausible handheld motion, the alignment found
//! on the small versions stays: refining never makes an alignment worse than not refining.
use lightcraft_geom::{Homography, Point};
use lightcraft_raster::{Plane, Rgb32f};
use rayon::prelude::*;
use crate::align::{Alignment, Models, choose_model, luma as lum};
use crate::ransac::Model;
/// Side of a matched patch (pixels).
pub const PATCH: usize = 48;
/// Cells along the long edge (sites are picked one per cell).
pub const CELLS: usize = 24;
/// RANSAC inlier threshold at full resolution (pixels).
pub const THRESH: f64 = 0.75;
/// Weakest correlation peak a match may have.
const MIN_SCORE: f32 = 0.8;
/// The best peak away from the best one must be at least this much weaker.
const UNIQUE: f32 = 0.03;
/// Least standard deviation (of the gamma-encoded luminance) a patch needs to be matched.
const MIN_STD: f32 = 0.01;
/// Fewest matches a fit is made from.
const MIN_MATCHES: usize = 12;
/// Share of the matches the fit must explain.
const MIN_SHARE: f64 = 0.4;
/// A frame's luminance for matching: `(luminance · gain)^(1/2.2)` at full size, NaN where the
/// frame is clipped (any channel at or above 95 % of `clip`) or `gain` takes it past 1, so such
/// pixels never take part. `gain` brings frames of a bracket to the same brightness.
pub fn luma(img: &Rgb32f, gain: f32, clip: f32) -> Plane {
let g = if gain.is_finite() && gain > 0.0 { gain } else { 1.0 };
img.map(|c| {
if c[0].max(c[1]).max(c[2]) >= 0.95 * clip {
return f32::NAN;
}
let y = lum(c) * g;
if y.is_finite() && y < 1.0 { y.max(0.0).powf(1.0 / 2.2) } else { f32::NAN }
})
}
/// The search radius (full-resolution pixels) for an alignment found at `scale` (work pixels per
/// full pixel): about two work pixels.
pub fn radius_for(scale: f64) -> usize {
if scale.is_finite() && scale > 0.0 { ((2.0 / scale).ceil() as usize + 1).clamp(3, 16) } else { 3 }
}
/// A patch of the reference found in a frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Match {
/// Centre of the patch in the reference (pixel centres at +0.5).
pub reference: Point,
/// Where it is in the frame.
pub frame: Point,
/// The offset from where the alignment put it, in reference pixels.
pub offset: Point,
/// Correlation at the peak (−1..1).
pub score: f32,
}
/// Top-left corners of the patches to match: one per cell, where the reference has the most
/// two-dimensional texture, `margin` pixels clear of the edges.
pub fn sites(reference: &Plane, margin: usize) -> Vec<(usize, usize)> {
let (w, h) = (reference.width, reference.height);
let need = PATCH + 2 * margin;
if w < need || h < need {
return Vec::new();
}
let long = w.max(h);
let cell = long.div_ceil(CELLS).max(PATCH);
let (nx, ny) = ((w - 2 * margin) / cell, (h - 2 * margin) / cell);
let (nx, ny) = (nx.max(1), ny.max(1));
let (cw, chh) = ((w - 2 * margin) / nx, (h - 2 * margin) / ny);
(0..nx * ny)
.into_par_iter()
.filter_map(|c| {
let (cx, cy) = (margin + (c % nx) * cw, margin + (c / nx) * chh);
let span_x = cw.saturating_sub(PATCH);
let span_y = chh.saturating_sub(PATCH);
let mut best: Option<((usize, usize), f32)> = None;
for j in 0..3 {
for i in 0..3 {
let (x, y) = (cx + span_x * i / 2, cy + span_y * j / 2);
if x + PATCH > w - margin || y + PATCH > h - margin {
continue;
}
if let Some(t) = texture(reference, x, y)
&& best.is_none_or(|(_, b)| t > b)
{
best = Some(((x, y), t));
}
}
}
best.filter(|(_, t)| *t > 1e-6).map(|(p, _)| p)
})
.collect()
}
/// The smaller eigenvalue of the patch's mean gradient structure tensor, over its usable pixels;
/// `None` when fewer than half of them are.
fn texture(p: &Plane, x0: usize, y0: usize) -> Option<f32> {
let w = p.width;
let at = |x: usize, y: usize| p.data.get(y * w + x).copied().unwrap_or(f32::NAN);
let (mut sxx, mut syy, mut sxy, mut n) = (0f64, 0f64, 0f64, 0usize);
for y in y0 + 1..y0 + PATCH - 1 {
for x in x0 + 1..x0 + PATCH - 1 {
let gx = (at(x + 1, y) - at(x - 1, y)) as f64;
let gy = (at(x, y + 1) - at(x, y - 1)) as f64;
if gx.is_finite() && gy.is_finite() {
sxx += gx * gx;
syy += gy * gy;
sxy += gx * gy;
n += 1;
}
}
}
if n * 2 < (PATCH - 2) * (PATCH - 2) {
return None;
}
let n = n as f64;
let (a, b, c) = (sxx / n, syy / n, sxy / n);
let tr = a + b;
let det = a * b - c * c;
Some((tr / 2.0 - ((tr * tr / 4.0 - det).max(0.0)).sqrt()) as f32)
}
/// Find the reference patch at `site` (top-left) in `frame`, which `h` maps reference pixels into,
/// searching offsets up to `radius` pixels.
pub fn match_patch(reference: &Plane, site: (usize, usize), frame: &Plane, h: &Homography, radius: usize) -> Option<Match> {
let (sx, sy) = site;
let (rw, rh) = (reference.width, reference.height);
if sx + PATCH > rw || sy + PATCH > rh || frame.width == 0 || frame.height == 0 {
return None;
}
let r = radius.max(1);
let s = PATCH + 2 * r;
let mut pr: Vec<f32> = Vec::with_capacity(PATCH * PATCH);
for y in sy..sy + PATCH {
for x in sx..sx + PATCH {
pr.push(reference.data.get(y * rw + x).copied().unwrap_or(f32::NAN));
}
}
// the frame around the patch, through `h`; NaN where it can't be used
let (fw, fh) = (frame.width as f64, frame.height as f64);
let mut win = vec![f32::NAN; s * s];
for j in 0..s {
for i in 0..s {
let q = h.apply(Point::new((sx + i) as f64 - r as f64 + 0.5, (sy + j) as f64 - r as f64 + 0.5));
if !(q.x >= 0.5 && q.y >= 0.5 && q.x <= fw - 0.5 && q.y <= fh - 0.5) {
return None;
}
win[j * s + i] = crate::features::bilinear(frame, q.x as f32, q.y as f32);
}
}
let d = 2 * r + 1;
// offsets that can't be scored (too few usable pixels) stay at −∞
let mut score = vec![f32::NEG_INFINITY; d * d];
for dy in 0..d {
for dx in 0..d {
if let Some(c) = correlation(&pr, |u, v| win.get((v + dy) * s + dx + u).copied().unwrap_or(f32::NAN)) {
score[dy * d + dx] = c;
}
}
}
let (bi, &best) = score.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1))?;
let (bx, by) = (bi % d, bi / d);
if best < MIN_SCORE || bx == 0 || by == 0 || bx == d - 1 || by == d - 1 {
return None;
}
// a second peak nearly as strong: repeating texture, the match is ambiguous
let second =
score.iter().enumerate().filter(|(k, _)| (k % d).abs_diff(bx) > 1 || (k / d).abs_diff(by) > 1).map(|(_, v)| *v).fold(-1f32, f32::max);
if second > best - UNIQUE {
return None;
}
let at = |x: usize, y: usize| score[y * d + x];
// the sub-pixel parabola needs the peak's four neighbours: one that couldn't be scored would pull it away
if [at(bx - 1, by), at(bx + 1, by), at(bx, by - 1), at(bx, by + 1)].iter().any(|v| !v.is_finite()) {
return None;
}
let sub = |m: f32, c: f32, p: f32| {
let den = m - 2.0 * c + p;
if den < -1e-9 { (0.5 * (m - p) / den).clamp(-0.5, 0.5) } else { 0.0 }
};
let ox = bx as f64 - r as f64 + sub(at(bx - 1, by), best, at(bx + 1, by)) as f64;
let oy = by as f64 - r as f64 + sub(at(bx, by - 1), best, at(bx, by + 1)) as f64;
let c = Point::new(sx as f64 + PATCH as f64 / 2.0, sy as f64 + PATCH as f64 / 2.0);
Some(Match { reference: c, frame: h.apply(Point::new(c.x + ox, c.y + oy)), offset: Point::new(ox, oy), score: best })
}
/// Correlation of the reference patch `a` (row-major, [`PATCH`] wide) with `b(u, v)` over the
/// pixels usable in both; `None` with fewer than half of the patch usable, or too little texture in
/// either.
fn correlation(a: &[f32], b: impl Fn(usize, usize) -> f32) -> Option<f32> {
let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0usize, 0f64, 0f64, 0f64, 0f64, 0f64);
for v in 0..PATCH {
for u in 0..PATCH {
let (x, y) = (a.get(v * PATCH + u).copied().unwrap_or(f32::NAN), b(u, v));
if x.is_finite() && y.is_finite() {
let (x, y) = (x as f64, y as f64);
n += 1;
sa += x;
sb += y;
saa += x * x;
sbb += y * y;
sab += x * y;
}
}
}
if n * 2 < PATCH * PATCH {
return None;
}
let nf = n as f64;
let (ma, mb) = (sa / nf, sb / nf);
let (va, vb) = (saa / nf - ma * ma, sbb / nf - mb * mb);
let min = (MIN_STD as f64).powi(2);
if !(va >= min && vb >= min * 0.25) {
return None;
}
let c = (sab / nf - ma * mb) / (va * vb).sqrt();
c.is_finite().then_some(c as f32)
}
/// Translation, similarity and homography: what a handheld frame may need.
pub const ALL_MODELS: [(Model, &str, usize); 3] =
[(Model::Translation, "translation", 6), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 12)];
/// Search radius (pixels) for a frame with no alignment to start from (the small versions found
/// none: a bright frame mostly clipped, too little texture left for features).
pub const RECOVER_RADIUS: usize = 16;
/// Fit `models` (reference → frame, full resolution) to patches of `reference` matched in `frame`
/// around `start` within `radius` pixels, then again within 2 px of that fit; both planes from
/// [`luma`]. `start` is an alignment found on small versions (`radius` from [`radius_for`]), or the
/// identity with [`RECOVER_RADIUS`]. `None` when too few patches match or no fit is a plausible
/// handheld motion: the caller keeps what it had.
pub fn fit(reference: &Plane, frame: &Plane, start: &Homography, radius: usize, seed: u64, models: &Models) -> Option<Alignment> {
let (w, ht) = (reference.width as f64, reference.height as f64);
let r0 = radius.max(2);
// the sites for the widest search fit the narrower one too
let s = sites(reference, r0 + 2);
let mut cur: Option<Alignment> = None;
for r in [r0, 2] {
let h = cur.as_ref().map_or(*start, |a| a.h);
let m: Vec<Match> = s.par_iter().filter_map(|&p| match_patch(reference, p, frame, &h, r)).collect();
if m.len() < MIN_MATCHES {
break;
}
let pa: Vec<Point> = m.iter().map(|m| m.reference).collect();
let pb: Vec<Point> = m.iter().map(|m| m.frame).collect();
let Some((name, f)) = choose_model(models, &pa, &pb, THRESH, MIN_SHARE, (w, ht), seed) else { break };
cur = Some(Alignment { h: f.h, model: name, inliers: f.inliers.len(), rms: f.rms });
}
cur
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// A smooth random texture, sampled anywhere.
pub(crate) fn tex(x: f64, y: f64) -> f32 {
let mut v = 0.45;
for k in 0..9 {
let a = k as f64 * 2.399;
let f = 0.02 + 0.025 * k as f64;
v += 0.05 * ((x * a.cos() + y * a.sin()) * f * std::f64::consts::TAU + k as f64).sin();
}
v as f32
}
fn plane(w: usize, h: usize, map: &Homography) -> Plane {
Plane::from_fn(w, h, |x, y| {
let p = map.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
tex(p.x, p.y)
})
}
fn corner_error(a: &Homography, b: &Homography, w: f64, h: f64) -> f64 {
[(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| a.apply(Point::new(x, y)).dist(b.apply(Point::new(x, y)))).fold(0.0, f64::max)
}
/// A frame rotated 0.2° and moved 1.37 px, given an alignment 2 px off: refined to a tenth of
/// a pixel at the corners.
#[test]
fn refines_a_rough_alignment_to_a_fraction_of_a_pixel() {
let (w, h) = (900usize, 600usize);
let reference = plane(w, h, &Homography::IDENTITY);
// the frame's pixel q shows the scene at s(q); reference → frame is s⁻¹
let (c, sn) = (0.2f64.to_radians().cos(), 0.2f64.to_radians().sin());
let s = Homography([c, -sn, 1.37, sn, c, -0.83, 0.0, 0.0, 1.0]);
let truth = s.inverse().unwrap();
let frame = plane(w, h, &s);
let rough = Homography([truth.0[0], truth.0[1], truth.0[2] + 1.6, truth.0[3], truth.0[4], truth.0[5] - 1.2, 0.0, 0.0, 1.0]);
assert!(corner_error(&rough, &truth, w as f64, h as f64) > 1.5);
let r = fit(&reference, &frame, &rough, 4, 1, &ALL_MODELS).unwrap();
let e = corner_error(&r.h, &truth, w as f64, h as f64);
assert!(e < 0.1, "corner error {e} px, model {}", r.model);
assert!(r.inliers >= MIN_MATCHES);
}
/// A frame the small versions couldn't align, 5 px and 0.1° off: found from nothing.
#[test]
fn recovers_a_frame_with_no_alignment() {
let (w, h) = (900usize, 600usize);
let reference = plane(w, h, &Homography::IDENTITY);
let (c, sn) = (0.1f64.to_radians().cos(), 0.1f64.to_radians().sin());
let s = Homography([c, -sn, -4.6, sn, c, 2.1, 0.0, 0.0, 1.0]);
let r = fit(&reference, &plane(w, h, &s), &Homography::IDENTITY, RECOVER_RADIUS, 1, &ALL_MODELS).unwrap();
let e = corner_error(&r.h, &s.inverse().unwrap(), w as f64, h as f64);
assert!(e < 0.1, "corner error {e} px, model {}", r.model);
assert!(fit(&reference, &Plane::from_fn(w, h, |_, _| 0.5), &Homography::IDENTITY, RECOVER_RADIUS, 1, &ALL_MODELS).is_none());
}
/// Nothing to match (a flat frame, planes too small for a patch, empty ones): no fit.
#[test]
fn no_fit_when_nothing_matches() {
let h = Homography([1.0, 0.0, 0.7, 0.0, 1.0, 0.2, 0.0, 0.0, 1.0]);
let flat = Plane::from_fn(400, 300, |_, _| 0.5);
assert!(fit(&flat, &flat, &h, 4, 1, &ALL_MODELS).is_none());
let tiny = Plane::from_fn(20, 20, |x, _| x as f32);
assert!(fit(&tiny, &tiny, &h, 4, 1, &ALL_MODELS).is_none());
assert!(fit(&Plane::new(0, 0), &Plane::new(0, 0), &h, 4, 1, &ALL_MODELS).is_none());
}
#[test]
fn clipped_pixels_and_odd_gains_are_left_out() {
let img = Rgb32f::from_fn(4, 1, |x, _| [x as f32 * 0.33; 3]);
let l = luma(&img, 1.0, 1.0);
assert!(l.data[0] == 0.0 && l.data[3].is_nan(), "{:?}", l.data);
let l = luma(&img, 4.0, 1.0);
assert!(l.data[1].is_nan(), "0.33 × 4 is past 1: {:?}", l.data);
assert!(luma(&img, f32::NAN, 1.0).data[1].is_finite());
assert_eq!(radius_for(0.125), 16);
assert_eq!(radius_for(1.0), 3);
assert_eq!(radius_for(f64::NAN), 3);
}
}