arnabai commited on
Commit
ab3cca1
Β·
verified Β·
1 Parent(s): f1649b5

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py.txt +41 -0
  2. raster_to_dxf.py +463 -0
  3. requirements.txt +6 -0
app.py.txt ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import tempfile, os
3
+ from raster_to_dxf import convert
4
+
5
+ def run_convert(image_path, upscale, denoise, hough_min, hough_gap, scale_mm):
6
+ settings = {
7
+ "upscale": upscale,
8
+ "denoise_h": int(denoise),
9
+ "hough_min_len": int(hough_min),
10
+ "hough_max_gap": int(hough_gap),
11
+ "output_scale_mm": scale_mm,
12
+ }
13
+ out_path = tempfile.mktemp(suffix=".dxf")
14
+ stats = convert(image_path, out_path, settings)
15
+ summary = (f"βœ… Lines: {stats['lines']} | "
16
+ f"Polylines: {stats['polylines']} | "
17
+ f"Circles: {stats['circles']}")
18
+ return out_path, summary
19
+
20
+ with gr.Blocks(title="VectorForge β€” PNG to DXF") as demo:
21
+ gr.Markdown("# ⬑ VectorForge\n### Raster-to-Vector converter Β· PNG β†’ DXF")
22
+
23
+ with gr.Row():
24
+ with gr.Column():
25
+ image_in = gr.Image(type="filepath", label="Upload PNG / JPG / BMP")
26
+ upscale = gr.Slider(1, 4, value=2, step=0.5, label="Upscale factor")
27
+ denoise = gr.Slider(1, 20, value=6, step=1, label="Denoise strength")
28
+ hmin = gr.Slider(5, 80, value=18, step=1, label="Hough min line length (px)")
29
+ hgap = gr.Slider(2, 40, value=10, step=1, label="Hough max gap (px)")
30
+ scale = gr.Slider(0.05, 1, value=0.1, step=0.05, label="Output scale (mm/px)")
31
+ btn = gr.Button("⚑ Convert to DXF", variant="primary")
32
+
33
+ with gr.Column():
34
+ dxf_out = gr.File(label="Download DXF")
35
+ status = gr.Textbox(label="Stats", interactive=False)
36
+
37
+ btn.click(run_convert,
38
+ inputs=[image_in, upscale, denoise, hmin, hgap, scale],
39
+ outputs=[dxf_out, status])
40
+
41
+ demo.launch()
raster_to_dxf.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Production-grade Raster-to-Vector converter: PNG -> DXF
4
+ Optimised for engineering/architectural drawings (like HVAC details).
5
+ """
6
+
7
+ import sys
8
+ import argparse
9
+ import numpy as np
10
+ import cv2
11
+ import ezdxf
12
+ from ezdxf import units
13
+ from pathlib import Path
14
+ from dataclasses import dataclass, field
15
+ from typing import List, Tuple, Optional
16
+ import math
17
+
18
+
19
+ # ─── Data types ──────────────────────────────────────────────────────────────
20
+
21
+ @dataclass
22
+ class VectorLine:
23
+ x1: float; y1: float; x2: float; y2: float
24
+ layer: str = "LINES"
25
+
26
+ @dataclass
27
+ class VectorPolyline:
28
+ points: List[Tuple[float, float]]
29
+ closed: bool = False
30
+ layer: str = "CONTOURS"
31
+
32
+ @dataclass
33
+ class VectorCircle:
34
+ cx: float; cy: float; r: float
35
+ layer: str = "CIRCLES"
36
+
37
+ @dataclass
38
+ class VectorArc:
39
+ cx: float; cy: float; r: float
40
+ start_angle: float; end_angle: float
41
+ layer: str = "ARCS"
42
+
43
+ @dataclass
44
+ class VectorText:
45
+ x: float; y: float; text: str; height: float = 2.5
46
+ layer: str = "TEXT"
47
+
48
+ @dataclass
49
+ class VectorResult:
50
+ lines: List[VectorLine] = field(default_factory=list)
51
+ polylines: List[VectorPolyline] = field(default_factory=list)
52
+ circles: List[VectorCircle] = field(default_factory=list)
53
+ arcs: List[VectorArc] = field(default_factory=list)
54
+ texts: List[VectorText] = field(default_factory=list)
55
+ width_px: int = 0
56
+ height_px: int = 0
57
+ scale: float = 1.0 # px β†’ mm
58
+
59
+
60
+ # ─── Image pre-processing ────────────────────────────────────────────────────
61
+
62
+ def preprocess(img_bgr: np.ndarray, settings: dict) -> Tuple[np.ndarray, np.ndarray]:
63
+ """Return (binary_clean, gray_original)."""
64
+ gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
65
+
66
+ # Upscale if small
67
+ h, w = gray.shape
68
+ scale_up = settings.get("upscale", 2.0)
69
+ if min(h, w) < 800 or scale_up != 1.0:
70
+ gray = cv2.resize(gray, (int(w * scale_up), int(h * scale_up)),
71
+ interpolation=cv2.INTER_CUBIC)
72
+
73
+ # Denoise
74
+ denoised = cv2.fastNlMeansDenoising(gray, h=settings.get("denoise_h", 8),
75
+ templateWindowSize=7, searchWindowSize=21)
76
+
77
+ # Adaptive threshold + Otsu combined
78
+ block = settings.get("adaptive_block", 51)
79
+ C = settings.get("adaptive_C", 10)
80
+ adapt = cv2.adaptiveThreshold(denoised, 255,
81
+ cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
82
+ cv2.THRESH_BINARY_INV, block, C)
83
+
84
+ _, otsu = cv2.threshold(denoised, 0, 255,
85
+ cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
86
+ binary = cv2.bitwise_or(adapt, otsu)
87
+
88
+ # Morphological clean-up
89
+ k_open = settings.get("morph_open", 2)
90
+ k_close = settings.get("morph_close", 3)
91
+ kernel_o = cv2.getStructuringElement(cv2.MORPH_RECT, (k_open, k_open))
92
+ kernel_c = cv2.getStructuringElement(cv2.MORPH_RECT, (k_close, k_close))
93
+ binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel_o)
94
+ binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel_c)
95
+
96
+ return binary, gray
97
+
98
+
99
+ # ─── Skeleton / thinning for line extraction ─────────────────────────────────
100
+
101
+ def thin_image(binary: np.ndarray) -> np.ndarray:
102
+ """Skeletonise binary image (Zhang-Suen via scikit-image)."""
103
+ from skimage.morphology import skeletonize as sk_skeletonize
104
+ bool_img = (binary > 0)
105
+ skel = sk_skeletonize(bool_img)
106
+ return (skel.astype(np.uint8) * 255)
107
+
108
+
109
+ # ─── Probabilistic Hough lines ───────────────────────────────────────────────
110
+
111
+ def extract_hough_lines(thin: np.ndarray, settings: dict,
112
+ scale: float) -> List[VectorLine]:
113
+ lines_out = []
114
+ rho = settings.get("hough_rho", 1)
115
+ theta = np.pi / 180 * settings.get("hough_theta_deg", 1)
116
+ threshold = settings.get("hough_threshold", 30)
117
+ min_len = settings.get("hough_min_len", 20)
118
+ max_gap = settings.get("hough_max_gap", 8)
119
+
120
+ detected = cv2.HoughLinesP(thin, rho, theta, threshold,
121
+ minLineLength=min_len, maxLineGap=max_gap)
122
+ h = thin.shape[0]
123
+ if detected is not None:
124
+ for ln in detected:
125
+ x1, y1, x2, y2 = ln[0]
126
+ lines_out.append(VectorLine(
127
+ x1 * scale, (h - y1) * scale,
128
+ x2 * scale, (h - y2) * scale,
129
+ layer="LINES"
130
+ ))
131
+ return lines_out
132
+
133
+
134
+ # ─── Contour extraction (closed shapes, arcs, circles) ───────────────────────
135
+
136
+ def _fit_circle(pts):
137
+ """Algebraic circle fit (KΓ₯sa method)."""
138
+ x = pts[:, 0].astype(float)
139
+ y = pts[:, 1].astype(float)
140
+ A = np.column_stack([x, y, np.ones(len(x))])
141
+ b = x**2 + y**2
142
+ result = np.linalg.lstsq(A, b, rcond=None)
143
+ c = result[0]
144
+ cx = c[0] / 2
145
+ cy = c[1] / 2
146
+ r = math.sqrt(c[2] + cx**2 + cy**2)
147
+ residuals = np.sqrt((x - cx)**2 + (y - cy)**2) - r
148
+ rmse = np.sqrt(np.mean(residuals**2))
149
+ return cx, cy, r, rmse
150
+
151
+
152
+ def _is_circular(cnt, tol=0.15) -> Optional[Tuple[float, float, float]]:
153
+ if len(cnt) < 20:
154
+ return None
155
+ pts = cnt[:, 0, :]
156
+ cx, cy, r, rmse = _fit_circle(pts)
157
+ if r < 3:
158
+ return None
159
+ if rmse / r < tol:
160
+ return cx, cy, r
161
+ return None
162
+
163
+
164
+ def extract_contours(binary: np.ndarray, settings: dict,
165
+ scale: float) -> Tuple[List[VectorPolyline],
166
+ List[VectorCircle],
167
+ List[VectorArc]]:
168
+ polylines_out = []
169
+ circles_out = []
170
+ arcs_out = []
171
+
172
+ min_area = settings.get("contour_min_area", 50)
173
+ epsilon_r = settings.get("contour_epsilon_ratio", 0.004)
174
+ h = binary.shape[0]
175
+
176
+ contours, hierarchy = cv2.findContours(binary, cv2.RETR_CCOMP,
177
+ cv2.CHAIN_APPROX_TC89_KCOS)
178
+ if hierarchy is None:
179
+ return polylines_out, circles_out, arcs_out
180
+
181
+ for i, cnt in enumerate(contours):
182
+ area = cv2.contourArea(cnt)
183
+ if area < min_area:
184
+ continue
185
+
186
+ # Try circle
187
+ circle = _is_circular(cnt)
188
+ if circle:
189
+ cx, cy, r = circle
190
+ circles_out.append(VectorCircle(
191
+ cx * scale, (h - cy) * scale, r * scale
192
+ ))
193
+ continue
194
+
195
+ # Approximate polygon / spline
196
+ peri = cv2.arcLength(cnt, True)
197
+ epsilon = epsilon_r * peri
198
+ approx = cv2.approxPolyDP(cnt, epsilon, True)
199
+ if len(approx) < 2:
200
+ continue
201
+
202
+ pts = [(p[0][0] * scale, (h - p[0][1]) * scale) for p in approx]
203
+ is_closed = (hierarchy[0][i][2] >= 0 or
204
+ cv2.isContourConvex(approx) or
205
+ len(pts) > 4)
206
+ polylines_out.append(VectorPolyline(pts, closed=is_closed))
207
+
208
+ return polylines_out, circles_out, arcs_out
209
+
210
+
211
+ # ─── Line merging / deduplication ────────────────────────────────────────────
212
+
213
+ def _line_angle(vl: VectorLine) -> float:
214
+ return math.atan2(vl.y2 - vl.y1, vl.x2 - vl.x1)
215
+
216
+
217
+ def _line_length(vl: VectorLine) -> float:
218
+ return math.hypot(vl.x2 - vl.x1, vl.y2 - vl.y1)
219
+
220
+
221
+ def _point_to_line_dist(px, py, x1, y1, x2, y2) -> float:
222
+ dx, dy = x2 - x1, y2 - y1
223
+ denom = math.hypot(dx, dy)
224
+ if denom < 1e-9:
225
+ return math.hypot(px - x1, py - y1)
226
+ return abs(dy * px - dx * py + x2 * y1 - y2 * x1) / denom
227
+
228
+
229
+ def merge_lines(lines: List[VectorLine],
230
+ angle_tol: float = 3.0,
231
+ dist_tol: float = 2.5) -> List[VectorLine]:
232
+ """Merge nearly collinear line segments."""
233
+ if not lines:
234
+ return lines
235
+ angle_tol_rad = math.radians(angle_tol)
236
+ merged = []
237
+ used = [False] * len(lines)
238
+
239
+ for i, a in enumerate(lines):
240
+ if used[i]:
241
+ continue
242
+ ang_a = _line_angle(a) % math.pi
243
+ group = [a]
244
+ used[i] = True
245
+ for j, b in enumerate(lines):
246
+ if used[j]:
247
+ continue
248
+ ang_b = _line_angle(b) % math.pi
249
+ da = min(abs(ang_a - ang_b), math.pi - abs(ang_a - ang_b))
250
+ if da > angle_tol_rad:
251
+ continue
252
+ d = _point_to_line_dist(b.x1, b.y1, a.x1, a.y1, a.x2, a.y2)
253
+ if d > dist_tol:
254
+ continue
255
+ group.append(b)
256
+ used[j] = True
257
+
258
+ # Longest span in the group
259
+ pts = [(g.x1, g.y1) for g in group] + [(g.x2, g.y2) for g in group]
260
+ best_len = -1
261
+ bx1 = bx2 = by1 = by2 = 0.0
262
+ for p1 in pts:
263
+ for p2 in pts:
264
+ l = math.hypot(p2[0] - p1[0], p2[1] - p1[1])
265
+ if l > best_len:
266
+ best_len = l
267
+ bx1, by1 = p1
268
+ bx2, by2 = p2
269
+ merged.append(VectorLine(bx1, by1, bx2, by2))
270
+ return merged
271
+
272
+
273
+ # ─── Main conversion pipeline ────────────────────────────────────────────────
274
+
275
+ DEFAULT_SETTINGS = {
276
+ "upscale": 2.0,
277
+ "denoise_h": 6,
278
+ "adaptive_block": 51,
279
+ "adaptive_C": 10,
280
+ "morph_open": 2,
281
+ "morph_close": 3,
282
+ "hough_rho": 1,
283
+ "hough_theta_deg": 0.5,
284
+ "hough_threshold": 25,
285
+ "hough_min_len": 18,
286
+ "hough_max_gap": 10,
287
+ "contour_min_area": 40,
288
+ "contour_epsilon_ratio": 0.003,
289
+ "merge_angle_tol": 2.5,
290
+ "merge_dist_tol": 3.0,
291
+ "output_scale_mm": 0.1, # 1 px in output image β†’ 0.1 mm
292
+ }
293
+
294
+
295
+ def convert(input_path: str,
296
+ output_path: str,
297
+ settings: dict = None,
298
+ progress_cb=None) -> dict:
299
+ """Full conversion pipeline. Returns stats dict."""
300
+ s = {**DEFAULT_SETTINGS, **(settings or {})}
301
+
302
+ def progress(msg, pct):
303
+ if progress_cb:
304
+ progress_cb(msg, pct)
305
+ else:
306
+ print(f" [{pct:3d}%] {msg}")
307
+
308
+ # ── Load ──────────────────────────────────────────────────────
309
+ progress("Loading image…", 5)
310
+ img = cv2.imread(input_path)
311
+ if img is None:
312
+ raise FileNotFoundError(f"Cannot load: {input_path}")
313
+ h0, w0 = img.shape[:2]
314
+
315
+ # ── Preprocess ────────────────────────────────────────────────
316
+ progress("Preprocessing (denoise + threshold)…", 15)
317
+ binary, gray = preprocess(img, s)
318
+ h_up, w_up = binary.shape[:2]
319
+ scale = s["output_scale_mm"] / s["upscale"] # px (upscaled) β†’ mm
320
+
321
+ # ── Thin ──────────────────────────────────────────────────────
322
+ progress("Thinning / skeletonising…", 28)
323
+ thin = thin_image(binary)
324
+
325
+ # ── Hough lines ───────────────────────────────────────────────
326
+ progress("Extracting straight lines (Hough)…", 40)
327
+ raw_lines = extract_hough_lines(thin, s, scale)
328
+
329
+ # ── Merge ─────────────────────────────────────────────────────
330
+ progress("Merging collinear segments…", 52)
331
+ merged_lines = merge_lines(raw_lines,
332
+ angle_tol=s["merge_angle_tol"],
333
+ dist_tol=s["merge_dist_tol"])
334
+
335
+ # ── Contours ──────────────────────────────────────────────────
336
+ progress("Extracting contours, circles, arcs…", 64)
337
+ polylines, circles, arcs = extract_contours(binary, s, scale)
338
+
339
+ result = VectorResult(
340
+ lines=merged_lines,
341
+ polylines=polylines,
342
+ circles=circles,
343
+ arcs=arcs,
344
+ width_px=w0,
345
+ height_px=h0,
346
+ scale=scale,
347
+ )
348
+
349
+ # ── Write DXF ─────────────────────────────────────────────────
350
+ progress("Writing DXF…", 80)
351
+ write_dxf(result, output_path)
352
+
353
+ stats = {
354
+ "lines": len(result.lines),
355
+ "polylines": len(result.polylines),
356
+ "circles": len(result.circles),
357
+ "arcs": len(result.arcs),
358
+ "source_w": w0,
359
+ "source_h": h0,
360
+ }
361
+ progress("Done βœ“", 100)
362
+ return stats
363
+
364
+
365
+ # ─── DXF writer ──────────────────────────────────────────────────────────────
366
+
367
+ LAYER_COLORS = {
368
+ "LINES": 7, # white/black
369
+ "CONTOURS": 3, # green
370
+ "CIRCLES": 4, # cyan
371
+ "ARCS": 1, # red
372
+ "TEXT": 2, # yellow
373
+ }
374
+
375
+
376
+ def write_dxf(result: VectorResult, path: str):
377
+ doc = ezdxf.new(dxfversion="R2010")
378
+ doc.units = units.MM
379
+ msp = doc.modelspace()
380
+
381
+ # Create layers
382
+ for name, color in LAYER_COLORS.items():
383
+ if name not in doc.layers:
384
+ doc.layers.add(name, dxfattribs={"color": color, "lineweight": 25})
385
+
386
+ # Lines
387
+ for vl in result.lines:
388
+ msp.add_line(
389
+ (vl.x1, vl.y1), (vl.x2, vl.y2),
390
+ dxfattribs={"layer": vl.layer}
391
+ )
392
+
393
+ # Polylines
394
+ for vp in result.polylines:
395
+ if len(vp.points) >= 2:
396
+ if vp.closed and len(vp.points) >= 3:
397
+ msp.add_lwpolyline(
398
+ vp.points,
399
+ close=True,
400
+ dxfattribs={"layer": vp.layer}
401
+ )
402
+ else:
403
+ msp.add_lwpolyline(
404
+ vp.points,
405
+ close=False,
406
+ dxfattribs={"layer": vp.layer}
407
+ )
408
+
409
+ # Circles
410
+ for vc in result.circles:
411
+ msp.add_circle(
412
+ (vc.cx, vc.cy), vc.r,
413
+ dxfattribs={"layer": vc.layer}
414
+ )
415
+
416
+ # Arcs
417
+ for va in result.arcs:
418
+ msp.add_arc(
419
+ (va.cx, va.cy), va.r,
420
+ va.start_angle, va.end_angle,
421
+ dxfattribs={"layer": va.layer}
422
+ )
423
+
424
+ # Texts
425
+ for vt in result.texts:
426
+ msp.add_text(
427
+ vt.text,
428
+ dxfattribs={"layer": vt.layer, "height": vt.height,
429
+ "insert": (vt.x, vt.y)}
430
+ )
431
+
432
+ doc.saveas(path)
433
+
434
+
435
+ # ─── CLI ─────────────────────────────────────────────────────────────────────
436
+
437
+ if __name__ == "__main__":
438
+ parser = argparse.ArgumentParser(
439
+ description="Raster-to-Vector converter: PNG β†’ DXF")
440
+ parser.add_argument("input", help="Input PNG/JPG/BMP path")
441
+ parser.add_argument("output", help="Output DXF path")
442
+ parser.add_argument("--upscale", type=float, default=2.0)
443
+ parser.add_argument("--denoise", type=int, default=6)
444
+ parser.add_argument("--hough-min", type=int, default=18,
445
+ dest="hough_min_len")
446
+ parser.add_argument("--hough-gap", type=int, default=10,
447
+ dest="hough_max_gap")
448
+ parser.add_argument("--scale-mm", type=float, default=0.1,
449
+ dest="output_scale_mm",
450
+ help="mm per source pixel (default 0.1)")
451
+ args = parser.parse_args()
452
+
453
+ settings = {
454
+ "upscale": args.upscale,
455
+ "denoise_h": args.denoise,
456
+ "hough_min_len": args.hough_min_len,
457
+ "hough_max_gap": args.hough_max_gap,
458
+ "output_scale_mm": args.output_scale_mm,
459
+ }
460
+ stats = convert(args.input, args.output, settings)
461
+ print("\nConversion complete:")
462
+ for k, v in stats.items():
463
+ print(f" {k}: {v}")
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio
2
+ ezdxf
3
+ opencv-python-headless
4
+ scikit-image
5
+ numpy
6
+ scipy