Files
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

240 lines
10 KiB
Rust
Raw Permalink 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.
//! Local alignment: what one motion model for the whole frame leaves.
//!
//! A homography is exact only for a camera that turned about its lens. Handheld, the camera also
//! moves, and things at different distances shift differently (parallax): on a real bracket (Sony,
//! 33 MP, gravel a few metres away and a building a kilometre off) the fit, dominated by the
//! foreground's texture, left the building 2.3 px off, and the merge doubled its inscription. Lens
//! distortion and rolling shutter leave local shifts too.
//!
//! After the global fit ([`crate::align`], [`crate::refine`]) the frame is compared with the
//! reference tile by tile:
//!
//! 1. **Measure.** On a grid of about [`GRID`] tiles along the long edge, the reference's patch at
//! the tile's centre is found in the frame, through the global fit, within [`RADIUS`] pixels
//! ([`crate::refine::match_patch`]: masked correlation, sub-pixel peak, ambiguous and textureless
//! patches dropped).
//! 2. **Clean.** A shift more than [`OUTLIER`] px from the median of its measured neighbours is
//! dropped (a person walking, a mismatch), as is one with no measured neighbour to check it by. Tiles without a measurement take the mean of their
//! measured neighbours, spreading outwards until every tile has one.
//! 3. **Smooth** with two 3 × 3 box passes, so the field bends gently.
//! 4. **Warp.** Each output pixel `p` samples the frame at `h(p + flow(p))` (the flow interpolated
//! bilinearly between tile centres): one Lanczos-3 resampling for both corrections.
use lightcraft_geom::{Homography, Point};
use lightcraft_raster::{Plane, Rgb32f};
use rayon::prelude::*;
use crate::refine::{self, PATCH};
/// Tiles along the long edge.
pub const GRID: usize = 96;
/// Largest local shift measured (pixels); farther ones are filled from neighbours.
pub const RADIUS: usize = 5;
/// A tile this far (pixels) from the median of its neighbours is an outlier.
pub const OUTLIER: f32 = 0.75;
/// A smooth field of local shifts in reference pixels: one value per tile, tile `(i, j)`'s at
/// `((i + ½)·step, (j + ½)·step)`, so pixel centres of `dx` / `dy` line up with the tiles'.
#[derive(Clone, Debug, PartialEq)]
pub struct Flow {
pub step: usize,
pub dx: Plane,
pub dy: Plane,
/// Tiles that were measured (before filling).
pub measured: usize,
}
impl Default for Flow {
/// No shift anywhere.
fn default() -> Self {
Flow { step: 0, dx: Plane::new(0, 0), dy: Plane::new(0, 0), measured: 0 }
}
}
impl Flow {
/// The shift at reference coordinates (pixel centres at +0.5), bilinear between tile centres;
/// zero for an empty flow.
pub fn at(&self, x: f64, y: f64) -> (f64, f64) {
let (fx, fy) = ((x / self.step as f64) as f32, (y / self.step as f64) as f32);
if self.dx.data.is_empty() || !fx.is_finite() || !fy.is_finite() {
return (0.0, 0.0);
}
(crate::features::bilinear(&self.dx, fx, fy) as f64, crate::features::bilinear(&self.dy, fx, fy) as f64)
}
/// The largest shift in the field (pixels).
pub fn max(&self) -> f32 {
self.dx.data.iter().zip(&self.dy.data).map(|(x, y)| x.hypot(*y)).fold(0.0, f32::max)
}
}
/// The local shifts between `reference` and `frame` (both from [`refine::luma`]) once `h` (reference
/// → frame pixels) is applied, within `radius` pixels.
pub fn estimate(reference: &Plane, frame: &Plane, h: &Homography, radius: usize) -> Flow {
let (w, ht) = (reference.width, reference.height);
// an even step, so a patch centred on a tile has a whole-pixel corner
let step = ((w.max(ht) / GRID).max(PATCH) + 1) & !1;
let (gw, gh) = (w / step, ht / step);
if gw == 0 || gh == 0 || frame.data.is_empty() {
return Flow::default();
}
let r = radius.max(1);
let raw: Vec<Option<[f32; 2]>> = (0..gw * gh)
.into_par_iter()
.map(|t| {
let (i, j) = (t % gw, t / gw);
let (sx, sy) = (i * step + (step - PATCH) / 2, j * step + (step - PATCH) / 2);
if sx < r + 1 || sy < r + 1 || sx + PATCH + r + 1 > w || sy + PATCH + r + 1 > ht {
return None;
}
refine::match_patch(reference, (sx, sy), frame, h, r).map(|m| [m.offset.x as f32, m.offset.y as f32])
})
.collect();
let measured = raw.iter().filter(|v| v.is_some()).count();
let at = |d: &[Option<[f32; 2]>], k: usize| d.get(k).copied().flatten();
// 2. outliers against the median of their measured neighbours
let mut d: Vec<Option<[f32; 2]>> = (0..gw * gh)
.map(|t| {
let v = at(&raw, t)?;
let n = neighbours(|k| at(&raw, k), gw, gh, t);
let med = [median(n.iter().map(|v| v[0]).collect()), median(n.iter().map(|v| v[1]).collect())];
// a tile with no measured neighbour can't be checked, and the fill would spread it: dropped
(!n.is_empty() && (v[0] - med[0]).hypot(v[1] - med[1]) <= OUTLIER).then_some(v)
})
.collect();
// fill outwards from measured tiles
for _ in 0..gw + gh {
if d.iter().all(Option::is_some) || d.iter().all(Option::is_none) {
break;
}
let prev = d.clone();
for (t, slot) in d.iter_mut().enumerate().filter(|(_, s)| s.is_none()) {
*slot = mean(&neighbours(|k| at(&prev, k), gw, gh, t));
}
}
let mut f: Vec<[f32; 2]> = d.into_iter().map(|v| v.unwrap_or([0.0; 2])).collect();
// 3. smooth (each tile with its neighbours)
for _ in 0..2 {
let prev = f.clone();
for (t, v) in f.iter_mut().enumerate() {
let mut n = neighbours(|k| prev.get(k).copied(), gw, gh, t);
n.push(*v);
*v = mean(&n).unwrap_or(*v);
}
}
let plane = |c: usize| Plane::from_fn(gw, gh, |i, j| f.get(j * gw + i).map_or(0.0, |v| v[c]));
Flow { step, dx: plane(0), dy: plane(1), measured }
}
/// The values `get` has among the 8 neighbours of tile `t`.
fn neighbours(get: impl Fn(usize) -> Option<[f32; 2]>, gw: usize, gh: usize, t: usize) -> Vec<[f32; 2]> {
let (i, j) = (t % gw, t / gw);
let mut out = Vec::with_capacity(8);
for b in j.saturating_sub(1)..=(j + 1).min(gh - 1) {
for a in i.saturating_sub(1)..=(i + 1).min(gw - 1) {
if (a, b) != (i, j)
&& let Some(v) = get(b * gw + a)
{
out.push(v);
}
}
}
out
}
fn mean(n: &[[f32; 2]]) -> Option<[f32; 2]> {
let k = n.len() as f32;
(!n.is_empty()).then(|| [n.iter().map(|v| v[0]).sum::<f32>() / k, n.iter().map(|v| v[1]).sum::<f32>() / k])
}
fn median(mut v: Vec<f32>) -> f32 {
v.sort_by(|a, b| a.total_cmp(b));
v.get(v.len() / 2).copied().unwrap_or(0.0)
}
/// `img` resampled into the reference's geometry through `h` after the local shifts:
/// `out(p) = img(h(p + flow(p)))` (Lanczos-3), and where that falls inside `img`.
pub fn warp(img: &Rgb32f, h: &Homography, flow: &Flow) -> (Rgb32f, Vec<bool>) {
let (w, ht) = (img.width, img.height);
let mut out = Rgb32f::new(w, ht);
let mut valid = vec![false; w * ht];
if w == 0 || ht == 0 {
return (out, valid);
}
out.data.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (row, vrow))| {
for x in 0..w {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let (dx, dy) = flow.at(px, py);
let q = h.apply(Point::new(px + dx, py + dy));
let (sx, sy) = (q.x as f32, q.y as f32);
if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 {
row[x] = img.sample_lanczos3(sx, sy);
vrow[x] = true;
}
}
});
(out, valid)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::refine::tests::tex;
/// Parallax: the top of the frame (far) shifted 2.2 px against the bottom (near), with a
/// smooth band between. The flow finds both, and the warp lines the frame up everywhere.
#[test]
fn finds_and_undoes_a_local_shift() {
let (w, h) = (1200usize, 800usize);
let shift = |y: f64| {
let t = ((y - 300.0) / 200.0).clamp(0.0, 1.0);
let s = t * t * (3.0 - 2.0 * t);
((1.0 - s) * 2.2, (1.0 - s) * -1.1)
};
let reference = Plane::from_fn(w, h, |x, y| tex(x as f64 + 0.5, y as f64 + 0.5));
// the frame's pixel q shows the scene at q + shift(q.y)
let frame = Rgb32f::from_fn(w, h, |x, y| {
let (sx, sy) = shift(y as f64 + 0.5);
[tex(x as f64 + 0.5 + sx, y as f64 + 0.5 + sy); 3]
});
let fl = refine::luma(&frame, 1.0, 10.0);
let flow = estimate(&reference, &fl, &Homography::IDENTITY, RADIUS);
let tiles = flow.dx.width * flow.dx.height;
assert!(flow.measured > tiles / 2, "{} of {tiles}", flow.measured);
// reference p matches frame p + d with d = −shift
let (tx, ty) = flow.at(600.0, 100.0);
assert!((tx + 2.2).abs() < 0.15 && (ty - 1.1).abs() < 0.15, "top {tx} {ty}");
let (bx, by) = flow.at(600.0, 700.0);
assert!(bx.abs() < 0.15 && by.abs() < 0.15, "bottom {bx} {by}");
let (out, valid) = warp(&frame, &Homography::IDENTITY, &flow);
let error = |img: &Rgb32f| {
let (mut e, mut n) = (0f64, 0usize);
for y in (40..h - 40).step_by(3) {
for x in (40..w - 40).step_by(3) {
if valid[y * w + x] {
e += (img.get(x, y)[0] - reference.get(x, y)).abs() as f64;
n += 1;
}
}
}
e / n as f64
};
let (before, after) = (error(&frame), error(&out));
assert!(after < before / 6.0, "mean error {before} unwarped, {after} warped");
}
#[test]
fn empty_and_flat_inputs_give_no_shift() {
let flat = Plane::from_fn(400, 300, |_, _| 0.5);
let f = estimate(&flat, &flat, &Homography::IDENTITY, RADIUS);
assert_eq!(f.measured, 0);
assert_eq!(f.max(), 0.0);
assert_eq!(estimate(&Plane::new(0, 0), &Plane::new(0, 0), &Homography::IDENTITY, 3), Flow::default());
assert_eq!(estimate(&flat, &Plane::new(0, 0), &Homography::IDENTITY, 3), Flow::default());
assert_eq!(Flow::default().at(5.0, f64::NAN), (0.0, 0.0));
assert_eq!(f.at(f64::INFINITY, 3.0), (0.0, 0.0));
let (img, v) = warp(&Rgb32f::new(0, 0), &Homography::IDENTITY, &Flow::default());
assert!(img.data.is_empty() && v.is_empty());
}
}