Skip to content

gui

The FastAPI app behind deeperfly gui: the browser workspace's server half.

A thin shell over deeperfly.project: the launcher (/) opens a project or a collection of them (ADR-0081), or sets one up over a directory of videos (deeperfly.gui.setup), and the tabbed workspace (/workspace) edits the skeleton, lists the projects, calibrates and labels. The workspace names what it opened by the paths it was given, repeatable directory= parameters, and every per-project route names its project by its folder. The front end computes no geometry (ADR-0022). build_app serves serve and the TestClient tests.

RenameViewIn

Bases: BaseModel

Rename a view in every project of a collection that has it.

Source code in src/deeperfly/gui/app.py
class RenameViewIn(BaseModel):
    """Rename a view in every project of a collection that has it."""

    directory: list[str]
    old: str
    new: str

SetSkeletonIn

Bases: BaseModel

Give every project of a collection this custom skeleton TOML.

directory is the collection's paths, as the workspace was opened with.

Source code in src/deeperfly/gui/app.py
class SetSkeletonIn(BaseModel):
    """Give every project of a collection this custom skeleton TOML.

    `directory` is the collection's paths, as the workspace was opened with.
    """

    directory: list[str]
    toml: str

SkeletonTomlIn

Bases: BaseModel

A custom skeleton's [skeleton] TOML text, for validation.

Source code in src/deeperfly/gui/app.py
class SkeletonTomlIn(BaseModel):
    """A custom skeleton's `[skeleton]` TOML text, for validation."""

    toml: str

build_app

build_app(
    on_quit: Callable[[], None] | None = None,
) -> FastAPI

Build the GUI's FastAPI app (routes and static files), no server attached.

on_quit asks the host to stop, so the Quit button can end the process; without one (the tests) POST /api/quit closes the sessions and says nothing stopped.

Source code in src/deeperfly/gui/app.py
def build_app(on_quit: Callable[[], None] | None = None) -> FastAPI:
    """Build the GUI's FastAPI app (routes and static files), no server attached.

    `on_quit` asks the host to stop, so the Quit button can end the process; without
    one (the tests) `POST /api/quit` closes the sessions and says nothing stopped.
    """
    app = FastAPI(title="deeperfly")
    sessions = SessionStore()

    @app.middleware("http")
    async def revalidate(request: Request, call_next):
        """Make the browser revalidate every response before reusing it.

        Without a `Cache-Control` header a browser caches the pages and ES modules
        heuristically, so after an update it can mix stale modules with new ones
        and render an empty page on a port it has used before. `no-cache` still
        lets it reuse an unchanged file after a 304; a response that sets its own
        header (the frames' `no-store`) keeps it.
        """
        response = await call_next(request)
        response.headers.setdefault("Cache-Control", "no-cache")
        return response

    @app.get("/api/skeletons")
    def skeletons() -> list[dict]:
        """The packaged skeleton presets and their point counts."""
        return [
            {"name": name, "n_points": load_skeleton(name).n_points}
            for name in skeleton_presets()
        ]

    @app.get("/api/skeletons/{name}/source")
    def skeleton_source(name: str) -> dict:
        """A preset's raw TOML, to seed the custom-skeleton editor."""
        try:
            return {"toml": skeleton_source_text(name)}
        except FileNotFoundError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc

    @app.post("/api/skeletons/validate")
    def validate_skeleton(body: SkeletonTomlIn) -> dict:
        """Parse and check custom skeleton TOML: its counts and drawable geometry."""
        try:
            skel = Skeleton.from_toml_text(body.toml)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        return _skeleton_geometry(skel)

    @app.get("/api/classify")
    def classify_path(path: str) -> dict:
        """What a path is to the launcher, as [classify][deeperfly.project.classify].

        `kind` is `"project"`, `"collection"`, `"videos"` (video files, no project
        yet), `"empty"` or `"missing"`.
        """
        return {"kind": classify(path)}

    @app.get("/api/browse")
    def browse(path: str | None = None) -> dict:
        """List a server directory so the front-end can offer a file/dir picker."""
        base = (Path(path).expanduser() if path else Path.home()).resolve()
        if not base.is_dir():
            raise HTTPException(status_code=404, detail=f"not a directory: {base}")
        dirs, files = [], []
        try:
            entries = sorted(base.iterdir(), key=lambda p: p.name.lower())
        except OSError as exc:
            raise HTTPException(status_code=403, detail=str(exc)) from exc
        for p in entries:
            if p.name.startswith("."):
                continue
            try:
                is_dir = p.is_dir()
            except OSError:
                continue
            (dirs if is_dir else files).append({"name": p.name, "path": str(p)})
        parent = str(base.parent) if base.parent != base else None
        return {"path": str(base), "parent": parent, "dirs": dirs, "files": files}

    @app.get("/api/open")
    def open_paths(directory: Annotated[list[str], Query()]) -> dict:
        """The collection some paths stand for: each a project or a directory of them.

        Repeat `directory` for several paths; one project is a collection of one.
        """
        return _collection_state(open_collection(directory), directory)

    @app.get("/api/skeleton")
    def get_skeleton(project: str) -> dict:
        """A project's current skeleton: its verbatim TOML and its counts."""
        proj = open_project(project)
        try:
            skel = proj.skeleton()
            text = proj.skeleton_source().read_text()
        except (OSError, ValueError) as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        return {
            "toml": text,
            "name": skel.name,
            "n_points": skel.n_points,
            "n_edges": skel.n_edges,
            "n_symmetries": skel.n_symmetries,
        }

    @app.post("/api/skeleton")
    def put_skeleton(body: SetSkeletonIn) -> dict:
        """Give every project of the collection the skeleton, remapping its labels.

        Each project is checked and written on its own, in order; one that refuses
        (a labeled keypoint dropped) stops the loop, and the message names the
        projects already changed. Runs are not rewritten: one made against another
        set of keypoints is then skipped by the merge of detections.
        """
        try:
            Skeleton.from_toml_text(body.toml)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        done: list[str] = []
        # Read and rewritten under the sessions' lock, so no edit in flight autosaves
        # labels of the old skeleton over the rewritten sidecar, and no manifest
        # written while this waited is overwritten from an older copy (ADR-0090).
        with sessions.lock:
            collection = open_collection(body.directory)
            for proj, name in zip(collection.projects, collection.names, strict=True):
                try:
                    set_skeleton(proj, body.toml)
                except (OSError, ValueError) as exc:
                    changed = f" (already changed: {', '.join(done)})" if done else ""
                    raise HTTPException(
                        status_code=400, detail=f"{name}: {exc}{changed}"
                    ) from exc
                finally:
                    sessions.evict(proj.folder)
                done.append(name)
            return _collection_state(open_collection(body.directory), body.directory)

    @app.post("/api/view/rename")
    def rename_view_everywhere(body: RenameViewIn) -> dict:
        """Rename a view in each project of the collection that has it.

        Each project renames it across its manifest, calibrations and labels. Runs
        keep the names they were written with, so the merge of detections then
        skips them. Every project is checked before the first is written, so a
        clash anywhere changes nothing.
        """
        new = body.new.strip()
        # Checked and rewritten under the sessions' lock, as the skeleton is.
        with sessions.lock:
            collection = open_collection(body.directory)
            holding = [p for p in collection.projects if body.old in p.views]
            if not holding:
                raise HTTPException(
                    status_code=400,
                    detail=f"no project of these has a view {body.old!r}",
                )
            if new != body.old:
                if not new:
                    raise HTTPException(
                        status_code=400, detail="a view name must not be empty"
                    )
                clash = [str(p.folder) for p in holding if new in p.views]
                if clash:
                    raise HTTPException(
                        status_code=400,
                        detail=f"a view {new!r} exists in {', '.join(clash)}",
                    )
            for proj in holding:
                try:
                    rename_view(proj, body.old, new)
                except (OSError, ValueError) as exc:
                    raise HTTPException(status_code=400, detail=str(exc)) from exc
                finally:
                    sessions.evict(proj.folder)
            return _collection_state(open_collection(body.directory), body.directory)

    @app.get("/")
    def index() -> FileResponse:
        """The launcher: open a project or a collection, or set one up."""
        return FileResponse(_WEB / "index.html")

    @app.get("/workspace")
    def workspace() -> FileResponse:
        """The workspace -- frames beside a panel of sections -- for a collection."""
        return FileResponse(_WEB / "workspace.html")

    @app.get("/api/whoami")
    def whoami() -> dict:
        """Who serves this port, for a second `deeperfly gui` to find it (ADR-0123)."""
        return {"app": "deeperfly", "pid": os.getpid()}

    @app.get("/api/open-projects")
    def open_projects(
        exclude: Annotated[list[str] | None, Query()] = None,
    ) -> list[str]:
        """The folders of the projects open on this server, but those in `exclude`.

        Quit stops the whole server, so it names the projects another tab has open.
        """
        skip = {Path(p).resolve() for p in exclude or []}
        return [str(f) for f in sessions.folders() if f not in skip]

    @app.post("/api/quit")
    def quit_server() -> dict:
        """Close every open project and stop the server.

        Unsaved labels are not flushed: each edit is already in the recovery sidecar,
        so the operator decides on the next open. `stopping` says whether the process
        is going down.
        """
        sessions.close_all()
        if on_quit is None:
            return {"stopping": False, "detail": "no server to stop"}
        on_quit()
        return {"stopping": True}

    app.include_router(build_labeling_router(sessions))
    app.include_router(build_runs_router(sessions))
    app.include_router(build_calibration_router(sessions))
    app.include_router(build_setup_router())
    app.mount("/static", StaticFiles(directory=_WEB / "static"), name="static")
    return app

open_collection

open_collection(paths: Sequence[str]) -> Collection

The collection some paths stand for, a 404 naming what is wrong otherwise.

Source code in src/deeperfly/gui/app.py
def open_collection(paths: Sequence[str]) -> Collection:
    """The collection some paths stand for, a 404 naming what is wrong otherwise."""
    try:
        return Collection.open(list(paths))
    except (FileNotFoundError, ValueError) as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

project_state

project_state(project: Project, name: str) -> dict

One project as the workspace reads it: its views, videos and progress.

name is the project's name in its collection. The labeled-frame counts come from the stored columns alone (n_complete counts the frames ADR-0102 calls complete), so they stay cheap on every read; a video that cannot be found is listed empty, with the reason in error. unsaved says the labels come from the recovery sidecar, i.e. edits not yet saved to labels.h5.

Source code in src/deeperfly/gui/app.py
def project_state(project: Project, name: str) -> dict:
    """One project as the workspace reads it: its views, videos and progress.

    `name` is the project's name in its collection. The labeled-frame counts come
    from the stored columns alone (`n_complete` counts the frames ADR-0102 calls
    complete), so they stay cheap on every read; a video that cannot be found is
    listed empty, with the reason in `error`. `unsaved` says the labels come from the
    recovery sidecar, i.e. edits not yet saved to `labels.h5`.
    """
    errors: list[str] = []
    videos: dict[str, list[str]] = {}
    for video in project.manifest.videos:
        try:
            videos[video] = [str(p) for p in project.video_paths(video)]
        except (OSError, ValueError) as exc:
            videos[video] = []
            errors.append(str(exc))
    try:
        skel = project.skeleton()
        skeleton = {"name": skel.name, "n_points": skel.n_points}
    except (OSError, ValueError) as exc:
        skeleton = None
        errors.append(str(exc))
    n_labeled, n_complete, unsaved = label_summary(project.folder)
    return {
        "folder": str(project.folder),
        "name": name,
        "views": project.views,
        "mirrored": list(project.manifest.mirrored),
        "videos": videos,
        "animals": project.animals,
        "skeleton": skeleton,
        "has_rig": project.manifest.active_calibration is not None,
        "n_labeled": n_labeled,
        "n_complete": n_complete,
        "unsaved": unsaved,
        "error": "; ".join(errors) or None,
    }

The labeling half of the GUI: multi-view frame annotation over one project.

The workspace follows Lightning Pose 3D's (ADR-0021): a canvas per source video with every view that reads it drawn on it, and the aid that triangulates two labeled views and reprojects into the others (an epipolar line when one is labeled). An EditorSession per project folder holds the mutable state (ADR-0023): open readers, the labels in memory, the rig, and an undo stack, each edit also written to the crash-recovery sidecar; /save flushes to labels.h5 and /discard drops the sidecar and reloads. Every route names its project by its folder, project=<folder> (ADR-0081), and every one but /frame and the two suggest routes runs under the session store's lock (ADR-0090, ADR-0111).

CHECK_WORKERS module-attribute

CHECK_WORKERS = 8

How many projects /checks/all checks at once.

HELD_PROJECTS module-attribute

HELD_PROJECTS = 2

How many projects keep their videos' decoders open between frame reads (ADR-0092).

A held decoder costs 90-230 MB per video, and a session lives until the GUI quits, so only the projects whose frames were read last keep theirs; the others reopen on demand.

READ_CHUNK module-attribute

READ_CHUNK = 256

How many frames of a run the suggest scoring reads per hold of its lock (ADR-0111).

A frame step's /points waits on the detections' lock for as long as a read holds it, and reading a sparse run of ten thousand frames takes a second; a chunk of this many takes about 25 ms.

STAMPED_FILES module-attribute

STAMPED_FILES = 16

A video of at most this many files is checked for replacement on every read.

An image sequence has a file per frame, and statting them all would cost more than the decode.

SUGGEST_METHODS module-attribute

SUGGEST_METHODS = ('merged', 'run', 'sample')

What /suggest and /suggest/all compute from.

UNDO_DEPTH module-attribute

UNDO_DEPTH = 200

How many edits back one session's undo stack reaches.

AbsentIn

Bases: BaseModel

Declare a keypoint absent (or present), in one frame or in every frame.

Source code in src/deeperfly/gui/labeling.py
class AbsentIn(BaseModel):
    """Declare a keypoint absent (or present), in one frame or in every frame."""

    project: str
    frame: int
    keypoint: int
    value: bool = True
    scope: Literal["frame", "every_frame"] = "frame"
    instance: int = 0

CellEdit

Bases: BaseModel

One cell's edit in a /cells batch: place, clear, exclude or include it.

instance is the animal the cell belongs to, where a batch reaches several; a cell without one is the batch's.

Source code in src/deeperfly/gui/labeling.py
class CellEdit(BaseModel):
    """One cell's edit in a `/cells` batch: place, clear, exclude or include it.

    `instance` is the animal the cell belongs to, where a batch reaches several; a
    cell without one is the batch's.
    """

    view: int = Field(ge=0)
    keypoint: int = Field(ge=0)
    op: Literal["place", "clear", "exclude", "include"]
    xy: tuple[float, float] | None = None
    instance: int | None = None

CellsIn

Bases: BaseModel

Edit cells of a frame, each in its own view, as one undoable step.

Source code in src/deeperfly/gui/labeling.py
class CellsIn(BaseModel):
    """Edit cells of a frame, each in its own view, as one undoable step."""

    project: str
    frame: int
    cells: list[CellEdit]
    instance: int = 0

EditorSession dataclass

Open project, readers, labels and geometry for one project being labeled.

Held across requests so a scrub reopens no video; built lazily by sessions and closed on eviction. skeleton is the project's, read once: a skeleton edit evicts the session. detections are the project's runs merged per frame (ADR-0083), which every request revalidates, so a run written or swapped since is read without a reopen.

Source code in src/deeperfly/gui/labeling.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
@dataclass
class EditorSession:
    """Open project, readers, labels and geometry for one project being labeled.

    Held across requests so a scrub reopens no video; built lazily by
    [sessions][deeperfly.gui.labeling.SessionStore.get] and closed on eviction.
    `skeleton` is the project's, read once: a skeleton edit evicts the session.
    `detections` are the project's runs merged per frame (ADR-0083), which every
    request revalidates, so a run written or swapped since is read without a reopen.
    """

    project: Project
    skeleton: Skeleton
    files: LabelFiles
    labels: Labels
    n_frames: int
    detections: Detections
    recovered: bool = False
    # What `labels.h5` holds, to tell the frames an edit left unsaved (ADR-0120).
    saved: Labels | None = None
    # Why the last autosave failed, `None` once one succeeds (the edit is kept).
    autosave_error: str | None = None
    _undo: list[UndoStep] = field(default_factory=list)
    _redo: list[UndoStep] = field(default_factory=list)
    # A step's id names the state of the labels it leaves. `_floor` names the state
    # under the undo stack: 0 at open, then the last step `UNDO_DEPTH` pushed off its
    # bottom. `_saved_top` names the state `labels.h5` holds, `None` for one no undo
    # reaches: labels recovered from the sidecar.
    _ids: Iterator[int] = field(default_factory=lambda: itertools.count(1))
    _floor: int = 0
    _saved_top: int | None = 0
    # The `(frame, instance)` slots the operator dropped the default-pose instance into
    # (`/seed`): a display choice, never written and never undone.
    seeded: set[tuple[int, int]] = field(default_factory=set)
    # One held-open reader per source video, keyed by its `videos` name, with the
    # files it reads and their `_file_stamp` when it opened (ADR-0092).
    _readers: dict[str, tuple[FrameReader, list[Path], tuple]] = field(
        default_factory=dict
    )
    # Guards opening, swapping and closing them, since `/frame` runs outside the
    # store's lock; each reader serializes its own reads.
    _readers_lock: threading.Lock = field(default_factory=threading.Lock)
    _closed: bool = False
    _frame_sizes: list[tuple[int, int]] | None = None
    _fps: tuple[float | None] | None = None
    _rig: Rig | None = None
    _rig_error: str | None = None
    _rig_resolved: bool = False
    # The thread compiling the rig's kernels, started when it first resolves.
    _warming: threading.Thread | None = None
    # The project's per-edge bone lengths (see `bone_reference`), keyed by what they
    # were measured from, so they are recomputed when that changes.
    _bone_ref: tuple[Any, tuple] | None = None
    # Each view's measured precision over the detections (see `view_precision`), keyed
    # as the bone lengths are.
    _view_precision: tuple[Any, np.ndarray | None] | None = None

    def __post_init__(self) -> None:
        """Recovered labels have no saved step to come back to."""
        if self.recovered:
            self._saved_top = None
        if self.saved is None:
            self.saved = (
                self.files.load() if self.recovered else copy.deepcopy(self.labels)
            )

    @property
    def view_names(self) -> list[str]:
        """The project's view names, in view order."""
        return self.project.views

    @property
    def sources(self) -> list[tuple[str, list[int]]]:
        """The videos the views read, each with the views that read it, in view order.

        The editor draws a panel per video, so shared pixels are decoded, sent and held
        once.
        """
        layout = self.project.layout
        out: dict[str, list[int]] = {}
        for v, view in enumerate(self.view_names):
            out.setdefault(layout.video_of(view), []).append(v)
        return list(out.items())

    @property
    def frame_sizes(self) -> list[tuple[int, int]]:
        """Per view, the `(height, width)` of the video it reads, probed once.

        Never through the display readers: reading frame 0 would pull their decoders
        away from the frames on screen.
        """
        if self._frame_sizes is None:
            sizes = self.project.frame_sizes()
            self._frame_sizes = [sizes[view] for view in self.view_names]
        return self._frame_sizes

    @property
    def fps(self) -> float | None:
        """The frame rate the first video declares, `None` for an image sequence.

        Probed once through a reader of its own, so the display readers keep their
        place.
        """
        if self._fps is None:
            with open_reader(self.project.video_paths(self.sources[0][0])) as reader:
                rate = reader.fps()
            self._fps = (float(rate) if rate else None,)
        return self._fps[0]

    def frame(self, source: str, index: int) -> np.ndarray:
        """One uint8 display frame: video `source` at `index`, whole.

        `(H, W)` for footage that carries no color, else `(H, W, 3)`. Every view of the
        video is drawn on it, in its own pixels, where its labels and calibration are
        (ADR-0053); nothing is flipped.

        Raises:
            SessionClosed: If the session closed before or during the read.
        """
        reader = self._display_reader(source)
        try:
            return reader[index]
        except ValueError:
            if self._closed:  # the reader was closed under the read
                raise SessionClosed(str(self.project.folder)) from None
            with self._readers_lock:
                held = self._readers.get(source)
            if held is None or held[0] is reader:
                raise
        # Another read found the files replaced and reopened them under this one.
        return self._display_reader(source)[index]

    def _display_reader(self, source: str) -> FrameReader:
        """The held-open reader for one video, reopened when its files were replaced.

        A held container keeps reading the file it opened, so a video re-encoded or
        realigned in place would otherwise be served stale until the GUI quits.
        """
        with self._readers_lock:
            if self._closed:
                raise SessionClosed(str(self.project.folder))
            held = self._readers.get(source)
            if held is not None:
                reader, paths, stamp = held
                if _file_stamp(paths) == stamp:
                    return reader
                reader.close()
                self._frame_sizes = None
            paths = self.project.video_paths(source)
            reader = open_reader(paths).hold_open(gray_ok=True)
            self._readers[source] = (reader, paths, _file_stamp(paths))
            return reader

    def release_readers(self) -> None:
        """Let go of every display reader's decoder; each reopens on its next read."""
        with self._readers_lock:
            readers = [reader for reader, _, _ in self._readers.values()]
        for reader in readers:
            reader.release()

    def point_name(self, keypoint: int) -> str:
        """The skeleton's name for a keypoint index, for undo descriptions."""
        return self.skeleton.point_names[keypoint]

    @property
    def can_undo(self) -> bool:
        """Whether any edit is on the undo stack."""
        return bool(self._undo)

    @property
    def can_redo(self) -> bool:
        """Whether any undone edit is waiting to be redone."""
        return bool(self._redo)

    @property
    def undo_label(self) -> str | None:
        """What an undo would reverse, `None` on an empty stack."""
        return self._undo[-1].label if self._undo else None

    @property
    def redo_label(self) -> str | None:
        """What a redo would apply again, `None` on an empty stack."""
        return self._redo[-1].label if self._redo else None

    @property
    def _top(self) -> int:
        """The id of the state the labels are in: the stack's top, else its floor."""
        return self._undo[-1].id if self._undo else self._floor

    def record(
        self,
        label: str,
        frame: int | None,
        snapshot: Callable[[], Callable[[], None]],
        restore: Callable[[], None],
    ) -> None:
        """Push one edit onto the undo stack, oldest dropped past the cap.

        Called after a route's mutation succeeded, with the `snapshot` of the slice it
        touches and the `restore` taken just before; a new edit empties the redo stack
        and leaves the labels unsaved, whatever they held.
        """
        self._undo.append(UndoStep(next(self._ids), label, frame, restore, snapshot))
        if len(self._undo) > UNDO_DEPTH:
            self._floor = self._undo[-UNDO_DEPTH - 1].id
            del self._undo[:-UNDO_DEPTH]
        self._redo.clear()
        self._sync_dirty()

    def undo(self) -> UndoStep | None:
        """Reverse the most recent recorded edit, or `None` if there is none.

        The labels are clean again once it returns to the saved step, and dirty
        otherwise: an undo writes the sidecar and not `labels.h5`.
        """
        return self._step(self._undo, self._redo)

    def redo(self) -> UndoStep | None:
        """Apply the most recently undone edit again, or `None` if there is none."""
        return self._step(self._redo, self._undo)

    def _step(self, source: list[UndoStep], target: list[UndoStep]) -> UndoStep | None:
        """Pop a step off `source`, apply it, and push its opposite onto `target`."""
        if not source:
            return None
        step = source.pop()
        target.append(replace(step, restore=step.snapshot()))
        step.restore()
        self._sync_dirty()
        self.autosave()
        return step

    def _sync_dirty(self) -> None:
        """Mark the labels unsaved, unless they are back at the state `labels.h5` holds.

        Away from the saved step means unsaved, whatever the labels add up to
        (ADR-0110): an edit and its undo cancel, an edit and its inverse edit do not.
        """
        self.labels.dirty = self._top != self._saved_top

    def mark_saved(self) -> None:
        """Note that `labels.h5` holds the labels as they are now, undo history kept."""
        self._saved_top = self._top
        self.saved = copy.deepcopy(self.labels)
        self.recovered = False
        self.autosave_error = None

    def discard_unsaved(self) -> None:
        """Set every edit since the last save aside, and the undo history with them.

        They are kept for `/restore-discarded` (ADR-0120); the undo stack is not.
        """
        self.labels = self.files.discard_unsaved(self.labels)
        self._undo.clear()
        self._redo.clear()
        self.mark_saved()

    def unsaved_frames(self) -> list[int]:
        """The frames whose labels differ from `labels.h5`'s, sorted (ADR-0120).

        Empty at the saved step. Away from it, an edit and its inverse edit leave the
        labels unsaved (ADR-0110) with no frame to show for it.
        """
        if not self.labels.dirty or self.saved is None:
            return []
        return self.labels.changed_frames(self.saved)

    def autosave(self) -> None:
        """Bring the crash-recovery sidecar up to date, after each edit.

        Unsaved labels are flushed to it and stay dirty, since `labels.h5` is still
        behind; `/save` clears it. Labels back at the saved step have nothing to
        recover, so the sidecar goes and a reopen finds none (ADR-0110). A write or a
        removal that fails keeps the edit in memory and sets `autosave_error` instead
        of failing the request: the operator can still save by hand.
        """
        dirty = self.labels.dirty
        try:
            if dirty:
                self.files.autosave(self.labels)
            else:
                self.files.recovery_path.unlink(missing_ok=True)
        except OSError as exc:
            verb = "write" if dirty else "remove"
            self.autosave_error = f"cannot {verb} {self.files.recovery_path}: {exc}"
        else:
            self.autosave_error = None

    def rig(self) -> Rig | None:
        """The active calibration applied to the project's views, `None` without one.

        Also `None` when the active calibration is missing or disagrees with the
        manifest, which `rig_error` then says; the Calibration panel repairs it. The
        frame sizes are the session's own readers', so no video is probed again. The
        first time a rig resolves, a thread starts compiling the kernels its shapes
        need (`_warm_up`, ADR-0111).
        """
        if not self._rig_resolved:
            sizes = self.frame_sizes
            frame_hw = {n: sizes[v] for v, n in enumerate(self.view_names)}
            self._rig, self._rig_error = _active_rig(self.project, frame_hw)
            self._rig_resolved = True
            if self._rig is not None:
                self._warming = _warm_up_async(
                    self._rig, self.skeleton, lambda: not self._closed
                )
        return self._rig

    @property
    def rig_error(self) -> str | None:
        """Why the project's active calibration gives no rig, when it names one."""
        self.rig()
        return self._rig_error

    def refresh_geometry(self, project: Project) -> None:
        """Adopt a freshly opened project and drop the cached rig.

        For a changed active calibration or run precedence; the labels and readers
        are kept, and the detections recompute their winners under the new manifest.
        """
        self.project = project
        self.detections.project = project
        self.detections.refresh()
        self._rig = None
        self._rig_error = None
        self._rig_resolved = False
        self._bone_ref = None
        self._view_precision = None

    def bone_reference(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """The project's `(median, mad, count)` bone lengths, cached.

        [bone_reference][deeperfly.labels.check.bone_reference], recomputed when the
        geometry, the detections or the labels behind it change.
        """
        skel = self.skeleton
        rig = self.rig()
        detections = self.detections
        if rig is None:
            return bone_reference(None, skel, self.labels, detections)
        detections.revalidate()
        if not detections.empty:
            order = detections.project.manifest.detections.runs
            key: tuple = ("detections", detections.built, order)
        else:
            key = (
                "labels",
                len(self.labels.labeled_frames()),
                sum(self.labels.label_counts()),
            )
        if self._bone_ref is None or self._bone_ref[0] != key:
            ref = bone_reference(rig, skel, self.labels, detections)
            self._bone_ref = (key, ref)
        return self._bone_ref[1]

    def view_precision(self) -> np.ndarray | None:
        """Each view's measured precision over the project's detections, cached.

        [detection_precision][deeperfly.labels.check.detection_precision], which the
        solves weigh their fits by (ADR-0129); recomputed when the geometry or the
        detections change, `None` without either.
        """
        rig = self.rig()
        detections = self.detections
        if rig is None:
            return None
        detections.revalidate()
        if detections.empty:
            return None
        order = detections.project.manifest.detections.runs
        key = (detections.built, order)
        if self._view_precision is None or self._view_precision[0] != key:
            self._view_precision = (key, detection_precision(rig, detections))
        return self._view_precision[1]

    def detected(
        self, frame: int, instance: int = 0
    ) -> tuple[np.ndarray | None, np.ndarray | None]:
        """[detected_frame][deeperfly.labels.check.detected_frame] over this session.

        The slot whose detections the instance's clicks sit on (ADR-0128); `(None,
        None)` where no run covers the frame, for an instance no slot is, and when the
        winner's read failed, which `runs_error` then says.
        """
        self.detections.revalidate()
        return detected_frame(self.detections, self.labels, frame, instance)

    def holds(self, frame: int, instance: int, detected: np.ndarray | None) -> bool:
        """Whether anything in `frame` places this animal, so a skeleton is drawn.

        A labeled pixel, a detection that passes `min_conf` (`detected` is what
        `detected` answered for the frame), or the operator's drop-in (`/seed`).
        Absent and hidden marks place nothing (ADR-0091).
        """
        if (frame, instance) in self.seeded:
            return True
        if np.isfinite(self.labels.frame(frame, instance=instance)).any():
            return True
        return detected is not None and bool(np.isfinite(detected).any())

    @property
    def runs_error(self) -> str | None:
        """Why the detections are missing where a run should serve them, if they are."""
        return self.detections.runs_error

    def prior(self, frame: int, instance: int = 0) -> tuple[np.ndarray, np.ndarray]:
        """[prior_3d][deeperfly.labels.check.prior_3d] over this session."""
        return prior_3d(
            self.rig(),
            self.skeleton,
            self.labels,
            self.detections,
            frame,
            instance,
            self.view_precision(),
        )

    def close(self) -> None:
        """Close every open reader (labels are the caller's to save first).

        A frame read in flight finishes first; one that starts later raises
        [SessionClosed][deeperfly.gui.labeling.SessionClosed].
        """
        with self._readers_lock:
            self._closed = True
            readers = [reader for reader, _, _ in self._readers.values()]
            self._readers.clear()
        for reader in readers:
            reader.close()
        self.detections.close()

can_redo property

can_redo: bool

Whether any undone edit is waiting to be redone.

can_undo property

can_undo: bool

Whether any edit is on the undo stack.

fps property

fps: float | None

The frame rate the first video declares, None for an image sequence.

Probed once through a reader of its own, so the display readers keep their place.

frame_sizes property

frame_sizes: list[tuple[int, int]]

Per view, the (height, width) of the video it reads, probed once.

Never through the display readers: reading frame 0 would pull their decoders away from the frames on screen.

redo_label property

redo_label: str | None

What a redo would apply again, None on an empty stack.

rig_error property

rig_error: str | None

Why the project's active calibration gives no rig, when it names one.

runs_error property

runs_error: str | None

Why the detections are missing where a run should serve them, if they are.

sources property

sources: list[tuple[str, list[int]]]

The videos the views read, each with the views that read it, in view order.

The editor draws a panel per video, so shared pixels are decoded, sent and held once.

undo_label property

undo_label: str | None

What an undo would reverse, None on an empty stack.

view_names property

view_names: list[str]

The project's view names, in view order.

__post_init__

__post_init__() -> None

Recovered labels have no saved step to come back to.

Source code in src/deeperfly/gui/labeling.py
def __post_init__(self) -> None:
    """Recovered labels have no saved step to come back to."""
    if self.recovered:
        self._saved_top = None
    if self.saved is None:
        self.saved = (
            self.files.load() if self.recovered else copy.deepcopy(self.labels)
        )

autosave

autosave() -> None

Bring the crash-recovery sidecar up to date, after each edit.

Unsaved labels are flushed to it and stay dirty, since labels.h5 is still behind; /save clears it. Labels back at the saved step have nothing to recover, so the sidecar goes and a reopen finds none (ADR-0110). A write or a removal that fails keeps the edit in memory and sets autosave_error instead of failing the request: the operator can still save by hand.

Source code in src/deeperfly/gui/labeling.py
def autosave(self) -> None:
    """Bring the crash-recovery sidecar up to date, after each edit.

    Unsaved labels are flushed to it and stay dirty, since `labels.h5` is still
    behind; `/save` clears it. Labels back at the saved step have nothing to
    recover, so the sidecar goes and a reopen finds none (ADR-0110). A write or a
    removal that fails keeps the edit in memory and sets `autosave_error` instead
    of failing the request: the operator can still save by hand.
    """
    dirty = self.labels.dirty
    try:
        if dirty:
            self.files.autosave(self.labels)
        else:
            self.files.recovery_path.unlink(missing_ok=True)
    except OSError as exc:
        verb = "write" if dirty else "remove"
        self.autosave_error = f"cannot {verb} {self.files.recovery_path}: {exc}"
    else:
        self.autosave_error = None

bone_reference

bone_reference() -> tuple[
    np.ndarray, np.ndarray, np.ndarray
]

The project's (median, mad, count) bone lengths, cached.

bone_reference, recomputed when the geometry, the detections or the labels behind it change.

Source code in src/deeperfly/gui/labeling.py
def bone_reference(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """The project's `(median, mad, count)` bone lengths, cached.

    [bone_reference][deeperfly.labels.check.bone_reference], recomputed when the
    geometry, the detections or the labels behind it change.
    """
    skel = self.skeleton
    rig = self.rig()
    detections = self.detections
    if rig is None:
        return bone_reference(None, skel, self.labels, detections)
    detections.revalidate()
    if not detections.empty:
        order = detections.project.manifest.detections.runs
        key: tuple = ("detections", detections.built, order)
    else:
        key = (
            "labels",
            len(self.labels.labeled_frames()),
            sum(self.labels.label_counts()),
        )
    if self._bone_ref is None or self._bone_ref[0] != key:
        ref = bone_reference(rig, skel, self.labels, detections)
        self._bone_ref = (key, ref)
    return self._bone_ref[1]

close

close() -> None

Close every open reader (labels are the caller's to save first).

A frame read in flight finishes first; one that starts later raises SessionClosed.

Source code in src/deeperfly/gui/labeling.py
def close(self) -> None:
    """Close every open reader (labels are the caller's to save first).

    A frame read in flight finishes first; one that starts later raises
    [SessionClosed][deeperfly.gui.labeling.SessionClosed].
    """
    with self._readers_lock:
        self._closed = True
        readers = [reader for reader, _, _ in self._readers.values()]
        self._readers.clear()
    for reader in readers:
        reader.close()
    self.detections.close()

detected

detected(
    frame: int, instance: int = 0
) -> tuple[np.ndarray | None, np.ndarray | None]

detected_frame over this session.

The slot whose detections the instance's clicks sit on (ADR-0128); (None, None) where no run covers the frame, for an instance no slot is, and when the winner's read failed, which runs_error then says.

Source code in src/deeperfly/gui/labeling.py
def detected(
    self, frame: int, instance: int = 0
) -> tuple[np.ndarray | None, np.ndarray | None]:
    """[detected_frame][deeperfly.labels.check.detected_frame] over this session.

    The slot whose detections the instance's clicks sit on (ADR-0128); `(None,
    None)` where no run covers the frame, for an instance no slot is, and when the
    winner's read failed, which `runs_error` then says.
    """
    self.detections.revalidate()
    return detected_frame(self.detections, self.labels, frame, instance)

discard_unsaved

discard_unsaved() -> None

Set every edit since the last save aside, and the undo history with them.

They are kept for /restore-discarded (ADR-0120); the undo stack is not.

Source code in src/deeperfly/gui/labeling.py
def discard_unsaved(self) -> None:
    """Set every edit since the last save aside, and the undo history with them.

    They are kept for `/restore-discarded` (ADR-0120); the undo stack is not.
    """
    self.labels = self.files.discard_unsaved(self.labels)
    self._undo.clear()
    self._redo.clear()
    self.mark_saved()

frame

frame(source: str, index: int) -> np.ndarray

One uint8 display frame: video source at index, whole.

(H, W) for footage that carries no color, else (H, W, 3). Every view of the video is drawn on it, in its own pixels, where its labels and calibration are (ADR-0053); nothing is flipped.

Raises:

Type Description
SessionClosed

If the session closed before or during the read.

Source code in src/deeperfly/gui/labeling.py
def frame(self, source: str, index: int) -> np.ndarray:
    """One uint8 display frame: video `source` at `index`, whole.

    `(H, W)` for footage that carries no color, else `(H, W, 3)`. Every view of the
    video is drawn on it, in its own pixels, where its labels and calibration are
    (ADR-0053); nothing is flipped.

    Raises:
        SessionClosed: If the session closed before or during the read.
    """
    reader = self._display_reader(source)
    try:
        return reader[index]
    except ValueError:
        if self._closed:  # the reader was closed under the read
            raise SessionClosed(str(self.project.folder)) from None
        with self._readers_lock:
            held = self._readers.get(source)
        if held is None or held[0] is reader:
            raise
    # Another read found the files replaced and reopened them under this one.
    return self._display_reader(source)[index]

holds

holds(
    frame: int, instance: int, detected: ndarray | None
) -> bool

Whether anything in frame places this animal, so a skeleton is drawn.

A labeled pixel, a detection that passes min_conf (detected is what detected answered for the frame), or the operator's drop-in (/seed). Absent and hidden marks place nothing (ADR-0091).

Source code in src/deeperfly/gui/labeling.py
def holds(self, frame: int, instance: int, detected: np.ndarray | None) -> bool:
    """Whether anything in `frame` places this animal, so a skeleton is drawn.

    A labeled pixel, a detection that passes `min_conf` (`detected` is what
    `detected` answered for the frame), or the operator's drop-in (`/seed`).
    Absent and hidden marks place nothing (ADR-0091).
    """
    if (frame, instance) in self.seeded:
        return True
    if np.isfinite(self.labels.frame(frame, instance=instance)).any():
        return True
    return detected is not None and bool(np.isfinite(detected).any())

mark_saved

mark_saved() -> None

Note that labels.h5 holds the labels as they are now, undo history kept.

Source code in src/deeperfly/gui/labeling.py
def mark_saved(self) -> None:
    """Note that `labels.h5` holds the labels as they are now, undo history kept."""
    self._saved_top = self._top
    self.saved = copy.deepcopy(self.labels)
    self.recovered = False
    self.autosave_error = None

point_name

point_name(keypoint: int) -> str

The skeleton's name for a keypoint index, for undo descriptions.

Source code in src/deeperfly/gui/labeling.py
def point_name(self, keypoint: int) -> str:
    """The skeleton's name for a keypoint index, for undo descriptions."""
    return self.skeleton.point_names[keypoint]

prior

prior(
    frame: int, instance: int = 0
) -> tuple[np.ndarray, np.ndarray]

prior_3d over this session.

Source code in src/deeperfly/gui/labeling.py
def prior(self, frame: int, instance: int = 0) -> tuple[np.ndarray, np.ndarray]:
    """[prior_3d][deeperfly.labels.check.prior_3d] over this session."""
    return prior_3d(
        self.rig(),
        self.skeleton,
        self.labels,
        self.detections,
        frame,
        instance,
        self.view_precision(),
    )

record

record(
    label: str,
    frame: int | None,
    snapshot: Callable[[], Callable[[], None]],
    restore: Callable[[], None],
) -> None

Push one edit onto the undo stack, oldest dropped past the cap.

Called after a route's mutation succeeded, with the snapshot of the slice it touches and the restore taken just before; a new edit empties the redo stack and leaves the labels unsaved, whatever they held.

Source code in src/deeperfly/gui/labeling.py
def record(
    self,
    label: str,
    frame: int | None,
    snapshot: Callable[[], Callable[[], None]],
    restore: Callable[[], None],
) -> None:
    """Push one edit onto the undo stack, oldest dropped past the cap.

    Called after a route's mutation succeeded, with the `snapshot` of the slice it
    touches and the `restore` taken just before; a new edit empties the redo stack
    and leaves the labels unsaved, whatever they held.
    """
    self._undo.append(UndoStep(next(self._ids), label, frame, restore, snapshot))
    if len(self._undo) > UNDO_DEPTH:
        self._floor = self._undo[-UNDO_DEPTH - 1].id
        del self._undo[:-UNDO_DEPTH]
    self._redo.clear()
    self._sync_dirty()

redo

redo() -> UndoStep | None

Apply the most recently undone edit again, or None if there is none.

Source code in src/deeperfly/gui/labeling.py
def redo(self) -> UndoStep | None:
    """Apply the most recently undone edit again, or `None` if there is none."""
    return self._step(self._redo, self._undo)

refresh_geometry

refresh_geometry(project: Project) -> None

Adopt a freshly opened project and drop the cached rig.

For a changed active calibration or run precedence; the labels and readers are kept, and the detections recompute their winners under the new manifest.

Source code in src/deeperfly/gui/labeling.py
def refresh_geometry(self, project: Project) -> None:
    """Adopt a freshly opened project and drop the cached rig.

    For a changed active calibration or run precedence; the labels and readers
    are kept, and the detections recompute their winners under the new manifest.
    """
    self.project = project
    self.detections.project = project
    self.detections.refresh()
    self._rig = None
    self._rig_error = None
    self._rig_resolved = False
    self._bone_ref = None
    self._view_precision = None

release_readers

release_readers() -> None

Let go of every display reader's decoder; each reopens on its next read.

Source code in src/deeperfly/gui/labeling.py
def release_readers(self) -> None:
    """Let go of every display reader's decoder; each reopens on its next read."""
    with self._readers_lock:
        readers = [reader for reader, _, _ in self._readers.values()]
    for reader in readers:
        reader.release()

rig

rig() -> Rig | None

The active calibration applied to the project's views, None without one.

Also None when the active calibration is missing or disagrees with the manifest, which rig_error then says; the Calibration panel repairs it. The frame sizes are the session's own readers', so no video is probed again. The first time a rig resolves, a thread starts compiling the kernels its shapes need (_warm_up, ADR-0111).

Source code in src/deeperfly/gui/labeling.py
def rig(self) -> Rig | None:
    """The active calibration applied to the project's views, `None` without one.

    Also `None` when the active calibration is missing or disagrees with the
    manifest, which `rig_error` then says; the Calibration panel repairs it. The
    frame sizes are the session's own readers', so no video is probed again. The
    first time a rig resolves, a thread starts compiling the kernels its shapes
    need (`_warm_up`, ADR-0111).
    """
    if not self._rig_resolved:
        sizes = self.frame_sizes
        frame_hw = {n: sizes[v] for v, n in enumerate(self.view_names)}
        self._rig, self._rig_error = _active_rig(self.project, frame_hw)
        self._rig_resolved = True
        if self._rig is not None:
            self._warming = _warm_up_async(
                self._rig, self.skeleton, lambda: not self._closed
            )
    return self._rig

undo

undo() -> UndoStep | None

Reverse the most recent recorded edit, or None if there is none.

The labels are clean again once it returns to the saved step, and dirty otherwise: an undo writes the sidecar and not labels.h5.

Source code in src/deeperfly/gui/labeling.py
def undo(self) -> UndoStep | None:
    """Reverse the most recent recorded edit, or `None` if there is none.

    The labels are clean again once it returns to the saved step, and dirty
    otherwise: an undo writes the sidecar and not `labels.h5`.
    """
    return self._step(self._undo, self._redo)

unsaved_frames

unsaved_frames() -> list[int]

The frames whose labels differ from labels.h5's, sorted (ADR-0120).

Empty at the saved step. Away from it, an edit and its inverse edit leave the labels unsaved (ADR-0110) with no frame to show for it.

Source code in src/deeperfly/gui/labeling.py
def unsaved_frames(self) -> list[int]:
    """The frames whose labels differ from `labels.h5`'s, sorted (ADR-0120).

    Empty at the saved step. Away from it, an edit and its inverse edit leave the
    labels unsaved (ADR-0110) with no frame to show for it.
    """
    if not self.labels.dirty or self.saved is None:
        return []
    return self.labels.changed_frames(self.saved)

view_precision

view_precision() -> np.ndarray | None

Each view's measured precision over the project's detections, cached.

detection_precision, which the solves weigh their fits by (ADR-0129); recomputed when the geometry or the detections change, None without either.

Source code in src/deeperfly/gui/labeling.py
def view_precision(self) -> np.ndarray | None:
    """Each view's measured precision over the project's detections, cached.

    [detection_precision][deeperfly.labels.check.detection_precision], which the
    solves weigh their fits by (ADR-0129); recomputed when the geometry or the
    detections change, `None` without either.
    """
    rig = self.rig()
    detections = self.detections
    if rig is None:
        return None
    detections.revalidate()
    if detections.empty:
        return None
    order = detections.project.manifest.detections.runs
    key = (detections.built, order)
    if self._view_precision is None or self._view_precision[0] != key:
        self._view_precision = (key, detection_precision(rig, detections))
    return self._view_precision[1]

FrameRef

Bases: ProjectRef

A reference to one frame of one project (seeding, the guide overlay).

Source code in src/deeperfly/gui/labeling.py
class FrameRef(ProjectRef):
    """A reference to one frame of one project (seeding, the guide overlay)."""

    frame: int
    instance: int = 0

HiddenIn

Bases: BaseModel

Set or clear the hidden (exclude-from-loss) flag of one cell.

Source code in src/deeperfly/gui/labeling.py
class HiddenIn(BaseModel):
    """Set or clear the hidden (exclude-from-loss) flag of one cell."""

    project: str
    frame: int
    view: int = Field(ge=0)
    keypoint: int = Field(ge=0)
    value: bool = True
    instance: int = 0

InstanceIn

Bases: ProjectRef

Add or remove one animal's instance in one frame (animals > 1 only).

Source code in src/deeperfly/gui/labeling.py
class InstanceIn(ProjectRef):
    """Add or remove one animal's instance in one frame (`animals > 1` only)."""

    frame: int
    instance: int
    action: str = "add"  # "add" | "remove"

OverrideIn

Bases: BaseModel

One view's pixel to solve as if it were labeled: the cursor mid-drag.

Source code in src/deeperfly/gui/labeling.py
class OverrideIn(BaseModel):
    """One view's pixel to solve *as if* it were labeled: the cursor mid-drag."""

    view: int
    xy: list[float]

PointIn

Bases: BaseModel

Set (xy given) or clear (xy null) one (view, keypoint) cell of a frame.

Source code in src/deeperfly/gui/labeling.py
class PointIn(BaseModel):
    """Set (`xy` given) or clear (`xy` null) one `(view, keypoint)` cell of a frame."""

    project: str
    frame: int
    view: int = Field(ge=0)
    keypoint: int = Field(ge=0)
    xy: tuple[float, float] | None = None
    instance: int = 0

ProjectRef

Bases: BaseModel

A reference to one project by its folder (for save, undo, redo, discard).

Source code in src/deeperfly/gui/labeling.py
class ProjectRef(BaseModel):
    """A reference to one project by its folder (for save, undo, redo, discard)."""

    project: str

ProjectsRef

Bases: BaseModel

Some projects of the collection by their folders (save all, discard all).

Source code in src/deeperfly/gui/labeling.py
class ProjectsRef(BaseModel):
    """Some projects of the collection by their folders (save all, discard all)."""

    projects: list[str]

ReprojectIn

Bases: BaseModel

A request to solve one keypoint of one frame and reproject it.

override stands a cursor pixel in for one view's label, so a drag in progress previews where it puts the keypoint in every other view; it authors nothing.

Source code in src/deeperfly/gui/labeling.py
class ReprojectIn(BaseModel):
    """A request to solve one keypoint of one frame and reproject it.

    `override` stands a cursor pixel in for one view's label, so a drag in progress
    previews where it puts the keypoint in every other view; it authors nothing.
    """

    project: str
    frame: int
    keypoint: int
    override: OverrideIn | None = None
    instance: int = 0

SessionClosed

Bases: RuntimeError

A session was closed (evicted) while a frame read was going through it.

Source code in src/deeperfly/gui/labeling.py
class SessionClosed(RuntimeError):
    """A session was closed (evicted) while a frame read was going through it."""

SessionRouter

Bases: APIRouter

An APIRouter whose every route runs under its session store's lock.

Where requests are serialized (ADR-0090): a route added here, by decorator or by add_api_route, holds the lock for its whole body, on the worker thread FastAPI runs it on. Only a sync route can be added, so the lock never spans an await.

Source code in src/deeperfly/gui/labeling.py
class SessionRouter(APIRouter):
    """An `APIRouter` whose every route runs under its session store's `lock`.

    Where requests are serialized (ADR-0090): a route added here, by decorator or by
    `add_api_route`, holds the lock for its whole body, on the worker thread FastAPI
    runs it on. Only a sync route can be added, so the lock never spans an `await`.
    """

    def __init__(self, store: SessionStore, **kwargs: Any) -> None:
        """A router over `store`; `kwargs` are `APIRouter`'s."""
        super().__init__(**kwargs)
        self.store = store

    def add_api_route(
        self, path: str, endpoint: Callable[..., Any], **kwargs: Any
    ) -> None:
        """Add a route whose endpoint holds the store's lock while it runs.

        Raises:
            TypeError: If `endpoint` is a coroutine function, which FastAPI would
                await after the lock was released.
        """
        if inspect.iscoroutinefunction(endpoint):
            raise TypeError(f"{path}: a session route must be sync to run locked")
        lock = self.store.lock

        @functools.wraps(endpoint)
        def locked(*args: Any, **kw: Any) -> Any:
            with lock:
                return endpoint(*args, **kw)

        super().add_api_route(path, locked, **kwargs)

__init__

__init__(store: SessionStore, **kwargs: Any) -> None

A router over store; kwargs are APIRouter's.

Source code in src/deeperfly/gui/labeling.py
def __init__(self, store: SessionStore, **kwargs: Any) -> None:
    """A router over `store`; `kwargs` are `APIRouter`'s."""
    super().__init__(**kwargs)
    self.store = store

add_api_route

add_api_route(
    path: str, endpoint: Callable[..., Any], **kwargs: Any
) -> None

Add a route whose endpoint holds the store's lock while it runs.

Raises:

Type Description
TypeError

If endpoint is a coroutine function, which FastAPI would await after the lock was released.

Source code in src/deeperfly/gui/labeling.py
def add_api_route(
    self, path: str, endpoint: Callable[..., Any], **kwargs: Any
) -> None:
    """Add a route whose endpoint holds the store's lock while it runs.

    Raises:
        TypeError: If `endpoint` is a coroutine function, which FastAPI would
            await after the lock was released.
    """
    if inspect.iscoroutinefunction(endpoint):
        raise TypeError(f"{path}: a session route must be sync to run locked")
    lock = self.store.lock

    @functools.wraps(endpoint)
    def locked(*args: Any, **kw: Any) -> Any:
        with lock:
            return endpoint(*args, **kw)

    super().add_api_route(path, locked, **kwargs)

SessionStore

A registry of live EditorSessions, keyed by the resolved project folder.

lock serializes the requests that read or change a session (ADR-0090): FastAPI runs the routes in its thread pool, and neither a session nor its labels are thread-safe. A SessionRouter route holds it for its whole body, and the store's own methods take it, so a session is opened, refreshed or evicted between two requests, never during one. The suggest routes hold it only to snapshot what they score and to write the answer (ADR-0111).

Source code in src/deeperfly/gui/labeling.py
class SessionStore:
    """A registry of live [EditorSession][]s, keyed by the resolved project folder.

    `lock` serializes the requests that read or change a session (ADR-0090): FastAPI
    runs the routes in its thread pool, and neither a session nor its labels are
    thread-safe. A [SessionRouter][deeperfly.gui.labeling.SessionRouter] route holds it
    for its whole body, and the store's own methods take it, so a session is opened,
    refreshed or evicted between two requests, never during one. The suggest routes
    hold it only to snapshot what they score and to write the answer (ADR-0111).
    """

    def __init__(self) -> None:
        """An empty store; sessions open lazily on first request."""
        self._sessions: dict[Path, EditorSession] = {}
        self.lock = threading.RLock()
        # The sessions whose frames were read last, oldest first, by `id`: the only
        # ones whose decoders stay open (`HELD_PROJECTS`). Its own lock, since
        # `/frame` never takes `lock`.
        self._showing: OrderedDict[int, EditorSession] = OrderedDict()
        self._showing_lock = threading.Lock()

    def get(self, folder: str) -> EditorSession:
        """The session for a project, opening its labels and recording on first use.

        Only the opening takes `lock`, so `/frame`, which runs without it, waits for
        nothing but the opening of its own project.
        """
        project = open_project(folder)
        key = project.folder.resolve()
        session = self._sessions.get(key)
        if session is not None:
            return session
        with self.lock:
            # A request that held the lock first may have opened it meanwhile.
            session = self._sessions.get(key)
            if session is None:
                session = self._sessions[key] = _open_session(project)
            return session

    def showing(self, session: EditorSession) -> None:
        """Note that `session`'s frames are being read; release all but the latest.

        Only the last `HELD_PROJECTS` sessions to serve a frame keep their decoders.
        """
        with self._showing_lock:
            if session._closed:  # evicted since its frame was read
                return
            self._showing[id(session)] = session
            self._showing.move_to_end(id(session))
            stale = []
            while len(self._showing) > HELD_PROJECTS:
                stale.append(self._showing.popitem(last=False)[1])
        for old in stale:
            old.release_readers()

    def _not_showing(self, session: EditorSession) -> None:
        """Forget a session being closed, so it holds no place among those shown."""
        with self._showing_lock:
            self._showing.pop(id(session), None)

    def folders(self) -> list[Path]:
        """The folders of the projects with a live session, in the order opened."""
        with self.lock:
            return list(self._sessions)

    def live(self, project: Project) -> EditorSession | None:
        """The project's open session, or `None` without opening one."""
        return self._sessions.get(project.folder.resolve())

    def refresh_geometry(self, folder: str | Path) -> None:
        """Re-resolve a live session's geometry after its calibration changed.

        The manifest is read under `lock`, so a refresh that waited on it never
        installs one older than a write that landed meanwhile.
        """
        with self.lock:
            try:
                project = Project.load(folder)
            except OSError, ValueError:
                return
            session = self._sessions.get(project.folder.resolve())
            if session is not None:
                session.refresh_geometry(project)

    def evict(self, folder: str | Path) -> None:
        """Close a project's session after its skeleton or views were rewritten.

        Its edits are in the recovery sidecar, which the edit rewrote too, so the next
        request reopens the session from it.
        """
        with self.lock:
            session = self._sessions.pop(Path(folder).resolve(), None)
            if session is not None:
                session.close()  # first, so a read landing now is not noted after
                self._not_showing(session)

    def close_all(self) -> None:
        """Close every live session's readers (labels are not auto-saved)."""
        with self.lock:
            for session in self._sessions.values():
                session.close()
                self._not_showing(session)
            self._sessions.clear()

__init__

__init__() -> None

An empty store; sessions open lazily on first request.

Source code in src/deeperfly/gui/labeling.py
def __init__(self) -> None:
    """An empty store; sessions open lazily on first request."""
    self._sessions: dict[Path, EditorSession] = {}
    self.lock = threading.RLock()
    # The sessions whose frames were read last, oldest first, by `id`: the only
    # ones whose decoders stay open (`HELD_PROJECTS`). Its own lock, since
    # `/frame` never takes `lock`.
    self._showing: OrderedDict[int, EditorSession] = OrderedDict()
    self._showing_lock = threading.Lock()

close_all

close_all() -> None

Close every live session's readers (labels are not auto-saved).

Source code in src/deeperfly/gui/labeling.py
def close_all(self) -> None:
    """Close every live session's readers (labels are not auto-saved)."""
    with self.lock:
        for session in self._sessions.values():
            session.close()
            self._not_showing(session)
        self._sessions.clear()

evict

evict(folder: str | Path) -> None

Close a project's session after its skeleton or views were rewritten.

Its edits are in the recovery sidecar, which the edit rewrote too, so the next request reopens the session from it.

Source code in src/deeperfly/gui/labeling.py
def evict(self, folder: str | Path) -> None:
    """Close a project's session after its skeleton or views were rewritten.

    Its edits are in the recovery sidecar, which the edit rewrote too, so the next
    request reopens the session from it.
    """
    with self.lock:
        session = self._sessions.pop(Path(folder).resolve(), None)
        if session is not None:
            session.close()  # first, so a read landing now is not noted after
            self._not_showing(session)

folders

folders() -> list[Path]

The folders of the projects with a live session, in the order opened.

Source code in src/deeperfly/gui/labeling.py
def folders(self) -> list[Path]:
    """The folders of the projects with a live session, in the order opened."""
    with self.lock:
        return list(self._sessions)

get

get(folder: str) -> EditorSession

The session for a project, opening its labels and recording on first use.

Only the opening takes lock, so /frame, which runs without it, waits for nothing but the opening of its own project.

Source code in src/deeperfly/gui/labeling.py
def get(self, folder: str) -> EditorSession:
    """The session for a project, opening its labels and recording on first use.

    Only the opening takes `lock`, so `/frame`, which runs without it, waits for
    nothing but the opening of its own project.
    """
    project = open_project(folder)
    key = project.folder.resolve()
    session = self._sessions.get(key)
    if session is not None:
        return session
    with self.lock:
        # A request that held the lock first may have opened it meanwhile.
        session = self._sessions.get(key)
        if session is None:
            session = self._sessions[key] = _open_session(project)
        return session

live

live(project: Project) -> EditorSession | None

The project's open session, or None without opening one.

Source code in src/deeperfly/gui/labeling.py
def live(self, project: Project) -> EditorSession | None:
    """The project's open session, or `None` without opening one."""
    return self._sessions.get(project.folder.resolve())

refresh_geometry

refresh_geometry(folder: str | Path) -> None

Re-resolve a live session's geometry after its calibration changed.

The manifest is read under lock, so a refresh that waited on it never installs one older than a write that landed meanwhile.

Source code in src/deeperfly/gui/labeling.py
def refresh_geometry(self, folder: str | Path) -> None:
    """Re-resolve a live session's geometry after its calibration changed.

    The manifest is read under `lock`, so a refresh that waited on it never
    installs one older than a write that landed meanwhile.
    """
    with self.lock:
        try:
            project = Project.load(folder)
        except OSError, ValueError:
            return
        session = self._sessions.get(project.folder.resolve())
        if session is not None:
            session.refresh_geometry(project)

showing

showing(session: EditorSession) -> None

Note that session's frames are being read; release all but the latest.

Only the last HELD_PROJECTS sessions to serve a frame keep their decoders.

Source code in src/deeperfly/gui/labeling.py
def showing(self, session: EditorSession) -> None:
    """Note that `session`'s frames are being read; release all but the latest.

    Only the last `HELD_PROJECTS` sessions to serve a frame keep their decoders.
    """
    with self._showing_lock:
        if session._closed:  # evicted since its frame was read
            return
        self._showing[id(session)] = session
        self._showing.move_to_end(id(session))
        stale = []
        while len(self._showing) > HELD_PROJECTS:
            stale.append(self._showing.popitem(last=False)[1])
    for old in stale:
        old.release_readers()

SuggestAllIn

Bases: SuggestParams

A request to compute the suggestions of every project of a collection.

Source code in src/deeperfly/gui/labeling.py
class SuggestAllIn(SuggestParams):
    """A request to compute the suggestions of every project of a collection."""

    projects: list[str]

SuggestIn

Bases: SuggestParams

A request to compute one project's suggestions and write suggestions.json.

Source code in src/deeperfly/gui/labeling.py
class SuggestIn(SuggestParams):
    """A request to compute one project's suggestions and write `suggestions.json`."""

    project: str

SuggestParams

Bases: BaseModel

How to compute suggestions: the method and the controls it reads.

method="merged" scores the project's merged detections (ADR-0083), "run" one run's 2D (run as /runs lists it), and "sample" spreads whole frames over the unlabeled part and reads count only. gap is the frames between two suggested frames, min_score the typical residuals a joint must be off by, and per_frame the joints a frame lists at most (ADR-0073).

Source code in src/deeperfly/gui/labeling.py
class SuggestParams(BaseModel):
    """How to compute suggestions: the method and the controls it reads.

    `method="merged"` scores the project's merged detections (ADR-0083), `"run"` one
    run's 2D (`run` as `/runs` lists it), and `"sample"` spreads whole frames over
    the unlabeled part and reads `count` only. `gap` is the frames between two
    suggested frames, `min_score` the typical residuals a joint must be off by, and
    `per_frame` the joints a frame lists at most (ADR-0073).
    """

    method: str = "merged"
    run: str | None = None
    count: int = 100
    gap: int = 20
    min_score: float = MIN_SCORE
    per_frame: int = PER_FRAME

UndoStep dataclass

One reversible edit: what it was, where it happened, and how to put it back.

id is the edit's own, never reused, and follows it from the undo stack to the redo stack and back, so the step on top of the undo stack names a state of the labels (ADR-0110). frame is where the editor should look to see the undo, None for an edit that spans every frame. snapshot reads the slice the edit touches and returns the closure that writes it back, so one record serves undo and redo alike.

Source code in src/deeperfly/gui/labeling.py
@dataclass(frozen=True)
class UndoStep:
    """One reversible edit: what it was, where it happened, and how to put it back.

    `id` is the edit's own, never reused, and follows it from the undo stack to the
    redo stack and back, so the step on top of the undo stack names a state of the
    labels (ADR-0110). `frame` is where the editor should look to see the undo, `None`
    for an edit that spans every frame. `snapshot` reads the slice the edit touches and
    returns the closure that writes it back, so one record serves undo and redo alike.
    """

    id: int
    label: str
    frame: int | None
    restore: Callable[[], None]
    snapshot: Callable[[], Callable[[], None]]

build_labeling_router

build_labeling_router(store: SessionStore) -> APIRouter

The labeling routes, bound to a session store.

Every route is a SessionRouter's but /frame, which only decodes video: outside the lock, a scrub's frames decode in parallel and none waits behind a solve. The two suggest routes score outside it as well, from a snapshot taken under it (ADR-0111), so a frame step or an edit made while they score is served at once.

Source code in src/deeperfly/gui/labeling.py
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
def build_labeling_router(store: SessionStore) -> APIRouter:
    """The labeling routes, bound to a session `store`.

    Every route is a [SessionRouter][deeperfly.gui.labeling.SessionRouter]'s but
    `/frame`, which only decodes video: outside the lock, a scrub's frames decode in
    parallel and none waits behind a solve. The two suggest routes score outside it as
    well, from a snapshot taken under it (ADR-0111), so a frame step or an edit made
    while they score is served at once.
    """
    frames = APIRouter(prefix="/api/labeling")
    router = SessionRouter(store)

    @router.get("/state")
    def state(project: str) -> dict:
        """Everything the labeling page needs to open a project.

        Each source carries its frame `size` as `[width, height]`, so the page lays
        its panels out at the frame's own aspect before an image arrives (ADR-0108).
        `n_runs` is how many runs the project has, for the Runs tab's badge; the list
        itself is read when the tab is shown. `pose_radius` is how far the skeleton's
        resolved pose reaches from its centroid, in millimeters: the 3D view's fixed
        scale (ADR-0093).
        """
        session = store.get(project)
        # Like every request: a run written since the session was cached is counted.
        session.detections.revalidate()
        skel = session.skeleton
        sizes = session.frame_sizes

        def source(name: str, views: list[int]) -> dict:
            # `frame_sizes` is (height, width) per view, and the views of a source
            # all read its one video.
            height, width = sizes[views[0]]
            return {"name": name, "views": views, "size": [int(width), int(height)]}

        out = {
            "project": str(session.project.folder),
            "views": session.view_names,
            "sources": [source(name, views) for name, views in session.sources],
            "n_frames": session.n_frames,
            "fps": session.fps,
            "point_names": list(skel.point_names),
            "edges": skel.edges.tolist(),
            "point_colors": list(skel.point_colors),
            "edge_colors": list(skel.edge_colors),
            "pose_radius": _pose_radius(skel),
            "animals": session.project.animals,
            "has_rig": session.rig() is not None,
            "rig_error": session.rig_error,
            "has_detections": not session.detections.empty,
            "n_runs": len(session.detections.runs),
            "runs_error": session.runs_error,
            "labeled_frames": session.labels.labeled_frames(),
            "labeled_counts": session.labels.label_counts(),
            "labeled_open": session.labels.open_keypoints(),
            "dirty": session.labels.dirty,
            "unsaved_frames": session.unsaved_frames(),
            "recovered": session.recovered,
            "autosave_error": session.autosave_error,
            "can_undo": session.can_undo,
            "can_redo": session.can_redo,
            "undo_label": session.undo_label,
            "redo_label": session.redo_label,
        }
        # The bone lengths the guide's checks read and the views' precision its solves
        # weigh, measured now: the first /overlay would otherwise hold the lock for
        # them (800 ms on hexaview) while the first steps' /points wait behind it.
        if out["has_rig"]:
            session.bone_reference()
            session.view_precision()
        return out

    @router.get("/cameras")
    def cameras(project: str) -> dict:
        """The active calibration's camera centers and axes, for the 3D view.

        `cameras` is empty without a rig; refetch after the calibration changes.
        """
        rig = store.get(project).rig()
        return {"cameras": _poses(rig) if rig is not None else []}

    @frames.get("/frame")
    def frame(project: str, source: int, index: int) -> Response:
        """One video's frame as a JPEG: the `source`-th video at `index`, whole.

        Gray where the footage carries no color. Never cached by the browser: the
        label tab keeps what it fetched for as long as the project is open (ADR-0092).
        A video that cannot be read answers 422 naming it and the frame.
        """
        video: list[str] = []

        def read() -> tuple[EditorSession, np.ndarray]:
            session = store.get(project)
            sources = session.sources
            if not 0 <= source < len(sources):
                raise HTTPException(status_code=404, detail=f"no source {source}")
            if not 0 <= index < session.n_frames:
                detail = f"frame {index} out of range"
                raise HTTPException(status_code=404, detail=detail)
            video[:] = [sources[source][0]]
            return session, session.frame(video[0], index)

        try:
            try:
                session, img = read()
            except SessionClosed:
                # Evicted under the read (a skeleton or views rewrite): get reopens it.
                session, img = read()
        except (OSError, ValueError, IndexError) as exc:
            # A missing, truncated or undecodable video: say which, and where.
            raise HTTPException(
                status_code=422,
                detail=f"cannot read frame {index} of video {video[0]!r}: {exc}",
            ) from exc
        store.showing(session)
        buf = io.BytesIO()
        Image.fromarray(np.asarray(img)).save(buf, format="JPEG", quality=85)
        return Response(
            content=buf.getvalue(),
            media_type="image/jpeg",
            headers={"Cache-Control": "no-store"},
        )

    @router.get("/points")
    def points(project: str, frame: int) -> dict:
        """One frame's authored state and the detector overlay to draw over it.

        `points` (the GT pixels), `hidden`, `absent` and `detected` (the merged
        detections' 2D, `null` where no run covers the frame) are animal 0's; a
        project that labels several animals also gets `instances`, the same keys per
        slot that holds anything, each animal's `detected` the run's slot its clicks
        sit on (ADR-0128). `runs_error` says why a run that should serve the frame
        could not be read (being written, torn): `detected` is then `null` and the
        answer still a 200.
        """
        session = store.get(project)
        labels = session.labels
        _check_frame(session, frame)
        out = _instance_payload(session, frame, 0)
        out["runs_error"] = session.runs_error
        if session.project.animals > 1:
            slots = {0, *labels.labeled_instances(frame)}
            slots |= {i for (f, i) in session.seeded if f == frame}
            slots |= set(range(session.detections.n_animals))
            out["instances"] = [
                {"instance": i, **_instance_payload(session, frame, i)}
                for i in sorted(i for i in slots if i < session.project.animals)
            ]
        return out

    @router.post("/point")
    def set_point(body: PointIn) -> dict:
        """Set or clear one label cell in memory (persist with `/save`)."""
        session = store.get(body.project)
        labels = session.labels
        slot = _check_instance(session, body.instance)
        cells = [(body.view, body.keypoint)]
        snapshot = partial(_snapshot_cells, labels, body.frame, cells, slot)
        try:
            undo = snapshot()
            if body.xy is None:
                labels.clear_point(body.view, body.frame, body.keypoint, instance=slot)
            else:
                labels.set_point(
                    body.view, body.frame, body.keypoint, body.xy, instance=slot
                )
        except (IndexError, ValueError) as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        verb = "clear" if body.xy is None else "place"
        label = _cells_label(session, verb, {slot: cells})
        session.record(label, body.frame, snapshot, undo)
        session.autosave()
        return _edit_state(session)

    @router.post("/hidden")
    def set_hidden(body: HiddenIn) -> dict:
        """Set or clear a cell's hidden (exclude-from-training-loss) flag."""
        session = store.get(body.project)
        labels = session.labels
        slot = _check_instance(session, body.instance)
        cells = [(body.view, body.keypoint)]
        snapshot = partial(_snapshot_cells, labels, body.frame, cells, slot)
        try:
            undo = snapshot()
            labels.set_hidden(
                body.view, body.frame, body.keypoint, body.value, instance=slot
            )
        except (IndexError, ValueError) as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        verb = "exclude" if body.value else "include"
        label = _cells_label(session, verb, {slot: cells})
        session.record(label, body.frame, snapshot, undo)
        session.autosave()
        return _edit_state(session)

    @router.post("/cells")
    def edit_cells(body: CellsIn) -> dict:
        """Edit cells of a frame, each in its own view, as one undoable step.

        The editor's verbs on a selection of one keypoint, one view or every view:
        place, clear or hold out. A cell that names its `instance` is that animal's,
        so a selection of every animal is still one step. The batch is checked whole
        first, so a bad cell edits nothing.
        """
        session = store.get(body.project)
        labels = session.labels
        _check_instance(session, body.instance)
        if not body.cells:
            return _edit_state(session)
        if any(c.op == "place" and c.xy is None for c in body.cells):
            raise HTTPException(status_code=400, detail="a place needs its xy")
        # A cell is its own animal's where the selection reaches several (ADR-0137).
        slots = [
            _check_instance(
                session, body.instance if c.instance is None else c.instance
            )
            for c in body.cells
        ]
        cells: dict[int, list[tuple[int, int]]] = {}
        for c, slot in zip(body.cells, slots, strict=True):
            cells.setdefault(slot, []).append((c.view, c.keypoint))
        cells = {slot: sorted(set(own)) for slot, own in sorted(cells.items())}
        snapshot = partial(_snapshot_animals, labels, body.frame, cells)
        try:
            undo = snapshot()
        except (IndexError, ValueError) as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        for c, slot in zip(body.cells, slots, strict=True):
            if c.op == "place":
                labels.set_point(c.view, body.frame, c.keypoint, c.xy, instance=slot)
            elif c.op == "clear":
                labels.clear_point(c.view, body.frame, c.keypoint, instance=slot)
            else:
                labels.set_hidden(
                    c.view, body.frame, c.keypoint, c.op == "exclude", instance=slot
                )
        ops = {c.op for c in body.cells}
        if len(ops) == 1:
            verb = ops.pop()
        else:
            verb = "reset" if ops == {"clear", "include"} else "edit"
        session.record(_cells_label(session, verb, cells), body.frame, snapshot, undo)
        session.autosave()
        return _edit_state(session)

    @router.post("/absent")
    def set_absent(body: AbsentIn) -> dict:
        """Declare a keypoint absent or present in one frame or in every frame."""
        session = store.get(body.project)
        labels = session.labels
        whole = body.scope == "every_frame"
        slot = _check_instance(session, body.instance)
        k = body.keypoint

        # `instance` by keyword throughout: a positional would land on the value.
        def snapshot() -> Callable[[], None]:
            if whole:
                was = labels.absent_keypoint(k, instance=slot)
                return partial(labels.set_absent_keypoint, k, was, instance=slot)
            was = labels.is_absent(body.frame, k, instance=slot)
            return partial(labels.set_absent, body.frame, k, was, instance=slot)

        try:
            undo = snapshot()
            if whole:
                labels.set_absent_every_frame(k, body.value, instance=slot)
            else:
                labels.set_absent(body.frame, k, body.value, instance=slot)
        except (IndexError, ValueError) as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        verb = "absent" if body.value else "present"
        scope = " (every frame)" if whole else ""
        session.record(
            f"{verb} {session.point_name(k)}{scope}",
            None if whole else body.frame,
            snapshot,
            undo,
        )
        session.autosave()
        return _edit_state(session)

    @router.post("/seed")
    def seed(body: FrameRef) -> dict:
        """Toggle the dropped-in instance in one frame's guide; authors nothing.

        A seeded frame holds the animal (ADR-0091), so its guide shows the prior
        whole, and a keypoint nothing places gets the skeleton's default pose to drag
        (`/overlay` source `"pose"`). A display choice held in the session: not a label,
        not dirty, not undoable, not saved.
        """
        session = store.get(body.project)
        if session.rig() is None:
            raise _no_rig(
                "project has no rig; cannot seed an instance", session.rig_error
            )
        key = (
            _check_frame(session, body.frame),
            _check_instance(session, body.instance),
        )
        if key in session.seeded:
            session.seeded.discard(key)
        else:
            session.seeded.add(key)
        return {"seeded": key in session.seeded}

    @router.post("/instance")
    def instance(body: InstanceIn) -> dict:
        """Add or remove one animal's instance in a frame (`animals > 1` only).

        `add` copies the animal's tracked detections into the labels, a real edit:
        those of the run's slot no other animal's clicks sit on (ADR-0128). Where the
        tracker found nothing it seeds the slot display-only, as `/seed` does.
        `remove` clears the slot. Both are undoable and autosave.
        """
        session = store.get(body.project)
        if session.project.animals <= 1:
            raise HTTPException(
                status_code=400,
                detail=(
                    "this project labels one animal; say how many it holds with "
                    "[project] animals"
                ),
            )
        if body.action not in ("add", "remove"):
            raise HTTPException(
                status_code=400, detail=f"action is add or remove, not {body.action!r}"
            )
        slot = _check_instance(session, body.instance)
        _check_frame(session, body.frame)
        labels = session.labels
        snapshot = partial(_snapshot_instance, labels, body.frame, slot)
        undo = snapshot()
        placed = 0
        if body.action == "remove":
            labels.clear_instance(body.frame, slot)
            session.seeded.discard((body.frame, slot))
            session.record(f"remove animal {slot}", body.frame, snapshot, undo)
        else:
            det, _ = session.detected(body.frame, slot)
            if det is not None:
                for view, keypoint in np.argwhere(np.isfinite(det).all(axis=-1)):
                    labels.set_point(
                        int(view),
                        body.frame,
                        int(keypoint),
                        det[view, keypoint],
                        instance=slot,
                    )
                    placed += 1
            if placed:
                session.record(f"add animal {slot}", body.frame, snapshot, undo)
            else:
                session.seeded.add((body.frame, slot))
        session.autosave()
        return {
            **_edit_state(session),
            "placed": placed,
            "seeded": (body.frame, slot) in session.seeded,
        }

    @router.post("/reproject")
    def reproject(body: ReprojectIn) -> dict:
        """Solve one keypoint's 3D from this frame's evidence, and reproject it.

        The solve is [solve_keypoint][deeperfly.labels.check.solve_keypoint]; an
        `override` replaces one view's label first, to preview a drag. `kind` is
        `"point"` (the reprojection, with the `source` that anchored it and the 3D
        `point3d` itself), `"epipolar"` when exactly one GT view places no point, or
        `"none"`; exactly one GT view also returns `lines`, the segment its ray traces
        in every other view. An absent keypoint is `"none"`, and the prior alone is
        never shown (ADR-0027).
        """
        session = store.get(body.project)
        rig = session.rig()
        if rig is None:
            raise _no_rig("project has no rig; cannot reproject", session.rig_error)
        labels = session.labels
        k = body.keypoint
        slot = _check_instance(session, body.instance)
        _check_frame(session, body.frame)
        if not 0 <= k < labels.n_keypoints:
            raise HTTPException(
                status_code=400,
                detail=f"keypoint {k} is outside 0..{labels.n_keypoints - 1}",
            )
        if labels.is_absent(body.frame, k, instance=slot):
            return {"kind": "none"}
        names = rig.names
        gt = labels.frame(body.frame, instance=slot)[:, k]  # (V, 2), a copy
        if body.override is not None:
            if not 0 <= body.override.view < len(names):
                raise HTTPException(
                    status_code=400, detail="override view out of range"
                )
            gt[body.override.view] = body.override.xy
        seen = np.isfinite(gt).all(axis=-1)
        det, conf = session.detected(body.frame, slot)
        det_k = det[:, k] if det is not None else np.full((len(names), 2), np.nan)
        conf_k = conf[:, k] if conf is not None else None
        det_seen = np.isfinite(det_k).all(axis=-1)
        prior = session.prior(body.frame, slot)[0][k]
        precision = session.view_precision()
        pts3d, source = solve_keypoint(rig, gt, det_k, prior, conf_k, precision)
        out: dict = {"kind": "none"}
        if pts3d is not None and source in ("gt", "ray", "detected"):
            out = {
                "kind": "point",
                "views": {
                    name: rig.views[name].project(pts3d).tolist() for name in names
                },
                "used": _used(names, det_seen if source == "detected" else seen),
                "source": source,
                "point3d": np.asarray(pts3d).tolist(),
            }
        if int(seen.sum()) == 1:
            src = int(np.flatnonzero(seen)[0])
            lines = epipolar_lines(rig, names, src, gt[src])
            if out["kind"] == "none":
                out = {"kind": "epipolar", "source": names[src], "lines": lines}
            else:
                out["lines"] = lines
        return out

    @router.post("/overlay")
    def overlay(body: FrameRef) -> dict:
        """The reprojected guide skeleton for one frame: the whole pose, per view.

        Every keypoint solved by [solve_keypoint][deeperfly.labels.check.solve_keypoint]
        and projected, a read-only reference drawn as a derived layer (ADR-0027), with
        any bone the animal cannot make repaired by
        [repair_bones][deeperfly.pose3d.bones.repair_bones] (ADR-0130). `points` is
        `[view][keypoint] -> [x, y] | null`, `points3d` the solved `[keypoint] -> [x,
        y, z] | null` they reproject (world millimeters, for the 3D view, ADR-0093),
        and `sources` what anchored each (`"gt"`, `"ray"`, `"detected"`, `"anatomy"`,
        `"prior"`, `"pose"` or `"none"`); all three empty without a rig,
        and every keypoint `"none"` in a frame that does not hold the animal
        (`EditorSession.holds`, ADR-0091). `weak` marks the keypoints the detections
        alone placed on less than `LOW_SUPPORT` of
        [consensus_support][deeperfly.pose3d.triangulation.consensus_support]: a 3D
        to doubt, drawn as a guess (ADR-0132). `checks` are
        [frame_checks][deeperfly.labels.check.frame_checks]' flags. `detected` is the
        detector 2D the solve read, as `/points` serves it: the slot the animal's
        clicks sit on, which a click can change (ADR-0128).
        """
        session = store.get(body.project)
        rig = session.rig()
        labels = session.labels
        slot = _check_instance(session, body.instance)
        _check_frame(session, body.frame)
        empty_checks: dict = {"outliers": [], "inconsistent": [], "bones": []}
        if rig is None:
            return {
                "points": [],
                "points3d": [],
                "sources": [],
                "weak": [],
                "checks": empty_checks,
            }
        gt = labels.frame_effective(body.frame, instance=slot)  # (V, K, 2)
        det, conf = session.detected(body.frame, slot)
        detected = None if det is None else _grid_points(det)
        if not session.holds(body.frame, slot, det):
            # Nothing in this frame says where the animal is: no guide (ADR-0091).
            none = np.full((len(session.view_names), labels.n_keypoints, 2), np.nan)
            return {
                "points": _grid_points(none),
                "points3d": [None] * labels.n_keypoints,
                "sources": ["none"] * labels.n_keypoints,
                "weak": [False] * labels.n_keypoints,
                "checks": empty_checks,
                "detected": detected,
            }
        if det is None:
            det = np.full_like(gt, np.nan)
        prior, from_pose = session.prior(body.frame, slot)  # (K, 3), absent NaN
        pts3d = np.full((labels.n_keypoints, 3), np.nan)
        sources = []
        ref = session.bone_reference()
        precision = session.view_precision()
        for k in range(labels.n_keypoints):
            conf_k = conf[:, k] if conf is not None else None
            x3, src = solve_keypoint(
                rig, gt[:, k], det[:, k], prior[k], conf_k, precision
            )
            # The default pose alone is not a guide unless the operator dropped the
            # instance into this frame -- see prior_3d and /seed.
            if src == "prior" and from_pose[k]:
                src = "pose" if (body.frame, slot) in session.seeded else "none"
                x3 = x3 if src == "pose" else None
            if x3 is not None:
                pts3d[k] = x3
            sources.append(src)
        # A keypoint whose bone the animal cannot make is re-placed at its bones' usual
        # lengths, or left out; the clicks that place their own keypoint stay
        # (ADR-0130).
        clicked = np.isfinite(gt).all(axis=-1)  # (V, K)
        weights = np.zeros(clicked.shape)
        if conf is not None:
            weights = np.where(conf >= DEFAULT_MIN_CONF, precision_from_conf(conf), 0.0)
        weights = np.where(clicked, 1.0, weights)
        held = np.array([src == "gt" for src in sources]) & placed_in_depth(
            rig, pts3d, clicked
        )
        pts3d, moved, dropped = repair_bones(
            rig,
            pts3d,
            np.where(clicked[..., None], gt, det),
            weights,
            session.skeleton.edges,
            ref[0],
            fixed=held,
        )
        for k in np.flatnonzero(moved):
            sources[k] = "anatomy"
        for k in np.flatnonzero(dropped):
            sources[k] = "none"
        # A keypoint the detections alone placed, on little: their consensus stands,
        # and reads as a guess (ADR-0132).
        weak = [False] * labels.n_keypoints
        if conf is not None and "detected" in sources:
            _, kept = detections_3d(rig, det, conf, precision)
            support = consensus_support(kept, conf)
            weak = [
                src == "detected" and bool(support[k] < LOW_SUPPORT)
                for k, src in enumerate(sources)
            ]
        return {
            "points": _grid_points(project_all(rig, session.view_names, pts3d)),
            "points3d": _cells(pts3d),
            "sources": sources,
            "weak": weak,
            "checks": frame_checks(rig, session.skeleton, ref, gt),
            "detected": detected,
        }

    @router.post("/checks")
    def checks(body: ProjectRef) -> dict:
        """The label checks of every labeled frame, worst first (ADR-0102).

        [label_checks][deeperfly.labels.check.label_checks] over the labels alone, so
        the pass is quick under the session lock; 400 without a rig.
        """
        session = store.get(body.project)
        rig = session.rig()
        if rig is None:
            raise _no_rig("project has no rig; cannot check labels", session.rig_error)
        return label_checks(
            rig, session.skeleton, session.bone_reference(), session.labels
        )

    @router.post("/save")
    def save(body: ProjectRef) -> dict:
        """Flush the in-memory labels to `labels.h5`; the undo stack survives it.

        The step on top of the stack becomes the saved one: an undo away from it is
        unsaved, and back to it is not.

        A write that fails answers 500 naming the file, with the session unchanged.
        """
        session = store.get(body.project)
        try:
            session.files.save(session.labels)
        except OSError as exc:
            # The labels stay dirty and their sidecar is kept: nothing was lost.
            path = session.files.labels_path
            raise HTTPException(
                status_code=500, detail=f"cannot save {path}: {exc}"
            ) from exc
        # The sidecar is gone and labels.h5 holds everything: nothing is unbacked.
        session.mark_saved()
        return {"recovered": False, **_edit_state(session)}

    @router.post("/undo")
    def undo(body: ProjectRef) -> dict:
        """Reverse the most recent edit to this project.

        `undone` names it (`null` on an empty stack) and `frame` is where to see it.
        The stack is per open session and in memory only.
        """
        session = store.get(body.project)
        step = session.undo()
        return {
            "undone": step.label if step else None,
            "frame": step.frame if step else None,
            **_edit_state(session),
        }

    @router.post("/redo")
    def redo(body: ProjectRef) -> dict:
        """Redo the most recently undone edit; `redone` and `frame` as `/undo`'s."""
        session = store.get(body.project)
        step = session.redo()
        return {
            "redone": step.label if step else None,
            "frame": step.frame if step else None,
            **_edit_state(session),
        }

    @router.post("/discard")
    def discard(body: ProjectRef) -> dict:
        """Set every unsaved edit aside, reloading the project from `labels.h5`.

        The undo stack is cleared; `/restore-discarded` puts the edits back until the
        next edit or save (ADR-0120).
        """
        session = store.get(body.project)
        try:
            session.discard_unsaved()
        except KeyError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        return {"recovered": False, **_edit_state(session)}

    @router.get("/unsaved")
    def unsaved(project: Annotated[list[str], Query()]) -> dict:
        """What each project holds that `labels.h5` does not (ADR-0120).

        `project` names each project by its folder. Each row has `dirty`, whether
        anything is unsaved; `frames`, the frames whose labels differ from
        `labels.h5`'s; `recovered`, whether they came back from a previous session;
        and `discarded`, whether a discard can be undone. An open session answers for
        itself, a closed project from its sidecar; one that cannot be read carries its
        `error` and counts as clean.
        """
        return {"projects": [_unsaved_row(store, folder) for folder in project]}

    @router.post("/save-all")
    def save_all(body: ProjectsRef) -> dict:
        """Save every named project with unsaved labels (see `/discard-all`)."""
        return _for_each_unsaved(store, body.projects, _save_project)

    @router.post("/discard-all")
    def discard_all(body: ProjectsRef) -> dict:
        """Discard every named project's unsaved labels, each kept for a restore.

        `done` lists the folders acted on, `errors` maps a folder to what stopped it,
        and `states` holds each open session's `_edit_state` afterwards.
        """
        return _for_each_unsaved(store, body.projects, _discard_project)

    @router.post("/restore-discarded")
    def restore_discarded(body: ProjectRef) -> dict:
        """Put back what the project's last discard set aside, as unsaved labels.

        The session is closed, so the next request reopens it from the sidecar, marked
        recovered; its undo history is gone. 409 when nothing is kept: an edit or a
        save since made the discard final.
        """
        project = open_project(body.project)
        files = _stored_files(project)
        store.evict(project.folder)
        if not files.restore_discarded():
            raise HTTPException(
                status_code=409, detail="nothing to restore: edited or saved since"
            )
        return {"restored": True}

    @router.post("/revert-frame")
    def revert_frame(body: FrameRef) -> dict:
        """Put one frame back as `labels.h5` holds it, every animal of it.

        An undoable edit like any other, so the labels stay unsaved until a save.
        """
        session = store.get(body.project)
        _check_frame(session, body.frame)
        labels, saved = session.labels, session.saved
        assert saved is not None
        t = body.frame
        if t not in session.unsaved_frames():
            return _edit_state(session)
        slots = sorted(set(labels.frame_instances(t)) | set(saved.frame_instances(t)))

        def snapshot() -> Callable[[], None]:
            restores = [_snapshot_instance(labels, t, i) for i in slots]

            def restore() -> None:
                for put_back in restores:
                    put_back()

            return restore

        undo = snapshot()
        for i in slots:
            was = {
                "points": saved.frame(t, instance=i),
                "hidden": saved.hidden_frame(t, instance=i),
                "absent": saved.absent_frame(t, instance=i),
            }
            _restore_instance(labels, t, i, was)
        session.record(f"revert frame {t}", t, snapshot, undo)
        session.autosave()
        return _edit_state(session)

    @router.get("/suggestions")
    def suggestions(project: str) -> dict:
        """The project's label suggestions, minus the joints labeled since.

        `available` is false without a `suggestions.json`. `runs` lists the project's
        runs, `results/<name>.h5`, newest first, with whether each carries `pose2d`
        and `pose3d`.
        """
        session = store.get(project)
        return _suggestions_payload(session)

    @router.get("/suggestions/all")
    def suggestions_all(project: Annotated[list[str], Query()]) -> dict:
        """Every project's open suggestions pooled into one list, worst frame first.

        `project` names each project of the collection by its folder. Each list is
        pruned against its open session's labels, or the saved ones, and pooled by
        `_pool`, each pick tagged with its project's folder and name; `projects`
        accounts for every project, with the `error` that kept one out.
        """
        return _pooled(store, [open_project(folder) for folder in project])

    @frames.post("/suggest")
    def suggest(body: SuggestIn) -> dict:
        """Compute the project's suggestions here and write `suggestions.json`.

        Scored from a snapshot of this session's labels, unsaved edits included, taken
        under the store's lock; the scoring runs outside it (ADR-0111), so an edit or a
        frame step made meanwhile is served at once. The answer is `/suggestions`', at
        the moment the file was written: a joint labeled since is already settled in it.
        A project closed meanwhile (a skeleton or views rewrite) is a 409, and writes
        nothing.
        """
        with store.lock:
            session = store.get(body.project)
            _check_suggest(body)
            scoring = _Scoring.of_session(session, body)
        document = _score(scoring, body)
        with store.lock:
            _check_still_open(store, session)
            write_suggestions(session.project.folder / SUGGESTIONS_FILENAME, document)
            return _suggestions_payload(session)

    @frames.post("/suggest/all")
    def suggest_all(body: SuggestAllIn) -> dict:
        """Compute and write every project's suggestions, then pool them.

        One `/suggest` per project of `projects`, with the same method and
        controls: an open session scores a snapshot of its labels, unsaved edits
        included, and a project with none is opened for the call and closed after.
        Each project is scored outside the store's lock and written under it. A project
        the method cannot score keeps its old `suggestions.json` and says why in its
        account's `error`; the answer is `/suggestions/all`'s over the same projects.
        """
        _check_suggest(body)
        with store.lock:
            projects = [open_project(folder) for folder in body.projects]
        errors: dict[str, str] = {}
        for proj in projects:
            try:
                _suggest_project(store, proj, body)
            except HTTPException as exc:
                errors[str(proj.folder)] = str(exc.detail)
            except (OSError, ValueError, KeyError) as exc:
                errors[str(proj.folder)] = str(exc)
        with store.lock:
            pooled = _pooled(store, projects)
        for account in pooled["projects"]:
            if account["project"] in errors:
                account["error"] = errors[account["project"]]
        return pooled

    @frames.get("/checks/all")
    def checks_all(project: Annotated[list[str], Query()]) -> dict:
        """Every project's label checks pooled into one list, worst first.

        `project` names each project of the collection by its folder. An open
        session's labels are checked as they stand, unsaved edits included, from a
        snapshot taken under the store's lock; a project without one is checked on its
        saved labels, opened for the call. The checks run outside the lock, as
        `/suggest/all`'s scoring does (ADR-0111). Each flag of `flags` is `/checks`'
        tagged with its `project` (the folder) and its `name`; `projects` accounts for
        every project, `{project, name, available, flags, frames_checked, views,
        keypoints, animals}` (the names its flags' indices stand for), with the
        `error` that kept one out (no usable calibration).
        """
        with store.lock:
            projects = [open_project(folder) for folder in project]
        try:
            names = project_names([p.folder for p in projects])
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc

        def one(proj: Project) -> dict | str:
            try:
                out = _project_checks(store, proj)
                if isinstance(out, dict):
                    out["keypoints"] = proj.skeleton().point_names
                return out
            except (OSError, ValueError, KeyError, ConfigError) as exc:
                return str(exc)

        # A project's checks are about a second, most of it outside numpy's reach;
        # a few at a time take a collection's in a fraction of it.
        with ThreadPoolExecutor(max_workers=CHECK_WORKERS) as pool:
            answers = list(pool.map(one, projects))
        accounts, flags = [], []
        for proj, name, out in zip(projects, names, answers, strict=True):
            folder = str(proj.folder)
            account: dict = {
                "project": folder,
                "name": name,
                "available": False,
                "flags": 0,
                "frames_checked": 0,
            }
            accounts.append(account)
            if isinstance(out, str):
                account["error"] = out
                continue
            account.update(
                available=True,
                flags=len(out["flags"]),
                frames_checked=out["frames_checked"],
                views=list(proj.views),
                keypoints=list(out["keypoints"]),
                animals=proj.animals,
            )
            flags += [{**f, "project": folder, "name": name} for f in out["flags"]]
        flags.sort(key=lambda f: -f["severity"])  # stable: each project's order kept
        return {"projects": accounts, "flags": flags}

    @frames.get("/labeled/all")
    def labeled_all(project: Annotated[list[str], Query()]) -> dict:
        """Every project's labeled frames in one list, by project, then frame.

        `project` names each project of the collection by its folder, in its order.
        An open session answers with its labels as they stand, unsaved edits
        included; a closed project with the labels it would open with, the recovery
        sidecar's when that is newer, read without a video. Each row of `frames` is
        `{project, name, frame, labels, short, unsaved}`: `labels` and `short` are
        the state's `labeled_counts` and `labeled_open`, `unsaved` whether the frame
        differs from `labels.h5`. `projects` accounts for every project, `{project,
        name, available, frames, complete}`, with the `error` that kept one out.
        """
        try:
            names = project_names(project)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        accounts, rows = [], []
        for folder, name in zip(project, names, strict=True):
            account: dict = {
                "project": folder,
                "name": name,
                "available": False,
                "frames": 0,
                "complete": 0,
            }
            accounts.append(account)
            try:
                frames_, counts, short, unsaved = _project_labeled(store, folder)
            except (HTTPException, OSError, ValueError, KeyError) as exc:
                detail = exc.detail if isinstance(exc, HTTPException) else str(exc)
                account["error"] = str(detail)
                continue
            account.update(
                available=True,
                frames=len(frames_),
                complete=sum(1 for n in short if n == 0),
            )
            rows += [
                {
                    "project": folder,
                    "name": name,
                    "frame": t,
                    "labels": n,
                    "short": k,
                    "unsaved": t in unsaved,
                }
                for t, n, k in zip(frames_, counts, short, strict=True)
            ]
        return {"projects": accounts, "frames": rows}

    frames.include_router(router)
    return frames

open_project

open_project(folder: str | Path) -> Project

The project in folder, a 404 naming what is wrong when there is none.

Source code in src/deeperfly/gui/labeling.py
def open_project(folder: str | Path) -> Project:
    """The project in `folder`, a 404 naming what is wrong when there is none."""
    try:
        return Project.load(folder)
    except (FileNotFoundError, ConfigError) as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

run_summary

run_summary(info: RunInfo) -> dict

One run as the panels list it (JSON-friendly).

written is the run's write time in unix seconds and written_at the same as a local ISO stamp; stretches are the [start, stop) ranges its pose2d covers; pose3d lists the 3D stages it holds, most refined first. A run that cannot be a source of detections has usable false and says why.

Source code in src/deeperfly/gui/labeling.py
def run_summary(info: RunInfo) -> dict:
    """One run as the panels list it (JSON-friendly).

    `written` is the run's write time in unix seconds and `written_at` the same as
    a local ISO stamp; `stretches` are the `[start, stop)` ranges its pose2d
    covers; `pose3d` lists the 3D stages it holds, most refined first. A run that
    cannot be a source of detections has `usable` false and says `why`.
    """
    written_at = None
    if info.written:
        stamp = datetime.fromtimestamp(info.written, tz=UTC).astimezone()
        written_at = stamp.isoformat(timespec="seconds")
    return {
        "name": info.name,
        "usable": info.usable,
        "why": info.why,
        "animals": info.animals,
        "stages": list(info.stages),
        "pose2d": "pose2d" in info.stages,
        "pose3d": [s for s in POSE3D_STAGES if s in info.stages],
        "covered": info.covered_count,
        "stretches": [list(r) for r in info.stretches],
        "written": info.written,
        "written_at": written_at,
    }

The Runs panel: a project's runs, where its detections come from (ADR-0084).

Each run is results/<name>.h5 beside the config it was run with (ADR-0082). The panel lists every run with its coverage, write time and stretches, the config and calibration text the file stores, and how many frames it wins in the merged detections (ADR-0083); it saves a precedence override to the manifest, marks the winners' track flags on the frame slider, draws a run's final 3D reprojected through that run's own calibration, and imports that calibration into the project. Runs are read-only apart from swap, which exchanges two animals of a run in place and drops the stages computed from the old assignment. Every route names its project by its folder, project=<folder>, as the labeling routes do.

PrecedenceIn

Bases: BaseModel

The runs that supply the detections, first wins; empty clears the override.

Source code in src/deeperfly/gui/runs.py
class PrecedenceIn(BaseModel):
    """The runs that supply the detections, first wins; empty clears the override."""

    project: str
    runs: list[str]

RunRef

Bases: BaseModel

One run of one project, as /runs lists it.

Source code in src/deeperfly/gui/runs.py
class RunRef(BaseModel):
    """One run of one project, as `/runs` lists it."""

    project: str
    run: str

SwapIn

Bases: RunRef

Exchange animals a and b of a run over the frames [start, stop).

Source code in src/deeperfly/gui/runs.py
class SwapIn(RunRef):
    """Exchange animals `a` and `b` of a run over the frames `[start, stop)`."""

    a: int = 0
    b: int = 1
    start: int = 0
    stop: int | None = None

build_runs_router

build_runs_router(store: SessionStore) -> APIRouter

The Runs panel's routes, bound to the sessions' store, under its lock.

Source code in src/deeperfly/gui/runs.py
def build_runs_router(store: SessionStore) -> APIRouter:
    """The Runs panel's routes, bound to the sessions' `store`, under its lock."""
    router = SessionRouter(store, prefix="/api/labeling/runs")

    @router.get("")
    def runs(project: str) -> dict:
        """Every run of the project and the precedence that merges them.

        Each run is [run_summary][deeperfly.gui.labeling.run_summary] plus `wins`,
        the frames it serves in the merged detections, `config`, the run config
        text it stores, `calibration`, the camera parameters it started from as
        TOML (`null` without any), and `solved`, those bundle adjustment refined
        (`null` without the stage); `error` says why a file could not be read for
        them. `precedence` is the manifest's `[detections] runs`, empty when the
        latest-written run wins, and `runs_error` why a run gave no detections.
        """
        return _runs_payload(store.get(project))

    @router.post("/precedence")
    def precedence(body: PrecedenceIn) -> dict:
        """Save a precedence override to the manifest and answer as `/runs` does.

        An empty list clears it, so the latest-written covering run wins again.
        """
        session = store.get(body.project)
        try:
            # Edited from the manifest on disk, not the session's copy, so a write
            # that landed since the session last read it is kept (ADR-0090).
            set_detection_precedence(open_project(body.project), body.runs)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        store.refresh_geometry(session.project.folder)
        return _runs_payload(session)

    @router.get("/flags")
    def flags(project: str) -> dict:
        """The merged detections' track flags, as ranges the frame slider marks.

        `covered` is the `[start, stop)` ranges some run serves; per animal,
        `held` and `lost` are the ranges of covered frames the tracker held or lost
        it on. An uncovered frame is neither: it has no detections to flag.
        """
        session = store.get(project)
        detections = session.detections
        track = detections.track_flags()  # revalidates
        covered = detections.winner >= 0
        return {
            "n_frames": session.n_frames,
            "covered": _ranges(covered),
            "animals": [
                {
                    "held": _ranges(covered & (row == HELD)),
                    "lost": _ranges(covered & (row == LOST)),
                }
                for row in track
            ],
            "runs_error": detections.runs_error,
        }

    @router.get("/pose3d")
    def pose3d(project: str, run: str, frame: int, stage: str | None = None) -> dict:
        """One frame of a run's 3D, reprojected through the run's own calibration.

        `stage` is one of `ik`, `correction`, `smoothing` and `pose3d`, by default
        the most refined the run holds. The calibration is the one bundle adjustment
        solved, else the one the run started from (`calibration` says which).
        `instances` holds, per animal, `points` as `[view][keypoint] -> [x, y] |
        null` in the project's view and keypoint order, null where the stage did
        not cover the frame, and the 3D they reproject as `points3d`, `[keypoint] ->
        [x, y, z] | null` in the run's own world (ADR-0093).
        """
        session = store.get(project)
        path = _run_results(session.project, run)
        with session.detections.lock, _open_run(path) as res:
            held = [s for s in POSE3D_STAGES if res.has(f"{s}/points")]
            if stage is None:
                if not held:
                    raise HTTPException(
                        status_code=400, detail=f"run {run!r} holds no 3D"
                    )
                stage = held[0]
            elif stage not in held:
                raise HTTPException(
                    status_code=400,
                    detail=f"run {run!r} has no {stage} points; it holds "
                    + (", ".join(held) or "no 3D"),
                )
            if not 0 <= frame < res.n_frames:
                raise HTTPException(
                    status_code=400,
                    detail=f"frame {frame} is outside 0..{res.n_frames - 1}",
                )
            calib = res.calibration()
            solved = res.has(_SOLVED)
            if calib is None:
                raise HTTPException(
                    status_code=400,
                    detail=f"run {run!r} stores no calibration to reproject with",
                )
            try:
                km = name_positions(
                    session.skeleton.point_names, res.keypoints, "keypoint"
                )
            except ValueError as exc:
                raise HTTPException(
                    status_code=400,
                    detail=f"run {run!r} was run against another skeleton",
                ) from exc
            covered = bool(res.covered(stage)[frame])
            pts = np.asarray(res.read_frame(f"{stage}/points", frame), dtype=float)
        rig = _rig(session, calib)
        pts = pts[:, km]  # (N, K, 3)
        if not covered:
            pts[:] = np.nan
        names = session.view_names
        return {
            "run": run,
            "stage": stage,
            "stages": held,
            "calibration": "solved" if solved else "initial",
            "instances": [
                {
                    "instance": n,
                    "points": _grid_points(
                        np.stack([rig.views[v].project(one) for v in names])
                    ),
                    "points3d": _cells(one),
                }
                for n, one in enumerate(pts)
            ],
        }

    @router.post("/swap")
    def swap(body: SwapIn) -> dict:
        """Exchange two animals of a run from a frame on, in place.

        [swap_animals][deeperfly.results.swap_animals]: pose2d is swapped over
        `[start, stop)`, every later stage dropped (a rerun of the same config under
        the run's name recomputes them), and the run's write time stamped, so it
        wins precedence where it covers. A run another process holds open for
        writing is a 409, a file of another results format a 400. Answers as
        `/runs` does, plus `dropped`.
        """
        session = store.get(body.project)
        path = _run_results(session.project, body.run)
        # The session's lock keeps its other requests from reopening the run
        # between the release and the write's close (labels/detections.py).
        with session.detections.lock:
            session.detections.release(body.run)
            with _open_run(path, "r+", action="swapped") as res:
                try:
                    dropped = swap_animals(res, body.a, body.b, body.start, body.stop)
                except ValueError as exc:
                    raise HTTPException(status_code=400, detail=str(exc)) from exc
        return {"dropped": dropped, **_runs_payload(session)}

    @router.post("/import-calibration")
    def import_calibration(body: RunRef) -> dict:
        """Add a run's calibration to the project's, labeled after the run.

        The solved parameters when the run adjusted them, else the ones it started
        from, written as the next numbered calibration under `calibrations/`. Its
        `refined_from` is the project calibration the run's starting values were
        copied from, when they name one (ADR-0086); an unsolved import is a plain
        copy of them, not solved. It is not made active: the Calibration panel does
        that.
        """
        session = store.get(body.project)
        proj = session.project
        path = _run_results(proj, body.run)
        with session.detections.lock, _open_run(path) as res:
            calib = res.calibration()
            initial = res.initial_calibration()
            solved = res.has(_SOLVED)
        if calib is None:
            raise HTTPException(
                status_code=400, detail=f"run {body.run!r} stores no calibration"
            )
        update: dict = {"label": body.run, "copied_from": None}
        if not solved:
            # `calib` is the initial copy, which carries its source's own provenance.
            update.update(
                solved=False,
                fingerprint=None,
                refined_from=None if initial is None else initial.copied_from,
            )
        calib = calib.model_copy(update=update)
        calib = _with_manifest_mirrored(proj, calib)
        try:
            placed = proj.placed_views(calib)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        cid = write_calibration(
            proj.calibrations_dir, calib.model_copy(update={"views": placed})
        )
        return {
            "id": cid,
            "label": calib.label,
            "solved": calib.solved,
            "refined_from": calib.refined_from,
        }

    return router

Launch the GUI on a local port (deeperfly gui).

uvicorn is imported only when serve runs, bound to localhost (reach a headless host with ssh -L), and run through uvicorn.Server so /api/quit can trip its exit flag. The server process also keeps the kernels JAX compiles across runs (ADR-0111), and is the user's only one: a second launch opens a tab on it (ADR-0123).

compilation_cache_dir

compilation_cache_dir() -> Path

Where the GUI server keeps the kernels JAX compiled.

jax/<version> in deeperfly's cache (cache_dir). A JAX of another version starts an empty directory, so an empty one means the kernels are not compiled yet (ADR-0111), and an old one can be deleted.

Source code in src/deeperfly/gui/server.py
def compilation_cache_dir() -> Path:
    """Where the GUI server keeps the kernels JAX compiled.

    `jax/<version>` in deeperfly's cache ([cache_dir][deeperfly.cache.cache_dir]). A
    JAX of another version starts an empty directory, so an empty one means the
    kernels are not compiled yet (ADR-0111), and an old one can be deleted.
    """
    import jax

    return cache_dir() / "jax" / jax.__version__

enable_compilation_cache

enable_compilation_cache() -> None

Keep the kernels JAX compiles in compilation_cache_dir(), for the next run.

A fresh server compiles a second and a half of them at a project's first frame; a run that finds them cached skips it (ADR-0111). Only the GUI server does this, so import deeperfly, build_app and the tests leave the user's cache alone. A directory that cannot be made, or a home that cannot be found, only costs the compiles it would have saved.

Source code in src/deeperfly/gui/server.py
def enable_compilation_cache() -> None:
    """Keep the kernels JAX compiles in `compilation_cache_dir()`, for the next run.

    A fresh server compiles a second and a half of them at a project's first frame;
    a run that finds them cached skips it (ADR-0111). Only the GUI server does this,
    so `import deeperfly`, `build_app` and the tests leave the user's cache alone. A
    directory that cannot be made, or a home that cannot be found, only costs the
    compiles it would have saved.
    """
    import jax

    try:
        path = compilation_cache_dir()
        path.mkdir(parents=True, exist_ok=True)
    except OSError, RuntimeError:
        return
    jax.config.update("jax_compilation_cache_dir", str(path))
    # Most of these kernels compile in well under the default second: the first
    # /cameras alone is forty of them.
    jax.config.update("jax_persistent_cache_min_compile_time_secs", 0)

serve

serve(
    host: str = "127.0.0.1",
    port: int | None = None,
    *,
    open_browser: bool = True,
    paths: Sequence[str | Path] = (),
) -> None

Run the GUI server until quit, the browser opening on start_page(paths).

A server of this user already running here gets the page instead, as a new tab, and this returns at once (ADR-0123). port None is 8000 or the next free port.

Raises:

Type Description
RuntimeError

If a server is up but does not answer.

OSError

If no port could be bound.

Source code in src/deeperfly/gui/server.py
def serve(
    host: str = "127.0.0.1",
    port: int | None = None,
    *,
    open_browser: bool = True,
    paths: Sequence[str | Path] = (),
) -> None:
    """Run the GUI server until quit, the browser opening on `start_page(paths)`.

    A server of this user already running here gets the page instead, as a new tab,
    and this returns at once (ADR-0123). `port` `None` is 8000 or the next free port.

    Raises:
        RuntimeError: If a server is up but does not answer.
        OSError: If no port could be bound.
    """
    answering, silent = running()
    if answering:
        url = answering[0].base_url + start_page(paths)
        print(f"deeperfly gui already serving; opening {url}")
        if open_browser:
            _open_browser(url)
        return
    if silent:
        raise RuntimeError(
            f"a deeperfly gui server (pid {silent[0].pid}) is not answering; stop it "
            "with `deeperfly gui --stop`"
        )

    import uvicorn

    sock = bind(host, port)
    bound: int = sock.getsockname()[1]
    enable_compilation_cache()
    server: uvicorn.Server | None = None

    def request_stop() -> None:
        """Ask uvicorn to shut down after the in-flight request finishes."""
        if server is not None:
            server.should_exit = True

    me = Server(os.getpid(), host, bound, socket.gethostname())
    url = me.base_url + start_page(paths)
    # uvicorn logs startup and every request at INFO; raise it to WARNING and
    # print the one useful line (the URL) ourselves, so the console stays quiet.
    print(f"deeperfly gui serving on {url} (Quit in the browser, or Ctrl+C)")
    config = uvicorn.Config(build_app(on_quit=request_stop), log_level="warning")
    server = uvicorn.Server(config)
    recorded = record(me)
    try:
        if open_browser:
            _open_browser(url)
        server.run(sockets=[sock])
    finally:
        recorded.unlink(missing_ok=True)
        sock.close()

start_page

start_page(paths: Sequence[str | Path] = ()) -> str

The page deeperfly gui PATH ... opens, as a path and query (ADR-0081).

No path is the launcher; one directory of videos without a project is the launcher's setup form over it, as the launcher offers it: video files, or folders of stills the form proposes as videos; paths that stand for projects, each a project or a directory of them, open the workspace on their collection.

Raises:

Type Description
ValueError

If a path stands for no project and is not that directory.

Source code in src/deeperfly/gui/server.py
def start_page(paths: Sequence[str | Path] = ()) -> str:
    """The page `deeperfly gui PATH ...` opens, as a path and query (ADR-0081).

    No path is the launcher; one directory of videos without a project is the
    launcher's setup form over it, as the launcher offers it: video files, or
    folders of stills the form proposes as videos; paths that stand for projects,
    each a project or a directory of them, open the workspace on their collection.

    Raises:
        ValueError: If a path stands for no project and is not that directory.
    """
    if not paths:
        return "/"
    given = [str(normalized(p)) for p in paths]
    if len(given) == 1:
        kind = classify(given[0])
        if kind == "videos" or (kind == "empty" and group_videos(Path(given[0]))):
            return "/?" + urlencode({"setup": given[0]})
    Collection.open(given)
    return "/workspace?" + urlencode([("directory", p) for p in given])

The setup form's routes: make a project folder over some videos (ADR-0081).

The form replaces "create a project" and "add a recording" with one step: the folder (<videos>/deeperfly by default), which videos it works on (grouped from a directory, or typed as file patterns), which views each video carries and which are mirrored, the skeleton (a preset or custom TOML) and the animal count. It writes the folder's deeperfly.toml, and skeleton.toml for custom TOML, and never overwrites either; a manifest whose videos do not open, or disagree on their frame count, is taken back before the form answers.

SetupIn

Bases: BaseModel

A project to create: the manifest's facts, as the setup form collects them.

videos maps each video name to its file patterns, each absolute or relative to directory, the directory the form grouped them from, or to folder without one. layout names the views each video carries; a video it leaves out, or that carries one view of its own name, is one view per video. skeleton_toml, custom TOML, takes precedence over the preset skeleton.

Source code in src/deeperfly/gui/setup.py
class SetupIn(BaseModel):
    """A project to create: the manifest's facts, as the setup form collects them.

    `videos` maps each video name to its file patterns, each absolute or relative to
    `directory`, the directory the form grouped them from, or to `folder` without
    one. `layout` names the views each video carries; a video it leaves out, or
    that carries one view of its own name, is one view per video. `skeleton_toml`,
    custom TOML, takes precedence over the preset `skeleton`.
    """

    folder: str
    directory: str | None = None
    videos: dict[str, list[str]]
    layout: dict[str, list[str]] = Field(default_factory=dict)
    mirrored: list[str] = Field(default_factory=list)
    skeleton: str = "fly38"
    skeleton_toml: str | None = None
    animals: int = 1

build_setup_router

build_setup_router() -> APIRouter

The /api/setup router: the form's defaults and the create step.

Source code in src/deeperfly/gui/setup.py
def build_setup_router() -> APIRouter:
    """The `/api/setup` router: the form's defaults and the create step."""
    router = APIRouter(prefix="/api/setup")

    @router.get("/defaults")
    def defaults(directory: str) -> dict:
        """What the form starts from for a directory: a free folder and its videos."""
        base = normalized(directory)
        if not base.is_dir():
            raise HTTPException(status_code=404, detail=f"not a directory: {base}")
        return {
            "directory": str(base),
            "folder": str(free_folder(base)),
            "videos": group_videos(base),
        }

    @router.post("")
    def create(body: SetupIn) -> dict:
        """Write the project's manifest and check its videos open; answer its folder.

        Raises:
            HTTPException: 409 when the folder already holds a manifest, or a
                `skeleton.toml` that custom TOML would overwrite, 400 when the
                manifest breaks a rule or its videos do not open together.
        """
        if not body.folder.strip():
            raise HTTPException(status_code=400, detail="name the project's folder")
        folder = normalized(body.folder)
        directory = normalized(body.directory) if body.directory else None
        videos = {
            name.strip(): manifest_patterns(patterns, folder, directory)
            for name, patterns in body.videos.items()
        }
        existed = folder.exists()
        if body.skeleton_toml is not None and (folder / SKELETON_FILENAME).exists():
            raise HTTPException(
                status_code=409,
                detail=f"{folder / SKELETON_FILENAME} exists already; move it away "
                "to write a custom skeleton there",
            )
        try:
            project = write_manifest(
                folder,
                videos=videos,
                skeleton=body.skeleton,
                skeleton_toml=body.skeleton_toml,
                animals=body.animals,
                layout=_layout(body),
                mirrored=[v.strip() for v in body.mirrored],
            )
        except FileExistsError as exc:
            raise HTTPException(status_code=409, detail=str(exc)) from exc
        except (FileNotFoundError, ValueError) as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        try:
            open_recording(project)
        except (OSError, ValueError) as exc:
            # Written by this request a moment ago: taking it back overwrites nothing.
            project.manifest_path.unlink()
            if body.skeleton_toml is not None:
                (folder / SKELETON_FILENAME).unlink(missing_ok=True)
            if not existed and not any(folder.iterdir()):
                folder.rmdir()
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        return {"folder": str(project.folder)}

    return router

free_folder

free_folder(directory: Path) -> Path

The default project folder for a directory's videos, holding no manifest yet.

deeperfly, else the first of deeperfly_2, deeperfly_3, ... that is free.

Source code in src/deeperfly/gui/setup.py
def free_folder(directory: Path) -> Path:
    """The default project folder for a directory's videos, holding no manifest yet.

    `deeperfly`, else the first of `deeperfly_2`, `deeperfly_3`, ... that is free.
    """
    folder, n = directory / DEFAULT_PROJECT_DIRNAME, 2
    while (folder / MANIFEST_FILENAME).exists():
        folder, n = directory / f"{DEFAULT_PROJECT_DIRNAME}_{n}", n + 1
    return folder

group_videos

group_videos(directory: Path) -> dict[str, list[str]]

The videos a directory holds, as the form proposes them, in name order.

A video file is one video named by its stem; a subdirectory of stills of one file type is one video named by the subdirectory, <sub>/*<ext>. The patterns are relative to directory and glob-escaped; a second video of a name gets a numbered one.

Source code in src/deeperfly/gui/setup.py
def group_videos(directory: Path) -> dict[str, list[str]]:
    """The videos a directory holds, as the form proposes them, in name order.

    A video file is one video named by its stem; a subdirectory of stills of one file
    type is one video named by the subdirectory, `<sub>/*<ext>`. The patterns are
    relative to `directory` and glob-escaped; a second video of a name gets a
    numbered one.
    """
    out: dict[str, list[str]] = {}
    try:
        entries = sorted(directory.iterdir())
    except OSError:
        return out
    for entry in entries:
        if entry.name.startswith("."):
            continue
        if is_video_file(entry):
            name, pattern = entry.stem, glob.escape(entry.name)
        elif entry.is_dir():
            suffixes = _still_suffixes(entry)
            if len(suffixes) != 1:
                continue
            name = entry.name
            pattern = f"{glob.escape(entry.name)}/*{suffixes.pop()}"
        else:
            continue
        unique, n = name, 2
        while unique in out:
            unique, n = f"{name}_{n}", n + 1
        out[unique] = [pattern]
    return out

manifest_patterns

manifest_patterns(
    patterns: list[str],
    folder: Path,
    directory: Path | None = None,
) -> list[str]

File patterns as the manifest keeps them, relative to its folder when near.

A relative pattern is taken against directory when given, else it already is the folder's. A pattern under the folder's parent is written relative to the folder (a.mp4 from /data/rec for /data/rec/deeperfly is ../a.mp4), so the project moves with its videos; any other is absolute.

Source code in src/deeperfly/gui/setup.py
def manifest_patterns(
    patterns: list[str], folder: Path, directory: Path | None = None
) -> list[str]:
    """File patterns as the manifest keeps them, relative to its folder when near.

    A relative pattern is taken against `directory` when given, else it already is
    the folder's. A pattern under the folder's parent is written relative to the
    folder (`a.mp4` from `/data/rec` for `/data/rec/deeperfly` is `../a.mp4`), so
    the project moves with its videos; any other is absolute.
    """
    out = []
    for pattern in (p.strip() for p in patterns):
        typed = Path(pattern).expanduser()
        if typed.is_absolute():
            near = normalized(typed).is_relative_to(folder.parent)
            out.append(os.path.relpath(normalized(typed), folder) if near else pattern)
        elif directory is None:
            out.append(pattern)
        else:
            base = directory
            if directory.is_relative_to(folder.parent):
                base = Path(os.path.relpath(directory, folder))
            out.append(os.path.normpath(os.path.join(glob.escape(str(base)), pattern)))
    return out