feat: copy core pipeline modules from karung_counter_semarang
This commit is contained in:
1 parent
7fb017488c
commit
71c1ae1cf2
8 files changed
+1542
No files matched your search
+401
@@ -0,0 +1,401 @@
|
||||
"""Batch lifecycle manager — 4-state machine for truck+sack sessions.
|
||||
|
||||
State machine:
|
||||
IDLE ──truck detected──▶ TRUCK_STABILIZING ──stable 5s──▶ COUNTING_SACKS
|
||||
▲ │ truck gone │ ▲
|
||||
│ └──────▶ IDLE │ │
|
||||
│ │ │
|
||||
│ 10s no sack activity │ │ sacks resume
|
||||
│ ▼ │
|
||||
│ WAITING_FOR_ACTIVITY
|
||||
│ (batch OPEN)
|
||||
│ │
|
||||
└──────────────── truck leaves ─────────────────────────────┘
|
||||
(batch finalized)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class BatchState(Enum):
|
||||
IDLE = auto()
|
||||
TRUCK_STABILIZING = auto()
|
||||
COUNTING_SACKS = auto()
|
||||
WAITING_FOR_ACTIVITY = auto() # Paused: no sacks, but truck still here
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchRecord:
|
||||
"""Completed batch summary."""
|
||||
|
||||
batch_id: int
|
||||
start_time: float
|
||||
end_time: float
|
||||
loading_count: int
|
||||
unloading_count: int
|
||||
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
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> float:
|
||||
return self.end_time - self.start_time
|
||||
|
||||
|
||||
class BatchLifecycleManager:
|
||||
"""Manages batch transitions based on truck stability and sack activity.
|
||||
|
||||
State flow:
|
||||
- IDLE: waiting for truck to appear in ROI polygon
|
||||
- TRUCK_STABILIZING: truck seen, tracking centroid stability
|
||||
- COUNTING_SACKS: actively counting sacks crossing line
|
||||
- WAITING_FOR_ACTIVITY: sacks idle, but truck still present — batch stays open
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stabilize_seconds: float = 5.0,
|
||||
stabilize_threshold_px: float = 15.0,
|
||||
sack_idle_timeout: float = 10.0,
|
||||
min_batch_duration: float = 30.0,
|
||||
truck_gone_tolerance: float = 3.0,
|
||||
timeout_seconds: float = 30.0, # kept for backward compat (unused)
|
||||
) -> None:
|
||||
# Tunable parameters
|
||||
self._stabilize_seconds = stabilize_seconds
|
||||
self._stabilize_threshold_px = stabilize_threshold_px
|
||||
self._sack_idle_timeout = sack_idle_timeout
|
||||
self._min_batch_duration = min_batch_duration
|
||||
self._truck_gone_tolerance = truck_gone_tolerance
|
||||
|
||||
# Internal state
|
||||
self._state = BatchState.IDLE
|
||||
self._batch_counter = 0
|
||||
self._current_batch_id: int | None = None
|
||||
self._batch_start_time = 0.0
|
||||
self._history: list[BatchRecord] = []
|
||||
|
||||
# Truck stabilization tracking
|
||||
self._truck_first_seen_time = 0.0
|
||||
self._truck_last_centroid: tuple[float, float] | None = None
|
||||
self._truck_stable_since = 0.0
|
||||
self._truck_is_stable = False
|
||||
self._truck_last_seen = 0.0 # timestamp when truck was last detected
|
||||
|
||||
# Sack activity tracking (for pause condition)
|
||||
self._last_sack_crossing_time = 0.0
|
||||
self._last_sack_seen_in_area_time = 0.0
|
||||
|
||||
# Waiting state tracking
|
||||
self._waiting_since = 0.0
|
||||
|
||||
# Callbacks
|
||||
self._on_batch_start: list = []
|
||||
self._on_batch_end: list = []
|
||||
|
||||
# -- Public API: Register callbacks --
|
||||
|
||||
def on_batch_start(self, callback) -> None:
|
||||
"""Register callback: fn(batch_id, timestamp)."""
|
||||
self._on_batch_start.append(callback)
|
||||
|
||||
def on_batch_end(self, callback) -> None:
|
||||
"""Register callback: fn(BatchRecord)."""
|
||||
self._on_batch_end.append(callback)
|
||||
|
||||
# -- Public API: State update methods --
|
||||
|
||||
def update_truck(
|
||||
self,
|
||||
truck_detected: bool,
|
||||
truck_centroid: tuple[float, float] | None,
|
||||
timestamp: float,
|
||||
) -> None:
|
||||
"""Called during IDLE, TRUCK_STABILIZING, and WAITING_FOR_ACTIVITY states.
|
||||
|
||||
Args:
|
||||
truck_detected: whether a truck is detected in the ROI polygon
|
||||
truck_centroid: (cx, cy) of the truck bounding box, or None
|
||||
timestamp: current time.time()
|
||||
"""
|
||||
if self._state == BatchState.IDLE:
|
||||
if truck_detected and truck_centroid is not None:
|
||||
# Transition to STABILIZING
|
||||
self._state = BatchState.TRUCK_STABILIZING
|
||||
self._truck_first_seen_time = timestamp
|
||||
self._truck_last_centroid = truck_centroid
|
||||
self._truck_stable_since = timestamp
|
||||
self._truck_is_stable = False
|
||||
self._truck_last_seen = timestamp
|
||||
print(f"[BATCH] Truk terdeteksi di area. Memantau stabilitas...")
|
||||
if self._stabilize_seconds <= 0.0:
|
||||
self._truck_is_stable = True
|
||||
print(f"[BATCH] Instant start batch (stabilize_seconds <= 0). Memulai counting...")
|
||||
self._start_batch(timestamp)
|
||||
|
||||
elif self._state == BatchState.TRUCK_STABILIZING:
|
||||
if truck_detected:
|
||||
self._truck_last_seen = timestamp
|
||||
|
||||
# Check if truck has been gone for too long (tolerance)
|
||||
time_since_last_seen = timestamp - self._truck_last_seen
|
||||
if not truck_detected and time_since_last_seen >= self._truck_gone_tolerance:
|
||||
print(f"[BATCH] Truk hilang selama {time_since_last_seen:.1f}s. Kembali ke IDLE.")
|
||||
self._state = BatchState.IDLE
|
||||
self._truck_last_centroid = None
|
||||
self._truck_is_stable = False
|
||||
return
|
||||
|
||||
if truck_centroid is not None and self._truck_last_centroid is not None:
|
||||
# Calculate centroid displacement
|
||||
dx = truck_centroid[0] - self._truck_last_centroid[0]
|
||||
dy = truck_centroid[1] - self._truck_last_centroid[1]
|
||||
displacement = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if displacement > self._stabilize_threshold_px:
|
||||
# Truck moved too much -> reset stability timer
|
||||
self._truck_stable_since = timestamp
|
||||
self._truck_is_stable = False
|
||||
|
||||
self._truck_last_centroid = truck_centroid
|
||||
|
||||
# Check if stable long enough
|
||||
stable_duration = timestamp - self._truck_stable_since
|
||||
if stable_duration >= self._stabilize_seconds:
|
||||
if not self._truck_is_stable:
|
||||
self._truck_is_stable = True
|
||||
print(f"[BATCH] Truk stabil selama {stable_duration:.1f}s. Memulai counting...")
|
||||
self._start_batch(timestamp)
|
||||
|
||||
elif self._state == BatchState.WAITING_FOR_ACTIVITY:
|
||||
if truck_detected:
|
||||
self._truck_last_seen = timestamp
|
||||
|
||||
# Check if truck has been gone for tolerance period
|
||||
time_since_last_seen = timestamp - self._truck_last_seen
|
||||
if not truck_detected and time_since_last_seen >= self._truck_gone_tolerance:
|
||||
# Truck has truly left! NOW we finalize the batch.
|
||||
wait_duration = timestamp - self._waiting_since
|
||||
print(
|
||||
f"[BATCH] Truk pergi setelah menunggu {wait_duration:.0f}s. "
|
||||
f"Batch selesai."
|
||||
)
|
||||
self._end_batch(timestamp, self._pending_loading, self._pending_unloading)
|
||||
|
||||
def update_sacks(
|
||||
self,
|
||||
has_crossing_event: bool,
|
||||
sacks_in_area_count: int,
|
||||
timestamp: float,
|
||||
loading_count: int = 0,
|
||||
unloading_count: int = 0,
|
||||
) -> None:
|
||||
"""Called during COUNTING_SACKS and WAITING_FOR_ACTIVITY states.
|
||||
|
||||
Args:
|
||||
has_crossing_event: True if a sack crossed the counting line this frame
|
||||
sacks_in_area_count: number of sacks currently detected in truck area
|
||||
timestamp: current time.time()
|
||||
loading_count: current cumulative loading count
|
||||
unloading_count: current cumulative unloading count
|
||||
"""
|
||||
# WAITING_FOR_ACTIVITY: if sacks appear again, resume counting in the SAME batch
|
||||
if self._state == BatchState.WAITING_FOR_ACTIVITY:
|
||||
if has_crossing_event or sacks_in_area_count > 0:
|
||||
wait_duration = timestamp - self._waiting_since
|
||||
print(
|
||||
f"[BATCH] Aktivitas karung terdeteksi setelah {wait_duration:.0f}s menunggu. "
|
||||
f"Melanjutkan counting batch #{self._current_batch_id}..."
|
||||
)
|
||||
self._state = BatchState.COUNTING_SACKS
|
||||
self._last_sack_crossing_time = timestamp
|
||||
self._last_sack_seen_in_area_time = timestamp
|
||||
# Fall through to counting logic below
|
||||
else:
|
||||
return
|
||||
|
||||
if self._state != BatchState.COUNTING_SACKS:
|
||||
return
|
||||
|
||||
# Update activity timers
|
||||
if has_crossing_event:
|
||||
self._last_sack_crossing_time = timestamp
|
||||
|
||||
if sacks_in_area_count > 0:
|
||||
self._last_sack_seen_in_area_time = timestamp
|
||||
|
||||
# Store latest counts for when batch eventually ends
|
||||
self._pending_loading = loading_count
|
||||
self._pending_unloading = unloading_count
|
||||
|
||||
# Check pause condition: no sack activity for timeout period
|
||||
batch_duration = timestamp - self._batch_start_time
|
||||
time_since_last_crossing = timestamp - self._last_sack_crossing_time
|
||||
time_since_last_sack_seen = timestamp - self._last_sack_seen_in_area_time
|
||||
|
||||
if (
|
||||
batch_duration >= self._min_batch_duration
|
||||
and time_since_last_crossing >= self._sack_idle_timeout
|
||||
and time_since_last_sack_seen >= self._sack_idle_timeout
|
||||
):
|
||||
print(
|
||||
f"[BATCH] Tidak ada aktivitas karung selama {self._sack_idle_timeout}s. "
|
||||
f"Menunggu truk pergi atau palet selanjutnya..."
|
||||
)
|
||||
self._state = BatchState.WAITING_FOR_ACTIVITY
|
||||
self._waiting_since = timestamp
|
||||
self._truck_last_seen = timestamp # Reset agar tolerance timer mulai dari 0, bukan dari awal batch
|
||||
|
||||
# -- Public API: Backward-compatible update (legacy) --
|
||||
|
||||
def update(
|
||||
self,
|
||||
truck_detected: bool,
|
||||
timestamp: float,
|
||||
loading_count: int = 0,
|
||||
unloading_count: int = 0,
|
||||
) -> None:
|
||||
"""Legacy update method — kept for backward compatibility."""
|
||||
if self._state in (BatchState.IDLE, BatchState.TRUCK_STABILIZING):
|
||||
self.update_truck(truck_detected, None, timestamp)
|
||||
elif self._state in (BatchState.COUNTING_SACKS, BatchState.WAITING_FOR_ACTIVITY):
|
||||
self.update_sacks(
|
||||
has_crossing_event=False,
|
||||
sacks_in_area_count=1 if truck_detected else 0,
|
||||
timestamp=timestamp,
|
||||
loading_count=loading_count,
|
||||
unloading_count=unloading_count,
|
||||
)
|
||||
|
||||
# -- Properties --
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""Return current state as human-readable string."""
|
||||
return self._state.name
|
||||
|
||||
@property
|
||||
def current_batch_id(self) -> int | None:
|
||||
return self._current_batch_id
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""True during COUNTING or WAITING (batch is still open)."""
|
||||
return self._state in (BatchState.COUNTING_SACKS, BatchState.WAITING_FOR_ACTIVITY)
|
||||
|
||||
@property
|
||||
def is_counting(self) -> bool:
|
||||
"""True only during active sack counting."""
|
||||
return self._state == BatchState.COUNTING_SACKS
|
||||
|
||||
@property
|
||||
def is_waiting(self) -> bool:
|
||||
"""True when paused waiting for next pallet or truck departure."""
|
||||
return self._state == BatchState.WAITING_FOR_ACTIVITY
|
||||
|
||||
@property
|
||||
def is_stabilizing(self) -> bool:
|
||||
return self._state == BatchState.TRUCK_STABILIZING
|
||||
|
||||
@property
|
||||
def history(self) -> list[BatchRecord]:
|
||||
return list(self._history)
|
||||
|
||||
@property
|
||||
def batch_duration(self) -> float:
|
||||
"""Duration of current batch in seconds (0 if not active)."""
|
||||
if not self.is_active:
|
||||
return 0.0
|
||||
return time.time() - self._batch_start_time
|
||||
|
||||
@property
|
||||
def time_since_last_sack_activity(self) -> float:
|
||||
"""Seconds since last sack crossed line or seen in area."""
|
||||
if not self.is_active:
|
||||
return 0.0
|
||||
now = time.time()
|
||||
last_activity = max(self._last_sack_crossing_time, self._last_sack_seen_in_area_time)
|
||||
return now - last_activity if last_activity > 0 else 0.0
|
||||
|
||||
@property
|
||||
def waiting_duration(self) -> float:
|
||||
"""How long we've been in WAITING_FOR_ACTIVITY state."""
|
||||
if self._state != BatchState.WAITING_FOR_ACTIVITY:
|
||||
return 0.0
|
||||
return time.time() - self._waiting_since
|
||||
|
||||
@property
|
||||
def stabilize_progress(self) -> float:
|
||||
"""Progress of truck stabilization (0.0 to 1.0)."""
|
||||
if self._state != BatchState.TRUCK_STABILIZING:
|
||||
return 0.0
|
||||
if self._stabilize_seconds <= 0.0:
|
||||
return 1.0
|
||||
elapsed = time.time() - self._truck_stable_since
|
||||
return min(1.0, elapsed / self._stabilize_seconds)
|
||||
|
||||
def resume_batch(
|
||||
self,
|
||||
batch_id: int,
|
||||
start_time: float,
|
||||
loading_count: int,
|
||||
unloading_count: int,
|
||||
) -> None:
|
||||
"""Resume a previously finalized batch."""
|
||||
self._current_batch_id = batch_id
|
||||
self._batch_counter = max(self._batch_counter, batch_id)
|
||||
self._batch_start_time = start_time
|
||||
self._pending_loading = loading_count
|
||||
self._pending_unloading = unloading_count
|
||||
self._state = BatchState.COUNTING_SACKS
|
||||
|
||||
# Pop from history if it was just completed
|
||||
if self._history and self._history[-1].batch_id == batch_id:
|
||||
self._history.pop()
|
||||
|
||||
# -- Private methods --
|
||||
|
||||
def _start_batch(self, timestamp: float) -> None:
|
||||
self._batch_counter += 1
|
||||
self._current_batch_id = self._batch_counter
|
||||
self._batch_start_time = timestamp
|
||||
self._last_sack_crossing_time = timestamp # Grace period
|
||||
self._last_sack_seen_in_area_time = timestamp # Grace period
|
||||
self._pending_loading = 0
|
||||
self._pending_unloading = 0
|
||||
self._state = BatchState.COUNTING_SACKS
|
||||
for cb in self._on_batch_start:
|
||||
cb(self._current_batch_id, timestamp)
|
||||
|
||||
def _end_batch(
|
||||
self,
|
||||
timestamp: float,
|
||||
loading_count: int,
|
||||
unloading_count: int,
|
||||
) -> None:
|
||||
record = BatchRecord(
|
||||
batch_id=self._current_batch_id or 0,
|
||||
start_time=self._batch_start_time,
|
||||
end_time=timestamp,
|
||||
loading_count=loading_count,
|
||||
unloading_count=unloading_count,
|
||||
)
|
||||
self._history.append(record)
|
||||
self._state = BatchState.IDLE
|
||||
self._current_batch_id = None
|
||||
self._truck_last_centroid = None
|
||||
self._truck_is_stable = False
|
||||
for cb in self._on_batch_end:
|
||||
cb(record)
|
||||
Reference in new issue
Block a user