Files

489 lines
19 KiB
Python

"""Per-frame door-mask loading and YOLO/RTMW-only 2-D risk scoring."""
from __future__ import annotations
import json
import math
import re
import time
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
import cv2
import numpy as np
RASTER_SUFFIXES = {".png", ".bmp", ".tif", ".tiff"}
DEFAULT_DOOR_LABELS = ("door", "车门")
@dataclass
class LoadedDoorMask:
status: str
annotation_index: int
path: Path | None
mask: np.ndarray | None
source_format: str | None
def metadata(self) -> dict:
return {
"status": self.status,
"annotation_index": self.annotation_index,
"path": str(self.path.resolve()) if self.path is not None else None,
"format": self.source_format,
"area_px": int(np.count_nonzero(self.mask)) if self.mask is not None else 0,
}
def _frame_index_from_path(path: Path) -> int | None:
matches = re.findall(r"\d+", path.stem)
return int(matches[-1]) if matches else None
class DoorMaskSequence:
"""Index a directory of X-AnyLabeling JSON polygons or raster masks."""
def __init__(
self,
directory: Path | None = None,
mask_format: str = "auto",
labels: tuple[str, ...] = DEFAULT_DOOR_LABELS,
frame_offset: int = 0,
require_every_frame: bool = False,
constant_mask: Path | None = None,
):
if (directory is None) == (constant_mask is None):
raise ValueError("Provide exactly one of directory or constant_mask")
self.constant_path = (
Path(constant_mask).expanduser().resolve()
if constant_mask is not None
else None
)
self.directory = (
self.constant_path.parent if self.constant_path is not None else Path(directory)
)
if self.constant_path is not None and not self.constant_path.is_file():
raise ValueError(f"Constant door mask does not exist: {self.constant_path}")
if self.constant_path is None and not self.directory.is_dir():
raise ValueError(f"Door mask directory does not exist: {self.directory}")
self.labels = {label.casefold() for label in labels}
self.frame_offset = int(frame_offset)
self.require_every_frame = require_every_frame
if self.constant_path is not None:
if mask_format == "auto":
selected_format = (
"xanylabeling"
if self.constant_path.suffix.lower() == ".json"
else "raster"
)
else:
selected_format = mask_format
self.source_format = selected_format
self.paths = {0: self.constant_path}
return
json_paths = sorted(self.directory.rglob("*.json"))
raster_paths = sorted(
path
for path in self.directory.rglob("*")
if path.is_file() and path.suffix.lower() in RASTER_SUFFIXES
)
if mask_format == "auto":
selected_format = "xanylabeling" if json_paths else "raster"
else:
selected_format = mask_format
candidates = json_paths if selected_format == "xanylabeling" else raster_paths
if not candidates:
expected = "JSON" if selected_format == "xanylabeling" else "PNG/BMP/TIFF"
raise ValueError(f"No {expected} door masks found in {self.directory}")
self.source_format = selected_format
self.paths: dict[int, Path] = {}
for path in candidates:
index = _frame_index_from_path(path)
if index is None and selected_format == "xanylabeling":
data = json.loads(path.read_text(encoding="utf-8-sig"))
explicit = data.get("frame_index", data.get("frameIndex"))
index = int(explicit) if explicit is not None else None
if index is None:
continue
if index in self.paths:
raise ValueError(
f"Multiple door masks map to annotation frame {index}: "
f"{self.paths[index]} and {path}"
)
self.paths[index] = path
if not self.paths:
raise ValueError(
"Door mask filenames need a frame number, for example 000000.json"
)
def load(self, video_frame_index: int, frame_shape: tuple[int, int]) -> LoadedDoorMask:
if self.constant_path is not None:
annotation_index = video_frame_index
path = self.constant_path
else:
annotation_index = video_frame_index + self.frame_offset
path = self.paths.get(annotation_index)
if path is None:
if self.require_every_frame:
raise RuntimeError(
f"No door mask for video frame {video_frame_index} "
f"(expected annotation index {annotation_index})"
)
return LoadedDoorMask("missing", annotation_index, None, None, self.source_format)
if self.source_format == "xanylabeling":
mask = self._load_xanylabeling(path, frame_shape)
else:
mask = self._load_raster(path, frame_shape)
status = "loaded" if np.any(mask) else "empty"
return LoadedDoorMask(status, annotation_index, path, mask, self.source_format)
@staticmethod
def _load_raster(path: Path, frame_shape: tuple[int, int]) -> np.ndarray:
image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
if image is None:
raise RuntimeError(f"Cannot read door mask: {path}")
if image.ndim == 3:
image = np.max(image[:, :, :3], axis=2)
height, width = frame_shape
if image.shape != (height, width):
image = cv2.resize(image, (width, height), interpolation=cv2.INTER_NEAREST)
return image > 0
def _load_xanylabeling(
self, path: Path, frame_shape: tuple[int, int]
) -> np.ndarray:
data = json.loads(path.read_text(encoding="utf-8-sig"))
height, width = frame_shape
source_width = float(data.get("imageWidth") or width)
source_height = float(data.get("imageHeight") or height)
scale = np.asarray(
[width / max(source_width, 1.0), height / max(source_height, 1.0)],
dtype=np.float32,
)
mask = np.zeros((height, width), dtype=np.uint8)
for shape in data.get("shapes", []):
if str(shape.get("label", "")).casefold() not in self.labels:
continue
points = np.asarray(shape.get("points", []), dtype=np.float32).reshape(-1, 2)
if len(points) < 2:
continue
points = np.rint(points * scale).astype(np.int32)
shape_type = str(shape.get("shape_type", "polygon")).lower()
if shape_type == "rectangle" and len(points) >= 2:
x1, y1 = points[0]
x2, y2 = points[1]
cv2.rectangle(mask, (x1, y1), (x2, y2), 1, -1)
elif shape_type == "circle" and len(points) >= 2:
radius = int(round(float(np.linalg.norm(points[1] - points[0]))))
cv2.circle(mask, tuple(points[0]), radius, 1, -1)
elif len(points) >= 3:
cv2.fillPoly(mask, [points], 1)
return mask > 0
def _fit_mask(mask: np.ndarray, frame_shape: tuple[int, int]) -> np.ndarray:
height, width = frame_shape
result = np.asarray(mask) > 0
if result.shape != (height, width):
result = cv2.resize(
result.astype(np.uint8), (width, height), interpolation=cv2.INTER_NEAREST
) > 0
return result
def _box_mask(box: list[float] | None, frame_shape: tuple[int, int]) -> np.ndarray | None:
if box is None or len(box) < 4:
return None
height, width = frame_shape
x1, y1, x2, y2 = [int(round(float(value))) for value in box[:4]]
x1, x2 = sorted((max(0, min(width - 1, x1)), max(0, min(width - 1, x2))))
y1, y2 = sorted((max(0, min(height - 1, y1)), max(0, min(height - 1, y2))))
if x2 <= x1 or y2 <= y1:
return None
mask = np.zeros((height, width), dtype=bool)
mask[y1 : y2 + 1, x1 : x2 + 1] = True
return mask
def _hand_mask(points: list, frame_shape: tuple[int, int]) -> np.ndarray | None:
hand = np.asarray(points, dtype=np.float32).reshape(-1, 2)
if len(hand) < 3 or not np.isfinite(hand).all():
return None
hull = cv2.convexHull(np.rint(hand).astype(np.int32))
mask = np.zeros(frame_shape, dtype=np.uint8)
cv2.fillConvexPoly(mask, hull, 1)
return mask > 0 if np.any(mask) else None
def _decision(distance_relative: float, overlap_px: int, warning: float, release: float) -> tuple[str, str]:
if overlap_px > 0:
return "danger", "DANGER_INTERSECTION"
if distance_relative <= warning:
return "warning", "WARNING_NEAR"
if distance_relative <= release:
return "watch", "WATCH"
return "clear", "CLEAR"
class DoorRiskEvaluator:
"""Score RTMW hand regions, with one YOLO body-box fallback per person."""
def __init__(
self,
sequence: DoorMaskSequence,
warning_relative: float = 0.30,
release_relative: float = 0.45,
):
if warning_relative < 0 or release_relative <= warning_relative:
raise ValueError("Risk thresholds require 0 <= warning < release")
self.sequence = sequence
self.warning_relative = float(warning_relative)
self.release_relative = float(release_relative)
self.mask_status_counts: Counter = Counter()
self.level_counts: Counter = Counter()
self.source_counts: Counter = Counter()
self.maximum_score = 0.0
self.timing_seconds: Counter = Counter()
self.timing_counts: Counter = Counter()
self._constant_cache_shape: tuple[int, int] | None = None
self._constant_loaded: LoadedDoorMask | None = None
self._constant_distance_map: np.ndarray | None = None
self.distance_map_cache_builds = 0
def _record_timing(self, name: str, started: float) -> None:
self.timing_seconds[name] += time.perf_counter() - started
self.timing_counts[name] += 1
def _measure_region(
self,
region: np.ndarray,
door_mask: np.ndarray,
distance_map: np.ndarray,
source: str,
track_id: int,
side: str,
state: str | None,
) -> dict:
area_px = int(np.count_nonzero(region))
overlap_px = int(np.count_nonzero(region & door_mask))
distances = distance_map[region]
distance_px = float(np.min(distances)) if len(distances) else math.inf
scale_px = max(math.sqrt(area_px), 1.0)
distance_relative = distance_px / scale_px
score = float(np.clip(1.0 - distance_relative / self.release_relative, 0.0, 1.0))
if overlap_px:
score = 1.0
level, decision = _decision(
distance_relative,
overlap_px,
self.warning_relative,
self.release_relative,
)
result = {
"status": "calculated",
"risk_score": round(score, 5),
"risk_level": level,
"decision": decision,
"source": source,
"track_id": int(track_id),
"side": side,
"state": state,
"region_area_px": area_px,
"door_overlap_px": overlap_px,
"region_overlap_ratio": round(overlap_px / max(area_px, 1), 6),
"min_distance_px": round(distance_px, 3),
"region_scale_px": round(scale_px, 3),
"distance_relative": round(distance_relative, 6),
}
self.level_counts[level] += 1
self.source_counts[source] += 1
self.maximum_score = max(self.maximum_score, score)
return result
@staticmethod
def _unavailable(source: str, track_id: int, side: str, status: str) -> dict:
return {
"status": status,
"risk_score": None,
"risk_level": "unavailable",
"decision": "EVIDENCE_UNAVAILABLE",
"source": source,
"track_id": int(track_id),
"side": side,
}
def evaluate(
self,
frame_index: int,
record: dict,
frame_shape: tuple[int, int],
) -> tuple[dict, np.ndarray | None]:
total_started = time.perf_counter()
stage_started = time.perf_counter()
use_constant_cache = self.sequence.constant_path is not None
if use_constant_cache and self._constant_cache_shape == frame_shape:
cached = self._constant_loaded
assert cached is not None
loaded = LoadedDoorMask(
cached.status,
frame_index,
cached.path,
cached.mask,
cached.source_format,
)
else:
loaded = self.sequence.load(frame_index, frame_shape)
if use_constant_cache:
self._constant_cache_shape = frame_shape
self._constant_loaded = loaded
self._constant_distance_map = None
self._record_timing("door_mask_load", stage_started)
self.mask_status_counts[loaded.status] += 1
mask_ready = loaded.status == "loaded" and loaded.mask is not None
door_mask = loaded.mask if mask_ready else None
distance_map = None
if door_mask is not None and use_constant_cache and self._constant_distance_map is not None:
distance_map = self._constant_distance_map
elif door_mask is not None:
stage_started = time.perf_counter()
distance_map = cv2.distanceTransform(
(~door_mask).astype(np.uint8), cv2.DIST_L2, cv2.DIST_MASK_PRECISE
)
self._record_timing("distance_transform", stage_started)
if use_constant_cache:
self._constant_distance_map = distance_map
self.distance_map_cache_builds += 1
stage_started = time.perf_counter()
regions: list[tuple[dict, np.ndarray, str, int, str, str | None]] = []
track_ids = [int(value) for value in record.get("track_ids", [])]
person_positions_with_hands: set[int] = set()
for hand in record.get("global_hands", []):
position = int(hand.get("person_id", 0)) - 1
track_id = track_ids[position] if 0 <= position < len(track_ids) else position + 1
side = str(hand.get("side", "unknown"))
region = _hand_mask(hand.get("keypoints", []), frame_shape)
if region is None:
continue
person_positions_with_hands.add(position)
regions.append(
(
hand,
region,
"RTMW_HAND_KEYPOINTS",
track_id,
side,
str(hand.get("state", "unknown")),
)
)
boxes = record.get("boxes", [])
body_fallbacks = []
for position, box in enumerate(boxes):
if position in person_positions_with_hands:
continue
region = _box_mask(box, frame_shape)
if region is None:
continue
track_id = track_ids[position] if position < len(track_ids) else position + 1
fallback = {
"track_id": track_id,
"side": "body",
"state": "rtmw_hand_unavailable",
"body_zone_box": box,
}
body_fallbacks.append(fallback)
regions.append(
(
fallback,
region,
"YOLO_BODY_BOX_FALLBACK",
track_id,
"body",
"rtmw_hand_unavailable",
)
)
record["body_fallbacks"] = body_fallbacks
self._record_timing("region_build", stage_started)
stage_started = time.perf_counter()
measurements = []
unavailable_status = f"door_mask_{loaded.status}"
for owner, region, source, track_id, side, state in regions:
if door_mask is None or distance_map is None:
measurement = self._unavailable(
source, track_id, side, unavailable_status
)
else:
measurement = self._measure_region(
region, door_mask, distance_map, source, track_id, side, state
)
owner["risk"] = measurement
measurements.append(measurement)
self._record_timing("region_measure", stage_started)
calculated = [item for item in measurements if item["risk_score"] is not None]
nearest = max(calculated, key=lambda item: item["risk_score"]) if calculated else None
if loaded.status != "loaded":
status = unavailable_status
elif not calculated:
status = "person_region_missing"
else:
status = "calculated"
frame_risk = {
"status": status,
"risk_score": nearest["risk_score"] if nearest else None,
"risk_level": nearest["risk_level"] if nearest else "unavailable",
"decision": nearest["decision"] if nearest else "EVIDENCE_UNAVAILABLE",
"source": nearest["source"] if nearest else None,
"track_id": nearest["track_id"] if nearest else None,
"side": nearest["side"] if nearest else None,
"door_mask": loaded.metadata(),
"warning_distance_relative": self.warning_relative,
"release_distance_relative": self.release_relative,
"regions": measurements,
}
self._record_timing("risk_total", total_started)
return frame_risk, loaded.mask
def summary(self) -> dict:
return {
"mask_directory": str(self.sequence.directory.resolve()),
"constant_mask": (
str(self.sequence.constant_path)
if self.sequence.constant_path is not None
else None
),
"constant_for_all_frames": self.sequence.constant_path is not None,
"mask_format": self.sequence.source_format,
"door_labels": sorted(self.sequence.labels),
"frame_offset": self.sequence.frame_offset,
"warning_distance_relative": self.warning_relative,
"release_distance_relative": self.release_relative,
"mask_status_counts": dict(self.mask_status_counts),
"risk_level_counts": dict(self.level_counts),
"source_counts": dict(self.source_counts),
"maximum_risk_score": round(self.maximum_score, 5),
"constant_distance_map_cached": self.sequence.constant_path is not None,
"distance_map_cache_builds": self.distance_map_cache_builds,
"timing_ms": {
name: {
"count": int(self.timing_counts[name]),
"total": round(float(seconds) * 1000.0, 3),
"mean": round(
float(seconds) * 1000.0
/ max(int(self.timing_counts[name]), 1),
3,
),
}
for name, seconds in self.timing_seconds.items()
},
"score_semantics": "2-D image overlap/proximity research score; not millimeters or safety certification",
"region_policy": "RTMW alarm-eligible hand hulls; YOLO body box only when a person has no usable RTMW hand region",
}