在实时视觉系统中,检测结果往往只是"对的",却不"有感觉" 。
当我们开始关心"表现力"时,问题就变成:
能否让模型的语义本身,直接参与画面的构成?
本文记录一次基于 YOLO Pose + Segmentation 的实验:
我们将人体分割掩码、关键点骨架与径向衰减模型结合,构建了一种语义驱动的佛光(Halo)视觉效果。

一、整体设计思路
系统目标很明确:
-
左侧:真实语义可视化
-
人体分割区域
-
COCO 语义骨架(真实拓扑,不做美化)
-
-
右侧:语义增强表现
-
以"鼻子 + 双肩"为语义锚点
-
自动推导佛光半径
-
使用连续衰减模型,而非高光或硬描边
-
最终输出为:
[ 原始帧 + 语义调试 ] | [ 语义佛光渲染 ]
这是一个调试友好 + 表现完整的结构。
二、YOLO 多模型并行推理设计
model_paths=("yolo11n-pose.pt", "yolo11n-seg.pt")
这里并没有强行把任务塞进一个模型,而是选择:
| 模型 | 职责 |
|---|---|
| YOLO-Pose | 关键点 & 真实人体拓扑 |
| YOLO-Seg | 像素级语义边界 |
这有三个好处:
-
语义解耦:姿态 ≠ 轮廓
-
稳定性更好:Pose 丢点 ≠ Seg 失效
-
表现层更自由
三、分割掩码的工程处理细节
def _mask_to_float(mask, shape):
这里非常关键的一点是:
-
统一所有 mask 到 float32
-
严格 resize 到原始图像空间
-
不做模糊、不做插值
原因只有一个:
掩码是语义边界,不是特效素材
一旦模糊,语义就被破坏了。
四、真实语义骨架(不是美术骨架)
self.SKELETON = [ (0, 1), (0, 2), (1, 3), (2, 4), ... ]
这不是"好看"的骨架,而是:
-
COCO 原始拓扑
-
保留人体左右不对称
-
保留真实关节关系
这样做的目的只有一个:
让所有后续视觉效果,都基于真实结构,而不是人为假设。
五、佛光的"半径"不是写死的
def _calc_base_radius(nose, lsh, rsh):
佛光半径来源于:
max( nose ↔ left shoulder, nose ↔ right shoulder, min_radius )
这意味着:
-
人越近,佛光越大
-
人越远,佛光越收敛
-
不依赖 bbox(避免抖动)
这是一个完全语义驱动的尺度系统。
六、为什么不用高光?而用径向衰减
def _radial_disk(...): exp(- (dist / radius) ** gamma)
这是整个系统的核心哲学。
你之前提到一句非常关键的话:
不应该是高光,而是前几帧慢慢透明
高光是能量叠加
而佛光应该是存在感衰减
径向指数衰减的优点:
-
没有边界
-
没有"亮点"
-
中心稳定,外围自然消失
这使佛光更像一种状态场,而不是特效。
七、颜色不是一层,而是三层语义叠加
gold_center * (a ** 0.35) gold_mid * (a ** 1.0) gold_outer * (a ** 2.2)
这是非常"反直觉"的地方:
-
中心:指数最低 → 稳定
-
中层:线性
-
外围:指数最高 → 快速消失
这不是为了"金色",而是为了:
让佛光看起来像"存在",而不是"光源"。
八、为什么最后要用分割掩码"盖回人像"
person = orig * M[..., None] out = out * (1 - M[..., None]) + person
这是为了一个非常重要的原则:
人不是特效的一部分
佛光可以包围人,但不能侵蚀人体语义。
这一步保证:
-
人体像素永远是原始真实的
-
特效只存在于"人之外的空间"
import cv2
import numpy as np
from ultralytics import YOLO
class YOLORLTObject:
def __init__( self, model_paths=("yolo11n-pose.pt", "yolo11n-seg.pt"), device="cuda", ): self.models = [] self.model_types = [] for p in model_paths: model = YOLO(p).to(device) self.models.append(model) if "pose" in p: self.model_types.append("pose") elif "seg" in p: self.model_types.append("seg") else: self.model_types.append("det") self.device = device # COCO keypoints self.NOSE = 0 self.L_SH = 5 self.R_SH = 6 # COCO skeleton (真实语义) self.SKELETON = [ (0, 1), (0, 2), (1, 3), (2, 4), (5, 6), (5, 7), (7, 9), (6, 8), (8, 10), (5, 11), (6, 12), (11, 12), (11, 13), (13, 15), (12, 14), (14, 16) ] # ===================================================== # 工具函数 # ===================================================== @staticmethod def _mask_to_float(mask, shape): m = mask.cpu().numpy() if m.dtype != np.uint8: m = (m > 0.5).astype(np.uint8) if m.shape[:2] != shape: m = cv2.resize(m, (shape[1], shape[0]), cv2.INTER_NEAREST) return m.astype(np.float32) @staticmethod def _calc_base_radius(nose, lsh, rsh, min_r=50): return max( np.linalg.norm(nose - lsh), np.linalg.norm(nose - rsh), min_r ) @staticmethod def _radial_disk(h, w, center, radius, gamma=2.2): cx, cy = center y, x = np.ogrid[:h, :w] dist = np.sqrt((x - cx) ** 2 + (y - cy) ** 2) return np.clip(np.exp(- (dist / radius) ** gamma), 0, 1) # ===================================================== # 左侧:真实语义骨架 + seg 可视化 # ===================================================== def _render_debug(self, frame, pose_res, seg_masks): vis = frame.copy() # --- SEG mask --- if seg_masks: M = np.maximum.reduce(seg_masks) overlay = np.zeros_like(vis) overlay[:] = (0, 255, 0) blended = cv2.addWeighted(vis, 0.75, overlay, 0.25, 0) vis[M > 0] = blended[M > 0] # --- Pose skeleton --- if pose_res is not None: for kp in pose_res.keypoints.data: pts = kp[:, :2].cpu().numpy().astype(int) # points for x, y in pts: if x > 0 and y > 0: cv2.circle(vis, (x, y), 3, (0, 0, 255), -1) # skeleton lines for i, j in self.SKELETON: if i < len(pts) and j < len(pts): x1, y1 = pts[i] x2, y2 = pts[j] if x1 > 0 and y1 > 0 and x2 > 0 and y2 > 0: cv2.line(vis, (x1, y1), (x2, y2), (255, 0, 0), 2) return vis # ===================================================== # 右侧:佛光 # ===================================================== def _render_halo(self, frame, pose_res, seg_masks): H, W = frame.shape[:2] orig = frame.astype(np.float32) out = orig.copy() M = np.maximum.reduce(seg_masks) for kp in pose_res.keypoints.data: nose = kp[self.NOSE][:2].cpu().numpy() lsh = kp[self.L_SH][:2].cpu().numpy() rsh = kp[self.R_SH][:2].cpu().numpy() if nose.sum() == 0 or lsh.sum() == 0 or rsh.sum() == 0: continue base_r = self._calc_base_radius(nose, lsh, rsh) radius = base_r * 2.0 alpha = self._radial_disk( H, W, (int(nose[0]), int(nose[1])), radius ) a = alpha[..., None] gold_center = np.array([255, 250, 240], np.float32) gold_mid = np.array([255, 220, 160], np.float32) gold_outer = np.array([180, 120, 40], np.float32) color = ( gold_center * (a ** 0.35) + gold_mid * (a ** 1.0) + gold_outer * (a ** 2.2) ) out += color * a * 0.85 # 人像复制覆盖 person = orig * M[..., None] out = out * (1 - M[..., None]) + person return np.clip(out, 0, 255).astype(np.uint8) # ===================================================== # 主入口 # ===================================================== def do(self, frame, device=None): if frame is None: return None device = device or self.device H, W = frame.shape[:2] pose_res = None seg_masks = [] # 一次推理 for i, model in enumerate(self.models): res = model.track( frame, persist=True, verbose=False, device=device )[0] if self.model_types[i] == "pose": pose_res = res elif self.model_types[i] == "seg" and res.masks is not None: for m in res.masks.data: seg_masks.append(self._mask_to_float(m, (H, W))) if pose_res is None or not seg_masks: return frame left = self._render_debug(frame, pose_res, seg_masks) right = self._render_halo(frame, pose_res, seg_masks) return np.hstack([left, right])

九、这种结构的可扩展性
在这个框架下,你可以非常容易地加入:
-
掉魂残影(Seg mask + 帧队列)
-
气泡 / 粒子(mask 作为碰撞体)
-
语义雾化 / 消散
-
姿态驱动的场变化
关键不是效果,而是:语义永远在控制表现,而不是相反。
十、结语
这不是一个"好看滤镜"的实现,而是一次尝试:
把深度学习模型的中间语义,
直接当作视觉语言来使用。
当你不再把模型当作"黑箱",
而是当作实时语义生成器 时------
视觉系统的可能性,会变得非常大。
对 PiscTrace or PiscCode感兴趣?更多精彩内容请移步官网看看~🔗 PiscTrace
