ThreeDimensionModeller 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ThreeDimensionModeller/__init__.py +8 -0
- ThreeDimensionModeller/__main__.py +6 -0
- ThreeDimensionModeller/about_page.py +200 -0
- ThreeDimensionModeller/check_system.py +521 -0
- ThreeDimensionModeller/cli.py +367 -0
- ThreeDimensionModeller/menu_language.py +754 -0
- ThreeDimensionModeller/menu_model.py +276 -0
- ThreeDimensionModeller/menu_painter.py +487 -0
- ThreeDimensionModeller/menu_session.py +118 -0
- ThreeDimensionModeller/model.py +835 -0
- ThreeDimensionModeller/self_management.py +79 -0
- ThreeDimensionModeller/system_log.py +92 -0
- ThreeDimensionModeller/tui.py +475 -0
- threedimensionmodeller-1.0.0.dist-info/METADATA +386 -0
- threedimensionmodeller-1.0.0.dist-info/RECORD +19 -0
- threedimensionmodeller-1.0.0.dist-info/WHEEL +5 -0
- threedimensionmodeller-1.0.0.dist-info/entry_points.txt +2 -0
- threedimensionmodeller-1.0.0.dist-info/licenses/LICENSE.md +21 -0
- threedimensionmodeller-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Outline images to a glTF model and an HTML viewer.
|
|
3
|
+
# requirement-domain-threedimensionmodeller — one folder in, model.glb and
|
|
4
|
+
# viewer.html in model/. ./convert.py calls convert_folder. The text menu
|
|
5
|
+
# and the model verb do too.
|
|
6
|
+
# The vision stack loads only when this folder has a supported image.
|
|
7
|
+
# A slow start shows its sentence before the work.
|
|
8
|
+
# requirement-domain-threedimensionmodeller — building the 3D model.
|
|
9
|
+
# =============================================================================
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import base64
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import struct
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SUPPORTED_INPUT_EXTENSIONS = (".webp", ".png", ".jpg", ".jpeg")
|
|
22
|
+
OUTPUT_DIR_NAME = "model"
|
|
23
|
+
GLB_NAME = "model.glb"
|
|
24
|
+
VIEWER_NAME = "viewer.html"
|
|
25
|
+
DEFAULT_GRID = 96
|
|
26
|
+
MIN_GRID = 16
|
|
27
|
+
MAX_GRID = 160
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_subfolders(folder):
|
|
31
|
+
"""General Purpose: Immediate child folders, hidden names omitted, sorted.
|
|
32
|
+
|
|
33
|
+
Names are the directory entries. A missing folder yields an empty tuple.
|
|
34
|
+
"""
|
|
35
|
+
place = Path(folder)
|
|
36
|
+
try:
|
|
37
|
+
names = [
|
|
38
|
+
entry.name
|
|
39
|
+
for entry in place.iterdir()
|
|
40
|
+
if entry.is_dir() and entry.name and not entry.name.startswith(".")
|
|
41
|
+
]
|
|
42
|
+
except OSError:
|
|
43
|
+
return ()
|
|
44
|
+
names.sort(key=str.lower)
|
|
45
|
+
return tuple(names)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def list_images(folder):
|
|
49
|
+
"""General Purpose: Supported images in this folder. Subfolders are not scanned."""
|
|
50
|
+
place = Path(folder)
|
|
51
|
+
try:
|
|
52
|
+
files = [
|
|
53
|
+
entry
|
|
54
|
+
for entry in place.iterdir()
|
|
55
|
+
if entry.is_file() and entry.suffix.lower() in SUPPORTED_INPUT_EXTENSIONS
|
|
56
|
+
]
|
|
57
|
+
except OSError:
|
|
58
|
+
return ()
|
|
59
|
+
files.sort(key=lambda item: item.name.lower())
|
|
60
|
+
return tuple(files)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def folder_for_pick(name, place=None):
|
|
64
|
+
"""General Purpose: '.' is this folder. Any other token is one child name.
|
|
65
|
+
|
|
66
|
+
A name with a separator, or '.' / '..', is refused.
|
|
67
|
+
"""
|
|
68
|
+
root = Path(place if place is not None else os.getcwd()).expanduser().resolve()
|
|
69
|
+
if name == ".":
|
|
70
|
+
return root
|
|
71
|
+
if (
|
|
72
|
+
not name
|
|
73
|
+
or name in (".", "..")
|
|
74
|
+
or name != Path(name).name
|
|
75
|
+
or "/" in name
|
|
76
|
+
or "\\" in name
|
|
77
|
+
):
|
|
78
|
+
raise ValueError(name)
|
|
79
|
+
return root / name
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def output_dir_for(folder):
|
|
83
|
+
"""General Purpose: The output folder next to the images being converted."""
|
|
84
|
+
return Path(folder) / OUTPUT_DIR_NAME
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def selected_choice_line(choice, process):
|
|
88
|
+
"""General Purpose: The one sentence shown before a slow process starts.
|
|
89
|
+
|
|
90
|
+
requirement-domain-threedimensionmodeller owns the words. A function that
|
|
91
|
+
returns the sentence is enough. This is not a module-level message constant.
|
|
92
|
+
"""
|
|
93
|
+
return "{0} has been selected. {1} takes time to finish.".format(choice, process)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _image_stack():
|
|
97
|
+
"""Import the vision stack once. The menu can list folders without it."""
|
|
98
|
+
import cv2
|
|
99
|
+
import numpy as np
|
|
100
|
+
from skimage import measure
|
|
101
|
+
|
|
102
|
+
return cv2, np, measure
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _read_views(path):
|
|
106
|
+
"""General Purpose: Azimuth and elevation rows from a views JSON file.
|
|
107
|
+
|
|
108
|
+
A bare list or an object with a views array are both accepted.
|
|
109
|
+
Each row needs file, azimuth, and elevation. file is one basename.
|
|
110
|
+
"""
|
|
111
|
+
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
112
|
+
rows = raw.get("views") if isinstance(raw, dict) else raw
|
|
113
|
+
if not isinstance(rows, list) or not rows:
|
|
114
|
+
raise ValueError("views JSON needs a non-empty list")
|
|
115
|
+
parsed = []
|
|
116
|
+
for row in rows:
|
|
117
|
+
if not isinstance(row, dict):
|
|
118
|
+
raise ValueError("each view must be an object")
|
|
119
|
+
name = row.get("file")
|
|
120
|
+
if not isinstance(name, str) or not name or name != Path(name).name:
|
|
121
|
+
raise ValueError("view file must be a basename")
|
|
122
|
+
if "/" in name or "\\" in name or name in (".", ".."):
|
|
123
|
+
raise ValueError("view file must be a basename")
|
|
124
|
+
parsed.append(
|
|
125
|
+
{
|
|
126
|
+
"file": name,
|
|
127
|
+
"azimuth": float(row["azimuth"]),
|
|
128
|
+
"elevation": float(row["elevation"]),
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
return tuple(parsed)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _equal_views(images):
|
|
135
|
+
"""General Purpose: Even azimuth steps at elevation 0, in name order."""
|
|
136
|
+
count = len(images)
|
|
137
|
+
if count == 0:
|
|
138
|
+
return ()
|
|
139
|
+
step = 360.0 / count
|
|
140
|
+
return tuple(
|
|
141
|
+
{"file": image.name, "azimuth": step * index, "elevation": 0.0}
|
|
142
|
+
for index, image in enumerate(images)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def outline_to_solid_mask(image_path, cv2, np):
|
|
147
|
+
"""General Purpose: Fill the area enclosed by the outer outline.
|
|
148
|
+
|
|
149
|
+
A short close seals small gaps in the line. When that fill is empty,
|
|
150
|
+
flood the outside from the corner and keep the interior.
|
|
151
|
+
"""
|
|
152
|
+
img = cv2.imread(str(image_path), cv2.IMREAD_GRAYSCALE)
|
|
153
|
+
if img is None:
|
|
154
|
+
raise ValueError("could not read image")
|
|
155
|
+
_threshold, thresh = cv2.threshold(img, 20, 255, cv2.THRESH_BINARY)
|
|
156
|
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
157
|
+
closed = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
|
|
158
|
+
contours, _hierarchy = cv2.findContours(
|
|
159
|
+
closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
|
160
|
+
)
|
|
161
|
+
mask = np.zeros_like(thresh)
|
|
162
|
+
if contours:
|
|
163
|
+
cv2.drawContours(mask, contours, -1, 255, thickness=cv2.FILLED)
|
|
164
|
+
filled = int(np.count_nonzero(mask))
|
|
165
|
+
height, width = mask.shape[:2]
|
|
166
|
+
if filled < 50:
|
|
167
|
+
flood = closed.copy()
|
|
168
|
+
flood_mask = np.zeros((height + 2, width + 2), np.uint8)
|
|
169
|
+
cv2.floodFill(flood, flood_mask, (0, 0), 128)
|
|
170
|
+
interior = np.where(flood == 0, 255, 0).astype(np.uint8)
|
|
171
|
+
if int(np.count_nonzero(interior)) > filled:
|
|
172
|
+
mask = interior
|
|
173
|
+
return mask
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _camera_axes(azimuth_deg, elevation_deg, np):
|
|
177
|
+
"""General Purpose: Image right and up for a camera looking at the origin.
|
|
178
|
+
|
|
179
|
+
Azimuth 0 places the camera on +Z. Positive elevation raises the camera.
|
|
180
|
+
"""
|
|
181
|
+
azimuth = np.radians(azimuth_deg)
|
|
182
|
+
elevation = np.radians(elevation_deg)
|
|
183
|
+
view_dir = np.array(
|
|
184
|
+
[
|
|
185
|
+
np.sin(azimuth) * np.cos(elevation),
|
|
186
|
+
np.sin(elevation),
|
|
187
|
+
np.cos(azimuth) * np.cos(elevation),
|
|
188
|
+
],
|
|
189
|
+
dtype=np.float64,
|
|
190
|
+
)
|
|
191
|
+
forward = -view_dir
|
|
192
|
+
world_up = np.array([0.0, 1.0, 0.0], dtype=np.float64)
|
|
193
|
+
if abs(float(np.dot(forward, world_up))) > 0.99:
|
|
194
|
+
world_up = np.array([0.0, 0.0, 1.0], dtype=np.float64)
|
|
195
|
+
right = np.cross(forward, world_up)
|
|
196
|
+
right = right / np.linalg.norm(right)
|
|
197
|
+
up = np.cross(right, forward)
|
|
198
|
+
up = up / np.linalg.norm(up)
|
|
199
|
+
return right, up
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def reconstruct_visual_hull(masks_with_angles, grid_res, np):
|
|
203
|
+
"""General Purpose: Keep voxels that project inside every solid mask.
|
|
204
|
+
|
|
205
|
+
masks_with_angles is (mask, azimuth_deg, elevation_deg). The grid is the
|
|
206
|
+
cube [-1, 1] on each axis. Each mask's bounding box is the projection
|
|
207
|
+
of that cube, so the object does not need to sit in the middle of the photo.
|
|
208
|
+
"""
|
|
209
|
+
voxels = np.ones((grid_res, grid_res, grid_res), dtype=bool)
|
|
210
|
+
linspace = np.linspace(-1.0, 1.0, grid_res)
|
|
211
|
+
xs, ys, zs = np.meshgrid(linspace, linspace, linspace, indexing="ij")
|
|
212
|
+
points = np.stack([xs.ravel(), ys.ravel(), zs.ravel()], axis=1)
|
|
213
|
+
counts = []
|
|
214
|
+
for mask, azimuth, elevation in masks_with_angles:
|
|
215
|
+
solid = mask > 0
|
|
216
|
+
rows, cols = np.where(solid)
|
|
217
|
+
if cols.size == 0:
|
|
218
|
+
counts.append(0)
|
|
219
|
+
voxels[:] = False
|
|
220
|
+
continue
|
|
221
|
+
min_x = float(cols.min())
|
|
222
|
+
max_x = float(cols.max())
|
|
223
|
+
min_y = float(rows.min())
|
|
224
|
+
max_y = float(rows.max())
|
|
225
|
+
center_x = (min_x + max_x) / 2.0
|
|
226
|
+
center_y = (min_y + max_y) / 2.0
|
|
227
|
+
half = max((max_x - min_x) / 2.0, (max_y - min_y) / 2.0, 1.0)
|
|
228
|
+
right, up = _camera_axes(azimuth, elevation, np)
|
|
229
|
+
horizontal = points @ right
|
|
230
|
+
vertical = points @ up
|
|
231
|
+
column = np.rint(center_x + horizontal * half).astype(np.int32)
|
|
232
|
+
row = np.rint(center_y - vertical * half).astype(np.int32)
|
|
233
|
+
height, width = mask.shape[:2]
|
|
234
|
+
inside = (column >= 0) & (column < width) & (row >= 0) & (row < height)
|
|
235
|
+
keep = np.zeros(points.shape[0], dtype=bool)
|
|
236
|
+
keep[inside] = solid[row[inside], column[inside]]
|
|
237
|
+
voxels &= keep.reshape((grid_res, grid_res, grid_res))
|
|
238
|
+
counts.append(int(np.count_nonzero(voxels)))
|
|
239
|
+
return voxels, tuple(counts)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def mesh_from_voxels(voxels, measure, np):
|
|
243
|
+
"""General Purpose: Marching-cubes surface in Y-up coordinates, centered."""
|
|
244
|
+
if not np.any(voxels):
|
|
245
|
+
return None
|
|
246
|
+
volume = voxels.astype(np.float32)
|
|
247
|
+
padded = np.pad(volume, 1, mode="constant", constant_values=0)
|
|
248
|
+
grid = voxels.shape[0]
|
|
249
|
+
step = 2.0 / (grid - 1) if grid > 1 else 1.0
|
|
250
|
+
verts, faces, normals, _values = measure.marching_cubes(
|
|
251
|
+
padded, level=0.5, spacing=(step, step, step)
|
|
252
|
+
)
|
|
253
|
+
origin = -1.0 - step
|
|
254
|
+
coords = np.asarray(verts, dtype=np.float32) + np.float32(origin)
|
|
255
|
+
center = coords.mean(axis=0)
|
|
256
|
+
coords = coords - center
|
|
257
|
+
extent = float(np.max(np.linalg.norm(coords, axis=1)))
|
|
258
|
+
if extent > 0:
|
|
259
|
+
coords = coords / np.float32(extent)
|
|
260
|
+
faces = np.asarray(faces, dtype=np.uint32)
|
|
261
|
+
normals = np.asarray(normals, dtype=np.float32)
|
|
262
|
+
return coords, faces, normals
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _pad4(blob):
|
|
266
|
+
extra = (4 - (len(blob) % 4)) % 4
|
|
267
|
+
if extra:
|
|
268
|
+
return blob + (b"\x00" * extra)
|
|
269
|
+
return blob
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def write_glb(path, positions, normals, indices):
|
|
273
|
+
"""General Purpose: Write one binary glTF 2.0 mesh with normals."""
|
|
274
|
+
pos = positions.astype("<f4", copy=False).reshape(-1, 3)
|
|
275
|
+
nrm = normals.astype("<f4", copy=False).reshape(-1, 3)
|
|
276
|
+
idx = indices.astype("<u4", copy=False).reshape(-1)
|
|
277
|
+
pos_bytes = np_tobytes(pos)
|
|
278
|
+
nrm_bytes = np_tobytes(nrm)
|
|
279
|
+
idx_bytes = np_tobytes(idx)
|
|
280
|
+
blob = pos_bytes + nrm_bytes + idx_bytes
|
|
281
|
+
nvert = int(pos.shape[0])
|
|
282
|
+
nidx = int(idx.shape[0])
|
|
283
|
+
pos_min = [float(value) for value in pos.min(axis=0)]
|
|
284
|
+
pos_max = [float(value) for value in pos.max(axis=0)]
|
|
285
|
+
document = {
|
|
286
|
+
"asset": {"version": "2.0", "generator": "ThreeDimensionModeller"},
|
|
287
|
+
"scene": 0,
|
|
288
|
+
"scenes": [{"nodes": [0]}],
|
|
289
|
+
"nodes": [{"mesh": 0}],
|
|
290
|
+
"materials": [
|
|
291
|
+
{
|
|
292
|
+
"doubleSided": True,
|
|
293
|
+
"pbrMetallicRoughness": {
|
|
294
|
+
"baseColorFactor": [0.72, 0.76, 0.8, 1.0],
|
|
295
|
+
"metallicFactor": 0.05,
|
|
296
|
+
"roughnessFactor": 0.55,
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
],
|
|
300
|
+
"meshes": [
|
|
301
|
+
{
|
|
302
|
+
"primitives": [
|
|
303
|
+
{
|
|
304
|
+
"attributes": {"POSITION": 0, "NORMAL": 1},
|
|
305
|
+
"indices": 2,
|
|
306
|
+
"material": 0,
|
|
307
|
+
"mode": 4,
|
|
308
|
+
}
|
|
309
|
+
]
|
|
310
|
+
}
|
|
311
|
+
],
|
|
312
|
+
"buffers": [{"byteLength": len(blob)}],
|
|
313
|
+
"bufferViews": [
|
|
314
|
+
{
|
|
315
|
+
"buffer": 0,
|
|
316
|
+
"byteOffset": 0,
|
|
317
|
+
"byteLength": len(pos_bytes),
|
|
318
|
+
"target": 34962,
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"buffer": 0,
|
|
322
|
+
"byteOffset": len(pos_bytes),
|
|
323
|
+
"byteLength": len(nrm_bytes),
|
|
324
|
+
"target": 34962,
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
"buffer": 0,
|
|
328
|
+
"byteOffset": len(pos_bytes) + len(nrm_bytes),
|
|
329
|
+
"byteLength": len(idx_bytes),
|
|
330
|
+
"target": 34963,
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
"accessors": [
|
|
334
|
+
{
|
|
335
|
+
"bufferView": 0,
|
|
336
|
+
"componentType": 5126,
|
|
337
|
+
"count": nvert,
|
|
338
|
+
"type": "VEC3",
|
|
339
|
+
"min": pos_min,
|
|
340
|
+
"max": pos_max,
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
"bufferView": 1,
|
|
344
|
+
"componentType": 5126,
|
|
345
|
+
"count": nvert,
|
|
346
|
+
"type": "VEC3",
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
"bufferView": 2,
|
|
350
|
+
"componentType": 5125,
|
|
351
|
+
"count": nidx,
|
|
352
|
+
"type": "SCALAR",
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
}
|
|
356
|
+
json_bytes = _pad4(json.dumps(document, separators=(",", ":")).encode("utf-8"))
|
|
357
|
+
bin_bytes = _pad4(blob)
|
|
358
|
+
total = 12 + 8 + len(json_bytes) + 8 + len(bin_bytes)
|
|
359
|
+
header = struct.pack("<4sII", b"glTF", 2, total)
|
|
360
|
+
json_chunk = struct.pack("<I4s", len(json_bytes), b"JSON") + json_bytes
|
|
361
|
+
bin_chunk = struct.pack("<I4s", len(bin_bytes), b"BIN\x00") + bin_bytes
|
|
362
|
+
Path(path).write_bytes(header + json_chunk + bin_chunk)
|
|
363
|
+
return nvert, nidx // 3
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def np_tobytes(array):
|
|
367
|
+
"""General Purpose: Contiguous little-endian bytes for one numeric array."""
|
|
368
|
+
import numpy as np
|
|
369
|
+
|
|
370
|
+
return np.ascontiguousarray(array).tobytes()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def viewer_html(positions, normals, indices):
|
|
374
|
+
"""General Purpose: One HTML file that draws this mesh with no network."""
|
|
375
|
+
pos_b64 = base64.b64encode(np_tobytes(positions.astype("<f4"))).decode("ascii")
|
|
376
|
+
nrm_b64 = base64.b64encode(np_tobytes(normals.astype("<f4"))).decode("ascii")
|
|
377
|
+
idx_b64 = base64.b64encode(np_tobytes(indices.astype("<u4"))).decode("ascii")
|
|
378
|
+
page = _VIEWER_PAGE
|
|
379
|
+
page = page.replace("__POS__", pos_b64)
|
|
380
|
+
page = page.replace("__NRM__", nrm_b64)
|
|
381
|
+
page = page.replace("__IDX__", idx_b64)
|
|
382
|
+
return page
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
_VIEWER_PAGE = """<!DOCTYPE html>
|
|
386
|
+
<html lang="en">
|
|
387
|
+
<head>
|
|
388
|
+
<meta charset="utf-8">
|
|
389
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
390
|
+
<title>ThreeDimensionModeller</title>
|
|
391
|
+
<style>
|
|
392
|
+
html, body { margin: 0; height: 100%; background: #121418; color: #d5dde6; font-family: sans-serif; }
|
|
393
|
+
canvas { display: block; width: 100%; height: 100%; }
|
|
394
|
+
p { position: fixed; left: 12px; bottom: 10px; margin: 0; font-size: 13px; }
|
|
395
|
+
</style>
|
|
396
|
+
</head>
|
|
397
|
+
<body>
|
|
398
|
+
<canvas id="view"></canvas>
|
|
399
|
+
<p>ThreeDimensionModeller — drag to orbit, wheel to zoom. model.glb is the glTF file.</p>
|
|
400
|
+
<script>
|
|
401
|
+
const posB64 = "__POS__";
|
|
402
|
+
const nrmB64 = "__NRM__";
|
|
403
|
+
const idxB64 = "__IDX__";
|
|
404
|
+
function b64floats(text) {
|
|
405
|
+
const raw = atob(text);
|
|
406
|
+
const bytes = new Uint8Array(raw.length);
|
|
407
|
+
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
|
408
|
+
return new Float32Array(bytes.buffer);
|
|
409
|
+
}
|
|
410
|
+
function b64uints(text) {
|
|
411
|
+
const raw = atob(text);
|
|
412
|
+
const bytes = new Uint8Array(raw.length);
|
|
413
|
+
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
|
414
|
+
return new Uint32Array(bytes.buffer);
|
|
415
|
+
}
|
|
416
|
+
const positions = b64floats(posB64);
|
|
417
|
+
const normals = b64floats(nrmB64);
|
|
418
|
+
const indices = b64uints(idxB64);
|
|
419
|
+
const canvas = document.getElementById("view");
|
|
420
|
+
const gl = canvas.getContext("webgl2", { antialias: true });
|
|
421
|
+
if (!gl) {
|
|
422
|
+
document.body.insertAdjacentHTML("beforeend", "<p>This browser has no WebGL2.</p>");
|
|
423
|
+
} else {
|
|
424
|
+
const vs = `#version 300 es
|
|
425
|
+
layout(location=0) in vec3 aPos;
|
|
426
|
+
layout(location=1) in vec3 aNrm;
|
|
427
|
+
uniform mat4 uMvp;
|
|
428
|
+
uniform mat3 uN;
|
|
429
|
+
out vec3 vNrm;
|
|
430
|
+
void main() {
|
|
431
|
+
vNrm = normalize(uN * aNrm);
|
|
432
|
+
gl_Position = uMvp * vec4(aPos, 1.0);
|
|
433
|
+
}`;
|
|
434
|
+
const fs = `#version 300 es
|
|
435
|
+
precision mediump float;
|
|
436
|
+
in vec3 vNrm;
|
|
437
|
+
out vec4 outColor;
|
|
438
|
+
void main() {
|
|
439
|
+
vec3 n = normalize(vNrm);
|
|
440
|
+
vec3 light = normalize(vec3(0.35, 0.85, 0.45));
|
|
441
|
+
float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);
|
|
442
|
+
outColor = vec4(vec3(0.73, 0.78, 0.84) * shade, 1.0);
|
|
443
|
+
}`;
|
|
444
|
+
function shader(type, source) {
|
|
445
|
+
const item = gl.createShader(type);
|
|
446
|
+
gl.shaderSource(item, source);
|
|
447
|
+
gl.compileShader(item);
|
|
448
|
+
return item;
|
|
449
|
+
}
|
|
450
|
+
const program = gl.createProgram();
|
|
451
|
+
gl.attachShader(program, shader(gl.VERTEX_SHADER, vs));
|
|
452
|
+
gl.attachShader(program, shader(gl.FRAGMENT_SHADER, fs));
|
|
453
|
+
gl.linkProgram(program);
|
|
454
|
+
gl.useProgram(program);
|
|
455
|
+
function buffer(data, target) {
|
|
456
|
+
const buf = gl.createBuffer();
|
|
457
|
+
gl.bindBuffer(target, buf);
|
|
458
|
+
gl.bufferData(target, data, gl.STATIC_DRAW);
|
|
459
|
+
return buf;
|
|
460
|
+
}
|
|
461
|
+
buffer(positions, gl.ARRAY_BUFFER);
|
|
462
|
+
gl.enableVertexAttribArray(0);
|
|
463
|
+
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
|
|
464
|
+
buffer(normals, gl.ARRAY_BUFFER);
|
|
465
|
+
gl.enableVertexAttribArray(1);
|
|
466
|
+
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);
|
|
467
|
+
buffer(indices, gl.ELEMENT_ARRAY_BUFFER);
|
|
468
|
+
const uMvp = gl.getUniformLocation(program, "uMvp");
|
|
469
|
+
const uN = gl.getUniformLocation(program, "uN");
|
|
470
|
+
gl.enable(gl.DEPTH_TEST);
|
|
471
|
+
gl.disable(gl.CULL_FACE);
|
|
472
|
+
let yaw = 0.7;
|
|
473
|
+
let pitch = 0.35;
|
|
474
|
+
let distance = 2.6;
|
|
475
|
+
let dragging = false;
|
|
476
|
+
let lastX = 0;
|
|
477
|
+
let lastY = 0;
|
|
478
|
+
canvas.addEventListener("pointerdown", (event) => {
|
|
479
|
+
dragging = true;
|
|
480
|
+
lastX = event.clientX;
|
|
481
|
+
lastY = event.clientY;
|
|
482
|
+
canvas.setPointerCapture(event.pointerId);
|
|
483
|
+
});
|
|
484
|
+
canvas.addEventListener("pointerup", () => { dragging = false; });
|
|
485
|
+
canvas.addEventListener("pointermove", (event) => {
|
|
486
|
+
if (!dragging) return;
|
|
487
|
+
yaw += (event.clientX - lastX) * 0.01;
|
|
488
|
+
pitch += (event.clientY - lastY) * 0.01;
|
|
489
|
+
pitch = Math.max(-1.2, Math.min(1.2, pitch));
|
|
490
|
+
lastX = event.clientX;
|
|
491
|
+
lastY = event.clientY;
|
|
492
|
+
});
|
|
493
|
+
canvas.addEventListener("wheel", (event) => {
|
|
494
|
+
event.preventDefault();
|
|
495
|
+
distance *= event.deltaY > 0 ? 1.08 : 0.92;
|
|
496
|
+
distance = Math.max(1.2, Math.min(8.0, distance));
|
|
497
|
+
}, { passive: false });
|
|
498
|
+
function perspective(fovy, aspect, near, far) {
|
|
499
|
+
const f = 1 / Math.tan(fovy / 2);
|
|
500
|
+
const nf = 1 / (near - far);
|
|
501
|
+
return new Float32Array([
|
|
502
|
+
f / aspect, 0, 0, 0,
|
|
503
|
+
0, f, 0, 0,
|
|
504
|
+
0, 0, (far + near) * nf, -1,
|
|
505
|
+
0, 0, 2 * far * near * nf, 0
|
|
506
|
+
]);
|
|
507
|
+
}
|
|
508
|
+
function look(eye, target, up) {
|
|
509
|
+
function norm(v) {
|
|
510
|
+
const n = Math.hypot(v[0], v[1], v[2]) || 1;
|
|
511
|
+
return [v[0] / n, v[1] / n, v[2] / n];
|
|
512
|
+
}
|
|
513
|
+
function cross(a, b) {
|
|
514
|
+
return [a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2], a[0]*b[1] - a[1]*b[0]];
|
|
515
|
+
}
|
|
516
|
+
function sub(a, b) { return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; }
|
|
517
|
+
const z = norm(sub(eye, target));
|
|
518
|
+
const x = norm(cross(up, z));
|
|
519
|
+
const y = cross(z, x);
|
|
520
|
+
return new Float32Array([
|
|
521
|
+
x[0], y[0], z[0], 0,
|
|
522
|
+
x[1], y[1], z[1], 0,
|
|
523
|
+
x[2], y[2], z[2], 0,
|
|
524
|
+
-(x[0]*eye[0] + x[1]*eye[1] + x[2]*eye[2]),
|
|
525
|
+
-(y[0]*eye[0] + y[1]*eye[1] + y[2]*eye[2]),
|
|
526
|
+
-(z[0]*eye[0] + z[1]*eye[1] + z[2]*eye[2]),
|
|
527
|
+
1
|
|
528
|
+
]);
|
|
529
|
+
}
|
|
530
|
+
function multiply(a, b) {
|
|
531
|
+
const out = new Float32Array(16);
|
|
532
|
+
for (let col = 0; col < 4; col++) {
|
|
533
|
+
for (let row = 0; row < 4; row++) {
|
|
534
|
+
out[col * 4 + row] =
|
|
535
|
+
a[row] * b[col * 4] +
|
|
536
|
+
a[4 + row] * b[col * 4 + 1] +
|
|
537
|
+
a[8 + row] * b[col * 4 + 2] +
|
|
538
|
+
a[12 + row] * b[col * 4 + 3];
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
function frame() {
|
|
544
|
+
const width = canvas.clientWidth || 1;
|
|
545
|
+
const height = canvas.clientHeight || 1;
|
|
546
|
+
if (canvas.width !== width || canvas.height !== height) {
|
|
547
|
+
canvas.width = width;
|
|
548
|
+
canvas.height = height;
|
|
549
|
+
}
|
|
550
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
551
|
+
gl.clearColor(0.07, 0.08, 0.1, 1);
|
|
552
|
+
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
553
|
+
const eye = [
|
|
554
|
+
distance * Math.cos(pitch) * Math.sin(yaw),
|
|
555
|
+
distance * Math.sin(pitch),
|
|
556
|
+
distance * Math.cos(pitch) * Math.cos(yaw)
|
|
557
|
+
];
|
|
558
|
+
const view = look(eye, [0, 0, 0], [0, 1, 0]);
|
|
559
|
+
const proj = perspective(0.9, canvas.width / canvas.height, 0.05, 40);
|
|
560
|
+
const mvp = multiply(proj, view);
|
|
561
|
+
gl.uniformMatrix4fv(uMvp, false, mvp);
|
|
562
|
+
const nmat = new Float32Array([
|
|
563
|
+
view[0], view[1], view[2],
|
|
564
|
+
view[4], view[5], view[6],
|
|
565
|
+
view[8], view[9], view[10]
|
|
566
|
+
]);
|
|
567
|
+
gl.uniformMatrix3fv(uN, false, nmat);
|
|
568
|
+
gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_INT, 0);
|
|
569
|
+
requestAnimationFrame(frame);
|
|
570
|
+
}
|
|
571
|
+
requestAnimationFrame(frame);
|
|
572
|
+
}
|
|
573
|
+
</script>
|
|
574
|
+
</body>
|
|
575
|
+
</html>
|
|
576
|
+
"""
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _done(code, lines, on_notice, announced):
|
|
580
|
+
if announced and lines and lines[0] != announced[0]:
|
|
581
|
+
lines.insert(0, announced[0])
|
|
582
|
+
elif announced and not lines:
|
|
583
|
+
lines = list(announced)
|
|
584
|
+
return code, lines
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def convert_folder(
|
|
588
|
+
folder,
|
|
589
|
+
output_dir=None,
|
|
590
|
+
views_path=None,
|
|
591
|
+
grid=None,
|
|
592
|
+
choice="model",
|
|
593
|
+
on_notice=None,
|
|
594
|
+
):
|
|
595
|
+
"""General Purpose: Build model.glb and viewer.html from outline images.
|
|
596
|
+
|
|
597
|
+
With no views file, images are spaced evenly in azimuth at elevation 0.
|
|
598
|
+
A views.json in the folder is used when views_path is omitted.
|
|
599
|
+
requirement-domain-threedimensionmodeller.
|
|
600
|
+
"""
|
|
601
|
+
announced = []
|
|
602
|
+
place = Path(folder).expanduser()
|
|
603
|
+
if not place.is_dir():
|
|
604
|
+
return _done(
|
|
605
|
+
1,
|
|
606
|
+
[
|
|
607
|
+
"ERROR: {0} is not a folder.".format(place),
|
|
608
|
+
" Next: three-dimension-modeller model",
|
|
609
|
+
],
|
|
610
|
+
on_notice,
|
|
611
|
+
announced,
|
|
612
|
+
)
|
|
613
|
+
place = place.resolve()
|
|
614
|
+
grid_res = DEFAULT_GRID if grid is None else grid
|
|
615
|
+
try:
|
|
616
|
+
grid_res = int(grid_res)
|
|
617
|
+
except (TypeError, ValueError):
|
|
618
|
+
grid_res = -1
|
|
619
|
+
if grid_res < MIN_GRID or grid_res > MAX_GRID:
|
|
620
|
+
return _done(
|
|
621
|
+
1,
|
|
622
|
+
[
|
|
623
|
+
"ERROR: grid must be an integer from {0} to {1}.".format(
|
|
624
|
+
MIN_GRID, MAX_GRID
|
|
625
|
+
),
|
|
626
|
+
" Next: three-dimension-modeller model --grid {0}".format(DEFAULT_GRID),
|
|
627
|
+
],
|
|
628
|
+
on_notice,
|
|
629
|
+
announced,
|
|
630
|
+
)
|
|
631
|
+
images = list_images(place)
|
|
632
|
+
view_file = None
|
|
633
|
+
if views_path:
|
|
634
|
+
view_file = Path(views_path).expanduser()
|
|
635
|
+
elif (place / "views.json").is_file():
|
|
636
|
+
view_file = place / "views.json"
|
|
637
|
+
try:
|
|
638
|
+
if view_file is not None:
|
|
639
|
+
if not view_file.is_file():
|
|
640
|
+
raise ValueError("views file is missing")
|
|
641
|
+
views = _read_views(view_file)
|
|
642
|
+
else:
|
|
643
|
+
views = _equal_views(images)
|
|
644
|
+
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as exc:
|
|
645
|
+
return _done(
|
|
646
|
+
1,
|
|
647
|
+
[
|
|
648
|
+
"ERROR: Could not read views: {0}".format(exc),
|
|
649
|
+
" Next: three-dimension-modeller model --views views.json",
|
|
650
|
+
],
|
|
651
|
+
on_notice,
|
|
652
|
+
announced,
|
|
653
|
+
)
|
|
654
|
+
if not views:
|
|
655
|
+
return _done(
|
|
656
|
+
0,
|
|
657
|
+
["No supported images found in {0}".format(place)],
|
|
658
|
+
on_notice,
|
|
659
|
+
announced,
|
|
660
|
+
)
|
|
661
|
+
by_name = {image.name: image for image in images}
|
|
662
|
+
missing = [row["file"] for row in views if row["file"] not in by_name]
|
|
663
|
+
if missing and len(missing) == len(views):
|
|
664
|
+
return _done(
|
|
665
|
+
1,
|
|
666
|
+
[
|
|
667
|
+
"ERROR: None of the named outlines are in {0}".format(place),
|
|
668
|
+
" Next: three-dimension-modeller model",
|
|
669
|
+
],
|
|
670
|
+
on_notice,
|
|
671
|
+
announced,
|
|
672
|
+
)
|
|
673
|
+
dest = Path(output_dir).expanduser().resolve() if output_dir else output_dir_for(place)
|
|
674
|
+
try:
|
|
675
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
676
|
+
except OSError as exc:
|
|
677
|
+
return _done(
|
|
678
|
+
1,
|
|
679
|
+
[
|
|
680
|
+
"ERROR: Could not create the output directory: {0}".format(exc),
|
|
681
|
+
" Next: three-dimension-modeller model",
|
|
682
|
+
],
|
|
683
|
+
on_notice,
|
|
684
|
+
announced,
|
|
685
|
+
)
|
|
686
|
+
sentence = selected_choice_line(choice, "Building the 3D model")
|
|
687
|
+
announced.append(sentence)
|
|
688
|
+
if on_notice is not None:
|
|
689
|
+
on_notice(sentence)
|
|
690
|
+
try:
|
|
691
|
+
cv2, np, measure = _image_stack()
|
|
692
|
+
except ImportError:
|
|
693
|
+
return _done(
|
|
694
|
+
1,
|
|
695
|
+
[
|
|
696
|
+
sentence,
|
|
697
|
+
"ERROR: numpy, opencv-python-headless, and scikit-image are required.",
|
|
698
|
+
' Next: python -m pip install "ThreeDimensionModeller"',
|
|
699
|
+
],
|
|
700
|
+
on_notice,
|
|
701
|
+
announced,
|
|
702
|
+
)
|
|
703
|
+
lines = [sentence]
|
|
704
|
+
failed = False
|
|
705
|
+
masks = []
|
|
706
|
+
for row in views:
|
|
707
|
+
image = by_name.get(row["file"])
|
|
708
|
+
if image is None:
|
|
709
|
+
failed = True
|
|
710
|
+
lines.append("ERROR: Missing outline {0}".format(row["file"]))
|
|
711
|
+
continue
|
|
712
|
+
try:
|
|
713
|
+
mask = outline_to_solid_mask(image, cv2, np)
|
|
714
|
+
except (OSError, ValueError) as exc:
|
|
715
|
+
failed = True
|
|
716
|
+
lines.append("ERROR: Failed on {0}: {1}".format(image.name, exc))
|
|
717
|
+
continue
|
|
718
|
+
foreground = int(np.count_nonzero(mask))
|
|
719
|
+
if foreground < 50:
|
|
720
|
+
failed = True
|
|
721
|
+
lines.append("ERROR: No closed outline in {0}".format(image.name))
|
|
722
|
+
continue
|
|
723
|
+
masks.append((mask, row["azimuth"], row["elevation"], image.name, foreground))
|
|
724
|
+
lines.append(
|
|
725
|
+
"Mask {0}: {1} foreground pixels, azimuth {2}, elevation {3}".format(
|
|
726
|
+
image.name, foreground, row["azimuth"], row["elevation"]
|
|
727
|
+
)
|
|
728
|
+
)
|
|
729
|
+
if not masks:
|
|
730
|
+
lines.append("ERROR: No outline could be filled.")
|
|
731
|
+
lines.append(" Next: three-dimension-modeller model")
|
|
732
|
+
return _done(1, lines, on_notice, announced)
|
|
733
|
+
voxels, counts = reconstruct_visual_hull(
|
|
734
|
+
[(mask, azimuth, elevation) for mask, azimuth, elevation, _name, _count in masks],
|
|
735
|
+
grid_res,
|
|
736
|
+
np,
|
|
737
|
+
)
|
|
738
|
+
for index, count in enumerate(counts):
|
|
739
|
+
lines.append(
|
|
740
|
+
"Carved {0}: {1} voxels remain.".format(masks[index][3], count)
|
|
741
|
+
)
|
|
742
|
+
mesh = mesh_from_voxels(voxels, measure, np)
|
|
743
|
+
if mesh is None:
|
|
744
|
+
lines.append(
|
|
745
|
+
"ERROR: The visual hull is empty. Check overlap and the view angles."
|
|
746
|
+
)
|
|
747
|
+
lines.append(" Next: three-dimension-modeller model --views views.json")
|
|
748
|
+
return _done(1, lines, on_notice, announced)
|
|
749
|
+
positions, faces, normals = mesh
|
|
750
|
+
glb_path = dest / GLB_NAME
|
|
751
|
+
html_path = dest / VIEWER_NAME
|
|
752
|
+
try:
|
|
753
|
+
vertex_count, triangle_count = write_glb(glb_path, positions, normals, faces)
|
|
754
|
+
html_path.write_text(viewer_html(positions, normals, faces), encoding="utf-8")
|
|
755
|
+
except OSError as exc:
|
|
756
|
+
lines.append("ERROR: Could not write the model: {0}".format(exc))
|
|
757
|
+
lines.append(" Next: three-dimension-modeller model")
|
|
758
|
+
return _done(1, lines, on_notice, announced)
|
|
759
|
+
lines.append(
|
|
760
|
+
"Saved model -> {0} ({1} vertices, {2} triangles)".format(
|
|
761
|
+
glb_path.name, vertex_count, triangle_count
|
|
762
|
+
)
|
|
763
|
+
)
|
|
764
|
+
lines.append("Saved viewer -> {0}".format(html_path.name))
|
|
765
|
+
lines.append("Model saved in: {0}".format(dest))
|
|
766
|
+
return _done(1 if failed else 0, lines, on_notice, announced)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def main(argv=None):
|
|
770
|
+
"""General Purpose: ./convert.py entry. Same conversion as the model verb."""
|
|
771
|
+
parser = argparse.ArgumentParser(
|
|
772
|
+
prog="convert.py",
|
|
773
|
+
description="Build a glTF model and an HTML viewer from outline images.",
|
|
774
|
+
)
|
|
775
|
+
parser.add_argument(
|
|
776
|
+
"folder",
|
|
777
|
+
nargs="?",
|
|
778
|
+
default=None,
|
|
779
|
+
help="Folder of outline images (default: current directory)",
|
|
780
|
+
)
|
|
781
|
+
parser.add_argument(
|
|
782
|
+
"--input-dir",
|
|
783
|
+
"-i",
|
|
784
|
+
default=None,
|
|
785
|
+
help="Same as the folder argument",
|
|
786
|
+
)
|
|
787
|
+
parser.add_argument(
|
|
788
|
+
"--output",
|
|
789
|
+
"--output-dir",
|
|
790
|
+
"-o",
|
|
791
|
+
dest="output",
|
|
792
|
+
default=None,
|
|
793
|
+
help="Output folder (default: <folder>/model)",
|
|
794
|
+
)
|
|
795
|
+
parser.add_argument(
|
|
796
|
+
"--views",
|
|
797
|
+
default=None,
|
|
798
|
+
help="JSON file of file, azimuth, and elevation for each outline",
|
|
799
|
+
)
|
|
800
|
+
parser.add_argument(
|
|
801
|
+
"--grid",
|
|
802
|
+
type=int,
|
|
803
|
+
default=None,
|
|
804
|
+
help="Voxels on each axis, {0} to {1} (default: {2})".format(
|
|
805
|
+
MIN_GRID, MAX_GRID, DEFAULT_GRID
|
|
806
|
+
),
|
|
807
|
+
)
|
|
808
|
+
args = parser.parse_args(argv)
|
|
809
|
+
if args.folder and args.input_dir:
|
|
810
|
+
print("ERROR: Pass the folder once.", file=sys.stderr)
|
|
811
|
+
print(" Next: ./convert.py --input-dir photos", file=sys.stderr)
|
|
812
|
+
return 1
|
|
813
|
+
folder = args.folder or args.input_dir or "."
|
|
814
|
+
spoken = []
|
|
815
|
+
|
|
816
|
+
def _notice(line):
|
|
817
|
+
print(line, flush=True)
|
|
818
|
+
spoken.append(line)
|
|
819
|
+
|
|
820
|
+
code, lines = convert_folder(
|
|
821
|
+
folder,
|
|
822
|
+
output_dir=args.output,
|
|
823
|
+
views_path=args.views,
|
|
824
|
+
grid=args.grid,
|
|
825
|
+
choice="convert.py",
|
|
826
|
+
on_notice=_notice,
|
|
827
|
+
)
|
|
828
|
+
rest = lines[len(spoken):]
|
|
829
|
+
if rest:
|
|
830
|
+
print("\n".join(rest))
|
|
831
|
+
return code
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
if __name__ == "__main__":
|
|
835
|
+
sys.exit(main())
|