Skip to content

project

A project: a folder holding deeperfly.toml, and everything made for its videos.

The manifest, Manifest, names the videos the project works on (file patterns relative to the folder, or absolute), which views each carries and which are mirrored, the skeleton, the animal count, the active calibration and, optionally, the precedence of runs. The folder holds what deeperfly produces for those videos: labels.h5, calibrations/, suggestions.json, results/ and renders/ (ADR-0081). The recommended layout, and the one create_default writes, is a folder deeperfly/ inside the directory holding the videos.

project = Project.resolve("/data/260730_001")   # or .../260730_001/deeperfly
project.views                                   # the V axis, the manifest's order
rig = project.rig(project.calibration())        # the active calibration applied

A Project opens nothing: its videos are opened by open_recording. A Collection is projects given together, and nothing is stored for it.

Collection dataclass

Projects given together, to the GUI or training; nothing is stored for it.

Attributes:

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

The projects, in the order given.

Source code in src/deeperfly/project.py
@dataclass(frozen=True)
class Collection:
    """Projects given together, to the GUI or training; nothing is stored for it.

    Attributes:
        projects: The projects, in the order given.
    """

    projects: tuple[Project, ...]

    @classmethod
    def open(cls, paths: str | Path | Sequence[str | Path]) -> Collection:
        """The projects some paths stand for: each a project or a directory of them.

        A path resolves to a project as
        [Project.resolve][deeperfly.project.Project.resolve] does, never creating one;
        else its children that do are taken, in name order.

        Raises:
            ValueError: If a path stands for no project, or a project comes twice.
        """
        if isinstance(paths, (str, Path)):
            paths = [paths]
        folders: list[Path] = []
        for given in paths:
            path = normalized(given)
            folder = _project_folder(path)
            if folder is not None:
                folders.append(folder)
                continue
            children = _child_projects(path)
            if not children:
                raise ValueError(f"{path}: neither a project nor a directory of them")
            folders.extend(children)
        projects = map_files(Project.load, folders)
        if not projects:
            raise ValueError("a collection needs at least one path")
        project_names([p.folder for p in projects])  # a project given twice raises
        return cls(tuple(projects))

    @property
    def names(self) -> list[str]:
        """Each project's name in the collection, in order."""
        return project_names([p.folder for p in self.projects])

    def get(self, folder: str | Path) -> Project:
        """The project in `folder` (`KeyError` when it is not one of the collection)."""
        folder = normalized(folder)
        for project in self.projects:
            if project.folder == folder:
                return project
        raise KeyError(f"{folder} is not a project of this collection")

    def skeleton_digest(self) -> str | None:
        """The skeleton digest every project shares, or `None` when they differ."""
        digests = set(map_files(lambda p: p.skeleton().digest, self.projects))
        return digests.pop() if len(digests) == 1 else None

names property

names: list[str]

Each project's name in the collection, in order.

get

get(folder: str | Path) -> Project

The project in folder (KeyError when it is not one of the collection).

Source code in src/deeperfly/project.py
def get(self, folder: str | Path) -> Project:
    """The project in `folder` (`KeyError` when it is not one of the collection)."""
    folder = normalized(folder)
    for project in self.projects:
        if project.folder == folder:
            return project
    raise KeyError(f"{folder} is not a project of this collection")

open classmethod

open(
    paths: str | Path | Sequence[str | Path],
) -> Collection

The projects some paths stand for: each a project or a directory of them.

A path resolves to a project as Project.resolve does, never creating one; else its children that do are taken, in name order.

Raises:

Type Description
ValueError

If a path stands for no project, or a project comes twice.

Source code in src/deeperfly/project.py
@classmethod
def open(cls, paths: str | Path | Sequence[str | Path]) -> Collection:
    """The projects some paths stand for: each a project or a directory of them.

    A path resolves to a project as
    [Project.resolve][deeperfly.project.Project.resolve] does, never creating one;
    else its children that do are taken, in name order.

    Raises:
        ValueError: If a path stands for no project, or a project comes twice.
    """
    if isinstance(paths, (str, Path)):
        paths = [paths]
    folders: list[Path] = []
    for given in paths:
        path = normalized(given)
        folder = _project_folder(path)
        if folder is not None:
            folders.append(folder)
            continue
        children = _child_projects(path)
        if not children:
            raise ValueError(f"{path}: neither a project nor a directory of them")
        folders.extend(children)
    projects = map_files(Project.load, folders)
    if not projects:
        raise ValueError("a collection needs at least one path")
    project_names([p.folder for p in projects])  # a project given twice raises
    return cls(tuple(projects))

skeleton_digest

skeleton_digest() -> str | None

The skeleton digest every project shares, or None when they differ.

Source code in src/deeperfly/project.py
def skeleton_digest(self) -> str | None:
    """The skeleton digest every project shares, or `None` when they differ."""
    digests = set(map_files(lambda p: p.skeleton().digest, self.projects))
    return digests.pop() if len(digests) == 1 else None

Manifest

Bases: Table

deeperfly.toml: the videos a project works on and how they are laid out.

Attributes:

Name Type Description
format_version int

The manifest's schema version, MANIFEST_FORMAT_VERSION.

skeleton str

A packaged preset name, or a .toml path relative to the folder.

animals int

How many animals the videos may hold: the instance slots.

active_calibration int | None

The id of the calibration the GUI labels with, or None.

mirrored tuple[str, ...]

The views the apparatus reflects (ADR-0053).

videos dict[str, tuple[str, ...]]

video name -> file patterns, each a file or a glob, concatenated in order into one stream.

layout dict[str, tuple[str, ...]]

video name -> the views it carries; empty for one view per video, of the video's name.

detections ManifestDetections

The [detections] table.

Source code in src/deeperfly/project.py
class Manifest(Table):
    """`deeperfly.toml`: the videos a project works on and how they are laid out.

    Attributes:
        format_version: The manifest's schema version, `MANIFEST_FORMAT_VERSION`.
        skeleton: A packaged preset name, or a `.toml` path relative to the folder.
        animals: How many animals the videos may hold: the instance slots.
        active_calibration: The id of the calibration the GUI labels with, or `None`.
        mirrored: The views the apparatus reflects (ADR-0053).
        videos: `video name -> file patterns`, each a file or a glob, concatenated in
            order into one stream.
        layout: `video name -> the views it carries`; empty for one view per video,
            of the video's name.
        detections: The `[detections]` table.
    """

    format_version: int = MANIFEST_FORMAT_VERSION
    skeleton: str = "fly38"
    animals: int = 1
    active_calibration: int | None = None
    mirrored: tuple[str, ...] = ()
    videos: dict[str, tuple[str, ...]]
    layout: dict[str, tuple[str, ...]] = Field(default_factory=dict)
    detections: ManifestDetections = Field(default_factory=ManifestDetections)

    @model_validator(mode="after")
    def _consistent(self):
        """Refuse a manifest whose videos, layout and mirrored views disagree."""
        if not self.skeleton.strip():
            raise ValueError("skeleton: name a preset or a .toml path")
        if self.animals < 1:
            raise ValueError(f"animals: at least one, not {self.animals}")
        if not self.videos:
            raise ValueError("[videos]: a project names at least one video")
        for video, patterns in self.videos.items():
            if not patterns or not all(p.strip() for p in patterns):
                raise ValueError(f"[videos].{video}: name at least one file or glob")
        if self.layout:
            unknown = [v for v in self.layout if v not in self.videos]
            if unknown:
                raise ValueError(
                    f"[layout]: {', '.join(unknown)} is not a video of [videos]"
                )
            bare = [v for v in self.videos if v not in self.layout]
            if bare:
                raise ValueError(
                    f"[layout]: video {', '.join(bare)} carries no view; name its "
                    "views in [layout]"
                )
            for video, views in self.layout.items():
                if not views:
                    raise ValueError(f"[layout].{video}: name at least one view")
            Layout(self.layout)  # a view carried by two videos raises here
        stray = [v for v in self.mirrored if v not in self.views]
        if stray:
            raise ValueError(
                f"mirrored: {', '.join(stray)} is not a view (views: "
                f"{', '.join(self.views)})"
            )
        return self

    @property
    def views(self) -> list[str]:
        """The view names in order, the `V` axis: the layout's, else the video keys."""
        if not self.layout:
            return list(self.videos)
        return [view for views in self.layout.values() for view in views]

    @classmethod
    def load(cls, path: str | Path) -> Manifest:
        """Parse a `deeperfly.toml`, its format version before the rest (ADR-0061).

        Raises:
            ConfigError: If the file is not valid TOML, is another format version,
                or breaks a rule, the message naming the file.
        """
        text = Path(path).read_text()
        try:
            check_commented_headers(text)
            try:
                data = tomllib.loads(text)
            except tomllib.TOMLDecodeError as exc:
                raise ConfigError(f"not valid TOML: {exc}") from exc
            check_format_version(data, "manifest", MANIFEST_FORMAT_VERSION)
            return parse(cls, data, "")
        except ConfigError as exc:
            raise ConfigError(f"{path}: {exc}") from exc

    def to_table(self) -> dict:
        """The mapping `deeperfly.io.toml` writes, scalars before tables.

        `active_calibration`, `mirrored`, `[layout]` and `[detections]` are left out
        when unset.
        """
        doc: dict = {
            "format_version": self.format_version,
            "skeleton": self.skeleton,
            "animals": self.animals,
        }
        if self.active_calibration is not None:
            doc["active_calibration"] = self.active_calibration
        if self.mirrored:
            doc["mirrored"] = list(self.mirrored)
        doc["videos"] = {v: list(p) for v, p in self.videos.items()}
        if self.layout:
            doc["layout"] = {v: list(w) for v, w in self.layout.items()}
        if self.detections.runs:
            doc["detections"] = {"runs": list(self.detections.runs)}
        return doc

views property

views: list[str]

The view names in order, the V axis: the layout's, else the video keys.

load classmethod

load(path: str | Path) -> Manifest

Parse a deeperfly.toml, its format version before the rest (ADR-0061).

Raises:

Type Description
ConfigError

If the file is not valid TOML, is another format version, or breaks a rule, the message naming the file.

Source code in src/deeperfly/project.py
@classmethod
def load(cls, path: str | Path) -> Manifest:
    """Parse a `deeperfly.toml`, its format version before the rest (ADR-0061).

    Raises:
        ConfigError: If the file is not valid TOML, is another format version,
            or breaks a rule, the message naming the file.
    """
    text = Path(path).read_text()
    try:
        check_commented_headers(text)
        try:
            data = tomllib.loads(text)
        except tomllib.TOMLDecodeError as exc:
            raise ConfigError(f"not valid TOML: {exc}") from exc
        check_format_version(data, "manifest", MANIFEST_FORMAT_VERSION)
        return parse(cls, data, "")
    except ConfigError as exc:
        raise ConfigError(f"{path}: {exc}") from exc

to_table

to_table() -> dict

The mapping deeperfly.io.toml writes, scalars before tables.

active_calibration, mirrored, [layout] and [detections] are left out when unset.

Source code in src/deeperfly/project.py
def to_table(self) -> dict:
    """The mapping `deeperfly.io.toml` writes, scalars before tables.

    `active_calibration`, `mirrored`, `[layout]` and `[detections]` are left out
    when unset.
    """
    doc: dict = {
        "format_version": self.format_version,
        "skeleton": self.skeleton,
        "animals": self.animals,
    }
    if self.active_calibration is not None:
        doc["active_calibration"] = self.active_calibration
    if self.mirrored:
        doc["mirrored"] = list(self.mirrored)
    doc["videos"] = {v: list(p) for v, p in self.videos.items()}
    if self.layout:
        doc["layout"] = {v: list(w) for v, w in self.layout.items()}
    if self.detections.runs:
        doc["detections"] = {"runs": list(self.detections.runs)}
    return doc

ManifestDetections

Bases: Table

[detections]: which runs supply the 2D detections, first wins (ADR-0083).

Attributes:

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

Run names, results/<name>.h5, in precedence order; empty means the latest-written run covering a frame wins.

Source code in src/deeperfly/project.py
class ManifestDetections(Table):
    """`[detections]`: which runs supply the 2D detections, first wins (ADR-0083).

    Attributes:
        runs: Run names, `results/<name>.h5`, in precedence order; empty means the
            latest-written run covering a frame wins.
    """

    runs: tuple[str, ...] = ()

Project dataclass

A project folder and its parsed manifest; nothing is opened.

Build one with load, resolve or create_default. The editing functions of this module return a new Project over the rewritten manifest.

Attributes:

Name Type Description
folder Path

The folder holding deeperfly.toml, absolute and normalized.

manifest Manifest

Its parsed manifest.

Source code in src/deeperfly/project.py
@dataclass(frozen=True)
class Project:
    """A project folder and its parsed manifest; nothing is opened.

    Build one with [load][deeperfly.project.Project.load],
    [resolve][deeperfly.project.Project.resolve] or
    [create_default][deeperfly.project.Project.create_default]. The editing
    functions of this module return a new `Project` over the rewritten manifest.

    Attributes:
        folder: The folder holding `deeperfly.toml`, absolute and normalized.
        manifest: Its parsed manifest.
    """

    folder: Path
    manifest: Manifest

    @classmethod
    def load(cls, folder: str | Path) -> Project:
        """The project in `folder`.

        Raises:
            FileNotFoundError: If `folder` holds no `deeperfly.toml`.
            ConfigError: If the manifest is not valid.
        """
        folder = normalized(folder)
        path = folder / MANIFEST_FILENAME
        if not path.is_file():
            raise FileNotFoundError(
                f"{folder}: not a project, it holds no {MANIFEST_FILENAME}"
            )
        return cls(folder, Manifest.load(path))

    @classmethod
    def resolve(cls, path: str | Path, *, create: bool = False) -> Project:
        """The project a path stands for, as every command resolves one (ADR-0081).

        A project is used as is; else `<path>/deeperfly/` if that is a project; else,
        with `create`, the default project is made in `<path>/deeperfly/` from the
        video files `<path>` holds.

        Raises:
            FileNotFoundError: If `path` does not exist.
            ValueError: If it resolves to no project and none is to be created, or
                it holds no video file to create one from.
        """
        path = normalized(path)
        folder = _project_folder(path)
        if folder is not None:
            return cls.load(folder)
        if not path.exists():
            raise FileNotFoundError(f"{path}: no such file or directory")
        if create and path.is_dir():
            return cls.create_default(path)
        hint = (
            "; it holds video files, which `deeperfly run` or the GUI's setup form "
            "make a project of"
            if path.is_dir() and _video_files(path)
            else ""
        )
        raise ValueError(
            f"{path}: not a project, and {DEFAULT_PROJECT_DIRNAME}/ in it is none "
            f"either{hint}"
        )

    @classmethod
    def default(cls, directory: str | Path) -> Project:
        """Build the default project, `<directory>/deeperfly/`, in memory.

        One video per file, named by its stem and sorted, each one view of its own
        name; `fly38`, one animal, entries `../<file>`, glob-escaped when the name
        holds `*?[`. Nothing is written: this is what
        [create_default][deeperfly.project.Project.create_default] writes, and what a
        dry run plans against.

        Raises:
            ValueError: If `directory` holds no video file, or two share a stem.
        """
        directory = normalized(directory)
        files = _video_files(directory)
        if not files:
            raise ValueError(
                f"{directory}: no video file ({', '.join(VIDEO_EXTS)}) to make a "
                "project of"
            )
        stems = [f.stem for f in files]
        clash = sorted({s for s in stems if stems.count(s) > 1})
        if clash:
            raise ValueError(
                f"{directory}: several video files are named {', '.join(clash)}; "
                "write the manifest by hand or with the GUI's setup form"
            )
        videos = {f.stem: [f"../{glob.escape(f.name)}"] for f in files}
        return cls(
            directory / DEFAULT_PROJECT_DIRNAME, parse(Manifest, {"videos": videos}, "")
        )

    @classmethod
    def create_default(cls, directory: str | Path) -> Project:
        """Make the default project, `<directory>/deeperfly/`, from its video files.

        The manifest [default][deeperfly.project.Project.default] builds, written.

        Raises:
            ValueError: If `directory` holds no video file, or two share a stem.
            FileExistsError: If the default project's manifest already exists.
        """
        default = cls.default(directory)
        project = write_manifest(default.folder, videos=default.manifest.videos)
        log.info(
            "%s: created the default project, %d videos",
            project.folder,
            len(project.manifest.videos),
        )
        return project

    # -- what the manifest says ----------------------------------------------

    @property
    def manifest_path(self) -> Path:
        """The folder's `deeperfly.toml`."""
        return self.folder / MANIFEST_FILENAME

    @property
    def name(self) -> str:
        """The project's name on its own, as [project_names][] gives it."""
        return project_names([self.folder])[0]

    @property
    def views(self) -> list[str]:
        """The view names in order, the `V` axis of every array."""
        return self.manifest.views

    @property
    def animals(self) -> int:
        """How many animals the videos may hold."""
        return self.manifest.animals

    @property
    def layout(self) -> Layout:
        """Which views each video carries and which are mirrored."""
        return Layout(self.manifest.layout, mirrored=self.manifest.mirrored)

    @property
    def calibrations_dir(self) -> Path:
        """The numbered calibrations, whether or not the directory exists yet."""
        return self.folder / CALIBRATIONS_DIRNAME

    @property
    def results_dir(self) -> Path:
        """The runs, `results/<name>.h5`, whether or not the directory exists yet."""
        return self.folder / RESULTS_DIRNAME

    @property
    def renders_dir(self) -> Path:
        """The rendered videos, `renders/<run>/`, whether or not it exists yet."""
        return self.folder / RENDERS_DIRNAME

    def video_paths(self, video: str) -> list[Path]:
        """The files of one video, in stream order; a glob's matches sorted.

        Raises:
            KeyError: If the manifest names no such video.
            FileNotFoundError: If a pattern matches no file.
            ValueError: If a pattern names a directory.
        """
        if video not in self.manifest.videos:
            raise KeyError(f"{self.manifest_path}: no video {video!r}")
        return match_patterns(
            self.folder, video, self.manifest.videos[video], str(self.manifest_path)
        )

    def frame_sizes(self) -> dict[str, tuple[int, int]]:
        """Each view's frame `(height, width)`, probed once per video (ADR-0056)."""
        sizes: dict[str, tuple[int, int]] = {}
        for video in self.manifest.videos:
            sizes[video] = Video(self.video_paths(video)).size
        layout = self.layout
        return {view: sizes[layout.video_of(view)] for view in self.views}

    def skeleton_source(self) -> Path:
        """The skeleton's TOML: a `.toml` path against the folder, else a preset.

        A `..` climbs out of the real folder, as in a video pattern
        ([under_folder][deeperfly.recording.under_folder]).

        Raises:
            FileNotFoundError: If the path is no file or the name no preset.
        """
        name = self.manifest.skeleton
        if name.endswith(".toml"):
            path = under_folder(name, self.folder)
            if not path.is_file():
                raise FileNotFoundError(
                    f"{self.manifest_path}: skeleton {path}: no such file"
                )
            return path
        preset = PRESETS_DIR / f"{name}.toml"
        if not preset.is_file():
            raise FileNotFoundError(
                f"{self.manifest_path}: skeleton {name!r} is no preset (presets: "
                f"{', '.join(skeleton_presets())}) and no .toml path"
            )
        return preset

    def skeleton(self) -> Skeleton:
        """The skeleton the labels and detectors use, read afresh."""
        return Skeleton.from_toml(self.skeleton_source())

    # -- calibrations ----------------------------------------------------------

    @overload
    def calibration(self, cid: int) -> Calibration: ...
    @overload
    def calibration(self, cid: None = None) -> Calibration | None: ...
    def calibration(self, cid: int | None = None) -> Calibration | None:
        """A calibration by id, or the active one; `None` only when none is active.

        Raises:
            KeyError: If a given `cid`, or the active one, is not in `calibrations/`.
        """
        if cid is None:
            cid = self.manifest.active_calibration
            if cid is None:
                return None
        path = calibration_path(self.calibrations_dir, cid)
        if path is None:
            raise KeyError(f"{self.folder}: no calibration {cid}")
        return read_calibration(path)

    def placed_views(self, calibration: Calibration) -> dict[str, OrbitView | RawView]:
        """A calibration's views checked against the manifest, in its view order.

        Views are matched by name; the manifest fixes their order and which are
        mirrored.

        Raises:
            ValueError: If the view sets differ, or a view's `mirrored` flag
                disagrees with the manifest.
        """
        views = self.views
        missing = [v for v in views if v not in calibration.views]
        extra = [v for v in calibration.views if v not in views]
        if missing or extra:
            parts = []
            if missing:
                parts.append("lacks " + ", ".join(missing))
            if extra:
                parts.append("adds " + ", ".join(extra))
            raise ValueError(
                f"calibration {calibration.label!r} does not place the views of "
                f"{self.folder} ({', '.join(views)}): it " + " and ".join(parts)
            )
        mirrored = set(self.manifest.mirrored)
        wrong = [v for v in views if calibration.views[v].mirrored != (v in mirrored)]
        if wrong:
            said = ", ".join(
                f"{v} ({'mirrored' if v in mirrored else 'not mirrored'})"
                for v in wrong
            )
            raise ValueError(
                f"calibration {calibration.label!r} disagrees with the manifest of "
                f"{self.folder} on which views are mirrored: {said} (ADR-0053)"
            )
        return {v: calibration.views[v] for v in views}

    def rig(
        self,
        calibration: Calibration,
        *,
        frame_hw: Mapping[str, tuple[int, int]] | None = None,
    ) -> Rig:
        """The manifest's views under a calibration's parameters, in manifest order.

        `frame_hw` sets an orbit view's default principal point; without it the
        videos are probed for their frame sizes.

        Raises:
            ValueError: As [placed_views][deeperfly.project.Project.placed_views].
        """
        placed = self.placed_views(calibration)
        if frame_hw is None:
            frame_hw = self.frame_sizes()
        return Rig.from_specs(placed, frame_hw)

    # -- labels and runs -------------------------------------------------------

    def label_files(self, n_frames: int | None = None) -> LabelFiles:
        """The project's label files, keyed to its views, skeleton and frame count.

        Without `n_frames` the recording is opened for the frame count its videos agree
        on, so hold the result for a session's reads and writes.
        """
        if n_frames is None:
            n_frames = open_recording(self).n_frames
        return LabelFiles(
            self.folder,
            view_names=tuple(self.views),
            keypoint_names=tuple(self.skeleton().point_names),
            n_frames=n_frames,
        )

    def results_paths(self) -> list[Path]:
        """Every run's results file, `results/**/*.h5`, sorted."""
        if not self.results_dir.is_dir():
            return []
        return sorted(p for p in self.results_dir.rglob("*.h5") if p.is_file())

    def run_name(self, path: str | Path) -> str | None:
        """The run a results file is, `results/<name>.h5` -> `<name>`, or `None`."""
        path = normalized(path)
        if path.suffix != ".h5" or not path.is_relative_to(self.results_dir):
            return None
        return path.relative_to(self.results_dir).with_suffix("").as_posix()

    def save(self) -> None:
        """Rewrite the manifest in place, atomically: an edit of this project."""
        with atomic_write(self.manifest_path) as tmp:
            tmp.write_text(toml.dumps(self.manifest.to_table()))

animals property

animals: int

How many animals the videos may hold.

calibrations_dir property

calibrations_dir: Path

The numbered calibrations, whether or not the directory exists yet.

layout property

layout: Layout

Which views each video carries and which are mirrored.

manifest_path property

manifest_path: Path

The folder's deeperfly.toml.

name property

name: str

The project's name on its own, as project_names gives it.

renders_dir property

renders_dir: Path

The rendered videos, renders/<run>/, whether or not it exists yet.

results_dir property

results_dir: Path

The runs, results/<name>.h5, whether or not the directory exists yet.

views property

views: list[str]

The view names in order, the V axis of every array.

calibration

calibration(cid: int) -> Calibration
calibration(cid: None = None) -> Calibration | None
calibration(cid: int | None = None) -> Calibration | None

A calibration by id, or the active one; None only when none is active.

Raises:

Type Description
KeyError

If a given cid, or the active one, is not in calibrations/.

Source code in src/deeperfly/project.py
def calibration(self, cid: int | None = None) -> Calibration | None:
    """A calibration by id, or the active one; `None` only when none is active.

    Raises:
        KeyError: If a given `cid`, or the active one, is not in `calibrations/`.
    """
    if cid is None:
        cid = self.manifest.active_calibration
        if cid is None:
            return None
    path = calibration_path(self.calibrations_dir, cid)
    if path is None:
        raise KeyError(f"{self.folder}: no calibration {cid}")
    return read_calibration(path)

create_default classmethod

create_default(directory: str | Path) -> Project

Make the default project, <directory>/deeperfly/, from its video files.

The manifest default builds, written.

Raises:

Type Description
ValueError

If directory holds no video file, or two share a stem.

FileExistsError

If the default project's manifest already exists.

Source code in src/deeperfly/project.py
@classmethod
def create_default(cls, directory: str | Path) -> Project:
    """Make the default project, `<directory>/deeperfly/`, from its video files.

    The manifest [default][deeperfly.project.Project.default] builds, written.

    Raises:
        ValueError: If `directory` holds no video file, or two share a stem.
        FileExistsError: If the default project's manifest already exists.
    """
    default = cls.default(directory)
    project = write_manifest(default.folder, videos=default.manifest.videos)
    log.info(
        "%s: created the default project, %d videos",
        project.folder,
        len(project.manifest.videos),
    )
    return project

default classmethod

default(directory: str | Path) -> Project

Build the default project, <directory>/deeperfly/, in memory.

One video per file, named by its stem and sorted, each one view of its own name; fly38, one animal, entries ../<file>, glob-escaped when the name holds *?[. Nothing is written: this is what create_default writes, and what a dry run plans against.

Raises:

Type Description
ValueError

If directory holds no video file, or two share a stem.

Source code in src/deeperfly/project.py
@classmethod
def default(cls, directory: str | Path) -> Project:
    """Build the default project, `<directory>/deeperfly/`, in memory.

    One video per file, named by its stem and sorted, each one view of its own
    name; `fly38`, one animal, entries `../<file>`, glob-escaped when the name
    holds `*?[`. Nothing is written: this is what
    [create_default][deeperfly.project.Project.create_default] writes, and what a
    dry run plans against.

    Raises:
        ValueError: If `directory` holds no video file, or two share a stem.
    """
    directory = normalized(directory)
    files = _video_files(directory)
    if not files:
        raise ValueError(
            f"{directory}: no video file ({', '.join(VIDEO_EXTS)}) to make a "
            "project of"
        )
    stems = [f.stem for f in files]
    clash = sorted({s for s in stems if stems.count(s) > 1})
    if clash:
        raise ValueError(
            f"{directory}: several video files are named {', '.join(clash)}; "
            "write the manifest by hand or with the GUI's setup form"
        )
    videos = {f.stem: [f"../{glob.escape(f.name)}"] for f in files}
    return cls(
        directory / DEFAULT_PROJECT_DIRNAME, parse(Manifest, {"videos": videos}, "")
    )

frame_sizes

frame_sizes() -> dict[str, tuple[int, int]]

Each view's frame (height, width), probed once per video (ADR-0056).

Source code in src/deeperfly/project.py
def frame_sizes(self) -> dict[str, tuple[int, int]]:
    """Each view's frame `(height, width)`, probed once per video (ADR-0056)."""
    sizes: dict[str, tuple[int, int]] = {}
    for video in self.manifest.videos:
        sizes[video] = Video(self.video_paths(video)).size
    layout = self.layout
    return {view: sizes[layout.video_of(view)] for view in self.views}

label_files

label_files(n_frames: int | None = None) -> LabelFiles

The project's label files, keyed to its views, skeleton and frame count.

Without n_frames the recording is opened for the frame count its videos agree on, so hold the result for a session's reads and writes.

Source code in src/deeperfly/project.py
def label_files(self, n_frames: int | None = None) -> LabelFiles:
    """The project's label files, keyed to its views, skeleton and frame count.

    Without `n_frames` the recording is opened for the frame count its videos agree
    on, so hold the result for a session's reads and writes.
    """
    if n_frames is None:
        n_frames = open_recording(self).n_frames
    return LabelFiles(
        self.folder,
        view_names=tuple(self.views),
        keypoint_names=tuple(self.skeleton().point_names),
        n_frames=n_frames,
    )

load classmethod

load(folder: str | Path) -> Project

The project in folder.

Raises:

Type Description
FileNotFoundError

If folder holds no deeperfly.toml.

ConfigError

If the manifest is not valid.

Source code in src/deeperfly/project.py
@classmethod
def load(cls, folder: str | Path) -> Project:
    """The project in `folder`.

    Raises:
        FileNotFoundError: If `folder` holds no `deeperfly.toml`.
        ConfigError: If the manifest is not valid.
    """
    folder = normalized(folder)
    path = folder / MANIFEST_FILENAME
    if not path.is_file():
        raise FileNotFoundError(
            f"{folder}: not a project, it holds no {MANIFEST_FILENAME}"
        )
    return cls(folder, Manifest.load(path))

placed_views

placed_views(
    calibration: Calibration,
) -> dict[str, OrbitView | RawView]

A calibration's views checked against the manifest, in its view order.

Views are matched by name; the manifest fixes their order and which are mirrored.

Raises:

Type Description
ValueError

If the view sets differ, or a view's mirrored flag disagrees with the manifest.

Source code in src/deeperfly/project.py
def placed_views(self, calibration: Calibration) -> dict[str, OrbitView | RawView]:
    """A calibration's views checked against the manifest, in its view order.

    Views are matched by name; the manifest fixes their order and which are
    mirrored.

    Raises:
        ValueError: If the view sets differ, or a view's `mirrored` flag
            disagrees with the manifest.
    """
    views = self.views
    missing = [v for v in views if v not in calibration.views]
    extra = [v for v in calibration.views if v not in views]
    if missing or extra:
        parts = []
        if missing:
            parts.append("lacks " + ", ".join(missing))
        if extra:
            parts.append("adds " + ", ".join(extra))
        raise ValueError(
            f"calibration {calibration.label!r} does not place the views of "
            f"{self.folder} ({', '.join(views)}): it " + " and ".join(parts)
        )
    mirrored = set(self.manifest.mirrored)
    wrong = [v for v in views if calibration.views[v].mirrored != (v in mirrored)]
    if wrong:
        said = ", ".join(
            f"{v} ({'mirrored' if v in mirrored else 'not mirrored'})"
            for v in wrong
        )
        raise ValueError(
            f"calibration {calibration.label!r} disagrees with the manifest of "
            f"{self.folder} on which views are mirrored: {said} (ADR-0053)"
        )
    return {v: calibration.views[v] for v in views}

resolve classmethod

resolve(
    path: str | Path, *, create: bool = False
) -> Project

The project a path stands for, as every command resolves one (ADR-0081).

A project is used as is; else <path>/deeperfly/ if that is a project; else, with create, the default project is made in <path>/deeperfly/ from the video files <path> holds.

Raises:

Type Description
FileNotFoundError

If path does not exist.

ValueError

If it resolves to no project and none is to be created, or it holds no video file to create one from.

Source code in src/deeperfly/project.py
@classmethod
def resolve(cls, path: str | Path, *, create: bool = False) -> Project:
    """The project a path stands for, as every command resolves one (ADR-0081).

    A project is used as is; else `<path>/deeperfly/` if that is a project; else,
    with `create`, the default project is made in `<path>/deeperfly/` from the
    video files `<path>` holds.

    Raises:
        FileNotFoundError: If `path` does not exist.
        ValueError: If it resolves to no project and none is to be created, or
            it holds no video file to create one from.
    """
    path = normalized(path)
    folder = _project_folder(path)
    if folder is not None:
        return cls.load(folder)
    if not path.exists():
        raise FileNotFoundError(f"{path}: no such file or directory")
    if create and path.is_dir():
        return cls.create_default(path)
    hint = (
        "; it holds video files, which `deeperfly run` or the GUI's setup form "
        "make a project of"
        if path.is_dir() and _video_files(path)
        else ""
    )
    raise ValueError(
        f"{path}: not a project, and {DEFAULT_PROJECT_DIRNAME}/ in it is none "
        f"either{hint}"
    )

results_paths

results_paths() -> list[Path]

Every run's results file, results/**/*.h5, sorted.

Source code in src/deeperfly/project.py
def results_paths(self) -> list[Path]:
    """Every run's results file, `results/**/*.h5`, sorted."""
    if not self.results_dir.is_dir():
        return []
    return sorted(p for p in self.results_dir.rglob("*.h5") if p.is_file())

rig

rig(
    calibration: Calibration,
    *,
    frame_hw: Mapping[str, tuple[int, int]] | None = None,
) -> Rig

The manifest's views under a calibration's parameters, in manifest order.

frame_hw sets an orbit view's default principal point; without it the videos are probed for their frame sizes.

Raises:

Type Description
ValueError
Source code in src/deeperfly/project.py
def rig(
    self,
    calibration: Calibration,
    *,
    frame_hw: Mapping[str, tuple[int, int]] | None = None,
) -> Rig:
    """The manifest's views under a calibration's parameters, in manifest order.

    `frame_hw` sets an orbit view's default principal point; without it the
    videos are probed for their frame sizes.

    Raises:
        ValueError: As [placed_views][deeperfly.project.Project.placed_views].
    """
    placed = self.placed_views(calibration)
    if frame_hw is None:
        frame_hw = self.frame_sizes()
    return Rig.from_specs(placed, frame_hw)

run_name

run_name(path: str | Path) -> str | None

The run a results file is, results/<name>.h5 -> <name>, or None.

Source code in src/deeperfly/project.py
def run_name(self, path: str | Path) -> str | None:
    """The run a results file is, `results/<name>.h5` -> `<name>`, or `None`."""
    path = normalized(path)
    if path.suffix != ".h5" or not path.is_relative_to(self.results_dir):
        return None
    return path.relative_to(self.results_dir).with_suffix("").as_posix()

save

save() -> None

Rewrite the manifest in place, atomically: an edit of this project.

Source code in src/deeperfly/project.py
def save(self) -> None:
    """Rewrite the manifest in place, atomically: an edit of this project."""
    with atomic_write(self.manifest_path) as tmp:
        tmp.write_text(toml.dumps(self.manifest.to_table()))

skeleton

skeleton() -> Skeleton

The skeleton the labels and detectors use, read afresh.

Source code in src/deeperfly/project.py
def skeleton(self) -> Skeleton:
    """The skeleton the labels and detectors use, read afresh."""
    return Skeleton.from_toml(self.skeleton_source())

skeleton_source

skeleton_source() -> Path

The skeleton's TOML: a .toml path against the folder, else a preset.

A .. climbs out of the real folder, as in a video pattern (under_folder).

Raises:

Type Description
FileNotFoundError

If the path is no file or the name no preset.

Source code in src/deeperfly/project.py
def skeleton_source(self) -> Path:
    """The skeleton's TOML: a `.toml` path against the folder, else a preset.

    A `..` climbs out of the real folder, as in a video pattern
    ([under_folder][deeperfly.recording.under_folder]).

    Raises:
        FileNotFoundError: If the path is no file or the name no preset.
    """
    name = self.manifest.skeleton
    if name.endswith(".toml"):
        path = under_folder(name, self.folder)
        if not path.is_file():
            raise FileNotFoundError(
                f"{self.manifest_path}: skeleton {path}: no such file"
            )
        return path
    preset = PRESETS_DIR / f"{name}.toml"
    if not preset.is_file():
        raise FileNotFoundError(
            f"{self.manifest_path}: skeleton {name!r} is no preset (presets: "
            f"{', '.join(skeleton_presets())}) and no .toml path"
        )
    return preset

video_paths

video_paths(video: str) -> list[Path]

The files of one video, in stream order; a glob's matches sorted.

Raises:

Type Description
KeyError

If the manifest names no such video.

FileNotFoundError

If a pattern matches no file.

ValueError

If a pattern names a directory.

Source code in src/deeperfly/project.py
def video_paths(self, video: str) -> list[Path]:
    """The files of one video, in stream order; a glob's matches sorted.

    Raises:
        KeyError: If the manifest names no such video.
        FileNotFoundError: If a pattern matches no file.
        ValueError: If a pattern names a directory.
    """
    if video not in self.manifest.videos:
        raise KeyError(f"{self.manifest_path}: no video {video!r}")
    return match_patterns(
        self.folder, video, self.manifest.videos[video], str(self.manifest_path)
    )

add_calibration

add_calibration(
    project: Project, calib: Calibration
) -> tuple[Project, int]

Add a calibration under calibrations/ and return the project and its id.

Its views are written in the manifest's order. It becomes active only when none is.

Raises:

Type Description
ValueError
Source code in src/deeperfly/project.py
def add_calibration(project: Project, calib: Calibration) -> tuple[Project, int]:
    """Add a calibration under `calibrations/` and return the project and its id.

    Its views are written in the manifest's order. It becomes active only when none
    is.

    Raises:
        ValueError: As [placed_views][deeperfly.project.Project.placed_views].
    """
    placed = project.placed_views(calib)
    cid = write_calibration(
        project.calibrations_dir, calib.model_copy(update={"views": placed})
    )
    if project.manifest.active_calibration is None:
        project = set_active_calibration(project, cid)
    return project, cid

classify

classify(path: str | Path) -> PathKind

What a path is to deeperfly, the question the GUI's launcher asks.

"project": it or its deeperfly/ holds a manifest; "videos": a directory of video files without a project, which setup makes one of; "collection": a directory whose children resolve to projects; "empty": none of these; "missing": nothing is there.

Source code in src/deeperfly/project.py
def classify(path: str | Path) -> PathKind:
    """What a path is to deeperfly, the question the GUI's launcher asks.

    `"project"`: it or its `deeperfly/` holds a manifest; `"videos"`: a directory of
    video files without a project, which setup makes one of; `"collection"`: a
    directory whose children resolve to projects; `"empty"`: none of these;
    `"missing"`: nothing is there.
    """
    path = normalized(path)
    if not path.exists():
        return "missing"
    if _project_folder(path) is not None:
        return "project"
    if path.is_dir():
        if _video_files(path):
            return "videos"
        if _child_projects(path):
            return "collection"
    return "empty"

expand_projects

expand_projects(
    patterns: Iterable[str | Path], root: str | Path
) -> list[Project]

The projects some paths or globs name, relative to root, in order, once each.

A path must resolve to a project or be a directory of them, whose children that resolve to one are taken in name order, as Collection.open takes them; a glob must match something, and a match that resolves to no project is logged and skipped. A .. in either is taken lexically, without following a symlinked root, as every path in a config document is. Nothing is created.

Raises:

Type Description
ConfigError

If a glob matches nothing.

(FileNotFoundError, ValueError)

If a path resolves to no project and holds none.

Source code in src/deeperfly/project.py
def expand_projects(patterns: Iterable[str | Path], root: str | Path) -> list[Project]:
    """The projects some paths or globs name, relative to `root`, in order, once each.

    A path must resolve to a project or be a directory of them, whose children that
    resolve to one are taken in name order, as
    [Collection.open][deeperfly.project.Collection.open] takes them; a glob must match
    something, and a match that resolves to no project is logged and skipped. A `..`
    in either is taken lexically, without following a symlinked `root`, as every path
    in a config document is. Nothing is created.

    Raises:
        ConfigError: If a glob matches nothing.
        FileNotFoundError, ValueError: If a path resolves to no project and holds
            none.
    """
    root = normalized(root)
    found: dict[Path, Project] = {}
    for pattern in patterns:
        full = under_document(pattern, root)
        if not any(c in str(pattern) for c in GLOB_CHARS):
            children = [] if _project_folder(full) else _child_projects(full)
            named = [Project.load(c) for c in children] or [Project.resolve(full)]
            for project in named:
                found.setdefault(project.folder, project)
            continue
        typed = Path(pattern).expanduser()
        # `root` is a literal: a folder named `fly[1]` must not act as a glob. `..` is
        # taken lexically, as for a path, so a symlinked root climbs where it is typed.
        rooted = typed if typed.is_absolute() else Path(glob.escape(str(root))) / typed
        matches = sorted(glob.glob(os.path.normpath(rooted)))
        if not matches:
            raise ConfigError(f"{str(pattern)!r} matches no path under {root}")
        for match in matches:
            folder = _project_folder(normalized(match))
            if folder is None:
                log.info("%s: not a project, skipped", match)
                continue
            found.setdefault(folder, Project.load(folder))
    return list(found.values())

project_names

project_names(folders: Sequence[str | Path]) -> list[str]

A distinct short name per project folder: its shortest distinct suffix.

The names are the last k parts of each folder's path, k the least that tells them apart, with a trailing /deeperfly dropped (unless the folder above is a project too). /mnt/a/001/deeperfly and /home/b/002/deeperfly are 001 and 002; the default project of /data/260730_001 beside /data/260730_001/deeperfly_bc gives 260730_001 and 260730_001/deeperfly_bc. One folder is named by its leaf.

Raises:

Type Description
ValueError

If a folder is given twice.

Source code in src/deeperfly/project.py
def project_names(folders: Sequence[str | Path]) -> list[str]:
    """A distinct short name per project folder: its shortest distinct suffix.

    The names are the last `k` parts of each folder's path, `k` the least that tells
    them apart, with a trailing `/deeperfly` dropped (unless the folder above is a
    project too). `/mnt/a/001/deeperfly` and `/home/b/002/deeperfly` are `001` and
    `002`; the default project of `/data/260730_001` beside
    `/data/260730_001/deeperfly_bc` gives `260730_001` and
    `260730_001/deeperfly_bc`. One folder is named by its leaf.

    Raises:
        ValueError: If a folder is given twice.
    """
    dirs = [normalized(f) for f in folders]
    if len(set(dirs)) != len(dirs):
        dupes = sorted({str(d) for d in dirs if dirs.count(d) > 1})
        raise ValueError("project given more than once: " + ", ".join(dupes))
    given = set(dirs)
    drop = [d.name == DEFAULT_PROJECT_DIRNAME and d.parent not in given for d in dirs]
    parts = [d.parts[1:] for d in dirs]
    for k in range(1, max((len(p) for p in parts), default=0) + 1):
        names = [
            "/".join(p[-k:][:-1] if dropped else p[-k:])
            for p, dropped in zip(parts, drop, strict=True)
        ]
        if all(names) and len(set(names)) == len(names):
            return names
    return [d.as_posix() for d in dirs]

rename_view

rename_view(
    project: Project, old: str, new: str
) -> Project

Rename a view across the manifest, every calibration and the labels.

The name is the join key, so all of them change together and the view order is kept; a no-op when new == old. In a one-view-per-video layout the video is renamed with it. Runs keep the names they were written with.

Raises:

Type Description
ValueError

If old is not a view, or new is empty or already a view.

Source code in src/deeperfly/project.py
def rename_view(project: Project, old: str, new: str) -> Project:
    """Rename a view across the manifest, every calibration and the labels.

    The name is the join key, so all of them change together and the view order is
    kept; a no-op when `new == old`. In a one-view-per-video layout the video is
    renamed with it. Runs keep the names they were written with.

    Raises:
        ValueError: If `old` is not a view, or `new` is empty or already a view.
    """
    new = new.strip()
    views = project.views
    if old not in views:
        raise ValueError(f"{project.folder}: no view {old!r}")
    if new == old:
        return project
    if not new:
        raise ValueError("a view name must not be empty")
    if new in views:
        raise ValueError(f"{project.folder}: a view {new!r} exists")

    def rename(name: str) -> str:
        return new if name == old else name

    # Read everything before the first write, against the names stored today.
    keypoints = tuple(project.skeleton().point_names)
    staged = _label_sets(project, tuple(views), keypoints)
    manifest = project.manifest
    if manifest.layout:
        changes: dict = {
            "layout": {v: [rename(w) for w in ws] for v, ws in manifest.layout.items()}
        }
    else:
        changes = {"videos": {rename(v): p for v, p in manifest.videos.items()}}
    changes["mirrored"] = [rename(v) for v in manifest.mirrored]
    edited = _edited(project, **changes)
    for _, path in calibration_files(project.calibrations_dir):
        calib = read_calibration(path)
        renamed = {rename(n): spec for n, spec in calib.views.items()}
        path.write_text(calibration_toml(calib.model_copy(update={"views": renamed})))
    for path, labels in staged:
        # The V axis is positional, so relabeling one view leaves the data put.
        labels.view_names = tuple(rename(n) for n in labels.view_names)
        save_labels(labels, path)
    _drop_discarded(project)
    return edited

set_active_calibration

set_active_calibration(
    project: Project, cid: int
) -> Project

Make calibration cid the one the GUI labels with.

Raises:

Type Description
KeyError

If calibrations/ holds no calibration cid.

Source code in src/deeperfly/project.py
def set_active_calibration(project: Project, cid: int) -> Project:
    """Make calibration `cid` the one the GUI labels with.

    Raises:
        KeyError: If `calibrations/` holds no calibration `cid`.
    """
    if cid not in calibration_ids(project.calibrations_dir):
        raise KeyError(f"{project.folder}: no calibration {cid}")
    return _edited(project, active_calibration=cid)

set_detection_precedence

set_detection_precedence(
    project: Project, runs: Sequence[str]
) -> Project

Name the runs that supply the detections, first wins; empty clears the list.

Raises:

Type Description
ValueError

If a run is named twice or has no results/<name>.h5.

Source code in src/deeperfly/project.py
def set_detection_precedence(project: Project, runs: Sequence[str]) -> Project:
    """Name the runs that supply the detections, first wins; empty clears the list.

    Raises:
        ValueError: If a run is named twice or has no `results/<name>.h5`.
    """
    runs = list(runs)
    twice = sorted({r for r in runs if runs.count(r) > 1})
    if twice:
        raise ValueError(f"runs named more than once: {', '.join(twice)}")
    known = {project.run_name(p) for p in project.results_paths()}
    unknown = [r for r in runs if r not in known]
    if unknown:
        raise ValueError(
            f"{project.folder}: no run {', '.join(unknown)} under {RESULTS_DIRNAME}/"
        )
    return _edited(project, detections={"runs": runs})

set_skeleton

set_skeleton(
    project: Project, skeleton_toml: str
) -> Project

Give the project its own skeleton, skeleton.toml, remapping its labels.

Stored verbatim, and the manifest then names it. Points may be reordered or added and edges or symmetries changed freely; a point may be dropped only when no label is on it.

Raises:

Type Description
ValueError

If the TOML is not a valid skeleton, or it drops a labeled keypoint.

Source code in src/deeperfly/project.py
def set_skeleton(project: Project, skeleton_toml: str) -> Project:
    """Give the project its own skeleton, `skeleton.toml`, remapping its labels.

    Stored verbatim, and the manifest then names it. Points may be reordered or added
    and edges or symmetries changed freely; a point may be dropped only when no label
    is on it.

    Raises:
        ValueError: If the TOML is not a valid skeleton, or it drops a labeled
            keypoint.
    """
    names = tuple(Skeleton.from_toml_text(skeleton_toml).point_names)
    survives = set(names)
    old = tuple(project.skeleton().point_names)
    staged = []
    for path, labels in _label_sets(project, tuple(project.views), old):
        coo = labels.to_coo()
        labeled = {labels.keypoint_names[k] for k in set(coo["keypoint"].tolist())}
        dropped = sorted(labeled - survives)
        if dropped:
            raise ValueError(
                f"{path} has labels on keypoints dropped by the new skeleton: {dropped}"
            )
        staged.append((path, reindex_keypoints(labels, names)))
    (project.folder / SKELETON_FILENAME).write_text(skeleton_toml)
    edited = _edited(project, skeleton=SKELETON_FILENAME)
    for path, labels in staged:
        save_labels(labels, path)
    _drop_discarded(project)
    return edited

write_manifest

write_manifest(
    folder: str | Path,
    *,
    videos: Mapping[str, str | Sequence[str]],
    skeleton: str | Path = "fly38",
    skeleton_toml: str | None = None,
    animals: int = 1,
    layout: Mapping[str, Sequence[str]] | None = None,
    mirrored: Sequence[str] = (),
) -> Project

Create a project: write <folder>/deeperfly.toml, atomically.

videos maps each video name to its file patterns, relative to folder or absolute. skeleton_toml, a custom table's text, takes precedence over skeleton and is written verbatim to skeleton.toml beside the manifest. Everything is checked before anything is written.

Raises:

Type Description
FileExistsError

If folder already holds a manifest, which setup never overwrites (ADR-0081).

ConfigError

If the manifest would break a rule.

FileNotFoundError

If skeleton is neither a preset nor a file.

ValueError

If skeleton_toml is not a valid skeleton.

Source code in src/deeperfly/project.py
def write_manifest(
    folder: str | Path,
    *,
    videos: Mapping[str, str | Sequence[str]],
    skeleton: str | Path = "fly38",
    skeleton_toml: str | None = None,
    animals: int = 1,
    layout: Mapping[str, Sequence[str]] | None = None,
    mirrored: Sequence[str] = (),
) -> Project:
    """Create a project: write `<folder>/deeperfly.toml`, atomically.

    `videos` maps each video name to its file patterns, relative to `folder` or
    absolute. `skeleton_toml`, a custom table's text, takes precedence over
    `skeleton` and is written verbatim to `skeleton.toml` beside the manifest.
    Everything is checked before anything is written.

    Raises:
        FileExistsError: If `folder` already holds a manifest, which setup never
            overwrites (ADR-0081).
        ConfigError: If the manifest would break a rule.
        FileNotFoundError: If `skeleton` is neither a preset nor a file.
        ValueError: If `skeleton_toml` is not a valid skeleton.
    """
    folder = normalized(folder)
    path = folder / MANIFEST_FILENAME
    if path.exists():
        raise FileExistsError(
            f"{path} exists; a project's manifest is never overwritten, edit it or "
            "choose another folder"
        )
    if skeleton_toml is not None:
        Skeleton.from_toml_text(skeleton_toml)
        skeleton = SKELETON_FILENAME
    raw = {
        "skeleton": str(skeleton),
        "animals": animals,
        "mirrored": list(mirrored),
        "videos": {
            name: [value] if isinstance(value, str) else list(value)
            for name, value in videos.items()
        },
        "layout": {video: list(views) for video, views in (layout or {}).items()},
    }
    project = Project(folder, parse(Manifest, raw, ""))
    if skeleton_toml is None:
        project.skeleton()
    folder.mkdir(parents=True, exist_ok=True)
    if skeleton_toml is not None:
        (folder / SKELETON_FILENAME).write_text(skeleton_toml)
    project.save()
    return project