You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
766 lines
27 KiB
766 lines
27 KiB
#!/usr/bin/env python3 |
|
# -*- coding: utf-8 -*- |
|
""" |
|
fill_all_empty_faces_v2_harmonic_atlas.py |
|
|
|
目标:替换原来的“一个缺色孤岛 -> 一个均值色块”的逻辑。 |
|
|
|
核心逻辑: |
|
1. 正常 face 保持原有 UV,只因为 texture 高度变大而 remap v 坐标。 |
|
2. missing face 不再按 region 写一个颜色点,也不再整组 face 指向同一个 vt。 |
|
3. 在 3D mesh 拓扑上,对 missing region 的“顶点颜色”做 harmonic/Laplace 扩散: |
|
- 边界顶点颜色来自相邻正常 face 的原始 texture 采样; |
|
- 内部顶点颜色由图拉普拉斯扩散得到; |
|
- 所以颜色从边缘向内部连续变化。 |
|
4. 每个 missing face 在底部新增 atlas 中分配一个小三角 tile。 |
|
- 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 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 内按三个顶点颜色插值,不是纯色块。 |
|
""" |
|
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) |
|
|
|
cols = max(1, W // tile_size) |
|
rows = int(np.ceil(n_missing / cols)) |
|
atlas_h = rows * tile_size |
|
new_H = H + atlas_h |
|
|
|
print(f"atlas tiles: {n_missing}, cols={cols}, rows={rows}, atlas_h={atlas_h}") |
|
|
|
new_texture = np.full((new_H, W, 3), 255, dtype=np.uint8) |
|
new_texture[:H, :, :] = texture_rgb |
|
|
|
# 原始 UV remap:保持原图像素位置不变,只是贴图高度变大。 |
|
remapped_uvs = original_uvs.copy().astype(np.float32) |
|
old_y = (1.0 - remapped_uvs[:, 1]) * (H - 1) |
|
remapped_uvs[:, 1] = 1.0 - old_y / max(new_H - 1, 1) |
|
|
|
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 |
|
|
|
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(W - 1, 1) |
|
v = 1.0 - py / max(new_H - 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) |
|
|
|
return new_texture, np.asarray(new_uvs, dtype=np.float32), 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: |
|
write_obj_with_uv_coordinates(output_obj_path, vertices, uvs, faces, face_uv_indices, mtllib_lines, usemtl_name) |
|
write_texture_rgb(output_texture_path, texture_rgb) |
|
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()
|
|
|