Skip to content

config

The config documents, typed: the run config, the training config and their tables.

The schema is docs/config-schema.md, its conventions are ADR-0028 to ADR-0034 and the loader is pydantic (ADR-0059). The package is torch-free, so a config loads and fails without building a model (ADR-0032).

Layer module-attribute

Layer = Annotated[
    VideoLayer
    | SkeletonLayer
    | MeshLayer
    | BoxLayer
    | BallLayer
    | TraceLayer
    | TextLayer,
    Field(discriminator="draw"),
]

One entry of a video's layers, a table per draw kind (ADR-0059).

A key on the wrong kind, radius on a text layer, is an unknown key.

STAGE_ARRAYS module-attribute

STAGE_ARRAYS: dict[str, dict[str, tuple]] = {
    "pose2d": {
        "points": ("N", *VTK2),
        "conf": ("N", *VTK),
        "crops": ("N", *VT4),
        "candidates/xy": ("N", "V", "T", "K", "C", 2),
        "candidates/score": ("N", "V", "T", "K", "C"),
        "centers": ("N", "T", 3),
        "track_flags": ("N", "T"),
        "center_candidates": ("V", "T", "C", 3),
    },
    "bundle_adjustment": {
        "reproj_before": VTK,
        "reproj_after": VTK,
    },
    "pose3d": {
        "points": ("N", *TK3),
        "reproj_error": ("N", *VTK),
        "inliers": ("N", *VTK),
        "support": ("N", "T", "K"),
        "selected/points": ("N", *VTK2),
        "selected/conf": ("N", *VTK),
    },
    "smoothing": {
        "points": ("N", *TK3),
        "var": ("N", *TK3),
        "smooth_param": ("N", "K"),
    },
    "correction": {"points": ("N", *TK3)},
    "ik": {
        "angles": ("N", *TD),
        "points": ("N", *TK3),
        "chain_scale": ("N", "C"),
        "chain_offset": ("N", "C", 3),
    },
    "ball": {
        "center": ("T", 3),
        "center_sd": ("T", 3),
        "radius": (),
        "rotation": ("T", 3),
        "step": ("T", 3),
        "axes": (3, 3),
        "tracks": TD,
    },
    "render": {},
}

Per stage, the arrays it writes into a run's results/<name>.h5 and their axes.

Every per-animal array leads with N (ADR-0052); those without it describe the recording: the center peaks, the bundle adjustment residuals, fitted on animal 0, and the treadmill ball.

AugmentSpec

Bases: Table

[augment]: the training-time augmentation, mild by default.

An absent table means no augmentation, an empty one these defaults: strong geometric augmentation and cutout cost contralateral accuracy in the previous training repo's measurements. The loop reads it as Augment. rig_symmetry replaces the mirror draw of a grouped sample with one symmetry of its rig (rig_symmetries): each view's picture flipped as a mirror or a turn of the world would flip it, which a flip of every view about its own axis is not on a rig whose cameras are turned against each other; flip_p then goes unused for calibrated projects.

Source code in src/deeperfly/config/train.py
class AugmentSpec(Table):
    """`[augment]`: the training-time augmentation, mild by default.

    An absent table means no augmentation, an empty one these defaults: strong
    geometric augmentation and cutout cost contralateral accuracy in the previous
    training repo's measurements. The loop reads it as
    [Augment][deeperfly.pose2d.training.augment.Augment]. `rig_symmetry` replaces
    the mirror draw of a grouped sample with one symmetry of its rig
    ([rig_symmetries][deeperfly.pose2d.training.dataset.rig_symmetries]):
    each view's picture flipped as a mirror or a turn of the world would flip it,
    which a flip of every view about its own axis is not on a rig whose cameras are
    turned against each other; `flip_p` then goes unused for calibrated projects.
    """

    degrees: float = 5.0
    scale: tuple[float, float] = (0.9, 1.1)
    aspect: tuple[float, float] = (1.0, 1.0)
    translate: tuple[float, float] = (0.03, 0.03)
    flip_p: float = 0.5
    rig_symmetry: bool = False
    brightness: float = 0.2
    contrast: float = 0.2
    gamma: tuple[float, float] | None = None
    blur_sigma: tuple[float, float] | None = None
    noise_std: float = 0.0
    photometric_p: float = 0.5

BallStage

Bases: Table

[ball]: track the treadmill ball under a tethered animal -> ball/*.

spintrack, an optional extra (deeperfly[ball]), tracks the ball's rotation in view's video; the ball's outline in the outline views places it on every frame, starting from where the 3D pose's leg tips rest on it, and the pose orients the animal on it (ball, ADR-0144). Everything it writes is in the world frame.

Attributes:

Name Type Description
view str

The view whose video spintrack tracks: one that sees the ball's texture, unmirrored.

outline tuple[str, ...]

View selector for the views whose outline of the ball places it; mirrored views are left out. Depth along a line of sight needs a view off it.

input str | None

The 3D the ball is started from and the animal oriented by; omitted, the latest upstream 3D.

contact tuple[str, ...]

Point selector for the points that rest on the ball in stance.

stand tuple[str, ...]

Point selector for the points whose centroid is where the animal stands, just above the ball.

front tuple[str, ...]

Point selector for the front of the body axis.

hind tuple[str, ...]

Point selector for the back of the body axis.

contact_frames int

How many opening frames of the 3D the ball's radius is fitted and the animal oriented in, as a ball can move in its holder later.

two_pass bool

Map the ball in a throwaway first pass, as spintrack run --two-pass does.

tracking dict[str, Any]

spintrack's [tracking] table, handed over as it is.

Source code in src/deeperfly/config/run.py
class BallStage(Table):
    """`[ball]`: track the treadmill ball under a tethered animal -> `ball/*`.

    spintrack, an optional extra (`deeperfly[ball]`), tracks the ball's rotation in
    `view`'s video; the ball's outline in the `outline` views places it on every frame,
    starting from where the 3D pose's leg tips rest on it, and the pose orients the
    animal on it ([ball][deeperfly.ball], ADR-0144). Everything it writes is in the
    world frame.

    Attributes:
        view: The view whose video spintrack tracks: one that sees the ball's texture,
            unmirrored.
        outline: View selector for the views whose outline of the ball places it;
            mirrored views are left out. Depth along a line of sight needs a view off
            it.
        input: The 3D the ball is started from and the animal oriented by; omitted,
            the latest upstream 3D.
        contact: Point selector for the points that rest on the ball in stance.
        stand: Point selector for the points whose centroid is where the animal
            stands, just above the ball.
        front: Point selector for the front of the body axis.
        hind: Point selector for the back of the body axis.
        contact_frames: How many opening frames of the 3D the ball's radius is fitted
            and the animal oriented in, as a ball can move in its holder later.
        two_pass: Map the ball in a throwaway first pass, as `spintrack run
            --two-pass` does.
        tracking: spintrack's `[tracking]` table, handed over as it is.
    """

    view: str
    outline: tuple[str, ...] = ("*",)
    input: str | None = None
    contact: tuple[str, ...] = ("*_pretarsus",)
    stand: tuple[str, ...] = ("*_thorax_coxa",)
    front: tuple[str, ...] = ("lf_thorax_coxa", "rf_thorax_coxa")
    hind: tuple[str, ...] = ("lh_thorax_coxa", "rh_thorax_coxa")
    contact_frames: int = Field(1000, gt=0)
    two_pass: bool = False
    tracking: dict[str, Any] = Field(default_factory=dict)

BundleAdjustmentStage

Bases: Table

[bundle_adjustment]: refine [calibration] on this project.

It starts from the parameters [calibration] gives and hands the refined rig to the 3D stages after it; the result is the run's bundle_adjustment/views, a raw view per view that a [calibration.views.<name>] table accepts. Cached like any stage, so --force re-solves from [calibration] rather than refining the refinement (ADR-0086).

Attributes:

Name Type Description
source Literal['pose2d/points', 'labels/points']

Which 2D to fit: the detector's, or the project's hand labels (which need no [pose2d]).

points tuple[str, ...]

Point selector to fit on; empty uses every point.

fixed tuple[str, ...]

Solver references held constant ("*.intr", "f.tvec[2]"); empty holds every view's intrinsics and distortion plus the first view's pose, which anchors the world frame.

shared tuple[tuple[str, ...], ...]

Groups of references tied to one value.

frames int

How many labeled or predicted frames to sample, evenly.

weighted bool

Weight each residual by the detector's confidence.

loss Literal['linear', 'soft_l1', 'huber', 'cauchy', 'arctan']

Robust loss for the solver.

threshold_px float

Inlier margin of the robust loss, in pixels.

max_nfev int

Maximum solver function evaluations.

Source code in src/deeperfly/config/run.py
class BundleAdjustmentStage(Table):
    """`[bundle_adjustment]`: refine `[calibration]` on this project.

    It starts from the parameters `[calibration]` gives and hands the refined rig to
    the 3D stages after it; the result is the run's `bundle_adjustment/views`, a raw
    view per view that a `[calibration.views.<name>]` table accepts. Cached like any
    stage, so `--force` re-solves from `[calibration]` rather than refining the
    refinement (ADR-0086).

    Attributes:
        source: Which 2D to fit: the detector's, or the project's hand labels
            (which need no `[pose2d]`).
        points: Point selector to fit on; empty uses every point.
        fixed: Solver references held constant (`"*.intr"`, `"f.tvec[2]"`); empty
            holds every view's intrinsics and distortion plus the first view's pose,
            which anchors the world frame.
        shared: Groups of references tied to one value.
        frames: How many labeled or predicted frames to sample, evenly.
        weighted: Weight each residual by the detector's confidence.
        loss: Robust loss for the solver.
        threshold_px: Inlier margin of the robust loss, in pixels.
        max_nfev: Maximum solver function evaluations.
    """

    source: Literal["pose2d/points", "labels/points"] = "pose2d/points"
    points: tuple[str, ...] = ()
    fixed: tuple[str, ...] = ()
    shared: tuple[tuple[str, ...], ...] = ()
    frames: int = 200
    weighted: bool = False
    loss: Literal["linear", "soft_l1", "huber", "cauchy", "arctan"] = "huber"
    threshold_px: float = 8.0
    max_nfev: int = 200

    @model_validator(mode="after")
    def _rules(self):
        """Hand labels carry no confidence, so there is nothing to weight by."""
        if self.weighted and self.source == "labels/points":
            raise ValueError(
                "weighted: hand labels carry no confidence; weight the detector's "
                'output instead (source = "pose2d/points")'
            )
        return self

    def fixed_refs(self, views: list[str]) -> list[str]:
        """The references to hold; empty expands to the default for these views."""
        if self.fixed:
            return list(self.fixed)
        return ["*.intr", "*.dist", f"{views[0]}.rvec", f"{views[0]}.tvec"]

fixed_refs

fixed_refs(views: list[str]) -> list[str]

The references to hold; empty expands to the default for these views.

Source code in src/deeperfly/config/run.py
def fixed_refs(self, views: list[str]) -> list[str]:
    """The references to hold; empty expands to the default for these views."""
    if self.fixed:
        return list(self.fixed)
    return ["*.intr", "*.dist", f"{views[0]}.rvec", f"{views[0]}.tvec"]

Compute

Bases: Table

[compute]: the machine, not the run, so a committed config stays portable.

Every knob is a CLI flag too, and none changes what a run computes (ADR-0033).

Attributes:

Name Type Description
device str | None

"cuda" or "cpu"; None picks CUDA when it is available.

Source code in src/deeperfly/config/_base.py
class Compute(Table):
    """`[compute]`: the machine, not the run, so a committed config stays portable.

    Every knob is a CLI flag too, and none changes what a run computes (ADR-0033).

    Attributes:
        device: `"cuda"` or `"cpu"`; `None` picks CUDA when it is available.
    """

    device: str | None = None

ConfigError

Bases: ValueError

A document that cannot be loaded, reported as [table].key: what is wrong.

Source code in src/deeperfly/document.py
class ConfigError(ValueError):
    """A document that cannot be loaded, reported as `[table].key: what is wrong`."""

CorrectionStage

Bases: Table

[correction]: fix known anatomical violations -> correction/points.

Two fixed sub-tables, applied in that order.

Attributes:

Name Type Description
input str | None

The 3D to correct; omitted, the latest upstream 3D.

static StaticCorrection | None

[correction.static].

symmetrize SymmetrizeCorrection | None

[correction.symmetrize].

Source code in src/deeperfly/config/run.py
class CorrectionStage(Table):
    """`[correction]`: fix known anatomical violations -> `correction/points`.

    Two fixed sub-tables, applied in that order.

    Attributes:
        input: The 3D to correct; omitted, the latest upstream 3D.
        static: `[correction.static]`.
        symmetrize: `[correction.symmetrize]`.
    """

    input: str | None = None
    static: StaticCorrection | None = None
    symmetrize: SymmetrizeCorrection | None = None

    @model_validator(mode="after")
    def _rules(self):
        """Refuse a correction stage that corrects nothing."""
        if self.static is None and self.symmetrize is None:
            raise ValueError(
                "add [correction.static] or [correction.symmetrize]; on its own "
                "the table corrects nothing"
            )
        return self

IkStage

Bases: Table

[ik]: fit a body model to the 3D pose -> ik/angles, ik/points.

The solve is QuickIK's, an optional extra (deeperfly[ik]); see ik. model names a pack and binding where the skeleton's points sit on it (ADR-0041); damping and symmetric_segments are ADR-0065 and ADR-0066.

Attributes:

Name Type Description
input str | None

The 3D to fit; omitted, the latest upstream 3D.

model str

A packaged model pack ("neuromechfly") or a path to its model.toml or its directory, against the config's directory.

binding str | None

The "<skeleton>@<model>" binding, or a path to one against the config's directory; omitted, the pair the project's skeleton and this model name.

chains tuple[str, ...]

The model's non-leg chains to solve; empty solves every one the pack defines. The legs are always solved.

iterations int

Gauss-Newton steps per frame.

damping float

Levenberg-Marquardt damping of the step.

neutral_weight float

Pull toward each DOF's neutral value, which pins the DOFs the keypoints do not determine.

position_tolerance float

Early-stop threshold on the observations, in model units.

angle_tolerance float

Early-stop threshold on the angles, in radians.

fixed_body bool

Hold the body in the model frame and fit the legs to it (a tethered fly); false fits a 6-DOF root per frame.

symmetric_segments bool

One shared length per segment for each leg and its mirror.

weighted bool

Weight the observations by the detector's confidence.

bounds dict[str, tuple[float, float]]

Per-DOF limits in degrees, "<parent>-<child>-<dof>" = [lo, hi], for the legs and the chains alike.

markers dict[str, dict[str, Any]]

Per chain, point = { body, offset, depth?, base? }, overriding the binding; a chain's table replaces its whole marker set, so it must carry over a base = true.

Source code in src/deeperfly/config/run.py
class IkStage(Table):
    """`[ik]`: fit a body model to the 3D pose -> `ik/angles`, `ik/points`.

    The solve is QuickIK's, an optional extra (`deeperfly[ik]`); see
    [ik][deeperfly.ik]. `model` names a pack and `binding` where the skeleton's points
    sit on it (ADR-0041); `damping` and `symmetric_segments` are ADR-0065 and
    ADR-0066.

    Attributes:
        input: The 3D to fit; omitted, the latest upstream 3D.
        model: A packaged model pack (`"neuromechfly"`) or a path to its `model.toml`
            or its directory, against the config's directory.
        binding: The `"<skeleton>@<model>"` binding, or a path to one against the
            config's directory; omitted, the pair the project's skeleton and this
            model name.
        chains: The model's non-leg chains to solve; empty solves every one the pack
            defines. The legs are always solved.
        iterations: Gauss-Newton steps per frame.
        damping: Levenberg-Marquardt damping of the step.
        neutral_weight: Pull toward each DOF's neutral value, which pins the DOFs the
            keypoints do not determine.
        position_tolerance: Early-stop threshold on the observations, in model units.
        angle_tolerance: Early-stop threshold on the angles, in radians.
        fixed_body: Hold the body in the model frame and fit the legs to it (a
            tethered fly); false fits a 6-DOF root per frame.
        symmetric_segments: One shared length per segment for each leg and its mirror.
        weighted: Weight the observations by the detector's confidence.
        bounds: Per-DOF limits in degrees, `"<parent>-<child>-<dof>" = [lo, hi]`, for
            the legs and the chains alike.
        markers: Per chain, `point = { body, offset, depth?, base? }`, overriding the
            binding; a chain's table replaces its whole marker set, so it must carry
            over a `base = true`.
    """

    input: str | None = None
    model: str = "neuromechfly"
    binding: str | None = None
    chains: tuple[str, ...] = ()
    iterations: int = 60
    damping: float = 0.1
    neutral_weight: float = 1e-3
    position_tolerance: float = 1e-3
    angle_tolerance: float = 1e-3
    fixed_body: bool = True
    symmetric_segments: bool = True
    weighted: bool = False
    bounds: dict[str, tuple[float, float]] = Field(default_factory=dict)
    markers: dict[str, dict[str, Any]] = Field(default_factory=dict)

InitialCalibration

Bases: Table

[calibration]: the camera parameters a run starts from (ADR-0086).

Either a copy made at run start or placements written here, never both. A copy is of the values, so the run reproduces after the calibration files change; the values are what the geometry stages use, what their fingerprints hash and what the run stores.

Attributes:

Name Type Description
source int | str | None

from: "active" (the project's active calibration), a calibration id of the project, or a calibration .toml, relative to the config and resolved on load.

views dict[str, ViewSpec]

[calibration.views.<name>], keyed by the project's view names: an orbit or a raw placement each (ViewSpec); a value every view shares is written once under [calibration.views]. Which views are mirrored is the manifest's to say.

Source code in src/deeperfly/config/run.py
class InitialCalibration(Table):
    """`[calibration]`: the camera parameters a run starts from (ADR-0086).

    Either a copy made at run start or placements written here, never both. A copy
    is of the values, so the run reproduces after the calibration files change; the
    values are what the geometry stages use, what their fingerprints hash and what
    the run stores.

    Attributes:
        source: `from`: `"active"` (the project's active calibration), a calibration
            id of the project, or a calibration `.toml`, relative to the config and
            resolved on load.
        views: `[calibration.views.<name>]`, keyed by the project's view names: an
            orbit or a raw placement each ([ViewSpec][deeperfly.rig.ViewSpec]); a
            value every view shares is written once under `[calibration.views]`.
            Which views are mirrored is the manifest's to say.
    """

    source: int | str | None = Field(default=None, alias="from")
    views: dict[str, ViewSpec] = Field(default_factory=dict)

    @model_validator(mode="after")
    def _rules(self):
        """One source: a copy or placements, and a copy names what it copies."""
        if (self.source is None) == (not self.views):
            raise ValueError(
                'say where the run starts from, in one way: from = "active", a '
                "calibration id or a calibration .toml; or [calibration.views.<name>] "
                "placements"
            )
        if isinstance(self.source, int) and self.source < 1:
            raise ValueError(f"from: a calibration id counts from 1, not {self.source}")
        if (
            isinstance(self.source, str)
            and self.source != ACTIVE
            and not self.source.endswith(".toml")
        ):
            raise ValueError(
                f'from: {self.source!r} is neither "active", a calibration id nor a '
                "calibration .toml"
            )
        return self

    @property
    def file(self) -> Path | None:
        """The calibration file copied, when `from` names one."""
        if isinstance(self.source, str) and self.source != ACTIVE:
            return Path(self.source)
        return None

file property

file: Path | None

The calibration file copied, when from names one.

OutputSpec

Bases: Table

[render.outputs.<name>]: one output video, <name>.mp4.

Attributes:

Name Type Description
grid tuple[tuple[str, ...], ...]

Panel names, row by row: views, videos, synthetic views and blank panels. "" is an empty cell; every other name must fill exactly one axis-aligned rectangle, which it then spans.

layers tuple[Layer, ...]

What to draw, in order, back to front.

crop str | None

A (N,V,T,4) reference giving each view's panel its window ("pose2d/crops"), the region every animal's crop spans over the whole run; omitted shows the whole frame, as a video's panel always does.

frames FrameRange

[start, stop] or [start, stop, step], half-open, overriding [render] frames.

speed float | None

Playback rate, overriding [render] speed.

fps float | None

Encoded frame rate, overriding [render] fps.

cell tuple[int, int] | None

Cell size [height, width], overriding [render] cell.

max_size tuple[Side, Side] | None

The largest [height, width] written, overriding [render] max_size.

background str | None

Background color, overriding [render] background.

Source code in src/deeperfly/config/render.py
class OutputSpec(Table):
    """`[render.outputs.<name>]`: one output video, `<name>.mp4`.

    Attributes:
        grid: Panel names, row by row: views, videos, synthetic views and blank
            panels. `""` is an empty cell; every other name must fill exactly one
            axis-aligned rectangle, which it then spans.
        layers: What to draw, in order, back to front.
        crop: A `(N,V,T,4)` reference giving each view's panel its window
            (`"pose2d/crops"`), the region every animal's crop spans over the whole
            run; omitted shows the whole frame, as a video's panel always does.
        frames: `[start, stop]` or `[start, stop, step]`, half-open, overriding
            `[render] frames`.
        speed: Playback rate, overriding `[render] speed`.
        fps: Encoded frame rate, overriding `[render] fps`.
        cell: Cell size `[height, width]`, overriding `[render] cell`.
        max_size: The largest `[height, width]` written, overriding `[render]
            max_size`.
        background: Background color, overriding `[render] background`.
    """

    grid: tuple[tuple[str, ...], ...]
    layers: tuple[Layer, ...]
    crop: str | None = None
    frames: FrameRange = ()
    speed: float | None = None
    fps: float | None = None
    cell: tuple[int, int] | None = None
    max_size: tuple[Side, Side] | None = None
    background: str | None = None

    @model_validator(mode="after")
    def _rules(self):
        """Check the grid is rectangular, and that each name spans a rectangle."""
        if not self.grid or not self.grid[0]:
            raise ValueError("grid needs at least one cell")
        widths = {len(row) for row in self.grid}
        if len(widths) != 1:
            raise ValueError(
                f"grid rows must all be the same length, got {sorted(widths)}"
            )
        _check_spans(self.grid)
        return self

    @property
    def names(self) -> list[str]:
        """The distinct non-empty panel names, in reading order."""
        seen: list[str] = []
        for row in self.grid:
            for name in row:
                if name and name not in seen:
                    seen.append(name)
        return seen

names property

names: list[str]

The distinct non-empty panel names, in reading order.

Pose2dStage

Bases: Table

[pose2d]: run the detector over the video -> pose2d/*.

Several weights are an ensemble, collapsed by combine inside the forward pass, so every later stage sees one pose2d/points.

Attributes:

Name Type Description
weights tuple[ArtifactRef, ...]

Trained artifacts: paths relative to the config (ADR-0085), a directory meaning its model.pth, or released models by name (ADR-0151).

combine Literal['mean', 'median']

How the members collapse into points and conf.

tta bool

Also run each frame mirrored and average the two decodes (ADR-0045).

candidates int

Distinct peaks kept per (view, frame, keypoint) as candidates/xy and candidates/score, for the pictorial [pose3d] method; slot 0 is the decoded point, so 1 keeps nothing new and 0 is off.

crops dict[str, tuple[int, int, int, int] | tuple[tuple[int, int, int, int], ...]]

Fixed per-view windows [x0, y0, x1, y1], one per animal where the project holds several; omitted, they are discovered with the detector.

tightness float

How much wider than the animal a discovered window is, as the training's crop tightness measures it: the static windows, and the size a per-frame window keeps. No effect on fixed crops.

crop_aspect dict[str, float]

Per view, the aspect (width / height) of the detector's window instead of the input's, { east = 0.5 }; it must equal the [data] view_aspect the detector trained with.

window Literal['static', 'per_frame']

"static", one window per view and animal for the run (a tethered rig), or "per_frame", centered on the animal in every frame (a free-walking rig); either is written as pose2d/crops.

locator ArtifactRef | None

The full-frame locator a per-frame run needs where views share a frame (ADR-0064), named as weights are.

tracking Tracking

[pose2d.tracking], read only with animals > 1 and a per-frame window.

Source code in src/deeperfly/config/run.py
class Pose2dStage(Table):
    """`[pose2d]`: run the detector over the video -> `pose2d/*`.

    Several `weights` are an ensemble, collapsed by `combine` inside the forward pass,
    so every later stage sees one `pose2d/points`.

    Attributes:
        weights: Trained artifacts: paths relative to the config (ADR-0085), a
            directory meaning its `model.pth`, or released models by name
            (ADR-0151).
        combine: How the members collapse into `points` and `conf`.
        tta: Also run each frame mirrored and average the two decodes (ADR-0045).
        candidates: Distinct peaks kept per `(view, frame, keypoint)` as
            `candidates/xy` and `candidates/score`, for the pictorial `[pose3d]`
            method; slot 0 is the decoded point, so 1 keeps nothing new and 0 is off.
        crops: Fixed per-view windows `[x0, y0, x1, y1]`, one per animal where the
            project holds several; omitted, they are discovered with the detector.
        tightness: How much wider than the animal a discovered window is, as the
            training's crop tightness measures it: the static windows, and the size
            a per-frame window keeps. No effect on fixed `crops`.
        crop_aspect: Per view, the aspect (width / height) of the detector's window
            instead of the input's, `{ east = 0.5 }`; it must equal the `[data]
            view_aspect` the detector trained with.
        window: `"static"`, one window per view and animal for the run (a
            tethered rig), or `"per_frame"`, centered on the animal in every frame
            (a free-walking rig); either is written as `pose2d/crops`.
        locator: The full-frame locator a per-frame run needs where views share a
            frame (ADR-0064), named as `weights` are.
        tracking: `[pose2d.tracking]`, read only with `animals > 1` and a per-frame
            window.
    """

    weights: tuple[ArtifactRef, ...]
    combine: Literal["mean", "median"] = "mean"
    tta: bool = False
    candidates: int = 0
    crops: dict[
        str, tuple[int, int, int, int] | tuple[tuple[int, int, int, int], ...]
    ] = Field(default_factory=dict)
    crop_aspect: dict[str, float] = Field(default_factory=dict)
    tightness: float = 1.5
    window: Literal["static", "per_frame"] = "static"
    locator: ArtifactRef | None = None
    tracking: Tracking = Field(default_factory=Tracking)

    @model_validator(mode="after")
    def _rules(self):
        """Refuse a stage with no detector, or a candidate set with nothing new."""
        if not self.weights:
            raise ValueError("weights: name at least one detector")
        flat = [v for v, aspect in self.crop_aspect.items() if not aspect > 0]
        if flat:
            raise ValueError(
                f"crop_aspect: {', '.join(flat)}: an aspect is width / height, so "
                "above zero"
            )
        if self.window == "per_frame" and self.crops:
            raise ValueError(
                'crops: a fixed window has no place in window = "per_frame"; the '
                "locator picks each frame's window"
            )
        if self.locator is not None and self.window != "per_frame":
            raise ValueError(
                "locator: a locator picks each frame's window; it has nothing to "
                'do with window = "static"'
            )
        if not self.tightness > 0:
            raise ValueError(f"tightness: above zero, got {self.tightness}")
        if self.candidates < 0 or self.candidates == 1:
            raise ValueError(
                "candidates: 0 keeps none; otherwise keep at least 2 (the decoded "
                f"point and one alternative), not {self.candidates}"
            )
        return self

Pose3dStage

Bases: Table

[pose3d]: lift the 2D to one 3D track per keypoint -> pose3d/*.

Attributes:

Name Type Description
method Literal['ransac', 'dlt', 'pictorial', 'election']

"ransac" keeps the largest two-view consensus; "dlt" is the plain least-squares fit; "pictorial" picks among 3D hypotheses from the candidate peaks by their support and a bone-length prior (pictorial); "election" re-elects each view's candidate nearest the other views' reprojection (elect), cheaper but the higher-variance of the two. Both write the 2D they settled on (selected/points, selected/conf), which [smoothing] observes instead of the decode.

threshold_px float

Inlier margin of the consensus, in pixels; for "pictorial", how close a candidate must reproject to support a hypothesis.

min_views int

Views a point needs to be triangulated; for "pictorial", the support a hypothesis needs.

weighted bool

Weight each observation by its confidence's precision (conf ** 2, precision_from_conf); for "ransac" that weight is also the consensus vote, and each view's measured noise scales its pull on the fit (view_precision, ADR-0129); for "pictorial", weight each supporting vote by its candidate's score.

bone_prior float

"pictorial" only: the weight of a Huber cost on each bone's deviation from its median length over the frames this invocation lifts, in median bone lengths, against at most one unit of evidence per supporting view; 0 is off.

veto tuple[str, ...]

View selectors that never vote (a weakly calibrated view): they take no part in any triangulation, but "pictorial" and "election" still re-select their 2D from the geometry the other views settled.

Source code in src/deeperfly/config/run.py
class Pose3dStage(Table):
    """`[pose3d]`: lift the 2D to one 3D track per keypoint -> `pose3d/*`.

    Attributes:
        method: `"ransac"` keeps the largest two-view consensus; `"dlt"` is the plain
            least-squares fit; `"pictorial"` picks among 3D hypotheses from the
            candidate peaks by their support and a bone-length prior
            ([pictorial][deeperfly.pose3d.pictorial]); `"election"` re-elects each
            view's candidate nearest the other views' reprojection
            ([elect][deeperfly.pose3d.pictorial.elect]), cheaper but the
            higher-variance of the two. Both write the 2D they settled on
            (`selected/points`, `selected/conf`), which `[smoothing]` observes instead
            of the decode.
        threshold_px: Inlier margin of the consensus, in pixels; for `"pictorial"`,
            how close a candidate must reproject to support a hypothesis.
        min_views: Views a point needs to be triangulated; for `"pictorial"`, the
            support a hypothesis needs.
        weighted: Weight each observation by its confidence's precision (`conf ** 2`,
            [precision_from_conf][deeperfly.pose3d.triangulation.precision_from_conf]);
            for `"ransac"` that weight is also the consensus vote, and each view's
            measured noise scales its pull on the fit
            ([view_precision][deeperfly.pose3d.triangulation.view_precision],
            ADR-0129); for `"pictorial"`, weight each supporting vote by its
            candidate's score.
        bone_prior: `"pictorial"` only: the weight of a Huber cost on each bone's
            deviation from its median length over the frames this invocation lifts,
            in median bone lengths, against at most one unit of evidence per
            supporting view; 0 is off.
        veto: View selectors that never vote (a weakly calibrated view): they take
            no part in any triangulation, but `"pictorial"` and `"election"` still
            re-select their 2D from the geometry the other views settled.
    """

    method: Literal["ransac", "dlt", "pictorial", "election"] = "ransac"
    threshold_px: float = 15.0
    min_views: int = 2
    weighted: bool = False
    bone_prior: float = 0.0
    veto: tuple[str, ...] = ()

    @model_validator(mode="after")
    def _rules(self):
        """`bone_prior` only means something to the pictorial method."""
        if self.bone_prior and self.method != "pictorial":
            raise ValueError(
                "bone_prior: only the 'pictorial' method has a bone prior, not "
                f"{self.method!r}"
            )
        return self

RenderConfig

Bases: Table

[render]: the videos a run writes, and the defaults they share.

Attributes:

Name Type Description
outputs dict[str, OutputSpec]

[render.outputs.<name>], at least one.

views dict[str, RenderView]

[render.views.<name>], the synthetic views a grid may name.

frames FrameRange

[start, stop] or [start, stop, step], half-open; empty means every frame.

speed float

Playback rate: the animation runs at the video's fps times speed over the frame step, so half speed means the same on any video.

fps float

The rate written into the file, each drawn frame held a whole number of output frames to reach it, so none is dropped; 0 writes the animation's own rate.

cell tuple[int, int]

One cell's (height, width) in pixels.

max_size tuple[Side, Side]

The largest (height, width) a video is written at. A video is drawn at cell a panel, which is what a mark's size counts in, and one that comes out larger is scaled down to fit, keeping its shape; a 0 leaves that side unbounded.

background str

What a panel is filled with where content does not reach.

codec str

The encoder to hand the frames to.

crf int | None

The encoder's constant rate factor: lower keeps more, in a larger file. 18 keeps a thin colored mark sharp over footage, which libx264's own 23 smears; None passes none, for an encoder without one.

Source code in src/deeperfly/config/render.py
class RenderConfig(Table):
    """`[render]`: the videos a run writes, and the defaults they share.

    Attributes:
        outputs: `[render.outputs.<name>]`, at least one.
        views: `[render.views.<name>]`, the synthetic views a grid may name.
        frames: `[start, stop]` or `[start, stop, step]`, half-open; empty means every
            frame.
        speed: Playback rate: the animation runs at the video's fps times `speed` over
            the frame step, so half speed means the same on any video.
        fps: The rate written into the file, each drawn frame held a whole number of
            output frames to reach it, so none is dropped; `0` writes the animation's
            own rate.
        cell: One cell's `(height, width)` in pixels.
        max_size: The largest `(height, width)` a video is written at. A video is
            drawn at `cell` a panel, which is what a mark's size counts in, and one
            that comes out larger is scaled down to fit, keeping its shape; a `0`
            leaves that side unbounded.
        background: What a panel is filled with where content does not reach.
        codec: The encoder to hand the frames to.
        crf: The encoder's constant rate factor: lower keeps more, in a larger
            file. 18 keeps a thin colored mark sharp over footage, which libx264's
            own 23 smears; `None` passes none, for an encoder without one.
    """

    outputs: dict[str, OutputSpec] = Field(default_factory=dict)
    views: dict[str, RenderView] = Field(default_factory=dict)
    frames: FrameRange = ()
    speed: float = 1.0
    fps: float = 50.0
    cell: tuple[int, int] = (240, 480)
    max_size: tuple[Side, Side] = (1080, 1920)
    background: str = "black"
    codec: str = "libx264"
    crf: int | None = Field(default=18, ge=0)

    @model_validator(mode="after")
    def _rules(self):
        """Refuse a render table that writes nothing."""
        if not self.outputs:
            raise ValueError(
                "no [render.outputs.<name>] table, so this would write no video; "
                "remove [render] to skip rendering"
            )
        return self

    def check(self, available: dict[str, tuple]) -> None:
        """Check every reference and panel against what the run actually writes.

        `available` is `{"group/array": axes}` for every array the enabled stages
        write, plus `video` and `labels/*`
        ([available][deeperfly.config.run.RunConfig.available]). A `(V,T,K)` alpha may
        not reach a synthetic view, which has no view axis.
        """
        for name, video in self.outputs.items():
            where = f"render.outputs.{name}"
            names = video.names
            if video.crop is not None:
                self._shape(video.crop, (VT4,), f"{where}.crop", available)
            for i, layer in enumerate(video.layers):
                self._check_layer(layer, f"{where}.layers[{i}]", names, available)

    def _check_layer(
        self, layer: Layer, where: str, names: list[str], available: dict[str, tuple]
    ) -> None:
        """One layer's data reference, alpha reference and panel selector."""
        panels = match_names(layer.panel, names, f"{at(f'{where}.panel')}")
        data = getattr(layer, "data", None)
        if data is not None:
            self._shape(data, DRAW_SHAPES[layer.draw], f"{where}.data", available)
        if layer.draw == "mesh":
            self._check_mesh(layer, where, available)
        if layer.draw == "ball":
            self._check_ball(layer, where, available)
        alpha = getattr(layer, "alpha", None)
        if isinstance(alpha, str):
            axes = self._shape(alpha, (TK, VTK), f"{where}.alpha", available)
            synthetic = [p for p in panels if p in self.views]
            if without_animal_axis(axes) == VTK and synthetic:
                raise ConfigError(
                    f"{at(f'{where}.alpha')}: {alpha!r} is per view, and "
                    f"the synthetic view {synthetic[0]!r} this layer reaches has "
                    "no view to take; narrow panel, or fade by a (T,K) array"
                )

    @staticmethod
    def _check_mesh(layer: MeshLayer, where: str, available: dict[str, tuple]) -> None:
        """A `mesh` layer's data reference: an IK fit, whole.

        The mesh is posed from the fitted joint positions, with the angles carrying the
        head and abdomen, so the layer names `ik/points` and reads the rest alongside.
        """
        need = ("ik/points", "ik/angles", "ik/chain_scale", "ik/chain_offset")
        if layer.data != need[0] or any(ref not in available for ref in need):
            raise ConfigError(
                f"{at(f'{where}.data')}: a mesh is the fitted model, so it is drawn "
                f"from the whole of what [ik] writes; use {need[0]!r} with [ik] on"
            )

    @staticmethod
    def _check_ball(layer: BallLayer, where: str, available: dict[str, tuple]) -> None:
        """A `ball` layer's data reference: the tracked ball, whole.

        The outline needs the center and radius, the trail the rotation and the
        animal's axes, so the layer names `ball/center` and reads the rest alongside.
        """
        need = ("ball/center", "ball/radius", "ball/rotation", "ball/axes")
        if layer.data != need[0] or any(ref not in available for ref in need):
            raise ConfigError(
                f"{at(f'{where}.data')}: the ball is drawn from the whole of what "
                f"[ball] writes; use {need[0]!r} with [ball] on"
            )

    @staticmethod
    def _shape(
        ref: str, allowed: tuple[tuple, ...], where: str, available: dict[str, tuple]
    ) -> tuple:
        """The axes of a data reference, checked against the shapes `where` accepts."""
        if ref not in available:
            raise ConfigError(
                f"{at(where)}: nothing writes {ref!r}; this run has "
                + ", ".join(sorted(available))
            )
        axes = available[ref]
        if allowed and without_animal_axis(axes) not in allowed:
            raise ConfigError(
                f"{at(where)}: {ref!r} is {_axes(axes)}, which cannot feed this; "
                "expected " + " or ".join(_axes(a) for a in allowed)
            )
        return axes

check

check(available: dict[str, tuple]) -> None

Check every reference and panel against what the run actually writes.

available is {"group/array": axes} for every array the enabled stages write, plus video and labels/* (available). A (V,T,K) alpha may not reach a synthetic view, which has no view axis.

Source code in src/deeperfly/config/render.py
def check(self, available: dict[str, tuple]) -> None:
    """Check every reference and panel against what the run actually writes.

    `available` is `{"group/array": axes}` for every array the enabled stages
    write, plus `video` and `labels/*`
    ([available][deeperfly.config.run.RunConfig.available]). A `(V,T,K)` alpha may
    not reach a synthetic view, which has no view axis.
    """
    for name, video in self.outputs.items():
        where = f"render.outputs.{name}"
        names = video.names
        if video.crop is not None:
            self._shape(video.crop, (VT4,), f"{where}.crop", available)
        for i, layer in enumerate(video.layers):
            self._check_layer(layer, f"{where}.layers[{i}]", names, available)

RenderView

Bases: Table

[render.views.<name>]: a synthetic view, written as an orbit placement.

The orbit keys are OrbitView's; distance, focal and image_size may be left to the renderer, which fits them to the 3D in the panel.

Attributes:

Name Type Description
azimuth_deg float

Orbit rotation in the world xy-plane.

elevation_deg float

Orbit lift toward +z.

roll_deg float

Rotation about the optical axis.

look_at tuple[float, float, float]

World point the camera aims at.

distance float | None

Distance from look_at, in world units.

focal float | None

Focal length in pixels.

image_size tuple[int, int] | None

Panel (height, width) this view images into.

Source code in src/deeperfly/config/render.py
class RenderView(Table):
    """`[render.views.<name>]`: a synthetic view, written as an orbit placement.

    The orbit keys are [OrbitView][deeperfly.rig.OrbitView]'s; `distance`, `focal` and
    `image_size` may be left to the renderer, which fits them to the 3D in the panel.

    Attributes:
        azimuth_deg: Orbit rotation in the world xy-plane.
        elevation_deg: Orbit lift toward `+z`.
        roll_deg: Rotation about the optical axis.
        look_at: World point the camera aims at.
        distance: Distance from `look_at`, in world units.
        focal: Focal length in pixels.
        image_size: Panel `(height, width)` this view images into.
    """

    azimuth_deg: float = 0.0
    elevation_deg: float = 0.0
    roll_deg: float = 0.0
    look_at: tuple[float, float, float] = (0.0, 0.0, 0.0)
    distance: float | None = None
    focal: float | None = None
    image_size: tuple[int, int] | None = None

RunConfig

Bases: Table

A whole run config: the frames, the starting calibration, one table per stage.

Attributes:

Name Type Description
format_version int

The schema version the document is written for.

name str | None

The run's name, its results file results/<name>.h5; omitted, the time of the invocation (run_name). --name overrides it.

frames tuple[int | str, ...]

The frame set the run covers: indices and "start:stop:step" slices, unioned (frame_set); empty is every frame. --frames overrides it, and it is outside every fingerprint.

compute RunCompute

[compute].

calibration InitialCalibration | None

[calibration], the camera parameters the run starts from; every geometry stage, and a project of several animals, needs it.

pose2d Pose2dStage | None

[pose2d], or None when the stage is off.

bundle_adjustment BundleAdjustmentStage | None

[bundle_adjustment], or None.

pose3d Pose3dStage | None

[pose3d], or None.

smoothing SmoothingStage | None

[smoothing], or None.

correction CorrectionStage | None

[correction], or None.

ik IkStage | None

[ik], or None.

ball BallStage | None

[ball], or None.

render RenderConfig | None

[render], or None.

path Path | None

The file this was loaded from, or None when built in memory; not a key.

text str | None

The document as a run records it when the command line set keys (--set), which are written into the file's text; None when the file's text is the record (recorded_text). Not a key.

Source code in src/deeperfly/config/run.py
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
class RunConfig(Table):
    """A whole run config: the frames, the starting calibration, one table per stage.

    Attributes:
        format_version: The schema version the document is written for.
        name: The run's name, its results file `results/<name>.h5`; omitted, the
            time of the invocation ([run_name][deeperfly.pipeline.plan.run_name]).
            `--name` overrides it.
        frames: The frame set the run covers: indices and `"start:stop:step"`
            slices, unioned ([frame_set][deeperfly.pipeline.plan.frame_set]);
            empty is every frame. `--frames` overrides it, and it is outside every
            fingerprint.
        compute: `[compute]`.
        calibration: `[calibration]`, the camera parameters the run starts from;
            every geometry stage, and a project of several animals, needs it.
        pose2d: `[pose2d]`, or `None` when the stage is off.
        bundle_adjustment: `[bundle_adjustment]`, or `None`.
        pose3d: `[pose3d]`, or `None`.
        smoothing: `[smoothing]`, or `None`.
        correction: `[correction]`, or `None`.
        ik: `[ik]`, or `None`.
        ball: `[ball]`, or `None`.
        render: `[render]`, or `None`.
        path: The file this was loaded from, or `None` when built in memory; not a
            key.
        text: The document as a run records it when the command line set keys
            (`--set`), which are written into the file's text; `None` when the
            file's text is the record (`recorded_text`). Not a key.
    """

    format_version: int = RUN_CONFIG_FORMAT_VERSION
    name: str | None = None
    frames: tuple[int | str, ...] = ()
    compute: RunCompute = Field(default_factory=RunCompute)
    calibration: InitialCalibration | None = None
    pose2d: Pose2dStage | None = None
    bundle_adjustment: BundleAdjustmentStage | None = None
    pose3d: Pose3dStage | None = None
    smoothing: SmoothingStage | None = None
    correction: CorrectionStage | None = None
    ik: IkStage | None = None
    ball: BallStage | None = None
    render: RenderConfig | None = None
    path: Path | None = None
    text: str | None = None

    #: `path` is where the document lives and `text` what a run records of it, not
    #: keys it may declare.
    _reserved: ClassVar[tuple[str, ...]] = ("path", "text")

    @model_validator(mode="after")
    def _rules(self):
        """Run every cross-table rule that needs no project."""
        self._check_name()
        self._check_frames()
        self._check_moving()
        self._check_requires()
        self._check_inputs()
        return self

    def _check_name(self) -> None:
        """A name is a path below the results directory, not a way out of it."""
        if self.name is not None:
            check_run_name(self.name)

    def _check_frames(self) -> None:
        """Every entry of `frames` is an index or a slice, none of it negative."""
        try:
            parse_frames(self.frames)
        except ValueError as exc:
            raise ConfigError(f"{at('frames')}: {exc}") from exc

    # -- what is on ----------------------------------------------------------

    @property
    def enabled(self) -> list[str]:
        """The enabled stages, in pipeline order."""
        return [s for s in STAGES if getattr(self, s) is not None]

    def available(self, animals: int) -> dict[str, tuple]:
        """`{"group/array": axes}` for everything a reference may name here.

        The enabled stages' arrays plus the virtual `video` and `labels/*`, for a
        project of `animals` animals; a stage that is off contributes nothing, so a
        stale reference to it fails the check.
        """
        out = dict(VIRTUAL_ARRAYS)
        declared = STAGE_ARRAYS
        for stage in self.enabled:
            for name, axes in declared.get(stage, {}).items():
                out[f"{stage}/{name}"] = axes
        static = self.pose2d is not None and self.pose2d.window == "static"
        # Nothing is tracked at one animal, nor where each of them keeps one window
        # for the whole recording.
        if animals == 1 or static:
            for key in TRACKING_ARRAYS:
                out.pop(key, None)
        if self.pose2d is not None and not self.pose2d.candidates:
            for key in ("pose2d/candidates/xy", "pose2d/candidates/score"):
                out.pop(key, None)
        if self.pose3d is not None and self.pose3d.method not in (
            "pictorial",
            "election",
        ):
            for key in ("pose3d/selected/points", "pose3d/selected/conf"):
                out.pop(key, None)
        return out

    # -- cross-table rules ---------------------------------------------------

    def _check_moving(self) -> None:
        """What a moving window rules out.

        A per-frame window means the animal walks, which rules out the two
        tethered-rig assumptions downstream: a point that never moves and a body held
        in the model frame.
        """
        if self.pose2d is None or self.pose2d.window != "per_frame":
            return
        if self.correction is not None and self.correction.static is not None:
            raise ConfigError(
                f"{table('correction.static')}: holds a point still over every "
                'frame, which is a tethered rig; window = "per_frame" follows '
                "an animal that moves"
            )
        if self.ik is not None and self.ik.fixed_body:
            raise ConfigError(
                f"{at('ik.fixed_body')}: an animal whose window follows it needs a "
                'free root; set it false with window = "per_frame"'
            )

    def _check_requires(self) -> None:
        """A stage cannot run without what it consumes."""
        if self.pose3d is not None and self.pose2d is None:
            raise ConfigError(
                f"{table('pose3d')}: needs [pose2d] -- nothing to triangulate"
            )
        if (
            self.bundle_adjustment is not None
            and self.pose2d is None
            and self.bundle_adjustment.source != "labels/points"
        ):
            raise ConfigError(
                f"{at('bundle_adjustment.source')}: "
                f"{self.bundle_adjustment.source!r} needs "
                '[pose2d]; fit the hand labels with source = "labels/points"'
            )
        for name in ("smoothing", "correction", "ik", "ball"):
            if getattr(self, name) is not None and self.pose3d is None:
                raise ConfigError(f"{table(name)}: needs [pose3d]")
        if (
            self.pose3d is not None
            and self.pose3d.method in ("pictorial", "election")
            and not (self.pose2d and self.pose2d.candidates)
        ):
            raise ConfigError(
                f"{at('pose3d.method')}: {self.pose3d.method!r} picks among stored "
                "peaks; set [pose2d] candidates to how many to keep"
            )

    def _check_inputs(self) -> None:
        """Every `input` must name a 3D array an enabled stage writes."""
        # A 3D pose is one whatever the animal count; only the tracker's arrays
        # depend on it.
        arrays = self.available(animals=1)
        for name in ("correction", "ik", "ball"):
            stage = getattr(self, name)
            if stage is None or stage.input is None:
                continue
            if stage.input.startswith(f"{name}/"):
                raise ConfigError(
                    f"{at(f'{name}.input')}: a stage cannot read its own output"
                )
            axes = arrays.get(stage.input)
            if axes is None or without_animal_axis(axes) != TK3:
                choices = [
                    k for k, v in arrays.items() if without_animal_axis(v) == TK3
                ]
                raise ConfigError(
                    f"{at(f'{name}.input')}: {stage.input!r} is not a 3D pose "
                    "this config writes; " + (", ".join(choices) or "enable a 3D stage")
                )

    def input_of(self, stage: str) -> str:
        """What `stage` reads: its declared `input`, else the latest upstream 3D."""
        declared = getattr(getattr(self, stage), "input", None)
        if declared:
            return declared
        upstream = [s for s in THREE_D if s in self.enabled and s != stage]
        if not upstream:
            raise ConfigError(f"{table(stage)}: no upstream 3D pose to read")
        return f"{upstream[-1]}/points"

    # -- the project ---------------------------------------------------------

    def check_project(
        self,
        views: Sequence[str],
        animals: int,
        points: Sequence[str],
        videos: Sequence[str] = (),
    ) -> None:
        """Check the config against a project: its views, videos, animals, keypoints.

        What a run config cannot know on its own, since the videos, the views, the
        skeleton and the animal count are the project's (ADR-0081). The run checks
        each project before any stage runs, and so does `--dry-run`. `videos` are the
        names a render grid may show whole.

        Raises:
            ConfigError: If a table names a view or a point the project lacks, the
                calibration places other views, the animals need what the config
                lacks, or a render reference names nothing this config writes.
        """
        self._check_animals(animals)
        self._check_views(list(views), list(videos))
        self.check_points(points)
        if self.render is not None:
            self.render.check(self.available(animals))

    def _check_animals(self, animals: int) -> None:
        """What more than one animal needs, and what one animal leaves unread.

        Animals are located by the detector's center channel or a multi-animal
        locator, so above one the stage must be on, and a fixed window is given per
        animal.
        """
        if animals > 1 and self.ball is not None:
            raise ConfigError(
                f"{table('ball')}: tracks the ball under one tethered animal, and the "
                f"project holds {animals}"
            )
        if animals == 1:
            if self.pose2d is not None and self.pose2d.tracking != Tracking():
                raise ConfigError(
                    f"{table('pose2d.tracking')}: nothing reads it at one animal, "
                    "and the project holds one (animals in its deeperfly.toml)"
                )
            return
        if self.pose2d is None:
            raise ConfigError(
                f"the project holds {animals} animals, which need [pose2d] -- "
                "locating them is the detector's coarse pass"
            )
        wrong = {
            view: box
            for view, box in self.pose2d.crops.items()
            if len(box) != animals or not isinstance(box[0], tuple)
        }
        if wrong:
            raise ConfigError(
                f"{at('pose2d.crops')}: {', '.join(sorted(wrong))} name(s) one window "
                f"where the project holds {animals} animals; give each view "
                "[[x0, y0, x1, y1], ...], one per animal, or omit crops and let the "
                "discovery pass measure them"
            )

    def _check_views(self, views: list[str], videos: list[str]) -> None:
        """Every view a table names must be one of the project's."""
        if self.calibration is not None and self.calibration.views:
            placed = list(self.calibration.views)
            missing = [v for v in views if v not in placed]
            extra = [v for v in placed if v not in views]
            if missing or extra:
                said = []
                if missing:
                    said.append("lacks " + ", ".join(missing))
                if extra:
                    said.append("adds " + ", ".join(extra))
                raise ConfigError(
                    f"{table('calibration.views')}: " + " and ".join(said) + "; the "
                    "project's views are " + ", ".join(views)
                )
        if self.pose2d is not None:
            for key in ("crops", "crop_aspect"):
                unknown = [v for v in getattr(self.pose2d, key) if v not in views]
                if unknown:
                    raise ConfigError(
                        f"{at(f'pose2d.{key}')}: {', '.join(unknown)} is not one of "
                        "the project's views, " + ", ".join(views)
                    )
        if self.pose3d is not None and self.pose3d.veto:
            match_names(self.pose3d.veto, views, at("pose3d.veto"))
        if self.ball is not None:
            if self.ball.view not in views:
                raise ConfigError(
                    f"{at('ball.view')}: {self.ball.view!r} is not one of the "
                    "project's views, " + ", ".join(views)
                )
            match_names(self.ball.outline, views, at("ball.outline"))
        if self.render is not None:
            self._check_grids(views, videos)

    def _check_grids(self, views: list[str], videos: list[str]) -> None:
        """A grid name: a view, a video, a synthetic view, or a layer's target panel."""
        assert self.render is not None
        known = {*views, *videos, *self.render.views}
        for name, output in self.render.outputs.items():
            targeted = {
                p
                for layer in output.layers
                for p in match_names(
                    layer.panel, output.names, at(f"render.outputs.{name}.layers.panel")
                )
            }
            for panel in output.names:
                if panel in known or panel in targeted:
                    continue
                raise ConfigError(
                    f"{at(f'render.outputs.{name}.grid')}: {panel!r} is not one of "
                    "the project's views or videos, not a [render.views.*] entry, and "
                    "no layer targets it"
                )

    def check_points(self, points: Sequence[str]) -> None:
        """Every point selector must match the skeleton's names.

        Part of [check_project][deeperfly.config.run.RunConfig.check_project].
        """
        points = list(points)
        for stage, attr in (
            ("bundle_adjustment", "points"),
            ("correction.static", "points"),
            ("correction.symmetrize", "points"),
            ("correction.symmetrize", "midline"),
            ("ball", "contact"),
            ("ball", "stand"),
            ("ball", "front"),
            ("ball", "hind"),
        ):
            owner: Any = self
            for part in stage.split("."):
                owner = getattr(owner, part, None)
                if owner is None:
                    break
            if owner is not None:
                match_names(getattr(owner, attr), points, at(f"{stage}.{attr}"))
        if self.render is not None:
            for name, video in self.render.outputs.items():
                for i, layer in enumerate(video.layers):
                    if layer.draw == "skeleton":
                        match_names(
                            layer.select,
                            points,
                            at(f"render.outputs.{name}.layers[{i}].select"),
                        )

    # -- loading -------------------------------------------------------------

    @classmethod
    def from_document(
        cls, data: Mapping, doc_dir: Path, path: Path | None = None
    ) -> RunConfig:
        """Build from a parsed document, resolving its paths against `doc_dir`."""
        version = data.get("format_version", 1)
        check_format_version(
            data,
            "run config",
            RUN_CONFIG_FORMAT_VERSION,
            hint=(
                "Format 4 takes the videos, the layout, the views, the skeleton and "
                "the animal count from the project's deeperfly.toml, and the "
                "placements under [views] move to [calibration.views] (ADR-0081, "
                "ADR-0086)"
                if isinstance(version, int) and version < RUN_CONFIG_FORMAT_VERSION
                else ""
            ),
        )
        for key, where in _MOVED_KEYS.items():
            if key in data:
                raise ConfigError(f"{key}: unknown key; {where}")
        folded = dict(data)
        calibration = data.get("calibration")
        if isinstance(calibration, Mapping) and "views" in calibration:
            folded["calibration"] = {
                **calibration,
                "views": _fold_views(calibration["views"]),
            }
        cfg = parse(cls, folded, "")
        return cfg._resolved(doc_dir, path)

    @classmethod
    def load(
        cls, path: str | Path, changes: Mapping[str, Any] | None = None
    ) -> RunConfig:
        """Load, lint and type a run config file.

        `changes` are values by dotted key ([overrides][deeperfly.document.overrides])
        set over the file's before it is typed, as if the file said them; the run
        records them, written into the file's text.

        Raises:
            ConfigError: If the file, with the changes, breaks any rule of the schema.
        """
        file = Path(path).expanduser()
        data, doc_dir = load_document(file)
        if not changes:
            return cls.from_document(data, doc_dir, file)
        cfg = cls.from_document(with_keys(data, changes), doc_dir, file)
        return cfg.model_copy(update={"text": write_keys(file.read_text(), changes)})

    def recorded_text(self) -> str:
        """The document as a run records it: the file's text, with any `--set` keys.

        Empty for a config built in memory.
        """
        if self.text is not None:
            return self.text
        return self.path.read_text() if self.path is not None else ""

    def _resolved(self, doc_dir: Path, path: Path | None) -> RunConfig:
        """Resolve every path against the document's directory (ADR-0085)."""
        pose2d = self.pose2d
        if pose2d is not None:
            pose2d = pose2d.model_copy(
                update={
                    "weights": tuple(artifact_path(w, doc_dir) for w in pose2d.weights),
                    "locator": (
                        None
                        if pose2d.locator is None
                        else artifact_path(pose2d.locator, doc_dir)
                    ),
                }
            )
        calibration = self.calibration
        if calibration is not None and calibration.file is not None:
            calibration = calibration.model_copy(
                update={"source": str(under_document(calibration.file, doc_dir))}
            )
        ik = self.ik
        if ik is not None:
            # A packaged name stays a name; anything else is a path.
            from ..ik.binding import bindings
            from ..ik.pack import MODELS

            binding = ik.binding
            if binding is not None and binding not in bindings():
                binding = str(under_document(binding, doc_dir))
            model = ik.model
            if model not in MODELS:
                model = str(under_document(model, doc_dir))
            ik = ik.model_copy(update={"model": model, "binding": binding})
        return self.model_copy(
            update={
                "pose2d": pose2d,
                "calibration": calibration,
                "ik": ik,
                "path": path,
            }
        )

enabled property

enabled: list[str]

The enabled stages, in pipeline order.

available

available(animals: int) -> dict[str, tuple]

{"group/array": axes} for everything a reference may name here.

The enabled stages' arrays plus the virtual video and labels/*, for a project of animals animals; a stage that is off contributes nothing, so a stale reference to it fails the check.

Source code in src/deeperfly/config/run.py
def available(self, animals: int) -> dict[str, tuple]:
    """`{"group/array": axes}` for everything a reference may name here.

    The enabled stages' arrays plus the virtual `video` and `labels/*`, for a
    project of `animals` animals; a stage that is off contributes nothing, so a
    stale reference to it fails the check.
    """
    out = dict(VIRTUAL_ARRAYS)
    declared = STAGE_ARRAYS
    for stage in self.enabled:
        for name, axes in declared.get(stage, {}).items():
            out[f"{stage}/{name}"] = axes
    static = self.pose2d is not None and self.pose2d.window == "static"
    # Nothing is tracked at one animal, nor where each of them keeps one window
    # for the whole recording.
    if animals == 1 or static:
        for key in TRACKING_ARRAYS:
            out.pop(key, None)
    if self.pose2d is not None and not self.pose2d.candidates:
        for key in ("pose2d/candidates/xy", "pose2d/candidates/score"):
            out.pop(key, None)
    if self.pose3d is not None and self.pose3d.method not in (
        "pictorial",
        "election",
    ):
        for key in ("pose3d/selected/points", "pose3d/selected/conf"):
            out.pop(key, None)
    return out

check_points

check_points(points: Sequence[str]) -> None

Every point selector must match the skeleton's names.

Part of check_project.

Source code in src/deeperfly/config/run.py
def check_points(self, points: Sequence[str]) -> None:
    """Every point selector must match the skeleton's names.

    Part of [check_project][deeperfly.config.run.RunConfig.check_project].
    """
    points = list(points)
    for stage, attr in (
        ("bundle_adjustment", "points"),
        ("correction.static", "points"),
        ("correction.symmetrize", "points"),
        ("correction.symmetrize", "midline"),
        ("ball", "contact"),
        ("ball", "stand"),
        ("ball", "front"),
        ("ball", "hind"),
    ):
        owner: Any = self
        for part in stage.split("."):
            owner = getattr(owner, part, None)
            if owner is None:
                break
        if owner is not None:
            match_names(getattr(owner, attr), points, at(f"{stage}.{attr}"))
    if self.render is not None:
        for name, video in self.render.outputs.items():
            for i, layer in enumerate(video.layers):
                if layer.draw == "skeleton":
                    match_names(
                        layer.select,
                        points,
                        at(f"render.outputs.{name}.layers[{i}].select"),
                    )

check_project

check_project(
    views: Sequence[str],
    animals: int,
    points: Sequence[str],
    videos: Sequence[str] = (),
) -> None

Check the config against a project: its views, videos, animals, keypoints.

What a run config cannot know on its own, since the videos, the views, the skeleton and the animal count are the project's (ADR-0081). The run checks each project before any stage runs, and so does --dry-run. videos are the names a render grid may show whole.

Raises:

Type Description
ConfigError

If a table names a view or a point the project lacks, the calibration places other views, the animals need what the config lacks, or a render reference names nothing this config writes.

Source code in src/deeperfly/config/run.py
def check_project(
    self,
    views: Sequence[str],
    animals: int,
    points: Sequence[str],
    videos: Sequence[str] = (),
) -> None:
    """Check the config against a project: its views, videos, animals, keypoints.

    What a run config cannot know on its own, since the videos, the views, the
    skeleton and the animal count are the project's (ADR-0081). The run checks
    each project before any stage runs, and so does `--dry-run`. `videos` are the
    names a render grid may show whole.

    Raises:
        ConfigError: If a table names a view or a point the project lacks, the
            calibration places other views, the animals need what the config
            lacks, or a render reference names nothing this config writes.
    """
    self._check_animals(animals)
    self._check_views(list(views), list(videos))
    self.check_points(points)
    if self.render is not None:
        self.render.check(self.available(animals))

from_document classmethod

from_document(
    data: Mapping, doc_dir: Path, path: Path | None = None
) -> RunConfig

Build from a parsed document, resolving its paths against doc_dir.

Source code in src/deeperfly/config/run.py
@classmethod
def from_document(
    cls, data: Mapping, doc_dir: Path, path: Path | None = None
) -> RunConfig:
    """Build from a parsed document, resolving its paths against `doc_dir`."""
    version = data.get("format_version", 1)
    check_format_version(
        data,
        "run config",
        RUN_CONFIG_FORMAT_VERSION,
        hint=(
            "Format 4 takes the videos, the layout, the views, the skeleton and "
            "the animal count from the project's deeperfly.toml, and the "
            "placements under [views] move to [calibration.views] (ADR-0081, "
            "ADR-0086)"
            if isinstance(version, int) and version < RUN_CONFIG_FORMAT_VERSION
            else ""
        ),
    )
    for key, where in _MOVED_KEYS.items():
        if key in data:
            raise ConfigError(f"{key}: unknown key; {where}")
    folded = dict(data)
    calibration = data.get("calibration")
    if isinstance(calibration, Mapping) and "views" in calibration:
        folded["calibration"] = {
            **calibration,
            "views": _fold_views(calibration["views"]),
        }
    cfg = parse(cls, folded, "")
    return cfg._resolved(doc_dir, path)

input_of

input_of(stage: str) -> str

What stage reads: its declared input, else the latest upstream 3D.

Source code in src/deeperfly/config/run.py
def input_of(self, stage: str) -> str:
    """What `stage` reads: its declared `input`, else the latest upstream 3D."""
    declared = getattr(getattr(self, stage), "input", None)
    if declared:
        return declared
    upstream = [s for s in THREE_D if s in self.enabled and s != stage]
    if not upstream:
        raise ConfigError(f"{table(stage)}: no upstream 3D pose to read")
    return f"{upstream[-1]}/points"

load classmethod

load(
    path: str | Path,
    changes: Mapping[str, Any] | None = None,
) -> RunConfig

Load, lint and type a run config file.

changes are values by dotted key (overrides) set over the file's before it is typed, as if the file said them; the run records them, written into the file's text.

Raises:

Type Description
ConfigError

If the file, with the changes, breaks any rule of the schema.

Source code in src/deeperfly/config/run.py
@classmethod
def load(
    cls, path: str | Path, changes: Mapping[str, Any] | None = None
) -> RunConfig:
    """Load, lint and type a run config file.

    `changes` are values by dotted key ([overrides][deeperfly.document.overrides])
    set over the file's before it is typed, as if the file said them; the run
    records them, written into the file's text.

    Raises:
        ConfigError: If the file, with the changes, breaks any rule of the schema.
    """
    file = Path(path).expanduser()
    data, doc_dir = load_document(file)
    if not changes:
        return cls.from_document(data, doc_dir, file)
    cfg = cls.from_document(with_keys(data, changes), doc_dir, file)
    return cfg.model_copy(update={"text": write_keys(file.read_text(), changes)})

recorded_text

recorded_text() -> str

The document as a run records it: the file's text, with any --set keys.

Empty for a config built in memory.

Source code in src/deeperfly/config/run.py
def recorded_text(self) -> str:
    """The document as a run records it: the file's text, with any `--set` keys.

    Empty for a config built in memory.
    """
    if self.text is not None:
        return self.text
    return self.path.read_text() if self.path is not None else ""

SmoothingStage

Bases: Table

[smoothing]: the geometric EKS over the 2D observations -> smoothing/*.

The keys are smooth's (ADR-0062).

Attributes:

Name Type Description
smooth_param float | None

The process-noise scale, smaller smoothing harder; omitted, fitted per keypoint.

fit_frames int

Frames sampled for that fit.

fit_iterations int

Iterations of the fit.

inflate bool

Inflate the variance of a view that disagrees with the others.

inflate_threshold float

The Mahalanobis distance above which it is inflated.

inflate_factor float

How much it is inflated by, per round.

var Literal['var', 'confidence_weighted_var']

The observation variance: the members' spread, or that spread weighted by their confidences.

fill_unobserved bool

Report the smoothed 2D in the cells nothing observed, instead of NaN.

Source code in src/deeperfly/config/run.py
class SmoothingStage(Table):
    """`[smoothing]`: the geometric EKS over the 2D observations -> `smoothing/*`.

    The keys are [smooth][deeperfly.pose3d.smoothing.smooth]'s (ADR-0062).

    Attributes:
        smooth_param: The process-noise scale, smaller smoothing harder; omitted,
            fitted per keypoint.
        fit_frames: Frames sampled for that fit.
        fit_iterations: Iterations of the fit.
        inflate: Inflate the variance of a view that disagrees with the others.
        inflate_threshold: The Mahalanobis distance above which it is inflated.
        inflate_factor: How much it is inflated by, per round.
        var: The observation variance: the members' spread, or that spread
            weighted by their confidences.
        fill_unobserved: Report the smoothed 2D in the cells nothing observed,
            instead of NaN.
    """

    smooth_param: float | None = None
    fit_frames: int = 2000
    fit_iterations: int = 24
    inflate: bool = True
    inflate_threshold: float = 5.0
    inflate_factor: float = 10.0
    var: Literal["var", "confidence_weighted_var"] = "confidence_weighted_var"
    fill_unobserved: bool = False

Tracking

Bases: Table

[pose2d.tracking]: how center peaks become animals and stay themselves.

Read only when animals > 1. Every gate is in the view's own video pixels, and the defaults are hexaview's (ADR-0063).

Attributes:

Name Type Description
min_score float

What a center peak must score to count as a detection.

min_views int

Views that must see a center before it is an animal; 3 on hexaview.

gate_px float

A view supports a 3D center when its nearest peak reprojects within this.

min_separation_px float

Two centers closer than this in every view are one animal found twice; touching flies on hexaview are 85-95 px apart in the views across them, a split single blob under 35 (ADR-0131).

max_step_px float

The frame-to-frame link gate, as the largest reprojected jump over the views.

hold_frames int

Frames a track keeps its last position without a detection before the slot is freed.

Source code in src/deeperfly/config/run.py
class Tracking(Table):
    """`[pose2d.tracking]`: how center peaks become animals and stay themselves.

    Read only when `animals > 1`. Every gate is in the view's own video pixels, and
    the defaults are hexaview's (ADR-0063).

    Attributes:
        min_score: What a center peak must score to count as a detection.
        min_views: Views that must see a center before it is an animal; 3 on
            hexaview.
        gate_px: A view supports a 3D center when its nearest peak reprojects
            within this.
        min_separation_px: Two centers closer than this in every view are one
            animal found twice; touching flies on hexaview are 85-95 px apart in the
            views across them, a split single blob under 35 (ADR-0131).
        max_step_px: The frame-to-frame link gate, as the largest reprojected
            jump over the views.
        hold_frames: Frames a track keeps its last position without a detection
            before the slot is freed.
    """

    min_score: float = 0.001
    min_views: int = 2
    gate_px: float = 30.0
    min_separation_px: float = 50.0
    max_step_px: float = 60.0
    hold_frames: int = 12

    @model_validator(mode="after")
    def _rules(self):
        """A center needs two views to exist, and every gate is a distance."""
        if self.min_score < 0.0:
            raise ValueError(f"min_score: a detector score, not {self.min_score}")
        if self.min_views < 2:
            raise ValueError(
                f"min_views: a 3D center takes two views, not {self.min_views}"
            )
        for name in ("gate_px", "min_separation_px", "max_step_px"):
            if getattr(self, name) <= 0:
                raise ValueError(f"{name}: a distance in pixels, so above zero")
        if self.hold_frames < 0:
            raise ValueError(f"hold_frames: not negative, got {self.hold_frames}")
        return self

TrainConfig

Bases: Table

A whole training document, with its paths resolved.

Attributes:

Name Type Description
data TrainData

[data], the only required table.

format_version int

The schema version the document is written for.

model TrainModel

[model].

augment AugmentSpec | None

[augment], or None when absent, which means no augmentation.

train TrainLoop

[train].

compute TrainCompute

[compute].

unlabeled TrainUnlabeled | None

[unlabeled], or None when absent.

path Path | None

The file this was loaded from, or None when built in memory; not a key.

root Path | None

The directory its paths resolved against, the file's own; not a key.

text str | None

The document as the training records it when the command line set keys (--set), which are written into the file's text; None when the file's text is the record (recorded_text). Not a key.

Source code in src/deeperfly/config/train.py
class TrainConfig(Table):
    """A whole training document, with its paths resolved.

    Attributes:
        data: `[data]`, the only required table.
        format_version: The schema version the document is written for.
        model: `[model]`.
        augment: `[augment]`, or `None` when absent, which means no augmentation.
        train: `[train]`.
        compute: `[compute]`.
        unlabeled: `[unlabeled]`, or `None` when absent.
        path: The file this was loaded from, or `None` when built in memory; not a
            key.
        root: The directory its paths resolved against, the file's own; not a key.
        text: The document as the training records it when the command line set
            keys (`--set`), which are written into the file's text; `None` when the
            file's text is the record (`recorded_text`). Not a key.
    """

    data: TrainData
    format_version: int = TRAIN_FORMAT_VERSION
    model: TrainModel = Field(default_factory=TrainModel)
    augment: AugmentSpec | None = None
    train: TrainLoop = Field(default_factory=TrainLoop)
    compute: TrainCompute = Field(default_factory=TrainCompute)
    unlabeled: TrainUnlabeled | None = None
    path: Path | None = None
    root: Path | None = None
    text: str | None = None

    #: `path` and `root` are where the document lives, not keys it may declare.
    _reserved: ClassVar[tuple[str, ...]] = ("path", "root", "text")

    @classmethod
    def from_document(cls, data: dict, doc_dir: Path, path: Path | None = None):
        """Build from a parsed document, resolving its paths (see the module docs)."""
        check_format_version(data, "training config", TRAIN_FORMAT_VERSION)
        cfg = parse(cls, data, "")
        return cfg._resolved(doc_dir, path)

    @classmethod
    def load(
        cls, path: str | Path, changes: Mapping[str, Any] | None = None
    ) -> TrainConfig:
        """Load, lint and type a training document.

        `changes` are values by dotted key ([overrides][deeperfly.document.overrides])
        set over the file's before it is typed, as if the file said them; the
        training records them, written into the file's text.

        Raises:
            ConfigError: If the file, with the changes, breaks any rule of the schema.
        """
        file = Path(path).expanduser()
        data, doc_dir = load_document(file)
        if not changes:
            return cls.from_document(data, doc_dir, file)
        cfg = cls.from_document(with_keys(data, changes), doc_dir, file)
        return cfg.model_copy(update={"text": write_keys(file.read_text(), changes)})

    def recorded_text(self) -> str:
        """The document as the training records it: the file's text, with `--set` keys.

        Empty for a document built in memory.
        """
        if self.text is not None:
            return self.text
        return self.path.read_text() if self.path is not None else ""

    def _resolved(self, doc_dir: Path, path: Path | None) -> TrainConfig:
        """This config with its paths resolved against `doc_dir` (ADR-0085).

        `[data] projects` stay patterns, expanded against `root` when training starts.
        """
        data = self.data
        if data.root is not None:
            data = _with(data, root=under_document(data.root, doc_dir))
        if data.split is not None:
            data = _with(data, split=under_document(data.split, doc_dir))
        cache = under_document(data.cache or "cache", doc_dir)
        data = _with(data, cache=cache)
        model = self.model
        if model.init_from is not None:
            model = _with(model, init_from=artifact_path(model.init_from, doc_dir))
        train = self.train
        if train.out is not None:
            train = _with(train, out=under_document(train.out, doc_dir))
        unlabeled = self.unlabeled
        if unlabeled is not None and unlabeled.teacher is not None:
            unlabeled = _with(
                unlabeled, teacher=artifact_path(unlabeled.teacher, doc_dir)
            )
        if unlabeled is not None:
            unlabeled = _with(
                unlabeled,
                cache=cache / "unlabeled"
                if unlabeled.cache is None
                else under_document(unlabeled.cache, doc_dir),
            )
        return _with(
            self,
            data=data,
            model=model,
            train=train,
            unlabeled=unlabeled,
            path=path,
            root=Path(doc_dir),
        )

from_document classmethod

from_document(
    data: dict, doc_dir: Path, path: Path | None = None
)

Build from a parsed document, resolving its paths (see the module docs).

Source code in src/deeperfly/config/train.py
@classmethod
def from_document(cls, data: dict, doc_dir: Path, path: Path | None = None):
    """Build from a parsed document, resolving its paths (see the module docs)."""
    check_format_version(data, "training config", TRAIN_FORMAT_VERSION)
    cfg = parse(cls, data, "")
    return cfg._resolved(doc_dir, path)

load classmethod

load(
    path: str | Path,
    changes: Mapping[str, Any] | None = None,
) -> TrainConfig

Load, lint and type a training document.

changes are values by dotted key (overrides) set over the file's before it is typed, as if the file said them; the training records them, written into the file's text.

Raises:

Type Description
ConfigError

If the file, with the changes, breaks any rule of the schema.

Source code in src/deeperfly/config/train.py
@classmethod
def load(
    cls, path: str | Path, changes: Mapping[str, Any] | None = None
) -> TrainConfig:
    """Load, lint and type a training document.

    `changes` are values by dotted key ([overrides][deeperfly.document.overrides])
    set over the file's before it is typed, as if the file said them; the
    training records them, written into the file's text.

    Raises:
        ConfigError: If the file, with the changes, breaks any rule of the schema.
    """
    file = Path(path).expanduser()
    data, doc_dir = load_document(file)
    if not changes:
        return cls.from_document(data, doc_dir, file)
    cfg = cls.from_document(with_keys(data, changes), doc_dir, file)
    return cfg.model_copy(update={"text": write_keys(file.read_text(), changes)})

recorded_text

recorded_text() -> str

The document as the training records it: the file's text, with --set keys.

Empty for a document built in memory.

Source code in src/deeperfly/config/train.py
def recorded_text(self) -> str:
    """The document as the training records it: the file's text, with `--set` keys.

    Empty for a document built in memory.
    """
    if self.text is not None:
        return self.text
    return self.path.read_text() if self.path is not None else ""

TrainData

Bases: Table

[data]: which labels to train on and what to hold out.

Three corpus kinds share the table, and a key of another kind is an error.

Attributes:

Name Type Description
kind Literal['project', 'df3d', 'lightning_pose']

"project" (deeperfly projects' labels), "df3d" or "lightning_pose" (an imported corpus directory).

task Literal['pose', 'locate']

"pose", the detector: one sample per (project, frame, view), a channel per keypoint. "locate", the full-frame locator (ADR-0064): one sample per (project, frame, video), the whole frame letterboxed, a channel per view; it needs calibrated projects and ignores the crop keys. kind = "project" only.

projects tuple[str, ...]

For kind = "project", the projects trained on: paths or globs against the document's directory, each match resolved to a project as deeperfly run resolves a path, never creating one (ADR-0085), and a path that is a directory of projects taking its projects, as deeperfly train takes one (ADR-0087). A glob match that is no project is skipped, a pattern that matches nothing is an error. Paths given to deeperfly train replace the list, so it may be empty here.

root TomlPath | None

The corpus directory, for the other two kinds.

skeleton str | None

A packaged preset for a corpus kind, which carries no project; None means "fly38".

holdout tuple[str, ...]

Selector held out and scored, never trained on: projects by their names in the collection (project_names), or a corpus's recordings.

train tuple[str, ...]

Selector trained on; empty means every labeled project not held out.

split TomlPath | None

A TOML file holding a holdout list, unioned with holdout.

holdout_every int

df3d only: hold out every n-th experiment; 0 is off.

exclude tuple[str, ...]

lightning_pose only: recordings left out of both sets.

views tuple[str, ...]

lightning_pose only: which views of the corpus to read.

crops Crops

[data.crops].

center_channel bool

Build the head one channel wider, trained to peak at each animal's center (ADR-0063): the median of the frame's solved 3D keypoints, projected, so it needs a calibrated project. Raise [data.crops] full_p with it, since the channel is read at the letterbox scale.

fill Fill

[data.fill], reprojected targets for the views a frame does not label.

boost Boost

[data.boost], projects drawn more often than their size gives.

mark_others bool

On a frame of several animals, mark every other animal's center in each sample's input (ADR-0148): a white disk in a black ring, so a window says which animal it is not about. The detector records it and draws the same mark at inference.

mark_jitter float

Training-time error of a marked center, in input pixels, a stand-in for the window's at inference; evaluation marks exactly.

off_view_weight float

Weight of a target that lands outside its own view's image, where no pixel is behind it (not the off-frame margin the head is built to carry).

view_aspect dict[str, float]

View name -> the aspect (width / height) of its crop box, instead of the input's, for a subject far from square such as a prism strip's; a run config's [pose2d] crop_aspect must repeat it. It lost as an arm (ADR-0051).

keypoint_weights dict[str, float]

Per-point loss weight overrides, point name -> weight.

view_sides dict[str, str]

View name -> "left" or "right", for the held-out report's ipsi/contralateral breakdown.

cache TomlPath | None

Where the decoded-frame cache lives; <document dir>/cache/ by default.

Source code in src/deeperfly/config/train.py
class TrainData(Table):
    """`[data]`: which labels to train on and what to hold out.

    Three corpus kinds share the table, and a key of another kind is an error.

    Attributes:
        kind: `"project"` (deeperfly projects' labels), `"df3d"` or
            `"lightning_pose"` (an imported corpus directory).
        task: `"pose"`, the detector: one sample per `(project, frame, view)`, a
            channel per keypoint. `"locate"`, the full-frame locator (ADR-0064): one
            sample per `(project, frame, video)`, the whole frame letterboxed, a
            channel per view; it needs calibrated projects and ignores the crop
            keys. `kind = "project"` only.
        projects: For `kind = "project"`, the projects trained on: paths or globs
            against the document's directory, each match resolved to a project as
            `deeperfly run` resolves a path, never creating one (ADR-0085), and a
            path that is a directory of projects taking its projects, as
            `deeperfly train` takes one (ADR-0087). A glob match that is no project
            is skipped, a pattern that matches nothing is an error. Paths given to
            `deeperfly train` replace the list, so it may be empty here.
        root: The corpus directory, for the other two kinds.
        skeleton: A packaged preset for a corpus kind, which carries no project;
            `None` means `"fly38"`.
        holdout: Selector held out and scored, never trained on: projects by their
            names in the collection ([project_names][deeperfly.project.project_names]),
            or a corpus's recordings.
        train: Selector trained on; empty means every labeled project not held
            out.
        split: A TOML file holding a `holdout` list, unioned with `holdout`.
        holdout_every: `df3d` only: hold out every n-th experiment; 0 is off.
        exclude: `lightning_pose` only: recordings left out of both sets.
        views: `lightning_pose` only: which views of the corpus to read.
        crops: `[data.crops]`.
        center_channel: Build the head one channel wider, trained to peak at each
            animal's center (ADR-0063): the median of the frame's solved 3D
            keypoints, projected, so it needs a calibrated project. Raise
            `[data.crops] full_p` with it, since the channel is read at the
            letterbox scale.
        fill: `[data.fill]`, reprojected targets for the views a frame does not
            label.
        boost: `[data.boost]`, projects drawn more often than their size gives.
        mark_others: On a frame of several animals, mark every other animal's
            center in each sample's input (ADR-0148): a white disk in a black ring,
            so a window says which animal it is not about. The detector records it
            and draws the same mark at inference.
        mark_jitter: Training-time error of a marked center, in input pixels, a
            stand-in for the window's at inference; evaluation marks exactly.
        off_view_weight: Weight of a target that lands outside its own view's
            image, where no pixel is behind it (not the off-frame margin the head
            is built to carry).
        view_aspect: View name -> the aspect (width / height) of its crop box,
            instead of the input's, for a subject far from square such as a prism
            strip's; a run config's `[pose2d] crop_aspect` must repeat it. It lost
            as an arm (ADR-0051).
        keypoint_weights: Per-point loss weight overrides, `point name -> weight`.
        view_sides: View name -> `"left"` or `"right"`, for the held-out report's
            ipsi/contralateral breakdown.
        cache: Where the decoded-frame cache lives; `<document dir>/cache/` by
            default.
    """

    kind: Literal["project", "df3d", "lightning_pose"] = "project"
    task: Literal["pose", "locate"] = "pose"
    projects: tuple[str, ...] = ()
    root: TomlPath | None = None
    skeleton: str | None = None
    holdout: tuple[str, ...] = ()
    train: tuple[str, ...] = ()
    split: TomlPath | None = None
    holdout_every: int = 0
    exclude: tuple[str, ...] = ()
    views: tuple[str, ...] = ()
    crops: Crops = Field(default_factory=lambda: Crops())
    center_channel: bool = False
    fill: Fill = Field(default_factory=lambda: Fill())
    boost: Boost = Field(default_factory=lambda: Boost())
    mark_others: bool = False
    mark_jitter: float = 6.0
    off_view_weight: float = 1.0
    view_aspect: dict[str, float] = Field(default_factory=dict)
    keypoint_weights: dict[str, float] = Field(default_factory=dict)
    view_sides: dict[str, str] = Field(default_factory=dict)
    cache: TomlPath | None = None

    @model_validator(mode="after")
    def _rules(self):
        """Check that each key belongs to the declared `kind`."""
        wrong = [
            (name, kinds)
            for name, kinds, given in (
                ("projects", ("project",), bool(self.projects)),
                ("root", ("df3d", "lightning_pose"), self.root is not None),
                ("skeleton", ("df3d", "lightning_pose"), self.skeleton is not None),
                ("holdout_every", ("df3d",), bool(self.holdout_every)),
                ("exclude", ("lightning_pose",), bool(self.exclude)),
                ("views", ("lightning_pose",), bool(self.views)),
                ("center_channel", ("project",), self.center_channel),
                ("boost", ("project",), bool(self.boost.projects)),
                ("mark_others", ("project",), self.mark_others),
                ("task", ("project",), self.task != "pose"),
            )
            if given and self.kind not in kinds
        ]
        if wrong:
            name, kinds = wrong[0]
            raise ValueError(
                f"{name}: only for kind = "
                + " / ".join(repr(k) for k in kinds)
                + f", not {self.kind!r}"
            )
        if self.kind != "project" and self.root is None:
            raise ValueError(f"root: required for kind = {self.kind!r}")
        if self.task == "locate" and self.center_channel:
            raise ValueError(
                "center_channel: a locator is nothing but center channels, one per "
                'view; the extra one has no meaning with task = "locate"'
            )
        return self

TrainLoop

Bases: Table

[train]: the optimization loop's knobs, one default each.

Attributes:

Name Type Description
steps int

Optimizer steps to run.

batch_size int

Samples per step -- view groups when grouped, images otherwise.

images_per_batch int

With grouped, cap a batch at this many images (groups have different view counts); 0 is off.

optimizer Optimizer

[train.optimizer].

loss Loss

[train.loss].

eval Eval

[train.eval].

grad_checkpoint bool

Recompute the backbone blocks in the backward pass, to fit a larger model or batch in memory. Slower, same numbers.

grad_clip float

Gradient-norm clip; 0 is off.

amp bool

Mixed-precision forward/backward.

ema_decay float

Exponential moving average of the weights; 0 is off.

view_drop_p float

Probability of dropping a view from a training group.

view_min int

Views a group keeps after dropping.

grouped bool | None

Train on view groups rather than independent images; None lets the model decide (the multiview transformer requires groups).

seed int

Seed for torch, numpy and the samplers.

log_every int

Steps between log lines.

out TomlPath | None

Where the training writes, against the document's directory; <document dir>/<stem>/ by default. deeperfly train --out overrides both.

Source code in src/deeperfly/config/train.py
class TrainLoop(Table):
    """`[train]`: the optimization loop's knobs, one default each.

    Attributes:
        steps: Optimizer steps to run.
        batch_size: Samples per step -- view groups when `grouped`, images
            otherwise.
        images_per_batch: With `grouped`, cap a batch at this many images
            (groups have different view counts); 0 is off.
        optimizer: `[train.optimizer]`.
        loss: `[train.loss]`.
        eval: `[train.eval]`.
        grad_checkpoint: Recompute the backbone blocks in the backward pass, to fit
            a larger model or batch in memory. Slower, same numbers.
        grad_clip: Gradient-norm clip; 0 is off.
        amp: Mixed-precision forward/backward.
        ema_decay: Exponential moving average of the weights; 0 is off.
        view_drop_p: Probability of dropping a view from a training group.
        view_min: Views a group keeps after dropping.
        grouped: Train on view groups rather than independent images; `None`
            lets the model decide (the multiview transformer requires groups).
        seed: Seed for torch, numpy and the samplers.
        log_every: Steps between log lines.
        out: Where the training writes, against the document's directory;
            `<document dir>/<stem>/` by default. `deeperfly train --out` overrides
            both.
    """

    steps: int = 10000
    batch_size: int = 16
    images_per_batch: int = 0
    optimizer: Optimizer = Field(default_factory=lambda: Optimizer())
    loss: Loss = Field(default_factory=lambda: Loss())
    eval: Eval = Field(default_factory=lambda: Eval())
    grad_checkpoint: bool = False
    grad_clip: float = 0.0
    amp: bool = False
    ema_decay: float = 0.0
    view_drop_p: float = 0.0
    view_min: int = 1
    grouped: bool | None = None
    seed: int = 0
    log_every: int = 50
    out: TomlPath | None = None

TrainModel

Bases: Table

[model]: the detector to build, the schema's one open table (ADR-0032).

Attributes:

Name Type Description
family Literal['monocular', 'simcc', 'mvt', 'fast']

"monocular", "simcc", "mvt" or "fast".

backbone str | None

A timm model name; None takes the builder's default.

pretrained bool

Load the backbone's pretrained weights.

input_hw tuple[int, int]

Network input (height, width), which every crop is resized to.

init_from ArtifactRef | None

Warm start from an earlier training's artifact: a path against the document's directory (a directory means its model.pth), or a released model's name (ADR-0151).

extra dict[str, Any]

The family's own builder keys, checked against MODEL_KEYS.

Source code in src/deeperfly/config/train.py
class TrainModel(Table):
    """`[model]`: the detector to build, the schema's one open table (ADR-0032).

    Attributes:
        family: `"monocular"`, `"simcc"`, `"mvt"` or `"fast"`.
        backbone: A `timm` model name; `None` takes the builder's default.
        pretrained: Load the backbone's pretrained weights.
        input_hw: Network input `(height, width)`, which every crop is resized to.
        init_from: Warm start from an earlier training's artifact: a path against
            the document's directory (a directory means its `model.pth`), or a
            released model's name (ADR-0151).
        extra: The family's own builder keys, checked against `MODEL_KEYS`.
    """

    family: Literal["monocular", "simcc", "mvt", "fast"] = "monocular"
    backbone: str | None = None
    pretrained: bool = False
    input_hw: tuple[int, int] = (256, 512)
    init_from: ArtifactRef | None = None
    extra: dict[str, Any] = Field(default_factory=dict)

    @model_validator(mode="before")
    @classmethod
    def _collect_extra(cls, data):
        """Unknown keys are the family's, not a typo -- see `MODEL_KEYS`."""
        if isinstance(data, Mapping):
            unknown = {k: v for k, v in data.items() if k not in cls.model_fields}
            if unknown:
                known = {k: v for k, v in data.items() if k in cls.model_fields}
                data = {**known, "extra": {**known.get("extra", {}), **unknown}}
        return data

    @model_validator(mode="after")
    def _rules(self):
        """Check every passthrough key against the family's builder keys."""
        allowed = MODEL_KEYS[self.family]
        unknown = sorted(set(self.extra) - set(allowed))
        if unknown:
            raise ValueError(
                f"{unknown[0]}: not a {self.family!r} key; {self.family!r} takes "
                + ", ".join(sorted(allowed))
            )
        return self

check_commented_headers

check_commented_headers(text: str) -> None

Refuse a commented-out table header that uncommented keys follow.

# [smoothing] looks like the way to switch a stage off, but TOML binds the keys below it to the previous table (ADR-0028).

Source code in src/deeperfly/document.py
def check_commented_headers(text: str) -> None:
    """Refuse a commented-out table header that uncommented keys follow.

    `# [smoothing]` looks like the way to switch a stage off, but TOML binds the keys
    below it to the previous table (ADR-0028).
    """
    pending: tuple[int, str] | None = None
    for i, line in enumerate(text.splitlines(), start=1):
        header = _COMMENTED_HEADER.match(line)
        if header:
            pending = (i, header.group(1))
            continue
        if _HEADER.match(line):
            pending = None
            continue
        if pending and _KEY.match(line):
            lineno, name = pending
            raise ConfigError(
                f"line {lineno}: {name} is commented out but line {i} still "
                f"declares a key, which TOML would attach to the table above. "
                f"Comment out its keys too, or delete the section -- removing a "
                f"table is how a stage is turned off."
            )

check_format_version

check_format_version(
    data: Mapping,
    kind: str,
    expected: int = DOCUMENT_FORMAT_VERSION,
    hint: str = "",
) -> None

Refuse a document written for another version of the schema.

Read before anything else, so another version's file is reported as such rather than as an unknown key; a document that declares none is version 1. kind names the document in the error and hint is appended to it.

Source code in src/deeperfly/document.py
def check_format_version(
    data: Mapping, kind: str, expected: int = DOCUMENT_FORMAT_VERSION, hint: str = ""
) -> None:
    """Refuse a document written for another version of the schema.

    Read before anything else, so another version's file is reported as such rather
    than as an unknown key; a document that declares none is version 1. `kind` names
    the document in the error and `hint` is appended to it.
    """
    version = data.get("format_version", 1)
    if version == expected:
        return
    raise ConfigError(
        f"format_version: this {kind} is version {version!r}; this deeperfly "
        f"reads version {expected}" + (f". {hint}" if hint else "")
    )

load_document

load_document(path: str | Path) -> tuple[dict, Path]

Parse a config file, linted, with the directory it lives in.

Every relative path in it resolves against that directory (ADR-0085), so it travels with the document.

Source code in src/deeperfly/document.py
def load_document(path: str | Path) -> tuple[dict, Path]:
    """Parse a config file, linted, with the directory it lives in.

    Every relative path in it resolves against that directory (ADR-0085), so it
    travels with the document.
    """
    file = Path(path).expanduser()
    text = file.read_text()
    check_commented_headers(text)
    try:
        data = tomllib.loads(text)
    except tomllib.TOMLDecodeError as exc:
        raise ConfigError(f"{file}: not valid TOML: {exc}") from exc
    return data, file.resolve().parent

match_names

match_names(
    patterns: Iterable[str],
    names: Sequence[str],
    where: str,
) -> list[str]

The names a selector claims, in names order; empty patterns mean all.

A list unions its matches. An entry with a glob character must match at least one name and one without must be a name, since either miss is a typo; where names the selector in the error.

Source code in src/deeperfly/document.py
def match_names(patterns: Iterable[str], names: Sequence[str], where: str) -> list[str]:
    """The names a selector claims, in `names` order; empty patterns mean all.

    A list unions its matches. An entry with a glob character must match at least one
    name and one without must be a name, since either miss is a typo; `where` names
    the selector in the error.
    """
    patterns = list(patterns)
    if not patterns:
        return list(names)
    claimed: set[str] = set()
    for pattern in patterns:
        if any(c in pattern for c in GLOB_CHARS):
            hits = [n for n in names if fnmatchcase(n, pattern)]
            if not hits:
                raise ConfigError(
                    f"{where}: pattern {pattern!r} matches none of " + ", ".join(names)
                )
        elif pattern in names:
            hits = [pattern]
        else:
            raise ConfigError(f"{where}: {pattern!r} is not one of " + ", ".join(names))
        claimed.update(hits)
    return [n for n in names if n in claimed]

without_animal_axis

without_animal_axis(axes: tuple) -> tuple

axes without a leading N, for a check written against one animal.

Source code in src/deeperfly/inventory.py
def without_animal_axis(axes: tuple) -> tuple:
    """`axes` without a leading `N`, for a check written against one animal."""
    return axes[1:] if axes[:1] == ("N",) else axes

The table every config shares, and the run config's format version.

Compute

Bases: Table

[compute]: the machine, not the run, so a committed config stays portable.

Every knob is a CLI flag too, and none changes what a run computes (ADR-0033).

Attributes:

Name Type Description
device str | None

"cuda" or "cpu"; None picks CUDA when it is available.

Source code in src/deeperfly/config/_base.py
class Compute(Table):
    """`[compute]`: the machine, not the run, so a committed config stays portable.

    Every knob is a CLI flag too, and none changes what a run computes (ADR-0033).

    Attributes:
        device: `"cuda"` or `"cpu"`; `None` picks CUDA when it is available.
    """

    device: str | None = None

deeperfly train -c config.toml: the typed training config.

The tables are [data], [model], [augment], [train], [compute] and the optional [unlabeled]. The schema still moves with the recipe, so a key may be renamed between versions, and format_version is the one promise (ADR-0061). [model] is the one open table: its extra keys go to the family's builder, checked against the torch-free MODEL_KEYS (ADR-0032). Every path resolves against the document's own directory (ADR-0085): [data] projects, root, split and cache, init_from, teacher, [train] out.

AugmentSpec

Bases: Table

[augment]: the training-time augmentation, mild by default.

An absent table means no augmentation, an empty one these defaults: strong geometric augmentation and cutout cost contralateral accuracy in the previous training repo's measurements. The loop reads it as Augment. rig_symmetry replaces the mirror draw of a grouped sample with one symmetry of its rig (rig_symmetries): each view's picture flipped as a mirror or a turn of the world would flip it, which a flip of every view about its own axis is not on a rig whose cameras are turned against each other; flip_p then goes unused for calibrated projects.

Source code in src/deeperfly/config/train.py
class AugmentSpec(Table):
    """`[augment]`: the training-time augmentation, mild by default.

    An absent table means no augmentation, an empty one these defaults: strong
    geometric augmentation and cutout cost contralateral accuracy in the previous
    training repo's measurements. The loop reads it as
    [Augment][deeperfly.pose2d.training.augment.Augment]. `rig_symmetry` replaces
    the mirror draw of a grouped sample with one symmetry of its rig
    ([rig_symmetries][deeperfly.pose2d.training.dataset.rig_symmetries]):
    each view's picture flipped as a mirror or a turn of the world would flip it,
    which a flip of every view about its own axis is not on a rig whose cameras are
    turned against each other; `flip_p` then goes unused for calibrated projects.
    """

    degrees: float = 5.0
    scale: tuple[float, float] = (0.9, 1.1)
    aspect: tuple[float, float] = (1.0, 1.0)
    translate: tuple[float, float] = (0.03, 0.03)
    flip_p: float = 0.5
    rig_symmetry: bool = False
    brightness: float = 0.2
    contrast: float = 0.2
    gamma: tuple[float, float] | None = None
    blur_sigma: tuple[float, float] | None = None
    noise_std: float = 0.0
    photometric_p: float = 0.5

Boost

Bases: Table

[data.boost]: projects whose labeled moments an epoch draws more often.

A corpus of thousands of composited or pseudo-labeled moments drowns a few dozen hand-labeled ones of the recordings that matter: drawn uniformly, each is seen less than once in a short run (ADR-0136).

Attributes:

Name Type Description
projects tuple[str, ...]

Project selector of the boosted projects; empty boosts nothing.

share float

The share of an epoch the boosted projects' labeled animals make up, each repeated as often as that takes and never less than once; 0 is off.

Source code in src/deeperfly/config/train.py
class Boost(Table):
    """`[data.boost]`: projects whose labeled moments an epoch draws more often.

    A corpus of thousands of composited or pseudo-labeled moments drowns a few dozen
    hand-labeled ones of the recordings that matter: drawn uniformly, each is seen
    less than once in a short run (ADR-0136).

    Attributes:
        projects: Project selector of the boosted projects; empty boosts nothing.
        share: The share of an epoch the boosted projects' labeled animals make up,
            each repeated as often as that takes and never less than once; 0 is off.
    """

    projects: tuple[str, ...] = ()
    share: float = 0.0

    @model_validator(mode="after")
    def _rules(self):
        """Check the share's range, and that projects and share come together."""
        if not 0.0 <= self.share < 1.0:
            raise ValueError(f"share: must be in [0, 1), got {self.share}")
        if bool(self.projects) != (self.share > 0):
            raise ValueError("projects and share: give both, or neither")
        return self

Crops

Bases: Table

[data.crops]: how a training sample is cropped around its animal (ADR-0046).

Attributes:

Name Type Description
tightness tuple[float, float]

Log-uniform range of the crop's tightness around the animal; below 1 leaves points outside the crop on purpose.

jitter float

Fraction of the crop size the window is shifted by.

eval_tightness float

The fixed tightness a held-out sample is cropped at.

min_points int

Below this many finite points a view falls back to the largest window of the frame at the input's aspect.

full_p float

Probability of a letterboxed whole-frame crop instead, as an inference without a window sees; 0 once a locator makes the coarse pass (ADR-0064).

Source code in src/deeperfly/config/train.py
class Crops(Table):
    """`[data.crops]`: how a training sample is cropped around its animal (ADR-0046).

    Attributes:
        tightness: Log-uniform range of the crop's tightness around the animal;
            below 1 leaves points outside the crop on purpose.
        jitter: Fraction of the crop size the window is shifted by.
        eval_tightness: The fixed tightness a held-out sample is cropped at.
        min_points: Below this many finite points a view falls back to the largest
            window of the frame at the input's aspect.
        full_p: Probability of a letterboxed whole-frame crop instead, as an
            inference without a window sees; 0 once a locator makes the coarse pass
            (ADR-0064).
    """

    tightness: tuple[float, float] = (0.9, 2.2)
    jitter: float = 0.1
    eval_tightness: float = 1.25
    min_points: int = 4
    full_p: float = 0.1

Eval

Bases: Table

[train.eval]: when the held-out projects are scored, and on what.

Attributes:

Name Type Description
every int

Steps between held-out evaluations; 0 evaluates only at the end.

pck_px float

PCK threshold of the held-out report, in image pixels.

tta bool

Also run the mirrored pass when evaluating.

select_best str | None

Keep the evaluated weights that scored best on this metric, a dotted path into the report ("overall.pck"; see metric_at), rather than the last; the final weights are still written as model_final.pth. Needs a holdout and every. A fixed step budget overtrains a small label set, so an arm with a holdout should set it.

Source code in src/deeperfly/config/train.py
class Eval(Table):
    """`[train.eval]`: when the held-out projects are scored, and on what.

    Attributes:
        every: Steps between held-out evaluations; 0 evaluates only at the end.
        pck_px: PCK threshold of the held-out report, in image pixels.
        tta: Also run the mirrored pass when evaluating.
        select_best: Keep the evaluated weights that scored best on this metric, a
            dotted path into the report (`"overall.pck"`; see
            [metric_at][deeperfly.pose2d.training.metrics.metric_at]), rather than
            the last; the final weights are still written as `model_final.pth`.
            Needs a holdout and `every`. A fixed step budget overtrains a small
            label set, so an arm with a holdout should set it.
    """

    every: int = 0
    pck_px: float = 10.0
    tta: bool = False
    select_best: str | None = None

Fill

Bases: Table

[data.fill]: reprojected targets for the views a frame does not label.

Attributes:

Name Type Description
weight float

Weight of the filled targets; 0 turns the fill off.

max_residual_px float

Skip the fill of a keypoint whose 3D reprojects further than this from the views that labeled it.

min_views int

Labeled views a keypoint needs before its 3D is triangulated for the fill.

Source code in src/deeperfly/config/train.py
class Fill(Table):
    """`[data.fill]`: reprojected targets for the views a frame does not label.

    Attributes:
        weight: Weight of the filled targets; 0 turns the fill off.
        max_residual_px: Skip the fill of a keypoint whose 3D reprojects further
            than this from the views that labeled it.
        min_views: Labeled views a keypoint needs before its 3D is triangulated
            for the fill.
    """

    weight: float = 1.0
    max_residual_px: float = 10.0
    min_views: int = 2

Loss

Bases: Table

[train.loss]: the objective's terms, each off at zero.

Attributes:

Name Type Description
sigma float

Target heatmap sigma in field cells.

fg_weight float

Weight of the foreground cells in the heatmap loss.

field_weight float

Weight of the field cross-entropy, which lets a soft-argmax readout learn the off-frame margin.

center_weight float

Weight of the center channel's cross-entropy, when [data] center_channel builds one (ADR-0063).

coord_weight float

Weight of the decoded-coordinate loss.

coord_radius int

Field cells the coordinate loss reads around the peak.

coord_huber float

Huber knee of the coordinate loss, in input pixels.

tri_weight float

Weight of the pair-triangulation loss over a view group.

tri_huber float

Huber knee of the triangulation loss, in world units.

tri_max_world float

Reject a triangulated pair further than this from the group's consensus, in world units.

decode Literal['local', 'softargmax']

How the geometric losses read coordinates off the field in training, "local" (the expectation around the peak) or "softargmax"; independent of the inference readout.

Source code in src/deeperfly/config/train.py
class Loss(Table):
    """`[train.loss]`: the objective's terms, each off at zero.

    Attributes:
        sigma: Target heatmap sigma in field cells.
        fg_weight: Weight of the foreground cells in the heatmap loss.
        field_weight: Weight of the field cross-entropy, which lets a soft-argmax
            readout learn the off-frame margin.
        center_weight: Weight of the center channel's cross-entropy, when
            `[data] center_channel` builds one (ADR-0063).
        coord_weight: Weight of the decoded-coordinate loss.
        coord_radius: Field cells the coordinate loss reads around the peak.
        coord_huber: Huber knee of the coordinate loss, in input pixels.
        tri_weight: Weight of the pair-triangulation loss over a view group.
        tri_huber: Huber knee of the triangulation loss, in world units.
        tri_max_world: Reject a triangulated pair further than this from the
            group's consensus, in world units.
        decode: How the geometric losses read coordinates off the field in
            training, `"local"` (the expectation around the peak) or
            `"softargmax"`; independent of the inference readout.
    """

    sigma: float = 2.0
    fg_weight: float = 30.0
    field_weight: float = 0.0
    center_weight: float = 0.25
    coord_weight: float = 0.0
    coord_radius: int = 3
    coord_huber: float = 4.0
    tri_weight: float = 0.0
    tri_huber: float = 0.0
    tri_max_world: float = 0.0
    decode: Literal["local", "softargmax"] = "local"

Optimizer

Bases: Table

[train.optimizer]: AdamW and its schedule.

Attributes:

Name Type Description
lr float

Peak learning rate after the warmup.

weight_decay float

AdamW weight decay.

warmup_steps int

Linear warmup length.

backbone_lr_mult float

Learning-rate multiplier for the backbone's parameters.

Source code in src/deeperfly/config/train.py
class Optimizer(Table):
    """`[train.optimizer]`: AdamW and its schedule.

    Attributes:
        lr: Peak learning rate after the warmup.
        weight_decay: AdamW weight decay.
        warmup_steps: Linear warmup length.
        backbone_lr_mult: Learning-rate multiplier for the backbone's parameters.
    """

    lr: float = 2e-4
    weight_decay: float = 0.05
    warmup_steps: int = 500
    backbone_lr_mult: float = 1.0

SplitFile

Bases: Table

A [data] split file: what is held out, by project or by frame.

Projects are named as [data].holdout names them.

Attributes:

Name Type Description
holdout tuple[str, ...]

Selector held out whole, unioned with [data].holdout.

holdout_frames dict[str, tuple[int, ...]]

project -> frames held out of a project whose other labeled frames are trained on (kind = "project" only).

exclude_frames dict[str, tuple[int, ...]]

project -> frames trained on by neither set: the guard band around held-out frames, so a frame seconds away from a scored one does not leak into training.

Source code in src/deeperfly/config/train.py
class SplitFile(Table):
    """A `[data] split` file: what is held out, by project or by frame.

    Projects are named as `[data].holdout` names them.

    Attributes:
        holdout: Selector held out whole, unioned with `[data].holdout`.
        holdout_frames: `project -> frames` held out of a project whose other
            labeled frames are trained on (`kind = "project"` only).
        exclude_frames: `project -> frames` trained on by neither set: the guard
            band around held-out frames, so a frame seconds away from a scored one
            does not leak into training.
    """

    holdout: tuple[str, ...] = ()
    holdout_frames: dict[str, tuple[int, ...]] = Field(default_factory=dict)
    exclude_frames: dict[str, tuple[int, ...]] = Field(default_factory=dict)

    @classmethod
    def load(cls, path: str | Path) -> SplitFile:
        """Parse a split file strictly: an unknown key is a `ConfigError`."""
        data, _ = load_document(Path(path).expanduser())
        return parse(cls, data, "")

load classmethod

load(path: str | Path) -> SplitFile

Parse a split file strictly: an unknown key is a ConfigError.

Source code in src/deeperfly/config/train.py
@classmethod
def load(cls, path: str | Path) -> SplitFile:
    """Parse a split file strictly: an unknown key is a `ConfigError`."""
    data, _ = load_document(Path(path).expanduser())
    return parse(cls, data, "")

TrainCompute

Bases: Compute

[compute] of a training config: the shared knobs and data loading (ADR-0088).

Attributes:

Name Type Description
workers int

Data-loading worker processes.

Source code in src/deeperfly/config/train.py
class TrainCompute(Compute):
    """`[compute]` of a training config: the shared knobs and data loading (ADR-0088).

    Attributes:
        workers: Data-loading worker processes.
    """

    workers: int = 4

TrainConfig

Bases: Table

A whole training document, with its paths resolved.

Attributes:

Name Type Description
data TrainData

[data], the only required table.

format_version int

The schema version the document is written for.

model TrainModel

[model].

augment AugmentSpec | None

[augment], or None when absent, which means no augmentation.

train TrainLoop

[train].

compute TrainCompute

[compute].

unlabeled TrainUnlabeled | None

[unlabeled], or None when absent.

path Path | None

The file this was loaded from, or None when built in memory; not a key.

root Path | None

The directory its paths resolved against, the file's own; not a key.

text str | None

The document as the training records it when the command line set keys (--set), which are written into the file's text; None when the file's text is the record (recorded_text). Not a key.

Source code in src/deeperfly/config/train.py
class TrainConfig(Table):
    """A whole training document, with its paths resolved.

    Attributes:
        data: `[data]`, the only required table.
        format_version: The schema version the document is written for.
        model: `[model]`.
        augment: `[augment]`, or `None` when absent, which means no augmentation.
        train: `[train]`.
        compute: `[compute]`.
        unlabeled: `[unlabeled]`, or `None` when absent.
        path: The file this was loaded from, or `None` when built in memory; not a
            key.
        root: The directory its paths resolved against, the file's own; not a key.
        text: The document as the training records it when the command line set
            keys (`--set`), which are written into the file's text; `None` when the
            file's text is the record (`recorded_text`). Not a key.
    """

    data: TrainData
    format_version: int = TRAIN_FORMAT_VERSION
    model: TrainModel = Field(default_factory=TrainModel)
    augment: AugmentSpec | None = None
    train: TrainLoop = Field(default_factory=TrainLoop)
    compute: TrainCompute = Field(default_factory=TrainCompute)
    unlabeled: TrainUnlabeled | None = None
    path: Path | None = None
    root: Path | None = None
    text: str | None = None

    #: `path` and `root` are where the document lives, not keys it may declare.
    _reserved: ClassVar[tuple[str, ...]] = ("path", "root", "text")

    @classmethod
    def from_document(cls, data: dict, doc_dir: Path, path: Path | None = None):
        """Build from a parsed document, resolving its paths (see the module docs)."""
        check_format_version(data, "training config", TRAIN_FORMAT_VERSION)
        cfg = parse(cls, data, "")
        return cfg._resolved(doc_dir, path)

    @classmethod
    def load(
        cls, path: str | Path, changes: Mapping[str, Any] | None = None
    ) -> TrainConfig:
        """Load, lint and type a training document.

        `changes` are values by dotted key ([overrides][deeperfly.document.overrides])
        set over the file's before it is typed, as if the file said them; the
        training records them, written into the file's text.

        Raises:
            ConfigError: If the file, with the changes, breaks any rule of the schema.
        """
        file = Path(path).expanduser()
        data, doc_dir = load_document(file)
        if not changes:
            return cls.from_document(data, doc_dir, file)
        cfg = cls.from_document(with_keys(data, changes), doc_dir, file)
        return cfg.model_copy(update={"text": write_keys(file.read_text(), changes)})

    def recorded_text(self) -> str:
        """The document as the training records it: the file's text, with `--set` keys.

        Empty for a document built in memory.
        """
        if self.text is not None:
            return self.text
        return self.path.read_text() if self.path is not None else ""

    def _resolved(self, doc_dir: Path, path: Path | None) -> TrainConfig:
        """This config with its paths resolved against `doc_dir` (ADR-0085).

        `[data] projects` stay patterns, expanded against `root` when training starts.
        """
        data = self.data
        if data.root is not None:
            data = _with(data, root=under_document(data.root, doc_dir))
        if data.split is not None:
            data = _with(data, split=under_document(data.split, doc_dir))
        cache = under_document(data.cache or "cache", doc_dir)
        data = _with(data, cache=cache)
        model = self.model
        if model.init_from is not None:
            model = _with(model, init_from=artifact_path(model.init_from, doc_dir))
        train = self.train
        if train.out is not None:
            train = _with(train, out=under_document(train.out, doc_dir))
        unlabeled = self.unlabeled
        if unlabeled is not None and unlabeled.teacher is not None:
            unlabeled = _with(
                unlabeled, teacher=artifact_path(unlabeled.teacher, doc_dir)
            )
        if unlabeled is not None:
            unlabeled = _with(
                unlabeled,
                cache=cache / "unlabeled"
                if unlabeled.cache is None
                else under_document(unlabeled.cache, doc_dir),
            )
        return _with(
            self,
            data=data,
            model=model,
            train=train,
            unlabeled=unlabeled,
            path=path,
            root=Path(doc_dir),
        )

from_document classmethod

from_document(
    data: dict, doc_dir: Path, path: Path | None = None
)

Build from a parsed document, resolving its paths (see the module docs).

Source code in src/deeperfly/config/train.py
@classmethod
def from_document(cls, data: dict, doc_dir: Path, path: Path | None = None):
    """Build from a parsed document, resolving its paths (see the module docs)."""
    check_format_version(data, "training config", TRAIN_FORMAT_VERSION)
    cfg = parse(cls, data, "")
    return cfg._resolved(doc_dir, path)

load classmethod

load(
    path: str | Path,
    changes: Mapping[str, Any] | None = None,
) -> TrainConfig

Load, lint and type a training document.

changes are values by dotted key (overrides) set over the file's before it is typed, as if the file said them; the training records them, written into the file's text.

Raises:

Type Description
ConfigError

If the file, with the changes, breaks any rule of the schema.

Source code in src/deeperfly/config/train.py
@classmethod
def load(
    cls, path: str | Path, changes: Mapping[str, Any] | None = None
) -> TrainConfig:
    """Load, lint and type a training document.

    `changes` are values by dotted key ([overrides][deeperfly.document.overrides])
    set over the file's before it is typed, as if the file said them; the
    training records them, written into the file's text.

    Raises:
        ConfigError: If the file, with the changes, breaks any rule of the schema.
    """
    file = Path(path).expanduser()
    data, doc_dir = load_document(file)
    if not changes:
        return cls.from_document(data, doc_dir, file)
    cfg = cls.from_document(with_keys(data, changes), doc_dir, file)
    return cfg.model_copy(update={"text": write_keys(file.read_text(), changes)})

recorded_text

recorded_text() -> str

The document as the training records it: the file's text, with --set keys.

Empty for a document built in memory.

Source code in src/deeperfly/config/train.py
def recorded_text(self) -> str:
    """The document as the training records it: the file's text, with `--set` keys.

    Empty for a document built in memory.
    """
    if self.text is not None:
        return self.text
    return self.path.read_text() if self.path is not None else ""

TrainData

Bases: Table

[data]: which labels to train on and what to hold out.

Three corpus kinds share the table, and a key of another kind is an error.

Attributes:

Name Type Description
kind Literal['project', 'df3d', 'lightning_pose']

"project" (deeperfly projects' labels), "df3d" or "lightning_pose" (an imported corpus directory).

task Literal['pose', 'locate']

"pose", the detector: one sample per (project, frame, view), a channel per keypoint. "locate", the full-frame locator (ADR-0064): one sample per (project, frame, video), the whole frame letterboxed, a channel per view; it needs calibrated projects and ignores the crop keys. kind = "project" only.

projects tuple[str, ...]

For kind = "project", the projects trained on: paths or globs against the document's directory, each match resolved to a project as deeperfly run resolves a path, never creating one (ADR-0085), and a path that is a directory of projects taking its projects, as deeperfly train takes one (ADR-0087). A glob match that is no project is skipped, a pattern that matches nothing is an error. Paths given to deeperfly train replace the list, so it may be empty here.

root TomlPath | None

The corpus directory, for the other two kinds.

skeleton str | None

A packaged preset for a corpus kind, which carries no project; None means "fly38".

holdout tuple[str, ...]

Selector held out and scored, never trained on: projects by their names in the collection (project_names), or a corpus's recordings.

train tuple[str, ...]

Selector trained on; empty means every labeled project not held out.

split TomlPath | None

A TOML file holding a holdout list, unioned with holdout.

holdout_every int

df3d only: hold out every n-th experiment; 0 is off.

exclude tuple[str, ...]

lightning_pose only: recordings left out of both sets.

views tuple[str, ...]

lightning_pose only: which views of the corpus to read.

crops Crops

[data.crops].

center_channel bool

Build the head one channel wider, trained to peak at each animal's center (ADR-0063): the median of the frame's solved 3D keypoints, projected, so it needs a calibrated project. Raise [data.crops] full_p with it, since the channel is read at the letterbox scale.

fill Fill

[data.fill], reprojected targets for the views a frame does not label.

boost Boost

[data.boost], projects drawn more often than their size gives.

mark_others bool

On a frame of several animals, mark every other animal's center in each sample's input (ADR-0148): a white disk in a black ring, so a window says which animal it is not about. The detector records it and draws the same mark at inference.

mark_jitter float

Training-time error of a marked center, in input pixels, a stand-in for the window's at inference; evaluation marks exactly.

off_view_weight float

Weight of a target that lands outside its own view's image, where no pixel is behind it (not the off-frame margin the head is built to carry).

view_aspect dict[str, float]

View name -> the aspect (width / height) of its crop box, instead of the input's, for a subject far from square such as a prism strip's; a run config's [pose2d] crop_aspect must repeat it. It lost as an arm (ADR-0051).

keypoint_weights dict[str, float]

Per-point loss weight overrides, point name -> weight.

view_sides dict[str, str]

View name -> "left" or "right", for the held-out report's ipsi/contralateral breakdown.

cache TomlPath | None

Where the decoded-frame cache lives; <document dir>/cache/ by default.

Source code in src/deeperfly/config/train.py
class TrainData(Table):
    """`[data]`: which labels to train on and what to hold out.

    Three corpus kinds share the table, and a key of another kind is an error.

    Attributes:
        kind: `"project"` (deeperfly projects' labels), `"df3d"` or
            `"lightning_pose"` (an imported corpus directory).
        task: `"pose"`, the detector: one sample per `(project, frame, view)`, a
            channel per keypoint. `"locate"`, the full-frame locator (ADR-0064): one
            sample per `(project, frame, video)`, the whole frame letterboxed, a
            channel per view; it needs calibrated projects and ignores the crop
            keys. `kind = "project"` only.
        projects: For `kind = "project"`, the projects trained on: paths or globs
            against the document's directory, each match resolved to a project as
            `deeperfly run` resolves a path, never creating one (ADR-0085), and a
            path that is a directory of projects taking its projects, as
            `deeperfly train` takes one (ADR-0087). A glob match that is no project
            is skipped, a pattern that matches nothing is an error. Paths given to
            `deeperfly train` replace the list, so it may be empty here.
        root: The corpus directory, for the other two kinds.
        skeleton: A packaged preset for a corpus kind, which carries no project;
            `None` means `"fly38"`.
        holdout: Selector held out and scored, never trained on: projects by their
            names in the collection ([project_names][deeperfly.project.project_names]),
            or a corpus's recordings.
        train: Selector trained on; empty means every labeled project not held
            out.
        split: A TOML file holding a `holdout` list, unioned with `holdout`.
        holdout_every: `df3d` only: hold out every n-th experiment; 0 is off.
        exclude: `lightning_pose` only: recordings left out of both sets.
        views: `lightning_pose` only: which views of the corpus to read.
        crops: `[data.crops]`.
        center_channel: Build the head one channel wider, trained to peak at each
            animal's center (ADR-0063): the median of the frame's solved 3D
            keypoints, projected, so it needs a calibrated project. Raise
            `[data.crops] full_p` with it, since the channel is read at the
            letterbox scale.
        fill: `[data.fill]`, reprojected targets for the views a frame does not
            label.
        boost: `[data.boost]`, projects drawn more often than their size gives.
        mark_others: On a frame of several animals, mark every other animal's
            center in each sample's input (ADR-0148): a white disk in a black ring,
            so a window says which animal it is not about. The detector records it
            and draws the same mark at inference.
        mark_jitter: Training-time error of a marked center, in input pixels, a
            stand-in for the window's at inference; evaluation marks exactly.
        off_view_weight: Weight of a target that lands outside its own view's
            image, where no pixel is behind it (not the off-frame margin the head
            is built to carry).
        view_aspect: View name -> the aspect (width / height) of its crop box,
            instead of the input's, for a subject far from square such as a prism
            strip's; a run config's `[pose2d] crop_aspect` must repeat it. It lost
            as an arm (ADR-0051).
        keypoint_weights: Per-point loss weight overrides, `point name -> weight`.
        view_sides: View name -> `"left"` or `"right"`, for the held-out report's
            ipsi/contralateral breakdown.
        cache: Where the decoded-frame cache lives; `<document dir>/cache/` by
            default.
    """

    kind: Literal["project", "df3d", "lightning_pose"] = "project"
    task: Literal["pose", "locate"] = "pose"
    projects: tuple[str, ...] = ()
    root: TomlPath | None = None
    skeleton: str | None = None
    holdout: tuple[str, ...] = ()
    train: tuple[str, ...] = ()
    split: TomlPath | None = None
    holdout_every: int = 0
    exclude: tuple[str, ...] = ()
    views: tuple[str, ...] = ()
    crops: Crops = Field(default_factory=lambda: Crops())
    center_channel: bool = False
    fill: Fill = Field(default_factory=lambda: Fill())
    boost: Boost = Field(default_factory=lambda: Boost())
    mark_others: bool = False
    mark_jitter: float = 6.0
    off_view_weight: float = 1.0
    view_aspect: dict[str, float] = Field(default_factory=dict)
    keypoint_weights: dict[str, float] = Field(default_factory=dict)
    view_sides: dict[str, str] = Field(default_factory=dict)
    cache: TomlPath | None = None

    @model_validator(mode="after")
    def _rules(self):
        """Check that each key belongs to the declared `kind`."""
        wrong = [
            (name, kinds)
            for name, kinds, given in (
                ("projects", ("project",), bool(self.projects)),
                ("root", ("df3d", "lightning_pose"), self.root is not None),
                ("skeleton", ("df3d", "lightning_pose"), self.skeleton is not None),
                ("holdout_every", ("df3d",), bool(self.holdout_every)),
                ("exclude", ("lightning_pose",), bool(self.exclude)),
                ("views", ("lightning_pose",), bool(self.views)),
                ("center_channel", ("project",), self.center_channel),
                ("boost", ("project",), bool(self.boost.projects)),
                ("mark_others", ("project",), self.mark_others),
                ("task", ("project",), self.task != "pose"),
            )
            if given and self.kind not in kinds
        ]
        if wrong:
            name, kinds = wrong[0]
            raise ValueError(
                f"{name}: only for kind = "
                + " / ".join(repr(k) for k in kinds)
                + f", not {self.kind!r}"
            )
        if self.kind != "project" and self.root is None:
            raise ValueError(f"root: required for kind = {self.kind!r}")
        if self.task == "locate" and self.center_channel:
            raise ValueError(
                "center_channel: a locator is nothing but center channels, one per "
                'view; the extra one has no meaning with task = "locate"'
            )
        return self

TrainLoop

Bases: Table

[train]: the optimization loop's knobs, one default each.

Attributes:

Name Type Description
steps int

Optimizer steps to run.

batch_size int

Samples per step -- view groups when grouped, images otherwise.

images_per_batch int

With grouped, cap a batch at this many images (groups have different view counts); 0 is off.

optimizer Optimizer

[train.optimizer].

loss Loss

[train.loss].

eval Eval

[train.eval].

grad_checkpoint bool

Recompute the backbone blocks in the backward pass, to fit a larger model or batch in memory. Slower, same numbers.

grad_clip float

Gradient-norm clip; 0 is off.

amp bool

Mixed-precision forward/backward.

ema_decay float

Exponential moving average of the weights; 0 is off.

view_drop_p float

Probability of dropping a view from a training group.

view_min int

Views a group keeps after dropping.

grouped bool | None

Train on view groups rather than independent images; None lets the model decide (the multiview transformer requires groups).

seed int

Seed for torch, numpy and the samplers.

log_every int

Steps between log lines.

out TomlPath | None

Where the training writes, against the document's directory; <document dir>/<stem>/ by default. deeperfly train --out overrides both.

Source code in src/deeperfly/config/train.py
class TrainLoop(Table):
    """`[train]`: the optimization loop's knobs, one default each.

    Attributes:
        steps: Optimizer steps to run.
        batch_size: Samples per step -- view groups when `grouped`, images
            otherwise.
        images_per_batch: With `grouped`, cap a batch at this many images
            (groups have different view counts); 0 is off.
        optimizer: `[train.optimizer]`.
        loss: `[train.loss]`.
        eval: `[train.eval]`.
        grad_checkpoint: Recompute the backbone blocks in the backward pass, to fit
            a larger model or batch in memory. Slower, same numbers.
        grad_clip: Gradient-norm clip; 0 is off.
        amp: Mixed-precision forward/backward.
        ema_decay: Exponential moving average of the weights; 0 is off.
        view_drop_p: Probability of dropping a view from a training group.
        view_min: Views a group keeps after dropping.
        grouped: Train on view groups rather than independent images; `None`
            lets the model decide (the multiview transformer requires groups).
        seed: Seed for torch, numpy and the samplers.
        log_every: Steps between log lines.
        out: Where the training writes, against the document's directory;
            `<document dir>/<stem>/` by default. `deeperfly train --out` overrides
            both.
    """

    steps: int = 10000
    batch_size: int = 16
    images_per_batch: int = 0
    optimizer: Optimizer = Field(default_factory=lambda: Optimizer())
    loss: Loss = Field(default_factory=lambda: Loss())
    eval: Eval = Field(default_factory=lambda: Eval())
    grad_checkpoint: bool = False
    grad_clip: float = 0.0
    amp: bool = False
    ema_decay: float = 0.0
    view_drop_p: float = 0.0
    view_min: int = 1
    grouped: bool | None = None
    seed: int = 0
    log_every: int = 50
    out: TomlPath | None = None

TrainModel

Bases: Table

[model]: the detector to build, the schema's one open table (ADR-0032).

Attributes:

Name Type Description
family Literal['monocular', 'simcc', 'mvt', 'fast']

"monocular", "simcc", "mvt" or "fast".

backbone str | None

A timm model name; None takes the builder's default.

pretrained bool

Load the backbone's pretrained weights.

input_hw tuple[int, int]

Network input (height, width), which every crop is resized to.

init_from ArtifactRef | None

Warm start from an earlier training's artifact: a path against the document's directory (a directory means its model.pth), or a released model's name (ADR-0151).

extra dict[str, Any]

The family's own builder keys, checked against MODEL_KEYS.

Source code in src/deeperfly/config/train.py
class TrainModel(Table):
    """`[model]`: the detector to build, the schema's one open table (ADR-0032).

    Attributes:
        family: `"monocular"`, `"simcc"`, `"mvt"` or `"fast"`.
        backbone: A `timm` model name; `None` takes the builder's default.
        pretrained: Load the backbone's pretrained weights.
        input_hw: Network input `(height, width)`, which every crop is resized to.
        init_from: Warm start from an earlier training's artifact: a path against
            the document's directory (a directory means its `model.pth`), or a
            released model's name (ADR-0151).
        extra: The family's own builder keys, checked against `MODEL_KEYS`.
    """

    family: Literal["monocular", "simcc", "mvt", "fast"] = "monocular"
    backbone: str | None = None
    pretrained: bool = False
    input_hw: tuple[int, int] = (256, 512)
    init_from: ArtifactRef | None = None
    extra: dict[str, Any] = Field(default_factory=dict)

    @model_validator(mode="before")
    @classmethod
    def _collect_extra(cls, data):
        """Unknown keys are the family's, not a typo -- see `MODEL_KEYS`."""
        if isinstance(data, Mapping):
            unknown = {k: v for k, v in data.items() if k not in cls.model_fields}
            if unknown:
                known = {k: v for k, v in data.items() if k in cls.model_fields}
                data = {**known, "extra": {**known.get("extra", {}), **unknown}}
        return data

    @model_validator(mode="after")
    def _rules(self):
        """Check every passthrough key against the family's builder keys."""
        allowed = MODEL_KEYS[self.family]
        unknown = sorted(set(self.extra) - set(allowed))
        if unknown:
            raise ValueError(
                f"{unknown[0]}: not a {self.family!r} key; {self.family!r} takes "
                + ", ".join(sorted(allowed))
            )
        return self

TrainUnlabeled

Bases: Table

[unlabeled]: the cross-view distillation stream over unlabeled moments.

kind = "project" only. A teacher predicts every view of sampled unlabeled moments, the predictions are triangulated robustly and reprojected, and the student trains on the consensus with some views' tokens masked (see unlabeled).

Attributes:

Name Type Description
teacher ArtifactRef | None

The teacher artifact, named as [model] init_from is, which must not have trained on the holdout. Absent, the teacher is each project's merged detections (ADR-0083), which a rig with per-frame windows needs; only detected frames are then sampled.

n_per_project int

Unlabeled moments sampled per project, spread over the video, labeled frames skipped.

exclude tuple[str, ...]

Project selector left out of the stream.

transductive bool

Also draw from the held-out projects' unlabeled frames.

agree_px float

Reprojection distance within which a view agrees with the consensus.

min_views int

Agreeing views a keypoint needs before it has a consensus.

conf_min float

Teacher confidence below which a view does not vote.

geom_weight float

How far the target moves from the teacher's own pixel (0) toward the reprojected consensus (1); see consensus_targets.

guard_frames int

Frames either side of a held-out frame the stream must not draw; 0 skips the held frames only.

weight float

Weight of the distillation term against the labeled loss.

mask_ratios tuple[float, ...]

Per-view token-mask ratios drawn per unlabeled group; one view per group always stays whole.

masked_weight float

Cell weight on masked views.

unmasked_weight float

Cell weight on whole views.

crop_tightness tuple[float, float]

Log-uniform crop tightness around the consensus points.

cache TomlPath | None

Where the unlabeled caches live, against the document's directory; None is unlabeled/ under [data] cache.

Source code in src/deeperfly/config/train.py
class TrainUnlabeled(Table):
    """`[unlabeled]`: the cross-view distillation stream over unlabeled moments.

    `kind = "project"` only. A teacher predicts every view of sampled unlabeled
    moments, the predictions are triangulated robustly and reprojected, and the
    student trains on the consensus with some views' tokens masked (see
    [unlabeled][deeperfly.pose2d.training.unlabeled]).

    Attributes:
        teacher: The teacher artifact, named as `[model] init_from` is, which
            must not have trained on the holdout. Absent, the teacher is each
            project's merged detections (ADR-0083), which a rig with per-frame
            windows needs; only detected frames are then sampled.
        n_per_project: Unlabeled moments sampled per project, spread over the
            video, labeled frames skipped.
        exclude: Project selector left out of the stream.
        transductive: Also draw from the held-out projects' unlabeled frames.
        agree_px: Reprojection distance within which a view agrees with the
            consensus.
        min_views: Agreeing views a keypoint needs before it has a consensus.
        conf_min: Teacher confidence below which a view does not vote.
        geom_weight: How far the target moves from the teacher's own pixel (0)
            toward the reprojected consensus (1); see
            [consensus_targets][deeperfly.pose2d.training.unlabeled.consensus_targets].
        guard_frames: Frames either side of a held-out frame the stream must not
            draw; 0 skips the held frames only.
        weight: Weight of the distillation term against the labeled loss.
        mask_ratios: Per-view token-mask ratios drawn per unlabeled group; one view
            per group always stays whole.
        masked_weight: Cell weight on masked views.
        unmasked_weight: Cell weight on whole views.
        crop_tightness: Log-uniform crop tightness around the consensus points.
        cache: Where the unlabeled caches live, against the document's directory;
            `None` is `unlabeled/` under `[data] cache`.
    """

    teacher: ArtifactRef | None = None
    n_per_project: int = 200
    exclude: tuple[str, ...] = ()
    transductive: bool = False
    agree_px: float = 6.0
    min_views: int = 3
    conf_min: float = 0.3
    geom_weight: float = 1.0
    guard_frames: int = 0
    weight: float = 0.5
    mask_ratios: tuple[float, ...] = (0.0, 0.5, 0.9)
    masked_weight: float = 1.0
    unmasked_weight: float = 0.5
    crop_tightness: tuple[float, float] = (0.75, 1.6)
    cache: TomlPath | None = None

artifact_path

artifact_path(value: Path, doc_dir: Path) -> Path

A detector reference of a training or a run config, as the config holds it.

A released model's name stays a name, found in the cache when the detector is loaded (ADR-0151). Anything else is a path against the document (ADR-0085), a directory meaning its ARTIFACT_NAME.

Source code in src/deeperfly/config/train.py
def artifact_path(value: Path, doc_dir: Path) -> Path:
    """A detector reference of a training or a run config, as the config holds it.

    A released model's name stays a name, found in the cache when the detector is
    loaded (ADR-0151). Anything else is a path against the document (ADR-0085), a
    directory meaning its `ARTIFACT_NAME`.
    """
    if str(value) in _released():
        return value
    path = under_document(value, doc_dir)
    return path / ARTIFACT_NAME if path.is_dir() else path

deeperfly run PATH... -c config.toml: the typed run config (format 4).

A run config says what to compute, never what the videos are: the videos, the layout, the views, the skeleton and the animal count are the project's (deeperfly.toml, ADR-0081), and one config applied to several projects gives one run per project. [calibration] holds the camera parameters a run starts from (ADR-0086); every other table is a stage, enabled by its presence (ADR-0028) and named for what it writes (ADR-0029, ADR-0034). Every path resolves against the config's own directory (ADR-0085). What can only be checked against a project is check_project. The schema is docs/config-schema.md.

BallStage

Bases: Table

[ball]: track the treadmill ball under a tethered animal -> ball/*.

spintrack, an optional extra (deeperfly[ball]), tracks the ball's rotation in view's video; the ball's outline in the outline views places it on every frame, starting from where the 3D pose's leg tips rest on it, and the pose orients the animal on it (ball, ADR-0144). Everything it writes is in the world frame.

Attributes:

Name Type Description
view str

The view whose video spintrack tracks: one that sees the ball's texture, unmirrored.

outline tuple[str, ...]

View selector for the views whose outline of the ball places it; mirrored views are left out. Depth along a line of sight needs a view off it.

input str | None

The 3D the ball is started from and the animal oriented by; omitted, the latest upstream 3D.

contact tuple[str, ...]

Point selector for the points that rest on the ball in stance.

stand tuple[str, ...]

Point selector for the points whose centroid is where the animal stands, just above the ball.

front tuple[str, ...]

Point selector for the front of the body axis.

hind tuple[str, ...]

Point selector for the back of the body axis.

contact_frames int

How many opening frames of the 3D the ball's radius is fitted and the animal oriented in, as a ball can move in its holder later.

two_pass bool

Map the ball in a throwaway first pass, as spintrack run --two-pass does.

tracking dict[str, Any]

spintrack's [tracking] table, handed over as it is.

Source code in src/deeperfly/config/run.py
class BallStage(Table):
    """`[ball]`: track the treadmill ball under a tethered animal -> `ball/*`.

    spintrack, an optional extra (`deeperfly[ball]`), tracks the ball's rotation in
    `view`'s video; the ball's outline in the `outline` views places it on every frame,
    starting from where the 3D pose's leg tips rest on it, and the pose orients the
    animal on it ([ball][deeperfly.ball], ADR-0144). Everything it writes is in the
    world frame.

    Attributes:
        view: The view whose video spintrack tracks: one that sees the ball's texture,
            unmirrored.
        outline: View selector for the views whose outline of the ball places it;
            mirrored views are left out. Depth along a line of sight needs a view off
            it.
        input: The 3D the ball is started from and the animal oriented by; omitted,
            the latest upstream 3D.
        contact: Point selector for the points that rest on the ball in stance.
        stand: Point selector for the points whose centroid is where the animal
            stands, just above the ball.
        front: Point selector for the front of the body axis.
        hind: Point selector for the back of the body axis.
        contact_frames: How many opening frames of the 3D the ball's radius is fitted
            and the animal oriented in, as a ball can move in its holder later.
        two_pass: Map the ball in a throwaway first pass, as `spintrack run
            --two-pass` does.
        tracking: spintrack's `[tracking]` table, handed over as it is.
    """

    view: str
    outline: tuple[str, ...] = ("*",)
    input: str | None = None
    contact: tuple[str, ...] = ("*_pretarsus",)
    stand: tuple[str, ...] = ("*_thorax_coxa",)
    front: tuple[str, ...] = ("lf_thorax_coxa", "rf_thorax_coxa")
    hind: tuple[str, ...] = ("lh_thorax_coxa", "rh_thorax_coxa")
    contact_frames: int = Field(1000, gt=0)
    two_pass: bool = False
    tracking: dict[str, Any] = Field(default_factory=dict)

BundleAdjustmentStage

Bases: Table

[bundle_adjustment]: refine [calibration] on this project.

It starts from the parameters [calibration] gives and hands the refined rig to the 3D stages after it; the result is the run's bundle_adjustment/views, a raw view per view that a [calibration.views.<name>] table accepts. Cached like any stage, so --force re-solves from [calibration] rather than refining the refinement (ADR-0086).

Attributes:

Name Type Description
source Literal['pose2d/points', 'labels/points']

Which 2D to fit: the detector's, or the project's hand labels (which need no [pose2d]).

points tuple[str, ...]

Point selector to fit on; empty uses every point.

fixed tuple[str, ...]

Solver references held constant ("*.intr", "f.tvec[2]"); empty holds every view's intrinsics and distortion plus the first view's pose, which anchors the world frame.

shared tuple[tuple[str, ...], ...]

Groups of references tied to one value.

frames int

How many labeled or predicted frames to sample, evenly.

weighted bool

Weight each residual by the detector's confidence.

loss Literal['linear', 'soft_l1', 'huber', 'cauchy', 'arctan']

Robust loss for the solver.

threshold_px float

Inlier margin of the robust loss, in pixels.

max_nfev int

Maximum solver function evaluations.

Source code in src/deeperfly/config/run.py
class BundleAdjustmentStage(Table):
    """`[bundle_adjustment]`: refine `[calibration]` on this project.

    It starts from the parameters `[calibration]` gives and hands the refined rig to
    the 3D stages after it; the result is the run's `bundle_adjustment/views`, a raw
    view per view that a `[calibration.views.<name>]` table accepts. Cached like any
    stage, so `--force` re-solves from `[calibration]` rather than refining the
    refinement (ADR-0086).

    Attributes:
        source: Which 2D to fit: the detector's, or the project's hand labels
            (which need no `[pose2d]`).
        points: Point selector to fit on; empty uses every point.
        fixed: Solver references held constant (`"*.intr"`, `"f.tvec[2]"`); empty
            holds every view's intrinsics and distortion plus the first view's pose,
            which anchors the world frame.
        shared: Groups of references tied to one value.
        frames: How many labeled or predicted frames to sample, evenly.
        weighted: Weight each residual by the detector's confidence.
        loss: Robust loss for the solver.
        threshold_px: Inlier margin of the robust loss, in pixels.
        max_nfev: Maximum solver function evaluations.
    """

    source: Literal["pose2d/points", "labels/points"] = "pose2d/points"
    points: tuple[str, ...] = ()
    fixed: tuple[str, ...] = ()
    shared: tuple[tuple[str, ...], ...] = ()
    frames: int = 200
    weighted: bool = False
    loss: Literal["linear", "soft_l1", "huber", "cauchy", "arctan"] = "huber"
    threshold_px: float = 8.0
    max_nfev: int = 200

    @model_validator(mode="after")
    def _rules(self):
        """Hand labels carry no confidence, so there is nothing to weight by."""
        if self.weighted and self.source == "labels/points":
            raise ValueError(
                "weighted: hand labels carry no confidence; weight the detector's "
                'output instead (source = "pose2d/points")'
            )
        return self

    def fixed_refs(self, views: list[str]) -> list[str]:
        """The references to hold; empty expands to the default for these views."""
        if self.fixed:
            return list(self.fixed)
        return ["*.intr", "*.dist", f"{views[0]}.rvec", f"{views[0]}.tvec"]

fixed_refs

fixed_refs(views: list[str]) -> list[str]

The references to hold; empty expands to the default for these views.

Source code in src/deeperfly/config/run.py
def fixed_refs(self, views: list[str]) -> list[str]:
    """The references to hold; empty expands to the default for these views."""
    if self.fixed:
        return list(self.fixed)
    return ["*.intr", "*.dist", f"{views[0]}.rvec", f"{views[0]}.tvec"]

CorrectionStage

Bases: Table

[correction]: fix known anatomical violations -> correction/points.

Two fixed sub-tables, applied in that order.

Attributes:

Name Type Description
input str | None

The 3D to correct; omitted, the latest upstream 3D.

static StaticCorrection | None

[correction.static].

symmetrize SymmetrizeCorrection | None

[correction.symmetrize].

Source code in src/deeperfly/config/run.py
class CorrectionStage(Table):
    """`[correction]`: fix known anatomical violations -> `correction/points`.

    Two fixed sub-tables, applied in that order.

    Attributes:
        input: The 3D to correct; omitted, the latest upstream 3D.
        static: `[correction.static]`.
        symmetrize: `[correction.symmetrize]`.
    """

    input: str | None = None
    static: StaticCorrection | None = None
    symmetrize: SymmetrizeCorrection | None = None

    @model_validator(mode="after")
    def _rules(self):
        """Refuse a correction stage that corrects nothing."""
        if self.static is None and self.symmetrize is None:
            raise ValueError(
                "add [correction.static] or [correction.symmetrize]; on its own "
                "the table corrects nothing"
            )
        return self

IkStage

Bases: Table

[ik]: fit a body model to the 3D pose -> ik/angles, ik/points.

The solve is QuickIK's, an optional extra (deeperfly[ik]); see ik. model names a pack and binding where the skeleton's points sit on it (ADR-0041); damping and symmetric_segments are ADR-0065 and ADR-0066.

Attributes:

Name Type Description
input str | None

The 3D to fit; omitted, the latest upstream 3D.

model str

A packaged model pack ("neuromechfly") or a path to its model.toml or its directory, against the config's directory.

binding str | None

The "<skeleton>@<model>" binding, or a path to one against the config's directory; omitted, the pair the project's skeleton and this model name.

chains tuple[str, ...]

The model's non-leg chains to solve; empty solves every one the pack defines. The legs are always solved.

iterations int

Gauss-Newton steps per frame.

damping float

Levenberg-Marquardt damping of the step.

neutral_weight float

Pull toward each DOF's neutral value, which pins the DOFs the keypoints do not determine.

position_tolerance float

Early-stop threshold on the observations, in model units.

angle_tolerance float

Early-stop threshold on the angles, in radians.

fixed_body bool

Hold the body in the model frame and fit the legs to it (a tethered fly); false fits a 6-DOF root per frame.

symmetric_segments bool

One shared length per segment for each leg and its mirror.

weighted bool

Weight the observations by the detector's confidence.

bounds dict[str, tuple[float, float]]

Per-DOF limits in degrees, "<parent>-<child>-<dof>" = [lo, hi], for the legs and the chains alike.

markers dict[str, dict[str, Any]]

Per chain, point = { body, offset, depth?, base? }, overriding the binding; a chain's table replaces its whole marker set, so it must carry over a base = true.

Source code in src/deeperfly/config/run.py
class IkStage(Table):
    """`[ik]`: fit a body model to the 3D pose -> `ik/angles`, `ik/points`.

    The solve is QuickIK's, an optional extra (`deeperfly[ik]`); see
    [ik][deeperfly.ik]. `model` names a pack and `binding` where the skeleton's points
    sit on it (ADR-0041); `damping` and `symmetric_segments` are ADR-0065 and
    ADR-0066.

    Attributes:
        input: The 3D to fit; omitted, the latest upstream 3D.
        model: A packaged model pack (`"neuromechfly"`) or a path to its `model.toml`
            or its directory, against the config's directory.
        binding: The `"<skeleton>@<model>"` binding, or a path to one against the
            config's directory; omitted, the pair the project's skeleton and this
            model name.
        chains: The model's non-leg chains to solve; empty solves every one the pack
            defines. The legs are always solved.
        iterations: Gauss-Newton steps per frame.
        damping: Levenberg-Marquardt damping of the step.
        neutral_weight: Pull toward each DOF's neutral value, which pins the DOFs the
            keypoints do not determine.
        position_tolerance: Early-stop threshold on the observations, in model units.
        angle_tolerance: Early-stop threshold on the angles, in radians.
        fixed_body: Hold the body in the model frame and fit the legs to it (a
            tethered fly); false fits a 6-DOF root per frame.
        symmetric_segments: One shared length per segment for each leg and its mirror.
        weighted: Weight the observations by the detector's confidence.
        bounds: Per-DOF limits in degrees, `"<parent>-<child>-<dof>" = [lo, hi]`, for
            the legs and the chains alike.
        markers: Per chain, `point = { body, offset, depth?, base? }`, overriding the
            binding; a chain's table replaces its whole marker set, so it must carry
            over a `base = true`.
    """

    input: str | None = None
    model: str = "neuromechfly"
    binding: str | None = None
    chains: tuple[str, ...] = ()
    iterations: int = 60
    damping: float = 0.1
    neutral_weight: float = 1e-3
    position_tolerance: float = 1e-3
    angle_tolerance: float = 1e-3
    fixed_body: bool = True
    symmetric_segments: bool = True
    weighted: bool = False
    bounds: dict[str, tuple[float, float]] = Field(default_factory=dict)
    markers: dict[str, dict[str, Any]] = Field(default_factory=dict)

InitialCalibration

Bases: Table

[calibration]: the camera parameters a run starts from (ADR-0086).

Either a copy made at run start or placements written here, never both. A copy is of the values, so the run reproduces after the calibration files change; the values are what the geometry stages use, what their fingerprints hash and what the run stores.

Attributes:

Name Type Description
source int | str | None

from: "active" (the project's active calibration), a calibration id of the project, or a calibration .toml, relative to the config and resolved on load.

views dict[str, ViewSpec]

[calibration.views.<name>], keyed by the project's view names: an orbit or a raw placement each (ViewSpec); a value every view shares is written once under [calibration.views]. Which views are mirrored is the manifest's to say.

Source code in src/deeperfly/config/run.py
class InitialCalibration(Table):
    """`[calibration]`: the camera parameters a run starts from (ADR-0086).

    Either a copy made at run start or placements written here, never both. A copy
    is of the values, so the run reproduces after the calibration files change; the
    values are what the geometry stages use, what their fingerprints hash and what
    the run stores.

    Attributes:
        source: `from`: `"active"` (the project's active calibration), a calibration
            id of the project, or a calibration `.toml`, relative to the config and
            resolved on load.
        views: `[calibration.views.<name>]`, keyed by the project's view names: an
            orbit or a raw placement each ([ViewSpec][deeperfly.rig.ViewSpec]); a
            value every view shares is written once under `[calibration.views]`.
            Which views are mirrored is the manifest's to say.
    """

    source: int | str | None = Field(default=None, alias="from")
    views: dict[str, ViewSpec] = Field(default_factory=dict)

    @model_validator(mode="after")
    def _rules(self):
        """One source: a copy or placements, and a copy names what it copies."""
        if (self.source is None) == (not self.views):
            raise ValueError(
                'say where the run starts from, in one way: from = "active", a '
                "calibration id or a calibration .toml; or [calibration.views.<name>] "
                "placements"
            )
        if isinstance(self.source, int) and self.source < 1:
            raise ValueError(f"from: a calibration id counts from 1, not {self.source}")
        if (
            isinstance(self.source, str)
            and self.source != ACTIVE
            and not self.source.endswith(".toml")
        ):
            raise ValueError(
                f'from: {self.source!r} is neither "active", a calibration id nor a '
                "calibration .toml"
            )
        return self

    @property
    def file(self) -> Path | None:
        """The calibration file copied, when `from` names one."""
        if isinstance(self.source, str) and self.source != ACTIVE:
            return Path(self.source)
        return None

file property

file: Path | None

The calibration file copied, when from names one.

Pose2dStage

Bases: Table

[pose2d]: run the detector over the video -> pose2d/*.

Several weights are an ensemble, collapsed by combine inside the forward pass, so every later stage sees one pose2d/points.

Attributes:

Name Type Description
weights tuple[ArtifactRef, ...]

Trained artifacts: paths relative to the config (ADR-0085), a directory meaning its model.pth, or released models by name (ADR-0151).

combine Literal['mean', 'median']

How the members collapse into points and conf.

tta bool

Also run each frame mirrored and average the two decodes (ADR-0045).

candidates int

Distinct peaks kept per (view, frame, keypoint) as candidates/xy and candidates/score, for the pictorial [pose3d] method; slot 0 is the decoded point, so 1 keeps nothing new and 0 is off.

crops dict[str, tuple[int, int, int, int] | tuple[tuple[int, int, int, int], ...]]

Fixed per-view windows [x0, y0, x1, y1], one per animal where the project holds several; omitted, they are discovered with the detector.

tightness float

How much wider than the animal a discovered window is, as the training's crop tightness measures it: the static windows, and the size a per-frame window keeps. No effect on fixed crops.

crop_aspect dict[str, float]

Per view, the aspect (width / height) of the detector's window instead of the input's, { east = 0.5 }; it must equal the [data] view_aspect the detector trained with.

window Literal['static', 'per_frame']

"static", one window per view and animal for the run (a tethered rig), or "per_frame", centered on the animal in every frame (a free-walking rig); either is written as pose2d/crops.

locator ArtifactRef | None

The full-frame locator a per-frame run needs where views share a frame (ADR-0064), named as weights are.

tracking Tracking

[pose2d.tracking], read only with animals > 1 and a per-frame window.

Source code in src/deeperfly/config/run.py
class Pose2dStage(Table):
    """`[pose2d]`: run the detector over the video -> `pose2d/*`.

    Several `weights` are an ensemble, collapsed by `combine` inside the forward pass,
    so every later stage sees one `pose2d/points`.

    Attributes:
        weights: Trained artifacts: paths relative to the config (ADR-0085), a
            directory meaning its `model.pth`, or released models by name
            (ADR-0151).
        combine: How the members collapse into `points` and `conf`.
        tta: Also run each frame mirrored and average the two decodes (ADR-0045).
        candidates: Distinct peaks kept per `(view, frame, keypoint)` as
            `candidates/xy` and `candidates/score`, for the pictorial `[pose3d]`
            method; slot 0 is the decoded point, so 1 keeps nothing new and 0 is off.
        crops: Fixed per-view windows `[x0, y0, x1, y1]`, one per animal where the
            project holds several; omitted, they are discovered with the detector.
        tightness: How much wider than the animal a discovered window is, as the
            training's crop tightness measures it: the static windows, and the size
            a per-frame window keeps. No effect on fixed `crops`.
        crop_aspect: Per view, the aspect (width / height) of the detector's window
            instead of the input's, `{ east = 0.5 }`; it must equal the `[data]
            view_aspect` the detector trained with.
        window: `"static"`, one window per view and animal for the run (a
            tethered rig), or `"per_frame"`, centered on the animal in every frame
            (a free-walking rig); either is written as `pose2d/crops`.
        locator: The full-frame locator a per-frame run needs where views share a
            frame (ADR-0064), named as `weights` are.
        tracking: `[pose2d.tracking]`, read only with `animals > 1` and a per-frame
            window.
    """

    weights: tuple[ArtifactRef, ...]
    combine: Literal["mean", "median"] = "mean"
    tta: bool = False
    candidates: int = 0
    crops: dict[
        str, tuple[int, int, int, int] | tuple[tuple[int, int, int, int], ...]
    ] = Field(default_factory=dict)
    crop_aspect: dict[str, float] = Field(default_factory=dict)
    tightness: float = 1.5
    window: Literal["static", "per_frame"] = "static"
    locator: ArtifactRef | None = None
    tracking: Tracking = Field(default_factory=Tracking)

    @model_validator(mode="after")
    def _rules(self):
        """Refuse a stage with no detector, or a candidate set with nothing new."""
        if not self.weights:
            raise ValueError("weights: name at least one detector")
        flat = [v for v, aspect in self.crop_aspect.items() if not aspect > 0]
        if flat:
            raise ValueError(
                f"crop_aspect: {', '.join(flat)}: an aspect is width / height, so "
                "above zero"
            )
        if self.window == "per_frame" and self.crops:
            raise ValueError(
                'crops: a fixed window has no place in window = "per_frame"; the '
                "locator picks each frame's window"
            )
        if self.locator is not None and self.window != "per_frame":
            raise ValueError(
                "locator: a locator picks each frame's window; it has nothing to "
                'do with window = "static"'
            )
        if not self.tightness > 0:
            raise ValueError(f"tightness: above zero, got {self.tightness}")
        if self.candidates < 0 or self.candidates == 1:
            raise ValueError(
                "candidates: 0 keeps none; otherwise keep at least 2 (the decoded "
                f"point and one alternative), not {self.candidates}"
            )
        return self

Pose3dStage

Bases: Table

[pose3d]: lift the 2D to one 3D track per keypoint -> pose3d/*.

Attributes:

Name Type Description
method Literal['ransac', 'dlt', 'pictorial', 'election']

"ransac" keeps the largest two-view consensus; "dlt" is the plain least-squares fit; "pictorial" picks among 3D hypotheses from the candidate peaks by their support and a bone-length prior (pictorial); "election" re-elects each view's candidate nearest the other views' reprojection (elect), cheaper but the higher-variance of the two. Both write the 2D they settled on (selected/points, selected/conf), which [smoothing] observes instead of the decode.

threshold_px float

Inlier margin of the consensus, in pixels; for "pictorial", how close a candidate must reproject to support a hypothesis.

min_views int

Views a point needs to be triangulated; for "pictorial", the support a hypothesis needs.

weighted bool

Weight each observation by its confidence's precision (conf ** 2, precision_from_conf); for "ransac" that weight is also the consensus vote, and each view's measured noise scales its pull on the fit (view_precision, ADR-0129); for "pictorial", weight each supporting vote by its candidate's score.

bone_prior float

"pictorial" only: the weight of a Huber cost on each bone's deviation from its median length over the frames this invocation lifts, in median bone lengths, against at most one unit of evidence per supporting view; 0 is off.

veto tuple[str, ...]

View selectors that never vote (a weakly calibrated view): they take no part in any triangulation, but "pictorial" and "election" still re-select their 2D from the geometry the other views settled.

Source code in src/deeperfly/config/run.py
class Pose3dStage(Table):
    """`[pose3d]`: lift the 2D to one 3D track per keypoint -> `pose3d/*`.

    Attributes:
        method: `"ransac"` keeps the largest two-view consensus; `"dlt"` is the plain
            least-squares fit; `"pictorial"` picks among 3D hypotheses from the
            candidate peaks by their support and a bone-length prior
            ([pictorial][deeperfly.pose3d.pictorial]); `"election"` re-elects each
            view's candidate nearest the other views' reprojection
            ([elect][deeperfly.pose3d.pictorial.elect]), cheaper but the
            higher-variance of the two. Both write the 2D they settled on
            (`selected/points`, `selected/conf`), which `[smoothing]` observes instead
            of the decode.
        threshold_px: Inlier margin of the consensus, in pixels; for `"pictorial"`,
            how close a candidate must reproject to support a hypothesis.
        min_views: Views a point needs to be triangulated; for `"pictorial"`, the
            support a hypothesis needs.
        weighted: Weight each observation by its confidence's precision (`conf ** 2`,
            [precision_from_conf][deeperfly.pose3d.triangulation.precision_from_conf]);
            for `"ransac"` that weight is also the consensus vote, and each view's
            measured noise scales its pull on the fit
            ([view_precision][deeperfly.pose3d.triangulation.view_precision],
            ADR-0129); for `"pictorial"`, weight each supporting vote by its
            candidate's score.
        bone_prior: `"pictorial"` only: the weight of a Huber cost on each bone's
            deviation from its median length over the frames this invocation lifts,
            in median bone lengths, against at most one unit of evidence per
            supporting view; 0 is off.
        veto: View selectors that never vote (a weakly calibrated view): they take
            no part in any triangulation, but `"pictorial"` and `"election"` still
            re-select their 2D from the geometry the other views settled.
    """

    method: Literal["ransac", "dlt", "pictorial", "election"] = "ransac"
    threshold_px: float = 15.0
    min_views: int = 2
    weighted: bool = False
    bone_prior: float = 0.0
    veto: tuple[str, ...] = ()

    @model_validator(mode="after")
    def _rules(self):
        """`bone_prior` only means something to the pictorial method."""
        if self.bone_prior and self.method != "pictorial":
            raise ValueError(
                "bone_prior: only the 'pictorial' method has a bone prior, not "
                f"{self.method!r}"
            )
        return self

RunCompute

Bases: Compute

[compute]: the run's machine knobs, on top of the shared ones.

Attributes:

Name Type Description
batch int

Inference frames per forward pass, all views of a frame together.

precision Literal['auto', 'float32', 'float16', 'bfloat16']

Inference dtype; "auto" is bfloat16 where the card has bfloat16 tensor cores and float16 below that (Turing emulates bfloat16).

compile bool

Compile the pose net with torch.compile (ADR-0139, ADR-0150): a third less time on a long run after a minute of compiling; the keypoints move by float rounding, as a precision does.

accumulate Literal['float32', 'float16']

What float16 matrix products accumulate in; "float16" needs precision = "float16" and runs them at twice the rate on GeForce cards, moving the keypoints by float rounding (ADR-0150).

Source code in src/deeperfly/config/run.py
class RunCompute(Compute):
    """`[compute]`: the run's machine knobs, on top of the shared ones.

    Attributes:
        batch: Inference frames per forward pass, all views of a frame together.
        precision: Inference dtype; `"auto"` is bfloat16 where the card has bfloat16
            tensor cores and float16 below that (Turing emulates bfloat16).
        compile: Compile the pose net with `torch.compile` (ADR-0139, ADR-0150): a
            third less time on a long run after a minute of compiling; the keypoints
            move by float rounding, as a precision does.
        accumulate: What float16 matrix products accumulate in; `"float16"` needs
            `precision = "float16"` and runs them at twice the rate on GeForce
            cards, moving the keypoints by float rounding (ADR-0150).
    """

    batch: int = 8
    precision: Literal["auto", "float32", "float16", "bfloat16"] = "auto"
    compile: bool = False
    accumulate: Literal["float32", "float16"] = "float32"

    @model_validator(mode="after")
    def _accumulate(self):
        """Refuse float16 accumulation for products that are not float16."""
        if self.accumulate == "float16" and self.precision != "float16":
            raise ValueError(
                'accumulate: "float16" accumulates float16 matrix products, so it '
                f'needs precision = "float16", not {self.precision!r}'
            )
        return self

RunConfig

Bases: Table

A whole run config: the frames, the starting calibration, one table per stage.

Attributes:

Name Type Description
format_version int

The schema version the document is written for.

name str | None

The run's name, its results file results/<name>.h5; omitted, the time of the invocation (run_name). --name overrides it.

frames tuple[int | str, ...]

The frame set the run covers: indices and "start:stop:step" slices, unioned (frame_set); empty is every frame. --frames overrides it, and it is outside every fingerprint.

compute RunCompute

[compute].

calibration InitialCalibration | None

[calibration], the camera parameters the run starts from; every geometry stage, and a project of several animals, needs it.

pose2d Pose2dStage | None

[pose2d], or None when the stage is off.

bundle_adjustment BundleAdjustmentStage | None

[bundle_adjustment], or None.

pose3d Pose3dStage | None

[pose3d], or None.

smoothing SmoothingStage | None

[smoothing], or None.

correction CorrectionStage | None

[correction], or None.

ik IkStage | None

[ik], or None.

ball BallStage | None

[ball], or None.

render RenderConfig | None

[render], or None.

path Path | None

The file this was loaded from, or None when built in memory; not a key.

text str | None

The document as a run records it when the command line set keys (--set), which are written into the file's text; None when the file's text is the record (recorded_text). Not a key.

Source code in src/deeperfly/config/run.py
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
class RunConfig(Table):
    """A whole run config: the frames, the starting calibration, one table per stage.

    Attributes:
        format_version: The schema version the document is written for.
        name: The run's name, its results file `results/<name>.h5`; omitted, the
            time of the invocation ([run_name][deeperfly.pipeline.plan.run_name]).
            `--name` overrides it.
        frames: The frame set the run covers: indices and `"start:stop:step"`
            slices, unioned ([frame_set][deeperfly.pipeline.plan.frame_set]);
            empty is every frame. `--frames` overrides it, and it is outside every
            fingerprint.
        compute: `[compute]`.
        calibration: `[calibration]`, the camera parameters the run starts from;
            every geometry stage, and a project of several animals, needs it.
        pose2d: `[pose2d]`, or `None` when the stage is off.
        bundle_adjustment: `[bundle_adjustment]`, or `None`.
        pose3d: `[pose3d]`, or `None`.
        smoothing: `[smoothing]`, or `None`.
        correction: `[correction]`, or `None`.
        ik: `[ik]`, or `None`.
        ball: `[ball]`, or `None`.
        render: `[render]`, or `None`.
        path: The file this was loaded from, or `None` when built in memory; not a
            key.
        text: The document as a run records it when the command line set keys
            (`--set`), which are written into the file's text; `None` when the
            file's text is the record (`recorded_text`). Not a key.
    """

    format_version: int = RUN_CONFIG_FORMAT_VERSION
    name: str | None = None
    frames: tuple[int | str, ...] = ()
    compute: RunCompute = Field(default_factory=RunCompute)
    calibration: InitialCalibration | None = None
    pose2d: Pose2dStage | None = None
    bundle_adjustment: BundleAdjustmentStage | None = None
    pose3d: Pose3dStage | None = None
    smoothing: SmoothingStage | None = None
    correction: CorrectionStage | None = None
    ik: IkStage | None = None
    ball: BallStage | None = None
    render: RenderConfig | None = None
    path: Path | None = None
    text: str | None = None

    #: `path` is where the document lives and `text` what a run records of it, not
    #: keys it may declare.
    _reserved: ClassVar[tuple[str, ...]] = ("path", "text")

    @model_validator(mode="after")
    def _rules(self):
        """Run every cross-table rule that needs no project."""
        self._check_name()
        self._check_frames()
        self._check_moving()
        self._check_requires()
        self._check_inputs()
        return self

    def _check_name(self) -> None:
        """A name is a path below the results directory, not a way out of it."""
        if self.name is not None:
            check_run_name(self.name)

    def _check_frames(self) -> None:
        """Every entry of `frames` is an index or a slice, none of it negative."""
        try:
            parse_frames(self.frames)
        except ValueError as exc:
            raise ConfigError(f"{at('frames')}: {exc}") from exc

    # -- what is on ----------------------------------------------------------

    @property
    def enabled(self) -> list[str]:
        """The enabled stages, in pipeline order."""
        return [s for s in STAGES if getattr(self, s) is not None]

    def available(self, animals: int) -> dict[str, tuple]:
        """`{"group/array": axes}` for everything a reference may name here.

        The enabled stages' arrays plus the virtual `video` and `labels/*`, for a
        project of `animals` animals; a stage that is off contributes nothing, so a
        stale reference to it fails the check.
        """
        out = dict(VIRTUAL_ARRAYS)
        declared = STAGE_ARRAYS
        for stage in self.enabled:
            for name, axes in declared.get(stage, {}).items():
                out[f"{stage}/{name}"] = axes
        static = self.pose2d is not None and self.pose2d.window == "static"
        # Nothing is tracked at one animal, nor where each of them keeps one window
        # for the whole recording.
        if animals == 1 or static:
            for key in TRACKING_ARRAYS:
                out.pop(key, None)
        if self.pose2d is not None and not self.pose2d.candidates:
            for key in ("pose2d/candidates/xy", "pose2d/candidates/score"):
                out.pop(key, None)
        if self.pose3d is not None and self.pose3d.method not in (
            "pictorial",
            "election",
        ):
            for key in ("pose3d/selected/points", "pose3d/selected/conf"):
                out.pop(key, None)
        return out

    # -- cross-table rules ---------------------------------------------------

    def _check_moving(self) -> None:
        """What a moving window rules out.

        A per-frame window means the animal walks, which rules out the two
        tethered-rig assumptions downstream: a point that never moves and a body held
        in the model frame.
        """
        if self.pose2d is None or self.pose2d.window != "per_frame":
            return
        if self.correction is not None and self.correction.static is not None:
            raise ConfigError(
                f"{table('correction.static')}: holds a point still over every "
                'frame, which is a tethered rig; window = "per_frame" follows '
                "an animal that moves"
            )
        if self.ik is not None and self.ik.fixed_body:
            raise ConfigError(
                f"{at('ik.fixed_body')}: an animal whose window follows it needs a "
                'free root; set it false with window = "per_frame"'
            )

    def _check_requires(self) -> None:
        """A stage cannot run without what it consumes."""
        if self.pose3d is not None and self.pose2d is None:
            raise ConfigError(
                f"{table('pose3d')}: needs [pose2d] -- nothing to triangulate"
            )
        if (
            self.bundle_adjustment is not None
            and self.pose2d is None
            and self.bundle_adjustment.source != "labels/points"
        ):
            raise ConfigError(
                f"{at('bundle_adjustment.source')}: "
                f"{self.bundle_adjustment.source!r} needs "
                '[pose2d]; fit the hand labels with source = "labels/points"'
            )
        for name in ("smoothing", "correction", "ik", "ball"):
            if getattr(self, name) is not None and self.pose3d is None:
                raise ConfigError(f"{table(name)}: needs [pose3d]")
        if (
            self.pose3d is not None
            and self.pose3d.method in ("pictorial", "election")
            and not (self.pose2d and self.pose2d.candidates)
        ):
            raise ConfigError(
                f"{at('pose3d.method')}: {self.pose3d.method!r} picks among stored "
                "peaks; set [pose2d] candidates to how many to keep"
            )

    def _check_inputs(self) -> None:
        """Every `input` must name a 3D array an enabled stage writes."""
        # A 3D pose is one whatever the animal count; only the tracker's arrays
        # depend on it.
        arrays = self.available(animals=1)
        for name in ("correction", "ik", "ball"):
            stage = getattr(self, name)
            if stage is None or stage.input is None:
                continue
            if stage.input.startswith(f"{name}/"):
                raise ConfigError(
                    f"{at(f'{name}.input')}: a stage cannot read its own output"
                )
            axes = arrays.get(stage.input)
            if axes is None or without_animal_axis(axes) != TK3:
                choices = [
                    k for k, v in arrays.items() if without_animal_axis(v) == TK3
                ]
                raise ConfigError(
                    f"{at(f'{name}.input')}: {stage.input!r} is not a 3D pose "
                    "this config writes; " + (", ".join(choices) or "enable a 3D stage")
                )

    def input_of(self, stage: str) -> str:
        """What `stage` reads: its declared `input`, else the latest upstream 3D."""
        declared = getattr(getattr(self, stage), "input", None)
        if declared:
            return declared
        upstream = [s for s in THREE_D if s in self.enabled and s != stage]
        if not upstream:
            raise ConfigError(f"{table(stage)}: no upstream 3D pose to read")
        return f"{upstream[-1]}/points"

    # -- the project ---------------------------------------------------------

    def check_project(
        self,
        views: Sequence[str],
        animals: int,
        points: Sequence[str],
        videos: Sequence[str] = (),
    ) -> None:
        """Check the config against a project: its views, videos, animals, keypoints.

        What a run config cannot know on its own, since the videos, the views, the
        skeleton and the animal count are the project's (ADR-0081). The run checks
        each project before any stage runs, and so does `--dry-run`. `videos` are the
        names a render grid may show whole.

        Raises:
            ConfigError: If a table names a view or a point the project lacks, the
                calibration places other views, the animals need what the config
                lacks, or a render reference names nothing this config writes.
        """
        self._check_animals(animals)
        self._check_views(list(views), list(videos))
        self.check_points(points)
        if self.render is not None:
            self.render.check(self.available(animals))

    def _check_animals(self, animals: int) -> None:
        """What more than one animal needs, and what one animal leaves unread.

        Animals are located by the detector's center channel or a multi-animal
        locator, so above one the stage must be on, and a fixed window is given per
        animal.
        """
        if animals > 1 and self.ball is not None:
            raise ConfigError(
                f"{table('ball')}: tracks the ball under one tethered animal, and the "
                f"project holds {animals}"
            )
        if animals == 1:
            if self.pose2d is not None and self.pose2d.tracking != Tracking():
                raise ConfigError(
                    f"{table('pose2d.tracking')}: nothing reads it at one animal, "
                    "and the project holds one (animals in its deeperfly.toml)"
                )
            return
        if self.pose2d is None:
            raise ConfigError(
                f"the project holds {animals} animals, which need [pose2d] -- "
                "locating them is the detector's coarse pass"
            )
        wrong = {
            view: box
            for view, box in self.pose2d.crops.items()
            if len(box) != animals or not isinstance(box[0], tuple)
        }
        if wrong:
            raise ConfigError(
                f"{at('pose2d.crops')}: {', '.join(sorted(wrong))} name(s) one window "
                f"where the project holds {animals} animals; give each view "
                "[[x0, y0, x1, y1], ...], one per animal, or omit crops and let the "
                "discovery pass measure them"
            )

    def _check_views(self, views: list[str], videos: list[str]) -> None:
        """Every view a table names must be one of the project's."""
        if self.calibration is not None and self.calibration.views:
            placed = list(self.calibration.views)
            missing = [v for v in views if v not in placed]
            extra = [v for v in placed if v not in views]
            if missing or extra:
                said = []
                if missing:
                    said.append("lacks " + ", ".join(missing))
                if extra:
                    said.append("adds " + ", ".join(extra))
                raise ConfigError(
                    f"{table('calibration.views')}: " + " and ".join(said) + "; the "
                    "project's views are " + ", ".join(views)
                )
        if self.pose2d is not None:
            for key in ("crops", "crop_aspect"):
                unknown = [v for v in getattr(self.pose2d, key) if v not in views]
                if unknown:
                    raise ConfigError(
                        f"{at(f'pose2d.{key}')}: {', '.join(unknown)} is not one of "
                        "the project's views, " + ", ".join(views)
                    )
        if self.pose3d is not None and self.pose3d.veto:
            match_names(self.pose3d.veto, views, at("pose3d.veto"))
        if self.ball is not None:
            if self.ball.view not in views:
                raise ConfigError(
                    f"{at('ball.view')}: {self.ball.view!r} is not one of the "
                    "project's views, " + ", ".join(views)
                )
            match_names(self.ball.outline, views, at("ball.outline"))
        if self.render is not None:
            self._check_grids(views, videos)

    def _check_grids(self, views: list[str], videos: list[str]) -> None:
        """A grid name: a view, a video, a synthetic view, or a layer's target panel."""
        assert self.render is not None
        known = {*views, *videos, *self.render.views}
        for name, output in self.render.outputs.items():
            targeted = {
                p
                for layer in output.layers
                for p in match_names(
                    layer.panel, output.names, at(f"render.outputs.{name}.layers.panel")
                )
            }
            for panel in output.names:
                if panel in known or panel in targeted:
                    continue
                raise ConfigError(
                    f"{at(f'render.outputs.{name}.grid')}: {panel!r} is not one of "
                    "the project's views or videos, not a [render.views.*] entry, and "
                    "no layer targets it"
                )

    def check_points(self, points: Sequence[str]) -> None:
        """Every point selector must match the skeleton's names.

        Part of [check_project][deeperfly.config.run.RunConfig.check_project].
        """
        points = list(points)
        for stage, attr in (
            ("bundle_adjustment", "points"),
            ("correction.static", "points"),
            ("correction.symmetrize", "points"),
            ("correction.symmetrize", "midline"),
            ("ball", "contact"),
            ("ball", "stand"),
            ("ball", "front"),
            ("ball", "hind"),
        ):
            owner: Any = self
            for part in stage.split("."):
                owner = getattr(owner, part, None)
                if owner is None:
                    break
            if owner is not None:
                match_names(getattr(owner, attr), points, at(f"{stage}.{attr}"))
        if self.render is not None:
            for name, video in self.render.outputs.items():
                for i, layer in enumerate(video.layers):
                    if layer.draw == "skeleton":
                        match_names(
                            layer.select,
                            points,
                            at(f"render.outputs.{name}.layers[{i}].select"),
                        )

    # -- loading -------------------------------------------------------------

    @classmethod
    def from_document(
        cls, data: Mapping, doc_dir: Path, path: Path | None = None
    ) -> RunConfig:
        """Build from a parsed document, resolving its paths against `doc_dir`."""
        version = data.get("format_version", 1)
        check_format_version(
            data,
            "run config",
            RUN_CONFIG_FORMAT_VERSION,
            hint=(
                "Format 4 takes the videos, the layout, the views, the skeleton and "
                "the animal count from the project's deeperfly.toml, and the "
                "placements under [views] move to [calibration.views] (ADR-0081, "
                "ADR-0086)"
                if isinstance(version, int) and version < RUN_CONFIG_FORMAT_VERSION
                else ""
            ),
        )
        for key, where in _MOVED_KEYS.items():
            if key in data:
                raise ConfigError(f"{key}: unknown key; {where}")
        folded = dict(data)
        calibration = data.get("calibration")
        if isinstance(calibration, Mapping) and "views" in calibration:
            folded["calibration"] = {
                **calibration,
                "views": _fold_views(calibration["views"]),
            }
        cfg = parse(cls, folded, "")
        return cfg._resolved(doc_dir, path)

    @classmethod
    def load(
        cls, path: str | Path, changes: Mapping[str, Any] | None = None
    ) -> RunConfig:
        """Load, lint and type a run config file.

        `changes` are values by dotted key ([overrides][deeperfly.document.overrides])
        set over the file's before it is typed, as if the file said them; the run
        records them, written into the file's text.

        Raises:
            ConfigError: If the file, with the changes, breaks any rule of the schema.
        """
        file = Path(path).expanduser()
        data, doc_dir = load_document(file)
        if not changes:
            return cls.from_document(data, doc_dir, file)
        cfg = cls.from_document(with_keys(data, changes), doc_dir, file)
        return cfg.model_copy(update={"text": write_keys(file.read_text(), changes)})

    def recorded_text(self) -> str:
        """The document as a run records it: the file's text, with any `--set` keys.

        Empty for a config built in memory.
        """
        if self.text is not None:
            return self.text
        return self.path.read_text() if self.path is not None else ""

    def _resolved(self, doc_dir: Path, path: Path | None) -> RunConfig:
        """Resolve every path against the document's directory (ADR-0085)."""
        pose2d = self.pose2d
        if pose2d is not None:
            pose2d = pose2d.model_copy(
                update={
                    "weights": tuple(artifact_path(w, doc_dir) for w in pose2d.weights),
                    "locator": (
                        None
                        if pose2d.locator is None
                        else artifact_path(pose2d.locator, doc_dir)
                    ),
                }
            )
        calibration = self.calibration
        if calibration is not None and calibration.file is not None:
            calibration = calibration.model_copy(
                update={"source": str(under_document(calibration.file, doc_dir))}
            )
        ik = self.ik
        if ik is not None:
            # A packaged name stays a name; anything else is a path.
            from ..ik.binding import bindings
            from ..ik.pack import MODELS

            binding = ik.binding
            if binding is not None and binding not in bindings():
                binding = str(under_document(binding, doc_dir))
            model = ik.model
            if model not in MODELS:
                model = str(under_document(model, doc_dir))
            ik = ik.model_copy(update={"model": model, "binding": binding})
        return self.model_copy(
            update={
                "pose2d": pose2d,
                "calibration": calibration,
                "ik": ik,
                "path": path,
            }
        )

enabled property

enabled: list[str]

The enabled stages, in pipeline order.

available

available(animals: int) -> dict[str, tuple]

{"group/array": axes} for everything a reference may name here.

The enabled stages' arrays plus the virtual video and labels/*, for a project of animals animals; a stage that is off contributes nothing, so a stale reference to it fails the check.

Source code in src/deeperfly/config/run.py
def available(self, animals: int) -> dict[str, tuple]:
    """`{"group/array": axes}` for everything a reference may name here.

    The enabled stages' arrays plus the virtual `video` and `labels/*`, for a
    project of `animals` animals; a stage that is off contributes nothing, so a
    stale reference to it fails the check.
    """
    out = dict(VIRTUAL_ARRAYS)
    declared = STAGE_ARRAYS
    for stage in self.enabled:
        for name, axes in declared.get(stage, {}).items():
            out[f"{stage}/{name}"] = axes
    static = self.pose2d is not None and self.pose2d.window == "static"
    # Nothing is tracked at one animal, nor where each of them keeps one window
    # for the whole recording.
    if animals == 1 or static:
        for key in TRACKING_ARRAYS:
            out.pop(key, None)
    if self.pose2d is not None and not self.pose2d.candidates:
        for key in ("pose2d/candidates/xy", "pose2d/candidates/score"):
            out.pop(key, None)
    if self.pose3d is not None and self.pose3d.method not in (
        "pictorial",
        "election",
    ):
        for key in ("pose3d/selected/points", "pose3d/selected/conf"):
            out.pop(key, None)
    return out

check_points

check_points(points: Sequence[str]) -> None

Every point selector must match the skeleton's names.

Part of check_project.

Source code in src/deeperfly/config/run.py
def check_points(self, points: Sequence[str]) -> None:
    """Every point selector must match the skeleton's names.

    Part of [check_project][deeperfly.config.run.RunConfig.check_project].
    """
    points = list(points)
    for stage, attr in (
        ("bundle_adjustment", "points"),
        ("correction.static", "points"),
        ("correction.symmetrize", "points"),
        ("correction.symmetrize", "midline"),
        ("ball", "contact"),
        ("ball", "stand"),
        ("ball", "front"),
        ("ball", "hind"),
    ):
        owner: Any = self
        for part in stage.split("."):
            owner = getattr(owner, part, None)
            if owner is None:
                break
        if owner is not None:
            match_names(getattr(owner, attr), points, at(f"{stage}.{attr}"))
    if self.render is not None:
        for name, video in self.render.outputs.items():
            for i, layer in enumerate(video.layers):
                if layer.draw == "skeleton":
                    match_names(
                        layer.select,
                        points,
                        at(f"render.outputs.{name}.layers[{i}].select"),
                    )

check_project

check_project(
    views: Sequence[str],
    animals: int,
    points: Sequence[str],
    videos: Sequence[str] = (),
) -> None

Check the config against a project: its views, videos, animals, keypoints.

What a run config cannot know on its own, since the videos, the views, the skeleton and the animal count are the project's (ADR-0081). The run checks each project before any stage runs, and so does --dry-run. videos are the names a render grid may show whole.

Raises:

Type Description
ConfigError

If a table names a view or a point the project lacks, the calibration places other views, the animals need what the config lacks, or a render reference names nothing this config writes.

Source code in src/deeperfly/config/run.py
def check_project(
    self,
    views: Sequence[str],
    animals: int,
    points: Sequence[str],
    videos: Sequence[str] = (),
) -> None:
    """Check the config against a project: its views, videos, animals, keypoints.

    What a run config cannot know on its own, since the videos, the views, the
    skeleton and the animal count are the project's (ADR-0081). The run checks
    each project before any stage runs, and so does `--dry-run`. `videos` are the
    names a render grid may show whole.

    Raises:
        ConfigError: If a table names a view or a point the project lacks, the
            calibration places other views, the animals need what the config
            lacks, or a render reference names nothing this config writes.
    """
    self._check_animals(animals)
    self._check_views(list(views), list(videos))
    self.check_points(points)
    if self.render is not None:
        self.render.check(self.available(animals))

from_document classmethod

from_document(
    data: Mapping, doc_dir: Path, path: Path | None = None
) -> RunConfig

Build from a parsed document, resolving its paths against doc_dir.

Source code in src/deeperfly/config/run.py
@classmethod
def from_document(
    cls, data: Mapping, doc_dir: Path, path: Path | None = None
) -> RunConfig:
    """Build from a parsed document, resolving its paths against `doc_dir`."""
    version = data.get("format_version", 1)
    check_format_version(
        data,
        "run config",
        RUN_CONFIG_FORMAT_VERSION,
        hint=(
            "Format 4 takes the videos, the layout, the views, the skeleton and "
            "the animal count from the project's deeperfly.toml, and the "
            "placements under [views] move to [calibration.views] (ADR-0081, "
            "ADR-0086)"
            if isinstance(version, int) and version < RUN_CONFIG_FORMAT_VERSION
            else ""
        ),
    )
    for key, where in _MOVED_KEYS.items():
        if key in data:
            raise ConfigError(f"{key}: unknown key; {where}")
    folded = dict(data)
    calibration = data.get("calibration")
    if isinstance(calibration, Mapping) and "views" in calibration:
        folded["calibration"] = {
            **calibration,
            "views": _fold_views(calibration["views"]),
        }
    cfg = parse(cls, folded, "")
    return cfg._resolved(doc_dir, path)

input_of

input_of(stage: str) -> str

What stage reads: its declared input, else the latest upstream 3D.

Source code in src/deeperfly/config/run.py
def input_of(self, stage: str) -> str:
    """What `stage` reads: its declared `input`, else the latest upstream 3D."""
    declared = getattr(getattr(self, stage), "input", None)
    if declared:
        return declared
    upstream = [s for s in THREE_D if s in self.enabled and s != stage]
    if not upstream:
        raise ConfigError(f"{table(stage)}: no upstream 3D pose to read")
    return f"{upstream[-1]}/points"

load classmethod

load(
    path: str | Path,
    changes: Mapping[str, Any] | None = None,
) -> RunConfig

Load, lint and type a run config file.

changes are values by dotted key (overrides) set over the file's before it is typed, as if the file said them; the run records them, written into the file's text.

Raises:

Type Description
ConfigError

If the file, with the changes, breaks any rule of the schema.

Source code in src/deeperfly/config/run.py
@classmethod
def load(
    cls, path: str | Path, changes: Mapping[str, Any] | None = None
) -> RunConfig:
    """Load, lint and type a run config file.

    `changes` are values by dotted key ([overrides][deeperfly.document.overrides])
    set over the file's before it is typed, as if the file said them; the run
    records them, written into the file's text.

    Raises:
        ConfigError: If the file, with the changes, breaks any rule of the schema.
    """
    file = Path(path).expanduser()
    data, doc_dir = load_document(file)
    if not changes:
        return cls.from_document(data, doc_dir, file)
    cfg = cls.from_document(with_keys(data, changes), doc_dir, file)
    return cfg.model_copy(update={"text": write_keys(file.read_text(), changes)})

recorded_text

recorded_text() -> str

The document as a run records it: the file's text, with any --set keys.

Empty for a config built in memory.

Source code in src/deeperfly/config/run.py
def recorded_text(self) -> str:
    """The document as a run records it: the file's text, with any `--set` keys.

    Empty for a config built in memory.
    """
    if self.text is not None:
        return self.text
    return self.path.read_text() if self.path is not None else ""

SmoothingStage

Bases: Table

[smoothing]: the geometric EKS over the 2D observations -> smoothing/*.

The keys are smooth's (ADR-0062).

Attributes:

Name Type Description
smooth_param float | None

The process-noise scale, smaller smoothing harder; omitted, fitted per keypoint.

fit_frames int

Frames sampled for that fit.

fit_iterations int

Iterations of the fit.

inflate bool

Inflate the variance of a view that disagrees with the others.

inflate_threshold float

The Mahalanobis distance above which it is inflated.

inflate_factor float

How much it is inflated by, per round.

var Literal['var', 'confidence_weighted_var']

The observation variance: the members' spread, or that spread weighted by their confidences.

fill_unobserved bool

Report the smoothed 2D in the cells nothing observed, instead of NaN.

Source code in src/deeperfly/config/run.py
class SmoothingStage(Table):
    """`[smoothing]`: the geometric EKS over the 2D observations -> `smoothing/*`.

    The keys are [smooth][deeperfly.pose3d.smoothing.smooth]'s (ADR-0062).

    Attributes:
        smooth_param: The process-noise scale, smaller smoothing harder; omitted,
            fitted per keypoint.
        fit_frames: Frames sampled for that fit.
        fit_iterations: Iterations of the fit.
        inflate: Inflate the variance of a view that disagrees with the others.
        inflate_threshold: The Mahalanobis distance above which it is inflated.
        inflate_factor: How much it is inflated by, per round.
        var: The observation variance: the members' spread, or that spread
            weighted by their confidences.
        fill_unobserved: Report the smoothed 2D in the cells nothing observed,
            instead of NaN.
    """

    smooth_param: float | None = None
    fit_frames: int = 2000
    fit_iterations: int = 24
    inflate: bool = True
    inflate_threshold: float = 5.0
    inflate_factor: float = 10.0
    var: Literal["var", "confidence_weighted_var"] = "confidence_weighted_var"
    fill_unobserved: bool = False

StaticCorrection

Bases: Table

[correction.static]: replace a point that should not move over time.

Attributes:

Name Type Description
points tuple[str, ...]

Point selector to hold still (thorax-coxa joints, the neck).

method Literal['mean', 'median', 'trimmed_mean', 'mode', 'geometric_median']

Which single position stands for every frame of a stretch.

trim float

Fraction trimmed from each tail; "trimmed_mean" only.

Source code in src/deeperfly/config/run.py
class StaticCorrection(Table):
    """`[correction.static]`: replace a point that should not move over time.

    Attributes:
        points: Point selector to hold still (thorax-coxa joints, the neck).
        method: Which single position stands for every frame of a stretch.
        trim: Fraction trimmed from each tail; `"trimmed_mean"` only.
    """

    points: tuple[str, ...] = ()
    method: Literal["mean", "median", "trimmed_mean", "mode", "geometric_median"] = (
        "median"
    )
    trim: float = 0.1

    @model_validator(mode="after")
    def _rules(self):
        """`trim` only means something to the trimmed mean."""
        if self.method != "trimmed_mean" and self.trim != 0.1:
            raise ValueError(f"trim: only 'trimmed_mean' trims, not {self.method!r}")
        return self

SymmetrizeCorrection

Bases: Table

[correction.symmetrize]: mirror paired points about the animal's midline.

Attributes:

Name Type Description
points tuple[str, ...]

Selector naming either half of each pair; the skeleton's point_symmetries names the partner.

midline tuple[str, ...]

Points that define the sagittal plane.

per_frame bool

Fit the plane per frame instead of once per stretch.

strength float

How far toward the symmetric solution to move; 0 is off.

Source code in src/deeperfly/config/run.py
class SymmetrizeCorrection(Table):
    """`[correction.symmetrize]`: mirror paired points about the animal's midline.

    Attributes:
        points: Selector naming either half of each pair; the skeleton's
            `point_symmetries` names the partner.
        midline: Points that define the sagittal plane.
        per_frame: Fit the plane per frame instead of once per stretch.
        strength: How far toward the symmetric solution to move; 0 is off.
    """

    points: tuple[str, ...] = ()
    midline: tuple[str, ...] = ()
    per_frame: bool = False
    strength: float = 1.0

Tracking

Bases: Table

[pose2d.tracking]: how center peaks become animals and stay themselves.

Read only when animals > 1. Every gate is in the view's own video pixels, and the defaults are hexaview's (ADR-0063).

Attributes:

Name Type Description
min_score float

What a center peak must score to count as a detection.

min_views int

Views that must see a center before it is an animal; 3 on hexaview.

gate_px float

A view supports a 3D center when its nearest peak reprojects within this.

min_separation_px float

Two centers closer than this in every view are one animal found twice; touching flies on hexaview are 85-95 px apart in the views across them, a split single blob under 35 (ADR-0131).

max_step_px float

The frame-to-frame link gate, as the largest reprojected jump over the views.

hold_frames int

Frames a track keeps its last position without a detection before the slot is freed.

Source code in src/deeperfly/config/run.py
class Tracking(Table):
    """`[pose2d.tracking]`: how center peaks become animals and stay themselves.

    Read only when `animals > 1`. Every gate is in the view's own video pixels, and
    the defaults are hexaview's (ADR-0063).

    Attributes:
        min_score: What a center peak must score to count as a detection.
        min_views: Views that must see a center before it is an animal; 3 on
            hexaview.
        gate_px: A view supports a 3D center when its nearest peak reprojects
            within this.
        min_separation_px: Two centers closer than this in every view are one
            animal found twice; touching flies on hexaview are 85-95 px apart in the
            views across them, a split single blob under 35 (ADR-0131).
        max_step_px: The frame-to-frame link gate, as the largest reprojected
            jump over the views.
        hold_frames: Frames a track keeps its last position without a detection
            before the slot is freed.
    """

    min_score: float = 0.001
    min_views: int = 2
    gate_px: float = 30.0
    min_separation_px: float = 50.0
    max_step_px: float = 60.0
    hold_frames: int = 12

    @model_validator(mode="after")
    def _rules(self):
        """A center needs two views to exist, and every gate is a distance."""
        if self.min_score < 0.0:
            raise ValueError(f"min_score: a detector score, not {self.min_score}")
        if self.min_views < 2:
            raise ValueError(
                f"min_views: a 3D center takes two views, not {self.min_views}"
            )
        for name in ("gate_px", "min_separation_px", "max_step_px"):
            if getattr(self, name) <= 0:
                raise ValueError(f"{name}: a distance in pixels, so above zero")
        if self.hold_frames < 0:
            raise ValueError(f"hold_frames: not negative, got {self.hold_frames}")
        return self

check_run_name

check_run_name(name: str) -> None

Refuse a run name that is not a path below the results directory.

A run is results/<name>.h5, so / nests it and anything else goes, but an absolute name or a .. would leave the directory (ADR-0082).

Raises:

Type Description
ConfigError

If the name is empty, absolute or climbs out.

Source code in src/deeperfly/config/run.py
def check_run_name(name: str) -> None:
    """Refuse a run name that is not a path below the results directory.

    A run is `results/<name>.h5`, so `/` nests it and anything else goes, but an
    absolute name or a `..` would leave the directory (ADR-0082).

    Raises:
        ConfigError: If the name is empty, absolute or climbs out.
    """
    parts = PurePosixPath(name).parts
    if not name or name.startswith("/") or ".." in parts:
        raise ConfigError(
            f"name: {name!r} is not a run name; a run is results/<name>.h5, so the "
            "name is a relative path that stays below results/"
        )

parse_frames

parse_frames(
    spec: Sequence[int | str],
) -> tuple[int | slice, ...]

A frame set's entries as indices and slices, checked but not yet bounded.

An entry is an index (5000, or "5000") or a numpy slice string "start:stop:step" with any part omitted ("0::2", ":1000", "::10").

Raises:

Type Description
ValueError

On an entry that is neither, a negative number, or a zero step.

Source code in src/deeperfly/config/run.py
def parse_frames(spec: Sequence[int | str]) -> tuple[int | slice, ...]:
    """A frame set's entries as indices and slices, checked but not yet bounded.

    An entry is an index (`5000`, or `"5000"`) or a numpy slice string
    `"start:stop:step"` with any part omitted (`"0::2"`, `":1000"`, `"::10"`).

    Raises:
        ValueError: On an entry that is neither, a negative number, or a zero step.
    """
    out: list[int | slice] = []
    for entry in spec:
        if isinstance(entry, int) and not isinstance(entry, bool):
            if entry < 0:
                raise ValueError(f"{entry}: frames count from 0, with no negatives")
            out.append(entry)
            continue
        parts = (entry if isinstance(entry, str) else repr(entry)).strip().split(":")
        if len(parts) > 3:
            raise ValueError(f"{entry!r}: a slice is start:stop:step")
        values: list[int | None] = []
        for part in (p.strip() for p in parts):
            if not part:
                values.append(None)
            elif part.isdigit():
                values.append(int(part))
            else:
                raise ValueError(
                    f"{entry!r}: {part!r} is not a frame number; frames count from "
                    "0, with no negatives"
                )
        if len(values) == 1:
            if values[0] is None:
                raise ValueError(f"{entry!r} is not a frame index or a slice")
            out.append(values[0])
            continue
        start, stop, step = (*values, None)[:3]
        if step == 0:
            raise ValueError(f"{entry!r}: the step must be at least 1")
        out.append(slice(start, stop, step))
    return tuple(out)

[render]: video frames and result arrays mapped onto the panels of a video.

A video is a grid of panels, each showing a recorded view, a whole video with every view it carries (ADR-0096), a synthetic one from [render.views], or a blank panel for plots and text. Layers draw in order into every panel that can host them, each pairing one data reference with one draw kind, and the referenced array's shape decides what it can feed (ADR-0031). The checks here need no project, only the inventory of what the enabled stages write; those that need one live in RunConfig.

Layer module-attribute

Layer = Annotated[
    VideoLayer
    | SkeletonLayer
    | MeshLayer
    | BoxLayer
    | BallLayer
    | TraceLayer
    | TextLayer,
    Field(discriminator="draw"),
]

One entry of a video's layers, a table per draw kind (ADR-0059).

A key on the wrong kind, radius on a text layer, is an unknown key.

BallLayer

Bases: _Layer

{ draw = "ball", data = "ball/center" }: the treadmill ball, as a view sees it.

The ball's outline, and the trail the animal has walked over it: the surface point it stood on at each recent frame, carried to where the ball has turned it since, on the side the view faces, dark blue to cyan from old to new, as spintrack's debug video draws it.

Attributes:

Name Type Description
draw Literal['ball']

The kind.

data str

"ball/center": the ball is drawn from the whole of what [ball] writes, and this is the array that places it.

color str | None

The outline's #rrggbb, black or white; spintrack's yellow when none is named.

width float | None

Line width in panel pixels, of the outline and the trail.

alpha float | None

Opacity.

window_frames int

How many frames of the trail to draw, back to the start of the frame's stretch at most; 0 draws none.

Source code in src/deeperfly/config/render.py
class BallLayer(_Layer):
    """`{ draw = "ball", data = "ball/center" }`: the treadmill ball, as a view sees it.

    The ball's outline, and the trail the animal has walked over it: the surface
    point it stood on at each recent frame, carried to where the ball has turned it
    since, on the side the view faces, dark blue to cyan from old to new, as
    spintrack's debug video draws it.

    Attributes:
        draw: The kind.
        data: `"ball/center"`: the ball is drawn from the whole of what `[ball]`
            writes, and this is the array that places it.
        color: The outline's `#rrggbb`, `black` or `white`; spintrack's yellow when
            none is named.
        width: Line width in panel pixels, of the outline and the trail.
        alpha: Opacity.
        window_frames: How many frames of the trail to draw, back to the start of the
            frame's stretch at most; 0 draws none.
    """

    draw: Literal["ball"]
    data: str
    color: str | None = None
    width: float | None = None
    alpha: float | None = None
    window_frames: int = Field(1024, ge=0)

BoxLayer

Bases: _Layer

{ draw = "box", data = "pose2d/crops" }: each animal's rectangle, per view.

Attributes:

Name Type Description
draw Literal['box']

The kind.

data str

A (V,T,4) array of [x0, y0, x1, y1] windows in each view's pixels, past N: the windows the detector saw are "pose2d/crops".

color str | None

#rrggbb, black or white; white when none is named.

width float | None

Line width in panel pixels.

dash tuple[float, float] | None

[on, off] dash pattern in panel pixels.

alpha float | None

Opacity.

Source code in src/deeperfly/config/render.py
class BoxLayer(_Layer):
    """`{ draw = "box", data = "pose2d/crops" }`: each animal's rectangle, per view.

    Attributes:
        draw: The kind.
        data: A `(V,T,4)` array of `[x0, y0, x1, y1]` windows in each view's pixels,
            past `N`: the windows the detector saw are `"pose2d/crops"`.
        color: `#rrggbb`, `black` or `white`; white when none is named.
        width: Line width in panel pixels.
        dash: `[on, off]` dash pattern in panel pixels.
        alpha: Opacity.
    """

    draw: Literal["box"]
    data: str
    color: str | None = None
    width: float | None = None
    dash: tuple[float, float] | None = None
    alpha: float | None = None

MeshLayer

Bases: _Layer

{ draw = "mesh", data = "ik/points" }: the fitted body model, skinned.

Attributes:

Name Type Description
draw Literal['mesh']

The kind.

data str

"ik/points": the mesh is posed from the whole of what [ik] writes, and this is the array it draws.

alpha float | None

Opacity. A mesh has no keypoints to fade by, so a number only.

hide tuple[str, ...]

Body-model segments left out.

Source code in src/deeperfly/config/render.py
class MeshLayer(_Layer):
    """`{ draw = "mesh", data = "ik/points" }`: the fitted body model, skinned.

    Attributes:
        draw: The kind.
        data: `"ik/points"`: the mesh is posed from the whole of what `[ik]`
            writes, and this is the array it draws.
        alpha: Opacity. A mesh has no keypoints to fade by, so a number only.
        hide: Body-model segments left out.
    """

    draw: Literal["mesh"]
    data: str
    alpha: float | None = None
    hide: tuple[str, ...] = ()

OutputSpec

Bases: Table

[render.outputs.<name>]: one output video, <name>.mp4.

Attributes:

Name Type Description
grid tuple[tuple[str, ...], ...]

Panel names, row by row: views, videos, synthetic views and blank panels. "" is an empty cell; every other name must fill exactly one axis-aligned rectangle, which it then spans.

layers tuple[Layer, ...]

What to draw, in order, back to front.

crop str | None

A (N,V,T,4) reference giving each view's panel its window ("pose2d/crops"), the region every animal's crop spans over the whole run; omitted shows the whole frame, as a video's panel always does.

frames FrameRange

[start, stop] or [start, stop, step], half-open, overriding [render] frames.

speed float | None

Playback rate, overriding [render] speed.

fps float | None

Encoded frame rate, overriding [render] fps.

cell tuple[int, int] | None

Cell size [height, width], overriding [render] cell.

max_size tuple[Side, Side] | None

The largest [height, width] written, overriding [render] max_size.

background str | None

Background color, overriding [render] background.

Source code in src/deeperfly/config/render.py
class OutputSpec(Table):
    """`[render.outputs.<name>]`: one output video, `<name>.mp4`.

    Attributes:
        grid: Panel names, row by row: views, videos, synthetic views and blank
            panels. `""` is an empty cell; every other name must fill exactly one
            axis-aligned rectangle, which it then spans.
        layers: What to draw, in order, back to front.
        crop: A `(N,V,T,4)` reference giving each view's panel its window
            (`"pose2d/crops"`), the region every animal's crop spans over the whole
            run; omitted shows the whole frame, as a video's panel always does.
        frames: `[start, stop]` or `[start, stop, step]`, half-open, overriding
            `[render] frames`.
        speed: Playback rate, overriding `[render] speed`.
        fps: Encoded frame rate, overriding `[render] fps`.
        cell: Cell size `[height, width]`, overriding `[render] cell`.
        max_size: The largest `[height, width]` written, overriding `[render]
            max_size`.
        background: Background color, overriding `[render] background`.
    """

    grid: tuple[tuple[str, ...], ...]
    layers: tuple[Layer, ...]
    crop: str | None = None
    frames: FrameRange = ()
    speed: float | None = None
    fps: float | None = None
    cell: tuple[int, int] | None = None
    max_size: tuple[Side, Side] | None = None
    background: str | None = None

    @model_validator(mode="after")
    def _rules(self):
        """Check the grid is rectangular, and that each name spans a rectangle."""
        if not self.grid or not self.grid[0]:
            raise ValueError("grid needs at least one cell")
        widths = {len(row) for row in self.grid}
        if len(widths) != 1:
            raise ValueError(
                f"grid rows must all be the same length, got {sorted(widths)}"
            )
        _check_spans(self.grid)
        return self

    @property
    def names(self) -> list[str]:
        """The distinct non-empty panel names, in reading order."""
        seen: list[str] = []
        for row in self.grid:
            for name in row:
                if name and name not in seen:
                    seen.append(name)
        return seen

names property

names: list[str]

The distinct non-empty panel names, in reading order.

RenderConfig

Bases: Table

[render]: the videos a run writes, and the defaults they share.

Attributes:

Name Type Description
outputs dict[str, OutputSpec]

[render.outputs.<name>], at least one.

views dict[str, RenderView]

[render.views.<name>], the synthetic views a grid may name.

frames FrameRange

[start, stop] or [start, stop, step], half-open; empty means every frame.

speed float

Playback rate: the animation runs at the video's fps times speed over the frame step, so half speed means the same on any video.

fps float

The rate written into the file, each drawn frame held a whole number of output frames to reach it, so none is dropped; 0 writes the animation's own rate.

cell tuple[int, int]

One cell's (height, width) in pixels.

max_size tuple[Side, Side]

The largest (height, width) a video is written at. A video is drawn at cell a panel, which is what a mark's size counts in, and one that comes out larger is scaled down to fit, keeping its shape; a 0 leaves that side unbounded.

background str

What a panel is filled with where content does not reach.

codec str

The encoder to hand the frames to.

crf int | None

The encoder's constant rate factor: lower keeps more, in a larger file. 18 keeps a thin colored mark sharp over footage, which libx264's own 23 smears; None passes none, for an encoder without one.

Source code in src/deeperfly/config/render.py
class RenderConfig(Table):
    """`[render]`: the videos a run writes, and the defaults they share.

    Attributes:
        outputs: `[render.outputs.<name>]`, at least one.
        views: `[render.views.<name>]`, the synthetic views a grid may name.
        frames: `[start, stop]` or `[start, stop, step]`, half-open; empty means every
            frame.
        speed: Playback rate: the animation runs at the video's fps times `speed` over
            the frame step, so half speed means the same on any video.
        fps: The rate written into the file, each drawn frame held a whole number of
            output frames to reach it, so none is dropped; `0` writes the animation's
            own rate.
        cell: One cell's `(height, width)` in pixels.
        max_size: The largest `(height, width)` a video is written at. A video is
            drawn at `cell` a panel, which is what a mark's size counts in, and one
            that comes out larger is scaled down to fit, keeping its shape; a `0`
            leaves that side unbounded.
        background: What a panel is filled with where content does not reach.
        codec: The encoder to hand the frames to.
        crf: The encoder's constant rate factor: lower keeps more, in a larger
            file. 18 keeps a thin colored mark sharp over footage, which libx264's
            own 23 smears; `None` passes none, for an encoder without one.
    """

    outputs: dict[str, OutputSpec] = Field(default_factory=dict)
    views: dict[str, RenderView] = Field(default_factory=dict)
    frames: FrameRange = ()
    speed: float = 1.0
    fps: float = 50.0
    cell: tuple[int, int] = (240, 480)
    max_size: tuple[Side, Side] = (1080, 1920)
    background: str = "black"
    codec: str = "libx264"
    crf: int | None = Field(default=18, ge=0)

    @model_validator(mode="after")
    def _rules(self):
        """Refuse a render table that writes nothing."""
        if not self.outputs:
            raise ValueError(
                "no [render.outputs.<name>] table, so this would write no video; "
                "remove [render] to skip rendering"
            )
        return self

    def check(self, available: dict[str, tuple]) -> None:
        """Check every reference and panel against what the run actually writes.

        `available` is `{"group/array": axes}` for every array the enabled stages
        write, plus `video` and `labels/*`
        ([available][deeperfly.config.run.RunConfig.available]). A `(V,T,K)` alpha may
        not reach a synthetic view, which has no view axis.
        """
        for name, video in self.outputs.items():
            where = f"render.outputs.{name}"
            names = video.names
            if video.crop is not None:
                self._shape(video.crop, (VT4,), f"{where}.crop", available)
            for i, layer in enumerate(video.layers):
                self._check_layer(layer, f"{where}.layers[{i}]", names, available)

    def _check_layer(
        self, layer: Layer, where: str, names: list[str], available: dict[str, tuple]
    ) -> None:
        """One layer's data reference, alpha reference and panel selector."""
        panels = match_names(layer.panel, names, f"{at(f'{where}.panel')}")
        data = getattr(layer, "data", None)
        if data is not None:
            self._shape(data, DRAW_SHAPES[layer.draw], f"{where}.data", available)
        if layer.draw == "mesh":
            self._check_mesh(layer, where, available)
        if layer.draw == "ball":
            self._check_ball(layer, where, available)
        alpha = getattr(layer, "alpha", None)
        if isinstance(alpha, str):
            axes = self._shape(alpha, (TK, VTK), f"{where}.alpha", available)
            synthetic = [p for p in panels if p in self.views]
            if without_animal_axis(axes) == VTK and synthetic:
                raise ConfigError(
                    f"{at(f'{where}.alpha')}: {alpha!r} is per view, and "
                    f"the synthetic view {synthetic[0]!r} this layer reaches has "
                    "no view to take; narrow panel, or fade by a (T,K) array"
                )

    @staticmethod
    def _check_mesh(layer: MeshLayer, where: str, available: dict[str, tuple]) -> None:
        """A `mesh` layer's data reference: an IK fit, whole.

        The mesh is posed from the fitted joint positions, with the angles carrying the
        head and abdomen, so the layer names `ik/points` and reads the rest alongside.
        """
        need = ("ik/points", "ik/angles", "ik/chain_scale", "ik/chain_offset")
        if layer.data != need[0] or any(ref not in available for ref in need):
            raise ConfigError(
                f"{at(f'{where}.data')}: a mesh is the fitted model, so it is drawn "
                f"from the whole of what [ik] writes; use {need[0]!r} with [ik] on"
            )

    @staticmethod
    def _check_ball(layer: BallLayer, where: str, available: dict[str, tuple]) -> None:
        """A `ball` layer's data reference: the tracked ball, whole.

        The outline needs the center and radius, the trail the rotation and the
        animal's axes, so the layer names `ball/center` and reads the rest alongside.
        """
        need = ("ball/center", "ball/radius", "ball/rotation", "ball/axes")
        if layer.data != need[0] or any(ref not in available for ref in need):
            raise ConfigError(
                f"{at(f'{where}.data')}: the ball is drawn from the whole of what "
                f"[ball] writes; use {need[0]!r} with [ball] on"
            )

    @staticmethod
    def _shape(
        ref: str, allowed: tuple[tuple, ...], where: str, available: dict[str, tuple]
    ) -> tuple:
        """The axes of a data reference, checked against the shapes `where` accepts."""
        if ref not in available:
            raise ConfigError(
                f"{at(where)}: nothing writes {ref!r}; this run has "
                + ", ".join(sorted(available))
            )
        axes = available[ref]
        if allowed and without_animal_axis(axes) not in allowed:
            raise ConfigError(
                f"{at(where)}: {ref!r} is {_axes(axes)}, which cannot feed this; "
                "expected " + " or ".join(_axes(a) for a in allowed)
            )
        return axes

check

check(available: dict[str, tuple]) -> None

Check every reference and panel against what the run actually writes.

available is {"group/array": axes} for every array the enabled stages write, plus video and labels/* (available). A (V,T,K) alpha may not reach a synthetic view, which has no view axis.

Source code in src/deeperfly/config/render.py
def check(self, available: dict[str, tuple]) -> None:
    """Check every reference and panel against what the run actually writes.

    `available` is `{"group/array": axes}` for every array the enabled stages
    write, plus `video` and `labels/*`
    ([available][deeperfly.config.run.RunConfig.available]). A `(V,T,K)` alpha may
    not reach a synthetic view, which has no view axis.
    """
    for name, video in self.outputs.items():
        where = f"render.outputs.{name}"
        names = video.names
        if video.crop is not None:
            self._shape(video.crop, (VT4,), f"{where}.crop", available)
        for i, layer in enumerate(video.layers):
            self._check_layer(layer, f"{where}.layers[{i}]", names, available)

RenderView

Bases: Table

[render.views.<name>]: a synthetic view, written as an orbit placement.

The orbit keys are OrbitView's; distance, focal and image_size may be left to the renderer, which fits them to the 3D in the panel.

Attributes:

Name Type Description
azimuth_deg float

Orbit rotation in the world xy-plane.

elevation_deg float

Orbit lift toward +z.

roll_deg float

Rotation about the optical axis.

look_at tuple[float, float, float]

World point the camera aims at.

distance float | None

Distance from look_at, in world units.

focal float | None

Focal length in pixels.

image_size tuple[int, int] | None

Panel (height, width) this view images into.

Source code in src/deeperfly/config/render.py
class RenderView(Table):
    """`[render.views.<name>]`: a synthetic view, written as an orbit placement.

    The orbit keys are [OrbitView][deeperfly.rig.OrbitView]'s; `distance`, `focal` and
    `image_size` may be left to the renderer, which fits them to the 3D in the panel.

    Attributes:
        azimuth_deg: Orbit rotation in the world xy-plane.
        elevation_deg: Orbit lift toward `+z`.
        roll_deg: Rotation about the optical axis.
        look_at: World point the camera aims at.
        distance: Distance from `look_at`, in world units.
        focal: Focal length in pixels.
        image_size: Panel `(height, width)` this view images into.
    """

    azimuth_deg: float = 0.0
    elevation_deg: float = 0.0
    roll_deg: float = 0.0
    look_at: tuple[float, float, float] = (0.0, 0.0, 0.0)
    distance: float | None = None
    focal: float | None = None
    image_size: tuple[int, int] | None = None

SkeletonLayer

Bases: _Layer

{ draw = "skeleton", data = ... }: keypoints and bones over the panel.

Attributes:

Name Type Description
draw Literal['skeleton']

The kind.

data str

The array to draw: a (V,T,K,2) track in the panel view's pixels, or a (T,K,3) track projected by whichever view the panel holds.

select tuple[str, ...]

Point selector (["lf_*", "rf_*"]).

color str | None

#rrggbb, black or white; the skeleton's own palette when none is named.

colors dict[str, str]

Per-name color overrides.

edge_color str | None

One color for every edge, overriding the palette.

radius float | None

Point radius in panel pixels; 0 draws edges only.

width float | None

Line width in panel pixels; 0 draws points only.

dash tuple[float, float] | None

[on, off] dash pattern in panel pixels.

outline float | None

Outline width drawn under the mark, for contrast on pale footage; none by default, marks carrying their own color.

alpha float | str | None

Opacity, or a (T,K)/(V,T,K) array reference to fade by.

alpha_range tuple[float, float] | None

[lo, hi] mapping an alpha array onto [0, 1]; lo > hi inverts, so [20, 0] fades what is large.

identity Literal['hue', 'label', 'dash', 'none']

How several animals are told apart: "hue" turns the palette's hue per animal; "label" keeps the one palette and tags each animal with its number, above its points in every view; "dash" keeps the palette and dashes the bones of every animal after the first; "none" keeps the palette alone. The palette says which joint, so "hue" spends a color cue on the animal that the others leave to the joint.

Source code in src/deeperfly/config/render.py
class SkeletonLayer(_Layer):
    """`{ draw = "skeleton", data = ... }`: keypoints and bones over the panel.

    Attributes:
        draw: The kind.
        data: The array to draw: a `(V,T,K,2)` track in the panel view's pixels,
            or a `(T,K,3)` track projected by whichever view the panel holds.
        select: Point selector (`["lf_*", "rf_*"]`).
        color: `#rrggbb`, `black` or `white`; the skeleton's own palette when
            none is named.
        colors: Per-name color overrides.
        edge_color: One color for every edge, overriding the palette.
        radius: Point radius in panel pixels; 0 draws edges only.
        width: Line width in panel pixels; 0 draws points only.
        dash: `[on, off]` dash pattern in panel pixels.
        outline: Outline width drawn under the mark, for contrast on pale
            footage; none by default, marks carrying their own color.
        alpha: Opacity, or a `(T,K)`/`(V,T,K)` array reference to fade by.
        alpha_range: `[lo, hi]` mapping an `alpha` array onto `[0, 1]`;
            `lo > hi` inverts, so `[20, 0]` fades what is large.
        identity: How several animals are told apart: `"hue"` turns the palette's
            hue per animal; `"label"` keeps the one palette and tags each animal
            with its number, above its points in every view; `"dash"` keeps the
            palette and dashes the bones of every animal after the first; `"none"`
            keeps the palette alone. The palette says which joint, so `"hue"`
            spends a color cue on the animal that the others leave to the joint.
    """

    draw: Literal["skeleton"]
    data: str
    select: tuple[str, ...] = ()
    color: str | None = None
    colors: dict[str, str] = Field(default_factory=dict)
    edge_color: str | None = None
    radius: float | None = None
    width: float | None = None
    dash: tuple[float, float] | None = None
    outline: float | None = None
    alpha: float | str | None = None
    alpha_range: tuple[float, float] | None = None
    identity: Literal["hue", "label", "dash", "none"] = "hue"

    @model_validator(mode="after")
    def _rules(self):
        """A range maps an array; a number needs none."""
        if self.alpha_range is not None and not isinstance(self.alpha, str):
            raise ValueError(
                "alpha_range: only maps an alpha that names an array; a number "
                "needs no range"
            )
        return self

TextLayer

Bases: _Layer

{ draw = "text", text = ... }: a caption.

Attributes:

Name Type Description
draw Literal['text']

The kind.

text str

The caption, with {project} (the project's name), {view}, {frame}, {time} and {fps} available.

size float | None

Text size in panel pixels.

color str | None

#rrggbb, black or white.

anchor Literal['top-left', 'top', 'top-right', 'left', 'center', 'right', 'bottom-left', 'bottom', 'bottom-right'] | None

Which corner (or center) the text sits at.

Source code in src/deeperfly/config/render.py
class TextLayer(_Layer):
    """`{ draw = "text", text = ... }`: a caption.

    Attributes:
        draw: The kind.
        text: The caption, with `{project}` (the project's name), `{view}`,
            `{frame}`, `{time}` and `{fps}` available.
        size: Text size in panel pixels.
        color: `#rrggbb`, `black` or `white`.
        anchor: Which corner (or center) the text sits at.
    """

    draw: Literal["text"]
    text: str
    size: float | None = None
    color: str | None = None
    anchor: (
        Literal[
            "top-left",
            "top",
            "top-right",
            "left",
            "center",
            "right",
            "bottom-left",
            "bottom",
            "bottom-right",
        ]
        | None
    ) = None

    @model_validator(mode="after")
    def _rules(self):
        """A caption names fields that exist."""
        if not self.text:
            raise ValueError("a 'text' layer needs text")
        unknown = [
            f
            for _, f, _, _ in Formatter().parse(self.text)
            if f is not None and f.split(".")[0].split("[")[0] not in TEXT_FIELDS
        ]
        if unknown:
            hint = " ('camera' is now 'view')" if "camera" in unknown else ""
            raise ValueError(
                f"text: {{{unknown[0]}}} is not a caption field{hint}; text takes "
                + ", ".join("{" + f + "}" for f in TEXT_FIELDS)
            )
        return self

TraceLayer

Bases: _Layer

{ draw = "trace", data = ... }: a named-channel time series, plotted.

Attributes:

Name Type Description
draw Literal['trace']

The kind.

data str

A (T,D) array with named channels (ik/angles).

select tuple[str, ...]

Channel selector.

window_frames int

How many frames of history the trace shows.

width float | None

Line width in panel pixels.

color str | None

#rrggbb, black or white.

Source code in src/deeperfly/config/render.py
class TraceLayer(_Layer):
    """`{ draw = "trace", data = ... }`: a named-channel time series, plotted.

    Attributes:
        draw: The kind.
        data: A `(T,D)` array with named channels (`ik/angles`).
        select: Channel selector.
        window_frames: How many frames of history the trace shows.
        width: Line width in panel pixels.
        color: `#rrggbb`, `black` or `white`.
    """

    draw: Literal["trace"]
    data: str
    select: tuple[str, ...] = ()
    window_frames: int = 0
    width: float | None = None
    color: str | None = None

VideoLayer

Bases: _Layer

{ draw = "video" }: the panel view's own frames.

Attributes:

Name Type Description
draw Literal['video']

The kind.

alpha float | str | None

Opacity, or a (T,K)/(V,T,K) array reference to fade by.

Source code in src/deeperfly/config/render.py
class VideoLayer(_Layer):
    """`{ draw = "video" }`: the panel view's own frames.

    Attributes:
        draw: The kind.
        alpha: Opacity, or a `(T,K)`/`(V,T,K)` array reference to fade by.
    """

    draw: Literal["video"]
    alpha: float | str | None = None