bokula commited on
Commit
58b1e11
·
verified ·
1 Parent(s): 1baefa4

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +160 -170
  2. relief_workshop.py +144 -0
app.py CHANGED
@@ -11,38 +11,53 @@ import gradio as gr
11
  import numpy as np
12
  from PIL import Image
13
 
14
- from relief_workshop import build_heightmap, heightmap_to_stl
 
 
 
15
 
16
 
17
  # ─────────────────────────────────────────────
18
  # CORE
19
  # ─────────────────────────────────────────────
20
 
21
- def preview_depthmap(image, invert, quantize_levels, tileable, blur):
 
22
  if image is None:
23
  return None, "⚠️ Carica un'immagine."
24
  try:
25
  with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
26
  tmp = f.name
27
  Image.fromarray(image).save(tmp)
28
- quantize = int(quantize_levels) if quantize_levels >= 2 else 0
29
- height_map, px_w, px_h = build_heightmap(
30
- tmp, max_px=300, invert=invert, blur=blur, gamma=1.1,
31
- quantize=quantize, tileable=tileable, fade_px=12,
32
- )
33
- Path(tmp).unlink(missing_ok=True)
34
- preview = (height_map * 255).astype(np.uint8)
35
- msg = f"Depth map {px_w}×{px_h}px bianco=alto, nero=basso"
36
- if invert:
37
- msg += " (invertita)"
38
- return preview, msg
 
 
 
 
 
 
 
 
 
 
 
39
  except Exception as e:
40
  return None, f"❌ {e}"
41
 
42
 
43
  def generate_stl(
44
  image, tile_w, tile_h_mode, tile_h_custom,
45
- relief_mm, base_mm, no_base,
46
  invert, quantize_levels, tileable, blur, resolution,
47
  ):
48
  if image is None:
@@ -60,56 +75,102 @@ def generate_stl(
60
  else:
61
  tile_h = tile_h_custom
62
 
63
- quantize = int(quantize_levels) if quantize_levels >= 2 else 0
64
- height_map, px_w, px_h = build_heightmap(
65
- tmp_in, max_px=int(resolution),
66
- invert=invert, blur=blur, gamma=1.1,
67
- quantize=quantize, tileable=tileable, fade_px=12,
68
  )
69
- Path(tmp_in).unlink(missing_ok=True)
70
 
71
- preview = (height_map * 255).astype(np.uint8)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
- effective_base = 0.0 if no_base else float(base_mm)
 
 
 
 
 
 
 
 
74
 
75
- stl_bytes = heightmap_to_stl(
76
- height_map, px_w, px_h,
77
- tile_w_mm=float(tile_w),
78
- tile_h_mm=float(tile_h),
79
- relief_mm=float(relief_mm),
80
- base_mm=effective_base,
81
- )
82
 
83
- stl_tmp = tempfile.NamedTemporaryFile(
84
- suffix=".stl", delete=False, prefix="tile_"
85
- )
86
- stl_tmp.write(stl_bytes)
87
- stl_tmp.close()
88
-
89
- n_tri = (len(stl_bytes) - 84) // 50
90
- size_kb = len(stl_bytes) / 1024
91
- base_info = "nessuna" if no_base else f"{effective_base:.1f} mm"
92
-
93
- info = (
94
- f"✓ Tile generata\n\n"
95
- f"Dimensioni: {tile_w:.0f} × {tile_h:.0f} mm\n"
96
- f"Rilievo: {relief_mm:.1f} mm\n"
97
- f"Base: {base_info}\n"
98
- f"Mesh: {px_w{px_h}px {n_tri:,} triangoli\n"
99
- f"File: {size_kb:.0f} KB\n\n"
100
- f"Slicer: layer 0.15 mm · infill 15% · no supports"
101
- )
102
- return preview, stl_tmp.name, info
 
 
 
103
 
104
  except Exception as e:
105
  return None, None, f"❌ {e}"
106
 
107
 
108
- def toggle_custom(mode):
 
 
 
 
109
  return gr.update(visible=(mode == "Personalizzata"))
110
 
111
- def toggle_base_slider(no_base):
112
- return gr.update(visible=not no_base)
 
 
 
 
 
 
 
 
 
113
 
114
 
115
  # ─────────────────────────────────────────────
@@ -120,117 +181,27 @@ CSS = """
120
  @import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Mono:ital,wght@0,400;0,500;1,400&display=swap');
121
 
122
  body { background: #0a0a0a !important; }
123
-
124
  .gradio-container {
125
  background: #0a0a0a !important;
126
  font-family: 'DM Mono', monospace !important;
127
  max-width: 1100px !important;
128
  }
129
-
130
- /* ── HEADER ── */
131
- .pg-header {
132
- padding: 28px 0 22px;
133
- border-bottom: 1px solid #1e1e1e;
134
- margin-bottom: 4px;
135
- }
136
- .pg-num {
137
- font-family: 'DM Mono', monospace;
138
- font-size: 10px;
139
- letter-spacing: 0.2em;
140
- color: #333;
141
- margin-bottom: 6px;
142
- display: block;
143
- }
144
- .pg-logo {
145
- font-family: 'Bebas Neue', sans-serif;
146
- font-size: 56px;
147
- letter-spacing: 0.1em;
148
- color: #f2efe8;
149
- line-height: 1;
150
- display: block;
151
- }
152
  .pg-logo .accent { color: #d4a912; }
153
- .pg-sub {
154
- font-family: 'DM Mono', monospace;
155
- font-size: 9px;
156
- letter-spacing: 0.22em;
157
- color: #444;
158
- text-transform: uppercase;
159
- margin-top: 8px;
160
- display: block;
161
- }
162
- .pg-rule {
163
- width: 100%;
164
- height: 1px;
165
- background: #1e1e1e;
166
- margin-top: 20px;
167
- }
168
-
169
- /* ── SECTION LABELS ── */
170
- .gradio-container h2,
171
- .section-label {
172
- font-family: 'DM Mono', monospace !important;
173
- font-size: 9px !important;
174
- letter-spacing: 0.2em !important;
175
- color: #d4a912 !important;
176
- text-transform: uppercase !important;
177
- border-bottom: 1px solid #1e1e1e !important;
178
- padding-bottom: 6px !important;
179
- margin-top: 20px !important;
180
- font-weight: 400 !important;
181
- }
182
-
183
- /* ── BUTTONS ── */
184
- button.primary {
185
- background: #d4a912 !important;
186
- color: #0a0a0a !important;
187
- border: none !important;
188
- border-radius: 0 !important;
189
- font-family: 'DM Mono', monospace !important;
190
- text-transform: uppercase !important;
191
- letter-spacing: 0.1em !important;
192
- font-size: 0.7rem !important;
193
- font-weight: 500 !important;
194
- }
195
- button.secondary {
196
- background: transparent !important;
197
- color: #555 !important;
198
- border: 1px solid #2a2a2a !important;
199
- border-radius: 0 !important;
200
- font-family: 'DM Mono', monospace !important;
201
- text-transform: uppercase !important;
202
- letter-spacing: 0.1em !important;
203
- font-size: 0.7rem !important;
204
- }
205
  button:hover { opacity: 0.85 !important; }
206
-
207
- /* ── INPUTS ── */
208
- label span {
209
- font-family: 'DM Mono', monospace !important;
210
- font-size: 10px !important;
211
- letter-spacing: 0.1em !important;
212
- color: #666 !important;
213
- text-transform: uppercase !important;
214
- }
215
  input[type=range] { accent-color: #d4a912 !important; }
216
  input[type=checkbox] { accent-color: #d4a912 !important; }
217
- textarea {
218
- background: #0d0d0d !important;
219
- border: 1px solid #1e1e1e !important;
220
- color: #888 !important;
221
- font-family: 'DM Mono', monospace !important;
222
- font-size: 12px !important;
223
- border-radius: 0 !important;
224
- }
225
-
226
- /* ── GUIDE ── */
227
- .guide {
228
- font-family: 'DM Mono', monospace;
229
- font-size: 11px;
230
- line-height: 1.9;
231
- color: #555;
232
- margin-top: 16px;
233
- }
234
  .guide strong { color: #888; font-weight: 500; }
235
  """
236
 
@@ -245,13 +216,12 @@ HEADER_HTML = """
245
 
246
  GUIDE_HTML = """
247
  <div class="guide">
248
- <strong>bianco = alto, nero = basso</strong> (o inverso con Invert).<br>
249
- Controlla sempre la depth map prima di generare l'STL.<br><br>
250
- <strong>Quantize</strong> — divide il rilievo in N livelli netti.<br>
251
- Ottimo per pattern urbani: sanpietrini, griglia, muratura.<br><br>
252
- <strong>Tileable</strong> — sfuma i bordi per affiancamento senza giunti.<br><br>
253
- <strong>Senza base</strong> mesh aperta sotto: utile se vuoi montare la tile<br>
254
- su un supporto o se la stampi su un piano già livellato.<br><br>
255
  <strong>Slicer:</strong> layer 0.15 mm · infill 15% · no supports · ironing top
256
  </div>
257
  """
@@ -278,9 +248,21 @@ with gr.Blocks(css=CSS, title="Pattern Ground") as demo:
278
  relief_mm = gr.Slider(1, 10, value=4, step=0.5, label="Altezza rilievo (mm)")
279
 
280
  gr.Markdown("## Base")
281
- no_base = gr.Checkbox(label="Senza base (mesh aperta sotto)", value=False)
282
  base_mm = gr.Slider(0.5, 5, value=2, step=0.5, label="Spessore base (mm)")
283
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  gr.Markdown("## Processing")
285
  quantize = gr.Slider(0, 12, value=0, step=1,
286
  label="Quantize — livelli (0=continuo, 4–6=gradini)")
@@ -301,23 +283,31 @@ with gr.Blocks(css=CSS, title="Pattern Ground") as demo:
301
  gr.Markdown("## Depth map")
302
  depth_out = gr.Image(label="Bianco = alto · Nero = basso",
303
  type="numpy", interactive=False)
304
-
305
  gr.Markdown("## Download")
306
  stl_out = gr.File(label="STL file")
307
-
308
  gr.Markdown("## Info")
309
  info_out = gr.Textbox(label="", lines=8, interactive=False)
310
-
311
  gr.HTML(GUIDE_HTML)
312
 
313
- # Events
314
- tile_h_mode.change(toggle_custom, tile_h_mode, tile_h_custom)
315
- no_base.change(toggle_base_slider, no_base, base_mm)
 
 
 
 
 
 
 
 
 
 
316
 
317
- preview_inputs = [image_in, invert, quantize, tileable, blur]
 
318
  gen_inputs = [
319
  image_in, tile_w, tile_h_mode, tile_h_custom,
320
- relief_mm, base_mm, no_base,
321
  invert, quantize, tileable, blur, resolution,
322
  ]
323
 
 
11
  import numpy as np
12
  from PIL import Image
13
 
14
+ from relief_workshop import (
15
+ build_heightmap, heightmap_to_stl,
16
+ blobs_to_stl, blob_preview,
17
+ )
18
 
19
 
20
  # ─────────────────────────────────────────────
21
  # CORE
22
  # ─────────────────────────────────────────────
23
 
24
+ def preview_depthmap(image, invert, quantize_levels, tileable, blur,
25
+ no_base, separate_blobs, threshold):
26
  if image is None:
27
  return None, "⚠️ Carica un'immagine."
28
  try:
29
  with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
30
  tmp = f.name
31
  Image.fromarray(image).save(tmp)
32
+
33
+ if no_base and separate_blobs:
34
+ # Blob preview: mostra i contorni trovati
35
+ prev, n = blob_preview(tmp, threshold=int(threshold))
36
+ Path(tmp).unlink(missing_ok=True)
37
+ msg = f"{n} blob trovati — ognuno diventerà un cilindretto separato"
38
+ if n == 0:
39
+ msg = "Nessun blob trovato. Prova ad abbassare la soglia o usare un'immagine con forme bianche su nero."
40
+ return prev, msg
41
+ else:
42
+ # Heightmap preview normale
43
+ quantize = int(quantize_levels) if quantize_levels >= 2 else 0
44
+ height_map, px_w, px_h = build_heightmap(
45
+ tmp, max_px=300, invert=invert, blur=blur, gamma=1.1,
46
+ quantize=quantize, tileable=tileable, fade_px=12,
47
+ )
48
+ Path(tmp).unlink(missing_ok=True)
49
+ preview = (height_map * 255).astype(np.uint8)
50
+ msg = f"Depth map {px_w}×{px_h}px — bianco=alto, nero=basso"
51
+ if invert:
52
+ msg += " (invertita)"
53
+ return preview, msg
54
  except Exception as e:
55
  return None, f"❌ {e}"
56
 
57
 
58
  def generate_stl(
59
  image, tile_w, tile_h_mode, tile_h_custom,
60
+ relief_mm, base_mm, no_base, separate_blobs, threshold,
61
  invert, quantize_levels, tileable, blur, resolution,
62
  ):
63
  if image is None:
 
75
  else:
76
  tile_h = tile_h_custom
77
 
78
+ stl_tmp = tempfile.NamedTemporaryFile(
79
+ suffix=".stl", delete=False, prefix="tile_"
 
 
 
80
  )
 
81
 
82
+ if no_base and separate_blobs:
83
+ # ── Pipeline blob ──
84
+ stl_bytes, n_blobs = blobs_to_stl(
85
+ tmp_in,
86
+ tile_w_mm=float(tile_w),
87
+ tile_h_mm=float(tile_h),
88
+ height_mm=float(relief_mm),
89
+ threshold=int(threshold),
90
+ )
91
+ stl_tmp.write(stl_bytes)
92
+ stl_tmp.close()
93
+ Path(tmp_in).unlink(missing_ok=True)
94
+
95
+ # Preview: contorni dei blob sull'immagine originale
96
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f2:
97
+ tmp2 = f2.name
98
+ Image.fromarray(image).save(tmp2)
99
+ preview, n_found = blob_preview(tmp2, threshold=int(threshold))
100
+ Path(tmp2).unlink(missing_ok=True)
101
+
102
+ n_tri = (len(stl_bytes) - 84) // 50
103
+ size_kb = len(stl_bytes) / 1024
104
+ info = (
105
+ f"✓ {n_blobs} cilindretti separati\n\n"
106
+ f"Dimensioni tile: {tile_w:.0f} × {tile_h:.0f} mm\n"
107
+ f"Altezza: {relief_mm:.1f} mm + base piatta\n"
108
+ f"Soglia blob: {threshold}\n"
109
+ f"Mesh: {n_tri:,} triangoli totali\n"
110
+ f"File: {size_kb:.0f} KB\n\n"
111
+ f"Slicer: layer 0.15 mm · infill 15% · no supports"
112
+ )
113
+ return preview, stl_tmp.name, info
114
 
115
+ else:
116
+ # ── Pipeline heightmap ──
117
+ quantize = int(quantize_levels) if quantize_levels >= 2 else 0
118
+ height_map, px_w, px_h = build_heightmap(
119
+ tmp_in, max_px=int(resolution),
120
+ invert=invert, blur=blur, gamma=1.1,
121
+ quantize=quantize, tileable=tileable, fade_px=12,
122
+ )
123
+ Path(tmp_in).unlink(missing_ok=True)
124
 
125
+ preview = (height_map * 255).astype(np.uint8)
126
+ effective_base = 0.0 if no_base else float(base_mm)
 
 
 
 
 
127
 
128
+ stl_bytes = heightmap_to_stl(
129
+ height_map, px_w, px_h,
130
+ tile_w_mm=float(tile_w),
131
+ tile_h_mm=float(tile_h),
132
+ relief_mm=float(relief_mm),
133
+ base_mm=effective_base,
134
+ )
135
+ stl_tmp.write(stl_bytes)
136
+ stl_tmp.close()
137
+
138
+ n_tri = (len(stl_bytes) - 84) // 50
139
+ size_kb = len(stl_bytes) / 1024
140
+ base_info = "nessuna (guscio aperto)" if no_base else f"{effective_base:.1f} mm"
141
+ info = (
142
+ f"✓ Tile generata\n\n"
143
+ f"Dimensioni: {tile_w:.0f} × {tile_h:.0f} mm\n"
144
+ f"Rilievo: {relief_mm:.1f} mm\n"
145
+ f"Base: {base_info}\n"
146
+ f"Mesh: {px_w}×{px_h}px — {n_tri:,} triangoli\n"
147
+ f"File: {size_kb:.0f} KB\n\n"
148
+ f"Slicer: layer 0.15 mm · infill 15% · no supports"
149
+ )
150
+ return preview, stl_tmp.name, info
151
 
152
  except Exception as e:
153
  return None, None, f"❌ {e}"
154
 
155
 
156
+ # ─────────────────────────────────────────────
157
+ # UI CALLBACKS
158
+ # ─────────────────────────────────────────────
159
+
160
+ def toggle_custom_height(mode):
161
  return gr.update(visible=(mode == "Personalizzata"))
162
 
163
+ def toggle_base_options(no_base):
164
+ """Quando si spunta 'Senza base', mostra slider base (se non separato) e opzione blob."""
165
+ return (
166
+ gr.update(visible=not no_base), # base_mm slider
167
+ gr.update(visible=no_base), # separate_blobs checkbox
168
+ )
169
+
170
+ def toggle_blob_options(no_base, separate_blobs):
171
+ """Mostra il threshold solo se entrambe le opzioni sono attive."""
172
+ show = no_base and separate_blobs
173
+ return gr.update(visible=show)
174
 
175
 
176
  # ─────────────────────────────────────────────
 
181
  @import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Mono:ital,wght@0,400;0,500;1,400&display=swap');
182
 
183
  body { background: #0a0a0a !important; }
 
184
  .gradio-container {
185
  background: #0a0a0a !important;
186
  font-family: 'DM Mono', monospace !important;
187
  max-width: 1100px !important;
188
  }
189
+ .pg-header { padding: 28px 0 22px; border-bottom: 1px solid #1e1e1e; margin-bottom: 4px; }
190
+ .pg-num { font-family: 'DM Mono', monospace; font-size: 10px; letter-spacing: 0.2em; color: #333; margin-bottom: 6px; display: block; }
191
+ .pg-logo { font-family: 'Bebas Neue', sans-serif; font-size: 56px; letter-spacing: 0.1em; color: #f2efe8; line-height: 1; display: block; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  .pg-logo .accent { color: #d4a912; }
193
+ .pg-sub { font-family: 'DM Mono', monospace; font-size: 9px; letter-spacing: 0.22em; color: #444; text-transform: uppercase; margin-top: 8px; display: block; }
194
+ .pg-rule { width: 100%; height: 1px; background: #1e1e1e; margin-top: 20px; }
195
+ .gradio-container h2 { font-family: 'DM Mono', monospace !important; font-size: 9px !important; letter-spacing: 0.2em !important; color: #d4a912 !important; text-transform: uppercase !important; border-bottom: 1px solid #1e1e1e !important; padding-bottom: 6px !important; margin-top: 20px !important; font-weight: 400 !important; }
196
+ button.primary { background: #d4a912 !important; color: #0a0a0a !important; border: none !important; border-radius: 0 !important; font-family: 'DM Mono', monospace !important; text-transform: uppercase !important; letter-spacing: 0.1em !important; font-size: 0.7rem !important; font-weight: 500 !important; }
197
+ button.secondary { background: transparent !important; color: #555 !important; border: 1px solid #2a2a2a !important; border-radius: 0 !important; font-family: 'DM Mono', monospace !important; text-transform: uppercase !important; letter-spacing: 0.1em !important; font-size: 0.7rem !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  button:hover { opacity: 0.85 !important; }
199
+ label span { font-family: 'DM Mono', monospace !important; font-size: 10px !important; letter-spacing: 0.1em !important; color: #666 !important; text-transform: uppercase !important; }
 
 
 
 
 
 
 
 
200
  input[type=range] { accent-color: #d4a912 !important; }
201
  input[type=checkbox] { accent-color: #d4a912 !important; }
202
+ textarea { background: #0d0d0d !important; border: 1px solid #1e1e1e !important; color: #888 !important; font-family: 'DM Mono', monospace !important; font-size: 12px !important; border-radius: 0 !important; }
203
+ .blob-box { border: 1px solid #2a1a00; background: #0f0900; border-radius: 0; padding: 10px 12px; margin-top: 4px; }
204
+ .guide { font-family: 'DM Mono', monospace; font-size: 11px; line-height: 1.9; color: #555; margin-top: 16px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  .guide strong { color: #888; font-weight: 500; }
206
  """
207
 
 
216
 
217
  GUIDE_HTML = """
218
  <div class="guide">
219
+ <strong>bianco = alto, nero = basso</strong> controlla la depth map prima di generare.<br><br>
220
+ <strong>Quantize</strong> livelli netti invece di gradiente continuo.<br>
221
+ <strong>Tileable</strong> — bordi sfumati per affiancamento senza giunti.<br>
222
+ <strong>Senza base</strong> guscio aperto (utile su supporto esterno).<br>
223
+ <strong>Separa i blob</strong> — ogni forma bianca diventa un cilindretto<br>
224
+ &nbsp;&nbsp;fisicamente separato. Ideal per pois, lettere, pattern binari.<br><br>
 
225
  <strong>Slicer:</strong> layer 0.15 mm · infill 15% · no supports · ironing top
226
  </div>
227
  """
 
248
  relief_mm = gr.Slider(1, 10, value=4, step=0.5, label="Altezza rilievo (mm)")
249
 
250
  gr.Markdown("## Base")
251
+ no_base = gr.Checkbox(label="Senza base (guscio aperto)", value=False)
252
  base_mm = gr.Slider(0.5, 5, value=2, step=0.5, label="Spessore base (mm)")
253
 
254
+ # Opzioni blob — visibili solo con "Senza base" attivo
255
+ with gr.Group(visible=False, elem_classes=["blob-box"]) as blob_group:
256
+ separate_blobs = gr.Checkbox(
257
+ label="Separa i blob bianchi (ogni forma = cilindretto separato)",
258
+ value=False,
259
+ )
260
+ threshold = gr.Slider(
261
+ 0, 255, value=127, step=1,
262
+ label="Soglia binaria (0–255, default 127)",
263
+ visible=False,
264
+ )
265
+
266
  gr.Markdown("## Processing")
267
  quantize = gr.Slider(0, 12, value=0, step=1,
268
  label="Quantize — livelli (0=continuo, 4–6=gradini)")
 
283
  gr.Markdown("## Depth map")
284
  depth_out = gr.Image(label="Bianco = alto · Nero = basso",
285
  type="numpy", interactive=False)
 
286
  gr.Markdown("## Download")
287
  stl_out = gr.File(label="STL file")
 
288
  gr.Markdown("## Info")
289
  info_out = gr.Textbox(label="", lines=8, interactive=False)
 
290
  gr.HTML(GUIDE_HTML)
291
 
292
+ # ── Events ──
293
+ tile_h_mode.change(toggle_custom_height, tile_h_mode, tile_h_custom)
294
+
295
+ def on_no_base(val):
296
+ base_vis, blob_vis = toggle_base_options(val)
297
+ return base_vis, blob_vis
298
+
299
+ no_base.change(on_no_base, no_base, [base_mm, blob_group])
300
+
301
+ def on_blob_toggle(no_base_val, sep_val):
302
+ return toggle_blob_options(no_base_val, sep_val)
303
+
304
+ separate_blobs.change(on_blob_toggle, [no_base, separate_blobs], threshold)
305
 
306
+ preview_inputs = [image_in, invert, quantize, tileable, blur,
307
+ no_base, separate_blobs, threshold]
308
  gen_inputs = [
309
  image_in, tile_w, tile_h_mode, tile_h_custom,
310
+ relief_mm, base_mm, no_base, separate_blobs, threshold,
311
  invert, quantize, tileable, blur, resolution,
312
  ]
313
 
relief_workshop.py CHANGED
@@ -238,3 +238,147 @@ def heightmap_to_stl(
238
 
239
  def save_depth_preview(height_map: np.ndarray, out_path: str):
240
  Image.fromarray((height_map * 255).astype(np.uint8)).save(out_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
 
239
  def save_depth_preview(height_map: np.ndarray, out_path: str):
240
  Image.fromarray((height_map * 255).astype(np.uint8)).save(out_path)
241
+
242
+
243
+ # ─────────────────────────────────────────────
244
+ # BLOB PIPELINE (pezzi separati)
245
+ # ─────────────────────────────────────────────
246
+
247
+ def _contour_to_stl(contour, height_mm: float) -> bytes:
248
+ """
249
+ Converte un singolo contorno OpenCV in un solido STL chiuso
250
+ (cilindretto: cappello superiore + base piatta + pareti laterali).
251
+ Il contorno è in coordinate pixel; scaliamo dopo.
252
+ """
253
+ import struct
254
+ pts = contour.reshape(-1, 2).astype(float)
255
+ n = len(pts)
256
+ if n < 3:
257
+ return b""
258
+
259
+ # Centra e normalizza — scala 1 px = 1 unità, poi il chiamante ri-scala
260
+ # Triangolazione fan dalla centroide per cappello e base
261
+ cx, cy = pts[:, 0].mean(), pts[:, 1].mean()
262
+
263
+ def pack_tri(normal, v1, v2, v3):
264
+ return struct.pack("<ffffffffffffH", *normal, *v1, *v2, *v3, 0)
265
+
266
+ tris = []
267
+ # Cappello superiore (z = height_mm) — fan dalla centroide
268
+ for i in range(n):
269
+ a = pts[i]
270
+ b = pts[(i + 1) % n]
271
+ v0 = (cx, cy, height_mm)
272
+ v1 = (a[0], a[1], height_mm)
273
+ v2 = (b[0], b[1], height_mm)
274
+ e1 = (v1[0]-v0[0], v1[1]-v0[1], 0)
275
+ e2 = (v2[0]-v0[0], v2[1]-v0[1], 0)
276
+ nx = e1[1]*e2[2] - e1[2]*e2[1]
277
+ ny = e1[2]*e2[0] - e1[0]*e2[2]
278
+ nz = e1[0]*e2[1] - e1[1]*e2[0]
279
+ ln = (nx**2+ny**2+nz**2)**0.5 + 1e-12
280
+ tris.append(pack_tri((nx/ln, ny/ln, nz/ln), v0, v1, v2))
281
+
282
+ # Base inferiore (z = 0) — fan inverso
283
+ for i in range(n):
284
+ a = pts[i]
285
+ b = pts[(i + 1) % n]
286
+ v0 = (cx, cy, 0)
287
+ v1 = (b[0], b[1], 0)
288
+ v2 = (a[0], a[1], 0)
289
+ tris.append(pack_tri((0, 0, -1), v0, v1, v2))
290
+
291
+ # Pareti laterali
292
+ for i in range(n):
293
+ a = pts[i]
294
+ b = pts[(i + 1) % n]
295
+ # due triangoli per ogni segmento
296
+ v00 = (a[0], a[1], 0)
297
+ v10 = (b[0], b[1], 0)
298
+ v01 = (a[0], a[1], height_mm)
299
+ v11 = (b[0], b[1], height_mm)
300
+ # normale laterale approssimata
301
+ dx, dy = b[0]-a[0], b[1]-a[1]
302
+ ln = (dx**2+dy**2)**0.5 + 1e-12
303
+ nx, ny = dy/ln, -dx/ln
304
+ tris.append(pack_tri((nx, ny, 0), v00, v10, v01))
305
+ tris.append(pack_tri((nx, ny, 0), v10, v11, v01))
306
+
307
+ header = b"\x00" * 80
308
+ count = struct.pack("<I", len(tris))
309
+ return header + count + b"".join(tris)
310
+
311
+
312
+ def blobs_to_stl(
313
+ image_path: str,
314
+ tile_w_mm: float,
315
+ tile_h_mm: float,
316
+ height_mm: float,
317
+ threshold: int = 127,
318
+ min_area_px: int = 20,
319
+ ) -> tuple[bytes, int]:
320
+ """
321
+ Pipeline blob: soglia binaria → findContours → un cilindretto per blob.
322
+ Restituisce (stl_bytes, n_blobs).
323
+ Tutti i cilindretti sono nella stessa mesh (un solo file STL).
324
+ """
325
+ img = Image.open(image_path).convert("L")
326
+ arr = np.array(img, dtype=np.uint8)
327
+
328
+ # Scala pixel → mm
329
+ scale_x = tile_w_mm / arr.shape[1]
330
+ scale_y = tile_h_mm / arr.shape[0]
331
+
332
+ # Soglia binaria
333
+ _, binary = cv2.threshold(arr, threshold, 255, cv2.THRESH_BINARY)
334
+
335
+ # Trova contorni esterni
336
+ contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
337
+
338
+ all_tris = []
339
+ n_blobs = 0
340
+
341
+ for cnt in contours:
342
+ area = cv2.contourArea(cnt)
343
+ if area < min_area_px:
344
+ continue
345
+
346
+ # Semplifica il contorno per ridurre la mesh
347
+ eps = 0.01 * cv2.arcLength(cnt, True)
348
+ cnt = cv2.approxPolyDP(cnt, eps, True)
349
+ pts = cnt.reshape(-1, 2).astype(float)
350
+ if len(pts) < 3:
351
+ continue
352
+
353
+ # Converti pixel → mm (y invertita: OpenCV ha y verso il basso)
354
+ pts[:, 0] *= scale_x
355
+ pts[:, 1] = tile_h_mm - pts[:, 1] * scale_y
356
+
357
+ # Genera STL del singolo blob
358
+ blob_bytes = _contour_to_stl(pts.reshape(-1, 1, 2), height_mm)
359
+ if blob_bytes:
360
+ # Estrai solo i triangoli (salta header 84 byte)
361
+ n_tri = (len(blob_bytes) - 84) // 50
362
+ all_tris.append(blob_bytes[84:])
363
+ n_blobs += 1
364
+
365
+ if not all_tris:
366
+ raise ValueError("Nessun blob trovato. Prova ad abbassare la soglia o caricare un'immagine con forme bianche su sfondo nero.")
367
+
368
+ import struct
369
+ total_tris = sum(len(t) // 50 for t in all_tris)
370
+ header = b"\x00" * 80
371
+ count = struct.pack("<I", total_tris)
372
+ return header + count + b"".join(all_tris), n_blobs
373
+
374
+
375
+ def blob_preview(image_path: str, threshold: int = 127, min_area_px: int = 20) -> np.ndarray:
376
+ """Restituisce un'immagine con i contorni trovati disegnati sopra l'originale."""
377
+ img = Image.open(image_path).convert("L")
378
+ arr = np.array(img, dtype=np.uint8)
379
+ _, binary = cv2.threshold(arr, threshold, 255, cv2.THRESH_BINARY)
380
+ contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
381
+ preview = cv2.cvtColor(arr, cv2.COLOR_GRAY2RGB)
382
+ valid = [c for c in contours if cv2.contourArea(c) >= min_area_px]
383
+ cv2.drawContours(preview, valid, -1, (212, 169, 18), 2)
384
+ return preview, len(valid)