Decoder update: predict.py
Browse files- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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(
|
| 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),
|