Spaces:
Sleeping
Sleeping
Commit ·
3f8d8c1
1
Parent(s): 5f01861
feat: add QR code generation, animation, and UI improvements
Browse filesAdd 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 +5 -4
- examples/back/jopa.png +0 -0
- examples/base/comb-simple.png +0 -0
- examples/front/rgb.png +0 -0
- requirements.txt +3 -2
- src/.gitignore +0 -1
- src/animate.scad.j2 +1 -0
- src/app.py +701 -229
- src/input.scad.j2 +18 -4
- src/png23mf.py +561 -213
Dockerfile
CHANGED
|
@@ -11,15 +11,16 @@ RUN apt-get update \
|
|
| 11 |
|
| 12 |
FROM base AS developer
|
| 13 |
|
| 14 |
-
ENV
|
| 15 |
|
| 16 |
-
CMD ["
|
| 17 |
|
| 18 |
|
| 19 |
FROM base AS executor
|
| 20 |
|
| 21 |
WORKDIR /app
|
| 22 |
|
| 23 |
-
COPY src
|
|
|
|
| 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.
|
| 2 |
-
ruff==0.15.
|
| 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 |
-
|
| 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
|
|
|
|
| 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 |
-
|
| 45 |
-
|
| 46 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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.
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
with gr.Column():
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 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 |
-
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
-
|
| 258 |
-
fn=
|
| 259 |
-
inputs=[
|
| 260 |
-
|
|
|
|
| 261 |
x_slider,
|
| 262 |
y_slider,
|
| 263 |
rot_slider,
|
| 264 |
zoom_slider,
|
| 265 |
indexed_slider,
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
],
|
| 269 |
)
|
| 270 |
|
| 271 |
-
|
| 272 |
-
fn=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 323 |
inputs=[
|
| 324 |
base_img,
|
| 325 |
-
|
| 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 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
generate_button,
|
| 345 |
],
|
| 346 |
)
|
| 347 |
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
fn=update_slider_ranges,
|
| 350 |
-
inputs=[base_img,
|
| 351 |
outputs=[
|
| 352 |
x_slider,
|
| 353 |
y_slider,
|
|
@@ -359,30 +714,147 @@ with gr.Blocks() as demo:
|
|
| 359 |
],
|
| 360 |
)
|
| 361 |
|
| 362 |
-
|
| 363 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 12 |
scale([{{ scale_x }}, {{ scale_y }}, {{ overlay_scale_z }}]) {
|
| 13 |
-
{% for mask_path, mask_color in
|
| 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 |
-
|
|
|
|
| 25 |
}
|
| 26 |
{% endif %}
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
| 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 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
| 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),
|
|
|
|
| 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]
|
| 50 |
else:
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 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 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 79 |
(mask_path.name, mask_color)
|
| 80 |
-
for mask_path, mask_color in zip(
|
| 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
|
| 86 |
)
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 111 |
|
|
|
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
| 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[
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 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 |
-
|
| 160 |
-
|
|
|
|
|
|
|
| 161 |
alpha_mask = arr[..., 3] > 0
|
| 162 |
rgb_pixels = arr[alpha_mask][:, :3]
|
| 163 |
if rgb_pixels.size == 0:
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
sorted_indices = np.argsort(-counts)
|
| 167 |
sorted_colors = unique_colors[sorted_indices]
|
| 168 |
top_colors = sorted_colors[:num_colors]
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
|
| 190 |
def overlay_images_core(
|
| 191 |
-
|
|
|
|
| 192 |
overlay_img,
|
| 193 |
-
x,
|
| 194 |
-
y,
|
| 195 |
-
rot,
|
| 196 |
-
zoom,
|
| 197 |
-
indexed_colors,
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
height,
|
| 202 |
-
use_common_colors,
|
| 203 |
-
morph_size,
|
| 204 |
-
resolution,
|
| 205 |
):
|
|
|
|
|
|
|
| 206 |
if overlay_img is None:
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
if base_img is None:
|
| 211 |
-
base =
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 220 |
-
new_h_base =
|
| 221 |
base = base.resize(
|
| 222 |
-
(new_w_base, new_h_base),
|
|
|
|
| 223 |
)
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
overlay = overlay.resize(
|
| 227 |
-
(int(w * zoom), int(h * zoom)),
|
|
|
|
| 228 |
)
|
| 229 |
-
overlay
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
alpha = overlay.split()[3]
|
| 231 |
-
rgb = overlay.convert("RGB")
|
|
|
|
| 232 |
if use_common_colors:
|
| 233 |
-
common_colors = get_most_common_colors(
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 240 |
-
|
| 241 |
-
|
| 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 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 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 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
):
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
scad_path = generate_scad_file(
|
| 306 |
-
base_img,
|
| 307 |
-
width,
|
| 308 |
-
height,
|
| 309 |
-
base_thickness,
|
| 310 |
-
overlay_thickness,
|
| 311 |
-
resolution,
|
| 312 |
-
|
| 313 |
-
|
|
|
|
|
|
|
| 314 |
)
|
| 315 |
if not scad_path:
|
| 316 |
-
|
|
|
|
|
|
|
| 317 |
scad_path_obj = Path(scad_path)
|
| 318 |
openscad_model_3mf = scad_path_obj.with_name("openscad_model.3mf")
|
| 319 |
subprocess.run(
|
| 320 |
-
[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"-
|
|
|
|
|
|
|
|
|
|
| 369 |
)
|
| 370 |
parser.add_argument(
|
| 371 |
-
"--
|
|
|
|
|
|
|
|
|
|
| 372 |
)
|
| 373 |
parser.add_argument(
|
| 374 |
-
"--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
args.
|
| 436 |
-
args.
|
| 437 |
-
args.
|
| 438 |
-
args.
|
| 439 |
-
args.
|
| 440 |
-
args.
|
| 441 |
-
args.
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
if
|
| 445 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
return
|
| 447 |
|
| 448 |
zip_path = generate_3mf_file(
|
| 449 |
-
base_img,
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
args.
|
| 455 |
-
args.
|
| 456 |
-
args.
|
|
|
|
|
|
|
|
|
|
| 457 |
)
|
| 458 |
|
| 459 |
if zip_path is None:
|
| 460 |
-
|
|
|
|
|
|
|
| 461 |
return
|
| 462 |
|
| 463 |
if args.output:
|
| 464 |
-
shutil.move(zip_path, args.output)
|
| 465 |
-
|
| 466 |
else:
|
| 467 |
-
|
|
|
|
| 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__":
|