feat: initialize managed portal
This commit is contained in:
1
managed/people_flow_project/src/people_flow/__init__.py
Normal file
1
managed/people_flow_project/src/people_flow/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""People flow analysis package."""
|
||||
141
managed/people_flow_project/src/people_flow/attributes.py
Normal file
141
managed/people_flow_project/src/people_flow/attributes.py
Normal 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),
|
||||
)
|
||||
99
managed/people_flow_project/src/people_flow/config.py
Normal file
99
managed/people_flow_project/src/people_flow/config.py
Normal 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
|
||||
52
managed/people_flow_project/src/people_flow/counting.py
Normal file
52
managed/people_flow_project/src/people_flow/counting.py
Normal 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)
|
||||
95
managed/people_flow_project/src/people_flow/io_utils.py
Normal file
95
managed/people_flow_project/src/people_flow/io_utils.py
Normal 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
|
||||
389
managed/people_flow_project/src/people_flow/manage_api.py
Normal file
389
managed/people_flow_project/src/people_flow/manage_api.py
Normal 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())
|
||||
105
managed/people_flow_project/src/people_flow/models.py
Normal file
105
managed/people_flow_project/src/people_flow/models.py
Normal 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
|
||||
445
managed/people_flow_project/src/people_flow/pipeline.py
Normal file
445
managed/people_flow_project/src/people_flow/pipeline.py
Normal 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
|
||||
35
managed/people_flow_project/src/people_flow/tracking.py
Normal file
35
managed/people_flow_project/src/people_flow/tracking.py
Normal 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
|
||||
Reference in New Issue
Block a user