mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 22:49:48 -06:00
LR-MERGE-PANO: Keep panoramas at full resolution when memory allows (#1455)
Drop the 40 MP default and free each photo's pixels once it's projected.
This commit is contained in:
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Panorama Merge",
|
||||
[],
|
||||
None,
|
||||
"{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=40, preview=false, previewPath?}",
|
||||
"{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=full size (≤ 512, held to memory), preview=false, previewPath?}",
|
||||
can_merge,
|
||||
|s, p| run(s, "merge.panorama", p)
|
||||
),
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::{PhotoId, Source};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_merge::pano::MAX_MEGAPIXELS;
|
||||
use lightcraft_merge::{Deghost, Frame, HdrOptions, PanoOptions, Projection};
|
||||
use lightcraft_pipeline::{Quality, RenderRequest, SourceInfo};
|
||||
use lightcraft_raster::{Rgb32f, Rgba8};
|
||||
@@ -149,8 +150,9 @@ impl MergeJob {
|
||||
MergeKind::Panorama { pano } => {
|
||||
let frames = self.load(true, progress, (0.0, 0.25))?;
|
||||
let p = sub(0.25, 0.9);
|
||||
let r = lightcraft_merge::stitch(frames, pano, &p).map_err(err)?;
|
||||
let info = pano_info(&r, &self.sources);
|
||||
let fit = fit_in_memory(pano, pixels(&frames), crate::memory::total_ram());
|
||||
let r = lightcraft_merge::stitch(frames, &fit, &p).map_err(err)?;
|
||||
let info = pano_info(&r, &self.sources, pano, &fit);
|
||||
(
|
||||
r.image,
|
||||
r.color,
|
||||
@@ -192,8 +194,9 @@ impl MergeJob {
|
||||
merged.push(f);
|
||||
}
|
||||
let p = sub(0.6, 0.9);
|
||||
let r = lightcraft_merge::stitch(merged, pano, &p).map_err(err)?;
|
||||
let mut info = pano_info(&r, &self.sources);
|
||||
let fit = fit_in_memory(pano, pixels(&merged), crate::memory::total_ram());
|
||||
let r = lightcraft_merge::stitch(merged, &fit, &p).map_err(err)?;
|
||||
let mut info = pano_info(&r, &self.sources, pano, &fit);
|
||||
info["bracket"] = json!(b);
|
||||
(
|
||||
r.image,
|
||||
@@ -230,7 +233,20 @@ impl MergeJob {
|
||||
}
|
||||
}
|
||||
|
||||
fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) -> Value {
|
||||
/// `asked` is what the command asked for, `fit` what ran (held to memory): when the panorama came
|
||||
/// out smaller than the photos' own resolution, `limit` says why (`memory`, `size`: Lightroom's
|
||||
/// 512 MP / 65,000 px, or `asked`).
|
||||
fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)], asked: &PanoOptions, fit: &PanoOptions) -> Value {
|
||||
let megapixels = (r.image.width * r.image.height) as f64 / 1e6;
|
||||
let native = r.native_megapixels;
|
||||
let why = if fit.max_megapixels < asked.max_megapixels && fit.max_megapixels < native {
|
||||
"memory"
|
||||
} else if asked.max_megapixels < MAX_MEGAPIXELS && asked.max_megapixels < native {
|
||||
"asked"
|
||||
} else {
|
||||
"size"
|
||||
};
|
||||
let limit = (megapixels < native * 0.98).then_some(why);
|
||||
json!({
|
||||
"projection": r.projection,
|
||||
"used": r.used.iter().filter_map(|&i| sources.get(i).map(|s| s.0.0)).collect::<Vec<_>>(),
|
||||
@@ -238,9 +254,46 @@ fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) ->
|
||||
"rmsPx": r.rms,
|
||||
"gains": r.gains,
|
||||
"crop": r.crop,
|
||||
"megapixels": megapixels,
|
||||
"nativeMegapixels": native,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
/// Peak memory of a panorama per pixel of its output (bytes), measured as the peak resident size
|
||||
/// of synthetic stitches of 20–81 MP from 38–154 MP of photos, rounded up from the smaller ones
|
||||
/// (the larger need less): while the photos are projected they are still held, beside the tiles
|
||||
/// ([`PANO_TILE_BYTES`] on top of the photos' own 12 bytes a pixel); the blend that follows, the
|
||||
/// photos freed, peaks at [`PANO_BLEND_BYTES`]. Writing the DNG takes less than either.
|
||||
const PANO_TILE_BYTES: f64 = 36.0;
|
||||
const PANO_BLEND_BYTES: f64 = 68.0;
|
||||
|
||||
/// What a merge may use of the computer's memory: three quarters of it (the rest is the system's
|
||||
/// and the other apps'), less the app's own memory budget (its caches).
|
||||
fn merge_memory(ram: Option<usize>) -> f64 {
|
||||
// a computer whose memory we can't read (Windows) is taken to have 8 GB
|
||||
let ram = ram.unwrap_or(8 << 30) as f64;
|
||||
(ram * 0.75 - crate::memory::budget() as f64).max(0.0)
|
||||
}
|
||||
|
||||
/// `pano` with its size held to what fits in memory beside its photos (`frame_px` pixels, 12
|
||||
/// bytes each, loaded already): a panorama of five 51 MP photos may have up to 166 MP on a 16 GB
|
||||
/// computer, 355 MP on 32 GB. Never below 40 megapixels, the size every panorama had before it
|
||||
/// followed the photos' resolution, and never above what was asked.
|
||||
pub(crate) fn fit_in_memory(pano: &PanoOptions, frame_px: f64, ram: Option<usize>) -> PanoOptions {
|
||||
let room = merge_memory(ram);
|
||||
let fits = ((room - frame_px * 12.0) / PANO_TILE_BYTES).min(room / PANO_BLEND_BYTES) / 1e6;
|
||||
PanoOptions { max_megapixels: pano.max_megapixels.min(fits.max(MIN_FITTED_MEGAPIXELS)), ..pano.clone() }
|
||||
}
|
||||
|
||||
/// The pixels of `frames`.
|
||||
fn pixels(frames: &[Frame]) -> f64 {
|
||||
frames.iter().map(|f| (f.width() * f.height()) as f64).sum()
|
||||
}
|
||||
|
||||
/// The smallest size [`fit_in_memory`] holds a panorama to (megapixels).
|
||||
pub(crate) const MIN_FITTED_MEGAPIXELS: f64 = 40.0;
|
||||
|
||||
/// Bracket size from EXIF: the smallest period of the exposure sequence (≥ 2).
|
||||
pub fn detect_bracket(frames: &[Frame]) -> Option<usize> {
|
||||
let ev: Vec<f64> = frames.iter().map(|f| f.exposure).collect::<Option<_>>()?;
|
||||
@@ -331,7 +384,8 @@ pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> {
|
||||
boundary_warp: p.get("boundaryWarp").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0),
|
||||
auto_crop: p.get("autoCrop").and_then(Value::as_bool).unwrap_or(false),
|
||||
fill_edges: p.get("fillEdges").and_then(Value::as_bool).unwrap_or(false),
|
||||
max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).unwrap_or(40.0),
|
||||
// the photos' own resolution unless asked (held to what fits in memory when it runs)
|
||||
max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(MAX_MEGAPIXELS).min(MAX_MEGAPIXELS),
|
||||
};
|
||||
let kind = match id {
|
||||
"merge.hdr" => MergeKind::Hdr { hdr },
|
||||
|
||||
@@ -164,7 +164,34 @@ fn panorama_command_with_auto_crop() {
|
||||
assert!(p.width > 800 && p.height > 300, "{}×{}", p.width, p.height);
|
||||
let crop = s.develop_of(id).unwrap().crop.geometry.rect;
|
||||
assert!(crop.width() < 1.0 && crop.width() * crop.height() > 0.5, "{crop:?}");
|
||||
// the photos' own resolution, so nothing held it back (issue #1455)
|
||||
let (mp, native) = (r["megapixels"].as_f64().unwrap(), r["nativeMegapixels"].as_f64().unwrap());
|
||||
assert!((mp / native - 1.0).abs() < 0.02 && r["limit"].is_null(), "{r}");
|
||||
// a size asked for says so
|
||||
let small =
|
||||
s.execute("merge.panorama", &json!({"ids": ids, "projection": "cylindrical", "autoSettings": false, "maxMegapixels": native / 2.0})).unwrap();
|
||||
assert_eq!(small["limit"], "asked", "{small}");
|
||||
assert!(small["megapixels"].as_f64().unwrap() <= native / 2.0 * 1.02, "{small}");
|
||||
// too few photos
|
||||
assert!(s.execute("merge.panorama", &json!({"ids": [ids[0]]})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #1455: a panorama keeps its photos' resolution, held to what fits in memory beside them
|
||||
/// (never below the 40 MP every panorama used to have), and never above a size asked for.
|
||||
#[test]
|
||||
fn panorama_size_is_held_to_memory() {
|
||||
use crate::merge::{MIN_FITTED_MEGAPIXELS, fit_in_memory};
|
||||
let full = lightcraft_merge::PanoOptions::default();
|
||||
let gb = |g: usize| Some(g << 30);
|
||||
let photos = 5.0 * 51e6; // five 51 MP photos
|
||||
let mp = |o: lightcraft_merge::PanoOptions| o.max_megapixels;
|
||||
assert_eq!(mp(fit_in_memory(&full, photos, gb(512))), lightcraft_merge::pano::MAX_MEGAPIXELS, "plenty of memory: the full size");
|
||||
let m16 = mp(fit_in_memory(&full, photos, gb(16)));
|
||||
assert!(m16 > MIN_FITTED_MEGAPIXELS && m16 < 512.0, "16 GB: {m16} MP");
|
||||
assert!(mp(fit_in_memory(&full, photos, gb(32))) > m16, "more memory, more pixels");
|
||||
assert_eq!(mp(fit_in_memory(&full, photos, gb(4))), MIN_FITTED_MEGAPIXELS, "never below 40 MP");
|
||||
let asked = lightcraft_merge::PanoOptions { max_megapixels: 20.0, ..full.clone() };
|
||||
assert_eq!(mp(fit_in_memory(&asked, photos, gb(16))), 20.0, "a size asked for is kept when it fits");
|
||||
assert_eq!(fit_in_memory(&full, 1e6, None), fit_in_memory(&full, 1e6, gb(8)), "memory unknown: taken as 8 GB");
|
||||
}
|
||||
|
||||
@@ -39,16 +39,23 @@ pub struct PanoOptions {
|
||||
pub boundary_warp: f64,
|
||||
pub auto_crop: bool,
|
||||
pub fill_edges: bool,
|
||||
/// Output size limit (megapixels); the panorama is scaled down to fit.
|
||||
/// Output size limit (megapixels); the panorama is scaled down to fit. At most
|
||||
/// [`MAX_MEGAPIXELS`] (the default: the photos' own resolution).
|
||||
pub max_megapixels: f64,
|
||||
}
|
||||
|
||||
impl Default for PanoOptions {
|
||||
fn default() -> Self {
|
||||
PanoOptions { projection: Projection::Auto, boundary_warp: 0.0, auto_crop: false, fill_edges: false, max_megapixels: 40.0 }
|
||||
PanoOptions { projection: Projection::Auto, boundary_warp: 0.0, auto_crop: false, fill_edges: false, max_megapixels: MAX_MEGAPIXELS }
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest panorama, in megapixels (Lightroom's limit too); a bigger one is scaled down.
|
||||
pub const MAX_MEGAPIXELS: f64 = 512.0;
|
||||
|
||||
/// The longest edge of a panorama, in pixels (Lightroom's limit too); a longer one is scaled down.
|
||||
pub const MAX_EDGE: f64 = 65_000.0;
|
||||
|
||||
/// The stitched panorama.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PanoResult {
|
||||
@@ -71,6 +78,8 @@ pub struct PanoResult {
|
||||
pub rms: f64,
|
||||
/// Horizontal and vertical field of view (degrees).
|
||||
pub fov: (f64, f64),
|
||||
/// The panorama's size at the photos' own resolution (megapixels), before any limit.
|
||||
pub native_megapixels: f64,
|
||||
/// Output focal length and the surface coordinates of the canvas's top-left corner: canvas
|
||||
/// pixel (x, y) (before boundary warp) shows surface point (x + 0.5 + origin.0, y + 0.5 + origin.1).
|
||||
pub focal: f64,
|
||||
@@ -210,7 +219,7 @@ pub fn register(frames: &[Frame], progress: &Progress) -> Result<(Vec<Option<Cam
|
||||
}
|
||||
|
||||
/// Stitch `frames` (oriented, linear) into a panorama.
|
||||
pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Result<PanoResult> {
|
||||
pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Result<PanoResult> {
|
||||
let n = frames.len();
|
||||
if n < 2 {
|
||||
return Err(MergeError::TooFew(n, 2));
|
||||
@@ -231,9 +240,9 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re
|
||||
if projection == Projection::Perspective && (bx1 - bx0) * (by1 - by0) > 12.0 * area {
|
||||
return Err(MergeError::NoOverlap(format!("the field of view ({:.0}°) is too wide for a perspective projection", fov.0)));
|
||||
}
|
||||
// output scale: native resolution, limited to max_megapixels
|
||||
let max_px = (opts.max_megapixels.max(0.1) * 1e6).min(400e6);
|
||||
let s = (max_px / ((bx1 - bx0) * (by1 - by0))).sqrt().min(1.0);
|
||||
// output scale: native resolution, limited to max_megapixels and the longest edge
|
||||
let max_px = if opts.max_megapixels.is_nan() { MAX_MEGAPIXELS } else { opts.max_megapixels.clamp(0.1, MAX_MEGAPIXELS) } * 1e6;
|
||||
let s = (max_px / ((bx1 - bx0) * (by1 - by0))).sqrt().min(MAX_EDGE / (bx1 - bx0).max(by1 - by0)).min(1.0);
|
||||
let f_out = fmed * s;
|
||||
let (ox, oy) = (bx0 * s, by0 * s);
|
||||
let (cw, ch) = (((bx1 - bx0) * s).ceil() as usize + 1, ((by1 - by0) * s).ceil() as usize + 1);
|
||||
@@ -298,6 +307,11 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re
|
||||
.collect();
|
||||
check(progress, 0.6, "Exposure compensation")?;
|
||||
compensate_gains(&tiles, &mut gains, &frames);
|
||||
// the tiles hold every pixel the blend needs: the frames' own go now, before the blend
|
||||
// takes its memory (12 bytes a pixel of every photo: 3 GB for five 51 MP frames)
|
||||
for f in &mut frames {
|
||||
f.image = Rgb32f::new(0, 0);
|
||||
}
|
||||
|
||||
// seams: label = max feather weight
|
||||
let mut best = vec![0f32; pw * ph];
|
||||
@@ -373,6 +387,7 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re
|
||||
rms_before,
|
||||
rms,
|
||||
fov,
|
||||
native_megapixels: (bx1 - bx0) * (by1 - by0) / 1e6,
|
||||
focal: f_out,
|
||||
origin: (ox, oy),
|
||||
color: r.color.clone(),
|
||||
|
||||
@@ -245,3 +245,26 @@ fn unrelated_photos_do_not_stitch() {
|
||||
let frames: Vec<Frame> = a.iter().take(2).map(|s| Frame::from_linear_rec2020(s.render(400, 300), None)).collect();
|
||||
assert!(stitch(frames, &PanoOptions::default(), &no_progress).is_err());
|
||||
}
|
||||
|
||||
/// Issue #1455: a panorama came out at 40 MP whatever its photos' resolution. It now keeps their
|
||||
/// own resolution unless a size is asked for (up to Lightroom's 512 MP); a size scales it down,
|
||||
/// and a NaN one (from an agent) is no size at all, not a 0.1 MP thumbnail.
|
||||
#[test]
|
||||
fn panoramas_keep_the_photos_resolution_unless_asked() {
|
||||
assert_eq!(PanoOptions::default().max_megapixels, lightcraft_merge::pano::MAX_MEGAPIXELS);
|
||||
let shots = [
|
||||
Shot { yaw: -22.0, pitch: 0.5, roll: 0.3, gain: 1.0 },
|
||||
Shot { yaw: 0.0, pitch: -0.4, roll: 0.0, gain: 1.0 },
|
||||
Shot { yaw: 21.0, pitch: 0.8, roll: -0.4, gain: 1.0 },
|
||||
];
|
||||
let opts = |max_megapixels: f64| PanoOptions { projection: Projection::Perspective, max_megapixels, ..Default::default() };
|
||||
let mp = |r: &lightcraft_merge::pano::PanoResult| (r.image.width * r.image.height) as f64 / 1e6;
|
||||
let full = run(&shots, 800.0, &PanoOptions { projection: Projection::Perspective, ..Default::default() }).0;
|
||||
assert!((mp(&full) / full.native_megapixels - 1.0).abs() < 0.02, "{} MP for {} MP", mp(&full), full.native_megapixels);
|
||||
let quarter = run(&shots, 800.0, &opts(full.native_megapixels / 4.0)).0;
|
||||
assert!((quarter.native_megapixels / full.native_megapixels - 1.0).abs() < 1e-6);
|
||||
let k = mp(&quarter) / (full.native_megapixels / 4.0);
|
||||
assert!(k > 0.9 && k <= 1.02, "a quarter of the size asked: {} MP", mp(&quarter));
|
||||
let nan = run(&shots, 800.0, &opts(f64::NAN)).0;
|
||||
assert_eq!((nan.image.width, nan.image.height), (full.image.width, full.image.height));
|
||||
}
|
||||
|
||||
@@ -247,5 +247,7 @@
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt",
|
||||
"{name} opened in the other editor": "{name} im anderen Editor geöffnet",
|
||||
"{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet",
|
||||
"· {in_source} identical copies": "· {in_source} identische Kopien"
|
||||
"· {in_source} identical copies": "· {in_source} identische Kopien",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}-Zusammenfügung mit {mp} hinzugefügt: die volle Größe, {full}, braucht mehr Arbeitsspeicher, als dieser Computer hat",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-Zusammenfügung mit {mp} hinzugefügt, der größten möglichen Panoramagröße (volle Größe: {full})"
|
||||
}
|
||||
|
||||
@@ -247,5 +247,7 @@
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original",
|
||||
"{name} opened in the other editor": "{name} abierto en el otro editor",
|
||||
"{n} originals opened in the other editor": "{n} originales abiertos en el otro editor",
|
||||
"· {in_source} identical copies": "· {in_source} copias idénticas"
|
||||
"· {in_source} identical copies": "· {in_source} copias idénticas",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Combinación {what} añadida a {mp}: el tamaño completo, {full}, necesita más memoria de la que tiene este ordenador",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Combinación {what} añadida a {mp}, el tamaño máximo de una panorámica (tamaño completo: {full})"
|
||||
}
|
||||
|
||||
@@ -247,5 +247,7 @@
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original",
|
||||
"{name} opened in the other editor": "{name} ouvert dans l’autre éditeur",
|
||||
"{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur",
|
||||
"· {in_source} identical copies": "· {in_source} copies identiques"
|
||||
"· {in_source} identical copies": "· {in_source} copies identiques",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Fusion de {what} ajoutée en {mp} : la taille complète, {full}, demande plus de mémoire que n’en a cet ordinateur",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Fusion de {what} ajoutée en {mp}, la plus grande taille possible pour un panorama (taille complète : {full})"
|
||||
}
|
||||
|
||||
@@ -247,5 +247,7 @@
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています",
|
||||
"{name} opened in the other editor": "{name}を別のエディタで開きました",
|
||||
"{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました",
|
||||
"· {in_source} identical copies": "・同一のコピー {in_source} 件"
|
||||
"· {in_source} identical copies": "・同一のコピー {in_source} 件",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}合成を{mp}で追加しました:フルサイズ({full})にはこのコンピューターのメモリが足りません",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})"
|
||||
}
|
||||
|
||||
@@ -247,5 +247,7 @@
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original",
|
||||
"{name} opened in the other editor": "{name} aberto no outro editor",
|
||||
"{n} originals opened in the other editor": "{n} originais abertos no outro editor",
|
||||
"· {in_source} identical copies": "· {in_source} cópias idênticas"
|
||||
"· {in_source} identical copies": "· {in_source} cópias idênticas",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} mesclagem adicionada em {mp}: o tamanho completo, {full}, precisa de mais memória do que este computador tem",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})"
|
||||
}
|
||||
|
||||
@@ -247,5 +247,7 @@
|
||||
"{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом",
|
||||
"{name} opened in the other editor": "{name} открыт в другом редакторе",
|
||||
"{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}",
|
||||
"· {in_source} identical copies": "· идентичных копий: {in_source}"
|
||||
"· {in_source} identical copies": "· идентичных копий: {in_source}",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: слияние добавлено в размере {mp}: для полного размера ({full}) не хватает памяти этого компьютера",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: слияние добавлено в размере {mp} — это наибольший размер панорамы (полный размер: {full})"
|
||||
}
|
||||
|
||||
@@ -247,5 +247,7 @@
|
||||
"{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом",
|
||||
"{name} opened in the other editor": "{name} відкрито в іншому редакторі",
|
||||
"{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}",
|
||||
"· {in_source} identical copies": "· ідентичних копій: {in_source}"
|
||||
"· {in_source} identical copies": "· ідентичних копій: {in_source}",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: об’єднання додано в розмірі {mp}: для повного розміру ({full}) бракує пам’яті цього комп’ютера",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: об’єднання додано в розмірі {mp} — це найбільший розмір панорами (повний розмір: {full})"
|
||||
}
|
||||
|
||||
@@ -247,5 +247,7 @@
|
||||
"{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起",
|
||||
"{name} opened in the other editor": "已在其他编辑器中打开 {name}",
|
||||
"{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件",
|
||||
"· {in_source} identical copies": "· {in_source} 个相同副本"
|
||||
"· {in_source} identical copies": "· {in_source} 个相同副本",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已添加 {what} 合并({mp}):完整尺寸({full})所需的内存超出了这台电脑的内存",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})"
|
||||
}
|
||||
|
||||
@@ -247,5 +247,7 @@
|
||||
"{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起",
|
||||
"{name} opened in the other editor": "已在其他編輯器中開啟 {name}",
|
||||
"{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案",
|
||||
"· {in_source} identical copies": "· {in_source} 個相同副本"
|
||||
"· {in_source} identical copies": "· {in_source} 個相同副本",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已新增{what}合併照片({mp}):完整尺寸({full})所需的記憶體超過這台電腦的記憶體",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})"
|
||||
}
|
||||
|
||||
@@ -269,8 +269,9 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
match r.map_err(lightcraft_engine::EngineError::Other).and_then(|out| app.session.finish_merge(&t.job, out)) {
|
||||
Ok(v) => {
|
||||
let message = added_message(what, &v);
|
||||
app.merge.last_result = Some(v);
|
||||
app.toast(ctx, crate::i18n::tr_format!("{what} merge added", what = what));
|
||||
app.toast(ctx, message);
|
||||
}
|
||||
Err(_) if t.cancel.load(Ordering::Relaxed) => app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what)),
|
||||
Err(e) => {
|
||||
@@ -292,6 +293,27 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The message for a finished merge (`what`: "Panorama", …; `info`: its result): a panorama
|
||||
/// smaller than its photos' resolution says why (issue #1455).
|
||||
fn added_message(what: &str, info: &Value) -> String {
|
||||
let size = |k: &str| info[k].as_f64().map(|mp| format!("{mp:.0} MP"));
|
||||
match (info["limit"].as_str(), size("megapixels"), size("nativeMegapixels")) {
|
||||
(Some("memory"), Some(mp), Some(full)) => crate::i18n::tr_format!(
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has",
|
||||
what = what,
|
||||
mp = mp,
|
||||
full = full
|
||||
),
|
||||
(Some("size"), Some(mp), Some(full)) => crate::i18n::tr_format!(
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})",
|
||||
what = what,
|
||||
mp = mp,
|
||||
full = full
|
||||
),
|
||||
_ => crate::i18n::tr_format!("{what} merge added", what = what),
|
||||
}
|
||||
}
|
||||
|
||||
fn last_failed_matches(app: &LightcraftApp, opts: &MergeDialog) -> bool {
|
||||
app.merge.failed_options.as_ref() == Some(opts)
|
||||
}
|
||||
@@ -423,3 +445,25 @@ fn num(ui: &mut egui::Ui, spec: &lightcraft_develop::ControlSpec, v: &mut f64) -
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Issue #1455: a panorama held to the computer's memory, or to the largest size, says so.
|
||||
#[test]
|
||||
fn a_smaller_panorama_says_why() {
|
||||
let added = |info: Value| added_message("Panorama", &info);
|
||||
assert_eq!(
|
||||
added(json!({"limit": "memory", "megapixels": 166.2, "nativeMegapixels": 194.0})),
|
||||
"Panorama merge added at 166 MP: its full size, 194 MP, needs more memory than this computer has"
|
||||
);
|
||||
assert_eq!(
|
||||
added(json!({"limit": "size", "megapixels": 512.0, "nativeMegapixels": 700.4})),
|
||||
"Panorama merge added at 512 MP, the largest a panorama can be (its full size is 700 MP)"
|
||||
);
|
||||
assert_eq!(added(json!({"limit": null, "megapixels": 90.0, "nativeMegapixels": 90.0})), "Panorama merge added");
|
||||
assert_eq!(added(json!({"limit": "asked", "megapixels": 20.0, "nativeMegapixels": 90.0})), "Panorama merge added");
|
||||
assert_eq!(added_message("HDR", &json!({"ev": [0.0]})), "HDR merge added");
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -13,7 +13,7 @@ All algorithms are our own implementations from published papers (cited in the m
|
||||
`<name>-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected.
|
||||
- **Commands** (CLI, MCP, control channel):
|
||||
- `merge.hdr {ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}`
|
||||
- `merge.panorama {ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, maxMegapixels=40, preview, previewPath?}`
|
||||
- `merge.panorama {ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, maxMegapixels (default: the photos' own resolution), preview, previewPath?}`
|
||||
- `merge.hdrPanorama {ids?, bracket=0 (from EXIF), …both sets of options}`
|
||||
- **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama [options] FILES…`;
|
||||
`lightcraft-cli synth-merge hdr|panorama -o DIR` writes synthetic inputs (procedural scene).
|
||||
@@ -67,5 +67,14 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB)
|
||||
not patch synthesis.
|
||||
- *Create Stack*: the result goes on top of a collapsed stack of its sources (`Catalog::stack_with_ops`); its
|
||||
caption also lists them.
|
||||
- Panorama output is limited to `maxMegapixels` (default 40) — blending memory is ~25 bytes/pixel.
|
||||
- A panorama comes out at its photos' own resolution, at most 512 MP and 65,000 px on its long edge
|
||||
(Lightroom's limits), unless `maxMegapixels` asks for less (issue #1455; it was 40 MP for every panorama).
|
||||
It is held to what fits in memory: three quarters of the computer's RAM less the app's memory budget
|
||||
(a computer whose memory can't be read, Windows, counts as 8 GB), never below 40 MP. Measured peak
|
||||
resident sizes of synthetic stitches (20–81 MP out of 38–154 MP of photos): while the photos are projected
|
||||
they are still held beside the tiles, 12 bytes per photo pixel plus 27–34 per output pixel; the blend that
|
||||
follows, with the photos' pixels freed, peaks at 55–67 bytes per output pixel; the DNG takes less. The
|
||||
limit uses 36 and 68. Five 51 MP photos can make up to 166 MP with 16 GB, 355 MP with 32 GB. The result's
|
||||
`megapixels`, `nativeMegapixels` and `limit` (`memory`, `size` or `asked`) say when it came out smaller,
|
||||
and the app's message says why.
|
||||
- The browser build has no file access for merges yet.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Lightroom parity checklist
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-MERGE-PANO: panoramas at full resolution, held to memory (#1455); integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **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
|
||||
@@ -348,7 +348,7 @@ 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`) | 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`); JPEG brackets treated as linear; 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 |
|
||||
| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap |
|
||||
| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG at the photos' own resolution up to 512 MP / 65,000 px, held to what fits in memory (issue #1455; was a fixed 40 MP); 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` | no dialog; the options of the last merge of that kind (defaults the first time) |
|
||||
|
||||
@@ -815,6 +815,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-13 | patch | LR-BEHAV-AUTOSAVE: a damaged snapshot that still parses is repaired on open (ids, stacks, folder loops, id counters) instead of panicking or hanging; status unchanged |
|
||||
| 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) |
|
||||
| 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). |
|
||||
| 2026-10-13 | minor | LR-MERGE-PANO: a panorama keeps its photos' resolution (up to 512 MP / 65,000 px) instead of a fixed 40 MP, held to what fits in memory (issue #1455); status unchanged |
|
||||
| 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references |
|
||||
| 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) |
|
||||
| 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
## 2026-10-14
|
||||
|
||||
### Full-resolution panoramas
|
||||
- Panorama and HDR Panorama merges keep the resolution of their photos instead of stopping at 40 megapixels
|
||||
(issue #1455), up to 512 MP and 65,000 pixels on the long edge, as in Lightroom. When the full size needs more
|
||||
memory than the computer has, the panorama is made as large as fits (five 51 MP photos: up to 166 MP with
|
||||
16 GB, 355 MP with 32 GB) and the message says so. The merge also frees each photo's pixels once they are
|
||||
projected, so it needs less memory than before at any size (an 81 MP stitch: 4.4 GB instead of 6.2 GB).
|
||||
|
||||
### Hard-edged Heal and Clone spots
|
||||
- A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity
|
||||
changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.
|
||||
|
||||
Reference in New Issue
Block a user