auto-save 2026-05-27 01:05 (~2)
This commit is contained in:
@@ -13,6 +13,13 @@
|
||||
"message": "init: project scaffold",
|
||||
"hash": "d430655",
|
||||
"files_changed": 9
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-27T01:00:12+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-27 01:00 (~2)",
|
||||
"hash": "cdee6f6",
|
||||
"files_changed": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
810
index.html
810
index.html
@@ -3,44 +3,806 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FreeMoCap 源码解析</title>
|
||||
<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, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0a0a0a; color: #e0e0e0;
|
||||
min-height: 100vh; padding: 2rem;
|
||||
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;
|
||||
}
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
h1 {
|
||||
font-size: 2.5rem; font-weight: 700;
|
||||
background: linear-gradient(135deg, #60a5fa, #a78bfa);
|
||||
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.5rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.subtitle { color: #888; font-size: 1.1rem; margin-bottom: 2rem; }
|
||||
.card {
|
||||
background: #141414; border: 1px solid #222; border-radius: 12px;
|
||||
padding: 2rem; margin-bottom: 1.5rem;
|
||||
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; }
|
||||
}
|
||||
.card h2 { color: #60a5fa; margin-bottom: 1rem; font-size: 1.3rem; }
|
||||
.card p { line-height: 1.8; color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>FreeMoCap 源码解析</h1>
|
||||
<p class="subtitle">开源动捕系统源码深度解析:多摄像头三角化 + MediaPipe pose + Blender 导出全链路</p>
|
||||
<div class="layout">
|
||||
|
||||
<div class="card">
|
||||
<h2>概述</h2>
|
||||
<p>待补充研究内容...</p>
|
||||
<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.10–3.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>
|
||||
把 4–6 个普通 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.6–6.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>SkellyViewer(3D 可视化)</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] 录制 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</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>硬剔除会丢信息,软剔除(指数权重)让"还行"的相机也能贡献。对低成本多机位(4–6 个 webcam,难免有 1–2 个角度差)尤其重要。</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>核心发现</h2>
|
||||
<p>待补充...</p>
|
||||
<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(...) -> 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) -> <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>tidy:frame/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) -> <span class="k">str</span>: ...
|
||||
<span class="k">@property</span>
|
||||
<span class="k">def</span> data_2d_npy_file_path(self) -> <span class="k">str</span>: ...
|
||||
<span class="k">@property</span>
|
||||
<span class="k">def</span> data_3d_npy_file_path(self) -> <span class="k">str</span>: ...
|
||||
<span class="k">@property</span>
|
||||
<span class="k">def</span> calibration_toml_path(self) -> <span class="k">str</span>: ...
|
||||
<span class="k">@property</span>
|
||||
<span class="k">def</span> status_check(self) -> 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_<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</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>主流程默认只接 MediaPipe;YOLO / OpenPose 在 <code>experimental/</code> 没上主线</td></tr>
|
||||
<tr><td>"GPU 加速"</td><td>主仓不暴露 GPU 配置,全靠 MediaPipe 内部判断</td></tr>
|
||||
<tr><td>"媲美 Vicon"</td><td>重投影误差量级 ~ 像素级;Vicon 亚毫米级标定。差 2–3 个数量级</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>
|
||||
|
||||
Reference in New Issue
Block a user