Yankkee commited on
Commit
4dc0a2b
·
verified ·
1 Parent(s): e136ff8

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -379
app.py DELETED
@@ -1,379 +0,0 @@
1
- """
2
- Vektor-Studio — PNG/JPG zu SVG
3
- Saubere Linienführung für generierte Bilder.
4
-
5
- Hugging Face Space (Gradio SDK). Start lokal: python app.py
6
- """
7
-
8
- import os
9
- import re
10
- import base64
11
- import tempfile
12
- import uuid
13
-
14
- import numpy as np
15
- from PIL import Image, ImageOps, ImageFilter
16
- import gradio as gr
17
- import vtracer
18
-
19
- MAX_SIDE = 2600 # Obergrenze nach dem Hochskalieren (Speed/Timeout-Schutz)
20
- OUT_DIR = os.path.join(tempfile.gettempdir(), "svg_out")
21
- os.makedirs(OUT_DIR, exist_ok=True)
22
-
23
-
24
- # --------------------------------------------------------------------------
25
- # Bildvorbereitung
26
- # --------------------------------------------------------------------------
27
- def flatten(img: Image.Image) -> Image.Image:
28
- """Alpha auf Weiß legen, damit Transparenz nicht als Schwarz getract wird."""
29
- img = ImageOps.exif_transpose(img)
30
- if img.mode in ("RGBA", "LA", "P"):
31
- img = img.convert("RGBA")
32
- bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
33
- img = Image.alpha_composite(bg, img)
34
- return img.convert("RGB")
35
-
36
-
37
- def otsu_threshold(arr: np.ndarray) -> int:
38
- hist = np.bincount(arr.ravel(), minlength=256).astype(np.float64)
39
- total = hist.sum()
40
- omega = np.cumsum(hist) / total
41
- mu = np.cumsum(hist * np.arange(256)) / total
42
- mu_t = mu[-1]
43
- denom = omega * (1.0 - omega)
44
- denom[denom == 0] = 1e-12
45
- sigma_b = (mu_t * omega - mu) ** 2 / denom
46
- return int(np.argmax(sigma_b))
47
-
48
-
49
- def binarize(gray: Image.Image, method: str, level: int, block: int, offset: int) -> np.ndarray:
50
- """Gibt eine Bool-Maske zurück: True = Tinte (Linie)."""
51
- arr = np.asarray(gray, dtype=np.uint8)
52
-
53
- if method == "Adaptiv (ungleiche Beleuchtung)":
54
- radius = max(1, int(block) // 2)
55
- local = np.asarray(gray.filter(ImageFilter.BoxBlur(radius)), dtype=np.int16)
56
- return arr.astype(np.int16) < (local - int(offset))
57
-
58
- if method == "Otsu (automatisch)":
59
- level = otsu_threshold(arr)
60
-
61
- return arr < int(level)
62
-
63
-
64
- def zhang_suen(mask: np.ndarray, max_iter: int = 60) -> np.ndarray:
65
- """Skelettierung: reduziert jede Linie auf 1 px Mittellinie."""
66
- img = mask.copy()
67
- for _ in range(max_iter):
68
- changed = False
69
- for step in (0, 1):
70
- P = np.pad(img, 1, constant_values=False)
71
- P2, P3, P4 = P[:-2, 1:-1], P[:-2, 2:], P[1:-1, 2:]
72
- P5, P6, P7 = P[2:, 2:], P[2:, 1:-1], P[2:, :-2]
73
- P8, P9 = P[1:-1, :-2], P[:-2, :-2]
74
-
75
- ring = [P2, P3, P4, P5, P6, P7, P8, P9, P2]
76
- B = sum(x.astype(np.uint8) for x in ring[:8])
77
- A = np.zeros(img.shape, np.uint8)
78
- for i in range(8):
79
- A += (~ring[i] & ring[i + 1]).astype(np.uint8)
80
-
81
- base = img & (B >= 2) & (B <= 6) & (A == 1)
82
- if step == 0:
83
- cond = base & ~(P2 & P4 & P6) & ~(P4 & P6 & P8)
84
- else:
85
- cond = base & ~(P2 & P4 & P8) & ~(P2 & P6 & P8)
86
-
87
- if cond.any():
88
- img &= ~cond
89
- changed = True
90
- if not changed:
91
- break
92
- return img
93
-
94
-
95
- def set_stroke_width(mask: np.ndarray, width: int) -> np.ndarray:
96
- """Skelett wieder auf eine gleichmäßige Strichstärke aufdicken."""
97
- if width <= 1:
98
- return mask
99
- ink = Image.fromarray(np.where(mask, 255, 0).astype(np.uint8))
100
- size = width if width % 2 == 1 else width + 1
101
- ink = ink.filter(ImageFilter.MaxFilter(size))
102
- return np.asarray(ink) > 127
103
-
104
-
105
- def cap_size(img: Image.Image) -> Image.Image:
106
- w, h = img.size
107
- if max(w, h) <= MAX_SIDE:
108
- return img
109
- s = MAX_SIDE / max(w, h)
110
- return img.resize((max(1, int(w * s)), max(1, int(h * s))), Image.LANCZOS)
111
-
112
-
113
- # --------------------------------------------------------------------------
114
- # SVG-Nachbearbeitung
115
- # --------------------------------------------------------------------------
116
- def strip_background(svg: str) -> str:
117
- """Entfernt fast-weiße Füllflächen (typisch: der Hintergrund im Farbmodus)."""
118
- def is_light(hexcol: str) -> bool:
119
- r, g, b = (int(hexcol[i:i + 2], 16) for i in (1, 3, 5))
120
- return r > 243 and g > 243 and b > 243
121
-
122
- return re.sub(
123
- r'<path[^>]*fill="(#[0-9a-fA-F]{6})"[^>]*/>\s*',
124
- lambda m: "" if is_light(m.group(1)) else m.group(0),
125
- svg,
126
- )
127
-
128
-
129
- def recolor(svg: str, color: str) -> str:
130
- return re.sub(r'fill="#000000"', f'fill="{color}"', svg)
131
-
132
-
133
- def rescale_root(svg: str, out_w: int, out_h: int) -> str:
134
- """viewBox ergänzen und die Anzeigegröße auf die Originalmaße setzen."""
135
- m = re.search(r'<svg([^>]*)width="(\d+)"\s+height="(\d+)"', svg)
136
- if not m:
137
- return svg
138
- vw, vh = m.group(2), m.group(3)
139
- new_tag = (
140
- f'<svg{m.group(1)}width="{out_w}" height="{out_h}" '
141
- f'viewBox="0 0 {vw} {vh}"'
142
- )
143
- return svg[: m.start()] + new_tag + svg[m.end():]
144
-
145
-
146
- def preview_html(svg: str, label: str) -> str:
147
- if len(svg) > 4_000_000:
148
- return (
149
- f"<div class='vs-note'>{label}: Die Datei ist sehr groß "
150
- f"({len(svg) // 1024} KB) — bitte herunterladen statt hier ansehen. "
151
- "Tipp: „Kleine Flecken filtern“ erhöhen.</div>"
152
- )
153
- b64 = base64.b64encode(svg.encode("utf-8")).decode("ascii")
154
- return (
155
- "<div class='vs-stage'>"
156
- f"<img src='data:image/svg+xml;base64,{b64}' alt='{label}'/>"
157
- "</div>"
158
- )
159
-
160
-
161
- # --------------------------------------------------------------------------
162
- # Hauptfunktion
163
- # --------------------------------------------------------------------------
164
- def vectorize(
165
- image,
166
- mode,
167
- invert,
168
- thr_method,
169
- thr_level,
170
- block,
171
- offset,
172
- denoise,
173
- upscale,
174
- uniform,
175
- stroke_w,
176
- curve_mode,
177
- speckle,
178
- corner,
179
- length_thr,
180
- splice,
181
- precision,
182
- colors,
183
- layer_diff,
184
- hierarchical,
185
- drop_bg,
186
- line_color,
187
- progress=gr.Progress(),
188
- ):
189
- if image is None:
190
- raise gr.Error("Lade zuerst ein PNG oder JPG hoch.")
191
-
192
- progress(0.1, desc="Bild vorbereiten")
193
- src = flatten(image)
194
- orig_w, orig_h = src.size
195
-
196
- work = src
197
- if upscale > 1:
198
- work = work.resize(
199
- (int(orig_w * upscale), int(orig_h * upscale)), Image.LANCZOS
200
- )
201
- work = cap_size(work)
202
-
203
- line_mode = mode.startswith("Strich")
204
-
205
- if line_mode:
206
- progress(0.3, desc="Linien freistellen")
207
- gray = ImageOps.autocontrast(work.convert("L"), cutoff=1)
208
- if invert:
209
- gray = ImageOps.invert(gray)
210
- if denoise > 0:
211
- gray = gray.filter(ImageFilter.MedianFilter(int(denoise) * 2 + 1))
212
-
213
- mask = binarize(gray, thr_method, thr_level, block, offset)
214
-
215
- if uniform:
216
- progress(0.45, desc="Mittellinien berechnen")
217
- mask = set_stroke_width(zhang_suen(mask), int(stroke_w))
218
-
219
- prepped = Image.fromarray(np.where(mask, 0, 255).astype(np.uint8)).convert("RGB")
220
- else:
221
- progress(0.35, desc="Farbflächen reduzieren")
222
- prepped = work
223
- if denoise > 0:
224
- prepped = prepped.filter(ImageFilter.MedianFilter(int(denoise) * 2 + 1))
225
-
226
- stem = uuid.uuid4().hex[:10]
227
- tmp_png = os.path.join(OUT_DIR, f"{stem}.png")
228
- out_svg = os.path.join(OUT_DIR, "vektor.svg" if line_mode else "vektor-farbe.svg")
229
- prepped.save(tmp_png)
230
-
231
- progress(0.6, desc="Pfade tracen")
232
- vtracer.convert_image_to_svg_py(
233
- tmp_png,
234
- out_svg,
235
- colormode="binary" if line_mode else "color",
236
- hierarchical="cutout" if hierarchical == "Ausgeschnitten" else "stacked",
237
- mode="polygon" if curve_mode.startswith("Polygon") else "spline",
238
- filter_speckle=int(speckle),
239
- color_precision=int(colors),
240
- layer_difference=int(layer_diff),
241
- corner_threshold=int(corner),
242
- length_threshold=float(length_thr),
243
- max_iterations=10,
244
- splice_threshold=int(splice),
245
- path_precision=int(precision),
246
- )
247
-
248
- progress(0.85, desc="SVG aufräumen")
249
- svg = open(out_svg, "r", encoding="utf-8").read()
250
- if drop_bg:
251
- svg = strip_background(svg)
252
- if line_mode:
253
- svg = recolor(svg, line_color)
254
- svg = rescale_root(svg, orig_w, orig_h)
255
-
256
- with open(out_svg, "w", encoding="utf-8") as f:
257
- f.write(svg)
258
- try:
259
- os.remove(tmp_png)
260
- except OSError:
261
- pass
262
-
263
- n_paths = svg.count("<path")
264
- stats = (
265
- f"**{n_paths} Pfade** · {len(svg) / 1024:.1f} KB · "
266
- f"Zeichenfläche {orig_w}×{orig_h} px · getract bei {prepped.size[0]}×{prepped.size[1]} px"
267
- )
268
- return preview_html(svg, "Vektorergebnis"), out_svg, stats
269
-
270
-
271
- # --------------------------------------------------------------------------
272
- # Oberfläche
273
- # --------------------------------------------------------------------------
274
- CSS = """
275
- .vs-stage {
276
- background-color: #f7f5f2;
277
- background-image:
278
- linear-gradient(45deg, #e6e2dc 25%, transparent 25%, transparent 75%, #e6e2dc 75%),
279
- linear-gradient(45deg, #e6e2dc 25%, transparent 25%, transparent 75%, #e6e2dc 75%);
280
- background-size: 18px 18px;
281
- background-position: 0 0, 9px 9px;
282
- border-radius: 10px; padding: 14px; min-height: 240px;
283
- display: flex; align-items: center; justify-content: center;
284
- }
285
- .vs-stage img { max-width: 100%; max-height: 62vh; }
286
- .vs-note { padding: 18px; font-size: 0.9rem; line-height: 1.5; }
287
- footer { display: none !important; }
288
- """
289
-
290
- # Gradio 6 erwartet theme/css in launch(), Gradio 5 im Blocks-Konstruktor.
291
- _MAJOR = int(gr.__version__.split(".")[0])
292
- _STYLE = {"theme": gr.themes.Soft(), "css": CSS}
293
- _BLOCKS_KW = {} if _MAJOR >= 6 else _STYLE
294
- _LAUNCH_KW = _STYLE if _MAJOR >= 6 else {}
295
-
296
- with gr.Blocks(title="PNG/JPG → SVG", **_BLOCKS_KW) as demo:
297
- gr.Markdown(
298
- "## PNG / JPG → SVG\n"
299
- "Vektorisiert Bitmaps zu sauberen, skalierbaren Pfaden. "
300
- "Für KI-generierte Zeichnungen, Logos und Skizzen."
301
- )
302
-
303
- with gr.Row():
304
- # ---------------- linke Spalte: Eingabe + Regler ----------------
305
- with gr.Column(scale=4):
306
- image = gr.Image(label="Bild", type="pil", sources=["upload", "clipboard"],
307
- image_mode="RGBA", height=300)
308
- mode = gr.Radio(
309
- ["Strichzeichnung (Schwarzweiß)", "Farbig (Flächen)"],
310
- value="Strichzeichnung (Schwarzweiß)",
311
- label="Modus",
312
- )
313
- run = gr.Button("Vektorisieren", variant="primary")
314
-
315
- with gr.Accordion("Linien freistellen", open=True) as line_box:
316
- thr_method = gr.Radio(
317
- ["Otsu (automatisch)", "Global", "Adaptiv (ungleiche Beleuchtung)"],
318
- value="Otsu (automatisch)",
319
- label="Schwellenwert",
320
- )
321
- thr_level = gr.Slider(0, 255, 128, step=1, label="Grenzwert (nur Global)")
322
- block = gr.Slider(5, 151, 41, step=2, label="Fensterbreite (nur Adaptiv)")
323
- offset = gr.Slider(0, 40, 8, step=1, label="Empfindlichkeit (nur Adaptiv)")
324
- invert = gr.Checkbox(False, label="Invertieren (helle Linien auf dunklem Grund)")
325
- denoise = gr.Slider(0, 3, 1, step=1, label="Rauschen glätten")
326
- upscale = gr.Slider(1, 4, 2, step=1,
327
- label="Vorher hochskalieren (glattere Kurven)")
328
- uniform = gr.Checkbox(
329
- False, label="Gleichmäßige Strichstärke (Mittellinie berechnen)"
330
- )
331
- stroke_w = gr.Slider(1, 9, 3, step=2, label="Strichstärke in px")
332
-
333
- with gr.Accordion("Pfadqualität", open=False):
334
- curve_mode = gr.Radio(
335
- ["Spline (weiche Kurven)", "Polygon (harte Kanten)"],
336
- value="Spline (weiche Kurven)", label="Kurventyp",
337
- )
338
- speckle = gr.Slider(0, 40, 6, step=1, label="Kleine Flecken filtern")
339
- corner = gr.Slider(0, 180, 60, step=1, label="Eckenschwelle")
340
- length_thr = gr.Slider(0.5, 10, 4, step=0.5, label="Kürzeste Segmentlänge")
341
- splice = gr.Slider(0, 180, 45, step=1, label="Kurven verbinden")
342
- precision = gr.Slider(1, 8, 4, step=1, label="Koordinatengenauigkeit")
343
-
344
- with gr.Accordion("Farbe & Ausgabe", open=False):
345
- colors = gr.Slider(1, 8, 6, step=1, label="Farbtiefe (nur Farbmodus)")
346
- layer_diff = gr.Slider(0, 64, 16, step=1, label="Ebenenabstand (nur Farbmodus)")
347
- hierarchical = gr.Radio(
348
- ["Gestapelt", "Ausgeschnitten"], value="Gestapelt", label="Ebenenaufbau"
349
- )
350
- drop_bg = gr.Checkbox(True, label="Weißen Hintergrund entfernen")
351
- line_color = gr.ColorPicker("#111111", label="Linienfarbe")
352
-
353
- # ---------------- rechte Spalte: Ergebnis ----------------
354
- with gr.Column(scale=6):
355
- preview = gr.HTML("<div class='vs-stage'><span class='vs-note'>"
356
- "Das Ergebnis erscheint hier.</span></div>",
357
- label="Vorschau")
358
- stats = gr.Markdown("")
359
- download = gr.File(label="SVG herunterladen", height=90)
360
-
361
- gr.Markdown(
362
- "**Für saubere Linien:** hochskalieren auf 2–3×, Spline-Kurven, Flecken-Filter "
363
- "auf 6–12. Wirkt eine Zeichnung ausgefranst, hilft „Gleichmäßige Strichstärke“ — "
364
- "das zieht jede Linie auf eine Mittelachse und zeichnet sie mit konstanter Breite neu."
365
- )
366
-
367
- def toggle(m):
368
- return gr.update(open=m.startswith("Strich"))
369
-
370
- mode.change(toggle, mode, line_box)
371
-
372
- inputs = [image, mode, invert, thr_method, thr_level, block, offset, denoise,
373
- upscale, uniform, stroke_w, curve_mode, speckle, corner, length_thr,
374
- splice, precision, colors, layer_diff, hierarchical, drop_bg, line_color]
375
-
376
- run.click(vectorize, inputs, [preview, download, stats])
377
-
378
- if __name__ == "__main__":
379
- demo.queue(max_size=20).launch(**_LAUNCH_KW)