-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathinfer.py
More file actions
349 lines (296 loc) · 16.4 KB
/
Copy pathinfer.py
File metadata and controls
349 lines (296 loc) · 16.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
"""
Simple inference interface for tooth segmentation + landmark prediction.
Takes a single scan (.stl/.obj) or a folder that is scanned recursively, and
writes for every scan:
- <stem>_seg.npy cleaned per-vertex segmentation mask
- <stem>_landmarks.ply landmark points, one color per landmark class (only with --save-ply)
- <stem>_segmentation_views.png (only with --vis-seg)
When the input is a folder, its directory structure is replicated inside the
output folder; a single scan is saved directly in the output folder.
At the end of the run, a single landmarks.json is also written at the root of
the output folder, aggregating every scan's raw landmark predictions (points
+ basePlane per tooth), flat and keyed by tooth_key exactly like
postprocess_predictions' own per-scan output (e.g. "118_lower_FDI_47").
Example:
python infer.py \
--input /path/to/scans --output /path/to/predictions \
--seg-config application/app_configs/Pt_semseg_teeth3ds_app.py \
--seg-weight application/app_weights/segmentator_best.pth \
--bond-config application/app_configs/Pt_landmarks_app.py \
--bond-weight application/app_weights/heatmap_landmarks.pth \
--preprocessing preprocessing/3dteethland_preprocessing.yaml
"""
import os
import sys
sys.path.append(os.path.abspath("application"))
os.environ["VTK_OPENGL_HAS_EGL"] = "0"
import argparse
import json
import re
import shutil
import tempfile
import time
import traceback
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from application.bond import ALL_LANDMARKS
from application.cache import TeethCache
from application.visualizers import json_to_ply
from application.pipeline import LandmarksPredictor
SCAN_EXTENSIONS = {".stl", ".obj"}
# Tooth-key parsing (application/utils.parse_tooth) expects a scan stem shaped
# like "<id>_<lower|upper>" or "STEM_<lower|upper>_<id>"; scans already in this
# form are used unchanged so tooth keys stay clean (e.g. "118_lower_FDI_47").
SCAN_NAME_RE = re.compile(r"^([^_]+_(lower|upper)|STEM_(lower|upper)_[^_]+)$")
def collect_scans(input_path: Path) -> list[Path]:
"""Return the single input scan, or all scans found recursively in a folder."""
if input_path.is_file():
if input_path.suffix.lower() not in SCAN_EXTENSIONS:
sys.exit(f"❌ Unsupported scan format: {input_path.suffix} (expected .stl/.obj)")
return [input_path]
if not input_path.is_dir():
sys.exit(f"❌ Input path does not exist: {input_path}")
return sorted(p for p in input_path.rglob("*") if p.suffix.lower() in SCAN_EXTENSIONS)
def output_dir_for(scan: Path, input_path: Path, output_root: Path) -> Path:
"""Mirror the input folder structure; a single-scan input maps to the output root."""
if input_path.is_file():
return output_root
return output_root / scan.parent.relative_to(input_path)
def internal_scan_name(scan: Path, unique_id: str | None = None) -> str:
"""Filename used for the scan symlink inside its work dir.
Scans already shaped "<id>_<lower|upper>" (or "STEM_<lower|upper>_<id>")
are used unchanged, so tooth keys downstream stay clean and match what
postprocess_predictions naturally produces (e.g. "118_lower_FDI_47").
Otherwise the scan carries no id of its own (e.g. a generic "lower.stl"),
so the id is taken from its parent directory instead — this project's
usual layout is one folder per patient (e.g. ".../302/lower.stl" -> id
"302"), which is also naturally unique, unlike the scan's own filename.
`unique_id` is only a last-resort disambiguator for the rare case where
two different parent folders share the same name within one batch.
"""
if SCAN_NAME_RE.match(scan.stem):
return scan.name
arch = "lower" if "lower" in scan.stem else "upper"
scan_id = scan.parent.name.replace("_", "-")
if unique_id is not None:
scan_id = f"{scan_id}-{unique_id}"
return f"{scan_id}_{arch}{scan.suffix}"
class InferencePipeline:
"""Loads both models once (via the shared LandmarksPredictor engine) and
runs the full pipeline scan by scan."""
def __init__(self, args):
self.engine = LandmarksPredictor(
args.seg_config, args.seg_weight, args.bond_config, args.bond_weight,
remesh=False,
visualize_segmentation=args.vis_seg,
save_ply=args.save_ply,
cache=TeethCache(), # tooth meshes are always cached, never exported
preprocessing=args.preprocessing,
landmarks=args.landmarks,
workers=args.workers,
)
self.workers = args.workers
self.processing_time = 0.0 # cumulative segmentation + bond + postprocess time (excludes I/O)
self.combined_landmarks: dict[str, dict] = {} # tooth_key -> landmark data, flat like postprocess_predictions' own landmarks.json
@property
def cache(self) -> TeethCache:
return self.engine.cache
def run(self, scan: Path, out_dir: Path):
"""Segment one scan, predict its landmarks and export results to out_dir."""
work_dir = Path(tempfile.mkdtemp(prefix=f"infer_{scan.stem}_"))
try:
# Fresh cache per scan: the cached datasets iterate over every cached scan.
self.cache.clear()
internal_name = internal_scan_name(scan)
symlink_path = work_dir / internal_name
symlink_path.symlink_to(scan.resolve())
self.cache.preload_scan_mesh(symlink_path)
proc_start = time.perf_counter()
ok, msg = self.engine.run_segmentation(work_dir)
if not ok:
raise RuntimeError(msg)
ok, msg = self.engine.run_bond_prediction(work_dir)
if not ok:
raise RuntimeError(msg)
self.engine.postprocess(work_dir, visualize=False)
self.processing_time += time.perf_counter() - proc_start
landmarks_path = work_dir / "output_reg" / "results" / "landmarks.json"
scan_landmarks = json.loads(landmarks_path.read_text()) if landmarks_path.exists() else {}
self._record_landmarks(scan_landmarks)
self._export(scan, internal_name, work_dir, out_dir, landmarks=scan_landmarks)
finally:
shutil.rmtree(work_dir, ignore_errors=True)
def run_batch(self, scans: list[Path], input_path: Path, output_root: Path) -> list[Path]:
"""Segment every scan in one pass, then bond every scan in one pass.
Faster than calling run() per scan for large sets, since dataset/
dataloader setup for segmentation and bonding is paid once instead
of once per scan. Trade-off: all scans share one work dir, so a
failure in the batched segmentation or bonding step aborts the
whole batch instead of failing just one scan; only the later
per-scan export step still fails scans individually.
Returns the list of scans that failed.
"""
work_dir = Path(tempfile.mkdtemp(prefix="infer_batch_"))
failed: list[Path] = []
try:
self.cache.clear()
# Precompute internal names sequentially (cheap, no I/O). Ids come
# from either the scan's own filename or its parent folder (see
# internal_scan_name), both normally unique on their own; this is
# just a last-resort safety net for the rare case where two scans
# would still collide (e.g. same-named parent folders at
# different paths under --input), disambiguated before anything
# touches disk.
seen_names: set[str] = set()
planned = [] # (scan, internal_name)
for i, scan in enumerate(scans):
name = internal_scan_name(scan)
if name in seen_names:
print(f"⚠️ Duplicate scan name '{name}' in this batch — disambiguating.")
name = internal_scan_name(scan, unique_id=f"{i:04d}")
seen_names.add(name)
planned.append((scan, name))
def _preload_one(item):
scan, internal_name = item
symlink_path = work_dir / internal_name
symlink_path.symlink_to(scan.resolve())
self.cache.preload_scan_mesh(symlink_path) # disk load + mesh cleanup, independent per scan
return scan, internal_name, output_dir_for(scan, input_path, output_root)
if self.workers > 1:
with ThreadPoolExecutor(max_workers=self.workers) as executor:
scan_entries = list(executor.map(_preload_one, planned))
else:
scan_entries = [_preload_one(item) for item in planned]
proc_start = time.perf_counter()
ok, msg = self.engine.run_segmentation(work_dir)
if not ok:
raise RuntimeError(f"batch segmentation failed: {msg}")
ok, msg = self.engine.run_bond_prediction(work_dir)
if not ok:
raise RuntimeError(f"batch landmark prediction failed: {msg}")
self.engine.postprocess(work_dir, visualize=False)
self.processing_time += time.perf_counter() - proc_start
landmarks_path = work_dir / "output_reg" / "results" / "landmarks.json"
all_landmarks = json.loads(landmarks_path.read_text()) if landmarks_path.exists() else {}
for scan, internal_name, out_dir in scan_entries:
try:
prefix = f"{Path(internal_name).stem}_FDI_"
scan_landmarks = {k: v for k, v in all_landmarks.items() if k.startswith(prefix)}
self._record_landmarks(scan_landmarks)
self._export(scan, internal_name, work_dir, out_dir, landmarks=scan_landmarks)
except Exception:
traceback.print_exc()
failed.append(scan)
except Exception:
traceback.print_exc()
failed = list(scans)
finally:
shutil.rmtree(work_dir, ignore_errors=True)
return failed
def _record_landmarks(self, scan_landmarks: dict):
"""Accumulate this scan's landmarks into the run-wide combined output.
Flat dict keyed by tooth_key, same shape postprocess_predictions
itself writes per scan (e.g. "118_lower_FDI_47": {...}). Tooth keys
are unique per scan (run_batch disambiguates any name collisions
before this is ever called), so a plain update() is safe.
"""
self.combined_landmarks.update(scan_landmarks)
def write_combined_landmarks(self, output_root: Path) -> Path:
"""Write every processed scan's landmarks into one landmarks.json at output_root."""
output_root.mkdir(parents=True, exist_ok=True)
out_path = output_root / "landmarks.json"
with open(out_path, "w") as f:
json.dump(self.combined_landmarks, f, indent=4)
print(f"\n💾 Saved combined landmarks for {len(self.combined_landmarks)} teeth to {out_path}")
return out_path
def _export(self, scan: Path, internal_name: str, work_dir: Path, out_dir: Path, landmarks: dict):
"""Copy the final predictions from the temporary work dir to out_dir.
Internal pipeline artifacts are named after `internal_name` (the
symlink used inside work_dir); exported files keep the user's
original scan name.
"""
out_dir.mkdir(parents=True, exist_ok=True)
internal_stem = Path(internal_name).stem
if self.engine.save_ply:
landmarks_json_path = work_dir / f"{internal_stem}_landmarks.json"
landmarks_json_path.write_text(json.dumps(landmarks))
json_to_ply(landmarks_json_path, out_dir / f"{scan.stem}_landmarks.ply")
shutil.copy(
work_dir / "output_seg" / "result" / f"{internal_stem}_pred.npy",
out_dir / f"{scan.stem}_seg.npy",
)
if self.engine.visualize_segmentation:
views = work_dir / "output_seg" / f"{internal_stem}_segmentation_views.png"
if views.exists():
shutil.copy(views, out_dir / f"{scan.stem}_segmentation_views.png")
print(f"💾 Saved predictions for {scan.name} to {out_dir}")
def parse_args():
parser = argparse.ArgumentParser(description="Segments scans and predicts landmarks.")
parser.add_argument("--input", required=True, help="Scan file (.stl/.obj) or folder scanned recursively")
parser.add_argument("--output", required=True, help="Output folder (mirrors the input folder structure)")
parser.add_argument("--seg-config", required=True, help="Segmentation config file")
parser.add_argument("--seg-weight", required=True, help="Segmentation model weights")
parser.add_argument("--bond-config", required=True, help="Bond prediction config file")
parser.add_argument("--bond-weight", required=True, help="Bond prediction model weights")
parser.add_argument("--preprocessing", help="YAML with per-arch scan normalization transforms")
parser.add_argument("--vis-seg", action="store_true", help="Also save a rendering of the segmentation")
parser.add_argument("--save-ply", action="store_true", help="Saves landmarks as point cloud")
parser.add_argument("--batch", action="store_true",
help="Segment every scan in one pass, then bond every scan in one pass, "
"instead of processing scans one at a time. Faster for large scan "
"sets, but a failure in the batched segmentation/bonding step aborts "
"the whole batch instead of failing just one scan.")
parser.add_argument("--landmarks", nargs="+", choices=ALL_LANDMARKS, default=None,
help="Restrict landmark prediction/post-processing to these classes "
"(default: all). Skips the k-means/connected-components decoding "
"for unrequested classes, notably 'Planar' and 'Cusp'. "
"'Bracket', 'Incisal' and 'OuterPoint' are always computed since "
"every landmark's basePlane is defined relative to them.")
parser.add_argument("--workers", type=int, default=1,
help="Number of worker threads for the CPU/IO-bound steps that don't "
"run on the GPU: loading scans from disk, splitting a segmented "
"scan into per-tooth meshes, and turning each tooth's predicted "
"heatmap into final landmark coordinates. Does not affect model "
"inference itself. Default: 1 (sequential).")
return parser.parse_args()
def main():
args = parse_args()
input_path = Path(args.input).resolve()
output_root = Path(args.output)
scans = []
for scan in collect_scans(input_path):
if "lower" in scan.stem or "upper" in scan.stem:
scans.append(scan)
else:
print(f"⚠️ Skipping {scan}: filename must contain 'lower' or 'upper'")
if not scans:
sys.exit("❌ No valid scans found.")
print(f"Found {len(scans)} scan(s) to process.")
pipeline = InferencePipeline(args)
loop_start = time.perf_counter()
if args.batch:
print(f"\n🦷 Processing {len(scans)} scan(s) in batch mode "
f"(all segmentations, then all bondings)...")
failed = pipeline.run_batch(scans, input_path, output_root)
else:
failed = []
for i, scan in enumerate(scans, 1):
print(f"\n{'=' * 70}\n🦷 [{i}/{len(scans)}] {scan.name}\n{'=' * 70}")
try:
pipeline.run(scan, output_dir_for(scan, input_path, output_root))
except Exception:
traceback.print_exc()
failed.append(scan)
total_with_io = time.perf_counter() - loop_start
print(f"\n✅ Done: {len(scans) - len(failed)}/{len(scans)} scans processed.")
for scan in failed:
print(f" ❌ Failed: {scan}")
pipeline.write_combined_landmarks(output_root)
succeeded = len(scans) - len(failed)
print(f"\n⏱️ Total time — with I/O: {total_with_io:.2f}s for {len(scans)} scan(s) "
f"({total_with_io / len(scans):.2f}s/scan)")
if succeeded:
print(f"⏱️ Total time — without I/O: {pipeline.processing_time:.2f}s for {succeeded} scan(s) "
f"({pipeline.processing_time / succeeded:.2f}s/scan)")
if __name__ == "__main__":
main()