Skip to content

Commit 1338207

Browse files
committed
first commit
0 parents  commit 1338207

14 files changed

Lines changed: 1568 additions & 0 deletions

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
exp
2+
gt
3+
input
4+
logs
5+
output
6+
splits
7+
venv
8+
__pycache__

README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ScanNormalizer
2+
3+
Minimal scan orientation normalizer.
4+
5+
The training task uses the consistently oriented scans as the canonical frame:
6+
7+
1. Load mesh vertices.
8+
2. Center them and scale to the unit sphere.
9+
3. Sample a fixed number of points with farthest point sampling.
10+
4. Apply either no rotation or a 180-degree rotation around X, Y, or Z.
11+
5. Predict which of the four rotation classes was applied.
12+
6. Train with cross entropy on the rotation class.
13+
14+
At inference time, the scan is PCA-aligned first, saved as a `_pca` mesh, then the model predicts which 180-degree correction to apply.
15+
16+
Train:
17+
18+
```bash
19+
python train.py --data-root /work/grana_maxillo/IOS_3DT --fold-dir splits/fold_1 --output-dir exp/rotation
20+
```
21+
22+
Generate evaluation ground-truth rotations:
23+
24+
```bash
25+
python generate_eval_gt.py --input-dir input --gt-json gt/ground_truth.json --seed 42
26+
```
27+
28+
During training, full evaluation runs before epoch 1 and after every epoch by default. It evaluates only the validation split from `fold_dir/val.txt`, loads those scan names from `input/`, reads GT matrices from `gt/ground_truth.json`, and writes all predicted matrices to one `json/predictions.json` file inside each run directory. Disable it with `--no-eval`, or override paths with `--eval-input-dir`, `--eval-split-file`, and `--eval-gt-json`.
29+
30+
Each training run creates a separate directory under `--output-dir` containing `last.pt`, `best.pt`, and the local Weights & Biases files. Training logs to the `ios_orientation` Weights & Biases project by default. Disable it with `--no-wandb`.
31+
32+
Orient one scan:
33+
34+
```bash
35+
python orient_scan.py /path/to/scan.stl --checkpoint exp/rotation/best.pt --output-dir output
36+
```
37+
38+
Orient a full input directory while preserving patient subfolders:
39+
40+
```bash
41+
python batch_orient_scans.py --input-dir input --output-dir output --checkpoint exp/rotation/best.pt
42+
```
43+
44+
The batch script writes oriented STL files under the same relative paths in `output/` and saves quick visual QA sheets under `output/qa/`. By default each QA image contains up to 10 scans with X/Y/Z axes drawn in red/green/blue.
45+
46+
Regenerate only the QA plots from already oriented scans:
47+
48+
```bash
49+
python batch_orient_scans.py --output-dir output --plot-only
50+
```
51+
52+
The QA plots render points by default. If needed, increase the point size or switch to slower surface rendering:
53+
54+
```bash
55+
python batch_orient_scans.py --output-dir output --plot-only --point-size 2.0
56+
python batch_orient_scans.py --output-dir output --plot-only --render-mode surface --render-faces 20000
57+
```
58+
59+
Use the inference API from Python:
60+
61+
```python
62+
from scan_inference import load_normalizer, normalize_scan
63+
64+
normalizer = load_normalizer("exp/rotation/best.pt", device="cuda", points=4096)
65+
result = normalize_scan("input/patient/lower.stl", "output/patient/lower.stl", normalizer)
66+
print(result.rotation_index)
67+
```

batch_orient_scans.py

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import argparse
2+
import math
3+
from pathlib import Path
4+
import numpy as np
5+
import torch
6+
import trimesh
7+
8+
from scan_inference import load_normalizer, normalize_scan
9+
10+
11+
def parse_args():
12+
parser = argparse.ArgumentParser(
13+
description="Orient every STL scan under an input directory and render QA plots."
14+
)
15+
parser.add_argument("--input-dir", type=Path, default=Path("input"))
16+
parser.add_argument("--output-dir", type=Path, default=Path("output"))
17+
parser.add_argument("--checkpoint")
18+
parser.add_argument("--points", type=int, default=None)
19+
parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
20+
parser.add_argument("--sampling", choices=("fps", "random"), default="fps")
21+
parser.add_argument("--plot-only", action="store_true")
22+
parser.add_argument("--plots-per-image", type=int, default=40)
23+
parser.add_argument("--render-mode", choices=("surface", "points"), default="points")
24+
parser.add_argument("--render-points", type=int, default=5000)
25+
parser.add_argument("--render-faces", type=int, default=10000)
26+
parser.add_argument("--point-size", type=float, default=2)
27+
return parser.parse_args()
28+
29+
30+
def main():
31+
args = parse_args()
32+
input_dir = args.input_dir.expanduser().resolve()
33+
output_dir = args.output_dir.expanduser().resolve()
34+
35+
if args.plot_only:
36+
output_paths = find_stl_files(output_dir, exclude_dir=output_dir / "qa")
37+
if not output_paths:
38+
raise RuntimeError(f"No oriented STL scans found under {output_dir}")
39+
render_contact_sheets(
40+
output_paths,
41+
output_dir / "qa",
42+
output_dir,
43+
args.plots_per_image,
44+
args.render_points,
45+
args.render_faces,
46+
args.render_mode,
47+
args.point_size,
48+
)
49+
print(f"QA images saved under {output_dir / 'qa'}", flush=True)
50+
return
51+
52+
if args.checkpoint is None:
53+
raise RuntimeError("--checkpoint is required unless --plot-only is used")
54+
55+
scans = sorted(
56+
path for path in input_dir.rglob("*") if path.is_file() and path.suffix.lower() == ".stl"
57+
)
58+
if not scans:
59+
raise RuntimeError(f"No STL scans found under {input_dir}")
60+
61+
normalizer = load_normalizer(
62+
args.checkpoint,
63+
device=args.device,
64+
points=args.points,
65+
sampling=args.sampling,
66+
)
67+
68+
output_paths = []
69+
for index, scan_path in enumerate(scans, start=1):
70+
relative_path = scan_path.relative_to(input_dir)
71+
output_path = output_dir / relative_path
72+
result = normalize_scan(
73+
scan_path,
74+
output_path,
75+
normalizer,
76+
)
77+
output_paths.append(output_path)
78+
print(
79+
f"[{index}/{len(scans)}] {relative_path} -> {output_path.relative_to(output_dir)} "
80+
f"| rotation_class {result.rotation_index}",
81+
flush=True,
82+
)
83+
84+
render_contact_sheets(
85+
output_paths,
86+
output_dir / "qa",
87+
output_dir,
88+
args.plots_per_image,
89+
args.render_points,
90+
args.render_faces,
91+
args.render_mode,
92+
args.point_size,
93+
)
94+
print(f"oriented {len(output_paths)} scans into {output_dir}", flush=True)
95+
print(f"QA images saved under {output_dir / 'qa'}", flush=True)
96+
97+
98+
def find_stl_files(root, exclude_dir=None):
99+
root = root.resolve()
100+
exclude_dir = exclude_dir.resolve() if exclude_dir is not None else None
101+
paths = []
102+
for path in root.rglob("*"):
103+
if not path.is_file() or path.suffix.lower() != ".stl":
104+
continue
105+
if exclude_dir is not None and exclude_dir in path.resolve().parents:
106+
continue
107+
paths.append(path)
108+
return sorted(paths)
109+
110+
111+
def render_contact_sheets(
112+
mesh_paths,
113+
qa_dir,
114+
output_dir,
115+
plots_per_image,
116+
render_points,
117+
render_faces,
118+
render_mode,
119+
point_size,
120+
):
121+
try:
122+
import matplotlib
123+
except ImportError as exc:
124+
raise RuntimeError(
125+
"matplotlib is required for QA plotting. Install requirements first."
126+
) from exc
127+
128+
matplotlib.use("Agg")
129+
import matplotlib.pyplot as plt
130+
131+
qa_dir.mkdir(parents=True, exist_ok=True)
132+
plots_per_image = max(1, plots_per_image)
133+
page_count = math.ceil(len(mesh_paths) / plots_per_image)
134+
135+
for page_index in range(page_count):
136+
page_paths = mesh_paths[
137+
page_index * plots_per_image : (page_index + 1) * plots_per_image
138+
]
139+
columns = min(5, len(page_paths))
140+
rows = math.ceil(len(page_paths) / columns)
141+
figure, axes = plt.subplots(
142+
rows,
143+
columns,
144+
figsize=(4.0 * columns, 3.6 * rows),
145+
subplot_kw={"projection": "3d"},
146+
)
147+
axes = np.asarray(axes, dtype=object).reshape(-1)
148+
149+
for axis, mesh_path in zip(axes, page_paths):
150+
plot_mesh(
151+
axis,
152+
mesh_path,
153+
mesh_path.relative_to(output_dir),
154+
render_points,
155+
render_faces,
156+
render_mode,
157+
point_size,
158+
)
159+
160+
for axis in axes[len(page_paths) :]:
161+
axis.set_axis_off()
162+
163+
figure.tight_layout()
164+
figure.savefig(qa_dir / f"oriented_scans_{page_index + 1:03d}.png", dpi=160)
165+
plt.close(figure)
166+
167+
168+
def plot_mesh(axis, mesh_path, title, render_points, render_faces, render_mode, point_size):
169+
mesh = trimesh.load(mesh_path, force="mesh", process=False)
170+
points = np.asarray(mesh.vertices, dtype=np.float32)
171+
if points.ndim != 2 or points.shape[1] != 3 or len(points) == 0:
172+
axis.set_title(f"{title}\n(no vertices)", fontsize=8)
173+
axis.set_axis_off()
174+
return
175+
176+
if render_mode == "surface" and len(mesh.faces) > 0:
177+
plot_surface(axis, points, np.asarray(mesh.faces), render_faces)
178+
else:
179+
plot_points(axis, points, render_points, point_size)
180+
181+
set_equal_axes(axis, points)
182+
draw_axes(axis, points)
183+
axis.set_title(str(title), fontsize=8)
184+
axis.view_init(elev=18, azim=-55)
185+
186+
187+
def plot_surface(axis, vertices, faces, render_faces):
188+
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
189+
190+
if len(faces) > render_faces:
191+
rng = np.random.default_rng(0)
192+
faces = faces[rng.choice(len(faces), size=render_faces, replace=False)]
193+
194+
surface = Poly3DCollection(
195+
vertices[faces],
196+
facecolor="#d8d8d8",
197+
edgecolor="none",
198+
alpha=0.96,
199+
)
200+
surface.set_sort_zpos(0)
201+
axis.add_collection3d(surface)
202+
203+
204+
def plot_points(axis, points, render_points, point_size):
205+
if len(points) > render_points:
206+
rng = np.random.default_rng(0)
207+
points = points[rng.choice(len(points), size=render_points, replace=False)]
208+
209+
axis.scatter(
210+
points[:, 0],
211+
points[:, 1],
212+
points[:, 2],
213+
s=point_size,
214+
c=points[:, 2],
215+
cmap="viridis",
216+
alpha=0.85,
217+
linewidths=0,
218+
)
219+
220+
221+
def set_equal_axes(axis, points):
222+
minimum = points.min(axis=0)
223+
maximum = points.max(axis=0)
224+
center = (minimum + maximum) / 2.0
225+
radius = max((maximum - minimum).max() / 2.0, 1e-3)
226+
227+
axis.set_xlim(center[0] - radius, center[0] + radius)
228+
axis.set_ylim(center[1] - radius, center[1] + radius)
229+
axis.set_zlim(center[2] - radius, center[2] + radius)
230+
axis.set_box_aspect((1, 1, 1))
231+
axis.set_xlabel("X", color="red")
232+
axis.set_ylabel("Y", color="green")
233+
axis.set_zlabel("Z", color="blue")
234+
235+
236+
def draw_axes(axis, points):
237+
span = points.max(axis=0) - points.min(axis=0)
238+
length = max(float(span.max()) * 0.6, 0.5)
239+
240+
axis.plot([-length, length], [0, 0], [0, 0], color="red", linewidth=1.4)
241+
axis.plot([0, 0], [-length, length], [0, 0], color="green", linewidth=1.4)
242+
axis.plot([0, 0], [0, 0], [-length, length], color="blue", linewidth=1.4)
243+
axis.text(length, 0, 0, "+X", color="red", fontsize=8)
244+
axis.text(0, length, 0, "+Y", color="green", fontsize=8)
245+
axis.text(0, 0, length, "+Z", color="blue", fontsize=8)
246+
247+
248+
if __name__ == "__main__":
249+
main()

0 commit comments

Comments
 (0)