i/o scaffold
This commit is contained in:
parent
5b722d3c6a
commit
3d2b2fc68d
|
|
@ -93,18 +93,5 @@ jobs:
|
||||||
- name: Install project + dev dependencies
|
- name: Install project + dev dependencies
|
||||||
run: uv sync --group dev
|
run: uv sync --group dev
|
||||||
|
|
||||||
# Tolerates exit code 5 ("no tests collected") because the tests
|
|
||||||
# directory does not exist yet at commit 3. Commit 4 lands the first
|
|
||||||
# tests and this tolerance block should be removed at that point.
|
|
||||||
- name: Pytest
|
- name: Pytest
|
||||||
shell: bash
|
run: uv run pytest --tb=short
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
uv run pytest --tb=short
|
|
||||||
ec=$?
|
|
||||||
set -e
|
|
||||||
if [ "$ec" -eq 5 ]; then
|
|
||||||
echo "::warning::pytest collected no tests. Remove this tolerance after commit 4 lands real tests."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
exit "$ec"
|
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,17 @@ known-first-party = ["neuropose"]
|
||||||
[tool.ruff.format]
|
[tool.ruff.format]
|
||||||
docstring-code-format = true
|
docstring-code-format = true
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pytest.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
addopts = ["--strict-markers", "--strict-config", "-ra"]
|
||||||
|
markers = [
|
||||||
|
"slow: tests that require model download or are otherwise slow (deselect with '-m \"not slow\"')",
|
||||||
|
]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Pyright. "standard" rather than "strict" because the TensorFlow / OpenCV /
|
# Pyright. "standard" rather than "strict" because the TensorFlow / OpenCV /
|
||||||
# scikit-learn stubs would generate thousands of unknown-type warnings under
|
# scikit-learn stubs would generate thousands of unknown-type warnings under
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""Runtime configuration for NeuroPose.
|
||||||
|
|
||||||
|
Central settings model built on pydantic-settings. Configuration can be
|
||||||
|
supplied by, in order of decreasing precedence:
|
||||||
|
|
||||||
|
1. Keyword arguments passed directly to ``Settings(...)``.
|
||||||
|
2. Environment variables prefixed with ``NEUROPOSE_`` (e.g.
|
||||||
|
``NEUROPOSE_DEVICE="/GPU:0"``).
|
||||||
|
3. A YAML file loaded explicitly via :meth:`Settings.from_yaml`.
|
||||||
|
4. Field defaults.
|
||||||
|
|
||||||
|
There is intentionally no implicit config-file discovery. The daemon must be
|
||||||
|
pointed at a config file explicitly via the CLI ``--config`` flag. This
|
||||||
|
avoids the relative-path footgun from the previous prototype, where the
|
||||||
|
daemon only worked when launched from a specific working directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import Field, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
_DEVICE_PATTERN = re.compile(r"^/(CPU|GPU):\d+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _xdg_data_home() -> Path:
|
||||||
|
"""Return ``$XDG_DATA_HOME``, falling back to ``~/.local/share``."""
|
||||||
|
env = os.environ.get("XDG_DATA_HOME")
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
return Path.home() / ".local" / "share"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_data_dir() -> Path:
|
||||||
|
"""Return the default runtime data directory (under XDG)."""
|
||||||
|
return _xdg_data_home() / "neuropose" / "jobs"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_model_cache_dir() -> Path:
|
||||||
|
"""Return the default MeTRAbs model cache directory (under XDG)."""
|
||||||
|
return _xdg_data_home() / "neuropose" / "models"
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""NeuroPose runtime configuration.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
data_dir
|
||||||
|
Base directory that holds ``in/``, ``out/``, and ``failed/``
|
||||||
|
subdirectories for job processing. Defaults to a location under
|
||||||
|
``$XDG_DATA_HOME`` so runtime data never lives inside the repository.
|
||||||
|
model_cache_dir
|
||||||
|
Directory where the MeTRAbs model is downloaded and cached.
|
||||||
|
poll_interval_seconds
|
||||||
|
Interval between filesystem scans performed by the interfacer daemon.
|
||||||
|
device
|
||||||
|
TensorFlow device string, e.g. ``"/CPU:0"`` or ``"/GPU:0"``.
|
||||||
|
default_fov_degrees
|
||||||
|
Default horizontal field of view passed to MeTRAbs when a video does
|
||||||
|
not supply camera intrinsics. The MeTRAbs upstream default is 55°.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="NEUROPOSE_",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
extra="forbid",
|
||||||
|
)
|
||||||
|
|
||||||
|
data_dir: Path = Field(default_factory=_default_data_dir)
|
||||||
|
model_cache_dir: Path = Field(default_factory=_default_model_cache_dir)
|
||||||
|
poll_interval_seconds: int = Field(default=10, ge=1)
|
||||||
|
device: str = Field(default="/CPU:0")
|
||||||
|
default_fov_degrees: float = Field(default=55.0, gt=0.0, lt=180.0)
|
||||||
|
|
||||||
|
@field_validator("device")
|
||||||
|
@classmethod
|
||||||
|
def _validate_device(cls, value: str) -> str:
|
||||||
|
if not _DEVICE_PATTERN.match(value):
|
||||||
|
raise ValueError(
|
||||||
|
f"device must match '/(CPU|GPU):<index>' "
|
||||||
|
f"(e.g. '/CPU:0', '/GPU:0'); got {value!r}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def input_dir(self) -> Path:
|
||||||
|
"""Return the directory containing job subdirectories to be processed."""
|
||||||
|
return self.data_dir / "in"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def output_dir(self) -> Path:
|
||||||
|
"""Return the directory where completed job results are written."""
|
||||||
|
return self.data_dir / "out"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failed_dir(self) -> Path:
|
||||||
|
"""Return the directory where inputs are quarantined after catastrophic failure."""
|
||||||
|
return self.data_dir / "failed"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_file(self) -> Path:
|
||||||
|
"""Return the path of the persistent job-status JSON file."""
|
||||||
|
return self.output_dir / "status.json"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_yaml(cls, path: Path) -> Settings:
|
||||||
|
"""Load settings from a YAML file.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
path
|
||||||
|
Path to a YAML configuration file. The file must be a mapping of
|
||||||
|
field names to values; unknown fields are rejected.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Settings
|
||||||
|
A validated settings instance.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
FileNotFoundError
|
||||||
|
If ``path`` does not exist.
|
||||||
|
ValueError
|
||||||
|
If the file is not a YAML mapping.
|
||||||
|
pydantic.ValidationError
|
||||||
|
If field validation fails.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"config file not found: {path}")
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
data: Any = yaml.safe_load(f)
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"config file must contain a YAML mapping; got {type(data).__name__}"
|
||||||
|
)
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
|
def ensure_dirs(self) -> None:
|
||||||
|
"""Create all runtime directories if they do not already exist.
|
||||||
|
|
||||||
|
Called by the interfacer daemon on startup. Kept as an explicit method
|
||||||
|
rather than a side effect of construction so that ``Settings()`` is
|
||||||
|
safe to call in tests without touching the filesystem.
|
||||||
|
"""
|
||||||
|
for path in (
|
||||||
|
self.data_dir,
|
||||||
|
self.input_dir,
|
||||||
|
self.output_dir,
|
||||||
|
self.failed_dir,
|
||||||
|
self.model_cache_dir,
|
||||||
|
):
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
"""I/O helpers and schema definitions for NeuroPose prediction data.
|
||||||
|
|
||||||
|
Defines pydantic models for per-frame predictions, per-video aggregated
|
||||||
|
predictions, job-level aggregated results, and the persistent status file.
|
||||||
|
All models are validated on load, so malformed files are caught at the
|
||||||
|
boundary rather than at some downstream call site.
|
||||||
|
|
||||||
|
Atomicity: :func:`save_status` and :func:`save_job_results` write to a sibling
|
||||||
|
temp file and then atomically rename, so a crash mid-write will not leave a
|
||||||
|
partially-written file behind. This matches the crash-resilience guarantee
|
||||||
|
the interfacer daemon makes to callers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, RootModel
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(str, Enum):
|
||||||
|
"""Lifecycle state of a single processing job."""
|
||||||
|
|
||||||
|
PROCESSING = "processing"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class FramePrediction(BaseModel):
|
||||||
|
"""Pose estimation output for a single video frame.
|
||||||
|
|
||||||
|
Each inner list corresponds to one detected person. Coordinate units
|
||||||
|
follow MeTRAbs conventions: ``poses3d`` in millimetres, ``poses2d`` in
|
||||||
|
pixels, ``boxes`` as ``[x, y, width, height, confidence]`` in pixels.
|
||||||
|
|
||||||
|
Frozen (immutable) to prevent the in-place coordinate-swap aliasing bug
|
||||||
|
that affected the previous prototype's visualizer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
boxes: list[list[float]] = Field(
|
||||||
|
description="Per-detection bounding boxes as [x, y, width, height, confidence]."
|
||||||
|
)
|
||||||
|
poses3d: list[list[list[float]]] = Field(
|
||||||
|
description="Per-detection 3D joint positions in millimetres."
|
||||||
|
)
|
||||||
|
poses2d: list[list[list[float]]] = Field(
|
||||||
|
description="Per-detection 2D joint positions in pixels."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoPredictions(RootModel[dict[str, FramePrediction]]):
|
||||||
|
"""Per-frame predictions for a single video, keyed by frame filename.
|
||||||
|
|
||||||
|
Frame names are expected to follow the ``frame_<index>.png`` convention
|
||||||
|
written by the estimator, but no constraint is enforced at the schema
|
||||||
|
level so downstream consumers can key by any naming scheme.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def frames(self) -> list[str]:
|
||||||
|
"""Return the frame names in insertion order."""
|
||||||
|
return list(self.root.keys())
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.root)
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[str]: # type: ignore[override]
|
||||||
|
return iter(self.root)
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> FramePrediction:
|
||||||
|
return self.root[key]
|
||||||
|
|
||||||
|
|
||||||
|
class JobResults(RootModel[dict[str, VideoPredictions]]):
|
||||||
|
"""Aggregated predictions for an entire job, keyed by video filename.
|
||||||
|
|
||||||
|
This is the shape of the top-level ``results.json`` written by the
|
||||||
|
interfacer daemon: one entry per video in the job directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def videos(self) -> list[str]:
|
||||||
|
"""Return the video names in insertion order."""
|
||||||
|
return list(self.root.keys())
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.root)
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[str]: # type: ignore[override]
|
||||||
|
return iter(self.root)
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> VideoPredictions:
|
||||||
|
return self.root[key]
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatusEntry(BaseModel):
|
||||||
|
"""Status entry for a single job in the persistent status file."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
status: JobStatus
|
||||||
|
started_at: datetime
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
results_path: Path | None = None
|
||||||
|
error: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Short human-readable reason if status == failed. "
|
||||||
|
"Populated by the interfacer on failure paths."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StatusFile(RootModel[dict[str, JobStatusEntry]]):
|
||||||
|
"""Mapping of job name to its status entry."""
|
||||||
|
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
"""Return ``True`` if the status file contains no entries."""
|
||||||
|
return len(self.root) == 0
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.root)
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[str]: # type: ignore[override]
|
||||||
|
return iter(self.root)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Load / save helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def load_video_predictions(path: Path) -> VideoPredictions:
|
||||||
|
"""Load and validate a per-video predictions JSON file."""
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
data: Any = json.load(f)
|
||||||
|
return VideoPredictions.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
def save_video_predictions(path: Path, predictions: VideoPredictions) -> None:
|
||||||
|
"""Serialize per-video predictions to a JSON file."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_write_json_atomic(path, predictions.model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
def load_job_results(path: Path) -> JobResults:
|
||||||
|
"""Load and validate an aggregated per-job results JSON file."""
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
data: Any = json.load(f)
|
||||||
|
return JobResults.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
def save_job_results(path: Path, results: JobResults) -> None:
|
||||||
|
"""Serialize aggregated job results to a JSON file atomically."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_write_json_atomic(path, results.model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
def load_status(path: Path) -> StatusFile:
|
||||||
|
"""Load the persistent job status file.
|
||||||
|
|
||||||
|
Returns an empty :class:`StatusFile` if the file is missing, is not valid
|
||||||
|
JSON, or does not contain a JSON mapping. This preserves the
|
||||||
|
crash-resilient behaviour the daemon relies on: a missing or corrupted
|
||||||
|
status file is treated as a clean slate rather than a fatal error.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
return StatusFile(root={})
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
data: Any = json.load(f)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return StatusFile(root={})
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return StatusFile(root={})
|
||||||
|
return StatusFile.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
def save_status(path: Path, status: StatusFile) -> None:
|
||||||
|
"""Persist the job status file atomically."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_write_json_atomic(path, status.model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json_atomic(path: Path, payload: Any) -> None:
|
||||||
|
"""Write ``payload`` to ``path`` as JSON, atomically.
|
||||||
|
|
||||||
|
Writes to a sibling ``<path>.tmp`` first, then atomically renames over
|
||||||
|
``path`` so a crash mid-write cannot leave behind a truncated file.
|
||||||
|
"""
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
with tmp.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(payload, f, indent=2)
|
||||||
|
tmp.replace(path)
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Shared pytest configuration and fixtures for the NeuroPose test suite."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_environment(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path_factory: pytest.TempPathFactory,
|
||||||
|
) -> Iterator[None]:
|
||||||
|
"""Isolate every test from the developer's real home directory.
|
||||||
|
|
||||||
|
Points ``$HOME`` and ``$XDG_DATA_HOME`` at per-test temp directories so
|
||||||
|
that any code path that uses the default ``Settings()`` (which reaches
|
||||||
|
into ``~/.local/share/neuropose``) cannot accidentally write to the real
|
||||||
|
machine. Also clears any ``NEUROPOSE_*`` environment variables that may
|
||||||
|
be set in the developer's shell, so test behaviour does not depend on
|
||||||
|
who is running the test suite.
|
||||||
|
"""
|
||||||
|
isolated = tmp_path_factory.mktemp("neuropose_env_isolation")
|
||||||
|
monkeypatch.setenv("HOME", str(isolated))
|
||||||
|
monkeypatch.setenv("XDG_DATA_HOME", str(isolated / "xdg"))
|
||||||
|
for key in list(os.environ):
|
||||||
|
if key.startswith("NEUROPOSE_"):
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def xdg_home() -> Path:
|
||||||
|
"""Return the isolated ``$XDG_DATA_HOME`` set up by ``_isolate_environment``."""
|
||||||
|
return Path(os.environ["XDG_DATA_HOME"])
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""Tests for :mod:`neuropose.config`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from neuropose.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaults:
|
||||||
|
"""Default values wire through XDG correctly."""
|
||||||
|
|
||||||
|
def test_data_dir_uses_xdg_data_home(self, xdg_home: Path) -> None:
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.data_dir == xdg_home / "neuropose" / "jobs"
|
||||||
|
|
||||||
|
def test_model_cache_dir_uses_xdg_data_home(self, xdg_home: Path) -> None:
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.model_cache_dir == xdg_home / "neuropose" / "models"
|
||||||
|
|
||||||
|
def test_derived_directories(self, xdg_home: Path) -> None:
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.input_dir == settings.data_dir / "in"
|
||||||
|
assert settings.output_dir == settings.data_dir / "out"
|
||||||
|
assert settings.failed_dir == settings.data_dir / "failed"
|
||||||
|
assert settings.status_file == settings.output_dir / "status.json"
|
||||||
|
|
||||||
|
def test_fallback_when_xdg_unset(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.data_dir == tmp_path / ".local" / "share" / "neuropose" / "jobs"
|
||||||
|
|
||||||
|
def test_default_scalars(self, xdg_home: Path) -> None:
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.poll_interval_seconds == 10
|
||||||
|
assert settings.device == "/CPU:0"
|
||||||
|
assert settings.default_fov_degrees == pytest.approx(55.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidation:
|
||||||
|
"""Field validators reject malformed input."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("device", ["/CPU:0", "/GPU:0", "/CPU:1", "/GPU:3"])
|
||||||
|
def test_device_accepts_valid(self, device: str, xdg_home: Path) -> None:
|
||||||
|
settings = Settings(device=device)
|
||||||
|
assert settings.device == device
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("device", ["cpu", "/cpu:0", "GPU:0", "/TPU:0", "", "/GPU"])
|
||||||
|
def test_device_rejects_invalid(self, device: str, xdg_home: Path) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Settings(device=device)
|
||||||
|
|
||||||
|
def test_poll_interval_rejects_zero(self, xdg_home: Path) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Settings(poll_interval_seconds=0)
|
||||||
|
|
||||||
|
def test_poll_interval_rejects_negative(self, xdg_home: Path) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Settings(poll_interval_seconds=-5)
|
||||||
|
|
||||||
|
def test_fov_rejects_zero(self, xdg_home: Path) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Settings(default_fov_degrees=0)
|
||||||
|
|
||||||
|
def test_fov_rejects_at_limit(self, xdg_home: Path) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Settings(default_fov_degrees=180)
|
||||||
|
|
||||||
|
def test_extra_fields_rejected(self, xdg_home: Path) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Settings(nonexistent_field=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestYamlLoad:
|
||||||
|
"""``Settings.from_yaml`` behaves correctly for valid and malformed files."""
|
||||||
|
|
||||||
|
def test_valid(self, tmp_path: Path, xdg_home: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
config_path.write_text(
|
||||||
|
yaml.safe_dump({"device": "/GPU:0", "poll_interval_seconds": 30})
|
||||||
|
)
|
||||||
|
settings = Settings.from_yaml(config_path)
|
||||||
|
assert settings.device == "/GPU:0"
|
||||||
|
assert settings.poll_interval_seconds == 30
|
||||||
|
|
||||||
|
def test_missing_file_raises(self, tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
Settings.from_yaml(tmp_path / "nope.yaml")
|
||||||
|
|
||||||
|
def test_empty_file_uses_defaults(self, tmp_path: Path, xdg_home: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
config_path.write_text("")
|
||||||
|
settings = Settings.from_yaml(config_path)
|
||||||
|
assert settings.poll_interval_seconds == 10
|
||||||
|
|
||||||
|
def test_non_mapping_rejected(self, tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
config_path.write_text("- item1\n- item2\n")
|
||||||
|
with pytest.raises(ValueError, match="YAML mapping"):
|
||||||
|
Settings.from_yaml(config_path)
|
||||||
|
|
||||||
|
def test_invalid_field_rejected(self, tmp_path: Path, xdg_home: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
config_path.write_text(yaml.safe_dump({"device": "cpu"}))
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Settings.from_yaml(config_path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnvironmentOverrides:
|
||||||
|
"""``NEUROPOSE_*`` env vars override field defaults."""
|
||||||
|
|
||||||
|
def test_device_override(
|
||||||
|
self,
|
||||||
|
xdg_home: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("NEUROPOSE_DEVICE", "/GPU:0")
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.device == "/GPU:0"
|
||||||
|
|
||||||
|
def test_poll_interval_override(
|
||||||
|
self,
|
||||||
|
xdg_home: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("NEUROPOSE_POLL_INTERVAL_SECONDS", "60")
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.poll_interval_seconds == 60
|
||||||
|
|
||||||
|
def test_kwargs_beat_env_vars(
|
||||||
|
self,
|
||||||
|
xdg_home: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("NEUROPOSE_DEVICE", "/GPU:0")
|
||||||
|
settings = Settings(device="/CPU:0")
|
||||||
|
assert settings.device == "/CPU:0"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureDirs:
|
||||||
|
"""``ensure_dirs`` creates all directories idempotently."""
|
||||||
|
|
||||||
|
def test_creates_all_directories(self, tmp_path: Path) -> None:
|
||||||
|
settings = Settings(
|
||||||
|
data_dir=tmp_path / "jobs",
|
||||||
|
model_cache_dir=tmp_path / "models",
|
||||||
|
)
|
||||||
|
settings.ensure_dirs()
|
||||||
|
assert settings.data_dir.is_dir()
|
||||||
|
assert settings.input_dir.is_dir()
|
||||||
|
assert settings.output_dir.is_dir()
|
||||||
|
assert settings.failed_dir.is_dir()
|
||||||
|
assert settings.model_cache_dir.is_dir()
|
||||||
|
|
||||||
|
def test_idempotent(self, tmp_path: Path) -> None:
|
||||||
|
settings = Settings(
|
||||||
|
data_dir=tmp_path / "jobs",
|
||||||
|
model_cache_dir=tmp_path / "models",
|
||||||
|
)
|
||||||
|
settings.ensure_dirs()
|
||||||
|
settings.ensure_dirs()
|
||||||
|
assert settings.data_dir.is_dir()
|
||||||
|
|
||||||
|
def test_construction_has_no_filesystem_side_effects(self, tmp_path: Path) -> None:
|
||||||
|
# Creating Settings() must NOT touch the filesystem.
|
||||||
|
target = tmp_path / "jobs"
|
||||||
|
assert not target.exists()
|
||||||
|
_ = Settings(data_dir=target, model_cache_dir=tmp_path / "models")
|
||||||
|
assert not target.exists()
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
"""Tests for :mod:`neuropose.io` schema and helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from neuropose.io import (
|
||||||
|
FramePrediction,
|
||||||
|
JobResults,
|
||||||
|
JobStatus,
|
||||||
|
StatusFile,
|
||||||
|
VideoPredictions,
|
||||||
|
load_job_results,
|
||||||
|
load_status,
|
||||||
|
load_video_predictions,
|
||||||
|
save_job_results,
|
||||||
|
save_status,
|
||||||
|
save_video_predictions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def one_frame() -> dict:
|
||||||
|
"""A minimal valid FramePrediction payload (one person, two joints)."""
|
||||||
|
return {
|
||||||
|
"boxes": [[10.0, 20.0, 100.0, 200.0, 0.95]],
|
||||||
|
"poses3d": [[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]],
|
||||||
|
"poses2d": [[[10.0, 20.0], [30.0, 40.0]]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def video_payload(one_frame: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"frame_0000.png": one_frame,
|
||||||
|
"frame_0001.png": one_frame,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FramePrediction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFramePrediction:
|
||||||
|
def test_roundtrip(self, one_frame: dict) -> None:
|
||||||
|
frame = FramePrediction.model_validate(one_frame)
|
||||||
|
assert frame.boxes == one_frame["boxes"]
|
||||||
|
assert frame.poses3d == one_frame["poses3d"]
|
||||||
|
assert frame.poses2d == one_frame["poses2d"]
|
||||||
|
|
||||||
|
def test_rejects_extra_fields(self, one_frame: dict) -> None:
|
||||||
|
one_frame["extra"] = "bogus"
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
FramePrediction.model_validate(one_frame)
|
||||||
|
|
||||||
|
def test_is_frozen(self, one_frame: dict) -> None:
|
||||||
|
frame = FramePrediction.model_validate(one_frame)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
frame.boxes = []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VideoPredictions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestVideoPredictions:
|
||||||
|
def test_from_dict(self, video_payload: dict) -> None:
|
||||||
|
vp = VideoPredictions.model_validate(video_payload)
|
||||||
|
assert len(vp) == 2
|
||||||
|
assert vp.frames() == ["frame_0000.png", "frame_0001.png"]
|
||||||
|
assert vp["frame_0000.png"].boxes[0][4] == pytest.approx(0.95)
|
||||||
|
|
||||||
|
def test_iteration(self, video_payload: dict) -> None:
|
||||||
|
vp = VideoPredictions.model_validate(video_payload)
|
||||||
|
assert list(vp) == ["frame_0000.png", "frame_0001.png"]
|
||||||
|
|
||||||
|
def test_save_and_load_roundtrip(self, tmp_path: Path, video_payload: dict) -> None:
|
||||||
|
vp = VideoPredictions.model_validate(video_payload)
|
||||||
|
path = tmp_path / "preds" / "video.json"
|
||||||
|
save_video_predictions(path, vp)
|
||||||
|
assert path.exists()
|
||||||
|
loaded = load_video_predictions(path)
|
||||||
|
assert loaded.frames() == vp.frames()
|
||||||
|
assert loaded["frame_0000.png"].poses3d == vp["frame_0000.png"].poses3d
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JobResults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobResults:
|
||||||
|
def test_save_and_load_roundtrip(self, tmp_path: Path, video_payload: dict) -> None:
|
||||||
|
jr = JobResults.model_validate(
|
||||||
|
{"video_a.mp4": video_payload, "video_b.mp4": video_payload}
|
||||||
|
)
|
||||||
|
path = tmp_path / "results.json"
|
||||||
|
save_job_results(path, jr)
|
||||||
|
loaded = load_job_results(path)
|
||||||
|
assert loaded.videos() == ["video_a.mp4", "video_b.mp4"]
|
||||||
|
assert len(loaded["video_a.mp4"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusFile:
|
||||||
|
def test_load_missing_returns_empty(self, tmp_path: Path) -> None:
|
||||||
|
status = load_status(tmp_path / "nope.json")
|
||||||
|
assert status.is_empty()
|
||||||
|
|
||||||
|
def test_load_corrupt_json_returns_empty(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "bad.json"
|
||||||
|
path.write_text("{ not valid json")
|
||||||
|
status = load_status(path)
|
||||||
|
assert status.is_empty()
|
||||||
|
|
||||||
|
def test_load_non_mapping_returns_empty(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "list.json"
|
||||||
|
path.write_text(json.dumps([1, 2, 3]))
|
||||||
|
status = load_status(path)
|
||||||
|
assert status.is_empty()
|
||||||
|
|
||||||
|
def test_save_and_load_completed_entry(self, tmp_path: Path) -> None:
|
||||||
|
started = datetime(2026, 4, 13, 12, 0, 0, tzinfo=UTC)
|
||||||
|
completed = datetime(2026, 4, 13, 12, 5, 0, tzinfo=UTC)
|
||||||
|
status = StatusFile.model_validate(
|
||||||
|
{
|
||||||
|
"job_001": {
|
||||||
|
"status": "completed",
|
||||||
|
"started_at": started.isoformat(),
|
||||||
|
"completed_at": completed.isoformat(),
|
||||||
|
"results_path": "/tmp/results.json",
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
path = tmp_path / "status.json"
|
||||||
|
save_status(path, status)
|
||||||
|
loaded = load_status(path)
|
||||||
|
entry = loaded.root["job_001"]
|
||||||
|
assert entry.status == JobStatus.COMPLETED
|
||||||
|
assert entry.started_at == started
|
||||||
|
assert entry.completed_at == completed
|
||||||
|
assert entry.error is None
|
||||||
|
|
||||||
|
def test_save_is_atomic(self, tmp_path: Path) -> None:
|
||||||
|
"""``save_status`` leaves no orphan ``.tmp`` file on success."""
|
||||||
|
started = datetime(2026, 4, 13, tzinfo=UTC)
|
||||||
|
status = StatusFile.model_validate(
|
||||||
|
{
|
||||||
|
"job_001": {
|
||||||
|
"status": "processing",
|
||||||
|
"started_at": started.isoformat(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
path = tmp_path / "status.json"
|
||||||
|
save_status(path, status)
|
||||||
|
assert path.exists()
|
||||||
|
tmps = list(tmp_path.glob("status.json.tmp"))
|
||||||
|
assert tmps == []
|
||||||
|
|
||||||
|
def test_failed_entry_carries_error_message(self, tmp_path: Path) -> None:
|
||||||
|
started = datetime(2026, 4, 13, tzinfo=UTC)
|
||||||
|
status = StatusFile.model_validate(
|
||||||
|
{
|
||||||
|
"job_001": {
|
||||||
|
"status": "failed",
|
||||||
|
"started_at": started.isoformat(),
|
||||||
|
"error": "ffmpeg decode failed: codec not supported",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
path = tmp_path / "status.json"
|
||||||
|
save_status(path, status)
|
||||||
|
loaded = load_status(path)
|
||||||
|
entry = loaded.root["job_001"]
|
||||||
|
assert entry.status == JobStatus.FAILED
|
||||||
|
assert entry.error is not None
|
||||||
|
assert "ffmpeg" in entry.error
|
||||||
|
|
||||||
|
def test_rejects_unknown_status(self, tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
StatusFile.model_validate(
|
||||||
|
{
|
||||||
|
"job_001": {
|
||||||
|
"status": "some-unknown-state",
|
||||||
|
"started_at": datetime(2026, 4, 13, tzinfo=UTC).isoformat(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue