Files
openMVS/fill_all_empty_faces_v1.2.py
T
2026-07-21 16:17:49 +08:00

892 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
fill_all_empty_faces_v3_square_harmonic_atlas.py
目标:替换原来的“一个缺色孤岛 -> 一个均值色块”的逻辑,
并保证输出贴图严格保持 1:1 方形。
核心逻辑:
1. 正常 face 保持原图像素位置不变;如果输出画布变大,则同时 remap u/v。
不能只 remap v,否则输出方形后旧 UV 会整体错位。
2. missing face 不再按 region 写一个颜色点,也不再整组 face 指向同一个 vt。
3. 在 3D mesh 拓扑上,对 missing region 的“顶点颜色”做 harmonic/Laplace 扩散:
- 边界顶点颜色来自相邻正常 face 的原始 texture 采样;
- 内部顶点颜色由图拉普拉斯扩散得到;
- 所以颜色从边缘向内部连续变化。
4. 每个 missing face 在新增 atlas 中分配一个小三角 tile。
- 输出画布是 square_size x square_size;原贴图放在左上角;
- atlas 放在原贴图下方的安全区域,不塞进原贴图右侧,避免采样串色;
- tile 内不是纯色,而是按三个顶点颜色做 barycentric 插值;
- 相邻 missing face 即使在 atlas 里不相邻,只要共享 3D 顶点,边上的插值颜色也连续;
- tile 周围做 padding,避免纹理过滤时采到其他 tile。
输入输出参数保持原脚本一致:
--input_obj
--input_texture
--missing_faces
--output_obj
--output_texture
额外参数都是可选。
"""
import argparse
import os
import time
from collections import defaultdict, deque
from typing import Dict, List, Tuple, Set
import cv2
import numpy as np
import tqdm
# ============================================================
# OBJ / texture IO
# ============================================================
def _parse_obj_index(s: str, current_len: int) -> int:
"""OBJ index: positive is 1-based, negative is relative to current list end."""
idx = int(s)
if idx > 0:
return idx - 1
if idx < 0:
return current_len + idx
raise ValueError("OBJ index 不能为 0")
def read_obj_basic(obj_path: str):
vertices: List[List[float]] = []
uvs: List[List[float]] = []
vertex_indices: List[List[int]] = []
uv_indices: List[List[int]] = []
mtllib_lines: List[str] = []
usemtl_name = "material_0"
with open(obj_path, "r", encoding="utf-8", errors="ignore") as f:
for line in f:
if line.startswith("mtllib "):
mtllib_lines.append(line.strip())
elif line.startswith("usemtl "):
parts = line.strip().split(maxsplit=1)
if len(parts) == 2:
usemtl_name = parts[1]
elif line.startswith("v "):
parts = line.strip().split()
if len(parts) < 4:
continue
vertices.append([float(parts[1]), float(parts[2]), float(parts[3])])
elif line.startswith("vt "):
parts = line.strip().split()
if len(parts) < 3:
continue
uvs.append([float(parts[1]), float(parts[2])])
elif line.startswith("f "):
parts = line.strip().split()[1:]
if len(parts) != 3:
raise ValueError(f"当前脚本只支持三角面,发现非三角 face: {line.strip()}")
v_row = []
vt_row = []
for token in parts:
sub = token.split("/")
if len(sub) < 2 or sub[1] == "":
raise ValueError(f"face 缺少 vt,无法处理: {line.strip()}")
v_row.append(_parse_obj_index(sub[0], len(vertices)))
vt_row.append(_parse_obj_index(sub[1], len(uvs)))
vertex_indices.append(v_row)
uv_indices.append(vt_row)
vertices_np = np.asarray(vertices, dtype=np.float32)
uvs_np = np.asarray(uvs, dtype=np.float32)
vertex_indices_np = np.asarray(vertex_indices, dtype=np.int64)
uv_indices_np = np.asarray(uv_indices, dtype=np.int64)
if len(vertices_np) == 0 or len(uvs_np) == 0 or len(vertex_indices_np) == 0:
raise ValueError("OBJ 内没有读到完整的 v / vt / f 数据。")
return vertices_np, uvs_np, vertex_indices_np, uv_indices_np, mtllib_lines, usemtl_name
def read_missing_faces(path: str, index_base: int = 0) -> np.ndarray:
arr = []
with open(path, "r", encoding="utf-8", errors="ignore") as f:
for line in f:
s = line.strip()
if not s:
continue
arr.append(int(s) - index_base)
return np.asarray(arr, dtype=np.int64)
def read_texture_rgb(path: str) -> np.ndarray:
bgr = cv2.imread(path, cv2.IMREAD_COLOR)
if bgr is None:
raise FileNotFoundError(f"无法读取贴图: {path}")
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
def write_texture_rgb(path: str, rgb: np.ndarray):
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
ok = cv2.imwrite(path, bgr, [cv2.IMWRITE_PNG_COMPRESSION, 3])
if not ok:
raise IOError(f"写入贴图失败: {path}")
def write_obj_with_uv_coordinates(
filename: str,
vertices: np.ndarray,
uvs: np.ndarray,
vertex_indices: np.ndarray,
uv_indices: np.ndarray,
mtllib_lines: List[str] = None,
usemtl_name: str = "material_0",
):
os.makedirs(os.path.dirname(os.path.abspath(filename)), exist_ok=True)
if not mtllib_lines:
mtllib_lines = ["mtllib mesh.mtl"]
estimated_size = len(vertices) * 48 + len(uvs) * 32 + len(vertex_indices) * 64
buffer_size = min(max(int(estimated_size * 1.2), 64 * 1024 * 1024), 1024 * 1024 * 1024)
lines: List[str] = []
lines.extend(mtllib_lines)
for v in vertices:
lines.append("v %.6f %.6f %.6f" % (v[0], v[1], v[2]))
lines.append("")
for uv in uvs:
lines.append("vt %.8f %.8f" % (uv[0], uv[1]))
lines.append("")
lines.append(f"usemtl {usemtl_name}")
for v_idx, vt_idx in zip(vertex_indices, uv_indices):
lines.append(
"f %d/%d %d/%d %d/%d" % (
int(v_idx[0]) + 1, int(vt_idx[0]) + 1,
int(v_idx[1]) + 1, int(vt_idx[1]) + 1,
int(v_idx[2]) + 1, int(vt_idx[2]) + 1,
)
)
with open(filename, "w", buffering=buffer_size, encoding="utf-8") as f:
f.write("\n".join(lines))
# ============================================================
# Mesh topology
# ============================================================
def build_face_adjacency_and_vertex_faces(faces: np.ndarray, num_vertices: int):
"""
基于共享边构建 face adjacency,同时构建 vertex -> faces。
返回:face_adjacency, vertex_faces
"""
faces = np.asarray(faces, dtype=np.int64)
num_faces = len(faces)
# vertex -> faces
vertex_faces: List[List[int]] = [[] for _ in range(num_vertices)]
for fi, row in enumerate(faces):
vertex_faces[int(row[0])].append(fi)
vertex_faces[int(row[1])].append(fi)
vertex_faces[int(row[2])].append(fi)
# edge -> faces,向量化排序
edges = np.stack([
np.column_stack((faces[:, 0], faces[:, 1])),
np.column_stack((faces[:, 1], faces[:, 2])),
np.column_stack((faces[:, 2], faces[:, 0])),
], axis=1).reshape(-1, 2)
edges.sort(axis=1)
edge_faces = np.repeat(np.arange(num_faces, dtype=np.int64), 3)
key = edges[:, 0] * num_vertices + edges[:, 1]
order = np.argsort(key)
key = key[order]
edge_faces = edge_faces[order]
adj = defaultdict(list)
same = key[1:] == key[:-1]
pos = np.where(same)[0]
for p in pos:
f0 = int(edge_faces[p])
f1 = int(edge_faces[p + 1])
if f0 != f1:
adj[f0].append(f1)
adj[f1].append(f0)
return dict(adj), vertex_faces
def find_missing_regions(face_adjacency: Dict[int, List[int]], missing_faces: np.ndarray, total_faces: int):
missing_all = set(int(x) for x in missing_faces if 0 <= int(x) < total_faces)
unused = set(missing_all)
regions: List[Tuple[Set[int], Set[int]]] = []
pbar = tqdm.tqdm(total=len(unused), desc="Finding missing islands")
done = 0
while unused:
start = unused.pop()
group = {start}
boundary = set()
stack = [start]
while stack:
f = stack.pop()
for nb in face_adjacency.get(f, []):
if nb in unused:
unused.remove(nb)
group.add(nb)
stack.append(nb)
elif nb not in missing_all:
boundary.add(nb)
regions.append((group, boundary))
new_done = len(missing_all) - len(unused)
pbar.update(new_done - done)
done = new_done
pbar.close()
if regions:
sizes = [len(x[0]) for x in regions]
print(f"Total missing islands: {len(regions)}")
print(f"Island size: min={min(sizes)}, max={max(sizes)}, avg={sum(sizes)/len(sizes):.2f}")
else:
print("No missing islands found.")
return regions
# ============================================================
# Texture sampling
# ============================================================
def sample_texture_bilinear_rgb(texture_rgb: np.ndarray, uv: np.ndarray) -> np.ndarray:
"""uv: [2], v is OBJ convention, image y is flipped."""
H, W = texture_rgb.shape[:2]
u = float(np.clip(uv[0], 0.0, 1.0))
v = float(np.clip(uv[1], 0.0, 1.0))
x = u * (W - 1)
y = (1.0 - v) * (H - 1)
x0 = int(np.floor(x))
y0 = int(np.floor(y))
x1 = min(x0 + 1, W - 1)
y1 = min(y0 + 1, H - 1)
dx = x - x0
dy = y - y0
c00 = texture_rgb[y0, x0].astype(np.float32)
c10 = texture_rgb[y0, x1].astype(np.float32)
c01 = texture_rgb[y1, x0].astype(np.float32)
c11 = texture_rgb[y1, x1].astype(np.float32)
c0 = c00 * (1.0 - dx) + c10 * dx
c1 = c01 * (1.0 - dx) + c11 * dx
return c0 * (1.0 - dy) + c1 * dy
def sample_face_corner_color(
face_id: int,
corner: int,
texture_rgb: np.ndarray,
uvs: np.ndarray,
face_uv_indices: np.ndarray,
corner_inset: float = 0.90,
) -> np.ndarray:
"""
在正常 face 的某个顶点附近采样。
不直接采顶点本身,而是往三角形内部缩一点,避免 seam 边界采样不稳。
corner_inset 越接近 1,越贴近该顶点。
"""
tri_uv = uvs[face_uv_indices[face_id]] # [3,2]
w = np.full(3, (1.0 - corner_inset) / 2.0, dtype=np.float32)
w[corner] = corner_inset
uv = w @ tri_uv
return sample_texture_bilinear_rgb(texture_rgb, uv)
def median_color(colors: List[np.ndarray]) -> np.ndarray:
if len(colors) == 0:
return np.asarray([128, 128, 128], dtype=np.float32)
return np.median(np.stack(colors, axis=0).astype(np.float32), axis=0)
# ============================================================
# Harmonic vertex color diffusion
# ============================================================
def build_region_vertex_graph(region_faces: Set[int], faces: np.ndarray):
"""
对一个 missing island 构建 local vertex graph。
返回:unique_vertices, vertex_to_local, unique_edges, local_face_ids
"""
face_ids = np.asarray(sorted(region_faces), dtype=np.int64)
tri_v = faces[face_ids] # [F,3]
unique_vertices = np.unique(tri_v.reshape(-1))
v2local = {int(v): i for i, v in enumerate(unique_vertices)}
local_faces = np.vectorize(lambda x: v2local[int(x)], otypes=[np.int64])(tri_v)
edges = np.concatenate([
local_faces[:, [0, 1]],
local_faces[:, [1, 2]],
local_faces[:, [2, 0]],
], axis=0)
edges.sort(axis=1)
edges = np.unique(edges, axis=0)
return face_ids, unique_vertices, v2local, edges, local_faces
def compute_boundary_vertex_constraints(
unique_vertices: np.ndarray,
vertex_faces: List[List[int]],
faces: np.ndarray,
face_uv_indices: np.ndarray,
uvs: np.ndarray,
texture_rgb: np.ndarray,
missing_mask: np.ndarray,
corner_inset: float = 0.90,
):
"""
对 region 内的每个顶点,如果它邻接任何非 missing face,就从这些正常 face 的对应 corner 采颜色。
这比“对边界 face 求一个均值”更合理,因为边界不同位置的颜色会保留下来。
"""
n = len(unique_vertices)
constrained = np.zeros(n, dtype=bool)
constraint_colors = np.zeros((n, 3), dtype=np.float32)
for local_i, gv in enumerate(unique_vertices):
samples = []
for f in vertex_faces[int(gv)]:
if missing_mask[f]:
continue
row = faces[f]
corners = np.where(row == gv)[0]
if len(corners) == 0:
continue
for c in corners:
samples.append(sample_face_corner_color(
f,
int(c),
texture_rgb,
uvs,
face_uv_indices,
corner_inset=corner_inset,
))
if samples:
constrained[local_i] = True
constraint_colors[local_i] = median_color(samples)
return constrained, constraint_colors
def initialize_vertex_colors_by_bfs(edges: np.ndarray, constrained: np.ndarray, constraint_colors: np.ndarray) -> np.ndarray:
n = len(constrained)
colors = np.zeros((n, 3), dtype=np.float32)
if constrained.any():
colors[constrained] = constraint_colors[constrained]
fallback = np.mean(constraint_colors[constrained], axis=0)
else:
fallback = np.asarray([128, 128, 128], dtype=np.float32)
colors[:] = fallback
return colors
adj = [[] for _ in range(n)]
for a, b in edges:
adj[int(a)].append(int(b))
adj[int(b)].append(int(a))
q = deque(np.where(constrained)[0].tolist())
visited = np.zeros(n, dtype=bool)
visited[constrained] = True
while q:
cur = q.popleft()
for nb in adj[cur]:
if visited[nb]:
continue
colors[nb] = colors[cur]
visited[nb] = True
q.append(nb)
colors[~visited] = fallback
return colors
def solve_harmonic_vertex_colors(
edges: np.ndarray,
constrained: np.ndarray,
constraint_colors: np.ndarray,
smooth_iters: int = 80,
self_weight: float = 0.0,
) -> np.ndarray:
"""
对 region 顶点颜色做 Dirichlet boundary harmonic extension。
constrained 顶点保持边界颜色;非 constrained 顶点反复取邻居平均。
"""
n = len(constrained)
colors = initialize_vertex_colors_by_bfs(edges, constrained, constraint_colors)
if n == 0 or len(edges) == 0:
return colors
e0 = edges[:, 0].astype(np.int64)
e1 = edges[:, 1].astype(np.int64)
deg = np.zeros(n, dtype=np.float32)
np.add.at(deg, e0, 1.0)
np.add.at(deg, e1, 1.0)
denom = deg[:, None] + float(self_weight)
denom = np.maximum(denom, 1e-8)
for _ in range(max(0, int(smooth_iters))):
acc = np.zeros_like(colors)
np.add.at(acc, e0, colors[e1])
np.add.at(acc, e1, colors[e0])
if self_weight > 0:
acc += colors * float(self_weight)
new_colors = acc / denom
# Dirichlet boundary:边界顶点锁住,不允许被内部均化掉。
new_colors[constrained] = constraint_colors[constrained]
colors = new_colors
return np.clip(colors, 0, 255).astype(np.float32)
# ============================================================
# Per-face triangular atlas baking
# ============================================================
def precompute_triangle_tile(tile_size: int, pad: int):
"""
预计算一个标准三角 tile 的 barycentric weights 和 padding 最近邻映射。
后续每个 missing face 只需要 weights @ 三个顶点颜色。
"""
tile_size = int(tile_size)
pad = int(pad)
if tile_size < 4:
raise ValueError("tile_size 至少要 >= 4")
if pad < 1:
pad = 1
if pad * 2 + 2 >= tile_size:
pad = max(1, tile_size // 4)
# 标准右三角,三个 UV 角点都留 padding,避免双线性采样采到 tile 外。
p0 = np.asarray([pad, pad], dtype=np.float32)
p1 = np.asarray([tile_size - pad - 1, pad], dtype=np.float32)
p2 = np.asarray([pad, tile_size - pad - 1], dtype=np.float32)
yy, xx = np.meshgrid(np.arange(tile_size, dtype=np.float32),
np.arange(tile_size, dtype=np.float32), indexing="ij")
pts = np.stack([xx + 0.5, yy + 0.5], axis=-1) # [T,T,2], pixel centers
# barycentric
v0 = p1 - p0
v1 = p2 - p0
v2 = pts - p0
d00 = float(np.dot(v0, v0))
d01 = float(np.dot(v0, v1))
d11 = float(np.dot(v1, v1))
denom = d00 * d11 - d01 * d01
if abs(denom) < 1e-8:
raise ValueError("退化 tile triangle")
d20 = v2[..., 0] * v0[0] + v2[..., 1] * v0[1]
d21 = v2[..., 0] * v1[0] + v2[..., 1] * v1[1]
w1 = (d11 * d20 - d01 * d21) / denom
w2 = (d00 * d21 - d01 * d20) / denom
w0 = 1.0 - w1 - w2
weights = np.stack([w0, w1, w2], axis=-1).astype(np.float32)
inside = (weights[..., 0] >= -1e-4) & (weights[..., 1] >= -1e-4) & (weights[..., 2] >= -1e-4)
# padding:tile 外部/三角外部像素复制最近的三角内部像素颜色。
inside_coords = np.argwhere(inside)
nearest_y = np.zeros((tile_size, tile_size), dtype=np.int32)
nearest_x = np.zeros((tile_size, tile_size), dtype=np.int32)
for y in range(tile_size):
for x in range(tile_size):
if inside[y, x]:
nearest_y[y, x] = y
nearest_x[y, x] = x
else:
d2 = (inside_coords[:, 0] - y) ** 2 + (inside_coords[:, 1] - x) ** 2
k = int(np.argmin(d2))
nearest_y[y, x] = int(inside_coords[k, 0])
nearest_x[y, x] = int(inside_coords[k, 1])
padded_weights = weights[nearest_y, nearest_x]
# UV 角点使用像素中心,更稳定。
uv_points = np.stack([p0, p1, p2], axis=0).astype(np.float32)
return padded_weights, uv_points
def compute_square_atlas_layout(
width: int,
height: int,
n_tiles: int,
tile_size: int,
):
"""
计算一个严格方形输出画布,并把新增 atlas 只放到原贴图下方。
为什么不用“右侧空白 + 下方空白”的 L 形区域:
- 原始 UV 通常会贴到原贴图右边界;
- 如果右侧直接塞 atlas,线性采样 / mipmap 很容易把 atlas 颜色串到旧 UV 边缘;
- 所以右侧只做 edge padding,下方才放新增 tile。
"""
width = int(width)
height = int(height)
n_tiles = int(n_tiles)
tile_size = int(tile_size)
if tile_size <= 0:
raise ValueError("tile_size 必须为正数")
side = max(width, height)
if n_tiles <= 0:
return side, 0, 0, 0
# 至少要有一行 atlas。原贴图本身如果已经是方形,需要把方形边长一起扩大。
side = max(side, height + tile_size)
# 简单稳定地增长边长。步长用 tile_size,避免大量无效循环。
while True:
cols = side // tile_size
rows = (side - height) // tile_size
capacity = cols * rows
if cols > 0 and rows > 0 and capacity >= n_tiles:
return side, int(cols), int(rows), int(capacity)
side += tile_size
def make_square_canvas_with_edge_padding(texture_rgb: np.ndarray, side: int) -> np.ndarray:
"""
把原贴图放在方形画布左上角,并用边缘像素扩展空白区域。
这里不要用纯白初始化:
- 旧 UV remap 后仍然贴在原图矩形内;
- 但边界处的双线性采样 / mipmap 可能会读到矩形外侧;
- 用边缘扩展比白底安全,不容易出现白边。
"""
H, W = texture_rgb.shape[:2]
side = int(side)
if side < max(H, W):
raise ValueError(f"square side 太小: side={side}, texture={W}x{H}")
canvas = np.empty((side, side, 3), dtype=np.uint8)
canvas[:H, :W, :] = texture_rgb
# 右侧扩展最后一列,保护原 UV 的右边界。
if side > W:
canvas[:H, W:side, :] = texture_rgb[:, W - 1:W, :]
# 下方扩展最后一行。注意这里复制的是已经完成右侧扩展后的整行。
if side > H:
canvas[H:side, :, :] = canvas[H - 1:H, :, :]
return canvas
def remap_original_uvs_to_square_canvas(
original_uvs: np.ndarray,
old_width: int,
old_height: int,
square_side: int,
) -> np.ndarray:
"""
保持原贴图像素坐标不变,只把归一化 UV 换算到新的方形画布。
原脚本只扩高,所以只改 v;现在输出变成 square_side x square_side,
必须同时改 u 和 v,否则原始 texture 会被横向拉错。
"""
remapped = original_uvs.copy().astype(np.float32)
old_x = remapped[:, 0] * max(old_width - 1, 1)
old_y = (1.0 - remapped[:, 1]) * max(old_height - 1, 1)
remapped[:, 0] = old_x / max(square_side - 1, 1)
remapped[:, 1] = 1.0 - old_y / max(square_side - 1, 1)
return remapped
def build_harmonic_atlas_texture(
texture_rgb: np.ndarray,
original_uvs: np.ndarray,
faces: np.ndarray,
face_uv_indices: np.ndarray,
missing_faces: np.ndarray,
face_corner_colors: Dict[int, np.ndarray],
tile_size: int = 12,
tile_pad: int = 2,
):
"""
给每个 missing face 分配一个小三角 tile。
tile 内按三个顶点颜色插值,不是纯色块。
输出贴图严格为 square_side x square_side。
"""
H, W = texture_rgb.shape[:2]
missing_faces_sorted = np.asarray(sorted(set(int(x) for x in missing_faces)), dtype=np.int64)
n_missing = len(missing_faces_sorted)
square_side, cols, rows, capacity = compute_square_atlas_layout(
width=W,
height=H,
n_tiles=n_missing,
tile_size=tile_size,
)
atlas_h = rows * tile_size
print(
f"atlas tiles: {n_missing}, cols={cols}, rows={rows}, "
f"capacity={capacity}, atlas_h={atlas_h}"
)
print(f"square canvas: {square_side} x {square_side}, original texture: {W} x {H}")
new_texture = make_square_canvas_with_edge_padding(texture_rgb, square_side)
# 原始 UV remap:保持原图像素位置不变,同时重映射 u/v 到方形画布。
remapped_uvs = remap_original_uvs_to_square_canvas(
original_uvs=original_uvs,
old_width=W,
old_height=H,
square_side=square_side,
)
new_uvs: List[List[float]] = remapped_uvs.tolist()
new_face_uv_indices = face_uv_indices.copy()
weights_map, uv_points = precompute_triangle_tile(tile_size, tile_pad)
weights_flat = weights_map.reshape(-1, 3) # [T*T,3]
for i, f in enumerate(tqdm.tqdm(missing_faces_sorted, desc="Baking missing face tiles")):
col = i % cols
row = i // cols
x0 = col * tile_size
y0 = H + row * tile_size
if y0 + tile_size > square_side or x0 + tile_size > square_side:
raise RuntimeError(
"atlas layout 计算错误:tile 超出方形画布,"
f"tile=({x0},{y0},{tile_size}), side={square_side}"
)
corner_colors = face_corner_colors.get(int(f))
if corner_colors is None:
corner_colors = np.full((3, 3), 128, dtype=np.float32)
corner_colors = np.asarray(corner_colors, dtype=np.float32)
tile = weights_flat @ corner_colors # [T*T,3]
tile = np.clip(np.rint(tile), 0, 255).astype(np.uint8).reshape(tile_size, tile_size, 3)
new_texture[y0:y0 + tile_size, x0:x0 + tile_size, :] = tile
new_vt = []
for p in uv_points:
px = x0 + float(p[0])
py = y0 + float(p[1])
u = px / max(square_side - 1, 1)
v = 1.0 - py / max(square_side - 1, 1)
new_uvs.append([u, v])
new_vt.append(len(new_uvs) - 1)
new_face_uv_indices[int(f)] = np.asarray(new_vt, dtype=new_face_uv_indices.dtype)
new_uvs_np = np.asarray(new_uvs, dtype=np.float32)
if new_texture.shape[0] != new_texture.shape[1]:
raise AssertionError(f"输出贴图不是 1:1: {new_texture.shape[1]} x {new_texture.shape[0]}")
if not np.isfinite(new_uvs_np).all():
raise AssertionError("输出 UV 中出现 NaN/Inf")
if new_face_uv_indices.min() < 0 or new_face_uv_indices.max() >= len(new_uvs_np):
raise AssertionError("输出 face uv index 越界")
return new_texture, new_uvs_np, new_face_uv_indices
# ============================================================
# Main process
# ============================================================
def process(
input_obj_path: str,
input_texture_path: str,
missing_faces_path: str,
output_obj_path: str,
output_texture_path: str,
missing_index_base: int = 0,
tile_size: int = 12,
tile_pad: int = 2,
smooth_iters: int = 80,
self_weight: float = 0.0,
corner_inset: float = 0.90,
):
start = time.time()
print("Reading input files...")
vertices, uvs, faces, face_uv_indices, mtllib_lines, usemtl_name = read_obj_basic(input_obj_path)
texture_rgb = read_texture_rgb(input_texture_path)
missing_faces = read_missing_faces(missing_faces_path, index_base=missing_index_base)
total_faces = len(faces)
missing_faces = missing_faces[(missing_faces >= 0) & (missing_faces < total_faces)]
missing_faces = np.unique(missing_faces)
missing_mask = np.zeros(total_faces, dtype=bool)
missing_mask[missing_faces] = True
print(f"vertices: {len(vertices)}")
print(f"uvs: {len(uvs)}")
print(f"faces: {len(faces)}")
print(f"missing faces: {len(missing_faces)}")
print(f"texture: {texture_rgb.shape[1]} x {texture_rgb.shape[0]}")
if len(missing_faces) == 0:
# 即使没有缺色面,也保证输出贴图是 1:1;如果输入已经是方形,这里等价于原样输出。
H, W = texture_rgb.shape[:2]
square_side, _, _, _ = compute_square_atlas_layout(W, H, 0, tile_size)
square_texture = make_square_canvas_with_edge_padding(texture_rgb, square_side)
square_uvs = remap_original_uvs_to_square_canvas(uvs, W, H, square_side)
write_obj_with_uv_coordinates(output_obj_path, vertices, square_uvs, faces, face_uv_indices, mtllib_lines, usemtl_name)
write_texture_rgb(output_texture_path, square_texture)
print(f"output texture: {square_texture.shape[1]} x {square_texture.shape[0]}")
return
t0 = time.time()
print("Building adjacency...")
face_adjacency, vertex_faces = build_face_adjacency_and_vertex_faces(faces, len(vertices))
print(f"adjacency using: {time.time() - t0:.2f}s")
t0 = time.time()
regions = find_missing_regions(face_adjacency, missing_faces, total_faces)
print(f"regions using: {time.time() - t0:.2f}s")
# face -> three corner colors
face_corner_colors: Dict[int, np.ndarray] = {}
for region_idx, (region_faces, _boundary_faces) in enumerate(tqdm.tqdm(regions, desc="Solving region colors")):
face_ids, unique_vertices, v2local, edges, local_faces = build_region_vertex_graph(region_faces, faces)
constrained, constraint_colors = compute_boundary_vertex_constraints(
unique_vertices=unique_vertices,
vertex_faces=vertex_faces,
faces=faces,
face_uv_indices=face_uv_indices,
uvs=uvs,
texture_rgb=texture_rgb,
missing_mask=missing_mask,
corner_inset=corner_inset,
)
if not constrained.any():
# 极少数情况:region 没有任何正常面顶点约束。
# 这种输入本身没有颜色来源,只能给灰色,避免崩。
print(f"Warning: region {region_idx} has no boundary vertex color constraints, using gray.")
vertex_colors = np.full((len(unique_vertices), 3), 128, dtype=np.float32)
else:
vertex_colors = solve_harmonic_vertex_colors(
edges=edges,
constrained=constrained,
constraint_colors=constraint_colors,
smooth_iters=smooth_iters,
self_weight=self_weight,
)
# 写成每个 missing face 的三个角颜色。
for local_fi, f in enumerate(face_ids):
lf = local_faces[local_fi]
face_corner_colors[int(f)] = vertex_colors[lf]
print("Building harmonic atlas...")
new_texture, new_uvs, new_face_uv_indices = build_harmonic_atlas_texture(
texture_rgb=texture_rgb,
original_uvs=uvs,
faces=faces,
face_uv_indices=face_uv_indices,
missing_faces=missing_faces,
face_corner_colors=face_corner_colors,
tile_size=tile_size,
tile_pad=tile_pad,
)
print("Writing outputs...")
write_obj_with_uv_coordinates(
output_obj_path,
vertices,
new_uvs,
faces,
new_face_uv_indices,
mtllib_lines=mtllib_lines,
usemtl_name=usemtl_name,
)
write_texture_rgb(output_texture_path, new_texture)
print(f"output texture: {new_texture.shape[1]} x {new_texture.shape[0]}")
print(f"new uv count: {len(new_uvs)}")
print(f"Total using: {time.time() - start:.2f}s")
def main():
parser = argparse.ArgumentParser(description="Fill missing color faces with harmonic vertex-color atlas baking.")
# 原始接口保持一致
parser.add_argument("--input_obj", type=str, required=True, help="Path to the input OBJ file")
parser.add_argument("--input_texture", type=str, required=True, help="Path to the input texture file")
parser.add_argument("--missing_faces", type=str, required=True, help="Path to missing face index file")
parser.add_argument("--output_obj", type=str, required=True, help="Path to the output OBJ file")
parser.add_argument("--output_texture", type=str, required=True, help="Path to the output texture file")
# 可选参数
parser.add_argument("--missing_index_base", type=int, default=0,
help="missing_faces.txt 的索引基准。原脚本默认 0-based;如果文件是 1-based,传 1。")
parser.add_argument("--tile_size", type=int, default=12,
help="每个 missing face 在新增 atlas 中的 tile 尺寸。越大越细,贴图越大。推荐 8~14。")
parser.add_argument("--tile_pad", type=int, default=2,
help="每个 tile 内三角形到边界的 padding。用于减少纹理过滤串色。推荐 2。")
parser.add_argument("--smooth_iters", type=int, default=80,
help="顶点颜色 harmonic 扩散迭代次数。越大越平滑,稍慢。推荐 50~120。")
parser.add_argument("--self_weight", type=float, default=0.0,
help="扩散时保留当前颜色的权重。一般用 0。")
parser.add_argument("--corner_inset", type=float, default=0.90,
help="边界顶点采样时往正常 face 内部缩进的比例。0.85~0.95 比较稳。")
args = parser.parse_args()
process(
input_obj_path=args.input_obj,
input_texture_path=args.input_texture,
missing_faces_path=args.missing_faces,
output_obj_path=args.output_obj,
output_texture_path=args.output_texture,
missing_index_base=args.missing_index_base,
tile_size=args.tile_size,
tile_pad=args.tile_pad,
smooth_iters=args.smooth_iters,
self_weight=args.self_weight,
corner_inset=args.corner_inset,
)
if __name__ == "__main__":
main()