cargo fmt and clippy across the panorama work, and one lint master carried

The dr-face comparison is master's: a negated partial-order test on the
eye box's width, rewritten as the two conditions it meant.
This commit is contained in:
2026-09-19 15:53:06 +02:00
parent 67f225beba
commit 42d11d919b
23 changed files with 330 additions and 123 deletions
+2 -1
View File
@@ -71,7 +71,7 @@ fn main() {
// The pixel at (1, 0) was written as (1000, 2000, 3000): if the samples
// come back interleaved in that order, cpp == 3 means what it says.
if let rawler::RawImageData::Integer(v) = &image.data {
let i = image.cpp * 1;
let i = image.cpp;
println!(" pixel (1,0) = {:?}", &v[i..i + image.cpp.min(3)]);
}
@@ -167,6 +167,7 @@ fn write_linear_dng(w: u32, h: u32) -> Vec<u8> {
entries.push((50707, 1, 4, [1, 4, 0, 0])); // DNGBackwardVersion
entries.push(ascii(&mut extra, extra_off, 50708, "DarkRoom Panorama")); // UniqueCameraModel
entries.push(long(50717, 65535)); // WhiteLevel
// ColorMatrix1: XYZ → camera, 9 SRATIONALs. A plausible sRGB-ish matrix
// (the inverse of the sRGB D65 primaries), scaled to integers.
{
+5 -1
View File
@@ -567,7 +567,11 @@ fn illuminant_code(temperature: f32) -> u16 {
];
TABLE
.iter()
.min_by(|a, b| (a.0 - temperature).abs().total_cmp(&(b.0 - temperature).abs()))
.min_by(|a, b| {
(a.0 - temperature)
.abs()
.total_cmp(&(b.0 - temperature).abs())
})
.map(|(_, code)| *code)
.unwrap_or(255)
}
+17 -4
View File
@@ -65,6 +65,10 @@ pub struct DngProfile {
/// rectangle the frames covered, found while the strips went by
/// (`Inscribed`) — becomes `DefaultCropOrigin`/`DefaultCropSize`
/// (FR-MRG-4): the file opens on the picture, and the border is still in it.
// Eight arguments, and each is a different thing: the sink, three
// dimensions, the profile, the header, the strip source and the crop. A
// struct for them would be a struct with one caller.
#[allow(clippy::too_many_arguments)]
pub fn write_linear_dng<W, F, C>(
out: W,
width: u32,
@@ -119,7 +123,10 @@ where
.map_err(enc)?;
image
.encoder()
.write_tag(Tag::Unknown(tag::DEFAULT_CROP_SIZE), &[r.width, r.height][..])
.write_tag(
Tag::Unknown(tag::DEFAULT_CROP_SIZE),
&[r.width, r.height][..],
)
.map_err(enc)?;
}
image.finish().map_err(enc)
@@ -136,8 +143,14 @@ where
dir.write_tag(Tag::Orientation, 1u16)?;
dir.write_tag(Tag::Unknown(tag::DNG_VERSION), &[1u8, 4, 0, 0][..])?;
dir.write_tag(Tag::Unknown(tag::DNG_BACKWARD_VERSION), &[1u8, 4, 0, 0][..])?;
dir.write_tag(Tag::Unknown(tag::UNIQUE_CAMERA_MODEL), Ascii(&profile.unique_model))?;
dir.write_tag(Tag::Unknown(tag::WHITE_LEVEL), &[profile.white_level; 3][..])?;
dir.write_tag(
Tag::Unknown(tag::UNIQUE_CAMERA_MODEL),
Ascii(&profile.unique_model),
)?;
dir.write_tag(
Tag::Unknown(tag::WHITE_LEVEL),
&[profile.white_level; 3][..],
)?;
dir.write_tag(Tag::Unknown(tag::BLACK_LEVEL), &[0u32; 3][..])?;
for (slot, (illuminant, matrix)) in profile.calibrations.iter().take(2).enumerate() {
@@ -281,7 +294,7 @@ mod tests {
let i = (12 * 20 + 19) * 3;
assert_eq!(data[i], (19 + 12 * 20) as u16);
// The profile came through as the camera's.
assert!(image.camera.color_matrix.len() >= 1);
assert!(!image.camera.color_matrix.is_empty());
assert_eq!(image.model, "Canon EOS 6D");
// The default crop is what the decoder reports as the picture.
let crop = image.crop_area.expect("a crop");
+1 -6
View File
@@ -126,12 +126,7 @@ mod tests {
fn ragged_edges_are_cut_off() {
// A cylinder's footprint: narrower at top and bottom.
let r = from_art(&[
"..####..",
".######.",
"########",
"########",
".######.",
"..####..",
"..####..", ".######.", "########", "########", ".######.", "..####..",
]);
// 6 wide × 4 tall = 24 beats 8 × 2 = 16 and 4 × 6 = 24 ties; the
// first found wins a tie, which is the wider one here.
+2 -2
View File
@@ -26,18 +26,18 @@ use dr_types::{ColourSpace, ExportFormat, ExportSettings};
mod dng;
mod encode;
mod inscribed;
mod error;
mod exif;
pub mod icc;
mod inscribed;
mod metadata;
mod name;
mod sharpen;
mod size;
pub use dng::{write_linear_dng, DngProfile};
pub use inscribed::{Inscribed, Rect};
pub use error::ExportError;
pub use inscribed::{Inscribed, Rect};
pub use metadata::SourceMetadata;
pub use name::{resolve_name, NameContext};
pub use size::target_size;
+1 -1
View File
@@ -465,7 +465,7 @@ pub fn eye_box(contour: &[(f32, f32)]) -> Option<(f32, f32, f32, f32)> {
y1 = y1.max(y);
}
let w = x1 - x0;
if contour.is_empty() || !(w > 0.0) {
if contour.is_empty() || w <= 0.0 || w.is_nan() {
return None;
}
let h = (y1 - y0).max(w * EYE_BOX_MIN_ASPECT);
+1 -1
View File
@@ -38,10 +38,10 @@ pub use adjust::AdjustPass;
// unclipped, full internal precision (FR-DEV-2), and anyone reasoning about
// VRAM at 24 MP needs to know what an intermediate costs.
pub use demosaic::{DemosaicedImage, Demosaicer};
pub use merge::{Band, MergeFrame, MergeOutput, MergePass};
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
pub use error::GpuError;
pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity};
pub use merge::{Band, MergeFrame, MergeOutput, MergePass};
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
// at all at a crate root shared with demosaic and segmentation.
pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS};
+16 -8
View File
@@ -179,12 +179,12 @@ impl MergePass {
storage(2, false),
],
});
let resolve_layout = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("merge-resolve-bgl"),
entries: &[uniform(0), storage(1, true), storage(2, false)],
});
let resolve_layout =
ctx.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("merge-resolve-bgl"),
entries: &[uniform(0), storage(1, true), storage(2, false)],
});
let pipeline = |name: &str, layout: &wgpu::BindGroupLayout| {
let pl = ctx
.device
@@ -418,7 +418,12 @@ impl MergePass {
self.ctx.queue.submit(Some(enc.finish()));
}
fn resolve_chunk(&mut self, chunk: (u32, u32), scale: f32, out: &mut Vec<u32>) -> Result<(), GpuError> {
fn resolve_chunk(
&mut self,
chunk: (u32, u32),
scale: f32,
out: &mut Vec<u32>,
) -> Result<(), GpuError> {
let params = ResolveParams {
chunk_size: [chunk.0, chunk.1],
scale,
@@ -522,7 +527,10 @@ fn source_rect(
// The interior too, coarsely: a chunk can contain a frame entirely.
for i in 1..4 {
for j in 1..4 {
visit(origin.0 + w * f64::from(i) / 4.0, origin.1 + h * f64::from(j) / 4.0);
visit(
origin.0 + w * f64::from(i) / 4.0,
origin.1 + h * f64::from(j) / 4.0,
);
}
}
if !any {
+31 -13
View File
@@ -32,11 +32,12 @@ fn main() {
eprintln!("usage: align [out-prefix] <frame>...");
std::process::exit(2);
}
let prefix = if args[0].ends_with(".CR2") || args[0].ends_with(".dng") || args[0].ends_with(".jpg") {
"align".to_string()
} else {
args.remove(0)
};
let prefix =
if args[0].ends_with(".CR2") || args[0].ends_with(".dng") || args[0].ends_with(".jpg") {
"align".to_string()
} else {
args.remove(0)
};
let paths: Vec<PathBuf> = args.iter().map(PathBuf::from).collect();
// Previews, oriented, at proxy size.
@@ -46,17 +47,25 @@ fn main() {
let bytes = std::fs::read(p).expect("read");
let preview = dr_decode::extract_preview(&bytes, dr_decode::PreviewSize::Full)
.expect("embedded preview");
let orientation = dr_decode::orientation(&bytes[..bytes.len().min(dr_decode::HEADER_BYTES as usize)])
.unwrap_or(dr_types::Orientation::NORMAL);
let orientation =
dr_decode::orientation(&bytes[..bytes.len().min(dr_decode::HEADER_BYTES as usize)])
.unwrap_or(dr_types::Orientation::NORMAL);
let tag = match orientation.quarter_turns {
1 => 6,
2 => 3,
3 => 8,
_ => 1,
};
let gray = Gray::from_rgba8(&preview.rgba, preview.width as usize, preview.height as usize)
.oriented(tag);
let (fitted, _) = gray.fitted(dr_pano::xfeat::INPUT_LONG_EDGE, dr_pano::xfeat::INPUT_LONG_EDGE);
let gray = Gray::from_rgba8(
&preview.rgba,
preview.width as usize,
preview.height as usize,
)
.oriented(tag);
let (fitted, _) = gray.fitted(
dr_pano::xfeat::INPUT_LONG_EDGE,
dr_pano::xfeat::INPUT_LONG_EDGE,
);
println!(
"{:<14} preview {}×{} orientation {} → proxy {}×{}",
p.file_name().unwrap().to_string_lossy(),
@@ -80,7 +89,11 @@ fn main() {
for (i, f) in features.iter().enumerate() {
println!("frame {i}: {} keypoints", f.len());
}
println!("detection in {:?} ({:?} per frame)", t.elapsed(), t.elapsed() / proxies.len() as u32);
println!(
"detection in {:?} ({:?} per frame)",
t.elapsed(),
t.elapsed() / proxies.len() as u32
);
// Alignment.
let t = Instant::now();
@@ -95,7 +108,10 @@ fn main() {
alignment.rms_px
);
for l in &alignment.links {
println!(" link {}–{}: {} inliers of {} matches", l.i, l.j, l.inliers, l.matches);
println!(
" link {}–{}: {} inliers of {} matches",
l.i, l.j, l.inliers, l.matches
);
}
for (k, why) in &alignment.unaligned {
println!(" UNALIGNED frame {k}: {why}");
@@ -143,7 +159,9 @@ fn main() {
let v = bounds.min_v + (oy as f64 + 0.5) * px;
let d = Projection::Cylindrical.to_direction(scale, u, v);
for (k, g) in proxies.iter().enumerate() {
let Some((x, y)) = cameras.project(k, d) else { continue };
let Some((x, y)) = cameras.project(k, d) else {
continue;
};
let (x, y) = (x + g.width as f64 / 2.0, y + g.height as f64 / 2.0);
if x < 0.0 || y < 0.0 || x >= g.width as f64 - 1.0 || y >= g.height as f64 - 1.0 {
continue;
+39 -16
View File
@@ -115,7 +115,11 @@ impl Alignment {
/// when [`Self::is_complete`].
pub fn cameras(&self) -> Cameras {
Cameras {
rotations: self.rotations.iter().map(|r| r.unwrap_or(Mat3::IDENTITY)).collect(),
rotations: self
.rotations
.iter()
.map(|r| r.unwrap_or(Mat3::IDENTITY))
.collect(),
focal: self.focal,
}
}
@@ -130,7 +134,9 @@ impl Alignment {
pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, PanoError> {
let n = frames.len();
if n < 2 {
return Err(PanoError::Input("a panorama needs at least two frames".into()));
return Err(PanoError::Input(
"a panorama needs at least two frames".into(),
));
}
let centre = |k: usize, i: usize| -> (f64, f64) {
@@ -177,10 +183,7 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
continue;
};
let needed = (8.0 + 0.3 * matches.len() as f64).ceil() as usize;
log::debug!(
"pair {i}-{j}: {} inliers, {needed} needed",
inliers.len()
);
log::debug!("pair {i}-{j}: {} inliers, {needed} needed", inliers.len());
if inliers.len() <= needed || inliers.len() < opts.min_inliers {
continue;
}
@@ -218,7 +221,10 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
.filter_map(|l| homography::focal_from_homography(&l.h))
.filter(|f| f.is_finite() && *f > 0.0)
.collect();
let longest = frames.iter().map(|f| f.width.max(f.height) as f64).fold(0.0, f64::max);
let longest = frames
.iter()
.map(|f| f.width.max(f.height) as f64)
.fold(0.0, f64::max);
let focal = if !estimates.is_empty() {
estimates.sort_by(f64::total_cmp);
let median = estimates[estimates.len() / 2];
@@ -236,10 +242,10 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
let mut rotations: Vec<Option<Mat3>> = vec![None; n];
let mut unaligned = Vec::new();
if links.is_empty() {
for k in 0..n {
for (k, &matched) in matched_any.iter().enumerate() {
unaligned.push((
k,
if matched_any[k] {
if matched {
Unaligned::NoOverlap
} else {
Unaligned::NoMatches
@@ -341,7 +347,13 @@ mod tests {
/// Frames of a synthetic sweep: world directions with random unit
/// descriptors, each frame seeing the ones in its field of view.
fn synthetic_sweep(n: usize, step: f64, f: f64, w: usize, h: usize) -> (Vec<Features>, Cameras) {
fn synthetic_sweep(
n: usize,
step: f64,
f: f64,
w: usize,
h: usize,
) -> (Vec<Features>, Cameras) {
let mut seed = 777u64;
let mut rnd = || {
seed = seed
@@ -355,7 +367,10 @@ mod tests {
* Mat3::rotation(Vec3::new(1.0, 0.0, 0.0), 0.02 * ((k % 3) as f64 - 1.0))
})
.collect();
let truth = Cameras { rotations, focal: f };
let truth = Cameras {
rotations,
focal: f,
};
let total = step * (n as f64 - 1.0);
let mut frames: Vec<Features> = (0..n)
.map(|_| Features {
@@ -368,20 +383,24 @@ mod tests {
for _ in 0..600 * n {
let yaw = rnd() * (total + 0.8) + total / 2.0;
let pitch = rnd() * 0.5;
let d = Vec3::new(yaw.sin() * pitch.cos(), pitch.sin(), yaw.cos() * pitch.cos());
let d = Vec3::new(
yaw.sin() * pitch.cos(),
pitch.sin(),
yaw.cos() * pitch.cos(),
);
let desc: Vec<f32> = (0..DESCRIPTOR_LEN).map(|_| rnd() as f32).collect();
let norm = desc.iter().map(|v| v * v).sum::<f32>().sqrt();
let desc: Vec<f32> = desc.iter().map(|v| v / norm).collect();
for k in 0..n {
for (k, frame) in frames.iter_mut().enumerate() {
if let Some(p) = truth.project(k, d) {
let (x, y) = (p.0 + w as f64 / 2.0, p.1 + h as f64 / 2.0);
if x >= 0.0 && x < w as f64 && y >= 0.0 && y < h as f64 {
frames[k].keypoints.push(Keypoint {
frame.keypoints.push(Keypoint {
x: (x + rnd() * 0.6) as f32,
y: (y + rnd() * 0.6) as f32,
score: 1.0,
});
frames[k].descriptors.extend_from_slice(&desc);
frame.descriptors.extend_from_slice(&desc);
}
}
}
@@ -402,7 +421,11 @@ mod tests {
assert!((out.focal - 1400.0).abs() < 15.0, "focal {}", out.focal);
assert!(out.rms_px < 1.0, "rms {}", out.rms_px);
// Relative rotations match the truth's, whichever frame is the root.
let root = out.rotations.iter().position(|r| *r == Some(Mat3::IDENTITY)).unwrap();
let root = out
.rotations
.iter()
.position(|r| *r == Some(Mat3::IDENTITY))
.unwrap();
for k in 0..6 {
let rel_truth = truth.rotations[root].transpose() * truth.rotations[k];
let rel_out = out.rotations[k].unwrap();
+17 -5
View File
@@ -325,7 +325,10 @@ mod tests {
* Mat3::rotation(Vec3::new(0.0, 0.0, 1.0), roll);
rotations.push(r);
}
let truth = Cameras { rotations, focal: f };
let truth = Cameras {
rotations,
focal: f,
};
// World directions: a fan across the whole sweep.
let mut obs = Vec::new();
@@ -340,8 +343,12 @@ mod tests {
for _ in 0..400 * n {
let yaw = rnd() * (total + 0.8) + total / 2.0;
let pitch = rnd() * 0.5;
let d = Vec3::new(yaw.sin() * pitch.cos(), pitch.sin(), yaw.cos() * pitch.cos())
.normalised();
let d = Vec3::new(
yaw.sin() * pitch.cos(),
pitch.sin(),
yaw.cos() * pitch.cos(),
)
.normalised();
// Visible in which frames? Within ±0.35 f of centre.
let mut seen: Vec<(usize, Point)> = Vec::new();
for k in 0..n {
@@ -383,7 +390,11 @@ mod tests {
let before = rms(&start, &obs);
let out = adjust(start, &obs, &AdjustOptions::default()).expect("solvable");
assert!(out.rms_px < 1e-3, "rms {} (was {before})", out.rms_px);
assert!((out.cameras.focal - 1400.0).abs() < 0.5, "focal {}", out.cameras.focal);
assert!(
(out.cameras.focal - 1400.0).abs() < 0.5,
"focal {}",
out.cameras.focal
);
for k in 0..6 {
let err = angle_between(out.cameras.rotations[k], truth.rotations[k]);
assert!(err < 1e-5, "frame {k} off by {err} rad");
@@ -395,7 +406,8 @@ mod tests {
let (truth, obs) = sweep(8, 0.25, 1400.0, 1.0);
let mut start = truth.clone();
for k in 1..8 {
start.rotations[k] = Mat3::exp(Vec3::new(0.0, 0.004 * k as f64, 0.0)) * start.rotations[k];
start.rotations[k] =
Mat3::exp(Vec3::new(0.0, 0.004 * k as f64, 0.0)) * start.rotations[k];
}
let out = adjust(start, &obs, &AdjustOptions::default()).expect("solvable");
// ±0.5 px of uniform noise on every coordinate has an RMS of 0.41 px
+9 -7
View File
@@ -108,9 +108,7 @@ pub fn ransac_homography(
let Some(h) = dlt(&sample.map(|k| pairs[k])) else {
continue;
};
let inliers: Vec<usize> = (0..n)
.filter(|&k| agrees(&h, pairs[k], thr2))
.collect();
let inliers: Vec<usize> = (0..n).filter(|&k| agrees(&h, pairs[k], thr2)).collect();
if best.as_ref().is_none_or(|(b, _)| inliers.len() > b.len()) {
// Adapt: enough iterations to have drawn one all-inlier sample
// with probability 0.99, given the ratio seen so far.
@@ -333,7 +331,11 @@ mod tests {
pairs.push((p, ((rng.below(1000) as f64 - 500.0) / 1000.0, 0.1)));
}
let robust = ransac_homography(&pairs, 0.003, 500, 3).expect("found");
assert!(robust.inliers.len() >= 55, "{} inliers", robust.inliers.len());
assert!(
robust.inliers.len() >= 55,
"{} inliers",
robust.inliers.len()
);
assert!(robust.inliers.iter().all(|&k| k < 60));
}
@@ -351,9 +353,9 @@ mod tests {
let est = focal_from_homography(&h).expect("estimable");
assert!((est - f).abs() / f < 1e-6, "{est}");
let back = rotation_from_homography(&h, f);
for i in 0..3 {
for j in 0..3 {
assert!((back.0[i][j] - m[i][j]).abs() < 1e-9);
for (row, truth) in back.0.iter().zip(&m) {
for (a, b) in row.iter().zip(truth) {
assert!((a - b).abs() < 1e-9);
}
}
}
+4 -1
View File
@@ -22,7 +22,10 @@ impl Gray {
/// frames because they were converted differently.
pub fn from_rgba8(rgba: &[u8], width: usize, height: usize) -> Gray {
let n = width * height;
assert!(rgba.len() >= n * 4, "rgba buffer is short for {width}×{height}");
assert!(
rgba.len() >= n * 4,
"rgba buffer is short for {width}×{height}"
);
let data = rgba[..n * 4]
.chunks_exact(4)
.map(|p| {
+6 -1
View File
@@ -339,7 +339,12 @@ mod tests {
#[test]
fn cholesky_solves_a_small_spd_system() {
// A = Bᵀ B for a random-ish B is SPD by construction.
let b = [[2.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 4.0], [1.0, 1.0, 1.0]];
let b = [
[2.0, 1.0, 0.0],
[1.0, 3.0, 1.0],
[0.0, 1.0, 4.0],
[1.0, 1.0, 1.0],
];
let mut a = DMat::zeros(3);
for i in 0..3 {
for j in 0..3 {
+5 -4
View File
@@ -16,7 +16,7 @@
use crate::features::{Features, DESCRIPTOR_LEN};
const _: () = assert!(DESCRIPTOR_LEN % 8 == 0);
const _: () = assert!(DESCRIPTOR_LEN.is_multiple_of(8));
/// A correspondence: keypoint `a` in the first image matches keypoint `b`
/// in the second, with the cosine similarity of their descriptors.
@@ -64,9 +64,10 @@ pub fn match_features(a: &Features, b: &Features, min_similarity: f32) -> Vec<Ma
let best_ab: Vec<(usize, f32)> = sim
.chunks_exact(nb)
.map(|row| {
row.iter()
.enumerate()
.fold((0usize, f32::MIN), |acc, (j, &s)| if s > acc.1 { (j, s) } else { acc })
row.iter().enumerate().fold(
(0usize, f32::MIN),
|acc, (j, &s)| if s > acc.1 { (j, s) } else { acc },
)
})
.collect();
let mut best_ba = vec![(0usize, f32::MIN); nb];
+14 -3
View File
@@ -151,18 +151,29 @@ mod tests {
#[test]
fn to_and_from_direction_are_inverses() {
for proj in [Projection::Perspective, Projection::Cylindrical, Projection::Spherical] {
for proj in [
Projection::Perspective,
Projection::Cylindrical,
Projection::Spherical,
] {
for (u, v) in [(0.0, 0.0), (300.0, -200.0), (-900.0, 450.0)] {
let d = proj.to_direction(1000.0, u, v);
let (bu, bv) = proj.from_direction(1000.0, d).expect("in front");
assert!((bu - u).abs() < 1e-9 && (bv - v).abs() < 1e-9, "{proj:?} {u} {v}");
assert!(
(bu - u).abs() < 1e-9 && (bv - v).abs() < 1e-9,
"{proj:?} {u} {v}"
);
}
}
}
#[test]
fn the_origin_looks_down_z_in_every_projection() {
for proj in [Projection::Perspective, Projection::Cylindrical, Projection::Spherical] {
for proj in [
Projection::Perspective,
Projection::Cylindrical,
Projection::Spherical,
] {
let d = proj.to_direction(500.0, 0.0, 0.0);
assert!((d.z() - 1.0).abs() < 1e-12);
}
+7 -3
View File
@@ -43,7 +43,10 @@ impl XFeat {
}
/// From the two exports on disk.
pub fn from_paths(landscape: &std::path::Path, portrait: &std::path::Path) -> Result<Self, PanoError> {
pub fn from_paths(
landscape: &std::path::Path,
portrait: &std::path::Path,
) -> Result<Self, PanoError> {
let l = std::fs::read(landscape).map_err(PanoError::ModelRead)?;
let p = std::fs::read(portrait).map_err(PanoError::ModelRead)?;
Self::from_bytes(&l, &p)
@@ -80,8 +83,9 @@ impl XFeat {
let (fitted, scale) = image.fitted(in_w, in_h);
let padded = fitted.padded(in_w, in_h);
let input = ndarray::Array::from_shape_vec(ndarray::IxDyn(&[1, 1, in_h, in_w]), padded.data)
.expect("shape matches the buffer by construction");
let input =
ndarray::Array::from_shape_vec(ndarray::IxDyn(&[1, 1, in_h, in_w]), padded.data)
.expect("shape matches the buffer by construction");
let tensor = ort::value::Tensor::from_array(input).map_err(PanoError::Inference)?;
let outputs = session
.run(ort::inputs![tensor])
+8 -2
View File
@@ -2243,8 +2243,14 @@ mod tests {
merge = panorama cylindrical 49.7mm 2 frames\n\n[version u1]\nname = Default\n\
revision = 1\nmodified = 0\n";
let sidecar = Sidecar::parse(text).expect("parses");
assert_eq!(sidecar.derived_from, vec!["2025/_MG_8320.CR2", "2025/_MG_8321.CR2"]);
assert_eq!(sidecar.merge.as_deref(), Some("panorama cylindrical 49.7mm 2 frames"));
assert_eq!(
sidecar.derived_from,
vec!["2025/_MG_8320.CR2", "2025/_MG_8321.CR2"]
);
assert_eq!(
sidecar.merge.as_deref(),
Some("panorama cylindrical 49.7mm 2 frames")
);
let out = sidecar.to_text();
assert!(out.starts_with("drsc 1\nderived_from = 2025/_MG_8320.CR2\n"));
assert_eq!(Sidecar::parse(&out).expect("re-parses"), sidecar);
+13 -8
View File
@@ -24,7 +24,11 @@ fn main() {
};
let shape: Vec<usize> = args
.next()
.map(|s| s.split('x').map(|d| d.parse().expect("dimension")).collect())
.map(|s| {
s.split('x')
.map(|d| d.parse().expect("dimension"))
.collect()
})
.unwrap_or_else(|| vec![1, 1, 768, 1024]);
let bytes = std::fs::read(&path).expect("read model");
@@ -32,13 +36,14 @@ fn main() {
let _ = ort::set_api(ort_tract::api());
let t = Instant::now();
let mut session = match ort::session::Session::builder().and_then(|mut b| b.commit_from_memory(&bytes)) {
Ok(s) => s,
Err(e) => {
println!("FAIL load: {e}");
std::process::exit(1);
}
};
let mut session =
match ort::session::Session::builder().and_then(|mut b| b.commit_from_memory(&bytes)) {
Ok(s) => s,
Err(e) => {
println!("FAIL load: {e}");
std::process::exit(1);
}
};
println!("ok loaded in {:?}", t.elapsed());
for i in session.inputs().iter() {
println!(" input {} {:?}", i.name(), i.dtype());
+16 -4
View File
@@ -41,7 +41,8 @@ fn main() {
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
});
let request = dr_ui::merge::MergeRequest::new(frames, dr_ui::merge::MergeDestination::Local(dir));
let request =
dr_ui::merge::MergeRequest::new(frames, dr_ui::merge::MergeDestination::Local(dir));
let (tx, rx) = std::sync::mpsc::channel();
let (decide, decision) = std::sync::mpsc::channel();
let cancel = dr_ui::merge::Cancel::default();
@@ -68,15 +69,26 @@ fn main() {
);
for (k, f) in r.frames.iter().enumerate() {
match f {
Ok((yaw, pitch)) => println!(" frame {k}: yaw {yaw:7.2}° pitch {pitch:6.2}°"),
Ok((yaw, pitch)) => {
println!(" frame {k}: yaw {yaw:7.2}° pitch {pitch:6.2}°")
}
Err(why) => println!(" frame {k}: NOT ALIGNED — {why}"),
}
}
// Headless: the confirmation the dialog would ask for.
let _ = decide.send(dr_ui::merge::Decision::Merge { projection: None });
}
Done { path, width, height, .. } => {
println!("wrote {} ({width}×{height}) in {:?}", path.display(), t.elapsed());
Done {
path,
width,
height,
..
} => {
println!(
"wrote {} ({width}×{height}) in {:?}",
path.display(),
t.elapsed()
);
}
Failed(e) => {
eprintln!("failed: {e}");
+4
View File
@@ -8722,6 +8722,10 @@ mod tests {
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None,
base_curve: dr_decode::BaseCurve::IDENTITY,
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: dr_decode::CropRect {
x: 0,
y: 0,
+72 -23
View File
@@ -34,7 +34,9 @@ use std::sync::Arc;
use std::time::Instant;
use dr_decode::RawImage;
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MergeFrame, MergeOutput, MergePass};
use dr_gpu::{
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MergeFrame, MergeOutput, MergePass,
};
use dr_pano::bundle::Cameras;
use dr_pano::projection::{self, Projection};
use dr_pano::{Alignment, Gray};
@@ -109,11 +111,15 @@ impl MergeRequest {
/// never automatic).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Decision {
Merge { projection: Option<Projection> },
Merge {
projection: Option<Projection>,
},
/// Draw the alignment again on another surface, and report it again.
/// The page's projection chips: what a choice looks like before it is
/// confirmed, at proxy cost rather than the merge's.
Preview { projection: Option<Projection> },
Preview {
projection: Option<Projection>,
},
Abandon,
}
@@ -221,7 +227,11 @@ fn run_inner(
graph.set_orientation(orientation);
graph.set_lens_profile(crate::develop::DevelopSession::profile_for(&meta));
let (w, h) = (raw.crop.width, raw.crop.height);
let size = if orientation.quarter_turns % 2 == 1 { (h, w) } else { (w, h) };
let size = if orientation.quarter_turns % 2 == 1 {
(h, w)
} else {
(w, h)
};
frames.push(Loaded {
raw,
meta,
@@ -255,8 +265,8 @@ fn run_inner(
// 3. Alignment.
let t = Instant::now();
progress("Aligning", 0, 1);
let alignment = dr_pano::align(&features, &dr_pano::AlignOptions::default())
.map_err(|e| e.to_string())?;
let alignment =
dr_pano::align(&features, &dr_pano::AlignOptions::default()).map_err(|e| e.to_string())?;
log::info!(
"merge: aligned in {:?}, focal {:.1} px, rms {:.2} px, {} links",
t.elapsed(),
@@ -294,9 +304,16 @@ fn run_inner(
let report = |projection: Projection| -> Result<AlignmentReport, String> {
let bounds = projection::bounds(projection, focal_full, &cameras, frame_size)
.ok_or("the frames project nowhere")?;
let preview = alignment
.is_complete()
.then(|| preview(&colour, &proxies, &alignment, &gains, projection, &frames[0].raw));
let preview = alignment.is_complete().then(|| {
preview(
&colour,
&proxies,
&alignment,
&gains,
projection,
&frames[0].raw,
)
});
Ok(AlignmentReport {
focal_mm: focal_full * 36.0 / full_long,
rms_px: alignment.rms_px,
@@ -316,7 +333,10 @@ fn run_inner(
.iter()
.map(|(k, why)| format!("{}: {why}", request.frames[*k].name))
.collect();
return Err(format!("not every frame could be placed — {}", names.join("; ")));
return Err(format!(
"not every frame could be placed — {}",
names.join("; ")
));
}
// Never automatic (FR-MRG-1): nothing is written until the alignment
@@ -400,7 +420,8 @@ fn run_inner(
// once the last strip is in, by which time every band has been seen.
let inscribed = Arc::new(std::sync::Mutex::new(dr_export::Inscribed::new(out_w)));
let inscribed_for_writer = inscribed.clone();
let file = std::fs::File::create(&out_path).map_err(|e| format!("{}: {e}", out_path.display()))?;
let file =
std::fs::File::create(&out_path).map_err(|e| format!("{}: {e}", out_path.display()))?;
let writer = std::thread::spawn(move || -> Result<(), String> {
let mut file = std::io::BufWriter::new(file);
dr_export::write_linear_dng(
@@ -411,9 +432,9 @@ fn run_inner(
&profile,
Some(&carried),
|_, buf| {
let band = band_rx
.recv()
.map_err(|_| dr_export::ExportError::Encode("the merge stopped early".into()))?;
let band = band_rx.recv().map_err(|_| {
dr_export::ExportError::Encode("the merge stopped early".into())
})?;
buf.extend_from_slice(&band);
Ok(())
},
@@ -458,7 +479,9 @@ fn run_inner(
|| cancel.is_cancelled(),
);
drop(band_tx);
let written = writer.join().unwrap_or_else(|_| Err("the writer panicked".into()));
let written = writer
.join()
.unwrap_or_else(|_| Err("the writer panicked".into()));
match merged {
Ok(()) => {}
Err(e) if cancel.is_cancelled() => {
@@ -475,7 +498,13 @@ fn run_inner(
cleanup(&out_path);
return Err(e);
}
log::info!("merge: {}×{} written to {} in {:?}", out_w, out_h, out_path.display(), t.elapsed());
log::info!(
"merge: {}×{} written to {} in {:?}",
out_w,
out_h,
out_path.display(),
t.elapsed()
);
// TRACES: FR-MRG-6
// Provenance, beside the composite: what it was merged from, in order,
@@ -547,7 +576,10 @@ fn camera_proxy(
.chunks_exact(4)
.map(|p| ((p[0] + p[1] + p[2]) / 3.0).clamp(0.0, 1.0).powf(1.0 / 2.2))
.collect();
let rgb: Vec<f32> = rgba.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
let rgb: Vec<f32> = rgba
.chunks_exact(4)
.flat_map(|p| [p[0], p[1], p[2]])
.collect();
Ok((
Gray {
width: rw as usize,
@@ -595,7 +627,9 @@ fn preview(
let mut sum = [0.0f32; 3];
let mut n = 0u32;
for (k, g) in proxies.iter().enumerate() {
let Some((x, y)) = cameras.project(k, d) else { continue };
let Some((x, y)) = cameras.project(k, d) else {
continue;
};
let (x, y) = (x + fw / 2.0, y + fh / 2.0);
if x < 0.0 || y < 0.0 || x >= fw - 1.0 || y >= fh - 1.0 {
continue;
@@ -611,7 +645,11 @@ fn preview(
out[o + 3] = 255;
continue;
}
let cam = [sum[0] / n as f32 * wb[0], sum[1] / n as f32 * wb[1], sum[2] / n as f32 * wb[2]];
let cam = [
sum[0] / n as f32 * wb[0],
sum[1] / n as f32 * wb[1],
sum[2] / n as f32 * wb[2],
];
for c in 0..3 {
let lin = m[c * 3] * cam[0] + m[c * 3 + 1] * cam[1] + m[c * 3 + 2] * cam[2];
out[o + c] = (lin.clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0) as u8;
@@ -637,7 +675,9 @@ fn preview(
(fw / 2.0 - 1.0, -fh / 2.0 + fh * t),
] {
let d = cameras.bearing(k, p);
let Some((u, v)) = projection.from_direction(scale, d) else { continue };
let Some((u, v)) = projection.from_direction(scale, d) else {
continue;
};
let x = ((u - bounds.min_u) / px) as i64;
let y = ((v - bounds.min_v) / px) as i64;
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
@@ -677,7 +717,10 @@ fn field_of_view(cameras: &Cameras, frame: (f64, f64)) -> (f64, f64) {
}
fn report_size(b: &projection::Bounds) -> (u32, u32) {
(b.width().ceil().max(1.0) as u32, b.height().ceil().max(1.0) as u32)
(
b.width().ceil().max(1.0) as u32,
b.height().ceil().max(1.0) as u32,
)
}
fn describe(a: &Alignment) -> Vec<Result<(f64, f64), String>> {
@@ -722,7 +765,9 @@ fn gains(proxies: &[Gray], a: &Alignment) -> Vec<f32> {
for x in (0..proxies[l.i].width).step_by(step) {
let p = (x as f64 - w / 2.0, y as f64 - h / 2.0);
let d = cameras.bearing(l.i, p);
let Some((qx, qy)) = cameras.project(l.j, d) else { continue };
let Some((qx, qy)) = cameras.project(l.j, d) else {
continue;
};
let (qx, qy) = (qx + w / 2.0, qy + h / 2.0);
if qx < 0.0 || qy < 0.0 || qx >= w - 1.0 || qy >= h - 1.0 {
continue;
@@ -771,7 +816,11 @@ fn gains(proxies: &[Gray], a: &Alignment) -> Vec<f32> {
fn dng_profile(first: &Loaded, white_level: u32) -> dr_export::DngProfile {
let wb = first.raw.wb_coeffs;
let neutral_from_wb = [1.0 / wb[0].max(1e-3), 1.0 / wb[1].max(1e-3), 1.0 / wb[2].max(1e-3)];
let neutral_from_wb = [
1.0 / wb[0].max(1e-3),
1.0 / wb[1].max(1e-3),
1.0 / wb[2].max(1e-3),
];
let (calibrations, as_shot_neutral) = match &first.raw.profile {
Some(p) => (p.dng_calibrations(), p.neutral().unwrap_or(neutral_from_wb)),
None => (Vec::new(), neutral_from_wb),
+40 -9
View File
@@ -146,7 +146,9 @@ pub fn wire<S, C, F>(
// bytes, which keeps it free of the library.
let conn = context.conn.clone();
let fetch_names = names.clone();
let fetch = move |tx: &Sender<MergeEvent>, cancel: &Cancel| -> Option<Vec<MergeInput>> {
let fetch = move |tx: &Sender<MergeEvent>,
cancel: &Cancel|
-> Option<Vec<MergeInput>> {
let mut frames = Vec::with_capacity(sources.len());
for (i, (path, cache)) in sources.into_iter().enumerate() {
if cancel.is_cancelled() {
@@ -162,7 +164,10 @@ pub fn wire<S, C, F>(
let bytes = match rx.recv() {
Ok(Ok(bytes)) => bytes,
Ok(Err(e)) => {
let _ = tx.send(MergeEvent::Failed(format!("{}: {}", fetch_names[i], e.message)));
let _ = tx.send(MergeEvent::Failed(format!(
"{}: {}",
fetch_names[i], e.message
)));
return None;
}
Err(_) => {
@@ -197,9 +202,16 @@ pub fn wire<S, C, F>(
if let (Some(gpu), Some(first)) = (gpu_for_start.clone(), paths.first()) {
let names: Vec<String> = paths
.iter()
.map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default())
.map(|p| {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
})
.collect();
let dir = first.parent().map(std::path::Path::to_path_buf).unwrap_or_default();
let dir = first
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_default();
let read_paths = paths.clone();
let fetch = move |tx: &Sender<MergeEvent>, _: &Cancel| -> Option<Vec<MergeInput>> {
let mut frames = Vec::new();
@@ -214,7 +226,15 @@ pub fn wire<S, C, F>(
}
Some(frames)
};
start(window, &ctl, gpu, names, MergeDestination::Local(dir), fetch, &on_done);
start(
window,
&ctl,
gpu,
names,
MergeDestination::Local(dir),
fetch,
&on_done,
);
}
}
@@ -309,7 +329,9 @@ fn start<Fetch>(
let cancel = cancel.clone();
let tx = tx.clone();
std::thread::spawn(move || {
let Some(frames) = fetch(&tx, &cancel) else { return };
let Some(frames) = fetch(&tx, &cancel) else {
return;
};
let request = MergeRequest::new(frames, destination);
merge::run(gpu, request, tx, decision, cancel);
});
@@ -334,7 +356,9 @@ fn start<Fetch>(
window.set_merge_aligned(false);
window.set_merge_complete(false);
window.set_merge_summary("".into());
window.set_merge_frames(slint::ModelRc::new(slint::VecModel::from(Vec::<MergeFrameRow>::new())));
window.set_merge_frames(slint::ModelRc::new(slint::VecModel::from(Vec::<
MergeFrameRow,
>::new())));
window.set_merge_preview(slint::Image::default());
window.set_merge_projection_selected(0);
window.set_merge_done(false);
@@ -379,7 +403,11 @@ fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&Ap
MergeEvent::Progress { stage, done, total } => {
window.set_merge_stage(stage.into());
window.set_merge_running(true);
window.set_merge_fraction(if total > 0 { done as f32 / total as f32 } else { 0.0 });
window.set_merge_fraction(if total > 0 {
done as f32 / total as f32
} else {
0.0
});
if let Some(job) = ctl.job.borrow().as_ref() {
job.activity.detail(stage.to_lowercase());
job.activity.progress(done, total.max(1));
@@ -463,7 +491,10 @@ fn show_report(window: &AppWindow, report: &AlignmentReport, names: &[String]) {
.iter()
.enumerate()
.map(|(k, f)| {
let name = names.get(k).cloned().unwrap_or_else(|| format!("frame {k}"));
let name = names
.get(k)
.cloned()
.unwrap_or_else(|| format!("frame {k}"));
match f {
Ok((yaw, pitch)) => MergeFrameRow {
name: name.into(),