tianguiti's picture
Publish local.face_tracking_application 0.2.1
9ae4cb2 verified
Raw
History Blame Contribute Delete
15.1 kB
"""Small native UI for the face-tracking application."""
from __future__ import annotations
from io import BytesIO
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from PIL import Image, ImageDraw, ImageTk, UnidentifiedImageError
# Reserve vertical room for music controls in the same desktop window.
PREVIEW_MAX_SIZE = (300, 300)
def render_preview_image(jpeg: bytes, faces: tuple[object, ...]) -> Image.Image:
"""Decode one SDK JPEG and overlay the associated face boxes."""
with Image.open(BytesIO(jpeg)) as source:
image = source.convert("RGB")
drawing = ImageDraw.Draw(image)
for index, face in enumerate(faces, start=1):
x = int(getattr(face, "x"))
y = int(getattr(face, "y"))
width = int(getattr(face, "width"))
height = int(getattr(face, "height"))
score = int(getattr(face, "score"))
left = x - width // 2
top = y - height // 2
right = left + width
bottom = top + height
drawing.rectangle((left, top, right, bottom), outline="#ff3b30", width=3)
drawing.text((left + 4, max(0, top - 18)), f"face {index}: {score}", fill="#ff3b30")
image.thumbnail(PREVIEW_MAX_SIZE, Image.Resampling.LANCZOS)
return image
class PreviewWindow:
"""Keep pairing, preview start/stop, and application status in one local window."""
def __init__(self) -> None:
self._is_open = True
self._pairing_code: str | None = None
self._preview_action: str | None = None
self._music_action: str | None = None
self._music_path: Path | None = None
self._online = False
self._preview_running = False
self._music_running = False
self._root = tk.Tk()
self._root.title("人脸跟随应用 - WatcheRobot")
self._root.geometry("600x1000")
self._root.minsize(520, 800)
self._root.protocol("WM_DELETE_WINDOW", self.close)
container = ttk.Frame(self._root, padding=32)
container.pack(fill="both", expand=True)
ttk.Label(
container,
text="人脸跟随应用",
font=("Microsoft YaHei UI", 22, "bold"),
).pack(anchor="w")
ttk.Label(
container,
text="此项目只读取预览帧与人脸框遥测,不进行身份识别。",
wraplength=500,
).pack(anchor="w", pady=(10, 0))
pairing = ttk.LabelFrame(container, text="连接机器人", padding=16)
pairing.pack(fill="x", pady=(22, 0))
ttk.Label(pairing, text="输入机器人屏幕上的 6 位配对码。").pack(anchor="w")
controls = ttk.Frame(pairing)
controls.pack(fill="x", pady=(10, 0))
self._pairing_code_var = tk.StringVar()
self._pairing_entry = ttk.Entry(controls, textvariable=self._pairing_code_var, width=18)
self._pairing_entry.pack(side="left")
self._connect_button = ttk.Button(controls, text="连接", command=self._request_pairing)
self._connect_button.pack(side="left", padx=(10, 0))
self._connection_var = tk.StringVar(value="尚未连接")
ttk.Label(pairing, textvariable=self._connection_var).pack(anchor="w", pady=(12, 0))
preview = ttk.LabelFrame(container, text="相机预览", padding=16)
preview.pack(fill="x", pady=(18, 0))
preview_controls = ttk.Frame(preview)
preview_controls.pack(anchor="w")
self._start_button = ttk.Button(preview_controls, text="开始预览", command=self._request_start)
self._start_button.pack(side="left")
self._stop_button = ttk.Button(preview_controls, text="停止预览", command=self._request_stop)
self._stop_button.pack(side="left", padx=(10, 0))
self._preview_var = tk.StringVar(value="等待开始预览")
ttk.Label(preview, textvariable=self._preview_var, wraplength=500).pack(anchor="w", pady=(12, 0))
self._preview_image = ttk.Label(
preview,
text="开始预览后,相机画面会显示在这里。",
anchor="center",
justify="center",
width=58,
)
self._preview_image.pack(fill="both", expand=True, pady=(12, 0))
self._preview_photo: ImageTk.PhotoImage | None = None
music = ttk.LabelFrame(container, text="播放音乐", padding=16)
music.pack(fill="x", pady=(18, 0))
ttk.Label(
music,
text="播放时会自动停止相机预览,并让机器人显示音乐表情。",
wraplength=500,
).pack(anchor="w")
self._music_path_var = tk.StringVar(value="尚未选择音频文件")
ttk.Label(music, textvariable=self._music_path_var, wraplength=500).pack(
anchor="w", pady=(10, 0)
)
music_controls = ttk.Frame(music)
music_controls.pack(anchor="w", pady=(10, 0))
self._select_music_button = ttk.Button(
music_controls,
text="选择音频",
command=self._request_select_music,
)
self._select_music_button.pack(side="left")
self._play_music_button = ttk.Button(
music_controls,
text="播放音乐",
command=self._request_play_music,
)
self._play_music_button.pack(side="left", padx=(10, 0))
self._stop_music_button = ttk.Button(
music_controls,
text="停止音乐",
command=self._request_stop_music,
)
self._stop_music_button.pack(side="left", padx=(10, 0))
self._music_var = tk.StringVar(value="选择音频后即可播放到机器人扬声器。")
ttk.Label(music, textvariable=self._music_var, wraplength=500).pack(
anchor="w", pady=(12, 0)
)
ttk.Button(container, text="关闭", command=self.close).pack(anchor="e", pady=(20, 0))
self._set_preview_buttons(online=False, running=False)
self._set_music_buttons(online=False, running=False)
@property
def is_open(self) -> bool:
return self._is_open
def pump(self) -> None:
if not self._is_open:
return
try:
self._root.update_idletasks()
self._root.update()
except tk.TclError:
self._is_open = False
def take_pairing_code(self) -> str | None:
pairing_code = self._pairing_code
self._pairing_code = None
return pairing_code
def take_preview_action(self) -> str | None:
action = self._preview_action
self._preview_action = None
return action
def take_music_action(self) -> tuple[str, Path | None] | None:
action = self._music_action
self._music_action = None
if action is None:
return None
return action, self._music_path
def update_connection(self, status: object) -> None:
if not isinstance(status, dict):
self.show_status_unavailable()
return
online = status.get("online") is True
self._online = online
state = str(status.get("state") or "")
if online:
name = str(status.get("name") or "机器人")
self._connection_var.set(f"已连接:{name}")
self._set_pairing_enabled(False)
self._set_preview_buttons(online=True, running=self._preview_running)
self._set_music_buttons(online=True, running=self._music_running)
return
if state in {"discovering", "connecting", "reconnecting"}:
self._connection_var.set("正在连接,请稍候…")
self._set_pairing_enabled(False)
else:
self._connection_var.set("尚未连接,请输入设备上的 6 位配对码。")
self._set_pairing_enabled(True)
self._set_preview_buttons(online=False, running=False)
self._set_music_buttons(online=False, running=False)
def show_pairing_error(self) -> None:
self._connection_var.set("连接未成功,请确认配对码和 Wi-Fi 后重试。")
self._set_pairing_enabled(True)
def show_status_unavailable(self) -> None:
self._online = False
self._connection_var.set("暂时无法检查连接状态,请稍后重试。")
self._set_pairing_enabled(True)
self._set_preview_buttons(online=False, running=False)
self._set_music_buttons(online=False, running=False)
def show_preview_running(self) -> None:
self._preview_running = True
self._preview_var.set("预览已启动,正在等待设备帧…")
self._set_preview_buttons(online=True, running=True)
def show_preview_waiting(self) -> None:
self._preview_var.set("预览已启动,但暂未收到完整帧。")
def show_preview_frame(
self,
*,
jpeg: bytes,
faces: tuple[object, ...],
frame_count: int,
face_count: int,
age_ms: int,
inference_ms: int | None,
dropped_frames: int,
) -> None:
inference = "未知" if inference_ms is None else f"{inference_ms} ms"
self._preview_var.set(
f"帧 {frame_count}:检测到 {face_count} 张脸;帧龄 {age_ms} ms;"
f"推理 {inference};丢帧 {dropped_frames}。"
)
try:
self._preview_photo = self._render_preview(jpeg, faces)
except (OSError, UnidentifiedImageError) as error:
self._preview_image.configure(text=f"无法显示当前相机帧:{error}", image="")
return
self._preview_image.configure(image=self._preview_photo, text="")
@staticmethod
def _render_preview(
jpeg: bytes,
faces: tuple[object, ...],
) -> ImageTk.PhotoImage:
return ImageTk.PhotoImage(render_preview_image(jpeg, faces))
def show_preview_stopping(self) -> None:
self._preview_var.set("正在停止预览…")
self._set_preview_buttons(online=True, running=True)
def show_preview_stopped(self) -> None:
self._preview_running = False
self._preview_var.set("预览已停止;最后一帧会保存到 artifacts/latest.jpg。")
self._set_preview_buttons(online=True, running=False)
def show_preview_error(self, message: str) -> None:
self._preview_running = False
self._preview_var.set(f"预览不可用:{message}")
self._set_preview_buttons(online=True, running=False)
def show_music_preparing(self, source: Path) -> None:
self._music_running = True
self._music_var.set(f"正在准备《{source.name}》…")
self._set_preview_buttons(online=True, running=False)
self._set_music_buttons(online=True, running=True)
def show_music_playing(self, source: Path, duration_seconds: float) -> None:
self._music_var.set(
f"正在播放《{source.name}》;机器人保持音乐表情,预计 {duration_seconds:.1f} 秒。"
)
def show_music_stopping(self) -> None:
self._music_var.set("正在停止音乐…")
def show_music_stopped(self, message: str = "音乐已停止,机器人已回到待机表情。") -> None:
self._music_running = False
self._music_var.set(message)
self._set_preview_buttons(online=self._online, running=False)
self._set_music_buttons(online=self._online, running=False)
def show_music_error(self, message: str) -> None:
self._music_running = False
self._music_var.set(f"音乐不可用:{message}")
self._set_preview_buttons(online=self._online, running=False)
self._set_music_buttons(online=self._online, running=False)
def _request_pairing(self) -> None:
pairing_code = self._pairing_code_var.get().strip()
if len(pairing_code) != 6 or not pairing_code.isdigit():
self._connection_var.set("请输入机器人显示的 6 位数字配对码。")
return
self._pairing_code_var.set("")
self._pairing_code = pairing_code
self._connection_var.set("正在连接,请稍候…")
self._set_pairing_enabled(False)
def _request_start(self) -> None:
self._preview_action = "start"
def _request_stop(self) -> None:
self._preview_action = "stop"
def _request_select_music(self) -> None:
selected = filedialog.askopenfilename(
parent=self._root,
title="选择要播放到机器人的音频",
filetypes=[
("音频文件", "*.wav *.mp3 *.m4a *.aac *.flac *.ogg"),
("所有文件", "*.*"),
],
)
if not selected:
return
self._music_path = Path(selected)
self._music_path_var.set(f"已选择:{self._music_path.name}")
self._music_var.set("点击“播放音乐”后,相机预览会停止。")
self._set_music_buttons(online=self._online, running=False)
def _request_play_music(self) -> None:
if self._music_path is None:
self._music_var.set("请先选择音频文件。")
return
self._music_action = "play"
def _request_stop_music(self) -> None:
if not messagebox.askyesno(
"停止音乐",
"确定停止当前音乐吗?",
parent=self._root,
):
return
self._music_action = "stop"
def _set_pairing_enabled(self, enabled: bool) -> None:
if not self._is_open:
return
state = "normal" if enabled else "disabled"
try:
self._pairing_entry.configure(state=state)
self._connect_button.configure(state=state)
except tk.TclError:
self._is_open = False
def _set_preview_buttons(self, *, online: bool, running: bool) -> None:
if not self._is_open:
return
try:
music_running = getattr(self, "_music_running", False)
self._start_button.configure(state="disabled" if running or music_running or not online else "normal")
self._stop_button.configure(state="normal" if running and not music_running else "disabled")
except tk.TclError:
self._is_open = False
def _set_music_buttons(self, *, online: bool, running: bool) -> None:
if not self._is_open:
return
try:
has_music = getattr(self, "_music_path", None) is not None
self._select_music_button.configure(state="disabled" if running or not online else "normal")
self._play_music_button.configure(
state="disabled" if running or not online or not has_music else "normal"
)
self._stop_music_button.configure(state="normal" if running else "disabled")
except tk.TclError:
self._is_open = False
def close(self) -> None:
if not self._is_open:
return
self._is_open = False
try:
self._root.destroy()
except tk.TclError:
pass