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.
@@ -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())