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 @@
"""People flow analysis package."""

View File

@@ -0,0 +1,141 @@
from __future__ import annotations
from collections import Counter, defaultdict
from statistics import median
from typing import Any
import cv2
import numpy as np
from .models import AttributeConfig, AttributeVote, TrackAttributeSummary, TrackObservation
def age_to_bucket(age: int) -> str:
if age < 18:
return "minor"
if age < 60:
return "adult"
return "senior"
def normalize_gender(raw_gender: str | None) -> str | None:
if not raw_gender:
return None
lowered = raw_gender.strip().lower()
if lowered in {"man", "male"}:
return "male"
if lowered in {"woman", "female"}:
return "female"
return None
class AttributeAggregator:
def __init__(self, config: AttributeConfig) -> None:
self.config = config
self.votes: dict[int, list[AttributeVote]] = defaultdict(list)
self.samples_taken: dict[int, int] = defaultdict(int)
self.last_sampled_frame: dict[int, int] = {}
self._deepface = self._load_deepface() if config.enabled else None
def _load_deepface(self) -> Any:
try:
from deepface import DeepFace
except ImportError as exc:
raise RuntimeError(
"DeepFace is not installed. Install dependencies with `pip install -r requirements.txt`."
) from exc
return DeepFace
def maybe_collect(self, frame: np.ndarray, frame_index: int, track: TrackObservation) -> None:
if self._deepface is None:
return
if self.samples_taken[track.track_id] >= self.config.max_samples_per_track:
return
last_frame = self.last_sampled_frame.get(track.track_id)
if last_frame is not None and frame_index - last_frame < self.config.sample_every_n_frames:
return
x1, y1, x2, y2 = track.bbox
width = x2 - x1
height = y2 - y1
if width < self.config.min_person_box_width or height < self.config.min_person_box_height:
return
crop = self._crop_person(frame, track.bbox)
if crop.size == 0:
return
vote = self._analyze_crop(crop)
self.last_sampled_frame[track.track_id] = frame_index
if vote is None:
return
self.samples_taken[track.track_id] += 1
self.votes[track.track_id].append(vote)
def reset(self) -> None:
self.votes.clear()
self.samples_taken.clear()
self.last_sampled_frame.clear()
def _crop_person(self, frame: np.ndarray, bbox: tuple[int, int, int, int]) -> np.ndarray:
x1, y1, x2, y2 = bbox
height, width = frame.shape[:2]
pad_x = int((x2 - x1) * self.config.person_crop_padding)
pad_y = int((y2 - y1) * self.config.person_crop_padding)
left = max(0, x1 - pad_x)
top = max(0, y1 - pad_y)
right = min(width, x2 + pad_x)
bottom = min(height, y2 + pad_y)
return frame[top:bottom, left:right]
def _analyze_crop(self, crop: np.ndarray) -> AttributeVote | None:
rgb_crop = cv2.cvtColor(crop, cv2.COLOR_BGR2RGB)
try:
analysis = self._deepface.analyze(
img_path=rgb_crop,
actions=["age", "gender"],
detector_backend=self.config.detector_backend,
enforce_detection=self.config.enforce_detection,
silent=True,
)
except Exception:
return None
if isinstance(analysis, list):
if not analysis:
return None
analysis = analysis[0]
age_value = analysis.get("age")
gender_value = normalize_gender(analysis.get("dominant_gender"))
if age_value is None or gender_value is None:
return None
age_int = int(round(float(age_value)))
return AttributeVote(
age=age_int,
age_bucket=age_to_bucket(age_int),
gender=gender_value,
)
def summarize_track(self, track_id: int) -> TrackAttributeSummary | None:
votes = self.votes.get(track_id, [])
if not votes:
return None
age_bucket_counts = Counter(vote.age_bucket for vote in votes)
gender_counts = Counter(vote.gender for vote in votes)
if not age_bucket_counts or not gender_counts:
return None
age_bucket = age_bucket_counts.most_common(1)[0][0]
gender = gender_counts.most_common(1)[0][0]
age_value = int(round(median(vote.age for vote in votes)))
return TrackAttributeSummary(
track_id=track_id,
age=age_value,
age_bucket=age_bucket,
gender=gender,
samples_used=len(votes),
)

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
import yaml
from .models import (
AppConfig,
AttributeConfig,
CountingConfig,
OutputConfig,
RtspConfig,
RuntimeConfig,
YoloConfig,
)
def _read_yaml(config_path: Path) -> dict:
if not config_path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}")
with config_path.open("r", encoding="utf-8") as handle:
loaded = yaml.safe_load(handle) or {}
if not isinstance(loaded, dict):
raise ValueError(f"Config file must contain a mapping: {config_path}")
return loaded
def load_config_document(config_path: Path) -> dict:
return _read_yaml(config_path)
def save_config_document(config_path: Path, payload: dict) -> None:
config_path.parent.mkdir(parents=True, exist_ok=True)
temp_path = config_path.with_suffix(config_path.suffix + ".tmp")
temp_path.write_text(
yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
temp_path.replace(config_path)
def resolve_project_root(config_path: Path) -> Path:
return config_path.expanduser().resolve().parent.parent
def resolve_project_path(project_root: Path, raw_path: str | Path) -> Path:
path = Path(raw_path)
if path.is_absolute():
return path.resolve()
return (project_root.resolve() / path).resolve()
def load_config(config_path: Path) -> AppConfig:
data = _read_yaml(config_path)
config = AppConfig(
yolo=YoloConfig(**data.get("yolo", {})),
counting=CountingConfig(**_normalize_counting_config(data.get("counting", {}))),
attributes=AttributeConfig(**data.get("attributes", {})),
output=OutputConfig(**data.get("output", {})),
rtsp=RtspConfig(**data.get("rtsp", {})),
runtime=RuntimeConfig(**data.get("runtime", {})),
config_path=config_path.resolve(),
)
return config
def _normalize_counting_config(data: dict) -> dict:
normalized = dict(data)
line = normalized.get("line")
if line is not None:
normalized["line"] = tuple(float(value) for value in line)
return normalized
def parse_line_override(raw_line: str) -> tuple[float, float, float, float]:
parts = [part.strip() for part in raw_line.split(",")]
if len(parts) != 4:
raise ValueError("--line must contain exactly four comma-separated values")
return tuple(float(part) for part in parts) # type: ignore[return-value]
def merge_cli_overrides(
config: AppConfig,
line: str | None,
line_mode: str | None,
device: str | None,
save_video: bool | None,
) -> AppConfig:
updated = config
if line:
updated.counting = replace(updated.counting, line=parse_line_override(line))
if line_mode:
updated.counting = replace(updated.counting, line_mode=line_mode)
if device:
updated.yolo = replace(updated.yolo, device=device)
if save_video is not None:
updated.output = replace(updated.output, save_video=save_video)
return updated

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from .models import CountingConfig, CrossingEvent, TrackObservation
def _line_side(
point: tuple[float, float], line: tuple[float, float, float, float]
) -> float:
px, py = point
x1, y1, x2, y2 = line
return (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1)
class LineCrossCounter:
def __init__(self, line: tuple[float, float, float, float], config: CountingConfig) -> None:
self.line = line
self.config = config
self.previous_side: dict[int, float] = {}
self.counted_ids: set[int] = set()
self.crossings: list[CrossingEvent] = []
def update(self, observations: list[TrackObservation]) -> list[CrossingEvent]:
events: list[CrossingEvent] = []
for observation in observations:
side = _line_side(observation.center, self.line)
previous = self.previous_side.get(observation.track_id)
self.previous_side[observation.track_id] = side
if observation.track_id in self.counted_ids:
continue
if previous is None:
continue
if abs(previous) <= self.config.crossing_tolerance or abs(side) <= self.config.crossing_tolerance:
continue
if previous * side >= 0:
continue
direction = "negative_to_positive" if previous < 0 < side else "positive_to_negative"
event = CrossingEvent(track_id=observation.track_id, direction=direction)
self.counted_ids.add(observation.track_id)
self.crossings.append(event)
events.append(event)
return events
def reset(self) -> None:
self.previous_side.clear()
self.counted_ids.clear()
self.crossings.clear()
@property
def total_people(self) -> int:
return len(self.counted_ids)

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
import cv2
from .models import TrackObservation
def ensure_dir(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
def make_video_writer(path: Path, width: int, height: int, fps: float) -> cv2.VideoWriter:
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
return cv2.VideoWriter(str(path), fourcc, fps if fps > 0 else 25.0, (width, height))
def write_json(path: Path, payload: dict) -> None:
with path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=True, indent=2)
def write_csv(path: Path, rows: list[dict]) -> None:
import pandas as pd
dataframe = pd.DataFrame(rows)
dataframe.to_csv(path, index=False)
def write_window_json(windows_dir: Path, latest_path: Path, payload: dict, window_end: datetime) -> Path:
ensure_dir(windows_dir)
ensure_dir(latest_path.parent)
target = windows_dir / f"stats_{window_end.strftime('%Y-%m-%d_%H-%M-%S')}.json"
write_json(target, payload)
write_json(latest_path, payload)
return target
def draw_line(frame, line: tuple[float, float, float, float]) -> None:
x1, y1, x2, y2 = (int(value) for value in line)
cv2.line(frame, (x1, y1), (x2, y2), (0, 255, 255), 2)
def draw_tracks(
frame,
observations: list[TrackObservation],
counted_ids: set[int],
draw_labels: bool,
) -> None:
for observation in observations:
x1, y1, x2, y2 = observation.bbox
color = (0, 200, 0) if observation.track_id in counted_ids else (255, 140, 0)
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
if draw_labels:
label = f"id={observation.track_id} conf={observation.confidence:.2f}"
cv2.putText(
frame,
label,
(x1, max(20, y1 - 6)),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
color,
1,
cv2.LINE_AA,
)
def draw_stats(frame, stats: dict) -> None:
lines = [
f"total_people: {stats['total_people']}",
f"minor: {stats['age_counts']['minor']}",
f"adult: {stats['age_counts']['adult']}",
f"senior: {stats['age_counts']['senior']}",
f"male: {stats['gender_counts']['male']}",
f"female: {stats['gender_counts']['female']}",
f"unknown_attributes: {stats['unknown_attributes']}",
]
x = 12
y = 24
for text in lines:
cv2.putText(
frame,
text,
(x, y),
cv2.FONT_HERSHEY_SIMPLEX,
0.65,
(255, 255, 255),
2,
cv2.LINE_AA,
)
y += 24

View File

@@ -0,0 +1,389 @@
from __future__ import annotations
import json
from argparse import ArgumentParser
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from flask import Flask, jsonify, request, send_file
from .config import (
load_config,
load_config_document,
resolve_project_path,
resolve_project_root,
save_config_document,
)
PROJECT_TYPE = "people_flow_project"
DEFAULT_MANAGE_PORT = 18082
MAX_PREVIEW_LINES = 2000
@dataclass(slots=True)
class ManageContext:
config_path: Path
project_root: Path
def create_app(config_path: str | Path) -> Flask:
resolved_config = Path(config_path).expanduser().resolve()
ctx = ManageContext(
config_path=resolved_config,
project_root=resolve_project_root(resolved_config),
)
app = Flask(__name__)
app.config["MANAGE_CONTEXT"] = ctx
@app.get("/api/manage/health")
def get_health():
return jsonify(
{
"status": "ok",
"project_type": PROJECT_TYPE,
"version": "dev",
"runtime_status": "running",
}
)
@app.get("/api/manage/config")
def get_config():
return jsonify(_config_payload(ctx))
@app.put("/api/manage/config")
def update_config():
payload = request.get_json(silent=True) or {}
rtsp_url = payload.get("rtsp_url")
if not isinstance(rtsp_url, str) or not rtsp_url.strip():
return jsonify({"error": "rtsp_url is required"}), 400
raw = load_config_document(ctx.config_path)
runtime = raw.setdefault("runtime", {})
runtime["rtsp_url"] = rtsp_url.strip()
save_config_document(ctx.config_path, raw)
return jsonify(_config_payload(ctx))
@app.get("/api/manage/summary")
def get_summary():
return jsonify(_build_summary(ctx))
@app.get("/api/manage/windows")
def get_windows():
page = max(_int_arg("page", 1), 1)
page_size = max(_int_arg("page_size", 24), 1)
limit = request.args.get("limit")
items = list(_load_window_stats(ctx))
if limit is not None:
items = items[: max(_int_value(limit), 0)]
start = (page - 1) * page_size
end = start + page_size
return jsonify(
{
"items": items[start:end],
"page": page,
"page_size": page_size,
"total": len(items),
}
)
@app.get("/api/manage/files")
def get_files():
return jsonify({"files": _list_result_files(ctx)})
@app.get("/api/manage/files/preview")
def preview_file():
target = _resolve_sandbox_file(ctx, request.args.get("path", ""))
lines = _tail_lines(target, _bounded_preview_lines(request.args.get("lines")))
return jsonify(
{
"path": _relative_path(ctx, target),
"lines": lines,
"count": len(lines),
}
)
@app.get("/api/manage/files/download")
def download_file():
target = _resolve_sandbox_file(ctx, request.args.get("path", ""))
return send_file(target, as_attachment=True, download_name=target.name)
@app.errorhandler(ValueError)
def handle_value_error(error: ValueError):
return jsonify({"error": str(error)}), 400
@app.errorhandler(FileNotFoundError)
def handle_missing_file(error: FileNotFoundError):
return jsonify({"error": str(error)}), 404
return app
def run_manage_api(
config_path: str | Path,
host: str = "0.0.0.0",
port: int = DEFAULT_MANAGE_PORT,
) -> None:
app = create_app(config_path)
app.run(host=host, port=port)
def parse_args() -> ArgumentParser:
parser = ArgumentParser(description="People flow management API")
parser.add_argument("--config", required=True, help="Path to YAML config file")
parser.add_argument("--host", default="0.0.0.0", help="Host for the management API")
parser.add_argument("--port", type=int, default=DEFAULT_MANAGE_PORT, help="Port for the management API")
return parser
def main() -> int:
parser = parse_args()
args = parser.parse_args()
run_manage_api(args.config, host=args.host, port=args.port)
return 0
def _config_payload(ctx: ManageContext) -> dict:
config = load_config(ctx.config_path)
output_root = resolve_project_path(ctx.project_root, config.runtime.output_dir)
return {
"project_type": PROJECT_TYPE,
"config_path": str(ctx.config_path),
"runtime": {
"rtsp_url": config.runtime.rtsp_url,
"output_dir": str(output_root),
},
"rtsp": {
"output_subdir": config.rtsp.output_subdir,
"window_seconds": config.rtsp.window_seconds,
},
}
def _build_summary(ctx: ManageContext) -> dict:
summary_path, payload = _load_summary_payload(ctx)
all_window_stats = _load_window_stats(ctx)
if payload is None:
latest_json = _latest_json_path(ctx)
return {
"result_type": PROJECT_TYPE,
"headline": "No RTSP summary output yet",
"metrics": {
"latest_path": str(latest_json),
"recent_window_stats": all_window_stats[:24],
"all_window_stats": all_window_stats,
},
}
tracks = payload.get("tracks", [])
direction_counts: dict[str, int] = {}
if isinstance(tracks, list):
for item in tracks:
if not isinstance(item, dict):
continue
direction = _string_value(item.get("direction"))
if not direction:
continue
direction_counts[direction] = direction_counts.get(direction, 0) + 1
total_people = _int_value(payload.get("total_people"))
window_end = _string_value(payload.get("window_end"))
return {
"result_type": PROJECT_TYPE,
"headline": f"Latest window counted {total_people} people",
"last_result_time": window_end,
"metrics": {
"summary_path": str(summary_path) if summary_path else "",
"window_start": _string_value(payload.get("window_start")),
"window_end": window_end,
"total_people": total_people,
"direction_counts": direction_counts,
"age_counts": _map_string_int(payload.get("age_counts")),
"gender_counts": _map_string_int(payload.get("gender_counts")),
"unknown_attributes": _int_value(payload.get("unknown_attributes")),
"recent_window_stats": all_window_stats[:24],
"all_window_stats": all_window_stats,
},
}
def _load_summary_payload(ctx: ManageContext) -> tuple[Path | None, dict | None]:
candidates: list[Path] = []
latest_json = _latest_json_path(ctx)
if latest_json.exists():
candidates.append(latest_json)
window_files = _window_files(ctx)
if window_files:
candidates.extend(window_files[:1])
for candidate in candidates:
try:
payload = json.loads(candidate.read_text(encoding="utf-8"))
except FileNotFoundError:
continue
except json.JSONDecodeError as exc:
raise ValueError(f"invalid summary json: {candidate}") from exc
if isinstance(payload, dict):
return candidate, payload
return None, None
def _load_window_stats(ctx: ManageContext) -> list[dict]:
stats: list[dict] = []
for path in _window_files(ctx):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError):
continue
if not isinstance(payload, dict):
continue
stats.append(
{
"window_start": _string_value(payload.get("window_start")),
"window_end": _string_value(payload.get("window_end")),
"total_people": _int_value(payload.get("total_people")),
"age_counts": _map_string_int(payload.get("age_counts")),
"gender_counts": _map_string_int(payload.get("gender_counts")),
"unknown_attributes": _int_value(payload.get("unknown_attributes")),
}
)
stats.sort(key=lambda item: item["window_end"], reverse=True)
return stats
def _list_result_files(ctx: ManageContext) -> list[dict]:
files: list[dict] = []
for path, label in (
(_latest_json_path(ctx), "Latest Summary"),
(_runtime_log_path(ctx), "Runtime Log"),
):
if path.exists() and path.is_file():
files.append(_build_result_file(ctx, path, label))
for path in _window_files(ctx):
if path.exists() and path.is_file():
files.append(_build_result_file(ctx, path, "Window Summary"))
return files
def _build_result_file(ctx: ManageContext, path: Path, label: str) -> dict:
info = path.stat()
return {
"path": _relative_path(ctx, path),
"name": path.name,
"label": label,
"kind": path.suffix.lstrip(".").lower(),
"size": info.st_size,
"modified_at": datetime.fromtimestamp(info.st_mtime).astimezone().isoformat(),
}
def _output_root(ctx: ManageContext) -> Path:
config = load_config(ctx.config_path)
return resolve_project_path(ctx.project_root, config.runtime.output_dir)
def _rtsp_output_root(ctx: ManageContext) -> Path:
config = load_config(ctx.config_path)
return _output_root(ctx) / config.rtsp.output_subdir
def _latest_json_path(ctx: ManageContext) -> Path:
return _rtsp_output_root(ctx) / "latest.json"
def _windows_dir(ctx: ManageContext) -> Path:
return _rtsp_output_root(ctx) / "windows"
def _runtime_log_path(ctx: ManageContext) -> Path:
return _output_root(ctx) / "rtsp_run.log"
def _window_files(ctx: ManageContext) -> list[Path]:
windows_dir = _windows_dir(ctx)
if not windows_dir.exists():
return []
return sorted(
[path for path in windows_dir.iterdir() if path.is_file()],
key=lambda path: path.name,
reverse=True,
)
def _resolve_sandbox_file(ctx: ManageContext, raw_path: str) -> Path:
relative = raw_path.strip().lstrip("/")
if not relative:
raise ValueError("path is required")
target = (ctx.project_root / relative).resolve()
project_root = ctx.project_root.resolve()
if target != project_root and project_root not in target.parents:
raise ValueError("invalid file path")
if not target.exists() or not target.is_file():
raise FileNotFoundError(relative)
return target
def _relative_path(ctx: ManageContext, target: Path) -> str:
return target.resolve().relative_to(ctx.project_root.resolve()).as_posix()
def _tail_lines(path: Path, line_count: int) -> list[str]:
lines: list[str] = []
with path.open("r", encoding="utf-8") as handle:
for raw_line in handle:
lines.append(raw_line.rstrip("\n"))
if len(lines) > line_count:
lines = lines[1:]
return lines
def _bounded_preview_lines(raw_value: str | None) -> int:
if raw_value is None:
return 200
value = _int_value(raw_value)
if value <= 0:
return 200
return min(value, MAX_PREVIEW_LINES)
def _int_arg(name: str, default: int) -> int:
value = request.args.get(name)
if value is None:
return default
return _int_value(value)
def _string_value(value) -> str:
if value is None:
return ""
return str(value)
def _int_value(value) -> int:
if value is None:
return 0
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
try:
return int(str(value).strip())
except ValueError:
return 0
def _map_string_int(value) -> dict[str, int]:
if not isinstance(value, dict):
return {}
return {str(key): _int_value(raw) for key, raw in value.items()}
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,105 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class YoloConfig:
model_path: str = "yolo11n.pt"
tracker: str = "botsort.yaml"
conf: float = 0.35
iou: float = 0.5
imgsz: int = 1280
device: str = "cuda:0"
@dataclass
class CountingConfig:
line: tuple[float, float, float, float] = (0.1, 0.55, 0.9, 0.55)
line_mode: str = "normalized"
crossing_tolerance: float = 12.0
def to_pixel_line(self, width: int, height: int) -> tuple[float, float, float, float]:
x1, y1, x2, y2 = self.line
if self.line_mode == "pixel":
return x1, y1, x2, y2
return x1 * width, y1 * height, x2 * width, y2 * height
@dataclass
class AttributeConfig:
enabled: bool = True
sample_every_n_frames: int = 12
max_samples_per_track: int = 5
min_person_box_width: int = 80
min_person_box_height: int = 160
person_crop_padding: float = 0.15
detector_backend: str = "retinaface"
enforce_detection: bool = False
@dataclass
class OutputConfig:
save_video: bool = True
save_json: bool = True
save_csv: bool = True
draw_boxes: bool = True
draw_labels: bool = True
@dataclass
class RtspConfig:
sample_interval_seconds: float = 1.0
window_seconds: int = 1800
reconnect_delay_seconds: float = 5.0
stream_open_timeout_seconds: float = 10.0
idle_sleep_seconds: float = 0.05
output_subdir: str = "rtsp_stream"
@dataclass
class RuntimeConfig:
rtsp_url: str = "rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream"
output_dir: str = "outputs"
@dataclass
class AppConfig:
yolo: YoloConfig = field(default_factory=YoloConfig)
counting: CountingConfig = field(default_factory=CountingConfig)
attributes: AttributeConfig = field(default_factory=AttributeConfig)
output: OutputConfig = field(default_factory=OutputConfig)
rtsp: RtspConfig = field(default_factory=RtspConfig)
runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
config_path: Path | None = None
@dataclass
class TrackObservation:
track_id: int
bbox: tuple[int, int, int, int]
confidence: float
center: tuple[float, float]
@dataclass
class CrossingEvent:
track_id: int
direction: str
@dataclass
class AttributeVote:
age: int
age_bucket: str
gender: str
@dataclass
class TrackAttributeSummary:
track_id: int
age: int
age_bucket: str
gender: str
samples_used: int

View File

@@ -0,0 +1,445 @@
from __future__ import annotations
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
import cv2
from .attributes import AttributeAggregator
from .counting import LineCrossCounter
from .io_utils import (
draw_line,
draw_stats,
draw_tracks,
ensure_dir,
make_video_writer,
write_csv,
write_json,
write_window_json,
)
from .models import AppConfig
from .tracking import extract_person_tracks
SUPPORTED_EXTENSIONS = {".mp4", ".mov", ".mkv", ".avi"}
def discover_videos(root: Path, pattern: str = "*.mp4") -> list[Path]:
if not root.exists():
raise FileNotFoundError(f"Input directory not found: {root}")
videos = [
path
for path in root.rglob(pattern)
if path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS
]
return sorted(videos)
class PeopleFlowPipeline:
def __init__(self, config: AppConfig, output_root: Path) -> None:
self.config = config
self.output_root = ensure_dir(output_root)
self.model = self._load_model()
def _load_model(self) -> Any:
try:
from ultralytics import YOLO
except ImportError as exc:
raise RuntimeError(
"Ultralytics is not installed. Install dependencies with `pip install -r requirements.txt`."
) from exc
return YOLO(self.config.yolo.model_path)
def get_rtsp_output_paths(self) -> dict[str, Path]:
root = ensure_dir(self.output_root / self.config.rtsp.output_subdir)
windows = ensure_dir(root / "windows")
latest_json = root / "latest.json"
return {"root": root, "windows": windows, "latest_json": latest_json}
def process_batch(self, videos: list[Path]) -> dict:
rows: list[dict] = []
for video_path in videos:
rows.append(self.process_video(video_path))
csv_path = self.output_root / "batch_summary.csv"
if self.config.output.save_csv:
csv_rows = [
{
"video_name": row["video_name"],
"video_path": row["video_path"],
"total_people": row["total_people"],
"minor": row["age_counts"]["minor"],
"adult": row["age_counts"]["adult"],
"senior": row["age_counts"]["senior"],
"male": row["gender_counts"]["male"],
"female": row["gender_counts"]["female"],
"unknown_attributes": row["unknown_attributes"],
"json_path": row["json_path"],
"video_output_path": row.get("video_output_path"),
}
for row in rows
]
write_csv(csv_path, csv_rows)
return {"videos": rows, "csv_path": str(csv_path)}
def process_video(self, video_path: Path) -> dict:
if not video_path.exists():
raise FileNotFoundError(f"Video file not found: {video_path}")
capture = cv2.VideoCapture(str(video_path))
if not capture.isOpened():
raise RuntimeError(f"Failed to open video: {video_path}")
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
fps = float(capture.get(cv2.CAP_PROP_FPS) or 25.0)
pixel_line = self.config.counting.to_pixel_line(width=width, height=height)
video_output_dir = ensure_dir(self.output_root / video_path.stem)
video_output_path = video_output_dir / f"{video_path.stem}.annotated.mp4"
json_path = video_output_dir / f"{video_path.stem}.json"
writer = None
if self.config.output.save_video:
writer = make_video_writer(video_output_path, width=width, height=height, fps=fps)
counter = LineCrossCounter(pixel_line, self.config.counting)
attributes = AttributeAggregator(self.config.attributes)
frame_index = 0
while True:
ok, frame = capture.read()
if not ok:
break
observations = self._track_frame(frame)
for observation in observations:
attributes.maybe_collect(frame=frame, frame_index=frame_index, track=observation)
counter.update(observations)
if writer is not None:
frame_stats = self._build_live_stats(counter, attributes)
annotated = frame.copy()
draw_line(annotated, pixel_line)
if self.config.output.draw_boxes:
draw_tracks(
annotated,
observations=observations,
counted_ids=counter.counted_ids,
draw_labels=self.config.output.draw_labels,
)
draw_stats(annotated, frame_stats)
writer.write(annotated)
frame_index += 1
capture.release()
if writer is not None:
writer.release()
summary = self._finalize_summary(video_path, counter, attributes, json_path)
if not self.config.output.save_video:
summary["video_output_path"] = None
else:
summary["video_output_path"] = str(video_output_path)
return summary
def process_rtsp(self, source: str) -> dict:
rtsp_paths = self.get_rtsp_output_paths()
sample_interval = max(float(self.config.rtsp.sample_interval_seconds), 0.01)
window_seconds = max(int(self.config.rtsp.window_seconds), 1)
reconnect_delay = max(float(self.config.rtsp.reconnect_delay_seconds), 0.1)
open_timeout_seconds = max(float(self.config.rtsp.stream_open_timeout_seconds), 1.0)
idle_sleep = max(float(self.config.rtsp.idle_sleep_seconds), 0.0)
window_index = 0
process_started_at = datetime.now().astimezone()
window_start = datetime.now().astimezone()
window_end = window_start + timedelta(seconds=window_seconds)
last_processed_at = 0.0
last_processed_wall_time: datetime | None = None
next_heartbeat_at = time.monotonic() + 60.0
frame_index = 0
capture = None
pixel_line = None
counter = None
attributes = AttributeAggregator(self.config.attributes)
try:
while True:
now = datetime.now().astimezone()
while now >= window_end:
payload = self._build_rtsp_summary(
source=source,
window_index=window_index,
window_start=window_start,
window_end=window_end,
counter=counter,
attributes=attributes,
)
json_path = write_window_json(
rtsp_paths["windows"],
rtsp_paths["latest_json"],
payload,
window_end,
)
print(f"window_json={json_path}", flush=True)
print(f"window_total_people={payload['total_people']}", flush=True)
window_index += 1
window_start = window_end
window_end = window_start + timedelta(seconds=window_seconds)
if counter is not None:
counter.reset()
attributes.reset()
now = datetime.now().astimezone()
if capture is None or not capture.isOpened():
capture = self._open_rtsp_capture(source, open_timeout_seconds)
if capture is None:
time.sleep(reconnect_delay)
continue
ok, frame = capture.read()
if not ok or frame is None:
capture.release()
capture = None
time.sleep(reconnect_delay)
continue
if pixel_line is None:
height, width = frame.shape[:2]
pixel_line = self.config.counting.to_pixel_line(width=width, height=height)
counter = LineCrossCounter(pixel_line, self.config.counting)
current_time = time.monotonic()
if current_time - last_processed_at < sample_interval:
if idle_sleep > 0:
time.sleep(idle_sleep)
continue
last_processed_at = current_time
observations = self._track_frame(frame)
for observation in observations:
attributes.maybe_collect(frame=frame, frame_index=frame_index, track=observation)
if counter is not None:
counter.update(observations)
if current_time >= next_heartbeat_at:
self._print_rtsp_heartbeat(
process_started_at=process_started_at,
window_index=window_index,
frame_index=frame_index + 1,
counter=counter,
attributes=attributes,
last_processed_wall_time=now,
)
next_heartbeat_at = current_time + 60.0
last_processed_wall_time = now
frame_index += 1
except KeyboardInterrupt:
pass
finally:
if capture is not None:
capture.release()
return {
"rtsp_output_dir": str(rtsp_paths["root"]),
"latest_json": str(rtsp_paths["latest_json"]),
}
def _track_frame(self, frame) -> list:
results = self.model.track(
frame,
persist=True,
tracker=self.config.yolo.tracker,
conf=self.config.yolo.conf,
iou=self.config.yolo.iou,
imgsz=self.config.yolo.imgsz,
device=self.config.yolo.device,
verbose=False,
classes=[0],
)
result = results[0] if isinstance(results, list) else results
return extract_person_tracks(result)
def _open_rtsp_capture(self, source: str, timeout_seconds: float):
capture = cv2.VideoCapture()
open_timeout = getattr(cv2, "CAP_PROP_OPEN_TIMEOUT_MSEC", None)
read_timeout = getattr(cv2, "CAP_PROP_READ_TIMEOUT_MSEC", None)
if open_timeout is not None:
capture.set(open_timeout, timeout_seconds * 1000.0)
if read_timeout is not None:
capture.set(read_timeout, timeout_seconds * 1000.0)
buffersize = getattr(cv2, "CAP_PROP_BUFFERSIZE", None)
if buffersize is not None:
capture.set(buffersize, 1)
capture.open(source)
if capture.isOpened():
return capture
capture.release()
return None
def _build_live_stats(self, counter: LineCrossCounter, attributes: AttributeAggregator) -> dict:
age_counts = {"minor": 0, "adult": 0, "senior": 0}
gender_counts = {"male": 0, "female": 0}
unknown_attributes = 0
for track_id in counter.counted_ids:
summary = attributes.summarize_track(track_id)
if summary is None:
unknown_attributes += 1
continue
age_counts[summary.age_bucket] += 1
gender_counts[summary.gender] += 1
return {
"total_people": counter.total_people,
"age_counts": age_counts,
"gender_counts": gender_counts,
"unknown_attributes": unknown_attributes,
}
def _print_rtsp_heartbeat(
self,
process_started_at: datetime,
window_index: int,
frame_index: int,
counter: LineCrossCounter,
attributes: AttributeAggregator,
last_processed_wall_time: datetime | None,
) -> None:
stats = self._build_live_stats(counter, attributes)
runtime_seconds = int((datetime.now().astimezone() - process_started_at).total_seconds())
last_processed = (
last_processed_wall_time.isoformat(timespec="seconds")
if last_processed_wall_time is not None
else None
)
print(
"heartbeat "
f"runtime_seconds={runtime_seconds} "
f"window_index={window_index} "
f"window_frames={frame_index} "
f"total_people={stats['total_people']} "
f"minor={stats['age_counts']['minor']} "
f"adult={stats['age_counts']['adult']} "
f"senior={stats['age_counts']['senior']} "
f"male={stats['gender_counts']['male']} "
f"female={stats['gender_counts']['female']} "
f"unknown_attributes={stats['unknown_attributes']} "
f"last_processed_at={last_processed}",
flush=True,
)
def _collect_track_summaries(
self,
counter: LineCrossCounter | None,
attributes: AttributeAggregator,
) -> tuple[dict[str, int], dict[str, int], int, list[dict]]:
age_counts = {"minor": 0, "adult": 0, "senior": 0}
gender_counts = {"male": 0, "female": 0}
unknown_attributes = 0
track_summaries: list[dict] = []
if counter is None:
return age_counts, gender_counts, unknown_attributes, track_summaries
for event in counter.crossings:
summary = attributes.summarize_track(event.track_id)
if summary is None:
unknown_attributes += 1
track_summaries.append(
{
"track_id": event.track_id,
"direction": event.direction,
"age": None,
"age_bucket": None,
"gender": None,
"samples_used": 0,
}
)
continue
age_counts[summary.age_bucket] += 1
gender_counts[summary.gender] += 1
track_summaries.append(
{
"track_id": summary.track_id,
"direction": event.direction,
"age": summary.age,
"age_bucket": summary.age_bucket,
"gender": summary.gender,
"samples_used": summary.samples_used,
}
)
return age_counts, gender_counts, unknown_attributes, track_summaries
def _build_rtsp_summary(
self,
source: str,
window_index: int,
window_start: datetime,
window_end: datetime,
counter: LineCrossCounter | None,
attributes: AttributeAggregator,
) -> dict:
age_counts, gender_counts, unknown_attributes, track_summaries = self._collect_track_summaries(
counter,
attributes,
)
total_people = 0 if counter is None else counter.total_people
return {
"source_type": "rtsp",
"source": source,
"window_index": window_index,
"window_start": window_start.isoformat(),
"window_end": window_end.isoformat(),
"window_duration_seconds": int((window_end - window_start).total_seconds()),
"config_path": str(self.config.config_path) if self.config.config_path else None,
"line": {
"coordinates": list(self.config.counting.line),
"mode": self.config.counting.line_mode,
},
"total_people": total_people,
"age_counts": age_counts,
"gender_counts": gender_counts,
"unknown_attributes": unknown_attributes,
"tracks": track_summaries,
}
def _finalize_summary(
self,
video_path: Path,
counter: LineCrossCounter,
attributes: AttributeAggregator,
json_path: Path,
) -> dict:
age_counts, gender_counts, unknown_attributes, track_summaries = self._collect_track_summaries(
counter,
attributes,
)
payload = {
"video_name": video_path.name,
"video_path": str(video_path),
"config_path": str(self.config.config_path) if self.config.config_path else None,
"line": {
"coordinates": list(self.config.counting.line),
"mode": self.config.counting.line_mode,
},
"total_people": counter.total_people,
"age_counts": age_counts,
"gender_counts": gender_counts,
"unknown_attributes": unknown_attributes,
"tracks": track_summaries,
}
if self.config.output.save_json:
write_json(json_path, payload)
payload["json_path"] = str(json_path)
return payload

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import Any
from .models import TrackObservation
def extract_person_tracks(result: Any) -> list[TrackObservation]:
boxes = getattr(result, "boxes", None)
if boxes is None:
return []
if getattr(boxes, "id", None) is None:
return []
xyxy = boxes.xyxy.int().cpu().tolist()
ids = boxes.id.int().cpu().tolist()
confs = boxes.conf.cpu().tolist()
classes = boxes.cls.int().cpu().tolist()
observations: list[TrackObservation] = []
for bbox, track_id, confidence, class_id in zip(xyxy, ids, confs, classes, strict=False):
if int(class_id) != 0:
continue
x1, y1, x2, y2 = bbox
center_x = (x1 + x2) / 2.0
center_y = (y1 + y2) / 2.0
observations.append(
TrackObservation(
track_id=int(track_id),
bbox=(int(x1), int(y1), int(x2), int(y2)),
confidence=float(confidence),
center=(center_x, center_y),
)
)
return observations