FreeMoCap v1.8.2 源码深度解析
用普通 webcam 做的开源 3D 动捕——标定、三角化、检测、后处理、Blender 导出全链路逐文件拆开。
skelly_* 子包和 aniposelib 串成端到端流水线。论真正算力,主仓不到 30%,70% 在外部依赖。要抄就抄 aniposelib 和 skellytracker,主仓的价值在 GUI 编排、状态零持久化、Tracker 策略模式 三个工程设计上。
1一句话定性
把 4–6 个普通 webcam / GoPro / 手机摄像头围成半圆,用 ChArUco 棋盘标定相机几何,MediaPipe 在每路视频上跑 2D 关键点,DLT + 加权异常点剔除三角化成 3D 骨架,Butterworth + 刚体约束后处理,最后 Blender 子进程导出 .blend 场景。所有计算本地 CPU 跑,数据不出设备。
官方话术 "实时动捕" 是录制时实时多机同步采集的实时,不是检测/三角化实时——后者是录完了离线后处理。这一点要在做决策前看清。
2依赖拓扑:算力在哪里
翻 pyproject.toml:68-84,依赖列表就是这个项目的真实地图:
| 外部包 | 版本 | 角色 |
|---|---|---|
skellycam | 2025.09.1097 | 多摄像头采集(PySide6 widget) |
skellytracker[all] | 2025.10.1024 | 2D 关键点检测(包 MediaPipe / YOLO / OpenPose) |
skelly_synchronize | 2025.04.1037 | 多机位时间戳软同步(音频 / 亮度) |
skellyforge | 2024.12.1009 | 后处理 task pipeline(插值 / 滤波 / 旋转) |
skelly_viewer | 2025.04.1028 | 3D 骨架可视化 |
ajc27_freemocap_blender_addon | 2026.04.1039 | Blender 桥接插件 |
aniposelib | 0.4.3 | 多视角几何真核心(DLT、Bundle Adjustment、外参) |
opencv-contrib-python | 4.8.* | ChArUco 检测、相机标定底层 |
PySide6 | 6.6–6.8 | GUI 框架 |
pydantic | 2.* | 数据 schema |
抄什么 ≠ 用什么。如果只想要"多相机三角化"这件事,aniposelib 比 FreeMoCap 主仓干净十倍——后者只是包了一层 GUI。
3入口与主流程
3.1 程序入口
freemocap/__main__.py:24 — qt_gui_main() 是唯一入口,CLI 一行带过:
def main():
...
qt_gui_main() # gui/qt/main_window/freemocap_main.py:29-59
启动后进 PySide6 主事件循环,支持 EXIT_CODE_REBOOT 重启机制(freemocap_main.py:87)。
3.2 主窗口 5 大 Tab
MainWindow(QMainWindow) @ gui/qt/main_window/freemocap_main_window.py:92-150
| Tab | Widget | 来源 |
|---|---|---|
| 0 | HomeWidget | 本仓 |
| 1 | SkellyCamWidget(录制) | 外部 skellycam |
| 2 | SkellyViewer(3D 可视化) | 外部 skelly_viewer |
| 3 | DirectoryViewWidget | 本仓 |
| 4 | ActiveRecordingInfoWidget | 本仓 |
右侧 ControlPanelWidget(widgets/control_panel/control_panel_dock_widget.py:32-62)3 子 Tab:摄像头配置 → 数据处理 → 导出。
3.3 端到端流水线
4标定 + 三角化(招牌核心)
4.1 CHARUCO 棋盘定义
core_processes/capture_volume_calibration/charuco_stuff/charuco_board_definition.py:7-44
@dataclass
class CharucoBoardDefinition:
name: str
number_of_squares_width: int
number_of_squares_height: int
black_square_side_length: int
aruco_marker_length_proportional: float
aruco_marker_dict: Dict = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_250)
预设两块板:7×5(默认)和 5×3(小空间备选),都用 DICT_4X4_250 字典,marker = 0.8 × square。
4.2 标定流程
主入口:AniposeCameraCalibrator @ anipose_camera_calibration/anipose_camera_calibrator.py:42-87
- 内参(每相机):
CameraGroup.calibrate_videos()@ freemocap_anipose.py:2178-2202,cv2.initCameraMatrix2D()从 ChArUco 角点初始化(:2091)。 - 外参初始化:
get_initial_extrinsics()@ :499-513,调 aniposelib 的extract_rtvecs。 - 相机图连通性:
get_calibration_graph()@ :387-403。 - Bundle Adjustment 迭代:
bundle_adjust_iter()@ :1145-1265。
4.3 三角化:Numba 加速的 DLT
freemocap_anipose.py:55-67
@jit(nopython=True, parallel=False)
def triangulate_simple(points, camera_mats):
num_cams = len(camera_mats)
A = np.zeros((num_cams * 2, 4))
for i in range(num_cams):
x, y = points[i]
mat = camera_mats[i]
A[(i * 2): (i * 2 + 1)] = x * mat[2] - mat[0]
A[(i * 2 + 1): (i * 2 + 2)] = y * mat[2] - mat[1]
u, s, vh = np.linalg.svd(A, full_matrices=True)
p3d = vh[-1]
p3d = p3d[:3] / p3d[3] # 齐次坐标归一化
return p3d
线性 DLT:构造 4×N 矩阵 → SVD → 取最小奇异向量 → 齐次归一。@jit(nopython=True) 让单点三角化贴近 C 速度。
4.4 招牌设计:加权异常点剔除三角化
freemocap_anipose.py:88-190 — triangulate_with_outlier_rejection()。
不同于硬 RANSAC 的"要么用要么扔",FreeMoCap 走柔和路线:
参数(freemocap_anipose.py:91-93):
为什么牛:硬剔除会丢信息,软剔除(指数权重)让"还行"的相机也能贡献。对低成本多机位(4–6 个 webcam,难免有 1–2 个角度差)尤其重要。
4.5 重投影误差诊断
freemocap_anipose.py:1105-1143 — CameraGroup.reprojection_error() 返回 (n_cams, n_points, 2) 误差张量。diagnostics/calibration/calculate_calibration_diagnostics.py:12-46 输出 CSV:相邻 ChArUco 角点距离的 mean/median/std/偏差。
4.6 多机位同步:纯软件
没有硬件触发。synchronize_videos_thread_worker.py:6,49-61 走两条路:
from skelly_synchronize.skelly_synchronize import (
synchronize_videos_from_audio, # 默认:音频对齐
synchronize_videos_from_brightness, # 备选:亮度跳变
)
录制时记每帧时间戳到 synchronized_videos/timestamps/*.npy,对齐在后处理阶段做:找音频对齐峰 → 算相对偏移 → 帧重索引。
52D 关键点检测(Tracker 策略模式)
5.1 支持的 Tracker
| Tracker | 状态 | 关键 file:line |
|---|---|---|
| MediaPipe Holistic(默认) | 主流程 | post_processing_parameter_models.py:6 |
| YOLO | 实验性 | experimental/alternative_trackers/run_yolo.py:7 |
| OpenPose | 实验性 | experimental/alternative_trackers/run_openpose.py:8 |
| YOLOMediapipeCombo | YOLO crop → MP pose | image_tracking_pipeline_functions.py:85-86 |
默认 tracker 写死在 recording_info_model.py:42:active_tracker="mediapipe"。
5.2 检测入口(极薄)
process_motion_capture_videos/processing_pipeline_functions/image_tracking_pipeline_functions.py:26-98
def run_image_tracking_pipeline(...) -> np.ndarray:
image_data_numCams_numFrames_numTrackedPts_XYZ = process_folder_of_videos(
model_info=processing_parameters.tracking_model_info,
tracking_params=processing_parameters.tracking_parameters_model,
synchronized_video_path=synchronized_videos_folder_path,
output_folder_path=output_data_folder_path,
num_processes=tracking_params.num_processes,
)
外包装薄如纸——实际检测全在 skellytracker.process_folder_of_videos() 里。FreeMoCap 不关心 tracker 内部,只接收统一 ndarray。
5.3 数据格式(钉死)
tests/test_image_tracking_data_shape.py:26-61 把 shape 钉死成:
shape = (num_cameras, num_frames, num_landmarks, 3)
^^^^^^^^^^^
xy + confidence
第 4 维是 3 不是 2——多出来的是 confidence。3D 三角化阶段才会丢掉 conf 通道。
5.4 并发:multiprocessing
image_tracking_pipeline_functions.py:89-96 接 tracking_params.num_processes。默认 multiprocessing.cpu_count() - 1。每路视频独立进程,避开 GIL。
5.5 Landmark 映射:属性反射
post_process_skeleton_data/post_process_skeleton.py:130-136
def get_landmark_names(model_info: ModelInfo) -> list:
if hasattr(model_info, "body_landmark_names"):
return model_info.body_landmark_names
elif hasattr(model_info, "landmark_names"):
return model_info.landmark_names
属性反射 + duck typing——加新 tracker 不改主流程,只要 ModelInfo 子类提供 *_landmark_names 字段。
63D 后处理(skellyforge 任务管道)
6.1 Butterworth 滤波参数
data_layer/recording_models/post_processing_parameter_models.py:25-28
class ButterworthFilterParametersModel(BaseModel):
sampling_rate: float = 30
cutoff_frequency: float = 7
order: int = 4
30 fps、7 Hz 截止、4 阶——典型人体运动学滤波(人手脚最快动作 ~10 Hz 上限)。
6.2 任务管道顺序
post_process_skeleton.py:69-104 走 skellyforge TaskWorkerThread,固定顺序(:83):
每个任务独立 boolean 开关,挂在 PostProcessingParametersModel 上。
6.3 刚体约束(自实现,不依赖外部)
post_process_skeleton_data/enforce_rigid_bones.py
calculate_bone_lengths_and_statistics()@ :10-41——每帧算骨长,求 median / stdevenforce_rigid_bones()@ :44-86——中位数骨长标准化 + 子关节级联传播
direction = (distal - proximal) / |distal - proximal|
adjustment = (desired_length - current_length) * direction
rigid_marker_data[distal_marker][frame] += adjustment
adjust_children(distal_marker, frame, adjustment, ...) # 递归
把当前帧骨长拉回到序列中位数,再把偏移传给下游所有子关节(保持相对位姿)。处理 2D 检测误差导致的"骨头忽长忽短"。
6.4 坐标系对齐:90° 绕 X 轴
utilities/geometry/rotate_by_90_degrees_around_x_axis.py:4-14
swapped[:, :, 0] = raw[:, :, 0] # X 不变
swapped[:, :, 1] = raw[:, :, 2] # Y ← Z
swapped[:, :, 2] = -raw[:, :, 1] # Z ← -Y
从"摄像头坐标系(Z 朝前)"换到"人体运动学坐标系(Y 朝上)"。
6.5 质心(替代关节角度)
post_process_skeleton_data/calculate_center_of_mass.py:12-141 返回:
segment_com_data:shape(frames, segments, 3)total_body_com:shape(frames, 3)
注意:v1.8.2 不输出原生关节角度(Euler / 四元数),只有关键点位置 + 质心。要做关节角度分析得拿 NPY 自己算。
7数据导出
7.1 格式矩阵
| 格式 | 路径 | 用途 |
|---|---|---|
| NPY | output_data/mediapipe_skeleton_3d.npy | 主产物 (frames, markers, 3) |
| CSV (by_trajectory) | {recording}_by_trajectory.csv | 每帧一行 |
| CSV (by_frame tidy) | {recording}_by_frame.csv | tidy:frame/timestamp/model/keypoint/x/y/z/err |
| JSON | {recording}_by_frame.json | 完整原始数据 |
| .blend | {recording}.blend | Blender 场景(带骨架 armature) |
| .ipynb | auto_generated_notebook.ipynb | Jupyter 数据探索模板 |
不支持 FBX / glTF——要用得手动从 .blend 二次导出。
7.2 Blender 桥接(最有意思的设计)
策略:不用 bpy 内嵌进程,而是外挂 Blender 可执行作为子进程。
core_processes/export_data/blender_stuff/export_to_blender/methods/ajc_addon/run_ajc_addon_main.py:73-99
command_list = [
str(blender_exe_path),
"--background", # 无 GUI
"--python",
simple_run_script, # methods/ajc_addon/run_simple.py
"--",
str(recording_folder_path),
str(blender_file_path),
]
blender_process = run_subprocess(command_list=command_list)
while True:
output = blender_process.stdout.readline()
if blender_process.poll() is not None:
break
if output:
logging.debug(output.strip().decode())
blender_process.terminate()
子进程脚本(run_simple.py:4-8)调用外部插件:
from ajc27_freemocap_blender_addon.main import ajc27_run_as_main_function
ajc27_run_as_main_function(recording_path=..., blend_file_path=...)
插件首次运行由 bpy_install_addon.py:33-51 现场安装:打 ZIP → bpy.ops.preferences.addon_install → bpy.ops.wm.save_userpref。
Blender 路径自动检测(get_best_guess_of_blender_path.py:27-86):Windows 扫 Program Files/Blender Foundation,macOS /Applications/Blender.app/Contents/MacOS/Blender,Linux /usr/bin/blender。
为什么聪明:bpy 作为 Python 库装起来很折磨(要 Blender 版本对齐)。子进程模式把 Blender 当成黑盒服务,FreeMoCap 主进程的 Python 环境干净。
7.3 列名约定(tracker-aware)
post_process_skeleton_data/split_and_save.py:67-121
# 如果 model_info 有具名 landmark:
column_names.append(f"{category}_{name}_{x|y|z}") # body_nose_x, body_left_shoulder_y
# 否则降级到数字索引:
column_names.append(f"{category}_{i:04d}_{x|y|z}") # body_0000_x, body_0001_x
同时按 category 切(:32-64):body / left_hand / right_hand / face。
7.4 时间戳:CSV 不带
CSV 行号即帧索引。帧率元数据存在 PostProcessingParametersModel.framerate(默认 30),不内嵌到导出文件。精确时序分析得拿 synchronized_videos/timestamps/*.npy 配合用。
8Skeleton Schema(统一骨架定义)
8.1 核心 Pydantic 模型
data_layer/skeleton_models/skeleton.py:13-133
class Skeleton(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
markers: MarkerInfo
num_tracked_points: int
segments: Optional[Dict[str, Segment]] = None
marker_data: Dict[str, np.ndarray] = {} # 每点 (frames, 3)
virtual_marker_data: Dict[str, np.ndarray] = {}
joint_hierarchy: Optional[Dict[str, List[str]]] = None
center_of_mass_definitions: Optional[Dict[str, SegmentAnthropometry]] = None
num_frames: Optional[int] = None
8.2 段定义
data_layer/skeleton_models/segments.py:8-17
class Segment(BaseModel):
proximal: str
distal: str
class SegmentAnthropometry(BaseModel):
segment_com_length: float # 质心位置占段长比例
segment_com_percentage: float # 段占全身质量比例
8.3 虚拟关键点
marker_info.py:38-68 支持加权平均生成虚拟点,典型如 mid_shoulder = 0.5 * left_shoulder + 0.5 * right_shoulder。这让上层算法(质心、骨长)能用稳定的解剖学参考点,不受单点遮挡影响。
9GUI / 数据层架构
9.1 RecordingInfoModel:路径属性生成器
data_layer/recording_models/recording_info_model.py:41-217
class RecordingInfoModel:
@property
def synchronized_videos_folder_path(self) -> str: ...
@property
def data_2d_npy_file_path(self) -> str: ...
@property
def data_3d_npy_file_path(self) -> str: ...
@property
def calibration_toml_path(self) -> str: ...
@property
def status_check(self) -> Dict[...]: # 递归查文件存在性
...
不存路径字符串、只用属性生成器——根路径变了之后所有派生路径自动跟。status_check 是状态机的反面:不维护"录制完成 / 处理完成"标志位,而是每次按需扫文件存在性。
9.2 录制目录结构
文件名常量集中在 system/paths_and_filenames/file_and_folder_names.py:1-83。
9.3 后台任务:QThread + multiprocessing 双层夹心
gui/qt/workers/process_motion_capture_data_thread_worker.py:16-76
class ProcessMotionCaptureDataThreadWorker(QThread):
in_progress = Signal(...)
finished = Signal(bool)
def run(self):
self._process = multiprocessing.Process(
target=process_recording_folder,
args=(...),
)
self._process.start()
while self._process.is_alive():
time.sleep(0.01)
if not self._queue.empty():
record = self._queue.get()
self.in_progress.emit(record)
self.finished.emit(self._success)
- QThread 外层:Qt 信号槽友好、生命周期可控、可发
in_progress给 UI - multiprocessing.Process 内层:真正干活,绕开 GIL
- multiprocessing.Queue:从工作进程把日志/进度推回 QThread
GUI 应用的"重活外包"教科书模式:UI 线程绝对不卡,重计算独立崩溃也不杀主进程。
10招牌设计 Top 10
- 加权异常点剔除三角化(freemocap_anipose.py:88-190)—
exp(-5·error/threshold)软权重,多相机时鲁棒胜过硬 RANSAC - Numba
@jit(nopython=True)单点 DLT(:55-67)— Python 几何运算贴近 C - RecordingInfoModel 属性生成器 + status_check — 不维护状态机,按需扫文件
- QThread × multiprocessing.Process × Queue × Signal 四件套 — GUI 重活外包标准范式
- Tracker 策略模式(ModelInfo + duck typing) — 加新 tracker 不动主流程
{tracker}_data_2d.npy前缀命名 — 多算法结果共存对比- skellyforge TaskWorkerThread 管道 — 可拼装的后处理 task 列表
- 刚体骨长中位数约束 + 子树级联传播(enforce_rigid_bones.py)— 不用 IK 就能压住骨头抖
- Blender 子进程模式 —
--background --python script.py,主进程 Python 环境干净 - CHARUCO + 软件音频/亮度同步 — 用 webcam 也能搞动捕
11局限性(别被官方话术骗了)
| 宣传 | 实情 |
|---|---|
| "实时动作捕捉" | 离线后处理。录的时候是实时多机同步采集,但 2D 检测 / 三角化 / 后处理全是录完了一起跑 |
| "实时关节角度" | 不输出关节角度。v1.8.2 只有关键点位置和质心,关节角度得自己算 |
| "支持任意 tracker" | 主流程默认只接 MediaPipe;YOLO / OpenPose 在 experimental/ 没上主线 |
| "GPU 加速" | 主仓不暴露 GPU 配置,全靠 MediaPipe 内部判断 |
| "媲美 Vicon" | 重投影误差量级 ~ 像素级;Vicon 亚毫米级标定。差 2–3 个数量级 |
12适合 / 不适合
✅ 适合
- 教学 / 科研动作分析(人体生物力学、运动学)
- 低成本 3D 角色动画毛坯(导 Blender 后人手 retarget)
- 隐私敏感场景(数据全本地)
❌ 不适合
- 实时游戏 / VR 全身追踪
- 高精度临床步态分析(亚毫米级)
- 商业产品发行(AGPL-3.0 — SaaS 化要开源整个调用链)
13延伸思考(可改造方向)
- 抽离 aniposelib + 加权剔除算法:单独打个 Python 库给所有"多相机三角化"业务用,不必带 PySide6。
- 替换 skellyforge 后处理:上 Kalman 滤波 / 双向 LSTM 时序去抖,比 Butterworth 强。
- 加关节角度计算:拿
Skeleton.joint_hierarchy+ Pydantic 模型,每段两个 segment 算夹角,输出 Euler / 四元数。 - GPU MediaPipe:用
mediapipe.tasks.python.vision.PoseLandmarker替代旧 holistic API,启 GPU delegate。 - WebRTC 实时模式:skellycam 出 RTP 流 → 服务器侧实时 2D + 增量三角化 → 真做到"实时动捕"。但要硬件触发同步才靠谱。
14文件级索引(快速查表)
| 模块 | 关键文件 |
|---|---|
| 入口 | freemocap/__main__.py:24 → gui/qt/freemocap_main.py:29 |
| 主窗口 | gui/qt/main_window/freemocap_main_window.py:92-150 |
| CHARUCO 板 | core_processes/capture_volume_calibration/charuco_stuff/charuco_board_definition.py:7-44 |
| 标定主入口 | .../run_anipose_capture_volume_calibration.py:17-35 |
| DLT 三角化 | .../freemocap_anipose.py:55-67 |
| 加权异常点剔除 | .../freemocap_anipose.py:88-190 |
| Bundle Adjustment | .../freemocap_anipose.py:1145-1265 |
| 重投影误差 | .../freemocap_anipose.py:1105-1143 |
| 软同步 | .../synchronize_videos_thread_worker.py:6,49-61 |
| 2D 检测入口 | .../image_tracking_pipeline_functions.py:26-98 |
| Tracker 参数 | data_layer/recording_models/post_processing_parameter_models.py:25-44 |
| 后处理任务管道 | core_processes/post_process_skeleton_data/post_process_skeleton.py:69-104 |
| 刚体约束 | .../enforce_rigid_bones.py:10-86 |
| 坐标系旋转 | utilities/geometry/rotate_by_90_degrees_around_x_axis.py:4-14 |
| 质心计算 | .../calculate_center_of_mass.py:12-141 |
| Blender 子进程 | .../export_to_blender/methods/ajc_addon/run_ajc_addon_main.py:73-99 |
| Blender 路径检测 | .../get_best_guess_of_blender_path.py:27-86 |
| Skeleton schema | data_layer/skeleton_models/skeleton.py:13-133 |
| RecordingInfoModel | data_layer/recording_models/recording_info_model.py:41-217 |
| 后台任务 worker | gui/qt/workers/process_motion_capture_data_thread_worker.py:16-76 |
| 跨平台路径 | system/paths_and_filenames/path_getters.py:31-250 |