Files
AIC-Project/docs/superpowers/plans/2026-07-23-adaptive-ray-bake.md
2026-07-23 17:25:44 +08:00

398 lines
17 KiB
Markdown
Raw Permalink 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.
# model_bake 自适应射线距离 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:**`model_bake``auto` 射线从固定"低模包围盒对角线 2%"改为按实测低↔高分离距离自适应,消除薄部件(如裙子)烘焙射线穿透打到背/内面造成的深色脏斑。
**Architecture:** 纯函数 `percentile` / `adaptive_ray_distance``bl_bake.py` 可 TDDBlender 侧加 `_measure_separation`(高模 BVH 测低模面心最近距)并在 `main` 的 auto 分支替换固定射线,失败回退固定 2%。显式 `--ray-distance` 仍覆盖。
**Tech Stack:** Python 标准库、Blender 5.0 `bpy`/`mathutils``unittest`、现有 headless Blender runner。
设计依据:`docs/superpowers/specs/2026-07-23-adaptive-ray-bake-design.md`
## Global Constraints
- 不加外部依赖。
- 显式 `--ray-distance` 优先;自适应失败回退固定 2%,绝不中断烘焙。
- 不改烘焙 pass 结构/输出命名/贴图协议;`MT_SUMMARY` 只增 `ray` 字段。
- 纯逻辑 TDDBlender 侧由真机 smoke 覆盖。
- 只 stage 本计划源码/文档;不碰用户脏工作区与 `out_*` 产物。
## File Map
- Modify `Tools/ModelTranslator/bl_bake.py`:加 `percentile``adaptive_ray_distance`、常量;加 `_measure_separation``main` auto 分支自适应 + `MT_SUMMARY.ray`
- Modify `Tools/ModelTranslator/tests/test_bl_bake.py``percentile``adaptive_ray_distance` 单测。
- Modify `Tools/ModelTranslator/model_bake.py`:打印射线来源行。
- Modify `Tools/ModelTranslator/README.md`:记录自适应射线。
---
### Task 1: 纯函数(percentile + adaptive_ray_distance
**Files:**
- Modify: `Tools/ModelTranslator/bl_bake.py`
- Modify: `Tools/ModelTranslator/tests/test_bl_bake.py`
- [ ] **Step 1: 写失败单测**
`tests/test_bl_bake.py``if __name__ == "__main__":` 之前插入:
```python
class TestPercentile(unittest.TestCase):
def test_interpolates(self):
self.assertAlmostEqual(bb.percentile([0.0, 10.0], 0.25), 2.5)
def test_rejects_empty(self):
with self.assertRaises(ValueError):
bb.percentile([], 0.99)
def test_rejects_bad_q(self):
with self.assertRaises(ValueError):
bb.percentile([1.0], 1.1)
class TestAdaptiveRayDistance(unittest.TestCase):
def test_p99_with_safety(self):
r = bb.adaptive_ray_distance([0.002] * 20, (1.0, 0.0, 0.0))
self.assertAlmostEqual(r["distance_p99"], 0.002)
self.assertAlmostEqual(r["value"], 0.003) # 0.002*1.5,未夹
self.assertFalse(r["capped"])
def test_lower_floor(self):
r = bb.adaptive_ray_distance([0.0], (1.0, 0.0, 0.0))
self.assertAlmostEqual(r["value"], 0.0005) # 下限 0.05%
self.assertFalse(r["capped"])
def test_upper_cap(self):
r = bb.adaptive_ray_distance([0.02], (1.0, 0.0, 0.0))
self.assertAlmostEqual(r["value"], 0.01) # 夹到 1% 上限
self.assertTrue(r["capped"])
def test_zero_bbox_raises(self):
with self.assertRaises(ValueError):
bb.adaptive_ray_distance([0.01], (0.0, 0.0, 0.0))
```
- [ ] **Step 2: 跑测试确认 RED**
Run(在 `Tools/ModelTranslator`):
```bash
python -m unittest tests.test_bl_bake.TestPercentile tests.test_bl_bake.TestAdaptiveRayDistance -v
```
Expected: `AttributeError: module 'bl_bake' has no attribute 'percentile'`(及 adaptive_ray_distance)。
- [ ] **Step 3: 实现纯函数**
`bl_bake.py``DEFAULT_RAY_PCT = 0.02` 那行之后新增常量:
```python
RAY_SAFETY = 1.5 # 自适应射线安全系数(分离 P99 × 此值)
RAY_LOWER_PCT = 0.0005 # 射线下限 = 包围盒对角线 * 0.05%
RAY_UPPER_PCT = 0.01 # 射线上限 = 包围盒对角线 * 1%
```
`estimate_ray_distance` 函数之后新增两个纯函数:
```python
def percentile(values, q):
"""插值分位数。空列表或 q∉[0,1] 抛 ValueError。"""
if not values:
raise ValueError("percentile 需要至少一个值")
if not 0.0 <= q <= 1.0:
raise ValueError("分位数 q 必须在 [0,1]")
ordered = sorted(float(v) for v in values)
pos = (len(ordered) - 1) * q
lo = int(math.floor(pos))
hi = int(math.ceil(pos))
if lo == hi:
return ordered[lo]
return ordered[lo] + (ordered[hi] - ordered[lo]) * (pos - lo)
def adaptive_ray_distance(distances, bbox_dims):
"""按低↔高分离距离定射线:P99×RAY_SAFETY,夹在对角线 [0.05%,1%]。
返回 {distance_p99, value, capped};包围盒退化抛 ValueError。"""
diagonal = math.sqrt(sum(float(d) * float(d) for d in bbox_dims))
if diagonal <= 0.0:
raise ValueError("低模包围盒对角线必须为正")
p99 = percentile(distances, 0.99)
lower = diagonal * RAY_LOWER_PCT
upper = diagonal * RAY_UPPER_PCT
raw = max(p99 * RAY_SAFETY, lower)
return {"distance_p99": p99, "value": min(raw, upper), "capped": raw > upper}
```
- [ ] **Step 4: 跑测试确认 GREEN + 回归**
Run
```bash
python -m unittest tests.test_bl_bake -v
python -m unittest discover -s tests
```
Expected: 新测试全过;现有 `TestEstimateRayDistance` 等仍过;全量零失败。
- [ ] **Step 5: 提交**
```bash
git add -- Tools/ModelTranslator/bl_bake.py Tools/ModelTranslator/tests/test_bl_bake.py
git commit -m "ModelTranslator: adaptive ray distance pure functions"
```
提交信息末尾附一行:`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`
---
### Task 2: Blender 侧分离测量 + main 集成 + 打印
**Files:**
- Modify: `Tools/ModelTranslator/bl_bake.py`
- Modify: `Tools/ModelTranslator/model_bake.py`
> `_measure_separation`/`main` 依赖 bpy,无法纯单测;本任务 `py_compile` + 回归把关,真机 smoke 在 Task 3。
- [ ] **Step 1: 加 `_measure_separation`**
`bl_bake.py``_import_fbx_joined` 函数之后(`# ---------------- 以下仅在 Blender 内执行 ----------------` 区内)插入:
```python
def _measure_separation(low, high, limit=10000):
"""低模面心到高模表面最近距离数组(供自适应射线)。超 limit 面则均匀采样。
高模 BVH 无命中的面丢弃;返回距离列表(可能为空)。"""
from mathutils.bvhtree import BVHTree
hv = [high.matrix_world @ v.co for v in high.data.vertices]
hp = [tuple(p.vertices) for p in high.data.polygons]
bvh = BVHTree.FromPolygons(hv, hp, all_triangles=False)
polys = low.data.polygons
n = len(polys)
if n == 0:
return []
if n <= limit:
idxs = range(n)
else:
idxs = [round(i * (n - 1) / float(limit - 1)) for i in range(limit)]
mw = low.matrix_world
dists = []
for i in idxs:
hit = bvh.find_nearest(mw @ polys[i].center)
if hit[0] is not None and hit[3] is not None:
dists.append(hit[3])
return dists
```
- [ ] **Step 2: `main` auto 分支改自适应**
`bl_bake.py` `main()` 中,找到这一行:
```python
ray = estimate_ray_distance(low_dims) if ray_arg == "auto" else float(ray_arg)
```
整体替换为:
```python
if ray_arg != "auto":
ray = float(ray_arg)
ray_info = {"source": "explicit", "distance_p99": None,
"value": ray, "capped": False}
else:
dists = _measure_separation(low, high)
try:
if not dists:
raise ValueError("低模面心到高模无有效最近距离")
ray_info = adaptive_ray_distance(dists, low_dims)
ray_info["source"] = "adaptive"
ray = ray_info["value"]
if ray_info["capped"]:
warnings.append("自适应射线达包围盒对角线 1%% 上限,请检查高低模对应关系")
except ValueError as e:
ray = estimate_ray_distance(low_dims)
ray_info = {"source": "fixed_fallback", "distance_p99": None,
"value": ray, "capped": False}
warnings.append("自适应射线测量失败(%s),回退固定射线" % e)
```
- [ ] **Step 3: `MT_SUMMARY` 加 ray 字段**
`bl_bake.py` `main()` 末尾,找到:
```python
print("MT_SUMMARY " + json.dumps(
{"stem": stem, "fbx": os.path.basename(out_fbx), "outputs": outputs,
"size": size, "ray_distance": round(ray, 6),
"high_tris": len(high.data.polygons), "low_tris": len(low.data.polygons),
"warnings": warnings}, ensure_ascii=False))
```
整体替换为:
```python
ray_info = dict(ray_info)
ray_info["distance_p99"] = (None if ray_info["distance_p99"] is None
else round(ray_info["distance_p99"], 6))
ray_info["value"] = round(ray_info["value"], 6)
print("MT_SUMMARY " + json.dumps(
{"stem": stem, "fbx": os.path.basename(out_fbx), "outputs": outputs,
"size": size, "ray_distance": round(ray, 6), "ray": ray_info,
"high_tris": len(high.data.polygons), "low_tris": len(low.data.polygons),
"warnings": warnings}, ensure_ascii=False))
```
- [ ] **Step 4: `model_bake.py` 打印射线来源**
`model_bake.py` 中,找到:
```python
print("== %s(高 %d 面 / 低 %d 面,射线距离 %.4f-> %s" %
(s["stem"], s["high_tris"], s["low_tris"], s["ray_distance"], outdir))
```
其后新增:
```python
r = s.get("ray", {})
p99 = "n/a" if r.get("distance_p99") is None else "%.4f" % r["distance_p99"]
print(" 射线: 来源=%s P99=%s 距离=%.4f%s" %
(r.get("source", "?"), p99, r.get("value", s["ray_distance"]),
"(已夹取)" if r.get("capped") else ""))
```
- [ ] **Step 5: 编译 + 回归**
Run
```bash
python -m py_compile bl_bake.py model_bake.py
python -m unittest discover -s tests
```
Expected: 编译 exit 0;全部测试通过(本任务未改纯函数与既有测试)。
- [ ] **Step 6: 提交**
```bash
git add -- Tools/ModelTranslator/bl_bake.py Tools/ModelTranslator/model_bake.py
git commit -m "ModelTranslator: use adaptive ray distance in bake (auto), report source"
```
提交信息末尾附一行:`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`
---
### Task 3: 真机验证与文档
**Files:**
- Modify: `Tools/ModelTranslator/README.md`
- Create(临时诊断,验证后删除): `Tools/ModelTranslator/_backhit_diag.py`
Blender 路径:`D:/tools/Blender/blender-5.0.0-windows-x64/blender.exe`。需先有 supergirl quad 低模(`out_supergirl/supergirl_low.fbx`;若无,先 `python model_decimate.py "C:/Users/Administrator/Downloads/supergirl.fbx" --tris 10000 --reducer quad -o out_supergirl --blender <B>`)。
- [ ] **Step 1: 写临时穿透诊断脚本**
Create `Tools/ModelTranslator/_backhit_diag.py`
```python
"""诊断"射线穿透打到背面":对低模每个面投射烘焙射线,判断命中高模面正/背。
用法:blender -b --factory-startup --python _backhit_diag.py -- <high> <low> <ray>"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import bl_decimate as bd
def main():
import bpy
import mathutils
from mathutils.bvhtree import BVHTree
argv = sys.argv[sys.argv.index("--") + 1:]
high_path, low_path, ray = argv[0], argv[1], float(argv[2])
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=high_path)
high = bd.join_meshes([o for o in bpy.data.objects if o.type == 'MESH'])
bd._clean_mesh(high, []); bd._triangulate(high)
hv = [high.matrix_world @ v.co for v in high.data.vertices]
hp = [tuple(p.vertices) for p in high.data.polygons]
hn = [(high.matrix_world.to_3x3() @ p.normal).normalized() for p in high.data.polygons]
bvh = BVHTree.FromPolygons(hv, hp, all_triangles=True)
before = set(bpy.data.objects)
bpy.ops.import_scene.fbx(filepath=low_path)
low = [o for o in bpy.data.objects if o not in before and o.type == 'MESH'][0]
bpy.ops.object.select_all(action='DESELECT'); low.select_set(True)
bpy.context.view_layer.objects.active = low
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
front = back = miss = 0
for p in low.data.polygons:
c = low.matrix_world @ p.center
n = (low.matrix_world.to_3x3() @ p.normal).normalized()
hit = bvh.ray_cast(c + n * ray, -n, ray * 3.0)
if hit[0] is None:
miss += 1
elif hn[hit[2]].dot(n) >= 0:
front += 1
else:
back += 1
t = len(low.data.polygons)
print("BH ray=%.5f 正面=%.2f%% 背面(穿透)=%.2f%% 落空=%.2f%%"
% (ray, 100.0*front/t, 100.0*back/t, 100.0*miss/t))
main()
```
- [ ] **Step 2: 重烘 supergirl quad(自适应射线)并取射线值**
Run
```bash
python model_bake.py "C:/Users/Administrator/Downloads/supergirl.fbx" out_supergirl/supergirl_low.fbx -o out_sg_adaptive --blender "D:/tools/Blender/blender-5.0.0-windows-x64/blender.exe"
```
Expected:打印 `射线: 来源=adaptive P99=... 距离=...`;自适应距离**明显小于**旧固定值(低模对角线约 1.32,旧固定 ray=0.0264;自适应应在 0.001~0.01 量级)。记下打印的 `距离=<ray>` 值。
- [ ] **Step 3: 穿透率对比(旧固定 vs 新自适应)**
用 Step 1 脚本分别在旧固定射线 0.0264 与 Step 2 的自适应 ray 上诊断:
```bash
B="D:/tools/Blender/blender-5.0.0-windows-x64/blender.exe"
H="C:/Users/Administrator/Downloads/supergirl.fbx"
L="out_supergirl/supergirl_low.fbx"
"$B" -b --factory-startup --python _backhit_diag.py -- "$H" "$L" 0.0264 2>&1 | grep -a "BH"
"$B" -b --factory-startup --python _backhit_diag.py -- "$H" "$L" <Step2的自适应ray> 2>&1 | grep -a "BH"
```
Expected:固定 0.0264 时背面(穿透) ≈ 7%;自适应 ray 时**背面(穿透)降到近 0**(例如 <1%),落空不显著上升(<2%)。这证明脏斑成因被消除。
- [ ] **Step 4: 目视确认前裙脏斑消失**
打开 `out_sg_adaptive/supergirl/supergirl_color.png`,对照旧 `out_supergirl_bake/supergirl/supergirl_color.png`:红裙区域深色涂抹应消失或大幅减轻。
- [ ] **Step 5: collapse 回归 + 显式覆盖**
Run
```bash
python model_bake.py "C:/Users/Administrator/Downloads/supergirl.fbx" out_supergirl/supergirl_low.fbx -o out_sg_explicit --ray-distance 0.02 --blender "D:/tools/Blender/blender-5.0.0-windows-x64/blender.exe"
```
Expected:打印 `射线: 来源=explicit ... 距离=0.0200`;证明显式覆盖不测分离、行为不变。
- [ ] **Step 6: 更新 README**
`Tools/ModelTranslator/README.md``model_bake.py` 段落,把射线距离描述:
```markdown
`--size` 默认 2048`--samples` AO 采样默认 64,射线距离默认低模包围盒对角线 2%(`--ray-distance` 覆盖);
```
改为:
```markdown
`--size` 默认 2048`--samples` AO 采样默认 64**射线距离 `auto`(默认)按实测低↔高分离距离自适应**(分离 P99×1.5,夹在对角线 0.05%~1%),避免薄部件射线穿透打到背/内面产生深色脏斑——尤其 QR 四边低模偏离高模处;测量失败回退固定 2%,`--ray-distance` 显式覆盖;
```
- [ ] **Step 7: 删诊断脚本 + 最终验证**
Run
```bash
rm -f Tools/ModelTranslator/_backhit_diag.py
cd Tools/ModelTranslator && python -m py_compile bl_bake.py model_bake.py && python -m unittest discover -s tests
```
Expected:编译 exit 0;全部测试通过。
- [ ] **Step 8: 检查暂存 + 提交文档**
Run
```bash
git status --short
git diff --check
```
Expected`out_*`/`_backhit_diag.py` 未被 stage;用户脏文件未动。
```bash
git add -- Tools/ModelTranslator/README.md docs/superpowers/specs/2026-07-23-adaptive-ray-bake-design.md docs/superpowers/plans/2026-07-23-adaptive-ray-bake.md
git commit -m "ModelTranslator: document adaptive ray bake"
```
提交信息末尾附一行:`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`
---
## Self-Review 记录
- **Spec 覆盖**:纯函数 percentile/adaptive_ray_distanceTask1)、`_measure_separation`Task2 Step1)、main auto 自适应 + 回退(Task2 Step2)、MT_SUMMARY.rayTask2 Step3)、model_bake 打印(Task2 Step4)、显式覆盖不变(Task2 Step2 的 `!= "auto"` 分支)、真机穿透率验证(Task3)、READMETask3 Step6)——全覆盖。
- **占位符**:无 TBD/TODO;代码步骤含完整代码,Step3 的自适应 ray 值以"Step2 打印值"明确指代(真机运行时得到具体数)。
- **类型一致**`adaptive_ray_distance` 返回 `{distance_p99,value,capped}`Task1)与 main 消费 + 加 `source`Task2)、MT_SUMMARY 序列化(Task2 Step3)、model_bake 读取 `r["distance_p99"]/value/source/capped`Task2 Step4)一致;`percentile` 定义(Task1)被 `adaptive_ray_distance` 调用一致;`_measure_separation(low, high, limit)` 定义(Task2 Step1)与调用(Task2 Step2)一致;`low_dims` 为 main 既有 `tuple(low.dimensions)`