Spaces:
Sleeping
Sleeping
| import os | |
| import tempfile | |
| from dataclasses import dataclass | |
| from typing import List, Tuple | |
| import gradio as gr | |
| import imageio.v3 as iio | |
| import numpy as np | |
| try: | |
| import cv2 # type: ignore | |
| except ImportError: # pragma: no cover - optional dependency | |
| cv2 = None | |
| SUPPORTED_EXTENSIONS = {".png", ".tif", ".tiff", ".exr"} | |
| class MeshData: | |
| vertices: List[Tuple[float, float, float]] | |
| faces: List[Tuple[int, int, int]] | |
| def _normalize_heightmap(heightmap: np.ndarray) -> np.ndarray: | |
| if heightmap.ndim == 3: | |
| if heightmap.shape[2] >= 3: | |
| heightmap = heightmap[..., :3].mean(axis=2) | |
| else: | |
| heightmap = heightmap[..., 0] | |
| heightmap = np.asarray(heightmap, dtype=np.float32) | |
| min_val = float(np.min(heightmap)) | |
| max_val = float(np.max(heightmap)) | |
| if max_val <= min_val: | |
| return np.zeros_like(heightmap, dtype=np.float32) | |
| return (heightmap - min_val) / (max_val - min_val) | |
| def _read_heightmap(path: str) -> Tuple[np.ndarray, bool]: | |
| ext = os.path.splitext(path)[1].lower() | |
| if ext not in SUPPORTED_EXTENSIONS: | |
| raise ValueError(f"Unsupported file type: {ext}") | |
| if ext == ".exr": | |
| if cv2 is None: | |
| raise RuntimeError("EXR読み込みにはopencv-pythonが必要です。") | |
| image = cv2.imread(path, cv2.IMREAD_ANYDEPTH | cv2.IMREAD_GRAYSCALE) | |
| if image is None: | |
| raise RuntimeError("EXRファイルの読み込みに失敗しました。") | |
| return _normalize_heightmap(image), False | |
| image = iio.imread(path) | |
| is_8bit = image.dtype == np.uint8 | |
| return _normalize_heightmap(image), is_8bit | |
| def _smooth_heightmap(heightmap: np.ndarray) -> np.ndarray: | |
| if heightmap.ndim != 2: | |
| raise ValueError("高さマップは2次元配列である必要があります。") | |
| padded = np.pad(heightmap, 1, mode="edge") | |
| blurred = ( | |
| padded[:-2, :-2] | |
| + 2 * padded[:-2, 1:-1] | |
| + padded[:-2, 2:] | |
| + 2 * padded[1:-1, :-2] | |
| + 4 * padded[1:-1, 1:-1] | |
| + 2 * padded[1:-1, 2:] | |
| + padded[2:, :-2] | |
| + 2 * padded[2:, 1:-1] | |
| + padded[2:, 2:] | |
| ) / 16.0 | |
| return blurred.astype(np.float32, copy=False) | |
| def _build_mesh( | |
| heightmap: np.ndarray, | |
| x_mm_per_px: float, | |
| y_mm_per_px: float, | |
| base_thickness_mm: float, | |
| height_scale_mm: float, | |
| bottom_mode: str, | |
| reverse_face: bool, | |
| ) -> MeshData: | |
| heightmap = np.clip(heightmap, 0.0, 1.0) | |
| height_values = base_thickness_mm + heightmap * height_scale_mm | |
| rows, cols = heightmap.shape | |
| vertices: List[Tuple[float, float, float]] = [] | |
| faces: List[Tuple[int, int, int]] = [] | |
| for r in range(rows): | |
| y = (rows - 1 - r) * y_mm_per_px | |
| for c in range(cols): | |
| x = c * x_mm_per_px | |
| z = float(height_values[r, c]) | |
| vertices.append((x, y, z)) | |
| top_offset = 0 | |
| for r in range(rows - 1): | |
| for c in range(cols - 1): | |
| v0 = top_offset + r * cols + c | |
| v1 = top_offset + r * cols + c + 1 | |
| v2 = top_offset + (r + 1) * cols + c | |
| v3 = top_offset + (r + 1) * cols + c + 1 | |
| faces.append((v0, v1, v2)) | |
| faces.append((v1, v3, v2)) | |
| def perimeter_indices() -> List[int]: | |
| if rows == 1 and cols == 1: | |
| return [0] | |
| indices = [c for c in range(cols)] | |
| if rows > 2: | |
| indices += [r * cols + (cols - 1) for r in range(1, rows - 1)] | |
| if rows > 1: | |
| indices += [(rows - 1) * cols + c for c in range(cols - 1, -1, -1)] | |
| if cols > 1 and rows > 2: | |
| indices += [r * cols for r in range(rows - 2, 0, -1)] | |
| return indices | |
| bottom_offset = len(vertices) | |
| bottom_index_map = None | |
| if bottom_mode == "normal": | |
| for r in range(rows): | |
| y = (rows - 1 - r) * y_mm_per_px | |
| for c in range(cols): | |
| x = c * x_mm_per_px | |
| z = 0.0 | |
| vertices.append((x, y, z)) | |
| for r in range(rows - 1): | |
| for c in range(cols - 1): | |
| v0 = bottom_offset + r * cols + c | |
| v1 = bottom_offset + r * cols + c + 1 | |
| v2 = bottom_offset + (r + 1) * cols + c | |
| v3 = bottom_offset + (r + 1) * cols + c + 1 | |
| faces.append((v0, v2, v1)) | |
| faces.append((v1, v2, v3)) | |
| else: | |
| perimeter = perimeter_indices() | |
| bottom_index_map = {} | |
| for top_idx in perimeter: | |
| r = top_idx // cols | |
| c = top_idx % cols | |
| x = c * x_mm_per_px | |
| y = (rows - 1 - r) * y_mm_per_px | |
| z = 0.0 | |
| bottom_index_map[top_idx] = len(vertices) | |
| vertices.append((x, y, z)) | |
| if bottom_mode == "reduction" and len(perimeter) >= 3: | |
| center = bottom_index_map[perimeter[0]] | |
| for i in range(1, len(perimeter) - 1): | |
| a = bottom_index_map[perimeter[i]] | |
| b = bottom_index_map[perimeter[i + 1]] | |
| faces.append((center, b, a)) | |
| def side_faces(index_iter, flip=False): | |
| for i in range(len(index_iter) - 1): | |
| top_a = index_iter[i] | |
| top_b = index_iter[i + 1] | |
| if bottom_index_map is None: | |
| bottom_a = bottom_offset + top_a | |
| bottom_b = bottom_offset + top_b | |
| else: | |
| bottom_a = bottom_index_map[top_a] | |
| bottom_b = bottom_index_map[top_b] | |
| if flip: | |
| faces.append((top_a, bottom_b, bottom_a)) | |
| faces.append((top_a, top_b, bottom_b)) | |
| else: | |
| faces.append((top_a, bottom_a, bottom_b)) | |
| faces.append((top_a, bottom_b, top_b)) | |
| # Top edge (row 0) | |
| side_faces([c for c in range(cols)], flip=False) | |
| # Bottom edge (row rows-1) | |
| side_faces([ (rows - 1) * cols + c for c in range(cols)], flip=True) | |
| # Left edge (col 0) | |
| side_faces([ r * cols for r in range(rows)], flip=True) | |
| # Right edge (col cols-1) | |
| side_faces([ r * cols + (cols - 1) for r in range(rows)], flip=False) | |
| if reverse_face: | |
| return MeshData(vertices=vertices, faces=faces) | |
| flipped_faces = [(a, c, b) for a, b, c in faces] | |
| return MeshData(vertices=vertices, faces=flipped_faces) | |
| def _resample_heightmap(heightmap: np.ndarray, factor: float) -> np.ndarray: | |
| if factor <= 0: | |
| raise ValueError("XY解像度係数は正の値である必要があります。") | |
| if abs(factor - 1.0) < 1e-6: | |
| return heightmap | |
| rows, cols = heightmap.shape | |
| new_rows = max(1, int(round(rows * factor))) | |
| new_cols = max(1, int(round(cols * factor))) | |
| if cv2 is not None: | |
| interpolation = cv2.INTER_AREA if factor < 1.0 else cv2.INTER_LINEAR | |
| resized = cv2.resize(heightmap, (new_cols, new_rows), interpolation=interpolation) | |
| return resized.astype(np.float32, copy=False) | |
| row_coords = np.linspace(0, rows - 1, new_rows) | |
| col_coords = np.linspace(0, cols - 1, new_cols) | |
| tmp = np.empty((new_rows, cols), dtype=np.float32) | |
| for idx, coord in enumerate(row_coords): | |
| base = int(np.floor(coord)) | |
| frac = coord - base | |
| if base >= rows - 1: | |
| tmp[idx, :] = heightmap[-1, :] | |
| else: | |
| tmp[idx, :] = heightmap[base, :] * (1.0 - frac) + heightmap[base + 1, :] * frac | |
| resized = np.empty((new_rows, new_cols), dtype=np.float32) | |
| for idx, coord in enumerate(col_coords): | |
| base = int(np.floor(coord)) | |
| frac = coord - base | |
| if base >= cols - 1: | |
| resized[:, idx] = tmp[:, -1] | |
| else: | |
| resized[:, idx] = tmp[:, base] * (1.0 - frac) + tmp[:, base + 1] * frac | |
| return resized | |
| def _write_obj(mesh: MeshData, out_path: str) -> str: | |
| with open(out_path, "w", encoding="utf-8") as obj_file: | |
| obj_file.write("# Heightmap mesh\n") | |
| for v in mesh.vertices: | |
| obj_file.write(f"v {v[0]:.6f} {v[1]:.6f} {v[2]:.6f}\n") | |
| for f in mesh.faces: | |
| v1, v2, v3 = (idx + 1 for idx in f) | |
| obj_file.write(f"f {v1} {v2} {v3}\n") | |
| return out_path | |
| def _write_ply(mesh: MeshData, out_path: str) -> str: | |
| with open(out_path, "w", encoding="utf-8") as ply_file: | |
| ply_file.write("ply\nformat ascii 1.0\n") | |
| ply_file.write(f"element vertex {len(mesh.vertices)}\n") | |
| ply_file.write("property float x\nproperty float y\nproperty float z\n") | |
| ply_file.write(f"element face {len(mesh.faces)}\n") | |
| ply_file.write("property list uchar int vertex_indices\nend_header\n") | |
| for v in mesh.vertices: | |
| ply_file.write(f"{v[0]:.6f} {v[1]:.6f} {v[2]:.6f}\n") | |
| for f in mesh.faces: | |
| ply_file.write(f"3 {f[0]} {f[1]} {f[2]}\n") | |
| return out_path | |
| def generate_mesh( | |
| file, | |
| output_format, | |
| height_ratio, | |
| xy_resolution_factor, | |
| width_mm, | |
| height_mm, | |
| base_thickness_mm, | |
| bottom_mode, | |
| invert_heightmap, | |
| reverse_face, | |
| ): | |
| if file is None: | |
| raise gr.Error("ファイルをアップロードしてください。") | |
| if width_mm <= 0 or height_mm <= 0: | |
| raise gr.Error("縦横サイズ(mm)は正の値で指定してください。") | |
| path = file.name if hasattr(file, "name") else file | |
| heightmap, is_8bit = _read_heightmap(path) | |
| if is_8bit: | |
| heightmap = _smooth_heightmap(heightmap) | |
| heightmap = _resample_heightmap(heightmap, float(xy_resolution_factor)) | |
| if invert_heightmap: | |
| heightmap = 1.0 - heightmap | |
| height_scale_mm = float(height_ratio) * float(np.sqrt(width_mm * height_mm)) | |
| bottom_mode_map = { | |
| "ノーマル": "normal", | |
| "リダクション": "reduction", | |
| "カット": "cut", | |
| } | |
| resolved_bottom_mode = bottom_mode_map.get(str(bottom_mode), "reduction") | |
| rows, cols = heightmap.shape | |
| x_mm_per_px = float(width_mm) / max(cols - 1, 1) | |
| y_mm_per_px = float(height_mm) / max(rows - 1, 1) | |
| mesh = _build_mesh( | |
| heightmap=heightmap, | |
| x_mm_per_px=x_mm_per_px, | |
| y_mm_per_px=y_mm_per_px, | |
| base_thickness_mm=float(base_thickness_mm), | |
| height_scale_mm=float(height_scale_mm), | |
| bottom_mode=resolved_bottom_mode, | |
| reverse_face=bool(reverse_face), | |
| ) | |
| suffix = ".obj" if output_format == "OBJ" else ".ply" | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file: | |
| out_path = tmp_file.name | |
| if output_format == "OBJ": | |
| _write_obj(mesh, out_path) | |
| else: | |
| _write_ply(mesh, out_path) | |
| return out_path | |
| def preview_heightmap(file): | |
| if file is None: | |
| return None | |
| path = file.name if hasattr(file, "name") else file | |
| heightmap, _ = _read_heightmap(path) | |
| return heightmap | |
| def _update_aspect_from_file(file, width_mm, height_mm, lock_aspect): | |
| if file is None: | |
| return 1.0, gr.update(), gr.update() | |
| path = file.name if hasattr(file, "name") else file | |
| heightmap, _ = _read_heightmap(path) | |
| rows, cols = heightmap.shape | |
| aspect = cols / rows if rows else 1.0 | |
| if lock_aspect: | |
| return aspect, gr.update(value=float(width_mm)), gr.update(value=float(width_mm) / aspect) | |
| return aspect, gr.update(), gr.update() | |
| def _sync_height_from_width(width_mm, aspect, lock_aspect, sync_source): | |
| if sync_source == "height": | |
| return gr.update(), "" | |
| if not lock_aspect or aspect <= 0: | |
| return gr.update(), "" | |
| return gr.update(value=float(width_mm) / aspect), "width" | |
| def _sync_width_from_height(height_mm, aspect, lock_aspect, sync_source): | |
| if sync_source == "width": | |
| return gr.update(), "" | |
| if not lock_aspect or aspect <= 0: | |
| return gr.update(), "" | |
| return gr.update(value=float(height_mm) * aspect), "height" | |
| def _height_scale_from_ratio(height_ratio, width_mm, height_mm): | |
| return float(height_ratio) * float(np.sqrt(float(width_mm) * float(height_mm))) | |
| def _height_ratio_from_scale(height_scale_mm, width_mm, height_mm): | |
| area = float(width_mm) * float(height_mm) | |
| if area <= 0: | |
| return 0.0 | |
| return float(height_scale_mm) / float(np.sqrt(area)) | |
| def _sync_height_scale_from_ratio(height_ratio, width_mm, height_mm, height_source): | |
| if height_source == "scale": | |
| return gr.update(), "" | |
| return gr.update(value=_height_scale_from_ratio(height_ratio, width_mm, height_mm)), "ratio" | |
| def _sync_ratio_from_height_scale( | |
| height_scale_mm, width_mm, height_mm, height_source | |
| ): | |
| if height_source == "ratio": | |
| return gr.update(), "" | |
| height_ratio = _height_ratio_from_scale(height_scale_mm, width_mm, height_mm) | |
| return gr.update(value=height_ratio), "scale" | |
| def _sync_height_inputs_from_size( | |
| width_mm, height_mm, height_ratio, height_scale_mm, height_source | |
| ): | |
| if height_source == "scale": | |
| height_ratio = _height_ratio_from_scale(height_scale_mm, width_mm, height_mm) | |
| return gr.update(value=height_ratio), gr.update() | |
| return ( | |
| gr.update(), | |
| gr.update(value=_height_scale_from_ratio(height_ratio, width_mm, height_mm)), | |
| ) | |
| def build_app() -> gr.Blocks: | |
| with gr.Blocks( | |
| title="Heightmap to Mesh", | |
| css=""" | |
| #preview-image button[aria-label="Share"] { | |
| display: none !important; | |
| } | |
| #preview-image button[title="Share"], | |
| #preview-image .share-btn, | |
| #preview-image .share-button { | |
| display: none !important; | |
| } | |
| """, | |
| ) as demo: | |
| gr.Markdown( | |
| """ | |
| # Heightmap to Mesh (OBJ/PLY) | |
| モノクロのハイトマップ画像からOBJ/PLYメッシュを生成します。 | |
| - 対応形式: PNG / TIFF / EXR - 8bit/16bit/float | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| file_input = gr.File(label="ハイトマップ画像") | |
| with gr.Row(): | |
| reverse_face = gr.Checkbox(value=False, label="ポリゴン裏表反転") | |
| output_format = gr.Radio(["OBJ", "PLY"], value="OBJ", label="出力形式") | |
| xy_resolution_input = gr.Number( | |
| value=0.5, | |
| minimum=0.001, | |
| label="XY解像度", | |
| ) | |
| bottom_mode = gr.Dropdown( | |
| ["ノーマル", "リダクション", "カット"], | |
| value="リダクション", | |
| label="底面メッシュ", | |
| ) | |
| with gr.Row(): | |
| lock_aspect = gr.Checkbox(value=True, label="比率固定") | |
| width_mm = gr.Number(value=100.0, minimum=0.001, label="横サイズ(mm)") | |
| height_mm = gr.Number(value=100.0, minimum=0.001, label="縦サイズ(mm)") | |
| with gr.Row(): | |
| invert_heightmap = gr.Checkbox(value=False, label="ハイトマップ上下反転") | |
| height_ratio_input = gr.Number( | |
| value=0.5, | |
| minimum=0.0, | |
| label="高さ比率", | |
| ) | |
| height_scale_mm = gr.Number( | |
| value=_height_scale_from_ratio(0.5, 100.0, 100.0), | |
| minimum=0.0, | |
| label="高さ(mm)", | |
| ) | |
| base_thickness = gr.Number(value=1.0, minimum=0.0, label="ベース厚み(mm)") | |
| generate_button = gr.Button("メッシュ生成") | |
| with gr.Column(): | |
| preview_image = gr.Image(label="プレビュー", type="numpy", elem_id="preview-image") | |
| output_file = gr.File(label="ダウンロード") | |
| aspect_state = gr.State(1.0) | |
| sync_state = gr.State("") | |
| height_source = gr.State("ratio") | |
| generate_button.click( | |
| generate_mesh, | |
| inputs=[ | |
| file_input, | |
| output_format, | |
| height_ratio_input, | |
| xy_resolution_input, | |
| width_mm, | |
| height_mm, | |
| base_thickness, | |
| bottom_mode, | |
| invert_heightmap, | |
| reverse_face, | |
| ], | |
| outputs=output_file, | |
| ) | |
| file_input.change(preview_heightmap, inputs=file_input, outputs=preview_image) | |
| file_input.change( | |
| _update_aspect_from_file, | |
| inputs=[file_input, width_mm, height_mm, lock_aspect], | |
| outputs=[aspect_state, width_mm, height_mm], | |
| ) | |
| width_mm.change( | |
| _sync_height_from_width, | |
| inputs=[width_mm, aspect_state, lock_aspect, sync_state], | |
| outputs=[height_mm, sync_state], | |
| ) | |
| width_mm.change( | |
| _sync_height_inputs_from_size, | |
| inputs=[ | |
| width_mm, | |
| height_mm, | |
| height_ratio_input, | |
| height_scale_mm, | |
| height_source, | |
| ], | |
| outputs=[height_ratio_input, height_scale_mm], | |
| ) | |
| height_mm.change( | |
| _sync_width_from_height, | |
| inputs=[height_mm, aspect_state, lock_aspect, sync_state], | |
| outputs=[width_mm, sync_state], | |
| ) | |
| height_mm.change( | |
| _sync_height_inputs_from_size, | |
| inputs=[ | |
| width_mm, | |
| height_mm, | |
| height_ratio_input, | |
| height_scale_mm, | |
| height_source, | |
| ], | |
| outputs=[height_ratio_input, height_scale_mm], | |
| ) | |
| height_ratio_input.change( | |
| _sync_height_scale_from_ratio, | |
| inputs=[height_ratio_input, width_mm, height_mm, height_source], | |
| outputs=[height_scale_mm, height_source], | |
| ) | |
| height_scale_mm.change( | |
| _sync_ratio_from_height_scale, | |
| inputs=[height_scale_mm, width_mm, height_mm, height_source], | |
| outputs=[height_ratio_input, height_source], | |
| ) | |
| lock_aspect.change( | |
| _sync_height_from_width, | |
| inputs=[width_mm, aspect_state, lock_aspect, sync_state], | |
| outputs=[height_mm, sync_state], | |
| ) | |
| return demo | |
| app = build_app() | |
| if __name__ == "__main__": | |
| port_value = os.getenv("PORT") | |
| app.launch( | |
| server_name="0.0.0.0", | |
| server_port=int(port_value) if port_value else 7860, | |
| ) | |