feat: initialize managed portal

This commit is contained in:
Yoilun
2026-04-27 10:04:36 +08:00
commit d4e351df71
145 changed files with 13425 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Runtime modules for the store dwell alert service."""

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
from app.modules.identity_resolver import build_color_signature
def filter_person_detections(detections: list[dict]) -> list[dict]:
return [item for item in detections if item["class_name"] == "person"]
def extract_tracked_people(results: list) -> list[dict]:
tracked_people: list[dict] = []
for result in results:
boxes = getattr(result, "boxes", None)
if boxes is None:
continue
for box in boxes:
class_name = result.names[int(box.cls[0])]
if class_name != "person":
continue
tracked_people.append(
{
"track_id": int(box.id[0]) if getattr(box, "id", None) is not None else None,
"class_name": class_name,
"confidence": float(box.conf[0]),
"xyxy": [float(value) for value in box.xyxy[0]],
}
)
return tracked_people
def attach_track_signatures(frame, tracked_people: list[dict]) -> list[dict]:
if frame is None:
return tracked_people
frame_height = len(frame)
frame_width = len(frame[0]) if frame_height else 0
enriched: list[dict] = []
for item in tracked_people:
x1, y1, x2, y2 = [int(value) for value in item["xyxy"]]
x1 = max(0, min(frame_width, x1))
x2 = max(0, min(frame_width, x2))
y1 = max(0, min(frame_height, y1))
y2 = max(0, min(frame_height, y2))
if y2 > y1 and x2 > x1:
try:
crop = frame[y1:y2, x1:x2]
except TypeError:
crop = [row[x1:x2] for row in frame[y1:y2]]
else:
crop = None
enriched.append({**item, "signature": build_color_signature(crop)})
return enriched
class YOLOTrackerAdapter:
def __init__(
self,
model_name: str = "yolo11n.pt",
conf: float = 0.25,
tracker: str = "botsort.yaml",
model_factory=None,
) -> None:
self.model_name = model_name
self.conf = conf
self.tracker = tracker
self.model_factory = model_factory
self.model = None
def load(self) -> None:
if self.model_factory is None:
try:
from ultralytics import YOLO # type: ignore
except ImportError as exc: # pragma: no cover - depends on runtime deps
raise RuntimeError("ultralytics is required for YOLO tracking") from exc
self.model_factory = YOLO
self.model = self.model_factory(self.model_name)
def track(self, frame) -> list[dict]:
if self.model is None:
self.load()
results = self.model.track(
frame,
persist=True,
classes=[0],
verbose=False,
conf=self.conf,
tracker=self.tracker,
)
return attach_track_signatures(frame, extract_tracked_people(results))

View File

@@ -0,0 +1,232 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from app.modules.reporter import floor_half_hour, previous_half_hour_window
@dataclass(slots=True)
class DwellSession:
person_id: str
session_id: str
entered_at: datetime
role: str = "customer"
state: str = "active"
accumulated_dwell_seconds: int = 0
active_started_at: datetime | None = None
last_seen_at: datetime | None = None
pause_started_at: datetime | None = None
closed_at: datetime | None = None
def __post_init__(self) -> None:
if self.active_started_at is None:
self.active_started_at = self.entered_at
if self.last_seen_at is None:
self.last_seen_at = self.entered_at
def dwell_seconds(self, when: datetime | None = None) -> int:
if self.state == "active" and self.active_started_at is not None:
current_time = when or self.last_seen_at or self.entered_at
return self.accumulated_dwell_seconds + max(
0,
int((current_time - self.active_started_at).total_seconds()),
)
return self.accumulated_dwell_seconds
def mark_seen(self, when: datetime) -> None:
if self.state == "paused":
self.active_started_at = when
self.pause_started_at = None
elif self.active_started_at is None:
self.active_started_at = when
self.last_seen_at = when
self.state = "active"
def pause(self, when: datetime) -> None:
if self.state != "active" or self.active_started_at is None:
return
self.accumulated_dwell_seconds += max(
0,
int((when - self.active_started_at).total_seconds()),
)
self.pause_started_at = when
self.last_seen_at = when
self.active_started_at = None
self.state = "paused"
def close_if_expired(self, when: datetime, pause_timeout_seconds: int) -> bool:
if self.pause_started_at is None:
return False
if int((when - self.pause_started_at).total_seconds()) <= pause_timeout_seconds:
return False
self.closed_at = when
self.state = "closed"
return True
def as_event_dict(self, when: datetime | None = None) -> dict:
return {
"person_id": self.person_id,
"session_id": self.session_id,
"role": self.role,
"status": self.state,
"dwell_seconds": self.dwell_seconds(when),
}
class DwellEngine:
def __init__(
self,
camera_id: str,
min_people: int,
min_dwell_seconds: int,
pause_timeout_seconds: int,
alert_cooldown_seconds: int,
) -> None:
self.camera_id = camera_id
self.min_people = min_people
self.min_dwell_seconds = min_dwell_seconds
self.pause_timeout_seconds = pause_timeout_seconds
self.alert_cooldown_seconds = alert_cooldown_seconds
self.sessions: dict[str, DwellSession] = {}
self.closed_sessions: list[DwellSession] = []
self.session_counts: dict[str, int] = {}
self.alert_rearmed = True
self.last_alert_at: datetime | None = None
self.last_report_boundary: datetime | None = None
def _next_session_id(self, person_id: str) -> str:
next_index = self.session_counts.get(person_id, 0) + 1
self.session_counts[person_id] = next_index
return f"{person_id}-s{next_index}"
def _create_session(self, person_id: str, role: str, when: datetime) -> DwellSession:
session = DwellSession(
person_id=person_id,
session_id=self._next_session_id(person_id),
entered_at=when,
role=role,
)
self.sessions[person_id] = session
return session
def process_observations(self, observations: list[dict], when: datetime) -> list[dict]:
events: list[dict] = []
seen_people: set[str] = set()
for observation in observations:
person_id = observation["person_id"]
role = observation.get("role", "customer")
seen_people.add(person_id)
session = self.sessions.get(person_id)
if session is None:
session = self._create_session(person_id, role, when)
else:
session.role = role
session.mark_seen(when)
for person_id, session in list(self.sessions.items()):
if person_id in seen_people:
continue
if session.state == "active":
session.pause(when)
if session.close_if_expired(when, self.pause_timeout_seconds):
self.closed_sessions.append(session)
del self.sessions[person_id]
alert_event = self._build_alert_event(when)
if alert_event is not None:
events.append(alert_event)
report_event = self._build_half_hour_report(when)
if report_event is not None:
events.append(report_event)
return events
def _active_customer_sessions(self, when: datetime) -> list[DwellSession]:
return [
session
for session in self.sessions.values()
if session.role == "customer"
and session.state == "active"
and session.dwell_seconds(when) >= self.min_dwell_seconds
]
def _build_alert_event(self, when: datetime) -> dict | None:
long_stay_sessions = self._active_customer_sessions(when)
if len(long_stay_sessions) < self.min_people:
self.alert_rearmed = True
return None
if not self.alert_rearmed:
return None
self.alert_rearmed = False
self.last_alert_at = when
return {
"event": "long_stay_alert",
"camera_id": self.camera_id,
"ts": when.isoformat(),
"threshold": {
"min_people": self.min_people,
"min_dwell_seconds": self.min_dwell_seconds,
},
"active_long_stay_count": len(long_stay_sessions),
"people": [
session.as_event_dict(when)
for session in sorted(
long_stay_sessions,
key=lambda item: item.dwell_seconds(when),
reverse=True,
)
],
}
def _build_half_hour_report(self, when: datetime) -> dict | None:
boundary = floor_half_hour(when)
if boundary == when and self.last_report_boundary == boundary:
return
if boundary == self.last_report_boundary:
return None
if when < boundary:
return None
window_start, window_end = previous_half_hour_window(when)
active_customers = [
session.as_event_dict(when)
for session in self.sessions.values()
if session.role == "customer" and session.state == "active"
]
closed_customers = [
{
"person_id": session.person_id,
"session_id": session.session_id,
"final_dwell_seconds": session.dwell_seconds(window_end),
}
for session in self.closed_sessions
if session.role == "customer"
and session.closed_at is not None
and window_start < session.closed_at <= window_end
]
staff_seen_count = sum(1 for session in self.sessions.values() if session.role == "staff")
self.last_report_boundary = boundary
return {
"event": "half_hour_report",
"camera_id": self.camera_id,
"window_start": window_start.isoformat(),
"window_end": window_end.isoformat(),
"active_customer_count": len(active_customers),
"active_customers": active_customers,
"closed_customers": closed_customers,
"staff_seen_count": staff_seen_count,
}
def long_stay_count(sessions: list[dict], min_dwell_seconds: int) -> int:
return sum(
1
for item in sessions
if item["role"] == "customer"
and item["state"] == "active"
and item["dwell_seconds"] >= min_dwell_seconds
)

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from math import sqrt
from typing import Iterable
def choose_reentry_match(
paused_people: list[dict],
now_ts: int,
pause_timeout_seconds: int,
min_similarity: float,
) -> str | None:
valid = [
item
for item in paused_people
if now_ts - item["paused_at"] <= pause_timeout_seconds
and item["similarity"] >= min_similarity
]
if not valid:
return None
valid.sort(key=lambda item: (item["similarity"], item["paused_at"]), reverse=True)
return valid[0]["person_id"]
def _average(values: Iterable[float]) -> float:
values = list(values)
if not values:
return 0.0
return sum(values) / len(values)
def build_color_signature(crop) -> list[float]:
if crop is None:
return [0.0, 0.0, 0.0]
height = len(crop)
if height == 0:
return [0.0, 0.0, 0.0]
width = len(crop[0])
if width == 0:
return [0.0, 0.0, 0.0]
blue_values = []
green_values = []
red_values = []
for row in crop:
for pixel in row:
blue_values.append(float(pixel[0]))
green_values.append(float(pixel[1]))
red_values.append(float(pixel[2]))
return [
round(_average(blue_values) / 255.0, 4),
round(_average(green_values) / 255.0, 4),
round(_average(red_values) / 255.0, 4),
]
def signature_similarity(left: list[float], right: list[float]) -> float:
if not left or not right:
return 0.0
distance = sqrt(sum((left[idx] - right[idx]) ** 2 for idx in range(min(len(left), len(right)))))
return max(0.0, 1.0 - distance)
@dataclass(slots=True)
class ActiveIdentity:
person_id: str
track_id: int
signature: list[float]
last_seen_at: datetime
role: str = "customer"
@dataclass(slots=True)
class PausedIdentity:
person_id: str
signature: list[float]
paused_at: datetime
role: str = "customer"
class IdentityResolver:
def __init__(
self,
pause_timeout_seconds: int,
reentry_similarity_threshold: float = 0.92,
) -> None:
self.pause_timeout_seconds = pause_timeout_seconds
self.reentry_similarity_threshold = reentry_similarity_threshold
self.active_by_track: dict[int, ActiveIdentity] = {}
self.paused_by_person: dict[str, PausedIdentity] = {}
self.person_counter = 0
def _next_person_id(self) -> str:
self.person_counter += 1
return f"cust_{self.person_counter:05d}"
def _expire_paused(self, when: datetime) -> None:
expired = [
person_id
for person_id, paused in self.paused_by_person.items()
if int((when - paused.paused_at).total_seconds()) > self.pause_timeout_seconds
]
for person_id in expired:
del self.paused_by_person[person_id]
def _match_paused(self, signature: list[float], when: datetime) -> str | None:
self._expire_paused(when)
best_person_id = None
best_similarity = 0.0
for person_id, paused in self.paused_by_person.items():
similarity = signature_similarity(signature, paused.signature)
if similarity < self.reentry_similarity_threshold:
continue
if similarity > best_similarity:
best_person_id = person_id
best_similarity = similarity
if best_person_id is not None:
del self.paused_by_person[best_person_id]
return best_person_id
def resolve(self, tracks: list[dict], when: datetime) -> list[dict]:
current_track_ids = {
track["track_id"]
for track in tracks
if track.get("track_id") is not None
}
disappeared_track_ids = [
track_id
for track_id in self.active_by_track
if track_id not in current_track_ids
]
for track_id in disappeared_track_ids:
active = self.active_by_track.pop(track_id)
self.paused_by_person[active.person_id] = PausedIdentity(
person_id=active.person_id,
signature=active.signature,
paused_at=when,
role=active.role,
)
observations: list[dict] = []
for track in tracks:
track_id = track.get("track_id")
if track_id is None:
continue
signature = track.get("signature", [0.0, 0.0, 0.0])
active = self.active_by_track.get(track_id)
if active is None:
person_id = self._match_paused(signature, when) or self._next_person_id()
active = ActiveIdentity(
person_id=person_id,
track_id=track_id,
signature=signature,
last_seen_at=when,
role=track.get("role", "customer"),
)
self.active_by_track[track_id] = active
else:
active.signature = signature
active.last_seen_at = when
observations.append(
{
"person_id": active.person_id,
"track_id": track_id,
"role": active.role,
"signature": signature,
}
)
return observations

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
import json
from pathlib import Path
from urllib import request
def build_json_request(url: str, payload: dict, timeout_seconds: float = 5.0) -> request.Request:
data = json.dumps(payload).encode("utf-8")
req = request.Request(url=url, data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.timeout_seconds = timeout_seconds
return req
def append_json_event(path: str | Path, payload: dict) -> None:
output_path = Path(path)
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import datetime, timedelta
def should_emit_half_hour_report(ts: str) -> bool:
dt = datetime.fromisoformat(ts)
return dt.minute in {0, 30} and dt.second == 0
def floor_half_hour(dt: datetime) -> datetime:
minute = 0 if dt.minute < 30 else 30
return dt.replace(minute=minute, second=0, microsecond=0)
def previous_half_hour_window(dt: datetime) -> tuple[datetime, datetime]:
window_end = floor_half_hour(dt)
window_start = window_end - timedelta(minutes=30)
return window_start, window_end

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
import json
from collections import defaultdict, deque
from dataclasses import dataclass
from pathlib import Path
from app.modules.identity_resolver import build_color_signature, signature_similarity
def staff_vote(matches: list[bool], min_hits: int) -> bool:
return sum(1 for item in matches if item) >= min_hits
@dataclass(slots=True)
class StaffEmbedding:
staff_id: str
signature: list[float]
source: str
def _normalize_signature(signature: list[float]) -> list[float]:
if len(signature) < 3:
return [0.0, 0.0, 0.0]
return [round(float(value), 4) for value in signature[:3]]
def load_staff_gallery(gallery_dir: str | Path) -> list[StaffEmbedding]:
path = Path(gallery_dir)
if not path.exists():
return []
embeddings: list[StaffEmbedding] = []
for json_path in sorted(path.glob("*.json")):
raw = json.loads(json_path.read_text(encoding="utf-8"))
if isinstance(raw, dict):
raw = [raw]
for item in raw:
staff_id = item.get("staff_id") or json_path.stem
signature = _normalize_signature(item.get("signature", []))
embeddings.append(
StaffEmbedding(
staff_id=staff_id,
signature=signature,
source=str(json_path),
)
)
image_paths = []
for pattern in ("*.jpg", "*.jpeg", "*.png", "*.bmp", "*.webp"):
image_paths.extend(sorted(path.glob(pattern)))
if not image_paths:
return embeddings
try:
import cv2 # type: ignore
except ImportError: # pragma: no cover - runtime dependency
return embeddings
for image_path in image_paths:
image = cv2.imread(str(image_path))
if image is None:
continue
staff_id = image_path.stem.split("_")[0]
embeddings.append(
StaffEmbedding(
staff_id=staff_id,
signature=build_color_signature(image),
source=str(image_path),
)
)
return embeddings
class StaffMatcher:
def __init__(
self,
gallery: list[StaffEmbedding],
similarity_threshold: float,
min_hits: int,
vote_window: int | None = None,
) -> None:
self.gallery = gallery
self.similarity_threshold = similarity_threshold
self.min_hits = min_hits
self.vote_window = vote_window or max(5, min_hits)
self.votes: dict[str, deque[bool]] = defaultdict(
lambda: deque(maxlen=self.vote_window)
)
def match_signature(self, signature: list[float]) -> StaffEmbedding | None:
best_match = None
best_similarity = 0.0
for embedding in self.gallery:
similarity = signature_similarity(signature, embedding.signature)
if similarity < self.similarity_threshold:
continue
if similarity > best_similarity:
best_match = embedding
best_similarity = similarity
return best_match
def classify(self, observations: list[dict]) -> list[dict]:
classified: list[dict] = []
for observation in observations:
person_id = observation["person_id"]
signature = observation.get("signature", [0.0, 0.0, 0.0])
embedding = self.match_signature(signature)
vote_history = self.votes[person_id]
vote_history.append(embedding is not None)
role = "staff" if staff_vote(list(vote_history), self.min_hits) else "customer"
classified.append(
{
**observation,
"role": role,
"staff_id": embedding.staff_id if embedding is not None else None,
}
)
return classified
def forget(self, person_id: str) -> None:
self.votes.pop(person_id, None)

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
from dataclasses import dataclass
from time import monotonic, sleep
from typing import Any, Callable
@dataclass(slots=True)
class StreamHealth:
max_failures: int
failures: int = 0
@property
def is_disconnected(self) -> bool:
return self.failures >= self.max_failures
def record_failure(self) -> None:
self.failures += 1
def reset(self) -> None:
self.failures = 0
class RTSPFrameReader:
def __init__(
self,
rtsp_url: str,
sample_fps: float,
reconnect_backoff_seconds: float,
capture_factory: Callable[[str], Any] | None = None,
) -> None:
self.rtsp_url = rtsp_url
self.sample_fps = sample_fps
self.reconnect_backoff_seconds = reconnect_backoff_seconds
self.capture_factory = capture_factory
self.health = StreamHealth(max_failures=3)
self.capture = None
self.last_read_at: float | None = None
def open(self) -> None:
if self.capture_factory is None:
try:
import cv2 # type: ignore
except ImportError as exc: # pragma: no cover - depends on runtime deps
raise RuntimeError("opencv-python is required for RTSP reading") from exc
self.capture_factory = cv2.VideoCapture
self.capture = self.capture_factory(self.rtsp_url)
self.health.reset()
def _throttle(self) -> None:
if self.sample_fps <= 0:
return
interval = 1.0 / self.sample_fps
if self.last_read_at is None:
return
remaining = interval - (monotonic() - self.last_read_at)
if remaining > 0:
sleep(remaining)
def read(self):
if self.capture is None:
self.open()
self._throttle()
ok, frame = self.capture.read()
if not ok:
self.health.record_failure()
if self.health.is_disconnected:
self.close()
sleep(self.reconnect_backoff_seconds)
return None
self.health.reset()
self.last_read_at = monotonic()
return frame
def close(self) -> None:
if self.capture is not None and hasattr(self.capture, "release"):
self.capture.release()
self.capture = None
self.last_read_at = None