Compare commits
No commits in common. "main" and "b02d527" have entirely different histories.
1
.gitignore
vendored
1
.gitignore
vendored
@ -115,6 +115,5 @@ dmypy.json
|
|||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
# Project specific
|
# Project specific
|
||||||
logs/
|
|
||||||
face_recognition.log
|
face_recognition.log
|
||||||
output.mp4
|
output.mp4
|
||||||
23
config.yaml
23
config.yaml
@ -34,40 +34,34 @@ camera:
|
|||||||
width: 1280
|
width: 1280
|
||||||
height: 720
|
height: 720
|
||||||
fps: 30
|
fps: 30
|
||||||
retry_interval: 2 # 打开摄像头失败后的重试间隔(秒)
|
retry_interval: 2 # 打开摄像头失败后的重试间隔(秒)
|
||||||
max_failures: 5 # 触发重新初始化的失败次数阈值
|
|
||||||
|
|
||||||
# 人脸检测配置
|
# 人脸检测配置
|
||||||
face_detection:
|
face_detection:
|
||||||
frame_interval: 10 # 检测帧间隔(每N帧检测一次)
|
frame_interval: 10 # 检测帧间隔(每N帧检测一次)
|
||||||
quality_threshold: 10 # 图像质量阈值(Laplacian方差)
|
quality_threshold: 10 # 图像质量阈值(Laplacian方差)
|
||||||
min_face_size: 80 # 最小人脸尺寸(像素),越小检测距离越远
|
min_face_size: 80 # 最小人脸尺寸(像素)
|
||||||
face_present_duration: 2.0 # 持续出现时长(秒)才触发识别
|
face_present_duration: 2.0 # 持续出现时长(秒)才触发识别
|
||||||
max_yaw: 20.0 # 最大允许的偏航角(度),超过此角度视为侧脸
|
max_yaw: 20.0 # 最大允许的偏航角(度),超过此角度视为侧脸
|
||||||
max_pitch: 20.0 # 最大允许的俯仰角(度),超过此角度视为抬头或低头
|
max_pitch: 20.0 # 最大允许的俯仰角(度),超过此角度视为抬头或低头
|
||||||
no_face_threshold: 60 # 人脸消失计数器阈值(帧),约2秒没有检测到人脸才清空
|
|
||||||
|
|
||||||
# 人脸识别配置
|
# 人脸识别配置
|
||||||
face_recognition:
|
face_recognition:
|
||||||
|
# similarity_threshold: 0.85 # 相似度阈值(低于此值视为陌生人)
|
||||||
recognition_cooldown: 20.0 # 同一人识别冷却时间(秒)
|
recognition_cooldown: 20.0 # 同一人识别冷却时间(秒)
|
||||||
|
|
||||||
# 角色映射配置
|
# 角色映射配置
|
||||||
role_mapping:
|
role_mapping:
|
||||||
stranger_threshold: 0.98 # 人脸识别阈值
|
stranger_threshold: 0.98 # 人脸识别阈值
|
||||||
|
# visitor_threshold: 0.70 # 访客识别阈值
|
||||||
# 性能控制配置
|
# 低于visitor_threshold视为陌生人
|
||||||
performance:
|
|
||||||
target_fps: 30 # 目标帧率
|
|
||||||
cleanup_interval: 60 # 清理过期记录的间隔(秒)
|
|
||||||
websocket_loop_delay: 0.01 # WebSocket消息循环延迟(秒)
|
|
||||||
|
|
||||||
# 日志配置
|
# 日志配置
|
||||||
logging:
|
logging:
|
||||||
level: "INFO" # DEBUG, INFO, WARNING, ERROR
|
level: "DEBUG" # DEBUG, INFO, WARNING, ERROR
|
||||||
dir: "logs" # 日志目录
|
file: "face_recognition.log"
|
||||||
max_bytes: 10485760 # 10MB
|
max_bytes: 10485760 # 10MB
|
||||||
backup_count: 5
|
backup_count: 5
|
||||||
keep_days: 7 # 日志保留天数
|
|
||||||
|
|
||||||
|
|
||||||
# 中文字体设置
|
# 中文字体设置
|
||||||
@ -76,9 +70,8 @@ display:
|
|||||||
|
|
||||||
|
|
||||||
stream:
|
stream:
|
||||||
# enabled: true # 开关推流功能
|
# enabled: true # 开关推流功能
|
||||||
enabled: false # 开关推流功能
|
enabled: false # 开关推流功能
|
||||||
max_retries: 5 # 最大重试次数
|
|
||||||
rtmp_url: "rtsp://127.0.0.1/live/video6"
|
rtmp_url: "rtsp://127.0.0.1/live/video6"
|
||||||
|
|
||||||
ffmpeg:
|
ffmpeg:
|
||||||
|
|||||||
416
face_rec.py
416
face_rec.py
@ -18,41 +18,10 @@ import queue
|
|||||||
from compreface import CompreFace
|
from compreface import CompreFace
|
||||||
from compreface.service import RecognitionService, DetectionService
|
from compreface.service import RecognitionService, DetectionService
|
||||||
|
|
||||||
# 把鼠标移到屏幕右下角
|
|
||||||
def move_cursor_to_corner():
|
|
||||||
"""把鼠标移到屏幕右下角"""
|
|
||||||
try:
|
|
||||||
import os
|
|
||||||
if not os.environ.get('DISPLAY'):
|
|
||||||
return
|
|
||||||
|
|
||||||
import ctypes
|
|
||||||
libX11 = cdll.LoadLibrary('libX11.so.6')
|
|
||||||
|
|
||||||
display = libX11.XOpenDisplay(None)
|
|
||||||
if not display:
|
|
||||||
return
|
|
||||||
|
|
||||||
screen = libX11.XDefaultScreen(display)
|
|
||||||
width = libX11.XDisplayWidth(display, screen)
|
|
||||||
height = libX11.XDisplayHeight(display, screen)
|
|
||||||
|
|
||||||
# 移动到右下角(减去一点偏移)
|
|
||||||
libX11.XWarpPointer(display, None, None, 0, 0, 0, 0, width - 10, height - 10)
|
|
||||||
libX11.XFlush(display)
|
|
||||||
libX11.XCloseDisplay(display)
|
|
||||||
|
|
||||||
print(f"Cursor moved to corner: {width}x{height}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Move cursor error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
class FaceRecognitionSystem:
|
class FaceRecognitionSystem:
|
||||||
def __init__(self, config_path: str = "config.yaml"):
|
def __init__(self, config_path: str = "config.yaml"):
|
||||||
"""初始化人脸识别系统"""
|
"""初始化人脸识别系统"""
|
||||||
# 先移动鼠标光标到右下角(在任何窗口创建之前)
|
|
||||||
move_cursor_to_corner()
|
|
||||||
|
|
||||||
# 加载配置
|
# 加载配置
|
||||||
with open(config_path, 'r', encoding='utf-8') as f:
|
with open(config_path, 'r', encoding='utf-8') as f:
|
||||||
self.config = yaml.safe_load(f)
|
self.config = yaml.safe_load(f)
|
||||||
@ -77,18 +46,7 @@ class FaceRecognitionSystem:
|
|||||||
'is_speaking': False,
|
'is_speaking': False,
|
||||||
'is_thinking': False,
|
'is_thinking': False,
|
||||||
'listening': False,
|
'listening': False,
|
||||||
'is_asr_processing': False,
|
'role_name': ''
|
||||||
'role_name': '',
|
|
||||||
'mode': '', # 模式: await(待机)/local(展厅讲解员)/smart(访客引导者)
|
|
||||||
'face_detect_inited': False, # 人脸识别系统是否准备就绪
|
|
||||||
'battery': 0, # 电池电量 %
|
|
||||||
'bms_electric': 0, # 电池电流 mA
|
|
||||||
'bms_voltage': 0, # 电池电压 mV
|
|
||||||
'run_time': 0, # 运行时间 s
|
|
||||||
'style_name': '', # 当前风格
|
|
||||||
'is_actioning': False, # 是否正在做动作
|
|
||||||
'volume': 0, # 当前音量
|
|
||||||
'voice_id': '', # 语音ID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 人脸检测状态
|
# 人脸检测状态
|
||||||
@ -111,7 +69,6 @@ class FaceRecognitionSystem:
|
|||||||
'quality': 0,
|
'quality': 0,
|
||||||
'face_detected': False,
|
'face_detected': False,
|
||||||
'face_box': None,
|
'face_box': None,
|
||||||
'face_box_mirrored': False, # 标记 face_box 是否已翻转
|
|
||||||
'person_name': None,
|
'person_name': None,
|
||||||
'person_role': None,
|
'person_role': None,
|
||||||
'similarity': 0,
|
'similarity': 0,
|
||||||
@ -149,7 +106,7 @@ class FaceRecognitionSystem:
|
|||||||
self.ffmpeg_process = None
|
self.ffmpeg_process = None
|
||||||
self.stream_enabled = self.config.get('stream', {}).get('enabled', False)
|
self.stream_enabled = self.config.get('stream', {}).get('enabled', False)
|
||||||
self.stream_retry_count = 0 # 推流重试计数
|
self.stream_retry_count = 0 # 推流重试计数
|
||||||
self.stream_max_retries = self.config.get('stream', {}).get('max_retries', 5) # 最大重试次数
|
self.stream_max_retries = 5 # 最大重试次数
|
||||||
self.stream_last_retry_time = None # 上次重试时间
|
self.stream_last_retry_time = None # 上次重试时间
|
||||||
self.stream_retry_cooldown = 10 # 重试冷却时间(秒)
|
self.stream_retry_cooldown = 10 # 重试冷却时间(秒)
|
||||||
|
|
||||||
@ -163,8 +120,8 @@ class FaceRecognitionSystem:
|
|||||||
# 添加摄像头状态跟踪
|
# 添加摄像头状态跟踪
|
||||||
self.camera_failure_count = 0 # 连续失败次数
|
self.camera_failure_count = 0 # 连续失败次数
|
||||||
self.camera_last_retry_time = None # 上次重试时间
|
self.camera_last_retry_time = None # 上次重试时间
|
||||||
self.camera_retry_cooldown = self.config['camera'].get('retry_interval', 2) # 重试冷却时间(秒)
|
self.camera_retry_cooldown = 3 # 重试冷却时间(秒)
|
||||||
self.camera_max_failures = self.config['camera'].get('max_failures', 5) # 触发重新初始化的失败次数阈值
|
self.camera_max_failures = 5 # 触发重新初始化的失败次数阈值
|
||||||
|
|
||||||
# 缓存屏幕尺寸,避免每帧重新获取
|
# 缓存屏幕尺寸,避免每帧重新获取
|
||||||
self._cache_screen_size()
|
self._cache_screen_size()
|
||||||
@ -203,7 +160,7 @@ class FaceRecognitionSystem:
|
|||||||
self.screen_height = screen.height
|
self.screen_height = screen.height
|
||||||
self.logger.info(f"屏幕尺寸已缓存: {self.screen_width}x{self.screen_height}")
|
self.logger.info(f"屏幕尺寸已缓存: {self.screen_width}x{self.screen_height}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"无法获取屏幕尺寸,使用默认尺寸 1920x1080: {e}")
|
self.logger.debug(f"无法获取屏幕尺寸,使用默认尺寸: {e}")
|
||||||
|
|
||||||
def _force_cleanup_ffmpeg(self):
|
def _force_cleanup_ffmpeg(self):
|
||||||
"""强制清理所有FFmpeg相关资源 - 防止多进程推流"""
|
"""强制清理所有FFmpeg相关资源 - 防止多进程推流"""
|
||||||
@ -342,35 +299,14 @@ class FaceRecognitionSystem:
|
|||||||
# 准备状态文本
|
# 准备状态文本
|
||||||
status_texts = []
|
status_texts = []
|
||||||
|
|
||||||
# 模式中文映射
|
|
||||||
mode_map = {
|
|
||||||
'await': '待机模式',
|
|
||||||
'local': '展厅讲解员',
|
|
||||||
'smart': '访客引导者'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 显示当前模式
|
|
||||||
mode_prefix = ""
|
|
||||||
if self.robot_status['mode']:
|
|
||||||
mode_name = mode_map.get(self.robot_status['mode'], self.robot_status['mode'])
|
|
||||||
mode_prefix = f"{mode_name}: "
|
|
||||||
|
|
||||||
# 收集状态信息
|
|
||||||
status_parts = []
|
|
||||||
if self.robot_status['listening']:
|
|
||||||
status_parts.append("已开启对话")
|
|
||||||
else:
|
|
||||||
status_parts.append("已关闭对话")
|
|
||||||
if self.robot_status['is_thinking']:
|
if self.robot_status['is_thinking']:
|
||||||
status_parts.append("正在思考...")
|
status_texts.append("🤔 正在思考...")
|
||||||
if self.robot_status['is_asr_processing']:
|
|
||||||
status_parts.append("正在倾听...")
|
|
||||||
if self.robot_status['is_speaking']:
|
|
||||||
status_parts.append("正在讲话...")
|
|
||||||
|
|
||||||
# 模式 + 状态信息在同一行
|
if self.robot_status['is_asr_processing']:
|
||||||
if mode_prefix or status_parts:
|
status_texts.append("👂 正在倾听...")
|
||||||
status_texts.append(mode_prefix + " ".join(status_parts))
|
|
||||||
|
if self.robot_status['is_speaking']:
|
||||||
|
status_texts.append("👂 正在讲话...")
|
||||||
|
|
||||||
# 如果没有状态需要显示,直接返回原帧
|
# 如果没有状态需要显示,直接返回原帧
|
||||||
if not status_texts:
|
if not status_texts:
|
||||||
@ -572,8 +508,176 @@ class FaceRecognitionSystem:
|
|||||||
# Composite
|
# Composite
|
||||||
image.alpha_composite(shadow_layer)
|
image.alpha_composite(shadow_layer)
|
||||||
|
|
||||||
|
def _build_qrcode_instruction_canvas(self, qr_image: np.ndarray, canvas_size: Tuple[int, int]) -> Optional[np.ndarray]:
|
||||||
|
try:
|
||||||
|
canvas_width, canvas_height = canvas_size
|
||||||
|
if canvas_width <= 0 or canvas_height <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
PRIMARY_COLOR = (23, 43, 77) # Dark Blue for headings
|
||||||
|
SECONDARY_COLOR = (94, 108, 132) # Grey for secondary text
|
||||||
|
ACCENT_COLOR = (0, 82, 204) # Bright Blue for highlights/numbers
|
||||||
|
|
||||||
|
# 1. Background
|
||||||
|
bg = self._create_gradient_background((canvas_width, canvas_height), (245, 247, 250), (223, 225, 230)).convert("RGBA")
|
||||||
|
draw = ImageDraw.Draw(bg, "RGBA")
|
||||||
|
|
||||||
|
# Layout Constants
|
||||||
|
MARGIN = 50
|
||||||
|
GUTTER = 40
|
||||||
|
|
||||||
|
# Left Panel (QR Code) - 35% width approx
|
||||||
|
left_width = int((canvas_width - 2 * MARGIN - GUTTER) * 0.35)
|
||||||
|
# Ensure minimum width for QR code
|
||||||
|
left_width = max(left_width, 400)
|
||||||
|
right_width = canvas_width - 2 * MARGIN - GUTTER - left_width
|
||||||
|
|
||||||
|
left_box = (MARGIN, MARGIN, MARGIN + left_width, canvas_height - MARGIN)
|
||||||
|
right_box = (MARGIN + left_width + GUTTER, MARGIN, canvas_width - MARGIN, canvas_height - MARGIN)
|
||||||
|
|
||||||
|
# --- Draw Left Panel ---
|
||||||
|
# Shadow
|
||||||
|
self._draw_shadow(bg, left_box, radius=30, offset=(0, 10), blur=20, shadow_color=(0,0,0,30))
|
||||||
|
# Card
|
||||||
|
self._draw_rounded_rect(draw, left_box, radius=30, fill=(255, 255, 255, 255))
|
||||||
|
|
||||||
|
# Left Content
|
||||||
|
cx = (left_box[0] + left_box[2]) // 2
|
||||||
|
cy_top = left_box[1] + 120
|
||||||
|
|
||||||
|
# Title
|
||||||
|
text = "访客登记"
|
||||||
|
try:
|
||||||
|
w = draw.textlength(text, font=self.font_qr_title)
|
||||||
|
except:
|
||||||
|
w, _ = draw.textsize(text, font=self.font_qr_title)
|
||||||
|
draw.text((cx - w/2, cy_top), text, font=self.font_qr_title, fill=PRIMARY_COLOR)
|
||||||
|
|
||||||
|
# Subtitle
|
||||||
|
text = "Visitor Registration"
|
||||||
|
try:
|
||||||
|
w = draw.textlength(text, font=self.font_qr_subtitle)
|
||||||
|
except:
|
||||||
|
w, _ = draw.textsize(text, font=self.font_qr_subtitle)
|
||||||
|
draw.text((cx - w/2, cy_top + 70), text, font=self.font_qr_subtitle, fill=SECONDARY_COLOR)
|
||||||
|
|
||||||
|
# QR Code
|
||||||
|
qr_size = min(left_width - 100, 500)
|
||||||
|
qr_y = cy_top + 180
|
||||||
|
|
||||||
|
# Resize QR
|
||||||
|
qr_pil = Image.fromarray(cv2.cvtColor(qr_image, cv2.COLOR_BGR2RGB))
|
||||||
|
qr_pil = qr_pil.resize((qr_size, qr_size), Image.LANCZOS)
|
||||||
|
bg.paste(qr_pil, (cx - qr_size//2, qr_y))
|
||||||
|
|
||||||
|
# Scan Hint
|
||||||
|
hint_y = qr_y + qr_size + 50
|
||||||
|
hint_text = "请使用微信扫码登记"
|
||||||
|
try:
|
||||||
|
w = draw.textlength(hint_text, font=self.font_qr_step_title)
|
||||||
|
except:
|
||||||
|
w, _ = draw.textsize(hint_text, font=self.font_qr_step_title)
|
||||||
|
|
||||||
|
# Draw a pill background for hint
|
||||||
|
pill_padding = 20
|
||||||
|
pill_box = (cx - w/2 - pill_padding, hint_y - pill_padding, cx + w/2 + pill_padding, hint_y + 40 + pill_padding)
|
||||||
|
self._draw_rounded_rect(draw, pill_box, radius=30, fill=(240, 242, 245), outline=None)
|
||||||
|
draw.text((cx - w/2, hint_y), hint_text, font=self.font_qr_step_title, fill=ACCENT_COLOR)
|
||||||
|
|
||||||
|
# --- Draw Right Panel ---
|
||||||
|
|
||||||
|
# Right Title Area - Compacted
|
||||||
|
rt_y = MARGIN + 20
|
||||||
|
draw.text((right_box[0], rt_y), self.qrcode_instruction_title, font=self.font_qr_title, fill=PRIMARY_COLOR)
|
||||||
|
draw.text((right_box[0], rt_y + 60), self.qrcode_instruction_subtitle, font=self.font_qr_subtitle, fill=SECONDARY_COLOR)
|
||||||
|
|
||||||
|
# Separator Line
|
||||||
|
sep_y = rt_y + 110
|
||||||
|
draw.line((right_box[0], sep_y, right_box[2], sep_y), fill=(200, 200, 200), width=2)
|
||||||
|
|
||||||
|
# Grid Configuration
|
||||||
|
grid_y_start = sep_y + 40
|
||||||
|
grid_width = right_width
|
||||||
|
cols = 2
|
||||||
|
col_gap = 30
|
||||||
|
row_gap = 30
|
||||||
|
|
||||||
|
col_width = (grid_width - (cols - 1) * col_gap) // cols
|
||||||
|
|
||||||
|
# Pre-calculate text layout to find uniform height
|
||||||
|
max_lines = 0
|
||||||
|
processed_steps = []
|
||||||
|
|
||||||
|
padding = 30
|
||||||
|
badge_size = 50
|
||||||
|
text_left_margin = badge_size + 20
|
||||||
|
|
||||||
|
# Calculate available width for text inside a card
|
||||||
|
text_max_width = col_width - padding * 2 - text_left_margin
|
||||||
|
|
||||||
|
for i, step in enumerate(self.qrcode_instruction_steps):
|
||||||
|
# Remove "第一步:" etc prefix if present to make it cleaner, we have badges
|
||||||
|
clean_step = step.split(":", 1)[-1] if ":" in step else step
|
||||||
|
|
||||||
|
lines = self._wrap_text_for_width(clean_step, self.font_qr_body, text_max_width, draw)
|
||||||
|
processed_steps.append(lines)
|
||||||
|
max_lines = max(max_lines, len(lines))
|
||||||
|
|
||||||
|
# Calculate Card Height
|
||||||
|
# Padding top + max_lines * line_height + Padding bottom
|
||||||
|
line_height = self.font_qr_body.size + 10
|
||||||
|
card_content_height = max(badge_size, max_lines * line_height)
|
||||||
|
uniform_card_height = int(padding * 2 + card_content_height)
|
||||||
|
|
||||||
|
# Draw Grid
|
||||||
|
for idx, lines in enumerate(processed_steps):
|
||||||
|
row = idx // cols
|
||||||
|
col = idx % cols
|
||||||
|
|
||||||
|
x = right_box[0] + col * (col_width + col_gap)
|
||||||
|
y = grid_y_start + row * (uniform_card_height + row_gap)
|
||||||
|
|
||||||
|
# Ensure we don't go out of bounds
|
||||||
|
if y + uniform_card_height > canvas_height:
|
||||||
|
break
|
||||||
|
|
||||||
|
card_box = (x, y, x + col_width, y + uniform_card_height)
|
||||||
|
|
||||||
|
# Card Shadow
|
||||||
|
self._draw_shadow(bg, card_box, radius=20, offset=(0, 4), blur=12, shadow_color=(0,0,0,15))
|
||||||
|
# Card Body
|
||||||
|
self._draw_rounded_rect(draw, card_box, radius=20, fill=(255, 255, 255))
|
||||||
|
|
||||||
|
# Badge (Step Number)
|
||||||
|
bx = x + padding
|
||||||
|
by = y + padding
|
||||||
|
draw.ellipse((bx, by, bx + badge_size, by + badge_size), fill=ACCENT_COLOR)
|
||||||
|
|
||||||
|
num_text = str(idx + 1)
|
||||||
|
try:
|
||||||
|
nw = draw.textlength(num_text, font=self.font_qr_badge)
|
||||||
|
except:
|
||||||
|
nw, _ = draw.textsize(num_text, font=self.font_qr_badge)
|
||||||
|
# Center number
|
||||||
|
draw.text((bx + (badge_size - nw)/2, by + (badge_size - self.font_qr_badge.size)/2 - 2),
|
||||||
|
num_text, font=self.font_qr_badge, fill=(255, 255, 255))
|
||||||
|
|
||||||
|
# Text
|
||||||
|
tx = x + padding + text_left_margin
|
||||||
|
ty = y + padding
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
draw.text((tx, ty), line, font=self.font_qr_body, fill=PRIMARY_COLOR)
|
||||||
|
ty += line_height
|
||||||
|
|
||||||
|
return cv2.cvtColor(np.array(bg.convert("RGB")), cv2.COLOR_RGB2BGR)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"渲染二维码指引面板失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def _show_qrcode(self):
|
def _show_qrcode(self):
|
||||||
"""显示二维码图片 - 直接全屏铺满"""
|
"""显示二维码图片"""
|
||||||
try:
|
try:
|
||||||
if not os.path.exists(self.qrcode_image_path):
|
if not os.path.exists(self.qrcode_image_path):
|
||||||
self.logger.error(f"二维码图片不存在: {self.qrcode_image_path}")
|
self.logger.error(f"二维码图片不存在: {self.qrcode_image_path}")
|
||||||
@ -585,20 +689,44 @@ class FaceRecognitionSystem:
|
|||||||
self.logger.error(f"无法读取二维码图片: {self.qrcode_image_path}")
|
self.logger.error(f"无法读取二维码图片: {self.qrcode_image_path}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 直接铺满全屏
|
|
||||||
canvas_width = self.screen_width
|
|
||||||
canvas_height = self.screen_height
|
|
||||||
qr_image_scaled = cv2.resize(qr_image, (canvas_width, canvas_height), interpolation=cv2.INTER_LINEAR)
|
|
||||||
|
|
||||||
# 创建二维码窗口
|
# 创建二维码窗口
|
||||||
cv2.namedWindow(self.qrcode_window_name, cv2.WINDOW_NORMAL | cv2.WINDOW_GUI_NORMAL)
|
cv2.namedWindow(self.qrcode_window_name, cv2.WINDOW_NORMAL | cv2.WINDOW_GUI_NORMAL)
|
||||||
|
|
||||||
|
# 使用缓存的屏幕尺寸
|
||||||
|
canvas_width = self.screen_width
|
||||||
|
canvas_height = self.screen_height
|
||||||
|
x_pos = 0
|
||||||
|
y_pos = 0
|
||||||
|
|
||||||
|
qr_height, qr_width = qr_image.shape[:2]
|
||||||
|
|
||||||
|
rendered = self._build_qrcode_instruction_canvas(qr_image, (canvas_width, canvas_height))
|
||||||
|
if rendered is None:
|
||||||
|
# 回退到原二维码展示
|
||||||
|
scale_width = canvas_width / qr_width
|
||||||
|
scale_height = canvas_height / qr_height
|
||||||
|
scale = min(scale_width, scale_height)
|
||||||
|
new_width = int(qr_width * scale)
|
||||||
|
new_height = int(qr_height * scale)
|
||||||
|
resized_qr = cv2.resize(qr_image, (new_width, new_height), interpolation=cv2.INTER_LINEAR)
|
||||||
|
|
||||||
|
# 创建黑色背景画布,完全填充屏幕
|
||||||
|
rendered = np.zeros((canvas_height, canvas_width, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
# 计算居中位置
|
||||||
|
x_offset = (canvas_width - new_width) // 2
|
||||||
|
y_offset = (canvas_height - new_height) // 2
|
||||||
|
|
||||||
|
# 将二维码放在中央
|
||||||
|
rendered[y_offset:y_offset+new_height, x_offset:x_offset+new_width] = resized_qr
|
||||||
|
|
||||||
cv2.resizeWindow(self.qrcode_window_name, canvas_width, canvas_height)
|
cv2.resizeWindow(self.qrcode_window_name, canvas_width, canvas_height)
|
||||||
cv2.moveWindow(self.qrcode_window_name, 0, 0)
|
cv2.moveWindow(self.qrcode_window_name, x_pos, y_pos)
|
||||||
|
|
||||||
# 设置窗口全屏显示
|
# 设置窗口全屏显示
|
||||||
cv2.setWindowProperty(self.qrcode_window_name, cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN)
|
cv2.setWindowProperty(self.qrcode_window_name, cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN)
|
||||||
|
|
||||||
cv2.imshow(self.qrcode_window_name, qr_image_scaled)
|
cv2.imshow(self.qrcode_window_name, rendered)
|
||||||
|
|
||||||
# 设置窗口置顶
|
# 设置窗口置顶
|
||||||
cv2.setWindowProperty(self.qrcode_window_name, cv2.WND_PROP_TOPMOST, 1)
|
cv2.setWindowProperty(self.qrcode_window_name, cv2.WND_PROP_TOPMOST, 1)
|
||||||
@ -682,53 +810,29 @@ class FaceRecognitionSystem:
|
|||||||
def _setup_logging(self):
|
def _setup_logging(self):
|
||||||
"""设置日志系统"""
|
"""设置日志系统"""
|
||||||
log_config = self.config['logging']
|
log_config = self.config['logging']
|
||||||
log_dir = log_config.get('dir', 'logs')
|
|
||||||
|
|
||||||
# 确保日志目录存在
|
|
||||||
os.makedirs(log_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# 按日期生成日志文件名
|
|
||||||
date_str = datetime.now().strftime('%Y%m%d')
|
|
||||||
log_file = os.path.join(log_dir, f'face_rec_{date_str}.log')
|
|
||||||
|
|
||||||
self.logger = logging.getLogger('FaceRecognition')
|
self.logger = logging.getLogger('FaceRecognition')
|
||||||
self.logger.setLevel(getattr(logging, log_config.get('level', 'INFO')))
|
self.logger.setLevel(getattr(logging, log_config['level']))
|
||||||
|
|
||||||
# 清理旧日志(保留最近的5个)
|
# 文件处理器
|
||||||
self._cleanup_old_logs(log_dir)
|
|
||||||
|
|
||||||
# 只保留文件处理器
|
|
||||||
file_handler = RotatingFileHandler(
|
file_handler = RotatingFileHandler(
|
||||||
log_file,
|
log_config['file'],
|
||||||
maxBytes=log_config.get('max_bytes', 10 * 1024 * 1024),
|
maxBytes=log_config['max_bytes'],
|
||||||
backupCount=log_config.get('backup_count', 5)
|
backupCount=log_config['backup_count']
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 控制台处理器
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
|
||||||
# 格式化
|
# 格式化
|
||||||
formatter = logging.Formatter(
|
formatter = logging.Formatter(
|
||||||
'%(asctime)s - %(levelname)s - %(message)s'
|
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
)
|
)
|
||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
|
console_handler.setFormatter(formatter)
|
||||||
|
|
||||||
self.logger.addHandler(file_handler)
|
self.logger.addHandler(file_handler)
|
||||||
|
self.logger.addHandler(console_handler)
|
||||||
self.logger.info(f"日志文件: {log_file}")
|
|
||||||
|
|
||||||
def _cleanup_old_logs(self, log_dir: str, keep_days: int = None):
|
|
||||||
"""清理旧的日志文件(保留最近N天)"""
|
|
||||||
if keep_days is None:
|
|
||||||
keep_days = self.config.get('logging', {}).get('keep_days', 7)
|
|
||||||
try:
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
cutoff_date = (datetime.now() - timedelta(days=keep_days)).strftime('%Y%m%d')
|
|
||||||
|
|
||||||
for f in os.listdir(log_dir):
|
|
||||||
if f.startswith('face_rec_') and f.endswith('.log'):
|
|
||||||
date_part = f.replace('face_rec_', '').replace('.log', '')
|
|
||||||
if date_part < cutoff_date:
|
|
||||||
os.remove(os.path.join(log_dir, f))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _get_chinese_font(self) -> str:
|
def _get_chinese_font(self) -> str:
|
||||||
"""获取中文字体路径"""
|
"""获取中文字体路径"""
|
||||||
@ -1482,9 +1586,6 @@ class FaceRecognitionSystem:
|
|||||||
|
|
||||||
async def handle_websocket_messages(self):
|
async def handle_websocket_messages(self):
|
||||||
"""处理WebSocket接收的消息"""
|
"""处理WebSocket接收的消息"""
|
||||||
# 从配置读取WebSocket循环延迟
|
|
||||||
ws_loop_delay = self.config.get('performance', {}).get('websocket_loop_delay', 0.01)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while self.ws_connected:
|
while self.ws_connected:
|
||||||
try:
|
try:
|
||||||
@ -1498,16 +1599,6 @@ class FaceRecognitionSystem:
|
|||||||
self.robot_status['listening'] = status.get('listening', False)
|
self.robot_status['listening'] = status.get('listening', False)
|
||||||
self.robot_status['is_asr_processing'] = status.get('is_asr_processing', False)
|
self.robot_status['is_asr_processing'] = status.get('is_asr_processing', False)
|
||||||
self.robot_status['role_name'] = status.get('role_name', '访客引导者')
|
self.robot_status['role_name'] = status.get('role_name', '访客引导者')
|
||||||
self.robot_status['mode'] = status.get('mode', '')
|
|
||||||
self.robot_status['face_detect_inited'] = status.get('face_detect_inited', False)
|
|
||||||
self.robot_status['battery'] = status.get('battery', 0)
|
|
||||||
self.robot_status['bms_electric'] = status.get('bms_electric', 0)
|
|
||||||
self.robot_status['bms_voltage'] = status.get('bms_voltage', 0)
|
|
||||||
self.robot_status['run_time'] = status.get('run_time', 0)
|
|
||||||
self.robot_status['style_name'] = status.get('style_name', '')
|
|
||||||
self.robot_status['is_actioning'] = status.get('is_actioning', False)
|
|
||||||
self.robot_status['volume'] = status.get('volume', 0)
|
|
||||||
self.robot_status['voice_id'] = status.get('voice_id', '')
|
|
||||||
|
|
||||||
self.logger.debug(f"机器人状态: {self.robot_status}")
|
self.logger.debug(f"机器人状态: {self.robot_status}")
|
||||||
|
|
||||||
@ -1519,8 +1610,6 @@ class FaceRecognitionSystem:
|
|||||||
self.logger.error(f"处理WebSocket消息错误: {e}")
|
self.logger.error(f"处理WebSocket消息错误: {e}")
|
||||||
self.ws_connected = False
|
self.ws_connected = False
|
||||||
break
|
break
|
||||||
|
|
||||||
await asyncio.sleep(ws_loop_delay) # 短暂延迟,避免 CPU 空转
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"WebSocket消息处理任务异常: {e}")
|
self.logger.error(f"WebSocket消息处理任务异常: {e}")
|
||||||
self.ws_connected = False
|
self.ws_connected = False
|
||||||
@ -1618,14 +1707,6 @@ class FaceRecognitionSystem:
|
|||||||
quality_threshold = self.config['face_detection']['quality_threshold']
|
quality_threshold = self.config['face_detection']['quality_threshold']
|
||||||
face_duration = self.config['face_detection']['face_present_duration']
|
face_duration = self.config['face_detection']['face_present_duration']
|
||||||
|
|
||||||
# 从配置读取性能控制参数
|
|
||||||
perf_config = self.config.get('performance', {})
|
|
||||||
target_fps = perf_config.get('target_fps', 30)
|
|
||||||
cleanup_interval = perf_config.get('cleanup_interval', 60)
|
|
||||||
target_frame_interval_ms = 1000 // target_fps # 每帧间隔毫秒
|
|
||||||
|
|
||||||
self.logger.info(f"目标帧率: {target_fps} FPS")
|
|
||||||
|
|
||||||
self.logger.info("开始处理视频流")
|
self.logger.info("开始处理视频流")
|
||||||
|
|
||||||
# 创建显示窗口并设置为全屏
|
# 创建显示窗口并设置为全屏
|
||||||
@ -1640,30 +1721,14 @@ class FaceRecognitionSystem:
|
|||||||
stream_check_counter = 0
|
stream_check_counter = 0
|
||||||
stream_check_interval = 150 # 每150帧(约5秒)检查一次
|
stream_check_interval = 150 # 每150帧(约5秒)检查一次
|
||||||
|
|
||||||
# 帧率控制初始化
|
|
||||||
last_frame_time = time.time() * 1000
|
|
||||||
|
|
||||||
# 清理定时控制
|
|
||||||
last_cleanup_time = time.time()
|
|
||||||
|
|
||||||
# 添加人脸消失计数器
|
# 添加人脸消失计数器
|
||||||
no_face_counter = 0
|
no_face_counter = 0
|
||||||
no_face_threshold = self.config['face_detection'].get('no_face_threshold', 30) # 连续N帧没有检测到人脸才清空
|
no_face_threshold = 30 # 连续30帧(约1秒)没有检测到人脸才清空
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
current_time = time.time()
|
# 定期清理过期记录,防止内存无限增长
|
||||||
|
self.cleanup_expired_records()
|
||||||
# 每cleanup_interval秒清理一次过期记录,防止内存无限增长
|
|
||||||
if current_time - last_cleanup_time >= cleanup_interval:
|
|
||||||
self.cleanup_expired_records()
|
|
||||||
last_cleanup_time = current_time
|
|
||||||
|
|
||||||
# 帧率控制:控制到约target_fps
|
|
||||||
elapsed = time.time() * 1000 - last_frame_time
|
|
||||||
if elapsed < target_frame_interval_ms:
|
|
||||||
await asyncio.sleep((target_frame_interval_ms - elapsed) / 1000)
|
|
||||||
last_frame_time = time.time() * 1000
|
|
||||||
|
|
||||||
# 检查机器人角色并显示/隐藏视频
|
# 检查机器人角色并显示/隐藏视频
|
||||||
self._check_role_and_display_video()
|
self._check_role_and_display_video()
|
||||||
@ -1741,7 +1806,6 @@ class FaceRecognitionSystem:
|
|||||||
|
|
||||||
self.display_info['face_detected'] = True
|
self.display_info['face_detected'] = True
|
||||||
self.display_info['face_box'] = face['box']
|
self.display_info['face_box'] = face['box']
|
||||||
self.display_info['face_box_mirrored'] = False # 重置翻转标志
|
|
||||||
|
|
||||||
# 记录人脸出现时间
|
# 记录人脸出现时间
|
||||||
if self.face_present_start is None:
|
if self.face_present_start is None:
|
||||||
@ -1848,39 +1912,9 @@ class FaceRecognitionSystem:
|
|||||||
# 镜像翻转:使画面与实际场景一致(在绘制信息之前)
|
# 镜像翻转:使画面与实际场景一致(在绘制信息之前)
|
||||||
frame = cv2.flip(frame, 1)
|
frame = cv2.flip(frame, 1)
|
||||||
|
|
||||||
# 调整 face_box 坐标以适应镜像翻转(只翻转一次)
|
|
||||||
if self.display_info['face_detected'] and self.display_info['face_box'] and not self.display_info['face_box_mirrored']:
|
|
||||||
h_flip, w_flip = frame.shape[:2]
|
|
||||||
box = self.display_info['face_box']
|
|
||||||
x_min = int(box['x_min'])
|
|
||||||
x_max = int(box['x_max'])
|
|
||||||
# 水平翻转坐标
|
|
||||||
self.display_info['face_box']['x_min'] = w_flip - 1 - x_max
|
|
||||||
self.display_info['face_box']['x_max'] = w_flip - 1 - x_min
|
|
||||||
self.display_info['face_box_mirrored'] = True
|
|
||||||
|
|
||||||
# 绘制信息
|
# 绘制信息
|
||||||
display_frame = self.draw_info_on_frame(frame)
|
display_frame = self.draw_info_on_frame(frame)
|
||||||
|
|
||||||
# 绘制人脸轮廓引导框(屏幕中央)
|
|
||||||
h, w = display_frame.shape[:2]
|
|
||||||
center_x = w // 2
|
|
||||||
center_y = h // 2
|
|
||||||
# 椭圆参数:中心点、宽半轴、高半轴、旋转角度、起始角度、结束角度
|
|
||||||
cv2.ellipse(display_frame, (center_x, center_y), (150, 200), 0, 0, 360, (0, 200, 0), 3)
|
|
||||||
|
|
||||||
# 添加提示文字(使用中文支持函数)
|
|
||||||
guide_text = "请将脸部对准圆圈内"
|
|
||||||
# 计算文字位置:距离底部40像素,居中显示
|
|
||||||
# 使用PIL计算中文文字的实际宽度
|
|
||||||
img_temp = Image.new('RGB', (1, 1))
|
|
||||||
draw_temp = ImageDraw.Draw(img_temp)
|
|
||||||
bbox = draw_temp.textbbox((0, 0), guide_text, font=self.font_medium)
|
|
||||||
text_width = bbox[2] - bbox[0]
|
|
||||||
text_x = center_x - text_width // 2
|
|
||||||
text_y = h - 40
|
|
||||||
display_frame = self.cv2_add_chinese_text(display_frame, guide_text, (text_x, text_y), self.font_medium, (0, 200, 0))
|
|
||||||
|
|
||||||
# 使用缓存的屏幕尺寸拉伸视频帧到全屏
|
# 使用缓存的屏幕尺寸拉伸视频帧到全屏
|
||||||
display_frame = cv2.resize(display_frame, (self.screen_width, self.screen_height), interpolation=cv2.INTER_LINEAR)
|
display_frame = cv2.resize(display_frame, (self.screen_width, self.screen_height), interpolation=cv2.INTER_LINEAR)
|
||||||
|
|
||||||
|
|||||||
333
preview_qrcode.py
Normal file
333
preview_qrcode.py
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
import math
|
||||||
|
import os
|
||||||
|
import cv2
|
||||||
|
import yaml
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||||||
|
|
||||||
|
# Canvas Configuration
|
||||||
|
CANVAS_SIZE = (1920, 1080)
|
||||||
|
BG_COLOR_START = (240, 242, 245)
|
||||||
|
BG_COLOR_END = (200, 210, 230)
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
PRIMARY_COLOR = (23, 43, 77) # Dark Blue for headings
|
||||||
|
SECONDARY_COLOR = (94, 108, 132) # Grey for secondary text
|
||||||
|
ACCENT_COLOR = (0, 82, 204) # Bright Blue for highlights/numbers
|
||||||
|
CARD_BG_COLOR = (255, 255, 255)
|
||||||
|
CARD_SHADOW_COLOR = (9, 30, 66, 40) # Shadow color
|
||||||
|
|
||||||
|
INSTRUCTION_TITLE = "访客预约流程"
|
||||||
|
INSTRUCTION_SUBTITLE = "Visitor Registration Process"
|
||||||
|
INSTRUCTION_STEPS = [
|
||||||
|
"第一步:扫码关注“康达新材”公众号。",
|
||||||
|
"第二步:点击【关于我们】→【我是访客】进入“访客注册”界面,填写并上传相应信息并点击“提交”。",
|
||||||
|
"第三步:将第二步信息提交完毕后在“访客预约”界面选择右下角的“+”号按钮,填写预约信息。",
|
||||||
|
"第四步:请仔细阅读安全告知书,点击我知道了。",
|
||||||
|
"第五步:填写被访人信息及来访事由等内容并提交。",
|
||||||
|
"第六步:显示提交成功,并且手机、微信会收到预约相关短信通知。",
|
||||||
|
]
|
||||||
|
|
||||||
|
def load_config(path="config.yaml"):
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def resolve_font(size: int, preferred: str | None = None) -> ImageFont.FreeTypeFont:
|
||||||
|
candidates = [preferred] if preferred else []
|
||||||
|
candidates += [
|
||||||
|
"C:/Windows/Fonts/msyhbd.ttc", # Microsoft YaHei Bold
|
||||||
|
"C:/Windows/Fonts/msyh.ttc", # Microsoft YaHei
|
||||||
|
"C:/Windows/Fonts/simhei.ttf",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoMono-Regular.ttf",
|
||||||
|
]
|
||||||
|
for path in candidates:
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(path, size)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.ImageDraw):
|
||||||
|
lines = []
|
||||||
|
current = ""
|
||||||
|
for ch in text:
|
||||||
|
tentative = current + ch
|
||||||
|
# Pillow 10+ uses textlength, older uses textsize
|
||||||
|
try:
|
||||||
|
width = draw.textlength(tentative, font=font)
|
||||||
|
except AttributeError:
|
||||||
|
width, _ = draw.textsize(tentative, font=font)
|
||||||
|
|
||||||
|
if width <= max_width:
|
||||||
|
current = tentative
|
||||||
|
else:
|
||||||
|
if current:
|
||||||
|
lines.append(current)
|
||||||
|
current = ch
|
||||||
|
if current:
|
||||||
|
lines.append(current)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def create_gradient_background(size: tuple[int, int], start_color: tuple[int, int, int], end_color: tuple[int, int, int]) -> Image.Image:
|
||||||
|
width, height = size
|
||||||
|
# Vertical gradient
|
||||||
|
base = Image.new('RGB', size, start_color)
|
||||||
|
top = Image.new('RGB', size, end_color)
|
||||||
|
mask = Image.new('L', size)
|
||||||
|
mask_data = np.tile(np.linspace(0, 255, height, dtype=np.uint8), (width, 1)).T
|
||||||
|
mask = Image.fromarray(mask_data, 'L')
|
||||||
|
return Image.composite(top, base, mask)
|
||||||
|
|
||||||
|
def draw_rounded_rect(draw, box, radius, fill, outline=None, width=0):
|
||||||
|
draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)
|
||||||
|
|
||||||
|
def draw_shadow(image, box, radius, offset=(0, 4), blur=10, shadow_color=(0,0,0,50)):
|
||||||
|
# Create a separate shadow layer
|
||||||
|
shadow_layer = Image.new('RGBA', image.size, (0,0,0,0))
|
||||||
|
shadow_draw = ImageDraw.Draw(shadow_layer)
|
||||||
|
|
||||||
|
sx0, sy0, sx1, sy1 = box
|
||||||
|
dx, dy = offset
|
||||||
|
|
||||||
|
shadow_box = (sx0 + dx, sy0 + dy, sx1 + dx, sy1 + dy)
|
||||||
|
shadow_draw.rounded_rectangle(shadow_box, radius=radius, fill=shadow_color)
|
||||||
|
|
||||||
|
# Blur the shadow
|
||||||
|
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(blur))
|
||||||
|
|
||||||
|
# Composite
|
||||||
|
image.alpha_composite(shadow_layer)
|
||||||
|
|
||||||
|
def build_canvas(qr_image: np.ndarray, title_font, subtitle_font, body_font, step_title_font, badge_font) -> np.ndarray:
|
||||||
|
canvas_width, canvas_height = CANVAS_SIZE
|
||||||
|
|
||||||
|
# 1. Background
|
||||||
|
bg = create_gradient_background(CANVAS_SIZE, (245, 247, 250), (223, 225, 230)).convert("RGBA")
|
||||||
|
|
||||||
|
draw = ImageDraw.Draw(bg, "RGBA")
|
||||||
|
|
||||||
|
# Layout Constants
|
||||||
|
MARGIN = 50
|
||||||
|
GUTTER = 40
|
||||||
|
|
||||||
|
# Left Panel (QR Code) - 35% width approx
|
||||||
|
left_width = int((canvas_width - 2 * MARGIN - GUTTER) * 0.35)
|
||||||
|
right_width = canvas_width - 2 * MARGIN - GUTTER - left_width
|
||||||
|
|
||||||
|
left_box = (MARGIN, MARGIN, MARGIN + left_width, canvas_height - MARGIN)
|
||||||
|
right_box = (MARGIN + left_width + GUTTER, MARGIN, canvas_width - MARGIN, canvas_height - MARGIN)
|
||||||
|
|
||||||
|
# --- Draw Left Panel ---
|
||||||
|
# Shadow
|
||||||
|
draw_shadow(bg, left_box, radius=30, offset=(0, 10), blur=20, shadow_color=(0,0,0,30))
|
||||||
|
# Card
|
||||||
|
draw_rounded_rect(draw, left_box, radius=30, fill=(255, 255, 255, 255))
|
||||||
|
|
||||||
|
# Left Content
|
||||||
|
cx = (left_box[0] + left_box[2]) // 2
|
||||||
|
cy_top = left_box[1] + 120
|
||||||
|
|
||||||
|
# Title
|
||||||
|
text = "访客登记"
|
||||||
|
try:
|
||||||
|
w = draw.textlength(text, font=title_font)
|
||||||
|
except:
|
||||||
|
w, _ = draw.textsize(text, font=title_font)
|
||||||
|
draw.text((cx - w/2, cy_top), text, font=title_font, fill=PRIMARY_COLOR)
|
||||||
|
|
||||||
|
# Subtitle
|
||||||
|
text = "Visitor Registration"
|
||||||
|
try:
|
||||||
|
w = draw.textlength(text, font=subtitle_font)
|
||||||
|
except:
|
||||||
|
w, _ = draw.textsize(text, font=subtitle_font)
|
||||||
|
draw.text((cx - w/2, cy_top + 70), text, font=subtitle_font, fill=SECONDARY_COLOR)
|
||||||
|
|
||||||
|
# QR Code
|
||||||
|
qr_size = min(left_width - 100, 500)
|
||||||
|
qr_y = cy_top + 180
|
||||||
|
|
||||||
|
# Resize QR
|
||||||
|
qr_pil = Image.fromarray(cv2.cvtColor(qr_image, cv2.COLOR_BGR2RGB))
|
||||||
|
qr_pil = qr_pil.resize((qr_size, qr_size), Image.LANCZOS)
|
||||||
|
bg.paste(qr_pil, (cx - qr_size//2, qr_y))
|
||||||
|
|
||||||
|
# Scan Hint
|
||||||
|
hint_y = qr_y + qr_size + 50
|
||||||
|
hint_text = "请使用微信扫码登记"
|
||||||
|
try:
|
||||||
|
w = draw.textlength(hint_text, font=step_title_font)
|
||||||
|
except:
|
||||||
|
w, _ = draw.textsize(hint_text, font=step_title_font)
|
||||||
|
|
||||||
|
# Draw a pill background for hint
|
||||||
|
pill_padding = 20
|
||||||
|
pill_box = (cx - w/2 - pill_padding, hint_y - pill_padding, cx + w/2 + pill_padding, hint_y + 40 + pill_padding)
|
||||||
|
draw.rounded_rectangle(pill_box, radius=30, fill=(240, 242, 245), outline=None)
|
||||||
|
draw.text((cx - w/2, hint_y), hint_text, font=step_title_font, fill=ACCENT_COLOR)
|
||||||
|
|
||||||
|
# --- Draw Right Panel ---
|
||||||
|
|
||||||
|
# Right Title Area - Compacted
|
||||||
|
rt_y = MARGIN + 20
|
||||||
|
draw.text((right_box[0], rt_y), INSTRUCTION_TITLE, font=title_font, fill=PRIMARY_COLOR)
|
||||||
|
draw.text((right_box[0], rt_y + 60), INSTRUCTION_SUBTITLE, font=subtitle_font, fill=SECONDARY_COLOR)
|
||||||
|
|
||||||
|
# Separator Line
|
||||||
|
sep_y = rt_y + 110
|
||||||
|
draw.line((right_box[0], sep_y, right_box[2], sep_y), fill=(200, 200, 200), width=2)
|
||||||
|
|
||||||
|
# Grid Configuration
|
||||||
|
grid_y_start = sep_y + 40
|
||||||
|
grid_width = right_width
|
||||||
|
cols = 2
|
||||||
|
col_gap = 30
|
||||||
|
row_gap = 30
|
||||||
|
|
||||||
|
col_width = (grid_width - (cols - 1) * col_gap) // cols
|
||||||
|
|
||||||
|
# Pre-calculate text layout to find uniform height
|
||||||
|
max_lines = 0
|
||||||
|
processed_steps = []
|
||||||
|
|
||||||
|
padding = 30
|
||||||
|
badge_size = 50
|
||||||
|
text_left_margin = badge_size + 20
|
||||||
|
|
||||||
|
# Calculate available width for text inside a card
|
||||||
|
text_max_width = col_width - padding * 2 - text_left_margin
|
||||||
|
|
||||||
|
for i, step in enumerate(INSTRUCTION_STEPS):
|
||||||
|
# Remove "第一步:" etc prefix if present to make it cleaner, we have badges
|
||||||
|
clean_step = step.split(":", 1)[-1] if ":" in step else step
|
||||||
|
|
||||||
|
lines = wrap_text(clean_step, body_font, text_max_width, draw)
|
||||||
|
processed_steps.append(lines)
|
||||||
|
max_lines = max(max_lines, len(lines))
|
||||||
|
|
||||||
|
# Calculate Card Height
|
||||||
|
# Padding top + max_lines * line_height + Padding bottom
|
||||||
|
line_height = body_font.size + 10
|
||||||
|
card_content_height = max(badge_size, max_lines * line_height)
|
||||||
|
uniform_card_height = int(padding * 2 + card_content_height)
|
||||||
|
|
||||||
|
# Check if we overflow canvas height
|
||||||
|
total_grid_height = 3 * uniform_card_height + 2 * row_gap
|
||||||
|
if grid_y_start + total_grid_height > canvas_height - MARGIN:
|
||||||
|
print(f"Warning: Content might overflow vertically. Required: {grid_y_start + total_grid_height}, Available: {canvas_height - MARGIN}")
|
||||||
|
# Dynamic adjustment if needed (e.g. reduce gaps) but for now we rely on the font resizing done in main()
|
||||||
|
|
||||||
|
# Draw Grid
|
||||||
|
for idx, lines in enumerate(processed_steps):
|
||||||
|
row = idx // cols
|
||||||
|
col = idx % cols
|
||||||
|
|
||||||
|
x = right_box[0] + col * (col_width + col_gap)
|
||||||
|
y = grid_y_start + row * (uniform_card_height + row_gap)
|
||||||
|
|
||||||
|
card_box = (x, y, x + col_width, y + uniform_card_height)
|
||||||
|
|
||||||
|
# Card Shadow
|
||||||
|
draw_shadow(bg, card_box, radius=20, offset=(0, 4), blur=12, shadow_color=(0,0,0,15))
|
||||||
|
# Card Body
|
||||||
|
draw_rounded_rect(draw, card_box, radius=20, fill=(255, 255, 255))
|
||||||
|
|
||||||
|
# Badge (Step Number)
|
||||||
|
bx = x + padding
|
||||||
|
by = y + padding
|
||||||
|
draw.ellipse((bx, by, bx + badge_size, by + badge_size), fill=ACCENT_COLOR)
|
||||||
|
|
||||||
|
num_text = str(idx + 1)
|
||||||
|
try:
|
||||||
|
nw = draw.textlength(num_text, font=badge_font)
|
||||||
|
except:
|
||||||
|
nw, _ = draw.textsize(num_text, font=badge_font)
|
||||||
|
# Center number
|
||||||
|
draw.text((bx + (badge_size - nw)/2, by + (badge_size - badge_font.size)/2 - 2),
|
||||||
|
num_text, font=badge_font, fill=(255, 255, 255))
|
||||||
|
|
||||||
|
# Text
|
||||||
|
tx = x + padding + text_left_margin
|
||||||
|
ty = y + padding
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
draw.text((tx, ty), line, font=body_font, fill=PRIMARY_COLOR)
|
||||||
|
ty += line_height
|
||||||
|
|
||||||
|
return cv2.cvtColor(np.array(bg.convert("RGB")), cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
def create_mock_qrcode(size: int = 640) -> np.ndarray:
|
||||||
|
img = np.full((size, size, 3), 255, dtype=np.uint8) # White background
|
||||||
|
|
||||||
|
# Draw some patterns
|
||||||
|
block = size // 15
|
||||||
|
for y in range(0, size, block):
|
||||||
|
for x in range(0, size, block):
|
||||||
|
if (x // block + y // block) % 2 == 0:
|
||||||
|
color = (0, 0, 0)
|
||||||
|
# Corner markers
|
||||||
|
if (x < 3*block and y < 3*block) or (x > size-4*block and y < 3*block) or (x < 3*block and y > size-4*block):
|
||||||
|
color = (0, 0, 0)
|
||||||
|
elif np.random.rand() > 0.3:
|
||||||
|
color = (0, 0, 0)
|
||||||
|
else:
|
||||||
|
color = (255, 255, 255)
|
||||||
|
|
||||||
|
cv2.rectangle(img, (x, y), (x + block, y + block), color, -1)
|
||||||
|
|
||||||
|
# Borders for markers
|
||||||
|
marker_len = 3 * block
|
||||||
|
thickness = block
|
||||||
|
# Top Left
|
||||||
|
cv2.rectangle(img, (0,0), (marker_len, marker_len), (0,0,0), thickness)
|
||||||
|
# Top Right
|
||||||
|
cv2.rectangle(img, (size-marker_len,0), (size, marker_len), (0,0,0), thickness)
|
||||||
|
# Bottom Left
|
||||||
|
cv2.rectangle(img, (0,size-marker_len), (marker_len, size), (0,0,0), thickness)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
def main():
|
||||||
|
config = load_config()
|
||||||
|
preferred_font = config.get("display", {}).get("font_path") if config else None
|
||||||
|
|
||||||
|
# Define Fonts - Adjusted sizes for better fit
|
||||||
|
# Title for left/right headers
|
||||||
|
title_font = resolve_font(56, preferred_font)
|
||||||
|
# Subtitles
|
||||||
|
subtitle_font = resolve_font(30, preferred_font)
|
||||||
|
# Step text - Reduced to ensure fit
|
||||||
|
body_font = resolve_font(24, preferred_font)
|
||||||
|
# Step titles or emphasis
|
||||||
|
step_title_font = resolve_font(28, preferred_font)
|
||||||
|
# Badge numbers
|
||||||
|
badge_font = resolve_font(30, preferred_font)
|
||||||
|
|
||||||
|
# Mock data
|
||||||
|
mock_qr = create_mock_qrcode(500)
|
||||||
|
|
||||||
|
# Build
|
||||||
|
canvas = build_canvas(mock_qr, title_font, subtitle_font, body_font, step_title_font, badge_font)
|
||||||
|
|
||||||
|
# Display
|
||||||
|
window_name = "Visitor Registration Preview"
|
||||||
|
cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
|
||||||
|
cv2.resizeWindow(window_name, 1280, 720)
|
||||||
|
cv2.imshow(window_name, canvas)
|
||||||
|
|
||||||
|
print("Displaying preview. Press any key to exit.")
|
||||||
|
cv2.waitKey(0)
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
|
||||||
|
# Save for verification
|
||||||
|
cv2.imwrite("preview_qrcode_sota.png", canvas)
|
||||||
|
print("Saved preview to preview_qrcode_sota.png")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -1,9 +0,0 @@
|
|||||||
pencv-python>=4.8.0
|
|
||||||
websockets>=12.0
|
|
||||||
pyyaml>=6.0
|
|
||||||
pillow>=10.0.0
|
|
||||||
numpy>=1.24.0
|
|
||||||
psutil>=5.9.0
|
|
||||||
requests>=2.31.0
|
|
||||||
compreface-sdk>=0.6.0
|
|
||||||
screeninfo>=0.8
|
|
||||||
32
start.sh
32
start.sh
@ -1,15 +1,30 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# 创建日志目录
|
# --- 日志相关 ---
|
||||||
LOG_DIR="/home/unitree/robot_face_rec/logs"
|
LOG_DIR="/home/unitree/robot_face_rec/logs/face_rec"
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
|
LOG_FILE="$LOG_DIR/face_rec_$(date +%Y%m%d_%H%M%S).log"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "===== $(date) Starting face_rec script ====="
|
||||||
|
echo "User: $(whoami)"
|
||||||
|
echo "PWD before cd: $(pwd)"
|
||||||
|
echo "Python: $(which python) / Version: $(python --version 2>&1)"
|
||||||
|
} >> "$LOG_FILE"
|
||||||
|
|
||||||
# 切换到项目目录
|
# 切换到项目目录
|
||||||
cd /home/unitree/robot_face_rec || {
|
cd /home/unitree/robot_face_rec || {
|
||||||
echo "Failed to cd to project dir"
|
echo "Failed to cd to project dir" >> "$LOG_FILE"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "PWD after cd: $(pwd)"
|
||||||
|
echo "Env before display detection: DISPLAY=$DISPLAY, XAUTHORITY=$XAUTHORITY"
|
||||||
|
} >> "$LOG_FILE"
|
||||||
|
|
||||||
|
# --- DISPLAY 和 XAUTHORITY 设置逻辑 ---
|
||||||
|
|
||||||
# 设置DISPLAY环境变量
|
# 设置DISPLAY环境变量
|
||||||
# 使用Linux系统最常见的默认值:0
|
# 使用Linux系统最常见的默认值:0
|
||||||
export DISPLAY=":0"
|
export DISPLAY=":0"
|
||||||
@ -46,5 +61,12 @@ fi
|
|||||||
export XAUTHORITY="$xauth_path"
|
export XAUTHORITY="$xauth_path"
|
||||||
echo "Using XAUTHORITY: $XAUTHORITY"
|
echo "Using XAUTHORITY: $XAUTHORITY"
|
||||||
|
|
||||||
# 执行 Python 程序(Python 代码自己管理日志)
|
export XAUTHORITY="$xauth_path"
|
||||||
exec python face_rec.py
|
|
||||||
|
{
|
||||||
|
echo "Chosen DISPLAY = $DISPLAY"
|
||||||
|
echo "Using XAUTHORITY = $XAUTHORITY"
|
||||||
|
} >> "$LOG_FILE"
|
||||||
|
|
||||||
|
# 最终执行 Python 程序(不后台化,用 exec 替换进程)
|
||||||
|
exec python face_rec.py >> "$LOG_FILE" 2>&1
|
||||||
Loading…
Reference in New Issue
Block a user