FreeMoCap v1.8.2 源码深度解析

用普通 webcam 做的开源 3D 动捕——标定、三角化、检测、后处理、Blender 导出全链路逐文件拆开。

★ 8,840 v1.8.2 14,210 LOC 145 .py files Python 3.10–3.12 PySide6 AGPL-3.0 github.com/freemocap/freemocap
结论先行:FreeMoCap 主仓本身不是动捕算法库,而是一个 PySide6 GUI 编排器——把 7 个 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,依赖列表就是这个项目的真实地图:

外部包版本角色
skellycam2025.09.1097多摄像头采集(PySide6 widget)
skellytracker[all]2025.10.10242D 关键点检测(包 MediaPipe / YOLO / OpenPose)
skelly_synchronize2025.04.1037多机位时间戳软同步(音频 / 亮度)
skellyforge2024.12.1009后处理 task pipeline(插值 / 滤波 / 旋转)
skelly_viewer2025.04.10283D 骨架可视化
ajc27_freemocap_blender_addon2026.04.1039Blender 桥接插件
aniposelib0.4.3多视角几何真核心(DLT、Bundle Adjustment、外参)
opencv-contrib-python4.8.*ChArUco 检测、相机标定底层
PySide66.6–6.8GUI 框架
pydantic2.*数据 schema

抄什么 ≠ 用什么。如果只想要"多相机三角化"这件事,aniposelib 比 FreeMoCap 主仓干净十倍——后者只是包了一层 GUI。

3入口与主流程

3.1 程序入口

freemocap/__main__.py:24qt_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

TabWidget来源
0HomeWidget本仓
1SkellyCamWidget(录制)外部 skellycam
2SkellyViewer(3D 可视化)外部 skelly_viewer
3DirectoryViewWidget本仓
4ActiveRecordingInfoWidget本仓

右侧 ControlPanelWidgetwidgets/control_panel/control_panel_dock_widget.py:32-62)3 子 Tab:摄像头配置 → 数据处理 → 导出。

3.3 端到端流水线

[1] 用户 → "Start New Session" → handle_start_new_session_action() freemocap_main_window.py:195 [2] 录制 Tab:SkellyCamWidget 启动多机位录制(外部) [3] 录完发信号 videos_saved_to_this_folder_signal freemocap_main_window.py:232 [4] 若 "Auto Process Videos" 勾上:自动点 Process 按钮 freemocap_main_window.py:177 [5] 后台处理(独立子进程): 标定 ─ CHARUCO 视频 → camera_calibration.toml ↓ 软同步 ─ skelly_synchronize(audio / brightness) ↓ 2D 检测 ─ skellytracker → MediaPipe / YOLO / OpenPose ↓ 三角化 ─ aniposelib DLT + 加权异常点剔除 ↓ 后处理 ─ skellyforge: 插值 → Butterworth → 找参考帧 → 坐标旋转 ↓ 刚体约束 ─ enforce_rigid_bones ↓ 质心 ─ segment COM + total body COM ↓ 导出 ─ CSV / NPY / Blender .blend / Jupyter [6] processing_finished_signal → 自动加载到 SkellyViewer Tab

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

  1. 内参(每相机):CameraGroup.calibrate_videos() @ freemocap_anipose.py:2178-2202cv2.initCameraMatrix2D() 从 ChArUco 角点初始化(:2091)。
  2. 外参初始化:get_initial_extrinsics() @ :499-513,调 aniposelib 的 extract_rtvecs
  3. 相机图连通性get_calibration_graph() @ :387-403
  4. 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-190triangulate_with_outlier_rejection()

不同于硬 RANSAC 的"要么用要么扔",FreeMoCap 走柔和路线:

全相机三角化 → 计算重投影误差 ↓ 若超过 target_reprojection_error 枚举"丢 1 / 2 / ... / maximum_cameras_to_drop 个相机"的所有子集 ↓ 每个子集都三角化一次 + 算误差 赋权:weight = exp(-5.0 × error / target_reprojection_error) ↓ 所有子集的 3D 点加权平均 → 输出最终 3D + 每相机置信度权重

参数(freemocap_anipose.py:91-93):

minimum_cameras_for_triangulation = 2 maximum_cameras_to_drop = 1 target_reprojection_error = 0.01

为什么牛:硬剔除会丢信息,软剔除(指数权重)让"还行"的相机也能贡献。对低成本多机位(4–6 个 webcam,难免有 1–2 个角度差)尤其重要。

4.5 重投影误差诊断

freemocap_anipose.py:1105-1143CameraGroup.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
YOLOMediapipeComboYOLO crop → MP poseimage_tracking_pipeline_functions.py:85-86

默认 tracker 写死在 recording_info_model.py:42active_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-96tracking_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):

TASK_INTERPOLATION TASK_FILTERING TASK_FINDING_GOOD_FRAME TASK_SKELETON_ROTATION

每个任务独立 boolean 开关,挂在 PostProcessingParametersModel 上。

6.3 刚体约束(自实现,不依赖外部)

post_process_skeleton_data/enforce_rigid_bones.py

  • calculate_bone_lengths_and_statistics() @ :10-41——每帧算骨长,求 median / stdev
  • enforce_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 格式矩阵

格式路径用途
NPYoutput_data/mediapipe_skeleton_3d.npy主产物 (frames, markers, 3)
CSV (by_trajectory){recording}_by_trajectory.csv每帧一行
CSV (by_frame tidy){recording}_by_frame.csvtidy:frame/timestamp/model/keypoint/x/y/z/err
JSON{recording}_by_frame.json完整原始数据
.blend{recording}.blendBlender 场景(带骨架 armature)
.ipynbauto_generated_notebook.ipynbJupyter 数据探索模板

不支持 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_installbpy.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 录制目录结构

freemocap_data/recording_sessions/ └── session_<timestamp>/ └── recording_<timestamp>/ ├── synchronized_videos/ │ ├── camera_0.mp4 │ ├── camera_1.mp4 │ └── timestamps/ │ └── camera_*_timestamps.npy ├── annotated_videos/ # 可选:2D 叠加 ├── output_data/ │ ├── mediapipe_2dData_*.npy │ ├── raw_data/ │ │ ├── mediapipe_3dData_*.npy │ │ └── mediapipe_3dData_*_reprojectionError.npy │ ├── center_of_mass/ │ │ └── mediapipe_total_body_center_of_mass_xyz.npy │ ├── mediapipe_skeleton_3d.npy │ └── recording_parameters.json ├── <recording>_camera_calibration.toml └── <recording>.blend

文件名常量集中在 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

  1. 加权异常点剔除三角化freemocap_anipose.py:88-190)— exp(-5·error/threshold) 软权重,多相机时鲁棒胜过硬 RANSAC
  2. Numba @jit(nopython=True) 单点 DLT:55-67)— Python 几何运算贴近 C
  3. RecordingInfoModel 属性生成器 + status_check — 不维护状态机,按需扫文件
  4. QThread × multiprocessing.Process × Queue × Signal 四件套 — GUI 重活外包标准范式
  5. Tracker 策略模式(ModelInfo + duck typing) — 加新 tracker 不动主流程
  6. {tracker}_data_2d.npy 前缀命名 — 多算法结果共存对比
  7. skellyforge TaskWorkerThread 管道 — 可拼装的后处理 task 列表
  8. 刚体骨长中位数约束 + 子树级联传播enforce_rigid_bones.py)— 不用 IK 就能压住骨头抖
  9. Blender 子进程模式--background --python script.py,主进程 Python 环境干净
  10. 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延伸思考(可改造方向)

  1. 抽离 aniposelib + 加权剔除算法:单独打个 Python 库给所有"多相机三角化"业务用,不必带 PySide6。
  2. 替换 skellyforge 后处理:上 Kalman 滤波 / 双向 LSTM 时序去抖,比 Butterworth 强。
  3. 加关节角度计算:拿 Skeleton.joint_hierarchy + Pydantic 模型,每段两个 segment 算夹角,输出 Euler / 四元数。
  4. GPU MediaPipe:用 mediapipe.tasks.python.vision.PoseLandmarker 替代旧 holistic API,启 GPU delegate。
  5. WebRTC 实时模式:skellycam 出 RTP 流 → 服务器侧实时 2D + 增量三角化 → 真做到"实时动捕"。但要硬件触发同步才靠谱。

14文件级索引(快速查表)

模块关键文件
入口freemocap/__main__.py:24gui/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 schemadata_layer/skeleton_models/skeleton.py:13-133
RecordingInfoModeldata_layer/recording_models/recording_info_model.py:41-217
后台任务 workergui/qt/workers/process_motion_capture_data_thread_worker.py:16-76
跨平台路径system/paths_and_filenames/path_getters.py:31-250