feat(demo): add export and silhouette visualization outputs
Add preprocess-only silhouette export and configurable result exporters so demo runs can be persisted for offline analysis and reproducible evaluation. Include optional parquet support and CLI visualization dumps while updating tests and tracking notes for the verified pipeline/debug workflow.
This commit is contained in:
+14
-15
@@ -91,7 +91,7 @@ def _start_nats_container(port: int) -> bool:
|
||||
"--name",
|
||||
CONTAINER_NAME,
|
||||
"-p",
|
||||
f"{port}:{port}",
|
||||
f"{port}:4222",
|
||||
"nats:latest",
|
||||
],
|
||||
capture_output=True,
|
||||
@@ -152,7 +152,7 @@ def _validate_result_schema(data: dict[str, object]) -> tuple[bool, str]:
|
||||
"track_id": int,
|
||||
"label": str (one of: "negative", "neutral", "positive"),
|
||||
"confidence": float in [0, 1],
|
||||
"window": list[int] (start, end),
|
||||
"window": int (non-negative),
|
||||
"timestamp_ns": int
|
||||
}
|
||||
"""
|
||||
@@ -190,14 +190,13 @@ def _validate_result_schema(data: dict[str, object]) -> tuple[bool, str]:
|
||||
return False, f"confidence must be numeric, got {type(confidence)}"
|
||||
if not 0.0 <= float(confidence) <= 1.0:
|
||||
return False, f"confidence must be in [0, 1], got {confidence}"
|
||||
|
||||
# Validate window (list of 2 ints)
|
||||
# Validate window (int, non-negative)
|
||||
window = data["window"]
|
||||
if not isinstance(window, list) or len(cast(list[object], window)) != 2:
|
||||
return False, f"window must be list of 2 ints, got {window}"
|
||||
window_list = cast(list[object], window)
|
||||
if not all(isinstance(x, int) for x in window_list):
|
||||
return False, f"window elements must be ints, got {window}"
|
||||
if not isinstance(window, int):
|
||||
return False, f"window must be int, got {type(window)}"
|
||||
if window < 0:
|
||||
return False, f"window must be non-negative, got {window}"
|
||||
|
||||
|
||||
# Validate timestamp_ns (int)
|
||||
timestamp_ns = data["timestamp_ns"]
|
||||
@@ -356,7 +355,7 @@ class TestNatsPublisherIntegration:
|
||||
"track_id": 1,
|
||||
"label": "positive",
|
||||
"confidence": 0.85,
|
||||
"window": [0, 30],
|
||||
"window": 30,
|
||||
"timestamp_ns": 1234567890,
|
||||
}
|
||||
|
||||
@@ -431,7 +430,7 @@ class TestNatsSchemaValidation:
|
||||
"track_id": 42,
|
||||
"label": "positive",
|
||||
"confidence": 0.85,
|
||||
"window": [1200, 1230],
|
||||
"window": 1230,
|
||||
"timestamp_ns": 1234567890000,
|
||||
}
|
||||
|
||||
@@ -445,7 +444,7 @@ class TestNatsSchemaValidation:
|
||||
"track_id": 42,
|
||||
"label": "invalid_label",
|
||||
"confidence": 0.85,
|
||||
"window": [1200, 1230],
|
||||
"window": 1230,
|
||||
"timestamp_ns": 1234567890000,
|
||||
}
|
||||
|
||||
@@ -460,7 +459,7 @@ class TestNatsSchemaValidation:
|
||||
"track_id": 42,
|
||||
"label": "positive",
|
||||
"confidence": 1.5,
|
||||
"window": [1200, 1230],
|
||||
"window": 1230,
|
||||
"timestamp_ns": 1234567890000,
|
||||
}
|
||||
|
||||
@@ -486,7 +485,7 @@ class TestNatsSchemaValidation:
|
||||
"track_id": 42,
|
||||
"label": "positive",
|
||||
"confidence": 0.85,
|
||||
"window": [1200, 1230],
|
||||
"window": 1230,
|
||||
"timestamp_ns": 1234567890000,
|
||||
}
|
||||
|
||||
@@ -502,7 +501,7 @@ class TestNatsSchemaValidation:
|
||||
"track_id": 1,
|
||||
"label": label_str,
|
||||
"confidence": 0.5,
|
||||
"window": [70, 100],
|
||||
"window": 100,
|
||||
"timestamp_ns": 1234567890,
|
||||
}
|
||||
is_valid, error = _validate_result_schema(data)
|
||||
|
||||
+410
-1
@@ -1,6 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import pickle
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Final, cast
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from opengait.demo.sconet_demo import ScoNetDemo
|
||||
|
||||
import json
|
||||
import pickle
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -105,7 +120,6 @@ def _assert_prediction_schema(prediction: dict[str, object]) -> None:
|
||||
|
||||
assert isinstance(prediction["timestamp_ns"], int)
|
||||
|
||||
|
||||
def test_pipeline_cli_fps_benchmark_smoke(
|
||||
compatible_checkpoint_path: Path,
|
||||
) -> None:
|
||||
@@ -277,3 +291,398 @@ def test_pipeline_cli_invalid_checkpoint_path_returns_user_error() -> None:
|
||||
|
||||
assert result.returncode == 2
|
||||
assert "Error: Checkpoint not found" in result.stderr
|
||||
|
||||
|
||||
|
||||
def test_pipeline_cli_preprocess_only_requires_export_path(
|
||||
compatible_checkpoint_path: Path,
|
||||
) -> None:
|
||||
"""Test that --preprocess-only requires --silhouette-export-path."""
|
||||
_require_integration_assets()
|
||||
|
||||
result = _run_pipeline_cli(
|
||||
"--source",
|
||||
str(SAMPLE_VIDEO_PATH),
|
||||
"--checkpoint",
|
||||
str(compatible_checkpoint_path),
|
||||
"--config",
|
||||
str(CONFIG_PATH),
|
||||
"--device",
|
||||
_device_for_runtime(),
|
||||
"--yolo-model",
|
||||
str(YOLO_MODEL_PATH),
|
||||
"--preprocess-only",
|
||||
"--max-frames",
|
||||
"10",
|
||||
timeout_seconds=30,
|
||||
)
|
||||
|
||||
assert result.returncode == 2
|
||||
assert "--silhouette-export-path is required" in result.stderr
|
||||
|
||||
|
||||
def test_pipeline_cli_preprocess_only_exports_pickle(
|
||||
compatible_checkpoint_path: Path,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test preprocess-only mode exports silhouettes to pickle."""
|
||||
_require_integration_assets()
|
||||
|
||||
export_path = tmp_path / "silhouettes.pkl"
|
||||
|
||||
result = _run_pipeline_cli(
|
||||
"--source",
|
||||
str(SAMPLE_VIDEO_PATH),
|
||||
"--checkpoint",
|
||||
str(compatible_checkpoint_path),
|
||||
"--config",
|
||||
str(CONFIG_PATH),
|
||||
"--device",
|
||||
_device_for_runtime(),
|
||||
"--yolo-model",
|
||||
str(YOLO_MODEL_PATH),
|
||||
"--preprocess-only",
|
||||
"--silhouette-export-path",
|
||||
str(export_path),
|
||||
"--silhouette-export-format",
|
||||
"pickle",
|
||||
"--max-frames",
|
||||
"30",
|
||||
timeout_seconds=180,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, (
|
||||
f"Expected exit code 0, got {result.returncode}. stderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
# Verify export file exists and contains silhouettes
|
||||
assert export_path.is_file(), f"Export file not found: {export_path}"
|
||||
|
||||
with open(export_path, "rb") as f:
|
||||
silhouettes = pickle.load(f)
|
||||
|
||||
assert isinstance(silhouettes, list)
|
||||
assert len(silhouettes) > 0, "Expected at least one silhouette"
|
||||
|
||||
# Verify silhouette schema
|
||||
for item in silhouettes:
|
||||
assert isinstance(item, dict)
|
||||
assert "frame" in item
|
||||
assert "track_id" in item
|
||||
assert "timestamp_ns" in item
|
||||
assert "silhouette" in item
|
||||
assert isinstance(item["frame"], int)
|
||||
assert isinstance(item["track_id"], int)
|
||||
assert isinstance(item["timestamp_ns"], int)
|
||||
|
||||
|
||||
def test_pipeline_cli_result_export_json(
|
||||
compatible_checkpoint_path: Path,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that results can be exported to JSON file."""
|
||||
_require_integration_assets()
|
||||
|
||||
export_path = tmp_path / "results.jsonl"
|
||||
|
||||
result = _run_pipeline_cli(
|
||||
"--source",
|
||||
str(SAMPLE_VIDEO_PATH),
|
||||
"--checkpoint",
|
||||
str(compatible_checkpoint_path),
|
||||
"--config",
|
||||
str(CONFIG_PATH),
|
||||
"--device",
|
||||
_device_for_runtime(),
|
||||
"--yolo-model",
|
||||
str(YOLO_MODEL_PATH),
|
||||
"--window",
|
||||
"10",
|
||||
"--stride",
|
||||
"10",
|
||||
"--result-export-path",
|
||||
str(export_path),
|
||||
"--result-export-format",
|
||||
"json",
|
||||
"--max-frames",
|
||||
"60",
|
||||
timeout_seconds=180,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, (
|
||||
f"Expected exit code 0, got {result.returncode}. stderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
# Verify export file exists
|
||||
assert export_path.is_file(), f"Export file not found: {export_path}"
|
||||
|
||||
# Read and verify JSON lines
|
||||
predictions: list[dict[str, object]] = []
|
||||
with open(export_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
predictions.append(cast(dict[str, object], json.loads(line)))
|
||||
|
||||
assert len(predictions) > 0, "Expected at least one prediction in export"
|
||||
|
||||
for prediction in predictions:
|
||||
_assert_prediction_schema(prediction)
|
||||
|
||||
|
||||
def test_pipeline_cli_result_export_pickle(
|
||||
compatible_checkpoint_path: Path,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that results can be exported to pickle file."""
|
||||
_require_integration_assets()
|
||||
|
||||
export_path = tmp_path / "results.pkl"
|
||||
|
||||
result = _run_pipeline_cli(
|
||||
"--source",
|
||||
str(SAMPLE_VIDEO_PATH),
|
||||
"--checkpoint",
|
||||
str(compatible_checkpoint_path),
|
||||
"--config",
|
||||
str(CONFIG_PATH),
|
||||
"--device",
|
||||
_device_for_runtime(),
|
||||
"--yolo-model",
|
||||
str(YOLO_MODEL_PATH),
|
||||
"--window",
|
||||
"10",
|
||||
"--stride",
|
||||
"10",
|
||||
"--result-export-path",
|
||||
str(export_path),
|
||||
"--result-export-format",
|
||||
"pickle",
|
||||
"--max-frames",
|
||||
"60",
|
||||
timeout_seconds=180,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, (
|
||||
f"Expected exit code 0, got {result.returncode}. stderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
# Verify export file exists
|
||||
assert export_path.is_file(), f"Export file not found: {export_path}"
|
||||
|
||||
# Read and verify pickle
|
||||
with open(export_path, "rb") as f:
|
||||
predictions = pickle.load(f)
|
||||
|
||||
assert isinstance(predictions, list)
|
||||
assert len(predictions) > 0, "Expected at least one prediction in export"
|
||||
|
||||
for prediction in predictions:
|
||||
_assert_prediction_schema(prediction)
|
||||
|
||||
|
||||
def test_pipeline_cli_silhouette_and_result_export(
|
||||
compatible_checkpoint_path: Path,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test exporting both silhouettes and results simultaneously."""
|
||||
_require_integration_assets()
|
||||
|
||||
silhouette_export = tmp_path / "silhouettes.pkl"
|
||||
result_export = tmp_path / "results.jsonl"
|
||||
|
||||
result = _run_pipeline_cli(
|
||||
"--source",
|
||||
str(SAMPLE_VIDEO_PATH),
|
||||
"--checkpoint",
|
||||
str(compatible_checkpoint_path),
|
||||
"--config",
|
||||
str(CONFIG_PATH),
|
||||
"--device",
|
||||
_device_for_runtime(),
|
||||
"--yolo-model",
|
||||
str(YOLO_MODEL_PATH),
|
||||
"--window",
|
||||
"10",
|
||||
"--stride",
|
||||
"10",
|
||||
"--silhouette-export-path",
|
||||
str(silhouette_export),
|
||||
"--silhouette-export-format",
|
||||
"pickle",
|
||||
"--result-export-path",
|
||||
str(result_export),
|
||||
"--result-export-format",
|
||||
"json",
|
||||
"--max-frames",
|
||||
"60",
|
||||
timeout_seconds=180,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, (
|
||||
f"Expected exit code 0, got {result.returncode}. stderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
# Verify both export files exist
|
||||
assert silhouette_export.is_file(), f"Silhouette export not found: {silhouette_export}"
|
||||
assert result_export.is_file(), f"Result export not found: {result_export}"
|
||||
|
||||
# Verify silhouette export
|
||||
with open(silhouette_export, "rb") as f:
|
||||
silhouettes = pickle.load(f)
|
||||
assert isinstance(silhouettes, list)
|
||||
assert len(silhouettes) > 0
|
||||
|
||||
# Verify result export
|
||||
with open(result_export, "r", encoding="utf-8") as f:
|
||||
predictions = [cast(dict[str, object], json.loads(line)) for line in f if line.strip()]
|
||||
assert len(predictions) > 0
|
||||
|
||||
|
||||
def test_pipeline_cli_parquet_export_requires_pyarrow(
|
||||
compatible_checkpoint_path: Path,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that parquet export fails gracefully when pyarrow is not available."""
|
||||
_require_integration_assets()
|
||||
|
||||
# Skip if pyarrow is actually installed
|
||||
if importlib.util.find_spec("pyarrow") is not None:
|
||||
pytest.skip("pyarrow is installed, skipping missing dependency test")
|
||||
try:
|
||||
import pyarrow # noqa: F401
|
||||
pytest.skip("pyarrow is installed, skipping missing dependency test")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
export_path = tmp_path / "results.parquet"
|
||||
|
||||
result = _run_pipeline_cli(
|
||||
"--source",
|
||||
str(SAMPLE_VIDEO_PATH),
|
||||
"--checkpoint",
|
||||
str(compatible_checkpoint_path),
|
||||
"--config",
|
||||
str(CONFIG_PATH),
|
||||
"--device",
|
||||
_device_for_runtime(),
|
||||
"--yolo-model",
|
||||
str(YOLO_MODEL_PATH),
|
||||
"--window",
|
||||
"10",
|
||||
"--stride",
|
||||
"10",
|
||||
"--result-export-path",
|
||||
str(export_path),
|
||||
"--result-export-format",
|
||||
"parquet",
|
||||
"--max-frames",
|
||||
"30",
|
||||
timeout_seconds=180,
|
||||
)
|
||||
|
||||
# Should fail with RuntimeError about pyarrow
|
||||
assert result.returncode == 1
|
||||
assert "parquet" in result.stderr.lower() or "pyarrow" in result.stderr.lower()
|
||||
|
||||
|
||||
|
||||
def test_pipeline_cli_silhouette_visualization(
|
||||
compatible_checkpoint_path: Path,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that silhouette visualization creates PNG files."""
|
||||
_require_integration_assets()
|
||||
|
||||
visualize_dir = tmp_path / "silhouette_viz"
|
||||
|
||||
result = _run_pipeline_cli(
|
||||
"--source",
|
||||
str(SAMPLE_VIDEO_PATH),
|
||||
"--checkpoint",
|
||||
str(compatible_checkpoint_path),
|
||||
"--config",
|
||||
str(CONFIG_PATH),
|
||||
"--device",
|
||||
_device_for_runtime(),
|
||||
"--yolo-model",
|
||||
str(YOLO_MODEL_PATH),
|
||||
"--window",
|
||||
"10",
|
||||
"--stride",
|
||||
"10",
|
||||
"--silhouette-visualize-dir",
|
||||
str(visualize_dir),
|
||||
"--max-frames",
|
||||
"30",
|
||||
timeout_seconds=180,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, (
|
||||
f"Expected exit code 0, got {result.returncode}. stderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
# Verify visualization directory exists and contains PNG files
|
||||
assert visualize_dir.is_dir(), f"Visualization directory not found: {visualize_dir}"
|
||||
|
||||
png_files = list(visualize_dir.glob("*.png"))
|
||||
assert len(png_files) > 0, "Expected at least one PNG visualization file"
|
||||
|
||||
# Verify filenames contain frame and track info
|
||||
for png_file in png_files:
|
||||
assert "silhouette_frame" in png_file.name
|
||||
assert "_track" in png_file.name
|
||||
|
||||
|
||||
def test_pipeline_cli_preprocess_only_with_visualization(
|
||||
compatible_checkpoint_path: Path,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test preprocess-only mode with both export and visualization."""
|
||||
_require_integration_assets()
|
||||
|
||||
export_path = tmp_path / "silhouettes.pkl"
|
||||
visualize_dir = tmp_path / "silhouette_viz"
|
||||
|
||||
result = _run_pipeline_cli(
|
||||
"--source",
|
||||
str(SAMPLE_VIDEO_PATH),
|
||||
"--checkpoint",
|
||||
str(compatible_checkpoint_path),
|
||||
"--config",
|
||||
str(CONFIG_PATH),
|
||||
"--device",
|
||||
_device_for_runtime(),
|
||||
"--yolo-model",
|
||||
str(YOLO_MODEL_PATH),
|
||||
"--preprocess-only",
|
||||
"--silhouette-export-path",
|
||||
str(export_path),
|
||||
"--silhouette-visualize-dir",
|
||||
str(visualize_dir),
|
||||
"--max-frames",
|
||||
"30",
|
||||
timeout_seconds=180,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, (
|
||||
f"Expected exit code 0, got {result.returncode}. stderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
# Verify export file exists
|
||||
assert export_path.is_file(), f"Export file not found: {export_path}"
|
||||
|
||||
# Verify visualization files exist
|
||||
assert visualize_dir.is_dir(), f"Visualization directory not found: {visualize_dir}"
|
||||
png_files = list(visualize_dir.glob("*.png"))
|
||||
assert len(png_files) > 0, "Expected at least one PNG visualization file"
|
||||
|
||||
# Load and verify pickle export
|
||||
with open(export_path, "rb") as f:
|
||||
silhouettes = pickle.load(f)
|
||||
assert isinstance(silhouettes, list)
|
||||
assert len(silhouettes) > 0
|
||||
# Number of exported silhouettes should match number of PNG files
|
||||
assert len(silhouettes) == len(png_files), (
|
||||
f"Mismatch: {len(silhouettes)} silhouettes exported but {len(png_files)} PNG files created"
|
||||
)
|
||||
|
||||
@@ -206,9 +206,9 @@ class TestSelectPerson:
|
||||
|
||||
def _create_mock_results(
|
||||
self,
|
||||
boxes_xyxy: NDArray[np.float32],
|
||||
masks_data: NDArray[np.float32],
|
||||
track_ids: NDArray[np.int64] | None,
|
||||
boxes_xyxy: NDArray[np.float32] | torch.Tensor,
|
||||
masks_data: NDArray[np.float32] | torch.Tensor,
|
||||
track_ids: NDArray[np.int64] | torch.Tensor | None,
|
||||
) -> Any:
|
||||
"""Create a mock detection results object."""
|
||||
mock_boxes = MagicMock()
|
||||
@@ -344,3 +344,56 @@ class TestSelectPerson:
|
||||
mask, _, _ = result
|
||||
# Should be 2D (extracted from expanded 3D)
|
||||
assert mask.shape == (100, 100)
|
||||
|
||||
|
||||
def test_select_person_tensor_cpu_inputs(self) -> None:
|
||||
"""Tensor-backed inputs (CPU) should work correctly."""
|
||||
boxes = torch.tensor([[10.0, 10.0, 50.0, 90.0]], dtype=torch.float32)
|
||||
masks = torch.rand(1, 100, 100, dtype=torch.float32)
|
||||
track_ids = torch.tensor([42], dtype=torch.int64)
|
||||
|
||||
results = self._create_mock_results(boxes, masks, track_ids)
|
||||
result = select_person(results)
|
||||
|
||||
assert result is not None
|
||||
mask, bbox, tid = result
|
||||
assert mask.shape == (100, 100)
|
||||
assert bbox == (10, 10, 50, 90)
|
||||
assert tid == 42
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available")
|
||||
def test_select_person_tensor_cuda_inputs(self) -> None:
|
||||
"""Tensor-backed inputs (CUDA) should work correctly."""
|
||||
boxes = torch.tensor([[10.0, 10.0, 50.0, 90.0]], dtype=torch.float32).cuda()
|
||||
masks = torch.rand(1, 100, 100, dtype=torch.float32).cuda()
|
||||
track_ids = torch.tensor([42], dtype=torch.int64).cuda()
|
||||
|
||||
results = self._create_mock_results(boxes, masks, track_ids)
|
||||
result = select_person(results)
|
||||
|
||||
assert result is not None
|
||||
mask, bbox, tid = result
|
||||
assert mask.shape == (100, 100)
|
||||
assert bbox == (10, 10, 50, 90)
|
||||
assert tid == 42
|
||||
|
||||
def test_select_person_tensor_multi_detection(self) -> None:
|
||||
"""Multiple tensor detections should select largest bbox."""
|
||||
boxes = torch.tensor(
|
||||
[
|
||||
[0.0, 0.0, 10.0, 10.0], # area = 100
|
||||
[0.0, 0.0, 30.0, 30.0], # area = 900 (largest)
|
||||
[0.0, 0.0, 20.0, 20.0], # area = 400
|
||||
],
|
||||
dtype=torch.float32,
|
||||
)
|
||||
masks = torch.rand(3, 100, 100, dtype=torch.float32)
|
||||
track_ids = torch.tensor([1, 2, 3], dtype=torch.int64)
|
||||
|
||||
results = self._create_mock_results(boxes, masks, track_ids)
|
||||
result = select_person(results)
|
||||
|
||||
assert result is not None
|
||||
_, bbox, tid = result
|
||||
assert bbox == (0, 0, 30, 30) # Largest box
|
||||
assert tid == 2 # Corresponding track ID
|
||||
|
||||
Reference in New Issue
Block a user