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

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +142 -76
  2. relief_workshop.py +126 -243
app.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
  app.py — Pattern Ground Relief Generator
3
- Gradio 5.x — compatibile Python 3.13 — HuggingFace Spaces
4
  """
5
 
6
  import tempfile
@@ -15,7 +15,7 @@ from relief_workshop import build_heightmap, heightmap_to_stl
15
 
16
 
17
  # ─────────────────────────────────────────────
18
- # CORE FUNCTIONS
19
  # ─────────────────────────────────────────────
20
 
21
  def preview_depthmap(image, invert, quantize_levels, tileable, blur):
@@ -25,28 +25,24 @@ def preview_depthmap(image, invert, quantize_levels, tileable, blur):
25
  with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
26
  tmp = f.name
27
  Image.fromarray(image).save(tmp)
28
-
29
  quantize = int(quantize_levels) if quantize_levels >= 2 else 0
30
  height_map, px_w, px_h = build_heightmap(
31
- tmp, max_px=300,
32
- invert=invert, blur=blur, gamma=1.1,
33
  quantize=quantize, tileable=tileable, fade_px=12,
34
  )
35
  Path(tmp).unlink(missing_ok=True)
36
-
37
  preview = (height_map * 255).astype(np.uint8)
38
- info = f"Depth map {px_w}×{px_h}px — bianco=alto, nero=basso"
39
  if invert:
40
- info += " (invertita)"
41
- return preview, info
42
  except Exception as e:
43
  return None, f"❌ {e}"
44
 
45
 
46
  def generate_stl(
47
- image,
48
- tile_w, tile_h_mode, tile_h_custom,
49
- relief_mm, base_mm,
50
  invert, quantize_levels, tileable, blur, resolution,
51
  ):
52
  if image is None:
@@ -72,19 +68,18 @@ def generate_stl(
72
  )
73
  Path(tmp_in).unlink(missing_ok=True)
74
 
75
- # Depth map preview
76
  preview = (height_map * 255).astype(np.uint8)
77
 
78
- # Generate STL bytes
 
79
  stl_bytes = heightmap_to_stl(
80
  height_map, px_w, px_h,
81
  tile_w_mm=float(tile_w),
82
  tile_h_mm=float(tile_h),
83
  relief_mm=float(relief_mm),
84
- base_mm=float(base_mm),
85
  )
86
 
87
- # Write to named temp file (Gradio 5 needs a real path)
88
  stl_tmp = tempfile.NamedTemporaryFile(
89
  suffix=".stl", delete=False, prefix="tile_"
90
  )
@@ -93,10 +88,13 @@ def generate_stl(
93
 
94
  n_tri = (len(stl_bytes) - 84) // 50
95
  size_kb = len(stl_bytes) / 1024
 
 
96
  info = (
97
  f"✓ Tile generata\n\n"
98
  f"Dimensioni: {tile_w:.0f} × {tile_h:.0f} mm\n"
99
- f"Altezza: {base_mm:.1f} base + {relief_mm:.1f} rilievo = {base_mm+relief_mm:.1f} mm\n"
 
100
  f"Mesh: {px_w}×{px_h}px — {n_tri:,} triangoli\n"
101
  f"File: {size_kb:.0f} KB\n\n"
102
  f"Slicer: layer 0.15 mm · infill 15% · no supports"
@@ -107,12 +105,19 @@ def generate_stl(
107
  return None, None, f"❌ {e}"
108
 
109
 
 
 
 
 
 
 
 
110
  # ─────────────────────────────────────────────
111
  # UI
112
  # ─────────────────────────────────────────────
113
 
114
  CSS = """
115
- @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Instrument+Serif:ital@0;1&display=swap');
116
 
117
  body { background: #0a0a0a !important; }
118
 
@@ -122,80 +127,139 @@ body { background: #0a0a0a !important; }
122
  max-width: 1100px !important;
123
  }
124
 
125
- h1 {
126
- font-family: 'Instrument Serif', serif !important;
127
- font-size: 2rem !important;
128
- color: #f2efe8 !important;
129
- font-weight: 400 !important;
130
- letter-spacing: -0.02em !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  }
132
 
133
- h2 {
 
 
134
  font-family: 'DM Mono', monospace !important;
135
- font-size: 0.6rem !important;
136
  letter-spacing: 0.2em !important;
137
  color: #d4a912 !important;
138
  text-transform: uppercase !important;
139
- border-bottom: 1px solid #222 !important;
140
- padding-bottom: 0.4rem !important;
141
- margin-top: 1.2rem !important;
142
- }
143
-
144
- .gr-button {
145
- font-family: 'DM Mono', monospace !important;
146
- text-transform: uppercase !important;
147
- letter-spacing: 0.08em !important;
148
- font-size: 0.72rem !important;
149
- border-radius: 0 !important;
150
  }
151
 
 
152
  button.primary {
153
  background: #d4a912 !important;
154
  color: #0a0a0a !important;
155
  border: none !important;
 
 
 
 
 
 
156
  }
157
-
158
  button.secondary {
159
  background: transparent !important;
160
- color: #888 !important;
161
- border: 1px solid #333 !important;
 
 
 
 
 
162
  }
163
- """
164
 
165
- HEADER = """
166
- # Pattern Ground
167
- **Fabrication Topologies Vectorealism × Dropcity · Design Week Milano 2026**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
- Converti un'immagine in una tile di rilievo 3D pronta per la stampa.
 
 
 
 
 
 
 
 
170
  """
171
 
172
- GUIDE = """
173
- **Come funziona**
174
-
175
- Ogni pixel dell'immagine diventa un'altezza: **bianco = alto, nero = basso** (o inverso con Invert).
176
- Controlla sempre la depth map prima di generare l'STL.
177
-
178
- **Quantize** — divide il rilievo in N livelli netti invece di un gradiente continuo.
179
- Ottimo per pattern urbani: sanpietrini, griglia, muratura. Prova 4–6 livelli.
180
-
181
- **Tileable** — sfuma i bordi così le tile si affiancano senza giunti visibili nella griglia.
182
-
183
- **Immagini che funzionano bene:** foto di pavimentazioni, mappe b/n di edifici, texture geometriche,
184
- pattern a contrasto alto. Evita foto a colori complesse senza preprocessarle in bianco/nero.
185
-
186
- **Slicer:** layer height 0.15 mm · infill 15% · no supports · ironing on top surface.
187
  """
188
 
189
-
190
- def toggle_custom(mode):
191
- return gr.update(visible=(mode == "Personalizzata"))
192
-
 
 
 
 
 
 
 
 
193
 
194
  with gr.Blocks(css=CSS, title="Pattern Ground") as demo:
195
- gr.Markdown(HEADER)
196
 
197
  with gr.Row():
198
- # ── Colonna sinistra ──
199
  with gr.Column(scale=1):
200
 
201
  gr.Markdown("## Immagine")
@@ -205,16 +269,17 @@ with gr.Blocks(css=CSS, title="Pattern Ground") as demo:
205
  tile_w = gr.Slider(20, 200, value=100, step=5, label="Larghezza (mm)")
206
  tile_h_mode = gr.Radio(
207
  ["Quadrata (uguale alla larghezza)", "Proporzioni originali", "Personalizzata"],
208
- value="Quadrata (uguale alla larghezza)",
209
- label="Altezza",
210
  )
211
  tile_h_custom = gr.Slider(20, 200, value=100, step=5,
212
  label="Altezza personalizzata (mm)", visible=False)
213
 
214
  gr.Markdown("## Rilievo")
215
- with gr.Row():
216
- relief_mm = gr.Slider(1, 10, value=4, step=0.5, label="Altezza rilievo (mm)")
217
- base_mm = gr.Slider(1, 5, value=2, step=0.5, label="Base (mm)")
 
 
218
 
219
  gr.Markdown("## Processing")
220
  quantize = gr.Slider(0, 12, value=0, step=1,
@@ -232,7 +297,6 @@ with gr.Blocks(css=CSS, title="Pattern Ground") as demo:
232
  preview_btn = gr.Button("Depth map preview", variant="secondary")
233
  gen_btn = gr.Button("Genera STL", variant="primary")
234
 
235
- # ── Colonna destra ──
236
  with gr.Column(scale=1):
237
  gr.Markdown("## Depth map")
238
  depth_out = gr.Image(label="Bianco = alto · Nero = basso",
@@ -242,17 +306,19 @@ with gr.Blocks(css=CSS, title="Pattern Ground") as demo:
242
  stl_out = gr.File(label="STL file")
243
 
244
  gr.Markdown("## Info")
245
- info_out = gr.Textbox(label="", lines=7, interactive=False)
246
 
247
- gr.Markdown(GUIDE)
248
 
249
- # ── Events ──
250
  tile_h_mode.change(toggle_custom, tile_h_mode, tile_h_custom)
 
251
 
252
  preview_inputs = [image_in, invert, quantize, tileable, blur]
253
  gen_inputs = [
254
  image_in, tile_w, tile_h_mode, tile_h_custom,
255
- relief_mm, base_mm, invert, quantize, tileable, blur, resolution,
 
256
  ]
257
 
258
  preview_btn.click(preview_depthmap, preview_inputs, [depth_out, info_out])
 
1
  """
2
  app.py — Pattern Ground Relief Generator
3
+ Gradio 5.x — Python 3.13 — HuggingFace Spaces
4
  """
5
 
6
  import tempfile
 
15
 
16
 
17
  # ─────────────────────────────────────────────
18
+ # CORE
19
  # ─────────────────────────────────────────────
20
 
21
  def preview_depthmap(image, invert, quantize_levels, tileable, blur):
 
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:
 
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
  )
 
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"
 
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
  # ─────────────────────────────────────────────
116
  # UI
117
  # ─────────────────────────────────────────────
118
 
119
  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
 
 
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
 
237
+ HEADER_HTML = """
238
+ <div class="pg-header">
239
+ <span class="pg-num">02 / Pattern Ground</span>
240
+ <span class="pg-logo">Pattern <span class="accent">Ground</span></span>
241
+ <span class="pg-sub">Fabrication Topologies · Vectorealism × Dropcity · Design Week Milano 2026</span>
242
+ <div class="pg-rule"></div>
243
+ </div>
 
 
 
 
 
 
 
 
244
  """
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
+ """
258
 
259
  with gr.Blocks(css=CSS, title="Pattern Ground") as demo:
260
+ gr.HTML(HEADER_HTML)
261
 
262
  with gr.Row():
 
263
  with gr.Column(scale=1):
264
 
265
  gr.Markdown("## Immagine")
 
269
  tile_w = gr.Slider(20, 200, value=100, step=5, label="Larghezza (mm)")
270
  tile_h_mode = gr.Radio(
271
  ["Quadrata (uguale alla larghezza)", "Proporzioni originali", "Personalizzata"],
272
+ value="Quadrata (uguale alla larghezza)", label="Altezza",
 
273
  )
274
  tile_h_custom = gr.Slider(20, 200, value=100, step=5,
275
  label="Altezza personalizzata (mm)", visible=False)
276
 
277
  gr.Markdown("## Rilievo")
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,
 
297
  preview_btn = gr.Button("Depth map preview", variant="secondary")
298
  gen_btn = gr.Button("Genera STL", variant="primary")
299
 
 
300
  with gr.Column(scale=1):
301
  gr.Markdown("## Depth map")
302
  depth_out = gr.Image(label="Bianco = alto · Nero = basso",
 
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
 
324
  preview_btn.click(preview_depthmap, preview_inputs, [depth_out, info_out])
relief_workshop.py CHANGED
@@ -1,18 +1,6 @@
1
  """
2
  relief_workshop.py
3
  Image → 3D relief tile — versione workshop Fabrication Topologies
4
-
5
- Uso:
6
- python relief_workshop.py input.png [opzioni]
7
-
8
- Esempi:
9
- python relief_workshop.py pattern.png
10
- python relief_workshop.py pattern.png --width 100 --height 100 --relief 4 --base 2
11
- python relief_workshop.py pattern.png --width 100 --height 100 --invert --tileable
12
- python relief_workshop.py pattern.png --width 100 --height 100 --relief 6 --quantize 4
13
-
14
- Dipendenze:
15
- pip install numpy pillow scipy opencv-python-headless
16
  """
17
 
18
  import argparse
@@ -25,64 +13,47 @@ import numpy as np
25
  from PIL import Image
26
 
27
 
28
- # ─────────────────────────────────────────────
29
- # PROCESSING
30
- # ─────────────────────────────────────────────
31
-
32
  def load_and_prepare(image_path: str, max_px: int) -> np.ndarray:
33
- """Carica l'immagine, converte in grigio, ridimensiona."""
34
  img = Image.open(image_path).convert("RGB")
35
  ratio = min(max_px / img.width, max_px / img.height)
36
  new_w = max(2, int(img.width * ratio))
37
  new_h = max(2, int(img.height * ratio))
38
  img = img.resize((new_w, new_h), Image.LANCZOS)
39
- gray = np.array(img.convert("L"), dtype=np.float32)
40
- return gray
41
 
42
 
43
  def smooth_heightmap(gray: np.ndarray, blur: float) -> np.ndarray:
44
- """Bilateral filter + smoothing leggero."""
45
  if blur <= 0:
46
  return gray
47
- sigma_color = min(60, blur * 15)
48
- sigma_space = min(10, blur * 3)
49
  result = cv2.bilateralFilter(
50
  gray.astype(np.uint8), d=9,
51
- sigmaColor=sigma_color,
52
- sigmaSpace=sigma_space
53
  ).astype(np.float32)
54
  return result
55
 
56
 
57
  def quantize_heightmap(gray: np.ndarray, levels: int) -> np.ndarray:
58
- """Quantizza i livelli di altezza: crea un effetto a gradini netti."""
59
  if levels < 2:
60
  return gray
61
- # Divide in N livelli e rimappa
62
  normalized = gray / 255.0
63
  quantized = np.floor(normalized * levels) / levels
64
  return (quantized * 255.0).astype(np.float32)
65
 
66
 
67
  def make_tileable(gray: np.ndarray, fade_px: int) -> np.ndarray:
68
- """
69
- Sfuma i bordi verso la media dell'immagine per permettere l'affiancamento
70
- senza scalini visibili ai giunti.
71
- """
72
  if fade_px <= 0:
73
  return gray
74
  h, w = gray.shape
75
  mean_val = float(np.mean(gray))
76
  result = gray.copy()
77
-
78
  fade = min(fade_px, w // 4, h // 4)
79
  for i in range(fade):
80
- alpha = i / fade # 0 al bordo → 1 all'interno
81
  result[i, :] = alpha * gray[i, :] + (1 - alpha) * mean_val
82
  result[h - 1 - i, :] = alpha * gray[h - 1 - i, :] + (1 - alpha) * mean_val
83
  result[:, i] = alpha * result[:, i] + (1 - alpha) * mean_val
84
  result[:, w - 1 - i] = alpha * result[:, w - 1 - i] + (1 - alpha) * mean_val
85
-
86
  return result
87
 
88
 
@@ -95,41 +66,23 @@ def build_heightmap(
95
  quantize: int = 0,
96
  tileable: bool = False,
97
  fade_px: int = 8,
98
- ) -> tuple[np.ndarray, int, int]:
99
- """Pipeline completa da immagine a heightmap normalizzata [0,1]."""
100
  gray = load_and_prepare(image_path, max_px)
101
  h, w = gray.shape
102
-
103
  gray = smooth_heightmap(gray, blur)
104
-
105
- # Gamma correction
106
  gray = np.power(gray / 255.0, gamma) * 255.0
107
-
108
  if quantize >= 2:
109
  gray = quantize_heightmap(gray, quantize)
110
-
111
  if tileable:
112
  gray = make_tileable(gray, fade_px)
113
-
114
  if invert:
115
  gray = 255.0 - gray
116
-
117
  gray = np.clip(gray, 0, 255)
 
118
 
119
- # Normalizza [0, 1]
120
- height_map = gray / 255.0
121
- return height_map, w, h
122
-
123
-
124
- # ─────────────────────────────────────────────
125
- # STL GENERATION
126
- # ─────────────────────────────────────────────
127
 
128
  def _pack_triangle(normal, v1, v2, v3) -> bytes:
129
- return struct.pack(
130
- "<ffffffffffffH",
131
- *normal, *v1, *v2, *v3, 0
132
- )
133
 
134
 
135
  def heightmap_to_stl(
@@ -142,102 +95,141 @@ def heightmap_to_stl(
142
  base_mm: float,
143
  ) -> bytes:
144
  """
145
- Genera un file STL binario da una heightmap normalizzata [0,1].
146
-
147
- La superficie superiore è il rilievo.
148
- La base è piatta a z=0 (superficie inferiore a z=-base_mm internamente,
149
- ma tutto shiftato così il bottom è a z=0 e il rilievo è in positivo).
150
  """
151
  pixel_x = tile_w_mm / (px_w - 1)
152
  pixel_y = tile_h_mm / (px_h - 1)
 
153
 
154
- # z assoluto di ogni pixel della superficie
155
- # base a z=0, rilievo sale fino a relief_mm
156
- def z(row, col):
157
  return base_mm + height_map[row, col] * relief_mm
158
 
 
 
 
 
 
 
159
  triangles = []
160
 
161
- # ── Superficie superiore (rilievo) ──
162
  for row in range(px_h - 1):
163
  for col in range(px_w - 1):
164
  x0, x1 = col * pixel_x, (col + 1) * pixel_x
165
- y0, y1 = (px_h - 1 - row) * pixel_y, (px_h - 2 - row) * pixel_y
166
-
167
- z00 = z(row, col)
168
- z10 = z(row, col + 1)
169
- z01 = z(row + 1, col)
170
- z11 = z(row + 1, col + 1)
171
-
172
- v00 = (x0, y0, z00)
173
- v10 = (x1, y0, z10)
174
- v01 = (x0, y1, z01)
175
- v11 = (x1, y1, z11)
176
-
177
- # Triangolo 1
178
- e1 = np.subtract(v10, v00)
179
- e2 = np.subtract(v01, v00)
180
- n = np.cross(e1, e2)
181
- nn = n / (np.linalg.norm(n) + 1e-12)
182
  triangles.append(_pack_triangle(nn, v00, v10, v01))
183
 
184
- # Triangolo 2
185
- e1 = np.subtract(v01, v11)
186
- e2 = np.subtract(v10, v11)
187
- n = np.cross(e1, e2)
188
- nn = n / (np.linalg.norm(n) + 1e-12)
189
  triangles.append(_pack_triangle(nn, v11, v01, v10))
190
 
191
- # ── Base inferiore (piatta a z=0) ──
192
- for row in range(px_h - 1):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  for col in range(px_w - 1):
194
  x0, x1 = col * pixel_x, (col + 1) * pixel_x
195
- y0, y1 = (px_h - 1 - row) * pixel_y, (px_h - 2 - row) * pixel_y
196
- n = (0.0, 0.0, -1.0)
197
- triangles.append(_pack_triangle(n, (x0, y0, 0), (x0, y1, 0), (x1, y0, 0)))
198
- triangles.append(_pack_triangle(n, (x1, y1, 0), (x1, y0, 0), (x0, y1, 0)))
199
-
200
- W = tile_w_mm
201
- H = tile_h_mm
202
-
203
- # ── Pareti laterali ──
204
- # Fronte (y=0)
205
- for col in range(px_w - 1):
206
- x0, x1 = col * pixel_x, (col + 1) * pixel_x
207
- z0_top = z(px_h - 1, col)
208
- z1_top = z(px_h - 1, col + 1)
209
- n = (0.0, -1.0, 0.0)
210
- triangles.append(_pack_triangle(n, (x0, 0, 0), (x1, 0, z1_top), (x0, 0, z0_top)))
211
- triangles.append(_pack_triangle(n, (x0, 0, 0), (x1, 0, 0), (x1, 0, z1_top)))
212
-
213
- # Retro (y=H)
214
- for col in range(px_w - 1):
215
- x0, x1 = col * pixel_x, (col + 1) * pixel_x
216
- z0_top = z(0, col)
217
- z1_top = z(0, col + 1)
218
- n = (0.0, 1.0, 0.0)
219
- triangles.append(_pack_triangle(n, (x0, H, z0_top), (x1, H, z1_top), (x0, H, 0)))
220
- triangles.append(_pack_triangle(n, (x1, H, z1_top), (x1, H, 0), (x0, H, 0)))
221
-
222
- # Sinistra (x=0)
223
- for row in range(px_h - 1):
224
- y0 = (px_h - 1 - row) * pixel_y
225
- y1 = (px_h - 2 - row) * pixel_y
226
- z0_top = z(row, 0)
227
- z1_top = z(row + 1, 0)
228
- n = (-1.0, 0.0, 0.0)
229
- triangles.append(_pack_triangle(n, (0, y0, z0_top), (0, y1, z1_top), (0, y0, 0)))
230
- triangles.append(_pack_triangle(n, (0, y1, 0), (0, y0, 0), (0, y1, z1_top)))
231
-
232
- # Destra (x=W)
233
- for row in range(px_h - 1):
234
- y0 = (px_h - 1 - row) * pixel_y
235
- y1 = (px_h - 2 - row) * pixel_y
236
- z0_top = z(row, px_w - 1)
237
- z1_top = z(row + 1, px_w - 1)
238
- n = (1.0, 0.0, 0.0)
239
- triangles.append(_pack_triangle(n, (W, y0, 0), (W, y1, z1_top), (W, y0, z0_top)))
240
- triangles.append(_pack_triangle(n, (W, y0, 0), (W, y1, 0), (W, y1, z1_top)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
  header = b"\x00" * 80
243
  count = struct.pack("<I", len(triangles))
@@ -245,113 +237,4 @@ def heightmap_to_stl(
245
 
246
 
247
  def save_depth_preview(height_map: np.ndarray, out_path: str):
248
- """Salva la depth map come PNG per verifica visiva."""
249
- preview = (height_map * 255).astype(np.uint8)
250
- Image.fromarray(preview).save(out_path)
251
-
252
-
253
- # ─────────────────────────────────────────────
254
- # CLI
255
- # ─────────────────────────────────────────────
256
-
257
- def main():
258
- parser = argparse.ArgumentParser(
259
- description="Image → 3D relief tile STL — Fabrication Topologies workshop",
260
- formatter_class=argparse.RawDescriptionHelpFormatter,
261
- epilog="""
262
- Parametri chiave:
263
- --width / --height dimensioni della tile in mm (default 100×100)
264
- --relief altezza massima del rilievo in mm (default 4)
265
- --base spessore della base piatta in mm (default 2)
266
- --invert inverte: scuro=alto invece di bianco=alto
267
- --quantize N quantizza in N livelli di altezza (effetto a gradini)
268
- es: --quantize 4 → 4 livelli netti, bello per pattern urbani
269
- --tileable sfuma i bordi per affiancamento senza giunti visibili
270
- --blur forza del smoothing (default 0.5, 0=nessuno, 2=molto)
271
- --resolution max pixel per lato (default 500, pi�� alto = più dettaglio ma più lento)
272
- --preview salva anche la depth map come PNG per verifica
273
- """,
274
- )
275
-
276
- parser.add_argument("input", help="Immagine di input (PNG, JPG, ...)")
277
- parser.add_argument("--output", "-o", help="File STL di output (default: input_relief.stl)")
278
- parser.add_argument("--width", type=float, default=100.0, help="Larghezza tile in mm (default 100)")
279
- parser.add_argument("--height", type=float, default=100.0, help="Altezza tile in mm (default 100, 0=proporzioni originali)")
280
- parser.add_argument("--relief", type=float, default=4.0, help="Spessore massimo rilievo in mm (default 4)")
281
- parser.add_argument("--base", type=float, default=2.0, help="Spessore base piatta in mm (default 2)")
282
- parser.add_argument("--invert", action="store_true", help="Inverte: scuro=alto, bianco=basso")
283
- parser.add_argument("--quantize", type=int, default=0, metavar="N", help="Quantizza in N livelli (0=continuo)")
284
- parser.add_argument("--tileable", action="store_true", help="Sfuma i bordi per affiancamento")
285
- parser.add_argument("--fade", type=int, default=12, help="Pixel di fade per --tileable (default 12)")
286
- parser.add_argument("--blur", type=float, default=0.5, help="Smoothing (0=nessuno, 0.5=default, 2=molto)")
287
- parser.add_argument("--gamma", type=float, default=1.1, help="Correzione contrasto (default 1.1)")
288
- parser.add_argument("--resolution", type=int, default=500, help="Max pixel per lato (default 500)")
289
- parser.add_argument("--preview", action="store_true", help="Salva depth map PNG per verifica")
290
-
291
- args = parser.parse_args()
292
-
293
- # Output path
294
- input_path = Path(args.input)
295
- if not input_path.exists():
296
- print(f"Errore: file non trovato: {args.input}")
297
- sys.exit(1)
298
-
299
- output_path = Path(args.output) if args.output else input_path.with_name(input_path.stem + "_relief.stl")
300
-
301
- print(f"Input: {input_path}")
302
- print(f"Output: {output_path}")
303
- print(f"Tile: {args.width:.1f} × {args.height:.1f} mm")
304
- print(f"Rilievo: {args.relief:.1f} mm | Base: {args.base:.1f} mm")
305
- print(f"Totale Z: {args.relief + args.base:.1f} mm")
306
- print(f"Opzioni: invert={args.invert} quantize={args.quantize} tileable={args.tileable}")
307
- print()
308
-
309
- # Build heightmap
310
- print("Elaborazione immagine...")
311
- height_map, px_w, px_h = build_heightmap(
312
- str(input_path),
313
- max_px=args.resolution,
314
- invert=args.invert,
315
- blur=args.blur,
316
- gamma=args.gamma,
317
- quantize=args.quantize,
318
- tileable=args.tileable,
319
- fade_px=args.fade,
320
- )
321
- print(f"Risoluzione mesh: {px_w} × {px_h} px ({px_w * px_h:,} vertici)")
322
-
323
- if args.preview:
324
- preview_path = input_path.with_name(input_path.stem + "_depthmap.png")
325
- save_depth_preview(height_map, str(preview_path))
326
- print(f"Depth map salvata: {preview_path}")
327
-
328
- # Calcola altezza tile se proporzioni originali
329
- tile_h = args.height if args.height > 0 else args.width * px_h / px_w
330
-
331
- # Generate STL
332
- print("Generazione STL...")
333
- stl_bytes = heightmap_to_stl(
334
- height_map, px_w, px_h,
335
- tile_w_mm=args.width,
336
- tile_h_mm=tile_h,
337
- relief_mm=args.relief,
338
- base_mm=args.base,
339
- )
340
-
341
- with open(output_path, "wb") as f:
342
- f.write(stl_bytes)
343
-
344
- size_kb = len(stl_bytes) / 1024
345
- print(f"\n✓ STL salvato: {output_path} ({size_kb:.0f} KB)")
346
- print(f" Triangoli: {(len(stl_bytes) - 84) // 50:,}")
347
-
348
- if size_kb > 30_000:
349
- print(" Avviso: file grande, considera --resolution 300 per ridurre le dimensioni")
350
-
351
- print()
352
- print("Impostazioni slicer consigliate:")
353
- print(" Layer height: 0.15 mm | Infill: 15% | No supports")
354
-
355
-
356
- if __name__ == "__main__":
357
- main()
 
1
  """
2
  relief_workshop.py
3
  Image → 3D relief tile — versione workshop Fabrication Topologies
 
 
 
 
 
 
 
 
 
 
 
 
4
  """
5
 
6
  import argparse
 
13
  from PIL import Image
14
 
15
 
 
 
 
 
16
  def load_and_prepare(image_path: str, max_px: int) -> np.ndarray:
 
17
  img = Image.open(image_path).convert("RGB")
18
  ratio = min(max_px / img.width, max_px / img.height)
19
  new_w = max(2, int(img.width * ratio))
20
  new_h = max(2, int(img.height * ratio))
21
  img = img.resize((new_w, new_h), Image.LANCZOS)
22
+ return np.array(img.convert("L"), dtype=np.float32)
 
23
 
24
 
25
  def smooth_heightmap(gray: np.ndarray, blur: float) -> np.ndarray:
 
26
  if blur <= 0:
27
  return gray
 
 
28
  result = cv2.bilateralFilter(
29
  gray.astype(np.uint8), d=9,
30
+ sigmaColor=min(60, blur * 15),
31
+ sigmaSpace=min(10, blur * 3)
32
  ).astype(np.float32)
33
  return result
34
 
35
 
36
  def quantize_heightmap(gray: np.ndarray, levels: int) -> np.ndarray:
 
37
  if levels < 2:
38
  return gray
 
39
  normalized = gray / 255.0
40
  quantized = np.floor(normalized * levels) / levels
41
  return (quantized * 255.0).astype(np.float32)
42
 
43
 
44
  def make_tileable(gray: np.ndarray, fade_px: int) -> np.ndarray:
 
 
 
 
45
  if fade_px <= 0:
46
  return gray
47
  h, w = gray.shape
48
  mean_val = float(np.mean(gray))
49
  result = gray.copy()
 
50
  fade = min(fade_px, w // 4, h // 4)
51
  for i in range(fade):
52
+ alpha = i / fade
53
  result[i, :] = alpha * gray[i, :] + (1 - alpha) * mean_val
54
  result[h - 1 - i, :] = alpha * gray[h - 1 - i, :] + (1 - alpha) * mean_val
55
  result[:, i] = alpha * result[:, i] + (1 - alpha) * mean_val
56
  result[:, w - 1 - i] = alpha * result[:, w - 1 - i] + (1 - alpha) * mean_val
 
57
  return result
58
 
59
 
 
66
  quantize: int = 0,
67
  tileable: bool = False,
68
  fade_px: int = 8,
69
+ ):
 
70
  gray = load_and_prepare(image_path, max_px)
71
  h, w = gray.shape
 
72
  gray = smooth_heightmap(gray, blur)
 
 
73
  gray = np.power(gray / 255.0, gamma) * 255.0
 
74
  if quantize >= 2:
75
  gray = quantize_heightmap(gray, quantize)
 
76
  if tileable:
77
  gray = make_tileable(gray, fade_px)
 
78
  if invert:
79
  gray = 255.0 - gray
 
80
  gray = np.clip(gray, 0, 255)
81
+ return gray / 255.0, w, h
82
 
 
 
 
 
 
 
 
 
83
 
84
  def _pack_triangle(normal, v1, v2, v3) -> bytes:
85
+ return struct.pack("<ffffffffffffH", *normal, *v1, *v2, *v3, 0)
 
 
 
86
 
87
 
88
  def heightmap_to_stl(
 
95
  base_mm: float,
96
  ) -> bytes:
97
  """
98
+ Genera STL da heightmap [0,1].
99
+ Se base_mm == 0: mesh aperta sotto (solo superficie + pareti che scendono
100
+ fino al punto più basso del rilievo, nessun piano inferiore).
101
+ Se base_mm > 0: solido chiuso con base piatta.
 
102
  """
103
  pixel_x = tile_w_mm / (px_w - 1)
104
  pixel_y = tile_h_mm / (px_h - 1)
105
+ no_base = (base_mm <= 0)
106
 
107
+ def z_surf(row, col):
108
+ # z della superficie di rilievo
 
109
  return base_mm + height_map[row, col] * relief_mm
110
 
111
+ def z_bottom(row, col):
112
+ # z del bordo inferiore: 0 se c'è la base, minimo locale se no
113
+ if no_base:
114
+ return height_map[row, col] * relief_mm # inizia dal rilievo stesso (min=0)
115
+ return 0.0
116
+
117
  triangles = []
118
 
119
+ # ── Superficie superiore ──
120
  for row in range(px_h - 1):
121
  for col in range(px_w - 1):
122
  x0, x1 = col * pixel_x, (col + 1) * pixel_x
123
+ y0 = (px_h - 1 - row) * pixel_y
124
+ y1 = (px_h - 2 - row) * pixel_y
125
+
126
+ z00 = z_surf(row, col)
127
+ z10 = z_surf(row, col + 1)
128
+ z01 = z_surf(row + 1, col)
129
+ z11 = z_surf(row + 1, col + 1)
130
+
131
+ v00, v10 = (x0, y0, z00), (x1, y0, z10)
132
+ v01, v11 = (x0, y1, z01), (x1, y1, z11)
133
+
134
+ e1 = np.subtract(v10, v00); e2 = np.subtract(v01, v00)
135
+ n = np.cross(e1, e2); nn = n / (np.linalg.norm(n) + 1e-12)
 
 
 
 
136
  triangles.append(_pack_triangle(nn, v00, v10, v01))
137
 
138
+ e1 = np.subtract(v01, v11); e2 = np.subtract(v10, v11)
139
+ n = np.cross(e1, e2); nn = n / (np.linalg.norm(n) + 1e-12)
 
 
 
140
  triangles.append(_pack_triangle(nn, v11, v01, v10))
141
 
142
+ # ── Base inferiore (solo se base_mm > 0) ──
143
+ if not no_base:
144
+ for row in range(px_h - 1):
145
+ for col in range(px_w - 1):
146
+ x0, x1 = col * pixel_x, (col + 1) * pixel_x
147
+ y0 = (px_h - 1 - row) * pixel_y
148
+ y1 = (px_h - 2 - row) * pixel_y
149
+ n = (0.0, 0.0, -1.0)
150
+ triangles.append(_pack_triangle(n, (x0,y0,0),(x0,y1,0),(x1,y0,0)))
151
+ triangles.append(_pack_triangle(n, (x1,y1,0),(x1,y0,0),(x0,y1,0)))
152
+
153
+ W, H = tile_w_mm, tile_h_mm
154
+
155
+ if no_base:
156
+ # Senza base: le pareti laterali collegano la superficie al suo stesso
157
+ # bordo inferiore (z = height_map * relief_mm, cioè parte da 0 al minimo)
158
+ # Fronte (y=0, row = px_h-1)
159
  for col in range(px_w - 1):
160
  x0, x1 = col * pixel_x, (col + 1) * pixel_x
161
+ zt0 = z_surf(px_h - 1, col); zt1 = z_surf(px_h - 1, col + 1)
162
+ zb0 = height_map[px_h-1, col] * relief_mm
163
+ zb1 = height_map[px_h-1, col+1] * relief_mm
164
+ n = (0.0, -1.0, 0.0)
165
+ triangles.append(_pack_triangle(n, (x0,0,zb0),(x1,0,zt1),(x0,0,zt0)))
166
+ triangles.append(_pack_triangle(n, (x0,0,zb0),(x1,0,zb1),(x1,0,zt1)))
167
+
168
+ # Retro (y=H, row = 0)
169
+ for col in range(px_w - 1):
170
+ x0, x1 = col * pixel_x, (col + 1) * pixel_x
171
+ zt0 = z_surf(0, col); zt1 = z_surf(0, col + 1)
172
+ zb0 = height_map[0, col] * relief_mm
173
+ zb1 = height_map[0, col+1] * relief_mm
174
+ n = (0.0, 1.0, 0.0)
175
+ triangles.append(_pack_triangle(n, (x0,H,zt0),(x1,H,zt1),(x0,H,zb0)))
176
+ triangles.append(_pack_triangle(n, (x1,H,zt1),(x1,H,zb1),(x0,H,zb0)))
177
+
178
+ # Sinistra (x=0, col=0)
179
+ for row in range(px_h - 1):
180
+ y0 = (px_h - 1 - row) * pixel_y
181
+ y1 = (px_h - 2 - row) * pixel_y
182
+ zt0 = z_surf(row, 0); zt1 = z_surf(row + 1, 0)
183
+ zb0 = height_map[row, 0] * relief_mm
184
+ zb1 = height_map[row+1, 0] * relief_mm
185
+ n = (-1.0, 0.0, 0.0)
186
+ triangles.append(_pack_triangle(n, (0,y0,zt0),(0,y1,zt1),(0,y0,zb0)))
187
+ triangles.append(_pack_triangle(n, (0,y1,zt1),(0,y1,zb1),(0,y0,zb0)))
188
+
189
+ # Destra (x=W, col=px_w-1)
190
+ for row in range(px_h - 1):
191
+ y0 = (px_h - 1 - row) * pixel_y
192
+ y1 = (px_h - 2 - row) * pixel_y
193
+ zt0 = z_surf(row, px_w-1); zt1 = z_surf(row+1, px_w-1)
194
+ zb0 = height_map[row, px_w-1] * relief_mm
195
+ zb1 = height_map[row+1, px_w-1] * relief_mm
196
+ n = (1.0, 0.0, 0.0)
197
+ triangles.append(_pack_triangle(n, (W,y0,zb0),(W,y1,zt1),(W,y0,zt0)))
198
+ triangles.append(_pack_triangle(n, (W,y0,zb0),(W,y1,zb1),(W,y1,zt1)))
199
+
200
+ else:
201
+ # Con base: pareti vanno da z=0 alla superficie
202
+ # Fronte
203
+ for col in range(px_w - 1):
204
+ x0, x1 = col * pixel_x, (col + 1) * pixel_x
205
+ zt0 = z_surf(px_h-1, col); zt1 = z_surf(px_h-1, col+1)
206
+ n = (0.0, -1.0, 0.0)
207
+ triangles.append(_pack_triangle(n,(x0,0,0),(x1,0,zt1),(x0,0,zt0)))
208
+ triangles.append(_pack_triangle(n,(x0,0,0),(x1,0,0),(x1,0,zt1)))
209
+
210
+ # Retro
211
+ for col in range(px_w - 1):
212
+ x0, x1 = col * pixel_x, (col + 1) * pixel_x
213
+ zt0 = z_surf(0, col); zt1 = z_surf(0, col+1)
214
+ n = (0.0, 1.0, 0.0)
215
+ triangles.append(_pack_triangle(n,(x0,H,zt0),(x1,H,zt1),(x0,H,0)))
216
+ triangles.append(_pack_triangle(n,(x1,H,zt1),(x1,H,0),(x0,H,0)))
217
+
218
+ # Sinistra
219
+ for row in range(px_h - 1):
220
+ y0 = (px_h-1-row)*pixel_y; y1 = (px_h-2-row)*pixel_y
221
+ zt0 = z_surf(row,0); zt1 = z_surf(row+1,0)
222
+ n = (-1.0, 0.0, 0.0)
223
+ triangles.append(_pack_triangle(n,(0,y0,zt0),(0,y1,zt1),(0,y0,0)))
224
+ triangles.append(_pack_triangle(n,(0,y1,0),(0,y0,0),(0,y1,zt1)))
225
+
226
+ # Destra
227
+ for row in range(px_h - 1):
228
+ y0 = (px_h-1-row)*pixel_y; y1 = (px_h-2-row)*pixel_y
229
+ zt0 = z_surf(row,px_w-1); zt1 = z_surf(row+1,px_w-1)
230
+ n = (1.0, 0.0, 0.0)
231
+ triangles.append(_pack_triangle(n,(W,y0,0),(W,y1,zt1),(W,y0,zt0)))
232
+ triangles.append(_pack_triangle(n,(W,y0,0),(W,y1,0),(W,y1,zt1)))
233
 
234
  header = b"\x00" * 80
235
  count = struct.pack("<I", len(triangles))
 
237
 
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)