Skip to content

deeplabcut.core.config

Modules:

Name Description
base_config
project_config

Project configuration classes for DeepLabCut pose estimation models.

utils

Centralized helpers for reading, writing, and creating configuration files (YAML).

versioning

Configuration migration system for handling version upgrades and downgrades.

Classes:

Name Description
DLCBaseConfig

Pydantic base for DeepLabCut configuration models.

DLCVersionedConfig

Top-level configs with schema migration and change tracking.

ProjectConfig

Complete project configuration.

Functions:

Name Description
create_config_template

Creates a template for config.yaml file. This specific order is preserved while saving as yaml file.

create_config_template_3d

Creates a template for config.yaml file for 3d project. This specific order is preserved while saving as yaml file.

edit_config

Convenience function to edit and save a config file from a dictionary.

ensure_plain_config

Convert typed config arguments into plain Python objects.

get_yaml_dumper

Get a ruamel.yaml YAML handler with representers for Enum and Path objects.

get_yaml_loader

Get a ruamel.yaml YAML handler with safe mode.

normalize_for_serialization

Recursively normalize Paths to strings and Enums to values.

pretty_print

Prints a model configuration in a pretty and readable way.

read_config

Reads structured config file defining a project.

read_config_as_dict

Args:

resolve_alias

Resolve a config key to its canonical field name.

resolve_aliases_in_dict

Rename deprecated config keys to their canonical names.

write_config

Writes a pose configuration file to disk.

write_config_3d

Write structured 3D project config file.

write_config_3d_template

Write 3D config from pre-built template and YAML instance.

write_project_config

Write structured project config file (config.yaml) preserving template order.

DLCBaseConfig

Bases: BaseModel

Pydantic base for DeepLabCut configuration models.

This class is used to create configuration models for DeepLabCut. It provides a base class for all configuration models that need YAML/dict I/O and optional deprecated field names via json_schema_extra["aliases"]. (Use for all nested configs, e.g. pytorch DataConfig, InferenceConfig, etc.)

For project-level schema migration and dirty-field tracking, subclass DLCVersionedConfig instead.

Features:

  • Strict schema (extra="forbid", validate_assignment=True).
  • Load and save: from_yaml, from_dict, from_any, to_yaml, to_dict.
  • Pretty-print via print.
  • Hooks: _post_yaml_load_updates.
  • Nested dot-notation via select.
  • Dict-like access over declared fields (legacy compatibility).
  • In-place bulk updates via update.
  • Field aliases from json_schema_extra.

Methods:

Name Description
resolve_aliases_before_validate

Resolves aliases to their canonical names. (Normalizes ArgsKwargs

select

Select a value from the config using dot notation for nested keys.

to_commented_map

Recursively convert the config to a CommentedMap with YAML comments.

Source code in deeplabcut/core/config/base_config.py
class DLCBaseConfig(BaseModel):
    """Pydantic base for DeepLabCut configuration models.

    This class is used to create configuration models for DeepLabCut.
    It provides a base class for all configuration models that need YAML/dict I/O
    and optional deprecated field names via ``json_schema_extra["aliases"]``.
    (Use for all nested configs, e.g. pytorch ``DataConfig``, ``InferenceConfig``, etc.)

    For project-level schema migration and dirty-field tracking, subclass
    `DLCVersionedConfig` instead.

    Features:

    - Strict schema (`extra="forbid"`, `validate_assignment=True`).
    - Load and save: `from_yaml`, `from_dict`, `from_any`, `to_yaml`, `to_dict`.
    - Pretty-print via `print`.
    - Hooks: `_post_yaml_load_updates`.
    - Nested dot-notation via `select`.
    - Dict-like access over declared fields (legacy compatibility).
    - In-place bulk updates via `update`.
    - Field aliases from `json_schema_extra`.
    """

    model_config = ConfigDict(extra="forbid", validate_assignment=True)

    # ------------------------------------------------------------------
    # Validation (before pydantic field validation)
    # ------------------------------------------------------------------

    @model_validator(mode="before")
    @classmethod
    def resolve_aliases_before_validate(cls, data: Any) -> Any:
        """Resolves aliases to their canonical names. (Normalizes ArgsKwargs
        input to a dict for downstream validation.)

        Args:
            data: Raw validator input (`dict`, `ArgsKwargs`, or other).

        Returns:
            A dict with canonical field names when input is ArgsKwargs or dict;
            otherwise `data` unchanged.
        """
        if isinstance(data, ArgsKwargs):
            data: dict = cls._args_kwargs_to_dict(data)
        if isinstance(data, dict):
            return resolve_aliases_in_dict(data, cls._alias_map(), target=cls.__name__)
        return data

    # ------------------------------------------------------------------
    # Construction
    # ------------------------------------------------------------------

    @classmethod
    def from_dict(cls, cfg_dict: dict) -> Self:
        return cls.model_validate(cfg_dict)

    @classmethod
    def from_any(
        cls,
        config: Self | dict | str | Path,
    ) -> Self:
        if isinstance(config, cls):
            return config
        elif isinstance(config, str | Path):
            return cls.from_yaml(config)
        elif isinstance(config, dict):
            return cls.from_dict(config)
        else:
            raise TypeError(
                "Failure to load configuration: Expected a config instance, "
                f"dictionary, string, or Path. Got {type(config)}"
            )

    # Note @deruyter92 2026-06-15: the ignore_empty option is currently just used to support
    # some top-level fields in v0 legacy configs that are often empty. Should be removed in v1.
    @classmethod
    def from_yaml(cls, yaml_path: str | Path, ignore_empty: bool = True) -> Self:
        yaml_dict = read_config_as_dict(yaml_path)
        if ignore_empty:
            yaml_dict = {k: v for k, v in yaml_dict.items() if v is not None}
        cfg = cls.from_dict(yaml_dict)
        cfg._post_yaml_load_updates(yaml_path=Path(yaml_path))
        return cfg

    # ------------------------------------------------------------------
    # Serialization
    # ------------------------------------------------------------------

    def to_commented_map(self) -> CommentedMap:
        """Recursively convert the config to a CommentedMap with YAML comments."""
        dumped = self.to_dict(normalize=True)
        data = CommentedMap()
        for name, info in type(self).model_fields.items():
            extra = info.json_schema_extra
            if isinstance(extra, dict) and (comment := extra.get("comment")):
                data.yaml_set_comment_before_after_key(name, before=comment)
            value = getattr(self, name)
            if isinstance(value, DLCBaseConfig):
                data[name] = value.to_commented_map()
            else:
                data[name] = dumped[name]
        return data

    def to_yaml(
        self,
        yaml_path: str | Path,
        *,
        overwrite: bool = True,
    ) -> None:
        write_config(yaml_path, self.to_commented_map(), overwrite=overwrite)

    def to_dict(self, *, normalize: bool = False) -> dict:
        if not normalize:
            return self.model_dump()
        return normalize_for_serialization(self.model_dump())

    def print(
        self,
        indent: int = 0,
        print_fn: Callable[[str], None] | None = None,
    ) -> None:
        pretty_print(config=self.to_dict(), indent=indent, print_fn=print_fn)

    # ------------------------------------------------------------------
    # Hooks (override in subclasses)
    # ------------------------------------------------------------------

    def _post_yaml_load_updates(self, *, yaml_path: Path) -> None:
        pass

    # ------------------------------------------------------------------
    # Field aliases (deprecated names in json_schema_extra)
    # ------------------------------------------------------------------

    @classmethod
    @functools.cache
    def _alias_map(cls) -> dict[str, str]:
        """Build a map of deprecated aliases to canonical field names.

        Returns:
            Dict mapping each alias in `json_schema_extra["aliases"]` to its
            canonical field name.

        Raises:
            ValueError: If the same alias is declared on more than one field.
        """
        mapping: dict[str, str] = {}
        for name, info in cls.model_fields.items():
            extra = info.json_schema_extra
            if not isinstance(extra, dict):
                continue
            for alias in extra.get("aliases", []):
                if alias in mapping:
                    raise ValueError(f"Duplicate alias '{alias}' for fields '{mapping[alias]}' and '{name}'")
                mapping[alias] = name
        return mapping

    def _resolve_alias(
        self,
        name: str,
        *,
        warn: bool = True,
        stacklevel: int = 4,
    ) -> str:
        return resolve_alias(name, type(self)._alias_map(), warn=warn, stacklevel=stacklevel)

    # ------------------------------------------------------------------
    # Dict-like access (canonical field names only in keys()/iter)
    # ------------------------------------------------------------------

    def __setattr__(self, name: str, value: Any) -> None:
        name = self._resolve_alias(name)
        super().__setattr__(name, value)

    def __getattr__(self, name: str) -> Any:
        # Only runs after normal lookup fails; try resolved alias or raise AttributeError via BaseModel.__getattr__.
        if name in type(self)._alias_map():
            return getattr(self, self._resolve_alias(name))
        return super().__getattr__(name)

    def __getitem__(self, key: str) -> Any:
        key = self._resolve_alias(key)
        try:
            return getattr(self, key)
        except AttributeError:
            raise KeyError(key) from None

    def __setitem__(self, key: str, value: Any) -> None:
        canonical = self._resolve_alias(key, warn=True)
        if canonical not in self._field_names():
            raise KeyError(f"'{type(self).__name__}' has no field '{key}'")
        setattr(self, canonical, value)

    def __contains__(self, key: object) -> bool:
        if not isinstance(key, str):
            return False
        if key in self._field_names():
            return True
        return key in type(self)._alias_map()

    def __iter__(self) -> Iterator[str]:
        return iter(self._field_names())

    def __len__(self) -> int:
        return len(self._field_names())

    def get(self, key: str, default: Any = None) -> Any:
        try:
            return self[key]
        except KeyError:
            return default

    def update(
        self,
        updates: dict[str, Any] | None = None,
        /,
        **kwargs: Any,
    ) -> Self:
        if updates is not None and kwargs:
            raise TypeError(f"{type(self).__name__}.update() accepts either a dict or keyword args, not both.")
        overrides = updates if updates is not None else kwargs
        # Resolve aliases together (checks for duplicate canonical field names)
        resolved = resolve_aliases_in_dict(overrides, type(self)._alias_map(), target=type(self).__name__)
        for name, value in resolved.items():
            setattr(self, name, value)
        return self

    def keys(self) -> list[str]:
        return self._field_names()

    def values(self) -> list[Any]:
        return [getattr(self, name) for name in self._field_names()]

    def items(self) -> list[tuple[str, Any]]:
        return [(name, getattr(self, name)) for name in self._field_names()]

    def select(self, path: str, default: Any = None) -> Any:
        """Select a value from the config using dot notation for nested keys."""
        try:
            return self._navigate_nested(fields=path.split("."))
        except (AttributeError, KeyError, TypeError):
            return default

    def set_nested(self, path: str, value: Any) -> Self:
        if "." not in path:
            setattr(self, path, value)
            return self
        parts = path.split(".")
        parent_fields, final_field = parts[:-1], parts[-1]
        try:
            parent = self._navigate_nested(parent_fields)
            parent[final_field] = value
        except (AttributeError, KeyError, TypeError) as e:
            raise AttributeError(f"{type(self).__name__} has no '{path}'") from e
        return self

    def _navigate_nested(self, fields: list[str]) -> Any:
        """Navigate a nested structure of fields. Raises AttributeError / KeyError if any field is not found."""
        obj: Any = self
        for field in fields:
            obj = obj[field] if isinstance(obj, dict) else getattr(obj, field)
        return obj

    def _field_names(self) -> list[str]:
        cls = type(self)
        if not isinstance(self, BaseModel):
            raise TypeError(f"{cls.__name__} must inherit from pydantic.BaseModel")
        return list(cls.model_fields.keys())

    @classmethod
    def _args_kwargs_to_dict(cls, data: ArgsKwargs) -> dict:
        """Map positional and keyword constructor args to a field-name dict."""
        names = list(cls.model_fields.keys())
        return dict(
            zip(names, data.args or [], strict=False),
            **(data.kwargs or {}),
        )

resolve_aliases_before_validate classmethod

resolve_aliases_before_validate(data: Any) -> Any

Resolves aliases to their canonical names. (Normalizes ArgsKwargs input to a dict for downstream validation.)

Parameters:

Name Type Description Default

data

Any

Raw validator input (dict, ArgsKwargs, or other).

required

Returns:

Type Description
Any

A dict with canonical field names when input is ArgsKwargs or dict; otherwise data unchanged.

Source code in deeplabcut/core/config/base_config.py
@model_validator(mode="before")
@classmethod
def resolve_aliases_before_validate(cls, data: Any) -> Any:
    """Resolves aliases to their canonical names. (Normalizes ArgsKwargs
    input to a dict for downstream validation.)

    Args:
        data: Raw validator input (`dict`, `ArgsKwargs`, or other).

    Returns:
        A dict with canonical field names when input is ArgsKwargs or dict;
        otherwise `data` unchanged.
    """
    if isinstance(data, ArgsKwargs):
        data: dict = cls._args_kwargs_to_dict(data)
    if isinstance(data, dict):
        return resolve_aliases_in_dict(data, cls._alias_map(), target=cls.__name__)
    return data

select

select(path: str, default: Any = None) -> Any

Select a value from the config using dot notation for nested keys.

Source code in deeplabcut/core/config/base_config.py
def select(self, path: str, default: Any = None) -> Any:
    """Select a value from the config using dot notation for nested keys."""
    try:
        return self._navigate_nested(fields=path.split("."))
    except (AttributeError, KeyError, TypeError):
        return default

to_commented_map

to_commented_map() -> CommentedMap

Recursively convert the config to a CommentedMap with YAML comments.

Source code in deeplabcut/core/config/base_config.py
def to_commented_map(self) -> CommentedMap:
    """Recursively convert the config to a CommentedMap with YAML comments."""
    dumped = self.to_dict(normalize=True)
    data = CommentedMap()
    for name, info in type(self).model_fields.items():
        extra = info.json_schema_extra
        if isinstance(extra, dict) and (comment := extra.get("comment")):
            data.yaml_set_comment_before_after_key(name, before=comment)
        value = getattr(self, name)
        if isinstance(value, DLCBaseConfig):
            data[name] = value.to_commented_map()
        else:
            data[name] = dumped[name]
    return data

DLCVersionedConfig

Bases: DLCBaseConfig

Top-level configs with schema migration and change tracking.

Subclass of DLCBaseConfig for project and pose YAML configs such as ProjectConfig and PoseConfig.

Note

Pydantic runs migrate_before_validate before the base resolve_aliases_before_validate (child-first order): schema migration on legacy keys, then alias resolution for the current model.

Additional behavior:

  • migrate_before_validate upgrades raw dicts to CURRENT_CONFIG_VERSION.
  • Tracks fields modified after load; to_yaml can log changes and mark clean.
  • Patches __setattr__ once per class to record dirty fields while delegating alias warnings to the base __setattr__.
Source code in deeplabcut/core/config/base_config.py
class DLCVersionedConfig(DLCBaseConfig):
    """Top-level configs with schema migration and change tracking.

    Subclass of `DLCBaseConfig` for project and pose YAML configs such as
    `ProjectConfig` and `PoseConfig`.

    Note:
        Pydantic runs `migrate_before_validate` before the base
        `resolve_aliases_before_validate` (child-first order): schema migration
        on legacy keys, then alias resolution for the current model.

    Additional behavior:

    - `migrate_before_validate` upgrades raw dicts to `CURRENT_CONFIG_VERSION`.
    - Tracks fields modified after load; `to_yaml` can log changes and mark clean.
    - Patches `__setattr__` once per class to record dirty fields while delegating
      alias warnings to the base `__setattr__`.
    """

    config_version: int = Field(
        default=versioning.CURRENT_CONFIG_VERSION,
        json_schema_extra={"comment": "Config schema version. Do not edit manually."},
    )

    _initialized: bool = PrivateAttr(default=False)
    _dirty_fields: set[str] = PrivateAttr(default_factory=set)
    _change_notes: dict[str, Any] = PrivateAttr(default_factory=dict)

    # ------------------------------------------------------------------
    # Version migration (before pydantic field validation)
    # ------------------------------------------------------------------

    @classmethod
    def from_dict(cls, cfg_dict: dict) -> Self:
        cfg_dict = versioning.migrate_config(
            cfg_dict,
            config_type=cls.__name__,
            target_version=versioning.CURRENT_CONFIG_VERSION,
        )
        return super().from_dict(cfg_dict)

    # ------------------------------------------------------------------
    # Serialization
    # ------------------------------------------------------------------

    def to_yaml(
        self,
        yaml_path: str | Path,
        *,
        overwrite: bool = True,
        log_changes: bool = True,
        mark_clean: bool = True,
    ) -> None:
        super().to_yaml(yaml_path, overwrite=overwrite)
        if log_changes:
            self.log_changes()
        if mark_clean:
            self.mark_clean()

    # ------------------------------------------------------------------
    # Change tracking
    # ------------------------------------------------------------------

    def model_post_init(self, __context: Any) -> None:
        super().model_post_init(__context)
        self._initialized = True

    def __setattr__(self, name: str, value: Any) -> None:
        name = self._resolve_alias(name)

        # Private attributes (not a model field) skip tracking logic
        if name not in type(self).model_fields or not self._initialized:
            super().__setattr__(name, value)
            return

        # Get the coerced values before and after setting; log changes
        old_value = getattr(self, name, None)
        super().__setattr__(name, value)
        new_value = getattr(self, name)
        if old_value != new_value:
            self._dirty_fields.add(name)

    @property
    def is_dirty(self) -> bool:
        return bool(self._dirty_fields)

    @property
    def dirty_fields(self) -> frozenset[str]:
        return frozenset(self._dirty_fields)

    @property
    def change_notes(self) -> list[str]:
        return list(self._change_notes.values())

    def record_change_note(
        self,
        field_name: str,
        message: str,
        *,
        include_caller: bool = False,
        _stack_depth: int = 1,
    ) -> None:
        field_name = self._resolve_alias(field_name)

        if field_name not in type(self).model_fields:
            raise KeyError(f"'{type(self).__name__}' has no field '{field_name}'")

        if include_caller:
            frame = sys._getframe(_stack_depth)
            filename = frame.f_code.co_filename.rsplit("/", 1)[-1]
            message = f"{message} [{filename}:{frame.f_lineno}]"

        self._change_notes[field_name] = message

    def log_changes(self) -> None:
        if not self.is_dirty:
            return
        logger.info(f"Updates to {type(self).__name__}:")
        for field_name in sorted(self._dirty_fields):
            if field_name in self._change_notes:
                logger.info(f"  {self._change_notes[field_name]}")
            else:
                logger.info(f"  {field_name} was modified")

    def mark_clean(self) -> None:
        self._dirty_fields.clear()
        self._change_notes.clear()

ProjectConfig

Bases: DLCVersionedConfig

Complete project configuration.

Mirrors the structure of the project config.yaml (and metadata in pose config). Field names match the old dictionary keys for round-trip compatibility.

Attributes:

Name Type Description
Task str

Project task identifier (do not edit).

scorer str

Scorer name (do not edit).

date str

Project date (do not edit).

multianimalproject bool

Whether the project is multi-animal.

identity bool | None

Whether identity tracking is enabled (project config.yaml key).

project_path Path

Path to the DeepLabCut project.

pose_config_path Path | None

Path to the pose configuration file (metadata only).

engine Literal['pytorch', 'tensorflow']

Default DeepLabCut engine (e.g. pytorch).

video_sets dict[str, Any]

Video set configuration.

bodyparts UniqueStrList | Literal['MULTI!']

List of body parts.

individuals UniqueStrList

List of individual animal identities (multi-animal).

uniquebodyparts UniqueStrList

List of unique body parts (multi-animal project key).

multianimalbodyparts UniqueStrList

List of multi-animal body parts (multi-animal key).

start Fraction

Fraction of video to start extracting frames.

stop Fraction

Fraction of video to stop extracting frames.

numframes2pick NonNegativeInt

Number of frames to pick for labeling.

skeleton list[BodypartPair]

Skeleton connectivity for plotting.

skeleton_color str

Skeleton color for plotting.

pcutoff Fraction

Confidence cutoff for plotting.

dotsize NonNegativeInt

Dot size for visualization.

alphavalue Fraction

Alpha value for visualization.

colormap str

Colormap for visualization.

TrainingFraction list[Fraction]

Training fractions for dataset splits.

iteration NonNegativeInt | None

Training iteration.

default_net_type str

Default network architecture.

default_augmenter str | None

Default data augmenter.

default_track_method str | None

Default tracking method.

snapshotindex Literal['all'] | int

Snapshot index for evaluation.

detector_snapshotindex int

Detector snapshot index.

batch_size StrictPositiveInt

Training batch size.

detector_batch_size StrictPositiveInt

Detector batch size.

cropping bool

Whether cropping is enabled for analysis.

x1 NonNegativeInt | None

Cropping x1 coordinate.

x2 NonNegativeInt | None

Cropping x2 coordinate.

y1 NonNegativeInt | None

Cropping y1 coordinate.

y2 NonNegativeInt | None

Cropping y2 coordinate.

corner2move2 list[NonNegativeInt] | None

Refinement corner configuration.

move2corner bool | None

Refinement move-to-corner flag.

SuperAnimalConversionTables dict[str, Any] | None

Conversion tables for SuperAnimal weights.

Methods:

Name Description
validate_project_path

Repair project_path from yaml location; optionally persist.

Source code in deeplabcut/core/config/project_config.py
class ProjectConfig(DLCVersionedConfig):
    """Complete project configuration.

    Mirrors the structure of the project config.yaml (and metadata in pose config).
    Field names match the old dictionary keys for round-trip compatibility.

    Attributes:
        Task: Project task identifier (do not edit).
        scorer: Scorer name (do not edit).
        date: Project date (do not edit).
        multianimalproject: Whether the project is multi-animal.
        identity: Whether identity tracking is enabled (project config.yaml key).
        project_path: Path to the DeepLabCut project.
        pose_config_path: Path to the pose configuration file (metadata only).
        engine: Default DeepLabCut engine (e.g. pytorch).
        video_sets: Video set configuration.
        bodyparts: List of body parts.
        individuals: List of individual animal identities (multi-animal).
        uniquebodyparts: List of unique body parts (multi-animal project key).
        multianimalbodyparts: List of multi-animal body parts (multi-animal key).
        start: Fraction of video to start extracting frames.
        stop: Fraction of video to stop extracting frames.
        numframes2pick: Number of frames to pick for labeling.
        skeleton: Skeleton connectivity for plotting.
        skeleton_color: Skeleton color for plotting.
        pcutoff: Confidence cutoff for plotting.
        dotsize: Dot size for visualization.
        alphavalue: Alpha value for visualization.
        colormap: Colormap for visualization.
        TrainingFraction: Training fractions for dataset splits.
        iteration: Training iteration.
        default_net_type: Default network architecture.
        default_augmenter: Default data augmenter.
        default_track_method: Default tracking method.
        snapshotindex: Snapshot index for evaluation.
        detector_snapshotindex: Detector snapshot index.
        batch_size: Training batch size.
        detector_batch_size: Detector batch size.
        cropping: Whether cropping is enabled for analysis.
        x1: Cropping x1 coordinate.
        x2: Cropping x2 coordinate.
        y1: Cropping y1 coordinate.
        y2: Cropping y2 coordinate.
        corner2move2: Refinement corner configuration.
        move2corner: Refinement move-to-corner flag.
        SuperAnimalConversionTables: Conversion tables for SuperAnimal weights.
    """

    # Project definitions (do not edit)
    Task: str = Field(default="", json_schema_extra={"comment": "Project definitions (do not edit)"})
    scorer: str = ""
    date: str = ""
    multianimalproject: bool = False
    identity: bool | None = Field(default=None, json_schema_extra={"aliases": ["with_identity"]})

    # Project path
    project_path: Path = Field(
        default_factory=Path,
        json_schema_extra={"comment": "\nProject path (change when moving around)"},
    )
    pose_config_path: Path | None = None

    # Engine
    engine: Literal["pytorch", "tensorflow"] = Field(
        default="pytorch",
        json_schema_extra={
            "comment": "\nDefault DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow)"
        },
    )

    # Annotation dataset configuration (and individual video cropping parameters)
    video_sets: dict[str, Any] = Field(
        default_factory=dict,
        json_schema_extra={"comment": "\nAnnotation data set configuration (and individual video cropping parameters)"},
    )
    # VV TODO @deruyter92 2026-01-30: following the old original config.yaml template for now. VV
    # VV We should change this to a list[str] in the future. VV
    bodyparts: UniqueStrList | Literal["MULTI!"] = Field(default_factory=list)

    # TODO @deruyter92 2026-02-06: The current pipeline requires at least one individual defined in the
    # default configuration. This will be removed in the future.
    individuals: UniqueStrList = Field(default_factory=lambda: ["individual_1"])
    uniquebodyparts: UniqueStrList = Field(default_factory=list, json_schema_extra={"aliases": ["unique_bodyparts"]})
    multianimalbodyparts: UniqueStrList = Field(default_factory=list)  # multi-animal project key

    # Fraction of video to start/stop when extracting frames for labeling/refinement
    start: Fraction = Field(
        default=0.0,
        json_schema_extra={
            "comment": "\nFraction of video to start/stop when extracting frames for labeling/refinement"
        },
    )
    stop: Fraction = 1.0
    numframes2pick: NonNegativeInt = 20

    # Plotting configuration
    skeleton: list[BodypartPair] = Field(
        default_factory=list,
        json_schema_extra={"comment": "\nPlotting configuration"},
    )
    skeleton_color: str = "black"
    pcutoff: Fraction = 0.6
    dotsize: NonNegativeInt = 12
    alphavalue: Fraction = 0.7
    colormap: str = "rainbow"

    # Training, evaluation and analysis configuration
    TrainingFraction: list[Fraction] = Field(
        default_factory=lambda: [0.95],
        json_schema_extra={"comment": "\nTraining,Evaluation and Analysis configuration"},
    )
    iteration: NonNegativeInt | None = None
    default_net_type: str = "resnet_50"
    default_augmenter: str | None = None
    default_track_method: str | None = None
    snapshotindex: Literal["all"] | int = "all"
    detector_snapshotindex: int = -1
    batch_size: StrictPositiveInt = 8
    detector_batch_size: StrictPositiveInt = 1

    # Cropping parameters (for analysis and outlier frame detection)
    cropping: bool = Field(
        default=False,
        json_schema_extra={"comment": "\nCropping Parameters (for analysis and outlier frame detection)"},
    )
    x1: NonNegativeInt | None = Field(
        default=None,
        json_schema_extra={"comment": "if cropping is true for analysis, then set the values here:"},
    )
    x2: NonNegativeInt | None = None
    y1: NonNegativeInt | None = None
    y2: NonNegativeInt | None = None

    # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
    corner2move2: list[NonNegativeInt] | None = Field(
        default=None,
        json_schema_extra={
            "comment": (
                "\nRefinement configuration (parameters from annotation dataset "
                "configuration also relevant in this stage)"
            )
        },
    )
    move2corner: bool | None = None

    # Conversion tables to fine-tune SuperAnimal weights
    SuperAnimalConversionTables: dict[str, Any] | None = Field(
        default=None,
        json_schema_extra={"comment": "\nConversion tables to fine-tune SuperAnimal weights"},
    )

    # TODO @deruyter92 2026-02-06: These parameters are no longer used in the new pipeline.
    # We should remove them in config schema v1. They are needed now to support reading old configs.
    resnet: int | None = Field(
        default=None,
        json_schema_extra={
            "comment": "\nThese are very old parameters that are no longer used They are simply ignored."
        },
    )
    croppedtraining: bool | None = None

    @property
    def bodyparts_list(self) -> list[str]:
        # Animal-count agnostic; Always return a list (never "MULTI!", None, etc.)
        if self.multianimalproject:
            return list(self.multianimalbodyparts)
        if self.bodyparts == "MULTI!":
            raise ValueError("bodyparts must be a list of strings when multianimalproject is False, got 'MULTI!'")
        return list(self.bodyparts)

    @property
    def config_yaml_path(self) -> Path:
        return self.project_path / "config.yaml"

    def validate_project_path(self, *, yaml_path: str | Path | None = None, write: bool = False) -> Self:
        """Repair project_path from yaml location; optionally persist."""
        path = Path(yaml_path) if yaml_path is not None else self.config_yaml_path
        if not path.is_file():
            raise FileNotFoundError(f"config.yaml not found: {path}")
        self._post_yaml_load_updates(yaml_path=path)
        if write and "project_path" in self.dirty_fields:
            self.to_yaml(path, log_changes=True, mark_clean=True)
        return self

    @classmethod
    def from_any(cls, config: Self | dict | str | Path, *, repair_path: bool = False) -> Self:
        cfg = super().from_any(config)
        if repair_path:
            cfg.validate_project_path(yaml_path=config if isinstance(config, (str, Path)) else None, write=True)
        return cfg

    def _post_yaml_load_updates(self, *, yaml_path: Path) -> None:
        """
        Override method for post-yaml load updates. Called automatically by from_yaml().
        These are logged but not written to disk -- call to_yaml() explicitly if needed.
        """
        project_path = yaml_path.parent
        if project_path.absolute() != self.project_path.absolute():
            old = self.project_path
            self.project_path = project_path
            self.record_change_note(
                "project_path",
                f"project_path updated: {old} -> {project_path} (resolved from YAML location when reading config.yaml)",
            )

    @model_validator(mode="before")
    @classmethod
    def normalize_legacy_empty_values(cls, data: Any) -> Any:
        if not isinstance(data, dict):
            return data
        data = dict(data)

        # Some old configs used empty strings for unset fields
        for fieldname in ("skeleton", "TrainingFraction", "video_sets", "bodyparts"):
            if data.get(fieldname) == "":
                data.pop(fieldname)

        # NOTE @deruyter92 2026-06-15: This should be removed in v1.
        if data.get("multianimalproject") and not data.get("bodyparts"):
            data["bodyparts"] = "MULTI!"

        return data

    @model_validator(mode="after")
    def validate_start_before_stop(self) -> Self:
        less_than(self.start, self.stop, name="start", threshold_name="stop")
        return self

    @model_validator(mode="after")
    def validate_bodyparts_single_animal(self) -> Self:
        if not self.multianimalproject and self.bodyparts == "MULTI!":
            raise ValueError("bodyparts must be a list of strings when multianimalproject is False, got 'MULTI!'")

        # TODO @deruyter92 2026-06-15: This sentinel should be removed in v1.
        elif self.multianimalproject and self.bodyparts != "MULTI!":
            raise ValueError(f"bodyparts must be 'MULTI!' when multianimalproject is True, got {self.bodyparts}")

        return self

    @model_validator(mode="after")
    def validate_cropping_bounds(self) -> Self:
        if self.cropping and any(value is None for value in (self.x1, self.x2, self.y1, self.y2)):
            raise ValueError("When cropping is enabled, x1, x2, y1, and y2 must all be set")
        validate_crop_bounds(x1=self.x1, x2=self.x2, y1=self.y1, y2=self.y2)
        return self

validate_project_path

validate_project_path(*, yaml_path: str | Path | None = None, write: bool = False) -> Self

Repair project_path from yaml location; optionally persist.

Source code in deeplabcut/core/config/project_config.py
def validate_project_path(self, *, yaml_path: str | Path | None = None, write: bool = False) -> Self:
    """Repair project_path from yaml location; optionally persist."""
    path = Path(yaml_path) if yaml_path is not None else self.config_yaml_path
    if not path.is_file():
        raise FileNotFoundError(f"config.yaml not found: {path}")
    self._post_yaml_load_updates(yaml_path=path)
    if write and "project_path" in self.dirty_fields:
        self.to_yaml(path, log_changes=True, mark_clean=True)
    return self

create_config_template

create_config_template(multianimal: bool = False) -> tuple

Creates a template for config.yaml file. This specific order is preserved while saving as yaml file.

Returns:

Type Description
tuple

(cfg_file, ruamelFile) for further editing and dumping.

Source code in deeplabcut/core/config/utils.py
@deprecated(replacement="deeplabcut.core.config.ProjectConfig", since="3.1")
def create_config_template(multianimal: bool = False) -> tuple:
    """
    Creates a template for config.yaml file. This specific order is preserved while saving as yaml file.

    Returns:
        (cfg_file, ruamelFile) for further editing and dumping.
    """
    from deeplabcut.core.config.project_config import ProjectConfig

    ruamelFile = get_yaml_dumper()

    # TODO @deruyter92 2026-06-15: This sentinel should be removed in v1.
    bodyparts = "MULTI!" if multianimal else []
    cfg_file = ProjectConfig(multianimalproject=multianimal, bodyparts=bodyparts).to_dict()
    return cfg_file, ruamelFile

create_config_template_3d

create_config_template_3d() -> tuple

Creates a template for config.yaml file for 3d project. This specific order is preserved while saving as yaml file.

Returns:

Type Description
tuple

(cfg_file_3d, ruamelFile_3d) for further editing and dumping.

Source code in deeplabcut/core/config/utils.py
def create_config_template_3d() -> tuple:
    """
    Creates a template for config.yaml file for 3d project. This specific order is preserved while saving as yaml file.

    Returns:
        (cfg_file_3d, ruamelFile_3d) for further editing and dumping.
    """
    yaml_str = """\
# Project definitions (do not edit)
Task:
scorer:
date:
\n
# Project path (change when moving around)
project_path:
\n
# Plotting configuration
skeleton: # Note that the pairs must be defined, as you want them linked!
skeleton_color:
pcutoff:
colormap:
dotsize:
alphaValue:
markerType:
markerColor:
\n
# Number of cameras, camera names, path of the config files, shuffle index and trainingsetindex used to analyze videos:
num_cameras:
camera_names:
scorername_3d: # Enter the scorer name for the 3D output
    """
    ruamelFile_3d = get_yaml_dumper()
    cfg_file_3d = ruamelFile_3d.load(yaml_str)
    return cfg_file_3d, ruamelFile_3d

edit_config

edit_config(configname: str | Path, edits: dict, output_name: str | Path = '') -> dict

Convenience function to edit and save a config file from a dictionary.

Note

Legacy helper without schema validation. Prefer manipulating and saving the typed config instead (e.g. cfg.update(edits).to_yaml(cfg_path))

Parameters

configname : string String containing the full path of the config file in the project. edits : dict Key–value pairs to edit in config output_name : string, optional (default='') Overwrite the original config.yaml by default. If passed in though, new filename of the edited config.

Examples

config_path = 'my_stellar_lab/dlc/config.yaml'

edits = {'numframes2pick': 5, 'trainingFraction': [0.5, 0.8], 'skeleton': [['a', 'b'], ['b', 'c']]}

deeplabcut.core.config.edit_config(config_path, edits)

Source code in deeplabcut/core/config/utils.py
def edit_config(configname: str | Path, edits: dict, output_name: str | Path = "") -> dict:
    """
    Convenience function to edit and save a config file from a dictionary.

    Note:
        Legacy helper without schema validation. Prefer manipulating and saving
        the typed config instead (e.g. cfg.update(edits).to_yaml(cfg_path))

    Parameters
    ----------
    configname : string
        String containing the full path of the config file in the project.
    edits : dict
        Key–value pairs to edit in config
    output_name : string, optional (default='')
        Overwrite the original config.yaml by default.
        If passed in though, new filename of the edited config.

    Examples
    --------
    config_path = 'my_stellar_lab/dlc/config.yaml'

    edits = {'numframes2pick': 5,
             'trainingFraction': [0.5, 0.8],
             'skeleton': [['a', 'b'], ['b', 'c']]}

    deeplabcut.core.config.edit_config(config_path, edits)
    """
    cfg = read_config_as_dict(configname)
    for key, value in edits.items():
        cfg[key] = value
    if not output_name:
        output_name = configname
    try:
        write_config(output_name, cfg)
    except ruamel.yaml.representer.RepresenterError:
        warnings.warn("Some edits could not be written. The configuration file will be left unchanged.", stacklevel=2)
        for key in edits:
            cfg.pop(key)
        write_config(output_name, cfg)
    return cfg

ensure_plain_config

ensure_plain_config(fn: Callable) -> Callable

Convert typed config arguments into plain Python objects.

Any positional or keyword argument that is a DLCBaseConfig is converted to a plain dict before the decorated function is called.

Source code in deeplabcut/core/config/utils.py
def ensure_plain_config(fn: Callable) -> Callable:
    """Convert typed config arguments into plain Python objects.

    Any positional or keyword argument that is a DLCBaseConfig is converted to
    a plain ``dict`` before the decorated function is called.
    """

    def _to_plain(value, fn_name: str = "<unknown>", var_name: str = "<unknown>"):
        # Lazy import to avoid circular imports during module initialization.
        from deeplabcut.core.config.base_config import DLCBaseConfig

        if isinstance(value, DLCBaseConfig):
            logger.debug(
                "converting %s (%s) to native dict in %s.",
                var_name,
                type(value).__name__,
                fn_name,
            )
            return value.to_dict()
        return value

    @wraps(fn)
    def wrapper(*args, **kwargs):
        fn_name = fn.__qualname__
        args = tuple(_to_plain(a, fn_name) for a in args)
        kwargs = {k: _to_plain(v, fn_name=fn_name, var_name=k) for k, v in kwargs.items()}
        return fn(*args, **kwargs)

    return wrapper

get_yaml_dumper

get_yaml_dumper() -> YAML

Get a ruamel.yaml YAML handler with representers for Enum and Path objects.

Source code in deeplabcut/core/config/utils.py
def get_yaml_dumper() -> YAML:
    """Get a ruamel.yaml YAML handler with representers for Enum and Path objects."""
    yaml = YAML(typ="rt", pure=True)

    # Use a very large width so long strings (e.g., file paths or keys with spaces)
    # are kept on a single line instead of being wrapped, which can otherwise cause
    # them to be emitted as complex keys. See also:
    # https://stackoverflow.com/questions/31197268/pyyaml-yaml-dump-produces-complex-key-for-string-key-122-chars/31199123#31199123
    # See PR https://github.com/DeepLabCut/DeepLabCut/pull/3140 for more details.
    yaml.width = 1_000_000

    # Auto-serialize Path objects as strings
    yaml.representer.add_multi_representer(PurePath, lambda r, p: r.represent_str(str(p)))
    yaml.representer.add_multi_representer(Enum, lambda r, e: r.represent_str(e.value))
    return yaml

get_yaml_loader

get_yaml_loader() -> YAML

Get a ruamel.yaml YAML handler with safe mode.

Source code in deeplabcut/core/config/utils.py
def get_yaml_loader() -> YAML:
    """Get a ruamel.yaml YAML handler with safe mode."""
    yaml = YAML(typ="safe", pure=True)
    return yaml

normalize_for_serialization

normalize_for_serialization(obj: Any) -> Any

Recursively normalize Paths to strings and Enums to values.

Source code in deeplabcut/core/config/utils.py
def normalize_for_serialization(obj: Any) -> Any:
    """Recursively normalize Paths to strings and Enums to values."""
    if isinstance(obj, Path):
        return str(obj)
    if isinstance(obj, Enum):
        return obj.value
    if isinstance(obj, Mapping):
        return type(obj)({k: normalize_for_serialization(v) for k, v in obj.items()})
    if isinstance(obj, tuple):
        return tuple(normalize_for_serialization(v) for v in obj)
    if isinstance(obj, (list, set)):
        return [normalize_for_serialization(v) for v in obj]
    if isinstance(obj, np.ndarray):
        return obj.tolist()
    return obj

pretty_print

pretty_print(config: dict, indent: int = 0, print_fn: Callable[[str], None] | None = None) -> None

Prints a model configuration in a pretty and readable way.

Parameters:

Name Type Description Default

config

dict

the config to print

required

indent

int

the base indent on all keys

0

print_fn

Callable[[str], None] | None

custom function to call (simply calls print if None)

None
Source code in deeplabcut/core/config/utils.py
def pretty_print(
    config: dict,
    indent: int = 0,
    print_fn: Callable[[str], None] | None = None,
) -> None:
    """Prints a model configuration in a pretty and readable way.

    Args:
        config: the config to print
        indent: the base indent on all keys
        print_fn: custom function to call (simply calls ``print`` if None)
    """
    if print_fn is None:
        print_fn = print

    for k, v in config.items():
        if isinstance(v, dict):
            print_fn(f"{indent * ' '}{k}:")
            pretty_print(v, indent + 2, print_fn=print_fn)
        else:
            print_fn(f"{indent * ' '}{k}: {v}")

read_config

read_config(configname: str | Path, ignore_empty: bool = True) -> ProjectConfig

Reads structured config file defining a project.

Applies default values and repairs (engine, detector_snapshotindex, project_path) and writes back if needed.

Parameters:

Name Type Description Default

configname

str | Path

Path to the project configuration file (config.yaml).

required

ignore_empty

bool

If True, empty/None values in the YAML are ignored and dataclass defaults are used instead. If False, empty values represent None. Defaults to True.

True

Returns:

Type Description
ProjectConfig

The project configuration as a ProjectConfig instance (supports dict-like access).

Source code in deeplabcut/core/config/utils.py
def read_config(configname: str | Path, ignore_empty: bool = True) -> ProjectConfig:
    """
    Reads structured config file defining a project.

    Applies default values and repairs (engine, detector_snapshotindex, project_path)
    and writes back if needed.

    Args:
        configname: Path to the project configuration file (config.yaml).
        ignore_empty: If True, empty/None values in the YAML are ignored and
            dataclass defaults are used instead. If False, empty values represent None.
            Defaults to True.

    Returns:
        The project configuration as a ProjectConfig instance (supports dict-like access).
    """
    from deeplabcut.core.config.project_config import ProjectConfig

    path = Path(configname)
    project_config = ProjectConfig.from_yaml(path, ignore_empty=ignore_empty)

    # If necessary, ProjectConfig automatically updates its project path via _post_yaml_load_updates.
    # if that is the case (marked as dirty), we write the config back to the file.
    if "project_path" in project_config.dirty_fields:
        # NOTE @deruyter92 2026-02-02: copied old behaviour of writing the config
        # immediately back to the file after reading it. We should consider separating
        # the writing and reading instead of having inplace edits during reading.
        project_config.to_yaml(configname, log_changes=True, mark_clean=True)
    return project_config

read_config_as_dict

read_config_as_dict(config_path: str | Path) -> dict

Parameters:

Name Type Description Default

config_path

str | Path

the path to the configuration file to load

required

Returns:

Type Description
dict

The configuration file with pure Python classes

Raises:

Type Description
FileNotFoundError

if the config file does not exist

Source code in deeplabcut/core/config/utils.py
def read_config_as_dict(config_path: str | Path) -> dict:
    """
    Args:
        config_path: the path to the configuration file to load

    Returns:
        The configuration file with pure Python classes

    Raises:
        FileNotFoundError: if the config file does not exist
    """
    if not Path(config_path).exists():
        raise FileNotFoundError(f"Config {config_path} is not found. Please make sure that the file exists.")
    with open(config_path) as f:
        cfg = get_yaml_loader().load(f)
    if cfg is None:
        raise ValueError(f"Config {config_path} is empty or null.")
    if not isinstance(cfg, dict):
        raise ValueError(f"Config {config_path} must be a YAML mapping at the top level, got {type(cfg).__name__}.")
    return cfg

resolve_alias

resolve_alias(name: str, alias_map: dict[str, str], *, warn: bool = True, stacklevel: int = 3) -> str

Resolve a config key to its canonical field name. Args: name: Raw key name (alias or canonical). alias_map: {alias: canonical_name} for deprecated keys. warn: If True, emit :class:DLCDeprecationWarning when name is an alias. stacklevel: Passed to :func:warnings.warn for deprecation messages. Returns: Canonical field name, or name unchanged if it is not an alias.

Source code in deeplabcut/core/config/utils.py
def resolve_alias(
    name: str,
    alias_map: dict[str, str],
    *,
    warn: bool = True,
    stacklevel: int = 3,
) -> str:
    """Resolve a config key to its canonical field name.
    Args:
        name: Raw key name (alias or canonical).
        alias_map: ``{alias: canonical_name}`` for deprecated keys.
        warn: If True, emit :class:`DLCDeprecationWarning` when ``name`` is an alias.
        stacklevel: Passed to :func:`warnings.warn` for deprecation messages.
    Returns:
        Canonical field name, or ``name`` unchanged if it is not an alias.
    """
    canonical = alias_map.get(name, name)
    if warn and name in alias_map:
        from deeplabcut.core.deprecation import DLCDeprecationWarning

        warnings.warn(
            f"'{name}' is deprecated, use '{canonical}' instead.",
            DLCDeprecationWarning,
            stacklevel=stacklevel,
        )
    return canonical

resolve_aliases_in_dict

resolve_aliases_in_dict(
    cfg_dict: dict, alias_map: dict[str, str], *, target: str = "config", warn: bool = True, stacklevel: int = 3
) -> dict

Rename deprecated config keys to their canonical names.

Parameters:

Name Type Description Default

cfg_dict

dict

Raw configuration mapping (e.g. from YAML).

required

alias_map

dict[str, str]

{alias: canonical_name} for deprecated keys.

required

target

str

Config class name shown in errors.

'config'

stacklevel

int

Passed to :func:warnings.warn for deprecation messages.

3

Returns:

Type Description
dict

A new dict with alias keys replaced by canonical names. Unchanged if alias_map is empty.

Raises:

Type Description
TypeError

If multiple keys resolve to the same canonical field name

Source code in deeplabcut/core/config/utils.py
def resolve_aliases_in_dict(
    cfg_dict: dict,
    alias_map: dict[str, str],
    *,
    target: str = "config",
    warn: bool = True,
    stacklevel: int = 3,
) -> dict:
    """Rename deprecated config keys to their canonical names.

    Args:
        cfg_dict: Raw configuration mapping (e.g. from YAML).
        alias_map: ``{alias: canonical_name}`` for deprecated keys.
        target: Config class name shown in errors.
        stacklevel: Passed to :func:`warnings.warn` for deprecation messages.

    Returns:
        A new dict with alias keys replaced by canonical names. Unchanged if
        ``alias_map`` is empty.

    Raises:
        TypeError: If multiple keys resolve to the same canonical field name
        (e.g. an alias and its canonical name, or two aliases for one field).
    """
    if not alias_map:
        return cfg_dict

    def _raise_for_duplicates(raw_to_canonical: dict[str, str]):
        counts = Counter(raw_to_canonical.values())
        conflicts = [f"{raw} -> {canonical}" for raw, canonical in raw_to_canonical.items() if counts[canonical] > 1]
        if conflicts:
            raise TypeError(f"{target} received duplicate canonical field names: {', '.join(conflicts)}.")

    raw_to_canonical = {raw: resolve_alias(raw, alias_map, warn=warn, stacklevel=stacklevel + 1) for raw in cfg_dict}
    _raise_for_duplicates(raw_to_canonical)
    return {raw_to_canonical[raw]: v for raw, v in cfg_dict.items()}

write_config

write_config(config_path: str | Path, config: dict, overwrite: bool = True) -> None

Writes a pose configuration file to disk.

Parameters:

Name Type Description Default

config_path

str | Path

the path where the config should be saved

required

config

dict

the config to save

required

overwrite

bool

whether to overwrite the file if it already exists

True
Source code in deeplabcut/core/config/utils.py
def write_config(config_path: str | Path, config: dict, overwrite: bool = True) -> None:
    """Writes a pose configuration file to disk.

    Args:
        config_path: the path where the config should be saved
        config: the config to save
        overwrite: whether to overwrite the file if it already exists

    Raises:
        FileExistsError if overwrite=True and the file already exists
    """
    if not overwrite and Path(config_path).exists():
        raise FileExistsError(f"Cannot write to {config_path} - set overwrite=True to force")

    with open(config_path, "w") as file:
        get_yaml_dumper().dump(config, file)

write_config_3d

write_config_3d(configname: str | Path, cfg: dict) -> None

Write structured 3D project config file.

Source code in deeplabcut/core/config/utils.py
def write_config_3d(configname: str | Path, cfg: dict) -> None:
    """Write structured 3D project config file."""
    with open(configname, "w") as cf:
        cfg_file, ruamelFile = create_config_template_3d()
        for key in cfg.keys():
            cfg_file[key] = cfg[key]
        ruamelFile.dump(cfg_file, cf)

write_config_3d_template

write_config_3d_template(projconfigfile: str | Path, cfg_file_3d: dict, ruamelFile_3d: YAML) -> None

Write 3D config from pre-built template and YAML instance.

Source code in deeplabcut/core/config/utils.py
def write_config_3d_template(projconfigfile: str | Path, cfg_file_3d: dict, ruamelFile_3d: YAML) -> None:
    """Write 3D config from pre-built template and YAML instance."""
    with open(projconfigfile, "w") as cf:
        ruamelFile_3d.dump(cfg_file_3d, cf)

write_project_config

write_project_config(configname: str | Path, cfg: dict | ProjectConfig) -> None

Write structured project config file (config.yaml) preserving template order.

Parameters:

Name Type Description Default

configname

str | Path

Path to the project configuration file (config.yaml).

required

cfg

dict | ProjectConfig

The project configuration to write (requires ProjectConfig schema).

required
Note

Validates before writing when possible, unvalidated legacy dump on failure; This may not round-trip via read_config for non-conformant legacy configurations.

Source code in deeplabcut/core/config/utils.py
def write_project_config(
    configname: str | Path,
    cfg: dict | ProjectConfig,
) -> None:
    """
    Write structured project config file (config.yaml) preserving template order.

    Args:
        configname (str | Path): Path to the project configuration file (config.yaml).
        cfg (dict | ProjectConfig): The project configuration to write (requires ProjectConfig schema).

    Note:
        Validates before writing when possible, unvalidated legacy dump on failure; This may not round-trip via
        read_config for non-conformant legacy configurations.
    """
    from deeplabcut.core.config.base_config import DLCBaseConfig
    from deeplabcut.core.config.project_config import ProjectConfig

    try:
        project_config: ProjectConfig = ProjectConfig.from_any(cfg)
        project_config.to_yaml(configname)
        return
    except ValidationError as e:
        logger.error(
            "Invalid configuration! Validation error in project config file %s. Error: %s "
            "Trying to write the file to disk anyway. Please verify the config file.",
            cfg,
            e,
        )
    if isinstance(cfg, DLCBaseConfig):
        logger.error("Expected a ProjectConfig, got %s.", type(cfg).__name__)
        cfg.to_yaml(configname)
        return

    warnings.warn("Reverting to legacy config file writing..", stacklevel=2)
    with open(configname, "w") as cf:
        cfg_file, ruamelFile = create_config_template(cfg.get("multianimalproject", False))
        for key in cfg.keys():
            cfg_file[key] = cfg[key]

        # Adding default value for variable skeleton and skeleton_color for backward compatibility.
        if "skeleton" not in cfg.keys():
            cfg_file["skeleton"] = []
            cfg_file["skeleton_color"] = "black"
        ruamelFile.dump(cfg_file, cf)