feat(v1): chinese screenshot labels via Pillow with ASCII fallback
annotate_frame draws Chinese labels (e.g. '确认摔倒 P-0001') using a CJK font via Pillow when available (Windows uses Microsoft YaHei), and falls back to the ASCII cv2 label when Pillow or a CJK font is missing. 86 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+82
-7
@@ -15,6 +15,12 @@ from typing import Callable, Dict, Optional, Sequence, Tuple
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
_PIL_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
_PIL_AVAILABLE = False
|
||||
|
||||
from v1.fall_state import FallEvent, FallState
|
||||
from v1.view_model import MonitorViewState, StatusColor
|
||||
|
||||
@@ -27,6 +33,37 @@ _COLOR_BGR: Dict[StatusColor, Tuple[int, int, int]] = {
|
||||
StatusColor.OFFLINE: (139, 116, 100),
|
||||
}
|
||||
|
||||
_STATE_LABEL_ZH: Dict[FallState, str] = {
|
||||
FallState.NORMAL: "正常",
|
||||
FallState.SUSPECT: "疑似",
|
||||
FallState.CONFIRMED: "确认摔倒",
|
||||
FallState.RECOVERING: "恢复中",
|
||||
}
|
||||
|
||||
# Only CJK-capable fonts, so auto-discovery never renders tofu for Chinese;
|
||||
# absent one, annotation falls back to ASCII. Windows finds Microsoft YaHei.
|
||||
_FONT_CANDIDATES: Tuple[str, ...] = (
|
||||
"C:/Windows/Fonts/msyh.ttc",
|
||||
"C:/Windows/Fonts/msyhbd.ttc",
|
||||
"C:/Windows/Fonts/simhei.ttf",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
)
|
||||
|
||||
|
||||
def annotation_label(track_id: str, state: FallState) -> str:
|
||||
"""Chinese screenshot label, e.g. '确认摔倒 P-0001'."""
|
||||
|
||||
return "{0} {1}".format(_STATE_LABEL_ZH.get(state, getattr(state, "value", state)), track_id)
|
||||
|
||||
|
||||
def _find_font(font_path: Optional[str] = None) -> Optional[str]:
|
||||
candidates = [font_path] if font_path else list(_FONT_CANDIDATES)
|
||||
for path in candidates:
|
||||
if path and Path(path).is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AlertRecord:
|
||||
@@ -58,8 +95,14 @@ def screenshot_label(track_id: str, state_value: str) -> str:
|
||||
return text.encode("ascii", "ignore").decode("ascii")
|
||||
|
||||
|
||||
def annotate_frame(image: np.ndarray, view: MonitorViewState) -> np.ndarray:
|
||||
"""Return a copy of ``image`` with boxes, skeleton, labels and a state border."""
|
||||
def annotate_frame(
|
||||
image: np.ndarray, view: MonitorViewState, font_path: Optional[str] = None
|
||||
) -> np.ndarray:
|
||||
"""Return a copy of ``image`` with boxes, skeleton, a state border and labels.
|
||||
|
||||
Labels are Chinese via Pillow when a CJK font is available, otherwise an
|
||||
ASCII fallback via ``cv2.putText`` (which cannot render non-ASCII glyphs).
|
||||
"""
|
||||
|
||||
canvas = image.copy()
|
||||
for overlay in view.people:
|
||||
@@ -77,6 +120,24 @@ def annotate_frame(image: np.ndarray, view: MonitorViewState) -> np.ndarray:
|
||||
for point in overlay.keypoints:
|
||||
if point is not None:
|
||||
cv2.circle(canvas, (int(round(point.x)), int(round(point.y))), 3, color, -1)
|
||||
if view.highest_state is FallState.CONFIRMED:
|
||||
height, width = canvas.shape[:2]
|
||||
cv2.rectangle(
|
||||
canvas, (0, 0), (width - 1, height - 1), _COLOR_BGR[StatusColor.CRITICAL], 6
|
||||
)
|
||||
return _draw_labels(canvas, view, font_path)
|
||||
|
||||
|
||||
def _draw_labels(
|
||||
canvas: np.ndarray, view: MonitorViewState, font_path: Optional[str]
|
||||
) -> np.ndarray:
|
||||
resolved = _find_font(font_path)
|
||||
if _PIL_AVAILABLE and resolved:
|
||||
return _draw_labels_pil(canvas, view, resolved)
|
||||
for overlay in view.people:
|
||||
color = _COLOR_BGR[overlay.color]
|
||||
left = int(round(overlay.box_xyxy[0]))
|
||||
top = int(round(overlay.box_xyxy[1]))
|
||||
cv2.putText(
|
||||
canvas,
|
||||
screenshot_label(overlay.track_id, overlay.state.value),
|
||||
@@ -87,14 +148,28 @@ def annotate_frame(image: np.ndarray, view: MonitorViewState) -> np.ndarray:
|
||||
1,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
if view.highest_state is FallState.CONFIRMED:
|
||||
height, width = canvas.shape[:2]
|
||||
cv2.rectangle(
|
||||
canvas, (0, 0), (width - 1, height - 1), _COLOR_BGR[StatusColor.CRITICAL], 6
|
||||
)
|
||||
return canvas
|
||||
|
||||
|
||||
def _draw_labels_pil(
|
||||
canvas: np.ndarray, view: MonitorViewState, font_path: str
|
||||
) -> np.ndarray:
|
||||
rgb = Image.fromarray(np.ascontiguousarray(canvas[:, :, ::-1]))
|
||||
draw = ImageDraw.Draw(rgb)
|
||||
font = ImageFont.truetype(font_path, 18)
|
||||
for overlay in view.people:
|
||||
blue, green, red = _COLOR_BGR[overlay.color]
|
||||
left = int(round(overlay.box_xyxy[0]))
|
||||
top = int(round(overlay.box_xyxy[1]))
|
||||
draw.text(
|
||||
(left, max(0, top - 24)),
|
||||
annotation_label(overlay.track_id, overlay.state),
|
||||
fill=(red, green, blue),
|
||||
font=font,
|
||||
)
|
||||
return np.ascontiguousarray(np.array(rgb)[:, :, ::-1])
|
||||
|
||||
|
||||
class EventArtifactWriter:
|
||||
"""Persist one annotated screenshot and one JSONL line per confirmed event."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user