Files
openMVS/fill2sc/generate_fill_mesh.py
T
2026-11-09 11:42:05 +08:00

1824 lines
84 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
generate_fill_mesh.py
=====================
为 3D 打印模型生成"眼镜 <-> 脸部"之间镂空区域的填充网格。
输入(与本脚本同目录):
632723.obj 完整模型(封闭网格)
index.txt 眼镜区域的顶点索引(每行一个, 1-based)
输出(同目录):
fill_mesh.obj 填充网格(独立、封闭、水密)
632723_filled.obj 原模型 + 填充 合并后的模型(可直接用于切片打印)
fill_preview.png 前视图深度预览(供人工检查)
原理:
1. 栅格化眼镜区域三角形 -> 眼镜足迹 mask(补洞=镜片开口, 腐蚀收边)
2. 对足迹内每个 (x,y) 竖直射线求交:
镜框管前/后面, 脸面前表面, 网格最后表面
3. 填充体 = 高度场实体:
前表面 h_front: 贴住镜框前面(留 FLUSH 嵌入余量, 与镜框平齐),
镜片开口区域 = 框前面的稳健二次拟合趋势面
+ 拟合残差的调和延拓(交界处严格连续, 无折痕),
且不超过附近镜框前面(小半径扩散最大值钳制)
后表面 h_back : 有真实脸面处 = 脸面 - 嵌入深度(填满镂空),
无真实脸面处(误标区)不低于 hf - SHELL_MAX(填充只在镜框附近),
且不低于后脑表面
4. 足迹边界提取 -> 重采样 -> 闭合 Laplacian 平滑(外边界平滑)
5. 区域三角化: 耳切法 + 内部网格点逐点插入(拆分所在三角形,
拓扑上不可能翻转), 前后表面 + 侧墙组成水密封闭实体
6. 输出前做数值校验(水密/定向/无翻转/覆盖/嵌入深度/边界平滑度)
仅依赖 numpy 与 PIL。
"""
import sys
import numpy as np
from collections import deque
from PIL import Image, ImageDraw
# ----------------------------- 参数 -----------------------------
# 用法: python3 generate_fill_mesh.py [模型.obj] [眼镜顶点索引.txt]
# (缺省用下方默认值; 输出 fill_mesh.obj / <模型名>_filled.obj /
# fill_preview.png / fill_report.txt 写在当前目录)
IN_OBJ = sys.argv[1] if len(sys.argv) > 1 else "632723.obj"
IN_IDX = sys.argv[2] if len(sys.argv) > 2 else "index.txt"
OUT_FILL = "fill_mesh.obj"
OUT_MERGED = (IN_OBJ[:-4] if IN_OBJ.endswith(".obj") else IN_OBJ) + "_filled.obj"
OUT_PREVIEW = "fill_preview.png"
RES = 0.1 # 栅格分辨率
# 栅格窗口不写死(旧值 x[-10.2,10.2] y[9.2,19.8] 只适配 632723 的头部位置,
# 换模型会切掉镜圈 -> 镜片开口连通窗口边界 -> 检测不到开口 -> 镜片镂空不填):
# 由眼镜主分量 XY bbox 自动外扩推导, 见 face_g 提取之后
WMARG = 2.0 # 窗口边距(眼镜 bbox 外扩, 容纳足迹膨胀/边界平滑/背景)
RY_PAD = 0.5 # 填充区域 y 裁剪内缩(避开窗口边缘栅格化截断)
SEP = 0.25 # 区分前后表面的最小 z 间距
FLUSH = 0.05 # 前表面低于本地框面的余量(≈平齐; 局部平滑抬高=轻微凸出)
BULGE = 0.45 # 镜片弧面凸出幅值(抛物面, 中心最高, 边缘归零平滑衔接)
EMBED_BOT = 0.6 # 填充底面嵌入脸部深度
SHELL_MAX = 7.0 # 无真实脸面处厚度上限(透明支撑体, 尾部厚属预期)
FACE_MIN = 2.0 # 脸面低于此值视为误标伪面(后脑等), 不作填充底面
CLAMP_BACK = 0.4 # 距最后表面的安全距离(防穿出后脑)
GAP_MIN = 0.25 # 填充最小厚度
SIGMA = 2.0 # 高度场平滑 sigma(格)
ERODE = 3 # 足迹腐蚀(格) 0.3, 与膨胀0.3配合净外扩0(XY严格不超镜框轮廓)
BSTEP = 0.18 # 边界重采样步长
BITER = 25 # 边界 Laplacian 平滑迭代
BLAM = 0.6 # 边界平滑系数
TOL = 1e-4 # Jacobi 收敛阈值
GEO_MIN_COMP = 500 # 标记边连通主分量面数低于此值 -> 判定 index 无效, 切几何模式
FUSE_BLOB = 25 # 贴合脸面的标记格最大 8 连通团块格数 >= 此值 -> 疑似融合
FUSE_SKIN_F = 2500 # 且主分量中贴合皮肤面数 >= 此值(或占比>=10%) -> 判定真融合, 切几何模式
# (眼镜搭鼻上的轻接触 ~800 面/4-5% 无害: 该处填充前面=脸面高度,
# 标记模式天然兼容; 只有分割把大块皮肤涂进标记才需要几何兜底)
GRES = 0.2 # 几何预扫描分辨率(仅用于定位眼镜, 主栅格仍为 RES)
report = []
def log(s):
print(s)
report.append(str(s))
# ----------------------------- IO -----------------------------
def parse_obj(path):
vs, fs = [], []
with open(path) as f:
for line in f:
if line.startswith("v "):
p = line.split()
vs.append((float(p[1]), float(p[2]), float(p[3])))
elif line.startswith("f "):
p = line.split()
fs.append((int(p[1].split("/")[0]) - 1,
int(p[2].split("/")[0]) - 1,
int(p[3].split("/")[0]) - 1))
return np.asarray(vs), np.asarray(fs, dtype=np.int64)
log("== 1. 读入模型 ==")
V, F = parse_obj(IN_OBJ)
_idx_raw = np.loadtxt(IN_IDX, dtype=np.int64)
# index 基址自适应: 不同批次数据的 index 有 0 基(直接顶点编号)与 1 基
# (OBJ 行号)两种约定. 判据 = "3 顶点全标记面"数: 正确基址下标记落在
# 眼镜表面的连续顶点块上, 全标记面多; 错位基址把标记挪到邻近无关顶点
# (顶点编号与空间不相关时彻底散开, 如 20261102), 全标记面骤减.
def _full_marked(_ids):
_ids = _ids[(_ids >= 0) & (_ids < len(V))]
if len(_ids) == 0:
return 0
_m = np.zeros(len(V), bool)
_m[_ids] = True
return int((_m[F].sum(1) == 3).sum())
_n1 = _full_marked(_idx_raw - 1)
_n0 = _full_marked(_idx_raw)
if _n1 > _n0:
idx = _idx_raw - 1
log(f" 顶点 {len(V)}, 面 {len(F)}, 眼镜标记顶点 {len(_idx_raw)} "
f"(判 1 基: 全标记面 {_n1} > {_n0})")
else:
idx = _idx_raw
log(f" 顶点 {len(V)}, 面 {len(F)}, 眼镜标记顶点 {len(_idx_raw)} "
f"(判 0 基: 全标记面 {_n0} >= {_n1})")
idx = idx[(idx >= 0) & (idx < len(V))]
ism = np.zeros(len(V), bool)
ism[idx] = True
face_g = ism[F].any(1)
# 真眼镜物体提取: 标记面的"边连通"主分量. 眼镜与头部网格互不共边,
# 误标皮肤(鼻梁/额头/后脑, 分割噪声)在边连通下是独立小分量 -> 一次性剥离;
# 剥离后的皮肤面自然归入脸面栅格 fz(它们本来就是头部表面).
# 这样足迹/外边框直接来自真眼镜几何, 无需 2D 启发式清洗.
emap = {}
for _fi in np.where(face_g)[0]:
_a, _b, _c = F[_fi]
for _u, _v in ((_a, _b), (_b, _c), (_c, _a)):
_e = (_u, _v) if _u < _v else (_v, _u)
if _e in emap:
emap[_e].append(_fi)
else:
emap[_e] = [_fi]
_seen = set()
_best = []
for _fi in np.where(face_g)[0]:
if _fi in _seen:
continue
_comp, _stk = [], [_fi]
_seen.add(_fi)
while _stk:
_f0 = _stk.pop()
_comp.append(_f0)
_a, _b, _c = F[_f0]
for _u, _v in ((_a, _b), (_b, _c), (_c, _a)):
_e = (_u, _v) if _u < _v else (_v, _u)
for _f2 in emap[_e]:
if _f2 not in _seen:
_seen.add(_f2)
_stk.append(_f2)
if len(_comp) > len(_best):
_best = _comp
face_g = np.zeros(len(F), bool)
face_g[_best] = True
log(f" 眼镜物体 = 边连通主分量 {len(_best)} 面, "
f"剥离误标皮肤 {int((ism[F].any(1)).sum() - len(_best))} 面")
# ------------- 粗栅格管对扫描(几何兜底 + 融合检测共用) -------------
# 眼镜 = 悬浮在脸面前的"管状"结构. 沿 z 射线在 (x,y) 粗格内出现一对
# 相邻 hit(镜框前后两面, 间距 0.1~2.0), 且这对身后还有表面(落后>=0.15,
# 即脸) -> 管格; 全模型最前方的管格 8 连通块 = 镜圈/镜桥正面, 再沿
# 管带 8 连通生长带上镜腿/尾部折返段(它们自身也是管对, 身后是头部).
# 该扫描无条件执行, 服务两种用途:
# a) 几何兜底: index 无效(标记主分量 < GEO_MIN_COMP)或标记与脸融合
# 时, 眼镜面片 = 质心落在眼镜区域且 z 落在邻域(3x3 管格)管对
# 极值窗口 [min(zb)-0.1, max(zf)+0.15] 内: 检测间隙 0.15 > 窗口
# 余量 0.1, 结构上保证管身后的皮肤/脸面进不了窗口 -> 无接触泄漏
# (无需洪泛, 眼镜与头部共边也不受影响); 镜桥 pinch 格邻域无管格
# -> 窗口空, 不选面(由调和膜插值). 后续管线不变.
# b) 融合检测: 有些数据(如 20261102)的眼镜与脸部部分连在一起, 分割
# 把接触区皮肤整块标了进来 -> 主分量 = 眼镜+融合皮肤, 直接用会把
# 填充泄到脸上. 皮肤面就贴在脸上: 主分量覆盖格的 5x5 内非主分量
# 面最高 z(脸面参考, 排除比覆盖面低 4.0+ 的后脑面)与覆盖面最高 z
# 落差 <=0.15 = 贴合格; 纯模型只有眉梁/镜桥贴触细条(几个格),
# 融合模型的皮肤是大块贴合团 -> 最大 8 连通贴合格团块 >= FUSE_BLOB
# -> 判定融合, 切几何模式.
# 1) 全模型粗栅格, 每格收集全部 z hit
# 全身模型(躯干/手臂/手)会在身体上产生大量伪管对, 经闭运算污染
# 眼镜区域 -> 有有效标记主分量时把扫描限定在标记 bbox 外扩 3.0
# (覆盖标记漏标的框上缘 ~1.2 与镜尾折返段; 标记无效仍全模型扫描)
_gx0 = float(np.floor(V[:, 0].min() / GRES)) * GRES
_gx1 = float(np.ceil(V[:, 0].max() / GRES)) * GRES
_gy0 = float(np.floor(V[:, 1].min() / GRES)) * GRES
_gy1 = float(np.ceil(V[:, 1].max() / GRES)) * GRES
if len(_best) >= GEO_MIN_COMP:
_bv = V[np.unique(F[_best])]
_gx0 = max(_gx0, float(np.floor((_bv[:, 0].min() - 3.0) / GRES)) * GRES)
_gx1 = min(_gx1, float(np.ceil((_bv[:, 0].max() + 3.0) / GRES)) * GRES)
_gy0 = max(_gy0, float(np.floor((_bv[:, 1].min() - 3.0) / GRES)) * GRES)
_gy1 = min(_gy1, float(np.ceil((_bv[:, 1].max() + 3.0) / GRES)) * GRES)
log(f" 管格扫描限定于标记 bbox 外扩 3.0: "
f"x[{_gx0:.1f},{_gx1:.1f}] y[{_gy0:.1f},{_gy1:.1f}]")
_gw = int(round((_gx1 - _gx0) / GRES)) + 1
_gh = int(round((_gy1 - _gy0) / GRES)) + 1
_ghit = [[[] for _ in range(_gw)] for _ in range(_gh)]
for _t in range(len(F)):
_a, _b, _c = F[_t]
_ax, _ay, _az = V[_a]
_bx, _by, _bz2 = V[_b]
_cx, _cy, _cz2 = V[_c]
_det = (_bx - _ax) * (_cy - _ay) - (_by - _ay) * (_cx - _ax)
if _det == 0:
continue
_i0 = int((min(_ax, _bx, _cx) - _gx0) / GRES)
_i1 = int((max(_ax, _bx, _cx) - _gx0) / GRES)
_j0 = int((min(_ay, _by, _cy) - _gy0) / GRES)
_j1 = int((max(_ay, _by, _cy) - _gy0) / GRES)
if _i1 < 0 or _j1 < 0 or _i0 >= _gw or _j0 >= _gh:
continue # 限定扫描范围外的面
_i0 = max(_i0, 0)
_j0 = max(_j0, 0)
_i1 = min(_i1, _gw - 1)
_j1 = min(_j1, _gh - 1)
for _j in range(_j0, _j1 + 1):
_py = _gy0 + (_j + 0.5) * GRES
for _i in range(_i0, _i1 + 1):
_px = _gx0 + (_i + 0.5) * GRES
_u = ((_px - _ax) * (_cy - _ay)
- (_py - _ay) * (_cx - _ax)) / _det
if _u < -1e-9:
continue
_v = ((_bx - _ax) * (_py - _ay)
- (_by - _ay) * (_px - _ax)) / _det
if _v < -1e-9 or _u + _v > 1 + 1e-9:
continue
_ghit[_j][_i].append(
_az + _u * (_bz2 - _az) + _v * (_cz2 - _az))
# 2) 管对检测: 从最前一对向后找第一个满足
# (对间距 0.1~2.0 且身后还有表面落后>=0.15) 的悬浮对
_tube = np.zeros((_gh, _gw), bool)
_gzf = np.full((_gh, _gw), -np.inf) # 管前面 z
_gzb = np.full((_gh, _gw), -np.inf) # 管背面 z
for _j in range(_gh):
for _i in range(_gw):
_zs = sorted(_ghit[_j][_i])
if len(_zs) < 3:
continue
for _k in range(len(_zs) - 2, 0, -1):
_dz = _zs[_k + 1] - _zs[_k]
if 0.1 <= _dz <= 2.0 and _zs[_k - 1] <= _zs[_k] - 0.15:
_tube[_j, _i] = True
_gzf[_j, _i] = _zs[_k + 1]
_gzb[_j, _i] = _zs[_k]
break
# 3) 管带闭运算连通: 镜桥压在鼻上无悬浮间隙 -> 无管格, 两镜圈管带
# 相距 ~1.4; 尾部折返段与镜圈相距 ~0.5-1.0. 圆盘闭运算(半径 1.0,
# 填 <=2.0 的 pinch, 不外扩外轮廓)把镜圈/镜桥带/尾部并成一个
# 连通域; 头发/手等杂散管 >=3.2 不会并入. 取含全局最前管格的
# 连通域 = 眼镜区域(管格 + 镜桥 pinch 填充)
_offs = [(_dj, _di) for _dj in range(-5, 6) for _di in range(-5, 6)
if _dj * _dj + _di * _di <= 25]
_td = np.zeros_like(_tube)
for _dj, _di in _offs:
_td |= np.roll(_tube, (_dj, _di), (0, 1))
_tc = np.ones_like(_tube)
for _dj, _di in _offs:
_tc &= np.roll(_td, (_dj, _di), (0, 1))
# 有效标记时: 种子取标记足迹(外扩 2.0)内最前的管格(全身模型上头发/
# 耳朵伪管可能比镜框更前), 且区域生长完成后与标记足迹外扩 2.0 相交,
# 排除远处伪管(头发距标记中位 2.8+)并入 -> 窗口选面污染
_mfootd = None
if len(_best) >= GEO_MIN_COMP:
_mv = V[np.unique(F[_best])]
_mf = np.zeros((_gh, _gw), bool)
_mi = np.clip(((_mv[:, 0] - _gx0) / GRES).astype(int), 0, _gw - 1)
_mj = np.clip(((_mv[:, 1] - _gy0) / GRES).astype(int), 0, _gh - 1)
_mf[_mj, _mi] = True
_mfootd = _mf.copy()
for _ in range(10): # 外扩 2.0 单位
_g = _mfootd.copy()
for _dj, _di in ((1, 0), (-1, 0), (0, 1), (0, -1)):
_g |= np.roll(_mfootd, (_dj, _di), (0, 1))
_mfootd = _g
_tt = _tube & _mfootd
else:
_tt = _tube
if _tt.any():
_jj, _ii = np.unravel_index(
np.argmax(np.where(_tt, _gzf, -np.inf)), _gzf.shape)
else:
_jj, _ii = np.unravel_index(
np.argmax(np.where(_tube, _gzf, -np.inf)), _gzf.shape)
_region = np.zeros_like(_tube)
_region[_jj, _ii] = True
_stk = [(_jj, _ii)]
while _stk:
_y, _x = _stk.pop()
for _dy in (-1, 0, 1):
for _dx in (-1, 0, 1):
_ny, _nx = _y + _dy, _x + _dx
if (0 <= _ny < _gh and 0 <= _nx < _gw
and _tc[_ny, _nx] and not _region[_ny, _nx]):
_region[_ny, _nx] = True
_stk.append((_ny, _nx))
if _mfootd is not None:
_region &= _mfootd
_ry, _rx = np.where(_region)
log(f" 管格 {int(_tube.sum())}, 眼镜区域(闭运算) {int(_region.sum())} 格 "
f"x[{_gx0 + _rx.min() * GRES:.1f},{_gx0 + _rx.max() * GRES:.1f}] "
f"y[{_gy0 + _ry.min() * GRES:.1f},{_gy0 + _ry.max() * GRES:.1f}], "
f"管前面 z 顶 {_gzf[_jj, _ii]:.2f}")
# ---- 融合检测: 标记主分量是否混入贴合脸面的皮肤 ----
# 皮肤面就贴在脸上 -> 与周围脸面(非主分量面)同高(落差 |dz|<=0.15);
# 眼镜面悬浮在脸前(正落差), 镜尾折返面低于头侧表面(大负落差) ->
# 都不是贴合. 贴合格聚成大 8 连通团块 = 融合皮肤(纯模型只有眉梁/
# 镜桥/轻度皮肤 fringe 的贴触细条, 十几个格).
_fused = False
if len(_best) >= GEO_MIN_COMP and _tube.any():
_fcs = V[F[_best]].mean(1)
_fci = np.clip(((_fcs[:, 0] - _gx0) / GRES).astype(int), 0, _gw - 1)
_fcj = np.clip(((_fcs[:, 1] - _gy0) / GRES).astype(int), 0, _gh - 1)
_fcc = np.zeros((_gh, _gw), bool)
_fcc[_fcj, _fci] = True
_fcz = np.full((_gh, _gw), -np.inf)
np.maximum.at(_fcz, (_fcj, _fci), _fcs[:, 2])
# 脸面参考: 非主分量面质心的每格最高 z, 逐格排除比覆盖面低 4.0+
# 的后脑面, 7x7 邻域传播(皮肤块深处的参考取自块外脸面的延续)
_allc = V[F].mean(1)
_aci = np.clip(((_allc[:, 0] - _gx0) / GRES).astype(int), 0, _gw - 1)
_acj = np.clip(((_allc[:, 1] - _gy0) / GRES).astype(int), 0, _gh - 1)
_nc = ~face_g
_nz = np.full((_gh, _gw), -np.inf)
np.maximum.at(_nz, (_acj[_nc], _aci[_nc]), _allc[_nc, 2])
_nv = np.where(_nz > _fcz - 4.0, _nz, -np.inf)
_nzp = _nv.copy()
for _dj in range(-3, 4):
for _di in range(-3, 4):
_nzp = np.maximum(_nzp, np.roll(_nv, (_dj, _di), (0, 1)))
_nzq = np.where(_nzp > -np.inf, _nzp, -1e9)
_att = _fcc & (np.abs(_fcz - _nzq) <= 0.15)
_skinf = int(_att[_fcj, _fci].sum())
_avis = set(map(tuple, np.argwhere(_att)))
_blobmax = 0
while _avis:
_seed = _avis.pop()
_stk2, _sz = [_seed], 0
while _stk2:
_cj2, _ci2 = _stk2.pop()
_sz += 1
for _dy in (-1, 0, 1):
for _dx in (-1, 0, 1):
_nb = (_cj2 + _dy, _ci2 + _dx)
if _nb in _avis:
_avis.remove(_nb)
_stk2.append(_nb)
_blobmax = max(_blobmax, _sz)
# 融合 = 大块贴合团 且 皮肤面量大(绝对或占比). 只有团块时可能只是
# 鼻托轻接触(如 617892: 团 26/皮肤 755 面 4.5%), 标记模式即可;
# 真融合(20261102: 团 38/皮肤 5000+ 面 17.8%)才切几何模式.
_fused = (_blobmax >= FUSE_BLOB
and (_skinf >= FUSE_SKIN_F or _skinf >= 0.10 * len(_best)))
log(f" 融合检测: 主分量覆盖 {int(_fcc.sum())} 格, 贴合格 "
f"{int(_att.sum())}, 最大贴合团块 {_blobmax} 格 (阈值 {FUSE_BLOB}), "
f"贴合皮肤面 {_skinf}/{len(_best)}"
f" -> {'融合, 切几何模式' if _fused else '标记纯净(轻接触无害), 走标记模式'}")
# ---- 路由: index 无效 / 标记与脸融合 -> 几何模式; 否则标记模式 ----
if len(_best) < GEO_MIN_COMP:
log(f"! 标记主分量仅 {len(_best)} 面 (<{GEO_MIN_COMP}), index 无效"
f" -> 切换几何检测模式")
elif _fused:
log("! 标记与脸融合(大块贴合皮肤团) -> 切换几何检测模式")
if len(_best) < GEO_MIN_COMP or _fused:
if not _tube.any():
raise SystemExit("几何模式: 未找到任何悬浮管对, 无法定位眼镜")
# 4) 眼镜面片 = 质心落在眼镜区域内, 且 z 落在邻域(3x3 管格)管对
# 极值窗口 [min(zb)-0.1, max(zf)+0.15] 内.
# 单格 ±0.1 窗口会漏选大量管面(陡斜管壁上质心与格中心 z 错位>0.1)
# -> face_g 覆盖稀疏 -> 环角/尾部出现大片调和膜口袋, 前表面
# 斜降到头后穿出. 邻域极值跟随管壁倾斜; 皮肤至少低于每个管背
# 0.15(检测间隙), 也低于邻域 min(zb), -0.1 余量仍保证不吸皮肤.
# 镜桥 pinch 格邻域无管格 -> 窗口空, 不选面(由调和膜插值)
_gzb3 = np.full((_gh, _gw), np.inf)
_gzf3 = np.full((_gh, _gw), -np.inf)
for _dj in (-1, 0, 1):
for _di in (-1, 0, 1):
_gzb3 = np.minimum(
_gzb3, np.roll(np.where(_tube, _gzb, np.inf), (_dj, _di), (0, 1)))
_gzf3 = np.maximum(
_gzf3, np.roll(np.where(_tube, _gzf, -np.inf), (_dj, _di), (0, 1)))
_gzlo = np.where(_region, _gzb3 - 0.1, np.inf)
_gzhi = np.where(_region, _gzf3 + 0.15, -np.inf)
_gfc = V[F].mean(1)
_gci = np.clip(((_gfc[:, 0] - _gx0) / GRES).astype(int), 0, _gw - 1)
_gcj = np.clip(((_gfc[:, 1] - _gy0) / GRES).astype(int), 0, _gh - 1)
_gcz = _gfc[:, 2]
_gsel = (_gcz >= _gzlo[_gcj, _gci]) & (_gcz <= _gzhi[_gcj, _gci])
_gfaces = np.where(_gsel)[0]
if not 300 <= len(_gfaces) <= 60000:
raise SystemExit(
f"几何模式: 管对窗口选出 {len(_gfaces)} 面, 超出合理范围"
f" [300, 60000], 定位失败")
face_g = np.zeros(len(F), bool)
face_g[_gfaces] = True
_gvz = V[np.unique(F[_gfaces])]
log(f" 几何模式选定眼镜面片: {len(_gfaces)} 面, "
f"bbox x[{_gvz[:,0].min():.1f},{_gvz[:,0].max():.1f}] "
f"y[{_gvz[:,1].min():.1f},{_gvz[:,1].max():.1f}] "
f"z[{_gvz[:,2].min():.1f},{_gvz[:,2].max():.1f}]")
_GEO_MODE = True
_GEO_FOOT = _region
else:
_GEO_MODE = False
# 栅格窗口自动推导: 眼镜主分量顶点 XY bbox 外扩 WMARG(对齐 RES),
# 任何模型/任何头部位置通用
_gv = V[np.unique(F[face_g])]
X0 = float(np.floor((_gv[:, 0].min() - WMARG) / RES)) * RES
X1 = float(np.ceil((_gv[:, 0].max() + WMARG) / RES)) * RES
Y0 = float(np.floor((_gv[:, 1].min() - WMARG) / RES)) * RES
Y1 = float(np.ceil((_gv[:, 1].max() + WMARG) / RES)) * RES
RY0, RY1 = Y0 + RY_PAD, Y1 - RY_PAD # 填充区域 y 裁剪(只处理正面眼镜部分)
log(f" 窗口自动推导: x[{X0:.1f},{X1:.1f}] y[{Y0:.1f},{Y1:.1f}] "
f"(眼镜 bbox x[{_gv[:,0].min():.1f},{_gv[:,0].max():.1f}] "
f"y[{_gv[:,1].min():.1f},{_gv[:,1].max():.1f}] + {WMARG} 边距)")
# ----------------------------- 栅格 -----------------------------
W = int(round((X1 - X0) / RES)) + 1
H = int(round((Y1 - Y0) / RES)) + 1
log(f"== 2. 栅格化 (窗口 x[{X0},{X1}] y[{Y0},{Y1}], res={RES}, {W}x{H}) ==")
gz1 = np.full((H, W), -np.inf) # 眼镜最前表面
gz2 = np.full((H, W), -np.inf) # 眼镜第二表面(镜框管背面)
fz = np.full((H, W), -np.inf) # 非眼镜最前表面(脸)
bz = np.full((H, W), np.inf) # 全网格最后表面(后脑)
mvert = np.zeros((H, W), np.int32) # 眼镜顶点计数
yi = np.clip(((V[ism, 1] - Y0) / RES).astype(int), 0, H - 1)
xi = np.clip(((V[ism, 0] - X0) / RES).astype(int), 0, W - 1)
np.add.at(mvert, (yi, xi), 1)
inwin = ((V[:, 0] > X0 - 0.5) & (V[:, 0] < X1 + 0.5) &
(V[:, 1] > Y0 - 0.5) & (V[:, 1] < Y1 + 0.5))
fwin = inwin[F].all(1)
log(f" 窗口内三角形 {int(fwin.sum())} (其中眼镜 {(face_g & fwin).sum()})")
A, B, C = V[F[:, 0]], V[F[:, 1]], V[F[:, 2]]
fi = np.where(fwin)[0]
for t in fi:
ax, ay, az = A[t]; bx, by, bz_ = B[t]; cx, cy, cz = C[t]
g = face_g[t]
i0 = max(int((min(ax, bx, cx) - X0) / RES), 0)
i1 = min(int((max(ax, bx, cx) - X0) / RES), W - 1)
j0 = max(int((min(ay, by, cy) - Y0) / RES), 0)
j1 = min(int((max(ay, by, cy) - Y0) / RES), H - 1)
if i1 < i0 or j1 < j0:
continue
v0x, v0y = cx - ax, cy - ay
v1x, v1y = bx - ax, by - ay
d00 = v0x * v0x + v0y * v0y
d01 = v0x * v1x + v0y * v1y
d11 = v1x * v1x + v1y * v1y
den = d00 * d11 - d01 * d01
if abs(den) < 1e-14:
continue
for j in range(j0, j1 + 1):
py = Y0 + j * RES
for i in range(i0, i1 + 1):
px = X0 + i * RES
v2x, v2y = px - ax, py - ay
d02 = v0x * v2x + v0y * v2y
d12 = v1x * v2x + v1y * v2y
u = (d11 * d02 - d01 * d12) / den # 权重 C
v = (d00 * d12 - d01 * d02) / den # 权重 B
if u < 0 or v < 0 or u + v > 1:
continue
z = az + u * (cz - az) + v * (bz_ - az)
if g:
if z > gz1[j, i] + 1e-9:
if z - gz1[j, i] > SEP and gz1[j, i] > -np.inf:
gz2[j, i] = max(gz2[j, i], gz1[j, i])
gz1[j, i] = z
elif z < gz1[j, i] - SEP and z > gz2[j, i]:
gz2[j, i] = z
else:
if z > fz[j, i]:
fz[j, i] = z
if z < bz[j, i]:
bz[j, i] = z
have_g1 = np.isfinite(gz1) & (gz1 > -1e30)
have_g2 = np.isfinite(gz2) & (gz2 > -1e30)
have_f = np.isfinite(fz) & (fz > -1e30)
have_b = np.isfinite(bz) & (bz < 1e30)
# 眼镜表面场: face_g 已是边连通主分量(真眼镜物体), 无需再按 z 剪杂散.
# 镜圈尾部向耳朵方向包裹折返(最高面 z 从 9.1 一路降到 0.5),
# 这些格必须进入足迹/框带, 否则尾部下镂空填不上(出现缝隙)
g1f = have_g1
log(f" 有眼镜表面格 {have_g1.sum()}, 管状(双面) {have_g2.sum()}, "
f"有脸面 {have_f.sum()}, 有背面 {have_b.sum()}")
# ----------------------- 足迹 mask 与填充区域 R -----------------------
log("== 3. 眼镜足迹 / 填充区域 R ==")
# 足迹 = 真眼镜物体(边连通主分量)覆盖的全部格子(含尾部包裹段).
# 误标杂散已在 face_g 阶段剥离, 无需 z 高度过滤; 旧 gz1>=7 过滤会剪掉
# 尾部折返带(z 低于 7) -> 尾部镂空填不上
if _GEO_MODE:
# 几何模式: 足迹 = 眼镜区域(管带闭运算, 已含镜桥 pinch 填充)上采样.
# 桥位格无眼镜面片 -> 无 gz1, 与镜片开口一样由调和膜插值前表面
mask = np.zeros((H, W), bool)
for _j, _i in zip(*np.where(_GEO_FOOT)):
_mj = int(np.floor(((_gy0 + _j * GRES) - Y0) / RES + 1e-9))
_mi = int(np.floor(((_gx0 + _i * GRES) - X0) / RES + 1e-9))
for _dj in (0, 1):
for _di in (0, 1):
_yy, _xx = _mj + _dj, _mi + _di
if 0 <= _yy < H and 0 <= _xx < W:
mask[_yy, _xx] = True
else:
mask = g1f
_labs = np.zeros(mask.shape, np.int32)
_cur = 0
from collections import deque as _dq
for _j in range(mask.shape[0]):
for _i in range(mask.shape[1]):
if mask[_j, _i] and _labs[_j, _i] == 0:
_cur += 1
_q = _dq([(_j, _i)])
_labs[_j, _i] = _cur
while _q:
_y, _x = _q.popleft()
for _dj, _di in ((1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)):
_yy, _xx = _y + _dj, _x + _di
if (0 <= _yy < mask.shape[0] and 0 <= _xx < mask.shape[1]
and mask[_yy, _xx] and _labs[_yy, _xx] == 0):
_labs[_yy, _xx] = _cur
_q.append((_yy, _xx))
_sizes = np.bincount(_labs.ravel())
_sizes[0] = 0
_main = int(np.argmax(_sizes))
_nstray = int(mask.sum() - _sizes[_main])
mask = _labs == _main
log(f" 足迹取主分量: 剔除杂散块 {_nstray} 格 (误标到镜框外的皮肤/后脑)")
# 中央区剪除(鼻侧多余填充的根源): 镜框外边框在鼻侧 = 镜桥底缘.
# 镜片开口(足迹的大内部洞)之间的中央区里, 开口底缘以下的"眼镜覆盖"
# 是误标鼻面皮肤悬挂半岛 -> 剪掉, 填充严格按镜框外边框
def _flood_out(msk):
out = np.zeros_like(msk, bool)
q = _dq()
for i in range(msk.shape[1]):
for j in (0, msk.shape[0] - 1):
if not msk[j, i] and not out[j, i]:
out[j, i] = True; q.append((j, i))
for j in range(msk.shape[0]):
for i in (0, msk.shape[1] - 1):
if not msk[j, i] and not out[j, i]:
out[j, i] = True; q.append((j, i))
while q:
y, x = q.popleft()
for dj, di in ((1,0),(-1,0),(0,1),(0,-1)):
yy, xx = y + dj, x + di
if 0 <= yy < msk.shape[0] and 0 <= xx < msk.shape[1] and not msk[yy, xx] and not out[yy, xx]:
out[yy, xx] = True; q.append((yy, xx))
return out
_holes = ~mask & ~_flood_out(mask)
def _label_comps(msk):
"""8 连通分量标注, 返回所有分量的格点列表(按扫描顺序)"""
_lab = np.zeros(msk.shape, np.int32)
_comps = []
for _j in range(msk.shape[0]):
for _i in range(msk.shape[1]):
if msk[_j, _i] and _lab[_j, _i] == 0:
_lab[_j, _i] = 1
_q = _dq([(_j, _i)])
_pts = [(_j, _i)]
while _q:
_y, _x = _q.popleft()
for _dj, _di in ((1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)):
_yy, _xx = _y + _dj, _x + _di
if (0 <= _yy < msk.shape[0] and 0 <= _xx < msk.shape[1]
and msk[_yy, _xx] and _lab[_yy, _xx] == 0):
_lab[_yy, _xx] = 1
_q.append((_yy, _xx))
_pts.append((_yy, _xx))
_comps.append(_pts)
return _comps
_comps = _label_comps(_holes)
_hsz = [len(_p) for _p in _comps]
_big = [_p for _p in _comps if len(_p) >= 250]
# 镜片开口掩码(供后面镜片光滑度校验区域使用, 不再写死坐标):
# 取两大开口洞并腐蚀 2 格(0.2) -> 只留镜片膜主体, 自动排除镜圈/
# 镜腿折叠处的陡降与狭缝袋(洞 bbox 会经狭缝袋延伸到镜圈外侧)
lens_boxes = []
lens_mask = np.zeros(mask.shape, bool)
for _pts in _big[:2]:
_js = [p[0] for p in _pts]; _is = [p[1] for p in _pts]
lens_boxes.append((X0 + min(_is) * RES, X0 + max(_is) * RES,
Y0 + min(_js) * RES, Y0 + max(_js) * RES))
for p in _pts:
lens_mask[p[0], p[1]] = True
for _ in range(2):
_nm = lens_mask.copy()
_nm[1:, :] &= lens_mask[:-1, :]; _nm[:-1, :] &= lens_mask[1:, :]
_nm[:, 1:] &= lens_mask[:, :-1]; _nm[:, :-1] &= lens_mask[:, 1:]
lens_mask = _nm
if lens_boxes:
log(" 镜片开口框: " + "; ".join(
"x[%.1f,%.1f] y[%.1f,%.1f]" % b for b in lens_boxes))
if len(_big) >= 2:
# 按 x 中心排序, 区分左右开口
_big.sort(key=len, reverse=True)
h_l, h_r = sorted(_big[:2], key=lambda pts: sum(p[1] for p in pts) / len(pts))
# 每个开口的 x 范围与底缘 y
def _bbox(pts):
return (min(p[1] for p in pts), max(p[1] for p in pts), min(p[0] for p in pts))
bl, br = _bbox(h_l), _bbox(h_r)
x_in_l = bl[1] # 左开口右缘(内缘)
x_in_r = br[0] # 右开口左缘(内缘)
y_cut = min(bl[2], br[2]) # 开口底缘(取较低者)
if x_in_l < x_in_r:
_cut = np.zeros_like(mask, bool)
_cut[:y_cut, x_in_l + 1:x_in_r] = True
_ncut = int((mask & _cut).sum())
mask &= ~_cut
log(f" 中央区剪除(两镜片开口之间, 开口底缘 y>{Y0 + y_cut * RES:.1f} 以下): "
f"剔除 {_ncut} 格误标鼻面")
else:
log(" ! 未找到两个镜片开口, 跳过中央区剪除"
+ (f"; 洞分量尺寸: {sorted(_hsz, reverse=True)[:8]}" if _hsz else "; 无内部洞(开口与外部连通)"))
# 膨胀 3 格(~0.3) 闭合采样缝隙, 防止镜片开口与外部连通;
# 与腐蚀 3 格(0.3) 配合净外扩 0: 边界平滑后落在栅格足迹内,
# XY 严格不超镜框轮廓(真外轮廓比栅格覆盖宽~0.1-0.2)
for _ in range(3):
nm = mask.copy()
nm[1:, :] |= mask[:-1, :]; nm[:-1, :] |= mask[1:, :]
nm[:, 1:] |= mask[:, :-1]; nm[:, :-1] |= mask[:, 1:]
nm[1:, 1:] |= mask[:-1, :-1]; nm[:-1, :-1] |= mask[1:, 1:]
nm[1:, :-1] |= mask[:-1, 1:]; nm[:-1, 1:] |= mask[1:, :-1]
mask = nm
def flood_outside(mask):
"""从窗口边界洪泛, 标记外部; 未标记且非 mask 即为洞"""
outside = np.zeros_like(mask, bool)
dq = deque()
for i in range(mask.shape[1]):
for j in (0, mask.shape[0] - 1):
if not mask[j, i] and not outside[j, i]:
outside[j, i] = True; dq.append((j, i))
for j in range(mask.shape[0]):
for i in (0, mask.shape[1] - 1):
if not mask[j, i] and not outside[j, i]:
outside[j, i] = True; dq.append((j, i))
while dq:
j, i = dq.popleft()
for dj, di in ((1,0),(-1,0),(0,1),(0,-1)):
nj, ni = j + dj, i + di
if 0 <= nj < mask.shape[0] and 0 <= ni < mask.shape[1]:
if not mask[nj, ni] and not outside[nj, ni]:
outside[nj, ni] = True; dq.append((nj, ni))
return ~outside & ~mask
# 几何模式: 镜圈内上角管对检测失败的楔形缺口把镜片开口与外部连通,
# flood 找不到开口 -> 开口镂空不填. 对已膨胀掩码再做 0.5 闭运算(仅用于
# 洞检测, 掩码本身不动, 外轮廓逻辑不变): 封闭 <=1.0 的缺口, 两镜片开口
# 成为内部洞 -> 正常补上(开口边界仅角部圆化 <=0.5). marks 模式掩码为
# 密集覆盖, 无此问题
if _GEO_MODE:
_clm = mask.copy()
for _ in range(5):
_nm = _clm.copy()
_nm[1:, :] |= _clm[:-1, :]; _nm[:-1, :] |= _clm[1:, :]
_nm[:, 1:] |= _clm[:, :-1]; _nm[:, :-1] |= _clm[:, 1:]
_nm[1:, 1:] |= _clm[:-1, :-1]; _nm[:-1, :-1] |= _clm[1:, 1:]
_nm[1:, :-1] |= _clm[:-1, 1:]; _nm[:-1, 1:] |= _clm[1:, :-1]
_clm = _nm
for _ in range(5):
_nm = _clm.copy()
_nm[1:, :] &= _clm[:-1, :]; _nm[:-1, :] &= _clm[1:, :]
_nm[:, 1:] &= _clm[:, :-1]; _nm[:, :-1] &= _clm[:, 1:]
_nm[1:, 1:] &= _clm[:-1, :-1]; _nm[:-1, :-1] &= _clm[1:, 1:]
_nm[1:, :-1] &= _clm[:-1, 1:]; _nm[:-1, 1:] &= _clm[1:, :-1]
_clm = _nm
holes = flood_outside(_clm)
else:
holes = flood_outside(mask)
log(f" 足迹 {mask.sum()} 格, 补内部洞 {holes.sum()} 格 (镜片开口)")
mask = mask | holes
# 镜片开口检测兜底(所有模式): 原始掩码上开口可能经细通道(镜圈栅格
# 缝隙/真实镜片面片打断)与外部连通 -> 洪泛把它当外部, 开口漏检 ->
# 该区无 lens_mask 豁免, 被穿皮钳压到眼面(填充凹陷). 在膨胀掩码
# (缝隙已闭合)的洞上重检测补齐
if len(lens_boxes) < 2:
_comps2 = _label_comps(holes)
_big2 = sorted((_p for _p in _comps2 if len(_p) >= 250), key=len, reverse=True)
if _big2:
for _pts in _big2[:2]:
# 跳过已检出的开口(与现有 lens_mask 有重叠的洞)
_ov = 0
for p in _pts:
if lens_mask[p[0], p[1]]:
_ov += 1
if _ov > len(_pts) // 3:
continue
_js = [p[0] for p in _pts]; _is = [p[1] for p in _pts]
lens_boxes.append((X0 + min(_is) * RES, X0 + max(_is) * RES,
Y0 + min(_js) * RES, Y0 + max(_js) * RES))
for p in _pts:
lens_mask[p[0], p[1]] = True
for _ in range(2):
_nm = lens_mask.copy()
_nm[1:, :] &= lens_mask[:-1, :]; _nm[:-1, :] &= lens_mask[1:, :]
_nm[:, 1:] &= lens_mask[:, :-1]; _nm[:, :-1] &= lens_mask[:, 1:]
lens_mask = _nm
log(" 镜片开口兜底(膨胀后重检测): "
+ ("; ".join("x[%.1f,%.1f] y[%.1f,%.1f]" % b for b in lens_boxes)
+ "; 洞分量尺寸: " + str(sorted((len(_p) for _p in _comps2), reverse=True)[:8])
if lens_boxes else
"未检出; 洞分量尺寸: " + str(sorted((len(_p) for _p in _comps2), reverse=True)[:8])))
# 腐蚀(膨胀0.3 + 腐蚀0.3 => 净外扩0, 闭运算): 边界平滑收缩后
# 仍落在栅格足迹内, XY 不超镜框轮廓
er = mask.copy()
for _ in range(ERODE):
ne = er.copy()
ne[1:, :] &= er[:-1, :]; ne[:-1, :] &= er[1:, :]
ne[:, 1:] &= er[:, :-1]; ne[:, :-1] &= er[:, 1:]
er = ne
# y 裁剪 + 最大连通块
jj = np.arange(H)
er = er & (jj[:, None] >= int((RY0 - Y0) / RES)) & (jj[:, None] <= int((RY1 - Y0) / RES))
R = np.zeros_like(er)
seen = np.zeros_like(er, bool)
best = None
for j0 in range(H):
for i0 in range(W):
if er[j0, i0] and not seen[j0, i0]:
comp = []
dq = deque([(j0, i0)])
seen[j0, i0] = True
touch = False
while dq:
j, i = dq.popleft(); comp.append((j, i))
if i in (0, W - 1) or j in (0, H - 1):
touch = True
for dj, di in ((1,0),(-1,0),(0,1),(0,-1)):
nj, ni = j + dj, i + di
if 0 <= nj < H and 0 <= ni < W and er[nj, ni] and not seen[nj, ni]:
seen[nj, ni] = True; dq.append((nj, ni))
if best is None or len(comp) > len(best[0]):
best = (comp, touch)
if best is None:
raise SystemExit("R 为空: 足迹检测失败")
comp, touch = best
for j, i in comp:
R[j, i] = True
log(f" R: {R.sum()} 格 ({R.sum()*RES*RES:.2f} 面积单位), "
f"触碰窗口边界={touch} (触碰=侧墙切在头内部, 正常)")
if R.sum() < 200:
raise SystemExit("R 过小")
# ---- R 裁剪到眼镜真实轮廓(填充严格不超镜框范围) ----
# 上面的区域构造会外扩: 几何模式粗栅格闭运算外扩 ~1 单位; 标记模式
# 开口泄漏通道(镜圈栅格缝隙 0.4-0.6)让开口绕过洞检测直接并入.
# 轮廓 = dilate(g1f, 0.6) 填内部洞后整体腐蚀 0.6(闭运算, 作用在填
# 洞后的实心区域上: 薄框带存活、开口为内部无边界、外边界光滑),
# 再并入镜片开口区(脚本自身开口检测的 lens_mask, 应对框环上角
# 选面失败的楔形缺口>0.6 时洪泛泄漏). 闭运算是扩展运算, 轮廓必含
# 全部覆盖格(镜腿等细结构不裁), XY 超出眼镜覆盖 <= 0.2.
_sil = g1f.copy()
for _ in range(6):
_sm = _sil.copy()
_sm[1:, :] |= _sil[:-1, :]; _sm[:-1, :] |= _sil[1:, :]
_sm[:, 1:] |= _sil[:, :-1]; _sm[:, :-1] |= _sil[:, 1:]
_sm[1:, 1:] |= _sil[:-1, :-1]; _sm[:-1, :-1] |= _sil[1:, 1:]
_sm[1:, :-1] |= _sil[:-1, 1:]; _sm[:-1, 1:] |= _sil[1:, :-1]
_sil = _sm
_sil = _sil | flood_outside(_sil) # 填内部洞(镜片开口) -> 实心轮廓
for _ in range(6):
_sm = _sil.copy()
_sm[1:, :] &= _sil[:-1, :]; _sm[:-1, :] &= _sil[1:, :]
_sm[:, 1:] &= _sil[:, :-1]; _sm[:, :-1] &= _sil[:, 1:]
_sm[1:, 1:] &= _sil[:-1, :-1]; _sm[:-1, :-1] &= _sil[1:, 1:]
_sm[1:, :-1] &= _sil[:-1, 1:]; _sm[:-1, 1:] &= _sil[1:, :-1]
_sil = _sm
_lexr = lens_mask.copy()
for _ in range(2):
_sm = _lexr.copy()
_sm[1:, :] |= _lexr[:-1, :]; _sm[:-1, :] |= _lexr[1:, :]
_sm[:, 1:] |= _lexr[:, :-1]; _sm[:, :-1] |= _lexr[:, 1:]
_sm[1:, 1:] |= _lexr[:-1, :-1]; _sm[:-1, :-1] |= _lexr[1:, 1:]
_sm[1:, :-1] |= _lexr[:-1, 1:]; _sm[:-1, 1:] |= _lexr[1:, :-1]
_lexr = _sm
_sil = _sil | _lexr
_nclip = int((R & ~_sil).sum())
R &= _sil
log(f" R 裁剪到眼镜轮廓: 剔除轮廓外 {_nclip} 格, R={R.sum()} 格")
if R.sum() < 200:
raise SystemExit("R 过小(轮廓裁剪后)")
# ----------------------- 高度场 -----------------------
log("== 4. 构造填充前/后高度场 ==")
def smooth_valid(Fld, valid, sigma=SIGMA, it=1):
"""归一化高斯卷积(只在 valid 上)"""
rad = int(3 * sigma)
k = np.exp(-0.5 * (np.arange(-rad, rad + 1) / sigma) ** 2)
Fld = Fld.copy()
for _ in range(it):
acc = np.zeros_like(Fld); accw = np.zeros_like(Fld)
val = valid & np.isfinite(Fld)
G = np.where(val, Fld, 0.0); M = val.astype(float)
tmp = np.zeros_like(G); tmpw = np.zeros_like(G)
for d in range(-rad, rad + 1):
if d == 0:
tmp += G * k[rad]; tmpw += M * k[rad]
elif d > 0:
tmp[d:, :] += G[:-d, :] * k[rad + d]; tmpw[d:, :] += M[:-d, :] * k[rad + d]
tmp[:-d, :] += G[d:, :] * k[rad + d]; tmpw[:-d, :] += M[d:, :] * k[rad + d]
acc += tmp; accw += tmpw
tmp = np.zeros_like(G); tmpw = np.zeros_like(G)
for d in range(-rad, rad + 1):
if d == 0:
tmp += acc * k[rad]; tmpw += accw * k[rad]
elif d > 0:
tmp[:, d:] += acc[:, :-d] * k[rad + d]; tmpw[:, d:] += accw[:, :-d] * k[rad + d]
tmp[:, :-d] += acc[:, d:] * k[rad + d]; tmpw[:, :-d] += accw[:, d:] * k[rad + d]
out = np.where(tmpw > 1e-9, tmp / np.maximum(tmpw, 1e-9), Fld)
Fld = np.where(val, out, Fld)
return Fld
def laplace_fill(Fld, known, iters=6000):
"""Dirichlet: known 固定, 其余解调和方程
缺失邻居(网格边缘)用反射(补 F 自身), 恒除以 4 保证系数和=1(收敛)"""
F = Fld.copy()
with np.errstate(all='ignore'):
mean_known = np.nanmean(F[known]) if known.any() else 0.0
if not np.isfinite(mean_known):
mean_known = 0.0
# 非 known 的格子: 有限值保留, NaN/inf 用已知均值初始化
F[~known] = np.where(np.isfinite(F[~known]), F[~known], mean_known)
for it in range(iters):
avg = np.zeros_like(F)
for dj, di in ((1,0),(-1,0),(0,1),(0,-1)):
s = np.roll(F, (dj, di), axis=(0, 1))
ok = np.ones_like(F, bool)
if dj == 1: ok[0, :] = False
if dj == -1: ok[-1, :] = False
if di == 1: ok[:, 0] = False
if di == -1: ok[:, -1] = False
avg += np.where(ok, s, F) # 缺失邻居补 F 自身(反射)
G = np.where(known, F, avg / 4.0) # 恒除 4: 系数和 = 1
df = np.abs(G - F)[~known]
delta = np.nanmax(df) if df.size and np.isfinite(df).any() else 0.0
F = G
if delta < TOL * RES:
break
return F
gx, gy = np.meshgrid(X0 + np.arange(W) * RES, Y0 + np.arange(H) * RES)
# 补 g1f 栅格化小洞(陡峭角落的覆盖缺口): 闭运算(膨胀2再腐蚀2)
g1c = g1f.copy()
for _ in range(2):
gd = g1c.copy()
for dj, di in ((1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)):
if dj == 1: gd[1:, :] |= g1c[:-1, :]
elif dj == -1: gd[:-1, :] |= g1c[1:, :]
elif di == 1: gd[:, 1:] |= g1c[:, :-1]
elif di == -1: gd[:, :-1] |= g1c[:, 1:]
elif dj == 1 and di == 1: gd[1:, 1:] |= g1c[:-1, :-1]
elif dj == 1 and di == -1: gd[1:, :-1] |= g1c[:-1, 1:]
elif dj == -1 and di == 1: gd[:-1, 1:] |= g1c[1:, :-1]
else: gd[:-1, :-1] |= g1c[1:, 1:]
g1c = gd
for _ in range(2):
ge = g1c.copy()
for dj, di in ((1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)):
if dj == 1: ge[1:, :] &= g1c[:-1, :]
elif dj == -1: ge[:-1, :] &= g1c[1:, :]
elif di == 1: ge[:, 1:] &= g1c[:, :-1]
elif di == -1: ge[:, :-1] &= g1c[:, 1:]
elif dj == 1 and di == 1: ge[1:, 1:] &= g1c[:-1, :-1]
elif dj == 1 and di == -1: ge[1:, :-1] &= g1c[:-1, 1:]
elif dj == -1 and di == 1: ge[:-1, 1:] &= g1c[1:, :-1]
else: ge[:-1, :-1] &= g1c[1:, 1:]
g1c = ge
g1c |= g1f
# 洞格的 gz1 用邻域最大值填充 —— 仅补两类缺口, 不覆盖正常原值:
# 1) g1c 缺口(栅格化角落缝隙, 原值 -inf);
# 2) 孤立针孔: 原值远低于全部邻居中位数(顶面近水平处三角形投影漏采
# 格心, 射线打到的其实是更低的背面) -> 用邻域最大值顶回真实顶面.
# 尾部包裹段是陡斜坡(有低侧邻居, 单调趋势)不是针孔, 必须保留原值跟随本地框面
gz1f = np.where(g1f, gz1, -np.inf)
_nb = []
for dj, di in ((1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)):
s = np.full_like(gz1, np.nan)
if dj == 1: s[1:, :] = gz1[:-1, :]
elif dj == -1: s[:-1, :] = gz1[1:, :]
elif di == 1: s[:, 1:] = gz1[:, :-1]
elif di == -1: s[:, :-1] = gz1[:, 1:]
elif dj == 1 and di == 1: s[1:, 1:] = gz1[:-1, :-1]
elif dj == 1 and di == -1: s[1:, :-1] = gz1[:-1, 1:]
elif dj == -1 and di == 1: s[:-1, 1:] = gz1[1:, :-1]
else: s[:-1, :-1] = gz1[1:, 1:]
_nb.append(s)
_nb = np.stack(_nb)
_valid_nb = _nb > -1e29
with np.errstate(all='ignore'):
_nbmed = np.nanmedian(np.where(_valid_nb, _nb, np.nan), axis=0)
_nbmin = np.nanmin(np.where(_valid_nb, _nb, np.inf), axis=0)
# 针孔 = 低于"所有"覆盖邻格最小值 0.4+ 的孤点. 旧判据(低于邻域中位数
# 0.4)会误杀镜腿尾端的真实陡降格(周围皆高, 如 632667 尾尖 6.3 被补成
# 11.0 -> 填充前端浮起 4+ 成鳍); 真陡降总有相近低的邻格(连成下降路径)
_pin = g1f & np.isfinite(_nbmed) & (gz1 < _nbmin - 0.4)
gz1d = gz1f.copy()
for _ in range(3):
gf = gz1d.copy()
for dj, di in ((1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)):
s = np.full_like(gz1d, -np.inf)
if dj == 1: s[1:, :] = gz1d[:-1, :]
elif dj == -1: s[:-1, :] = gz1d[1:, :]
elif di == 1: s[:, 1:] = gz1d[:, :-1]
elif di == -1: s[:, :-1] = gz1d[:, 1:]
elif dj == 1 and di == 1: s[1:, 1:] = gz1d[:-1, :-1]
elif dj == 1 and di == -1: s[1:, :-1] = gz1d[:-1, 1:]
elif dj == -1 and di == 1: s[:-1, 1:] = gz1d[1:, :-1]
else: s[:-1, :-1] = gz1d[1:, 1:]
gf = np.maximum(gf, s)
gz1d = gf
gz1f = np.where(g1f & ~_pin, gz1, gz1d)
log(f" 顶面针孔修补: {int(_pin.sum())} 格 (孤立低谷, 用邻域最大值补回顶面)")
# 镜框带补栅格化角落缺口(阈值相对镜框管前面中位高度, 适配任意模型;
# 原写死 z>=7.0 只适配 632723)
_ztube = gz1[g1f & (gz2 > -1e29)]
_zcut = float(np.median(_ztube)) - 2.0 if len(_ztube) else -np.inf
nfill = int((g1c & ~g1f & R & (gz1f >= _zcut)).sum())
log(f" 镜框带补栅格化角落缺口: {nfill} 格")
# ---- 前表面(v6): 跟随镜框前面 + 开口调和膜 + 整体平滑 ----
# 有栅格覆盖处(框前面/鼻梁/镜尾包裹斜坡) 前面 = 本地框面(平齐-FLUSH);
# 无覆盖处(镜片开口/中央缝隙/外缘) = 覆盖值的调和延拓膜. 调和解内部无
# 局部极值 => 镜片面天然无凹陷; 镜尾跟随包裹段下折(侧视无鳍状多余凸出),
# 局部低谷(鼻梁等)被整体平滑略微抬高 = 允许的轻微凸出(不突兀)
# 角落缺口格(g1c & ~g1f)的 gz1f 为形态学最大延拓, 在折返坡会高出本地框面 ~1.
# 用 0.3 内真实覆盖格的最小值钳制(让覆盖值跟随本地框面),
# 再轻平滑消除形态学噪声
_mdil = np.where(g1f, gz1, np.inf)
for _ in range(3):
_mn = _mdil.copy()
for dj, di in ((1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)):
s = np.full_like(_mdil, np.inf)
if dj == 1: s[1:, :] = _mdil[:-1, :]
elif dj == -1: s[:-1, :] = _mdil[1:, :]
elif di == 1: s[:, 1:] = _mdil[:, :-1]
elif di == -1: s[:, :-1] = _mdil[:, 1:]
elif dj == 1 and di == 1: s[1:, 1:] = _mdil[:-1, :-1]
elif dj == 1 and di == -1: s[1:, :-1] = _mdil[:-1, 1:]
elif dj == -1 and di == 1: s[:-1, 1:] = _mdil[1:, :-1]
else: s[:-1, :-1] = _mdil[1:, 1:]
_mn = np.minimum(_mn, s)
_mdil = _mn
_fringe = g1c & ~g1f
# 最小值钳制扩展到所有"无覆盖但 0.3 内有覆盖"的格(不止角落缺口格):
# 折叠处(镜圈-镜腿仅隔 0.1~0.3, 如 632667)闭合掩码 g1c 有洞, 未钳制的
# 格保留形态学最大延拓(取自镜圈 10.9), 与相邻钳制格(取自镜腿 7.9)交替
# -> 种子/天花板基准场本身振荡 -> 前表面出现 1+ 深的凹坑
gz1f = np.where((~g1f) & (_mdil < 1e29), np.minimum(gz1f, _mdil), gz1f)
with np.errstate(all='ignore'):
gz1f = np.where(_fringe, smooth_valid(gz1f, _fringe, sigma=2.0, it=1), gz1f)
if _GEO_MODE:
# 陡降段基准软化: 几何模式选面在折返框陡壁上的逐格最大 z 是台阶状,
# 而种子/走廊钳制(天花板 gz1f+0.3 / 下界 gz1f-0.15)每轮平滑后都把
# 前表面拉回该台阶基准 -> 前表面台阶化(片二面角 p90 ~3 倍于标记模式,
# 632723/632667 无此问题: 稠密标记的框面本来就平滑). 对陡降格
# (1.0 邻域内框面高出本地 >=1.2, 与走廊天花板同判据)的基准 gz1f 做
# 掩码高斯平滑: 高斯保线性 -> 光滑斜坡原样保留, 只消台阶抖动; 再用
# 3x3 原始最大值封顶, 基准不超出近旁真实框前面 -> 防鳍性质保留
# (填充不伸到框面前方之外, 与原 +0.3 天花板余量同级). 平缓格/镜片
# 膜/标记模式不受影响.
_gzv = gz1f > -1e29
_gzm = np.where(_gzv, gz1f, np.nan)
_gmax0 = np.where(_gzv, gz1f, -np.inf)
_gmx = _gmax0.copy()
for _ in range(10):
_gmx = np.maximum.reduce([_gmx, np.roll(_gmx, 1, 0), np.roll(_gmx, -1, 0),
np.roll(_gmx, 1, 1), np.roll(_gmx, -1, 1)])
_steepb = _gzv & (_gmax0 < _gmx - 1.2)
if _steepb.any():
_gzs = smooth_valid(_gzm, _gzv, sigma=2.0, it=1)
_m3 = np.full((H, W), -np.inf)
for _dj in (-1, 0, 1):
for _di in (-1, 0, 1):
_m3 = np.maximum(_m3, np.roll(_gmax0, (_dj, _di), (0, 1)))
_dlt = np.where(_steepb & np.isfinite(_gzs), _gzs - gz1f, np.nan)
gz1f = np.where(_steepb & np.isfinite(_gzs),
np.minimum(_gzs, _m3), gz1f)
log(f" 陡降段基准软化(几何模式): {int(_steepb.sum())} 格, "
f"基准改动量中位 {np.nanmedian(np.abs(_dlt)):.2f} "
f"max {np.nanmax(np.abs(_dlt)):.2f}")
# 前表面: 边界条件 = 覆盖格(含钳制后的角落缺口格)的框前面, 轻平滑去栅格
# 噪声(σ 取小, 防止把镜尾陡崖格拉向邻值), 调和延拓到无覆盖区
# (镜片开口/缝隙/外缘) —— 调和解内部无局部极值 => 无凹陷
seed = np.full((H, W), np.nan)
bc = R & g1c & (gz1f > -1e29)
seed[bc] = gz1f[bc]
seed = smooth_valid(seed, bc, sigma=1.0)
hf = laplace_fill(seed, bc)
hf = np.where(R, hf - FLUSH, np.nan)
# 镜片弧面(轻微凸出): 以镜框前方两侧(镜圈前面)为缘, 向开口内部连成小
# 弧度抛物面: 凸出量 = BULGE * (1-(1-d/D)^2), d = 离填充区边界的精确
# 欧氏距离(城市块距离的水平集是菱形, 沿对角线有折角, 会让弧面起折痕),
# D = 全区最大深度. 镜片中心约 +0.35(整体平滑后), 边缘归零 -> 与框带
# 平滑衔接(不突兀); 盖住镜框前方属于允许行为. 其后走廊钳制仍生效
# (镜尾陡降格防鳍不受影响)
_nbr = (np.roll(R, 1, 0) | np.roll(R, -1, 0) | np.roll(R, 1, 1) | np.roll(R, -1, 1))
_ring = ~R & _nbr # R 边界外圈格(距离源)
_jr, _ir = np.where(R)
_js, _is = np.where(_ring)
_dx = (_ir[:, None] - _is[None, :]) * RES
_dy = (_jr[:, None] - _js[None, :]) * RES
_dist = np.full((H, W), np.inf)
_dist[_jr, _ir] = np.sqrt(_dx * _dx + _dy * _dy).min(1)
dmax = float(np.max(_dist[R]))
mprof = np.where(R, 1.0 - (1.0 - np.minimum(_dist / max(dmax, 1e-9), 1.0)) ** 2,
np.nan)
mprof = smooth_valid(mprof, R, sigma=2.5)
hf = np.where(R, hf + BULGE * mprof, np.nan)
# 整体平滑: 柔化框带/镜片膜交界折角(打印外观光滑)
hf = smooth_valid(hf, R, sigma=2.0, it=2)
# 走廊钳制: 平滑/延拓会把陡崖格与缺口格拉向邻值, 造成镜尾鳍状凸出(向上)
# 或管底缝隙(向下, 如管顶 6.0 邻格 1.8 的悬崖). 钳制范围与量:
# 上界: 仅陡降格(1.0 邻域内框面高出本地 >=1.2: 镜尾尖端/对角缺口/陡崖)
# 钳到 gz1f+0.3 防鳍; 平缓格不设天花板 -- 弧面允许盖住镜框前方(用户
# 许可), 单格最大值代理会压出尖点凹坑. 邻域取 1.0: 尾尖孤立低点(四周
# 皆低)会被整体平滑从远处(镜圈在 ~1.0 外)拉高 ~3, 窄邻域看不见 -> 悬浮
# 鳍(632667 尾尖曾 fill 高出框面 3.0/皮肤 2.5)
# 下界(仅真实覆盖格) = gz1f - 0.15: ① 真实镜框管深(前后表面差)>=0.27,
# 故该下界恒在管背之上 -> 与框体交叠无缝(旧值 -0.5 按粗框管半径 0.67
# 调, 细框模型管深 0.3 时填充顶会低于管背 -> 内部空隙); ② 防止整体
# 平滑把覆盖带前表面拉离本地框面超过 0.15(陡崖邻域的拖拽, 632667 的
# 镜圈-镜腿折叠处曾拉低 0.5 造成交界折角)
# 钳后轻平滑柔化折痕, 再钳(两轮收敛)
# 镜片邻近带(镜片开口+0.6): 防鳍天花板豁免区. 开口边缘无覆盖格的延拓
# gz1f 取自镜圈内侧面(比框前沿低 ~1), 与 1.0 内框前沿差 >1.2 会被误判
# "陡降格"钳到 gzfm+0.3, 在穹顶边缘压出 0.4-0.7 深的沟(617892 右镜下缘
# ceil=9.36 恰等于沟底). 穹顶/开口边缘允许升到框面, 不属鳍.
_lex = lens_mask.copy()
for _ in range(6):
_lg = _lex.copy()
for _dj, _di in ((1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)):
_lg |= np.roll(_lex, (_dj, _di), (0, 1))
_lex = _lg
gzfm = np.where(gz1f > -1e29, gz1f, -np.inf)
gmax2 = gzfm.copy()
for _ in range(10):
gmax2 = np.maximum.reduce([gmax2, np.roll(gmax2, 1, 0), np.roll(gmax2, -1, 0),
np.roll(gmax2, 1, 1), np.roll(gmax2, -1, 1)])
# 防鳍豁免镜片邻近带: 见上方 _lex 注释(镜片区误钳出沟)
ceil = np.where((gz1f > -1e29) & (gzfm < gmax2 - 1.2) & ~_lex, gzfm + 0.30, np.inf)
floor = np.where(bc, gz1f - 0.15, -np.inf)
if _GEO_MODE:
# 贴脸锚连续权重版: 走廊/覆盖格的真实前界 = 该列最前表面 = max(镜框延
# 拓, 脸面). 脸高于框参考 gap>0.35 的格进入贴脸影响带, 地板 = 贴脸目标
# T - (0.15 + 0.5*(1-w)), w = clip((gap-0.35)/1.65) 随 gap 线性收紧
# [T-0.65, T-0.15] —— 连续无阈值悬崖(阈值版在边缘一格差 2.5, 83°二
# 面角); 填充贴到脸面(镂空无气袋), 过渡带留 0.65 圆角余量. gap>=2(深
# 臂覆盖格 gz1f 可为负深度, 旧版前表面潜入峡谷 80°; 走廊袋)时天花板
# 直接覆盖反鳍天花板(负深度会削回峡谷); 0.35<gap<2 天花板纯抬升到 T
# (穹顶格原天花板 inf, 不受影响). 袋状 0.5 邻域内无脸格(镜尾)天花板
# 放开铺斜坡. 镜片中心格无延拓(gz1f 空)不进影响带(穹顶不可削); 镜片
# 区脸面恒低于镜圈延拓, 不触发. marks 模式无此几何, 不受影响.
_gzf = np.where(gz1f > -1e29, gz1f, -np.inf)
_hasf = R & (gz1f > -1e29) & have_f & (fz > _gzf + 0.35)
_pcl = _hasf & (fz > _gzf + 2.0)
if _hasf.any():
_fzs = smooth_valid(np.where(have_f, fz, np.nan), have_f, sigma=1.5, it=1)
_T = np.where(_hasf, np.minimum(_fzs, fz) - 0.05, np.nan)
_gap = np.where(_hasf, fz - _gzf, 0.0)
_w = np.clip((_gap - 0.35) / 1.65, 0.0, 1.0)
_fl = _T - 0.15 - 0.5 * (1.0 - _w)
floor = np.maximum(floor, np.where(_hasf, np.nan_to_num(_fl, nan=-np.inf), -np.inf))
ceil = np.where(_pcl & np.isfinite(_T), _T, ceil)
ceil = np.maximum(ceil, np.where(_hasf & np.isfinite(_T), _T, -np.inf))
# 贴脸带 0.5 邻域内无脸格(镜尾走廊)天花板放开到贴脸目标, 铺斜坡;
# 有脸面处以 min(平滑脸面, 脸面)-0.05 封顶(不穿皮, 不出脸)
_bd = np.where(_pcl & np.isfinite(_T), _T, -np.inf)
for _ in range(5):
_bd = np.maximum.reduce([_bd, np.roll(_bd, 1, 0), np.roll(_bd, -1, 0),
np.roll(_bd, 1, 1), np.roll(_bd, -1, 1)])
_capr = np.where(have_f & np.isfinite(_fzs), np.minimum(_fzs, fz) - 0.05, np.inf)
_raise = np.where(_bd > -1e29, np.minimum(_bd, _capr), -np.inf)
ceil = np.maximum(ceil, np.where(R & ~g1f & ~_hasf, _raise, -np.inf))
log(f" 贴脸锚(连续权重): 影响带 {int(_hasf.sum())} 格, "
f"其中贴皮带(gap>=2) {int(_pcl.sum())} 格")
# ---- 穿皮防护(全模式): 无镜框覆盖且非镜片穹顶的真实脸面格, 前面不高出脸面 ----
# 走廊/外缘格的膜面跟随镜框平面(防鳍天花板来自延拓框面), 而该处皮肤
# 退到框平面下方 1-3 -> 填充高出皮肤, 正面/侧面可见"穿透脸皮"
# (20261102 外上角最显著 +2.8). 钳到 T=min(平滑脸,脸)-0.05; 有镜框
# 直接覆盖(g1c)或镜片穹顶区(含 0.4 边缘环, 穹顶高于其后眼面属镜片
# 设计)豁免. 距覆盖带 4 格内线性松弛(每格 0.45), 与钳制-平滑循环
# 一起铺成连续斜坡, 避免框缘一步崖(v9 教训: 硬钳=103° 二面角).
_fzs2 = smooth_valid(np.where(have_f, fz, np.nan), have_f, sigma=1.5, it=1)
_rf = have_f & (fz >= FACE_MIN)
_T2 = np.where(_rf, np.minimum(np.nan_to_num(_fzs2, nan=np.inf), fz) - 0.05,
np.inf)
# _lex(镜片开口+0.6 膨胀带)已在防鳍天花板前计算(上方), 此处直接引用
# 豁免 = 镜框直接覆盖(g1c) 或 镜片穹顶区(含 0.4 边缘环; 穹顶高于其后
# 眼面属镜片设计). 注意不能用延拓 gz1f 判"框在上方": 延拓只说明框在
# 0.2-0.3 外侧, 该列正上方无框, 填充高出照样可见.
_exempt = g1c | _lex
_dcv = np.full((H, W), 99)
_dcv[g1c] = 0
_dcur = g1c.copy()
for _s in range(1, 6):
_dg = _dcur.copy()
for _dj, _di in ((1, 0), (-1, 0), (0, 1), (0, -1)):
_dg |= np.roll(_dcur, (_dj, _di), (0, 1))
_nw = _dg & ~_dcur
_dcv[_nw] = _s
_dcur = _dg
# 局部框面前沿参考(6 格延拓, 供落差计算): 斜坡从框面线性降到脸面
_gref = gzfm.copy()
for _ in range(6):
_gg = _gref.copy()
for _dj, _di in ((1, 0), (-1, 0), (0, 1), (0, -1)):
_gg = np.maximum(_gg, np.roll(_gref, (_dj, _di), (0, 1)))
_gref = _gg
_drop = np.where(np.isfinite(_T2) & np.isfinite(_gref),
np.clip(_gref + 0.15 - _T2, 0.0, None), 0.0)
_slack = _drop * np.maximum(0, 3 - np.minimum(_dcv, 3)) / 3.0
# 穿皮钳(R 内)停用: R 已裁剪到眼镜轮廓+0.2, 轮廓内填充前面高出脸面 =
# 填满镜框-脸面之间镂空的支撑体本体(设计意图, 镜片穹顶/镜桥同理).
# 旧版把无框覆盖但有脸面的格(镜桥 pinch/镜下袋)钳到脸面-0.05 -> 前表面
# 凹陷(617892 鼻梁塌 2.8, 20261102 塌 0.6-0.9, 用户明确要求镜片区无凹陷).
# 原防穿皮目标(填充伸出镜框轮廓外高出脸面)已由 R 轮廓裁剪根治;
# 防鳍天花板(陡降格 gzfm+0.3)与贴脸锚(脸面在框延拓前方的融合带)仍生效.
_skincap = np.zeros_like(R)
log(" 穿皮防护(内部): 已停用 (R 限轮廓内; 镜桥/镜下袋/镜片穹顶统一填到框面)")
hf = np.minimum(np.maximum(hf, floor), ceil)
for _ in range(2):
hf = smooth_valid(hf, R, sigma=1.0, it=1)
hf = np.where(R, np.minimum(np.maximum(hf, floor), ceil), np.nan)
# 几何模式袋状斜坡追加收敛: 主循环 2 轮 σ1.0 不足以把 2+ 单位悬崖完全
# 铺开, 追加 2 轮(钳制同前, 袋状贴皮带/防穿皮帽持续生效)
if _GEO_MODE and _pcl.any():
for _ in range(2):
hf = smooth_valid(hf, R, sigma=1.0, it=1)
hf = np.where(R, np.minimum(np.maximum(hf, floor), ceil), np.nan)
log(f" 前表面(框面跟随+调和膜)范围: z [{np.nanmin(hf):.2f}, {np.nanmax(hf):.2f}]")
hb = np.full((H, W), np.nan)
# 真实脸面: fz >= FACE_MIN. 低于此值的"脸面"是误标导致的深伪面(后脑/口腔),
# 不能作为填充底面(否则镜桥下会出现十几单位深的伪填充柱)
real_f = have_f & (fz >= FACE_MIN)
hb[real_f] = fz[real_f] - EMBED_BOT
knownb = R & real_f
hb = laplace_fill(hb, knownb)
hb = smooth_valid(hb, R)
# 无真实脸面处(误标区): 厚度上限仅作安全钳(透明支撑体尾部较厚属预期);
# 有真实脸面处仍嵌到脸面(填满镂空)
cap = hf - SHELL_MAX
ncap = int((R & ~real_f & np.isfinite(hb) & (hb < cap)).sum())
hb = np.where(R & ~real_f, np.maximum(hb, cap), hb)
log(f" 厚度上限(无真实脸面处, 距前面>{SHELL_MAX})生效格数: {ncap}")
# 缝隙修补: 平滑可能把底缘底面抬到脸面之前 -> 钳回脸面内(嵌入>=0.25, 并集无缝)
hb = np.where(R & real_f, np.minimum(hb, fz - 0.25), hb)
bzs = smooth_valid(np.where(have_b, bz, np.nan), have_b)
hb = np.where(R, hb, np.nan)
hb = np.where(have_b, np.maximum(hb, bzs + CLAMP_BACK), hb)
hb = np.minimum(hb, hf - GAP_MIN)
# 外扩一格填充 NaN(边界采样用)
for _ in range(60):
nanm = ~np.isfinite(hf)
if not nanm.any(): break
for dj, di in ((1,0),(-1,0),(0,1),(0,-1)):
s = np.roll(np.where(nanm, 0, hf), (dj, di), axis=(0, 1))
ok = ~np.roll(nanm, (dj, di), axis=(0, 1)) & np.isfinite(s)
hf = np.where(nanm & ok, s, hf)
for _ in range(60):
nanm = ~np.isfinite(hb)
if not nanm.any(): break
for dj, di in ((1,0),(-1,0),(0,1),(0,-1)):
s = np.roll(np.where(nanm, 0, hb), (dj, di), axis=(0, 1))
ok = ~np.roll(nanm, (dj, di), axis=(0, 1)) & np.isfinite(s)
hb = np.where(nanm & ok, s, hb)
# 外扩带坡度延拓: 逐格复制外扩是平檐, 与陡坡(包裹带/袋状斜坡)交界成折角
# (前表面外缘片二面角 p90 ~35°). 对 R 外的有限格做掩码平滑, 坡面平滑延续
# 到边界环(仅影响边界采样, 不动 R 内字段); 并保持外扩带厚度 >= 0.1 防
# 前后面交叠.
_fring = np.isfinite(hf) & ~R
if _fring.any():
with np.errstate(all='ignore'):
_hfs = smooth_valid(hf, np.isfinite(hf), sigma=1.0, it=2)
_hbs = smooth_valid(hb, np.isfinite(hb), sigma=1.0, it=2)
hb = np.where(_fring, _hbs, hb)
hf = np.where(_fring, np.maximum(_hfs, hb + 0.10), hf)
# 穿皮防护延伸到外扩带: fringe 会被 σ 平滑从内部覆盖带拉高, 而
# 边界环顶点采样的是 fringe 值 -> 边界环仍高出皮肤(外上角 +2.3,
# 内部钳制对此无效). 用同一 T+距框松弛钳外扩带, 边界环收口到皮肤.
# 外扩带穿皮钳停用: 它把边界环压到皮肤, 前表面边缘与底面在皮肤处
# 捏成刀刃(脸面距框面 1-3 时侧视尖角). 停用后边界环 = 坡度延拓
# (≈本地框面), 侧墙从框面平滑落到脸面 = 实心支撑体外形.
log(" 穿皮防护(外扩带): 已停用 (边界环=坡度延拓, 侧墙平滑落脸)")
thickm = np.where(R, hf - hb, np.nan)
log(f" 填充厚度: min {np.nanmin(thickm):.2f}, 中位 {np.nanmedian(thickm):.2f}, "
f"mean {np.nanmean(thickm):.2f}, max {np.nanmax(thickm):.2f}")
# ----------------------- 边界: 提取/平滑 -----------------------
log("== 5. 边界提取与平滑 ==")
# 裂缝边(crack edge)链成回路
edges = {}
def add_edge(a, b):
edges.setdefault(a, []).append(b)
for j in range(H):
for i in range(W):
if not R[j, i]:
continue
if i == 0 or not R[j, i - 1]:
add_edge((i, j), (i, j + 1))
if i == W - 1 or not R[j, i + 1]:
add_edge((i + 1, j + 1), (i + 1, j))
if j == 0 or not R[j - 1, i]:
add_edge((i + 1, j), (i, j))
if j == H - 1 or not R[j + 1, i]:
add_edge((i, j + 1), (i + 1, j + 1))
if not edges:
raise SystemExit("无边界")
start = next(iter(edges))
loop = [start]
cur = edges[start].pop(0)
used = {(start, cur)}
while cur != start:
loop.append(cur)
nxts = [e for e in edges.get(cur, []) if (cur, e) not in used]
if not nxts:
break
nxt = nxts[0]
used.add((cur, nxt))
cur = nxt
P = np.array([[X0 + i * RES, Y0 + j * RES] for (i, j) in loop], float)
area2 = np.sum(P[:, 0] * np.roll(P[:, 1], -1) - np.roll(P[:, 0], -1) * P[:, 1])
if area2 < 0:
P = P[::-1]
log(f" 边界回路 {len(P)} 点, 周长 {np.linalg.norm(np.roll(P,-1,0)-P,axis=1).sum():.2f}")
def resample(P, step):
d = np.linalg.norm(np.roll(P, -1, 0) - P, axis=1)
L = np.concatenate([[0], np.cumsum(d)])
total = L[-1]
n = max(8, int(round(total / step)))
t = np.linspace(0, total, n, endpoint=False)
x = np.interp(t, L, np.concatenate([P[:, 0], P[:1, 0]]))
y = np.interp(t, L, np.concatenate([P[:, 1], P[:1, 1]]))
return np.stack([x, y], 1)
def roughness(P):
d = np.roll(P, -1, 0) - P
a = np.arctan2(d[:, 1], d[:, 0])
da = np.abs((a - np.roll(a, -1) + np.pi) % (2 * np.pi) - np.pi)
return float(da.mean()), float(da.std())
r0 = roughness(P)
B = resample(P, BSTEP)
for _ in range(BITER):
B = (1 - BLAM) * B + BLAM * 0.5 * (np.roll(B, 1, 0) + np.roll(B, -1, 0))
# 面积恢复: 抵消平滑收缩(目标 = R 面积的 97%)
areaR = R.sum() * RES * RES
for _ in range(30):
aB = 0.5 * abs(np.sum(B[:, 0] * np.roll(B[:, 1], -1) - np.roll(B[:, 0], -1) * B[:, 1]))
if aB >= 0.97 * areaR:
break
c = B.mean(0)
s = np.sqrt(0.97 * areaR / max(aB, 1e-9))
s = min(s, 1.05)
B = c + (B - c) * s
B = resample(B, BSTEP)
r1 = roughness(B)
log(f" 边界粗糙度(平均转折角/标准差): 平滑前 {np.degrees(r0[0]):.2f}°/{np.degrees(r0[1]):.2f}° "
f"-> 平滑后 {np.degrees(r1[0]):.2f}°/{np.degrees(r1[1]):.2f}°")
def inside_mask(pts, msk):
ii = np.clip(((pts[:, 0] - X0) / RES).astype(int), 0, W - 1)
jj2 = np.clip(((pts[:, 1] - Y0) / RES).astype(int), 0, H - 1)
return msk[jj2, ii]
bad = ~inside_mask(B, mask)
if bad.any():
c = B.mean(0)
for k in np.where(bad)[0]:
p = B[k]
for s in np.linspace(0.1, 0.9, 30):
q = c + s * (p - c)
if inside_mask(q[None, :], mask)[0]:
B[k] = q
break
for _ in range(10):
B = (1 - BLAM) * B + BLAM * 0.5 * (np.roll(B, 1, 0) + np.roll(B, -1, 0))
B = resample(B, BSTEP)
# 边界须压在镜框带上(存在眼镜表面), 统计覆盖率
bii = np.clip(((B[:, 0] - X0) / RES).astype(int), 0, W - 1)
bjj = np.clip(((B[:, 1] - Y0) / RES).astype(int), 0, H - 1)
nb = np.zeros(len(B), bool)
for dj, di in ((0,0),(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)):
jj2 = np.clip(bjj + dj, 0, H - 1); ii2 = np.clip(bii + di, 0, W - 1)
nb |= g1f[jj2, ii2]
log(f" 平滑边界 {len(B)} 点, 超出足迹 {int((~inside_mask(B, mask)).sum())}, "
f"压在镜框带上 {nb.mean()*100:.1f}%")
# ----------------------- 区域三角化: 耳切 + 内部点插入 -----------------------
log("== 6. 区域三角化 (耳切法 + 内部网格点插入, 无翻转风险) ==")
Nb = len(B)
def cross3(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
def ear_clip(P):
"""P 为 CCW 简单多边形, 返回三角形索引列表"""
idx = list(range(len(P)))
tris = []
guard = 0
while len(idx) > 3 and guard < 5000:
guard += 1
m = len(idx)
clipped = False
best_k, best_score = None, None
for k in range(m):
i0, i1, i2 = idx[(k - 1) % m], idx[k], idx[(k + 1) % m]
a, b, c = P[i0], P[i1], P[i2]
cr = cross3(a, b, c)
if cr <= 1e-10:
continue
inside_any = False
for j in idx:
if j in (i0, i1, i2):
continue
p = P[j]
if (cross3(a, b, p) >= -1e-12 and cross3(b, c, p) >= -1e-12
and cross3(c, a, p) >= -1e-12):
inside_any = True
break
if not inside_any:
# 质量分: 面积/边长平方 越大越好
lab = np.hypot(*(np.subtract(b, a))); lac = np.hypot(*(np.subtract(c, a)))
lbc = np.hypot(*(np.subtract(c, b)))
score = cr / max(lab * lac, lab * lbc, lac * lbc, 1e-12)
if best_score is None or score > best_score:
best_k, best_score = k, score
if best_k is None:
# 兜底: 耳切失败(理论不应发生), 取最大正面积顶点
m = len(idx)
areas = [(cross3(P[idx[(k-1)%m]], P[idx[k]], P[idx[(k+1)%m]]), k)
for k in range(m)]
areas.sort(reverse=True)
best_k = areas[0][1]
log(" ! 耳切兜底触发")
i0, i1, i2 = (idx[(best_k - 1) % len(idx)], idx[best_k],
idx[(best_k + 1) % len(idx)])
tris.append((i0, i1, i2))
idx.pop(best_k)
tris.append((idx[0], idx[1], idx[2]))
return tris
T = np.asarray(ear_clip(B.tolist()), np.int64)
log(f" 耳切三角化: {len(T)} 三角形")
# 内部点: R 深处格子中心, 间隔 ~0.3
inner2 = R.copy()
for _ in range(2):
ne = inner2.copy()
ne[1:, :] &= inner2[:-1, :]; ne[:-1, :] &= inner2[1:, :]
ne[:, 1:] &= inner2[:, :-1]; ne[:, :-1] &= inner2[:, 1:]
inner2 = ne
# 网格步长 0.2(原 0.3): 弧面鞍形曲率处交替对角线扭折 ~ 间距^2, 加密后
# 前表面二面角明显下降(镜片光滑)
step = max(1, int(round(0.2 / RES)))
cand = []
for j in range(0, H, step):
for i in range(0, W, step):
if inner2[j, i]:
cand.append((X0 + i * RES, Y0 + j * RES))
cand = np.asarray(cand)
log(f" 内部插入点候选 {len(cand)}")
# 逐点插入(拆分包含它的三角形 -> 拓扑恒不翻转)
xy = np.vstack([B, cand])
n_cand = len(cand)
extra = 0
Ta = [list(map(int, t)) for t in T]
for ci in range(n_cand):
p = xy[Nb + ci]
m = len(Ta)
TA = np.asarray(Ta)
a = xy[TA[:, 0]]; b = xy[TA[:, 1]]; c = xy[TA[:, 2]]
d = ((b[:, 1] - c[:, 1]) * (a[:, 0] - c[:, 0]) + (c[:, 0] - b[:, 0]) * (a[:, 1] - c[:, 1]))
with np.errstate(divide='ignore', invalid='ignore'):
l1 = ((b[:, 1] - c[:, 1]) * (p[0] - c[:, 0]) + (c[:, 0] - b[:, 0]) * (p[1] - c[:, 1])) / d
l2 = ((c[:, 1] - a[:, 1]) * (p[0] - c[:, 0]) + (a[:, 0] - c[:, 0]) * (p[1] - c[:, 1])) / d
l3 = 1 - l1 - l2
hit = np.where((l1 > 5e-3) & (l2 > 5e-3) & (l3 > 5e-3)
& (l1 < 1 - 5e-3) & (l2 < 1 - 5e-3) & (l3 < 1 - 5e-3))[0]
if len(hit) == 0:
continue
t = int(hit[0])
i0, i1, i2 = Ta[t]
vnew = Nb + ci
Ta[t] = [i0, i1, vnew]
Ta.append([i1, i2, vnew])
Ta.append([i2, i0, vnew])
extra += 1
T = np.asarray([t for t in Ta if t[0] != t[1] and t[1] != t[2] and t[0] != t[2]], np.int64)
xy = xy[:Nb + n_cand]
# 内部顶点 XY 轻度 Laplacian 平滑(边界固定), 消除细长三角形; 有翻转则回退
es = np.unique(np.sort(np.concatenate([T[:, [0, 1]], T[:, [1, 2]], T[:, [2, 0]]]), axis=1), axis=0)
src = np.concatenate([es[:, 0], es[:, 1]])
dst = np.concatenate([es[:, 1], es[:, 0]])
deg = np.bincount(src, minlength=len(xy)).astype(float)
def tri_areas(xy, T):
cr = xy[T[:, 1]] - xy[T[:, 0]]
cs = xy[T[:, 2]] - xy[T[:, 0]]
return cr[:, 0] * cs[:, 1] - cr[:, 1] * cs[:, 0]
if _GEO_MODE:
# 几何模式逐点 Laplacian 平滑: 全局整轮接受/回退在楔形缺口(细长三
# 角形)处第一轮就出现反演 -> 完全不平滑 -> 网格噪声大(镜片二面角
# 中位数翻倍). 逐点接受: 只移动使所有关联三角形保持正面积的顶点,
# 其余留原地, 细长三角形逐步被规整化
_vf = [[] for _ in range(len(xy))]
for _fi, (_a, _b, _c) in enumerate(T):
_vf[_a].append(_fi); _vf[_b].append(_fi); _vf[_c].append(_fi)
for _r in range(8):
_moved = 0
for _v in range(Nb, len(xy)):
_nb = set()
for _fi in _vf[_v]:
for _u in T[_fi]:
if _u != _v:
_nb.add(int(_u))
if not _nb:
continue
_tgt = xy[list(_nb)].mean(0)
_newp = 0.5 * xy[_v] + 0.5 * _tgt
_ok = True
for _fi in _vf[_v]:
_t = T[_fi]
_p0 = _newp if _t[0] == _v else xy[_t[0]]
_p1 = _newp if _t[1] == _v else xy[_t[1]]
_p2 = _newp if _t[2] == _v else xy[_t[2]]
_cr = _p1 - _p0; _cs = _p2 - _p0
if _cr[0] * _cs[1] - _cr[1] * _cs[0] <= 1e-9:
_ok = False
break
if _ok:
xy[_v] = _newp
_moved += 1
if _moved == 0:
break
else:
xy_best = xy.copy()
for _ in range(8):
sx = np.bincount(src, weights=xy[dst, 0], minlength=len(xy))
sy = np.bincount(src, weights=xy[dst, 1], minlength=len(xy))
new = np.stack([sx, sy], 1) / np.maximum(deg, 1)[:, None]
xy2 = xy.copy()
xy2[Nb:] = 0.5 * xy[Nb:] + 0.5 * new[Nb:]
if tri_areas(xy2, T).min() > 1e-6:
xy = xy2
xy_best = xy2.copy()
else:
break
xy = xy_best
# 边翻转优化(Lawson): 公共边换成严格更短的对角线(总边长严格递减 -> 必收敛),
# 不新增顶点即可消除耳切弦两侧的细长退化三角形(表面粗糙的根源)
def flip_pass(Tl, XY):
em = {}
for fi, t in enumerate(Tl):
a, b, c = t
for u, v in ((a, b), (b, c), (c, a)):
em.setdefault((min(u, v), max(u, v)), []).append(fi)
eset = set(em)
n = 0
for (u, v), fs in em.items():
if len(fs) != 2:
continue
f1, f2 = fs
if not (u in Tl[f1] and v in Tl[f1] and u in Tl[f2] and v in Tl[f2]):
continue # 本趟已被改动的面, 下趟再处理
w1 = next(x for x in Tl[f1] if x != u and x != v)
w2 = next(x for x in Tl[f2] if x != u and x != v)
if w1 == w2 or (min(w1, w2), max(w1, w2)) in eset:
continue
pu, pv = XY[u], XY[v]
s1 = (pv[0]-pu[0])*(XY[w1][1]-pu[1]) - (pv[1]-pu[1])*(XY[w1][0]-pu[0])
s2 = (pv[0]-pu[0])*(XY[w2][1]-pu[1]) - (pv[1]-pu[1])*(XY[w2][0]-pu[0])
if s1 * s2 >= 0:
continue # w1,w2 须在 uv 两侧(凸四边形)
q1, q2 = XY[w1], XY[w2]
s3 = (q2[0]-q1[0])*(pu[1]-q1[1]) - (q2[1]-q1[1])*(pu[0]-q1[0])
s4 = (q2[0]-q1[0])*(pv[1]-q1[1]) - (q2[1]-q1[1])*(pv[0]-q1[0])
if s3 * s4 >= 0:
continue
# 共线格点容差: 顶点恰好落在对角线上时(|s|~1e-15, 浮点噪声使严格
# 符号判断失效), 翻转会生成零面积/翻反面三角形(定向校验失败);
# 要求四个点-线有向面积都显著大于零
if min(abs(s1), abs(s2), abs(s3), abs(s4)) < 1e-9:
continue
if np.hypot(*(XY[w2] - XY[w1])) >= np.hypot(*(XY[v] - XY[u])):
continue # 新对角线须更短
# 凸四边形 u->a->v->b (a 在 u->v 右侧, b 在左侧) 沿对角线 a-b 拆分
if s1 > 0: # w1 左, w2 右
Tl[f1] = [u, w2, w1]
Tl[f2] = [v, w1, w2]
else: # w1 右, w2 左
Tl[f1] = [u, w1, w2]
Tl[f2] = [v, w2, w1]
eset.add((min(w1, w2), max(w1, w2)))
n += 1
return n
Tlist = [list(map(int, t)) for t in T]
nflips = 0
for _p in range(200):
k = flip_pass(Tlist, xy)
nflips += k
if k == 0:
break
log(f" 边翻转优化: {nflips} 次, 收敛于 {_p+1} 趟")
# 残余长边轻度细分(翻转后仅少量, 不会级联)
MAXEDGE = 0.5
nsplit = 0
for _p in range(6):
emap = {}
for fi, (a, b, c) in enumerate(Tlist):
for u, v in ((a, b), (b, c), (c, a)):
emap.setdefault((min(u, v), max(u, v)), []).append(fi)
longe = [(u, v) for (u, v) in emap if np.hypot(*(xy[v] - xy[u])) > MAXEDGE]
if not longe:
break
mid = {}
newpts = []
_pos = {(round(float(p[0]), 6), round(float(p[1]), 6)): k
for k, p in enumerate(xy)}
for u, v in longe:
m = (xy[u] + xy[v]) / 2.0
_key = (round(float(m[0]), 6), round(float(m[1]), 6))
_hit = _pos.get(_key)
if _hit is not None:
# 中点与已有顶点重合(边界/插入点落在对角格点上): 复用旧点,
# 否则产生零长度边 -> 零面积三角形(定向校验失败)
mid[(u, v)] = _hit
else:
mid[(u, v)] = len(xy) + len(newpts)
_pos[_key] = mid[(u, v)]
newpts.append(m)
xy = np.vstack([xy, np.asarray(newpts)])
nsplit += len(newpts)
newT = []
for (a, b, c) in Tlist:
mab = mid.get((min(a, b), max(a, b)))
mbc = mid.get((min(b, c), max(b, c)))
mca = mid.get((min(c, a), max(c, a)))
if mab is None and mbc is None and mca is None:
newT.append([a, b, c])
elif mbc is None and mca is None:
newT += [[a, mab, c], [mab, b, c]]
elif mab is None and mca is None:
newT += [[a, b, mbc], [a, mbc, c]]
elif mab is None and mbc is None:
newT += [[a, b, mca], [mca, b, c]]
elif mca is None:
newT += [[a, mab, mbc], [mab, b, mbc], [a, mbc, c]]
elif mab is None:
newT += [[a, b, mbc], [a, mbc, mca], [mbc, c, mca]]
elif mbc is None:
newT += [[a, mab, mca], [mab, b, c], [mab, c, mca]]
else:
newT += [[a, mab, mca], [mab, b, mbc], [mca, mbc, c], [mab, mbc, mca]]
Tlist = newT
T = np.asarray(Tlist, np.int64)
log(f" 残余长边细分(>{MAXEDGE}): 新增 {nsplit} 中点, 总三角形 {len(T)}")
cr = xy[T[:, 1]] - xy[T[:, 0]]
cs = xy[T[:, 2]] - xy[T[:, 0]]
area = cr[:, 0] * cs[:, 1] - cr[:, 1] * cs[:, 0]
if area.min() <= 0:
raise SystemExit(f"三角形定向异常 (min area {area.min():.2e})")
log(f" 实际插入 {extra} 点, 总三角形 {len(T)}, 最小有向面积 {area.min():.5f} (>0 = 无翻转)")
nV = len(xy)
# ----------------------- 组装实体 -----------------------
log("== 7. 组装填充实体 ==")
def bilinear(Grd, pts):
fx = (pts[:, 0] - X0) / RES
fy = (pts[:, 1] - Y0) / RES
i = np.clip(np.floor(fx).astype(int), 0, W - 2)
j = np.clip(np.floor(fy).astype(int), 0, H - 2)
tx = np.clip(fx - i, 0, 1); ty = np.clip(fy - j, 0, 1)
v = (Grd[j, i] * (1 - tx) * (1 - ty) + Grd[j, i + 1] * tx * (1 - ty) +
Grd[j + 1, i] * (1 - tx) * ty + Grd[j + 1, i + 1] * tx * ty)
return v
zf = bilinear(hf, xy)
zb = bilinear(hb, xy)
zb = np.minimum(zb, zf - 0.15)
zb = np.maximum(zb, zf - 50)
# 前表面对角线优化: 高度场本身光滑, 但网格四边形对角线方向交替会在鞍形
# 曲率处产生小扭折(前表面二面角 ~6°). 对"前表面-前表面"内部边尝试翻转
# 对角线, 二面角显著减小则翻(边界边不动 => 侧墙环不受影响; 面片绕向按
# XY 正面积统一, 边界有向环自动保持一致)
def _tri_nrm(P3, t):
n = np.cross(P3[t[1]] - P3[t[0]], P3[t[2]] - P3[t[0]])
return n / max(np.linalg.norm(n), 1e-12)
def _pair_ang(P3, t0, t1):
n0, n1 = _tri_nrm(P3, t0), _tri_nrm(P3, t1)
return np.degrees(np.arccos(np.clip(n0 @ n1, -1, 1)))
P3f = np.column_stack([xy, zf])
for _round in range(4):
em = {}
for fi, (a, b, c) in enumerate(T):
for u, v in ((a, b), (b, c), (c, a)):
em.setdefault((min(u, v), max(u, v)), []).append(fi)
nfl = 0
for (u, v), fsl in list(em.items()):
if len(fsl) != 2:
continue
f0, f1 = fsl
# em 在轮内会过期: 翻转会把四边形边界边在两面间重新分配, 后续条目
# 引用的面可能已不含该边. 要求两面各恰含 u,v 一次(第三点唯一), 否则
# 视为过期条目跳过(下一轮重建 em 后再处理)
_p = [t for t in T[f0] if t != u and t != v]
_q = [t for t in T[f1] if t != u and t != v]
if len(_p) != 1 or len(_q) != 1 or _p[0] == _q[0]:
continue
p, q = _p[0], _q[0]
# 凸四边形检验: 两条对角线在 XY 严格相交才可翻
cr = ((xy[v, 0] - xy[u, 0]) * (xy[p, 1] - xy[u, 1]) -
(xy[v, 1] - xy[u, 1]) * (xy[p, 0] - xy[u, 0]))
cr2 = ((xy[v, 0] - xy[u, 0]) * (xy[q, 1] - xy[u, 1]) -
(xy[v, 1] - xy[u, 1]) * (xy[q, 0] - xy[u, 0]))
cr3 = ((xy[q, 0] - xy[p, 0]) * (xy[u, 1] - xy[p, 1]) -
(xy[q, 1] - xy[p, 1]) * (xy[u, 0] - xy[p, 0]))
cr4 = ((xy[q, 0] - xy[p, 0]) * (xy[v, 1] - xy[p, 1]) -
(xy[q, 1] - xy[p, 1]) * (xy[v, 0] - xy[p, 0]))
if not (cr * cr2 < 0 and cr3 * cr4 < 0):
continue
t1 = (p, q, u) if ((xy[q, 0] - xy[p, 0]) * (xy[u, 1] - xy[p, 1]) -
(xy[q, 1] - xy[p, 1]) * (xy[u, 0] - xy[p, 0])) > 0 else (u, q, p)
t2 = (p, q, v) if ((xy[q, 0] - xy[p, 0]) * (xy[v, 1] - xy[p, 1]) -
(xy[q, 1] - xy[p, 1]) * (xy[v, 0] - xy[p, 0])) > 0 else (v, q, p)
a_before = _pair_ang(P3f, tuple(T[f0]), tuple(T[f1]))
a_after = _pair_ang(P3f, t1, t2)
# 翻转后二面角改善 >0.3° 才翻(防振荡)
if a_after < a_before - 0.3:
T[f0], T[f1] = t1, t2
nfl += 1
if nfl == 0:
break
log(f" 前表面对角线优化: 翻转 {nfl} 边(减小鞍形曲率处折角)")
Pf = np.column_stack([xy, zf])
Pb = np.column_stack([xy, zb])
faces = []
for a, b, c in T:
faces.append((a, b, c)) # 前表面(法向 +z, 向外)
for a, b, c in T:
faces.append((a + nV, c + nV, b + nV)) # 后表面(翻转, 法向 -z, 向外)
# 侧墙: 用细分后的三角化边界环(1-面有向边链成 CCW 环, 与盘边界严格一致)
dired = {}
unded = {}
for a, b, c in T:
for u, v in ((a, b), (b, c), (c, a)):
dired[(u, v)] = dired.get((u, v), 0) + 1
for (u, v) in dired:
unded[(min(u, v), max(u, v))] = unded.get((min(u, v), max(u, v)), 0) + 1
bnext = {}
for (u, v) in dired:
if unded[(min(u, v), max(u, v))] == 1:
bnext[u] = v
ring = [next(iter(bnext))]
while bnext[ring[-1]] != ring[0]:
ring.append(bnext[ring[-1]])
Nr = len(ring)
for i in range(Nr):
u, v = ring[i], ring[(i + 1) % Nr]
faces.append((u, v + nV, v)) # (F_i, B_{i+1}, F_{i+1})
faces.append((u, u + nV, v + nV)) # (F_i, B_i, B_{i+1})
Ff = np.asarray(faces, np.int64)
Pall = np.vstack([Pf, Pb])
# 定向: 与原模型一致
def signed_vol(P, Tt):
a = P[Tt[:, 0]]; b = P[Tt[:, 1]]; c = P[Tt[:, 2]]
return float(np.sum(np.einsum('ij,ij->i', a, np.cross(b, c))) / 6.0)
vfill = signed_vol(Pall, Ff)
vorig = signed_vol(V, F)
if vfill * vorig < 0:
Ff = Ff[:, [0, 2, 1]]
vfill = -vfill
# 期望体积 = 三角形 2D 面积 x 平均厚度 (验证定向正确性)
exp_vol = float(np.sum(0.5 * np.abs(area) * ((zf[T[:, 0]] + zf[T[:, 1]] + zf[T[:, 2]]
- zb[T[:, 0]] - zb[T[:, 1]] - zb[T[:, 2]]) / 3.0)))
log(f" 填充实体体积 {vfill:.2f} (期望 {exp_vol:.2f}), 原模型 {vorig:.0f}, 定向一致")
if abs(vfill - exp_vol) > 0.02 * abs(exp_vol) + 1e-6:
raise SystemExit("体积校验失败: 面片定向不一致")
# 水密性: 无向边计数 + 有向边方向一致性
e2 = np.sort(np.concatenate([Ff[:, [0, 1]], Ff[:, [1, 2]], Ff[:, [2, 0]]]), axis=1)
ue, cnt = np.unique(e2, axis=0, return_counts=True)
de = np.concatenate([Ff[:, [0, 1]], Ff[:, [1, 2]], Ff[:, [2, 0]]])
ude, udcnt = np.unique(de, axis=0, return_counts=True)
log(f" 水密性: 非流形边(计数!=2) {int((cnt != 2).sum())} / {len(ue)}, "
f"方向冲突边 {int((udcnt != 1).sum())} (两者都应为 0)")
# 镜片光滑度: 前表面相邻面二面角(打印外观指标)
nv = np.cross(Pall[Ff[:, 1]] - Pall[Ff[:, 0]], Pall[Ff[:, 2]] - Pall[Ff[:, 0]])
nv = nv / np.linalg.norm(nv, axis=1, keepdims=True)
fcen = Pall[Ff].mean(1)
emap2 = {}
for fi, (a, b, c) in enumerate(Ff):
for u, v in ((a, b), (b, c), (c, a)):
emap2.setdefault((min(u, v), max(u, v)), []).append(fi)
lang, lloc = [], []
for (u, v), fs in emap2.items():
if len(fs) == 2 and nv[fs[0], 2] > 0.5 and nv[fs[1], 2] > 0.5:
m = fcen[fs[0]]
# 左右镜片膜主体(开口洞腐蚀 0.2 后的掩码, 自动适配任意模型)
_gi = int((m[0] - X0) / RES); _gj = int((m[1] - Y0) / RES)
if (0 <= _gj < lens_mask.shape[0] and 0 <= _gi < lens_mask.shape[1]
and lens_mask[_gj, _gi]):
lang.append(np.degrees(np.arccos(np.clip(nv[fs[0]] @ nv[fs[1]], -1, 1))))
lloc.append(m)
lang = np.asarray(lang)
if len(lang):
k = np.argmax(lang)
log(f" 镜片光滑度(相邻面二面角): 中位 {np.median(lang):.2f}°, p90 {np.percentile(lang, 90):.2f}°, "
f"max {lang[k]:.2f}° @ ({lloc[k][0]:.1f},{lloc[k][1]:.1f}) (中位应<2°)")
# 前表面校验: 陡降格(镜尾尖端/缺口)走廊 +0.3 内防鳍; 平缓格允许弧面
# 盖住框前方(轻微凸出); 低于框面属正常(框体覆盖). 贴皮带(前表面在本
# 地脸面 0.70 内: 贴皮锚/连续权重过渡带, 几何模式)以皮为外边界, 凸出
# 非鳍, 不计入鳍检查(过渡带允许 0.65 圆角下沉)
_skinc = R & have_f & (fz >= hf - 0.70)
_mchk = R & g1c & (gz1f > -1e29) & ~_skinc
_steep = _mchk & (gz1f < gmax2 - 1.2)
gf = (hf - gz1f)[_mchk]
gs = (hf - gz1f)[_steep]
log(f" 前表面相对本地框面: 陡降格最大凸出 {np.nanmax(gs):.3f} (应<0.35), "
f"平缓格最大 {np.nanmax(gf):.3f} (弧面盖框, 允许), 最大低于 {np.nanmin(gf):.3f}")
bmargin = (hb - bzs)[R & have_b]
log(f" 后表面高于最后表面: 最小余量 {np.nanmin(bmargin):.3f} (应>0)")
embed = (fz - hb)[R & real_f]
log(f" 底面嵌入真实脸面: 最小 {np.nanmin(embed):.3f} (应>-0.05, 负=缝隙), "
f"中位 {np.nanmedian(embed):.2f}")
inR = inside_mask(xy, R)
log(f" 圆盘顶点落在 R 内比例 {inR.mean()*100:.1f}%")
# ----------------------- 输出 -----------------------
log("== 8. 输出 ==")
with open(OUT_FILL, "w") as f:
f.write("# fill mesh between glasses and face\n")
f.write(f"o fill\n")
for p in Pall:
f.write(f"v {p[0]:.5f} {p[1]:.5f} {p[2]:.5f}\n")
for a, b, c in Ff:
f.write(f"f {a+1} {b+1} {c+1}\n")
log(f" {OUT_FILL}: {len(Pall)} 顶点, {len(Ff)} 面")
with open(IN_OBJ) as f:
orig = f.read()
with open(OUT_MERGED, "w") as f:
f.write(orig.rstrip("\n") + "\n")
f.write("o fill\n")
off = len(V)
for p in Pall:
f.write(f"v {p[0]:.5f} {p[1]:.5f} {p[2]:.5f}\n")
for a, b, c in Ff:
f.write(f"f {a+1+off} {b+1+off} {c+1+off}\n")
log(f" {OUT_MERGED}: 原模型 + 填充 (顶点偏移 {off})")
# ----------------------- 预览 -----------------------
front = np.where(g1f, gz1, np.nan)
mix = np.where(R & np.isfinite(hf), np.where(np.isfinite(front), np.maximum(front, hf), hf), front)
front = mix
panels = []
span = (4, 12)
def to_img(Grd, span):
G = np.where(np.isfinite(Grd), Grd, span[0])
t = np.clip((G - span[0]) / (span[1] - span[0]), 0, 1)
return (t * 255).astype(np.uint8)
for name, Grd in (("front-depth", front), ("fill-front", np.where(R, hf, np.nan)),
("thickness", np.where(R, hf - hb, np.nan))):
im = Image.fromarray(to_img(np.flipud(Grd), span if "thick" not in name else (0, 4)))
im = im.resize((W * 4, H * 4), Image.NEAREST)
d = ImageDraw.Draw(im)
pts = [((b[0] - X0) / RES * 4, (Y1 - b[1]) / RES * 4) for b in B]
d.line(pts + pts[:1], fill=255, width=2)
panels.append((name, im))
cw = sum(p.width for _, p in panels) + 20
ch = max(p.height for _, p in panels)
combo = Image.new("L", (cw, ch), 60)
xoff = 0
for name, p in panels:
combo.paste(p, (xoff, 0)); xoff += p.width + 10
combo.save(OUT_PREVIEW)
log(f" {OUT_PREVIEW}: 3 面板(前深度/填充前表面/厚度), 红线=平滑边界")
log("\n== 完成 ==")
with open("fill_report.txt", "w") as f:
f.write("\n".join(report) + "\n")
print("报告已写入 fill_report.txt")