#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ vacuum_wrap.py —— 眼镜“抽真空包装” mesh 生成脚本 ================================================ 参考 youshui_result.obj 中新增的 o Transparent 部分(一个完全包裹眼镜外轮廓、 并填充镜片镂空处的水密闭合 mesh), 本脚本从 youshui.obj + index.txt 出发重新生成它。 输入/输出(自动探测, 也可用 --obj/--index/--out 显式指定): 模型 本目录最大的非输出 *.obj(排除 *result*/*wrap*/*_fill*) 索引 模型同目录的 index.txt(眼镜顶点索引, 每行一个, 0-based) 输出 模型同目录 <模型名>_wrap_result.obj = 原模型 + 追加 o Transparent 处理其它数据集时, 把本脚本拷到该数据目录下直接运行即可。 思路(“抽真空包装”): 1. 由 index.txt 提取眼镜子网格(三个顶点都在索引里的面); 2. 判定正面视线轴后, 只取“朝前的面”(法线与视线轴余弦 > 阈值)投影 栅格化, 形态学闭运算补缝, 取含镜片镂空的最大连通组件, 内部封闭镂空自动填满 → 得到镜框正面“外轮廓”多边形 (镜片镂空被盖住, 鼻梁处的凹口保留; 向后弯折的镜腿被排除, 包装不超出眼镜正面外框); 3. 在外轮廓内铺一层网格点, 作为“包装薄膜”的采样点; 4. 每个网格点从正前方贴到眼镜表面(取该处最靠前的表面高度); 镜片镂空处没有表面 → 用周边镜框高度拟合二次趋势面外推 (跟随镜框倾斜/缓弯, 平齐不下陷), 残差在孔内做调和衔接, 平滑无台阶; footprint 按镜架整体投影剪裁, 不超出镜架; 5. 薄膜沿视线后退 thickness 得到背面膜(最深不超过镜架最后缘), 周边侧壁缝合 → 一个水密(watertight)闭合的包装 mesh; 6. 棋盘式交替对角线剖分; 薄膜边缘沿边界曲线做一维拉普拉斯收边, 磨掉 footprint 栅格化的锯齿轮廓; 7. 原样拷贝原 obj(仅修正 mtllib/usemtl 指向本目录的 youshui.mtl), 把新 mesh 追加在文件末尾(o Transparent / usemtl Transparent)。 只依赖 numpy; 所有读写都限制在本脚本所在目录。 与参考结果 youshui_result.obj 的对比: 结构一致(原模型 + o Transparent 水密闭合 包装 mesh); 本脚本只包装镜框正面核心(含镜片镂空填充), 不包裹向后弯折的镜腿, 因此与参考一样不超出眼镜正面外框、z 向深度一致; 参考在镜腿后另有一小片 手工处理的收拢薄膜, 此处不复现。 用法: python3 vacuum_wrap.py python3 vacuum_wrap.py --spacing 0.06 --thickness 3.4 --cell 0.05 --front 0.25 """ import os import glob import argparse import numpy as np SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) def resolve(p): """路径一律相对本脚本目录解析, 不碰其它目录。""" return p if os.path.isabs(p) else os.path.join(SCRIPT_DIR, p) # ----------------------------------------------------------------- OBJ 读取 def load_obj(path): """读取顶点与三角面(多于三个顶点的面做三角扇拆分)。""" verts, faces = [], [] with open(path, 'r', errors='ignore') as f: for line in f: if line.startswith('v '): p = line.split() verts.append((float(p[1]), float(p[2]), float(p[3]))) elif line.startswith('f '): ids = [int(x.split('/')[0]) - 1 for x in line.split()[1:]] for k in range(1, len(ids) - 1): faces.append((ids[0], ids[k], ids[k + 1])) return np.asarray(verts, np.float64), np.asarray(faces, np.int64) def load_index(path, nverts): idx = [] with open(path) as f: for line in f: line = line.strip() if line: idx.append(int(line)) idx = np.asarray(idx, np.int64) if len(idx) == 0: raise RuntimeError('index.txt 为空') if idx.min() < 0 or idx.max() >= nverts: raise RuntimeError('index.txt 中索引越界(0..%d)' % (nverts - 1)) return idx # ------------------------------------------------------------ 正面朝向判定 def pick_front_axis(verts, sub, wind, coarse): """判定“抽真空”的视线轴: 沿该轴投影时, 眼镜区域内部会出现封闭的 镜片镂空(孔洞), 孔洞面积最大的轴即包装轴(如从正面看眼镜)。 wind: 整mesh体积符号(+1=法线朝外), 用于确定正面在轴的正/负侧。 返回 (k, sgn, u轴, v轴), (u, v, w=sgn*第k轴) 构成右手系。""" p0, p1, p2 = verts[sub[:, 0]], verts[sub[:, 1]], verts[sub[:, 2]] n = np.cross(p1 - p0, p2 - p0) sgn_area = wind * n.sum(axis=0) # 各轴带符号投影面积 best_hole, best_k = -1.0, 2 rc = max(2, int(round(0.6 / coarse))) # 较大闭运算: 桥接处覆盖稀疏时也能封住镂空 for k in range(3): u, v = (k + 1) % 3, (k + 2) % 3 tri_uv = verts[sub][:, :, [u, v]] tri_w = verts[sub][:, :, k] cover, _, _ = rasterize(tri_uv, tri_w, coarse) foot = fill_enclosed(binary_closing(cover, rc)) hole = float((foot & ~cover).sum()) * coarse * coarse print(' 投影轴 %s: 表面覆盖 %.1f, 封闭镂空 %.1f' % ('xyz'[k], cover.sum() * coarse * coarse, hole)) if hole > best_hole: best_hole, best_k = hole, k k = best_k sgn = 1.0 if sgn_area[k] >= 0 else -1.0 return k, sgn, (k + 1) % 3, (k + 2) % 3 # ------------------------------------------------------------------ 栅格化 def rasterize(tri_uv, tri_w, h, keep=None, collect=False): """把子网格三角形投影到 (u,v) 平面栅格化。 keep: 可选的三角形布尔掩码(如只保留朝前的面)。 collect: 额外收集 每格覆盖的三角形编号表(用于后续精确求高)。 返回 cover(是否被覆盖) 与 topz(该格内最靠前的表面高度 w); collect=True 时再附带 cell_tris。""" umin, vmin = tri_uv[..., 0].min(), tri_uv[..., 1].min() umax, vmax = tri_uv[..., 0].max(), tri_uv[..., 1].max() # 留出边距(>= 闭运算半径+2), 保证膨胀/洪泛不会越过网格边界 mg = 8 umin -= mg * h; vmin -= mg * h nu = int(np.ceil((umax - umin) / h)) + 1 + mg nv = int(np.ceil((vmax - vmin) / h)) + 1 + mg if keep is not None: # 网格 bbox 一律取自全集, 保证可对齐 tri_uv = tri_uv[keep] tri_w = tri_w[keep] nu -= nu % 2; nv -= nv % 2 # 偶数尺寸, 红黑迭代对齐 cover = np.zeros((nu, nv), bool) topz = np.full((nu, nv), np.nan) cell_tris = {} if collect else None ntri = tri_uv.shape[0] for t in range(ntri): a = tri_uv[t, 0]; b = tri_uv[t, 1]; c = tri_uv[t, 2] wa, wb, wc = tri_w[t] i0 = max(0, int(np.floor((min(a[0], b[0], c[0]) - umin) / h)) - 1) i1 = min(nu - 1, int(np.floor((max(a[0], b[0], c[0]) - umin) / h)) + 1) j0 = max(0, int(np.floor((min(a[1], b[1], c[1]) - vmin) / h)) - 1) j1 = min(nv - 1, int(np.floor((max(a[1], b[1], c[1]) - vmin) / h)) + 1) if i1 < i0 or j1 < j0: continue cx = umin + (np.arange(i0, i1 + 1) + 0.5) * h cy = vmin + (np.arange(j0, j1 + 1) + 0.5) * h CX, CY = np.meshgrid(cx, cy, indexing='ij') v0 = c - a v1 = b - a d00 = v0 @ v0; d01 = v0 @ v1; d11 = v1 @ v1 den = d00 * d11 - d01 * d01 if abs(den) < 1e-14: # 投影退化(近似垂直面), 跳过 continue px, py = CX - a[0], CY - a[1] d20 = px * v0[0] + py * v0[1] d21 = px * v1[0] + py * v1[1] bu = (d11 * d20 - d01 * d21) / den bv = (d00 * d21 - d01 * d20) / den ins = (bu >= -1e-9) & (bv >= -1e-9) & (bu + bv <= 1 + 1e-9) if not ins.any(): continue z = wa + bu * (wb - wa) + bv * (wc - wa) sub = topz[i0:i1 + 1, j0:j1 + 1] topz[i0:i1 + 1, j0:j1 + 1] = np.where(ins, np.fmax(sub, z), sub) cover[i0:i1 + 1, j0:j1 + 1] |= ins if collect: ii, jj = np.nonzero(ins) for ci_, cj_ in zip(ii, jj): cell_tris.setdefault((int(ci_) + i0, int(cj_) + j0), []).append(t) if collect: return cover, topz, (umin, vmin), cell_tris return cover, topz, (umin, vmin) # ------------------------------------------------------- 精确薄膜高度 def exact_film_heights(pts, tri_uv, tri_w, cell_tris, u0, v0, h): """每个薄膜网格点沿视线轴与子网格求精确交点, 取最靠前的高度。 相比栅格最近格值, 没有分段常数的阶梯量化, 薄膜贴合更光滑。""" n = len(pts) Z = np.full(n, np.nan) for p in range(n): x, y = pts[p, 0], pts[p, 1] i = int((x - u0) / h) j = int((y - v0) / h) ids = cell_tris.get((i, j)) if not ids: continue best = -np.inf for t in ids: a = tri_uv[t, 0]; b = tri_uv[t, 1]; c = tri_uv[t, 2] e0 = c - a e1 = b - a d00 = e0 @ e0; d01 = e0 @ e1; d11 = e1 @ e1 den = d00 * d11 - d01 * d01 if abs(den) < 1e-14: continue d20 = (x - a[0]) * e0[0] + (y - a[1]) * e0[1] d21 = (x - a[0]) * e1[0] + (y - a[1]) * e1[1] bu = (d11 * d20 - d01 * d21) / den bv = (d00 * d21 - d01 * d20) / den if bu >= -1e-9 and bv >= -1e-9 and bu + bv <= 1 + 1e-9: wa, wb, wc = tri_w[t] z = wa + bu * (wb - wa) + bv * (wc - wa) if z > best: best = z if best > -np.inf: Z[p] = best return Z # ---------------------------------------------------------------- 形态学 def dilate(m): r = m.copy() r[1:, :] |= m[:-1, :] r[:-1, :] |= m[1:, :] r[:, 1:] |= m[:, :-1] r[:, :-1] |= m[:, 1:] return r def erode(m): r = m.copy() r[1:, :] &= m[:-1, :] r[:-1, :] &= m[1:, :] r[:, 1:] &= m[:, :-1] r[:, :-1] &= m[:, 1:] return r def binary_closing(mask, r): if r <= 0: return mask.copy() m = np.pad(mask, r, constant_values=False) # pad 足够大, 膨胀不会越界 for _ in range(r): m = dilate(m) for _ in range(r): m = erode(m) return m[r:-r, r:-r] def fill_enclosed(mask): """从外部洪泛, 补上内部封闭的镂空(镜片孔洞), 开向边界的凹口(鼻梁)保留。""" outside = np.zeros_like(mask) outside[0, :] = ~mask[0, :] outside[-1, :] = ~mask[-1, :] outside[:, 0] = ~mask[:, 0] outside[:, -1] = ~mask[:, -1] while True: new = dilate(outside) & ~mask if np.array_equal(new, outside): break outside = new return ~outside def largest_filled_component(mask): """先补内部封闭孔洞, 再取面积最大的连通组件(含镜片镂空的镜框核心), 丢掉镜腿等散碎覆盖 → 包装不会越过镜框正面外框。""" filled = fill_enclosed(mask) BIG = filled.size + 1 lab = np.where(filled, np.arange(filled.size).reshape(filled.shape), BIG) while True: # 4邻域最小标签传播 → 连通组件标记 new = lab.copy() for (s1, s2) in (((slice(1, None), slice(None)), (slice(None, -1), slice(None))), ((slice(None, -1), slice(None)), (slice(1, None), slice(None))), ((slice(None), slice(1, None)), (slice(None), slice(None, -1))), ((slice(None), slice(None, -1)), (slice(None), slice(1, None)))): cand = np.full_like(lab, BIG) cand[s1] = lab[s2] cand[~filled] = BIG # 标签只在组件内传播, 不漏进空格 np.minimum(new, cand, out=new) if np.array_equal(new, lab): break lab = new flat = lab.ravel() labels = flat[flat < BIG] if len(labels) == 0: raise RuntimeError('没有可用连通组件') best_lab, best_area = None, -1 for L in np.unique(labels): a = int((flat == L).sum()) if a > best_area: best_area, best_lab = a, L return lab == best_lab # ----------------------------------------------------------- 外轮廓追踪 def trace_all_cycles(mask): """沿 cell 边缘追踪 mask 的所有边界环(内部在行进方向左侧)。""" nu, nv = mask.shape edges = {} def add(a, b): edges.setdefault(a, []).append(b) I, J = np.nonzero(mask) for i, j in zip(I, J): if i == 0 or not mask[i - 1, j]: # 左侧外部 → 向 -v 走 add((i, j + 1), (i, j)) if i == nu - 1 or not mask[i + 1, j]: # 右侧外部 → 向 +v 走 add((i + 1, j), (i + 1, j + 1)) if j == 0 or not mask[i, j - 1]: # 下侧外部 → 向 +u 走 add((i, j), (i + 1, j)) if j == nv - 1 or not mask[i, j + 1]: # 上侧外部 → 向 -u 走 add((i + 1, j + 1), (i, j + 1)) cycles, visited = [], set() for start in edges: if start in visited: continue cyc = [start]; visited.add(start); cur = start while True: nxts = [e for e in edges.get(cur, ()) if e not in visited] if not nxts: break nxt = nxts[0] cyc.append(nxt); visited.add(nxt); cur = nxt cycles.append(cyc) return cycles def signed_area(cyc): a = 0.0 for k in range(len(cyc)): x1, y1 = cyc[k]; x2, y2 = cyc[(k + 1) % len(cyc)] a += x1 * y2 - x2 * y1 return a / 2.0 def trace_outer_cycle(mask): """返回 (最大面积的边界环, 显著环个数)。 环的方向统一为逆时针(内部在左侧)。""" cycles = trace_all_cycles(mask) if not cycles: raise RuntimeError('外轮廓追踪失败: mask 无边界') cycles.sort(key=lambda c: abs(signed_area(c)), reverse=True) main = cycles[0] if signed_area(main) < 0: main = main[::-1] ref = abs(signed_area(main)) n_sig = 1 + sum(1 for c in cycles[1:] if abs(signed_area(c)) > 0.02 * ref) return main, n_sig # -------------------------------------------------------------- 点在多边形内 def points_in_poly(pts, poly): """even-odd 规则, pts:(N,2), poly:(K,2) 隐式闭合。""" x, y = pts[:, 0], pts[:, 1] inside = np.zeros(len(pts), bool) x1, y1 = poly[:, 0], poly[:, 1] x2, y2 = np.roll(x1, -1), np.roll(y1, -1) with np.errstate(divide='ignore', invalid='ignore'): for k in range(len(poly)): cond = (y1[k] > y) != (y2[k] > y) xint = (x2[k] - x1[k]) * (y - y1[k]) / (y2[k] - y1[k]) + x1[k] inside ^= cond & (x < xint) return inside # --------------------------------------------- 镂空处高度场(与镜框平齐) def _neighbor_avg4(Z, ok_mask): """4 邻域均值: 只统计 ok_mask 为真且值非 nan 的格子。 返回 (均值, 计数)。""" acc = np.zeros(Z.shape) cnt = np.zeros(Z.shape) nanZ = np.isnan(Z) for sl_src, sl_dst in ((slice(None, -1), slice(1, None)), (slice(1, None), slice(None, -1))): have = ok_mask[sl_src, :] & ~nanZ[sl_src, :] acc[sl_dst, :] += np.where(have, Z[sl_src, :], 0.0) cnt[sl_dst, :] += have have = ok_mask[:, sl_src] & ~nanZ[:, sl_src] acc[:, sl_dst] += np.where(have, Z[:, sl_src], 0.0) cnt[:, sl_dst] += have return np.where(cnt > 0, acc / np.maximum(cnt, 1), np.nan), cnt def _fit_surface(di, dj, z): """最小二乘拟合 z ≈ a + b·di + c·dj (平面趋势)。 采样点只分布在镂空周围的环带上: 二次趋势面对这种采样是病态 外推(环带上常数项与二次项近似共线, 中心值可飘出 ±2), 平面 趋势则稳定; 曲率起伏留给残差调和项(边界值插值, 良定)。 点太少/退化时回退到常数。返回 (系数, 项数)。""" n = len(z) di = di.astype(np.float64) dj = dj.astype(np.float64) if n >= 7: A = np.stack([np.ones(n), di, dj], 1) G = A.T @ A G[np.diag_indices_from(G)] += 1e-6 * max(1.0, G.diagonal().max()) try: return np.linalg.solve(G, A.T @ z), 3 except np.linalg.LinAlgError: pass return np.array([float(np.mean(z))]), 1 def _eval_surface(coef, nterm, di, dj): di = np.asarray(di, np.float64) dj = np.asarray(dj, np.float64) if nterm == 3: return coef[0] + coef[1] * di + coef[2] * dj return np.full(di.shape, coef[0], float) def trend_fill(Z, valid, select=None, blend_passes=500, omega=0.8): """镜片镂空处高度场填充: 全局平面趋势 + 残差调和, 平齐且平滑。 薄膜要"平齐"(跟随镜框正面的倾斜)也要平滑(无台阶无尖峰): 1) 用正面表面(select)上的贴合点拟合平面趋势 T(镜框正面倾角)。 采样点铺满整片 footprint, 平面拟合稳定(二次面在只环绕 镜片镂空的环带采样下是病态外推, 中心值可飘出 ±2); 2) 残差 R = 贴合高度 - T 在**所有**贴合点锚定(含外缘陡边/ 镜托/镜腿根处的深锚点——薄膜要包住它们), 镂空处解 Laplace 方程: 调和解无内部极值、不会过冲, 天然平滑; 镜片中央 ≈ 趋势面(平齐), 孔缘精确衔接, 深锚点周围形成 光滑下凹而不是尖 crater。 blend_passes 为残差调和迭代次数; 迭代用欠松弛(ω≤1)—— 超松弛会放大棋盘模式留下二阶差分尖峰。""" Z = Z.copy() unknown = valid & np.isnan(Z) if not unknown.any(): return Z known = valid & ~np.isnan(Z) # 1) 平面趋势: 只用正面表面高度, 稳健剔除离群锚点 K = known if select is None else (known & select) if K.sum() >= 7: ki, kj = np.nonzero(K) for _ in range(2): coef, nterm = _fit_surface(ki - ki.mean(), kj - kj.mean(), Z[ki, kj]) if len(ki) < 16: break res = Z[ki, kj] - _eval_surface( coef, nterm, ki - ki.mean(), kj - kj.mean()) keep = np.abs(res) <= max(0.12, 4.0 * np.median(np.abs(res))) if keep.all() or keep.sum() < 8: break ki, kj = ki[keep], kj[keep] ci, cj = ki.mean(), kj.mean() elif K.sum() > 0: coef, nterm = np.array([float(np.mean(Z[K]))]), 1 ci = cj = 0.0 else: coef, nterm = np.array([float(np.nanmean(Z[known]))]), 1 ci = cj = 0.0 ii, jj = np.indices(Z.shape) T = _eval_surface(coef, nterm, ii - ci, jj - cj) # 2) 残差调和填充: 全体镂空处一起解, 锚定所有贴合点 R = np.where(known, Z - T, 0.0) for _ in range(blend_passes): avg, cnt = _neighbor_avg4(R, valid) upd = unknown & (cnt > 0) if not upd.any(): break R[upd] += omega * (avg[upd] - R[upd]) Z[unknown] = T[unknown] + R[unknown] return Z # ------------------------------------------------------------ 贴合带平滑 def smooth_adherence(Z, known, valid, thr, spacing, alpha=0.6, passes=60, near=0.3, near_w=0.4): """贴合带受限平滑(消除镜框陡坡处高度场的棱面)。 高度场 z(u,v) 表示坡度为 s 的表面时, 其曲率会被放大 ~s^2 倍, 镜框侧弯坡度大处逐格贴合会出现 30-60 度的相邻面法线跳变。 本函数把与 8 邻域均值偏差超过 thr 的点向邻域均值加权松弛: - 贴合点(known) 权重 1: 磨掉曲率突变棱面, 薄膜在陡坡处"绷而不贴"; 坡度平缓处/均匀斜面的偏差本来就小于 thr, 不会被改动; - 绷紧区离贴合边界 near 以内的点权重 near_w: 轻推, 软化接缝褶皱; - 更远处不参与(保持真空 Laplace 解)。 thr 的量纲是高度: 相邻面法线跳变约 atan(4*thr/spacing)。""" band = known.copy() for _ in range(max(1, int(round(near / spacing)))): band = dilate(band) w = np.where(known, 1.0, np.where(band & valid, near_w, 0.0)) Z = Z.copy() ni, nj = Z.shape for _ in range(passes): Zp = np.pad(Z, 1, constant_values=np.nan) Vp = np.pad(valid, 1, constant_values=False) acc = np.zeros(Z.shape) cnt = np.zeros(Z.shape) for di in (-1, 0, 1): for dj in (-1, 0, 1): if di == 0 and dj == 0: continue sl = (slice(1 + di, 1 + di + ni), slice(1 + dj, 1 + dj + nj)) nb = np.where(Vp[sl], Zp[sl], np.nan) ok = ~np.isnan(nb) acc += np.where(ok, nb, 0.0) cnt += ok avg = np.where(cnt > 0, acc / np.maximum(cnt, 1), np.nan) res = np.abs(Z - avg) upd = (w > 0) & (cnt > 0) & (res > thr) if not upd.any(): break Z[upd] += w[upd] * alpha * (avg[upd] - Z[upd]) return Z, w # ------------------------------------------------------------ 网格清理 def drop_small_components(faces, tags=None): """只保留面数最多的连通组件(正常情况下包装 mesh 只有唯一一层壳, 栅格噪声/异常轮廓可能产生碎块或多壳, 一律丢弃)。 tags: 面标签(0=顶面 1=底面 2=侧壁), 随面一同过滤; 返回 faces 或 (faces, tags)。""" from collections import defaultdict parent = list(range(len(faces))) def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x e2f = defaultdict(list) for fi, f in enumerate(faces): for x, y in ((f[0], f[1]), (f[1], f[2]), (f[2], f[0])): e2f[(min(x, y), max(x, y))].append(fi) for fl in e2f.values(): for fi in fl[1:]: parent[find(fi)] = find(fl[0]) groups = defaultdict(int) for fi in range(len(faces)): groups[find(fi)] += 1 if len(groups) <= 1: return (faces, tags) if tags is not None else faces best = max(groups.values()) if best < len(faces) * 0.9: print(' 警告: 网格有 %d 个组件, 仅保留最大者(%d/%d 面)' % (len(groups), best, len(faces))) keep = [fi for fi in range(len(faces)) if groups[find(fi)] == best] if len(keep) != len(faces): keep = np.asarray(keep) faces = faces[keep] if tags is not None: tags = tags[keep] return (faces, tags) if tags is not None else faces def split_pinch_vertices(verts, faces): """拆分“夹点”顶点(一个顶点被多片不相连的面共享, 网格对角接触造成), 拆分后网格为严格的顶点流形。返回 (verts, faces)。""" from collections import defaultdict vfaces = defaultdict(list) for fi, f in enumerate(faces): for v in f: vfaces[int(v)].append(fi) # v -> [ (本组面id列表), ... ] splits = [] for v, fl in vfaces.items(): if len(fl) < 2: continue edge_faces = defaultdict(list) for fi in fl: f = faces[fi] for x, y in ((f[0], f[1]), (f[1], f[2]), (f[2], f[0])): u = y if x == v else (x if y == v else None) if u is not None: edge_faces[int(u)].append(fi) parent = {fi: fi for fi in fl} def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x for fl2 in edge_faces.values(): for fi in fl2[1:]: parent[find(fi)] = find(fl2[0]) groups = defaultdict(list) for fi in fl: groups[find(fi)].append(fi) if len(groups) > 1: splits.append((v, [g for g in groups.values()])) if not splits: return verts, faces faces = faces.copy() extra_verts = [] for v, groups in splits: for g in groups[1:]: nv = len(verts) + len(extra_verts) extra_verts.append(verts[v]) for fi in g: f = faces[fi] faces[fi] = [nv if x == v else x for x in f] if extra_verts: verts = np.vstack([verts, np.asarray(extra_verts)]) return verts, faces # ------------------------------------------------------------ 网格松弛 def smooth_closed_curve(P, passes=3, lam=0.5, mu=-0.53): """闭合折线 Taubin(λ/μ) 低通平滑: 消除栅格楼梯而不收缩轮廓。 交替"向邻点均值收缩(λ)"与"反向外推(μ)", 大尺度形状(鼻梁凹口、 镜框圆角)保留, 只磨掉 cell 量级的锯齿。用于外轮廓多边形。""" P = np.array(P, np.float64, copy=True) for _ in range(passes): for a in (lam, mu): avg = 0.5 * (np.roll(P, 1, axis=0) + np.roll(P, -1, axis=0)) P = P + a * (avg - P) return P def smooth_rim_curve(verts, faces, tags, passes=20, lam=0.5): """薄膜周边收边: 沿顶膜边界曲线做 1D 拉普拉斯平滑。 footprint 是网格点掩码, 薄膜边缘天生带 spacing 级楼梯。本函数: - 取顶面(tags==0)的边界边 → 每个边缘顶点恰有两个曲线邻居, 沿这条闭合曲线反复做拉普拉斯松弛, 把楼梯磨成圆角; 曲线点数近千、点距 ~spacing, 整体收缩量可忽略; - 底膜边缘顶点(顶/底膜是同一网格点的上下两个副本, 组装时 (u,v) 相同)通过侧壁找到配对, 加上与顶膜边缘相同的位移, 侧壁整体随缘平移, 不会被剪切; - 薄膜内部完全不被动(贴合不受影响)。 注意: 不能改用对整个闭合 mesh 的拉普拉斯松弛来做收边/平滑—— 侧壁是 spacing x thickness 的细长三角形(长宽比可达 70:1), 伞形算子会把膜面边缘顶点拽向侧壁另一端的底膜顶点, 反而在 周边产生大幅 z 向锯齿(膜面顶点也会被拉伸错位)。 tags: build_wrap_mesh 的面标签(0=顶面 1=底面 2=侧壁)。""" from collections import Counter, defaultdict topF = faces[tags == 0] botF = faces[tags == 1] wallF = faces[tags == 2] in_bot = np.zeros(len(verts), bool) in_bot[botF.ravel()] = True # 顶膜边界无向边(在顶面内出现一次的边) → 曲线邻接表 cnt = Counter() for f in topF: for x, y in ((f[0], f[1]), (f[1], f[2]), (f[2], f[0])): cnt[(min(x, y), max(x, y))] += 1 nbr = {} for (a, b) in (e for e, c in cnt.items() if c == 1): nbr.setdefault(int(a), []).append(int(b)) nbr.setdefault(int(b), []).append(int(a)) # 顶/底膜边缘配对: 与顶膜边缘顶点共享侧壁且 (u,v) 相同的底膜点 v2wall = defaultdict(list) for fi, f in enumerate(wallF): for x in f: v2wall[int(x)].append(fi) keys = sorted(nbr.keys()) pair = {} for v in keys: pv = verts[v] for fi in v2wall.get(v, ()): for x in wallF[fi]: x = int(x) if in_bot[x] and abs(verts[x, 0] - pv[0]) < 1e-9 \ and abs(verts[x, 1] - pv[1]) < 1e-9: pair[v] = x # 沿曲线拉普拉斯松弛(Jacobi) pos = {v: verts[v].copy() for v in keys} for _ in range(passes): new = {} for v, P in pos.items(): a, b = nbr[v] new[v] = P + lam * (0.5 * (pos[a] + pos[b]) - P) pos = new # 应用: 顶膜边缘取新位置, 底膜配对点加相同位移(侧壁随缘平移) for v in keys: delta = pos[v] - verts[v] verts[v] = pos[v] b = pair.get(v) if b is not None: verts[b] += delta return verts # ------------------------------------------------------------ 包装 mesh 组装 def build_wrap_mesh(U, V, Z, inside, thickness, w_floor=None): """顶面膜 + 底面膜 + 侧壁 → 闭合网格。 顶点/面均为 (u,v,w) 坐标, 返回 (verts, faces, tags), tags: 每个面的标签(0=顶面 1=底面 2=侧壁), 供收边平滑 定位薄膜边缘(周边锯齿所在的侧壁)。 w_floor: 底面膜最深不超过该值(默认=镜架最后缘再退 0.1), 防止薄膜沿侧影线下潜时背面膜探出镜架范围。""" ni, nj = Z.shape idx = -np.ones((ni, nj), np.int64) ii, jj = np.nonzero(inside) n = len(ii) idx[ii, jj] = np.arange(n) zt = Z[ii, jj] zb = zt - thickness if w_floor is not None: zb = np.maximum(zb, w_floor) verts = np.concatenate([ np.stack([U[ii, jj], V[ii, jj], zt], axis=1), np.stack([U[ii, jj], V[ii, jj], zb], axis=1), ]) # 单元格四角: a=(i,j) b=(i+1,j) c=(i+1,j+1) d=(i,j+1) a = idx[:-1, :-1]; b = idx[1:, :-1]; c = idx[1:, 1:]; d = idx[:-1, 1:] ok = (a >= 0) & (b >= 0) & (c >= 0) & (d >= 0) ci, cj = np.nonzero(ok) A, B, C, D = a[ci, cj], b[ci, cj], c[ci, cj], d[ci, cj] # 棋盘式交替对角线: 相邻单元的剖分方向相反, 避免整片同向的机械条纹 m0 = ((ci + cj) % 2 == 0) topF = np.concatenate([ np.stack([A[m0], B[m0], C[m0]], 1), np.stack([A[m0], C[m0], D[m0]], 1), np.stack([A[~m0], B[~m0], D[~m0]], 1), np.stack([B[~m0], C[~m0], D[~m0]], 1)]) botF = np.concatenate([ np.stack([A[m0], C[m0], B[m0]], 1), np.stack([A[m0], D[m0], C[m0]], 1), np.stack([A[~m0], D[~m0], B[~m0]], 1), np.stack([B[~m0], D[~m0], C[~m0]], 1)]) + n # 顶面的边界有向边(内部在左侧) → 缝合到背面 # 先做无向计数找边界(内部边在相邻两面中方向相反、各出现一次), # 再取其在面中出现的方向用于侧壁绕向 from collections import Counter und = Counter() directed = [] for f in topF: for x, y in ((f[0], f[1]), (f[1], f[2]), (f[2], f[0])): key = (x, y) if x < y else (y, x) und[key] += 1 directed.append((x, y)) bndset = {e for e, c in und.items() if c == 1} rim_pairs = [(p, q) for (p, q) in directed if ((p, q) if p < q else (q, p)) in bndset] rim = [[q, p, p + n] for (p, q) in rim_pairs] rim += [[q, p + n, q + n] for (p, q) in rim_pairs] faces = np.concatenate([topF, botF, np.asarray(rim, np.int64)]) tags = np.concatenate([np.zeros(len(topF), np.int8), np.ones(len(botF), np.int8), np.full(len(rim), 2, np.int8)]) # 去掉未被任何面引用的孤立顶点 faces, tags = drop_small_components(faces, tags) verts, faces = split_pinch_vertices(verts, faces) used = np.zeros(len(verts), bool) used[faces.ravel()] = True remap = -np.ones(len(verts), np.int64) remap[used] = np.arange(used.sum()) return verts[used], remap[faces], tags def check_closed(faces, nverts): """水密性与拓扑自检: 每条有向边恰好出现一次且其反向也存在 (方向一致的水密闭合), 欧拉示性数 V - E + F = 2。""" d = {} for f in faces: for x, y in ((f[0], f[1]), (f[1], f[2]), (f[2], f[0])): d[(x, y)] = d.get((x, y), 0) + 1 bad = [e for e, c in d.items() if c != 1 or (e[1], e[0]) not in d] n_e = 3 * len(faces) // 2 chi = nverts - n_e + len(faces) return len(bad) == 0, chi # ------------------------------------------------------------------- 主流程 def default_obj_path(): """自动探测输入模型: 取脚本所在目录下最大的 *.obj, 排除历史输出(*_result/wrap_result/*_fill* 等)。""" cands = [] for p in glob.glob(os.path.join(SCRIPT_DIR, '*.obj')): b = os.path.basename(p).lower() if 'result' in b or 'wrap' in b or '_fill' in b: continue cands.append((os.path.getsize(p), p)) if not cands: return None return max(cands)[1] def main(): ap = argparse.ArgumentParser(description='眼镜“抽真空包装”mesh 生成') ap.add_argument('--obj', default=None, help='原始模型 obj(默认: 自动探测本目录最大的非输出 obj)') ap.add_argument('--index', default=None, help='眼镜顶点索引(默认: 模型同目录的 index.txt)') ap.add_argument('--out', default=None, help='输出路径(默认: 模型同目录 <模型名>_wrap_result.obj)') ap.add_argument('--cell', type=float, default=0.05, help='外轮廓栅格分辨率(模型单位, 默认0.05)') ap.add_argument('--spacing', type=float, default=0.06, help='包装薄膜网格点间距(默认0.06)') ap.add_argument('--thickness', type=float, default=3.4, help='背面膜相对薄膜的后退厚度(默认3.4)') ap.add_argument('--front', type=float, default=0.25, help='朝前面阈值(法线与视线轴余弦, 默认0.25); ' '调小会包含更斜的面乃至镜腿, 调大只贴更正对的面') ap.add_argument('--closing', type=int, default=4, help='形态学闭运算半径(格数, 默认4)') ap.add_argument('--hole-blend', type=int, default=500, help='镜片镂空处残差调和的迭代次数(默认500): ' '趋势面跟随镜框平齐, 欠松弛迭代负责孔缘平滑衔接; ' '越大越平滑') ap.add_argument('--rim-smooth', type=int, default=20, help='周边收边平滑次数(0=关闭, 默认20): 沿薄膜边缘曲线 ' '拉普拉斯平滑, 磨掉轮廓栅格锯齿') ap.add_argument('--poly-smooth', type=int, default=3, help='外轮廓多边形 Taubin 平滑次数(0=关闭, 默认3), ' '从源头去掉 cell 级楼梯') ap.add_argument('--band-smooth', type=float, default=0.035, help='贴合带平滑阈值(×spacing, 0=关闭; 磨掉陡坡棱面)') ap.add_argument('--mtl', default=None, help='覆盖 mtllib 行的材料文件名(默认自动找本目录 *.mtl)') args = ap.parse_args() if args.obj is None: args.obj = default_obj_path() if args.obj is None: raise RuntimeError('未指定 --obj, 且本目录没有可自动探测的模型文件') obj_path = resolve(args.obj) obj_dir = os.path.dirname(obj_path) if args.index is None: args.index = os.path.join(obj_dir, 'index.txt') if args.out is None: stem = os.path.splitext(os.path.basename(obj_path))[0] args.out = os.path.join(obj_dir, stem + '_wrap_result.obj') idx_path, out_path = resolve(args.index), resolve(args.out) print('[1/6] 读取 %s ...' % os.path.basename(obj_path)) verts, faces = load_obj(obj_path) print(' %d 顶点, %d 三角面' % (len(verts), len(faces))) gidx = load_index(idx_path, len(verts)) print(' index.txt: %d 个眼镜顶点' % len(gidx)) # ---- 眼镜子网格 & 正面轴向 in_g = np.zeros(len(verts), bool) in_g[gidx] = True sel = in_g[faces].all(axis=1) sub = faces[sel] if len(sub) < 10: raise RuntimeError('眼镜子网格面太少(%d), 请检查 index.txt' % len(sub)) print(' 眼镜子网格: %d 面' % len(sub)) # 整 mesh 体积符号 → 面法线是否朝外 p0, p1, p2 = verts[faces[:, 0]], verts[faces[:, 1]], verts[faces[:, 2]] vol = np.einsum('ij,ij->i', p0, np.cross(p1, p2)).sum() / 6.0 wind = 1.0 if vol >= 0 else -1.0 coarse = max(0.15, args.cell * 1.5) print(' 判定包装视线轴(封闭镂空最大者):') k, sgn, ax_u, ax_v = pick_front_axis(verts, sub, wind, coarse) print(' 正面视线轴 = %s, u=轴%d(%s), v=轴%d(%s)' % ('xyz'[k] + ('+' if sgn > 0 else '-'), ax_u, 'xyz'[ax_u], ax_v, 'xyz'[ax_v])) tri_uv = verts[sub][:, :, [ax_u, ax_v]] tri_w = sgn * verts[sub][:, :, k] # ---- 朝前面掩码(法线与视线轴余弦) → 只包装镜框正面, 排除向后弯折的镜腿 sp0, sp1, sp2 = verts[sub[:, 0]], verts[sub[:, 1]], verts[sub[:, 2]] nrm = np.cross(sp1 - sp0, sp2 - sp0) ln = np.linalg.norm(nrm, axis=1) front = (sgn * nrm[:, k] / np.where(ln > 1e-12, ln, 1.0)) > args.front print(' 朝前面: %d/%d (front>%.2f)' % (front.sum(), len(sub), args.front)) # ---- 栅格化 → 外轮廓(闭运算补缝 → 只取含镜片镂空的镜框核心组件, # 内部封闭孔洞填满; 半径自适应有限加大, 不会封死鼻梁凹口) print('[2/6] 栅格化朝前表面 (cell=%.2f) ...' % args.cell) cover, topz, (u0, v0) = rasterize(tri_uv, tri_w, args.cell, front) cover_all, topz_all, (ua, va), cell_tris = rasterize( tri_uv, tri_w, args.cell, collect=True) assert (ua, va) == (u0, v0) # 两次栅格化共用同一网格 print(' 朝前表面覆盖 %d/%d 格' % (cover.sum(), cover.size)) r = args.closing cap = max(args.closing + 4, int(round(1.0 / args.cell))) cover_area = float(cover.sum()) * args.cell ** 2 while True: # 镜片镂空改以"全表面轮缘环"的封闭孔判定: # 轮缘正面覆盖稀疏/有缺口时, 正面环闭不上会漏填整块镜片 # (632587 右镜片曾缺失 1/3); 全表面环更完整, 而 开向边界的 # 鼻梁凹口不属于封闭孔, 不会被误填 ring = binary_closing(cover_all, r) holes = fill_enclosed(ring) & ~ring foot = largest_filled_component(binary_closing(cover, r) | holes) # 上/下缘的掠射面不在正面覆盖内但属于镜架: # 正面 footprint 附近 0.2 内的轮缘格也纳入, 包装才铺满镜框外缘 near = foot.copy() for _ in range(4): near = dilate(near) foot = largest_filled_component(foot | (ring & near)) cycle, n_sig = trace_outer_cycle(foot) cyc_area = abs(signed_area(cycle)) * args.cell ** 2 foot_area = float(foot.sum()) * args.cell ** 2 # 判据一: 外轮廓单环完整(追踪不破); # 判据二: 镜片镂空被填上(footprint 达到朝前覆盖的 2 倍以上, # 覆盖稀疏/有缺口的数据需要更大的闭运算半径) if (cyc_area >= 0.9 * foot_area and foot_area >= 2.0 * cover_area) \ or r >= cap: break print(' 闭运算半径 %d → 外轮廓覆盖 footprint 的 %.0f%%, ' '镂空填充率 %.2f, 继续加大...' % (r, 100 * cyc_area / foot_area, foot_area / cover_area)) r += 2 if foot_area < 1.5 * cover_area: print(' 注意: 镜片镂空可能未被填满(填充率 %.2f), ' '可尝试加大 --closing' % (foot_area / cover_area)) print(' 镜框正面 footprint: %d 格(面积 %.1f), 闭运算半径 %d' % (foot.sum(), foot_area, r)) poly = np.array([[u0 + i * args.cell, v0 + j * args.cell] for (i, j) in cycle], dtype=np.float64) print(' 外轮廓多边形: %d 个角点' % len(poly)) if args.poly_smooth > 0: poly = smooth_closed_curve(poly, passes=args.poly_smooth) print(' 外轮廓 Taubin 平滑 %d 次(消除 cell 级栅格楼梯, 不收缩轮廓)' % args.poly_smooth) # ---- 薄膜网格点 print('[3/6] 铺薄膜网格 (spacing=%.2f) ...' % args.spacing) s = args.spacing gu0 = poly[:, 0].min() - s; gu1 = poly[:, 0].max() + s gv0 = poly[:, 1].min() - s; gv1 = poly[:, 1].max() + s ni = int(np.ceil((gu1 - gu0) / s)) + 1 nj = int(np.ceil((gv1 - gv0) / s)) + 1 GU, GV = np.meshgrid(gu0 + np.arange(ni) * s, gv0 + np.arange(nj) * s, indexing='ij') pts = np.stack([GU.ravel(), GV.ravel()], axis=1) ins = points_in_poly(pts, poly).reshape(ni, nj) print(' 轮廓内网格点: %d' % ins.sum()) if ins.sum() < 4: raise RuntimeError('轮廓内网格点太少, 请增大 --spacing 或减小 --cell') # ---- 按镜架整体投影剪裁: 闭运算/轮缘带会把 footprint 向外胀出 # 0.1~0.2 的薄裙边(镜架上缘/下缘可见"超出"), 以镜架外轮廓 # (全表面投影 + 补采样缝 + 填充内部镂空)为准裁掉 frame_sil = fill_enclosed(binary_closing(cover_all, 2)) ic = np.clip(np.round((GU - u0) / args.cell).astype(int), 0, frame_sil.shape[0] - 1) jc = np.clip(np.round((GV - v0) / args.cell).astype(int), 0, frame_sil.shape[1] - 1) n_clip = int((ins & ~frame_sil[ic, jc]).sum()) if n_clip: ins &= frame_sil[ic, jc] print(' 按镜架投影裁掉外轮廓之外的裙边: %d 个网格点' % n_clip) # 趋势拟合的锚点只取正面表面(cover 膨胀 3 格)上的贴合高度: # 外缘陡边/掠射面处薄膜本该顺侧影下潜, 高度不能拿来外推镜片平面 cov_d = cover.copy() for _ in range(3): cov_d = dilate(cov_d) front_sel = cov_d[ic, jc] # ---- 顶面高度: 贴表面 + 镂空处与镜框平齐 print('[4/6] 计算薄膜高度(贴眼镜表面, 镜片处与镜框平齐) ...') Z = exact_film_heights(pts, tri_uv, tri_w, cell_tris, u0, v0, args.cell).reshape(ni, nj) known = ~np.isnan(Z) & ins n_hole = int((ins & ~known).sum()) print(' %d 点直接贴到表面, %d 点位于镂空处(趋势平齐填充)' % (known.sum(), n_hole)) Z = trend_fill(Z, ins, select=front_sel, blend_passes=args.hole_blend) # ---- 贴合带平滑: 镜框侧弯坡度大处, 高度场曲率被坡度放大成棱面 # (相邻面法线跳变 30-60 度), 受限松弛磨掉棱面并软化接缝; # 浅坡处与均匀斜面本来就光滑, 不会被改动 if args.band_smooth > 0: thr = args.band_smooth * args.spacing Zs, w = smooth_adherence(Z, known, ins, thr, args.spacing) n_up = int((np.abs(Zs - Z) > 1e-6).sum()) if n_up: # 松弛后的点(含软化接缝带)作为新边界, 镂空处重新平齐外推 Z = trend_fill(np.where(w > 0, Zs, np.nan), ins, select=front_sel, blend_passes=args.hole_blend) print(' 贴合带平滑: %d 个点被松弛(阈值 %.4f), ' '镂空处重新平齐' % (n_up, thr)) # ---- 组装闭合 mesh print('[5/6] 组装水密包装 mesh (thickness=%.2f) ...' % args.thickness) # 底面膜深度钳制: 不超过镜架最后缘(留 0.1 薄膜厚度), # 防止薄膜沿侧影线下潜时背面膜探出镜架范围 w_floor = float((sgn * verts[sub][:, :, k]).min()) - 0.1 wverts, wfaces, ftags = build_wrap_mesh(GU, GV, Z, ins, args.thickness, w_floor) if args.rim_smooth > 0: # 周边收边: 顶膜边缘沿边界曲线 1D 拉普拉斯, 磨掉栅格楼梯, # 底膜边缘同步位移(侧壁随缘平移), 薄膜内部不动。 # 注意: 不做整 mesh 的拉普拉斯松弛——侧壁细长三角形会把 # 伞形算子变成"跨 3~4 个单位距离的强混合", 边缘反而出锯齿。 print(' 周边收边: 边缘曲线拉普拉斯平滑 %d 次 ...' % args.rim_smooth) wverts = smooth_rim_curve(wverts, wfaces, ftags, passes=args.rim_smooth) closed, chi = check_closed(wfaces, len(wverts)) print(' %d 顶点, %d 面; 水密=%s, 欧拉示性数=%d' % (len(wverts), len(wfaces), closed, chi)) if not closed: raise RuntimeError('生成的网格不是水密闭合网格, 请检查参数') # 变换回世界坐标: (u, v, w) -> 原坐标 W = sgn * wverts[:, 2] verts_world = np.zeros_like(wverts) verts_world[:, ax_u] = wverts[:, 0] verts_world[:, ax_v] = wverts[:, 1] verts_world[:, k] = W # 顶点法线(面积加权) p0, p1, p2 = verts_world[wfaces[:, 0]], verts_world[wfaces[:, 1]], verts_world[wfaces[:, 2]] fn = np.cross(p1 - p0, p2 - p0) vn = np.zeros_like(verts_world) for c in range(3): np.add.at(vn, wfaces[:, c], fn) ln = np.linalg.norm(vn, axis=1, keepdims=True) vn = vn / np.where(ln > 1e-12, ln, 1.0) # ---- 写出 obj print('[6/6] 写出 %s ...' % os.path.basename(out_path)) mtl = args.mtl if mtl is None: # 先找模型同目录的 mtl, 再退回脚本目录 found = sorted(glob.glob(os.path.join(obj_dir, '*.mtl'))) or \ sorted(glob.glob(os.path.join(SCRIPT_DIR, '*.mtl'))) mtl = os.path.basename(found[0]) if found else None n_v = n_vn = 0 with open(out_path, 'w') as out, open(obj_path, 'r', errors='ignore') as src: for line in src: # 原模型逐行原样拷贝 if line.startswith('v '): n_v += 1 elif line.startswith('vn '): n_vn += 1 elif line.startswith('mtllib ') and mtl: line = 'mtllib %s\n' % mtl # 指向本目录实际存在的 mtl elif line.startswith('usemtl ') and line.strip().endswith('.001'): line = 'usemtl %s\n' % line.split()[1][:-4] # Blender 重命名残留 out.write(line) # 追加包装 mesh out.write('o Transparent\n') out.write('s 0\n') out.write('usemtl Transparent\n') for p in verts_world: out.write('v %.6f %.6f %.6f\n' % (p[0], p[1], p[2])) base_v, base_n = n_v, n_vn for nn in vn: out.write('vn %.6f %.6f %.6f\n' % (nn[0], nn[1], nn[2])) for f in wfaces: out.write('f %d//%d %d//%d %d//%d\n' % ( f[0] + 1 + base_v, f[0] + 1 + base_n, f[1] + 1 + base_v, f[1] + 1 + base_n, f[2] + 1 + base_v, f[2] + 1 + base_n)) lo, hi = W.min(), W.max() if sgn < 0: lo, hi = -hi, -lo print('完成! 输出: %s' % out_path) print(' 新增 Transparent mesh: %d 顶点 / %d 面 (参考结果为 9999/19994)' % (len(wverts), len(wfaces))) print(' 沿视线轴 %s 的包装范围: [%.2f, %.2f]' % ('xyz'[k], lo, hi)) print(' 包装 xy 外接盒: x[%.2f, %.2f], y[%.2f, %.2f]' % (verts_world[:, ax_u].min(), verts_world[:, ax_u].max(), verts_world[:, ax_v].min(), verts_world[:, ax_v].max())) if __name__ == '__main__': main()