wire neuropose analyze to run_analysis

Replaces the placeholder stub that returned EXIT_PENDING with a real
analyze --config <yaml> [--output <json>] command. Loads the YAML,
validates through AnalysisConfig (so typos fail with a clear
ValidationError before any predictions load), runs the pipeline,
and writes the AnalysisReport atomically.

Surfaces YAML parse errors and schema violations as EXIT_USAGE=2
with messages pointing at the offending file. Missing predictions
files during execution also surface as EXIT_USAGE rather than a
bare traceback.

Prints a one-line summary after the run: segmentation counts, the
analysis kind, and — for DTW — the per-segment distance count and
mean. --output / -o overrides the report path declared in the
config, useful when sweeping a single config over multiple input
pairs from a shell loop.
This commit is contained in:
Levi Neuwirth 2026-04-22 11:40:24 -04:00
parent dc48988450
commit 01b374451f
3 changed files with 232 additions and 25 deletions

View File

@ -292,8 +292,16 @@ be split into per-release sections once tagging begins.
`"left_heel_strikes[i]"` / `"right_heel_strikes[i]"` labels. `"left_heel_strikes[i]"` / `"right_heel_strikes[i]"` labels.
`load_config(path)` parses YAML, `save_report(path, report)` `load_config(path)` parses YAML, `save_report(path, report)`
writes atomically, and `load_report(path)` rehydrates via the writes atomically, and `load_report(path)` rehydrates via the
migration chain. CLI wiring and example configs land in migration chain. Wired to the CLI as `neuropose analyze --config
follow-up commits. <yaml> [--output <json>]` — replaces the placeholder stub that
previously returned `EXIT_PENDING`. The CLI surfaces schema
violations and YAML parse errors as `EXIT_USAGE=2` with a clear
message pointing at the offending file, prints a one-line summary
of the run (segmentation counts, analysis kind, per-segment
distance count + mean for DTW), and supports `--output`/`-o` to
override the report path declared in the config (useful for
sweeping a single config over multiple input pairs from a shell
loop). Example configs land in a follow-up commit.
- **`neuropose.analyzer.segment.segment_gait_cycles`** and - **`neuropose.analyzer.segment.segment_gait_cycles`** and
**`segment_gait_cycles_bilateral`** — clinical convenience **`segment_gait_cycles_bilateral`** — clinical convenience
wrappers over `segment_predictions` that pre-fill a `joint_axis` wrappers over `segment_predictions` that pre-fill a `joint_axis`
@ -435,7 +443,9 @@ be split into per-release sections once tagging begins.
the resulting `poses3d` arrays, and reports throughput speedup the resulting `poses3d` arrays, and reports throughput speedup
and max divergence in mm — the missing Apple Silicon numerical and max divergence in mm — the missing Apple Silicon numerical
verification answer from `RESEARCH.md`), and verification answer from `RESEARCH.md`), and
`analyze <results>` (stub). The `segment` subcommand accepts `analyze --config <yaml>` (run the declarative analysis
pipeline — see the dedicated entry above for scope). The
`segment` subcommand accepts
joint specifiers as either berkeley_mhad_43 names (`lwri`, joint specifiers as either berkeley_mhad_43 names (`lwri`,
`rwri`, …) or integer indices, and refuses to overwrite an `rwri`, …) or integer indices, and refuses to overwrite an
existing segmentation of the same name without `--force`. existing segmentation of the same name without `--force`.

View File

@ -25,8 +25,11 @@ Eight subcommands:
vs CPU numerical-divergence checks. Prints a human report to stdout vs CPU numerical-divergence checks. Prints a human report to stdout
and (optionally) writes a structured :class:`~neuropose.io.BenchmarkResult` and (optionally) writes a structured :class:`~neuropose.io.BenchmarkResult`
JSON to ``--output``. JSON to ``--output``.
- ``neuropose analyze <results>`` stubbed placeholder pending the - ``neuropose analyze --config <yaml>`` run the declarative analysis
analyzer rewrite in commit 10. pipeline described in a YAML config. Loads the named predictions
files, applies segmentation + analysis, writes an
:class:`~neuropose.analyzer.pipeline.AnalysisReport` JSON. See
``examples/analysis/*.yaml`` for runnable references.
User-facing error handling User-facing error handling
-------------------------- --------------------------
@ -57,6 +60,7 @@ from pathlib import Path
from typing import Annotated from typing import Annotated
import typer import typer
import yaml
from pydantic import ValidationError from pydantic import ValidationError
from neuropose import __version__ from neuropose import __version__
@ -1196,26 +1200,94 @@ def benchmark(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# analyze (stub) # analyze
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.command() @app.command()
def analyze( def analyze(
ctx: typer.Context, ctx: typer.Context,
results: Annotated[ config: Annotated[
Path, Path,
typer.Argument(help="Path to a results.json produced by watch or process."), typer.Option(
"--config",
"-c",
help=(
"Path to a YAML AnalysisConfig file. See examples/analysis/ "
"for runnable references."
),
),
], ],
output: Annotated[
Path | None,
typer.Option(
"--output",
"-o",
help=(
"Override the report path declared in the config's "
"output.report field. Useful when running the same config "
"against multiple input pairs from a shell loop."
),
),
] = None,
) -> None: ) -> None:
"""Run the analyzer subpackage against a results.json (pending commit 10).""" """Run the declarative analysis pipeline described by a YAML config.
del ctx, results
typer.echo( Loads the config, parses it through
"error: the analyzer subpackage is pending commit 10. " :class:`~neuropose.analyzer.pipeline.AnalysisConfig` (so typos fail
"Until it lands, use neuropose.io to load results.json from Python.", immediately with a clear error), executes the pipeline via
err=True, :func:`~neuropose.analyzer.pipeline.run_analysis`, and writes the
) resulting :class:`~neuropose.analyzer.pipeline.AnalysisReport` to
raise typer.Exit(code=EXIT_PENDING) ``--output`` (or to ``output.report`` declared in the config).
Cross-field invariants (for example,
``method='dtw_relation'`` requires ``joint_i`` / ``joint_j``) are
enforced at parse time, so a typo fails before any predictions
are loaded.
"""
del ctx
# Deferred import keeps the CLI module's top-level imports free of
# pipeline dependencies so ``watch`` / ``process`` startup stays
# cheap.
from neuropose.analyzer.pipeline import load_config, run_analysis, save_report
if not config.exists():
typer.echo(f"error: config file not found: {config}", err=True)
raise typer.Exit(code=EXIT_USAGE)
try:
analysis_config = load_config(config)
except ValidationError as exc:
typer.echo(f"error: invalid config {config}:\n{exc}", err=True)
raise typer.Exit(code=EXIT_USAGE) from exc
except yaml.YAMLError as exc:
typer.echo(f"error: could not parse YAML {config}: {exc}", err=True)
raise typer.Exit(code=EXIT_USAGE) from exc
report_path = output if output is not None else analysis_config.output.report
try:
report = run_analysis(analysis_config)
except (FileNotFoundError, ValueError) as exc:
typer.echo(f"error: analysis failed: {exc}", err=True)
raise typer.Exit(code=EXIT_USAGE) from exc
save_report(report_path, report)
typer.echo(f"wrote analysis report to {report_path}")
if report.segmentations:
seg_summary = ", ".join(
f"{name}={len(seg.segments)}" for name, seg in report.segmentations.items()
)
typer.echo(f"segmentations: {seg_summary}")
# Emit a one-line summary of the results regardless of kind.
typer.echo(f"analysis kind: {report.results.kind}")
if report.results.kind == "dtw":
n = len(report.results.distances)
mean = report.results.summary.get("mean", float("nan"))
typer.echo(f"distances computed: {n} (mean={mean:.4f})")
elif report.results.kind == "stats":
typer.echo(f"statistic blocks computed: {len(report.results.statistics)}")
def run() -> None: def run() -> None:

View File

@ -776,17 +776,142 @@ class TestBenchmarkSubcommand:
class TestAnalyze: class TestAnalyze:
def test_analyze_stub_exits_with_pending_message( """Covers the ``neuropose analyze --config <yaml>`` subcommand.
Execution happy path is exercised in detail in
:mod:`tests.unit.test_analyzer_pipeline` this file focuses on
the CLI wiring: argument parsing, config-loading error modes, and
end-to-end smoke.
"""
def _make_predictions_file(self, tmp_path: Path, name: str, num_frames: int = 30) -> Path:
"""Write a trivial VideoPredictions file to disk for the CLI to load."""
import math
from neuropose.io import VideoPredictions, save_video_predictions
num_joints = 43
frames = {}
for i in range(num_frames):
poses = [[[0.0, 0.0, 0.0] for _ in range(num_joints)]]
poses[0][41][1] = float(math.sin(i * 0.3)) * 100.0 # rhee Y
frames[f"frame_{i:06d}"] = {
"boxes": [[0.0, 0.0, 1.0, 1.0, 0.9]],
"poses3d": poses,
"poses2d": [[[0.0, 0.0]] * num_joints],
}
preds = VideoPredictions.model_validate(
{
"metadata": {
"frame_count": num_frames,
"fps": 30.0,
"width": 640,
"height": 480,
},
"frames": frames,
}
)
path = tmp_path / name
save_video_predictions(path, preds)
return path
def _write_dtw_config(
self,
tmp_path: Path,
*,
primary: Path,
reference: Path,
report: Path,
) -> Path:
import yaml as _yaml
config_path = tmp_path / "config.yaml"
config_path.write_text(
_yaml.safe_dump(
{
"inputs": {"primary": str(primary), "reference": str(reference)},
"analysis": {"kind": "dtw", "method": "dtw_all"},
"output": {"report": str(report)},
}
)
)
return config_path
def test_missing_config_is_usage_error(self, runner: CliRunner, tmp_path: Path) -> None:
result = runner.invoke(app, ["analyze", "--config", str(tmp_path / "nope.yaml")])
assert result.exit_code == EXIT_USAGE
assert "config file not found" in result.output
def test_missing_config_flag_is_usage_error(self, runner: CliRunner) -> None:
result = runner.invoke(app, ["analyze"])
assert result.exit_code == EXIT_USAGE
def test_invalid_yaml_is_usage_error(self, runner: CliRunner, tmp_path: Path) -> None:
bad = tmp_path / "bad.yaml"
bad.write_text("inputs: {primary: foo\n") # unclosed flow mapping
result = runner.invoke(app, ["analyze", "--config", str(bad)])
assert result.exit_code == EXIT_USAGE
assert "could not parse YAML" in result.output
def test_schema_violation_is_usage_error(self, runner: CliRunner, tmp_path: Path) -> None:
import yaml as _yaml
bad = tmp_path / "schema.yaml"
bad.write_text(
_yaml.safe_dump(
{
"inputs": {"primary": str(tmp_path / "a.json")},
# dtw without reference — violates cross-field invariant.
"analysis": {"kind": "dtw", "method": "dtw_all"},
"output": {"report": str(tmp_path / "r.json")},
}
)
)
result = runner.invoke(app, ["analyze", "--config", str(bad)])
assert result.exit_code == EXIT_USAGE
assert "invalid config" in result.output
def test_happy_path_writes_report(self, runner: CliRunner, tmp_path: Path) -> None:
primary = self._make_predictions_file(tmp_path, "a.json")
reference = self._make_predictions_file(tmp_path, "b.json")
report_path = tmp_path / "report.json"
config = self._write_dtw_config(
tmp_path, primary=primary, reference=reference, report=report_path
)
result = runner.invoke(app, ["analyze", "--config", str(config)])
assert result.exit_code == EXIT_OK, result.output
assert report_path.exists()
assert "wrote analysis report" in result.output
assert "analysis kind: dtw" in result.output
def test_output_option_overrides_config_path(self, runner: CliRunner, tmp_path: Path) -> None:
primary = self._make_predictions_file(tmp_path, "a.json")
reference = self._make_predictions_file(tmp_path, "b.json")
# Config points at one report path ...
config = self._write_dtw_config(
tmp_path,
primary=primary,
reference=reference,
report=tmp_path / "declared.json",
)
# ... but --output overrides.
override = tmp_path / "override.json"
result = runner.invoke(app, ["analyze", "--config", str(config), "--output", str(override)])
assert result.exit_code == EXIT_OK, result.output
assert override.exists()
assert not (tmp_path / "declared.json").exists()
def test_missing_predictions_file_is_usage_error(
self, runner: CliRunner, tmp_path: Path self, runner: CliRunner, tmp_path: Path
) -> None: ) -> None:
results_path = tmp_path / "results.json" # Config points at a primary that does not exist.
results_path.write_text("{}") config = self._write_dtw_config(
result = runner.invoke(app, ["analyze", str(results_path)]) tmp_path,
assert result.exit_code == EXIT_PENDING primary=tmp_path / "missing_primary.json",
assert "commit 10" in result.output reference=tmp_path / "missing_reference.json",
report=tmp_path / "report.json",
def test_analyze_requires_an_argument(self, runner: CliRunner) -> None: )
result = runner.invoke(app, ["analyze"]) result = runner.invoke(app, ["analyze", "--config", str(config)])
assert result.exit_code == EXIT_USAGE assert result.exit_code == EXIT_USAGE