Files
freemocap-source-analysis/index.html
2026-05-27 01:05:40 +08:00

809 lines
43 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FreeMoCap v1.8.2 源码深度解析</title>
<style>
:root {
--bg: #0b0d10;
--bg-card: #12161b;
--bg-code: #0e1216;
--border: #1f262e;
--text: #d8dde3;
--text-dim: #8b95a1;
--text-muted: #5d6773;
--accent: #7fb3ff;
--accent-2: #a78bfa;
--green: #6ee7b7;
--amber: #fbbf24;
--rose: #fb7185;
--code-bg: #0a0d11;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text);
line-height: 1.7;
font-size: 15px;
}
code, pre, .mono {
font-family: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
font-size: 13px;
}
/* Layout */
.layout {
display: grid;
grid-template-columns: 280px 1fr;
max-width: 1380px;
margin: 0 auto;
}
nav.toc {
position: sticky; top: 0;
height: 100vh;
overflow-y: auto;
padding: 2.4rem 1.4rem 2rem 2rem;
border-right: 1px solid var(--border);
}
nav.toc .brand {
font-size: 1rem; font-weight: 700;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
margin-bottom: 0.3rem;
}
nav.toc .brand-sub { color: var(--text-muted); font-size: 0.78rem; margin-bottom: 1.6rem; }
nav.toc ol { list-style: none; counter-reset: section; }
nav.toc li { counter-increment: section; margin-bottom: 2px; }
nav.toc a {
display: block;
padding: 6px 10px;
color: var(--text-dim);
text-decoration: none;
font-size: 0.84rem;
border-left: 2px solid transparent;
border-radius: 4px;
transition: all 0.15s;
}
nav.toc a::before {
content: counter(section, decimal) ". ";
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
nav.toc a:hover { color: var(--text); background: rgba(127,179,255,0.05); }
nav.toc a.active {
color: var(--accent);
border-left-color: var(--accent);
background: rgba(127,179,255,0.07);
}
main { padding: 3rem 4rem 6rem; max-width: 980px; }
section { margin-bottom: 4.5rem; scroll-margin-top: 1.5rem; }
section.hero { margin-bottom: 5.5rem; }
h1 {
font-size: 2.4rem; font-weight: 700; letter-spacing: -0.02em;
background: linear-gradient(135deg, #cfe1ff 0%, var(--accent) 50%, var(--accent-2) 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
margin-bottom: 0.6rem;
}
h2 {
font-size: 1.55rem; font-weight: 700;
color: var(--text); margin-bottom: 1.2rem;
padding-bottom: 0.7rem; border-bottom: 1px solid var(--border);
letter-spacing: -0.01em;
}
h2 .num {
color: var(--accent); font-variant-numeric: tabular-nums;
margin-right: 0.6em; font-weight: 600;
}
h3 { font-size: 1.05rem; font-weight: 600; color: var(--text); margin: 1.8rem 0 0.8rem; }
h4 { font-size: 0.92rem; font-weight: 600; color: var(--accent); margin: 1.2rem 0 0.5rem; }
p { color: var(--text-dim); margin-bottom: 1rem; }
p strong, li strong { color: var(--text); font-weight: 600; }
p em { color: var(--green); font-style: normal; }
ul, ol { margin: 0.5rem 0 1rem 1.4rem; color: var(--text-dim); }
li { margin-bottom: 0.4rem; }
li::marker { color: var(--text-muted); }
.lede {
font-size: 1.05rem; color: var(--text);
padding: 1.2rem 1.4rem;
background: linear-gradient(135deg, rgba(127,179,255,0.07), rgba(167,139,250,0.04));
border-left: 3px solid var(--accent);
border-radius: 6px;
margin-bottom: 1.6rem;
}
.meta-row {
display: flex; flex-wrap: wrap; gap: 8px;
margin-bottom: 2rem;
}
.badge {
display: inline-flex; align-items: center;
padding: 4px 10px; border-radius: 14px;
font-size: 0.74rem; font-weight: 500;
background: var(--bg-card); border: 1px solid var(--border);
color: var(--text-dim);
}
.badge.accent { color: var(--accent); border-color: rgba(127,179,255,0.3); }
.badge.warn { color: var(--amber); border-color: rgba(251,191,36,0.3); }
code:not(pre code) {
background: var(--bg-code);
padding: 1px 6px; border-radius: 4px;
color: var(--green);
border: 1px solid var(--border);
font-size: 0.85em;
}
pre {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem 1.2rem;
margin: 0.8rem 0 1.4rem;
overflow-x: auto;
line-height: 1.6;
}
pre code { background: none; padding: 0; color: var(--text); border: none; }
pre .c { color: var(--text-muted); font-style: italic; }
pre .k { color: var(--accent-2); }
pre .s { color: var(--green); }
pre .n { color: var(--amber); }
table {
width: 100%; border-collapse: collapse;
margin: 1rem 0 1.6rem; font-size: 0.9rem;
}
th, td {
text-align: left; padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
th { color: var(--text); font-weight: 600; background: rgba(127,179,255,0.04); }
td { color: var(--text-dim); }
td code { font-size: 0.82em; }
tr:hover td { background: rgba(255,255,255,0.015); }
.file {
color: var(--accent);
font-family: "JetBrains Mono", "SF Mono", Menlo, monospace;
font-size: 0.84em;
word-break: break-word;
}
.callout {
padding: 1rem 1.2rem; border-radius: 8px;
margin: 1rem 0 1.4rem;
border-left: 3px solid;
background: var(--bg-card);
}
.callout.tip { border-color: var(--green); }
.callout.warn { border-color: var(--amber); }
.callout.danger{ border-color: var(--rose); }
.callout strong { color: var(--text); }
.callout p { margin-bottom: 0; }
.grid-2 {
display: grid; grid-template-columns: 1fr 1fr;
gap: 1.2rem; margin: 1rem 0;
}
.grid-2 > div {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.1rem 1.3rem;
}
.grid-2 h4 { margin-top: 0; }
.ascii {
font-family: "JetBrains Mono", monospace; font-size: 12px;
background: var(--code-bg); border: 1px solid var(--border);
border-radius: 8px; padding: 1.1rem;
white-space: pre; overflow-x: auto;
color: var(--text-dim); line-height: 1.5;
}
.pill-list {
display: flex; flex-wrap: wrap; gap: 6px;
margin: 0.5rem 0 1.2rem;
}
.pill {
padding: 4px 10px; border-radius: 4px;
font-size: 0.78rem;
background: var(--bg-card); border: 1px solid var(--border);
color: var(--text-dim); font-family: "JetBrains Mono", monospace;
}
.footer {
margin-top: 3rem; padding-top: 1.6rem;
border-top: 1px solid var(--border);
color: var(--text-muted); font-size: 0.83rem;
}
.footer a { color: var(--accent); text-decoration: none; }
.footer a:hover { text-decoration: underline; }
@media (max-width: 960px) {
.layout { grid-template-columns: 1fr; }
nav.toc { display: none; }
main { padding: 2rem 1.4rem 4rem; }
.grid-2 { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="layout">
<nav class="toc" id="toc">
<div class="brand">FreeMoCap</div>
<div class="brand-sub">v1.8.2 源码深度解析 · 2026-05-27</div>
<ol>
<li><a href="#sec-1">一句话定性</a></li>
<li><a href="#sec-2">依赖拓扑:算力在哪里</a></li>
<li><a href="#sec-3">入口与主流程</a></li>
<li><a href="#sec-4">标定 + 三角化(招牌核心)</a></li>
<li><a href="#sec-5">2D 关键点检测</a></li>
<li><a href="#sec-6">3D 后处理</a></li>
<li><a href="#sec-7">数据导出</a></li>
<li><a href="#sec-8">Skeleton Schema</a></li>
<li><a href="#sec-9">GUI / 数据层架构</a></li>
<li><a href="#sec-10">招牌设计 Top 10</a></li>
<li><a href="#sec-11">局限性</a></li>
<li><a href="#sec-12">适合 / 不适合</a></li>
<li><a href="#sec-13">延伸思考</a></li>
<li><a href="#sec-14">文件级索引</a></li>
</ol>
</nav>
<main>
<section class="hero">
<h1>FreeMoCap v1.8.2 源码深度解析</h1>
<p style="font-size:1.05rem;color:var(--text-dim);margin-bottom:1.4rem;">
用普通 webcam 做的开源 3D 动捕——标定、三角化、检测、后处理、Blender 导出全链路逐文件拆开。
</p>
<div class="meta-row">
<span class="badge accent">★ 8,840</span>
<span class="badge">v1.8.2</span>
<span class="badge">14,210 LOC</span>
<span class="badge">145 .py files</span>
<span class="badge">Python 3.103.12</span>
<span class="badge">PySide6</span>
<span class="badge warn">AGPL-3.0</span>
<span class="badge">github.com/freemocap/freemocap</span>
</div>
<div class="lede">
<strong>结论先行:</strong>FreeMoCap 主仓本身<em>不是动捕算法库</em>,而是一个 PySide6 GUI 编排器——把 7 个 <code>skelly_*</code> 子包和 <code>aniposelib</code> 串成端到端流水线。论真正算力,主仓不到 30%70% 在外部依赖。要抄就抄 aniposelib 和 skellytracker主仓的价值在 <em>GUI 编排、状态零持久化、Tracker 策略模式</em> 三个工程设计上。
</div>
</section>
<section id="sec-1">
<h2><span class="num">1</span>一句话定性</h2>
<p>
把 46 个普通 webcam / GoPro / 手机摄像头围成半圆,用 <strong>ChArUco 棋盘</strong>标定相机几何,<strong>MediaPipe</strong> 在每路视频上跑 2D 关键点,<strong>DLT + 加权异常点剔除</strong>三角化成 3D 骨架,<strong>Butterworth + 刚体约束</strong>后处理,最后 <strong>Blender 子进程</strong>导出 .blend 场景。所有计算<em>本地 CPU 跑,数据不出设备</em>
</p>
<p>
官方话术 "实时动捕" 是<em>录制时实时多机同步采集</em>的实时,<em>不是检测/三角化实时</em>——后者是录完了离线后处理。这一点要在做决策前看清。
</p>
</section>
<section id="sec-2">
<h2><span class="num">2</span>依赖拓扑:算力在哪里</h2>
<p><span class="file">pyproject.toml:68-84</span>,依赖列表就是这个项目的真实地图:</p>
<table>
<thead><tr><th>外部包</th><th>版本</th><th>角色</th></tr></thead>
<tbody>
<tr><td><code>skellycam</code></td><td>2025.09.1097</td><td>多摄像头采集PySide6 widget</td></tr>
<tr><td><code>skellytracker[all]</code></td><td>2025.10.1024</td><td>2D 关键点检测(包 MediaPipe / YOLO / OpenPose</td></tr>
<tr><td><code>skelly_synchronize</code></td><td>2025.04.1037</td><td>多机位时间戳软同步(音频 / 亮度)</td></tr>
<tr><td><code>skellyforge</code></td><td>2024.12.1009</td><td>后处理 task pipeline插值 / 滤波 / 旋转)</td></tr>
<tr><td><code>skelly_viewer</code></td><td>2025.04.1028</td><td>3D 骨架可视化</td></tr>
<tr><td><code>ajc27_freemocap_blender_addon</code></td><td>2026.04.1039</td><td>Blender 桥接插件</td></tr>
<tr><td><code>aniposelib</code></td><td><strong>0.4.3</strong></td><td><strong>多视角几何真核心</strong>DLT、Bundle Adjustment、外参</td></tr>
<tr><td><code>opencv-contrib-python</code></td><td>4.8.*</td><td>ChArUco 检测、相机标定底层</td></tr>
<tr><td><code>PySide6</code></td><td>6.66.8</td><td>GUI 框架</td></tr>
<tr><td><code>pydantic</code></td><td>2.*</td><td>数据 schema</td></tr>
</tbody>
</table>
<div class="callout tip">
<p><strong>抄什么 ≠ 用什么。</strong>如果只想要"多相机三角化"这件事,<code>aniposelib</code> 比 FreeMoCap 主仓干净十倍——后者只是包了一层 GUI。</p>
</div>
</section>
<section id="sec-3">
<h2><span class="num">3</span>入口与主流程</h2>
<h3>3.1 程序入口</h3>
<p><span class="file">freemocap/__main__.py:24</span><code>qt_gui_main()</code> 是唯一入口CLI 一行带过:</p>
<pre><code><span class="k">def</span> main():
...
qt_gui_main() <span class="c"># gui/qt/main_window/freemocap_main.py:29-59</span></code></pre>
<p>启动后进 PySide6 主事件循环,支持 <code>EXIT_CODE_REBOOT</code> 重启机制(<span class="file">freemocap_main.py:87</span>)。</p>
<h3>3.2 主窗口 5 大 Tab</h3>
<p><code>MainWindow(QMainWindow)</code> @ <span class="file">gui/qt/main_window/freemocap_main_window.py:92-150</span></p>
<table>
<thead><tr><th>Tab</th><th>Widget</th><th>来源</th></tr></thead>
<tbody>
<tr><td>0</td><td>HomeWidget</td><td>本仓</td></tr>
<tr><td>1</td><td>SkellyCamWidget录制</td><td><strong>外部 skellycam</strong></td></tr>
<tr><td>2</td><td>SkellyViewer3D 可视化)</td><td><strong>外部 skelly_viewer</strong></td></tr>
<tr><td>3</td><td>DirectoryViewWidget</td><td>本仓</td></tr>
<tr><td>4</td><td>ActiveRecordingInfoWidget</td><td>本仓</td></tr>
</tbody>
</table>
<p>右侧 <code>ControlPanelWidget</code><span class="file">widgets/control_panel/control_panel_dock_widget.py:32-62</span>3 子 Tab摄像头配置 → 数据处理 → 导出。</p>
<h3>3.3 端到端流水线</h3>
<div class="ascii">[1] 用户 → "Start New Session" → handle_start_new_session_action() freemocap_main_window.py:195
[2] 录制 TabSkellyCamWidget 启动多机位录制(外部)
[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_synchronizeaudio / 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</div>
</section>
<section id="sec-4">
<h2><span class="num">4</span>标定 + 三角化(招牌核心)</h2>
<h3>4.1 CHARUCO 棋盘定义</h3>
<p><span class="file">core_processes/capture_volume_calibration/charuco_stuff/charuco_board_definition.py:7-44</span></p>
<pre><code><span class="k">@dataclass</span>
<span class="k">class</span> CharucoBoardDefinition:
name: <span class="k">str</span>
number_of_squares_width: <span class="k">int</span>
number_of_squares_height: <span class="k">int</span>
black_square_side_length: <span class="k">int</span>
aruco_marker_length_proportional: <span class="k">float</span>
aruco_marker_dict: <span class="k">Dict</span> = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_250)</code></pre>
<p>预设两块板:<strong>7×5</strong>(默认)和 <strong>5×3</strong>(小空间备选),都用 <code>DICT_4X4_250</code> 字典marker = 0.8 × square。</p>
<h3>4.2 标定流程</h3>
<p>主入口:<code>AniposeCameraCalibrator</code> @ <span class="file">anipose_camera_calibration/anipose_camera_calibrator.py:42-87</span></p>
<ol>
<li><strong>内参</strong>(每相机):<code>CameraGroup.calibrate_videos()</code> @ <span class="file">freemocap_anipose.py:2178-2202</span><code>cv2.initCameraMatrix2D()</code> 从 ChArUco 角点初始化(<span class="file">:2091</span>)。</li>
<li><strong>外参</strong>初始化:<code>get_initial_extrinsics()</code> @ <span class="file">:499-513</span>,调 aniposelib 的 <code>extract_rtvecs</code></li>
<li><strong>相机图连通性</strong><code>get_calibration_graph()</code> @ <span class="file">:387-403</span></li>
<li><strong>Bundle Adjustment 迭代</strong><code>bundle_adjust_iter()</code> @ <span class="file">:1145-1265</span></li>
</ol>
<h3>4.3 三角化Numba 加速的 DLT</h3>
<p><span class="file">freemocap_anipose.py:55-67</span></p>
<pre><code><span class="k">@jit</span>(nopython=<span class="k">True</span>, parallel=<span class="k">False</span>)
<span class="k">def</span> triangulate_simple(points, camera_mats):
num_cams = len(camera_mats)
A = np.zeros((num_cams * 2, 4))
<span class="k">for</span> i <span class="k">in</span> 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=<span class="k">True</span>)
p3d = vh[-1]
p3d = p3d[:3] / p3d[3] <span class="c"># 齐次坐标归一化</span>
<span class="k">return</span> p3d</code></pre>
<p>线性 DLT构造 4×N 矩阵 → SVD → 取最小奇异向量 → 齐次归一。<code>@jit(nopython=True)</code> 让单点三角化贴近 C 速度。</p>
<h3>4.4 招牌设计:加权异常点剔除三角化</h3>
<p><span class="file">freemocap_anipose.py:88-190</span><code>triangulate_with_outlier_rejection()</code></p>
<p>不同于硬 RANSAC 的"要么用要么扔"FreeMoCap 走柔和路线:</p>
<div class="ascii">全相机三角化 → 计算重投影误差
↓ 若超过 target_reprojection_error
枚举"丢 1 / 2 / ... / maximum_cameras_to_drop 个相机"的所有子集
↓ 每个子集都三角化一次 + 算误差
赋权weight = exp(-5.0 × error / target_reprojection_error)
所有子集的 3D 点加权平均 → 输出最终 3D + 每相机置信度权重</div>
<p>参数(<span class="file">freemocap_anipose.py:91-93</span></p>
<div class="pill-list">
<span class="pill">minimum_cameras_for_triangulation = 2</span>
<span class="pill">maximum_cameras_to_drop = 1</span>
<span class="pill">target_reprojection_error = 0.01</span>
</div>
<div class="callout tip">
<p><strong>为什么牛:</strong>硬剔除会丢信息,软剔除(指数权重)让"还行"的相机也能贡献。对低成本多机位46 个 webcam难免有 12 个角度差)尤其重要。</p>
</div>
<h3>4.5 重投影误差诊断</h3>
<p><span class="file">freemocap_anipose.py:1105-1143</span><code>CameraGroup.reprojection_error()</code> 返回 <code>(n_cams, n_points, 2)</code> 误差张量。<span class="file">diagnostics/calibration/calculate_calibration_diagnostics.py:12-46</span> 输出 CSV相邻 ChArUco 角点距离的 mean/median/std/偏差。</p>
<h3>4.6 多机位同步:纯软件</h3>
<p><strong>没有硬件触发</strong><span class="file">synchronize_videos_thread_worker.py:6,49-61</span> 走两条路:</p>
<pre><code><span class="k">from</span> skelly_synchronize.skelly_synchronize <span class="k">import</span> (
synchronize_videos_from_audio, <span class="c"># 默认:音频对齐</span>
synchronize_videos_from_brightness, <span class="c"># 备选:亮度跳变</span>
)</code></pre>
<p>录制时记每帧时间戳到 <code>synchronized_videos/timestamps/*.npy</code>,对齐在后处理阶段做:找音频对齐峰 → 算相对偏移 → 帧重索引。</p>
</section>
<section id="sec-5">
<h2><span class="num">5</span>2D 关键点检测Tracker 策略模式)</h2>
<h3>5.1 支持的 Tracker</h3>
<table>
<thead><tr><th>Tracker</th><th>状态</th><th>关键 file:line</th></tr></thead>
<tbody>
<tr><td><strong>MediaPipe Holistic</strong>(默认)</td><td>主流程</td><td><span class="file">post_processing_parameter_models.py:6</span></td></tr>
<tr><td>YOLO</td><td>实验性</td><td><span class="file">experimental/alternative_trackers/run_yolo.py:7</span></td></tr>
<tr><td>OpenPose</td><td>实验性</td><td><span class="file">experimental/alternative_trackers/run_openpose.py:8</span></td></tr>
<tr><td>YOLOMediapipeCombo</td><td>YOLO crop → MP pose</td><td><span class="file">image_tracking_pipeline_functions.py:85-86</span></td></tr>
</tbody>
</table>
<p>默认 tracker 写死在 <span class="file">recording_info_model.py:42</span><code>active_tracker="mediapipe"</code></p>
<h3>5.2 检测入口(极薄)</h3>
<p><span class="file">process_motion_capture_videos/processing_pipeline_functions/image_tracking_pipeline_functions.py:26-98</span></p>
<pre><code><span class="k">def</span> run_image_tracking_pipeline(...) -&gt; 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,
)</code></pre>
<p>外包装薄如纸——实际检测全在 <code>skellytracker.process_folder_of_videos()</code> 里。FreeMoCap 不关心 tracker 内部,只接收统一 ndarray。</p>
<h3>5.3 数据格式(钉死)</h3>
<p><span class="file">tests/test_image_tracking_data_shape.py:26-61</span> 把 shape 钉死成:</p>
<pre><code>shape = (num_cameras, num_frames, num_landmarks, 3)
^^^^^^^^^^^
xy + confidence</code></pre>
<p>第 4 维是 <strong>3 不是 2</strong>——多出来的是 confidence。3D 三角化阶段才会丢掉 conf 通道。</p>
<h3>5.4 并发multiprocessing</h3>
<p><span class="file">image_tracking_pipeline_functions.py:89-96</span><code>tracking_params.num_processes</code>。默认 <code>multiprocessing.cpu_count() - 1</code>。每路视频独立进程,避开 GIL。</p>
<h3>5.5 Landmark 映射:属性反射</h3>
<p><span class="file">post_process_skeleton_data/post_process_skeleton.py:130-136</span></p>
<pre><code><span class="k">def</span> get_landmark_names(model_info: ModelInfo) -&gt; <span class="k">list</span>:
<span class="k">if</span> hasattr(model_info, <span class="s">"body_landmark_names"</span>):
<span class="k">return</span> model_info.body_landmark_names
<span class="k">elif</span> hasattr(model_info, <span class="s">"landmark_names"</span>):
<span class="k">return</span> model_info.landmark_names</code></pre>
<p>属性反射 + duck typing——加新 tracker 不改主流程,只要 <code>ModelInfo</code> 子类提供 <code>*_landmark_names</code> 字段。</p>
</section>
<section id="sec-6">
<h2><span class="num">6</span>3D 后处理skellyforge 任务管道)</h2>
<h3>6.1 Butterworth 滤波参数</h3>
<p><span class="file">data_layer/recording_models/post_processing_parameter_models.py:25-28</span></p>
<pre><code><span class="k">class</span> ButterworthFilterParametersModel(BaseModel):
sampling_rate: <span class="k">float</span> = 30
cutoff_frequency: <span class="k">float</span> = 7
order: <span class="k">int</span> = 4</code></pre>
<p>30 fps、7 Hz 截止、4 阶——典型人体运动学滤波(人手脚最快动作 ~10 Hz 上限)。</p>
<h3>6.2 任务管道顺序</h3>
<p><span class="file">post_process_skeleton.py:69-104</span> 走 skellyforge <code>TaskWorkerThread</code>,固定顺序(<span class="file">:83</span></p>
<div class="pill-list">
<span class="pill">TASK_INTERPOLATION</span>
<span class="pill">TASK_FILTERING</span>
<span class="pill">TASK_FINDING_GOOD_FRAME</span>
<span class="pill">TASK_SKELETON_ROTATION</span>
</div>
<p>每个任务独立 boolean 开关,挂在 <code>PostProcessingParametersModel</code> 上。</p>
<h3>6.3 刚体约束(自实现,不依赖外部)</h3>
<p><span class="file">post_process_skeleton_data/enforce_rigid_bones.py</span></p>
<ul>
<li><code>calculate_bone_lengths_and_statistics()</code> @ <span class="file">:10-41</span>——每帧算骨长,求 median / stdev</li>
<li><code>enforce_rigid_bones()</code> @ <span class="file">:44-86</span>——<strong>中位数骨长标准化 + 子关节级联传播</strong></li>
</ul>
<pre><code>direction = (distal - proximal) / |distal - proximal|
adjustment = (desired_length - current_length) * direction
rigid_marker_data[distal_marker][frame] += adjustment
adjust_children(distal_marker, frame, adjustment, ...) <span class="c"># 递归</span></code></pre>
<p>把当前帧骨长拉回到序列中位数,再把偏移传给下游所有子关节(保持相对位姿)。处理 2D 检测误差导致的"骨头忽长忽短"。</p>
<h3>6.4 坐标系对齐90° 绕 X 轴</h3>
<p><span class="file">utilities/geometry/rotate_by_90_degrees_around_x_axis.py:4-14</span></p>
<pre><code>swapped[:, :, 0] = raw[:, :, 0] <span class="c"># X 不变</span>
swapped[:, :, 1] = raw[:, :, 2] <span class="c"># Y ← Z</span>
swapped[:, :, 2] = -raw[:, :, 1] <span class="c"># Z ← -Y</span></code></pre>
<p>从"摄像头坐标系Z 朝前)"换到"人体运动学坐标系Y 朝上)"。</p>
<h3>6.5 质心(替代关节角度)</h3>
<p><span class="file">post_process_skeleton_data/calculate_center_of_mass.py:12-141</span> 返回:</p>
<ul>
<li><code>segment_com_data</code>shape <code>(frames, segments, 3)</code></li>
<li><code>total_body_com</code>shape <code>(frames, 3)</code></li>
</ul>
<div class="callout warn">
<p><strong>注意:</strong>v1.8.2 <em>不输出原生关节角度</em>Euler / 四元数),只有关键点位置 + 质心。要做关节角度分析得拿 NPY 自己算。</p>
</div>
</section>
<section id="sec-7">
<h2><span class="num">7</span>数据导出</h2>
<h3>7.1 格式矩阵</h3>
<table>
<thead><tr><th>格式</th><th>路径</th><th>用途</th></tr></thead>
<tbody>
<tr><td>NPY</td><td><code>output_data/mediapipe_skeleton_3d.npy</code></td><td>主产物 <code>(frames, markers, 3)</code></td></tr>
<tr><td>CSV (by_trajectory)</td><td><code>{recording}_by_trajectory.csv</code></td><td>每帧一行</td></tr>
<tr><td>CSV (by_frame tidy)</td><td><code>{recording}_by_frame.csv</code></td><td>tidyframe/timestamp/model/keypoint/x/y/z/err</td></tr>
<tr><td>JSON</td><td><code>{recording}_by_frame.json</code></td><td>完整原始数据</td></tr>
<tr><td><strong>.blend</strong></td><td><code>{recording}.blend</code></td><td>Blender 场景(带骨架 armature</td></tr>
<tr><td>.ipynb</td><td><code>auto_generated_notebook.ipynb</code></td><td>Jupyter 数据探索模板</td></tr>
</tbody>
</table>
<p><strong>不支持 FBX / glTF</strong>——要用得手动从 .blend 二次导出。</p>
<h3>7.2 Blender 桥接(最有意思的设计)</h3>
<p>策略:<strong>不用 bpy 内嵌进程,而是外挂 Blender 可执行作为子进程</strong></p>
<p><span class="file">core_processes/export_data/blender_stuff/export_to_blender/methods/ajc_addon/run_ajc_addon_main.py:73-99</span></p>
<pre><code>command_list = [
str(blender_exe_path),
<span class="s">"--background"</span>, <span class="c"># 无 GUI</span>
<span class="s">"--python"</span>,
simple_run_script, <span class="c"># methods/ajc_addon/run_simple.py</span>
<span class="s">"--"</span>,
str(recording_folder_path),
str(blender_file_path),
]
blender_process = run_subprocess(command_list=command_list)
<span class="k">while</span> <span class="k">True</span>:
output = blender_process.stdout.readline()
<span class="k">if</span> blender_process.poll() <span class="k">is not None</span>:
<span class="k">break</span>
<span class="k">if</span> output:
logging.debug(output.strip().decode())
blender_process.terminate()</code></pre>
<p>子进程脚本(<span class="file">run_simple.py:4-8</span>)调用外部插件:</p>
<pre><code><span class="k">from</span> ajc27_freemocap_blender_addon.main <span class="k">import</span> ajc27_run_as_main_function
ajc27_run_as_main_function(recording_path=..., blend_file_path=...)</code></pre>
<p>插件首次运行由 <span class="file">bpy_install_addon.py:33-51</span> 现场安装:打 ZIP → <code>bpy.ops.preferences.addon_install</code><code>bpy.ops.wm.save_userpref</code></p>
<p>Blender 路径自动检测(<span class="file">get_best_guess_of_blender_path.py:27-86</span>Windows 扫 <code>Program Files/Blender Foundation</code>macOS <code>/Applications/Blender.app/Contents/MacOS/Blender</code>Linux <code>/usr/bin/blender</code></p>
<div class="callout tip">
<p><strong>为什么聪明:</strong>bpy 作为 Python 库装起来很折磨(要 Blender 版本对齐)。子进程模式把 Blender 当成黑盒服务FreeMoCap 主进程的 Python 环境干净。</p>
</div>
<h3>7.3 列名约定tracker-aware</h3>
<p><span class="file">post_process_skeleton_data/split_and_save.py:67-121</span></p>
<pre><code><span class="c"># 如果 model_info 有具名 landmark</span>
column_names.append(<span class="k">f</span><span class="s">"{category}_{name}_{x|y|z}"</span>) <span class="c"># body_nose_x, body_left_shoulder_y</span>
<span class="c"># 否则降级到数字索引:</span>
column_names.append(<span class="k">f</span><span class="s">"{category}_{i:04d}_{x|y|z}"</span>) <span class="c"># body_0000_x, body_0001_x</span></code></pre>
<p>同时按 category 切(<span class="file">:32-64</span><code>body / left_hand / right_hand / face</code></p>
<h3>7.4 时间戳CSV 不带</h3>
<p>CSV 行号即帧索引。帧率元数据存在 <code>PostProcessingParametersModel.framerate</code>(默认 30不内嵌到导出文件。精确时序分析得拿 <code>synchronized_videos/timestamps/*.npy</code> 配合用。</p>
</section>
<section id="sec-8">
<h2><span class="num">8</span>Skeleton Schema统一骨架定义</h2>
<h3>8.1 核心 Pydantic 模型</h3>
<p><span class="file">data_layer/skeleton_models/skeleton.py:13-133</span></p>
<pre><code><span class="k">class</span> Skeleton(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=<span class="k">True</span>)
markers: MarkerInfo
num_tracked_points: <span class="k">int</span>
segments: Optional[Dict[<span class="k">str</span>, Segment]] = <span class="k">None</span>
marker_data: Dict[<span class="k">str</span>, np.ndarray] = {} <span class="c"># 每点 (frames, 3)</span>
virtual_marker_data: Dict[<span class="k">str</span>, np.ndarray] = {}
joint_hierarchy: Optional[Dict[<span class="k">str</span>, List[<span class="k">str</span>]]] = <span class="k">None</span>
center_of_mass_definitions: Optional[Dict[<span class="k">str</span>, SegmentAnthropometry]] = <span class="k">None</span>
num_frames: Optional[<span class="k">int</span>] = <span class="k">None</span></code></pre>
<h3>8.2 段定义</h3>
<p><span class="file">data_layer/skeleton_models/segments.py:8-17</span></p>
<pre><code><span class="k">class</span> Segment(BaseModel):
proximal: <span class="k">str</span>
distal: <span class="k">str</span>
<span class="k">class</span> SegmentAnthropometry(BaseModel):
segment_com_length: <span class="k">float</span> <span class="c"># 质心位置占段长比例</span>
segment_com_percentage: <span class="k">float</span> <span class="c"># 段占全身质量比例</span></code></pre>
<h3>8.3 虚拟关键点</h3>
<p><span class="file">marker_info.py:38-68</span> 支持加权平均生成虚拟点,典型如 <code>mid_shoulder = 0.5 * left_shoulder + 0.5 * right_shoulder</code>。这让上层算法(质心、骨长)能用稳定的解剖学参考点,不受单点遮挡影响。</p>
</section>
<section id="sec-9">
<h2><span class="num">9</span>GUI / 数据层架构</h2>
<h3>9.1 RecordingInfoModel路径属性生成器</h3>
<p><span class="file">data_layer/recording_models/recording_info_model.py:41-217</span></p>
<pre><code><span class="k">class</span> RecordingInfoModel:
<span class="k">@property</span>
<span class="k">def</span> synchronized_videos_folder_path(self) -&gt; <span class="k">str</span>: ...
<span class="k">@property</span>
<span class="k">def</span> data_2d_npy_file_path(self) -&gt; <span class="k">str</span>: ...
<span class="k">@property</span>
<span class="k">def</span> data_3d_npy_file_path(self) -&gt; <span class="k">str</span>: ...
<span class="k">@property</span>
<span class="k">def</span> calibration_toml_path(self) -&gt; <span class="k">str</span>: ...
<span class="k">@property</span>
<span class="k">def</span> status_check(self) -&gt; Dict[...]: <span class="c"># 递归查文件存在性</span>
...</code></pre>
<p><strong>不存路径字符串、只用属性生成器</strong>——根路径变了之后所有派生路径自动跟。<code>status_check</code> 是状态机的反面:不维护"录制完成 / 处理完成"标志位,而是每次按需扫文件存在性。</p>
<h3>9.2 录制目录结构</h3>
<div class="ascii">freemocap_data/recording_sessions/
└── session_&lt;timestamp&gt;/
└── recording_&lt;timestamp&gt;/
├── 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
├── &lt;recording&gt;_camera_calibration.toml
└── &lt;recording&gt;.blend</div>
<p>文件名常量集中在 <span class="file">system/paths_and_filenames/file_and_folder_names.py:1-83</span></p>
<h3>9.3 后台任务QThread + multiprocessing 双层夹心</h3>
<p><span class="file">gui/qt/workers/process_motion_capture_data_thread_worker.py:16-76</span></p>
<pre><code><span class="k">class</span> ProcessMotionCaptureDataThreadWorker(QThread):
in_progress = Signal(...)
finished = Signal(<span class="k">bool</span>)
<span class="k">def</span> run(self):
self._process = multiprocessing.Process(
target=process_recording_folder,
args=(...),
)
self._process.start()
<span class="k">while</span> self._process.is_alive():
time.sleep(0.01)
<span class="k">if not</span> self._queue.empty():
record = self._queue.get()
self.in_progress.emit(record)
self.finished.emit(self._success)</code></pre>
<ul>
<li><strong>QThread 外层</strong>Qt 信号槽友好、生命周期可控、可发 <code>in_progress</code> 给 UI</li>
<li><strong>multiprocessing.Process 内层</strong>:真正干活,绕开 GIL</li>
<li><strong>multiprocessing.Queue</strong>:从工作进程把日志/进度推回 QThread</li>
</ul>
<p>GUI 应用的"重活外包"教科书模式UI 线程绝对不卡,重计算独立崩溃也不杀主进程。</p>
</section>
<section id="sec-10">
<h2><span class="num">10</span>招牌设计 Top 10</h2>
<ol>
<li><strong>加权异常点剔除三角化</strong><span class="file">freemocap_anipose.py:88-190</span>)— <code>exp(-5·error/threshold)</code> 软权重,多相机时鲁棒胜过硬 RANSAC</li>
<li><strong>Numba <code>@jit(nopython=True)</code> 单点 DLT</strong><span class="file">:55-67</span>)— Python 几何运算贴近 C</li>
<li><strong>RecordingInfoModel 属性生成器 + status_check</strong> — 不维护状态机,按需扫文件</li>
<li><strong>QThread × multiprocessing.Process × Queue × Signal</strong> 四件套 — GUI 重活外包标准范式</li>
<li><strong>Tracker 策略模式ModelInfo + duck typing</strong> — 加新 tracker 不动主流程</li>
<li><strong><code>{tracker}_data_2d.npy</code> 前缀命名</strong> — 多算法结果共存对比</li>
<li><strong>skellyforge TaskWorkerThread 管道</strong> — 可拼装的后处理 task 列表</li>
<li><strong>刚体骨长中位数约束 + 子树级联传播</strong><span class="file">enforce_rigid_bones.py</span>)— 不用 IK 就能压住骨头抖</li>
<li><strong>Blender 子进程模式</strong><code>--background --python script.py</code>,主进程 Python 环境干净</li>
<li><strong>CHARUCO + 软件音频/亮度同步</strong> — 用 webcam 也能搞动捕</li>
</ol>
</section>
<section id="sec-11">
<h2><span class="num">11</span>局限性(别被官方话术骗了)</h2>
<table>
<thead><tr><th>宣传</th><th>实情</th></tr></thead>
<tbody>
<tr><td>"实时动作捕捉"</td><td><strong>离线后处理</strong>。录的时候是实时多机同步采集,但 2D 检测 / 三角化 / 后处理全是录完了一起跑</td></tr>
<tr><td>"实时关节角度"</td><td><strong>不输出关节角度</strong>。v1.8.2 只有关键点位置和质心,关节角度得自己算</td></tr>
<tr><td>"支持任意 tracker"</td><td>主流程默认只接 MediaPipeYOLO / OpenPose 在 <code>experimental/</code> 没上主线</td></tr>
<tr><td>"GPU 加速"</td><td>主仓不暴露 GPU 配置,全靠 MediaPipe 内部判断</td></tr>
<tr><td>"媲美 Vicon"</td><td>重投影误差量级 ~ 像素级Vicon 亚毫米级标定。差 23 个数量级</td></tr>
</tbody>
</table>
</section>
<section id="sec-12">
<h2><span class="num">12</span>适合 / 不适合</h2>
<div class="grid-2">
<div>
<h4 style="color:var(--green)">✅ 适合</h4>
<ul>
<li>教学 / 科研动作分析(人体生物力学、运动学)</li>
<li>低成本 3D 角色动画毛坯(导 Blender 后人手 retarget</li>
<li>隐私敏感场景(数据全本地)</li>
</ul>
</div>
<div>
<h4 style="color:var(--rose)">❌ 不适合</h4>
<ul>
<li>实时游戏 / VR 全身追踪</li>
<li>高精度临床步态分析(亚毫米级)</li>
<li>商业产品发行(<strong>AGPL-3.0</strong> — SaaS 化要开源整个调用链)</li>
</ul>
</div>
</div>
</section>
<section id="sec-13">
<h2><span class="num">13</span>延伸思考(可改造方向)</h2>
<ol>
<li><strong>抽离 aniposelib + 加权剔除算法</strong>:单独打个 Python 库给所有"多相机三角化"业务用,不必带 PySide6。</li>
<li><strong>替换 skellyforge 后处理</strong>:上 Kalman 滤波 / 双向 LSTM 时序去抖,比 Butterworth 强。</li>
<li><strong>加关节角度计算</strong>:拿 <code>Skeleton.joint_hierarchy</code> + Pydantic 模型,每段两个 segment 算夹角,输出 Euler / 四元数。</li>
<li><strong>GPU MediaPipe</strong>:用 <code>mediapipe.tasks.python.vision.PoseLandmarker</code> 替代旧 holistic API启 GPU delegate。</li>
<li><strong>WebRTC 实时模式</strong>skellycam 出 RTP 流 → 服务器侧实时 2D + 增量三角化 → 真做到"实时动捕"。但要硬件触发同步才靠谱。</li>
</ol>
</section>
<section id="sec-14">
<h2><span class="num">14</span>文件级索引(快速查表)</h2>
<table>
<thead><tr><th>模块</th><th>关键文件</th></tr></thead>
<tbody>
<tr><td>入口</td><td><span class="file">freemocap/__main__.py:24</span><span class="file">gui/qt/freemocap_main.py:29</span></td></tr>
<tr><td>主窗口</td><td><span class="file">gui/qt/main_window/freemocap_main_window.py:92-150</span></td></tr>
<tr><td>CHARUCO 板</td><td><span class="file">core_processes/capture_volume_calibration/charuco_stuff/charuco_board_definition.py:7-44</span></td></tr>
<tr><td>标定主入口</td><td><span class="file">.../run_anipose_capture_volume_calibration.py:17-35</span></td></tr>
<tr><td>DLT 三角化</td><td><span class="file">.../freemocap_anipose.py:55-67</span></td></tr>
<tr><td>加权异常点剔除</td><td><span class="file">.../freemocap_anipose.py:88-190</span></td></tr>
<tr><td>Bundle Adjustment</td><td><span class="file">.../freemocap_anipose.py:1145-1265</span></td></tr>
<tr><td>重投影误差</td><td><span class="file">.../freemocap_anipose.py:1105-1143</span></td></tr>
<tr><td>软同步</td><td><span class="file">.../synchronize_videos_thread_worker.py:6,49-61</span></td></tr>
<tr><td>2D 检测入口</td><td><span class="file">.../image_tracking_pipeline_functions.py:26-98</span></td></tr>
<tr><td>Tracker 参数</td><td><span class="file">data_layer/recording_models/post_processing_parameter_models.py:25-44</span></td></tr>
<tr><td>后处理任务管道</td><td><span class="file">core_processes/post_process_skeleton_data/post_process_skeleton.py:69-104</span></td></tr>
<tr><td>刚体约束</td><td><span class="file">.../enforce_rigid_bones.py:10-86</span></td></tr>
<tr><td>坐标系旋转</td><td><span class="file">utilities/geometry/rotate_by_90_degrees_around_x_axis.py:4-14</span></td></tr>
<tr><td>质心计算</td><td><span class="file">.../calculate_center_of_mass.py:12-141</span></td></tr>
<tr><td>Blender 子进程</td><td><span class="file">.../export_to_blender/methods/ajc_addon/run_ajc_addon_main.py:73-99</span></td></tr>
<tr><td>Blender 路径检测</td><td><span class="file">.../get_best_guess_of_blender_path.py:27-86</span></td></tr>
<tr><td>Skeleton schema</td><td><span class="file">data_layer/skeleton_models/skeleton.py:13-133</span></td></tr>
<tr><td>RecordingInfoModel</td><td><span class="file">data_layer/recording_models/recording_info_model.py:41-217</span></td></tr>
<tr><td>后台任务 worker</td><td><span class="file">gui/qt/workers/process_motion_capture_data_thread_worker.py:16-76</span></td></tr>
<tr><td>跨平台路径</td><td><span class="file">system/paths_and_filenames/path_getters.py:31-250</span></td></tr>
</tbody>
</table>
</section>
<div class="footer">
<p>解析方法4 个 Explore 子 agent 并行拆 ①标定+三角化 ②pose 检测 ③后处理+导出 ④GUI+数据层,主线程读 <code>pyproject.toml</code> 和入口串总线索。所有论断带 file:line 证据。详细 markdown<code>.memory/source-analysis.md</code></p>
<p style="margin-top:0.6rem;">上游:<a href="https://github.com/freemocap/freemocap" target="_blank">github.com/freemocap/freemocap</a> · v1.8.2 · AGPL-3.0 · 2026-05-27</p>
</div>
</main>
</div>
<script>
// Scrollspy
const links = document.querySelectorAll('nav.toc a');
const sections = Array.from(links).map(a => document.querySelector(a.getAttribute('href')));
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.id;
links.forEach(l => {
l.classList.toggle('active', l.getAttribute('href') === '#' + id);
});
}
});
}, { rootMargin: '-30% 0px -60% 0px', threshold: 0 });
sections.forEach(s => s && observer.observe(s));
</script>
</body>
</html>