☰
Python+MediaPipe 实现24式太极拳姿态提取与动作质量分析
2026/10/24 2:40:24 网站建设 项目流程

很长一段时间里,“练太极”这件事在技术圈都是一个纯靠自觉的行为:早上起来,打开一段背向演练的 24 式简化太极拳视频,跟着比划二十分钟,出点汗,然后关掉视频去写代码。问题在于,这套流程里没有任何反馈环节——你不知道自己的弓步是不是塌了,不知道云手时肩是不是耸着,也不知道昨天和今天相比动作有没有变形。视频是单向的,它只能示范,不能纠错。

这件事其实非常适合用工程手段补上。24 式简化太极拳的动作幅度大、节奏慢、姿态稳定,公开资料显示它是 1956 年在传统杨式太极拳基础上编订的简化套路,共 24 个动作,是目前普及度最高的入门套路之一。慢节奏意味着相邻帧之间的姿态变化小,关节角度可以被稳定测量;固定的招式意味着动作有明确的“标准形态”可以对齐。换句话说,它是一个姿态估计任务里相对友好的场景。

所以这篇文章不讲“太极拳有多好”,而是讲一个具体的技术问题:如何用 Python + OpenCV + MediaPipe,把一段晨练视频变成可量化、可对比、可追踪的骨架数据,并在此基础上做动作质量分析。判断先放在前面:单纯把骨架画在视频上,价值不大,那只是一层可视化;真正有价值的是把骨架转成“角度、重心、对称性、节奏”这几类数值指标,再用规则引擎去比对阈值,最后输出一份能看懂的偏差报告。

读者读完应该能做到三件事:第一,搭起一套本地可运行的姿态提取流程;第二,理解 24 式太极拳里哪些特征可以被量化、哪些不能被量化;第三,知道背向拍摄视角会带来什么坑,以及怎么在代码里绕开它。全文代码都可以直接复制运行,不需要 GPU,普通笔记本的 CPU 就能跑起来。

1. 为什么“跟着视频练太极”需要一个技术方案

先把痛点说透。跟着视频练拳的人,通常会在三个阶段出问题。第一个阶段是“记不住”,招式顺序混乱,这个靠反复看视频能解决。第二个阶段是“做得像但不对”,外形看着差不多,实际上膝盖内扣、塌腰、耸肩,这些错误在镜子里自己都很难发现,因为人对自己身体的感知是失真的。第三个阶段是“练了半年没变化”,没有记录就没有对比,动作里的细微退化完全无法察觉。

视频教学能解决第一阶段,解决不了第二、第三阶段。而第二、第三阶段恰好是可以用数据解决的问题。膝关节角度是可以算的,两脚间距与身高的比例是可以算的,双肩的水平度是可以算的,身体重心在两脚之间的投影位置也是可以算的。这些数值不需要多高的精度,只要稳定、可重复,就足够用来做趋势对比。

这里要提前说清楚一个边界,避免读者产生错误预期:这套方案不适合做“绝对分数评判”。太极拳的“标准”本身就因人而异,身高、年龄、柔韧性、既往伤病史都会影响合理的关节角度范围。一个六十岁练习者的弓步深度,和一个二十岁练习者的弓步深度,不该用同一套阈值去打分。所以更稳妥的定位是:把自动分析当作“相对对比工具”和“异常提醒工具”,而不是“教练替代品”。它能告诉你“今天右膝的弯曲比上周平均水平小了 15 度”,但不能告诉你“你练得对不对”。

明确了这个边界,后面的技术选型和指标设计才有意义。我们要做的不是造一个评分器,而是造一个可观测性系统——把原本不可见的身体状态变成可见的时间序列。

2. 24 式简化太极拳的动作特征与技术建模

要把动作变成数据,第一步是把“动作”这件事拆成计算机能理解的结构。24 式简化太极拳的动作可以粗略分成三类,每一类适合的建模方式并不相同。

第一类是定式,比如白鹤亮翅、手挥琵琶、海底针。这类动作有明显的停顿点,躯干和四肢在一个相对稳定的姿态上保持若干帧。定式是最好分析的,因为可以在时间轴上找到“速度接近零”的区间,取这个区间的中位数帧作为代表帧,再计算各项指标。

第二类是过渡动作,比如左右野马分鬃、云手、搂膝拗步。这类动作持续运动,没有明显停顿,重点是轨迹是否连贯、重心转移是否平滑。对过渡动作做逐帧打分意义不大,更有价值的是分析整段轨迹的平滑度和左右对称性。

第三类是对称重复动作,比如左右倒卷肱、左右穿梭、左右下势独立。这类动作天然成对出现,是检验“左右一致性”的最佳样本。同一个人做左侧和右侧时,如果膝关节角度差了 20 度以上,通常说明存在习惯性的力量偏差或者柔韧度差异。

在技术层面,人体可以被抽象成一张骨架图:节点是关键点(肩、肘、腕、髋、膝、踝等),边是骨骼连接。MediaPipe Pose 输出的正是这样一张图,包含 33 个关键点,每个点有归一化的 x、y、z 坐标和可见度置信度。有了这张图,太极拳里那些“师傅说你要沉肩坠肘”的模糊描述,就可以翻译成具体的几何量。

动作要素传统表述可量化指标计算方式
弓步深度前腿屈膝、膝盖不过脚尖膝关节夹角髋—膝—踝三点夹角
沉肩坠肘肩放松、肘下垂肘关节夹角、肩部高度差肩—肘—腕夹角;左右肩 y 坐标差
立身中正虚领顶劲、不塌腰躯干倾斜度肩中点与髋中点的水平偏移 / 躯干长度
重心稳定重心落在支撑面内重心投影比髋中点 x 在两脚踝 x 之间的位置比例
开合有度步幅舒展步幅身型比两脚踝水平距离 / 躯干长度
左右均衡左右式一致对称性偏差同侧关节角度的左右差值

这张表是整套方案的核心。它说明了一件事:太极拳里大量“感觉类”的要领,其实都可以落到两三个几何量上。反过来说,也有些东西是完全无法用 33 个关键点表达的,比如呼吸配合、意念引导、劲力的松沉感,这些必须承认超出范围,不要硬套。

还有一个容易被忽略的点:24 式的动作顺序和名称必须以权威教材或教练示范为准。不同流派、不同教练在教学细节上会有差异,本文的代码和阈值只提供工程框架,具体数值请按你实际跟练的版本调整。把阈值写死在代码里是常见的错误做法,正确做法是配置化,这一点在第 8 章会展开。

3. 方案选型与环境准备

3.1 三种姿态估计方案对比

在动手之前,先花几分钟做选型,能省掉后面很多返工。做人体姿态估计目前主要有三条路线,各有明确的适用边界。

方案部署成本运行环境关键点数量适合场景主要短板
手工特征 + 传统图像处理高CPU自定义背景极简的固定工位换衣服、换光线就失效
OpenPose 系列中高建议 GPU25(BODY_25)多人、高精度科研场景依赖重、环境配置折腾
MediaPipe Pose低CPU 可跑33单人、消费级设备、快速验证遮挡和大角度侧身时精度下降

选 MediaPipe 的理由很直接:pip install一条命令,不需要编译,不需要 CUDA,笔记本上就能实时跑。对于一个“晨练自检”的场景,精度要求并不极致,稳定性、易部署、可迭代比绝对精度更重要。如果你的场景是体育科研级别的动作分析,那应该上 OpenPose 或者专门的多视角动捕方案,本文的框架仍然适用,只是替换掉提取层。

需要坦白 MediaPipe 的局限:单目摄像头在深度方向上信息不足,z 坐标是估计值不是测量值;宽松的衣服会干扰髋部和膝部的定位;侧身 90 度时靠近镜头一侧的肢体可能互相遮挡。这些限制决定了后续所有指标都应该用相对量(比值、角度、差值),而不是绝对量(厘米、米)。

3.2 运行环境与依赖

环境部分尽量说清楚,因为 MediaPipe 对 Python 版本比较敏感。建议使用 Python 3.9 到 3.11 之间的版本,较新的 Python 大版本上,预编译 wheel 的支持往往会滞后一段时间,具体以你执行pip install mediapipe时能否顺利拿到对应平台的包为准。如果安装失败,第一反应应该是换 Python 版本,而不是折腾编译工具链。

依赖清单如下,建议用虚拟环境隔离,避免和项目里其他 OpenCV 版本打架。

# 文件路径:requirements.txt opencv-python>=4.8.0 mediapipe>=0.10.0 numpy>=1.24.0 PyYAML>=6.0

安装命令:

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt

工程目录建议按“原始素材—中间产物—输出结果”三层分开,后面做批量处理会省很多事:

taichi-pose/ ├── config/ │ └── rules.yaml # 评分规则与阈值配置 ├── data/ │ ├── raw/ # 原始视频 │ └── out/ # 关键点 JSON、报告、叠加视频 ├── src/ │ ├── __init__.py │ ├── pose_extractor.py # 视频 -> 关键点 JSON │ ├── angle_utils.py # 几何指标计算 │ ├── scoring.py # 规则引擎与报告 │ └── render.py # 骨架叠加可视化 └── requirements.txt

src/__init__.py可以是空文件,但必须有,否则python -m src.scoring会因为包导入问题报错。这是新手最容易卡住的地方之一。

4. 核心流程拆解:从视频帧到动作评分

4.1 五个处理阶段

整条链路可以拆成五步,每一步的输入输出都明确,便于单独调试。

第一步,抽帧。视频通常是 25 到 60 fps,逐帧跑姿态估计是浪费。太极拳动作慢,10 fps 的采样率已经足够捕捉主要姿态变化。抽帧策略是“按固定步长跳帧”,而不是“先导出图片再处理”,后者会产生大量磁盘 IO。

第二步,关键点检测。对每一帧调用 MediaPipe,得到 33 个点的归一化坐标。归一化坐标的好处是与分辨率无关,同一套阈值可以跨设备复用。注意 MediaPipe 默认要求输入 RGB 图像,OpenCV 读进来的是 BGR,必须转换,这一步漏掉会导致检测结果完全错乱。

第三步,指标计算。把关键点坐标转成角度、比值、差值。核心是三点夹角公式:给定 A、B、C 三点,计算 ∠ABC。用向量点积加反余弦即可,注意要做数值裁剪,防止浮点误差让acos的入参超出[-1, 1]。

第四步,规则判定。每个指标配一个合理区间,落在区间内得满分,超出区间按偏差衰减。这里的“合理区间”必须可配置,不能写死在代码里。

第五步,汇总输出。逐帧分数聚合成视频级摘要,包括平均分、最低分、出现频次最高的偏差类型,以及每帧的具体问题列表。

4.2 背向演练视角的镜像问题

这一节单独拿出来讲,因为它直接对应“背向演练”这个使用场景,也是整套流程里最容易出错的地方。

背向拍摄时,练习者背对镜头。画面中的左侧,实际对应练习者的右手;画面中的右侧,对应练习者的左手。对跟练的人来说这是好事,因为“镜像模仿”符合直觉——视频里左手往左,你也左手往左。但对自动分析来说,这里存在两层麻烦。

第一层是可视化误读。MediaPipe 输出的关键点标签是按被检对象的解剖学左右定义的,left_shoulder指的是这个人的左肩。在背向视角下,这个左肩会出现在画面的右侧。如果你直接把骨架画上去又按画面方位去读,很容易把左右搞反。这个不影响程序,但影响人看报告。

第二层是混合视角导致指标跳变。很多教学视频会在中途切换机位,前段正面、后段背面,或者干脆是分屏。这种视频里,“左膝角度”这个指标在切换点会发生突变,规则引擎会把切换点误判成一次严重偏差。如果规则里带了“左侧必须如何”这类强约束,整段分析就废了。

最实用的工程妥协是:把规则设计成左右无关的指标。具体做法是使用max_knee_bend(左右膝角取较小值,即弯曲更充分的那条腿)、min_elbow_bend(左右肘角取较小值)、shoulder_tilt(左右肩高度差的绝对值)、stance_width(两脚水平距离的绝对值)。这些量在视角切换时是连续变化的,不会因为左右标签翻转而跳变。

只有在你确实需要区分“这是左野马分鬃还是右野马分鬃”时,才需要额外的朝向判定。一个简单可行的判据是:比较鼻尖的 x 坐标与双肩中点的 x 坐标,或者看面部关键点的可见度——背向时面部关键点可见度通常明显低于正面。判定结果写进配置文件的camera.mirror字段,供后续渲染和报告使用。

# 文件路径:config/rules.yaml camera: # 背向拍摄时置为 true,仅影响可视化标注与报告文案,不影响左右无关指标的判定 mirror: true # 视角判定的置信度下限,低于该值不做左右标签翻转 orientation_confidence: 0.6 scoring: tolerance_default: 8.0 rules: - id: knee_bend name: 屈膝幅度 metric: max_knee_bend min: 95 max: 140 tolerance: 10 weight: 0.25 message: 前腿屈膝幅度偏离参考范围,注意膝盖方向与脚尖一致 - id: elbow_relax name: 肘部放松度 metric: min_elbow_bend min: 100 max: 180 tolerance: 12 weight: 0.20 message: 手臂过度僵直或过度折叠,注意沉肩坠肘 - id: stance name: 步幅比例 metric: stance_width_ratio min: 0.6 max: 1.6 tolerance: 0.20 weight: 0.20 message: 两脚横向距离与躯干长度比例偏离常见范围 - id: spine name: 躯干垂直度 metric: spine_tilt min: 0.0 max: 0.18 tolerance: 0.06 weight: 0.20 message: 上身前后倾斜偏大,注意立身中正 - id: shoulder name: 双肩水平度 metric: shoulder_tilt min: 0.0 max: 0.12 tolerance: 0.05 weight: 0.15 message: 两肩高低差偏大,注意放松与对称

这些阈值是工程示例值,不是标准答案。建议的做法是:先拿自己一段自认为动作比较规范的视频跑一遍,看各项指标的分布,把区间设在实际分布的中位数附近,再用它去比较其他视频。这样得到的是“相对于你自己”的判断,比套用别人的数值靠谱得多。

5. 完整代码实现

5.1 关键点提取模块

这个模块负责把视频转成 JSON。注意几个细节:image_rgb.flags.writeable = False是一个小的性能优化,避免 MediaPipe 内部拷贝;smooth_landmarks=True会启用跨帧平滑,对慢动作场景效果不错,但如果视频里有快速切换镜头,建议关掉。

# 文件路径:src/pose_extractor.py import argparse import json import os import cv2 import mediapipe as mp mp_pose = mp.solutions.pose def extract_landmarks(video_path: str, output_path: str, sample_fps: float = 10.0, model_complexity: int = 1, min_detection_confidence: float = 0.5, min_tracking_confidence: float = 0.5) -> dict: cap = cv2.VideoCapture(video_path) if not cap.isOpened(): raise RuntimeError(f"无法打开视频,请检查路径:{video_path}") src_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 step = max(int(round(src_fps / sample_fps)), 1) frames = [] frame_index = 0 with mp_pose.Pose( static_image_mode=False, model_complexity=model_complexity, smooth_landmarks=True, enable_segmentation=False, min_detection_confidence=min_detection_confidence, min_tracking_confidence=min_tracking_confidence, ) as pose: while True: ok, frame = cap.read() if not ok: break if frame_index % step != 0: frame_index += 1 continue image_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) image_rgb.flags.writeable = False result = pose.process(image_rgb) if result.pose_landmarks: points = [] for lm in result.pose_landmarks.landmark: points.append({ "x": round(lm.x, 6), "y": round(lm.y, 6), "z": round(lm.z, 6), "visibility": round(lm.visibility, 4), }) frames.append({ "frame_index": frame_index, "timestamp_ms": int(cap.get(cv2.CAP_PROP_POS_MSEC)), "landmarks": points, }) frame_index += 1 cap.release() payload = { "video": os.path.basename(video_path), "source_fps": round(src_fps, 2), "sample_fps": sample_fps, "detected_frames": len(frames), "frames": frames, } out_dir = os.path.dirname(output_path) if out_dir: os.makedirs(out_dir, exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False) print(f"已处理帧数:{len(frames)},输出至 {output_path}") return payload if __name__ == "__main__": parser = argparse.ArgumentParser(description="从视频中提取 33 点人体姿态") parser.add_argument("--video", required=True, help="输入视频路径") parser.add_argument("--out", required=True, help="输出 JSON 路径") parser.add_argument("--sample-fps", type=float, default=10.0) args = parser.parse_args() extract_landmarks(args.video, args.out, args.sample_fps)

关键逻辑解释:step控制跳帧步长,用源帧率除以目标采样率再四舍五入,保证采样率稳定;frame_index全程递增而不是在跳帧后重置,这样后续渲染才能把关键点准确对回原始帧。

5.2 几何指标计算模块

角度计算是整套方案的基础,写的时候要特别注意零向量和浮点越界。

# 文件路径:src/angle_utils.py import math LANDMARK_INDEX = { "nose": 0, "left_shoulder": 11, "right_shoulder": 12, "left_elbow": 13, "right_elbow": 14, "left_wrist": 15, "right_wrist": 16, "left_hip": 23, "right_hip": 24, "left_knee": 25, "right_knee": 26, "left_ankle": 27, "right_ankle": 28, } def get_point(frame: dict, name: str) -> tuple: lm = frame["landmarks"][LANDMARK_INDEX[name]] return (lm["x"], lm["y"]) def angle_deg(a: tuple, b: tuple, c: tuple) -> float: """返回顶点为 b 的夹角 ∠ABC,单位为度。""" ax, ay = a[0] - b[0], a[1] - b[1] cx, cy = c[0] - b[0], c[1] - b[1] dot = ax * cx + ay * cy norm = math.hypot(ax, ay) * math.hypot(cx, cy) if norm < 1e-9: return 0.0 cos_value = max(-1.0, min(1.0, dot / norm)) return math.degrees(math.acos(cos_value)) def body_center(frame: dict) -> tuple: lh = get_point(frame, "left_hip") rh = get_point(frame, "right_hip") return ((lh[0] + rh[0]) / 2.0, (lh[1] + rh[1]) / 2.0) def stance_width(frame: dict) -> float: la = get_point(frame, "left_ankle") ra = get_point(frame, "right_ankle") return abs(la[0] - ra[0]) def support_ratio(frame: dict) -> float: """重心在两脚之间的投影比例。0 表示完全在左脚,1 表示完全在右脚。""" cx, _ = body_center(frame) lx = get_point(frame, "left_ankle")[0] rx = get_point(frame, "right_ankle")[0] span = rx - lx if abs(span) < 1e-6: return 0.5 return max(0.0, min(1.0, (cx - lx) / span))

这里用的是二维坐标,忽略了 z。原因是单目摄像头的 z 是估计值,噪声比平面坐标大得多,放到角度计算里反而引入误差。对于太极拳这类以正面平面运动为主的动作,二维已经够用。如果后续要做三维重建,可以引入 z 做加权,但要先做可信度过滤。

5.3 规则打分模块

规则引擎的设计原则是“配置驱动、无状态、可解释”。每条规则独立打分,最后按权重加权,同时输出命中的问题描述,这样报告能直接告诉使用者哪里出了问题,而不是只给一个数字。

# 文件路径:src/scoring.py import argparse import json from collections import Counter import yaml from .angle_utils import angle_deg, body_center, get_point, stance_width, support_ratio def extract_metrics(frame: dict) -> dict: ls = get_point(frame, "left_shoulder") rs = get_point(frame, "right_shoulder") le = get_point(frame, "left_elbow") re_ = get_point(frame, "right_elbow") lw = get_point(frame, "left_wrist") rw = get_point(frame, "right_wrist") lh = get_point(frame, "left_hip") rh = get_point(frame, "right_hip") lk = get_point(frame, "left_knee") rk = get_point(frame, "right_knee") la = get_point(frame, "left_ankle") ra = get_point(frame, "right_ankle") shoulder_mid = ((ls[0] + rs[0]) / 2.0, (ls[1] + rs[1]) / 2.0) hip_mid = ((lh[0] + rh[0]) / 2.0, (lh[1] + rh[1]) / 2.0) # 用肩中点到髋中点的垂直距离作为身型尺度,抵消拍摄距离带来的整体缩放 body_scale = max(abs(hip_mid[1] - shoulder_mid[1]), 1e-6) knee_l = angle_deg(lh, lk, la) knee_r = angle_deg(rh, rk, ra) elbow_l = angle_deg(ls, le, lw) elbow_r = angle_deg(rs, re_, rw) stance = stance_width(frame) return { "left_knee_angle": round(knee_l, 3), "right_knee_angle": round(knee_r, 3), "max_knee_bend": round(min(knee_l, knee_r), 3), "left_elbow_angle": round(elbow_l, 3), "right_elbow_angle": round(elbow_r, 3), "min_elbow_bend": round(min(elbow_l, elbow_r), 3), "left_wrist_height": round((lw[1] - shoulder_mid[1]) / body_scale, 3), "right_wrist_height": round((rw[1] - shoulder_mid[1]) / body_scale, 3), "stance_width": round(stance, 4), "stance_width_ratio": round(stance / body_scale, 3), "support_ratio": round(support_ratio(frame), 3), "shoulder_tilt": round(abs(ls[1] - rs[1]) / body_scale, 3), "spine_tilt": round(abs(shoulder_mid[0] - hip_mid[0]) / body_scale, 3), } def score_value(value: float, rule: dict) -> float: lo = rule.get("min") hi = rule.get("max") tolerance = float(rule.get("tolerance", 8.0)) if lo is not None and value < lo: deviation = lo - value elif hi is not None and value > hi: deviation = value - hi else: return 100.0 if tolerance <= 0: return 0.0 return max(0.0, 100.0 * (1.0 - deviation / (2.0 * tolerance))) def evaluate_frame(frame: dict, rules: list, tolerance_default: float = 8.0) -> dict: metrics = extract_metrics(frame) weighted_sum = 0.0 weight_sum = 0.0 issues = [] for rule in rules: value = metrics.get(rule["metric"]) if value is None: continue rule_cfg = dict(rule) rule_cfg.setdefault("tolerance", tolerance_default) score = score_value(value, rule_cfg) weight = float(rule.get("weight", 1.0)) weighted_sum += score * weight weight_sum += weight if score < 80.0: issues.append({ "rule": rule["id"], "name": rule["name"], "value": value, "score": round(score, 1), "message": rule.get("message", ""), }) total = round(weighted_sum / weight_sum, 1) if weight_sum else 0.0 return {"score": total, "metrics": metrics, "issues": issues} def summarize(results: list, video_name: str) -> dict: scores = [item["score"] for item in results] counter = Counter() for item in results: for issue in item["issues"]: counter[issue["name"]] += 1 return { "video": video_name, "analyzed_frames": len(results), "avg_score": round(sum(scores) / len(scores), 1) if scores else 0.0, "min_score": round(min(scores), 1) if scores else 0.0, "max_score": round(max(scores), 1) if scores else 0.0, "top_issues": [ {"name": name, "count": count} for name, count in counter.most_common(5) ], } def main() -> None: parser = argparse.ArgumentParser(description="基于规则的动作质量评估") parser.add_argument("--landmarks", required=True) parser.add_argument("--rules", required=True) parser.add_argument("--report", required=True) args = parser.parse_args() with open(args.rules, "r", encoding="utf-8") as f: config = yaml.safe_load(f) rules = config.get("rules", []) tolerance_default = config.get("scoring", {}).get("tolerance_default", 8.0) with open(args.landmarks, "r", encoding="utf-8") as f: payload = json.load(f) results = [] for item in payload["frames"]: evaluated = evaluate_frame(item, rules, tolerance_default) results.append({"frame_index": item["frame_index"], **evaluated}) summary = summarize(results, payload["video"]) with open(args.report, "w", encoding="utf-8") as f: json.dump({"summary": summary, "frames": results}, f, ensure_ascii=False, indent=2) print(json.dumps(summary, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()

这里有个设计取舍值得说明:为什么分数是加权平均而不是连乘?连乘的好处是任何一项严重不达标都会把总分拉到很低,坏处是一旦某一帧检测抖动导致某项指标异常,整段视频的分数就失真。加权平均对单点噪声更鲁棒,更适合“趋势观察”这个目标。如果要做严格的合规检测,可以在加权平均之外单独加一个“一票否决”规则列表。

5.4 骨架叠加渲染模块

可视化不是为了好看,是为了核对提取结果是否正确。当分数出现异常时,第一反应应该是看叠加视频里关键点有没有画错位置,而不是去改阈值。

# 文件路径:src/render.py import argparse import json import cv2 import mediapipe as mp mp_pose = mp.solutions.pose POSE_CONNECTIONS = mp_pose.POSE_CONNECTIONS def draw_skeleton(frame, landmarks, min_visibility: float = 0.5): h, w = frame.shape[:2] points = {} for idx, lm in enumerate(landmarks): if lm["visibility"] < min_visibility: continue points[idx] = (int(lm["x"] * w), int(lm["y"] * h)) for start, end in POSE_CONNECTIONS: if start in points and end in points: cv2.line(frame, points[start], points[end], (0, 255, 0), 2) for point in points.values(): cv2.circle(frame, point, 3, (0, 0, 255), -1) return frame def render(video_path: str, landmarks_path: str, out_path: str) -> None: with open(landmarks_path, "r", encoding="utf-8") as f: payload = json.load(f) frame_map = {item["frame_index"]: item for item in payload["frames"]} cap = cv2.VideoCapture(video_path) src_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) writer = cv2.VideoWriter( out_path, cv2.VideoWriter_fourcc(*"mp4v"), src_fps, (width, height), ) index = 0 while True: ok, frame = cap.read() if not ok: break item = frame_map.get(index) if item: frame = draw_skeleton(frame, item["landmarks"]) cv2.putText( frame, f"frame {index}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2, ) writer.write(frame) index += 1 cap.release() writer.release() print(f"叠加视频已输出:{out_path}") if __name__ == "__main__": parser = argparse.ArgumentParser(description="将关键点叠加回

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询