Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a897bbc01 | ||
|
|
a03e082fe2 | ||
|
|
4576499c3b | ||
|
|
2f47087223 | ||
|
|
764ad55ead |
Generated
+25
-25
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-android"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-plat",
|
||||
@@ -1234,7 +1234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-desktop"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-plat",
|
||||
@@ -1408,7 +1408,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dr-bench"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-catalog",
|
||||
@@ -1425,7 +1425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-catalog"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"dr-face",
|
||||
"dr-plat",
|
||||
@@ -1440,7 +1440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-decode"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
@@ -1454,7 +1454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-export"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-gpu",
|
||||
@@ -1473,7 +1473,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-face"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-film"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1495,7 +1495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-gpu"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dr-decode",
|
||||
@@ -1513,7 +1513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-inference-engine"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"log",
|
||||
@@ -1527,7 +1527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ingest"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
@@ -1539,7 +1539,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-lens"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"lensfun",
|
||||
"log",
|
||||
@@ -1547,7 +1547,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pano"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-inference-engine",
|
||||
@@ -1561,7 +1561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pipeline"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -1570,7 +1570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-plat"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"android-native-keyring-store",
|
||||
"dr-types",
|
||||
@@ -1586,7 +1586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-preset-xmp"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"dr-pipeline",
|
||||
"log",
|
||||
@@ -1596,7 +1596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1609,7 +1609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-plat",
|
||||
@@ -1623,7 +1623,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-folder"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-sync",
|
||||
@@ -1635,7 +1635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-nextcloud"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-decode",
|
||||
@@ -1657,7 +1657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-thumbs"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"jpeg-encoder",
|
||||
@@ -1669,7 +1669,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-types"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1678,7 +1678,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ui"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1720,7 +1720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-xmp"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -7021,7 +7021,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "traceability"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -76,7 +76,7 @@ controls, its place in the chain and its tests.
|
||||
|
||||
## Where it stands
|
||||
|
||||
**0.13.3**, seventeen tagged releases in. 184 numbered requirements in
|
||||
**0.13.4**, eighteen tagged releases in. 184 numbered requirements in
|
||||
scope, 84% of them claimed by code and [traced to it](docs/traceability.md);
|
||||
the rest are written down rather than merely absent.
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ pub mod eyes;
|
||||
pub mod landmarks;
|
||||
pub mod naming;
|
||||
pub mod neighbours;
|
||||
pub mod references;
|
||||
|
||||
/// Smallest long edge a face crop may be sampled from.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! TRACES: FR-CULL-10 | NFR-P9
|
||||
//! Which of a person's faces stand for them in a grouping pass.
|
||||
//!
|
||||
//! # Why not all of them
|
||||
//!
|
||||
//! Every face the user has ruled on enters [`crate::cluster`] as an anchor,
|
||||
//! and the pass compares every face against every other
|
||||
//! ([`crate::neighbours`] is exhaustive by design). So a person with 750
|
||||
//! confirmed faces costs 750 comparisons against each of the library's other
|
||||
//! faces, and the cost of naming a library well grows with how well it is
|
||||
//! named: a fully confirmed library of 25,000 faces spends almost the whole
|
||||
//! scan re-comparing faces whose identity is already settled against each
|
||||
//! other.
|
||||
//!
|
||||
//! Most of those comparisons say nothing new. A person's confirmed faces are
|
||||
//! heavily redundant — thirty frames from one afternoon are one point of
|
||||
//! view, not thirty — and a new face that matches one of them matches the
|
||||
//! others too. What a new face needs to be measured against is the person's
|
||||
//! *range*: the angles, ages and lights they have been photographed in, each
|
||||
//! represented once.
|
||||
//!
|
||||
//! # The choice: the most diverse of the good ones
|
||||
//!
|
||||
//! Two rules, in order.
|
||||
//!
|
||||
//! **Good enough to vouch.** Only faces whose raw embedding was at least
|
||||
//! [`MIN_REFERENCE_QUALITY`] long are eligible — a stricter floor than the
|
||||
//! gallery's ([`crate::embedding::MIN_GALLERY_QUALITY`]), because a reference
|
||||
//! is asked to speak *for* a person rather than merely be admitted to the
|
||||
//! comparison. A face whose length was never recorded is admitted, as it is
|
||||
//! everywhere else: a rule that cannot be checked admits rather than excludes.
|
||||
//!
|
||||
//! **As far apart as possible.** From the eligible pool, up to
|
||||
//! [`MAX_REFERENCES`] faces are chosen to maximise the volume they span —
|
||||
//! the determinant of their Gram matrix — greedily: start from the longest
|
||||
//! vector, and at each step add the face with the largest component
|
||||
//! orthogonal to everything chosen so far. That is Gram–Schmidt with a
|
||||
//! pivot, and the product of the squared residuals it picks *is* the
|
||||
//! determinant, so the greedy step is the exact greedy on the objective.
|
||||
//! The effect is that a near-duplicate of a chosen face has almost no
|
||||
//! residual and is passed over, while the one profile shot among two
|
||||
//! hundred frontal frames is taken early.
|
||||
//!
|
||||
//! What is not chosen still belongs to the person. Those faces keep their
|
||||
//! confirmations and are not touched by the pass; they are simply not
|
||||
//! compared, which is the whole saving.
|
||||
|
||||
/// The most faces that stand for one person.
|
||||
///
|
||||
/// A hundred is far more points of view than a person has. What it bounds
|
||||
/// is the cost: with every person at the cap, a scan against the named part
|
||||
/// of a library is `people × 100` comparisons per face rather than
|
||||
/// `confirmations`, and the two part company as soon as a library is used.
|
||||
pub const MAX_REFERENCES: usize = 100;
|
||||
|
||||
/// The shortest raw embedding that may stand for a person.
|
||||
///
|
||||
/// One above the gallery floor: a reference vouches for someone, and the
|
||||
/// margin keeps the faces that only just cleared the gallery — the ones
|
||||
/// nearest the middle of the sphere — out of the set that speaks for a
|
||||
/// person.
|
||||
pub const MIN_REFERENCE_QUALITY: f32 = 15.0;
|
||||
|
||||
/// Whether a face of this quality may stand for a person.
|
||||
///
|
||||
/// `None` is "never measured" and is admitted, as in
|
||||
/// [`crate::embedding::in_gallery`].
|
||||
pub fn eligible(quality: Option<f32>) -> bool {
|
||||
quality.is_none_or(|q| q >= MIN_REFERENCE_QUALITY)
|
||||
}
|
||||
|
||||
/// Choose which of one person's faces stand for them.
|
||||
///
|
||||
/// `embeddings` and `quality` are one entry per face, the embeddings unit
|
||||
/// length and all of one dimension. Returns the indices chosen, in the order
|
||||
/// chosen — the first is the longest eligible vector, and each after it is
|
||||
/// the one furthest from the span of those before. Every eligible face is
|
||||
/// returned when there are `max` or fewer of them, so a person under the
|
||||
/// cap loses nothing.
|
||||
///
|
||||
/// Deterministic: equal residuals break on the longer vector, then the lower
|
||||
/// index, so two devices holding the same faces choose the same references
|
||||
/// and group the same way (`cluster::clustering_is_deterministic`).
|
||||
pub fn select(embeddings: &[&[f32]], quality: &[Option<f32>], max: usize) -> Vec<usize> {
|
||||
debug_assert_eq!(embeddings.len(), quality.len());
|
||||
let mut pool: Vec<usize> = (0..embeddings.len())
|
||||
.filter(|&i| eligible(quality[i]))
|
||||
.collect();
|
||||
if pool.len() <= max {
|
||||
return pool;
|
||||
}
|
||||
// Longest first, so the seed is the pool's front and a tie on residual
|
||||
// resolves to the earlier position. A missing reading ranks below any
|
||||
// measured one for this purpose only: it is admitted, but a face that
|
||||
// was measured and found long is the better seed.
|
||||
pool.sort_by(|&a, &b| {
|
||||
let qa = quality[a].unwrap_or(0.0);
|
||||
let qb = quality[b].unwrap_or(0.0);
|
||||
qb.total_cmp(&qa).then(a.cmp(&b))
|
||||
});
|
||||
|
||||
// Residuals: what remains of each pool vector outside the span of the
|
||||
// chosen ones. Copied, since they are rewritten in place.
|
||||
let mut residual: Vec<Vec<f32>> = pool.iter().map(|&i| embeddings[i].to_vec()).collect();
|
||||
let mut taken = vec![false; pool.len()];
|
||||
let mut chosen = Vec::with_capacity(max);
|
||||
|
||||
while chosen.len() < max {
|
||||
// The face with the most left outside the span. The seed is the
|
||||
// pool's front by construction: every unit vector has the same
|
||||
// residual before anything is chosen, up to rounding, and rounding
|
||||
// is not a reason to prefer one. After that `> best` and not `>=`,
|
||||
// so a genuine tie keeps the earlier (longer) candidate.
|
||||
let mut pick = None;
|
||||
let mut best = 0.0_f32;
|
||||
if chosen.is_empty() {
|
||||
pick = Some(0);
|
||||
best = residual[0].iter().map(|x| x * x).sum();
|
||||
} else {
|
||||
for (k, r) in residual.iter().enumerate() {
|
||||
if taken[k] {
|
||||
continue;
|
||||
}
|
||||
let n2: f32 = r.iter().map(|x| x * x).sum();
|
||||
if n2 > best {
|
||||
best = n2;
|
||||
pick = Some(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nothing left outside the span: every remaining face is a
|
||||
// combination of the chosen ones and adds no volume.
|
||||
let Some(k) = pick.filter(|_| best > 1e-6) else {
|
||||
break;
|
||||
};
|
||||
taken[k] = true;
|
||||
chosen.push(pool[k]);
|
||||
|
||||
// Project the chosen direction out of every remaining residual.
|
||||
let inv = best.sqrt().recip();
|
||||
let q: Vec<f32> = residual[k].iter().map(|x| x * inv).collect();
|
||||
for (j, r) in residual.iter_mut().enumerate() {
|
||||
if taken[j] {
|
||||
continue;
|
||||
}
|
||||
let d: f32 = r.iter().zip(&q).map(|(a, b)| a * b).sum();
|
||||
for (x, y) in r.iter_mut().zip(&q) {
|
||||
*x -= d * y;
|
||||
}
|
||||
}
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unit(v: &[f32]) -> Vec<f32> {
|
||||
let n = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
v.iter().map(|x| x / n).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_person_under_the_cap_keeps_every_eligible_face() {
|
||||
let e = [unit(&[1.0, 0.0]), unit(&[0.0, 1.0]), unit(&[1.0, 1.0])];
|
||||
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
|
||||
let q = [Some(20.0), None, Some(16.0)];
|
||||
assert_eq!(select(&refs, &q, 100), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_vector_never_stands_for_a_person() {
|
||||
let e = [unit(&[1.0, 0.0]), unit(&[0.0, 1.0])];
|
||||
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
|
||||
let q = [Some(20.0), Some(MIN_REFERENCE_QUALITY - 0.01)];
|
||||
assert_eq!(select(&refs, &q, 100), vec![0]);
|
||||
}
|
||||
|
||||
/// Two hundred frames from one afternoon and one profile shot: the
|
||||
/// profile is the second choice, not the two-hundred-and-first.
|
||||
#[test]
|
||||
fn the_odd_one_out_is_chosen_before_any_duplicate() {
|
||||
let mut e: Vec<Vec<f32>> = Vec::new();
|
||||
let mut q = Vec::new();
|
||||
for i in 0..200 {
|
||||
// Near-duplicates of one direction, with a little noise.
|
||||
let t = (i as f32) * 1e-3;
|
||||
e.push(unit(&[1.0, t, t * 0.5]));
|
||||
q.push(Some(20.0 + (i % 7) as f32));
|
||||
}
|
||||
e.push(unit(&[0.0, 0.0, 1.0]));
|
||||
q.push(Some(16.0));
|
||||
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
|
||||
let chosen = select(&refs, &q, 3);
|
||||
assert_eq!(chosen.len(), 3);
|
||||
assert_eq!(
|
||||
chosen[1], 200,
|
||||
"the profile shot was not second: {chosen:?}"
|
||||
);
|
||||
// Seeded on the longest vector.
|
||||
assert_eq!(q[chosen[0]], Some(26.0));
|
||||
}
|
||||
|
||||
/// Faces inside the span of the chosen ones add no volume and are not
|
||||
/// taken to fill the cap.
|
||||
#[test]
|
||||
fn the_cap_is_not_filled_from_inside_the_span() {
|
||||
let e = [
|
||||
unit(&[1.0, 0.0]),
|
||||
unit(&[0.0, 1.0]),
|
||||
unit(&[1.0, 1.0]),
|
||||
unit(&[2.0, -1.0]),
|
||||
];
|
||||
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
|
||||
let q = [Some(20.0); 4];
|
||||
assert_eq!(select(&refs, &q, 3).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_choice_is_deterministic() {
|
||||
let e: Vec<Vec<f32>> = (0..50)
|
||||
.map(|i| {
|
||||
let a = (i as f32) * 0.37;
|
||||
unit(&[a.cos(), a.sin(), (a * 3.0).sin(), 0.2])
|
||||
})
|
||||
.collect();
|
||||
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
|
||||
let q = vec![Some(18.0); 50];
|
||||
assert_eq!(select(&refs, &q, 5), select(&refs, &q, 5));
|
||||
}
|
||||
}
|
||||
@@ -857,6 +857,17 @@ impl EditGraph {
|
||||
crate::operation::compose_camera_linear(&self.warps, self.framing.baseline(), view)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The camera-space tap under this edit's own framing — crop, view,
|
||||
/// rotation and all — so a fraction of what is on the canvas is a
|
||||
/// fraction of what this renders. Nothing else of the edit: no
|
||||
/// operation, no mask, no repair. See
|
||||
/// [`crate::operation::compose_camera_probe`] for why the white balance
|
||||
/// picker reads from here and not from the display.
|
||||
pub fn compose_camera_probe(&self) -> ComposedShader {
|
||||
crate::operation::compose_camera_probe(&self.warps, &self.framing)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// [`Self::compose_for`], with one layer's mask drawn over the picture.
|
||||
///
|
||||
|
||||
@@ -65,7 +65,8 @@ pub use history::{Edit, Entry as HistoryEntry, History, Step};
|
||||
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
|
||||
pub use operation::{
|
||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
||||
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS,
|
||||
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET,
|
||||
RESERVED_UNIFORM_FIELDS,
|
||||
};
|
||||
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
|
||||
pub use sidecar::{Sidecar, Version};
|
||||
|
||||
@@ -69,20 +69,26 @@ const ROUNDS: u32 = 3;
|
||||
|
||||
/// Below this a channel carries no ratio worth balancing.
|
||||
///
|
||||
/// A sample in the deep shadows, or one taken on a blown highlight where a
|
||||
/// channel has already clipped to nothing, has no white balance in it: the
|
||||
/// logarithms below would run away and the picker would slam a slider to its
|
||||
/// stop. Refusing is the honest answer, and the caller reports that the point
|
||||
/// was not usable rather than moving the photograph.
|
||||
/// A sample in the deep shadows has no white balance in it: the logarithms
|
||||
/// below would run away and the picker would slam a slider to its stop.
|
||||
/// Refusing is the honest answer, and the caller reports that the point was
|
||||
/// not usable rather than moving the photograph. The other end — a blown
|
||||
/// highlight, where every channel has stopped counting — is refused by the
|
||||
/// caller before the sample is taken, because only the caller can see the
|
||||
/// sensor value; see [`crate::operation::CLIP_ONSET`].
|
||||
const FLOOR: f32 = 1e-4;
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Move the graph so that `sample` renders neutral.
|
||||
///
|
||||
/// `sample` is linear RGB, as the operation's own gains multiply it — that is,
|
||||
/// measured with the sampling operation at its defaults. Returns whether the
|
||||
/// graph was moved: `false` where the chain offers no white point widget, or
|
||||
/// where the colour has no balance in it to correct.
|
||||
/// `sample` is the linear triple the operation's own gains multiply — camera
|
||||
/// RGB with the camera's as-shot balance on, *before* the body's base curve
|
||||
/// and matrix, and with the sampling operation at its defaults. Not the
|
||||
/// pixel on the screen: the matrix mixes the channels on the way there, so
|
||||
/// a colour read after it does not answer to these gains, and a solve over
|
||||
/// one lands somewhere no sample asked for. Returns whether the graph was
|
||||
/// moved: `false` where the chain offers no white point widget, or where the
|
||||
/// colour has no balance in it to correct.
|
||||
///
|
||||
/// **Absolute, not relative.** The values written depend on the colour and not
|
||||
/// on where the sliders happened to be, so sampling the same wall twice lands
|
||||
|
||||
@@ -431,9 +431,11 @@ pub enum OutputMode {
|
||||
/// no operations, and the caller fills the reserved uniforms neutral —
|
||||
/// unit white balance, identity matrix, base curve off — so what is
|
||||
/// stored is the sensor's own numbers, demosaiced and undistorted. Only
|
||||
/// [`compose_camera_linear`] produces it, and only
|
||||
/// `AdjustPass::render_camera_linear` accepts it, so the neutral
|
||||
/// uniforms cannot be forgotten by a caller that composed it by mistake.
|
||||
/// [`compose_camera_probe`] produces it — for a merge through
|
||||
/// [`compose_camera_linear`], and for the white balance picker under the
|
||||
/// edit's own framing — and only `AdjustPass::render_camera_linear`
|
||||
/// accepts it, so the neutral uniforms cannot be forgotten by a caller
|
||||
/// that composed it by mistake.
|
||||
///
|
||||
/// Thirty-two bits rather than sixteen because the composite is written
|
||||
/// back as a RAW at the sensor's scale (FR-MRG-3): a 14-bit sensor has
|
||||
@@ -490,6 +492,19 @@ pub const BASE_CURVE_UNIFORM_OFFSET: usize = 16;
|
||||
/// selection in [`compose_full`].
|
||||
pub const BASE_CURVE_POINTS: usize = 5;
|
||||
|
||||
/// Where the highlight desaturation begins: the fraction of the white level
|
||||
/// above which a photosite is treated as clipped.
|
||||
///
|
||||
/// A photosite this close to saturation has stopped counting, so its ratio
|
||||
/// to its neighbours is not a colour. The generated prologue fades a pixel
|
||||
/// above this toward a neutral of the same brightness before any operation
|
||||
/// runs, and the white balance probe refuses to sample one: a blown sky is
|
||||
/// sensor white, which the as-shot multipliers make magenta, and a solve
|
||||
/// over that slams tint to its stop. One number, so the two cannot drift
|
||||
/// apart — a probe that accepted what the shader had already desaturated
|
||||
/// would be balancing against a pixel the photographer cannot see.
|
||||
pub const CLIP_ONSET: f32 = 0.985;
|
||||
|
||||
/// Where an operation's own uniforms begin in the generated block.
|
||||
///
|
||||
/// The base fields, then framing's. Exported because `dr-gpu` writes the
|
||||
@@ -614,9 +629,26 @@ pub fn compose_camera_linear(
|
||||
// upright too, and `view` is a fraction of the upright frame.
|
||||
framing.set_baseline(baseline);
|
||||
framing.set_view(view);
|
||||
compose_camera_probe(warps, &framing)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The same tap under a framing the caller chose: the white balance probe.
|
||||
///
|
||||
/// A neutral picked off the canvas has to be measured in the space the
|
||||
/// white balance gains multiply, and that is camera RGB — the operation
|
||||
/// runs before the body's matrix, and a probe read after the matrix would
|
||||
/// be solving the wrong equation on any body whose matrix mixes the
|
||||
/// channels, which is every body. It also has to be measured at the pixel
|
||||
/// the canvas is showing, which is why this takes the edit's own framing
|
||||
/// where a merge passes the file's orientation and a tile.
|
||||
pub fn compose_camera_probe(
|
||||
warps: &[Box<dyn crate::lens::Warp>],
|
||||
framing: &Framing,
|
||||
) -> ComposedShader {
|
||||
compose_inner(
|
||||
&[],
|
||||
&framing,
|
||||
framing,
|
||||
ColourSpace::Srgb,
|
||||
&MaskStack::new(),
|
||||
&crate::spot::SpotSet::new(),
|
||||
@@ -668,7 +700,7 @@ fn compose_inner(
|
||||
// twice and be bound to a texture of the wrong format.
|
||||
//
|
||||
// `forced` is the one exception, and it is not a caller flag in the
|
||||
// sense above: `compose_camera_linear` is the only function that passes
|
||||
// sense above: `compose_camera_probe` is the only function that passes
|
||||
// it, with an empty operation list, and the mode it forces has its own
|
||||
// storage format and its own render entry on the GPU side.
|
||||
let output_mode = forced.unwrap_or(
|
||||
@@ -997,6 +1029,9 @@ fn compose_inner(
|
||||
.to_string()
|
||||
};
|
||||
|
||||
// Formatted with Rust's `Display` so the shader reads the same threshold
|
||||
// the probe checks against; see `CLIP_ONSET`.
|
||||
let clip_onset = CLIP_ONSET;
|
||||
let source = format!(
|
||||
"// GENERATED — do not edit.
|
||||
//
|
||||
@@ -1067,7 +1102,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
// A photosite at its white level carries no colour information — every
|
||||
// channel simply stopped counting — so the balance below must not be
|
||||
// allowed to tint it.
|
||||
let clipped = smoothstep(0.985, 1.0, max(c.r, max(c.g, c.b)));
|
||||
let clipped = smoothstep({clip_onset}, 1.0, max(c.r, max(c.g, c.b)));
|
||||
|
||||
c = c * u.as_shot_wb.rgb;
|
||||
|
||||
|
||||
@@ -821,6 +821,16 @@ here, and it is the phone and tablet story that should decide whether it gets bu
|
||||
already has, the raw vector written over the old one and its id, box and identity untouched
|
||||
(`faces::record_updates`). No detector runs and no suggestion is lost — the cost is the
|
||||
original fetched once more, since the length exists only at the moment of embedding.
|
||||
- **A person is stood for by their references.** Every face the user has ruled on is an anchor,
|
||||
and the scan is exhaustive, so a person with 750 confirmations would cost 750 comparisons
|
||||
against every other face — and the cost of a library would grow with how well it was named.
|
||||
Instead each person enters through at most `MAX_REFERENCES` (100) of their anchored faces,
|
||||
chosen by `dr_face::references`: those whose raw embedding is at least `MIN_REFERENCE_QUALITY`
|
||||
(15) long, and among them the set spanning the greatest volume — greedy max-determinant, the
|
||||
longest vector first and then, at each step, the face with the largest component orthogonal to
|
||||
the chosen so far. Thirty frames from one afternoon contribute one reference; the single profile
|
||||
shot is taken early. The faces not chosen keep their confirmations and are not touched by the
|
||||
pass; they are simply not compared.
|
||||
|
||||
**The algorithm.** Constrained average-link agglomeration over the probability graph, merging while
|
||||
the average pairwise probability exceeds **0.9** and no cannot-link is violated. Average-link rather
|
||||
|
||||
Binary file not shown.
+35
-35
File diff suppressed because one or more lines are too long
+1
-1
@@ -4,7 +4,7 @@
|
||||
# makes `makepkg -si` in this directory install what you are actually working
|
||||
# on. Swap `source` for a tagged tarball when there is something to release.
|
||||
pkgname=darkroom
|
||||
pkgver=0.13.3
|
||||
pkgver=0.13.4
|
||||
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
||||
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
||||
pkgrel=1
|
||||
|
||||
@@ -207,7 +207,7 @@ def develop_wb():
|
||||
group('colour')
|
||||
rec('develop-wb')
|
||||
pause(0.4)
|
||||
dr.click(1542, 767); pause(0.8) # pick
|
||||
dr.click(1487, 776); pause(0.8) # pick
|
||||
dr.click(1000, 300); pause(1.5) # a neutral wall
|
||||
hold(*BEFORE, 1.4); pause(0.8)
|
||||
cut()
|
||||
|
||||
+181
-51
@@ -4067,9 +4067,9 @@ impl DevelopSession {
|
||||
let space = self.display_space;
|
||||
// TRACES: FR-DEV-19c
|
||||
// **The one composition that may show a mask.** Every other caller of
|
||||
// the graph — `render_the_file`, the thumbnail, `sample_as_shot` —
|
||||
// goes through `compose_for`, which cannot ask for a reveal, so no
|
||||
// exported file can carry one.
|
||||
// the graph — `render_the_file`, the thumbnail — goes through
|
||||
// `compose_for`, which cannot ask for a reveal, so no exported file
|
||||
// can carry one; `sample_as_shot` composes no operations at all.
|
||||
let shader = self.graph.compose_revealing(space, self.reveal().as_ref());
|
||||
|
||||
// Rasterise the masks first: the shader addresses array slices by
|
||||
@@ -4442,18 +4442,28 @@ impl DevelopSession {
|
||||
true
|
||||
}
|
||||
|
||||
/// The colour at a point with every adjustment taken off, in linear RGB.
|
||||
/// The colour at a point in the space the white balance gains multiply:
|
||||
/// camera RGB with the camera's own balance on, linear, nothing else.
|
||||
///
|
||||
/// **Measured before the chain rather than off the screen**, and that is
|
||||
/// the difference between a picker that converges and one that chases
|
||||
/// itself. The frame on the canvas has already been through the white
|
||||
/// balance being solved for, the tone curve, the contrast and whatever
|
||||
/// else is on; neutralising *that* pixel would be correcting a correction,
|
||||
/// and the second sample of the same wall would land somewhere else. With
|
||||
/// the adjustments stripped the value read is the colour as the file has
|
||||
/// it, which is the domain `neutralise` is defined over.
|
||||
/// **Measured where the operation acts, not where the photographer
|
||||
/// looks.** The white balance node runs first in the chain, on camera
|
||||
/// RGB, before the body's base curve and its matrix; the canvas shows
|
||||
/// the pixel after all three. The probe used to be read off a display
|
||||
/// render with the adjustments stripped, and the solve then treated an
|
||||
/// sRGB triple as if the gains multiplied it directly. On a JPEG the two
|
||||
/// spaces coincide, so it worked; on a raw file from any real body the
|
||||
/// matrix mixes the channels, and a slightly blue wall on a Canon 6D
|
||||
/// came back tint −77 with the whole frame green. This reads the
|
||||
/// camera-space tap a merge stitches from — the sensor's numbers after
|
||||
/// the lens warp — and puts the as-shot balance on itself, which is
|
||||
/// exactly the value the operation's gains are about to multiply.
|
||||
///
|
||||
/// The framing stays on, exactly as it does for [`Self::render_original`]
|
||||
/// That also means nothing has to be stripped and restored: the tap
|
||||
/// runs no operations at all, and the display target is untouched, so
|
||||
/// a sample that found nothing usable leaves the canvas exactly as it
|
||||
/// was.
|
||||
///
|
||||
/// The framing is the edit's own, exactly as for [`Self::render_original`]
|
||||
/// and for the same reason: `x` and `y` are fractions of what is on
|
||||
/// screen, and a probe rendered without the crop and the zoom would be
|
||||
/// answering about a different part of the photograph.
|
||||
@@ -4462,41 +4472,23 @@ impl DevelopSession {
|
||||
/// averages a small neighbourhood into each of its pixels, which is what
|
||||
/// every eyedropper does deliberately: a single photosite off a noisy
|
||||
/// shadow is a worse answer than the patch around it, and the photographer
|
||||
/// is pointing at a grey card rather than at a pixel. It is also two
|
||||
/// dispatches' worth of work on a click.
|
||||
///
|
||||
/// This overwrites the frame the adjust pass is holding, so the caller
|
||||
/// must redraw — which the callback that samples does anyway, since the
|
||||
/// picture has just changed.
|
||||
/// is pointing at a grey card rather than at a pixel. It is also one
|
||||
/// dispatch's worth of work on a click.
|
||||
fn sample_as_shot(&mut self, x: f32, y: f32) -> Option<[f32; 3]> {
|
||||
/// Long edge of the probe render. See the note above on why it is
|
||||
/// small rather than large.
|
||||
const PROBE_EDGE: u32 = 192;
|
||||
|
||||
// sRGB regardless of the display: this is a measurement, not something
|
||||
// anybody looks at, and decoding it needs a transfer function known
|
||||
// here. Composing for a wide-gamut panel would put the reading in a
|
||||
// space the arithmetic below does not undo.
|
||||
let space = dr_types::ColourSpace::Srgb;
|
||||
|
||||
let saved = self.graph.state();
|
||||
self.strip_adjustments();
|
||||
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||||
let (w, h) = fit(fw, fh, PROBE_EDGE, PROBE_EDGE);
|
||||
let shader = self.graph.compose_for(space);
|
||||
let probe = self
|
||||
.render_with_masks(&shader, w, h, space)
|
||||
.and_then(|()| self.adjust.export_pixels().map_err(|e| e.to_string()));
|
||||
|
||||
// Restored whatever happened, for the reason every other suspension
|
||||
// here restores: leaving the graph stripped after a failed probe would
|
||||
// discard the edit silently.
|
||||
let debt = self.graph.set_state(&saved);
|
||||
self.pay_film_debt(&debt);
|
||||
|
||||
let (rgba, pw, ph) = probe
|
||||
let shader = self.graph.compose_camera_probe();
|
||||
let rendered = self
|
||||
.adjust
|
||||
.render_camera_linear(&self.demosaiced, &shader, w, h)
|
||||
.map(|_| ());
|
||||
let (rgba, pw, ph) = rendered
|
||||
.and_then(|()| self.adjust.read_camera_linear())
|
||||
.inspect_err(|e| log::warn!("could not read a neutral off the frame: {e}"))
|
||||
.ok()?;
|
||||
|
||||
@@ -4504,18 +4496,27 @@ impl DevelopSession {
|
||||
let px = ((x.clamp(0.0, 1.0) * pw as f32) as usize).min(pw.saturating_sub(1));
|
||||
let py = ((y.clamp(0.0, 1.0) * ph as f32) as usize).min(ph.saturating_sub(1));
|
||||
let at = (py * pw + px) * 4;
|
||||
let pixel = rgba.get(at..at + 3)?;
|
||||
let pixel = rgba.get(at..at + 4)?;
|
||||
// The tap marks a pixel the lens correction pulled in from outside
|
||||
// the frame with alpha 0. There is nothing there to balance against.
|
||||
if pixel[3] < 0.5 {
|
||||
return None;
|
||||
}
|
||||
// Nor in a clipped one. A blown sky reads as sensor white, and sensor
|
||||
// white with the as-shot balance on is strongly magenta — a solve over
|
||||
// it drives tint to its stop for a pixel that, on the canvas, the
|
||||
// shader has already desaturated to neutral. The same threshold the
|
||||
// shader fades from, so what is refused here is what it would have
|
||||
// hidden there.
|
||||
if pixel[..3].iter().any(|c| *c >= dr_pipeline::CLIP_ONSET) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The probe was encoded for the screen; the solve is multiplicative
|
||||
// and only means anything in linear light (ARCH §5.2). Undone with
|
||||
// the space's own transfer function rather than a second copy of the
|
||||
// curve written out here.
|
||||
let transfer = space.transfer();
|
||||
Some([
|
||||
transfer.decode(f32::from(pixel[0]) / 255.0),
|
||||
transfer.decode(f32::from(pixel[1]) / 255.0),
|
||||
transfer.decode(f32::from(pixel[2]) / 255.0),
|
||||
])
|
||||
// The tap is the sensor's numbers with the profile filled neutral;
|
||||
// the operation multiplies them *after* the camera's own balance, so
|
||||
// that goes on here and the solve sees what the gains will see.
|
||||
let wb = self.demosaiced.as_shot_wb();
|
||||
Some([pixel[0] * wb[0], pixel[1] * wb[1], pixel[2] * wb[2]])
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
|
||||
@@ -6670,6 +6671,135 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The whole point of the picker, measured where the photographer sees
|
||||
/// it: a cast grey on a *raw* frame, sampled, renders grey.
|
||||
///
|
||||
/// On a raw frame and not a JPEG, because that is where it was wrong. The
|
||||
/// white balance gains multiply camera RGB, before the body's matrix
|
||||
/// turns it into sRGB; the probe was read *after* the matrix, and the
|
||||
/// solve treated the two as the same space. On a body whose matrix mixes
|
||||
/// the channels as much as a Canon's does, a slightly blue wall came back
|
||||
/// tint −77 and the whole frame went green. A JPEG carries an identity
|
||||
/// matrix, so the same test on one passed while the picker was broken.
|
||||
#[test]
|
||||
fn sampling_a_cast_grey_on_a_raw_frame_renders_it_grey() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
|
||||
// A Canon EOS 6D's D65 matrix (rows summing to one, as
|
||||
// `neutral_stays_neutral_through_the_colour_matrix` requires) and a
|
||||
// typical as-shot balance for it.
|
||||
let cam_to_srgb = [
|
||||
1.9125, -1.0587, 0.1461, //
|
||||
-0.2249, 1.6466, -0.4217, //
|
||||
0.0099, -0.5093, 1.4994,
|
||||
];
|
||||
let as_shot = [1.9, 1.0, 1.7];
|
||||
// What the wall should look like once the camera's own balance is on:
|
||||
// a warm cast, a little over half a stop between red and blue.
|
||||
let balanced = [0.30f32, 0.25, 0.20];
|
||||
let sensor: Vec<u16> = (0..3)
|
||||
.map(|c| (balanced[c] / as_shot[c] * 65535.0).round() as u16)
|
||||
.collect();
|
||||
let size = 64u32;
|
||||
let raw = RawImage {
|
||||
width: size,
|
||||
height: size,
|
||||
data: sensor.repeat((size * size) as usize),
|
||||
cfa_pattern: dr_decode::CfaPattern::Rggb,
|
||||
black_level: [0; 4],
|
||||
white_level: 65535,
|
||||
wb_coeffs: [as_shot[0], as_shot[1], as_shot[2], 0.0],
|
||||
color_matrix: Some(cam_to_srgb),
|
||||
base_curve: dr_decode::BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 3,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: dr_decode::CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
};
|
||||
let mut session =
|
||||
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
|
||||
|
||||
let at = ((size / 2) * size + size / 2) as usize * 4;
|
||||
let before = read_back(&ctx, &session.render(size, size).expect("render"));
|
||||
let cast = |px: &[u8]| px.iter().max().unwrap() - px.iter().min().unwrap();
|
||||
assert!(
|
||||
cast(&before[at..at + 3]) > 20,
|
||||
"the premise: the wall renders with a cast, {:?}",
|
||||
&before[at..at + 3]
|
||||
);
|
||||
|
||||
assert!(
|
||||
session.sample_neutral(0.5, 0.5),
|
||||
"a mid-grey is a usable sample"
|
||||
);
|
||||
|
||||
let after = read_back(&ctx, &session.render(size, size).expect("render"));
|
||||
let px = &after[at..at + 3];
|
||||
assert!(
|
||||
cast(px) <= 3,
|
||||
"the sampled point should render neutral, got {px:?} with {:?}",
|
||||
session
|
||||
.rows()
|
||||
.iter()
|
||||
.filter(|r| r.value != r.default_value)
|
||||
.map(|r| (r.param_label.to_string(), r.value))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// A blown highlight is refused, the way black is.
|
||||
///
|
||||
/// Sensor white is not a colour: every channel stopped counting, so the
|
||||
/// ratio between them is the as-shot multipliers and nothing about the
|
||||
/// scene. Sampling the overcast sky on a Canon 6D frame drove tint to
|
||||
/// -100 and temperature to -15 for a patch the canvas showed as pure
|
||||
/// white, which is the picker being wrong rather than the point being a
|
||||
/// poor choice. Refused, nothing moves and no step is taken.
|
||||
#[test]
|
||||
fn sampling_a_blown_highlight_moves_nothing() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let size = 16u32;
|
||||
let raw = RawImage {
|
||||
width: size,
|
||||
height: size,
|
||||
data: vec![65535; (size * size * 3) as usize],
|
||||
cfa_pattern: dr_decode::CfaPattern::Rggb,
|
||||
black_level: [0; 4],
|
||||
white_level: 65535,
|
||||
wb_coeffs: [1.9, 1.0, 1.7, 0.0],
|
||||
color_matrix: None,
|
||||
base_curve: dr_decode::BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 3,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: dr_decode::CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
};
|
||||
let mut session =
|
||||
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
|
||||
let steps = session.history_rows().len();
|
||||
|
||||
assert!(
|
||||
!session.sample_neutral(0.5, 0.5),
|
||||
"a clipped photosite has no balance in it"
|
||||
);
|
||||
assert!(session.is_neutral(), "and so nothing was corrected");
|
||||
assert_eq!(session.history_rows().len(), steps);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-7 | FR-DEV-5
|
||||
/// A held comparison hands the edit straight back.
|
||||
///
|
||||
|
||||
@@ -1069,6 +1069,79 @@ impl Population {
|
||||
ids.push(f.face);
|
||||
}
|
||||
|
||||
// Not every anchored face enters the scan: each person is stood for
|
||||
// by their references (`dr_face::references`), and the rest of their
|
||||
// faces stay out of the comparison. They keep their place -- they are
|
||||
// in `anchors`, so the pass never releases them -- and they are the
|
||||
// cost that would otherwise grow with every name the user gives.
|
||||
let mut by_person: std::collections::BTreeMap<u64, Vec<usize>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for (i, c) in candidates.iter().enumerate() {
|
||||
if let Some(p) = c.confirmed_person {
|
||||
by_person.entry(p).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut keep = vec![true; candidates.len()];
|
||||
let mut anchored = 0usize;
|
||||
for members in by_person.values() {
|
||||
anchored += members.len();
|
||||
// Only a person over the cap loses anyone: under it, `select`
|
||||
// returns every eligible face, and the ineligible are kept too,
|
||||
// since a short vector was a probe before and still is.
|
||||
if members.len() <= dr_face::references::MAX_REFERENCES {
|
||||
continue;
|
||||
}
|
||||
let embeddings: Vec<&[f32]> = members
|
||||
.iter()
|
||||
.map(|&i| candidates[i].embedding.as_slice())
|
||||
.collect();
|
||||
let quality: Vec<Option<f32>> =
|
||||
members.iter().map(|&i| candidates[i].quality).collect();
|
||||
let chosen = dr_face::references::select(
|
||||
&embeddings,
|
||||
&quality,
|
||||
dr_face::references::MAX_REFERENCES,
|
||||
);
|
||||
for &i in members {
|
||||
keep[i] = false;
|
||||
}
|
||||
for &k in &chosen {
|
||||
keep[members[k]] = true;
|
||||
}
|
||||
// A person none of whose faces is long enough to vouch is still
|
||||
// a person, and a pass they had no anchor in would file their
|
||||
// next face as a stranger. The longest stand in.
|
||||
if chosen.is_empty() {
|
||||
let mut by_quality = members.clone();
|
||||
by_quality.sort_by(|&a, &b| {
|
||||
let qa = candidates[a].quality.unwrap_or(0.0);
|
||||
let qb = candidates[b].quality.unwrap_or(0.0);
|
||||
qb.total_cmp(&qa).then(a.cmp(&b))
|
||||
});
|
||||
for &i in by_quality.iter().take(dr_face::references::MAX_REFERENCES) {
|
||||
keep[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let left_out = keep.iter().filter(|k| !**k).count();
|
||||
if left_out > 0 {
|
||||
let mut i = 0;
|
||||
candidates.retain(|_| {
|
||||
i += 1;
|
||||
keep[i - 1]
|
||||
});
|
||||
let mut i = 0;
|
||||
ids.retain(|_| {
|
||||
i += 1;
|
||||
keep[i - 1]
|
||||
});
|
||||
log::info!(
|
||||
"references: {} of {anchored} anchored face(s) stand for {} people; {left_out} left out of the scan",
|
||||
anchored - left_out,
|
||||
by_person.len(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
cal,
|
||||
candidates,
|
||||
@@ -2121,6 +2194,45 @@ mod tests {
|
||||
assert_eq!(after[0].suggested_faces, 3);
|
||||
}
|
||||
|
||||
/// A person over the reference cap is stood for by a chosen few, and the
|
||||
/// rest of their faces stay where they are: not compared, not released,
|
||||
/// and still theirs when the next face arrives.
|
||||
#[test]
|
||||
fn a_well_covered_person_is_stood_for_by_their_references() {
|
||||
let many = dr_face::references::MAX_REFERENCES + 50;
|
||||
let catalog = catalog_with(many + 1);
|
||||
for i in 0..many {
|
||||
put_face(&catalog, i as u64 + 1, 0, 1.0 - (i as f32) * 1e-3);
|
||||
}
|
||||
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
|
||||
let him = faces::people(catalog.connection()).unwrap()[0].id;
|
||||
faces::rename_person(catalog.connection(), him, "Ian").unwrap();
|
||||
|
||||
// Every one of his faces lies in one plane, so two references span
|
||||
// them all and the selector stops there rather than filling the cap.
|
||||
let pop = Population::read(&catalog, TEST_MODEL).unwrap();
|
||||
let standing = pop
|
||||
.candidates
|
||||
.iter()
|
||||
.filter(|c| c.confirmed_person.is_some())
|
||||
.count();
|
||||
assert!(
|
||||
(2..=dr_face::references::MAX_REFERENCES).contains(&standing),
|
||||
"{standing} of {many} faces entered the scan"
|
||||
);
|
||||
assert_eq!(pop.anchors.len(), many, "the rest lost their anchor");
|
||||
|
||||
put_face(&catalog, many as u64 + 1, 0, 0.98);
|
||||
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
|
||||
let after = faces::people(catalog.connection()).unwrap();
|
||||
assert_eq!(after.len(), 1, "a second Ian appeared: {after:?}");
|
||||
assert_eq!(
|
||||
after[0].suggested_faces,
|
||||
(many + 1) as u64,
|
||||
"faces were released"
|
||||
);
|
||||
}
|
||||
|
||||
/// `dr-types` sits below the face engine and cannot name its constant, so
|
||||
/// it restates the number. This is the thing that stops the two drifting:
|
||||
/// a settings page marking 0.80 as "default" while the engine had moved to
|
||||
|
||||
+3
-5
@@ -3127,14 +3127,12 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
.borrow_mut()
|
||||
.as_mut()
|
||||
.is_some_and(|s| s.sample_neutral(x, y));
|
||||
// Nothing to redraw otherwise: the probe renders to its own
|
||||
// target and a sample that found nothing usable moved nothing.
|
||||
if sampled {
|
||||
sync_rows(&w, &rows, &session);
|
||||
redraw(&w);
|
||||
}
|
||||
// Redrawn either way. A sample that found nothing usable still
|
||||
// overwrote the frame the adjust pass was holding, and leaving the
|
||||
// canvas showing a probe of the unedited image would look like the
|
||||
// edit had been thrown away.
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user