MichaelRKessler commited on
Commit
a2f76ca
·
1 Parent(s): 6074bb1

Add Gradio STL slicer app

Browse files
Files changed (8) hide show
  1. .gitignore +3 -0
  2. README.md +26 -0
  3. app.py +221 -0
  4. pyproject.toml +23 -0
  5. stl_slicer.py +222 -0
  6. tests/conftest.py +9 -0
  7. tests/test_stl_slicer.py +49 -0
  8. uv.lock +0 -0
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
README.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # STL to TIFF Gradio App
2
+
3
+ This project provides a Gradio app that takes an uploaded STL file, slices it along the Z axis, saves each slice as a TIFF image, and lets you browse the stack inside the UI.
4
+
5
+ ## Run
6
+
7
+ ```powershell
8
+ uv sync --all-groups
9
+ uv run python app.py
10
+ ```
11
+
12
+ Then open the local Gradio URL in your browser, upload an STL file, and generate the TIFF stack.
13
+
14
+ ## What the app does
15
+
16
+ - Uploads a single `.stl` file
17
+ - Lets you choose layer height and XY pixel size
18
+ - Produces one `.tif` image per slice
19
+ - Lets you step through the slice stack in the browser
20
+ - Exports a ZIP containing the generated TIFF images
21
+
22
+ ## Test
23
+
24
+ ```powershell
25
+ uv run pytest
26
+ ```
app.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import gradio as gr
7
+ from PIL import Image
8
+
9
+ from stl_slicer import SliceStack, slice_stl_to_tiffs
10
+
11
+
12
+ ViewerState = dict[str, Any]
13
+
14
+
15
+ def _read_slice_preview(path: str) -> Image.Image:
16
+ with Image.open(path) as image:
17
+ return image.copy()
18
+
19
+
20
+ def _empty_state() -> ViewerState:
21
+ return {"tiff_paths": [], "z_values": []}
22
+
23
+
24
+ def _stack_to_state(stack: SliceStack) -> ViewerState:
25
+ return {
26
+ "tiff_paths": [str(path) for path in stack.tiff_paths],
27
+ "z_values": stack.z_values,
28
+ }
29
+
30
+
31
+ def _format_summary(stack: SliceStack, source_name: str) -> str:
32
+ (x_min, y_min, z_min), (x_max, y_max, z_max) = stack.bounds
33
+ return "\n".join(
34
+ [
35
+ "### Slice Stack Ready",
36
+ f"- Source: `{source_name}`",
37
+ f"- TIFF count: `{len(stack.tiff_paths)}`",
38
+ f"- Image size: `{stack.image_size[0]} x {stack.image_size[1]}` pixels",
39
+ f"- Layer height: `{stack.layer_height}`",
40
+ f"- Pixel size: `{stack.pixel_size}`",
41
+ f"- Bounds: `x={x_min:.3f}..{x_max:.3f}`, `y={y_min:.3f}..{y_max:.3f}`, `z={z_min:.3f}..{z_max:.3f}`",
42
+ ]
43
+ )
44
+
45
+
46
+ def _slice_label(state: ViewerState, index: int) -> str:
47
+ path = Path(state["tiff_paths"][index]).name
48
+ z_value = state["z_values"][index]
49
+ total = len(state["tiff_paths"])
50
+ return f"Slice {index + 1} / {total} | z = {z_value:.4f} | {path}"
51
+
52
+
53
+ def _render_selected_slice(state: ViewerState, index: int) -> tuple[str, Image.Image | None, str | None]:
54
+ tiff_paths = state.get("tiff_paths", [])
55
+ if not tiff_paths:
56
+ return "No slice stack loaded yet.", None, None
57
+
58
+ bounded_index = max(0, min(int(index), len(tiff_paths) - 1))
59
+ selected_path = tiff_paths[bounded_index]
60
+ return (
61
+ _slice_label(state, bounded_index),
62
+ _read_slice_preview(selected_path),
63
+ selected_path,
64
+ )
65
+
66
+
67
+ def generate_stack(
68
+ stl_file: str | None,
69
+ layer_height: float,
70
+ pixel_size: float,
71
+ progress: gr.Progress = gr.Progress(),
72
+ ):
73
+ if not stl_file:
74
+ raise gr.Error("Upload an STL file before generating slices.")
75
+
76
+ def report_progress(current: int, total: int) -> None:
77
+ progress(current / total, desc=f"Rendering slice {current} of {total}")
78
+
79
+ stack = slice_stl_to_tiffs(
80
+ stl_file,
81
+ layer_height=layer_height,
82
+ pixel_size=pixel_size,
83
+ progress_callback=report_progress,
84
+ )
85
+
86
+ state = _stack_to_state(stack)
87
+ label, preview, selected_path = _render_selected_slice(state, 0)
88
+ slider_update = gr.update(
89
+ minimum=0,
90
+ maximum=max(0, len(stack.tiff_paths) - 1),
91
+ value=0,
92
+ step=1,
93
+ interactive=len(stack.tiff_paths) > 1,
94
+ )
95
+
96
+ return (
97
+ _format_summary(stack, Path(stl_file).name),
98
+ state,
99
+ slider_update,
100
+ label,
101
+ preview,
102
+ str(stack.zip_path),
103
+ selected_path,
104
+ )
105
+
106
+
107
+ def jump_to_slice(state: ViewerState, index: float) -> tuple[str, Image.Image | None, str | None]:
108
+ return _render_selected_slice(state, int(index))
109
+
110
+
111
+ def shift_slice(state: ViewerState, index: float, delta: int) -> tuple[int, str, Image.Image | None, str | None]:
112
+ tiff_paths = state.get("tiff_paths", [])
113
+ if not tiff_paths:
114
+ return 0, "No slice stack loaded yet.", None, None
115
+
116
+ new_index = max(0, min(int(index) + delta, len(tiff_paths) - 1))
117
+ label, preview, selected_path = _render_selected_slice(state, new_index)
118
+ return new_index, label, preview, selected_path
119
+
120
+
121
+ def build_demo() -> gr.Blocks:
122
+ with gr.Blocks(title="STL TIFF Slicer") as demo:
123
+ gr.Markdown(
124
+ """
125
+ # STL to TIFF Slicer
126
+ Upload an STL, choose a layer height and XY pixel size, and generate a TIFF stack.
127
+ Use the slider or the previous and next buttons to browse through the resulting slices.
128
+ """
129
+ )
130
+
131
+ state = gr.State(_empty_state())
132
+
133
+ with gr.Row():
134
+ with gr.Column(scale=1):
135
+ stl_file = gr.File(
136
+ label="STL File",
137
+ file_types=[".stl"],
138
+ type="filepath",
139
+ )
140
+ layer_height = gr.Number(
141
+ label="Layer Height",
142
+ value=0.1,
143
+ minimum=0.0001,
144
+ step=0.01,
145
+ )
146
+ pixel_size = gr.Number(
147
+ label="Pixel Size",
148
+ value=0.05,
149
+ minimum=0.0001,
150
+ step=0.01,
151
+ )
152
+ generate_button = gr.Button("Generate TIFF Stack", variant="primary")
153
+ download_zip = gr.File(label="Download All TIFFs (ZIP)")
154
+ current_tiff = gr.File(label="Current TIFF Slice")
155
+
156
+ with gr.Column(scale=2):
157
+ summary = gr.Markdown("Upload an STL file to begin.")
158
+ slice_label = gr.Markdown("No slice stack loaded yet.")
159
+ slice_preview = gr.Image(
160
+ label="Slice Preview",
161
+ type="pil",
162
+ image_mode="L",
163
+ height=520,
164
+ )
165
+ with gr.Row():
166
+ prev_button = gr.Button("Previous Slice")
167
+ next_button = gr.Button("Next Slice")
168
+ slice_slider = gr.Slider(
169
+ label="Slice Index",
170
+ minimum=0,
171
+ maximum=0,
172
+ value=0,
173
+ step=1,
174
+ interactive=False,
175
+ )
176
+
177
+ generate_button.click(
178
+ fn=generate_stack,
179
+ inputs=[stl_file, layer_height, pixel_size],
180
+ outputs=[
181
+ summary,
182
+ state,
183
+ slice_slider,
184
+ slice_label,
185
+ slice_preview,
186
+ download_zip,
187
+ current_tiff,
188
+ ],
189
+ )
190
+
191
+ slice_slider.release(
192
+ fn=jump_to_slice,
193
+ inputs=[state, slice_slider],
194
+ outputs=[slice_label, slice_preview, current_tiff],
195
+ queue=False,
196
+ )
197
+
198
+ prev_button.click(
199
+ fn=lambda state_value, index: shift_slice(state_value, index, -1),
200
+ inputs=[state, slice_slider],
201
+ outputs=[slice_slider, slice_label, slice_preview, current_tiff],
202
+ queue=False,
203
+ )
204
+
205
+ next_button.click(
206
+ fn=lambda state_value, index: shift_slice(state_value, index, 1),
207
+ inputs=[state, slice_slider],
208
+ outputs=[slice_slider, slice_label, slice_preview, current_tiff],
209
+ queue=False,
210
+ )
211
+
212
+ return demo
213
+
214
+
215
+ def main() -> None:
216
+ demo = build_demo()
217
+ demo.launch()
218
+
219
+
220
+ if __name__ == "__main__":
221
+ main()
pyproject.toml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "stl-to-gcode"
3
+ version = "0.1.0"
4
+ description = "Gradio app for slicing STL files into TIFF image stacks."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "gradio>=5.23.0",
9
+ "networkx>=3.4.2",
10
+ "numpy>=2.2.0",
11
+ "pillow>=11.1.0",
12
+ "scipy>=1.15.2",
13
+ "shapely>=2.0.7",
14
+ "trimesh>=4.6.5",
15
+ ]
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "pytest>=8.3.5",
20
+ ]
21
+
22
+ [tool.uv]
23
+ package = false
stl_slicer.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import tempfile
5
+ import uuid
6
+ import zipfile
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Callable
10
+
11
+ import numpy as np
12
+ from PIL import Image, ImageDraw
13
+ from shapely.geometry import GeometryCollection, MultiPolygon, Polygon
14
+ import trimesh
15
+
16
+
17
+ ProgressCallback = Callable[[int, int], None] | None
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class SliceStack:
22
+ output_dir: Path
23
+ zip_path: Path
24
+ tiff_paths: list[Path]
25
+ z_values: list[float]
26
+ image_size: tuple[int, int]
27
+ bounds: tuple[tuple[float, float, float], tuple[float, float, float]]
28
+ layer_height: float
29
+ pixel_size: float
30
+
31
+
32
+ def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
33
+ loaded = trimesh.load(stl_path, force="scene")
34
+ if isinstance(loaded, trimesh.Scene):
35
+ if not loaded.geometry:
36
+ raise ValueError("The STL file does not contain any mesh geometry.")
37
+ mesh = trimesh.util.concatenate(tuple(loaded.geometry.values()))
38
+ else:
39
+ mesh = loaded
40
+
41
+ if not isinstance(mesh, trimesh.Trimesh) or mesh.is_empty:
42
+ raise ValueError("Unable to load a valid mesh from the STL file.")
43
+
44
+ return mesh
45
+
46
+
47
+ def calculate_z_levels(z_min: float, z_max: float, layer_height: float) -> list[float]:
48
+ if layer_height <= 0:
49
+ raise ValueError("Layer height must be greater than zero.")
50
+
51
+ thickness = z_max - z_min
52
+ if thickness <= 0:
53
+ return [z_min]
54
+
55
+ layer_count = max(1, math.ceil(thickness / layer_height))
56
+ top_guard = math.nextafter(z_max, z_min)
57
+
58
+ return [
59
+ min(z_min + ((index + 0.5) * layer_height), top_guard)
60
+ for index in range(layer_count)
61
+ ]
62
+
63
+
64
+ def _to_pixel_ring(
65
+ coords: np.ndarray,
66
+ x_min: float,
67
+ y_min: float,
68
+ pixel_size: float,
69
+ image_height: int,
70
+ ) -> list[tuple[int, int]]:
71
+ pixels: list[tuple[int, int]] = []
72
+ for x_value, y_value in coords:
73
+ x_pixel = int(round((float(x_value) - x_min) / pixel_size))
74
+ y_pixel = int(round((float(y_value) - y_min) / pixel_size))
75
+ pixels.append((x_pixel, image_height - 1 - y_pixel))
76
+ return pixels
77
+
78
+
79
+ def _ring_to_world_xy(ring_coords: object, to_3d: np.ndarray) -> np.ndarray:
80
+ planar = np.asarray(ring_coords, dtype=float)
81
+ if planar.ndim != 2 or planar.shape[1] < 2:
82
+ raise ValueError("Encountered an invalid polygon ring while slicing.")
83
+
84
+ planar_3d = np.column_stack([planar[:, 0], planar[:, 1], np.zeros(len(planar))])
85
+ world = trimesh.transform_points(planar_3d, to_3d)
86
+ return world[:, :2]
87
+
88
+
89
+ def _compose_even_odd_polygons(polygons: list[Polygon]) -> list[Polygon]:
90
+ geometry: Polygon | MultiPolygon | GeometryCollection | None = None
91
+ for polygon in polygons:
92
+ geometry = polygon if geometry is None else geometry.symmetric_difference(polygon)
93
+
94
+ if geometry is None or geometry.is_empty:
95
+ return []
96
+
97
+ if isinstance(geometry, Polygon):
98
+ return [geometry]
99
+
100
+ if isinstance(geometry, MultiPolygon):
101
+ return list(geometry.geoms)
102
+
103
+ if isinstance(geometry, GeometryCollection):
104
+ return [geom for geom in geometry.geoms if isinstance(geom, Polygon) and not geom.is_empty]
105
+
106
+ return []
107
+
108
+
109
+ def _extract_world_polygons(section: trimesh.path.Path3D) -> list[tuple[np.ndarray, list[np.ndarray]]]:
110
+ if hasattr(section, "to_2D"):
111
+ planar, to_3d = section.to_2D()
112
+ else:
113
+ planar, to_3d = section.to_planar()
114
+
115
+ composed_polygons = _compose_even_odd_polygons(list(planar.polygons_closed))
116
+ polygons: list[tuple[np.ndarray, list[np.ndarray]]] = []
117
+ for polygon in composed_polygons:
118
+ exterior = _ring_to_world_xy(polygon.exterior.coords, to_3d)
119
+ holes = [_ring_to_world_xy(interior.coords, to_3d) for interior in polygon.interiors]
120
+ polygons.append((exterior, holes))
121
+
122
+ return polygons
123
+
124
+
125
+ def _render_slice(
126
+ section: trimesh.path.Path3D | None,
127
+ x_min: float,
128
+ y_min: float,
129
+ image_size: tuple[int, int],
130
+ pixel_size: float,
131
+ ) -> Image.Image:
132
+ image = Image.new("L", image_size, 0)
133
+
134
+ if section is None:
135
+ return image
136
+
137
+ polygons = _extract_world_polygons(section)
138
+ if not polygons:
139
+ return image
140
+
141
+ draw = ImageDraw.Draw(image)
142
+ for exterior, holes in polygons:
143
+ draw.polygon(
144
+ _to_pixel_ring(exterior, x_min, y_min, pixel_size, image.height),
145
+ fill=255,
146
+ )
147
+ for hole in holes:
148
+ draw.polygon(
149
+ _to_pixel_ring(hole, x_min, y_min, pixel_size, image.height),
150
+ fill=0,
151
+ )
152
+
153
+ return image
154
+
155
+
156
+ def _make_output_paths(stl_path: Path, output_root: str | Path | None) -> tuple[Path, Path]:
157
+ root = Path(output_root) if output_root else Path(tempfile.mkdtemp(prefix="stl_slices_"))
158
+ stem = stl_path.stem or "mesh"
159
+ job_dir = root / f"{stem}_{uuid.uuid4().hex[:8]}"
160
+ slices_dir = job_dir / "tiff_slices"
161
+ slices_dir.mkdir(parents=True, exist_ok=True)
162
+ zip_path = job_dir / f"{stem}_tiff_slices.zip"
163
+ return slices_dir, zip_path
164
+
165
+
166
+ def _zip_tiffs(tiff_paths: list[Path], zip_path: Path) -> None:
167
+ with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
168
+ for tiff_path in tiff_paths:
169
+ archive.write(tiff_path, arcname=tiff_path.name)
170
+
171
+
172
+ def slice_stl_to_tiffs(
173
+ stl_path: str | Path,
174
+ layer_height: float,
175
+ pixel_size: float,
176
+ output_root: str | Path | None = None,
177
+ progress_callback: ProgressCallback = None,
178
+ ) -> SliceStack:
179
+ if pixel_size <= 0:
180
+ raise ValueError("Pixel size must be greater than zero.")
181
+
182
+ stl_path = Path(stl_path)
183
+ mesh = load_mesh(stl_path)
184
+ bounds = mesh.bounds
185
+ (x_min, y_min, z_min), (x_max, y_max, z_max) = bounds
186
+
187
+ z_values = calculate_z_levels(float(z_min), float(z_max), layer_height)
188
+ width = max(1, math.ceil((float(x_max) - float(x_min)) / pixel_size) + 1)
189
+ height = max(1, math.ceil((float(y_max) - float(y_min)) / pixel_size) + 1)
190
+ image_size = (width, height)
191
+
192
+ output_dir, zip_path = _make_output_paths(stl_path, output_root)
193
+ tiff_paths: list[Path] = []
194
+
195
+ for index, z_value in enumerate(z_values):
196
+ section = mesh.section(
197
+ plane_origin=np.array([0.0, 0.0, z_value], dtype=float),
198
+ plane_normal=np.array([0.0, 0.0, 1.0], dtype=float),
199
+ )
200
+ image = _render_slice(section, float(x_min), float(y_min), image_size, pixel_size)
201
+ tiff_path = output_dir / f"slice_{index:04d}.tif"
202
+ image.save(tiff_path, compression="tiff_deflate")
203
+ tiff_paths.append(tiff_path)
204
+
205
+ if progress_callback is not None:
206
+ progress_callback(index + 1, len(z_values))
207
+
208
+ _zip_tiffs(tiff_paths, zip_path)
209
+
210
+ return SliceStack(
211
+ output_dir=output_dir,
212
+ zip_path=zip_path,
213
+ tiff_paths=tiff_paths,
214
+ z_values=z_values,
215
+ image_size=image_size,
216
+ bounds=(
217
+ (float(x_min), float(y_min), float(z_min)),
218
+ (float(x_max), float(y_max), float(z_max)),
219
+ ),
220
+ layer_height=layer_height,
221
+ pixel_size=pixel_size,
222
+ )
tests/conftest.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
8
+ if str(PROJECT_ROOT) not in sys.path:
9
+ sys.path.insert(0, str(PROJECT_ROOT))
tests/test_stl_slicer.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from PIL import Image
5
+ from shapely.geometry import Polygon
6
+ import trimesh
7
+
8
+ from stl_slicer import _compose_even_odd_polygons, calculate_z_levels, slice_stl_to_tiffs
9
+
10
+
11
+ def test_calculate_z_levels_creates_single_layer_for_thin_mesh() -> None:
12
+ z_values = calculate_z_levels(0.0, 0.01, 0.1)
13
+
14
+ assert len(z_values) == 1
15
+ assert 0.0 <= z_values[0] < 0.01
16
+
17
+
18
+ def test_slice_stl_to_tiffs_creates_non_empty_tiffs(tmp_path) -> None:
19
+ mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
20
+ stl_path = tmp_path / "cube.stl"
21
+ mesh.export(stl_path)
22
+
23
+ stack = slice_stl_to_tiffs(
24
+ stl_path,
25
+ layer_height=0.5,
26
+ pixel_size=0.25,
27
+ output_root=tmp_path / "generated",
28
+ )
29
+
30
+ assert len(stack.tiff_paths) == 4
31
+ assert stack.zip_path.exists()
32
+ assert all(path.exists() for path in stack.tiff_paths)
33
+
34
+ with Image.open(stack.tiff_paths[0]) as first_image:
35
+ pixels = np.array(first_image)
36
+
37
+ assert pixels.max() == 255
38
+ assert pixels.sum() > 0
39
+
40
+
41
+ def test_compose_even_odd_polygons_preserves_holes() -> None:
42
+ outer = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
43
+ inner = Polygon([(3, 3), (7, 3), (7, 7), (3, 7)])
44
+
45
+ composed = _compose_even_odd_polygons([outer, inner])
46
+
47
+ assert len(composed) == 1
48
+ assert composed[0].area == outer.area - inner.area
49
+ assert len(composed[0].interiors) == 1
uv.lock ADDED
The diff for this file is too large to render. See raw diff