feat: propagate sack vs box counts to results and UI

This commit is contained in:
jetson committed 2026-09-28 09:36:27 +07:00
1 parent d38375986e
commit 7932119e17
8 files changed
+358 -4

No files matched your search

+3
View File
@@ -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,
+9
View File
@@ -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:
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+53
View File
@@ -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
+124
View File
@@ -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
+139
View File
@@ -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