harayoki commited on
Commit
6ea0d47
·
verified ·
1 Parent(s): bdc6f25

Upload 5 files

Browse files
Files changed (5) hide show
  1. .gitignore +207 -0
  2. LICENSE +21 -0
  3. README.md +19 -14
  4. app.py +521 -0
  5. requirements.txt +4 -0
.gitignore ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 harayoki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,14 +1,19 @@
1
- ---
2
- title: Heightmap2Mesh
3
- emoji: 👁
4
- colorFrom: red
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.4.0
8
- app_file: app.py
9
- pinned: false
10
- license: mit
11
- short_description: Heightmap2Mesh
12
- ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
1
+ # Heightmap to Mesh (OBJ/PLY)
2
+
3
+ Gradioでモノクロのハイトマップ画像(PNG / TIFF / EXR)からOBJまたはPLYメッシュを生成します。
4
+
5
+ ## 使い方
6
+ 1. ハイトマップ画像をアップロード
7
+ 2. 出力形式と各パラメータを調整
8
+ 3. 「メッシュ生成」を押してダウンロード
9
+
10
+ ## パラメータ
11
+ - **最大高さ(mm)**: 0〜1に正規化されたハイトマップの最大高さ(mm)です。
12
+ - **ベース厚み(mm)**: メッシュの底面からの厚みです。
13
+ - **XY解像度(mm/px)**: X/Y方向の1ピクセル当たりのスケール(mm)です。
14
+
15
+ ## 依存関係
16
+ - gradio
17
+ - numpy
18
+ - imageio
19
+ - opencv-python (EXR読み込み用)
app.py ADDED
@@ -0,0 +1,521 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ from dataclasses import dataclass
4
+ from typing import List, Tuple
5
+
6
+ import gradio as gr
7
+ import imageio.v3 as iio
8
+ import numpy as np
9
+
10
+ try:
11
+ import cv2 # type: ignore
12
+ except ImportError: # pragma: no cover - optional dependency
13
+ cv2 = None
14
+
15
+
16
+ SUPPORTED_EXTENSIONS = {".png", ".tif", ".tiff", ".exr"}
17
+
18
+
19
+ @dataclass
20
+ class MeshData:
21
+ vertices: List[Tuple[float, float, float]]
22
+ faces: List[Tuple[int, int, int]]
23
+
24
+
25
+ def _normalize_heightmap(heightmap: np.ndarray) -> np.ndarray:
26
+ if heightmap.ndim == 3:
27
+ if heightmap.shape[2] >= 3:
28
+ heightmap = heightmap[..., :3].mean(axis=2)
29
+ else:
30
+ heightmap = heightmap[..., 0]
31
+
32
+ heightmap = np.asarray(heightmap, dtype=np.float32)
33
+ min_val = float(np.min(heightmap))
34
+ max_val = float(np.max(heightmap))
35
+ if max_val <= min_val:
36
+ return np.zeros_like(heightmap, dtype=np.float32)
37
+ return (heightmap - min_val) / (max_val - min_val)
38
+
39
+
40
+ def _read_heightmap(path: str) -> Tuple[np.ndarray, bool]:
41
+ ext = os.path.splitext(path)[1].lower()
42
+ if ext not in SUPPORTED_EXTENSIONS:
43
+ raise ValueError(f"Unsupported file type: {ext}")
44
+
45
+ if ext == ".exr":
46
+ if cv2 is None:
47
+ raise RuntimeError("EXR読み込みにはopencv-pythonが必要です。")
48
+ image = cv2.imread(path, cv2.IMREAD_ANYDEPTH | cv2.IMREAD_GRAYSCALE)
49
+ if image is None:
50
+ raise RuntimeError("EXRファイルの読み込みに失敗しました。")
51
+ return _normalize_heightmap(image), False
52
+
53
+ image = iio.imread(path)
54
+ is_8bit = image.dtype == np.uint8
55
+ return _normalize_heightmap(image), is_8bit
56
+
57
+
58
+ def _smooth_heightmap(heightmap: np.ndarray) -> np.ndarray:
59
+ if heightmap.ndim != 2:
60
+ raise ValueError("高さマップは2次元配列である必要があります。")
61
+ padded = np.pad(heightmap, 1, mode="edge")
62
+ blurred = (
63
+ padded[:-2, :-2]
64
+ + 2 * padded[:-2, 1:-1]
65
+ + padded[:-2, 2:]
66
+ + 2 * padded[1:-1, :-2]
67
+ + 4 * padded[1:-1, 1:-1]
68
+ + 2 * padded[1:-1, 2:]
69
+ + padded[2:, :-2]
70
+ + 2 * padded[2:, 1:-1]
71
+ + padded[2:, 2:]
72
+ ) / 16.0
73
+ return blurred.astype(np.float32, copy=False)
74
+
75
+
76
+ def _build_mesh(
77
+ heightmap: np.ndarray,
78
+ x_mm_per_px: float,
79
+ y_mm_per_px: float,
80
+ base_thickness_mm: float,
81
+ height_scale_mm: float,
82
+ bottom_mode: str,
83
+ ) -> MeshData:
84
+ heightmap = np.clip(heightmap, 0.0, 1.0)
85
+ height_values = base_thickness_mm + heightmap * height_scale_mm
86
+
87
+ rows, cols = heightmap.shape
88
+ vertices: List[Tuple[float, float, float]] = []
89
+ faces: List[Tuple[int, int, int]] = []
90
+
91
+ for r in range(rows):
92
+ y = (rows - 1 - r) * y_mm_per_px
93
+ for c in range(cols):
94
+ x = c * x_mm_per_px
95
+ z = float(height_values[r, c])
96
+ vertices.append((x, y, z))
97
+
98
+ top_offset = 0
99
+ for r in range(rows - 1):
100
+ for c in range(cols - 1):
101
+ v0 = top_offset + r * cols + c
102
+ v1 = top_offset + r * cols + c + 1
103
+ v2 = top_offset + (r + 1) * cols + c
104
+ v3 = top_offset + (r + 1) * cols + c + 1
105
+ faces.append((v0, v1, v2))
106
+ faces.append((v1, v3, v2))
107
+
108
+ def perimeter_indices() -> List[int]:
109
+ if rows == 1 and cols == 1:
110
+ return [0]
111
+ indices = [c for c in range(cols)]
112
+ if rows > 2:
113
+ indices += [r * cols + (cols - 1) for r in range(1, rows - 1)]
114
+ if rows > 1:
115
+ indices += [(rows - 1) * cols + c for c in range(cols - 1, -1, -1)]
116
+ if cols > 1 and rows > 2:
117
+ indices += [r * cols for r in range(rows - 2, 0, -1)]
118
+ return indices
119
+
120
+ bottom_offset = len(vertices)
121
+ bottom_index_map = None
122
+ if bottom_mode == "normal":
123
+ for r in range(rows):
124
+ y = (rows - 1 - r) * y_mm_per_px
125
+ for c in range(cols):
126
+ x = c * x_mm_per_px
127
+ z = 0.0
128
+ vertices.append((x, y, z))
129
+
130
+ for r in range(rows - 1):
131
+ for c in range(cols - 1):
132
+ v0 = bottom_offset + r * cols + c
133
+ v1 = bottom_offset + r * cols + c + 1
134
+ v2 = bottom_offset + (r + 1) * cols + c
135
+ v3 = bottom_offset + (r + 1) * cols + c + 1
136
+ faces.append((v0, v2, v1))
137
+ faces.append((v1, v2, v3))
138
+ else:
139
+ perimeter = perimeter_indices()
140
+ bottom_index_map = {}
141
+ for top_idx in perimeter:
142
+ r = top_idx // cols
143
+ c = top_idx % cols
144
+ x = c * x_mm_per_px
145
+ y = (rows - 1 - r) * y_mm_per_px
146
+ z = 0.0
147
+ bottom_index_map[top_idx] = len(vertices)
148
+ vertices.append((x, y, z))
149
+
150
+ if bottom_mode == "reduction" and len(perimeter) >= 3:
151
+ center = bottom_index_map[perimeter[0]]
152
+ for i in range(1, len(perimeter) - 1):
153
+ a = bottom_index_map[perimeter[i]]
154
+ b = bottom_index_map[perimeter[i + 1]]
155
+ faces.append((center, b, a))
156
+
157
+ def side_faces(index_iter, flip=False):
158
+ for i in range(len(index_iter) - 1):
159
+ top_a = index_iter[i]
160
+ top_b = index_iter[i + 1]
161
+ if bottom_index_map is None:
162
+ bottom_a = bottom_offset + top_a
163
+ bottom_b = bottom_offset + top_b
164
+ else:
165
+ bottom_a = bottom_index_map[top_a]
166
+ bottom_b = bottom_index_map[top_b]
167
+ if flip:
168
+ faces.append((top_a, bottom_b, bottom_a))
169
+ faces.append((top_a, top_b, bottom_b))
170
+ else:
171
+ faces.append((top_a, bottom_a, bottom_b))
172
+ faces.append((top_a, bottom_b, top_b))
173
+
174
+ # Top edge (row 0)
175
+ side_faces([c for c in range(cols)], flip=False)
176
+ # Bottom edge (row rows-1)
177
+ side_faces([ (rows - 1) * cols + c for c in range(cols)], flip=True)
178
+ # Left edge (col 0)
179
+ side_faces([ r * cols for r in range(rows)], flip=True)
180
+ # Right edge (col cols-1)
181
+ side_faces([ r * cols + (cols - 1) for r in range(rows)], flip=False)
182
+
183
+ flipped_faces = [(a, c, b) for a, b, c in faces]
184
+ return MeshData(vertices=vertices, faces=flipped_faces)
185
+
186
+
187
+ def _resample_heightmap(heightmap: np.ndarray, factor: float) -> np.ndarray:
188
+ if factor <= 0:
189
+ raise ValueError("XY解像度係数は正の値である必要があります。")
190
+ if abs(factor - 1.0) < 1e-6:
191
+ return heightmap
192
+
193
+ rows, cols = heightmap.shape
194
+ new_rows = max(1, int(round(rows * factor)))
195
+ new_cols = max(1, int(round(cols * factor)))
196
+
197
+ if cv2 is not None:
198
+ interpolation = cv2.INTER_AREA if factor < 1.0 else cv2.INTER_LINEAR
199
+ resized = cv2.resize(heightmap, (new_cols, new_rows), interpolation=interpolation)
200
+ return resized.astype(np.float32, copy=False)
201
+
202
+ row_coords = np.linspace(0, rows - 1, new_rows)
203
+ col_coords = np.linspace(0, cols - 1, new_cols)
204
+ tmp = np.empty((new_rows, cols), dtype=np.float32)
205
+ for idx, coord in enumerate(row_coords):
206
+ base = int(np.floor(coord))
207
+ frac = coord - base
208
+ if base >= rows - 1:
209
+ tmp[idx, :] = heightmap[-1, :]
210
+ else:
211
+ tmp[idx, :] = heightmap[base, :] * (1.0 - frac) + heightmap[base + 1, :] * frac
212
+
213
+ resized = np.empty((new_rows, new_cols), dtype=np.float32)
214
+ for idx, coord in enumerate(col_coords):
215
+ base = int(np.floor(coord))
216
+ frac = coord - base
217
+ if base >= cols - 1:
218
+ resized[:, idx] = tmp[:, -1]
219
+ else:
220
+ resized[:, idx] = tmp[:, base] * (1.0 - frac) + tmp[:, base + 1] * frac
221
+
222
+ return resized
223
+
224
+
225
+ def _write_obj(mesh: MeshData, out_path: str) -> str:
226
+ with open(out_path, "w", encoding="utf-8") as obj_file:
227
+ obj_file.write("# Heightmap mesh\n")
228
+ for v in mesh.vertices:
229
+ obj_file.write(f"v {v[0]:.6f} {v[1]:.6f} {v[2]:.6f}\n")
230
+ for f in mesh.faces:
231
+ v1, v2, v3 = (idx + 1 for idx in f)
232
+ obj_file.write(f"f {v1} {v2} {v3}\n")
233
+ return out_path
234
+
235
+
236
+ def _write_ply(mesh: MeshData, out_path: str) -> str:
237
+ with open(out_path, "w", encoding="utf-8") as ply_file:
238
+ ply_file.write("ply\nformat ascii 1.0\n")
239
+ ply_file.write(f"element vertex {len(mesh.vertices)}\n")
240
+ ply_file.write("property float x\nproperty float y\nproperty float z\n")
241
+ ply_file.write(f"element face {len(mesh.faces)}\n")
242
+ ply_file.write("property list uchar int vertex_indices\nend_header\n")
243
+ for v in mesh.vertices:
244
+ ply_file.write(f"{v[0]:.6f} {v[1]:.6f} {v[2]:.6f}\n")
245
+ for f in mesh.faces:
246
+ ply_file.write(f"3 {f[0]} {f[1]} {f[2]}\n")
247
+ return out_path
248
+
249
+
250
+ def generate_mesh(
251
+ file,
252
+ output_format,
253
+ height_ratio,
254
+ xy_resolution_factor,
255
+ width_mm,
256
+ height_mm,
257
+ base_thickness_mm,
258
+ bottom_mode,
259
+ invert_heightmap,
260
+ ):
261
+ if file is None:
262
+ raise gr.Error("ファイルをアップロードしてください。")
263
+
264
+ if width_mm <= 0 or height_mm <= 0:
265
+ raise gr.Error("縦横サイズ(mm)は正の値で指定してください。")
266
+
267
+ path = file.name if hasattr(file, "name") else file
268
+ heightmap, is_8bit = _read_heightmap(path)
269
+ if is_8bit:
270
+ heightmap = _smooth_heightmap(heightmap)
271
+ heightmap = _resample_heightmap(heightmap, float(xy_resolution_factor))
272
+ if invert_heightmap:
273
+ heightmap = 1.0 - heightmap
274
+
275
+ height_scale_mm = float(height_ratio) * float(np.sqrt(width_mm * height_mm))
276
+ bottom_mode_map = {
277
+ "ノーマル": "normal",
278
+ "リダクション": "reduction",
279
+ "カット": "cut",
280
+ }
281
+ resolved_bottom_mode = bottom_mode_map.get(str(bottom_mode), "reduction")
282
+
283
+ rows, cols = heightmap.shape
284
+ x_mm_per_px = float(width_mm) / max(cols - 1, 1)
285
+ y_mm_per_px = float(height_mm) / max(rows - 1, 1)
286
+
287
+ mesh = _build_mesh(
288
+ heightmap=heightmap,
289
+ x_mm_per_px=x_mm_per_px,
290
+ y_mm_per_px=y_mm_per_px,
291
+ base_thickness_mm=float(base_thickness_mm),
292
+ height_scale_mm=float(height_scale_mm),
293
+ bottom_mode=resolved_bottom_mode,
294
+ )
295
+
296
+ suffix = ".obj" if output_format == "OBJ" else ".ply"
297
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file:
298
+ out_path = tmp_file.name
299
+
300
+ if output_format == "OBJ":
301
+ _write_obj(mesh, out_path)
302
+ else:
303
+ _write_ply(mesh, out_path)
304
+
305
+ return out_path
306
+
307
+
308
+ def preview_heightmap(file):
309
+ if file is None:
310
+ return None
311
+ path = file.name if hasattr(file, "name") else file
312
+ heightmap, _ = _read_heightmap(path)
313
+ return heightmap
314
+
315
+
316
+ def _update_aspect_from_file(file, width_mm, height_mm, lock_aspect):
317
+ if file is None:
318
+ return 1.0, gr.update(), gr.update()
319
+ path = file.name if hasattr(file, "name") else file
320
+ heightmap, _ = _read_heightmap(path)
321
+ rows, cols = heightmap.shape
322
+ aspect = cols / rows if rows else 1.0
323
+ if lock_aspect:
324
+ return aspect, gr.update(value=float(width_mm)), gr.update(value=float(width_mm) / aspect)
325
+ return aspect, gr.update(), gr.update()
326
+
327
+
328
+ def _sync_height_from_width(width_mm, aspect, lock_aspect, sync_source):
329
+ if sync_source == "height":
330
+ return gr.update(), ""
331
+ if not lock_aspect or aspect <= 0:
332
+ return gr.update(), ""
333
+ return gr.update(value=float(width_mm) / aspect), "width"
334
+
335
+
336
+ def _sync_width_from_height(height_mm, aspect, lock_aspect, sync_source):
337
+ if sync_source == "width":
338
+ return gr.update(), ""
339
+ if not lock_aspect or aspect <= 0:
340
+ return gr.update(), ""
341
+ return gr.update(value=float(height_mm) * aspect), "height"
342
+
343
+
344
+ def _height_scale_from_ratio(height_ratio, width_mm, height_mm):
345
+ return float(height_ratio) * float(np.sqrt(float(width_mm) * float(height_mm)))
346
+
347
+
348
+ def _height_ratio_from_scale(height_scale_mm, width_mm, height_mm):
349
+ area = float(width_mm) * float(height_mm)
350
+ if area <= 0:
351
+ return 0.0
352
+ return float(height_scale_mm) / float(np.sqrt(area))
353
+
354
+
355
+ def _sync_height_scale_from_ratio(height_ratio, width_mm, height_mm, height_source):
356
+ if height_source == "scale":
357
+ return gr.update(), ""
358
+ return gr.update(value=_height_scale_from_ratio(height_ratio, width_mm, height_mm)), "ratio"
359
+
360
+
361
+ def _sync_ratio_from_height_scale(
362
+ height_scale_mm, width_mm, height_mm, height_source
363
+ ):
364
+ if height_source == "ratio":
365
+ return gr.update(), ""
366
+ height_ratio = _height_ratio_from_scale(height_scale_mm, width_mm, height_mm)
367
+ return gr.update(value=height_ratio), "scale"
368
+
369
+
370
+ def _sync_height_inputs_from_size(
371
+ width_mm, height_mm, height_ratio, height_scale_mm, height_source
372
+ ):
373
+ if height_source == "scale":
374
+ height_ratio = _height_ratio_from_scale(height_scale_mm, width_mm, height_mm)
375
+ return gr.update(value=height_ratio), gr.update()
376
+ return (
377
+ gr.update(),
378
+ gr.update(value=_height_scale_from_ratio(height_ratio, width_mm, height_mm)),
379
+ )
380
+
381
+
382
+ def build_app() -> gr.Blocks:
383
+ with gr.Blocks(
384
+ title="Heightmap to Mesh",
385
+ css="""
386
+ #preview-image button[aria-label="Share"] {
387
+ display: none !important;
388
+ }
389
+ #preview-image button[title="Share"],
390
+ #preview-image .share-btn,
391
+ #preview-image .share-button {
392
+ display: none !important;
393
+ }
394
+ """,
395
+ ) as demo:
396
+ gr.Markdown(
397
+ """
398
+ # Heightmap to Mesh (OBJ/PLY)
399
+ モノクロのハイトマップ画像からOBJ/PLYメッシュを生成します。
400
+ - 対応形式: PNG / TIFF / EXR - 8bit/16bit/float
401
+ """
402
+ )
403
+ with gr.Row():
404
+ with gr.Column():
405
+ file_input = gr.File(label="ハイトマップ画像")
406
+ output_format = gr.Radio(["OBJ", "PLY"], value="OBJ", label="出力形式")
407
+ xy_resolution_slider = gr.Slider(
408
+ 0.001,
409
+ 10.0,
410
+ value=1.0,
411
+ step=0.001,
412
+ label="XY解像度",
413
+ )
414
+ bottom_mode = gr.Dropdown(
415
+ ["ノーマル", "リダクション", "カット"],
416
+ value="リダクション",
417
+ label="底面メッシュ",
418
+ )
419
+ with gr.Row():
420
+ lock_aspect = gr.Checkbox(value=True, label="比率固定")
421
+ width_mm = gr.Number(value=100.0, minimum=0.001, label="横サイズ(mm)")
422
+ height_mm = gr.Number(value=100.0, minimum=0.001, label="縦サイズ(mm)")
423
+ with gr.Row():
424
+ invert_heightmap = gr.Checkbox(value=False, label="ハイトマップ反転(計算のみ)")
425
+ height_ratio_input = gr.Number(
426
+ value=0.5,
427
+ minimum=0.0,
428
+ label="��さ比率",
429
+ )
430
+ height_scale_mm = gr.Number(
431
+ value=_height_scale_from_ratio(0.5, 100.0, 100.0),
432
+ minimum=0.0,
433
+ label="高さ(mm)",
434
+ )
435
+ base_thickness = gr.Number(value=1.0, minimum=0.0, label="ベース厚み(mm)")
436
+ generate_button = gr.Button("メッシュ生成")
437
+ with gr.Column():
438
+ preview_image = gr.Image(label="プレビュー", type="numpy", elem_id="preview-image")
439
+ output_file = gr.File(label="ダウンロード")
440
+ aspect_state = gr.State(1.0)
441
+ sync_state = gr.State("")
442
+ height_source = gr.State("ratio")
443
+ generate_button.click(
444
+ generate_mesh,
445
+ inputs=[
446
+ file_input,
447
+ output_format,
448
+ height_ratio_input,
449
+ xy_resolution_slider,
450
+ width_mm,
451
+ height_mm,
452
+ base_thickness,
453
+ bottom_mode,
454
+ invert_heightmap,
455
+ ],
456
+ outputs=output_file,
457
+ )
458
+ file_input.change(preview_heightmap, inputs=file_input, outputs=preview_image)
459
+ file_input.change(
460
+ _update_aspect_from_file,
461
+ inputs=[file_input, width_mm, height_mm, lock_aspect],
462
+ outputs=[aspect_state, width_mm, height_mm],
463
+ )
464
+ width_mm.change(
465
+ _sync_height_from_width,
466
+ inputs=[width_mm, aspect_state, lock_aspect, sync_state],
467
+ outputs=[height_mm, sync_state],
468
+ )
469
+ width_mm.change(
470
+ _sync_height_inputs_from_size,
471
+ inputs=[
472
+ width_mm,
473
+ height_mm,
474
+ height_ratio_input,
475
+ height_scale_mm,
476
+ height_source,
477
+ ],
478
+ outputs=[height_ratio_input, height_scale_mm],
479
+ )
480
+ height_mm.change(
481
+ _sync_width_from_height,
482
+ inputs=[height_mm, aspect_state, lock_aspect, sync_state],
483
+ outputs=[width_mm, sync_state],
484
+ )
485
+ height_mm.change(
486
+ _sync_height_inputs_from_size,
487
+ inputs=[
488
+ width_mm,
489
+ height_mm,
490
+ height_ratio_input,
491
+ height_scale_mm,
492
+ height_source,
493
+ ],
494
+ outputs=[height_ratio_input, height_scale_mm],
495
+ )
496
+ height_ratio_input.change(
497
+ _sync_height_scale_from_ratio,
498
+ inputs=[height_ratio_input, width_mm, height_mm, height_source],
499
+ outputs=[height_scale_mm, height_source],
500
+ )
501
+ height_scale_mm.change(
502
+ _sync_ratio_from_height_scale,
503
+ inputs=[height_scale_mm, width_mm, height_mm, height_source],
504
+ outputs=[height_ratio_input, height_source],
505
+ )
506
+ lock_aspect.change(
507
+ _sync_height_from_width,
508
+ inputs=[width_mm, aspect_state, lock_aspect, sync_state],
509
+ outputs=[height_mm, sync_state],
510
+ )
511
+ return demo
512
+
513
+
514
+ app = build_app()
515
+
516
+ if __name__ == "__main__":
517
+ port_value = os.getenv("PORT")
518
+ app.launch(
519
+ server_name="0.0.0.0",
520
+ server_port=int(port_value) if port_value else 7860,
521
+ )
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio
2
+ numpy
3
+ imageio
4
+ opencv-python-headless