Carry every identity across a re-detection, by box and by embedding

record_detections replaces an image's faces and carried only the user's
confirmations onto the new ones, by box overlap above 0.5 IoU. Everything
else on the old faces was dropped: the suggestions the last grouping pass
made, and the people the user had said a face was not. On the reference
library that is 13,011 suggestions and 77 rejections beside 3,778
confirmations — a re-detection of it would have been correct by
FR-CULL-12's letter, since suggestions are derived data, and would have
handed back a People screen of strangers.

Now every old face is read before the delete — box, vector, assignment,
rejections — and matched to the new faces one-to-one, best pair first. A
pair qualifies when the boxes overlap at all and either the overlap alone
says so (IoU above 0.5, the old rule) or the embeddings do (cosine above
SAME_FACE_COSINE, 0.45, the reference library's P≈0.95 line). The
embedding route claims the box a low-resolution pass drew badly enough
that overlap alone would not; the vector is also what breaks the tie in a
group photograph, where two neighbouring faces overlap both new boxes.
Overlap is required on both routes, because the same vector elsewhere in
the frame — a mirror, a print on the wall — is not the same face and must
not take its name. Onto the matched face go the assignment as it was,
confirmed or suggested with its probability, and every rejection.

The merge's match_faces still matches by overlap alone across devices; it
is the same question and is not changed here.
This commit is contained in:
2026-09-19 18:34:31 +02:00
parent 46af2a0a46
commit 2a4ac0ed3d
5 changed files with 468 additions and 131 deletions
+2 -2
View File
@@ -762,8 +762,8 @@ pub fn export_to_shards_reporting(
/// computed is the one it can vouch for. A peer's *stronger* pass is another
/// matter: it is the re-detection this device's own sweep would queue
/// (`FaceDetector::supersedes`), already done, and taking it is what spares
/// a tablet the fetch. Names survive the replacement by box overlap, as they
/// do a local re-detection.
/// a tablet the fetch. Names survive the replacement by box overlap and
/// embedding, as they do a local re-detection (`faces::record_detections`).
///
/// A peer's faces are taken under whichever compatible detector found them:
/// a tablet set to the fast detector adopts the desktop's thorough pass
+380 -44
View File
@@ -207,16 +207,60 @@ pub use dr_face::Calibration;
// ── detection ─────────────────────────────────────────────────────────────
/// The cosine above which two vectors from one embedder are taken to be
/// the same face in the same photograph, for carrying an identity across a
/// re-detection.
///
/// Set at the reference library's P≈0.95 line (docs/faces.md §9's table:
/// 0.449), which is far above anything two different people in one frame
/// reach and below what one face re-embedded from a better crop of itself
/// does. The number is only ever asked about *overlapping* boxes on *one*
/// image, which is what makes such a loose figure safe: the question is not
/// "is this the same person" but "is this the same face, now that the box
/// has moved".
pub const SAME_FACE_COSINE: f32 = 0.45;
/// What one stored face knew about itself, read before a re-detection
/// replaces it.
struct Prior {
x: f32,
y: f32,
w: f32,
h: f32,
/// Decoded and unit length, under the *embedder* half of its id so a
/// cosine against a new face from the same embedder is defined.
embedding: Option<dr_face::Embedding>,
/// `(person, probability, confirmed)`, where the face had one.
assignment: Option<(i64, f64, bool)>,
rejected: Vec<i64>,
}
/// Replace every face on an image with a fresh detection pass.
///
/// Replace rather than append, because `DetectFaces` is coalesced per image
/// (FR-CAT-3) and re-running it must be idempotent — appending would double the
/// faces on every re-index and silently inflate every cluster.
///
/// **Confirmed assignments survive** where the new detection covers the same
/// part of the frame: a re-index with a better model must not discard the
/// user's labelling (FR-CULL-10). Matching is by box overlap, since the face is
/// in the same place even when the box moves a little.
/// # What survives the replacement
///
/// **The identity of every face the new detection finds again** — the user's
/// confirmations first of all (FR-CULL-10), and with them the suggestions
/// the last grouping pass made and the people the user has said a face is
/// *not*. A re-index with a better model must not discard the user's
/// labelling, and it should not empty the People screen either: a library
/// of thirteen thousand suggestions re-detected into thirteen thousand
/// unassigned faces is correct by the letter of FR-CULL-12 and looks, to
/// the person who spent an afternoon on it, like the work was thrown away.
///
/// A new face is the same face as an old one when the two **overlap in the
/// frame and either the boxes agree or the embeddings do**: an IoU above 0.5,
/// which is loose on purpose because a better detector is entitled to move
/// the box; or a cosine above [`SAME_FACE_COSINE`] between the vectors, for
/// the box a low-resolution pass drew badly enough that overlap alone would
/// not claim it. The embedding is also what breaks a tie — two faces side by
/// side in a group photograph both overlap both new boxes, and the vector
/// says which is which where the rectangles cannot. Each old face is
/// carried onto at most one new one, best pair first.
///
/// **Every model's faces are replaced, and every other model's marker goes
/// with them.** An image holds the faces of whichever pipeline looked at it
@@ -236,29 +280,15 @@ pub fn record_detections(
) -> Result<Vec<FaceId>, CatalogError> {
let tx = conn.unchecked_transaction()?;
// What the user had asserted, so it can be carried across the replacement.
let mut prior: Vec<(f32, f32, f32, f32, i64, f64)> = Vec::new();
{
let mut q = tx.prepare(
"SELECT f.x, f.y, f.w, f.h, fp.person_id, fp.probability
FROM faces f
JOIN face_person fp ON fp.face_id = f.id
WHERE f.image_id = ?1 AND fp.confirmed = 1",
)?;
let rows = q.query_map([image_id.0 as i64], |r| {
Ok((
r.get::<_, f64>(0)? as f32,
r.get::<_, f64>(1)? as f32,
r.get::<_, f64>(2)? as f32,
r.get::<_, f64>(3)? as f32,
r.get::<_, i64>(4)?,
r.get::<_, f64>(5)?,
))
})?;
for row in rows {
prior.push(row?);
}
}
// Everything the old faces knew, so it can be carried across the
// replacement. Read only when there is something to carry it onto: a
// pass that found nothing has nothing to match, and decoding a vector
// per face for no reader would be the wasted work.
let prior = if faces.is_empty() {
Vec::new()
} else {
read_priors(&tx, image_id)?
};
tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?;
tx.execute(
@@ -266,9 +296,11 @@ pub fn record_detections(
rusqlite::params![image_id.0 as i64, model_id],
)?;
let carried = match_priors(&prior, faces);
let now = now_secs();
let mut ids = Vec::with_capacity(faces.len());
for f in faces {
for (i, f) in faces.iter().enumerate() {
tx.execute(
"INSERT INTO faces
(image_id, x, y, w, h, landmarks, detector_confidence,
@@ -306,28 +338,38 @@ pub fn record_detections(
)?;
let id = FaceId(tx.last_insert_rowid() as u64);
// Re-attach a confirmation whose box this detection clearly replaces.
// 0.5 IoU is loose on purpose: the question is "is this the same face
// in the frame", not "is this the same box", and a better detector is
// entitled to move the box.
if let Some((_, _, _, _, person, prob)) = prior
.iter()
.filter(|p| iou((p.0, p.1, p.2, p.3), (f.x, f.y, f.w, f.h)) > 0.5)
.max_by(|a, b| {
iou((a.0, a.1, a.2, a.3), (f.x, f.y, f.w, f.h))
.total_cmp(&iou((b.0, b.1, b.2, b.3), (f.x, f.y, f.w, f.h)))
})
{
if let Some(p) = carried[i].map(|at| &prior[at]) {
if let Some((person, prob, confirmed)) = p.assignment {
tx.execute(
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
VALUES (?1, ?2, ?3, 1)",
rusqlite::params![id.0 as i64, person, prob],
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![id.0 as i64, person, prob, confirmed],
)?;
}
for person in &p.rejected {
tx.execute(
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
VALUES (?1, ?2)",
rusqlite::params![id.0 as i64, person],
)?;
}
}
ids.push(id);
}
let lost = prior
.iter()
.enumerate()
.filter(|(at, p)| p.assignment.is_some() && !carried.contains(&Some(*at)))
.count();
if lost > 0 {
log::debug!(
"image {}: {lost} assigned face(s) not found again by the re-detection",
image_id.0
);
}
// The run marker, written whether or not anything was found. Zero faces is
// a real answer and recording it is what stops the next pass looking at
// this photograph again -- see the V9 migration.
@@ -351,6 +393,115 @@ pub fn record_detections(
Ok(ids)
}
/// The faces on an image as they stand, with everything a re-detection
/// carries forward.
fn read_priors(tx: &Connection, image_id: ImageId) -> Result<Vec<Prior>, CatalogError> {
let mut out: Vec<(i64, Prior)> = Vec::new();
{
let mut q = tx.prepare(
"SELECT f.id, f.x, f.y, f.w, f.h, f.model_id, f.embedding,
fp.person_id, fp.probability, fp.confirmed
FROM faces f
LEFT JOIN face_person fp ON fp.face_id = f.id
WHERE f.image_id = ?1
ORDER BY f.id",
)?;
let rows = q.query_map([image_id.0 as i64], |r| {
let model: String = r.get(5)?;
let blob: Vec<u8> = r.get(6)?;
let person: Option<i64> = r.get(7)?;
Ok((
r.get::<_, i64>(0)?,
Prior {
x: r.get::<_, f64>(1)? as f32,
y: r.get::<_, f64>(2)? as f32,
w: r.get::<_, f64>(3)? as f32,
h: r.get::<_, f64>(4)? as f32,
embedding: dr_face::Embedding::from_f16_bytes(
dr_face::ModelId::new(embedder_of(&model).to_string()),
&blob,
),
assignment: match person {
Some(p) => Some((p, r.get::<_, f64>(8)?, r.get::<_, i64>(9)? != 0)),
None => None,
},
rejected: Vec::new(),
},
))
})?;
for row in rows {
out.push(row?);
}
}
{
let mut q = tx.prepare(
"SELECT fr.face_id, fr.person_id
FROM face_person_rejected fr
JOIN faces f ON f.id = fr.face_id
WHERE f.image_id = ?1",
)?;
let rows = q.query_map([image_id.0 as i64], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
})?;
for row in rows {
let (face, person) = row?;
if let Some((_, p)) = out.iter_mut().find(|(id, _)| *id == face) {
p.rejected.push(person);
}
}
}
Ok(out.into_iter().map(|(_, p)| p).collect())
}
/// Which old face, if any, each new detection replaces — indexed like
/// `faces`, holding an index into `prior`.
///
/// One-to-one, best pair first: every qualifying pair is scored by how much
/// the boxes and the vectors agree, and pairs are taken in that order until
/// each side is spoken for. A pair qualifies when the boxes overlap at all
/// and either the overlap alone says so (IoU above 0.5) or the embeddings do
/// ([`SAME_FACE_COSINE`]). Overlap is required on both routes, because two
/// faces of one person in one frame — a mirror, a photograph within the
/// photograph — have the same vector and must not swap names.
fn match_priors(prior: &[Prior], faces: &[DetectedFace]) -> Vec<Option<usize>> {
let mut pairs: Vec<(f32, usize, usize)> = Vec::new();
for (pi, p) in prior.iter().enumerate() {
let old = (p.x, p.y, p.w, p.h);
for (fi, f) in faces.iter().enumerate() {
let overlap = iou(old, (f.x, f.y, f.w, f.h));
if overlap <= 0.0 {
continue;
}
let cosine = p
.embedding
.as_ref()
.and_then(|old| {
let new = dr_face::Embedding::from_f16_bytes(
dr_face::ModelId::new(embedder_of(&f.model_id).to_string()),
&f.embedding,
)?;
old.cosine(&new)
})
.unwrap_or(0.0);
if overlap > 0.5 || cosine >= SAME_FACE_COSINE {
pairs.push((overlap + cosine.max(0.0), pi, fi));
}
}
}
pairs.sort_by(|a, b| b.0.total_cmp(&a.0));
let mut carried = vec![None; faces.len()];
let mut taken = vec![false; prior.len()];
for (_, pi, fi) in pairs {
if taken[pi] || carried[fi].is_some() {
continue;
}
taken[pi] = true;
carried[fi] = Some(pi);
}
carried
}
/// A face embedded again from its stored landmarks: the new vector and its
/// length. What the measuring pass hands back per face.
#[derive(Debug, Clone, PartialEq)]
@@ -373,8 +524,8 @@ pub struct Measurement {
/// created: the boxes and landmarks are right, the identities are the user's
/// work, and only the vector needs doing again. Updating in place is what
/// keeps `face_person` and the face ids exactly as they were -- a
/// re-detection would carry confirmations across by box overlap and lose
/// every suggestion, for no gain.
/// re-detection carries identities across by matching old faces to new, and
/// a match is a judgement where an update in place is a fact.
///
/// `dropped` are faces whose landmarks turned out to be degenerate -- the warp
/// could not be built from them. Deleted here, as detection would have refused
@@ -1678,6 +1829,191 @@ mod tests {
assert!(got[0].confirmed);
}
/// A stored vector pointing along one axis of the embedding space, so a
/// test can say "these two faces are the same" or "orthogonal" exactly.
fn along(axis: usize) -> Vec<u8> {
let mut v = Box::new([0.0_f32; dr_face::EMBEDDING_DIM]);
v[axis] = 1.0;
dr_face::Embedding {
model: dr_face::ModelId::new("w600k_mbf"),
v,
}
.to_f16_bytes()
}
fn face_at(x: f32, y: f32, w: f32, h: f32, axis: usize) -> DetectedFace {
DetectedFace {
x,
y,
w,
h,
embedding: along(axis),
..face(1)
}
}
/// The other half of FR-CULL-10's promise: the suggestions and the
/// rejections are the state of the People screen, and a re-index that
/// dropped them would hand back a library of strangers.
#[test]
fn re_detection_carries_a_suggestion_and_a_rejection_across() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
let anna = create_person(&c, "Anna").unwrap();
let bob = create_person(&c, "Bob").unwrap();
suggest(&c, ids[0], anna, 0.83).unwrap();
reject(&c, ids[0], bob).unwrap();
let mut moved = face(9);
moved.x = 0.11;
record_detections(&c, img, "scrfd_10g+w600k_mbf", 4000, &[moved]).unwrap();
let got = for_image(&c, img).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].person, Some(anna), "suggestion was lost on re-index");
assert!(
!got[0].confirmed,
"a suggestion came back as a confirmation"
);
assert!((got[0].probability - 0.83).abs() < 1e-6);
assert!(
!suggest(&c, got[0].id, bob, 0.99).unwrap(),
"the rejection of Bob was lost on re-index"
);
}
/// A box the low-resolution pass drew badly enough that overlap alone
/// would not claim it: the vector does.
#[test]
fn a_moved_box_is_claimed_by_its_embedding() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(
&c,
img,
"w600k_mbf",
1024,
&[face_at(0.10, 0.10, 0.20, 0.20, 3)],
)
.unwrap();
let anna = create_person(&c, "Anna").unwrap();
confirm(&c, ids[0], anna).unwrap();
// Shifted by two thirds of its width: IoU ≈ 0.2, well under the 0.5
// the box route needs, but the boxes still touch.
let again = face_at(0.23, 0.10, 0.20, 0.20, 3);
record_detections(&c, img, "scrfd_10g+w600k_mbf", 4000, &[again]).unwrap();
let got = for_image(&c, img).unwrap();
assert_eq!(got[0].person, Some(anna));
assert!(got[0].confirmed);
// The same shift with a stranger's vector is a different face, and
// the confirmation is not handed to it.
let other = image(&c, 2);
let ids = record_detections(
&c,
other,
"w600k_mbf",
1024,
&[face_at(0.10, 0.10, 0.20, 0.20, 3)],
)
.unwrap();
confirm(&c, ids[0], anna).unwrap();
let stranger = face_at(0.23, 0.10, 0.20, 0.20, 4);
record_detections(&c, other, "scrfd_10g+w600k_mbf", 4000, &[stranger]).unwrap();
assert_eq!(for_image(&c, other).unwrap()[0].person, None);
}
/// Two people side by side, boxes overlapping both ways, and the new
/// boxes shifted so that overlap alone would swap them.
#[test]
fn the_embedding_breaks_a_tie_between_neighbouring_faces() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(
&c,
img,
"w600k_mbf",
1024,
&[
face_at(0.10, 0.10, 0.20, 0.20, 1),
face_at(0.25, 0.10, 0.20, 0.20, 2),
],
)
.unwrap();
let anna = create_person(&c, "Anna").unwrap();
let bob = create_person(&c, "Bob").unwrap();
confirm(&c, ids[0], anna).unwrap();
confirm(&c, ids[1], bob).unwrap();
// Anna's new box overlaps Bob's old one more than her own.
let anna_again = face_at(0.20, 0.10, 0.20, 0.20, 1);
let bob_again = face_at(0.35, 0.10, 0.20, 0.20, 2);
let new = record_detections(
&c,
img,
"scrfd_10g+w600k_mbf",
4000,
&[anna_again, bob_again],
)
.unwrap();
let got = for_image(&c, img).unwrap();
let person_of = |id: FaceId| got.iter().find(|f| f.id == id).unwrap().person;
assert_eq!(person_of(new[0]), Some(anna));
assert_eq!(person_of(new[1]), Some(bob));
}
/// The same vector somewhere else in the frame — a mirror, a print on
/// the wall — is not the same face, and overlap is what says so.
#[test]
fn the_same_person_elsewhere_in_the_frame_is_not_claimed() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(
&c,
img,
"w600k_mbf",
1024,
&[face_at(0.10, 0.10, 0.20, 0.20, 5)],
)
.unwrap();
let anna = create_person(&c, "Anna").unwrap();
confirm(&c, ids[0], anna).unwrap();
record_detections(
&c,
img,
"w600k_mbf",
1024,
&[face_at(0.60, 0.60, 0.20, 0.20, 5)],
)
.unwrap();
assert_eq!(for_image(&c, img).unwrap()[0].person, None);
}
/// Each old face is carried onto at most one new one: a second box over
/// the same face — a detector that fires twice — does not become a
/// second Anna.
#[test]
fn an_identity_is_carried_onto_one_face_only() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
let anna = create_person(&c, "Anna").unwrap();
confirm(&c, ids[0], anna).unwrap();
let mut twin = face(1);
twin.x += 0.02;
record_detections(&c, img, "w600k_mbf", 1024, &[face(1), twin]).unwrap();
let named = for_image(&c, img)
.unwrap()
.iter()
.filter(|f| f.person == Some(anna))
.count();
assert_eq!(named, 1);
}
#[test]
fn a_suggestion_never_overwrites_a_confirmation() {
let c = db();
+2 -1
View File
@@ -199,7 +199,8 @@ pub struct FaceSettings {
/// the clustering and the sync all go on over every face already found.
/// What a stronger choice does is queue the images a weaker one indexed for
/// re-detection, after the ones nothing has indexed ([`Self::supersedes`]),
/// with confirmed names carried across by `record_detections`' box overlap.
/// with names carried across by `record_detections`' box and embedding
/// matching.
///
/// The first version of this setting keyed everything on the full id, and
/// choosing Thorough emptied both devices' People screens until a whole-
+11 -11
View File
@@ -25,7 +25,7 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between.
<sub>`ui/dr-ui/ui/app.slint:2040`</sub>
<sub>`ui/dr-ui/ui/app.slint:2042`</sub>
### Move a magnified photograph about
@@ -34,7 +34,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
<sub>`ui/dr-ui/ui/app.slint:2131`</sub>
<sub>`ui/dr-ui/ui/app.slint:2133`</sub>
### Paint a mask by hand
@@ -43,7 +43,7 @@ Only once there is something outside the viewport to reach, which is why the cur
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
<sub>`ui/dr-ui/ui/app.slint:2218`</sub>
<sub>`ui/dr-ui/ui/app.slint:2220`</sub>
### Take back the last change
@@ -53,7 +53,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
<sub>`ui/dr-ui/ui/app.slint:2439`</sub>
<sub>`ui/dr-ui/ui/app.slint:2441`</sub>
### Do it again after taking it back
@@ -61,7 +61,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Pointer** — Click it, or press Redo in the History header
- **Keyboard** — Ctrl+Shift+Z
<sub>`ui/dr-ui/ui/app.slint:2452`</sub>
<sub>`ui/dr-ui/ui/app.slint:2454`</sub>
### Copy the settings from this photograph
@@ -71,7 +71,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
<sub>`ui/dr-ui/ui/app.slint:2485`</sub>
<sub>`ui/dr-ui/ui/app.slint:2487`</sub>
### Paste the settings onto this photograph
@@ -81,7 +81,7 @@ The panel is the copy that has to work: a tablet has no modifier key to hold and
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
<sub>`ui/dr-ui/ui/app.slint:2497`</sub>
<sub>`ui/dr-ui/ui/app.slint:2499`</sub>
### Change which group of adjustments is on screen
@@ -91,7 +91,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
<sub>`ui/dr-ui/ui/app.slint:2525`</sub>
<sub>`ui/dr-ui/ui/app.slint:2527`</sub>
### Look at the photograph at 1:1
@@ -101,7 +101,7 @@ The groups are whatever the operation set declares itself to be about, so there
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans.
<sub>`ui/dr-ui/ui/app.slint:2560`</sub>
<sub>`ui/dr-ui/ui/app.slint:2562`</sub>
### Move to the next or previous photograph
@@ -111,7 +111,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing.
<sub>`ui/dr-ui/ui/app.slint:2612`</sub>
<sub>`ui/dr-ui/ui/app.slint:2614`</sub>
### See the photograph before you edited it
@@ -121,7 +121,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
<sub>`ui/dr-ui/ui/app.slint:2736`</sub>
<sub>`ui/dr-ui/ui/app.slint:2738`</sub>
### Put one control back to its default
+70 -70
View File
File diff suppressed because one or more lines are too long