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:
ila
2026-07-22 00:10:07 +08:00
co-authored by Claude Opus 4.8
parent c5e4d87b42
commit 8b33f61112
6 changed files with 129 additions and 9 deletions
+82 -7
View File
@@ -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."""