constructelligence commited on
Commit
bde0399
·
verified ·
1 Parent(s): 48133c8

Decoder update: predict.py

Browse files
Files changed (1) hide show
  1. predict.py +238 -4
predict.py CHANGED
@@ -11,6 +11,7 @@ from __future__ import annotations
11
 
12
  import argparse
13
  import json
 
14
  import pathlib
15
 
16
  import numpy as np
@@ -54,6 +55,232 @@ def run_sheet(sess, gray: np.ndarray):
54
  return peaks, size, wire
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def main():
58
  ap = argparse.ArgumentParser()
59
  ap.add_argument("image")
@@ -61,14 +288,20 @@ def main():
61
  ap.add_argument("--symbol-px", type=float, default=20, help="receptacle circle diameter in the input (20 suits a 150-dpi PDF render)")
62
  ap.add_argument("--out")
63
  ap.add_argument("--json")
 
 
 
64
  a = ap.parse_args()
65
- img = Image.open(a.image).convert("L")
 
 
 
 
66
  scale = TRAIN_SYMBOL_PX / a.symbol_px
67
  if abs(scale - 1) > 0.05:
68
  img = img.resize((round(img.width * scale), round(img.height * scale)), Image.LANCZOS)
69
  sess = ort.InferenceSession(a.model, providers=["CPUExecutionProvider"])
70
- peaks, size, wire = run_sheet(sess, np.asarray(img, np.float32))
71
- res = D.decode(peaks, size, wire)
72
  inv = 1 / scale
73
  for d in res["devices"]:
74
  d["box"] = [round(v * inv, 1) for v in d["box"]]
@@ -84,9 +317,10 @@ def main():
84
  if res["unwired"]:
85
  print(f"{len(res['unwired'])} powered symbols with no wiring found")
86
  if a.json:
 
87
  pathlib.Path(a.json).write_text(json.dumps(res, indent=1))
88
  if a.out:
89
- draw_result(Image.open(a.image).convert("RGB"), res).save(a.out)
90
 
91
 
92
  PALETTE = [(31, 119, 180), (214, 39, 40), (44, 160, 44), (148, 103, 189), (255, 127, 14), (23, 190, 207),
 
11
 
12
  import argparse
13
  import json
14
+ import math
15
  import pathlib
16
 
17
  import numpy as np
 
55
  return peaks, size, wire
56
 
57
 
58
+ def decode_two_scale(peaks, size, wire, peaks2, size2, k, cfg=None):
59
+ """decode() plus home runs whose arrowheads only fire on a coarser pass of the same sheet (arrows drawn large
60
+ next to small fixtures, e.g. Windsor E103). peaks2/size2: the model's outputs on the sheet shrunk by 1/k (so
61
+ k = this pass's px per coarse-pass px, i.e. cfg["arrow_scale"]). Wiring is the normal pass's, untouched: the
62
+ coarse arrows only add home runs, each landing on a device that pass already found."""
63
+ cfg = D.CFG if cfg is None else cfg
64
+ base = D.decode(peaks, size, wire, cfg)
65
+ dets = D.detect(peaks, size, cfg)
66
+ fine = [d for d in dets if d["cls"] == D.ARROW]
67
+ extra = []
68
+ for a in D.detect(peaks2, size2, cfg):
69
+ if a["cls"] != D.ARROW:
70
+ continue
71
+ cx, cy = a["cx"] * k, a["cy"] * k
72
+ if any(math.hypot(cx - f["cx"], cy - f["cy"]) < cfg.get("arrow_merge", 8) for f in fine + extra):
73
+ continue
74
+ extra.append({**a, "cx": cx, "cy": cy, "box": [v * k for v in a["box"]]})
75
+ if not extra:
76
+ return base
77
+ merged = [dict(d) for d in dets + extra]
78
+ for i, d in enumerate(merged):
79
+ d["id"] = i
80
+ res = D.decode(peaks, size, wire, cfg, dets=merged)
81
+
82
+ cen = lambda b: ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)
83
+ bdev = [d for d in base["devices"] if d["cls"] != D.ARROW]
84
+ rbox = {d["id"]: d["box"] for d in res["devices"]}
85
+ has_hr = {h["device"] for h in base["homeruns"]}
86
+ circ_of = {i: c for c in base["circuits"] for i in c["devices"]}
87
+ for h in res["homeruns"]:
88
+ if h["arrow"] < len(dets):
89
+ continue # a normal-pass arrow: base already decided that home run
90
+ p = cen(rbox[h["device"]])
91
+ m = min(bdev, key=lambda d: (math.dist(cen(d["box"]), p), d["id"]), default=None)
92
+ if m is None or math.dist(cen(m["box"]), p) > 4 or m["id"] in has_hr:
93
+ continue
94
+ a = res["devices"][h["arrow"]]
95
+ aid = len(base["devices"])
96
+ base["devices"].append({"id": aid, "cls": D.ARROW, "score": a["score"], "box": a["box"]})
97
+ base["homeruns"].append({"device": m["id"], "arrow": aid})
98
+ base["runs"].append({"devices": [m["id"]], "homerun": True, "arrow": aid, "length_px": 0.0, "points": []})
99
+ has_hr.add(m["id"])
100
+ c = circ_of.get(m["id"])
101
+ if c is None:
102
+ c = {"devices": [m["id"]], "homeruns": [], "length_px": 0.0}
103
+ base["circuits"].append(c)
104
+ circ_of[m["id"]] = c
105
+ c["homeruns"] = sorted(c["homeruns"] + [aid])
106
+ if m["id"] in base["unwired"]:
107
+ base["unwired"].remove(m["id"])
108
+ base["homeruns"].sort(key=lambda h: h["device"])
109
+ return base
110
+
111
+
112
+ def bridge_dashes(peaks, size, wire, cfg=None):
113
+ """Close the gaps of dashed runs (UK switching links) in the wire map before decode(). Without this every dash
114
+ is its own stroke: its two free ends become unrecognised devices and a dashed run decodes as a string of
115
+ dash-length fragments. Two free skeleton ends (not on a detected symbol) are joined when they are at most
116
+ cfg["dash_gap"] grid px apart and each one's outward direction, measured cfg["dash_look"] px back along its
117
+ stroke, points at the other within cos >= cfg["dash_cos"]; closest pairs first, each end used once.
118
+ dash_gap 0 (the default) returns the map unchanged."""
119
+ cfg = D.CFG if cfg is None else cfg
120
+ gap = cfg.get("dash_gap", 0)
121
+ if gap <= 0:
122
+ return wire
123
+ cos_min, look = cfg.get("dash_cos", 0.75), int(cfg.get("dash_look", 4))
124
+ dets = [d for d in D.detect(peaks, size, cfg) if d["cls"] != D.ARROW]
125
+ sk = D.zhang_suen((wire > cfg["wire_thr"]).astype(np.uint8))
126
+ H, W = sk.shape
127
+ pts = set(zip(*(a.tolist() for a in np.nonzero(sk))))
128
+ ends = []
129
+ for y, x in sorted(pts):
130
+ t, n = D.crossing(sk, y, x, H, W)
131
+ if not (t == 1 and n <= 2):
132
+ continue
133
+ if any(D.box_dist(y, x, d["box"]) <= cfg["attach"] for d in dets):
134
+ continue # lands on a symbol: decode() attaches it
135
+ path, cur = [(y, x)], (y, x)
136
+ for _ in range(look):
137
+ nxt = [(cur[0] + dy, cur[1] + dx) for dy, dx in D.NB if (cur[0] + dy, cur[1] + dx) in pts
138
+ and (cur[0] + dy, cur[1] + dx) not in path]
139
+ if not nxt:
140
+ break
141
+ cur = nxt[0]
142
+ path.append(cur)
143
+ vy, vx = y - cur[0], x - cur[1]
144
+ nrm = math.sqrt(vy * vy + vx * vx) # sqrt, not hypot: bit-identical to circuit-model.js
145
+ if nrm < 1.5:
146
+ continue # a speck: no direction to continue along
147
+ ends.append((y, x, vy / nrm, vx / nrm))
148
+ cell = {}
149
+ for i, (y, x, _, _) in enumerate(ends):
150
+ cell.setdefault((int(y // gap), int(x // gap)), []).append(i)
151
+ cand = []
152
+ for i, (y, x, uy, ux) in enumerate(ends):
153
+ cy, cx = int(y // gap), int(x // gap)
154
+ for oy in (-1, 0, 1):
155
+ for ox in (-1, 0, 1):
156
+ for j in cell.get((cy + oy, cx + ox), []):
157
+ if j <= i:
158
+ continue
159
+ y2, x2, uy2, ux2 = ends[j]
160
+ d = math.sqrt((y2 - y) * (y2 - y) + (x2 - x) * (x2 - x))
161
+ if d == 0 or d > gap:
162
+ continue
163
+ if (uy * (y2 - y) + ux * (x2 - x)) / d >= cos_min and (uy2 * (y - y2) + ux2 * (x - x2)) / d >= cos_min:
164
+ cand.append((d, i, j))
165
+ out, used = wire.copy(), set()
166
+ for d, i, j in sorted(cand):
167
+ if i in used or j in used:
168
+ continue
169
+ used.update((i, j))
170
+ (y1, x1), (y2, x2) = ends[i][:2], ends[j][:2]
171
+ n = math.ceil(d) * 2
172
+ for k in range(n + 1):
173
+ t = k / n
174
+ # floor(v + 0.5), not round(): Python rounds halves to even, JS does not
175
+ out[math.floor(y1 + (y2 - y1) * t + 0.5), math.floor(x1 + (x2 - x1) * t + 0.5)] = np.float32(1.0)
176
+ return out
177
+
178
+
179
+ def estimate_skew(img: Image.Image, max_deg: float = 4.0, work_px: int = 900) -> float:
180
+ """Degrees to rotate the sheet so its drawn lines sit square. Plan sheets are dominated by horizontal and vertical
181
+ strokes, so the right angle is the one where ink piles into the fewest rows (largest row-sum variance).
182
+ Coarse 0.5 degree sweep, then 0.1; numpy and PIL only."""
183
+ g = img.convert("L")
184
+ k = work_px / max(g.size)
185
+ if k < 1:
186
+ g = g.resize((max(1, round(g.width * k)), max(1, round(g.height * k))), Image.BILINEAR)
187
+ ink = Image.fromarray(((np.asarray(g, np.float32) < 140) * 255).astype(np.uint8))
188
+
189
+ def score(deg):
190
+ rows = (np.asarray(ink.rotate(deg, resample=Image.BILINEAR, fillcolor=0), np.float32) / 255).sum(axis=1)
191
+ return float(np.var(rows))
192
+ best = max(np.arange(-max_deg, max_deg + 1e-9, 0.5), key=score)
193
+ best = max(np.arange(best - 0.5, best + 0.5 + 1e-9, 0.1), key=score)
194
+ # only trust a tilt that clearly sharpens the row profile over the unrotated sheet
195
+ return float(best) if score(best) > 1.05 * score(0.0) else 0.0
196
+
197
+
198
+ def deskew(img: Image.Image, min_deg: float = 0.25):
199
+ """(square image, angle applied). Tilts under min_deg are left alone: rotating resamples every stroke."""
200
+ deg = estimate_skew(img)
201
+ if abs(deg) < min_deg:
202
+ return img, 0.0
203
+ return img.rotate(deg, resample=Image.BICUBIC, fillcolor=255), deg
204
+
205
+
206
+ SCALE_CANDIDATES = (10, 12, 14, 16, 18, 20, 23, 26, 30, 35) # receptacle diameter in page px
207
+
208
+
209
+ def _ink_windows(gray: np.ndarray, win: int, n: int = 3):
210
+ """Top-left corners of the ``n`` most ink-dense, non-overlapping ``win`` x ``win`` windows of a sheet."""
211
+ H, W = gray.shape
212
+ if H <= win or W <= win:
213
+ return [(0, 0)]
214
+ b = max(8, win // 4)
215
+ ink = (gray < 128).astype(np.float32)
216
+ hh, ww = H // b, W // b
217
+ cells = ink[: hh * b, : ww * b].reshape(hh, b, ww, b).mean(axis=(1, 3))
218
+ k = max(1, win // b)
219
+ from scipy.ndimage import uniform_filter
220
+ dens = uniform_filter(cells, size=k, mode="constant")
221
+ out = []
222
+ for _ in range(n):
223
+ i = int(np.argmax(dens))
224
+ cy, cx = divmod(i, ww)
225
+ if dens[cy, cx] <= 0:
226
+ break
227
+ y0 = int(np.clip(cy * b - win // 2, 0, H - win))
228
+ x0 = int(np.clip(cx * b - win // 2, 0, W - win))
229
+ out.append((y0, x0))
230
+ dens[max(0, cy - k):cy + k + 1, max(0, cx - k):cx + k + 1] = -1
231
+ return out or [(0, 0)]
232
+
233
+
234
+ def scale_evidence(res):
235
+ """Self-consistency of one decode, needing no labels: at the right symbol scale the model finds confident symbols
236
+ AND the wiring it traces ends on them. Returns (score, parts)."""
237
+ arrow = D.CLASSES.index("homerun_arrow")
238
+ devs = [d for d in res["devices"] if d["cls"] not in (arrow, D.VIRTUAL)] # real symbols the model found
239
+ scores = sorted((d.get("score", 0.0) for d in devs), reverse=True)[:25]
240
+ conf = float(np.mean(scores)) if scores else 0.0
241
+ runs = res["runs"]
242
+ real = set(d["id"] for d in devs)
243
+ ends = sum(len(r["devices"]) for r in runs)
244
+ landed = sum(1 for r in runs for i in r["devices"] if i in real)
245
+ snapped = landed / ends if ends else 0.0
246
+ return conf * (0.25 + 0.75 * snapped) * min(1.0, len(devs) / 6.0), {"conf": round(conf, 3), "snapped": round(snapped, 3), "devices": len(devs), "runs": len(runs)}
247
+
248
+
249
+ def estimate_symbol_px(sess, img: Image.Image, candidates=SCALE_CANDIDATES, window_model_px=512, windows=3, cfg=None):
250
+ """Pick the page's symbol size (receptacle diameter in page px) without asking: decode a few ink-dense crops at every
251
+ candidate scale and keep the scale whose decode is most self-consistent (``scale_evidence``). Returns
252
+ (best_symbol_px, {candidate: score})."""
253
+ gray = np.asarray(img.convert("L"), np.float32)
254
+ cfg = {**(D.CFG if cfg is None else cfg), "arrow_scale": 1.0}
255
+ table = {}
256
+ for c in candidates:
257
+ k = TRAIN_SYMBOL_PX / c # page px -> model px
258
+ win = int(round(window_model_px / k))
259
+ vals = []
260
+ for y0, x0 in _ink_windows(gray, win, windows):
261
+ crop = Image.fromarray(gray[y0:y0 + win, x0:x0 + win].astype(np.uint8))
262
+ crop = crop.resize((max(32, round(crop.width * k)), max(32, round(crop.height * k))), Image.LANCZOS)
263
+ vals.append(scale_evidence(decode_sheet(sess, crop, cfg))[0])
264
+ table[c] = round(float(np.mean(vals)), 4) if vals else 0.0
265
+ best = max(table, key=table.get)
266
+ return best, table
267
+
268
+
269
+ def decode_sheet(sess, img: Image.Image, cfg=None):
270
+ """Model + decoder on a sheet already at the model's symbol size. With cfg["arrow_scale"] > 1, a second pass on
271
+ the sheet shrunk by that factor adds home runs whose arrowheads only fire there (decode_two_scale). With
272
+ cfg["dash_gap"] > 0, dashed runs are closed first (bridge_dashes)."""
273
+ cfg = D.CFG if cfg is None else cfg
274
+ peaks, size, wire = run_sheet(sess, np.asarray(img, np.float32))
275
+ wire = bridge_dashes(peaks, size, wire, cfg)
276
+ f = cfg.get("arrow_scale", 1.0)
277
+ if f <= 1.0:
278
+ return D.decode(peaks, size, wire, cfg)
279
+ small = img.resize((max(1, round(img.width / f)), max(1, round(img.height / f))), Image.LANCZOS)
280
+ peaks2, size2, _ = run_sheet(sess, np.asarray(small, np.float32))
281
+ return decode_two_scale(peaks, size, wire, peaks2, size2, img.width / small.width, cfg)
282
+
283
+
284
  def main():
285
  ap = argparse.ArgumentParser()
286
  ap.add_argument("image")
 
288
  ap.add_argument("--symbol-px", type=float, default=20, help="receptacle circle diameter in the input (20 suits a 150-dpi PDF render)")
289
  ap.add_argument("--out")
290
  ap.add_argument("--json")
291
+ ap.add_argument("--no-deskew", action="store_true", help="skip straightening a tilted scan or photo (on by default; tilts under 0.25 degrees are left alone)")
292
+ ap.add_argument("--arrow-scale", type=float, default=None,
293
+ help="also read home-run arrows from the sheet shrunk by this factor (default: decoder_config.json arrow_scale; 1 = off)")
294
  a = ap.parse_args()
295
+ shown = Image.open(a.image).convert("L")
296
+ img, tilt = (shown, 0.0) if a.no_deskew else deskew(shown)
297
+ if tilt:
298
+ print(f"straightened the sheet by {tilt:+.1f} degrees")
299
+ shown = img
300
  scale = TRAIN_SYMBOL_PX / a.symbol_px
301
  if abs(scale - 1) > 0.05:
302
  img = img.resize((round(img.width * scale), round(img.height * scale)), Image.LANCZOS)
303
  sess = ort.InferenceSession(a.model, providers=["CPUExecutionProvider"])
304
+ res = decode_sheet(sess, img, D.CFG if a.arrow_scale is None else {**D.CFG, "arrow_scale": a.arrow_scale})
 
305
  inv = 1 / scale
306
  for d in res["devices"]:
307
  d["box"] = [round(v * inv, 1) for v in d["box"]]
 
317
  if res["unwired"]:
318
  print(f"{len(res['unwired'])} powered symbols with no wiring found")
319
  if a.json:
320
+ res["deskew_degrees"] = round(tilt, 2)
321
  pathlib.Path(a.json).write_text(json.dumps(res, indent=1))
322
  if a.out:
323
+ draw_result(shown.convert("RGB"), res).save(a.out)
324
 
325
 
326
  PALETTE = [(31, 119, 180), (214, 39, 40), (44, 160, 44), (148, 103, 189), (255, 127, 14), (23, 190, 207),