Files
feedmill-auto-label/algoritma-batch/counting.py
T
asus 5c7c122105 feat: add counting bench, triage, and dataset modules
This commit includes major additions and updates to the frontend and backend architectures, introducing new dataset management, live counting features, batch processing, and triage logic. Includes new UI pages, components, and API routes.
2026-08-14 16:28:52 +07:00

260 lines
9.2 KiB
Python

"""Line-crossing counter — hybrid zone-based state tracking.
Counting logic (Low-FPS robust):
Uses y1 (top edge) of the stabilized sack bounding box.
Each track_id goes through states:
UNKNOWN → ABOVE → COUNTED (when seen below line)
UNKNOWN → BELOW (ghost/appeared below line first → never counted)
Loading: track had state ABOVE, now detected BELOW the zone
Unloading: track had state BELOW, now detected ABOVE the zone (if needed)
4-Layer deduplication:
Layer 1: State guard — must have been ABOVE before counting
Layer 2: Entry circle — must have exited the radius from first appearance
Layer 3: Spatial dedup radius — same position can't trigger twice
Layer 4: Track ID — one track_id can only be counted once per direction
This approach is immune to low FPS because it doesn't require
detecting the exact frame of crossing. It only needs the track
to have been seen ABOVE the line at ANY point in its lifetime.
"""
from __future__ import annotations
import time
from src.interfaces import Detection
class LineCrossCounter:
"""Counts sacks crossing a horizontal zone using y1 (top edge).
The zone is a band [line_y - margin, line_y + margin].
A sack is "above" if y1 < line_y - margin,
"below" if y1 > line_y + margin.
While y1 is inside the band, state is held (no trigger).
Loading = track was ever "above", now "below" (entered truck)
Unloading = track was ever "below", now "above" (left truck)
"""
def __init__(
self,
line_y: int,
line_x_start: int,
line_x_end: int,
margin: int = 20,
dedup_radius: float = 60.0,
) -> None:
self._line_y = line_y
self._line_x_start = line_x_start
self._line_x_end = line_x_end
self._margin = margin
self._dedup_radius = dedup_radius
self._loading_count = 0
self._unloading_count = 0
# track_id -> zone state for y1: "above" | "below" | None
self._state: dict[int, str | None] = {}
# track_id -> whether this track has EVER been in each zone
self._has_been_above: dict[int, bool] = {}
self._has_been_below: dict[int, bool] = {}
# track_id -> set of directions already counted
self._counted: dict[int, set[str]] = {}
# track_id -> initial coordinates (cx, y1) when first tracked
self._entry_points: dict[int, tuple[float, float]] = {}
# track_id -> whether this track has exited its entry circle
# A track must move beyond dedup_radius from its first appearance
# before it can be counted. This prevents ghost bboxes that suddenly
# appear near the counting line from being counted immediately.
self._has_exited_entry_circle: dict[int, bool] = {}
# list of active deduplication circles
self._dedup_circles: list[dict] = []
@property
def entry_points(self) -> dict[int, tuple[float, float]]:
return self._entry_points
@property
def counted_tracks(self) -> dict[int, set[str]]:
return self._counted
@property
def line_y(self) -> int:
return self._line_y
@line_y.setter
def line_y(self, value: int) -> None:
self._line_y = value
@property
def line_x_start(self) -> int:
return self._line_x_start
@line_x_start.setter
def line_x_start(self, value: int) -> None:
self._line_x_start = value
@property
def line_x_end(self) -> int:
return self._line_x_end
@line_x_end.setter
def line_x_end(self, value: int) -> None:
self._line_x_end = value
def update(self, detections: list[Detection]) -> list[dict]:
"""Process detections, return list of crossing events.
Hybrid approach:
- Tracks zone state per frame (above/below/in-band)
- BUT uses accumulated history (has_been_above) for counting decision
- A track counts as "loading" when:
1. It has been seen ABOVE the line at any previous point
2. It has exited its entry circle (moved beyond dedup_radius from first appearance)
3. Its current y1 is now BELOW the line
4. It hasn't been counted for loading yet
5. It passes spatial dedup check
"""
now_t = time.time()
events: list[dict] = []
upper = self._line_y - self._margin
lower = self._line_y + self._margin
# Clean up expired dedup circles (older than 3.0 seconds)
self._dedup_circles = [c for c in self._dedup_circles if (now_t - c["time"]) <= 3.0]
for det in detections:
if det.track_id is None:
continue
x1, y1, x2, y2 = det.bbox
cx = (x1 + x2) / 2.0
tid = det.track_id
if tid not in self._entry_points:
self._entry_points[tid] = (cx, y1)
self._has_exited_entry_circle[tid] = False
# Check if sack has exited its entry circle (moved beyond dedup_radius
# from where it first appeared). Once exited, stays exited permanently.
if not self._has_exited_entry_circle.get(tid, False):
entry_cx, entry_y1 = self._entry_points[tid]
dist_from_entry = ((cx - entry_cx) ** 2 + (y1 - entry_y1) ** 2) ** 0.5
if dist_from_entry > self._dedup_radius:
self._has_exited_entry_circle[tid] = True
# Skip if centroid X outside counting bounds
if cx < self._line_x_start or cx > self._line_x_end:
continue
counted_dirs = self._counted.setdefault(tid, set())
# Determine y1 zone state (top edge of sack bbox)
if y1 < upper:
new_state = "above"
elif y1 > lower:
new_state = "below"
else:
new_state = self._state.get(tid) # in band: hold
prev_state = self._state.get(tid)
self._state[tid] = new_state
# Track zone history — CRITICAL for low-FPS robustness
# Once a track has been seen above/below, it stays recorded forever
if new_state == "above":
self._has_been_above[tid] = True
elif new_state == "below":
self._has_been_below[tid] = True
# --- HYBRID COUNTING LOGIC ---
# Loading: track was EVER above, NOW below (entered truck from top)
# AND has exited its entry circle (proven real movement, not ghost bbox)
# This works even if the track jumped over the line between frames
has_exited = self._has_exited_entry_circle.get(tid, False)
is_loading = (
new_state == "below"
and self._has_been_above.get(tid, False)
and has_exited
and "loading" not in counted_dirs
)
# Unloading: track was EVER below, NOW above (left truck)
is_unloading = (
new_state == "above"
and self._has_been_below.get(tid, False)
and has_exited
and "unloading" not in counted_dirs
)
if is_loading or is_unloading:
# Check spatial distance against all active dedup circles
is_duplicate = False
for circle in self._dedup_circles:
dist = ((cx - circle["x"]) ** 2 + (y1 - circle["y"]) ** 2) ** 0.5
if dist <= self._dedup_radius:
is_duplicate = True
break
if is_duplicate:
continue
# Add this coordinate to the active dedup circles
self._dedup_circles.append({
"x": cx,
"y": y1,
"time": now_t,
"track_id": tid
})
if is_loading:
self._loading_count += 1
counted_dirs.add("loading")
events.append({
"track_id": tid,
"direction": "loading",
"cx": cx,
"cy": y1
})
elif is_unloading:
self._unloading_count += 1
counted_dirs.add("unloading")
events.append({
"track_id": tid,
"direction": "unloading",
"cx": cx,
"cy": y1
})
return events
@property
def loading_count(self) -> int:
return self._loading_count
@property
def unloading_count(self) -> int:
return self._unloading_count
@property
def net_count(self) -> int:
return self._loading_count - self._unloading_count
def reset(self) -> None:
"""Reset all counters (new batch)."""
self._loading_count = 0
self._unloading_count = 0
self._state.clear()
self._has_been_above.clear()
self._has_been_below.clear()
self._counted.clear()
self._entry_points.clear()
self._has_exited_entry_circle.clear()
self._dedup_circles.clear()