mikhail-shevtsov commited on
Commit
3f8d8c1
·
1 Parent(s): 5f01861

feat: add QR code generation, animation, and UI improvements

Browse files

Add QR code generation to 3MF files.
Add animation support for 3MF generation.
Reorganize UI into tabs for front, back, base, settings.
Refactor overlay logic and add helper functions.
Update Dockerfile with a new command and copy examples.
Update requirements with new dependencies and updated versions.
Add example images to repository.

Dockerfile CHANGED
@@ -11,15 +11,16 @@ RUN apt-get update \
11
 
12
  FROM base AS developer
13
 
14
- ENV GRADIO_WATCH_DIRS="/app/src"
15
 
16
- CMD ["python3", "src/app.py"]
17
 
18
 
19
  FROM base AS executor
20
 
21
  WORKDIR /app
22
 
23
- COPY src/app.py src/3mf2mmuv3.py src/input.scad.j2 src/png23mf.py /app/
 
24
 
25
- CMD ["python3", "app.py"]
 
11
 
12
  FROM base AS developer
13
 
14
+ ENV GRADIO_ANALYTICS_ENABLED=False
15
 
16
+ CMD ["sh", "-c", "cd src; gradio app.py"]
17
 
18
 
19
  FROM base AS executor
20
 
21
  WORKDIR /app
22
 
23
+ COPY src /app/src
24
+ COPY examples /app/examples
25
 
26
+ CMD ["python3", "src/app.py"]
examples/back/jopa.png ADDED
examples/base/comb-simple.png ADDED
examples/front/rgb.png ADDED
requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
- gradio==6.9.0
2
- ruff==0.15.6
3
  scipy==1.17.1
4
  jinja2==3.1.6
 
 
1
+ gradio==6.10.0
2
+ ruff==0.15.8
3
  scipy==1.17.1
4
  jinja2==3.1.6
5
+ qrcode==8.2
src/.gitignore DELETED
@@ -1 +0,0 @@
1
- tmp/
 
 
src/animate.scad.j2 ADDED
@@ -0,0 +1 @@
 
 
1
+ rotate([0,$t*360,0]) import("{{ model_path }}", center=true);
src/app.py CHANGED
@@ -1,53 +1,180 @@
1
  #!/usr/bin/env python3
2
 
 
3
  import shutil
4
  import tempfile
 
5
  from pathlib import Path
6
 
7
  import gradio as gr
8
 
9
  from png23mf import (
10
- calculate_zoom_value,
11
  generate_3mf_file,
 
12
  overlay_images_core,
13
- update_height_from_width,
14
- update_width_from_height,
15
  )
16
 
 
 
17
  temp_dir = Path(tempfile.gettempdir())
18
 
19
 
20
- def overlay_images(
 
21
  base_img,
22
  overlay_img,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  x,
24
  y,
25
  rot,
26
  zoom,
27
  indexed_colors,
28
- base_thickness,
29
- overlay_thickness,
30
- width,
31
- height,
32
  use_common_colors,
33
  morph_size,
34
  resolution,
35
  ):
 
 
 
 
 
 
36
  base_rgb, masks, mask_colors = overlay_images_core(
37
- base_img,
38
- overlay_img,
39
- x,
40
- y,
41
- rot,
42
- zoom,
43
- indexed_colors,
44
- base_thickness,
45
- overlay_thickness,
46
- width,
47
- height,
48
- use_common_colors,
49
- morph_size,
50
- resolution,
51
  )
52
  if base_rgb is None:
53
  return None, None, None, None, gr.update(interactive=False)
@@ -70,7 +197,12 @@ def update_slider_ranges(base_img, overlay_img, resolution):
70
  default_width = round(100, 2)
71
  default_height = round(default_width * h / w, 2)
72
  if overlay_img is not None:
73
- zoom_val = calculate_zoom_value(base_img, overlay_img, 1.0, resolution)
 
 
 
 
 
74
  else:
75
  zoom_val = 1.0
76
  return (
@@ -85,7 +217,6 @@ def update_slider_ranges(base_img, overlay_img, resolution):
85
 
86
 
87
  def get_user_dir(req: gr.Request) -> str:
88
- """Create a unique directory for the user session."""
89
  session_hash = (
90
  str(req.session_hash) if req.session_hash is not None else "unknown"
91
  )
@@ -95,7 +226,6 @@ def get_user_dir(req: gr.Request) -> str:
95
 
96
 
97
  def delete_user_dir(req: gr.Request) -> None:
98
- """Delete the user directory when the session ends."""
99
  session_hash = (
100
  str(req.session_hash) if req.session_hash is not None else "unknown"
101
  )
@@ -104,206 +234,358 @@ def delete_user_dir(req: gr.Request) -> None:
104
  shutil.rmtree(user_dir)
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  with gr.Blocks() as demo:
108
  gr.Markdown(value="# PNG to scad/3mf")
109
- gr.Markdown(
110
- value="**Tip:** The base image must be black and white. "
111
- "Black is treated as transparent and White will be extruded."
112
- )
113
  with gr.Row():
114
  with gr.Column():
115
- with gr.Row():
116
- base_img = gr.Image(
117
- label="Base Image",
118
- type="pil",
119
- image_mode="RGB",
120
- )
121
- overlay_img = gr.Image(
122
- label="Overlay Image",
123
- type="pil",
124
- image_mode="RGBA",
125
- )
126
- with gr.Column():
127
- x_slider = gr.Slider(
128
- label="X (px)",
129
- minimum=-500,
130
- maximum=500,
131
- step=1,
132
- value=0,
133
- interactive=False,
134
- info="Overlay image X coordinate relative to base",
135
- )
136
- y_slider = gr.Slider(
137
- label="Y (px)",
138
- minimum=-500,
139
- maximum=500,
140
- step=1,
141
- value=0,
142
- interactive=False,
143
- info="Overlay image Y coordinate relative to base",
144
- )
145
- rot_slider = gr.Slider(
146
- label="Rotation (°)",
147
- minimum=-180,
148
- maximum=180,
149
- step=1,
150
- value=0,
151
- interactive=False,
152
- info="Rotate overlay image",
153
- )
154
- zoom_slider = gr.Slider(
155
- label="Zoom",
156
- minimum=0.01,
157
- maximum=10.0,
158
- step=0.01,
159
- value=1.0,
160
- interactive=False,
161
- info="Make overlay image bigger or smaller",
162
- )
163
- indexed_slider = gr.Slider(
164
- label="Indexed Colors",
165
- minimum=2,
166
- maximum=255,
167
- step=1,
168
- value=4,
169
- info="Overlay image colors will be quantized",
170
- )
171
- use_common_colors_checkbox = gr.Checkbox(
172
- label="Use most common colors for quantization",
173
- value=True,
174
- interactive=True,
175
- info="Best for the logo's.",
176
- )
177
- morphology_slider = gr.Slider(
178
- label="Morphology Size",
179
- minimum=1,
180
- maximum=20,
181
- step=1,
182
- value=1,
183
- info=(
184
- "Size of morphological opening/closing "
185
- "to remove small features"
186
- ),
187
- )
188
- resolution_dropdown = gr.Dropdown(
189
- label="Resolution (px)",
190
- choices=[512, 1024, 2048],
191
- value=512,
192
- interactive=True,
193
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  with gr.Column():
196
- out = gr.Image(label="Result")
197
- mask_gallery = gr.Gallery(label="Masks")
198
- base_thickness = gr.Number(
199
- label="Base Thickness (mm)", minimum=0, step=0.1, value=1.0
200
- )
201
- overlay_thickness = gr.Number(
202
- label="Overlay Thickness (mm)", minimum=0, step=0.1, value=0.5
203
- )
204
- width_slider = gr.Number(
205
- label="Width (mm)",
206
- minimum=0,
207
- step=0.01,
208
- value=100,
209
- interactive=True,
210
- )
211
- height_slider = gr.Number(
212
- label="Height (mm)",
213
- minimum=0,
214
- step=0.01,
215
- value=100,
216
- interactive=True,
217
- )
218
- use_aspect_ratio = gr.Checkbox(
219
- label="Use image aspect ratio (base/overlay)",
220
- value=True,
221
- interactive=True,
222
- )
223
- height_slider.input(
224
- fn=update_width_from_height,
225
- inputs=[
226
- height_slider,
227
- width_slider,
228
- use_aspect_ratio,
229
- base_img,
230
- overlay_img,
231
- ],
232
- outputs=[width_slider],
233
- )
234
- width_slider.input(
235
- fn=update_height_from_width,
236
- inputs=[
237
- width_slider,
238
- height_slider,
239
- use_aspect_ratio,
240
- base_img,
241
- overlay_img,
242
- ],
243
- outputs=[height_slider],
244
- )
245
- masks_state = gr.State()
246
- mask_colors_state = gr.State()
247
 
248
- generate_button = gr.Button("Generate scad/3mf", interactive=False)
249
- scad_file = gr.File(label="SCAD/3mf output")
 
 
 
250
 
251
- masks_state.change(
252
- fn=lambda masks: gr.update(interactive=bool(masks)),
253
- inputs=[masks_state],
254
- outputs=[generate_button],
255
- )
 
 
 
 
 
256
 
257
- base_img.change(
258
- fn=update_slider_ranges,
259
- inputs=[base_img, overlay_img, resolution_dropdown],
260
- outputs=[
 
261
  x_slider,
262
  y_slider,
263
  rot_slider,
264
  zoom_slider,
265
  indexed_slider,
266
- width_slider,
267
- height_slider,
 
 
 
 
 
 
 
 
268
  ],
269
  )
270
 
271
- base_img.change(
272
- fn=lambda img: (
273
- gr.update(interactive=True),
274
- gr.update(interactive=True),
275
- gr.update(interactive=True),
276
- ),
277
- inputs=base_img,
278
- outputs=[width_slider, height_slider, use_aspect_ratio],
279
- )
280
-
281
- overlay_img.change(
282
- fn=lambda img, base, current_zoom, res: (
283
- gr.update(interactive=bool(img)),
284
- gr.update(interactive=bool(img)),
285
- gr.update(interactive=bool(img)),
286
- gr.update(
287
- interactive=bool(img),
288
- value=calculate_zoom_value(base, img, current_zoom, res)
289
- if img
290
- else current_zoom,
291
- ),
292
- ),
293
- inputs=[overlay_img, base_img, zoom_slider, resolution_dropdown],
294
- outputs=[x_slider, y_slider, rot_slider, zoom_slider],
295
- )
296
-
297
- overlay_img.change(
298
- fn=update_height_from_width,
299
  inputs=[
300
- width_slider,
301
- height_slider,
302
- use_aspect_ratio,
303
  base_img,
304
- overlay_img,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  ],
306
- outputs=[height_slider],
307
  )
308
 
309
  for comp in [
@@ -313,41 +595,114 @@ with gr.Blocks() as demo:
313
  zoom_slider,
314
  indexed_slider,
315
  use_common_colors_checkbox,
316
- base_img,
317
- overlay_img,
318
  morphology_slider,
319
  resolution_dropdown,
320
  ]:
321
  comp.change(
322
- fn=overlay_images,
323
  inputs=[
324
  base_img,
325
- overlay_img,
326
  x_slider,
327
  y_slider,
328
  rot_slider,
329
  zoom_slider,
330
  indexed_slider,
331
- base_thickness,
332
- overlay_thickness,
333
- width_slider,
334
- height_slider,
335
  use_common_colors_checkbox,
336
  morphology_slider,
337
  resolution_dropdown,
338
  ],
339
  outputs=[
340
- out,
341
- mask_gallery,
342
- masks_state,
343
- mask_colors_state,
344
  generate_button,
345
  ],
346
  )
347
 
348
- resolution_dropdown.change(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  fn=update_slider_ranges,
350
- inputs=[base_img, overlay_img, resolution_dropdown],
351
  outputs=[
352
  x_slider,
353
  y_slider,
@@ -359,30 +714,147 @@ with gr.Blocks() as demo:
359
  ],
360
  )
361
 
362
- # Hidden component to hold the user directory path
363
- user_dir = gr.Text(visible=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
 
365
- # Set the user directory when the app loads
366
  demo.load(get_user_dir, outputs=user_dir)
 
367
 
368
  generate_button.click(
369
- fn=generate_3mf_file,
 
 
 
 
370
  inputs=[
371
  base_img,
372
- masks_state,
373
- mask_colors_state,
374
  width_slider,
375
  height_slider,
376
  base_thickness,
377
  overlay_thickness,
378
  resolution_dropdown,
 
 
 
 
379
  user_dir,
380
  ],
381
- outputs=[scad_file],
382
  )
383
 
384
- # Delete the user directory when the session ends
385
- demo.unload(delete_user_dir)
386
-
387
  if __name__ == "__main__":
388
  demo.launch(share=False, server_name="0.0.0.0")
 
1
  #!/usr/bin/env python3
2
 
3
+ import logging
4
  import shutil
5
  import tempfile
6
+ from functools import partial
7
  from pathlib import Path
8
 
9
  import gradio as gr
10
 
11
  from png23mf import (
12
+ flip_image_horizontally,
13
  generate_3mf_file,
14
+ generate_qrcode,
15
  overlay_images_core,
 
 
16
  )
17
 
18
+ logger = logging.getLogger(__name__)
19
+
20
  temp_dir = Path(tempfile.gettempdir())
21
 
22
 
23
+ def calculate_zoom_value(
24
+ *,
25
  base_img,
26
  overlay_img,
27
+ current_zoom,
28
+ resolution,
29
+ ):
30
+ """Calculate zoom to fit overlay on base image."""
31
+ logger.debug(f"Entering calculate_zoom_value with arguments: {locals()}")
32
+ if base_img is None or overlay_img is None:
33
+ result = current_zoom
34
+ logger.debug(f"Exiting calculate_zoom_value with result: {result!r}")
35
+ return result
36
+ base = base_img.convert(mode="RGBA")
37
+ w_base, h_base = base.size
38
+ if max(w_base, h_base) > resolution:
39
+ scale_factor = resolution / max(w_base, h_base)
40
+ w_base = int(w_base * scale_factor)
41
+ h_base = int(h_base * scale_factor)
42
+ base_longer = max(w_base, h_base)
43
+ w_overlay, h_overlay = overlay_img.size
44
+ overlay_longer = max(w_overlay, h_overlay)
45
+ if overlay_longer > base_longer:
46
+ zoom = round(base_longer / overlay_longer, 2)
47
+ zoom = max(0.01, min(10.0, zoom))
48
+ result = zoom
49
+ logger.debug(f"Exiting calculate_zoom_value with result: {result!r}")
50
+ return result
51
+ else:
52
+ result = current_zoom
53
+ logger.debug(f"Exiting calculate_zoom_value with result: {result!r}")
54
+ return result
55
+
56
+
57
+ def update_width_from_height(
58
+ height, width, use_aspect_ratio, base_img, overlay_img
59
+ ):
60
+ """Calculate new width based on height and aspect ratio."""
61
+ logger.debug(
62
+ f"Entering update_width_from_height with arguments: {locals()}"
63
+ )
64
+ if use_aspect_ratio:
65
+ if base_img is not None:
66
+ w_base, h_base = base_img.size
67
+ elif overlay_img is not None:
68
+ w_base, h_base = overlay_img.size
69
+ else:
70
+ result = width
71
+ logger.debug(
72
+ f"Exiting update_width_from_height with result: {result!r}"
73
+ )
74
+ return result
75
+ new_width = round(height * w_base / h_base, 2)
76
+ if abs(new_width - width) > 1e-6:
77
+ result = new_width
78
+ logger.debug(
79
+ f"Exiting update_width_from_height with result: {result!r}"
80
+ )
81
+ return result
82
+ result = width
83
+ logger.debug(f"Exiting update_width_from_height with result: {result!r}")
84
+
85
+ return result
86
+
87
+
88
+ def update_height_from_width(
89
+ width, height, use_aspect_ratio, base_img, overlay_img
90
+ ):
91
+ """Calculate new height based on width and aspect ratio."""
92
+ logger.debug(
93
+ f"Entering update_height_from_width with arguments: {locals()}"
94
+ )
95
+ if use_aspect_ratio:
96
+ if base_img is not None:
97
+ w_base, h_base = base_img.size
98
+ elif overlay_img is not None:
99
+ w_base, h_base = overlay_img.size
100
+ else:
101
+ result = height
102
+ logger.debug(
103
+ f"Exiting update_height_from_width with result: {result!r}"
104
+ )
105
+ return result
106
+ new_height = round(width * h_base / w_base, 2)
107
+ if abs(new_height - height) > 1e-6:
108
+ result = new_height
109
+ logger.debug(
110
+ f"Exiting update_height_from_width with result: {result!r}"
111
+ )
112
+ return result
113
+ result = height
114
+ logger.debug(f"Exiting update_height_from_width with result: {result!r}")
115
+
116
+ return result
117
+
118
+
119
+ def overlay_front_images(
120
+ base_img,
121
+ front_img,
122
+ x,
123
+ y,
124
+ rot,
125
+ zoom,
126
+ indexed_colors,
127
+ use_common_colors,
128
+ morph_size,
129
+ resolution,
130
+ ):
131
+ """Overlay an image onto a base image and extract masks."""
132
+ base_rgb, masks, mask_colors = overlay_images_core(
133
+ base_img=base_img,
134
+ overlay_img=front_img,
135
+ x=x,
136
+ y=y,
137
+ rot=rot,
138
+ zoom=zoom,
139
+ indexed_colors=indexed_colors,
140
+ use_common_colors=use_common_colors,
141
+ morph_size=morph_size,
142
+ resolution=resolution,
143
+ )
144
+ if base_rgb is None:
145
+ return None, None, None, None, gr.update(interactive=False)
146
+ return base_rgb, masks, masks, mask_colors, gr.update(interactive=True)
147
+
148
+
149
+ def overlay_back_images(
150
+ base_img,
151
+ back_img,
152
  x,
153
  y,
154
  rot,
155
  zoom,
156
  indexed_colors,
 
 
 
 
157
  use_common_colors,
158
  morph_size,
159
  resolution,
160
  ):
161
+ """Overlay an image onto a base image and extract masks."""
162
+ flipped_base_img = (
163
+ flip_image_horizontally(image=base_img)
164
+ if base_img is not None
165
+ else None
166
+ )
167
  base_rgb, masks, mask_colors = overlay_images_core(
168
+ base_img=flipped_base_img,
169
+ overlay_img=back_img,
170
+ x=x,
171
+ y=y,
172
+ rot=rot,
173
+ zoom=zoom,
174
+ indexed_colors=indexed_colors,
175
+ use_common_colors=use_common_colors,
176
+ morph_size=morph_size,
177
+ resolution=resolution,
 
 
 
 
178
  )
179
  if base_rgb is None:
180
  return None, None, None, None, gr.update(interactive=False)
 
197
  default_width = round(100, 2)
198
  default_height = round(default_width * h / w, 2)
199
  if overlay_img is not None:
200
+ zoom_val = calculate_zoom_value(
201
+ base_img=base_img,
202
+ overlay_img=overlay_img,
203
+ current_zoom=1.0,
204
+ resolution=resolution,
205
+ )
206
  else:
207
  zoom_val = 1.0
208
  return (
 
217
 
218
 
219
  def get_user_dir(req: gr.Request) -> str:
 
220
  session_hash = (
221
  str(req.session_hash) if req.session_hash is not None else "unknown"
222
  )
 
226
 
227
 
228
  def delete_user_dir(req: gr.Request) -> None:
 
229
  session_hash = (
230
  str(req.session_hash) if req.session_hash is not None else "unknown"
231
  )
 
234
  shutil.rmtree(user_dir)
235
 
236
 
237
+ def switch_to_model_tab():
238
+ return gr.update(selected="model")
239
+
240
+
241
+ generate_3mf_with_frames = partial(generate_3mf_file, animate_frames=18)
242
+
243
+
244
+ def generate_qr_code_from_text(text: str):
245
+ """Generate a QR code image from input text."""
246
+ logger.debug(
247
+ f"Entering generate_qr_code_from_text with arguments: {locals()}"
248
+ )
249
+ image = generate_qrcode(fill_color="random", text=text)
250
+ result = image
251
+ logger.debug(f"Exiting generate_qrcode with result: {result!r}")
252
+
253
+ return result
254
+
255
+
256
  with gr.Blocks() as demo:
257
  gr.Markdown(value="# PNG to scad/3mf")
258
+
 
 
 
259
  with gr.Row():
260
  with gr.Column():
261
+ with gr.Tabs() as left_tabs:
262
+ with gr.Tab("Front Image"):
263
+ with gr.Row():
264
+ front_overlay_img = gr.Image(
265
+ label="Front Image",
266
+ type="pil",
267
+ image_mode="RGBA",
268
+ )
269
+ with gr.Row():
270
+ qr_text_front = gr.Textbox(
271
+ label="Generate QR-code",
272
+ placeholder="Enter text",
273
+ submit_btn = "▣ QRCode"
274
+ )
275
+ with gr.Column():
276
+ x_slider = gr.Slider(
277
+ label="X (px)",
278
+ minimum=-500,
279
+ maximum=500,
280
+ step=1,
281
+ value=0,
282
+ interactive=False,
283
+ info="Overlay image X coordinate relative to base",
284
+ )
285
+ y_slider = gr.Slider(
286
+ label="Y (px)",
287
+ minimum=-500,
288
+ maximum=500,
289
+ step=1,
290
+ value=0,
291
+ interactive=False,
292
+ info="Overlay image Y coordinate relative to base",
293
+ )
294
+ rot_slider = gr.Slider(
295
+ label="Rotation (°)",
296
+ minimum=-180,
297
+ maximum=180,
298
+ step=1,
299
+ value=0,
300
+ interactive=False,
301
+ info="Rotate overlay image",
302
+ )
303
+ zoom_slider = gr.Slider(
304
+ label="Zoom",
305
+ minimum=0.01,
306
+ maximum=10.0,
307
+ step=0.01,
308
+ value=1.0,
309
+ interactive=False,
310
+ info="Make overlay image bigger or smaller",
311
+ )
312
+ indexed_slider = gr.Slider(
313
+ label="Indexed Colors",
314
+ minimum=2,
315
+ maximum=255,
316
+ step=1,
317
+ value=4,
318
+ info="Overlay image colors will be quantized",
319
+ )
320
+ use_common_colors_checkbox = gr.Checkbox(
321
+ label="Use most common colors for quantization",
322
+ value=True,
323
+ interactive=True,
324
+ info="Best for the logo's.",
325
+ )
326
+ morphology_slider = gr.Slider(
327
+ label="Morphology Size",
328
+ minimum=1,
329
+ maximum=20,
330
+ step=1,
331
+ value=1,
332
+ info=(
333
+ "Size of morphological opening/closing "
334
+ "to remove small features"
335
+ ),
336
+ )
337
+ with gr.Tab("Back Image"):
338
+ with gr.Row():
339
+ back_overlay_img = gr.Image(
340
+ label="Back Image",
341
+ type="pil",
342
+ image_mode="RGBA",
343
+ )
344
+ with gr.Row():
345
+ qr_text_back = gr.Textbox(
346
+ label="Generate QR-code",
347
+ placeholder="Enter text",
348
+ submit_btn = "▣ QRCode"
349
+ )
350
+ with gr.Column():
351
+ x_slider_back = gr.Slider(
352
+ label="X (px)",
353
+ minimum=-500,
354
+ maximum=500,
355
+ step=1,
356
+ value=0,
357
+ interactive=False,
358
+ info="Overlay image X coordinate relative to base",
359
+ )
360
+ y_slider_back = gr.Slider(
361
+ label="Y (px)",
362
+ minimum=-500,
363
+ maximum=500,
364
+ step=1,
365
+ value=0,
366
+ interactive=False,
367
+ info="Overlay image Y coordinate relative to base",
368
+ )
369
+ rot_slider_back = gr.Slider(
370
+ label="Rotation (°)",
371
+ minimum=-180,
372
+ maximum=180,
373
+ step=1,
374
+ value=0,
375
+ interactive=False,
376
+ info="Rotate overlay image",
377
+ )
378
+ zoom_slider_back = gr.Slider(
379
+ label="Zoom",
380
+ minimum=0.01,
381
+ maximum=10.0,
382
+ step=0.01,
383
+ value=1.0,
384
+ interactive=False,
385
+ info="Make overlay image bigger or smaller",
386
+ )
387
+ indexed_slider_back = gr.Slider(
388
+ label="Indexed Colors",
389
+ minimum=2,
390
+ maximum=255,
391
+ step=1,
392
+ value=4,
393
+ info="Overlay image colors will be quantized",
394
+ )
395
+ use_common_colors_checkbox_back = gr.Checkbox(
396
+ label="Use most common colors for quantization",
397
+ value=True,
398
+ interactive=True,
399
+ info="Best for the logo's.",
400
+ )
401
+ morphology_slider_back = gr.Slider(
402
+ label="Morphology Size",
403
+ minimum=1,
404
+ maximum=20,
405
+ step=1,
406
+ value=1,
407
+ info=(
408
+ "Size of morphological opening/closing "
409
+ "to remove small features"
410
+ ),
411
+ )
412
+ with gr.Tab("Base Image"):
413
+ gr.Markdown(
414
+ value="**Tip:** The base image must be "
415
+ "black and white. Black is treated as transparent"
416
+ "and White will be extruded."
417
+ )
418
+ with gr.Row():
419
+ base_img = gr.Image(
420
+ label="Base Image",
421
+ type="pil",
422
+ image_mode="RGB",
423
+ )
424
+ gr.Markdown(value="**Examples**")
425
+ base_examples_dir = (
426
+ Path(__file__).parent.parent / "examples" / "base"
427
+ )
428
+ gr.Examples(
429
+ examples=str(base_examples_dir),
430
+ inputs=[base_img],
431
+ label="Base",
432
+ )
433
+ front_examples_dir = (
434
+ Path(__file__).parent.parent / "examples" / "front"
435
+ )
436
+ gr.Examples(
437
+ examples=str(front_examples_dir),
438
+ inputs=[front_overlay_img],
439
+ label="Front",
440
+ )
441
+ back_examples_dir = (
442
+ Path(__file__).parent.parent / "examples" / "back"
443
+ )
444
+ gr.Examples(
445
+ examples=str(back_examples_dir),
446
+ inputs=[back_overlay_img],
447
+ label="Back",
448
+ )
449
+ with gr.Tab("Settings"):
450
+ with gr.Column():
451
+ resolution_dropdown = gr.Dropdown(
452
+ label="Resolution (px)",
453
+ choices=[512, 1024, 2048],
454
+ value=512,
455
+ interactive=True,
456
+ )
457
+ width_slider = gr.Number(
458
+ label="Width (mm)",
459
+ minimum=0,
460
+ step=0.01,
461
+ value=100,
462
+ interactive=True,
463
+ )
464
+ height_slider = gr.Number(
465
+ label="Height (mm)",
466
+ minimum=0,
467
+ step=0.01,
468
+ value=100,
469
+ interactive=True,
470
+ )
471
+ use_aspect_ratio = gr.Checkbox(
472
+ label="Use image aspect ratio (base/overlay)",
473
+ value=True,
474
+ interactive=True,
475
+ )
476
+ height_slider.input(
477
+ fn=update_height_from_width,
478
+ inputs=[
479
+ height_slider,
480
+ width_slider,
481
+ use_aspect_ratio,
482
+ base_img,
483
+ front_overlay_img,
484
+ ],
485
+ outputs=[width_slider],
486
+ )
487
+ width_slider.input(
488
+ fn=update_width_from_height,
489
+ inputs=[
490
+ width_slider,
491
+ height_slider,
492
+ use_aspect_ratio,
493
+ base_img,
494
+ front_overlay_img,
495
+ ],
496
+ outputs=[height_slider],
497
+ )
498
+ base_thickness = gr.Number(
499
+ label="Base Thickness (mm)",
500
+ minimum=0,
501
+ step=0.1,
502
+ value=1.0,
503
+ )
504
+ overlay_thickness = gr.Number(
505
+ label="Overlay Thickness (mm)",
506
+ minimum=0,
507
+ step=0.1,
508
+ value=0.5,
509
+ )
510
 
511
  with gr.Column():
512
+ with gr.Tabs() as right_tabs:
513
+ with gr.Tab("Preview", id="preview"):
514
+ front_out = gr.Image(label="Front Preview")
515
+ back_out = gr.Image(label="Back Preview")
516
+ with gr.Tab("Masks", id="masks"):
517
+ mask_gallery_front = gr.Gallery(label="Front Masks")
518
+ mask_gallery_back = gr.Gallery(label="Back Masks")
519
+ with gr.Tab("Model", id="model"):
520
+ animate_gif = gr.Image(
521
+ label="Animation GIF",
522
+ type="filepath",
523
+ interactive=False,
524
+ )
525
+ scad_file = gr.File(label="scad/3mf archive")
526
+ generate_button = gr.Button("Generate Model", interactive=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
 
528
+ masks_state_front = gr.State()
529
+ mask_colors_state_front = gr.State()
530
+ masks_state_back = gr.State()
531
+ mask_colors_state_back = gr.State()
532
+ user_dir = gr.Text(visible=False)
533
 
534
+ qr_text_front.submit(
535
+ fn=generate_qr_code_from_text,
536
+ inputs=[qr_text_front],
537
+ outputs=[front_overlay_img],
538
+ )
539
+ qr_text_back.submit(
540
+ fn=generate_qr_code_from_text,
541
+ inputs=[qr_text_back],
542
+ outputs=[back_overlay_img],
543
+ )
544
 
545
+ front_overlay_img.change(
546
+ fn=overlay_front_images,
547
+ inputs=[
548
+ base_img,
549
+ front_overlay_img,
550
  x_slider,
551
  y_slider,
552
  rot_slider,
553
  zoom_slider,
554
  indexed_slider,
555
+ use_common_colors_checkbox,
556
+ morphology_slider,
557
+ resolution_dropdown,
558
+ ],
559
+ outputs=[
560
+ front_out,
561
+ mask_gallery_front,
562
+ masks_state_front,
563
+ mask_colors_state_front,
564
+ generate_button,
565
  ],
566
  )
567
 
568
+ back_overlay_img.change(
569
+ fn=overlay_back_images,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  inputs=[
 
 
 
571
  base_img,
572
+ back_overlay_img,
573
+ x_slider_back,
574
+ y_slider_back,
575
+ rot_slider_back,
576
+ zoom_slider_back,
577
+ indexed_slider_back,
578
+ use_common_colors_checkbox_back,
579
+ morphology_slider_back,
580
+ resolution_dropdown,
581
+ ],
582
+ outputs=[
583
+ back_out,
584
+ mask_gallery_back,
585
+ masks_state_back,
586
+ mask_colors_state_back,
587
+ generate_button,
588
  ],
 
589
  )
590
 
591
  for comp in [
 
595
  zoom_slider,
596
  indexed_slider,
597
  use_common_colors_checkbox,
 
 
598
  morphology_slider,
599
  resolution_dropdown,
600
  ]:
601
  comp.change(
602
+ fn=overlay_front_images,
603
  inputs=[
604
  base_img,
605
+ front_overlay_img,
606
  x_slider,
607
  y_slider,
608
  rot_slider,
609
  zoom_slider,
610
  indexed_slider,
 
 
 
 
611
  use_common_colors_checkbox,
612
  morphology_slider,
613
  resolution_dropdown,
614
  ],
615
  outputs=[
616
+ front_out,
617
+ mask_gallery_front,
618
+ masks_state_front,
619
+ mask_colors_state_front,
620
  generate_button,
621
  ],
622
  )
623
 
624
+ for comp in [
625
+ x_slider_back,
626
+ y_slider_back,
627
+ rot_slider_back,
628
+ zoom_slider_back,
629
+ indexed_slider_back,
630
+ use_common_colors_checkbox_back,
631
+ morphology_slider_back,
632
+ resolution_dropdown,
633
+ ]:
634
+ comp.change(
635
+ fn=overlay_back_images,
636
+ inputs=[
637
+ base_img,
638
+ back_overlay_img,
639
+ x_slider_back,
640
+ y_slider_back,
641
+ rot_slider_back,
642
+ zoom_slider_back,
643
+ indexed_slider_back,
644
+ use_common_colors_checkbox_back,
645
+ morphology_slider_back,
646
+ resolution_dropdown,
647
+ ],
648
+ outputs=[
649
+ back_out,
650
+ mask_gallery_back,
651
+ masks_state_back,
652
+ mask_colors_state_back,
653
+ generate_button,
654
+ ],
655
+ )
656
+
657
+ base_img.change(
658
+ fn=overlay_front_images,
659
+ inputs=[
660
+ base_img,
661
+ front_overlay_img,
662
+ x_slider,
663
+ y_slider,
664
+ rot_slider,
665
+ zoom_slider,
666
+ indexed_slider,
667
+ use_common_colors_checkbox,
668
+ morphology_slider,
669
+ resolution_dropdown,
670
+ ],
671
+ outputs=[
672
+ front_out,
673
+ mask_gallery_front,
674
+ masks_state_front,
675
+ mask_colors_state_front,
676
+ generate_button,
677
+ ],
678
+ )
679
+
680
+ base_img.change(
681
+ fn=overlay_back_images,
682
+ inputs=[
683
+ base_img,
684
+ back_overlay_img,
685
+ x_slider_back,
686
+ y_slider_back,
687
+ rot_slider_back,
688
+ zoom_slider_back,
689
+ indexed_slider_back,
690
+ use_common_colors_checkbox_back,
691
+ morphology_slider_back,
692
+ resolution_dropdown,
693
+ ],
694
+ outputs=[
695
+ back_out,
696
+ mask_gallery_back,
697
+ masks_state_back,
698
+ mask_colors_state_back,
699
+ generate_button,
700
+ ],
701
+ )
702
+
703
+ base_img.change(
704
  fn=update_slider_ranges,
705
+ inputs=[base_img, front_overlay_img, resolution_dropdown],
706
  outputs=[
707
  x_slider,
708
  y_slider,
 
714
  ],
715
  )
716
 
717
+ base_img.change(
718
+ fn=update_slider_ranges,
719
+ inputs=[base_img, back_overlay_img, resolution_dropdown],
720
+ outputs=[
721
+ x_slider_back,
722
+ y_slider_back,
723
+ rot_slider_back,
724
+ zoom_slider_back,
725
+ indexed_slider_back,
726
+ width_slider,
727
+ height_slider,
728
+ ],
729
+ )
730
+
731
+ front_overlay_img.change(
732
+ fn=update_height_from_width,
733
+ inputs=[
734
+ width_slider,
735
+ height_slider,
736
+ use_aspect_ratio,
737
+ base_img,
738
+ front_overlay_img,
739
+ ],
740
+ outputs=[height_slider],
741
+ )
742
+
743
+ back_overlay_img.change(
744
+ fn=update_height_from_width,
745
+ inputs=[
746
+ width_slider,
747
+ height_slider,
748
+ use_aspect_ratio,
749
+ base_img,
750
+ back_overlay_img,
751
+ ],
752
+ outputs=[height_slider],
753
+ )
754
+
755
+ front_overlay_img.change(
756
+ fn=lambda img, base, current_zoom, res: (
757
+ gr.update(interactive=bool(img)),
758
+ gr.update(interactive=bool(img)),
759
+ gr.update(interactive=bool(img)),
760
+ gr.update(
761
+ interactive=bool(img),
762
+ value=calculate_zoom_value(
763
+ base_img=base,
764
+ overlay_img=img,
765
+ current_zoom=current_zoom,
766
+ resolution=res,
767
+ )
768
+ if img
769
+ else current_zoom,
770
+ ),
771
+ ),
772
+ inputs=[front_overlay_img, base_img, zoom_slider, resolution_dropdown],
773
+ outputs=[x_slider, y_slider, rot_slider, zoom_slider],
774
+ )
775
+
776
+ back_overlay_img.change(
777
+ fn=lambda img, base, current_zoom, res: (
778
+ gr.update(interactive=bool(img)),
779
+ gr.update(interactive=bool(img)),
780
+ gr.update(interactive=bool(img)),
781
+ gr.update(
782
+ interactive=bool(img),
783
+ value=calculate_zoom_value(
784
+ base_img=base,
785
+ overlay_img=img,
786
+ current_zoom=current_zoom,
787
+ resolution=res,
788
+ )
789
+ if img
790
+ else current_zoom,
791
+ ),
792
+ ),
793
+ inputs=[
794
+ back_overlay_img,
795
+ base_img,
796
+ zoom_slider_back,
797
+ resolution_dropdown,
798
+ ],
799
+ outputs=[
800
+ x_slider_back,
801
+ y_slider_back,
802
+ rot_slider_back,
803
+ zoom_slider_back,
804
+ ],
805
+ )
806
+
807
+ resolution_dropdown.change(
808
+ fn=update_slider_ranges,
809
+ inputs=[base_img, front_overlay_img, resolution_dropdown],
810
+ outputs=[
811
+ x_slider,
812
+ y_slider,
813
+ rot_slider,
814
+ zoom_slider,
815
+ indexed_slider,
816
+ width_slider,
817
+ height_slider,
818
+ ],
819
+ )
820
+ resolution_dropdown.change(
821
+ fn=update_slider_ranges,
822
+ inputs=[base_img, back_overlay_img, resolution_dropdown],
823
+ outputs=[
824
+ x_slider_back,
825
+ y_slider_back,
826
+ rot_slider_back,
827
+ zoom_slider_back,
828
+ indexed_slider_back,
829
+ width_slider,
830
+ height_slider,
831
+ ],
832
+ )
833
 
 
834
  demo.load(get_user_dir, outputs=user_dir)
835
+ demo.unload(delete_user_dir)
836
 
837
  generate_button.click(
838
+ fn=switch_to_model_tab,
839
+ inputs=[],
840
+ outputs=right_tabs,
841
+ ).then(
842
+ fn=generate_3mf_with_frames,
843
  inputs=[
844
  base_img,
 
 
845
  width_slider,
846
  height_slider,
847
  base_thickness,
848
  overlay_thickness,
849
  resolution_dropdown,
850
+ masks_state_front,
851
+ mask_colors_state_front,
852
+ masks_state_back,
853
+ mask_colors_state_back,
854
  user_dir,
855
  ],
856
+ outputs=[scad_file, animate_gif],
857
  )
858
 
 
 
 
859
  if __name__ == "__main__":
860
  demo.launch(share=False, server_name="0.0.0.0")
src/input.scad.j2 CHANGED
@@ -8,9 +8,9 @@ module base() {
8
  }
9
  {% endif %}
10
 
11
- module masks() {
12
  scale([{{ scale_x }}, {{ scale_y }}, {{ overlay_scale_z }}]) {
13
- {% for mask_path, mask_color in mask_paths_and_colors %}
14
  color({{ mask_color }}) linear_extrude(height = 100) projection(cut = true) {
15
  surface(file="{{ mask_path }}", center=false);
16
  }
@@ -18,11 +18,25 @@ module masks() {
18
  }
19
  }
20
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  {% if include_base %}
22
  difference() {
23
  base();
24
- masks();
 
25
  }
26
  {% endif %}
27
 
28
- masks();
 
 
8
  }
9
  {% endif %}
10
 
11
+ module front_masks() {
12
  scale([{{ scale_x }}, {{ scale_y }}, {{ overlay_scale_z }}]) {
13
+ {% for mask_path, mask_color in front_mask_paths_and_colors %}
14
  color({{ mask_color }}) linear_extrude(height = 100) projection(cut = true) {
15
  surface(file="{{ mask_path }}", center=false);
16
  }
 
18
  }
19
  }
20
 
21
+ module back_masks() {
22
+ translate([0, 0, {{ (scale_z - overlay_scale_z) * 100 }}]) {
23
+ scale([{{ scale_x }}, {{ scale_y }}, {{ overlay_scale_z }}]) {
24
+ {% for mask_path, mask_color in back_mask_paths_and_colors %}
25
+ color({{ mask_color }}) linear_extrude(height = 100) projection(cut = true) {
26
+ surface(file="{{ mask_path }}", center=false);
27
+ }
28
+ {% endfor %}
29
+ }
30
+ }
31
+ }
32
+
33
  {% if include_base %}
34
  difference() {
35
  base();
36
+ front_masks();
37
+ back_masks();
38
  }
39
  {% endif %}
40
 
41
+ front_masks();
42
+ back_masks();
src/png23mf.py CHANGED
@@ -1,6 +1,7 @@
1
  #!/usr/bin/env python3
2
 
3
  import argparse
 
4
  import os
5
  import random
6
  import shutil
@@ -12,32 +13,131 @@ from pathlib import Path
12
 
13
  import jinja2
14
  import numpy as np
 
15
  from PIL import Image
16
  from scipy import ndimage
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  def generate_scad_file(
20
- base_img,
21
- width_mm,
22
- height_mm,
23
- base_thickness_mm,
24
- overlay_thickness_mm,
25
- resolution,
26
- masks,
27
- mask_colors,
 
 
 
28
  ):
 
 
29
  include_base = base_img is not None
30
  tmp_dir = tempfile.mkdtemp(prefix="scad_")
31
- # Base image handling
32
  if include_base:
33
- base = base_img.convert("RGBA")
34
  w_base, h_base = base.size
35
  if max(w_base, h_base) > resolution:
36
  scale_factor = resolution / max(w_base, h_base)
37
  new_w_base = int(w_base * scale_factor)
38
  new_h_base = int(h_base * scale_factor)
39
  base = base.resize(
40
- (new_w_base, new_h_base), Image.Resampling.BICUBIC
 
41
  )
42
  w_base, h_base = base.size
43
  base_path = Path(tmp_dir) / "base.png"
@@ -46,13 +146,12 @@ def generate_scad_file(
46
  scale_y = height_mm / h_base if h_base != 0 else 1.0
47
  scale_z = base_thickness_mm / 100.0 if base_thickness_mm != 0 else 1.0
48
  base_rel_path = base_path.name
49
- base_color = [0.0, 0.0, 0.0] # placeholder, will be set below
50
  else:
51
- # No base image: use overlay size for scaling
52
- if masks:
53
- w_overlay, h_overlay = masks[0].size
54
- scale_x = width_mm / w_overlay if w_overlay != 0 else 1.0
55
- scale_y = height_mm / h_overlay if h_overlay != 0 else 1.0
56
  else:
57
  scale_x = 1.0
58
  scale_y = 1.0
@@ -62,37 +161,44 @@ def generate_scad_file(
62
  overlay_scale_z = (
63
  overlay_thickness_mm / 100.0 if overlay_thickness_mm != 0 else 1.0
64
  )
65
- # Save masks
66
- mask_paths = []
67
- for idx, mask_img in enumerate(masks):
68
- mask_path = Path(tmp_dir) / f"mask_{idx}.png"
69
- mask_img = mask_img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
70
  mask_path.parent.mkdir(parents=True, exist_ok=True)
71
  mask_img.save(mask_path)
72
- mask_paths.append(mask_path)
73
- # Load template
 
 
 
 
 
 
74
  template_path = os.path.join(os.path.dirname(__file__), "input.scad.j2")
75
  with open(template_path, "r") as f:
76
  template_text = f.read()
77
  template = jinja2.Template(template_text)
78
- mask_paths_and_colors = [
79
  (mask_path.name, mask_color)
80
- for mask_path, mask_color in zip(mask_paths, mask_colors)
81
  ]
 
 
 
 
 
 
82
  if include_base:
83
  mask_color_ints = set(
84
  (int(r * 255), int(g * 255), int(b * 255))
85
- for r, g, b in mask_colors
86
  )
87
- max_attempts = 1000
88
- for _ in range(max_attempts):
89
- r = random.randint(0, 255)
90
- g = random.randint(0, 255)
91
- b = random.randint(0, 255)
92
- if (r, g, b) not in mask_color_ints:
93
- break
94
- else:
95
- r, g, b = 0, 0, 0
96
  base_color = [r / 255.0, g / 255.0, b / 255.0]
97
  rendered_scad = template.render(
98
  scale_x=scale_x,
@@ -100,23 +206,29 @@ def generate_scad_file(
100
  scale_z=scale_z,
101
  base_rel_path=base_rel_path,
102
  overlay_scale_z=overlay_scale_z,
103
- mask_paths_and_colors=mask_paths_and_colors,
 
104
  base_color=base_color,
105
  include_base=include_base,
106
  )
107
  scad_path = Path(tmp_dir) / "model.scad"
108
  with open(scad_path, "w") as f:
109
  f.write(rendered_scad)
110
- return str(scad_path)
 
111
 
 
112
 
113
- def extract_masks(overlay_image, morph_size=1):
114
- overlay_rgba = overlay_image.convert("RGBA")
115
- overlay_np = np.array(overlay_rgba)
 
 
 
116
  alpha_mask = overlay_np[..., 3] != 0
117
  flat = overlay_np.reshape(-1, 4)
118
- unique_colors = np.unique(flat, axis=0)
119
- unique_colors = unique_colors[unique_colors[:, 3] != 0]
120
  masks = []
121
  mask_colors = []
122
  selem = np.ones((int(morph_size), int(morph_size)), dtype=bool)
@@ -124,52 +236,51 @@ def extract_masks(overlay_image, morph_size=1):
124
  mask_array = (
125
  np.all(overlay_np[..., :3] == color[:3], axis=-1) & alpha_mask
126
  )
127
- opened = ndimage.binary_opening(mask_array, structure=selem)
128
- closed = ndimage.binary_closing(opened, structure=selem)
129
- mask_image = Image.fromarray((closed.astype(np.uint8) * 255), mode="L")
 
 
 
130
  masks.append(mask_image)
131
  r = int(color[0]) / 255.0
132
  g = int(color[1]) / 255.0
133
  b = int(color[2]) / 255.0
134
  mask_colors.append([r, g, b])
135
- return masks, mask_colors
136
-
137
-
138
- def calculate_zoom_value(base_img, overlay_img, current_zoom, resolution):
139
- if base_img is None or overlay_img is None:
140
- return current_zoom
141
- base = base_img.convert("RGBA")
142
- w_base, h_base = base.size
143
- if max(w_base, h_base) > resolution:
144
- scale_factor = resolution / max(w_base, h_base)
145
- w_base = int(w_base * scale_factor)
146
- h_base = int(h_base * scale_factor)
147
- base_longer = max(w_base, h_base)
148
- w_overlay, h_overlay = overlay_img.size
149
- overlay_longer = max(w_overlay, h_overlay)
150
- if overlay_longer > base_longer:
151
- zoom = round(base_longer / overlay_longer, 2)
152
- zoom = max(0.01, min(10.0, zoom))
153
- return zoom
154
- else:
155
- return current_zoom
156
 
157
 
158
- def get_most_common_colors(overlay_img, num_colors):
159
- rgba = overlay_img.convert("RGBA")
160
- arr = np.array(rgba)
 
 
161
  alpha_mask = arr[..., 3] > 0
162
  rgb_pixels = arr[alpha_mask][:, :3]
163
  if rgb_pixels.size == 0:
164
- return [(0, 0, 0)] * num_colors
165
- unique_colors, counts = np.unique(rgb_pixels, axis=0, return_counts=True)
 
 
 
 
 
 
166
  sorted_indices = np.argsort(-counts)
167
  sorted_colors = unique_colors[sorted_indices]
168
  top_colors = sorted_colors[:num_colors]
169
- return [tuple(color) for color in top_colors]
 
 
 
170
 
171
 
172
- def create_palette_image(colors):
 
 
173
  palette = [0] * 768
174
  for i, color in enumerate(colors):
175
  if i >= 256:
@@ -182,148 +293,265 @@ def create_palette_image(colors):
182
  palette[i * 3] = last_color[0]
183
  palette[i * 3 + 1] = last_color[1]
184
  palette[i * 3 + 2] = last_color[2]
185
- palette_img = Image.new("P", (1, 1))
186
  palette_img.putpalette(palette)
187
- return palette_img
 
 
 
188
 
189
 
190
  def overlay_images_core(
191
- base_img,
 
192
  overlay_img,
193
- x,
194
- y,
195
- rot,
196
- zoom,
197
- indexed_colors,
198
- base_thickness,
199
- overlay_thickness,
200
- width,
201
- height,
202
- use_common_colors,
203
- morph_size,
204
- resolution,
205
  ):
 
 
206
  if overlay_img is None:
207
- return None, None, None
208
- width = round(width, 2)
209
- height = round(height, 2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  if base_img is None:
211
- base = overlay_img.convert("RGBA")
 
 
 
212
  x, y = 0, 0
213
  else:
214
- base = base_img.convert("RGBA")
215
  w_base, h_base = base.size
216
  max_dim = resolution
217
  if max(w_base, h_base) > max_dim:
218
  scale_factor = max_dim / max(w_base, h_base)
219
- new_w_base = int(w_base * scale_factor)
220
- new_h_base = int(h_base * scale_factor)
221
  base = base.resize(
222
- (new_w_base, new_h_base), Image.Resampling.BICUBIC
 
223
  )
224
- overlay = overlay_img.convert("RGBA")
225
- w, h = overlay.size
 
 
 
 
 
 
 
226
  overlay = overlay.resize(
227
- (int(w * zoom), int(h * zoom)), Image.Resampling.NEAREST
 
228
  )
229
- overlay = overlay.rotate(rot, expand=True)
 
 
 
 
 
 
 
230
  alpha = overlay.split()[3]
231
- rgb = overlay.convert("RGB")
 
232
  if use_common_colors:
233
- common_colors = get_most_common_colors(overlay_img, indexed_colors)
234
- palette_img = create_palette_image(common_colors)
235
- rgb_quant = rgb.quantize(palette=palette_img, dither=0).convert("RGB")
 
 
 
 
 
 
 
 
 
 
236
  else:
237
- rgb_quant = rgb.quantize(colors=indexed_colors).convert("RGB")
 
 
238
  alpha = overlay.split()[3].point(lambda p: 255 if p > 0 else 0)
239
- overlay = Image.merge("RGBA", (*rgb_quant.split(), alpha))
240
- if base_img is not None:
241
- base.paste(overlay, (int(x), int(y)), overlay)
242
- overlay_on_transparent = Image.new("RGBA", base.size, (0, 0, 0, 0))
243
- overlay_on_transparent.paste(overlay, (int(x), int(y)), overlay)
244
- masks, mask_colors = extract_masks(overlay_on_transparent, morph_size)
245
- return (
246
- base.convert("RGB"),
247
- masks,
248
- mask_colors,
249
  )
 
250
 
 
 
251
 
252
- def update_width_from_height(
253
- height,
254
- width,
255
- use_aspect_ratio,
256
- base_img,
257
- overlay_img,
258
- ):
259
- if use_aspect_ratio:
260
- if base_img is not None:
261
- w_base, h_base = base_img.size
262
- elif overlay_img is not None:
263
- w_base, h_base = overlay_img.size
264
- else:
265
- return width
266
- new_width = round(height * w_base / h_base, 2)
267
- if abs(new_width - width) > 1e-6:
268
- return new_width
269
- return width
270
 
 
 
 
 
 
271
 
272
- def update_height_from_width(
273
- width,
274
- height,
275
- use_aspect_ratio,
276
- base_img,
277
- overlay_img,
278
- ):
279
- if use_aspect_ratio:
280
- if base_img is not None:
281
- w_base, h_base = base_img.size
282
- elif overlay_img is not None:
283
- w_base, h_base = overlay_img.size
284
- else:
285
- return height
286
- new_height = round(width * h_base / w_base, 2)
287
- if abs(new_height - height) > 1e-6:
288
- return new_height
289
- return height
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
 
292
  def generate_3mf_file(
293
  base_img,
294
- masks,
295
- mask_colors,
296
  width,
297
  height,
298
  base_thickness,
299
  overlay_thickness,
300
  resolution,
 
 
 
 
301
  user_dir=None,
 
302
  ):
303
- if not masks or not mask_colors:
304
- return None
 
 
 
 
 
 
 
 
 
305
  scad_path = generate_scad_file(
306
- base_img,
307
- width,
308
- height,
309
- base_thickness,
310
- overlay_thickness,
311
- resolution,
312
- masks,
313
- mask_colors,
 
 
314
  )
315
  if not scad_path:
316
- return None
 
 
317
  scad_path_obj = Path(scad_path)
318
  openscad_model_3mf = scad_path_obj.with_name("openscad_model.3mf")
319
  subprocess.run(
320
- ["openscad", "-o", str(openscad_model_3mf), str(scad_path_obj)],
 
 
 
 
 
321
  check=True,
322
  )
323
  converted_model_3mf = openscad_model_3mf.with_name("converted_model.3mf")
324
  script_path = Path(__file__).parent / "3mf2mmuv3.py"
325
  subprocess.run(
326
- [
327
  "python3",
328
  str(script_path),
329
  str(openscad_model_3mf),
@@ -333,7 +561,9 @@ def generate_3mf_file(
333
  check=True,
334
  )
335
  temp_dir = scad_path_obj.parent
336
- mask_paths = sorted(Path(temp_dir).glob("mask_*.png"))
 
 
337
  random_string = "".join(
338
  random.choices(string.ascii_letters + string.digits, k=6)
339
  )
@@ -343,8 +573,30 @@ def generate_3mf_file(
343
  zip_path = zip_dir / f"model_{random_string}.zip"
344
  else:
345
  zip_path = Path(tempfile.gettempdir()) / f"model_{random_string}.zip"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  with zipfile.ZipFile(
347
- zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=9
 
 
 
348
  ) as zf:
349
  zf.write(scad_path_obj, arcname=scad_path_obj.name)
350
  zf.write(openscad_model_3mf, arcname=openscad_model_3mf.name)
@@ -354,24 +606,63 @@ def generate_3mf_file(
354
  if base_img is not None:
355
  base_img_path = Path(temp_dir) / "base.png"
356
  zf.write(base_img_path, arcname=base_img_path.name)
 
 
 
 
 
 
 
 
357
  shutil.rmtree(temp_dir)
358
- return str(zip_path)
 
 
 
 
359
 
360
 
361
  def main():
 
 
362
  parser = argparse.ArgumentParser(
363
  description="Generate 3MF from base and overlay images."
364
  )
365
- parser.add_argument("base_image", help="Path to base image (PNG)")
366
- parser.add_argument("overlay_image", help="Path to overlay image (PNG)")
367
  parser.add_argument(
368
- "-o", "--output", default=None, help="Output zip file path (optional)"
 
 
 
369
  )
370
  parser.add_argument(
371
- "--width", type=float, default=100.0, help="Width in mm"
 
 
 
372
  )
373
  parser.add_argument(
374
- "--height", type=float, default=100.0, help="Height in mm"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  )
376
  parser.add_argument(
377
  "--base-thickness",
@@ -398,11 +689,13 @@ def main():
398
  default=1,
399
  help="Morphology size for mask extraction",
400
  )
401
- # New arguments that mirror the Gradio UI
402
  parser.add_argument("--x", type=int, default=0, help="X offset (px)")
403
  parser.add_argument("--y", type=int, default=0, help="Y offset (px)")
404
  parser.add_argument(
405
- "--rot", type=int, default=0, help="Rotation (degrees)"
 
 
 
406
  )
407
  parser.add_argument("--zoom", type=float, default=1.0, help="Zoom factor")
408
  parser.add_argument(
@@ -417,54 +710,109 @@ def main():
417
  default=True,
418
  help="Use most common colors for quantization",
419
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  args = parser.parse_args()
421
 
422
- # Load images
423
- base_img = Image.open(args.base_image).convert("RGBA")
424
- overlay_img = Image.open(args.overlay_image).convert("RGBA")
425
-
426
- # Generate masks and mask colors using overlay images core logic
427
- _, masks, mask_colors = overlay_images_core(
428
- base_img,
429
- overlay_img,
430
- args.x,
431
- args.y,
432
- args.rot,
433
- args.zoom,
434
- args.indexed_colors,
435
- args.base_thickness,
436
- args.overlay_thickness,
437
- args.width,
438
- args.height,
439
- args.use_common_colors,
440
- args.morph_size,
441
- args.resolution,
442
- )
443
-
444
- if not masks or not mask_colors:
445
- print("Failed to generate masks from overlay image.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  return
447
 
448
  zip_path = generate_3mf_file(
449
- base_img,
450
- masks,
451
- mask_colors,
452
- args.width,
453
- args.height,
454
- args.base_thickness,
455
- args.overlay_thickness,
456
- args.resolution,
 
 
 
457
  )
458
 
459
  if zip_path is None:
460
- print("Failed to generate 3MF zip.")
 
 
461
  return
462
 
463
  if args.output:
464
- shutil.move(zip_path, args.output)
465
- print(f"Generated zip file: {args.output}")
466
  else:
467
- print(f"Generated zip file: {zip_path}")
 
468
 
469
 
470
  if __name__ == "__main__":
 
1
  #!/usr/bin/env python3
2
 
3
  import argparse
4
+ import logging
5
  import os
6
  import random
7
  import shutil
 
13
 
14
  import jinja2
15
  import numpy as np
16
+ import qrcode
17
  from PIL import Image
18
  from scipy import ndimage
19
 
20
+ logger = logging.getLogger(__name__)
21
+ logging.basicConfig(
22
+ level=logging.INFO,
23
+ format="%(asctime)s - %(levelname)s - %(message)s",
24
+ )
25
+
26
+
27
+ def generate_random_color(*, excluded_colors=None):
28
+ """Generate a random RGB color not in excluded_colors."""
29
+ logger.debug(f"Entering generate_random_color with arguments: {locals()}")
30
+ if excluded_colors is None:
31
+ excluded_colors = set()
32
+ logger.debug("excluded_colors was None, set to empty set")
33
+ max_attempts = 1000
34
+ for attempt in range(1, max_attempts + 1):
35
+ r = random.randint(0, 255)
36
+ g = random.randint(0, 255)
37
+ b = random.randint(0, 255)
38
+ logger.debug(f"Attempt {attempt}: generated color ({r}, {g}, {b})")
39
+ if (r, g, b) not in excluded_colors:
40
+ result = (r, g, b)
41
+ break
42
+ else:
43
+ result = (0, 0, 0)
44
+
45
+ logger.debug(f"Exiting generate_random_color with value {result}")
46
+
47
+ return result
48
+
49
+
50
+ def flip_image_horizontally(*, image):
51
+ """Flip an image horizontally."""
52
+ logger.debug(
53
+ f"Entering flip_image_horizontally with image size: {image.size}"
54
+ )
55
+ result = image.transpose(method=Image.Transpose.FLIP_LEFT_RIGHT)
56
+ logger.debug("Exiting flip_image_horizontally")
57
+
58
+ return result
59
+
60
+
61
+ def generate_qrcode(
62
+ *,
63
+ text: str,
64
+ fill_color: str = "black",
65
+ back_color: str = "transparent",
66
+ ) -> Image.Image:
67
+ """Generate a QR code image."""
68
+ logger.debug(f"Entering generate_qrcode with arguments: {locals()}")
69
+ if fill_color == "random":
70
+ r, g, b = generate_random_color()
71
+ fill_color = f"#{r:02x}{g:02x}{b:02x}"
72
+ qr = qrcode.QRCode()
73
+ qr.add_data(text)
74
+ qr.make(fit=True)
75
+ result = qr.make_image(
76
+ fill_color=fill_color,
77
+ back_color=back_color,
78
+ ).get_image()
79
+ logger.debug(f"Exiting generate_qrcode with result: {result!r}")
80
+
81
+ return result
82
+
83
+
84
+ def load_image_or_qr(*, path=None, qr_text=None):
85
+ """Return an RGBA image from a file path or a QR‑text."""
86
+ logger.debug(f"Entering load_image_or_qr with arguments: {locals()}")
87
+ if path is not None:
88
+ result = Image.open(fp=path).convert(mode="RGBA")
89
+ elif qr_text is not None:
90
+ result = generate_qrcode(text=qr_text)
91
+ else:
92
+ result = None
93
+ logger.debug(f"Exiting load_image_or_qr with result: {result!r}")
94
+
95
+ return result
96
+
97
+
98
+ def get_front_image(*, path=None, qr_text=None):
99
+ """Return a front image from a path, QR text, or user input."""
100
+ logger.debug(f"Entering get_front_image with arguments: {locals()}")
101
+ img = load_image_or_qr(path=path, qr_text=qr_text)
102
+ if img is None:
103
+ user_input = input("Enter front image path or QR text: ")
104
+ logger.debug(f"User input for front image: {user_input!r}")
105
+ img = load_image_or_qr(path=user_input) or generate_qrcode(
106
+ text=user_input
107
+ )
108
+ result = img
109
+ logger.debug(f"Exiting get_front_image with result: {result!r}")
110
+
111
+ return result
112
+
113
 
114
  def generate_scad_file(
115
+ *,
116
+ base_img=None,
117
+ width_mm: float,
118
+ height_mm: float,
119
+ base_thickness_mm: float,
120
+ overlay_thickness_mm: float,
121
+ resolution: int,
122
+ front_masks,
123
+ front_mask_colors,
124
+ back_masks=None,
125
+ back_mask_colors=None,
126
  ):
127
+ """Generate an OpenSCAD file from images and parameters."""
128
+ logger.debug(f"Entering generate_scad_file with arguments: {locals()}")
129
  include_base = base_img is not None
130
  tmp_dir = tempfile.mkdtemp(prefix="scad_")
 
131
  if include_base:
132
+ base = base_img.convert(mode="RGBA")
133
  w_base, h_base = base.size
134
  if max(w_base, h_base) > resolution:
135
  scale_factor = resolution / max(w_base, h_base)
136
  new_w_base = int(w_base * scale_factor)
137
  new_h_base = int(h_base * scale_factor)
138
  base = base.resize(
139
+ size=(new_w_base, new_h_base),
140
+ resample=Image.Resampling.BICUBIC,
141
  )
142
  w_base, h_base = base.size
143
  base_path = Path(tmp_dir) / "base.png"
 
146
  scale_y = height_mm / h_base if h_base != 0 else 1.0
147
  scale_z = base_thickness_mm / 100.0 if base_thickness_mm != 0 else 1.0
148
  base_rel_path = base_path.name
149
+ base_color = [0.0, 0.0, 0.0]
150
  else:
151
+ if front_masks:
152
+ w_front, h_front = front_masks[0].size
153
+ scale_x = width_mm / w_front if w_front != 0 else 1.0
154
+ scale_y = height_mm / h_front if h_front != 0 else 1.0
 
155
  else:
156
  scale_x = 1.0
157
  scale_y = 1.0
 
161
  overlay_scale_z = (
162
  overlay_thickness_mm / 100.0 if overlay_thickness_mm != 0 else 1.0
163
  )
164
+ mask_paths_front = []
165
+ for idx, mask_img in enumerate(front_masks):
166
+ mask_path = Path(tmp_dir) / f"mask_front_{idx}.png"
 
 
167
  mask_path.parent.mkdir(parents=True, exist_ok=True)
168
  mask_img.save(mask_path)
169
+ mask_paths_front.append(mask_path)
170
+ mask_paths_back = []
171
+ if back_masks:
172
+ for idx, mask_img in enumerate(back_masks):
173
+ mask_path = Path(tmp_dir) / f"mask_back_{idx}.png"
174
+ mask_path.parent.mkdir(parents=True, exist_ok=True)
175
+ mask_img.save(mask_path)
176
+ mask_paths_back.append(mask_path)
177
  template_path = os.path.join(os.path.dirname(__file__), "input.scad.j2")
178
  with open(template_path, "r") as f:
179
  template_text = f.read()
180
  template = jinja2.Template(template_text)
181
+ front_mask_paths_and_colors = [
182
  (mask_path.name, mask_color)
183
+ for mask_path, mask_color in zip(mask_paths_front, front_mask_colors)
184
  ]
185
+ back_mask_paths_and_colors = []
186
+ if back_masks and back_mask_colors:
187
+ back_mask_paths_and_colors = [
188
+ (mask_path.name, mask_color)
189
+ for mask_path, mask_color in zip(mask_paths_back, back_mask_colors)
190
+ ]
191
  if include_base:
192
  mask_color_ints = set(
193
  (int(r * 255), int(g * 255), int(b * 255))
194
+ for r, g, b in front_mask_colors
195
  )
196
+ if back_mask_colors:
197
+ mask_color_ints.update(
198
+ (int(r * 255), int(g * 255), int(b * 255))
199
+ for r, g, b in back_mask_colors
200
+ )
201
+ r, g, b = generate_random_color(excluded_colors=mask_color_ints)
 
 
 
202
  base_color = [r / 255.0, g / 255.0, b / 255.0]
203
  rendered_scad = template.render(
204
  scale_x=scale_x,
 
206
  scale_z=scale_z,
207
  base_rel_path=base_rel_path,
208
  overlay_scale_z=overlay_scale_z,
209
+ front_mask_paths_and_colors=front_mask_paths_and_colors,
210
+ back_mask_paths_and_colors=back_mask_paths_and_colors,
211
  base_color=base_color,
212
  include_base=include_base,
213
  )
214
  scad_path = Path(tmp_dir) / "model.scad"
215
  with open(scad_path, "w") as f:
216
  f.write(rendered_scad)
217
+ result = str(scad_path)
218
+ logger.debug(f"Exiting generate_scad_file with result: {result!r}")
219
 
220
+ return result
221
 
222
+
223
+ def extract_masks(*, overlay_image, morph_size=1):
224
+ """Extract masks and corresponding colors from an overlay image."""
225
+ logger.debug(f"Entering extract_masks with arguments: {locals()}")
226
+ overlay_rgba = overlay_image.convert(mode="RGBA")
227
+ overlay_np = np.array(object=overlay_rgba)
228
  alpha_mask = overlay_np[..., 3] != 0
229
  flat = overlay_np.reshape(-1, 4)
230
+ unique_colors = np.unique(ar=flat, axis=0)
231
+ unique_colors = unique_colors[unique_colors[..., 3] != 0]
232
  masks = []
233
  mask_colors = []
234
  selem = np.ones((int(morph_size), int(morph_size)), dtype=bool)
 
236
  mask_array = (
237
  np.all(overlay_np[..., :3] == color[:3], axis=-1) & alpha_mask
238
  )
239
+ opened = ndimage.binary_opening(input=mask_array, structure=selem)
240
+ closed = ndimage.binary_closing(input=opened, structure=selem)
241
+ mask_image = Image.fromarray(
242
+ obj=(closed.astype(np.uint8) * 255),
243
+ mode="L",
244
+ )
245
  masks.append(mask_image)
246
  r = int(color[0]) / 255.0
247
  g = int(color[1]) / 255.0
248
  b = int(color[2]) / 255.0
249
  mask_colors.append([r, g, b])
250
+ result = (masks, mask_colors)
251
+ logger.debug(f"Exiting extract_masks with result: {result!r}")
252
+
253
+ return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
 
256
+ def get_most_common_colors(*, overlay_img, num_colors):
257
+ """Return the most common colors in the overlay image."""
258
+ logger.debug(f"Entering get_most_common_colors with arguments: {locals()}")
259
+ rgba = overlay_img.convert(mode="RGBA")
260
+ arr = np.array(object=rgba)
261
  alpha_mask = arr[..., 3] > 0
262
  rgb_pixels = arr[alpha_mask][:, :3]
263
  if rgb_pixels.size == 0:
264
+ result = [(0, 0, 0)] * num_colors
265
+ logger.debug(f"Exiting get_most_common_colors with result: {result!r}")
266
+ return result
267
+ unique_colors, counts = np.unique(
268
+ ar=rgb_pixels,
269
+ axis=0,
270
+ return_counts=True,
271
+ )
272
  sorted_indices = np.argsort(-counts)
273
  sorted_colors = unique_colors[sorted_indices]
274
  top_colors = sorted_colors[:num_colors]
275
+ result = [tuple(color) for color in top_colors]
276
+ logger.debug(f"Exiting get_most_common_colors with result: {result!r}")
277
+
278
+ return result
279
 
280
 
281
+ def create_palette_image(*, colors):
282
+ """Create a palette image from a list of RGB colors."""
283
+ logger.debug(f"Entering create_palette_image with arguments: {locals()}")
284
  palette = [0] * 768
285
  for i, color in enumerate(colors):
286
  if i >= 256:
 
293
  palette[i * 3] = last_color[0]
294
  palette[i * 3 + 1] = last_color[1]
295
  palette[i * 3 + 2] = last_color[2]
296
+ palette_img = Image.new(mode="P", size=(1, 1))
297
  palette_img.putpalette(palette)
298
+ result = palette_img
299
+ logger.debug(f"Exiting create_palette_image with result: {result!r}")
300
+
301
+ return result
302
 
303
 
304
  def overlay_images_core(
305
+ *,
306
+ base_img=None,
307
  overlay_img,
308
+ x: int,
309
+ y: int,
310
+ rot: int,
311
+ zoom: float,
312
+ indexed_colors: int,
313
+ use_common_colors: bool,
314
+ morph_size: int,
315
+ resolution: int,
 
 
 
 
316
  ):
317
+ """Overlay an image onto a base image and extract masks."""
318
+ logger.debug(f"Entering overlay_images_core: with arguments: {locals()}")
319
  if overlay_img is None:
320
+ result = (None, None, None)
321
+ logger.debug(
322
+ "Exiting overlay_images_core with result: (None, None, None)"
323
+ )
324
+ return result
325
+ overlay = overlay_img.convert(mode="RGBA")
326
+ logger.debug(f"Overlay image converted to RGBA: size={overlay.size}")
327
+ w, h = overlay.size
328
+ logger.debug(f"Overlay size: {w}x{h}")
329
+
330
+ if base_img is None and (w > resolution or h > resolution):
331
+ scale_factor = resolution / max(w, h)
332
+ new_w = int(w * scale_factor)
333
+ new_h = int(h * scale_factor)
334
+ w, h = new_w, new_h
335
+ logger.debug(
336
+ f"Scaled overlay to resolution {resolution}: new size={w}x{h}"
337
+ )
338
+
339
  if base_img is None:
340
+ base = Image.new(mode="RGBA", size=(w, h), color=(0, 0, 0, 0))
341
+ logger.debug(
342
+ f"No base image - created base image with size={base.size}"
343
+ )
344
  x, y = 0, 0
345
  else:
346
+ base = base_img.convert(mode="RGBA")
347
  w_base, h_base = base.size
348
  max_dim = resolution
349
  if max(w_base, h_base) > max_dim:
350
  scale_factor = max_dim / max(w_base, h_base)
351
+ new_w_base = round(w_base * scale_factor)
352
+ new_h_base = round(h_base * scale_factor)
353
  base = base.resize(
354
+ size=(new_w_base, new_h_base),
355
+ resample=Image.Resampling.BICUBIC,
356
  )
357
+ logger.debug(
358
+ f"Resized base image to {base.size} "
359
+ f"to fit resolution {resolution}"
360
+ )
361
+ else:
362
+ logger.debug(
363
+ f"Base image size {base.size} within resolution limit"
364
+ )
365
+
366
  overlay = overlay.resize(
367
+ size=(int(w * zoom), int(h * zoom)),
368
+ resample=Image.Resampling.NEAREST,
369
  )
370
+ logger.debug(f"Scaled overlay with zoom {zoom}: new size={overlay.size}")
371
+
372
+ if rot != 0:
373
+ overlay = overlay.rotate(angle=rot, expand=True)
374
+ logger.debug(
375
+ f"Rotated overlay by {rot} degrees: new size={overlay.size}"
376
+ )
377
+
378
  alpha = overlay.split()[3]
379
+ rgb = overlay.convert(mode="RGB")
380
+
381
  if use_common_colors:
382
+ common_colors = get_most_common_colors(
383
+ overlay_img=overlay_img,
384
+ num_colors=indexed_colors,
385
+ )
386
+ logger.debug(f"Using common colors: {common_colors}")
387
+ palette_img = create_palette_image(colors=common_colors)
388
+ rgb_quant = rgb.quantize(
389
+ palette=palette_img,
390
+ dither=0,
391
+ ).convert(mode="RGB")
392
+ logger.debug(
393
+ f"Quantized overlay using common colors to {indexed_colors} colors"
394
+ )
395
  else:
396
+ rgb_quant = rgb.quantize(colors=indexed_colors).convert(mode="RGB")
397
+ logger.debug(f"Quantized overlay to {indexed_colors} colors")
398
+
399
  alpha = overlay.split()[3].point(lambda p: 255 if p > 0 else 0)
400
+ overlay = Image.merge(
401
+ mode="RGBA",
402
+ bands=(*rgb_quant.split(), alpha),
 
 
 
 
 
 
 
403
  )
404
+ logger.debug("Overlay image merged with quantized RGB and alpha channel")
405
 
406
+ base.paste(im=overlay, box=(int(x), int(y)), mask=overlay)
407
+ logger.debug(f"Pasted overlay onto base at position ({x}, {y})")
408
 
409
+ overlay_on_transparent = Image.new(
410
+ mode="RGBA",
411
+ size=base.size,
412
+ color=(0, 0, 0, 0),
413
+ )
414
+ overlay_on_transparent.paste(
415
+ im=overlay,
416
+ box=(int(x), int(y)),
417
+ mask=overlay,
418
+ )
419
+ logger.debug(
420
+ "Created overlay_on_transparent image with "
421
+ f"size={overlay_on_transparent.size}"
422
+ )
 
 
 
 
423
 
424
+ masks, mask_colors = extract_masks(
425
+ overlay_image=overlay_on_transparent,
426
+ morph_size=morph_size,
427
+ )
428
+ logger.debug(f"Extracted {len(masks)} masks from overlay")
429
 
430
+ response = (base.convert(mode="RGB"), masks, mask_colors)
431
+ logger.debug(f"Exiting overlay_images_core with result: {response!r}")
432
+
433
+ return response
434
+
435
+
436
+ def render_animation_frames(*, model_3mf, animate_frames, temp_dir):
437
+ """Render animation frames for the 3MF model."""
438
+ logger.debug(
439
+ f"Entering render_animation_frames with arguments: {locals()}"
440
+ )
441
+ template_path = os.path.join(os.path.dirname(__file__), "animate.scad.j2")
442
+ with open(template_path, "r") as f:
443
+ template_text = f.read()
444
+ template = jinja2.Template(template_text)
445
+ relative_model_path = os.path.relpath(str(model_3mf), start=str(temp_dir))
446
+ rendered_scad = template.render(model_path=relative_model_path)
447
+ animate_scad_path = temp_dir / "animate.scad"
448
+ animate_scad_path.write_text(rendered_scad)
449
+
450
+ output_prefix = temp_dir / "model_"
451
+ subprocess.run(
452
+ args=[
453
+ "openscad",
454
+ "--camera",
455
+ "0,0,10,0,0,0",
456
+ "--viewall",
457
+ "--animate",
458
+ str(animate_frames),
459
+ "--projection",
460
+ "perspective",
461
+ "-o",
462
+ f"{output_prefix}.png",
463
+ str(animate_scad_path),
464
+ ],
465
+ check=True,
466
+ )
467
+ logger.debug("Exiting render_animation_frames with result: None")
468
+ result = None
469
+
470
+ return result
471
+
472
+
473
+ def convert_frames_to_gif(*, temp_dir):
474
+ """Convert rendered frames to a GIF animation."""
475
+ logger.debug(f"Entering convert_frames_to_gif with arguments: {locals()}")
476
+ frame_files = sorted(temp_dir.glob("model_*.png"))
477
+ if not frame_files:
478
+ logger.debug("Exiting convert_frames_to_gif with result: None")
479
+ result = None
480
+ return result
481
+ frames = [Image.open(fp=fp) for fp in frame_files]
482
+ gif_path = temp_dir / "animation.gif"
483
+ frames[0].save(
484
+ fp=gif_path,
485
+ save_all=True,
486
+ append_images=frames[1:],
487
+ duration=100,
488
+ loop=0,
489
+ )
490
+ for f in frames[1:]:
491
+ f.close()
492
+ frames[0].close()
493
+ logger.debug(f"Exiting convert_frames_to_gif with result: {gif_path!r}")
494
+ result = gif_path
495
+
496
+ return result
497
 
498
 
499
  def generate_3mf_file(
500
  base_img,
 
 
501
  width,
502
  height,
503
  base_thickness,
504
  overlay_thickness,
505
  resolution,
506
+ front_masks,
507
+ front_mask_colors,
508
+ back_masks=None,
509
+ back_mask_colors=None,
510
  user_dir=None,
511
+ animate_frames=0,
512
  ):
513
+ """Generate a 3MF file and optional GIF animation."""
514
+ logger.debug(f"Entering generate_3mf_file with arguments: {locals()}")
515
+ if not front_masks or not front_mask_colors:
516
+ logger.debug("Exiting generate_3mf_file with result: None")
517
+ result = None
518
+ return result
519
+ logger.debug("Flipping front masks horizontally before generating 3MF")
520
+ front_masks = [flip_image_horizontally(image=mask) for mask in front_masks]
521
+ if base_img is not None:
522
+ logger.debug("Flipping base image horizontally before generating 3MF")
523
+ base_img = flip_image_horizontally(image=base_img)
524
  scad_path = generate_scad_file(
525
+ base_img=base_img,
526
+ width_mm=width,
527
+ height_mm=height,
528
+ base_thickness_mm=base_thickness,
529
+ overlay_thickness_mm=overlay_thickness,
530
+ resolution=resolution,
531
+ front_masks=front_masks,
532
+ front_mask_colors=front_mask_colors,
533
+ back_masks=back_masks,
534
+ back_mask_colors=back_mask_colors,
535
  )
536
  if not scad_path:
537
+ logger.debug("Exiting generate_3mf_file with result: None")
538
+ result = None
539
+ return result
540
  scad_path_obj = Path(scad_path)
541
  openscad_model_3mf = scad_path_obj.with_name("openscad_model.3mf")
542
  subprocess.run(
543
+ args=[
544
+ "openscad",
545
+ "-o",
546
+ str(openscad_model_3mf),
547
+ str(scad_path_obj),
548
+ ],
549
  check=True,
550
  )
551
  converted_model_3mf = openscad_model_3mf.with_name("converted_model.3mf")
552
  script_path = Path(__file__).parent / "3mf2mmuv3.py"
553
  subprocess.run(
554
+ args=[
555
  "python3",
556
  str(script_path),
557
  str(openscad_model_3mf),
 
561
  check=True,
562
  )
563
  temp_dir = scad_path_obj.parent
564
+ mask_paths_front = sorted(Path(temp_dir).glob("mask_front_*.png"))
565
+ mask_paths_back = sorted(Path(temp_dir).glob("mask_back_*.png"))
566
+ mask_paths = mask_paths_front + mask_paths_back
567
  random_string = "".join(
568
  random.choices(string.ascii_letters + string.digits, k=6)
569
  )
 
573
  zip_path = zip_dir / f"model_{random_string}.zip"
574
  else:
575
  zip_path = Path(tempfile.gettempdir()) / f"model_{random_string}.zip"
576
+ if animate_frames and animate_frames > 0:
577
+ render_animation_frames(
578
+ model_3mf=openscad_model_3mf,
579
+ animate_frames=animate_frames,
580
+ temp_dir=temp_dir,
581
+ )
582
+ gif_path = None
583
+ gif_path_to_return = None
584
+ if animate_frames and animate_frames > 0:
585
+ gif_path = convert_frames_to_gif(temp_dir=temp_dir)
586
+ if gif_path:
587
+ if user_dir:
588
+ dest_dir = Path(user_dir)
589
+ dest_dir.mkdir(parents=True, exist_ok=True)
590
+ gif_dest_path = dest_dir / gif_path.name
591
+ shutil.copy(src=gif_path, dst=gif_dest_path)
592
+ gif_path_to_return = str(gif_dest_path)
593
+ else:
594
+ gif_path_to_return = str(gif_path)
595
  with zipfile.ZipFile(
596
+ zip_path,
597
+ mode="w",
598
+ compression=zipfile.ZIP_DEFLATED,
599
+ compresslevel=9,
600
  ) as zf:
601
  zf.write(scad_path_obj, arcname=scad_path_obj.name)
602
  zf.write(openscad_model_3mf, arcname=openscad_model_3mf.name)
 
606
  if base_img is not None:
607
  base_img_path = Path(temp_dir) / "base.png"
608
  zf.write(base_img_path, arcname=base_img_path.name)
609
+ if animate_frames and animate_frames > 0:
610
+ animate_scad_path = temp_dir / "animate.scad"
611
+ zf.write(animate_scad_path, arcname=animate_scad_path.name)
612
+ frame_files = sorted(temp_dir.glob("model_*.png"))
613
+ for frame_file in frame_files:
614
+ zf.write(frame_file, arcname=frame_file.name)
615
+ if gif_path:
616
+ zf.write(gif_path, arcname=gif_path.name)
617
  shutil.rmtree(temp_dir)
618
+ logger.debug(f"Removed {temp_dir}")
619
+ result = (str(zip_path), gif_path_to_return)
620
+ logger.debug(f"Exiting generate_3mf_file with result: {result!r}")
621
+
622
+ return result
623
 
624
 
625
  def main():
626
+ """Main entry point for generating 3MF files from images."""
627
+ logger.debug(f"Entering main with arguments: {locals()}")
628
  parser = argparse.ArgumentParser(
629
  description="Generate 3MF from base and overlay images."
630
  )
 
 
631
  parser.add_argument(
632
+ "--base-image",
633
+ dest="base_image",
634
+ help="Path to base image (PNG, optional)",
635
+ default=None,
636
  )
637
  parser.add_argument(
638
+ "--front-image",
639
+ dest="front_image",
640
+ help="Path to front image (PNG, optional)",
641
+ required=False,
642
  )
643
  parser.add_argument(
644
+ "--back-image",
645
+ dest="back_image",
646
+ help="Path to back image (PNG, optional)",
647
+ default=None,
648
+ )
649
+ parser.add_argument(
650
+ "-o",
651
+ "--output",
652
+ default=None,
653
+ help="Output zip file path (optional)",
654
+ )
655
+ parser.add_argument(
656
+ "--width",
657
+ type=float,
658
+ default=100.0,
659
+ help="Width in mm",
660
+ )
661
+ parser.add_argument(
662
+ "--height",
663
+ type=float,
664
+ default=100.0,
665
+ help="Height in mm",
666
  )
667
  parser.add_argument(
668
  "--base-thickness",
 
689
  default=1,
690
  help="Morphology size for mask extraction",
691
  )
 
692
  parser.add_argument("--x", type=int, default=0, help="X offset (px)")
693
  parser.add_argument("--y", type=int, default=0, help="Y offset (px)")
694
  parser.add_argument(
695
+ "--rot",
696
+ type=int,
697
+ default=0,
698
+ help="Rotation (degrees)",
699
  )
700
  parser.add_argument("--zoom", type=float, default=1.0, help="Zoom factor")
701
  parser.add_argument(
 
710
  default=True,
711
  help="Use most common colors for quantization",
712
  )
713
+ parser.add_argument(
714
+ "--animate",
715
+ type=int,
716
+ default=0,
717
+ help="Number of frames to render for animation",
718
+ )
719
+ parser.add_argument(
720
+ "--debug",
721
+ action="store_true",
722
+ default=False,
723
+ help="Enable debug output",
724
+ )
725
+ parser.add_argument(
726
+ "--front-qr",
727
+ dest="front_qr",
728
+ help="Text to generate a QR code for front image",
729
+ default=None,
730
+ )
731
+ parser.add_argument(
732
+ "--back-qr",
733
+ dest="back_qr",
734
+ help="Text to generate a QR code for back image",
735
+ default=None,
736
+ )
737
  args = parser.parse_args()
738
 
739
+ if args.debug:
740
+ logger.setLevel(logging.DEBUG)
741
+ logger.debug("Debug logging enabled")
742
+
743
+ base_img = load_image_or_qr(path=args.base_image)
744
+ front_img = get_front_image(path=args.front_image, qr_text=args.front_qr)
745
+ back_img = load_image_or_qr(path=args.back_image, qr_text=args.back_qr)
746
+
747
+ logger.info(f"Parsed arguments: {args}")
748
+
749
+ _, front_masks, front_mask_colors = overlay_images_core(
750
+ base_img=base_img,
751
+ overlay_img=front_img,
752
+ x=args.x,
753
+ y=args.y,
754
+ rot=args.rot,
755
+ zoom=args.zoom,
756
+ indexed_colors=args.indexed_colors,
757
+ use_common_colors=args.use_common_colors,
758
+ morph_size=args.morph_size,
759
+ resolution=args.resolution,
760
+ )
761
+ if front_masks is None:
762
+ logger.error("Failed to generate masks from front image.")
763
+ logger.debug("Exiting main with result: None")
764
+
765
+ return
766
+
767
+ if back_img:
768
+ _, back_masks, back_mask_colors = overlay_images_core(
769
+ base_img=base_img,
770
+ overlay_img=back_img,
771
+ x=args.x,
772
+ y=args.y,
773
+ rot=args.rot,
774
+ zoom=args.zoom,
775
+ indexed_colors=args.indexed_colors,
776
+ use_common_colors=args.use_common_colors,
777
+ morph_size=args.morph_size,
778
+ resolution=args.resolution,
779
+ )
780
+ else:
781
+ back_masks = None
782
+ back_mask_colors = None
783
+
784
+ if not front_masks or not front_mask_colors:
785
+ logger.error("Failed to generate masks from front image.")
786
+ logger.debug("Exiting main with result: None")
787
+
788
  return
789
 
790
  zip_path = generate_3mf_file(
791
+ base_img=base_img,
792
+ front_masks=front_masks,
793
+ front_mask_colors=front_mask_colors,
794
+ back_masks=back_masks,
795
+ back_mask_colors=back_mask_colors,
796
+ width=args.width,
797
+ height=args.height,
798
+ base_thickness=args.base_thickness,
799
+ overlay_thickness=args.overlay_thickness,
800
+ resolution=args.resolution,
801
+ animate_frames=args.animate,
802
  )
803
 
804
  if zip_path is None:
805
+ logger.error("Failed to generate 3MF zip.")
806
+ logger.debug("Exiting main with result: None")
807
+
808
  return
809
 
810
  if args.output:
811
+ shutil.move(src=zip_path[0], dst=args.output)
812
+ logger.info(f"Generated zip file: {args.output}. Parsed args: {args}")
813
  else:
814
+ logger.info(f"Generated zip file: {zip_path}. Parsed args: {args}")
815
+ logger.debug("Exiting main with result: None")
816
 
817
 
818
  if __name__ == "__main__":