feat: propagate sack vs box counts to results and UI
This commit is contained in:
1 parent
d38375986e
commit
7932119e17
8 files changed
+358
-4
No files matched your search
@@ -356,6 +356,9 @@ def api_job_detail(job_id):
|
||||
"loading_count": r.loading_count,
|
||||
"unloading_count": r.unloading_count,
|
||||
"net_count": r.net_count,
|
||||
"box_loading_count": r.box_loading_count,
|
||||
"box_unloading_count": r.box_unloading_count,
|
||||
"box_net_count": r.box_net_count,
|
||||
"batch_count": r.batch_count,
|
||||
"frame_count": r.frame_count,
|
||||
"duration_seconds": r.duration_seconds,
|
||||
|
||||
@@ -37,6 +37,9 @@ class JobResult:
|
||||
frame_count: int
|
||||
duration_seconds: float
|
||||
error: str | None = None
|
||||
box_loading_count: int = 0
|
||||
box_unloading_count: int = 0
|
||||
box_net_count: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -223,6 +226,9 @@ class JobQueue:
|
||||
batch_count=result.batch_count,
|
||||
frame_count=result.frame_count,
|
||||
duration_seconds=result.duration_seconds,
|
||||
box_loading_count=result.box_loading_count,
|
||||
box_unloading_count=result.box_unloading_count,
|
||||
box_net_count=result.box_net_count,
|
||||
))
|
||||
else:
|
||||
# Multiple models: run merged pipeline
|
||||
@@ -267,6 +273,9 @@ class JobQueue:
|
||||
batch_count=result.batch_count,
|
||||
frame_count=result.frame_count,
|
||||
duration_seconds=result.duration_seconds,
|
||||
box_loading_count=result.box_loading_count,
|
||||
box_unloading_count=result.box_unloading_count,
|
||||
box_net_count=result.box_net_count,
|
||||
))
|
||||
|
||||
with self._lock:
|
||||
|
||||
@@ -36,11 +36,17 @@ class PipelineResult:
|
||||
duration_seconds: float
|
||||
model_name: str
|
||||
class_filter: list[str] | None
|
||||
box_loading_count: int = 0
|
||||
box_unloading_count: int = 0
|
||||
|
||||
@property
|
||||
def net_count(self) -> int:
|
||||
return self.loading_count - self.unloading_count
|
||||
|
||||
@property
|
||||
def box_net_count(self) -> int:
|
||||
return self.box_loading_count - self.box_unloading_count
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergedPipelineResult:
|
||||
@@ -54,11 +60,17 @@ class MergedPipelineResult:
|
||||
duration_seconds: float
|
||||
model_names: list[str]
|
||||
class_filter: list[str] | None
|
||||
box_loading_count: int = 0
|
||||
box_unloading_count: int = 0
|
||||
|
||||
@property
|
||||
def net_count(self) -> int:
|
||||
return self.loading_count - self.unloading_count
|
||||
|
||||
@property
|
||||
def box_net_count(self) -> int:
|
||||
return self.box_loading_count - self.box_unloading_count
|
||||
|
||||
|
||||
def apply_class_filter(
|
||||
detections: list[Detection], class_filter: list[str] | None
|
||||
@@ -330,6 +342,8 @@ def run_pipeline(
|
||||
frame_count=frame_idx,
|
||||
loading_count=counter.loading_count,
|
||||
unloading_count=counter.unloading_count,
|
||||
box_loading_count=counter.box_loading_count,
|
||||
box_unloading_count=counter.box_unloading_count,
|
||||
batch_count=completed_batches,
|
||||
duration_seconds=duration,
|
||||
model_name=model_config.filename,
|
||||
@@ -592,6 +606,8 @@ def run_merged_pipeline(
|
||||
frame_count=frame_idx,
|
||||
loading_count=counter.loading_count,
|
||||
unloading_count=counter.unloading_count,
|
||||
box_loading_count=counter.box_loading_count,
|
||||
box_unloading_count=counter.box_unloading_count,
|
||||
batch_count=completed_batches,
|
||||
duration_seconds=duration,
|
||||
model_names=[c.filename for c in model_configs],
|
||||
|
||||
+4
-2
@@ -583,8 +583,10 @@ function initStatusPage(jobId, initialStatus) {
|
||||
card.innerHTML =
|
||||
'<div class="result-model">' + escapeHtml(r.model_name) + '</div>' +
|
||||
'<div class="result-stats">' +
|
||||
'<div class="stat"><span class="stat-value stat-loading">' + r.loading_count + '</span><span class="stat-label">Loading</span></div>' +
|
||||
'<div class="stat"><span class="stat-value stat-unloading">' + r.unloading_count + '</span><span class="stat-label">Unloading</span></div>' +
|
||||
'<div class="stat"><span class="stat-value stat-loading">' + r.loading_count + '</span><span class="stat-label">Sack In</span></div>' +
|
||||
'<div class="stat"><span class="stat-value stat-unloading">' + r.unloading_count + '</span><span class="stat-label">Sack Out</span></div>' +
|
||||
'<div class="stat"><span class="stat-value stat-loading">' + (r.box_loading_count || 0) + '</span><span class="stat-label">Box In</span></div>' +
|
||||
'<div class="stat"><span class="stat-value stat-unloading">' + (r.box_unloading_count || 0) + '</span><span class="stat-label">Box Out</span></div>' +
|
||||
'<div class="stat"><span class="stat-value stat-net">' + r.net_count + '</span><span class="stat-label">Net</span></div>' +
|
||||
'</div>' +
|
||||
'<div class="result-meta">' +
|
||||
|
||||
+10
-2
@@ -83,11 +83,19 @@
|
||||
<div class="result-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value stat-loading">{{ r.loading_count }}</span>
|
||||
<span class="stat-label">Loading</span>
|
||||
<span class="stat-label">Sack In</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value stat-unloading">{{ r.unloading_count }}</span>
|
||||
<span class="stat-label">Unloading</span>
|
||||
<span class="stat-label">Sack Out</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value stat-loading">{{ r.box_loading_count }}</span>
|
||||
<span class="stat-label">Box In</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value stat-unloading">{{ r.box_unloading_count }}</span>
|
||||
<span class="stat-label">Box Out</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value stat-net">{{ r.net_count }}</span>
|
||||
|
||||
@@ -110,3 +110,56 @@ def test_cancel_pending_or_running_job(client):
|
||||
# Verify job is now CANCELLED
|
||||
fetched = job_queue.get_job(job.job_id)
|
||||
assert fetched.status == JobStatus.CANCELLED
|
||||
|
||||
|
||||
# ── Box counts in API + status page (Task 2) ────────────────────────────
|
||||
|
||||
|
||||
def _job_with_box_results():
|
||||
import time
|
||||
|
||||
from app import job_queue
|
||||
from src.job import JobResult, JobStatus
|
||||
|
||||
job = job_queue.add_job(video_path="/tmp/test.mp4", model_configs=[])
|
||||
time.sleep(0.3)
|
||||
with job_queue._lock:
|
||||
job.status = JobStatus.COMPLETED
|
||||
job.results.append(JobResult(
|
||||
model_name="a.pt",
|
||||
output_path="/tmp/out.mp4",
|
||||
loading_count=5,
|
||||
unloading_count=2,
|
||||
net_count=3,
|
||||
batch_count=1,
|
||||
frame_count=10,
|
||||
duration_seconds=1.0,
|
||||
box_loading_count=4,
|
||||
box_unloading_count=1,
|
||||
box_net_count=3,
|
||||
))
|
||||
return job
|
||||
|
||||
|
||||
def test_api_job_detail_includes_box_counts(client):
|
||||
"""GET /api/jobs/<id> results JSON carries box counts."""
|
||||
job = _job_with_box_results()
|
||||
resp = client.get(f"/api/jobs/{job.job_id}")
|
||||
assert resp.status_code == 200
|
||||
r = resp.get_json()["results"][0]
|
||||
assert r["box_loading_count"] == 4
|
||||
assert r["box_unloading_count"] == 1
|
||||
assert r["box_net_count"] == 3
|
||||
assert r["loading_count"] == 5
|
||||
|
||||
|
||||
def test_status_page_renders_box_and_sack_labels(client):
|
||||
"""status.html shows Sack In/Out and Box In/Out stat labels."""
|
||||
job = _job_with_box_results()
|
||||
resp = client.get(f"/status/{job.job_id}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
assert "Sack In" in html
|
||||
assert "Sack Out" in html
|
||||
assert "Box In" in html
|
||||
assert "Box Out" in html
|
||||
@@ -111,3 +111,127 @@ def test_queue_status_counts():
|
||||
assert isinstance(counts, dict)
|
||||
# At least some count should be populated
|
||||
assert sum(counts.values()) >= 2
|
||||
|
||||
|
||||
# ── Box count propagation to JobResult (Task 2) ─────────────────────────
|
||||
|
||||
|
||||
def test_job_result_box_fields_default_zero():
|
||||
"""JobResult has box fields defaulting to 0."""
|
||||
from src.job import JobResult
|
||||
|
||||
r = JobResult(
|
||||
model_name="a.pt",
|
||||
output_path="/tmp/out.mp4",
|
||||
loading_count=5,
|
||||
unloading_count=2,
|
||||
net_count=3,
|
||||
batch_count=1,
|
||||
frame_count=10,
|
||||
duration_seconds=1.0,
|
||||
)
|
||||
assert (r.box_loading_count, r.box_unloading_count, r.box_net_count) == (0, 0, 0)
|
||||
|
||||
|
||||
def test_job_result_box_fields_accept_values():
|
||||
from src.job import JobResult
|
||||
|
||||
r = JobResult(
|
||||
model_name="a.pt",
|
||||
output_path="/tmp/out.mp4",
|
||||
loading_count=5,
|
||||
unloading_count=2,
|
||||
net_count=3,
|
||||
batch_count=1,
|
||||
frame_count=10,
|
||||
duration_seconds=1.0,
|
||||
box_loading_count=4,
|
||||
box_unloading_count=1,
|
||||
box_net_count=3,
|
||||
)
|
||||
assert (r.box_loading_count, r.box_unloading_count, r.box_net_count) == (4, 1, 3)
|
||||
|
||||
|
||||
def test_run_job_single_model_propagates_box_counts(tmp_path, monkeypatch):
|
||||
"""_run_job single-model branch copies box counts from PipelineResult."""
|
||||
import src.job as job_mod
|
||||
from src.job import Job, JobQueue, JobStatus
|
||||
from src.model_registry import ModelConfig
|
||||
from src.pipeline import PipelineResult
|
||||
|
||||
fake = PipelineResult(
|
||||
output_path=str(tmp_path / "out.mp4"),
|
||||
frame_count=10,
|
||||
loading_count=5,
|
||||
unloading_count=2,
|
||||
batch_count=1,
|
||||
duration_seconds=1.0,
|
||||
model_name="a.pt",
|
||||
class_filter=None,
|
||||
box_loading_count=4,
|
||||
box_unloading_count=1,
|
||||
)
|
||||
monkeypatch.setattr(job_mod, "run_pipeline", lambda **kwargs: fake)
|
||||
|
||||
q = JobQueue(output_dir=str(tmp_path))
|
||||
job = Job(
|
||||
job_id="job-t2-single",
|
||||
video_path="/tmp/test.mp4",
|
||||
model_configs=[
|
||||
ModelConfig(filename="a.pt", path=str(tmp_path / "a.pt"), stem="a",
|
||||
known_classes=["sack", "box", "truck"])
|
||||
],
|
||||
output_dir=str(tmp_path / "job-t2-single"),
|
||||
)
|
||||
q._jobs[job.job_id] = job
|
||||
q._run_job(job.job_id)
|
||||
|
||||
assert job.status == JobStatus.COMPLETED
|
||||
r = job.results[0]
|
||||
assert r.loading_count == 5
|
||||
assert r.box_loading_count == 4
|
||||
assert r.box_unloading_count == 1
|
||||
assert r.box_net_count == 3
|
||||
|
||||
|
||||
def test_run_job_merged_propagates_box_counts(tmp_path, monkeypatch):
|
||||
"""_run_job merged branch copies box counts from MergedPipelineResult."""
|
||||
import src.job as job_mod
|
||||
from src.job import Job, JobQueue, JobStatus
|
||||
from src.model_registry import ModelConfig
|
||||
from src.pipeline import MergedPipelineResult
|
||||
|
||||
fake = MergedPipelineResult(
|
||||
output_path=str(tmp_path / "merged.mp4"),
|
||||
frame_count=10,
|
||||
loading_count=9,
|
||||
unloading_count=4,
|
||||
batch_count=2,
|
||||
duration_seconds=2.0,
|
||||
model_names=["a.pt", "b.pt"],
|
||||
class_filter=None,
|
||||
box_loading_count=6,
|
||||
box_unloading_count=2,
|
||||
)
|
||||
monkeypatch.setattr(job_mod, "run_merged_pipeline", lambda **kwargs: fake)
|
||||
|
||||
q = JobQueue(output_dir=str(tmp_path))
|
||||
job = Job(
|
||||
job_id="job-t2-merged",
|
||||
video_path="/tmp/test.mp4",
|
||||
model_configs=[
|
||||
ModelConfig(filename="a.pt", path=str(tmp_path / "a.pt"), stem="a",
|
||||
known_classes=["sack", "box", "truck"]),
|
||||
ModelConfig(filename="b.pt", path=str(tmp_path / "b.pt"), stem="b",
|
||||
known_classes=["sack", "box"]),
|
||||
],
|
||||
output_dir=str(tmp_path / "job-t2-merged"),
|
||||
)
|
||||
q._jobs[job.job_id] = job
|
||||
q._run_job(job.job_id)
|
||||
|
||||
assert job.status == JobStatus.COMPLETED
|
||||
r = job.results[0]
|
||||
assert r.box_loading_count == 6
|
||||
assert r.box_unloading_count == 2
|
||||
assert r.box_net_count == 4
|
||||
@@ -188,3 +188,142 @@ def test_dashboard_label_uses_class_name(monkeypatch):
|
||||
frame, [_cross_det("truck", 7, 50.0, 100.0)]
|
||||
)
|
||||
assert any(t.startswith("truck") for t in drawn), drawn
|
||||
|
||||
|
||||
# ── Box count propagation to results (Task 2) ───────────────────────────
|
||||
|
||||
|
||||
def _box_result_kwargs() -> dict:
|
||||
return dict(
|
||||
output_path="/tmp/out.mp4",
|
||||
frame_count=100,
|
||||
loading_count=5,
|
||||
unloading_count=2,
|
||||
batch_count=1,
|
||||
duration_seconds=10.0,
|
||||
class_filter=None,
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_result_box_fields_default_zero():
|
||||
"""PipelineResult box fields default to 0, box_net_count derives."""
|
||||
r = PipelineResult(model_name="v4-best.pt", **_box_result_kwargs())
|
||||
assert r.box_loading_count == 0
|
||||
assert r.box_unloading_count == 0
|
||||
assert r.box_net_count == 0
|
||||
|
||||
|
||||
def test_pipeline_result_box_fields_accept_values():
|
||||
"""PipelineResult accepts box counts, box_net_count = in - out."""
|
||||
r = PipelineResult(
|
||||
model_name="v4-best.pt",
|
||||
**_box_result_kwargs(),
|
||||
box_loading_count=4,
|
||||
box_unloading_count=1,
|
||||
)
|
||||
assert (r.box_loading_count, r.box_unloading_count, r.box_net_count) == (4, 1, 3)
|
||||
|
||||
|
||||
def test_merged_pipeline_result_box_fields():
|
||||
"""MergedPipelineResult has same box fields with defaults."""
|
||||
from src.pipeline import MergedPipelineResult
|
||||
|
||||
kwargs = _box_result_kwargs()
|
||||
r = MergedPipelineResult(
|
||||
model_names=["a.pt", "b.pt"],
|
||||
**kwargs,
|
||||
box_loading_count=2,
|
||||
box_unloading_count=5,
|
||||
)
|
||||
assert (r.box_loading_count, r.box_unloading_count, r.box_net_count) == (2, 5, -3)
|
||||
r0 = MergedPipelineResult(model_names=["a.pt"], **kwargs)
|
||||
assert (r0.box_loading_count, r0.box_unloading_count, r0.box_net_count) == (0, 0, 0)
|
||||
|
||||
|
||||
class _FakeCounter:
|
||||
"""Stands in for MultiClassLineCounter with fixed counts."""
|
||||
|
||||
def __init__(self, line_y=0, line_x_start=0, line_x_end=0, margin=0):
|
||||
self.line_y = line_y
|
||||
self.line_x_start = line_x_start
|
||||
self.line_x_end = line_x_end
|
||||
self.margin = margin
|
||||
self.loading_count = 7
|
||||
self.unloading_count = 3
|
||||
self.box_loading_count = 4
|
||||
self.box_unloading_count = 1
|
||||
|
||||
def update(self, detections):
|
||||
return []
|
||||
|
||||
|
||||
class _FakeYOLO:
|
||||
def __init__(self, path="fake.pt"):
|
||||
self.names = {0: "sack", 1: "box", 2: "truck"}
|
||||
self.ckpt_path = path
|
||||
|
||||
|
||||
def _patch_pipeline_fakes(monkeypatch):
|
||||
from src import detection as det_mod
|
||||
from src import pipeline as pipe_mod
|
||||
from src import tracking as trk_mod
|
||||
|
||||
monkeypatch.setattr(pipe_mod, "YOLO", _FakeYOLO)
|
||||
monkeypatch.setattr(det_mod, "YOLO", _FakeYOLO)
|
||||
monkeypatch.setattr(trk_mod, "YOLO", _FakeYOLO)
|
||||
monkeypatch.setattr(pipe_mod, "MultiClassLineCounter", _FakeCounter)
|
||||
|
||||
|
||||
def _write_video(path: str, frames: int = 3) -> None:
|
||||
writer = cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*"mp4v"), 25.0, (320, 240))
|
||||
for _ in range(frames):
|
||||
writer.write(np.zeros((240, 320, 3), dtype=np.uint8))
|
||||
writer.release()
|
||||
|
||||
|
||||
def test_run_pipeline_propagates_box_counts(tmp_path, monkeypatch):
|
||||
"""run_pipeline passes counter box counts into PipelineResult."""
|
||||
_patch_pipeline_fakes(monkeypatch)
|
||||
video_path = str(tmp_path / "t.mp4")
|
||||
_write_video(video_path)
|
||||
|
||||
result = run_pipeline(
|
||||
video_path=video_path,
|
||||
model_config=ModelConfig(
|
||||
filename="test.pt",
|
||||
path=str(tmp_path / "test.pt"),
|
||||
stem="test",
|
||||
known_classes=["sack", "box"],
|
||||
),
|
||||
output_path=str(tmp_path / "out.mp4"),
|
||||
preview_enabled=False,
|
||||
)
|
||||
assert result.loading_count == 7
|
||||
assert result.unloading_count == 3
|
||||
assert result.box_loading_count == 4
|
||||
assert result.box_unloading_count == 1
|
||||
assert result.box_net_count == 3
|
||||
|
||||
|
||||
def test_run_merged_pipeline_propagates_box_counts(tmp_path, monkeypatch):
|
||||
"""run_merged_pipeline passes counter box counts into MergedPipelineResult."""
|
||||
from src.pipeline import run_merged_pipeline
|
||||
|
||||
_patch_pipeline_fakes(monkeypatch)
|
||||
video_path = str(tmp_path / "t.mp4")
|
||||
_write_video(video_path)
|
||||
cfgs = [
|
||||
ModelConfig(filename="a.pt", path=str(tmp_path / "a.pt"), stem="a", known_classes=["sack", "box"]),
|
||||
ModelConfig(filename="b.pt", path=str(tmp_path / "b.pt"), stem="b", known_classes=["sack", "box"]),
|
||||
]
|
||||
|
||||
result = run_merged_pipeline(
|
||||
video_path=video_path,
|
||||
model_configs=cfgs,
|
||||
output_path=str(tmp_path / "merged.mp4"),
|
||||
preview_enabled=False,
|
||||
)
|
||||
assert result.loading_count == 7
|
||||
assert result.box_loading_count == 4
|
||||
assert result.box_unloading_count == 1
|
||||
assert result.box_net_count == 3
|
||||
Reference in new issue
Block a user