"""黑手套检测器:YOLO-World 手套框 + RTMPose hand5 21 点 + 帧间稳定。 检测契约使用本包的 DetectedHand:detect(frame_bgr) -> list[DetectedHand](landmarks (21,2) 像素、MediaPipe 拓扑 0=腕、 label "Left"/"Right"、score=检测框 conf)。下游(voter/抬升/槽位/平滑/ 渲染)零改动——唯一注意 HandednessVoter 只数 score>=0.7 的票(identity.py MIN_VOTE_SCORE=0.7),框 conf 不足 0.7 时该手 label 不进票仓。已核实 identity.py:139/156-158:voter 对此退化为 latch(票仓只累积已表决 label,轨迹存续期锁死首票)——本模块按 track_id 自建票仓与 voter 互补。 2026-08-20 重构(用户指令:切换 world 自带检测器 + 抑制抖动): 检测框后端 = 本包 glove_package.world_detector.WorldDetector(YOLO-World yolov8m-worldv2.pt + 提示词 ["hand","glove"],imgsz 320、conf 0.05、 两级 NMS、按 conf Top-N——包内 40 张实测 40/40 召回)。权重名含 "world" 走 world 后端,否则普通 YOLO(best.pt 回退开关,conf 0.3)。 world_detector 只读复用,不改包内任何文件。 帧间稳定层(对齐裸手 MediaPipeDetector 内部机制,见方案对照表): 1. HandTracker(本包 glove_package.hand_tracker):贪心 IoU 匹配 + 框 EMA α=0.7 + 运动门控 3px(静止帧免 RTMPose 推理、0.33s 强制 刷新)+ 丢框持 3 帧 + track_id 帧间身份。注意 _needs_pose 要求 last_good_kpts 非 None 门控才生效(hand_tracker.py:293)——每 track 首次合格 pose 后必须 update_last_good 初始化。 2. 逐点 OneEuroFilter2D(freq_min=5.0/beta=0.05/dcutoff=1.0,归一化 坐标域,键 (track_id, 点序号),单调钟)——裸手同款同参数;冻结帧 不重复喂滤波器(防把点吸向陈旧值)。 3. 快动/低置信稳定:低置信结果冻结、框中心运动补偿、双手重叠裁剪, 以及短时丢失后的轨迹状态复活,避免骨架钉死、跳手和左右身份重置。 4. 退化族过滤"丢弃→冻结":三条件参数不动(旧链 3D 毒化定案——框外 点≥16 / 唯一点<15 / span<0.2×框对角线,对 tracker 平滑框判)。命中 退化的新 pose 不写 tracker → 输出上次滤波值;连续无合格 pose 超 freeze_max(默认 15,对齐 --propagate-max)帧不输出走传播。运动 门控跳过推理的帧不算退化(免误伤健康静止手)。 5. 手性 per-track 锁存(连续 3 票相同 → 锁死不再变,镜像 voter latch; 双手框重叠冻结,镜像 voter OVERLAP_PX)。实测否决 7 帧滑窗多数表决: 手旋转时 cross_z 符号缓慢翻转,翻转过渡期两手滑窗多数会短暂同值 → 下游同 label 守卫触发 358/359 帧(旧裸直通+voter latch 只 5 帧)。 锁存后 label 永不变,慢翻转被完全吸收。手性本身几何合成: cross_z = axis.x*d.y - axis.y*d.x(axis=P9-P0,d=P5-P0),>0 → Left(掌心朝相机约定;手背朝相机会反转——下游 voter latch + 几何 兜底吸收)。 """ from __future__ import annotations import math import os import sys import time from collections import deque import numpy as np from app.processing.black_glove.contracts import DetectedHand from app.processing.black_glove.filters import OneEuroFilter2D from app.processing.black_glove.glove_package.hand_tracker import HandTracker from app.processing.black_glove.glove_package.world_detector import WorldDetector, iou from app.processing.black_glove.pose_backends import ( MediaPipePoseBackend, RtmposePoseBackend, ) _PACKAGE_ROOT = os.path.dirname(os.path.abspath(__file__)) _WORLD_WEIGHTS = os.path.join(_PACKAGE_ROOT, "weights", "yolov8m-worldv2.pt") _MP_TASK = os.path.join(_PACKAGE_ROOT, "hand_landmarker.task") _WORLD_PROMPT = ["hand", "glove"] _WORLD_CONF = 0.05 _YOLO_CONF = 0.3 _OVERLAP_FRAC = 0.05 # 双手框中心距 < 0.05*max(w,h) → 手性冻结(镜像 voter) _REVIVE_MAX = 90 # 丢失后允许继承稳定状态的最长帧数 _REVIVE_DIST = 0.3 # 复活时框中心允许的最大相对距离 class GloveDetector: """YOLO-World/YOLO 出框 + HandTracker 追踪 + RTMPose 关键点。 惰性加载:ultralytics/torch/rtmlib 只在首次构造时 import(裸手启动 零开销;首次切换有一次 ~1s 加载 + CUDA 预热)。 """ def __init__(self, weights=None, det_conf=None, num_hands=2, device="auto", pose_device="auto", pose_backend="rtmpose", pose_model=None, prompt=None, imgsz=320, nms_iou=0.6, use_tracker=True, movement_thresh=3.0, skip_timeout=10, box_alpha=0.7, lost_timeout=3, iou_thr=0.3, use_oe=True, oe_freq_min=5.0, oe_beta=0.05, oe_dcutoff=1.0, use_vote=True, vote_window=3, freeze_max=15, pose_conf_thr=0.3, hold_max=12, pose_box_raw=False, hold_translate=True, new_track_conf=0.25, spawn_confirm=2, match_contain_thr=0.7): if weights is None: weights = _WORLD_WEIGHTS if not os.path.isfile(weights): raise FileNotFoundError(weights) self.backend = "world" if "world" in os.path.basename(weights).lower() \ else "yolo" if det_conf is None: det_conf = _WORLD_CONF if self.backend == "world" else _YOLO_CONF self.det_conf = det_conf self.num_hands = num_hands self.imgsz = max(160, int(imgsz)) self.last_boxes = [] # 本轮 raw 框 (x1,y1,x2,y2,conf),供 overlay 画框 if device == "auto": try: import torch device = "cuda" if torch.cuda.is_available() else "cpu" except ImportError: device = "cpu" self.device = device if self.backend == "world": self._world = WorldDetector(weights, prompt=prompt, imgsz=imgsz, device=self.device, nms_iou=nms_iou) self._model = None else: from ultralytics import YOLO self._world = None self._model = YOLO(weights) if pose_device == "auto": pose_device = "cuda" if device == "cuda" else "cpu" self.pose_backend = pose_backend self._pose_model = pose_model self._pose = self._build_pose(pose_backend, pose_device) self.pose_device = self._pose.device # 帧间稳定层 self._use_oe = use_oe self._oe_freq_min, self._oe_beta, self._oe_dcutoff = \ oe_freq_min, oe_beta, oe_dcutoff self._use_vote = use_vote self._vote_window = vote_window self._freeze_max = freeze_max self._pose_conf_thr = pose_conf_thr self._hold_max = None if (hold_max is not None and hold_max < 0) \ else hold_max self._pose_box_raw = pose_box_raw self._hold_translate = hold_translate self._iou_thr = iou_thr self._spawn_confirm = spawn_confirm self._spawn_cand = {} # 新 track 候选:(量化位置键 -> (框, 帧数)) self._tracker = HandTracker(max_hands=num_hands, iou_match_thr=iou_thr, lost_timeout=lost_timeout, movement_thresh=movement_thresh, skip_timeout=skip_timeout, box_smooth_alpha=box_alpha, new_track_conf=new_track_conf, match_contain_thr=match_contain_thr) \ if use_tracker else None self._filters = {} # (track_id, 点序号) -> OneEuroFilter2D self._votes = {} # track_id -> deque(label, maxlen=7) self._last_label = {} # track_id -> 上次稳定 label self._last_out = {} # track_id -> (21,2) 上次滤波输出(冻结帧复用) self._posed = {} # track_id -> True(至少出过一次合格 pose) self._stale = {} # track_id -> 连续退化帧数(门控跳过不计) self._conf_mem = {} # track_id -> 上次框 conf self._held = set() # 本轮低置信度持出 self._hold_count = {} # tid -> 连续低置信 hold 帧数(hold_max 逃逸) self._last_pose_conf = {} # track_id -> 姿态点均值置信度 self._grave = {} # 死亡轨迹的稳定状态,供短期复活 self._last_tbox = {} # 上一帧活跃轨迹框 self._last_raw_pose = [] self._frame_n = 0 self._t0 = time.perf_counter() # ── 主入口 ────────────────────────────────────────────── def _confirm_spawns(self, raw): """新框入场确认:与现有 track(含丢失未超时的短闪回)有重叠 (IoU≥iou_thr)的框视为持续检测直接放行;其余须连续 spawn_confirm 帧按最佳 IoU 链(≥0.5)延续才喂 tracker。只拦 单帧/断续的背景误检(new_track_conf 放低后易被低门放行的碎片), 跟踪中或只闪失 1-2 帧的手不受影响。""" if self._spawn_confirm <= 1: return raw refs = [] # 放行基准:活跃 track 的平滑框+原始框 for t in self._tracker.tracks: if t.active: refs.append(list(t.box)) refs.append(list(t.raw_box)) out, cand, used = [], {}, set() for b in raw: if refs and max(iou(b[:4], r) for r in refs) >= self._iou_thr: out.append(b) continue best_key, best_iou = None, 0.5 for key, (pb, _n) in self._spawn_cand.items(): if key in used: continue v = iou(b[:4], pb[:4]) if v > best_iou: best_key, best_iou = key, v if best_key is not None: n = self._spawn_cand[best_key][1] + 1 if n >= self._spawn_confirm: out.append(b) # 确认入场 else: cand[best_key] = (b, n) used.add(best_key) else: cand[(len(cand),)] = (b, 1) # 新候选 self._spawn_cand = cand return out def detect(self, frame_bgr): self._frame_n += 1 self._held = set() self._last_pose_conf = {} self._last_raw_pose = [] h, w = frame_bgr.shape[:2] raw = self._detect_boxes(frame_bgr, h, w) self.last_boxes = raw if self._tracker is None: return self._detect_stateless(frame_bgr, raw, h, w) # 1) 追踪 + 入殓 + 复活继承 + 运动门控推理 # 新框先过入场确认(spawn_confirm),再喂 tracker feed = (self._confirm_spawns(raw) if self._spawn_confirm > 1 else raw) self._tracker.update_detections([list(b[:4]) for b in feed], [b[4] for b in feed]) self._bury_dead() self._revive_tracks(h, w) boxes_for_pose, idxs = self._tracker.get_boxes_for_pose() if boxes_for_pose: # 裁剪来源统一口径:再对两活跃框做重叠裁剪,最后取本轮 # 需要推理的子集。另一只手即使本帧不推理,也必须参与裁剪。 crop_src = {} for track in self._tracker.tracks: if track.active and track.lost_counter == 0: crop_src[track.id] = ( list(track.raw_box) if self._pose_box_raw else list(track.box)) # pose 裁剪边距:平滑框在快速运动时滞后于手(EMA α=0.7 稳态 # 滞后 ~2.3 帧),手的前进边缘会冲出框外被裁掉 → RTMPose 在 # 缺边的裁剪上估计 → 关键点被拉回框内 → 运动方向上出现 # 肉眼可见的"延迟感"(实测 x 方向响应率仅 0.64、y 方向正常 # 的不对称滞后)。按框尺寸外扩 15% 边距把滞后量包进来; # RTMPose 输入固定 256×256,边距只增加裁剪余量,不改变 # tracker/匹配/显示任何状态。 for _tid, _b in crop_src.items(): _pad_x = 0.15 * max(1.0, _b[2] - _b[0]) _pad_y = 0.15 * max(1.0, _b[3] - _b[1]) _b[0] = max(0.0, _b[0] - _pad_x) _b[1] = max(0.0, _b[1] - _pad_y) _b[2] = min(float(w), _b[2] + _pad_x) _b[3] = min(float(h), _b[3] + _pad_y) if len(crop_src) == 2: ids2 = sorted(crop_src) crop_src[ids2[0]], crop_src[ids2[1]] = self._clip_overlap( crop_src[ids2[0]], crop_src[ids2[1]]) boxes_for_pose = [crop_src[self._tracker.tracks[i].id] for i in idxs] new_pose = {} # track_id -> (tracks 下标, (21,2) 合格原始点) raw_map = {} # track_id -> (21,2) 原始点 degraded = set() # 本轮要求推理但退化/无输出的 track_id if boxes_for_pose: kpts, scores = self._pose(frame_bgr, bboxes=boxes_for_pose) if kpts is None: degraded.update(self._tracker.tracks[i].id for i in idxs) else: score_array = (None if scores is None else np.asarray(scores, np.float32)) for j, (idx, k) in enumerate(zip(idxs, kpts)): pts = np.asarray(k, np.float32).reshape(21, 2) tid = self._tracker.tracks[idx].id if score_array is not None and j < len(score_array): self._last_pose_conf[tid] = ( float(np.asarray(score_array[j], np.float32).mean()) ) if self._degenerate(pts, boxes_for_pose[j]): degraded.add(tid) # 冻结:不写 tracker elif self._pose_conf_thr is not None \ and tid in self._last_pose_conf \ and self._last_pose_conf[tid] < self._pose_conf_thr \ and tid in self._last_out: # hold_max 逃逸:持续低置信 = 大概率真实新姿势 # (手背/握拳——hand5 逐点置信天然偏低),无限 # hold 会把骨架永久冻在旧姿势。连续满 hold_max # 帧放行本轮低置信点(走正常新 pose 路径收敛); # 瞬时低置信(运动模糊)仍持旧点防抖。 if self._hold_max is not None: self._hold_count[tid] = \ self._hold_count.get(tid, 0) + 1 if self._hold_count[tid] >= self._hold_max: new_pose[tid] = (idx, pts) self._hold_count[tid] = 0 else: self._held.add(tid) else: self._held.add(tid) else: self._hold_count[tid] = 0 new_pose[tid] = (idx, pts) if tid not in degraded: raw_map[tid] = pts if new_pose: idxs2 = [v[0] for v in new_pose.values()] kp = np.stack([v[1] for v in new_pose.values()]) self._tracker.update_pose_results( idxs2, kp, np.zeros((len(idxs2), 21), np.float32)) for v in new_pose.values(): # 门控生效前提:_needs_pose 要求 last_good_kpts 非 None # (hand_tracker.py:293)。首次合格 pose 后初始化一次。 # 不用 update_last_good API——它按 active 列表下标取 track # (hand_tracker.py:270),与 get_boxes_for_pose 返回的 # self.tracks 下标不一致(超 max_hands 被停用的 track 仍在 # self.tracks 中,实机 world 假框触发 3 active → IndexError)。 tr = self._tracker.tracks[v[0]] tr.last_good_kpts = tr.kpts.copy() # 2) 汇总输出(track_id 稳定序,防 top-2 conf 换位造成槽位换手) tboxes, tkpts, _, ids = self._tracker.get_results() if not ids: self._gc_dead(set()) return [] self._last_tbox = {int(tid): list(box) for tid, box in zip(ids, tboxes)} ts = (time.perf_counter() - self._t0) * 1000.0 confs = self._match_confs(tboxes, raw) t_by_id = {t.id: t for t in self._tracker.tracks} out = [] raw_out = [] for i in np.argsort(ids): tid = int(ids[i]) if tid in new_pose: pts = new_pose[tid][1] self._posed[tid] = True self._stale[tid] = 0 self._last_out[tid] = (self._smooth_points(tid, pts, w, h, ts) if self._use_oe else pts.astype(np.float32)) raw_lab = self._handedness(pts) pts_out = self._last_out[tid] raw_out.append(raw_map.get(tid)) elif tid not in self._posed: raw_out.append(None) continue # 全零占位(get_results 对未出过 pose 的 track # 吐 (21,2) 零数组,hand_tracker.py:231)→ 不输出 else: raw_out.append(raw_map.get(tid)) if tid in degraded: self._stale[tid] = self._stale.get(tid, 0) + 1 if self._stale.get(tid, 0) > self._freeze_max: continue # 幽灵手上限 → 本帧不出,走下游传播 pts_out = self._last_out[tid] feed = (self._use_oe and tid in self._held and tid in self._last_out) if feed: self._last_out[tid] = self._smooth_points( tid, pts_out, w, h, ts) pts_out = self._last_out[tid] if self._hold_translate: tr = t_by_id.get(tid) if tr is not None and tr.prev_center is not None: cx = (tr.box[0] + tr.box[2]) / 2.0 cy = (tr.box[1] + tr.box[3]) / 2.0 pts_out = pts_out + np.asarray( [cx - tr.prev_center[0], cy - tr.prev_center[1]], np.float32) raw_lab = None label = (self._vote_label(tid, raw_lab, w, h, tboxes, ids) if self._use_vote else (raw_lab or self._last_label.get(tid, ""))) conf = confs[i] if conf is not None: self._conf_mem[tid] = conf else: conf = self._conf_mem.get(tid, 0.5) out.append(DetectedHand(landmarks=pts_out.copy(), label=label, score=float(conf), index=0)) self._last_raw_pose = raw_out self._gc_dead(set(int(i) for i in ids)) return out # ── 框源(world / yolo 后端) ─────────────────────────── def _detect_boxes(self, frame_bgr, h, w): if self._world is not None: boxes, confs = self._world(frame_bgr, conf=self.det_conf, max_boxes=self.num_hands) # world postprocess 已做 clip/8px 过滤/两级 NMS/按 conf Top-N return [(float(x1), float(y1), float(x2), float(y2), float(c)) for (x1, y1, x2, y2), c in zip(boxes, confs)] r = self._model(frame_bgr, conf=self.det_conf, imgsz=640, device=self.device, verbose=False)[0] boxes = [] for xyxy, conf in zip(r.boxes.xyxy.cpu().numpy(), r.boxes.conf.cpu().numpy()): x1 = max(0.0, min(float(xyxy[0]), w - 1.0)) y1 = max(0.0, min(float(xyxy[1]), h - 1.0)) x2 = max(0.0, min(float(xyxy[2]), w - 1.0)) y2 = max(0.0, min(float(xyxy[3]), h - 1.0)) if x2 - x1 < 8 or y2 - y1 < 8: # 同 glove_package 口径 continue boxes.append((x1, y1, x2, y2, float(conf))) boxes.sort(key=lambda b: -b[4]) return boxes[:self.num_hands] def _detect_stateless(self, frame_bgr, raw, h, w): """use_tracker=False 的保底路径(原无状态行为原样保留)。""" if not raw: return [] result = self._pose(frame_bgr, bboxes=[[b[0], b[1], b[2], b[3]] for b in raw]) if isinstance(result, tuple): kpts, scores = result else: kpts, scores = result, None hands = [] if kpts is None: return hands score_array = (None if scores is None else np.asarray(scores, np.float32)) for i, ((x1, y1, x2, y2, conf), k) in enumerate(zip(raw, kpts)): pts = np.asarray(k, np.float32).reshape(21, 2) if self._degenerate(pts, (x1, y1, x2, y2)): continue if (score_array is not None and i < len(score_array) and self._pose_conf_thr is not None and float(np.nanmean(score_array[i])) < self._pose_conf_thr): continue hands.append(DetectedHand(landmarks=pts, label=self._handedness(pts), score=conf, index=0)) return hands # ── 稳定层组件 ───────────────────────────────────────── def _smooth_points(self, tid, k, w, h, ts_ms): """逐点 OneEuroFilter2D(归一化坐标域,与裸手同域同参数)。""" norm = np.asarray(k, np.float64) / [w, h] out = np.empty_like(norm) for j in range(21): key = (tid, j) f = self._filters.get(key) if f is None: f = self._filters[key] = OneEuroFilter2D( self._oe_freq_min, self._oe_beta, self._oe_dcutoff) out[j, 0], out[j, 1] = f(norm[j, 0], norm[j, 1], ts_ms) return (out * [w, h]).astype(np.float32) def _vote_label(self, tid, raw_lab, w, h, tboxes, ids): """per-track 手性锁存:连续 vote_window 票相同 → 锁死不再变。 未锁定期返回最近一票原始几何 label(= 旧裸直通行为,下游 voter latch + 同 label 守卫吸收);锁存后 label 永不变(镜像 voter latch——它才是旧验收 5 帧同 label 的稳定来源,滑窗多数会重开 已定问题,Project_Test10 实测 358/359 帧同 label)。 """ if len(tboxes) == 2: ca = ((tboxes[0][0] + tboxes[0][2]) / 2, (tboxes[0][1] + tboxes[0][3]) / 2) cb = ((tboxes[1][0] + tboxes[1][2]) / 2, (tboxes[1][1] + tboxes[1][3]) / 2) if math.hypot(ca[0] - cb[0], ca[1] - cb[1]) \ < _OVERLAP_FRAC * max(w, h): return self._last_label.get(tid, "") or (raw_lab or "") dq = self._votes.setdefault(tid, deque(maxlen=self._vote_window)) if raw_lab: dq.append(raw_lab) if self._last_label.get(tid): return self._last_label[tid] # 已锁死:翻转不再重开 votes = list(dq) if not votes: return "" if len(votes) == self._vote_window \ and all(v == votes[0] for v in votes): self._last_label[tid] = votes[0] # 连续 N 票一致 → 锁死 return votes[0] return votes[-1] def track_boxes(self): """返回当前活跃且未丢失轨迹的平滑框。""" if self._tracker is None: return [] return [list(track.box) for track in self._tracker.tracks if track.active and track.lost_counter == 0] def pose_on_boxes(self, frame_bgr, boxes): """在指定框上调用当前姿态后端,不修改追踪器状态。""" if not boxes or self._pose is None: return [] kpts, _ = self._pose( frame_bgr, bboxes=[list(map(float, box)) for box in boxes], ) if kpts is None: return [] out = [] for k, box in zip(kpts, boxes): pts = np.asarray(k, np.float32).reshape(21, 2) if not self._degenerate(pts, box): out.append(pts) return out def last_raw_pose(self): """返回本帧实际推理得到的原始姿态,顺序与 detect() 一致。""" return self._last_raw_pose def _match_confs(self, tboxes, raw): """tracker 平滑框 ↔ raw 框(带 conf)mini-IoU 回填 conf(score 用)。""" confs = [] for tb in tboxes: best = None for rb in raw: iou_v = iou(tb, rb[:4]) if iou_v >= 0.1 and (best is None or iou_v > best[0]): best = (iou_v, rb[4]) confs.append(best[1] if best else None) return confs def _clip_overlap(self, box_a, box_b): """双手接近时沿主导轴裁开姿态 crop,避免 RTMPose 跟错手。""" a, b = list(box_a), list(box_b) ca = ((a[0] + a[2]) / 2.0, (a[1] + a[3]) / 2.0) cb = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0) dx, dy = abs(ca[0] - cb[0]), abs(ca[1] - cb[1]) if max(dx, dy) < 20.0: return a, b if dx >= dy: if max(a[0], b[0]) >= min(a[2], b[2]): return a, b mid = (ca[0] + cb[0]) / 2.0 if ca[0] < cb[0]: a[2] = min(a[2], mid) b[0] = max(b[0], mid) else: a[0] = max(a[0], mid) b[2] = min(b[2], mid) else: if max(a[1], b[1]) >= min(a[3], b[3]): return a, b mid = (ca[1] + cb[1]) / 2.0 if ca[1] < cb[1]: a[3] = min(a[3], mid) b[1] = max(b[1], mid) else: a[1] = max(a[1], mid) b[3] = min(b[3], mid) return a, b def _bury_dead(self): """保存刚被 tracker 删除的轨迹,供短时间内的新轨迹复活。""" live = {track.id for track in self._tracker.tracks} state_ids = set().union(self._votes, self._last_label, self._last_out, self._posed, self._stale, self._conf_mem) for tid in state_ids - live: if tid in self._posed and tid in self._last_tbox: self._grave[tid] = ( self._frame_n, self._last_tbox[tid], self._last_out.get(tid), self._votes.get(tid), self._last_label.get(tid), self._conf_mem.get(tid), ) for state in (self._votes, self._last_label, self._last_out, self._posed, self._stale, self._conf_mem): state.pop(tid, None) def _revive_tracks(self, h, w): """将位置接近的短期新轨迹接回旧轨迹的平滑和左右手状态。""" if not self._grave: return live = {track.id for track in self._tracker.tracks} fresh = [tid for tid in live if tid not in self._posed and tid not in self._votes] if not fresh: return boxes = {track.id: track.box for track in self._tracker.tracks} max_dist = _REVIVE_DIST * max(h, w) candidates = { gid: value for gid, value in self._grave.items() if self._frame_n - value[0] <= _REVIVE_MAX } used = set() while True: best = None for tid in fresh: box = boxes.get(tid) if box is None: continue center = ((box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0) for gid, value in candidates.items(): if gid in used: continue old_box = value[1] old_center = ((old_box[0] + old_box[2]) / 2.0, (old_box[1] + old_box[3]) / 2.0) distance = math.hypot(center[0] - old_center[0], center[1] - old_center[1]) if distance < max_dist and (best is None or distance < best[0]): best = (distance, tid, gid) if best is None: break _, tid, gid = best _, _, old_out, old_votes, old_label, old_conf = \ self._grave.pop(gid) for point_index in range(21): filt = self._filters.pop((gid, point_index), None) if filt is not None: self._filters[(tid, point_index)] = filt if old_votes is not None: self._votes[tid] = old_votes if old_label: self._last_label[tid] = old_label if old_conf is not None: self._conf_mem[tid] = old_conf if old_out is not None: self._last_out[tid] = old_out self._posed[tid] = True self._stale[tid] = 0 fresh.remove(tid) used.add(gid) def _gc_dead(self, live_ids): graved = set(self._grave) for key in [k for k in self._filters if k[0] not in live_ids and k[0] not in graved]: del self._filters[key] for tid in [t for t in set().union(self._votes, self._last_label, self._last_out, self._posed, self._stale, self._conf_mem, self._hold_count) if t not in live_ids]: for d in (self._votes, self._last_label, self._last_out, self._posed, self._stale, self._conf_mem, self._hold_count): d.pop(tid, None) for gid in [gid for gid, value in self._grave.items() if self._frame_n - value[0] > _REVIVE_MAX]: del self._grave[gid] for point_index in range(21): self._filters.pop((gid, point_index), None) def _clear_state(self): self._filters.clear() self._votes.clear() self._last_label.clear() self._last_out.clear() self._posed.clear() self._stale.clear() self._conf_mem.clear() self._held.clear() self._spawn_cand = {} self._hold_count.clear() self._last_pose_conf.clear() self._grave.clear() self._last_tbox.clear() self._last_raw_pose = [] self._frame_n = 0 self._t0 = time.perf_counter() # ── 退化过滤 / 手性(旧链定案,不动) ─────────────────── @staticmethod def _degenerate(pts, box): """退化族三条件(对 tracker 平滑框判):钳边/聚团/出框任一命中即弃。""" x1, y1, x2, y2 = box outside = int(np.sum((pts[:, 0] < x1) | (pts[:, 0] > x2) | (pts[:, 1] < y1) | (pts[:, 1] > y2))) if outside >= 16: return True uniq = len(np.unique(pts.round(0).astype(np.int64), axis=0)) if uniq < 15: return True span = math.hypot(pts[:, 0].max() - pts[:, 0].min(), pts[:, 1].max() - pts[:, 1].min()) return span < 0.2 * math.hypot(x2 - x1, y2 - y1) @staticmethod def _handedness(pts): """几何合成:axis=P9-P0 与 d=P5-P0 的 z 叉积符号定左右(掌心朝相机)。""" axis = pts[9] - pts[0] d = pts[5] - pts[0] cross = axis[0] * d[1] - axis[1] * d[0] return "Left" if cross > 0 else "Right" # ── 生命周期 ─────────────────────────────────────────── def _build_pose(self, backend, pose_device): if backend == "mediapipe": return MediaPipePoseBackend(model_path=self._pose_model, device=pose_device) if backend != "rtmpose": raise ValueError(f"未知姿态后端: {backend}(rtmpose/mediapipe)") return RtmposePoseBackend(device=pose_device) def set_pose_backend(self, backend): """只切换姿态后端,保留追踪、滤波和手性状态。""" if backend == self.pose_backend: return self._pose.close() self._pose = self._build_pose(backend, self.pose_device) self.pose_backend = backend self.pose_device = self._pose.device def reset(self): """清空全部帧间状态(tracker/滤波器/票仓)。""" if self._tracker is not None: self._tracker.clear() self._clear_state() self.last_boxes = [] def close(self): self._model = None self._world = None if self._pose is not None: self._pose.close() self._pose = None self._tracker = None self._clear_state()