From ec740115b60aa29e71a379ebf2eff9fd2afd3dd0 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 26 Aug 2026 19:44:39 +0200 Subject: [PATCH] Spec the face pipeline on SCRFD and MobileFaceNet FR-CULL-8..12 specify the subsystem in terms of "a 512-dimension embedding from a stated model" and stop there, because D13 was open. This names the models, and grounds them in the measurements and the working C++ pipeline in ../scene-actor-extraction rather than in a literature reading. The licensing half of D13 stays open, but with a route through it: the InsightFace weights are non-commercial and cannot be committed, so the app ships the code and the user fetches the model. faces.model_id already makes that a survivable choice. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + docs/catalog.md | 3 + docs/faces.md | 769 +++++++++++++++++++++++++++++++++++++++++++ docs/traceability.md | 18 +- 4 files changed, 782 insertions(+), 9 deletions(-) create mode 100644 docs/faces.md diff --git a/README.md b/README.md index 1ef0308..0125092 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A cross-platform, non-destructive RAW photo editor for Linux and Android. | [requirements.md](docs/requirements.md) | What the software must do — 122 numbered requirements | | [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync | | [milestone-v0.1.md](docs/milestone-v0.1.md) | The first buildable milestone | +| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measures | ## Building diff --git a/docs/catalog.md b/docs/catalog.md index 08793fc..1268bd3 100644 --- a/docs/catalog.md +++ b/docs/catalog.md @@ -716,6 +716,9 @@ Specified by FR-CULL-8 … FR-CULL-12, NFR-SEC-5, [architecture.md §6.4](archit spike S14 and decision D13 — the runtime and the model licences are unresolved, so this is the shape of the subsystem, not a build order. +[faces.md](faces.md) names the models this shape is filled in with, and adds one column to §10.1's +`faces` table (`crop_px`) that the calibration in its §8 depends on. + ### 10.1 Schema (a v5 migration) ```sql diff --git a/docs/faces.md b/docs/faces.md new file mode 100644 index 0000000..a5988eb --- /dev/null +++ b/docs/faces.md @@ -0,0 +1,769 @@ +# Faces and identity — SCRFD and MobileFaceNet + +Spec for **S14**, the spike that decides whether §3.9.1 is buildable, and the build that follows it. + +FR-CULL-8 … FR-CULL-12 specify the subsystem in terms of "a 512-dimension embedding from a stated +model" and stop there, deliberately: [catalog.md §10.4](catalog.md) records that the model was left +abstract because D13 was open. This document names the two models, fixes the pre- and +post-processing they need, specifies the calibration and clustering that sit on top, and states what +S14 has to measure before any of it is trusted. + +**It does not close the licensing half of D13.** §2 is the reason, and it comes first because +[segmentation.md §7](segmentation.md) established the precedent that reading the grant is cheaper +than discovering it at packaging time. + +--- + +## 1. The two models, and why these two + +**Detector — SCRFD.** *Sample and Computation Redistribution for Efficient Face Detection* (Guo et +al., ICLR 2022). A single-stage anchor-based detector whose contribution is where the FLOPs go, not +a new architecture: the shallow stages carry more capacity because that is where small faces are +decided. It emits, per face, a box, a confidence, and **five landmarks in the same forward pass** — +which is the property that matters here, because FR-CULL-8 requires landmarks and because §4's +alignment step is not optional. A detector without landmarks would need a second network to supply +them. + +The `500M` variant is the one to ship: 500 MFLOPs at VGA, against 10 GFLOPs for `10G`. Face indexing +is a background sweep over a whole library on a phone as well as a desktop (NFR-RES-2), so the cheap +variant's accuracy loss on tiny faces is the right trade — a face too small for `500M` to find is +also too small for §5 to embed usefully. + +**Embedder — MobileFaceNet trained with ArcFace loss ("MBF").** ~1M parameters, ~0.45 GFLOPs for one +112×112 crop, 512-d output. ArcFace's additive angular margin is what makes the output space one +where *cosine similarity means something* — the loss explicitly optimises angular separation between +identities, which is what §6's calibration then has to convert into a probability. + +**Why not the larger ResNet50 embedder** (`w600k_r50`, the other half of InsightFace's `buffalo_l`): +because it is not measurably better here, and it is 13× the file. §1.1's reference project fitted the +same Platt calibration (§8) to all four candidates over a 2,418-identity gallery, which is a clean +read on the embedding space alone with no tracking or matching logic in it: + +| Model | File | Steepness `a` | Boundary at P=0.5 | Held-out macro F1 | +|---|---|---|---|---| +| LVFace-B (Glint360K) | 455 MB | 17.7 | sim 0.228 | 67.4% | +| **ArcFace w600k-MBF** | **13 MB** | **16.2** | **sim 0.267** | **64.4%** | +| ArcFace w600k-R50 | 174 MB | 15.4 | sim 0.301 | — | +| ArcFace R18 | 48 MB | 15.3 | sim 0.309 | 63.1% | + +**MBF's calibration curve is steeper than R50's and R18's**, separating same-identity from +different-identity pairs more confidently and at a lower boundary, at a fraction of the size. LVFace-B +is genuinely better and is 35× the file — a defensible choice for a desktop-only feature and not one +for a subsystem that has to index a library on a phone (NFR-RES-2). + +Two caveats on that table, both from the source: the R50 gallery was built with ~30% fewer reference +images per identity, which is why it carries no F1 figure — the calibration row does not depend on the +image count and is comparable, the benchmark row would not have been. And the F1 column measures a +*film-cast identification* task, not this one. It ranks the models; it does not predict DarkRoom's +accuracy. + +**Both are pure convolutional graphs**, which matters for §3: the runtime is tract, and tract's +operator coverage is the thing that decides whether a graph runs at all. + +### 1.1 A working reference implementation exists + +`../scene-actor-extraction` is a C++ pipeline by the same author that runs **exactly this model +pair** — SCRFD-500MF then ArcFace over 5-point-aligned 112×112 crops — over feature films, with a +fitted Platt calibration and a scored benchmark behind it. It is **MIT licensed**, so porting from it +into GPL-3.0-or-later is straightforward and needs attribution, not permission. + +That changes what S14 is. The open questions are no longer "does this pair work" and "what are the +magic numbers" — they are *does tract load these graphs* (§12 M1, and §4.1 says why that is in +genuine doubt) and *does the accuracy hold on family snapshots rather than film frames*. The +pre-processing constants, the decode layout, and the calibration algorithm are all readable rather +than rediscoverable, and several of them are not what the published Python would lead you to write. + +| What | Reference | Ports to | +|---|---|---| +| ArcFace 5-point template and warp | `src/face_utils.hpp` | `align.rs` (§5) | +| SCRFD pre-process, decode, NMS | `src/backends/ort_backend.cpp` `SCRFDDecoder` | `detect.rs` (§4) | +| ArcFace pre-process and L2 normalise | same file, `ArcFaceEmbedder` | `embed.rs` (§6) | +| Platt fit over a similarity histogram | `src/gallery/gallery_calibration.hpp` | `calibrate.rs` (§8) | +| Model-free unit tests for both | `tests/test_face_utils.cpp`, `tests/test_calibration.cpp` | the §3 feature split | + +That last row is worth noticing: the reference already separates the geometry and arithmetic from the +inference well enough to unit-test them with no model on the machine. §3's `inference` feature flag is +the same boundary, enforced by Cargo rather than by discipline. + +**What does not port.** The reference matches faces against a *known gallery* of named identities; +DarkRoom clusters an unlabelled library (§9). And it is a video pipeline, so it has a tracker, a +`max_faces` cap, and frame-to-frame identity annealing that have no analogue here. + +--- + +## 2. Licensing — the open half of D13 + +The architectures are published research. **The weights everyone actually uses are not +redistributable by this project.** + +InsightFace's code is MIT. Its *pretrained models* — `buffalo_l`, `buffalo_s`, `buffalo_sc`, and +every `det_*` and `w600k_*` checkpoint inside them — carry a **non-commercial research-only** grant, +stated in the model-zoo README and applying to both the auto-downloaded and the manually downloaded +artefacts. `w600k_mbf` is trained on WebFace600K, whose own terms are research-only as well, so the +restriction has two independent sources rather than one that might be renegotiated. + +These are the exact artefacts in question — §1.1's reference project fetches them from InsightFace's +own release page, which is where the grant attaches: + +| File | Source | +|---|---| +| `det_500m.onnx` (2.5 MB) | `insightface/releases/download/v0.7/buffalo_sc.zip` | +| `w600k_mbf.onnx` (13 MB) | `insightface/releases/download/v0.7/buffalo_s.zip` | + +DarkRoom is GPL-3.0-or-later. A non-commercial field-of-use restriction is not a GPL-compatible +grant, so: + +- these weights **cannot be committed to this repository**, the way `yolo26n-seg.onnx` is (D14); +- they cannot ship inside an APK, a Flatpak, or an F-Droid build; +- and the restriction binds the *user*, not only the project — a professional photographer using + DarkRoom commercially is outside the grant even if they fetched the file themselves. + +That last point is the one that is easy to miss and dishonest to leave implicit. It is why §2.2's +recommended route puts the licence text in front of the user rather than in a footnote. + +### 2.1 The routes, priced + +| Route | What ships | Cost | +|---|---|---| +| **A — commit the weights** | Everything works out of the box, one `git lfs pull` | **Not available.** Licence-incompatible with GPL-3.0 and with all three distribution channels (NFR-COMPAT-2). Listed only so it is visibly rejected rather than quietly assumed. | +| **B — permissively licensed weights** | Same, legally | Requires weights that do not exist off the shelf in this architecture pair. Either found (§2.3) or trained, and training an ArcFace embedder needs a face dataset whose own terms permit redistribution — the harder half of the problem. | +| **C — the user fetches them** | The app ships the *code*, not the weights; face indexing is off until the user obtains a model | Works today, keeps the repository clean, and is honest. Costs a first-run flow, an F-Droid anti-feature declaration, and a licence notice the user has to read. | + +**Recommendation: C now, B when it becomes possible.** §1.1's project already works this way — a +`scripts/download_models.sh` that fetches the weights rather than a repository that carries them — +so route C is a practised pattern here rather than a theory. Two things DarkRoom must do that the +reference does not: pin a checksum for **every** model (the reference verifies only one of the four), +and put the licence in front of the user, because a photo editor's users are not all researchers. + +The schema already forces this to be a survivable choice — `faces.model_id` (catalog.md §10.1) exists precisely so that a model change is +detectable and re-indexable rather than silently poisoning every similarity in the library. Under C, +swapping in a permissive model later is a new `model_id` and a re-index, not a migration. + +### 2.2 What route C requires of the code + +- **The weights are never a build input.** `dr-face` (§3) takes bytes; it has no `embedded-model` + feature and no `models/` directory. This is the one structural difference from `dr-segment`, and + it is deliberate — a feature flag that *could* embed weights is a feature flag someone eventually + turns on in a packaging script. +- **The fetch does not live in `dr-face`.** It lives in the app layer, so the inference crate keeps + no network dependency at all. §11 makes that a checkable property rather than a convention. +- **The licence is shown, not linked.** Before the first download, the app states in plain language + that the weights are research-only, that commercial use is outside the grant, and who the grantor + is. NFR-SEC-5's last bullet already requires the models to be named, versioned and licensed in the + about screen; this is the same obligation moved to the moment where it can still change a + decision. +- **A checksum is pinned.** The app verifies the digest of what it fetched against a value compiled + in, and refuses a mismatch. An unverified model file is an arbitrary graph executed over the + user's family photographs. +- **Indexing stays off until a model is present and verified**, and disabling it deletes nothing — + that is a separate control (NFR-SEC-5, §11). + +### 2.3 Before S14 writes any code + +Three questions to answer by reading, in this order, and to record with the date they were checked — +the same discipline [segmentation.md §7](segmentation.md) applied: + +1. **Is there a permissively licensed SCRFD export?** Third-party ONNX exports of SCRFD are + plentiful; a re-export inherits the original weights' grant, so the question is whether anyone has + *trained* SCRFD on a redistributable dataset, not whether they have converted the InsightFace one. +2. **Is there a permissively licensed 512-d MobileFaceNet/ArcFace checkpoint?** Candidates worth + reading the terms on rather than assuming: EdgeFace (Idiap), the ONNX Model Zoo's ArcFace entry + (MIT, but a ResNet100 at ~250 MB — licence-clean and the wrong size), and any MobileFaceNet + trained on a dataset with distribution terms. +3. **What does a permissive *detector* alternative cost in accuracy?** YuNet (OpenCV Zoo) is 232 KB, + emits five landmarks, and comes from a permissively licensed repository. If it is close enough, + the detector half of the licence problem disappears and only the embedder remains — a materially + better position than either half alone. + +Question 3 is nearly free to answer: `face_detection_yunet_2023mar.onnx` is **already sitting in +§1.1's `models/` directory**, fetched from `opencv/opencv_zoo`. It needs its own decode path (its +output layout is different, which is why the reference's `SCRFDDecoder` explicitly rejects it at load +rather than silently misreading it), and then it is one more row in §12's table. + +--- + +## 3. Crate shape — `core/dr-face` + +A new workspace member, modelled on `dr-segment` and for the same reason: everything that reasons +about faces is testable with no GPU adapter and no model present (ARCH §6.5a). + +``` +core/dr-face/ + src/lib.rs FaceError, re-exports + src/detect.rs SCRFD: preprocess, decode, NMS + src/align.rs 5-point similarity transform → 112×112 crop + src/embed.rs MBF: preprocess, forward, L2 normalise + src/calibrate.rs cosine → P(same person) (FR-CULL-9) + src/cluster.rs constrained agglomeration (FR-CULL-10) +``` + +```toml +[features] +# No `embedded-model`. §2.2 — the weights are not a build input, ever. +default = [] +# The ONNX runtime. Off by default so `calibrate` and `cluster` — which are +# arithmetic over embeddings and have no model in them — stay testable in a +# build that carries no inference engine at all. +inference = ["dep:ort", "dep:ort-tract", "dep:ndarray"] +``` + +The `ort` + `ort-tract` pairing is settled by D13's 2026-08-21 update and already in the workspace +manifest: `ort`'s API, tract's pure-Rust engine, no C under the NDK. + +**The split between `inference` and the rest is load-bearing.** Calibration and clustering are where +the subsystem's accuracy actually lives, they are pure functions of embeddings and labels, and they +must be testable against synthetic embedding sets with no weights on the machine. A test suite that +needs a research-licensed download to run is a test suite that does not run in CI. + +### 3.1 The API + +```rust +/// A loaded detector. Fixed input shape — see §4. +pub struct Detector { /* session, input edge */ } + +impl Detector { + pub fn from_bytes(onnx: &[u8], id: ModelId) -> Result; + /// `rgb` is f32 0..=1, row-major, three per pixel. + pub fn detect(&self, rgb: &[f32], w: usize, h: usize, opts: &DetectOptions) + -> Result, FaceError>; +} + +pub struct Detection { + /// Normalised to the image's long edge (catalog.md §10.1). + pub bbox: (f32, f32, f32, f32), + /// Five points, same normalisation, in the model's own order (§5). + pub landmarks: [(f32, f32); 5], + pub confidence: f32, +} + +pub struct Embedder { /* session */ } + +impl Embedder { + pub fn from_bytes(onnx: &[u8], id: ModelId) -> Result; + /// Takes an *aligned* 112×112 crop. Alignment is `align::warp`; passing a + /// raw bbox crop here is the silent-accuracy-loss bug §5 exists to prevent. + pub fn embed(&self, aligned: &Aligned112) -> Result; +} + +/// L2-normalised, 512-d. Carries its model id so a comparison across models +/// is a type error rather than a plausible-looking number (catalog.md §10.1). +pub struct Embedding { pub model: ModelId, pub v: [f32; 512] } +``` + +`Aligned112` is a newtype over the pixel buffer that only `align::warp` can construct. That is the +whole defence against §5's failure mode, and it costs nothing. + +--- + +## 4. Detection + +### 4.1 Fixed input, and how the image is fitted to it + +**`det_500m.onnx` has a dynamic H/W input, and this is the largest single risk in the document.** +§1.1's reference had to use ONNX Runtime rather than OpenCV's `dnn` module precisely because OpenCV +could not load SCRFD's dynamic `Shape` nodes. tract is in the same family of problem: `dr-segment` +exists in its current shape because tract failed shape inference on YOLO's dynamic export, which is +why `tools/export-seg-model.sh` passes `dynamic=False` and why `semantic.rs` has a fixed +`INPUT_EDGE`. + +So the graph almost certainly needs its input dimensions frozen before tract will touch it. That is a +known, cheap operation — `onnxruntime.tools.make_dynamic_shape_fixed` rewrites the declared dims +without retraining or re-exporting from PyTorch — but it is a step, it produces an artefact that must +itself be reproducible (a `tools/fix-face-model-shapes.sh`, in the spirit of the existing export +script), and **it must be tried before anything else in this document is scheduled.** §12's M1. + +Fixed at **640**, with 320 available as a faster, blinder option to measure. + +**Letterbox.** Scale by `min(640/w, 640/h)` preserving aspect, then paste into a 640×640 canvas. The +reference centres the image and fills the margin with grey `114`, inverting with +`x_src = (x_model − pad_x) / scale`. What matters is not *where* the padding goes but that the +forward and inverse agree and that the fill value is treated as part of the contract: a mismatch +between them offsets every box and landmark the model returns by the padding, which yields detections +that look plausible, landmarks that align badly, and an embedding-quality loss that only shows up as +poor clustering three stages later. Port the reference's convention rather than inventing one, and +assert it with a round-trip test. + +Normalisation is `(x·255 − 127.5) / 128`, RGB, NCHW — note `/128`, not `/127.5`; see §6. + +### 4.2 Outputs, and decoding them + +**Nine tensors for three strides `{8, 16, 32}`, twelve for four `{8, 16, 32, 64}`.** Which one a +given export produces is discovered at load — `strides = output_count / 3` — not assumed, because +both variants exist and hardcoding three silently ignores the largest faces in a twelve-output model. +With two anchors per location and `N_s = (640/s)²` locations: + +| Tensor | Shape | Meaning | +|---|---|---| +| `score_s` | `[N_s·2, 1]` | sigmoid already applied inside the graph | +| `bbox_s` | `[N_s·2, 4]` | distances left, top, right, bottom **in units of the stride** | +| `kps_s` | `[N_s·2, 10]` | five `(dx, dy)` offsets, same units | + +Anchor centres are `(x·s, y·s)` for each grid cell, repeated once per anchor. Decoding is therefore + +``` +x1 = cx − l·s x2 = cx + r·s kx_i = cx + dx_i·s +y1 = cy − t·s y2 = cy + b·s ky_i = cy + dy_i·s +``` + +then divide by the letterbox scale to return to source pixels, then normalise by the long edge before +storage. + +Flat index within a stride is `(row · fw + col) · 2 + anchor`. + +**A shape assertion at load time, not a decode-time surprise.** `dr-segment` already learned this — +`SegmentError::OutputShape` exists because a different export of the same model produces confidently +wrong results otherwise. The reference implements exactly this and it is worth porting verbatim: +output count divisible by three and between 9 and 12, then the **last dimension of each group checked +against `{1, 4, 10}`**. That single check is what catches a YuNet file passed where an SCRFD one was +meant — YuNet also has twelve outputs, so the count alone does not distinguish them, and the failure +without the check is not an error but a page of plausible garbage. + +### 4.3 Thresholds + +Score ≥ **0.5**, NMS IoU **0.4**, plain greedy NMS per image across all strides together. + +Deliberately *not* the low threshold `dr-segment` chose. There, a false positive costs one spurious +entry in a list the user is picking from. Here it costs an entry in the People view that the user has +to reject, in a library with thousands of images — and worse, a garbage embedding that participates +in clustering and can bridge two real clusters into one. False negatives are recoverable by a later +re-index with a better model; a polluted cluster graph is not, once the user has confirmed faces +inside it. + +A minimum box size of **40 px on the source proxy** is applied on top — the reference's figure, +chosen there for the same reason it holds here: below it there is not enough face left to align +reliably, and §7's table shows what the embedder actually receives at that size. §12's M4 is what +moves this number, if measurement says it should move. + +**One thing not to port:** the reference caps detections at ten per frame, largest first. That is +right for a film frame, where the extras in the background are noise. It is wrong for a photo +library, where a group shot with thirty faces in it is precisely the picture the user wants indexed. +No cap; the min-size floor is the only filter. + +--- + +## 5. Alignment — the step that is silently wrong when skipped + +ArcFace embeddings are trained on faces warped to a canonical 112×112 arrangement. Feeding the model +a plain bounding-box crop *works* — it produces 512 numbers, they are unit-norm, and cosine +similarities between them look entirely reasonable. They are just much worse, and nothing in the +system reports it. + +The transform is a **similarity transform** — rotation, uniform scale, translation, four degrees of +freedom — from the five detected landmarks to this template, which is the arrangement the weights were +trained against: + +``` +(38.2946, 51.6963) subject's right eye ─┐ image-left of centre +(73.5318, 51.5014) subject's left eye ─┘ +(56.0252, 71.7366) nose tip +(41.5493, 92.3655) subject's right mouth corner +(70.7299, 92.2041) subject's left mouth corner +``` + +Similarity, not affine: a full affine fit to five noisy points will happily shear a face, and shear is +exactly the deformation the embedder was never shown. + +**The naming is a trap and the order is not.** Point 0 sits at x=38 on a 112-wide canvas — left of +centre *in the image*, which is the subject's **right** eye. Both namings are in circulation and they +are opposite. What matters is that SCRFD emits its five points in this same order, so the correct +amount of reordering between detector and template is **none**; the reference states this explicitly +in `types.hpp` for the benefit of whoever next reads it and doubts it. A future detector with a +different order carries its own permutation next to its `model_id`, rather than this file growing an +assumption. + +**One divergence from the reference to settle by measurement.** It fits the transform with OpenCV's +`estimateAffinePartial2D` under **RANSAC** at a 3-pixel threshold, and drops the detection when the +fit fails. RANSAC over five points is a strange fit — the minimal sample for a similarity is two, so +it can discard landmarks it judges outliers and solve from a subset, which on a profile face is as +likely to be the correct geometry as the wrong one. InsightFace's own pipeline uses a plain +least-squares (Umeyama) similarity over all five points, which cannot silently drop anything and is +the one to write first. §12's M5 measures whether the choice matters; the degenerate-input path still +has to exist either way, because collinear landmarks do occur. + +**A trick worth keeping.** The reference retries detection on a frame where nothing was found, after +replicate-padding by 25% and applying CLAHE to the L channel. Padding gives a face at the very edge a +context the detector needs; CLAHE rescues underexposed frames. Both are plausible on scanned negatives +and backlit photographs — worth trying at low priority, since a retry doubles the cost of exactly the +images that yielded nothing. + +Sampling is bilinear from the *source proxy*, in one step — never crop-then-warp, which resamples +twice and throws away detail the warp could have used. Pixels falling outside the source are black. + +**The landmark order must match the template order.** The template above is written in the detector's +own output order; if a future detector emits them differently, the template is reordered with it and +that mapping belongs next to the model id, not compiled in as an assumption. + +*Test:* warp a synthetic image with a known rotation and scale, and assert the five points land on +the template within a fraction of a pixel. This is testable with no model present, which is why +`align` sits outside the `inference` feature. + +--- + +## 6. Embedding + +112×112 **RGB** — the crop comes out of the warp in whatever order the source was in, and the +reference converts BGR→RGB explicitly before the blob rather than relying on a flag, which is the +readable way to do it. + +Normalisation is `(x·255 − 127.5) / 128`. **Note `/128`, not `/127.5`:** InsightFace's published +Python uses `1.0/127.5` for the recognition model, the reference uses `1.0/128` for both models, and +every measured number in §1's table was produced with `/128`. The difference is 0.4% of scale and +almost certainly immaterial, but "almost certainly" is not a reason to pick silently — write `/128` to +match the numbers we have, and settle it with one back-to-back run in §12. + +Output is 512 floats; **L2-normalise before storing**, so every downstream comparison is a dot product +and no code path has to remember to normalise. The reference clamps the norm at 1e-6 before dividing, +which costs nothing and removes a NaN path. + +Some exports of these graphs are fp16 in, fp16 out. The reference detects this from the graph's +declared element type rather than from the filename; worth porting, because the alternative failure is +a silent garbage tensor. + +Storage is `512 × f16` (catalog.md §10.1) — 1 KB per face, 25 MB for a 25,000-face library. The f16 +round-trip perturbs a unit vector by ~1e-3 in cosine, three orders of magnitude below the separation +between a match and a non-match, and the halving matters because these rows are the ones NFR-SEC-5 +contemplates optionally syncing. + +Re-normalise on load after the f16 widen. It is one pass over 512 floats and it removes a class of +drift that is otherwise invisible. + +--- + +## 7. Which pixels the pipeline actually sees + +FR-CULL-8 pins indexing to the FR-CULL-2 ladder: **the proxy tier, never a full decode.** The +relevant tier is `ThumbSize::Large` — 1024 px on the long edge (`dr-thumbs`). + +That has a consequence worth stating in numbers rather than discovering in a clustering report. On a +1024 px proxy: + +| Face size in frame | Pixels across | What §6 receives | +|---|---|---| +| A portrait, face fills a third of the frame | ~340 | Downsampled to 112. Ideal. | +| Two people, half-length | ~120 | Roughly native. Good. | +| A group of eight | ~50 | **Upsampled** to 112. Degraded, and usable. | +| A figure in a landscape | ~20 | At or below §4.3's floor. Rejected. | + +So `faces` records one column beyond catalog.md §10.1's schema: + +```sql +ALTER TABLE faces ADD COLUMN crop_px INTEGER NOT NULL; -- source pixels across the aligned crop +``` + +`crop_px` earns its place three times over. It is the honest quality signal for the UI; it is a +**feature in §8's calibration**, which FR-CULL-9 explicitly demands ("a raw cosine means something +different for every model, every population, and *every face size*"); and it is what a future +higher-resolution re-embedding pass would select on, so that pass becomes a query rather than a +re-index of everything. + +**No RAW decode is added.** Where the Large proxy is missing, the job enqueues a `Thumbnail` job at +background priority and re-queues itself, exactly as FR-CULL-8 requires. + +--- + +## 8. Calibration — cosine to probability + +FR-CULL-9 makes this a hard requirement: no code path may threshold a bare cosine, every threshold +in the subsystem is stated as a probability, and the fit is per library and reports its own validity. +This section is how that is obtained, and the interesting part is where the training pairs come from +when the user has labelled nothing yet. + +### 8.1 Where the pairs come from + +**Negatives are free and abundant.** Two faces detected in *the same photograph* are almost never the +same person. That gives every multi-face image in the library a full set of negative pairs at no +labelling cost — and they are *hard* negatives, drawn from the same camera, lighting, and processing, +which is exactly the population where a threshold tuned on easy negatives fails. The known exceptions +— mirrors, photographs of photographs, collages, a spliced panorama — are rare enough to be noise at +this scale and worth naming so the exception is not mistaken for a bug later. + +**Positives, in order of trustworthiness:** + +1. **User confirmations** (FR-CULL-10). Every pair of faces confirmed to the same person. The gold + standard, and empty on day one. +2. **Burst siblings.** FR-CULL-5 already groups bursts. Two faces in adjacent frames of one burst, + in nearly the same position, are near-certainly the same person. Free, requires no labelling, and + available immediately on any library with continuous-shooting frames in it. **Their purity is an + S14 measurement** (§12), not an assumption — if bursts turn out to be dirtier than expected, this + source is dropped and the calibration simply stays invalid for longer. +3. Nothing else. Bootstrapping positives from high cosine similarity is circular — it fits the + calibration to the belief it was supposed to test. + +### 8.2 The fit + +§1.1's reference already implements this and its shape should be ported rather than reinvented: + +``` +P(same | cos) = σ(a·cos + b + log_prior_odds) +``` + +Four details in it are the difference between working and nearly working. + +**Fit against a histogram, not against pairs.** A 25,000-face library has 3×10⁸ pairs; no gradient +descent is running over that. The reference buckets every pair into **200 bins over cos ∈ [−1, 1]**, +carrying a positive and a negative count per bin, and fits the two parameters against the per-bin +counts. The cost becomes the similarity matrix plus 200 numbers, and the matrix is a single GEMM +(§9). This is the trick that makes a per-library fit affordable at all, and it is not obvious from +the outside. + +**Balance the classes explicitly.** `w_pos = total/(2·n_pos)`, `w_neg = total/(2·n_neg)`. Negatives +outnumber positives by orders of magnitude; an unweighted fit produces a well-shaped curve sitting at +the wrong height — precisely the "plausible number all the way to the user interface" failure +FR-CULL-9 describes. + +**The base rate is a runtime argument, not part of the fit.** `log_prior_odds` is added at evaluation +time, so the balanced fit is stored once and the prior varies per query — the odds that two faces +drawn from a 40-image holiday album match are not the odds for a 40,000-image archive. Its inverse, +`boundary_at(p)`, gives the cosine at which the probability crosses `p`, which is what turns §9's +"merge above 0.9" into an actual comparison. Baking a prior into `a` and `b` would need a refit per +context and would make the stored parameters mean something different depending on where they came +from. + +**Deduplicate before pairing.** Near-identical embeddings (cos > 1 − 1e-7) are the same photograph +counted twice; the reference drops them per identity first. In a photo library the equivalent is +duplicates and virtual copies (FR-CAT-11), and leaving them in stacks the positive histogram at +cos ≈ 1 with pairs that teach the fit nothing about hard cases. + +**A third feature this design adds.** The reference fits on cosine alone; DarkRoom should carry +`crop_px` too: + +``` +logit P(same) = w₀ + w₁·cos + w₂·log₂(min(crop_px_a, crop_px_b)) +``` + +FR-CULL-9 explicitly names face size as an axis along which an uncalibrated similarity misbehaves, +§7 shows a real library spans 40 px to 340 px of face, and `crop_px` is already in hand. The +*minimum* of the pair, because a comparison is only as good as its worse crop. It generalises the +histogram to a small 2-D grid of bins, which changes nothing structural. If M7 says the extra feature +buys nothing, drop it and match the reference exactly — but a film pipeline working from broadcast +frames had far less size variation to explain than this one does. + +### 8.3 Validity, and saying so + +Stored per catalog.md §10.3: parameters, a validity flag, and a hash of the face set fitted from. + +Invalid when fewer than **200 positive pairs** or **2,000 negative pairs** are available, or when the +reliability check fails. + +Deliberately far stricter than the reference's floor of two positives and one negative. That floor is +reasonable there: its pairs come from a curated gallery of labelled reference portraits, where a +positive pair is trustworthy by construction. Here the positives are bootstrapped from bursts and +early confirmations (§8.1) and the whole risk is fitting confidently to a handful of them. The +reference also refuses to draw positives from an identity with fewer than five distinct embeddings, +letting it contribute negatives only — the same asymmetry applies to a thinly-confirmed person and is +worth keeping. In that state the UI says confidence is unavailable and the People view +still works — clustering falls back to a documented default operating point, labelled in the +interface as an untuned default, and no probability is displayed. That is FR-CULL-9's requirement +read literally: not presenting an untuned default *as though it were measured*. + +Refit is triggered by the same debounce as clustering (§9), and when the confirmed-pair count grows +materially. + +*Acceptance (FR-CULL-9):* a reliability diagram over ten probability bins, on a held-out labelled +split, with observed match rate within a stated tolerance of the predicted probability in each +populated bin. A single accuracy figure is not an answer to this requirement. + +--- + +## 9. Clustering + +Not a job kind, for the reason catalog.md §10.2 gives: it is a whole-library operation with no +natural `subject_id`. It runs as a debounced library pass when detection has been idle and the face +count has moved materially. + +**The graph.** For each face, its `k = 20` nearest neighbours by cosine, then each candidate edge +scored through §8 to a probability. + +Brute force is honest arithmetic here, and §1.1's project is the evidence rather than the estimate: it +computes the full similarity matrix as **one GEMM** (`E · Eᵀ` over L2-normalised rows) at gallery +scale, on CPU, and instruments it well enough to have compared CPU against OpenCL and found the +question worth asking rather than urgent. For DarkRoom that is a 25,000 × 512 matrix at 25 MB in and +~2.5 GB out — so the matrix is computed **in row blocks**, with each block reduced to its top-k and +its histogram contribution before the next is started, and never materialised whole. Once, in the +background, after an indexing sweep that took an hour. An approximate index is an optimisation to +reach for when §12 says it is needed, not before. + +**Constraints, not just thresholds:** + +- **Cannot-link on co-occurrence.** Two faces in the same image are never merged. This is the same + observation §8.1 mines for negatives, used here as a hard constraint, and it is the single + cheapest defence against the over-merging FR-CULL-10 warns about. +- **Confirmed faces are anchors.** A confirmation is user data (FR-CULL-12) and clustering never + moves it. Two clusters each containing confirmations of *different* people cannot merge; a cluster + containing confirmations of one person absorbs suggestions but never reassigns the confirmed. + +**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 +than single-link because single-link chains — one bad edge welds two identities together, which is +the documented way face clustering fails on families. + +**Incremental by default.** A new face joins the existing cluster whose average probability against +it is highest, if that clears the threshold; otherwise it starts an unnamed group. A full +re-agglomeration happens on model change, on recalibration, and on explicit user request. Suggestions +are recomputed freely; confirmations survive all of it (FR-CULL-10). + +**Splitting** re-agglomerates within one person at a raised threshold and offers the resulting groups +as the split. FR-CULL-10 requires splitting to be as easy as merging, and a split that hands the user +a pile of loose faces to re-sort is not that. + +--- + +## 10. Catalog and jobs + +**Schema** is catalog.md §10.1's v5 migration, plus `faces.crop_px` (§7) and the calibration table +(§8.3). Nothing else changes. + +**One new job kind:** + +```rust +/// Detect and embed faces in one image, from its proxy (FR-CULL-8). +DetectFaces = 8, +``` + +Background priority, coalesced per `image_id`, interruptible, resumable — it inherits FR-CAT-3's +properties with no exceptions, which is what FR-CULL-8's acceptance criterion is about. It is not a +network job. + +One job does detection *and* embedding for every face in the image, rather than splitting them. +Splitting would double the queue's row count for no benefit: the proxy is already decoded and in +memory, and the natural unit of resumable work is one photograph. + +**Selector term** (FR-CULL-11): + +```rust +Person { id: PersonId, include_suggested: bool }, // defaults to false +``` + +Confirmed-only by default, so a saved smart collection does not silently change membership when a +later indexing pass revises a guess. + +--- + +## 11. Privacy obligations that constrain the code's shape + +NFR-SEC-5 is not a policy to remember; it is a set of properties the code either has structurally or +does not have at all. The checkable ones: + +- **`dr-face` has no network dependency.** No `reqwest`, no `ureq`, transitively. Worth a CI check + over `cargo tree`, alongside the existing lints — the crate that holds the embeddings should be + provably unable to send them anywhere. +- **The model fetch (§2.2) is in the app layer**, which is why the API in §3.1 takes bytes. +- **The diagnostics bundle is an allowlist** (NFR-OPS-1), so `faces`, `face_person` and the + calibration table are excluded by not being named, and a future table cannot become uploadable by + existing. +- **Delete-all is one transaction and one control**: faces, links, people, calibration, and the + cached crops if any. Separately, a switch that stops indexing so no such data is produced. The two + are different actions and are not collapsed into one. +- **The about screen reads the model metadata from the loader** — id, version, licence — rather than + from a hardcoded string that will drift from what is actually running. + +--- + +## 12. What S14 measures + +The corpus is a real personal library — the ~2,000-image sample S14 already specifies — with a +hand-labelled identity subset. Fixed in advance, so the result is a measurement rather than an +argument: + +| # | Measure | Why it decides something | +|---|---|---| +| **M1** | **Does tract load both graphs?** As shipped, then with the input dims frozen | Go/no-go, and it is *first*. `det_500m.onnx` has a **dynamic H/W input** (§4.1), which is the exact thing tract failed on for YOLO and which OpenCV's `dnn` could not load either — so the as-shipped answer is expected to be no, and the real question is whether `make_dynamic_shape_fixed` is enough or whether a PyTorch re-export is needed. Costs an afternoon; everything below is void without it. | +| **M2** | Detection latency per image at 640, and at 320, on the reference desktop and one Android device | Whether a 17k library indexes in a background sweep or a weekend. The one measured datapoint we have — YOLO26n-seg, ~470 ms at 640×640 in tract — suggests SCRFD-500M lands well under it, but tract is not ORT and an extrapolation is not a measurement. | +| **M3** | Embedding latency per face, and faces per image in a real library | The multiplier on M2. A library averaging 1.5 faces per image at 100 ms per face is an hour for 25k faces; at 400 ms it is four. | +| **M4** | Detection recall against hand-labelled faces, bucketed by `crop_px` | Where §4.3's floor should actually sit, and what proportion of a real library's faces are in the degraded bucket §7 predicts. | +| **M5** | **Aligned versus unaligned embeddings**, same corpus, same clustering. Then two A/Bs that cost one run each: least-squares versus RANSAC alignment (§5), and `/128` versus `/127.5` normalisation (§6) | Quantifies §5. If the gap is small the alignment code is still correct; if it is large, this is the measurement that stops someone "simplifying" it later. The two A/Bs close divergences between the reference and the published Python that are currently settled by assertion. | +| **M6** | Cluster purity and completeness against the labelled subset | Whether the subsystem is worth building at all. §1's F1 figures rank the models on *film frames against a known cast*; nothing yet says how MBF behaves on family snapshots it must cluster blind. | +| **M7** | Calibration reliability, and **the sample size at which the fit becomes valid** | FR-CULL-9's acceptance criterion, plus the practical question of whether a 2,000-image library ever gets a valid fit or whether §8.3's "unavailable" is the normal state. | +| **M8** | Burst-derived positive-pair purity (§8.1) | Whether the free positives are usable or the fit waits for user confirmations. | +| **M9** | YuNet as a drop-in detector: M4 and M6, re-run | §2.3's question 3, and the model file is already on disk. If the answer is "close enough", half the licence problem disappears. Needs its own decode path — its outputs are not SCRFD's. | +| **M10** | Peak RSS during an indexing sweep | NFR-RES-2. Two loaded graphs plus a proxy plus a batch of crops, on a phone. | + +--- + +## 13. Order + +1. **M1** — load both graphs in tract, freezing the input dims if needed (§4.1). Go/no-go, and it + now comes first: it is an afternoon, and §2.3's reading is wasted effort if the graphs will not + run at all. This is a change from the S14 brief's ordering, made because §1.1 removed the + uncertainty that justified reading first — the models are known to work, so the open question is + the runtime, not the choice. +2. **Licence reading** (§2.3), in parallel with 3. Before anything *ships*, per D13. +3. `dr-face` skeleton, `detect` + `align` + `embed`, ported from §1.1's table, with an example binary + that draws boxes and landmarks on a JPEG — the same shape as `dr-segment`'s `examples/detect.rs`, + and for the same reason: the thing worth looking at is whether the landmarks land on a real + photograph. Port `tests/test_face_utils.cpp`'s cases first; they are model-free and they fail + loudly on exactly the mistakes §5 describes. +4. M2–M5 on the corpus. Alignment is validated here, before anything depends on embedding quality. +5. `calibrate` + `cluster` against the labelled subset. M6–M8. The Platt fit ports from + `gallery_calibration.hpp`; the pair *sourcing* (§8.1) is new and is the part to get wrong. +6. The v5 migration, the `DetectFaces` job, the debounced clustering pass. +7. UI: People view, confirm and reject, merge and split, the `Person` selector term. +8. The route-C first-run flow, the about-screen entry, and the delete-all control — with the feature + shipped disabled until they exist. + +Steps 1–5 are the spike. Steps 6–8 are the build, and they are only justified if §12 says so. + +--- + +## 14. What this does not settle + +- **Whether a permissive model pair exists.** §2.3. If it does, route B replaces route C and step 8's + first-run flow shrinks to nothing. +- **Whether tract runs these graphs at all.** §12 M1, and §4.1 says why the answer is in real doubt. + Every other line of this document is conditional on it. +- **Faces in trashed images.** catalog.md §10.4's open question, unchanged: probably excluded from + suggestions but not deleted, so a restore does not re-index. +- **Whether embeddings sync.** NFR-SEC-5 permits it, opt-in, separately consented. Nothing here + depends on the answer. +- **Re-embedding at higher resolution.** §7's `crop_px` makes it a query rather than a full re-index, + but whether it is worth doing is an M4 question. +- **Approximate nearest neighbours.** §9 says brute force until measured otherwise. A 100k-face + library is where this stops being true. + +--- + +## 15. Register entries + +**D13** — *face inference runtime and model licensing* · the runtime half stays answered (`ort` + +`ort-tract`, unchanged since 2026-08-21). **The licensing half is answered conditionally by §2:** +route C — ship the code, not the weights — unblocks the build without breaking GPL-3.0 or any +distribution channel, and §2.3's reading may yet convert it to route B. Closing D13 outright waits on +that reading. + +**D17** — *face model pair and distribution route* · **PROPOSED**. SCRFD-500M for detection, +MobileFaceNet/ArcFace for embedding, weights obtained by the user rather than redistributed, with a +pinned checksum and a licence notice shown before the first fetch. The alternative considered and +rejected is committing the InsightFace weights (§2.1 route A), which is not available at any price. +The model *pair* is better evidenced than a proposal usually is — §1's table is a measured comparison +over a 2,418-identity gallery, not a literature reading — so the open half of this decision is the +route, not the pair. Relates to: D13, D14, NFR-COMPAT-2, FR-CULL-8. + +**D18** — *porting from `scene-actor-extraction`* · **PROPOSED, and the easy half of a decision**. +That project is MIT and by this project's author, so MIT-into-GPL-3.0-or-later is a compatible +one-way combination requiring attribution, not permission. Ported files carry a header naming the +origin. Worth recording because "we already have this working in another language" is exactly the +provenance that goes undocumented and then cannot be answered three years later. + +**S14** — *face pipeline in Rust* · scope sharpened, and **substantially de-risked**, by this +document. §12 replaces "measure per-image latency, cluster purity and calibration convergence" with +ten specific measures. Two changes to the brief itself: its instruction to resolve the licence +question *before writing any of it* is relaxed to "before shipping any of it", because M1 is an +afternoon and is worth knowing first (§13); and its central question — whether the pipeline works at +all — is largely answered in advance by §1.1, leaving the runtime and the domain shift from film +frames to family snapshots as the real unknowns. + +--- + +## 16. Requirements touched + +| ID | How this document addresses it | +|---|---| +| FR-CULL-8 | §4 detection, §6 embedding, §7 the proxy tier and its consequences, §10 the `DetectFaces` job | +| FR-CULL-9 | §8 — pairs, fit, validity, and the reliability-diagram acceptance test | +| FR-CULL-10 | §9 constrained agglomeration, confirmations as anchors, split by re-agglomeration | +| FR-CULL-11 | §10 the `Person` selector term, confirmed-only by default | +| FR-CULL-12 | §10 schema unchanged from catalog.md §10.1: embeddings derived, names to the sidecar | +| NFR-SEC-5 | §11 — the obligations restated as structural properties, one of them CI-checkable | +| NFR-COMPAT-2 | §2 — why the obvious weights cannot ship, and what does instead | +| NFR-RES-2 | §1 the cheap model pair, §12 M2/M3/M10 on a phone | +| NFR-ARCH-2 | §10 background priority, preempted by visible work | diff --git a/docs/traceability.md b/docs/traceability.md index 06c0a50..5b25898 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -10,7 +10,7 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| | Source files scanned | 227 | -| TRACES tags found | 614 | +| TRACES tags found | 621 | | Requirements defined | 177 | | Requirements covered | 91 | | **Coverage** | **51.4%** (91/177) | @@ -46,27 +46,27 @@ _None._ | FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/schema.rs:840`](../core/dr-catalog/src/schema.rs#L840), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`ui/dr-ui/src/collections_ui.rs:1578`](../ui/dr-ui/src/collections_ui.rs#L1578), [`ui/dr-ui/src/collections_ui.rs:1597`](../ui/dr-ui/src/collections_ui.rs#L1597), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3055`](../ui/dr-ui/src/library.rs#L3055), [`ui/dr-ui/src/library_ui.rs:6255`](../ui/dr-ui/src/library_ui.rs#L6255), [`ui/dr-ui/src/library_ui.rs:6266`](../ui/dr-ui/src/library_ui.rs#L6266), [`ui/dr-ui/src/library_ui.rs:6279`](../ui/dr-ui/src/library_ui.rs#L6279), [`ui/dr-ui/src/library_ui.rs:628`](../ui/dr-ui/src/library_ui.rs#L628), [`ui/dr-ui/src/library_ui.rs:6294`](../ui/dr-ui/src/library_ui.rs#L6294), [`ui/dr-ui/src/library_ui.rs:6303`](../ui/dr-ui/src/library_ui.rs#L6303), [`ui/dr-ui/ui/app.slint:458`](../ui/dr-ui/ui/app.slint#L458), [`ui/dr-ui/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1212`](../ui/dr-ui/ui/library.slint#L1212), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:850`](../ui/dr-ui/ui/library.slint#L850), [`ui/dr-ui/ui/library.slint:901`](../ui/dr-ui/ui/library.slint#L901) | | FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:3301`](../ui/dr-ui/src/library.rs#L3301), [`ui/dr-ui/src/library_ui.rs:315`](../ui/dr-ui/src/library_ui.rs#L315), [`ui/dr-ui/src/library_ui.rs:387`](../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:5124`](../ui/dr-ui/src/library_ui.rs#L5124), [`ui/dr-ui/src/library_ui.rs:5177`](../ui/dr-ui/src/library_ui.rs#L5177), [`ui/dr-ui/src/library_ui.rs:6395`](../ui/dr-ui/src/library_ui.rs#L6395), [`ui/dr-ui/ui/app.slint:461`](../ui/dr-ui/ui/app.slint#L461), [`ui/dr-ui/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/app.slint:826`](../ui/dr-ui/ui/app.slint#L826), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1278`](../ui/dr-ui/ui/library.slint#L1278), [`ui/dr-ui/ui/library.slint:901`](../ui/dr-ui/ui/library.slint#L901), [`ui/dr-ui/ui/settings.slint:115`](../ui/dr-ui/ui/settings.slint#L115) | | FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3466`](../ui/dr-ui/src/library_ui.rs#L3466), [`ui/dr-ui/src/library_ui.rs:4164`](../ui/dr-ui/src/library_ui.rs#L4164), [`ui/dr-ui/ui/app.slint:582`](../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2516`](../ui/dr-ui/ui/library.slint#L2516), [`ui/dr-ui/ui/library.slint:872`](../ui/dr-ui/ui/library.slint#L872), [`ui/dr-ui/ui/library.slint:891`](../ui/dr-ui/ui/library.slint#L891) | -| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:136`](../core/dr-pipeline/src/ops/curve.rs#L136), [`core/dr-pipeline/src/ops/curve.rs:652`](../core/dr-pipeline/src/ops/curve.rs#L652), [`core/dr-pipeline/src/sidecar.rs:1343`](../core/dr-pipeline/src/sidecar.rs#L1343), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`core/dr-pipeline/tests/tone_curve.rs:33`](../core/dr-pipeline/tests/tone_curve.rs#L33), [`ui/dr-ui/src/develop.rs:2757`](../ui/dr-ui/src/develop.rs#L2757), [`ui/dr-ui/src/develop.rs:2785`](../ui/dr-ui/src/develop.rs#L2785), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1239`](../ui/dr-ui/src/lib.rs#L1239), [`ui/dr-ui/src/lib.rs:1579`](../ui/dr-ui/src/lib.rs#L1579), [`ui/dr-ui/src/lib.rs:1684`](../ui/dr-ui/src/lib.rs#L1684), [`ui/dr-ui/src/lib.rs:464`](../ui/dr-ui/src/lib.rs#L464), [`ui/dr-ui/src/lib.rs:849`](../ui/dr-ui/src/lib.rs#L849), [`ui/dr-ui/src/library.rs:1545`](../ui/dr-ui/src/library.rs#L1545), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library_ui.rs:4866`](../ui/dr-ui/src/library_ui.rs#L4866), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:136`](../core/dr-pipeline/src/ops/curve.rs#L136), [`core/dr-pipeline/src/ops/curve.rs:652`](../core/dr-pipeline/src/ops/curve.rs#L652), [`core/dr-pipeline/src/sidecar.rs:1343`](../core/dr-pipeline/src/sidecar.rs#L1343), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`core/dr-pipeline/tests/tone_curve.rs:33`](../core/dr-pipeline/tests/tone_curve.rs#L33), [`ui/dr-ui/src/develop.rs:2762`](../ui/dr-ui/src/develop.rs#L2762), [`ui/dr-ui/src/develop.rs:2790`](../ui/dr-ui/src/develop.rs#L2790), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1239`](../ui/dr-ui/src/lib.rs#L1239), [`ui/dr-ui/src/lib.rs:1579`](../ui/dr-ui/src/lib.rs#L1579), [`ui/dr-ui/src/lib.rs:1684`](../ui/dr-ui/src/lib.rs#L1684), [`ui/dr-ui/src/lib.rs:464`](../ui/dr-ui/src/lib.rs#L464), [`ui/dr-ui/src/lib.rs:849`](../ui/dr-ui/src/lib.rs#L849), [`ui/dr-ui/src/library.rs:1545`](../ui/dr-ui/src/library.rs#L1545), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library_ui.rs:4866`](../ui/dr-ui/src/library_ui.rs#L4866), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:394`](../core/dr-catalog/src/schema.rs#L394), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2325`](../ui/dr-ui/src/develop.rs#L2325), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1505`](../ui/dr-ui/src/library.rs#L1505), [`ui/dr-ui/src/library.rs:1582`](../ui/dr-ui/src/library.rs#L1582), [`ui/dr-ui/src/library.rs:220`](../ui/dr-ui/src/library.rs#L220), [`ui/dr-ui/src/library.rs:3408`](../ui/dr-ui/src/library.rs#L3408), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library_ui.rs:1564`](../ui/dr-ui/src/library_ui.rs#L1564), [`ui/dr-ui/src/library_ui.rs:1590`](../ui/dr-ui/src/library_ui.rs#L1590), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:1700`](../ui/dr-ui/src/library_ui.rs#L1700), [`ui/dr-ui/src/library_ui.rs:226`](../ui/dr-ui/src/library_ui.rs#L226), [`ui/dr-ui/src/library_ui.rs:2316`](../ui/dr-ui/src/library_ui.rs#L2316), [`ui/dr-ui/src/library_ui.rs:259`](../ui/dr-ui/src/library_ui.rs#L259), [`ui/dr-ui/src/library_ui.rs:2754`](../ui/dr-ui/src/library_ui.rs#L2754), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:3257`](../ui/dr-ui/src/library_ui.rs#L3257), [`ui/dr-ui/src/library_ui.rs:3329`](../ui/dr-ui/src/library_ui.rs#L3329), [`ui/dr-ui/src/library_ui.rs:3517`](../ui/dr-ui/src/library_ui.rs#L3517), [`ui/dr-ui/src/library_ui.rs:3635`](../ui/dr-ui/src/library_ui.rs#L3635), [`ui/dr-ui/src/library_ui.rs:440`](../ui/dr-ui/src/library_ui.rs#L440), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/library_ui.rs:5222`](../ui/dr-ui/src/library_ui.rs#L5222), [`ui/dr-ui/src/library_ui.rs:5337`](../ui/dr-ui/src/library_ui.rs#L5337), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) | | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:154`](../core/dr-pipeline/src/graph.rs#L154), [`core/dr-pipeline/src/graph.rs:457`](../core/dr-pipeline/src/graph.rs#L457), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:473`](../core/dr-pipeline/src/operation.rs#L473), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1343`](../core/dr-pipeline/src/sidecar.rs#L1343), [`core/dr-pipeline/src/sidecar.rs:1399`](../core/dr-pipeline/src/sidecar.rs#L1399), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:2757`](../ui/dr-ui/src/develop.rs#L2757), [`ui/dr-ui/src/develop.rs:3189`](../ui/dr-ui/src/develop.rs#L3189), [`ui/dr-ui/src/develop.rs:3243`](../ui/dr-ui/src/develop.rs#L3243), [`ui/dr-ui/src/develop.rs:3287`](../ui/dr-ui/src/develop.rs#L3287), [`ui/dr-ui/src/develop.rs:3337`](../ui/dr-ui/src/develop.rs#L3337), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1275`](../ui/dr-ui/src/lib.rs#L1275), [`ui/dr-ui/src/lib.rs:1934`](../ui/dr-ui/src/lib.rs#L1934), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1915`](../ui/dr-ui/ui/app.slint#L1915), [`ui/dr-ui/ui/app.slint:913`](../ui/dr-ui/ui/app.slint#L913) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:154`](../core/dr-pipeline/src/graph.rs#L154), [`core/dr-pipeline/src/graph.rs:457`](../core/dr-pipeline/src/graph.rs#L457), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:473`](../core/dr-pipeline/src/operation.rs#L473), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1343`](../core/dr-pipeline/src/sidecar.rs#L1343), [`core/dr-pipeline/src/sidecar.rs:1399`](../core/dr-pipeline/src/sidecar.rs#L1399), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:2762`](../ui/dr-ui/src/develop.rs#L2762), [`ui/dr-ui/src/develop.rs:3194`](../ui/dr-ui/src/develop.rs#L3194), [`ui/dr-ui/src/develop.rs:3248`](../ui/dr-ui/src/develop.rs#L3248), [`ui/dr-ui/src/develop.rs:3292`](../ui/dr-ui/src/develop.rs#L3292), [`ui/dr-ui/src/develop.rs:3342`](../ui/dr-ui/src/develop.rs#L3342), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1275`](../ui/dr-ui/src/lib.rs#L1275), [`ui/dr-ui/src/lib.rs:1934`](../ui/dr-ui/src/lib.rs#L1934), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1915`](../ui/dr-ui/ui/app.slint#L1915), [`ui/dr-ui/ui/app.slint:913`](../ui/dr-ui/ui/app.slint#L913) | | FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:18`](../core/dr-pipeline/src/graph.rs#L18), [`core/dr-pipeline/src/graph.rs:218`](../core/dr-pipeline/src/graph.rs#L218), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1008`](../ui/dr-ui/src/develop.rs#L1008), [`ui/dr-ui/src/lib.rs:579`](../ui/dr-ui/src/lib.rs#L579) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:218`](../core/dr-pipeline/src/graph.rs#L218), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:3916`](../ui/dr-ui/src/develop.rs#L3916) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:218`](../core/dr-pipeline/src/graph.rs#L218), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:3921`](../ui/dr-ui/src/develop.rs#L3921) | | FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:433`](../core/dr-gpu/tests/capture_sharpen.rs#L433), [`core/dr-gpu/tests/detail_stage.rs:241`](../core/dr-gpu/tests/detail_stage.rs#L241), [`core/dr-gpu/tests/local_contrast.rs:475`](../core/dr-gpu/tests/local_contrast.rs#L475), [`core/dr-gpu/tests/noise_reduction.rs:555`](../core/dr-gpu/tests/noise_reduction.rs#L555), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:486`](../core/dr-pipeline/src/graph.rs#L486), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | | FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1410`](../core/dr-pipeline/src/operation.rs#L1410), [`core/dr-pipeline/src/operation.rs:1491`](../core/dr-pipeline/src/operation.rs#L1491), [`core/dr-pipeline/src/operation.rs:1516`](../core/dr-pipeline/src/operation.rs#L1516), [`core/dr-pipeline/src/operation.rs:1531`](../core/dr-pipeline/src/operation.rs#L1531), [`core/dr-pipeline/src/operation.rs:1555`](../core/dr-pipeline/src/operation.rs#L1555), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/operation.rs:403`](../core/dr-pipeline/src/operation.rs#L403), [`core/dr-pipeline/src/operation.rs:413`](../core/dr-pipeline/src/operation.rs#L413), [`core/dr-pipeline/src/operation.rs:549`](../core/dr-pipeline/src/operation.rs#L549) | -| FR-DEV-3f | [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:88`](../core/dr-film/src/grain.rs#L88), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:167`](../core/dr-film/src/profile.rs#L167), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:110`](../core/dr-pipeline/src/graph.rs#L110), [`core/dr-pipeline/src/graph.rs:292`](../core/dr-pipeline/src/graph.rs#L292), [`core/dr-pipeline/src/graph.rs:96`](../core/dr-pipeline/src/graph.rs#L96), [`core/dr-pipeline/src/operation.rs:1014`](../core/dr-pipeline/src/operation.rs#L1014), [`core/dr-pipeline/src/operation.rs:1043`](../core/dr-pipeline/src/operation.rs#L1043), [`core/dr-pipeline/src/operation.rs:1410`](../core/dr-pipeline/src/operation.rs#L1410), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/ops/film_sim.rs:120`](../core/dr-pipeline/src/ops/film_sim.rs#L120), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:291`](../core/dr-pipeline/src/ops/film_sim.rs#L291), [`core/dr-pipeline/src/ops/film_sim.rs:96`](../core/dr-pipeline/src/ops/film_sim.rs#L96), [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:1647`](../core/dr-pipeline/src/sidecar.rs#L1647), [`core/dr-pipeline/src/sidecar.rs:169`](../core/dr-pipeline/src/sidecar.rs#L169), [`core/dr-pipeline/src/sidecar.rs:1723`](../core/dr-pipeline/src/sidecar.rs#L1723), [`core/dr-pipeline/src/sidecar.rs:415`](../core/dr-pipeline/src/sidecar.rs#L415), [`core/dr-pipeline/src/sidecar.rs:555`](../core/dr-pipeline/src/sidecar.rs#L555), [`core/dr-pipeline/src/sidecar.rs:678`](../core/dr-pipeline/src/sidecar.rs#L678), [`ui/dr-ui/src/develop.rs:2340`](../ui/dr-ui/src/develop.rs#L2340), [`ui/dr-ui/src/develop.rs:2357`](../ui/dr-ui/src/develop.rs#L2357), [`ui/dr-ui/src/develop.rs:2388`](../ui/dr-ui/src/develop.rs#L2388), [`ui/dr-ui/src/develop.rs:2480`](../ui/dr-ui/src/develop.rs#L2480), [`ui/dr-ui/src/develop.rs:2794`](../ui/dr-ui/src/develop.rs#L2794), [`ui/dr-ui/src/lib.rs:1908`](../ui/dr-ui/src/lib.rs#L1908), [`ui/dr-ui/src/lib.rs:512`](../ui/dr-ui/src/lib.rs#L512), [`ui/dr-ui/src/lib.rs:570`](../ui/dr-ui/src/lib.rs#L570), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:879`](../ui/dr-ui/ui/adjust.slint#L879), [`ui/dr-ui/ui/adjust.slint:949`](../ui/dr-ui/ui/adjust.slint#L949), [`ui/dr-ui/ui/app.slint:2370`](../ui/dr-ui/ui/app.slint#L2370), [`ui/dr-ui/ui/app.slint:707`](../ui/dr-ui/ui/app.slint#L707) | +| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:88`](../core/dr-film/src/grain.rs#L88), [`core/dr-film/src/lib.rs:158`](../core/dr-film/src/lib.rs#L158), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:110`](../core/dr-pipeline/src/graph.rs#L110), [`core/dr-pipeline/src/graph.rs:292`](../core/dr-pipeline/src/graph.rs#L292), [`core/dr-pipeline/src/graph.rs:96`](../core/dr-pipeline/src/graph.rs#L96), [`core/dr-pipeline/src/operation.rs:1014`](../core/dr-pipeline/src/operation.rs#L1014), [`core/dr-pipeline/src/operation.rs:1043`](../core/dr-pipeline/src/operation.rs#L1043), [`core/dr-pipeline/src/operation.rs:1410`](../core/dr-pipeline/src/operation.rs#L1410), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/ops/film_sim.rs:102`](../core/dr-pipeline/src/ops/film_sim.rs#L102), [`core/dr-pipeline/src/ops/film_sim.rs:126`](../core/dr-pipeline/src/ops/film_sim.rs#L126), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:300`](../core/dr-pipeline/src/ops/film_sim.rs#L300), [`core/dr-pipeline/src/ops/film_sim.rs:68`](../core/dr-pipeline/src/ops/film_sim.rs#L68), [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:1647`](../core/dr-pipeline/src/sidecar.rs#L1647), [`core/dr-pipeline/src/sidecar.rs:169`](../core/dr-pipeline/src/sidecar.rs#L169), [`core/dr-pipeline/src/sidecar.rs:1723`](../core/dr-pipeline/src/sidecar.rs#L1723), [`core/dr-pipeline/src/sidecar.rs:415`](../core/dr-pipeline/src/sidecar.rs#L415), [`core/dr-pipeline/src/sidecar.rs:555`](../core/dr-pipeline/src/sidecar.rs#L555), [`core/dr-pipeline/src/sidecar.rs:678`](../core/dr-pipeline/src/sidecar.rs#L678), [`ui/dr-ui/src/develop.rs:2340`](../ui/dr-ui/src/develop.rs#L2340), [`ui/dr-ui/src/develop.rs:2357`](../ui/dr-ui/src/develop.rs#L2357), [`ui/dr-ui/src/develop.rs:2388`](../ui/dr-ui/src/develop.rs#L2388), [`ui/dr-ui/src/develop.rs:2485`](../ui/dr-ui/src/develop.rs#L2485), [`ui/dr-ui/src/develop.rs:2799`](../ui/dr-ui/src/develop.rs#L2799), [`ui/dr-ui/src/lib.rs:1908`](../ui/dr-ui/src/lib.rs#L1908), [`ui/dr-ui/src/lib.rs:512`](../ui/dr-ui/src/lib.rs#L512), [`ui/dr-ui/src/lib.rs:570`](../ui/dr-ui/src/lib.rs#L570), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:879`](../ui/dr-ui/ui/adjust.slint#L879), [`ui/dr-ui/ui/adjust.slint:949`](../ui/dr-ui/ui/adjust.slint#L949), [`ui/dr-ui/ui/app.slint:2370`](../ui/dr-ui/ui/app.slint#L2370), [`ui/dr-ui/ui/app.slint:707`](../ui/dr-ui/ui/app.slint#L707) | | FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | -| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2812`](../ui/dr-ui/src/develop.rs#L2812), [`ui/dr-ui/src/develop.rs:2822`](../ui/dr-ui/src/develop.rs#L2822), [`ui/dr-ui/src/develop.rs:488`](../ui/dr-ui/src/develop.rs#L488), [`ui/dr-ui/src/lib.rs:1267`](../ui/dr-ui/src/lib.rs#L1267) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:2751`](../ui/dr-ui/src/develop.rs#L2751), [`ui/dr-ui/src/develop.rs:2772`](../ui/dr-ui/src/develop.rs#L2772), [`ui/dr-ui/src/lib.rs:1239`](../ui/dr-ui/src/lib.rs#L1239), [`ui/dr-ui/src/library.rs:1545`](../ui/dr-ui/src/library.rs#L1545), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:392`](../ui/dr-ui/src/library.rs#L392), [`ui/dr-ui/src/library_ui.rs:2576`](../ui/dr-ui/src/library_ui.rs#L2576), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:535`](../ui/dr-ui/src/settings_ui.rs#L535), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1305`](../ui/dr-ui/ui/library.slint#L1305), [`ui/dr-ui/ui/library.slint:831`](../ui/dr-ui/ui/library.slint#L831), [`ui/dr-ui/ui/library.slint:912`](../ui/dr-ui/ui/library.slint#L912), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) | +| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2817`](../ui/dr-ui/src/develop.rs#L2817), [`ui/dr-ui/src/develop.rs:2827`](../ui/dr-ui/src/develop.rs#L2827), [`ui/dr-ui/src/develop.rs:488`](../ui/dr-ui/src/develop.rs#L488), [`ui/dr-ui/src/lib.rs:1267`](../ui/dr-ui/src/lib.rs#L1267) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:2756`](../ui/dr-ui/src/develop.rs#L2756), [`ui/dr-ui/src/develop.rs:2777`](../ui/dr-ui/src/develop.rs#L2777), [`ui/dr-ui/src/lib.rs:1239`](../ui/dr-ui/src/lib.rs#L1239), [`ui/dr-ui/src/library.rs:1545`](../ui/dr-ui/src/library.rs#L1545), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:392`](../ui/dr-ui/src/library.rs#L392), [`ui/dr-ui/src/library_ui.rs:2576`](../ui/dr-ui/src/library_ui.rs#L2576), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:535`](../ui/dr-ui/src/settings_ui.rs#L535), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1305`](../ui/dr-ui/ui/library.slint#L1305), [`ui/dr-ui/ui/library.slint:831`](../ui/dr-ui/ui/library.slint#L831), [`ui/dr-ui/ui/library.slint:912`](../ui/dr-ui/ui/library.slint#L912), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) | | FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2090`](../core/dr-gpu/src/adjust.rs#L2090), [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:2252`](../core/dr-gpu/src/adjust.rs#L2252), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:199`](../core/dr-gpu/tests/capture_sharpen.rs#L199), [`core/dr-gpu/tests/detail_stage.rs:327`](../core/dr-gpu/tests/detail_stage.rs#L327), [`core/dr-gpu/tests/local_contrast.rs:262`](../core/dr-gpu/tests/local_contrast.rs#L262), [`core/dr-gpu/tests/noise_reduction.rs:377`](../core/dr-gpu/tests/noise_reduction.rs#L377), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:427`](../core/dr-pipeline/src/graph.rs#L427), [`core/dr-pipeline/src/graph.rs:457`](../core/dr-pipeline/src/graph.rs#L457), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:658`](../core/dr-pipeline/src/ops/local_contrast.rs#L658), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:2161`](../ui/dr-ui/src/develop.rs#L2161), [`ui/dr-ui/src/develop.rs:2978`](../ui/dr-ui/src/develop.rs#L2978), [`ui/dr-ui/src/develop.rs:3461`](../ui/dr-ui/src/develop.rs#L3461), [`ui/dr-ui/src/develop.rs:3495`](../ui/dr-ui/src/develop.rs#L3495), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:720`](../ui/dr-ui/src/lib.rs#L720) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2090`](../core/dr-gpu/src/adjust.rs#L2090), [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:2252`](../core/dr-gpu/src/adjust.rs#L2252), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:199`](../core/dr-gpu/tests/capture_sharpen.rs#L199), [`core/dr-gpu/tests/detail_stage.rs:327`](../core/dr-gpu/tests/detail_stage.rs#L327), [`core/dr-gpu/tests/local_contrast.rs:262`](../core/dr-gpu/tests/local_contrast.rs#L262), [`core/dr-gpu/tests/noise_reduction.rs:377`](../core/dr-gpu/tests/noise_reduction.rs#L377), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:427`](../core/dr-pipeline/src/graph.rs#L427), [`core/dr-pipeline/src/graph.rs:457`](../core/dr-pipeline/src/graph.rs#L457), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:658`](../core/dr-pipeline/src/ops/local_contrast.rs#L658), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:2161`](../ui/dr-ui/src/develop.rs#L2161), [`ui/dr-ui/src/develop.rs:2983`](../ui/dr-ui/src/develop.rs#L2983), [`ui/dr-ui/src/develop.rs:3466`](../ui/dr-ui/src/develop.rs#L3466), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:720`](../ui/dr-ui/src/lib.rs#L720) | | FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | -| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2206`](../ui/dr-ui/src/develop.rs#L2206), [`ui/dr-ui/src/develop.rs:4563`](../ui/dr-ui/src/develop.rs#L4563), [`ui/dr-ui/src/develop.rs:4595`](../ui/dr-ui/src/develop.rs#L4595), [`ui/dr-ui/src/develop.rs:499`](../ui/dr-ui/src/develop.rs#L499), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1326`](../ui/dr-ui/src/lib.rs#L1326), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | +| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2206`](../ui/dr-ui/src/develop.rs#L2206), [`ui/dr-ui/src/develop.rs:4568`](../ui/dr-ui/src/develop.rs#L4568), [`ui/dr-ui/src/develop.rs:4600`](../ui/dr-ui/src/develop.rs#L4600), [`ui/dr-ui/src/develop.rs:499`](../ui/dr-ui/src/develop.rs#L499), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1326`](../ui/dr-ui/src/lib.rs#L1326), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2400`](../core/dr-gpu/src/adjust.rs#L2400), [`core/dr-pipeline/src/graph.rs:417`](../core/dr-pipeline/src/graph.rs#L417), [`core/dr-pipeline/src/graph.rs:470`](../core/dr-pipeline/src/graph.rs#L470), [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |