Group the frames of one moment, by when they were taken and what they look like
A burst is the commonest thing in a cull and the least interesting: twelve frames of the same gull at 10 fps occupy twelve cells, are scrolled past twelve times, and end with the photographer keeping one. FR-CULL-5 asks for them to collapse to one representative and be judged as a unit. Two signals, because neither alone survives a real library. Time alone groups a whole wedding ceremony -- a photographer working steadily never leaves the gap that would end the run. Similarity alone groups a studio setup shot across two days, which is a project rather than a moment. Together they are specific: adjacent in time *and* looks like the frame before it. Two seconds is the time bound, and the reason is worth recording because the figure looks absurd next to a 10 fps camera. `images.captured_at` is whole seconds -- EXIF's DateTimeOriginal has no sub-second field and SubSecTimeOriginal is optional and widely omitted -- so a burst arrives in the catalog as ten frames sharing one timestamp. Any threshold finer than a second is a threshold on information that is not there. Where the pace really is faster, the similarity bound is what separates the frames. Similarity is a 64-bit difference hash over a 9x8 box-averaged reduction, compared between *adjacent* frames only. Chained rather than anchored on the first frame, because by frame twenty a camera following a bird has nothing in common with frame one while no two neighbours differ by much; the time bound is what stops the chain running away. There is no all-pairs step and there must never be one -- that is what turns a grouping pass into something nobody can afford to run over 50k images. Nothing here ranks a frame. FR-CULL-5 names the failure it is avoiding, which is rejecting the only frame of an important moment because somebody blinked, so there is no sharpness score and no best-of-burst. The representative is the earliest frame -- a fact about the clock, not a judgement about the photograph -- and the user's own choice lives in its own table so that rebuilding the grouping cannot erase it. Same argument `people.ignored` makes one subsystem over: nothing short of remembering a decision survives re-clustering. A newly found burst is recorded *open*. Collapsing on discovery would be tidier, and would also mean a background pass taking photographs off the screen part way through a cull. The pass marks; the user folds. It is a pass rather than a job kind for the reason catalog.md 10.2 gives for face clustering: a burst is a property of a run of frames and has no natural subject_id, so a per-image job would rebuild the world once per photograph. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@
|
|||||||
//! - [`collections`] — the collection tree and membership the UI edits
|
//! - [`collections`] — the collection tree and membership the UI edits
|
||||||
//! - [`keywords`] — the keyword vocabulary and what it is assigned to
|
//! - [`keywords`] — the keyword vocabulary and what it is assigned to
|
||||||
//! - [`faces`] — detected faces, the people they belong to, and who said so
|
//! - [`faces`] — detected faces, the people they belong to, and who said so
|
||||||
|
//! - [`bursts`] — frames that are one moment, grouped so they judge as one
|
||||||
//! - [`jobs`] — the durable background work queue
|
//! - [`jobs`] — the durable background work queue
|
||||||
//! - [`trash`] — soft delete to a folder, then permanent delete
|
//! - [`trash`] — soft delete to a folder, then permanent delete
|
||||||
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
|
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
|
||||||
@@ -33,6 +34,7 @@ use std::path::Path;
|
|||||||
use dr_types::{Availability, ImageId};
|
use dr_types::{Availability, ImageId};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
pub mod bursts;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod collections;
|
pub mod collections;
|
||||||
pub mod dedup;
|
pub mod dedup;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
|||||||
use crate::error::CatalogError;
|
use crate::error::CatalogError;
|
||||||
|
|
||||||
/// Schema version this build writes and understands.
|
/// Schema version this build writes and understands.
|
||||||
pub const SCHEMA_VERSION: i64 = 10;
|
pub const SCHEMA_VERSION: i64 = 11;
|
||||||
|
|
||||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||||
///
|
///
|
||||||
@@ -98,6 +98,13 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
|||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if from < 11 {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute_batch(V11)?;
|
||||||
|
tx.pragma_update(None, "user_version", 11)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(from)
|
Ok(from)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +212,11 @@ pub fn v1_for_attached(schema_name: &str) -> String {
|
|||||||
/// creating it over there would fail on columns that are not there. Nothing is
|
/// creating it over there would fail on columns that are not there. Nothing is
|
||||||
/// lost by its absence — it exists to make the *grid* page quickly, and the
|
/// lost by its absence — it exists to make the *grid* page quickly, and the
|
||||||
/// grid never reads across an attachment.
|
/// grid never reads across an attachment.
|
||||||
|
///
|
||||||
|
/// V11 is excluded on the same grounds and for the plainer reason that a merge
|
||||||
|
/// has nothing to do with it: burst grouping is rebuilt locally from local
|
||||||
|
/// signatures, and no code reads a remote catalog's `burst_*` tables. Its
|
||||||
|
/// `ALTER TABLE` would fail here anyway, being unqualifiable by the rewrite.
|
||||||
pub fn for_attached(schema_name: &str) -> String {
|
pub fn for_attached(schema_name: &str) -> String {
|
||||||
// V10 is `ALTER TABLE`, which the textual rewrite cannot qualify, so its
|
// V10 is `ALTER TABLE`, which the textual rewrite cannot qualify, so its
|
||||||
// columns are spelled out. A remote genuinely older than V10 is a real
|
// columns are spelled out. A remote genuinely older than V10 is a real
|
||||||
@@ -397,6 +409,73 @@ ALTER TABLE people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;
|
|||||||
ALTER TABLE faces ADD COLUMN crop BLOB;
|
ALTER TABLE faces ADD COLUMN crop BLOB;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// TRACES: FR-CULL-5
|
||||||
|
/// Burst grouping: which frames are one moment, and which one stands for it.
|
||||||
|
///
|
||||||
|
/// The reasoning behind the grouping itself is in [`crate::bursts`]; what
|
||||||
|
/// belongs here is why it is stored in three pieces rather than one.
|
||||||
|
///
|
||||||
|
/// **`images.perceptual_hash` is a column, not a table**, for the same reason
|
||||||
|
/// `content_hash` is: it is one number per image, NULL until something has had
|
||||||
|
/// the pixels in hand, and every query that wants it is already reading the
|
||||||
|
/// image row. It is local derived state — a rebuilt catalog recomputes it from
|
||||||
|
/// thumbnails — which is also why it is absent from [`for_attached`], alongside
|
||||||
|
/// the shadowing and trashing columns V2 through V5 add.
|
||||||
|
///
|
||||||
|
/// **`burst_members` is rewritten whole by every pass.** No id of its own: the
|
||||||
|
/// group is named by the image id of its earliest frame, so a burst that has not
|
||||||
|
/// changed keeps its name across a regroup and the interface can remember that
|
||||||
|
/// this one is open. There is no `bursts` table to go with it because a group
|
||||||
|
/// has no properties beyond its members — inventing a row for it would create an
|
||||||
|
/// identity that survives the grouping being rebuilt, which is precisely what
|
||||||
|
/// must not happen.
|
||||||
|
///
|
||||||
|
/// **`burst_pick` is the one thing here that is not derived**, and it is a
|
||||||
|
/// separate table so that rewriting the grouping cannot erase it. A
|
||||||
|
/// representative stored on `burst_members` would be forgotten every time a
|
||||||
|
/// frame arrived; the user would be asked the same question after every scan.
|
||||||
|
/// The same argument `people.ignored` makes in V10, one subsystem over.
|
||||||
|
///
|
||||||
|
/// **`burst_expanded` is view state in the catalog**, which is unusual enough to
|
||||||
|
/// justify. The grid is a window over an ordered query — `LIMIT n OFFSET k` —
|
||||||
|
/// so what a collapsed burst hides has to be decided by the query, or the row
|
||||||
|
/// count stops agreeing with the scrollbar and the ordinals a scrub resolves to.
|
||||||
|
/// Once SQL has to see it, this is where it lives. Nothing else reads it, and it
|
||||||
|
/// is emptied of stale groups by every pass.
|
||||||
|
const V11: &str = r#"
|
||||||
|
-- A 64-bit perceptual signature. Local derived state: NULL until something has
|
||||||
|
-- decoded the image, recomputed from thumbnails if the catalog is rebuilt, and
|
||||||
|
-- comparable only to signatures produced by the same build (`bursts`).
|
||||||
|
ALTER TABLE images ADD COLUMN perceptual_hash INTEGER;
|
||||||
|
|
||||||
|
CREATE TABLE burst_members (
|
||||||
|
-- One burst at most per image: a frame belongs to the moment it was taken
|
||||||
|
-- in, and nothing else.
|
||||||
|
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
|
||||||
|
-- The image id of the burst's earliest frame. Not a foreign key by
|
||||||
|
-- accident: the leader is itself a member, so this genuinely references
|
||||||
|
-- images(id), and cascading its deletion is right.
|
||||||
|
burst_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||||
|
-- The frame the group collapses to. Exactly one per burst.
|
||||||
|
representative INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
-- Counting a burst's frames and listing them are what the grid asks for, once
|
||||||
|
-- per window; without this both are a scan of every grouped frame in the
|
||||||
|
-- library.
|
||||||
|
CREATE INDEX burst_members_burst ON burst_members(burst_id);
|
||||||
|
|
||||||
|
-- The user's own choice of representative. User data, never rewritten by a
|
||||||
|
-- grouping pass -- see the module doc above.
|
||||||
|
CREATE TABLE burst_pick (
|
||||||
|
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Bursts the grid is currently showing in full.
|
||||||
|
CREATE TABLE burst_expanded (
|
||||||
|
burst_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
const V9: &str = r#"
|
const V9: &str = r#"
|
||||||
-- TRACES: FR-CULL-8
|
-- TRACES: FR-CULL-8
|
||||||
-- A record that face detection has *run* on an image, distinct from what it
|
-- A record that face detection has *run* on an image, distinct from what it
|
||||||
|
|||||||
@@ -810,6 +810,60 @@ confidence is unavailable. It does not fall back to an untuned default dressed u
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 10a. Bursts and near-duplicates
|
||||||
|
|
||||||
|
Specified by FR-CULL-5, implemented in `dr_catalog::bursts` (a v11 migration) with the pass that
|
||||||
|
feeds it in `dr_ui::bursts`.
|
||||||
|
|
||||||
|
A burst is a run of frames that are **adjacent in time and look like the frame before them**. Both
|
||||||
|
halves are load-bearing. Time alone groups a whole wedding ceremony, because a photographer working
|
||||||
|
steadily never leaves the gap that would end the run. Similarity alone groups a studio setup shot
|
||||||
|
across two days, which is a project rather than a moment. The bounds are two seconds and eight bits
|
||||||
|
of a 64-bit difference hash, and the reasoning for each figure is in the module.
|
||||||
|
|
||||||
|
**Two seconds, for a burst that fires ten frames in one.** `images.captured_at` is whole seconds:
|
||||||
|
EXIF's `DateTimeOriginal` has no sub-second field, and `SubSecTimeOriginal` is optional and widely
|
||||||
|
omitted. Ten frames of a burst therefore arrive sharing a timestamp, and any threshold finer than a
|
||||||
|
second is a threshold on information the catalog does not have. Where the pace really is faster than
|
||||||
|
two seconds, the similarity bound is what separates the frames.
|
||||||
|
|
||||||
|
**The signal is a perceptual hash of the thumbnail, not of the original.** `images.perceptual_hash`
|
||||||
|
is filled from the 256px thumbnails §7 already stores — vastly more resolution than a 9×8 reduction
|
||||||
|
uses — so a library that has been browsed has already paid for its signatures and no RAW is decoded
|
||||||
|
for this. The consequence is stated rather than hidden: an image with no thumbnail gets no
|
||||||
|
signature, and a frame with no signature never joins a burst. It is picked up by the next pass.
|
||||||
|
|
||||||
|
**It is a pass, not a job kind**, for exactly the reason §10.2 gives for face clustering: a burst is
|
||||||
|
a property of a *run* of frames and has no natural `subject_id`, so a per-image job would rebuild
|
||||||
|
the world once per photograph. It runs when the thumbnail sweep finishes, which is the first moment
|
||||||
|
the signatures can all be computed.
|
||||||
|
|
||||||
|
**A newly found burst arrives open.** The pass marks frames; it never takes them off the screen.
|
||||||
|
Collapsing on discovery would be tidier and would also mean a background pass removing photographs
|
||||||
|
from under someone part way through a cull. Folding a burst up is the user's act, it is remembered
|
||||||
|
(`burst_expanded`), and a burst that is already known keeps whatever state it is in — so the pass
|
||||||
|
that follows the next import does not spring open a morning's work.
|
||||||
|
|
||||||
|
**Nothing here ranks a frame.** The representative of a collapsed burst is its *earliest* frame,
|
||||||
|
which is a fact about the clock rather than a judgement about the photograph. FR-CULL-5 names the
|
||||||
|
failure this avoids — rejecting the only frame of an important moment because someone blinked — and
|
||||||
|
the only judgement in the subsystem is the user's own choice of representative, which lives in its
|
||||||
|
own table (`burst_pick`) so that rebuilding the grouping cannot erase it. Same argument as
|
||||||
|
`people.ignored` in §10.
|
||||||
|
|
||||||
|
**What the collapse costs the grid.** Which rows a collapsed burst hides has to be decided by the
|
||||||
|
query rather than by the cells, because the grid is a window (`LIMIT n OFFSET k`) and the frames it
|
||||||
|
hides are mostly not loaded. So the predicate joins `VISIBLE` in every query that lists or counts
|
||||||
|
cells, under the same discipline: present in four places of five, the header's count, the
|
||||||
|
scrollbar, the shift-click range and the scrub's ordinal stop describing the same list.
|
||||||
|
|
||||||
|
**What this does not settle.** Bursts are local: the tables ride along in the uploaded catalog
|
||||||
|
snapshot and nothing on the far side reads them, so a second device rebuilds its own grouping from
|
||||||
|
its own signatures. Making `burst_pick` cross-device is a merge question of the same shape as §8.4's
|
||||||
|
and is not answered here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 11. Requirements touched
|
## 11. Requirements touched
|
||||||
|
|
||||||
| ID | How this document addresses it |
|
| ID | How this document addresses it |
|
||||||
@@ -828,6 +882,7 @@ confidence is unavailable. It does not fall back to an untuned default dressed u
|
|||||||
| NFR-P1 | §3.1 one stat per directory, not per file |
|
| NFR-P1 | §3.1 one stat per directory, not per file |
|
||||||
| NFR-P3 | §7.1 on-demand generation |
|
| NFR-P3 | §7.1 on-demand generation |
|
||||||
| NFR-ARCH-2 | §6.3 priority classes shared with the GPU scheduler |
|
| NFR-ARCH-2 | §6.3 priority classes shared with the GPU scheduler |
|
||||||
|
| FR-CULL-5 | §10a burst grouping: capture-time proximity and image similarity, collapse without selection |
|
||||||
| NFR-ARCH-3 | §4.3 query cancellation, §6 job cancellation |
|
| NFR-ARCH-3 | §4.3 query cancellation, §6 job cancellation |
|
||||||
| NFR-RES-4 | §7.3 LRU cap, eviction order |
|
| NFR-RES-4 | §7.3 LRU cap, eviction order |
|
||||||
| FR-CULL-8 | §10.1 `faces` schema, §6.1 `DetectFaces` job kind on the proxy tier |
|
| FR-CULL-8 | §10.1 `faces` schema, §6.1 `DetectFaces` job kind on the proxy tier |
|
||||||
|
|||||||
Reference in New Issue
Block a user