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:
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// 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`].
|
||||
///
|
||||
@@ -98,6 +98,13 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// lost by its absence — it exists to make the *grid* page quickly, and the
|
||||
/// 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 {
|
||||
// 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
|
||||
@@ -397,6 +409,73 @@ ALTER TABLE people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;
|
||||
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#"
|
||||
-- TRACES: FR-CULL-8
|
||||
-- A record that face detection has *run* on an image, distinct from what it
|
||||
|
||||
Reference in New Issue
Block a user