mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 22:49:48 -06:00
Merge branch 'worktree-agent-a454c39b02296b0eb'
# Conflicts: # crates/engine/src/files.rs
This commit is contained in:
@@ -146,6 +146,10 @@ pub struct Photo {
|
||||
/// Hash of the file's bytes (hex), for duplicate detection and preview-cache keys.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content_hash: Option<String>,
|
||||
/// Lens corrections embedded in the file (DNG `WarpRectilinear` / `FixVignetteRadial`), applied when
|
||||
/// "Enable Profile Corrections" is on.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
@@ -173,6 +177,7 @@ impl Photo {
|
||||
duration: None,
|
||||
as_shot_wb: None,
|
||||
content_hash: None,
|
||||
embedded_lens: None,
|
||||
}
|
||||
}
|
||||
pub fn is_edited(&self) -> bool {
|
||||
|
||||
@@ -213,7 +213,13 @@ controls! {
|
||||
"optics.profileDistortion" => optics.profile_distortion, "Profile Distortion", Optics, 0, 200, 100, 1, 0, Plain;
|
||||
"optics.profileVignetting" => optics.profile_vignetting, "Profile Vignetting", Optics, 0, 200, 100, 1, 0, Plain;
|
||||
"optics.defringePurple" => optics.defringe_purple_amount, "Purple Amount", Optics, 0, 20, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#a040d0" };
|
||||
"optics.defringePurpleHueLo" => optics.defringe_purple_hue_lo, "Purple Hue Low", Optics, 0, 90, 30, 1, 0, Gradient { from: "#6040e0", to: "#d040b0" };
|
||||
"optics.defringePurpleHueHi" => optics.defringe_purple_hue_hi, "Purple Hue High", Optics, 10, 100, 70, 1, 0, Gradient { from: "#6040e0", to: "#d040b0" };
|
||||
"optics.defringeGreen" => optics.defringe_green_amount, "Green Amount", Optics, 0, 20, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#40c040" };
|
||||
"optics.defringeGreenHueLo" => optics.defringe_green_hue_lo, "Green Hue Low", Optics, 0, 90, 40, 1, 0, Gradient { from: "#c0d030", to: "#30c0b0" };
|
||||
"optics.defringeGreenHueHi" => optics.defringe_green_hue_hi, "Green Hue High", Optics, 10, 100, 60, 1, 0, Gradient { from: "#c0d030", to: "#30c0b0" };
|
||||
"optics.caRed" => optics.ca_red, "Red/Cyan Fringe", Optics, -100, 100, 0, 1, 0, Gradient { from: "#30c0c0", to: "#e04040" };
|
||||
"optics.caBlue" => optics.ca_blue, "Blue/Yellow Fringe", Optics, -100, 100, 0, 1, 0, Gradient { from: "#d0c030", to: "#4060e0" };
|
||||
"geometry.vertical" => geometry.vertical, "Vertical", Geometry, -100, 100, 0, 1, 0, Centered;
|
||||
"geometry.horizontal" => geometry.horizontal, "Horizontal", Geometry, -100, 100, 0, 1, 0, Centered;
|
||||
"geometry.rotate" => geometry.rotate, "Rotate", Geometry, -10, 10, 0, 0.1, 1, Centered;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The develop settings schema. Every field has a neutral default; serde uses `#[serde(default)]` so
|
||||
//! older/newer files load (unknown fields are ignored, missing fields take defaults).
|
||||
|
||||
use lightcraft_geom::{CropGeometry, Orientation, Point};
|
||||
use lightcraft_geom::{CropGeometry, Homography, Orientation, Point};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
@@ -419,6 +419,10 @@ pub struct Optics {
|
||||
pub distortion: f64,
|
||||
pub vignetting: f64,
|
||||
pub vignetting_midpoint: f64,
|
||||
/// Manual lateral chromatic aberration (red/cyan, blue/yellow fringes), −100..100, added to the
|
||||
/// automatic estimate when `remove_ca` is on.
|
||||
pub ca_red: f64,
|
||||
pub ca_blue: f64,
|
||||
}
|
||||
|
||||
impl Default for Optics {
|
||||
@@ -437,10 +441,42 @@ impl Default for Optics {
|
||||
distortion: 0.0,
|
||||
vignetting: 0.0,
|
||||
vignetting_midpoint: 50.0,
|
||||
ca_red: 0.0,
|
||||
ca_blue: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lens corrections embedded in a DNG file (`OpcodeList3`: `WarpRectilinear`, `FixVignetteRadial`), converted
|
||||
/// to the oriented, default-cropped image. This is camera/file data (stored on the photo record, not in the develop
|
||||
/// settings); "Enable Profile Corrections" applies it, scaled by the profile distortion/vignetting amounts.
|
||||
/// LightCraft never uses Adobe LCP lens profiles.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct EmbeddedLens {
|
||||
pub warp: Option<EmbeddedWarp>,
|
||||
pub vignette: Option<EmbeddedVignette>,
|
||||
}
|
||||
|
||||
/// DNG `WarpRectilinear`: per plane (R, G, B) `[kr0, kr1, kr2, kr3, kt0, kt1]`. For an output (corrected) point at
|
||||
/// offset `d = (p − center) / radius`, the source point is `center + radius · (d·f(r²) + tangential(d))`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbeddedWarp {
|
||||
pub planes: [[f64; 6]; 3],
|
||||
/// Optical centre, normalized to the oriented image (0..1).
|
||||
pub center: Point,
|
||||
/// Normalisation radius as a fraction of the oriented image's long edge.
|
||||
pub radius: f64,
|
||||
}
|
||||
|
||||
/// DNG `FixVignetteRadial`: gain `1 + k0 r² + k1 r⁴ + … + k4 r¹⁰` with `r = |p − center| / radius`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbeddedVignette {
|
||||
pub k: [f64; 5],
|
||||
pub center: Point,
|
||||
pub radius: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Upright {
|
||||
@@ -457,8 +493,13 @@ pub enum Upright {
|
||||
#[serde(default)]
|
||||
pub struct Geometry {
|
||||
pub upright: Upright,
|
||||
/// Guided Upright guides (normalized image coords), up to 4 line segments.
|
||||
/// Guided Upright guides, up to 4 line segments, in normalized coordinates of the lens-corrected
|
||||
/// (pre-perspective) oriented image.
|
||||
pub guides: Vec<(Point, Point)>,
|
||||
/// The automatic Upright correction found by analysis for `upright` (Auto/Level/Vertical/Full), as a
|
||||
/// homography lens-corrected → transformed in centred coordinates (`(p − centre) / (long edge / 2)`).
|
||||
/// Stored so previews and exports use the identical transform; recomputed by `geometry.upright`.
|
||||
pub upright_transform: Option<Homography>,
|
||||
pub vertical: f64,
|
||||
pub horizontal: f64,
|
||||
pub rotate: f64,
|
||||
@@ -474,6 +515,7 @@ impl Default for Geometry {
|
||||
Self {
|
||||
upright: Upright::Off,
|
||||
guides: Vec::new(),
|
||||
upright_transform: None,
|
||||
vertical: 0.0,
|
||||
horizontal: 0.0,
|
||||
rotate: 0.0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Develop commands. All operate on the active photo unless `ids` are given (sync).
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Version};
|
||||
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, WbMode, controls};
|
||||
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
|
||||
use lightcraft_geom::{CropGeometry, Point, Rect, crop_fit_angle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -382,6 +382,66 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
// ---- geometry: Upright
|
||||
cmd!(
|
||||
"geometry.upright",
|
||||
"Upright",
|
||||
[],
|
||||
None,
|
||||
"{mode: off|auto|guided|level|vertical|full} — analyses the photo (line segments → vanishing points) and stores the correction; run again to update after lens changes",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let mode: Upright =
|
||||
serde_json::from_value(p.get("mode").cloned().unwrap_or(json!("auto"))).map_err(|e| bad("geometry.upright", e.to_string()))?;
|
||||
let id = active(s, "geometry.upright")?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let transform = match mode {
|
||||
Upright::Off | Upright::Guided => None,
|
||||
m => {
|
||||
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("geometry.upright", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
Some(lightcraft_pipeline::upright::auto_transform(&src, &info, &d, m))
|
||||
}
|
||||
};
|
||||
edit(s, "geometry.upright", "Upright", |d| {
|
||||
d.geometry.upright = mode;
|
||||
d.geometry.upright_transform = transform;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"mode": mode, "transform": transform}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"geometry.guides",
|
||||
"Guided Upright Guides",
|
||||
[],
|
||||
None,
|
||||
"{guides: [[x0,y0,x1,y1], …] (≤ 4, normalized coords of the lens-corrected image), add?: bool (append one guide, dropping the oldest beyond 4)} — switches Upright to Guided",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let parse = |v: &Value| -> Option<(Point, Point)> {
|
||||
let a: Vec<f64> = v.as_array()?.iter().filter_map(Value::as_f64).collect();
|
||||
(a.len() == 4 && a.iter().all(|x| x.is_finite())).then(|| (Point::new(a[0], a[1]), Point::new(a[2], a[3])))
|
||||
};
|
||||
let guides: Vec<(Point, Point)> =
|
||||
p.get("guides").and_then(Value::as_array).map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default();
|
||||
let add = bool_or(p, "add", false);
|
||||
edit(s, "geometry.guides", "Guided Upright", |d| {
|
||||
if add {
|
||||
d.geometry.guides.extend(guides);
|
||||
} else {
|
||||
d.geometry.guides = guides;
|
||||
}
|
||||
let n = d.geometry.guides.len();
|
||||
if n > 4 {
|
||||
d.geometry.guides.drain(..n - 4);
|
||||
}
|
||||
d.geometry.upright = Upright::Guided;
|
||||
d.geometry.upright_transform = None;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
),
|
||||
// ---- copy / paste / sync
|
||||
cmd!(
|
||||
"develop.copy",
|
||||
|
||||
@@ -34,6 +34,54 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
|
||||
}
|
||||
|
||||
/// Lens corrections embedded in a DNG's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`), re-expressed for
|
||||
/// the default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies.
|
||||
pub fn embedded_lens(raw: &lightcraft_raw::RawImage) -> Option<lightcraft_develop::EmbeddedLens> {
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
|
||||
use lightcraft_geom::Point;
|
||||
let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64);
|
||||
if aw < 2.0 || ah < 2.0 {
|
||||
return None;
|
||||
}
|
||||
let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
|
||||
let (cx0, cy0, cw, ch) =
|
||||
if c.width == 0 || c.height == 0 { (0.0, 0.0, aw, ah) } else { (c.x as f64, c.y as f64, c.width as f64, c.height as f64) };
|
||||
let long = cw.max(ch);
|
||||
// opcode centres are relative to the (uncropped) active area, in pixel-index units
|
||||
let centre = |rel: [f64; 2]| -> (Point, f64) {
|
||||
let (px, py) = (rel[0] * (aw - 1.0), rel[1] * (ah - 1.0));
|
||||
let m = [(0.0, 0.0), (aw - 1.0, 0.0), (0.0, ah - 1.0), (aw - 1.0, ah - 1.0)]
|
||||
.iter()
|
||||
.map(|&(x, y)| (x - px).hypot(y - py))
|
||||
.fold(0.0, f64::max)
|
||||
.max(1e-9);
|
||||
(Point::new((px + 0.5 - cx0) / cw, (py + 0.5 - cy0) / ch), m / long)
|
||||
};
|
||||
let mut lens = EmbeddedLens::default();
|
||||
for op in &raw.opcodes.list3 {
|
||||
match op {
|
||||
lightcraft_raw::Opcode::WarpRectilinear { planes, center } if !planes.is_empty() && lens.warp.is_none() => {
|
||||
let (center, radius) = centre(*center);
|
||||
let p = |i: usize| planes[i.min(planes.len() - 1)];
|
||||
lens.warp = Some(EmbeddedWarp { planes: [p(0), p(1), p(2)], center, radius });
|
||||
}
|
||||
lightcraft_raw::Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => {
|
||||
let (center, radius) = centre(*center);
|
||||
lens.vignette = Some(EmbeddedVignette { k: *k, center, radius });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if lens.warp.is_none() && lens.vignette.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch))
|
||||
}
|
||||
|
||||
fn is_lens_opcode(op: &lightcraft_raw::Opcode) -> bool {
|
||||
matches!(op, lightcraft_raw::Opcode::WarpRectilinear { .. } | lightcraft_raw::Opcode::FixVignetteRadial { .. })
|
||||
}
|
||||
|
||||
fn ext_upper(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default()
|
||||
}
|
||||
@@ -60,6 +108,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
as_shot_wb: None,
|
||||
content_hash,
|
||||
xmp: lightcraft_meta::embedded(bytes).xmp,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
@@ -73,7 +122,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
}
|
||||
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let as_shot_wb = Some((t.round(), tint.round()));
|
||||
let embedded_lens = embedded_lens(&raw);
|
||||
return Ok(ProbeInfo {
|
||||
embedded_lens,
|
||||
width: w,
|
||||
height: h,
|
||||
format: ext_upper(name),
|
||||
@@ -111,6 +162,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
meta,
|
||||
as_shot_wb: None,
|
||||
content_hash,
|
||||
embedded_lens: None,
|
||||
xmp: None,
|
||||
})
|
||||
}
|
||||
@@ -118,7 +170,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented.
|
||||
pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
|
||||
if lightcraft_raw::probe(bytes).is_some() {
|
||||
let raw = match lightcraft_raw::decode(bytes) {
|
||||
let mut raw = match lightcraft_raw::decode(bytes) {
|
||||
Ok(r) => r,
|
||||
Err(lightcraft_raw::RawError::Unsupported(why)) => {
|
||||
// show the camera's embedded JPEG (rendered, not raw) until the variant is supported
|
||||
@@ -126,6 +178,9 @@ pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo),
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in.
|
||||
let lens = embedded_lens(&raw);
|
||||
raw.opcodes.list3.retain(|op| !is_lens_opcode(op));
|
||||
let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
|
||||
let mut img = raw.develop(method).map_err(|e| e.to_string())?;
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
@@ -144,7 +199,7 @@ pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo),
|
||||
});
|
||||
let img = fit(&img, max_edge, max_edge, Filter::Box).oriented(raw.orientation);
|
||||
let (temp, tint) = xy_to_temp_tint(xy);
|
||||
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round() }));
|
||||
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round(), lens }));
|
||||
}
|
||||
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?;
|
||||
let img = d.to_working();
|
||||
|
||||
@@ -223,6 +223,11 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res
|
||||
if let Some((t, tint)) = info.as_shot_wb {
|
||||
p.develop = std::sync::Arc::new(lightcraft_develop::DevelopSettings::for_raw(t, tint));
|
||||
}
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
if p.embedded_lens.is_some() {
|
||||
// built-in (file-embedded) lens corrections are on by default, like the camera intended
|
||||
std::sync::Arc::make_mut(&mut p.develop).optics.lens_profile = true;
|
||||
}
|
||||
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
|
||||
@@ -220,9 +220,13 @@ pub fn content_key(p: &Photo) -> String {
|
||||
pub fn source_info(p: &Photo) -> SourceInfo {
|
||||
// Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map.
|
||||
if matches!(p.source, Source::Demo { .. }) {
|
||||
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0 };
|
||||
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None };
|
||||
}
|
||||
if p.kind == MediaKind::Raw {
|
||||
SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens }
|
||||
} else {
|
||||
SourceInfo::default()
|
||||
}
|
||||
if p.kind == MediaKind::Raw { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0 } } else { SourceInfo::default() }
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
@@ -301,6 +305,8 @@ pub struct ProbeInfo {
|
||||
pub as_shot_wb: Option<(f64, f64)>,
|
||||
/// Hash of the file's bytes (hex), for duplicate detection.
|
||||
pub content_hash: Option<String>,
|
||||
/// Lens corrections embedded in the file (DNG opcodes).
|
||||
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
|
||||
/// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file.
|
||||
pub xmp: Option<String>,
|
||||
}
|
||||
|
||||
@@ -166,3 +166,26 @@ fn command_sweep() {
|
||||
}
|
||||
assert!(s.commands().len() >= 70, "{}", s.commands().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_and_guides_commands() {
|
||||
let mut s = demo();
|
||||
let r = s.execute("geometry.upright", &json!({"mode": "level"})).unwrap();
|
||||
assert_eq!(r["mode"], "level");
|
||||
let d = active_dev(&s);
|
||||
assert_eq!(d.geometry.upright, lightcraft_develop::Upright::Level);
|
||||
assert!(d.geometry.upright_transform.is_some(), "analysis result is stored");
|
||||
s.execute("geometry.upright", &json!({"mode": "off"})).unwrap();
|
||||
assert!(active_dev(&s).geometry.upright_transform.is_none());
|
||||
for i in 0..5 {
|
||||
let x = 0.1 + 0.15 * i as f64;
|
||||
s.execute("geometry.guides", &json!({"guides": [[x, 0.2, x + 0.02, 0.8]], "add": true})).unwrap();
|
||||
}
|
||||
let d = active_dev(&s);
|
||||
assert_eq!(d.geometry.upright, lightcraft_develop::Upright::Guided);
|
||||
assert_eq!(d.geometry.guides.len(), 4, "at most four guides");
|
||||
assert!((d.geometry.guides[0].0.x - 0.25).abs() < 1e-9, "oldest dropped");
|
||||
let id = s.active().unwrap();
|
||||
assert!(s.render_now(id, 160, 160).is_ok());
|
||||
assert!(s.execute("geometry.upright", &json!({"mode": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
//! Output framing: user orientation, crop + straighten, flips — and the mapping between output
|
||||
//! pixels and normalized source coordinates (used by masks, spots and on-canvas tools).
|
||||
//! Output framing: user orientation, lens corrections and perspective ([`Warp`]), crop + straighten,
|
||||
//! flips — and the mapping between output pixels and normalized coordinates (used by masks, spots and
|
||||
//! on-canvas tools).
|
||||
//!
|
||||
//! Normalized coordinates (masks, spots, crop) refer to the *transformed* image: the oriented source after
|
||||
//! lens correction and perspective, at the source's size. Everything is sampled in one resample.
|
||||
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_geom::{Affine, CropGeometry, Orientation, Point, Rect};
|
||||
use lightcraft_develop::{DevelopSettings, EmbeddedLens};
|
||||
use lightcraft_geom::{Affine, CropGeometry, Homography, Orientation, Point, Rect};
|
||||
use lightcraft_raster::resample::{Filter, resize};
|
||||
use lightcraft_raster::{Rgb32f, par_rows};
|
||||
|
||||
use crate::optics::{Warp, reorient_lens};
|
||||
|
||||
/// Linear value of areas outside the source after a warp (a light neutral, like an empty canvas).
|
||||
pub const BLANK: [f32; 3] = [0.55, 0.55, 0.55];
|
||||
|
||||
/// The geometric frame of a render.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Frame {
|
||||
@@ -20,14 +29,104 @@ pub struct Frame {
|
||||
pub crop: CropGeometry,
|
||||
pub flip_h: bool,
|
||||
pub flip_v: bool,
|
||||
/// Lens corrections / perspective (None = identity).
|
||||
pub warp: Option<Warp>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub fn new(src_w: usize, src_h: usize, s: &DevelopSettings, apply_crop: bool) -> Frame {
|
||||
Frame::with_lens(src_w, src_h, s, apply_crop, None)
|
||||
}
|
||||
|
||||
/// Like [`Frame::new`], with the source's embedded lens corrections (relative to the EXIF-oriented source).
|
||||
pub fn with_lens(src_w: usize, src_h: usize, s: &DevelopSettings, apply_crop: bool, lens: Option<&EmbeddedLens>) -> Frame {
|
||||
let orient = s.orientation;
|
||||
let (ow, oh) = if orient.swaps_axes() { (src_h as f64, src_w as f64) } else { (src_w as f64, src_h as f64) };
|
||||
let crop = if apply_crop { s.crop.geometry } else { CropGeometry::default() };
|
||||
Frame { src_w, src_h, orient, ow, oh, crop, flip_h: apply_crop && s.crop.flip_h, flip_v: apply_crop && s.crop.flip_v }
|
||||
let lens = lens.map(|l| reorient_lens(l, orient, src_w as f64, src_h as f64));
|
||||
let mut warp = Warp::from_settings(ow, oh, s, lens.as_ref());
|
||||
let persp = perspective(s, ow, oh);
|
||||
if persp != Homography::IDENTITY
|
||||
&& let Some(inv) = persp.inverse()
|
||||
{
|
||||
warp.persp = persp;
|
||||
warp.persp_inv = inv;
|
||||
}
|
||||
let mut f = Frame {
|
||||
src_w,
|
||||
src_h,
|
||||
orient,
|
||||
ow,
|
||||
oh,
|
||||
crop,
|
||||
flip_h: apply_crop && s.crop.flip_h,
|
||||
flip_v: apply_crop && s.crop.flip_v,
|
||||
warp: (!warp.is_identity()).then_some(warp),
|
||||
};
|
||||
if apply_crop && s.geometry.constrain_crop {
|
||||
f.constrain_to_image();
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// "Constrain Crop": shrink the crop about its centre until it contains no area outside the warped source.
|
||||
fn constrain_to_image(&mut self) {
|
||||
let Some(wp) = self.warp.as_ref().filter(|w| w.moves_pixels()) else { return };
|
||||
let a = self.crop.output_to_source(self.ow, self.oh, 1.0, 1.0);
|
||||
let (w, h) = (self.ow, self.oh);
|
||||
let fits = |t: f64| {
|
||||
(0..=48).all(|i| {
|
||||
let u = i as f64 / 48.0;
|
||||
[(u, 0.0), (u, 1.0), (0.0, u), (1.0, u)].iter().all(|&(x, y)| {
|
||||
let p = Point::new(0.5 + t * (x - 0.5), 0.5 + t * (y - 0.5));
|
||||
let s = wp.to_source(a.apply(p), 1);
|
||||
s.x >= -1e-6 * w && s.y >= -1e-6 * h && s.x <= w * (1.0 + 1e-6) && s.y <= h * (1.0 + 1e-6)
|
||||
})
|
||||
})
|
||||
};
|
||||
if fits(1.0) {
|
||||
return;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0, 1.0);
|
||||
for _ in 0..30 {
|
||||
let m = (lo + hi) / 2.0;
|
||||
if fits(m) { lo = m } else { hi = m }
|
||||
}
|
||||
let t = lo.max(0.02);
|
||||
let r = self.crop.rect;
|
||||
self.crop.rect = Rect::from_center(r.center(), r.width() * t, r.height() * t);
|
||||
}
|
||||
|
||||
/// Add automatically estimated lateral CA (`[α_R, α_B]`, see [`crate::optics::estimate_lateral_ca`]).
|
||||
pub fn add_lateral_ca(&mut self, ca: [f64; 2]) {
|
||||
if ca == [0.0; 2] {
|
||||
return;
|
||||
}
|
||||
let w = self.warp.get_or_insert_with(|| Warp::identity(self.ow, self.oh));
|
||||
w.ca[0] += ca[0];
|
||||
w.ca[2] += ca[1];
|
||||
}
|
||||
|
||||
/// Map a point of the transformed image (normalized) to the lens-corrected, pre-perspective image (normalized).
|
||||
pub fn transformed_to_corrected(&self, p: Point) -> Point {
|
||||
match &self.warp {
|
||||
Some(w) => {
|
||||
let q = w.to_corrected(Point::new(p.x * self.ow, p.y * self.oh));
|
||||
Point::new(q.x / self.ow, q.y / self.oh)
|
||||
}
|
||||
None => p,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`Frame::transformed_to_corrected`].
|
||||
pub fn corrected_to_transformed(&self, p: Point) -> Point {
|
||||
match &self.warp {
|
||||
Some(w) => {
|
||||
let q = w.from_corrected(Point::new(p.x * self.ow, p.y * self.oh));
|
||||
Point::new(q.x / self.ow, q.y / self.oh)
|
||||
}
|
||||
None => p,
|
||||
}
|
||||
}
|
||||
|
||||
/// Aspect ratio (w/h) of the output.
|
||||
@@ -92,7 +191,11 @@ impl Frame {
|
||||
} else {
|
||||
(o.clone(), 1.0)
|
||||
};
|
||||
let xf = Affine::scale(s, base.height as f64 / o.height as f64) * self.out_to_oriented(w, h);
|
||||
let (sx, sy) = (s, base.height as f64 / o.height as f64);
|
||||
if let Some(wp) = &self.warp {
|
||||
return sample_warped(&base, sx, sy, wp, self.out_to_oriented(w, h), w, h);
|
||||
}
|
||||
let xf = Affine::scale(sx, sy) * self.out_to_oriented(w, h);
|
||||
let identity_like = self.crop.is_identity() && !self.flip_h && !self.flip_v && base.width == w && base.height == h;
|
||||
if identity_like {
|
||||
return base;
|
||||
@@ -118,6 +221,47 @@ impl Frame {
|
||||
}
|
||||
}
|
||||
|
||||
/// The perspective homography of the settings (Upright, then the manual Transform sliders), lens-corrected →
|
||||
/// transformed, in centred coordinates.
|
||||
pub fn perspective(s: &DevelopSettings, ow: f64, oh: f64) -> Homography {
|
||||
if !s.section_enabled("geometry") {
|
||||
return Homography::IDENTITY;
|
||||
}
|
||||
crate::transform::manual(&s.geometry).mul(&crate::upright::homography(&s.geometry, ow, oh))
|
||||
}
|
||||
|
||||
/// One resample through the warp: `o2t` maps output px → transformed px, the warp maps those to source px
|
||||
/// (scaled by `sx, sy` into `base`).
|
||||
fn sample_warped(base: &Rgb32f, sx: f64, sy: f64, wp: &Warp, o2t: Affine, w: usize, h: usize) -> Rgb32f {
|
||||
let per_channel = wp.per_channel();
|
||||
let gain = wp.has_gain();
|
||||
let mut out = Rgb32f::new(w, h);
|
||||
par_rows(&mut out.data, w, |y, row| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let t = o2t.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
|
||||
let c = wp.to_corrected(t);
|
||||
let g = wp.corrected_to_source(c, 1);
|
||||
if !wp.inside(g) {
|
||||
*px = BLANK;
|
||||
continue;
|
||||
}
|
||||
let mut v = base.sample_bilinear((g.x * sx) as f32, (g.y * sy) as f32);
|
||||
if per_channel {
|
||||
for ch in [0usize, 2] {
|
||||
let q = wp.corrected_to_source(c, ch);
|
||||
v[ch] = base.sample_bilinear((q.x * sx) as f32, (q.y * sy) as f32)[ch];
|
||||
}
|
||||
}
|
||||
if gain {
|
||||
let k = wp.gain(g);
|
||||
v = v.map(|c| c * k);
|
||||
}
|
||||
*px = v;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Whole-image rectangle in normalized coordinates.
|
||||
pub const FULL: Rect = Rect::UNIT;
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! requested size, and its histogram.
|
||||
//!
|
||||
//! Stage order (see `docs/pipeline.md`):
|
||||
//! 1. geometry — user orientation, crop + straighten, flips; one resample at output resolution
|
||||
//! 1. geometry — user orientation, lens corrections (distortion, CA, vignetting), perspective, crop +
|
||||
//! straighten, flips; one resample at output resolution; then defringe
|
||||
//! 2. scene-linear — white balance, exposure, dehaze, local tone (highlights/shadows), texture,
|
||||
//! clarity, local adjustments (masks)
|
||||
//! 3. tone map — contrast / whites / blacks filmic curve on luminance, highlight desaturation
|
||||
@@ -22,9 +23,12 @@ mod finish;
|
||||
pub mod geometry;
|
||||
mod local;
|
||||
pub mod masks;
|
||||
pub mod optics;
|
||||
pub mod profiles;
|
||||
pub mod spots;
|
||||
mod tone;
|
||||
pub mod transform;
|
||||
pub mod upright;
|
||||
|
||||
use lightcraft_develop::{DevelopSettings, Treatment};
|
||||
use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8, par_rows};
|
||||
@@ -34,6 +38,8 @@ pub use tone::ToneMap;
|
||||
/// Facts about the source the settings are interpreted against.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SourceInfo {
|
||||
/// Lens corrections embedded in the file (DNG opcodes), relative to the EXIF-oriented source.
|
||||
pub lens: Option<lightcraft_develop::EmbeddedLens>,
|
||||
/// Raw sources use absolute Kelvin white balance; rendered sources use a relative scale around
|
||||
/// their as-shot white.
|
||||
pub raw: bool,
|
||||
@@ -43,7 +49,7 @@ pub struct SourceInfo {
|
||||
|
||||
impl Default for SourceInfo {
|
||||
fn default() -> Self {
|
||||
Self { raw: false, as_shot_temp: 6500.0, as_shot_tint: 0.0 }
|
||||
Self { raw: false, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +101,15 @@ pub fn output_size(src_w: usize, src_h: usize, s: &DevelopSettings, req: &Render
|
||||
geometry::Frame::new(src_w, src_h, s, req.apply_crop).fit(req.max_w, req.max_h)
|
||||
}
|
||||
|
||||
/// The geometric frame `render` uses for `src` (lens data, automatic CA estimate included).
|
||||
pub fn frame_for(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, apply_crop: bool) -> geometry::Frame {
|
||||
let mut frame = geometry::Frame::with_lens(src.width, src.height, s, apply_crop, info.lens.as_ref());
|
||||
if s.optics.remove_ca && s.section_enabled("optics") {
|
||||
frame.add_lateral_ca(optics::estimate_lateral_ca(src));
|
||||
}
|
||||
frame
|
||||
}
|
||||
|
||||
/// Render `src` with settings `s`.
|
||||
pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Rendered {
|
||||
let prof = std::env::var_os("LIGHTCRAFT_PROFILE").is_some();
|
||||
@@ -107,13 +122,15 @@ pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &Render
|
||||
};
|
||||
let mut t = prof.then(std::time::Instant::now);
|
||||
let s = &*profiles::effective(s);
|
||||
let frame = geometry::Frame::new(src.width, src.height, s, req.apply_crop);
|
||||
let s = &*upright::resolve(src, info, s);
|
||||
let frame = frame_for(src, info, s, req.apply_crop);
|
||||
let (w, h) = frame.fit(req.max_w, req.max_h);
|
||||
let mut img = frame.sample(src, w, h);
|
||||
lap("sample", &mut t);
|
||||
let px_per_long = frame.px_per_long(w);
|
||||
|
||||
local::scene_linear_pre(&mut img, info, s);
|
||||
optics::defringe(&mut img, s, px_per_long / optics::DEFRINGE_REF_LONG);
|
||||
spots::apply(&mut img, &s.spots, &frame, px_per_long);
|
||||
local::denoise(&mut img, s, src.width.max(src.height), w.max(h));
|
||||
lap("wb/exposure/spots/nr", &mut t);
|
||||
@@ -162,3 +179,5 @@ pub(crate) fn for_rows<T: Send>(data: &mut [T], w: usize, f: impl Fn(usize, &mut
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_geometry;
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
//! Lens corrections (Optics) and the geometric warp.
|
||||
//!
|
||||
//! [`Warp`] is the complete inverse map from the *transformed* image (what the user sees before the crop:
|
||||
//! after lens correction and perspective) back to the source pixels, evaluated once per output pixel inside the
|
||||
//! single geometry resample:
|
||||
//!
|
||||
//! transformed px → (inverse perspective) → lens-corrected px → (manual distortion, embedded DNG warp,
|
||||
//! per-channel lateral CA scale) → source px
|
||||
//!
|
||||
//! plus a radial vignetting gain evaluated at the source position. Every parameter is relative (normalized radii,
|
||||
//! dimensionless scales), so a 400 px preview and a full-size export are warped identically.
|
||||
//!
|
||||
//! Also here: automatic lateral chromatic aberration estimation ([`estimate_lateral_ca`]) and [`defringe`].
|
||||
//! Profile corrections use only lens data embedded in DNG files (`WarpRectilinear` / `FixVignetteRadial`).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use lightcraft_color::perceptual::{oklab_from_2020, oklab_to_2020};
|
||||
use lightcraft_develop::{DevelopSettings, EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
|
||||
use lightcraft_geom::{Homography, Orientation, Point};
|
||||
use lightcraft_raster::{Plane, Rgb32f};
|
||||
|
||||
use crate::for_rows;
|
||||
|
||||
/// Manual "Distortion" ±100 → radial coefficient (r normalized to the half diagonal).
|
||||
const DISTORTION_K: f64 = 0.2;
|
||||
/// Manual vignetting ±100 → stops of gain at the corners.
|
||||
const VIGNETTE_STOPS: f64 = 1.5;
|
||||
/// Manual CA ±100 → radial scale difference of a colour plane.
|
||||
const CA_SCALE: f64 = 0.003;
|
||||
|
||||
/// The inverse map transformed → source, in pixels of the (user-)oriented source image `w × h`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Warp {
|
||||
pub w: f64,
|
||||
pub h: f64,
|
||||
/// Forward perspective (lens-corrected → transformed), in centred coordinates `(p − centre) / (long edge / 2)`.
|
||||
pub persp: Homography,
|
||||
/// Its inverse (transformed → lens-corrected).
|
||||
pub persp_inv: Homography,
|
||||
/// Manual radial distortion: source radius = r·(1 + k1·r²), r relative to the half diagonal.
|
||||
pub k1: f64,
|
||||
/// Manual vignetting: gain = 2^(amount·r^power) stops at source radius r (half-diagonal units).
|
||||
pub vig_stops: f64,
|
||||
pub vig_power: f64,
|
||||
/// Lateral CA: per-channel radial scale about the image centre (R, G, B).
|
||||
pub ca: [f64; 3],
|
||||
/// Embedded DNG lens corrections (already in this oriented frame) and their strengths (1 = as recorded).
|
||||
pub lens: Option<EmbeddedLens>,
|
||||
pub lens_dist: f64,
|
||||
pub lens_vig: f64,
|
||||
}
|
||||
|
||||
impl Warp {
|
||||
pub fn identity(w: f64, h: f64) -> Warp {
|
||||
Warp {
|
||||
w,
|
||||
h,
|
||||
persp: Homography::IDENTITY,
|
||||
persp_inv: Homography::IDENTITY,
|
||||
k1: 0.0,
|
||||
vig_stops: 0.0,
|
||||
vig_power: 4.0,
|
||||
ca: [0.0; 3],
|
||||
lens: None,
|
||||
lens_dist: 0.0,
|
||||
lens_vig: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The lens part of the warp from the settings (optics section) and optional embedded lens data.
|
||||
pub fn from_settings(w: f64, h: f64, s: &DevelopSettings, lens: Option<&EmbeddedLens>) -> Warp {
|
||||
let mut wp = Warp::identity(w, h);
|
||||
if !s.section_enabled("optics") {
|
||||
return wp;
|
||||
}
|
||||
let o = &s.optics;
|
||||
wp.k1 = -DISTORTION_K * o.distortion / 100.0;
|
||||
wp.vig_stops = VIGNETTE_STOPS * o.vignetting / 100.0;
|
||||
wp.vig_power = 1.0 + 6.0 * (o.vignetting_midpoint / 100.0).clamp(0.0, 1.0);
|
||||
wp.ca = [CA_SCALE * o.ca_red / 100.0, 0.0, CA_SCALE * o.ca_blue / 100.0];
|
||||
if o.lens_profile
|
||||
&& let Some(l) = lens
|
||||
{
|
||||
wp.lens = Some(*l);
|
||||
wp.lens_dist = (o.profile_distortion / 100.0).clamp(0.0, 2.0);
|
||||
wp.lens_vig = (o.profile_vignetting / 100.0).clamp(0.0, 2.0);
|
||||
}
|
||||
wp
|
||||
}
|
||||
|
||||
fn half_long(&self) -> f64 {
|
||||
self.w.max(self.h) / 2.0
|
||||
}
|
||||
|
||||
fn centre(&self) -> Point {
|
||||
Point::new(self.w / 2.0, self.h / 2.0)
|
||||
}
|
||||
|
||||
/// Whether pixels move at all.
|
||||
pub fn moves_pixels(&self) -> bool {
|
||||
self.persp != Homography::IDENTITY
|
||||
|| self.k1 != 0.0
|
||||
|| self.ca.iter().any(|c| *c != 0.0)
|
||||
|| (self.lens_dist != 0.0 && self.lens.is_some_and(|l| l.warp.is_some()))
|
||||
}
|
||||
|
||||
/// Whether the colour planes are sampled at different positions.
|
||||
pub fn per_channel(&self) -> bool {
|
||||
self.ca[0] != self.ca[1]
|
||||
|| self.ca[1] != self.ca[2]
|
||||
|| (self.lens_dist != 0.0 && self.lens.and_then(|l| l.warp).is_some_and(|w| w.planes[0] != w.planes[1] || w.planes[1] != w.planes[2]))
|
||||
}
|
||||
|
||||
pub fn has_gain(&self) -> bool {
|
||||
self.vig_stops != 0.0 || (self.lens_vig != 0.0 && self.lens.is_some_and(|l| l.vignette.is_some()))
|
||||
}
|
||||
|
||||
pub fn is_identity(&self) -> bool {
|
||||
!self.moves_pixels() && !self.has_gain()
|
||||
}
|
||||
|
||||
/// Transformed px → lens-corrected px (inverse perspective).
|
||||
pub fn to_corrected(&self, p: Point) -> Point {
|
||||
if self.persp == Homography::IDENTITY {
|
||||
return p;
|
||||
}
|
||||
let (c, l) = (self.centre(), self.half_long());
|
||||
let q = self.persp_inv.apply(Point::new((p.x - c.x) / l, (p.y - c.y) / l));
|
||||
Point::new(c.x + q.x * l, c.y + q.y * l)
|
||||
}
|
||||
|
||||
/// Lens-corrected px → transformed px (forward perspective).
|
||||
pub fn from_corrected(&self, p: Point) -> Point {
|
||||
if self.persp == Homography::IDENTITY {
|
||||
return p;
|
||||
}
|
||||
let (c, l) = (self.centre(), self.half_long());
|
||||
let q = self.persp.apply(Point::new((p.x - c.x) / l, (p.y - c.y) / l));
|
||||
Point::new(c.x + q.x * l, c.y + q.y * l)
|
||||
}
|
||||
|
||||
/// Lens-corrected px → source px for colour plane `ch` (0 = R, 1 = G, 2 = B).
|
||||
pub fn corrected_to_source(&self, p: Point, ch: usize) -> Point {
|
||||
let c = self.centre();
|
||||
let hd = (self.w.hypot(self.h) / 2.0).max(1e-9);
|
||||
let mut q = p;
|
||||
if self.k1 != 0.0 {
|
||||
let (dx, dy) = (q.x - c.x, q.y - c.y);
|
||||
let r2 = (dx * dx + dy * dy) / (hd * hd);
|
||||
let f = 1.0 + self.k1 * r2;
|
||||
q = Point::new(c.x + dx * f, c.y + dy * f);
|
||||
}
|
||||
if self.lens_dist != 0.0
|
||||
&& let Some(wp) = self.lens.and_then(|l| l.warp)
|
||||
{
|
||||
let s = embedded_warp(&wp, q, ch, self.w, self.h);
|
||||
q = Point::new(q.x + (s.x - q.x) * self.lens_dist, q.y + (s.y - q.y) * self.lens_dist);
|
||||
}
|
||||
let k = self.ca[ch];
|
||||
if k != 0.0 {
|
||||
q = Point::new(c.x + (q.x - c.x) * (1.0 + k), c.y + (q.y - c.y) * (1.0 + k));
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
/// Transformed px → source px for plane `ch`.
|
||||
pub fn to_source(&self, p: Point, ch: usize) -> Point {
|
||||
self.corrected_to_source(self.to_corrected(p), ch)
|
||||
}
|
||||
|
||||
/// Vignetting compensation gain at source position `src` (px).
|
||||
pub fn gain(&self, src: Point) -> f32 {
|
||||
let mut g = 1.0;
|
||||
if self.vig_stops != 0.0 {
|
||||
let hd = (self.w.hypot(self.h) / 2.0).max(1e-9);
|
||||
let r = (src.dist(self.centre()) / hd).min(1.5);
|
||||
g *= (self.vig_stops * r.powf(self.vig_power)).exp2();
|
||||
}
|
||||
if self.lens_vig != 0.0
|
||||
&& let Some(v) = self.lens.and_then(|l| l.vignette)
|
||||
{
|
||||
g *= 1.0 + (embedded_vignette_gain(&v, src, self.w, self.h) - 1.0) * self.lens_vig;
|
||||
}
|
||||
g as f32
|
||||
}
|
||||
|
||||
/// Whether a source position lies on the image (with a half-pixel margin).
|
||||
pub fn inside(&self, s: Point) -> bool {
|
||||
s.x >= -0.5 && s.y >= -0.5 && s.x <= self.w + 0.5 && s.y <= self.h + 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// DNG `WarpRectilinear` source position for corrected position `p` (px) and plane `ch`.
|
||||
pub fn embedded_warp(wp: &EmbeddedWarp, p: Point, ch: usize, w: f64, h: f64) -> Point {
|
||||
let (cx, cy) = (wp.center.x * w, wp.center.y * h);
|
||||
let m = (wp.radius * w.max(h)).max(1e-9);
|
||||
let [kr0, kr1, kr2, kr3, kt0, kt1] = wp.planes[ch.min(2)];
|
||||
let dx = (p.x - cx) / m;
|
||||
let dy = (p.y - cy) / m;
|
||||
let r2 = dx * dx + dy * dy;
|
||||
let f = kr0 + r2 * (kr1 + r2 * (kr2 + r2 * kr3));
|
||||
let sx = dx * f + kt0 * 2.0 * dx * dy + kt1 * (r2 + 2.0 * dx * dx);
|
||||
let sy = dy * f + kt1 * 2.0 * dx * dy + kt0 * (r2 + 2.0 * dy * dy);
|
||||
Point::new(cx + m * sx, cy + m * sy)
|
||||
}
|
||||
|
||||
/// DNG `FixVignetteRadial` gain at source position `p` (px).
|
||||
pub fn embedded_vignette_gain(v: &EmbeddedVignette, p: Point, w: f64, h: f64) -> f64 {
|
||||
let (cx, cy) = (v.center.x * w, v.center.y * h);
|
||||
let m = (v.radius * w.max(h)).max(1e-9);
|
||||
let r2 = ((p.x - cx).powi(2) + (p.y - cy).powi(2)) / (m * m);
|
||||
let mut g = 1.0;
|
||||
let mut rp = r2;
|
||||
for k in v.k {
|
||||
g += k * rp;
|
||||
rp *= r2;
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Re-express embedded lens data (relative to a `w × h` image) for the image after orientation `o`.
|
||||
pub fn reorient_lens(lens: &EmbeddedLens, o: Orientation, w: f64, h: f64) -> EmbeddedLens {
|
||||
if o == Orientation::Normal {
|
||||
return *lens;
|
||||
}
|
||||
let (ow, oh) = if o.swaps_axes() { (h, w) } else { (w, h) };
|
||||
let long_ratio = w.max(h) / ow.max(oh); // 1 (rotations keep the long edge)
|
||||
let map_pt = |c: Point| {
|
||||
let (x, y) = o.map(c.x * w, c.y * h, w, h);
|
||||
Point::new(x / ow, y / oh)
|
||||
};
|
||||
let warp = lens.warp.map(|wp| {
|
||||
let c = Point::new(wp.center.x * w, wp.center.y * h);
|
||||
let c2 = o.map(c.x, c.y, w, h);
|
||||
let planes = wp.planes.map(|[a, b, cc, d, kt0, kt1]| {
|
||||
// tangential coefficients form the vector (kt1, kt0), which rotates/reflects with the image
|
||||
let e = o.map(c.x + kt1, c.y + kt0, w, h);
|
||||
[a, b, cc, d, e.1 - c2.1, e.0 - c2.0]
|
||||
});
|
||||
EmbeddedWarp { planes, center: map_pt(wp.center), radius: wp.radius * long_ratio }
|
||||
});
|
||||
let vignette = lens.vignette.map(|v| EmbeddedVignette { k: v.k, center: map_pt(v.center), radius: v.radius * long_ratio });
|
||||
EmbeddedLens { warp, vignette }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ lateral CA
|
||||
|
||||
/// Estimate lateral chromatic aberration: the radial magnification of the red and blue planes relative to green
|
||||
/// (`[α_R, α_B]`, dimensionless; red edges sit at `r·(1 + α_R)`). Strong, radially oriented green edges are
|
||||
/// located in each plane with sub-pixel 1-D matching of normalized edge profiles; a weighted, outlier-trimmed
|
||||
/// least-squares fit of displacement = α·r gives the scale. Results are cached per image.
|
||||
pub fn estimate_lateral_ca(img: &Rgb32f) -> [f64; 2] {
|
||||
static CACHE: Mutex<Vec<(u64, [f64; 2])>> = Mutex::new(Vec::new());
|
||||
let key = fingerprint(img);
|
||||
if let Ok(c) = CACHE.lock()
|
||||
&& let Some((_, v)) = c.iter().find(|(k, _)| *k == key)
|
||||
{
|
||||
return *v;
|
||||
}
|
||||
let v = estimate_lateral_ca_uncached(img);
|
||||
if let Ok(mut c) = CACHE.lock() {
|
||||
c.push((key, v));
|
||||
if c.len() > 16 {
|
||||
c.remove(0);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Cheap content fingerprint of an image (dimensions + 512 sampled pixels), for analysis caches.
|
||||
pub(crate) fn fingerprint(img: &Rgb32f) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ ((img.width as u64) << 32 | img.height as u64);
|
||||
let n = img.data.len();
|
||||
for i in 0..512usize {
|
||||
let p = img.data[(i.wrapping_mul(2_654_435_761) ^ i) % n.max(1)];
|
||||
for c in p {
|
||||
h ^= c.to_bits() as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn estimate_lateral_ca_uncached(img: &Rgb32f) -> [f64; 2] {
|
||||
let (w, h) = (img.width, img.height);
|
||||
if w < 32 || h < 32 {
|
||||
return [0.0; 2];
|
||||
}
|
||||
let plane = |c: usize| -> Plane { img.map(|p| p[c]) };
|
||||
let planes = [plane(0), plane(1), plane(2)];
|
||||
let g = &planes[1];
|
||||
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
|
||||
let hd = (w as f64).hypot(h as f64) / 2.0;
|
||||
// candidate edge points, best per grid cell (spread over the frame)
|
||||
const CELLS: usize = 24;
|
||||
const PER_CELL: usize = 12;
|
||||
let mut cells: Vec<Vec<(f32, usize, usize)>> = vec![Vec::new(); CELLS * CELLS];
|
||||
let at = |x: usize, y: usize| g.data[y * w + x];
|
||||
for y in (6..h - 6).step_by(2) {
|
||||
for x in (6..w - 6).step_by(2) {
|
||||
let gx = at(x + 1, y) - at(x - 1, y);
|
||||
let gy = at(x, y + 1) - at(x, y - 1);
|
||||
let mag = gx.hypot(gy);
|
||||
let mean = (at(x + 1, y) + at(x - 1, y) + at(x, y + 1) + at(x, y - 1)) * 0.25 + 0.02;
|
||||
let rel = mag / mean;
|
||||
if rel < 0.2 {
|
||||
continue;
|
||||
}
|
||||
let (vx, vy) = (x as f64 - cx, y as f64 - cy);
|
||||
let rr = vx.hypot(vy);
|
||||
if rr < 0.2 * hd {
|
||||
continue;
|
||||
}
|
||||
let cos = ((gx as f64 * vx + gy as f64 * vy) / (mag as f64 * rr)).abs();
|
||||
if cos < 0.85 {
|
||||
continue;
|
||||
}
|
||||
let cell = (y * CELLS / h) * CELLS + x * CELLS / w;
|
||||
let v = &mut cells[cell];
|
||||
if v.len() < PER_CELL {
|
||||
v.push((rel, x, y));
|
||||
} else if let Some(min) = v.iter_mut().min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
&& min.0 < rel
|
||||
{
|
||||
*min = (rel, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
let pts: Vec<(f32, usize, usize)> = cells.into_iter().flatten().collect();
|
||||
// sub-pixel displacement of R and B against G along the radial direction
|
||||
const HALF: i32 = 4; // profile half length (px)
|
||||
const SUB: i32 = 20; // samples per px
|
||||
const MAXD: i32 = 3; // max displacement (px)
|
||||
let sample = |p: &Plane, x: f64, y: f64| -> f32 {
|
||||
let fx = x.clamp(0.0, (w - 1) as f64);
|
||||
let fy = y.clamp(0.0, (h - 1) as f64);
|
||||
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
|
||||
let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1));
|
||||
let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32);
|
||||
let a = p.data[y0 * w + x0] + (p.data[y0 * w + x1] - p.data[y0 * w + x0]) * tx;
|
||||
let b = p.data[y1 * w + x0] + (p.data[y1 * w + x1] - p.data[y1 * w + x0]) * tx;
|
||||
a + (b - a) * ty
|
||||
};
|
||||
let znorm = |v: &mut [f32]| -> bool {
|
||||
let n = v.len() as f32;
|
||||
let m = v.iter().sum::<f32>() / n;
|
||||
let sd = (v.iter().map(|x| (x - m) * (x - m)).sum::<f32>() / n).sqrt();
|
||||
if sd < 1e-5 {
|
||||
return false;
|
||||
}
|
||||
v.iter_mut().for_each(|x| *x = (*x - m) / sd);
|
||||
true
|
||||
};
|
||||
let mut obs: [Vec<(f64, f64, f64)>; 2] = [Vec::new(), Vec::new()];
|
||||
for &(wt, x, y) in &pts {
|
||||
let (vx, vy) = (x as f64 - cx, y as f64 - cy);
|
||||
let rr = vx.hypot(vy);
|
||||
let (ux, uy) = (vx / rr, vy / rr);
|
||||
let (px, py) = (x as f64, y as f64);
|
||||
let mut gp: Vec<f32> = (-HALF..=HALF).map(|t| sample(g, px + ux * t as f64, py + uy * t as f64)).collect();
|
||||
if !znorm(&mut gp) {
|
||||
continue;
|
||||
}
|
||||
for (k, ch) in [0usize, 2].into_iter().enumerate() {
|
||||
let reach = HALF + MAXD;
|
||||
let fine: Vec<f32> =
|
||||
(-reach * SUB..=reach * SUB).map(|i| sample(&planes[ch], px + ux * i as f64 / SUB as f64, py + uy * i as f64 / SUB as f64)).collect();
|
||||
let mut best = (f32::MAX, 0i32);
|
||||
for d in -MAXD * SUB..=MAXD * SUB {
|
||||
let mut prof: Vec<f32> = (-HALF..=HALF).map(|t| fine[((t + reach) * SUB + d) as usize]).collect();
|
||||
if !znorm(&mut prof) {
|
||||
continue;
|
||||
}
|
||||
let ssd: f32 = prof.iter().zip(&gp).map(|(a, b)| (a - b) * (a - b)).sum();
|
||||
if ssd < best.0 {
|
||||
best = (ssd, d);
|
||||
}
|
||||
}
|
||||
// normalized SSD = 2n(1 − corr): require a good match
|
||||
let n = (2 * HALF + 1) as f32;
|
||||
if best.0 < 0.3 * n && best.1.abs() < MAXD * SUB {
|
||||
obs[k].push((rr, best.1 as f64 / SUB as f64, wt as f64));
|
||||
}
|
||||
}
|
||||
}
|
||||
let fit = |o: &[(f64, f64, f64)]| -> f64 {
|
||||
if o.len() < 12 {
|
||||
return 0.0;
|
||||
}
|
||||
let solve = |o: &[&(f64, f64, f64)]| {
|
||||
let (num, den) = o.iter().fold((0.0, 0.0), |(n, d), (r, dd, wt)| (n + wt * dd * r, d + wt * r * r));
|
||||
if den > 0.0 { num / den } else { 0.0 }
|
||||
};
|
||||
let all: Vec<&(f64, f64, f64)> = o.iter().collect();
|
||||
let mut a = solve(&all);
|
||||
for _ in 0..2 {
|
||||
let mut res: Vec<f64> = o.iter().map(|(r, d, _)| (d - a * r).abs()).collect();
|
||||
res.sort_by(f64::total_cmp);
|
||||
let mad = res[res.len() / 2].max(0.05);
|
||||
let keep: Vec<&(f64, f64, f64)> = o.iter().filter(|(r, d, _)| (d - a * r).abs() <= 2.5 * mad).collect();
|
||||
if keep.len() < 8 {
|
||||
break;
|
||||
}
|
||||
a = solve(&keep);
|
||||
}
|
||||
a
|
||||
};
|
||||
[fit(&obs[0]), fit(&obs[1])]
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ defringe
|
||||
|
||||
/// OkLab hue (degrees) spanned by the purple hue slider (0..100) and the green one.
|
||||
const PURPLE_HUES: (f32, f32) = (240.0, 360.0);
|
||||
const GREEN_HUES: (f32, f32) = (90.0, 210.0);
|
||||
|
||||
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Membership of hue `h` (deg) in `[lo, hi]` (deg) with soft shoulders.
|
||||
fn hue_weight(h: f32, lo: f32, hi: f32) -> f32 {
|
||||
let feather = 8.0;
|
||||
let mid = (lo + hi) / 2.0;
|
||||
let half = (hi - lo) / 2.0;
|
||||
let mut d = (h - mid).abs() % 360.0;
|
||||
if d > 180.0 {
|
||||
d = 360.0 - d;
|
||||
}
|
||||
1.0 - smoothstep(half, half + feather, d)
|
||||
}
|
||||
|
||||
/// Long edge (px) at which defringe widths are specified: fringes are 1–7 px wide on a ~4000 px image.
|
||||
pub const DEFRINGE_REF_LONG: f64 = 4000.0;
|
||||
|
||||
/// Defringe: desaturate purple / green colour fringes that sit next to high-contrast edges. `scale` is output
|
||||
/// pixels per reference pixel (output long edge / [`DEFRINGE_REF_LONG`]) so the fringe width is the same
|
||||
/// fraction of the image at any size.
|
||||
pub fn defringe(img: &mut Rgb32f, s: &DevelopSettings, scale: f64) {
|
||||
let o = &s.optics;
|
||||
if !s.section_enabled("optics") || (o.defringe_purple_amount <= 0.0 && o.defringe_green_amount <= 0.0) {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (img.width, img.height);
|
||||
let max_amt = o.defringe_purple_amount.max(o.defringe_green_amount);
|
||||
// fringe width in source px grows with the amount (1..7 px), in output px at least one
|
||||
let radius = ((1.0 + max_amt * 0.3) * scale).round().max(1.0) as usize;
|
||||
// perceptual lightness and its local range (dilated contrast)
|
||||
let lab: Vec<[f32; 3]> = img.data.iter().map(|p| oklab_from_2020([p[0].max(0.0), p[1].max(0.0), p[2].max(0.0)])).collect();
|
||||
let l = Plane { width: w, height: h, data: lab.iter().map(|p| p[0]).collect() };
|
||||
let (lo, hi) = (min_filter(&l, radius, false), min_filter(&l, radius, true));
|
||||
let range = |lo: f64, hi: f64, span: (f32, f32)| {
|
||||
let a = span.0 + (span.1 - span.0) * (lo / 100.0) as f32;
|
||||
let b = span.0 + (span.1 - span.0) * (hi / 100.0) as f32;
|
||||
(a.min(b), a.max(b))
|
||||
};
|
||||
let pr = range(o.defringe_purple_hue_lo, o.defringe_purple_hue_hi, PURPLE_HUES);
|
||||
let gr = range(o.defringe_green_hue_lo, o.defringe_green_hue_hi, GREEN_HUES);
|
||||
let ps = (o.defringe_purple_amount / 8.0).min(1.0) as f32;
|
||||
let gs = (o.defringe_green_amount / 8.0).min(1.0) as f32;
|
||||
for_rows(&mut img.data, w, |y, row| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
let edge = smoothstep(0.08, 0.25, hi.data[i] - lo.data[i]);
|
||||
if edge <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let [ll, a, b] = lab[i];
|
||||
let c = a.hypot(b);
|
||||
let chroma_w = smoothstep(0.015, 0.05, c);
|
||||
if chroma_w <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let hue = b.atan2(a).to_degrees().rem_euclid(360.0);
|
||||
let k = (ps * hue_weight(hue, pr.0, pr.1)).max(gs * hue_weight(hue, gr.0, gr.1)) * edge * chroma_w;
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let f = 1.0 - k.min(1.0);
|
||||
*px = oklab_to_2020([ll, a * f, b * f]).map(|v| v.max(0.0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Separable square min (or max) filter of radius `r`.
|
||||
fn min_filter(p: &Plane, r: usize, max: bool) -> Plane {
|
||||
let (w, h) = (p.width, p.height);
|
||||
let pick = |a: f32, b: f32| if max { a.max(b) } else { a.min(b) };
|
||||
let mut tmp = Plane::new(w, h);
|
||||
for_rows(&mut tmp.data, w, |y, row| {
|
||||
let src = &p.data[y * w..(y + 1) * w];
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let (a, b) = (x.saturating_sub(r), (x + r).min(w - 1));
|
||||
*v = src[a..=b].iter().copied().fold(src[x], pick);
|
||||
}
|
||||
});
|
||||
let mut out = Plane::new(w, h);
|
||||
for_rows(&mut out.data, w, |y, row| {
|
||||
let (a, b) = (y.saturating_sub(r), (y + r).min(h - 1));
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let mut m = tmp.data[y * w + x];
|
||||
for yy in a..=b {
|
||||
m = pick(m, tmp.data[yy * w + x]);
|
||||
}
|
||||
*v = m;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A grid of dark lines on white with lateral CA: red magnified by `ar`, blue by `ab` (about the centre).
|
||||
fn ca_grid(w: usize, h: usize, ar: f64, ab: f64) -> Rgb32f {
|
||||
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
|
||||
let val = |x: f64, y: f64| -> f32 {
|
||||
// smooth-edged discs on a grid (edges in every direction)
|
||||
let cell = w as f64 / 10.0;
|
||||
let (fx, fy) = ((x / cell).fract() - 0.5, (y / cell).fract() - 0.5);
|
||||
let d = fx.hypot(fy) * cell - cell * 0.28;
|
||||
(0.05 + 0.85 * (d / 1.2).clamp(-0.5, 0.5) + 0.425) as f32
|
||||
};
|
||||
Rgb32f::from_fn(w, h, |x, y| {
|
||||
let (x, y) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let at = |a: f64| val(cx + (x - cx) / (1.0 + a), cy + (y - cy) / (1.0 + a));
|
||||
[at(ar), at(0.0), at(ab)]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ca_estimate_recovers_scale() {
|
||||
let img = ca_grid(600, 400, 0.004, -0.003);
|
||||
let [r, b] = estimate_lateral_ca_uncached(&img);
|
||||
assert!((r - 0.004).abs() < 0.0008, "red {r}");
|
||||
assert!((b + 0.003).abs() < 0.0008, "blue {b}");
|
||||
let clean = ca_grid(600, 400, 0.0, 0.0);
|
||||
let [r, b] = estimate_lateral_ca_uncached(&clean);
|
||||
assert!(r.abs() < 0.0005 && b.abs() < 0.0005, "{r} {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_identity_and_distortion_direction() {
|
||||
let s = DevelopSettings::default();
|
||||
let wp = Warp::from_settings(300.0, 200.0, &s, None);
|
||||
assert!(wp.is_identity());
|
||||
let mut s = s;
|
||||
s.optics.distortion = 50.0; // corrects barrel: corners sample from further in
|
||||
let wp = Warp::from_settings(300.0, 200.0, &s, None);
|
||||
let q = wp.to_source(Point::new(0.0, 0.0), 1);
|
||||
assert!(q.x > 0.0 && q.y > 0.0);
|
||||
let c = wp.to_source(Point::new(150.0, 100.0), 1);
|
||||
assert!(c.dist(Point::new(150.0, 100.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_vignette_brightens_corners() {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.vignetting = 100.0;
|
||||
let wp = Warp::from_settings(300.0, 200.0, &s, None);
|
||||
assert!((wp.gain(Point::new(150.0, 100.0)) - 1.0).abs() < 1e-6);
|
||||
assert!(wp.gain(Point::new(0.0, 0.0)) > 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorient_keeps_centre_mapping() {
|
||||
let lens = EmbeddedLens {
|
||||
warp: Some(EmbeddedWarp { planes: [[1.0, 0.01, 0.0, 0.0, 0.001, 0.002]; 3], center: Point::new(0.4, 0.5), radius: 0.6 }),
|
||||
vignette: Some(EmbeddedVignette { k: [0.2, 0.0, 0.0, 0.0, 0.0], center: Point::new(0.4, 0.5), radius: 0.6 }),
|
||||
};
|
||||
let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0);
|
||||
// rotate 90° cw: (x, y) → (h − y, x)
|
||||
let c = r.warp.unwrap().center;
|
||||
assert!((c.x - 0.5).abs() < 1e-9 && (c.y - 0.4).abs() < 1e-9, "{c:?}");
|
||||
// a point's warped position transforms like the point itself
|
||||
let p = Point::new(50.0, 30.0);
|
||||
let s0 = embedded_warp(&lens.warp.unwrap(), p, 0, 300.0, 200.0);
|
||||
let m = |q: Point| {
|
||||
let (x, y) = Orientation::Rotate90.map(q.x, q.y, 300.0, 200.0);
|
||||
Point::new(x, y)
|
||||
};
|
||||
let s1 = embedded_warp(&r.warp.unwrap(), m(p), 0, 200.0, 300.0);
|
||||
assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defringe_removes_purple_at_edges_only() {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.defringe_purple_amount = 10.0;
|
||||
// white | black edge with a purple column next to it, and a purple patch far from edges
|
||||
let purple = [0.25f32, 0.05, 0.45];
|
||||
let mut img = Rgb32f::from_fn(40, 20, |x, _| if x < 20 { [0.9; 3] } else { [0.02; 3] });
|
||||
for y in 0..20 {
|
||||
img.set(20, y, purple);
|
||||
}
|
||||
let flat = Rgb32f::filled(40, 20, purple);
|
||||
let mut flat2 = flat.clone();
|
||||
defringe(&mut img, &s, 1.0);
|
||||
defringe(&mut flat2, &s, 1.0);
|
||||
let chroma = |p: [f32; 3]| {
|
||||
let l = oklab_from_2020(p);
|
||||
l[1].hypot(l[2])
|
||||
};
|
||||
assert!(chroma(img.get(20, 10)) < chroma(purple) * 0.3);
|
||||
assert!((chroma(flat2.get(20, 10)) - chroma(purple)).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
//! Geometry / optics tests on synthetic images with known distortions.
|
||||
|
||||
use lightcraft_develop::{DevelopSettings, EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
|
||||
use lightcraft_geom::Point;
|
||||
use lightcraft_raster::resample::{Filter, resize};
|
||||
use lightcraft_raster::{Rgb32f, Rgba8};
|
||||
|
||||
use crate::{RenderRequest, SourceInfo, render};
|
||||
|
||||
/// A light grid: dark lines every `step` px (soft, ~3 px wide) on a light background.
|
||||
pub(crate) fn grid_value(p: Point, step: f64) -> f32 {
|
||||
let d = |v: f64| {
|
||||
let m = v.rem_euclid(step);
|
||||
m.min(step - m)
|
||||
};
|
||||
let dist = d(p.x).min(d(p.y));
|
||||
(0.05 + 0.75 * ((dist - 1.0) / 1.5).clamp(0.0, 1.0)) as f32
|
||||
}
|
||||
|
||||
/// Render a source of `w × h` where each pixel shows `pattern` at `undistort(pixel centre)`.
|
||||
pub(crate) fn synth(w: usize, h: usize, undistort: impl Fn(Point) -> Point + Sync, pattern: impl Fn(Point) -> [f32; 3] + Sync) -> Rgb32f {
|
||||
Rgb32f::from_fn(w, h, |x, y| pattern(undistort(Point::new(x as f64 + 0.5, y as f64 + 0.5))))
|
||||
}
|
||||
|
||||
/// Sub-pixel x of the dark vertical line nearest `x0` on row `y` (green channel darkness centroid).
|
||||
pub(crate) fn line_x(img: &Rgba8, y: usize, x0: f64, win: f64) -> f64 {
|
||||
let (a, b) = ((x0 - win).max(0.0) as usize, ((x0 + win) as usize).min(img.width - 1));
|
||||
let row: Vec<f64> = (a..=b).map(|x| img.get(x, y)[1] as f64).collect();
|
||||
let max = row.iter().cloned().fold(0.0, f64::max);
|
||||
let (mut s, mut sw) = (0.0, 0.0);
|
||||
for (i, v) in row.iter().enumerate() {
|
||||
let wgt = (max - v).max(0.0).powi(2);
|
||||
s += wgt * (a + i) as f64;
|
||||
sw += wgt;
|
||||
}
|
||||
s / sw.max(1e-9) + 0.5
|
||||
}
|
||||
|
||||
/// Sub-pixel y of the dark horizontal line nearest `y0` in column `x`.
|
||||
pub(crate) fn line_y(img: &Rgba8, x: usize, y0: f64, win: f64) -> f64 {
|
||||
let (a, b) = ((y0 - win).max(0.0) as usize, ((y0 + win) as usize).min(img.height - 1));
|
||||
let col: Vec<f64> = (a..=b).map(|y| img.get(x, y)[1] as f64).collect();
|
||||
let max = col.iter().cloned().fold(0.0, f64::max);
|
||||
let (mut s, mut sw) = (0.0, 0.0);
|
||||
for (i, v) in col.iter().enumerate() {
|
||||
let wgt = (max - v).max(0.0).powi(2);
|
||||
s += wgt * (a + i) as f64;
|
||||
sw += wgt;
|
||||
}
|
||||
s / sw.max(1e-9) + 0.5
|
||||
}
|
||||
|
||||
fn spread(v: &[f64]) -> f64 {
|
||||
v.iter().cloned().fold(f64::MIN, f64::max) - v.iter().cloned().fold(f64::MAX, f64::min)
|
||||
}
|
||||
|
||||
/// Barrel distortion with coefficient `k` (< 0): distorted radius = r·(1 + k·r²), r in half-diagonal units.
|
||||
fn barrel_undistort(w: f64, h: f64, k: f64) -> impl Fn(Point) -> Point + Sync {
|
||||
let (cx, cy, hd) = (w / 2.0, h / 2.0, w.hypot(h) / 2.0);
|
||||
move |p: Point| {
|
||||
let (dx, dy) = ((p.x - cx) / hd, (p.y - cy) / hd);
|
||||
let rd = dx.hypot(dy);
|
||||
if rd < 1e-12 {
|
||||
return p;
|
||||
}
|
||||
// solve r·(1 + k r²) = rd (Newton)
|
||||
let mut r = rd;
|
||||
for _ in 0..20 {
|
||||
let f = r * (1.0 + k * r * r) - rd;
|
||||
r -= f / (1.0 + 3.0 * k * r * r);
|
||||
}
|
||||
let s = r / rd;
|
||||
Point::new(cx + dx * s * hd, cy + dy * s * hd)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_distortion_straightens_barrel_grid() {
|
||||
let (w, h) = (480usize, 320usize);
|
||||
// the Distortion slider at +60 corrects exactly this barrel
|
||||
let k = -0.2 * 0.6;
|
||||
let src = synth(w, h, barrel_undistort(w as f64, h as f64, k), |p| [grid_value(p, 40.0); 3]);
|
||||
let rows = [12usize, 150, 308];
|
||||
let measure = |s: &DevelopSettings| {
|
||||
let img = render(&src, &SourceInfo::default(), s, &RenderRequest::fit(w, h)).image;
|
||||
let xs: Vec<f64> = rows.iter().map(|&y| line_x(&img, y, 40.0, 15.0)).collect();
|
||||
let ys: Vec<f64> = [12usize, 220, 468].iter().map(|&x| line_y(&img, x, 40.0, 15.0)).collect();
|
||||
(spread(&xs), spread(&ys))
|
||||
};
|
||||
let (bx, by) = measure(&DevelopSettings::default());
|
||||
assert!(bx > 4.0 && by > 3.0, "the synthetic barrel should bend lines: {bx} {by}");
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.distortion = 60.0;
|
||||
let (cx, cy) = measure(&s);
|
||||
assert!(cx < 1.0 && cy < 1.0, "corrected lines should be straight: {cx} {cy}");
|
||||
}
|
||||
|
||||
/// CA: red plane magnified by `ar`, blue by `ab`, on a neutral disc grid.
|
||||
fn ca_source(w: usize, h: usize, ar: f64, ab: f64) -> Rgb32f {
|
||||
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
|
||||
let disc = move |p: Point| -> f32 {
|
||||
let cell = 40.0;
|
||||
let (fx, fy) = ((p.x / cell).rem_euclid(1.0) - 0.5, (p.y / cell).rem_euclid(1.0) - 0.5);
|
||||
let d = fx.hypot(fy) * cell - 11.0;
|
||||
(0.04 + 0.8 * (d / 1.2 + 0.5).clamp(0.0, 1.0)) as f32
|
||||
};
|
||||
Rgb32f::from_fn(w, h, |x, y| {
|
||||
let p = Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
let at = |a: f64| disc(Point::new(cx + (p.x - cx) / (1.0 + a), cy + (p.y - cy) / (1.0 + a)));
|
||||
[at(ar), at(0.0), at(ab)]
|
||||
})
|
||||
}
|
||||
|
||||
fn fringe(img: &Rgba8) -> f64 {
|
||||
img.data.iter().map(|p| (p[0] as f64 - p[1] as f64).abs() + (p[2] as f64 - p[1] as f64).abs()).sum::<f64>() / img.len() as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_ca_aligns_colour_planes() {
|
||||
let (w, h) = (640usize, 420usize);
|
||||
let src = ca_source(w, h, 0.005, -0.004);
|
||||
let before = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(w, h)).image;
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.remove_ca = true;
|
||||
let after = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(w, h)).image;
|
||||
let (fb, fa) = (fringe(&before), fringe(&after));
|
||||
assert!(fa < fb * 0.35, "fringing {fb:.2} → {fa:.2}");
|
||||
// manual sliders can do the same: +ca_red shrinks... the red plane is sampled further out
|
||||
let mut m = DevelopSettings::default();
|
||||
m.optics.ca_red = 0.005 / 0.003 * 100.0;
|
||||
m.optics.ca_blue = -0.004 / 0.003 * 100.0;
|
||||
let manual = render(&src, &SourceInfo::default(), &m, &RenderRequest::fit(w, h)).image;
|
||||
assert!(fringe(&manual) < fb * 0.35, "manual {:.2}", fringe(&manual));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_corrections_use_embedded_lens_only_when_enabled() {
|
||||
let (w, h) = (300usize, 200usize);
|
||||
let src = synth(w, h, |p| p, |p| [grid_value(p, 30.0); 3]);
|
||||
let lens = EmbeddedLens {
|
||||
warp: Some(EmbeddedWarp { planes: [[1.0, -0.08, 0.0, 0.0, 0.0, 0.0]; 3], center: Point::new(0.5, 0.5), radius: 0.6 }),
|
||||
vignette: Some(EmbeddedVignette { k: [0.5, 0.0, 0.0, 0.0, 0.0], center: Point::new(0.5, 0.5), radius: 0.6 }),
|
||||
};
|
||||
let info = SourceInfo { lens: Some(lens), ..SourceInfo::default() };
|
||||
let mut s = DevelopSettings::default();
|
||||
let off = render(&src, &info, &s, &RenderRequest::fit(w, h)).image;
|
||||
let plain = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(w, h)).image;
|
||||
assert_eq!(off.data, plain.data, "profile corrections off: embedded data ignored");
|
||||
s.optics.lens_profile = true;
|
||||
let on = render(&src, &info, &s, &RenderRequest::fit(w, h)).image;
|
||||
assert_ne!(on.data, off.data);
|
||||
// corners brightened by the embedded vignette correction
|
||||
assert!(on.get(2, 2)[1] as i32 >= off.get(2, 2)[1] as i32);
|
||||
s.optics.profile_distortion = 0.0;
|
||||
s.optics.profile_vignetting = 0.0;
|
||||
let zero = render(&src, &info, &s, &RenderRequest::fit(w, h)).image;
|
||||
assert_eq!(zero.data, off.data, "0 % = no correction");
|
||||
}
|
||||
|
||||
fn mean_abs_diff_downscaled(small: &Rgba8, big: &Rgba8) -> f32 {
|
||||
let down = resize(&big.to_linear(), small.width, small.height, Filter::Box).to_srgb8();
|
||||
small.data.iter().zip(&down.data).map(|(a, b)| (0..3).map(|i| (a[i] as f32 - b[i] as f32).abs()).sum::<f32>() / 3.0).sum::<f32>()
|
||||
/ small.len() as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optics_preview_matches_export() {
|
||||
let src = lightcraft_scenes::demo_library()[2].render(960, 640);
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.distortion = 40.0;
|
||||
s.optics.vignetting = 60.0;
|
||||
s.optics.vignetting_midpoint = 30.0;
|
||||
s.optics.ca_red = 30.0;
|
||||
s.optics.defringe_purple_amount = 8.0;
|
||||
let small = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(240, 240)).image;
|
||||
let big = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(960, 960)).image;
|
||||
let err = mean_abs_diff_downscaled(&small, &big);
|
||||
assert!(err < 4.0, "mean abs error {err}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ M4.3 transforms
|
||||
|
||||
/// Source whose content is a straight grid after the forward transform `h` (centred coordinates of a `w × h`
|
||||
/// image): each source pixel shows the grid at its transformed position.
|
||||
fn keystoned_grid(w: usize, h: usize, fwd: lightcraft_geom::Homography, step: f64) -> Rgb32f {
|
||||
let l = w.max(h) as f64 / 2.0;
|
||||
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
|
||||
synth(
|
||||
w,
|
||||
h,
|
||||
move |p| {
|
||||
let q = fwd.apply(Point::new((p.x - cx) / l, (p.y - cy) / l));
|
||||
Point::new(cx + q.x * l, cy + q.y * l)
|
||||
},
|
||||
move |p| [grid_value(p, step); 3],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_vertical_keystone_widens_the_top() {
|
||||
let g = lightcraft_develop::Geometry { vertical: -50.0, ..Default::default() };
|
||||
let hm = crate::transform::manual(&g);
|
||||
let (tl, tr) = (hm.apply(Point::new(-1.0, -0.66)), hm.apply(Point::new(1.0, -0.66)));
|
||||
let (bl, br) = (hm.apply(Point::new(-1.0, 0.66)), hm.apply(Point::new(1.0, 0.66)));
|
||||
assert!(tr.x - tl.x > (br.x - bl.x) * 1.15, "top {} bottom {}", tr.x - tl.x, br.x - bl.x);
|
||||
let c = hm.apply(Point::new(0.0, 0.0));
|
||||
assert!(c.dist(Point::new(0.0, 0.0)) < 1e-9, "centre stays put");
|
||||
let g = lightcraft_develop::Geometry { horizontal: -50.0, ..Default::default() };
|
||||
let hm = crate::transform::manual(&g);
|
||||
let (lt, lb) = (hm.apply(Point::new(-0.9, -0.6)), hm.apply(Point::new(-0.9, 0.6)));
|
||||
let (rt, rb) = (hm.apply(Point::new(0.9, -0.6)), hm.apply(Point::new(0.9, 0.6)));
|
||||
assert!(lb.y - lt.y > rb.y - rt.y, "left side enlarged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_transforms_straighten_known_keystone() {
|
||||
let (w, h) = (480usize, 320usize);
|
||||
for g in [
|
||||
lightcraft_develop::Geometry { vertical: -40.0, ..Default::default() },
|
||||
lightcraft_develop::Geometry { horizontal: 35.0, rotate: 3.0, ..Default::default() },
|
||||
lightcraft_develop::Geometry { vertical: 25.0, aspect: 30.0, scale: 110.0, offset_x: 6.0, offset_y: -4.0, ..Default::default() },
|
||||
] {
|
||||
// the source was "shot" with the inverse transform; correcting with `g` must give the straight grid back
|
||||
let fwd = crate::transform::manual(&g);
|
||||
let src = keystoned_grid(w, h, fwd, 40.0);
|
||||
let s = DevelopSettings { geometry: g.clone(), ..Default::default() };
|
||||
let img = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(w, h)).image;
|
||||
let xs: Vec<f64> = [60usize, 150, 260].iter().map(|&y| line_x(&img, y, 200.0, 15.0)).collect();
|
||||
let ys: Vec<f64> = [60usize, 220, 420].iter().map(|&x| line_y(&img, x, 200.0, 15.0)).collect();
|
||||
assert!(spread(&xs) < 1.0 && (xs[0] - 200.0).abs() < 1.0, "{g:?}: vertical line at {xs:?}");
|
||||
assert!(spread(&ys) < 1.0 && (ys[0] - 200.0).abs() < 1.0, "{g:?}: horizontal line at {ys:?}");
|
||||
// and without the correction the lines are not straight/where they belong
|
||||
let raw = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(w, h)).image;
|
||||
let rx: Vec<f64> = [60usize, 150, 260].iter().map(|&y| line_x(&raw, y, 200.0, 15.0)).collect();
|
||||
let ry: Vec<f64> = [60usize, 220, 420].iter().map(|&x| line_y(&raw, x, 200.0, 15.0)).collect();
|
||||
assert!(spread(&rx) > 1.5 || spread(&ry) > 1.5 || (rx[0] - 200.0).abs() > 1.5, "{g:?}: source should be distorted");
|
||||
}
|
||||
}
|
||||
|
||||
fn blank_fraction(img: &Rgba8) -> f64 {
|
||||
let blank =
|
||||
crate::render(&Rgb32f::filled(4, 4, crate::geometry::BLANK), &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(4, 4))
|
||||
.image
|
||||
.get(1, 1);
|
||||
img.data.iter().filter(|p| (0..3).all(|i| (p[i] as i32 - blank[i] as i32).abs() <= 1)).count() as f64 / img.len() as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrain_crop_removes_blank_areas() {
|
||||
let src = Rgb32f::from_fn(300, 200, |x, y| [0.05 + 0.002 * x as f32, 0.1 + 0.002 * y as f32, 0.3]);
|
||||
let mut s = DevelopSettings::default();
|
||||
s.geometry.vertical = -60.0;
|
||||
s.geometry.rotate = 4.0;
|
||||
let free = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(300, 300)).image;
|
||||
assert!(blank_fraction(&free) > 0.03, "keystone leaves blank corners: {}", blank_fraction(&free));
|
||||
s.geometry.constrain_crop = true;
|
||||
let c = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(300, 300)).image;
|
||||
assert_eq!((c.width, c.height), (free.width, free.height), "constrain keeps the aspect");
|
||||
assert!(blank_fraction(&c) < 0.002, "constrained: {}", blank_fraction(&c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_preview_matches_export() {
|
||||
let src = lightcraft_scenes::demo_library()[4].render(960, 640);
|
||||
let mut s = DevelopSettings::default();
|
||||
s.geometry.vertical = -30.0;
|
||||
s.geometry.horizontal = 15.0;
|
||||
s.geometry.rotate = 2.5;
|
||||
s.geometry.scale = 105.0;
|
||||
s.geometry.constrain_crop = true;
|
||||
let small = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(240, 240)).image;
|
||||
let big = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(960, 960)).image;
|
||||
assert_eq!(small.width * 4, big.width);
|
||||
let err = mean_abs_diff_downscaled(&small, &big);
|
||||
assert!(err < 4.0, "mean abs error {err}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ M4.4 Upright
|
||||
|
||||
use crate::upright::{Segment, detect_segments, solve_guided};
|
||||
use lightcraft_develop::Upright;
|
||||
|
||||
#[test]
|
||||
fn segment_detector_finds_known_lines() {
|
||||
// three dark lines at known angles on a light background
|
||||
let lines = [
|
||||
(Point::new(60.0, 40.0), Point::new(80.0, 280.0)),
|
||||
(Point::new(120.0, 300.0), Point::new(420.0, 260.0)),
|
||||
(Point::new(300.0, 30.0), Point::new(440.0, 200.0)),
|
||||
];
|
||||
let dist = |p: Point, a: Point, b: Point| {
|
||||
let ab = b - a;
|
||||
let t = ((p - a).dot(ab) / ab.dot(ab)).clamp(0.0, 1.0);
|
||||
p.dist(a + ab * t)
|
||||
};
|
||||
let lum = lightcraft_raster::Plane::from_fn(480, 320, |x, y| {
|
||||
let p = Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
let d = lines.iter().map(|(a, b)| dist(p, *a, *b)).fold(f64::MAX, f64::min);
|
||||
(0.15 + 0.7 * ((d - 1.0) / 1.2).clamp(0.0, 1.0)) as f32
|
||||
});
|
||||
let segs = detect_segments(&lum);
|
||||
let l = 240.0;
|
||||
for (a, b) in lines {
|
||||
let want = (b.y - a.y).atan2(b.x - a.x);
|
||||
let len = a.dist(b) / l;
|
||||
let hit = segs.iter().any(|s| {
|
||||
let got = (s.b.y - s.a.y).atan2(s.b.x - s.a.x);
|
||||
let mut d = (got - want).abs() % std::f64::consts::PI;
|
||||
if d > std::f64::consts::FRAC_PI_2 {
|
||||
d = std::f64::consts::PI - d;
|
||||
}
|
||||
d.to_degrees() < 0.7 && s.len() > 0.6 * len
|
||||
});
|
||||
assert!(hit, "line {a:?}–{b:?} not found in {segs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// x of the same dark vertical line at the top, bottom and middle of the output (tracked from the middle).
|
||||
fn vertical_line_track(img: &Rgba8, x0: f64) -> Vec<f64> {
|
||||
let mid = line_x(img, img.height / 2 + 7, x0, 14.0);
|
||||
[img.height / 6 + 7, img.height * 5 / 6 - 7].iter().map(|&y| line_x(img, y, mid, 10.0)).chain([mid]).collect()
|
||||
}
|
||||
|
||||
fn horizontal_line_track(img: &Rgba8, y0: f64) -> Vec<f64> {
|
||||
let mid = line_y(img, img.width / 2 + 7, y0, 14.0);
|
||||
[img.width / 6 + 7, img.width * 5 / 6 - 7].iter().map(|&x| line_y(img, x, mid, 10.0)).chain([mid]).collect()
|
||||
}
|
||||
|
||||
fn upright_render(src: &Rgb32f, mode: Upright) -> Rgba8 {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.geometry.upright = mode;
|
||||
s.geometry.upright_transform = Some(crate::upright::auto_transform(src, &SourceInfo::default(), &s, mode));
|
||||
render(src, &SourceInfo::default(), &s, &RenderRequest::fit(src.width, src.height)).image
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_vertical_fixes_converging_verticals() {
|
||||
let (w, h) = (720usize, 480usize);
|
||||
// "shot from below" and slightly rolled: the inverse of what the correction should do
|
||||
let shot = lightcraft_develop::Geometry { vertical: 35.0, rotate: -2.0, ..Default::default() };
|
||||
let src = keystoned_grid(w, h, crate::transform::manual(&shot), 48.0);
|
||||
let raw = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(w, h)).image;
|
||||
let before = vertical_line_track(&raw, 130.0);
|
||||
assert!(spread(&before) > 6.0, "source verticals converge: {before:?}");
|
||||
for mode in [Upright::Vertical, Upright::Auto, Upright::Full] {
|
||||
let img = upright_render(&src, mode);
|
||||
for x0 in [130.0, 360.0, 590.0] {
|
||||
let t = vertical_line_track(&img, x0);
|
||||
assert!(spread(&t) < 1.2, "{mode:?}: line near {x0} at {t:?}");
|
||||
}
|
||||
let t = horizontal_line_track(&img, 240.0);
|
||||
assert!(spread(&t) < 1.5, "{mode:?}: horizontals stay level: {t:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_level_straightens_a_tilted_horizon() {
|
||||
let (w, h) = (720usize, 480usize);
|
||||
let shot = lightcraft_develop::Geometry { rotate: 3.5, ..Default::default() };
|
||||
let src = keystoned_grid(w, h, crate::transform::manual(&shot), 48.0);
|
||||
let img = upright_render(&src, Upright::Level);
|
||||
for y0 in [120.0, 240.0, 360.0] {
|
||||
let t = horizontal_line_track(&img, y0);
|
||||
assert!(spread(&t) < 1.2, "line near {y0} at {t:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_full_fixes_both_families() {
|
||||
let (w, h) = (720usize, 480usize);
|
||||
let shot = lightcraft_develop::Geometry { vertical: 25.0, horizontal: -25.0, ..Default::default() };
|
||||
let src = keystoned_grid(w, h, crate::transform::manual(&shot), 48.0);
|
||||
let img = upright_render(&src, Upright::Full);
|
||||
for x0 in [200.0, 360.0, 520.0] {
|
||||
let t = vertical_line_track(&img, x0);
|
||||
assert!(spread(&t) < 1.5, "vertical near {x0}: {t:?}");
|
||||
}
|
||||
for y0 in [150.0, 330.0] {
|
||||
let t = horizontal_line_track(&img, y0);
|
||||
assert!(spread(&t) < 1.5, "horizontal near {y0}: {t:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guided_upright_from_two_vertical_guides() {
|
||||
let (w, h) = (720usize, 480usize);
|
||||
let shot = lightcraft_develop::Geometry { vertical: 30.0, ..Default::default() };
|
||||
let fwd = crate::transform::manual(&shot);
|
||||
let src = keystoned_grid(w, h, fwd, 48.0);
|
||||
// guides drawn along two building edges: straight lines of the "true" image, seen through fwd⁻¹
|
||||
let inv = fwd.inverse().unwrap();
|
||||
let l = 360.0;
|
||||
let norm = |p: Point| {
|
||||
let q = inv.apply(p);
|
||||
Point::new((q.x * l + 360.0) / 720.0, (q.y * l + 240.0) / 480.0)
|
||||
};
|
||||
let mut s = DevelopSettings::default();
|
||||
s.geometry.upright = Upright::Guided;
|
||||
s.geometry.guides =
|
||||
vec![(norm(Point::new(-0.5, -0.5)), norm(Point::new(-0.5, 0.5))), (norm(Point::new(0.55, -0.4)), norm(Point::new(0.55, 0.5)))];
|
||||
let img = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(w, h)).image;
|
||||
for x0 in [180.0, 360.0, 540.0] {
|
||||
let t = vertical_line_track(&img, x0);
|
||||
assert!(spread(&t) < 1.2, "guided: line near {x0} at {t:?}");
|
||||
}
|
||||
// a single guide only levels (rotation): it becomes vertical
|
||||
let one = solve_guided(&[Segment { a: Point::new(0.1, -0.5), b: Point::new(0.12, 0.5) }]);
|
||||
let p = one.apply(Point::new(0.1, -0.5));
|
||||
let q = one.apply(Point::new(0.12, 0.5));
|
||||
assert!((p.x - q.x).abs() < 1e-9, "single vertical guide becomes vertical: {p:?} {q:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_analysis_is_resolution_independent() {
|
||||
let shot = lightcraft_develop::Geometry { vertical: 30.0, rotate: 1.5, ..Default::default() };
|
||||
let fwd = crate::transform::manual(&shot);
|
||||
let big = keystoned_grid(1200, 800, fwd, 80.0);
|
||||
let small = keystoned_grid(600, 400, fwd, 40.0);
|
||||
let s = DevelopSettings::default();
|
||||
let a = crate::upright::auto_transform(&big, &SourceInfo::default(), &s, Upright::Vertical);
|
||||
let b = crate::upright::auto_transform(&small, &SourceInfo::default(), &s, Upright::Vertical);
|
||||
for p in [Point::new(-1.0, -0.66), Point::new(1.0, -0.66), Point::new(1.0, 0.66), Point::new(-1.0, 0.66)] {
|
||||
assert!(a.apply(p).dist(b.apply(p)) < 0.006, "{:?} vs {:?}", a.apply(p), b.apply(p));
|
||||
}
|
||||
// a setting that names the mode without a stored transform renders like the stored one
|
||||
let mut s1 = DevelopSettings::default();
|
||||
s1.geometry.upright = Upright::Vertical;
|
||||
let lazy = render(&small, &SourceInfo::default(), &s1, &RenderRequest::fit(300, 300)).image;
|
||||
s1.geometry.upright_transform = Some(b);
|
||||
let stored = render(&small, &SourceInfo::default(), &s1, &RenderRequest::fit(300, 300)).image;
|
||||
assert_eq!(lazy.data, stored.data);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Perspective transforms: the manual Transform sliders (vertical/horizontal keystone, rotate, aspect, scale,
|
||||
//! offset) as one homography, composed with the Upright correction.
|
||||
//!
|
||||
//! All homographies here map *lens-corrected* centred coordinates to *transformed* centred coordinates, where
|
||||
//! centred coordinates are `(p − image centre) / (long edge / 2)`. Keystone corrections are modelled as a virtual
|
||||
//! camera rotation `K·R·K⁻¹` (focal length [`DEFAULT_FOCAL`] in those units), re-centred so the image centre stays
|
||||
//! put and its local scale is preserved.
|
||||
|
||||
use lightcraft_develop::Geometry;
|
||||
use lightcraft_geom::Homography;
|
||||
|
||||
/// Assumed focal length in half-long-edge units (≈ 27 mm on a 36 mm-wide frame).
|
||||
pub const DEFAULT_FOCAL: f64 = 1.5;
|
||||
/// Vertical/Horizontal ±100 → virtual camera rotation (radians).
|
||||
const KEYSTONE_MAX: f64 = 0.5;
|
||||
|
||||
pub type Mat3 = [[f64; 3]; 3];
|
||||
|
||||
pub fn mat_mul(a: &Mat3, b: &Mat3) -> Mat3 {
|
||||
let mut r = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
r[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
pub fn to_h(m: &Mat3) -> Homography {
|
||||
Homography([m[0][0], m[0][1], m[0][2], m[1][0], m[1][1], m[1][2], m[2][0], m[2][1], m[2][2]])
|
||||
}
|
||||
|
||||
/// `K·R·K⁻¹` for a rotation `r` (rows) and focal length `f`.
|
||||
pub fn rotation_homography(r: &Mat3, f: f64) -> Homography {
|
||||
let k = [[f, 0.0, 0.0], [0.0, f, 0.0], [0.0, 0.0, 1.0]];
|
||||
let ki = [[1.0 / f, 0.0, 0.0], [0.0, 1.0 / f, 0.0], [0.0, 0.0, 1.0]];
|
||||
to_h(&mat_mul(&k, &mat_mul(r, &ki)))
|
||||
}
|
||||
|
||||
pub fn rot_x(a: f64) -> Mat3 {
|
||||
let (s, c) = a.sin_cos();
|
||||
[[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]]
|
||||
}
|
||||
|
||||
pub fn rot_y(a: f64) -> Mat3 {
|
||||
let (s, c) = a.sin_cos();
|
||||
[[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]]
|
||||
}
|
||||
|
||||
/// Translate so the origin maps to itself and scale so the local area there is unchanged.
|
||||
pub fn recentre(h: &Homography) -> Homography {
|
||||
let o = h.apply(lightcraft_geom::Point::new(0.0, 0.0));
|
||||
let t = Homography([1.0, 0.0, -o.x, 0.0, 1.0, -o.y, 0.0, 0.0, 1.0]).mul(h);
|
||||
// Jacobian at the origin (numerically)
|
||||
let e = 1e-4;
|
||||
let px = t.apply(lightcraft_geom::Point::new(e, 0.0));
|
||||
let py = t.apply(lightcraft_geom::Point::new(0.0, e));
|
||||
let det = ((px.x * py.y - px.y * py.x) / (e * e)).abs();
|
||||
let s = if det > 1e-12 { 1.0 / det.sqrt() } else { 1.0 };
|
||||
Homography([s, 0.0, 0.0, 0.0, s, 0.0, 0.0, 0.0, 1.0]).mul(&t)
|
||||
}
|
||||
|
||||
/// The manual Transform sliders as a homography (lens-corrected → transformed, centred coordinates).
|
||||
/// Signs (like Lightroom): Vertical − widens the top (fixes converging verticals shot from below), Horizontal −
|
||||
/// enlarges the left side, Rotate + turns the image clockwise, Aspect + stretches vertically, Scale > 100 zooms in,
|
||||
/// Offset X + moves the image right, Offset Y + moves it up.
|
||||
pub fn manual(g: &Geometry) -> Homography {
|
||||
let mut h = Homography::IDENTITY;
|
||||
if g.vertical != 0.0 || g.horizontal != 0.0 {
|
||||
let r = mat_mul(&rot_y(g.horizontal / 100.0 * KEYSTONE_MAX), &rot_x(-g.vertical / 100.0 * KEYSTONE_MAX));
|
||||
h = recentre(&rotation_homography(&r, DEFAULT_FOCAL));
|
||||
}
|
||||
if g.rotate != 0.0 {
|
||||
let (s, c) = g.rotate.to_radians().sin_cos();
|
||||
h = Homography([c, -s, 0.0, s, c, 0.0, 0.0, 0.0, 1.0]).mul(&h);
|
||||
}
|
||||
if g.aspect != 0.0 {
|
||||
let k = (g.aspect / 200.0).exp2();
|
||||
h = Homography([1.0 / k, 0.0, 0.0, 0.0, k, 0.0, 0.0, 0.0, 1.0]).mul(&h);
|
||||
}
|
||||
let s = (g.scale / 100.0).clamp(0.1, 10.0);
|
||||
if s != 1.0 {
|
||||
h = Homography([s, 0.0, 0.0, 0.0, s, 0.0, 0.0, 0.0, 1.0]).mul(&h);
|
||||
}
|
||||
if g.offset_x != 0.0 || g.offset_y != 0.0 {
|
||||
h = Homography([1.0, 0.0, g.offset_x / 100.0 * 0.5, 0.0, 1.0, -g.offset_y / 100.0 * 0.5, 0.0, 0.0, 1.0]).mul(&h);
|
||||
}
|
||||
h
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
//! Upright: automatic perspective correction.
|
||||
//!
|
||||
//! 1. **Line segments** — our own gradient-based detector: pixels with a strong luminance gradient are grown into
|
||||
//! regions of consistent level-line orientation (seeded from the strongest gradients), and each elongated region
|
||||
//! is fitted with a segment (weighted principal axis).
|
||||
//! 2. **Vanishing points** — near-vertical and near-horizontal segments are grouped; for each family a RANSAC over
|
||||
//! segment pairs finds the point (possibly at infinity) most segments point at, refined by weighted least squares
|
||||
//! (smallest eigenvector of `Σ w·l·lᵀ` over the inlier lines).
|
||||
//! 3. **Correction** — a virtual camera rotation `K·R·K⁻¹` that sends the vertical vanishing direction to the image
|
||||
//! y axis (and, for Full, the horizontal one to the x axis, with the focal length estimated from their
|
||||
//! orthogonality when possible); Level is a pure rotation.
|
||||
//!
|
||||
//! Guided Upright solves the same problem from up to four user-drawn guides.
|
||||
//!
|
||||
//! Coordinates: centred, `(p − centre) / (long edge / 2)` of the lens-corrected image, so results are independent
|
||||
//! of the analysis resolution. Results map lens-corrected → transformed coordinates.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use lightcraft_develop::{Crop, DevelopSettings, Geometry, Upright};
|
||||
use lightcraft_geom::{Homography, Point};
|
||||
use lightcraft_raster::{Plane, Rgb32f};
|
||||
|
||||
use crate::SourceInfo;
|
||||
|
||||
use crate::transform::{DEFAULT_FOCAL, Mat3, recentre, rotation_homography};
|
||||
|
||||
/// A detected (or user-drawn) line segment in centred coordinates.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Segment {
|
||||
pub a: Point,
|
||||
pub b: Point,
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
pub fn len(&self) -> f64 {
|
||||
self.a.dist(self.b)
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0.0
|
||||
}
|
||||
fn mid(&self) -> Point {
|
||||
self.a.lerp(self.b, 0.5)
|
||||
}
|
||||
/// Homogeneous line through the endpoints, normalized so (a, b) is a unit normal.
|
||||
fn line(&self) -> [f64; 3] {
|
||||
let l = cross([self.a.x, self.a.y, 1.0], [self.b.x, self.b.y, 1.0]);
|
||||
let n = l[0].hypot(l[1]).max(1e-12);
|
||||
[l[0] / n, l[1] / n, l[2] / n]
|
||||
}
|
||||
/// Angle from vertical in degrees (0 = vertical), folded to 0..90.
|
||||
fn tilt_from_vertical(&self) -> f64 {
|
||||
let (dx, dy) = (self.b.x - self.a.x, self.b.y - self.a.y);
|
||||
dx.abs().atan2(dy.abs()).to_degrees()
|
||||
}
|
||||
}
|
||||
|
||||
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
fn norm(a: [f64; 3]) -> [f64; 3] {
|
||||
let n = dot(a, a).sqrt().max(1e-300);
|
||||
[a[0] / n, a[1] / n, a[2] / n]
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ segments
|
||||
|
||||
/// Detect line segments in a luminance plane (any scale; values roughly 0..1, perceptual). Returns segments in
|
||||
/// centred coordinates of the plane.
|
||||
pub fn detect_segments(lum: &Plane) -> Vec<Segment> {
|
||||
let (w, h) = (lum.width, lum.height);
|
||||
if w < 16 || h < 16 {
|
||||
return Vec::new();
|
||||
}
|
||||
let img = lightcraft_raster::blur::gaussian(lum, 0.8);
|
||||
let at = |x: usize, y: usize| img.data[y * w + x];
|
||||
let n = w * h;
|
||||
let mut mag = vec![0.0f32; n];
|
||||
let mut ang = vec![0.0f32; n]; // level-line angle (gradient + 90°), radians, mod π
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..w - 1 {
|
||||
let gx = (at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1)) - (at(x - 1, y - 1) + 2.0 * at(x - 1, y) + at(x - 1, y + 1));
|
||||
let gy = (at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1)) - (at(x - 1, y - 1) + 2.0 * at(x, y - 1) + at(x + 1, y - 1));
|
||||
let m = gx.hypot(gy) / 8.0;
|
||||
mag[y * w + x] = m;
|
||||
ang[y * w + x] = (gy.atan2(gx) + std::f32::consts::FRAC_PI_2).rem_euclid(std::f32::consts::PI);
|
||||
}
|
||||
}
|
||||
// adaptive threshold: strong edges relative to the image's own contrast
|
||||
let mut sorted: Vec<f32> = mag.iter().copied().filter(|m| *m > 1e-4).collect();
|
||||
if sorted.len() < 32 {
|
||||
return Vec::new();
|
||||
}
|
||||
sorted.sort_by(f32::total_cmp);
|
||||
let p90 = sorted[sorted.len() * 9 / 10];
|
||||
let thr = (p90 * 0.35).max(0.008);
|
||||
let mut order: Vec<u32> = (0..n as u32).filter(|&i| mag[i as usize] > thr).collect();
|
||||
order.sort_by(|&a, &b| mag[b as usize].total_cmp(&mag[a as usize]));
|
||||
let mut used = vec![false; n];
|
||||
let tol = 22.5f32.to_radians();
|
||||
let min_len = 0.04 * w.max(h) as f64;
|
||||
let half_long = w.max(h) as f64 / 2.0;
|
||||
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
let mut region: Vec<usize> = Vec::new();
|
||||
for &seed in &order {
|
||||
let seed = seed as usize;
|
||||
if used[seed] {
|
||||
continue;
|
||||
}
|
||||
used[seed] = true;
|
||||
region.clear();
|
||||
region.push(seed);
|
||||
stack.clear();
|
||||
stack.push(seed);
|
||||
// region angle via doubled-angle mean
|
||||
let (mut sc, mut ss) = ((2.0 * ang[seed]).cos(), (2.0 * ang[seed]).sin());
|
||||
while let Some(i) = stack.pop() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let ra = ss.atan2(sc) / 2.0;
|
||||
for dy in -1i32..=1 {
|
||||
for dx in -1i32..=1 {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue;
|
||||
}
|
||||
let (nx, ny) = (x as i32 + dx, y as i32 + dy);
|
||||
if nx < 1 || ny < 1 || nx >= w as i32 - 1 || ny >= h as i32 - 1 {
|
||||
continue;
|
||||
}
|
||||
let j = ny as usize * w + nx as usize;
|
||||
if used[j] || mag[j] <= thr {
|
||||
continue;
|
||||
}
|
||||
let mut d = (ang[j] - ra).abs() % std::f32::consts::PI;
|
||||
if d > std::f32::consts::FRAC_PI_2 {
|
||||
d = std::f32::consts::PI - d;
|
||||
}
|
||||
if d > tol {
|
||||
continue;
|
||||
}
|
||||
used[j] = true;
|
||||
region.push(j);
|
||||
stack.push(j);
|
||||
sc += (2.0 * ang[j]).cos();
|
||||
ss += (2.0 * ang[j]).sin();
|
||||
}
|
||||
}
|
||||
}
|
||||
if region.len() < 12 {
|
||||
continue;
|
||||
}
|
||||
// weighted principal axis
|
||||
let (mut sw, mut mx, mut my) = (0.0f64, 0.0f64, 0.0f64);
|
||||
for &i in ®ion {
|
||||
let wt = mag[i] as f64;
|
||||
sw += wt;
|
||||
mx += wt * (i % w) as f64;
|
||||
my += wt * (i / w) as f64;
|
||||
}
|
||||
mx /= sw;
|
||||
my /= sw;
|
||||
let (mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0);
|
||||
for &i in ®ion {
|
||||
let wt = mag[i] as f64;
|
||||
let (dx, dy) = ((i % w) as f64 - mx, (i / w) as f64 - my);
|
||||
sxx += wt * dx * dx;
|
||||
syy += wt * dy * dy;
|
||||
sxy += wt * dx * dy;
|
||||
}
|
||||
let theta = 0.5 * (2.0 * sxy).atan2(sxx - syy);
|
||||
let (ux, uy) = (theta.cos(), theta.sin());
|
||||
let (mut lo, mut hi, mut wlo, mut whi) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
|
||||
for &i in ®ion {
|
||||
let (dx, dy) = ((i % w) as f64 - mx, (i / w) as f64 - my);
|
||||
let t = dx * ux + dy * uy;
|
||||
let s = -dx * uy + dy * ux;
|
||||
lo = lo.min(t);
|
||||
hi = hi.max(t);
|
||||
wlo = wlo.min(s);
|
||||
whi = whi.max(s);
|
||||
}
|
||||
let (len, width) = (hi - lo, (whi - wlo).max(1.0));
|
||||
if len < min_len || len / width < 5.0 {
|
||||
continue;
|
||||
}
|
||||
let a = Point::new((mx + 0.5 + ux * lo - cx) / half_long, (my + 0.5 + uy * lo - cy) / half_long);
|
||||
let b = Point::new((mx + 0.5 + ux * hi - cx) / half_long, (my + 0.5 + uy * hi - cy) / half_long);
|
||||
out.push(Segment { a, b });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ vanishing points
|
||||
|
||||
/// Angular error (radians) between a segment and the direction from its midpoint to the (homogeneous) point `v`.
|
||||
fn vp_error(s: &Segment, v: [f64; 3]) -> f64 {
|
||||
let m = s.mid();
|
||||
let (dx, dy) = (v[0] - m.x * v[2], v[1] - m.y * v[2]);
|
||||
let (sx, sy) = (s.b.x - s.a.x, s.b.y - s.a.y);
|
||||
let d = dx.hypot(dy);
|
||||
if d < 1e-12 {
|
||||
return 0.0;
|
||||
}
|
||||
let c = ((dx * sx + dy * sy) / (d * sx.hypot(sy).max(1e-12))).abs().min(1.0);
|
||||
c.acos()
|
||||
}
|
||||
|
||||
/// Smallest eigenvector of a symmetric 3×3 matrix (Jacobi rotations).
|
||||
fn smallest_eigvec(m: Mat3) -> [f64; 3] {
|
||||
let mut a = m;
|
||||
let mut v = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
for _ in 0..50 {
|
||||
let (mut p, mut q, mut big) = (0, 1, 0.0);
|
||||
for i in 0..3 {
|
||||
for j in i + 1..3 {
|
||||
if a[i][j].abs() > big {
|
||||
big = a[i][j].abs();
|
||||
p = i;
|
||||
q = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
if big < 1e-15 {
|
||||
break;
|
||||
}
|
||||
let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
|
||||
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
|
||||
let t = if theta == 0.0 { 1.0 } else { t };
|
||||
let c = 1.0 / (t * t + 1.0).sqrt();
|
||||
let s = t * c;
|
||||
for k in 0..3 {
|
||||
let (akp, akq) = (a[k][p], a[k][q]);
|
||||
a[k][p] = c * akp - s * akq;
|
||||
a[k][q] = s * akp + c * akq;
|
||||
}
|
||||
for k in 0..3 {
|
||||
let (apk, aqk) = (a[p][k], a[q][k]);
|
||||
a[p][k] = c * apk - s * aqk;
|
||||
a[q][k] = s * apk + c * aqk;
|
||||
}
|
||||
for row in v.iter_mut() {
|
||||
let (vp, vq) = (row[p], row[q]);
|
||||
row[p] = c * vp - s * vq;
|
||||
row[q] = s * vp + c * vq;
|
||||
}
|
||||
}
|
||||
let i = (0..3).min_by(|&i, &j| a[i][i].total_cmp(&a[j][j])).unwrap_or(0);
|
||||
norm([v[0][i], v[1][i], v[2][i]])
|
||||
}
|
||||
|
||||
/// Least-squares vanishing point of `segs` (weights = lengths).
|
||||
fn refine_vp(segs: &[&Segment]) -> [f64; 3] {
|
||||
let mut m = [[0.0; 3]; 3];
|
||||
for s in segs {
|
||||
let l = s.line();
|
||||
let w = s.len();
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
m[i][j] += w * l[i] * l[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
smallest_eigvec(m)
|
||||
}
|
||||
|
||||
/// The dominant vanishing point of a segment family: RANSAC over pairs, then least squares on the inliers.
|
||||
/// Returns the point and the total inlier length, or None with fewer than two consistent segments.
|
||||
pub fn vanishing_point(segs: &[Segment]) -> Option<([f64; 3], f64)> {
|
||||
if segs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let thr = 1.5f64.to_radians();
|
||||
let score = |v: [f64; 3]| -> f64 { segs.iter().map(|s| if vp_error(s, v) < thr { s.len() } else { 0.0 }).sum() };
|
||||
// deterministic pair sampling, longest segments first
|
||||
let mut idx: Vec<usize> = (0..segs.len()).collect();
|
||||
idx.sort_by(|&a, &b| segs[b].len().total_cmp(&segs[a].len()));
|
||||
idx.truncate(60);
|
||||
let mut best: Option<([f64; 3], f64)> = None;
|
||||
for (k, &i) in idx.iter().enumerate() {
|
||||
for &j in &idx[k + 1..] {
|
||||
let v = cross(segs[i].line(), segs[j].line());
|
||||
if dot(v, v) < 1e-24 {
|
||||
continue;
|
||||
}
|
||||
let v = norm(v);
|
||||
let sc = score(v);
|
||||
if best.is_none_or(|b| sc > b.1) {
|
||||
best = Some((v, sc));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (v, _) = best?;
|
||||
let inl: Vec<&Segment> = segs.iter().filter(|s| vp_error(s, v) < thr * 2.0).collect();
|
||||
if inl.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let v = refine_vp(&inl);
|
||||
let total: f64 = segs.iter().filter(|s| vp_error(s, v) < thr * 2.0).map(|s| s.len()).sum();
|
||||
let count = segs.iter().filter(|s| vp_error(s, v) < thr * 2.0).count();
|
||||
(count >= 2).then_some((v, total))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ solving
|
||||
|
||||
/// Direction (camera frame) of a vanishing point for focal length `f`.
|
||||
fn direction(v: [f64; 3], f: f64) -> [f64; 3] {
|
||||
norm([v[0] / f, v[1] / f, v[2]])
|
||||
}
|
||||
|
||||
/// The rotation (rows) taking unit vector `a` to unit vector `b` (Rodrigues; minimal angle).
|
||||
fn rotation_between(a: [f64; 3], b: [f64; 3]) -> Mat3 {
|
||||
let axis = cross(a, b);
|
||||
let s = dot(axis, axis).sqrt();
|
||||
let c = dot(a, b);
|
||||
if s < 1e-12 {
|
||||
return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
}
|
||||
let k = [axis[0] / s, axis[1] / s, axis[2] / s];
|
||||
let kx = [[0.0, -k[2], k[1]], [k[2], 0.0, -k[0]], [-k[1], k[0], 0.0]];
|
||||
let mut r = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
let kk: f64 = (0..3).map(|m| kx[i][m] * kx[m][j]).sum();
|
||||
r[i][j] = if i == j { 1.0 } else { 0.0 } + s * kx[i][j] + (1.0 - c) * kk;
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn rotation_angle(r: &Mat3) -> f64 {
|
||||
((r[0][0] + r[1][1] + r[2][2] - 1.0) / 2.0).clamp(-1.0, 1.0).acos()
|
||||
}
|
||||
|
||||
/// Vertical correction: send the vertical vanishing direction to the image y axis.
|
||||
fn solve_vertical(v: [f64; 3], f: f64) -> Mat3 {
|
||||
let mut d = direction(v, f);
|
||||
if d[1] < 0.0 {
|
||||
d = [-d[0], -d[1], -d[2]];
|
||||
}
|
||||
rotation_between(d, [0.0, 1.0, 0.0])
|
||||
}
|
||||
|
||||
/// Full correction: vertical direction → y, horizontal direction → x (Gram–Schmidt).
|
||||
fn solve_full(v: [f64; 3], hz: [f64; 3], f: f64) -> Mat3 {
|
||||
let mut dv = direction(v, f);
|
||||
if dv[1] < 0.0 {
|
||||
dv = [-dv[0], -dv[1], -dv[2]];
|
||||
}
|
||||
let mut dh = direction(hz, f);
|
||||
if dh[0] < 0.0 {
|
||||
dh = [-dh[0], -dh[1], -dh[2]];
|
||||
}
|
||||
let p = dot(dh, dv);
|
||||
let r1 = norm([dh[0] - p * dv[0], dh[1] - p * dv[1], dh[2] - p * dv[2]]);
|
||||
let r3 = cross(r1, dv);
|
||||
[r1, dv, r3]
|
||||
}
|
||||
|
||||
/// Focal length (centred units) that makes two finite vanishing points orthogonal, when plausible.
|
||||
fn focal_from_vps(v: [f64; 3], h: [f64; 3]) -> Option<f64> {
|
||||
if v[2].abs() < 1e-9 || h[2].abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let (vx, vy, hx, hy) = (v[0] / v[2], v[1] / v[2], h[0] / h[2], h[1] / h[2]);
|
||||
let f2 = -(vx * hx + vy * hy);
|
||||
(f2 > 0.0).then(|| f2.sqrt()).filter(|f| (0.5..=6.0).contains(f))
|
||||
}
|
||||
|
||||
/// Pure rotation (degrees, clockwise +) that levels the dominant lines.
|
||||
fn level_angle(vert: &[Segment], horiz: &[Segment]) -> Option<f64> {
|
||||
let dev = |s: &Segment, vertical: bool| {
|
||||
let (dx, dy) = (s.b.x - s.a.x, s.b.y - s.a.y);
|
||||
// signed deviation in degrees of the segment from the axis
|
||||
let a = if vertical { dx.atan2(dy) } else { dy.atan2(dx) };
|
||||
let a = a.to_degrees();
|
||||
let a = if a > 90.0 {
|
||||
a - 180.0
|
||||
} else if a < -90.0 {
|
||||
a + 180.0
|
||||
} else {
|
||||
a
|
||||
};
|
||||
if vertical { -a } else { a }
|
||||
};
|
||||
let lh: f64 = horiz.iter().map(Segment::len).sum();
|
||||
let lv: f64 = vert.iter().map(Segment::len).sum();
|
||||
let (set, vertical) = if lh >= lv * 0.5 && lh > 0.0 { (horiz, false) } else { (vert, true) };
|
||||
if set.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// length-weighted median
|
||||
let mut v: Vec<(f64, f64)> = set.iter().map(|s| (dev(s, vertical), s.len())).collect();
|
||||
v.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let total: f64 = v.iter().map(|x| x.1).sum();
|
||||
let mut acc = 0.0;
|
||||
for (d, l) in &v {
|
||||
acc += l;
|
||||
if acc >= total / 2.0 {
|
||||
return Some(-*d);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn rot_z_h(deg: f64) -> Homography {
|
||||
let (s, c) = deg.to_radians().sin_cos();
|
||||
Homography([c, -s, 0.0, s, c, 0.0, 0.0, 0.0, 1.0])
|
||||
}
|
||||
|
||||
/// What an Upright analysis found.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Analysis {
|
||||
pub vertical: Vec<Segment>,
|
||||
pub horizontal: Vec<Segment>,
|
||||
pub vertical_vp: Option<[f64; 3]>,
|
||||
pub horizontal_vp: Option<[f64; 3]>,
|
||||
}
|
||||
|
||||
/// Group segments into near-vertical / near-horizontal families and find their vanishing points.
|
||||
pub fn analyze(segs: &[Segment]) -> Analysis {
|
||||
let vertical: Vec<Segment> = segs.iter().copied().filter(|s| s.tilt_from_vertical() < 30.0).collect();
|
||||
let horizontal: Vec<Segment> = segs.iter().copied().filter(|s| s.tilt_from_vertical() > 60.0).collect();
|
||||
let vertical_vp = vanishing_point(&vertical).map(|v| v.0);
|
||||
let horizontal_vp = vanishing_point(&horizontal).map(|v| v.0);
|
||||
Analysis { vertical, horizontal, vertical_vp, horizontal_vp }
|
||||
}
|
||||
|
||||
/// The Upright correction for `mode` from an analysis (lens-corrected → transformed, centred coordinates).
|
||||
pub fn solve(mode: Upright, a: &Analysis) -> Homography {
|
||||
let level = || level_angle(&a.vertical, &a.horizontal).map(rot_z_h).unwrap_or(Homography::IDENTITY);
|
||||
let vertical = || a.vertical_vp.map(|v| solve_vertical(v, DEFAULT_FOCAL));
|
||||
let full = || {
|
||||
let (v, h) = (a.vertical_vp?, a.horizontal_vp?);
|
||||
let f = focal_from_vps(v, h).unwrap_or(DEFAULT_FOCAL);
|
||||
Some((solve_full(v, h, f), f))
|
||||
};
|
||||
match mode {
|
||||
Upright::Off | Upright::Guided => Homography::IDENTITY,
|
||||
Upright::Level => level(),
|
||||
Upright::Vertical => vertical().map(|r| recentre(&rotation_homography(&r, DEFAULT_FOCAL))).unwrap_or_else(level),
|
||||
Upright::Full => match full() {
|
||||
Some((r, f)) => recentre(&rotation_homography(&r, f)),
|
||||
None => vertical().map(|r| recentre(&rotation_homography(&r, DEFAULT_FOCAL))).unwrap_or_else(level),
|
||||
},
|
||||
Upright::Auto => {
|
||||
// balanced: full correction only when it is moderate, else vertical, else level
|
||||
if let Some((r, f)) = full().filter(|(r, _)| rotation_angle(r) < 20f64.to_radians()) {
|
||||
return recentre(&rotation_homography(&r, f));
|
||||
}
|
||||
match vertical().filter(|r| rotation_angle(r) < 25f64.to_radians()) {
|
||||
Some(r) => recentre(&rotation_homography(&r, DEFAULT_FOCAL)),
|
||||
None => level(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Guided Upright from user guides (centred coordinates of the lens-corrected image). Guides closer to vertical
|
||||
/// define the vertical vanishing point, the others the horizontal one; one guide of a kind only levels.
|
||||
pub fn solve_guided(guides: &[Segment]) -> Homography {
|
||||
let guides: Vec<Segment> = guides.iter().copied().filter(|g| g.len() > 1e-6).take(4).collect();
|
||||
let vert: Vec<Segment> = guides.iter().copied().filter(|g| g.tilt_from_vertical() <= 45.0).collect();
|
||||
let horiz: Vec<Segment> = guides.iter().copied().filter(|g| g.tilt_from_vertical() > 45.0).collect();
|
||||
let vp = |s: &[Segment]| -> Option<[f64; 3]> {
|
||||
if s.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let refs: Vec<&Segment> = s.iter().collect();
|
||||
Some(refine_vp(&refs))
|
||||
};
|
||||
let (v, h) = (vp(&vert), vp(&horiz));
|
||||
match (v, h) {
|
||||
(Some(v), Some(h)) => {
|
||||
let f = focal_from_vps(v, h).unwrap_or(DEFAULT_FOCAL);
|
||||
recentre(&rotation_homography(&solve_full(v, h, f), f))
|
||||
}
|
||||
(Some(v), None) => recentre(&rotation_homography(&solve_vertical(v, DEFAULT_FOCAL), DEFAULT_FOCAL)),
|
||||
(None, Some(_)) => {
|
||||
// two horizontal guides: make them parallel and horizontal (rotate the problem by 90°)
|
||||
let swap = |p: Point| Point::new(p.y, -p.x);
|
||||
let hs: Vec<Segment> = horiz.iter().map(|s| Segment { a: swap(s.a), b: swap(s.b) }).collect();
|
||||
let refs: Vec<&Segment> = hs.iter().collect();
|
||||
let r = solve_vertical(refine_vp(&refs), DEFAULT_FOCAL);
|
||||
let inner = rotation_homography(&r, DEFAULT_FOCAL);
|
||||
let to = Homography([0.0, 1.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0]); // (x, y) → (y, −x)
|
||||
let back = to.inverse().unwrap_or(Homography::IDENTITY);
|
||||
recentre(&back.mul(&inner).mul(&to))
|
||||
}
|
||||
(None, None) => level_angle(&vert, &horiz).map(rot_z_h).unwrap_or(Homography::IDENTITY),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ sources
|
||||
|
||||
/// Long edge of the analysis image.
|
||||
const ANALYSIS_LONG: usize = 1024;
|
||||
|
||||
/// Analyse a source: the lens-corrected (optics applied, no perspective/crop) image at [`ANALYSIS_LONG`] px,
|
||||
/// segment detection and vanishing points. Cached per source + optics settings.
|
||||
pub fn analyze_source(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> Analysis {
|
||||
static CACHE: Mutex<Vec<(u64, Analysis)>> = Mutex::new(Vec::new());
|
||||
let mut base = s.clone();
|
||||
base.geometry = Geometry::default();
|
||||
base.crop = Crop::default();
|
||||
let key = crate::optics::fingerprint(src)
|
||||
^ lightcraft_develop::DevelopSettings { optics: s.optics, orientation: s.orientation, ..Default::default() }.hash64().rotate_left(17);
|
||||
if let Ok(c) = CACHE.lock()
|
||||
&& let Some((_, a)) = c.iter().find(|(k, _)| *k == key)
|
||||
{
|
||||
return a.clone();
|
||||
}
|
||||
let frame = crate::frame_for(src, info, &base, false);
|
||||
let (w, h) = frame.fit(ANALYSIS_LONG, ANALYSIS_LONG);
|
||||
let img = frame.sample(src, w, h);
|
||||
// perceptual lightness, normalized to the image's bright end
|
||||
let lum = img.luminance();
|
||||
let mut v: Vec<f32> = lum.data.iter().copied().filter(|x| x.is_finite()).collect();
|
||||
v.sort_by(f32::total_cmp);
|
||||
let white = v.get(v.len() * 99 / 100).copied().unwrap_or(1.0).max(1e-4);
|
||||
let lum = lum.map(|x| (x.max(0.0) / white).min(1.5).powf(1.0 / 2.4));
|
||||
let a = analyze(&detect_segments(&lum));
|
||||
if let Ok(mut c) = CACHE.lock() {
|
||||
c.push((key, a.clone()));
|
||||
if c.len() > 8 {
|
||||
c.remove(0);
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// The automatic Upright correction for `mode` on `src` (identity for Off and Guided).
|
||||
pub fn auto_transform(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, mode: Upright) -> Homography {
|
||||
if matches!(mode, Upright::Off | Upright::Guided) {
|
||||
return Homography::IDENTITY;
|
||||
}
|
||||
solve(mode, &analyze_source(src, info, s))
|
||||
}
|
||||
|
||||
/// Fill in a missing automatic Upright transform (settings from XMP/CLI that name a mode but carry no analysis).
|
||||
pub fn resolve<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings) -> Cow<'a, DevelopSettings> {
|
||||
let g = &s.geometry;
|
||||
if matches!(g.upright, Upright::Off | Upright::Guided) || g.upright_transform.is_some() || !s.section_enabled("geometry") {
|
||||
return Cow::Borrowed(s);
|
||||
}
|
||||
let mut e = s.clone();
|
||||
e.geometry.upright_transform = Some(auto_transform(src, info, s, g.upright));
|
||||
Cow::Owned(e)
|
||||
}
|
||||
|
||||
/// The Upright homography in effect for settings (stored automatic transform, or solved from guides).
|
||||
/// `ow × oh` is the oriented image size (guides are normalized to it).
|
||||
pub fn homography(g: &Geometry, ow: f64, oh: f64) -> Homography {
|
||||
match g.upright {
|
||||
Upright::Off => Homography::IDENTITY,
|
||||
Upright::Guided => {
|
||||
let l = ow.max(oh) / 2.0;
|
||||
let c = |p: Point| Point::new((p.x * ow - ow / 2.0) / l, (p.y * oh - oh / 2.0) / l);
|
||||
let segs: Vec<Segment> = g.guides.iter().map(|(a, b)| Segment { a: c(*a), b: c(*b) }).collect();
|
||||
solve_guided(&segs)
|
||||
}
|
||||
_ => g.upright_transform.unwrap_or(Homography::IDENTITY),
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,33 @@ use crate::widgets::register;
|
||||
/// An in-progress on-canvas gesture.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Gesture {
|
||||
Brush { points: Vec<Point> },
|
||||
Spot { points: Vec<Point> },
|
||||
CropHandle { handle: u8, start: lightcraft_geom::Rect, angle: f64 },
|
||||
CropRotate { start_angle: f64, a0: f32 },
|
||||
Pan { start: (f32, f32), at: Pos2 },
|
||||
MaskHandle { mask: u32, handle: u8 },
|
||||
Brush {
|
||||
points: Vec<Point>,
|
||||
},
|
||||
Spot {
|
||||
points: Vec<Point>,
|
||||
},
|
||||
CropHandle {
|
||||
handle: u8,
|
||||
start: lightcraft_geom::Rect,
|
||||
angle: f64,
|
||||
},
|
||||
CropRotate {
|
||||
start_angle: f64,
|
||||
a0: f32,
|
||||
},
|
||||
Pan {
|
||||
start: (f32, f32),
|
||||
at: Pos2,
|
||||
},
|
||||
MaskHandle {
|
||||
mask: u32,
|
||||
handle: u8,
|
||||
},
|
||||
/// Guided Upright: a guide being drawn from `a` (normalized transformed coordinates).
|
||||
Guide {
|
||||
a: Point,
|
||||
},
|
||||
}
|
||||
|
||||
/// Screen ↔ normalized-image mapping for the displayed frame.
|
||||
@@ -98,7 +119,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
|
||||
let d = (*photo.develop).clone();
|
||||
let crop_tool = app.ui.right == RightPanel::Crop;
|
||||
let frame = Frame::new(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool);
|
||||
let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool, photo.embedded_lens.as_ref());
|
||||
let aspect = frame.aspect() as f32;
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let area = canvas.shrink(if crop_tool { 48.0 } else { 24.0 });
|
||||
@@ -237,6 +258,10 @@ fn general_interaction(
|
||||
// ------------------------------------------------------------------------ crop
|
||||
|
||||
fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings, id: PhotoId) {
|
||||
if app.ui.tool == "guidedUpright" {
|
||||
guided_overlay(app, ui, resp, map, frame, d);
|
||||
return;
|
||||
}
|
||||
let quad = frame_crop_quad(d, frame);
|
||||
let pts: Vec<Pos2> = quad.iter().map(|q| map.screen(*q)).collect();
|
||||
let p = ui.painter();
|
||||
@@ -366,6 +391,45 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
|
||||
let _ = id;
|
||||
}
|
||||
|
||||
/// Guided Upright: draw up to four guides along lines that should be vertical or horizontal. Guides are
|
||||
/// stored in lens-corrected (pre-perspective) coordinates, so they stay attached to the image as it warps.
|
||||
fn guided_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings) {
|
||||
let p = ui.painter_at(map.rect.expand(8.0));
|
||||
let col = Color32::from_rgb(255, 196, 40);
|
||||
let draw = |a: Pos2, b: Pos2| {
|
||||
p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(120)));
|
||||
p.line_segment([a, b], Stroke::new(1.5, col));
|
||||
for q in [a, b] {
|
||||
p.circle_filled(q, 4.0, col);
|
||||
p.circle_stroke(q, 4.0, Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
};
|
||||
for (i, (a, b)) in d.geometry.guides.iter().enumerate() {
|
||||
let (sa, sb) = (map.screen(frame.corrected_to_transformed(*a)), map.screen(frame.corrected_to_transformed(*b)));
|
||||
register(ui.ctx(), format!("uprightGuide:{i}"), Rect::from_two_pos(sa, sb));
|
||||
draw(sa, sb);
|
||||
}
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
if resp.drag_started()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
app.gesture = Some(Gesture::Guide { a: map.norm(q) });
|
||||
}
|
||||
if let (Some(Gesture::Guide { a }), Some(q)) = (&app.gesture, resp.interact_pointer_pos()) {
|
||||
draw(map.screen(*a), q);
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& let (Some(Gesture::Guide { a }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
|
||||
{
|
||||
app.gesture = None;
|
||||
let b = map.norm(q);
|
||||
if a.dist(b) > 0.02 {
|
||||
let (ca, cb) = (frame.transformed_to_corrected(a), frame.transformed_to_corrected(b));
|
||||
let _ = app.run("geometry.guides", json!({"guides": [[ca.x, ca.y, cb.x, cb.y]], "add": true}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized oriented coords → the straightened (rotated) frame the crop rect lives in.
|
||||
fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point {
|
||||
let (w, h) = (frame.ow, frame.oh);
|
||||
|
||||
@@ -213,19 +213,52 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
section(app, ui, &d, "optics", "Optics", |app, ui, d| {
|
||||
let has_lens = app.session.catalog.photo(id).is_some_and(|p| p.embedded_lens.is_some());
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| {
|
||||
let mut ca = d.optics.remove_ca;
|
||||
if ui.checkbox(&mut ca, "Remove Chromatic Aberration").changed() {
|
||||
let _ = app.run("develop.merge", json!({"settings": {"optics": {"remove_ca": ca}}, "label": "Remove CA"}));
|
||||
}
|
||||
let mut lp = d.optics.lens_profile;
|
||||
if ui.checkbox(&mut lp, "Use Lens Corrections").changed() {
|
||||
if ui.checkbox(&mut lp, "Enable Lens Corrections").changed() {
|
||||
let _ = app.run("develop.merge", json!({"settings": {"optics": {"lens_profile": lp}}, "label": "Lens Corrections"}));
|
||||
}
|
||||
if lp && !has_lens {
|
||||
ui.label(
|
||||
egui::RichText::new("No lens data embedded in this file (DNG lens opcodes only).")
|
||||
.size(11.0)
|
||||
.color(Tokens::get(ui.ctx()).text_dim),
|
||||
);
|
||||
}
|
||||
});
|
||||
for c in ["optics.distortion", "optics.vignetting", "optics.vignettingMidpoint", "optics.defringePurple", "optics.defringeGreen"] {
|
||||
if d.optics.lens_profile {
|
||||
for c in ["optics.profileDistortion", "optics.profileVignetting"] {
|
||||
control(app, ui, d, c, has_lens);
|
||||
}
|
||||
}
|
||||
sub_title(ui, "Manual");
|
||||
for c in ["optics.distortion", "optics.vignetting"] {
|
||||
control(app, ui, d, c, true);
|
||||
}
|
||||
control(app, ui, d, "optics.vignettingMidpoint", d.optics.vignetting != 0.0);
|
||||
ui.add_space(6.0);
|
||||
let open = app.ui.flyout_open("defringe");
|
||||
if flyout_row(ui, "defringe", "Defringe", Icon::Picker, open).clicked() {
|
||||
app.ui.toggle_flyout("defringe");
|
||||
}
|
||||
if open {
|
||||
control(app, ui, d, "optics.defringePurple", true);
|
||||
let on = d.optics.defringe_purple_amount > 0.0;
|
||||
control(app, ui, d, "optics.defringePurpleHueLo", on);
|
||||
control(app, ui, d, "optics.defringePurpleHueHi", on);
|
||||
control(app, ui, d, "optics.defringeGreen", true);
|
||||
let on = d.optics.defringe_green_amount > 0.0;
|
||||
control(app, ui, d, "optics.defringeGreenHueLo", on);
|
||||
control(app, ui, d, "optics.defringeGreenHueHi", on);
|
||||
sub_title(ui, "Lateral Chromatic Aberration");
|
||||
control(app, ui, d, "optics.caRed", true);
|
||||
control(app, ui, d, "optics.caBlue", true);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
ui.add_space(40.0);
|
||||
|
||||
@@ -133,6 +133,53 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
});
|
||||
divider(ui);
|
||||
header(ui, "Geometry");
|
||||
padded(ui, |ui| {
|
||||
ui.label("Upright");
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
use lightcraft_develop::Upright;
|
||||
for (label, mode, key) in [
|
||||
("Off", Upright::Off, "off"),
|
||||
("Auto", Upright::Auto, "auto"),
|
||||
("Guided", Upright::Guided, "guided"),
|
||||
("Level", Upright::Level, "level"),
|
||||
("Vertical", Upright::Vertical, "vertical"),
|
||||
("Full", Upright::Full, "full"),
|
||||
] {
|
||||
if text_button(ui, &format!("upright-{key}"), label, d.geometry.upright == mode).clicked() {
|
||||
let _ = app.run("geometry.upright", json!({"mode": key}));
|
||||
app.ui.tool = if mode == Upright::Guided { "guidedUpright".into() } else { String::new() };
|
||||
}
|
||||
}
|
||||
});
|
||||
let guided = d.geometry.upright == lightcraft_develop::Upright::Guided;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if !guided && d.geometry.upright != lightcraft_develop::Upright::Off && text_button(ui, "uprightUpdate", "Update", false).clicked() {
|
||||
let mode = serde_json::to_value(d.geometry.upright).unwrap_or_default();
|
||||
let _ = app.run("geometry.upright", json!({"mode": mode}));
|
||||
}
|
||||
if guided {
|
||||
let drawing = app.ui.tool == "guidedUpright";
|
||||
if text_button(ui, "uprightDraw", "Draw Guides", drawing).clicked() {
|
||||
app.ui.tool = if drawing { String::new() } else { "guidedUpright".into() };
|
||||
}
|
||||
if !d.geometry.guides.is_empty() && text_button(ui, "uprightClear", "Clear Guides", false).clicked() {
|
||||
let _ = app.run("geometry.guides", json!({"guides": []}));
|
||||
}
|
||||
}
|
||||
});
|
||||
if guided {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} of 4 guides — drag along lines that should be vertical or horizontal.", d.geometry.guides.len()))
|
||||
.size(11.0)
|
||||
.color(Tokens::get(ui.ctx()).text_dim),
|
||||
);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let mut c = d.geometry.constrain_crop;
|
||||
if ui.checkbox(&mut c, "Constrain Crop").changed() {
|
||||
let _ = app.run("develop.merge", json!({"settings": {"geometry": {"constrain_crop": c}}, "label": "Constrain Crop"}));
|
||||
}
|
||||
});
|
||||
for spec in lightcraft_develop::controls::in_section(lightcraft_develop::Section::Geometry).filter(|c| c.id != "crop.angle") {
|
||||
let v = lightcraft_develop::controls::get(&d, spec.id).unwrap_or(spec.default);
|
||||
let out = slider(ui, spec, v, true, None);
|
||||
|
||||
Reference in New Issue
Block a user