app
AI Flow: Reverse Engineering
Public Made by Adomby adom
Photos of a PCB to a tagged KiCad copy: parts placed, copper and nets with their evidence, a schematic, a measured confidence score, bench tests and a video of the run.
main
Caleb
Publish 0.5.0
1984b40
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125
"""The fixed sequence of scenes. Each builder reads the run, and returns a Scene or raises Skip with the reason.
A scene is its shots (camera moves over real pictures of the run) and its beats (narration lines, each tied to the shot
it is spoken over). A beat with opt 0 is always spoken. The others are added in the order of their opt value, lowest first across the whole
video, while it stays near two minutes: the house voice takes about five seconds for a fifteen word sentence, so only
some twenty-five lines fit, and the value says which matter most.
Every figure goes through run.num with the file it came from, so nothing is on screen that the run's files do not say.
"""
import glob
import json
import math
import os
import re
import numpy as np
import cv2
from PIL import Image, ImageDraw
import style as st
from style import W, H, PANEL_X, ACCENT, INK, SOFT, BG, PANEL, LINE, CHIP
from engine import Plate, View, WipeView, Layer, Shot, Scene, cam_fit, cam_fill
from rundata import clean, say_num, plural, dim, zone_mask, tint, draw_tracks, draw_vias, densest
VP = (0, 0, PANEL_X, H) # the evidence picture
SAFE = (30, 128, PANEL_X - 60, H - 128 - 44) # where a whole board sits inside it, clear of the chapter chip
PASSIVE = re.compile(r"^(C|R|L|FB|RN|D)\d")
class Skip(Exception):
pass
def beat(text, shot, opt=0):
return {"text": clean(text), "shot": shot, "opt": opt}
def cam_safe(plate, rect=None, safe=SAFE, vp=VP):
"""The camera that puts rect (plate pixels; the whole plate when None) inside the safe area of the viewport."""
x0, y0, x1, y1 = rect if rect else (0, 0, plate.w, plate.h)
sx, sy, sw, sh = safe
s = min(sw / max(1.0, x1 - x0), sh / max(1.0, y1 - y0))
return ((x0 + x1) / 2 - ((sx + sw / 2) - vp[2] / 2) / s, (y0 + y1) / 2 - ((sy + sh / 2) - vp[3] / 2) / s, vp[2] / s)
def push(cam, k):
"""The same camera moved in by a factor k."""
return (cam[0], cam[1], cam[2] / k)
def cam_rect(rect, vp=VP, margin=0.0):
x0, y0, x1, y1 = rect
cw = max(x1 - x0, (y1 - y0) * vp[2] / vp[3]) * (1 + 2 * margin)
return ((x0 + x1) / 2, (y0 + y1) / 2, cw)
def bottom_left(rgba, lift=0):
return Layer(rgba, st.MARGIN, H - st.MARGIN - rgba.shape[0] - lift)
FACE_WORDS = {"top": "TOP FACE", "bottom": "BOTTOM FACE, turned over to line up with the top"}
def face_chip(side):
return bottom_left(st.label_chip(FACE_WORDS[side]))
def side_panel(rows, title=None, note=None, extra=None):
return Layer(st.panel(rows, title, note, extra=extra), PANEL_X, 0, fade=0)
def plate_text(rgb, items):
"""Text on a board picture, with a dark edge so it reads on a busy photo. items: (x, y, text, size, colour, anchor)."""
im = Image.fromarray(rgb)
d = ImageDraw.Draw(im)
for x, y, s, size, colour, anchor in items:
d.text((x, y), s, font=st.font(size, True), fill=tuple(colour), anchor=anchor, stroke_width=max(2, int(size / 9)), stroke_fill=CHIP)
return np.asarray(im).copy()
def photo_count_words(run):
photos = [p for p, _ in input_photos(run)[0]]
return photos, sum(1 for p in photos if p.get("side") == "top"), sum(1 for p in photos if p.get("side") == "bottom")
def board_name(run):
"""What to call the board, and where the name came from."""
notes = run.j("video-notes.json") or {}
try:
with open(run.p("report.md"), encoding="utf-8") as f:
for line in f:
if line.startswith("# "):
h1 = clean(line[2:].split(":")[0])
if h1 and h1 != run.facts.get("project"):
return h1, "report.md, first heading", notes
break
except OSError:
pass
return run.facts.get("project") or os.path.basename(run.dir), "the run folder's name", notes
# ---------------------------------------------------------------- 0 title
def title(run):
name, src, notes = board_name(run)
photos, _, _ = photo_count_words(run)
if not photos:
raise Skip("state.json lists no photos")
run.num("board name", name, src)
n = run.num("photos", len(photos), input_photos(run)[1])
top = run.face("top")
views, ground = [], None
if top is not None:
soft = cv2.GaussianBlur(dim(top, 0.42, 0.25), (0, 0), 6)
p = Plate(soft)
views = [View(p, (0, 0, W, H), cam_fill(p, W, H, 1.02), cam_fill(p, W, H, 1.12))]
c = st.Canvas(W, H)
c.rect(0, 0, W, H, CHIP + (120,))
c.text(120, 250, "REVERSE ENGINEERED FROM PHOTOS", 30, ACCENT, True)
size = 96
lines = st.wrap(name, size, W - 240, True)
while len(lines) > 1 and size > 72:
size -= 6
lines = st.wrap(name, size, W - 240, True)
while len(lines) > 2 and size > 56:
size -= 8
lines = st.wrap(name, size, W - 240, True)
y = c.lines(120, 310, lines[:2], size, INK, True, gap=1.16)
tiles = []
parts = run.parts()
if parts:
tiles.append((say_num(run.num("parts", len(parts), "parts.json parts")), "parts"))
cu = run.j("copper.json") or {}
if cu.get("zones") or cu.get("traces"):
tiles.append((say_num(run.num("pieces of copper", len(cu.get("zones") or []) + len(cu.get("traces") or []), "copper.json zones + traces")), "pieces of copper"))
tiles.append((say_num(run.num("vias", len(cu.get("vias") or []), "copper.json vias")), "vias"))
nets = (run.j("nets.json") or {}).get("nets") or []
if nets:
tiles.append((say_num(run.num("nets", len(nets), "nets.json nets")), "nets"))
tiles.insert(0, (say_num(n), "photos"))
x, y = 120, max(y + 70, 640)
for fig, lab in tiles[:5]:
w = max(st.text_w(fig, 84, True), st.text_w(lab, 30)) + 70
c.text(x, y, fig, 84, ACCENT, True)
c.text(x, y + 104, lab, 30, INK)
x += w
foot = f"run {run.facts.get('project', '')} | adom-aiflow-reverse {run.facts.get('version', '')} | built {str(run.facts.get('builtAt', ''))[:10]} by the flow, from the run's files"
c.text(120, H - 96, st.fit(foot, 26, W - 240), 26, SOFT)
said = notes.get("board")
if said:
run.num("board line", clean(said), "video-notes.json board")
beats = [beat(clean(said), "title"), beat(f"Reverse engineered from {plural(n, 'photo')}.", "title")]
else:
beats = [beat(f"{name}, reverse engineered from {plural(n, 'photo')}.", "title")]
beats.append(beat("The flow built this video itself, from the run's files.", "title", opt=3.5))
return Scene("title", name, [Shot("title", views, [Layer(c.array(), 0, 0, fade=0)])], beats, weight=0.4)
# ---------------------------------------------------------------- 1 photos
def _stitch(run):
"""The record of how the photos were joined, when the run has one, and the folder its evidence pictures are in:
the flow's own stitch step (stitch.json with stitch/), a stitcher's record kept by `init` (photos/stitch/), or the
record still lying beside the photos' source."""
for rel, ev in (("stitch.json", "stitch"), ("photos/stitch/stitch.json", "photos/stitch"), ("photos/stitch.json", "photos")):
if run.has(rel) and isinstance(run.j(rel), dict):
return run.j(rel), run.p(ev)
for ph in (run.j("state.json") or {}).get("photos") or []:
src = ph.get("source") or ""
src = src if os.path.isabs(src) else os.path.join(run.dir, src)
cand = os.path.join(os.path.dirname(src), "stitch.json")
if os.path.isfile(cand) and "sealed-key" not in cand:
try:
with open(cand, encoding="utf-8") as f:
return json.load(f), os.path.dirname(cand)
except (OSError, ValueError):
pass
return None, None
def input_photos(run):
"""The photos the run started from, as (record, path) pairs, with where the list came from. When the flow sorted and
joined a set itself, these are the photos as they came (photos-raw/), not the two mosaics made from them."""
sj, _ = _stitch(run)
raw = []
for ph in (sj or {}).get("photos") or []:
path = run.p(os.path.join("photos-raw", str(ph.get("file") or "")))
if os.path.isfile(path) and ph.get("face") in ("top", "bottom"):
raw.append(({"file": ph["file"], "side": ph["face"], "role": ph.get("role")}, path))
if raw:
return raw, "stitch.json photos (the photos as they came, reverse-flow/photos-raw/)"
out = []
for ph in (run.j("state.json") or {}).get("photos") or []:
out.append((ph, run.p(os.path.join("photos", str(ph.get("file") or "")))))
return out, "state.json photos"
def photos(run):
photos, n_top, n_bot = photo_count_words(run)
listed, src = input_photos(run)
imgs = []
for ph, path in listed:
im = run.img(path) if len(imgs) < 12 else None # twelve are shown; the rest are counted, not opened
if im is not None:
imgs.append((ph, im))
if not imgs:
raise Skip("no photo of the run could be read from reverse-flow/photos")
n = run.num("photos", len(photos), src)
run.num("top photos", n_top, src + ", side top")
run.num("bottom photos", n_bot, src + ", side bottom")
sizes = sorted({f"{im.shape[1]} x {im.shape[0]}" for _, im in imgs})
rows_p = [(say_num(n), "photos went in"), (say_num(n_top), "of the top face"), (say_num(n_bot), "of the bottom face")]
note = "Photo size: " + ", ".join(sizes[:3]) + " pixels."
def small(im, w=2400):
return cv2.resize(im, (w, int(round(im.shape[0] * w / im.shape[1]))), interpolation=cv2.INTER_AREA) if im.shape[1] > w else im
shots = []
if len(imgs) <= 2:
# one or two photos: each is shown whole, one after the other
for k, (ph, im) in enumerate(imgs):
p = Plate(small(im))
cam = cam_safe(p)
chip = st.label_chip(st.fit(f"{str(ph.get('side', '')).upper()} PHOTO {ph.get('file', '')}", 26, 900, True))
shots.append(Shot("grid" if k == 0 else f"photo-{k}", [View(p, VP, cam, push(cam, 1.08))], [side_panel(rows_p, "The photos", note), bottom_left(chip)], min_len=0 if k == 0 else 2.6))
else:
show = imgs[:12]
cols = 2 if len(show) <= 4 else 3 if len(show) <= 6 else 4
rows = math.ceil(len(show) / cols)
gx, gy, gw, gh = 36, 132, PANEL_X - 72, H - 132 - 44
gap = 18
cw, ch = (gw - gap * (cols - 1)) // cols, (gh - gap * (rows - 1)) // rows
views, layers = [], []
for k, (ph, im) in enumerate(show):
x, y = gx + (k % cols) * (cw + gap), gy + (k // cols) * (ch + gap)
p = Plate(small(im, 1200))
s = min(cw / p.w, ch / p.h)
vw, vh = int(p.w * s) // 2 * 2, int(p.h * s) // 2 * 2
vx, vy = x + (cw - vw) // 2, y + (ch - vh) // 2
cam = cam_fill(p, vw, vh, 1.0)
views.append(View(p, (vx, vy, vw, vh), cam, push(cam, 1.08)))
words = str(ph.get("side", "")).upper() + (", WHOLE BOARD" if ph.get("role") == "overview" else "")
if cols < 4:
words += f" {ph.get('file', '')}"
if st.text_w(words, 24, True) > vw - 72:
words = str(ph.get("side", "")).upper() # a narrow cell carries the face alone
chip = st.label_chip(st.fit(words, 24, vw - 72, True), size=24)
layers.append(Layer(chip, vx + 14, vy + vh - chip.shape[0] - 14))
if len(photos) > len(show):
note += f" {len(photos) - len(show)} more photos are not shown here."
shots.append(Shot("grid", views, layers + [side_panel(rows_p, "The photos", note)]))
if n_bot:
b = [beat(f"The photos: {say_num(n_top)} of the top face, {say_num(n_bot)} of the bottom.", "grid")]
else:
b = [beat(f"The photos: {say_num(n_top)}, all of the top face. No bottom photo was given.", "grid")]
sj, sdir = _stitch(run)
faces = (sj or {}).get("faces") or {}
joined = False
for side in ("top", "bottom"):
f = faces.get(side) or {}
ph = next((p for p in (run.j("state.json") or {}).get("photos") or [] if p.get("side") == side and p.get("file") == f.get("file")), None)
if not f or ph is None:
continue
base = run.img(os.path.join("photos", ph["file"]))
idx = ((f.get("evidence") or {}).get("sourcesIndex") or {}).get("file")
src = run.img(os.path.join(sdir, idx)) if idx else None
if base is None or src is None:
continue
pw = min(base.shape[1], 2800)
base = cv2.resize(base, (pw, int(round(base.shape[0] * pw / base.shape[1]))), interpolation=cv2.INTER_AREA)
srcb = cv2.resize(src, (base.shape[1], base.shape[0]), interpolation=cv2.INTER_NEAREST)
seen = srcb.max(axis=2) > 0
mix = base.copy()
mix[seen] = (base[seen] * 0.62 + srcb[seen] * 0.38).astype(np.uint8)
edges = cv2.Canny(srcb, 10, 30)
mix[cv2.dilate(edges, np.ones((5, 5), np.uint8)) > 0] = (255, 255, 255)
p0, p1 = Plate(base), Plate(mix)
tiles = [t for t in f.get("tiles") or [] if t.get("role", "tile") == "tile"]
k = run.num(f"{side} close-ups joined", len(tiles), "stitch.json faces." + side + ".tiles")
ov, cov = f.get("overlap") or {}, f.get("coverage") or {}
rows_s = [(say_num(k), f"close-ups joined into the {side} face")]
if isinstance(ov.get("medPx"), (int, float)):
rows_s.append((say_num(run.num(f"{side} overlap disagreement, median px", ov["medPx"], f"stitch.json faces.{side}.overlap.medPx"), 2), "pixels of disagreement where two close-ups overlap, at the median"))
if isinstance(cov.get("closeUpsPct"), (int, float)):
rows_s.append((say_num(run.num(f"{side} board area seen in close-up, percent", cov["closeUpsPct"], f"stitch.json faces.{side}.coverage.closeUpsPct"), 1) + "%", "of the board area is seen in a close-up"))
cam = cam_safe(p0)
shots.append(Shot(f"join-{side}", [View([p0, p1], VP, cam, push(cam, 1.08), fades=[0, 0.7])],
[side_panel(rows_s, "Joining the photos", "Each colour is the close-up that area was taken from. White lines are the joins."), face_chip(side) if side == "top" else bottom_left(st.label_chip("BOTTOM FACE"))]))
if not joined:
line = f"The {side} face was joined from {plural(k, 'close-up')}."
if isinstance(ov.get("medPx"), (int, float)):
line += f" Where two overlap, they disagree by {say_num(ov['medPx'], 2)} pixels at the median."
b.append(beat(line, f"join-{side}", opt=1.05))
if isinstance(cov.get("closeUpsPct"), (int, float)):
b.append(beat(f"Close-ups cover {say_num(cov['closeUpsPct'], 1)} percent of that face.", f"join-{side}", opt=3.2))
else:
b.append(beat(f"The {side} face was joined from {plural(k, 'close-up')} the same way.", f"join-{side}", opt=2.6))
joined = True
if not joined:
prep = run.j("prep.json") or {}
holes = [len(p.get("holes") or []) for p in prep.get("photos") or []]
if holes and sum(holes):
run.num("plated holes found per photo", holes, "prep.json photos[].holes")
b.append(beat("Each photo is measured before anything is read from it: its sharpness, its plated holes and its rows of holes.", "grid", opt=3.6))
return Scene("photos", "The photos", shots, b)
# ---------------------------------------------------------------- 2 outline and scale
def outline(run):
scale = run.j("scale.json")
top = run.face("top")
if not scale or top is None:
raise Skip("no scale.json or board/top.jpg: the scale step did not finish")
bw, bh = (scale.get("board_mm") or [top.shape[1] / run.ppm, top.shape[0] / run.ppm])[:2]
run.num("board width, mm", bw, "scale.json board_mm")
run.num("board height, mm", bh, "scale.json board_mm")
shots, beats = [], []
prep = run.j("prep.json") or {}
primary = (prep.get("primary") or {}).get("top")
ev = None
for ph in prep.get("photos") or []:
if ph.get("file") == primary or (primary is None and ph.get("side") == "top"):
ev = next((e for e in ph.get("evidence") or [] if "outline-" in e), None)
break
oimg = run.img(ev) if ev else None
rows = [(f"{say_num(bw, 2)} x {say_num(bh, 2)} mm", "board outline")]
k_photo = scale.get("pxPerMm")
if isinstance(k_photo, (int, float)):
rows.append((say_num(run.num("pixels per mm in the photo", k_photo, "scale.json pxPerMm"), 1), "pixels per millimetre in the photo"))
reg = (scale.get("alignment") or {}).get("registration") or {}
if oimg is not None:
p = Plate(oimg)
cam = cam_safe(p)
shots.append(Shot("found", [View(p, VP, cam, push(cam, 1.06))], [side_panel(rows[:1], "Outline and scale", "The line is the outline the program fitted to the photo."), bottom_left(st.label_chip("OUTLINE FOUND IN THE PHOTO"))], min_len=2.2))
beats.append(beat("First the outline is found in each photo, and the photo is straightened.", "found", opt=2.2))
# the straightened board with its measured size drawn round it, at a size the padded picture fits a plate at
f = min(1.0, Plate.MAX_W / (top.shape[1] + 0.48 * max(top.shape[:2]) + 2))
if f < 1.0:
top = cv2.resize(top, (int(top.shape[1] * f), int(top.shape[0] * f)), interpolation=cv2.INTER_AREA)
pad = int(0.24 * max(top.shape[:2]))
big = np.empty((top.shape[0] + 2 * pad, top.shape[1] + 2 * pad, 3), np.uint8)
big[:] = BG
big[pad:pad + top.shape[0], pad:pad + top.shape[1]] = top
pts = ((run.j("copper.json") or {}).get("outline") or {}).get("points_mm")
poly = (run.pts(pts) * f + pad) if pts and len(pts) >= 3 else np.array([[pad, pad], [pad + top.shape[1], pad], [pad + top.shape[1], pad + top.shape[0]], [pad, pad + top.shape[0]]], np.float32)
lw = max(4, int(top.shape[1] / 420))
cv2.polylines(big, [np.round(poly).astype(np.int32)], True, ACCENT, lw, cv2.LINE_AA)
x0, y0, x1, y1 = pad, pad, pad + top.shape[1], pad + top.shape[0]
off = int(pad * 0.26)
for a, c in (((x0, y0 - off), (x1, y0 - off)), ((x0 - off, y0), (x0 - off, y1))):
cv2.line(big, a, c, INK, lw, cv2.LINE_AA)
horizontal = a[1] == c[1]
for q in (a, c):
d = (0, off // 3) if horizontal else (off // 3, 0)
cv2.line(big, (q[0] - d[0], q[1] - d[1]), (q[0] + d[0], q[1] + d[1]), INK, lw, cv2.LINE_AA)
fs = int(big.shape[1] / 48)
big = plate_text(big, [((x0 + x1) / 2, y0 - off - fs * 0.25, f"{say_num(bw, 2)} mm", fs, INK, "mb"), (x0 - off - fs * 0.3, (y0 + y1) / 2, f"{say_num(bh, 2)} mm", fs, INK, "rm")])
run.keep("outline-size", big)
p = Plate(big)
label_w = st.text_w(f"{say_num(bh, 2)} mm", fs, True)
cam = cam_safe(p, (x0 - off - fs * 0.6 - label_w, y0 - off - fs * 1.7, x1 + fs * 0.5, y1 + fs * 0.5))
if isinstance(reg.get("pairs"), (int, float)) and reg.get("pairs"):
rows.append((say_num(run.num("points lining the bottom up with the top", reg["pairs"], "scale.json alignment.registration.pairs")), clean(f"{reg.get('through') or 'points seen on both faces'} line the bottom photo up with the top")))
method = scale.get("method") or ("hole row pitches, confirmed by one part body of datasheet size" if scale.get("body") else None)
note = clean(f"Scale from {method}." if method else "")
if (run.facts.get("photosCut") or {}).get("where"):
note = clean("The photos cut the board off: this is the size of the photographed window, not of the board. " + note)
shots.append(Shot("size", [View(p, VP, cam, push(cam, 1.05))], [side_panel(rows, "Outline and scale", note), face_chip("top")]))
cut = run.facts.get("photosCut") or {}
if cut.get("where"):
# the photos stop before the board does: the size is the window's, and the video says so
run.num("sides where the photo cuts the board off", cut["where"], "prep.json photos[].notBoardEdge")
beats.append(beat(f"The photos do not show the whole board. The window they show measures {say_num(bw, 2)} by {say_num(bh, 2)} millimetres; the board itself is larger.", "size"))
else:
beats.append(beat(f"The board measures {say_num(bw, 2)} by {say_num(bh, 2)} millimetres.", "size"))
if method and isinstance(k_photo, (int, float)):
run.num("scale method", method, "scale.json method" if scale.get("method") else "scale.json body (the flow's method, in the program's words)")
beats.append(beat(f"The scale comes from {method}: {say_num(k_photo, 1)} pixels per millimetre in the photo.", "size", opt=1.5))
if isinstance(reg.get("pairs"), (int, float)) and reg.get("pairs") and isinstance(reg.get("leaveOneOutMaxMm"), (int, float)):
run.num("bottom to top registration, worst left-out point, mm", reg["leaveOneOutMaxMm"], "scale.json alignment.registration.leaveOneOutMaxMm")
beats.append(beat(f"The bottom photo is turned over and lined up with the top through {say_num(reg['pairs'])} points seen on both faces, within {say_num(reg['leaveOneOutMaxMm'], 2)} millimetres.", "size", opt=3.3))
return Scene("outline", "Outline and scale", shots, beats)
# ---------------------------------------------------------------- 3 parts
def _area(c):
c = np.asarray(c, np.float32)
return float(np.linalg.norm(c[1] - c[0]) * np.linalg.norm(c[2] - c[1]))
def _marked(p):
return str(p.get("marking") or "").strip().lower() not in ("", "none", "unreadable", "illegible")
def parts(run):
parts = run.parts()
if not parts or not run.sides():
raise Skip("no parts.json or no board picture")
corners = run.body_corners()
n = run.num("parts", len(parts), "parts.json parts")
by_side = {s: [p for p in parts if (p.get("side") or "top") == s] for s in ("top", "bottom")}
n_top = run.num("parts on the top", len(by_side["top"]), "parts.json parts[].side")
n_bot = run.num("parts on the bottom", len(by_side["bottom"]), "parts.json parts[].side")
marked = run.num("parts with a marking read", sum(1 for p in parts if _marked(p)), "parts.json parts[].marking, not empty and not 'none'")
blank = run.num("unmarked capacitors, resistors and other small parts", sum(1 for p in parts if not _marked(p) and PASSIVE.match(str(p.get("ref") or ""))), "parts.json parts[] with no marking and a C, R, L, FB, RN or D reference")
rows = [(say_num(n), "parts placed"), (say_num(n_top), "on the top face"), (say_num(n_bot), "on the bottom face"), (say_num(marked), "with a marking that was read")]
plates, shots = {}, []
for side in run.sides():
photo = run.face(side)
boxed = photo.copy()
lw = max(2, int(run.ppm / 13))
for p in by_side[side]:
c = corners.get(p.get("ref"))
if c:
q = np.round(run.pts(c)).astype(np.int32)
cv2.polylines(boxed, [q], True, CHIP, lw + 3, cv2.LINE_AA)
cv2.polylines(boxed, [q], True, ACCENT, lw, cv2.LINE_AA)
plates[side] = (Plate(photo), Plate(boxed))
run.keep(f"parts-{side}", boxed)
order = [s for s in ("top", "bottom") if s in plates and by_side[s]] or run.sides()[:1]
for side in order:
a, b = plates[side]
cam = cam_safe(a)
shots.append(Shot(f"boxes-{side}", [View([a, b], VP, cam, push(cam, 1.07), fades=[0, 0.5])], [side_panel(rows, "Parts found", "A box is drawn where each part was placed."), face_chip(side)], min_len=0 if side == order[0] else 2.2))
beats = []
if n_bot and n_top:
beats.append(beat(f"{plural(n, 'part')} were found and placed: {say_num(n_top)} on the top face and {say_num(n_bot)} on the bottom.", f"boxes-{order[0]}"))
else:
beats.append(beat(f"{plural(n, 'part')} were found and placed, all on the {'top' if n_top else 'bottom'} face.", f"boxes-{order[0]}"))
last = f"boxes-{order[-1]}"
beats.append(beat(f"{say_num(marked)} carry a marking that was read.", last, opt=1.3))
if blank:
beats.append(beat(f"{say_num(blank)} small parts are unmarked, so photos cannot give their values.", last, opt=2.1))
# the main chips: the largest marked integrated circuits
notes = (run.j("video-notes.json") or {}).get("chips") or {}
chips = [p for p in parts if re.match(r"^(U|IC)\d", str(p.get("ref") or "")) and (_marked(p) or p.get("value")) and corners.get(p.get("ref")) and (p.get("side") or "top") in plates]
chips.sort(key=lambda p: -_area(corners[p["ref"]]))
for k, p in enumerate(chips[:2]):
ref, side = p["ref"], p.get("side") or "top"
a, b = plates[side]
q = run.pts(corners[ref])
x0, y0, x1, y1 = q[:, 0].min(), q[:, 1].min(), q[:, 0].max(), q[:, 1].max()
m = max(x1 - x0, y1 - y0) * 0.22
cam1 = cam_rect((x0 - m, y0 - m, x1 + m, y1 + m), margin=0.02)
cam0 = cam_safe(a) if k == 0 else shots[-1].views[0].cam1
value = clean(p.get("value") or "")
func = clean(p.get("function") or "")
tag = (p.get("fieldTags") or {}).get("identity") or p.get("tag") or ""
run.num(f"{ref} value", value, f"parts.json parts[{ref}].value")
rows_c = [(ref, value or func or "not identified")]
def extra(c, y, p=p, func=func, tag=tag):
x0_, inner = 44, c.w - 84
if func and func != (p.get("value") or ""):
c.text(x0_, y, "FUNCTION", 24, SOFT, True)
y = c.lines(x0_, y + 36, st.wrap(func, 28, inner, max_lines=3), 28, INK) + 22
mk = clean(p.get("marking") or "")
if _marked(p):
c.text(x0_, y, "MARKING AS READ", 24, SOFT, True)
y = c.lines(x0_, y + 36, st.wrap(mk, 28, inner, max_lines=4), 28, INK) + 22
if tag:
c.text(x0_, y, "IDENTITY TAGGED", 24, SOFT, True)
c.text(x0_, y + 36, str(tag), 30, ACCENT, True)
y += 90
return y
shots.append(Shot(f"chip-{ref}", [View(a, VP, cam0, cam1)], [side_panel(rows_c, "Main chips", extra=extra), face_chip(side)], min_len=2.6 if k == 0 else 0))
said = notes.get(ref)
if said:
run.num(f"{ref} line", clean(said), f"video-notes.json chips.{ref}")
line = clean(said)
else:
line = f"{ref}: {value}." if value else f"{ref}: {func}."
if value and func and len(func) < 48 and func.lower() not in value.lower():
line += f" {func[0].upper() + func[1:]}."
# a line the AI wrote for this chip comes before every template line that is not required
beats.append(beat(line, f"chip-{ref}", opt=(0.5 + 0.1 * k) if said else (1.8 + 1.6 * k)))
return Scene("parts", "Parts found", shots, beats, weight=1.3)
# ---------------------------------------------------------------- 4 blocks
def blocks(run):
doc = run.j("blocks.json") or {}
bl = [b for b in doc.get("blocks") or [] if b.get("parts")]
if not bl or not run.sides():
raise Skip("no blocks.json: the blocks were not named")
corners = run.body_corners()
side_of = {p.get("ref"): (p.get("side") or "top") for p in run.parts()}
n = run.num("blocks", len(bl), "blocks.json blocks")
bl_sorted = sorted(bl, key=lambda b: -len(b["parts"]))
colour = {b["id"]: st.BLOCK_HUES[k % len(st.BLOCK_HUES)] for k, b in enumerate(bl_sorted)}
shots = []
on_side = {s: sum(1 for b in bl for r in b["parts"] if side_of.get(r) == s) for s in ("top", "bottom")}
order = [s for s in run.sides() if on_side[s] > 0]
order = [s for s in order if on_side[s] >= 0.15 * max(on_side.values())] or order
def extra(c, y):
for b in bl_sorted[:11]:
c.rect(44, y + 6, 44 + 26, y + 32, colour[b["id"]], radius=5)
cnt = say_num(len(b["parts"]))
c.text(c.w - 40, y + 2, cnt, 28, SOFT, anchor="ra")
c.text(86, y + 2, st.fit(clean(b.get("name") or b["id"]), 28, c.w - 86 - 50 - st.text_w(cnt, 28)), 28, INK)
y += 50
if len(bl_sorted) > 11:
c.text(44, y, f"and {len(bl_sorted) - 11} more", 26, SOFT)
y += 44
return y
tags = {}
for b in bl:
tags[str(b.get("tag") or "UNKNOWN")] = tags.get(str(b.get("tag") or "UNKNOWN"), 0) + 1
run.num("blocks by tag", tags, "blocks.json blocks[].tag")
note = "Tagged: " + ", ".join(f"{v} {k}" for k, v in sorted(tags.items(), key=lambda kv: -kv[1])) + "."
for side in order:
photo = dim(run.face(side), 0.62, 0.45)
fill = photo.copy()
boxes = photo.copy()
labels = []
board_area = photo.shape[0] * photo.shape[1]
for b in bl_sorted:
refs = [r for r in b["parts"] if side_of.get(r) == side and corners.get(r)]
for r in refs:
cv2.fillPoly(boxes, [np.round(run.pts(corners[r])).astype(np.int32)], colour[b["id"]], cv2.LINE_AA)
if len(refs) < 3:
continue
hull = cv2.convexHull(np.round(run.pts([q for r in refs for q in corners[r]])).astype(np.int32))
own = sum(_area(run.pts(corners[r])) for r in refs)
if cv2.contourArea(hull) > 0.4 * board_area or cv2.contourArea(hull) > 8 * own:
continue # parts spread thinly or over the whole board (mounting holes, marks): their boxes carry the colour
cv2.fillPoly(fill, [hull], colour[b["id"]], cv2.LINE_AA)
labels.append((hull, b))
out = cv2.addWeighted(fill, 0.26, photo, 0.74, 0)
out = cv2.addWeighted(boxes, 0.5, out, 0.5, 0)
np.copyto(out, cv2.addWeighted(fill, 0.26, photo, 0.74, 0), where=(boxes == photo).all(axis=2)[:, :, None])
items = []
for hull, b in labels:
cv2.polylines(out, [hull], True, colour[b["id"]], max(3, int(run.ppm / 10)), cv2.LINE_AA)
x, y, w, h = cv2.boundingRect(hull)
fs = int(max(photo.shape[1] / 52, 22))
name = clean(b.get("name") or b["id"])
if w > st.text_w(name, fs, True) * 0.9 and h > fs * 1.6:
mo = cv2.moments(hull)
cx, cy = (mo["m10"] / mo["m00"], mo["m01"] / mo["m00"]) if mo["m00"] else (x + w / 2, y + h / 2)
items.append((cx, cy, name, fs, (255, 255, 255), "mm"))
out = plate_text(out, items)
run.keep(f"blocks-{side}", out)
p = Plate(out)
cam = cam_safe(p)
shots.append(Shot(f"blocks-{side}", [View(p, VP, cam, push(cam, 1.06))], [side_panel([(say_num(n), "functional blocks")], "Functional blocks", note, extra=extra), face_chip(side)]))
if not shots:
raise Skip("no block has a placed part on a photographed face")
named = [clean(b.get("name") or b["id"]) for b in bl_sorted if b["id"] != "mechanical"][:3]
seen = sum(v for k, v in tags.items() if k in ("SEEN", "MEASURED", "DATASHEET"))
head = f"The parts fall into {plural(n, 'functional block')}"
if seen == 0:
head += ", all of them inferred, none confirmed on the board."
elif seen == n:
head += ", all confirmed on the board or by a document."
else:
head += f": {say_num(seen)} confirmed on the board or by a document, {say_num(n - seen)} inferred."
beats = [beat(head, shots[0].key)]
if len(named) >= 2:
beats.append(beat("The largest are " + ", ".join(named[:-1]) + " and " + named[-1] + ".", shots[-1].key, opt=2.4))
for s in shots[1:]:
s.min_len = 2.2
return Scene("blocks", "Functional blocks", shots, beats, weight=0.9)
# ---------------------------------------------------------------- 5 copper and vias
def _layer_side(x):
return "bottom" if x.get("layer") == "B.Cu" else "top"
def _copper_plate(run, side, cu, colour_of=None):
"""The face photo dimmed, with the run's pours, tracks and vias drawn on it. colour_of(item) gives a colour per piece."""
photo = run.face(side)
base = dim(photo, 0.62, 0.55)
out = base.copy()
zones = [z for z in cu.get("zones") or [] if _layer_side(z) == side]
traces = [t for t in cu.get("traces") or [] if _layer_side(t) == side]
groups = {}
for z in zones:
groups.setdefault(colour_of(z) if colour_of else ACCENT, []).append(z)
for col, zs in groups.items():
m = zone_mask(run, zs, out.shape)
out = tint(out, m, col, 0.6 if colour_of else 0.4)
edges, _ = cv2.findContours((m > 127).astype(np.uint8), cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
cv2.drawContours(out, edges, -1, col, max(1, int(run.ppm / 28)), cv2.LINE_AA)
tg = {}
for t in traces:
tg.setdefault(colour_of(t) if colour_of else ACCENT, []).append(t)
for col, ts in tg.items():
draw_tracks(run, out, ts, col)
vias = cu.get("vias") or []
if colour_of:
vg = {}
for v in vias:
vg.setdefault(colour_of(v), []).append(v)
for col, vs in vg.items():
draw_vias(run, out, vs, col)
else:
draw_vias(run, out, vias, (255, 255, 255))
return photo, out, zones, traces
def copper(run):
cu = run.j("copper.json")
if not cu or not (cu.get("zones") or cu.get("traces")) or not run.sides():
raise Skip("no copper.json: the copper was not traced")
nz = run.num("pours", len(cu.get("zones") or []), "copper.json zones")
nt = run.num("tracks", len(cu.get("traces") or []), "copper.json traces")
nv = run.num("vias", len(cu.get("vias") or []), "copper.json vias")
rows = [(say_num(nz), "pours"), (say_num(nt), "tracks"), (say_num(nv), "vias")]
note = "Amber: the copper as traced from this photo. White rings: vias."
audit = _audit(run, "copper")
if audit:
rows.append((f"{audit[0]} of {audit[1]}", "sampled points judged right by an independent audit"))
# a face whose copper failed the copper check: said in words, in the panel and aloud, and never left to look like a result
unread = run.facts.get("copperNotRead") or []
for f in unread:
rows.append(("NOT READ", f"{f.get('side')} face: " + ("not photographed" if f.get("photographed") is False else "its copper could not be read")))
if unread:
note = clean(note + " Nothing is claimed on a face marked NOT READ.")
shots, made = [], {}
for side in run.sides():
photo, drawn, zones, traces = _copper_plate(run, side, cu)
run.keep(f"copper-{side}", drawn)
made[side] = (Plate(dim(photo, 0.95, 0.0)), Plate(drawn), traces)
first = run.sides()[0]
a, b, _ = made[first]
cam = cam_safe(a)
shots.append(Shot(f"copper-{first}", [View([a, b], VP, cam, push(cam, 1.05), fades=[0, 0.9])], [side_panel(rows, "Copper and vias", note), face_chip(first)]))
beats = [beat(f"The copper is traced from the photos: {plural(nz, 'pour')} and {plural(nt, 'track')}.", f"copper-{first}")]
# the densest area: where the most track corners and vias are
dense_side = max(made, key=lambda s: len(made[s][2]))
a, b, traces = made[dense_side]
scale_k = b.w / (run.face(dense_side).shape[1])
pts = [run.px(q) for t in traces for q in (t.get("path_mm") or [])]
pts += [run.px(v["pos_mm"]) for v in cu.get("vias") or [] if isinstance(v.get("pos_mm"), list)]
pts = [(x * scale_k, y * scale_k) for x, y in pts]
win_w = b.w / 3.4
rect = densest(pts, (b.h, b.w), win_w, win_w * VP[3] / VP[2])
if rect:
cam0 = cam_safe(b) if dense_side != first else push(cam, 1.05)
shots.append(Shot("dense", [View(b, VP, cam0, cam_rect(rect))], [side_panel(rows, "Copper and vias", note), face_chip(dense_side)]))
beats.append(beat(f"And {plural(nv, 'via')}. This is the densest area.", "dense", opt=1.2))
shots[-1].min_len = 2.6
else:
beats.append(beat(f"And {plural(nv, 'via')}.", f"copper-{first}", opt=1.2))
others = [s for s in run.sides() if s != first]
if others:
a, b, _ = made[others[0]]
cam = cam_safe(a)
shots.append(Shot(f"copper-{others[0]}", [View(b, VP, cam, push(cam, 1.06))], [side_panel(rows, "Copper and vias", note), face_chip(others[0])], min_len=2.2))
for f in unread:
key = f"copper-{f.get('side')}" if any(sh.key == f"copper-{f.get('side')}" for sh in shots) else shots[-1].key
run.num(f"copper not read: {f.get('side')} face", f.get("short"), "reverse-flow/checks/copper.ok summary.notRead")
if f.get("photographed") is False:
beats.append(beat(f"The {f.get('side')} face was not photographed: this run read one face only, so no via and no join between the faces is claimed.", key))
else:
beats.append(beat(f"The copper of the {f.get('side')} face could not be read, so nothing on that face is claimed.", key))
if audit:
beats.append(beat(f"An independent audit of sampled points judged {audit[0]} of {audit[1]} right.", shots[-1].key, opt=2.0))
return Scene("copper", "Copper and vias", shots, beats, weight=1.3)
def _audit(run, cat):
"""(ok, audited) over the strata of a category's scored audit, or None when no complete audit stands."""
for c in (run.facts.get("scores") or {}).get("categories") or []:
if c.get("name") == cat:
a = c.get("audit") or {}
strata = a.get("strata") or []
if str(a.get("status", "")).startswith("scored") and strata:
ok = sum(int(s.get("ok") or 0) for s in strata)
n = sum(int(s.get("audited") or 0) for s in strata)
if n:
run.num(f"{cat} audit: judged right", ok, f"the {cat} audit as the program scores it now (reverse-flow/audits/{cat}/, facts.json scores.categories.{cat}.audit.strata)")
run.num(f"{cat} audit: sampled", n, f"the same audit, items judged")
return say_num(ok), say_num(n)
return None
# ---------------------------------------------------------------- 6 the KiCad copy against the photo
def _kicad_render(run, photo):
"""KiCad's own picture of the copy's top face, cut to the board and sized like the photo; None when it cannot be lined up."""
rel = ((run.facts.get("kicad") or {}).get("render") or {}).get("top")
if not rel or not run.has(rel):
return None, (run.facts.get("kicad") or {}).get("why") or "no KiCad picture of the copy was rendered"
try:
im = Image.open(run.p(rel)).convert("RGBA")
except Exception as e:
return None, f"the KiCad picture could not be read: {e}"
a = np.asarray(im)
solid = a[:, :, 3] > 8 if a[:, :, 3].min() < 250 else (a[:, :, :3].min(axis=2) < 246)
rows, cols = solid.sum(axis=1), solid.sum(axis=0)
if rows.max() < 8 or cols.max() < 8:
return None, "the KiCad picture is empty"
# the board's edges: where at least 60% of a full row or column is solid, so a part hanging over an edge does not move them
ys, xs = np.flatnonzero(rows >= 0.6 * rows.max()), np.flatnonzero(cols >= 0.6 * cols.max())
x0, x1, y0, y1 = xs[0], xs[-1] + 1, ys[0], ys[-1] + 1
want = photo.shape[1] / photo.shape[0]
if abs((x1 - x0) / (y1 - y0) / want - 1) > 0.02:
return None, "the board in KiCad's picture does not have the photo's proportions, so the two cannot be lined up by their outlines"
rgb = a[y0:y1, x0:x1, :3].astype(np.float32)
al = (a[y0:y1, x0:x1, 3:4].astype(np.float32) / 255.0)
rgb = (rgb * al + np.array(BG, np.float32) * (1 - al)).astype(np.uint8)
run.used(run.p(rel))
return cv2.resize(rgb, (photo.shape[1], photo.shape[0]), interpolation=cv2.INTER_AREA), None
def kicad(run):
pcb = (run.facts.get("kicad") or {}).get("pcb")
photo = run.face("top")
if not pcb or photo is None:
raise Skip("no KiCad board is registered in state.json: the build step did not run")
cu = run.j("copper.json") or {}
n_parts = run.num("footprints", len(run.parts()), "parts.json parts")
nz, nt, nv = len(cu.get("zones") or []), len(cu.get("traces") or []), len(cu.get("vias") or [])
render, why = _kicad_render(run, photo)
fid = run.j("fidelity.json") or {}
target = fid.get("targetIoU")
iou = {}
for side, layer in (("top", "F.Cu"), ("bottom", "B.Cu")):
v = (((fid.get("layers") or {}).get(layer) or {}).get("heldOutTiles") or {}).get("iou")
if isinstance(v, (int, float)):
iou[side] = run.num(f"{layer} overlap with the photo on held-out tiles (intersection over union)", v, f"fidelity.json layers.{layer}.heldOutTiles.iou")
rows = [(say_num(n_parts), "footprints"), (say_num(nz + nt), "pours and tracks"), (say_num(nv), "vias")]
shots, beats = [], []
if render is not None:
a, b = Plate(photo), Plate(render)
cam = cam_safe(a)
shots.append(Shot("wipe", [WipeView([a, b], VP, cam, push(cam, 1.04))], [side_panel(rows, "The KiCad copy", "Left of the line: the photo. Right of it: KiCad's own picture of the copy, the same place at the same size."), face_chip("top")]))
beats.append(beat("All of it goes into a KiCad project. This is KiCad's own picture of the copy, against the photo.", "wipe"))
else:
run.note(f"KiCad's own picture is not shown: {why}. The scene uses the program's overlay instead.")
ov = run.img("evidence/overlay-top.jpg")
if ov is not None:
a, b = Plate(photo), Plate(ov)
cam = cam_safe(a)
shots.append(Shot("overlay", [View([a, b], VP, cam, push(cam, 1.06), fades=[0, 0.9])], [side_panel(rows, "The KiCad copy", "Orange dots: copper the copy fills. Cyan: the pads of its footprints."), face_chip("top")]))
beats.append(beat("All of it goes into a KiCad project. Here the copy's copper and pads are drawn back over the photo.", "overlay"))
fimg = run.img("evidence/fidelity-F_Cu.jpg") if "top" in iou else None
if fimg is not None:
p = Plate(fimg)
cam = cam_safe(p)
pct = lambda v: say_num(round(100 * v))
rows_f = [(pct(iou["top"]) + "%", "top copper: overlap of the copy with the photo, on tiles held out from tuning")]
if "bottom" in iou:
rows_f.append((pct(iou["bottom"]) + "%", "bottom copper, the same measure"))
if isinstance(target, (int, float)):
run.num("overlap target", target, "fidelity.json targetIoU")
rows_f.append((pct(target) + "%", "target"))
note = "Overlap is the shared area divided by the area either shows."
if run.facts.get("fidelityCurrent") is False:
note += " Scored before the last change to the copper or the parts."
run.note("fidelity.json is older than the copper or the parts it scored: the figures are from the last layer scoring")
def legend(c, y):
for col, lab in (((30, 190, 60), "copy and photo agree"), ((255, 40, 40), "only in the copy"), ((60, 200, 255), "only in the photo"), ((100, 40, 100), "not scored: parts, print")):
c.rect(44, y + 4, 44 + 28, y + 32, col, radius=5)
c.text(88, y, lab, 28, INK)
y += 46
return y + 8
shots.append(Shot("fidelity", [View(p, VP, cam, push(cam, 1.08))], [side_panel(rows_f, "Copy against photo", note, extra=legend), face_chip("top")]))
line = f"On tiles held out from tuning, the copy's top copper overlaps the photo's by {pct(iou['top'])} percent."
if isinstance(target, (int, float)):
line += f" The target is {pct(target)}."
beats.append(beat(line, "fidelity", opt=1.1))
shots[-1].min_len = 2.4
if not shots:
raise Skip("no picture of the copy: neither a KiCad render nor evidence/overlay-top.jpg nor a layer score")
return Scene("kicad", "The KiCad copy", shots, beats, weight=1.1)
# ---------------------------------------------------------------- 7 nets
def _net_panel(run, side):
"""The board panel of the program's own nets/confidence-<side>.jpg, by the layout `nets draw` uses for it."""
img = run.img(f"nets/confidence-{side}.jpg", record=False)
face = run.face(side)
if img is None or face is None:
return None
pw, ph = int(round(face.shape[1] / run.k)), int(round(face.shape[0] / run.k))
u = max(pw / 1500.0, 1.2)
x, y = 12.0 * u, 108.0 * u
if abs(img.shape[1] - (pw + 2 * x)) > 3 or abs(img.shape[0] - (ph + y + 238.0 * u)) > 3:
run.note(f"nets/confidence-{side}.jpg is not laid out as expected, so the scene draws the copper by rung itself")
return None
run.used(run.p(f"nets/confidence-{side}.jpg"))
out = img[int(round(y)):int(round(y)) + ph, int(round(x)):int(round(x)) + pw]
return cv2.resize(out, (face.shape[1], face.shape[0]), interpolation=cv2.INTER_AREA) if run.k < 1.0 else out.copy()
def nets(run):
cu = run.j("copper.json") or {}
if not run.sides() or not (cu.get("zones") or cu.get("traces")):
raise Skip("no copper to colour by net")
conf = run.j("net-confidence.json")
nets_doc = (run.j("nets.json") or {}).get("nets") or []
if not nets_doc and not conf:
raise Skip("no nets.json: no net was named")
shots, beats = [], []
plates = {}
for side in run.sides():
img = _net_panel(run, side) if conf else None
if img is None:
_, img, _, _ = _copper_plate(run, side, cu, lambda it: st.RUNG_COLOURS.get(((it.get("netEvidence") or {}).get("rung")) or "none", st.RUNG_COLOURS["none"]))
run.keep(f"nets-by-rung-{side}", img)
plates[side] = (Plate(img), "drawn here from copper.json netEvidence.rung")
else:
plates[side] = (Plate(img), "the program's own picture, reverse-flow/nets/")
col = st.RUNG_COLOURS
if conf:
sm = conf.get("summary") or {}
lv = sm.get("levels") or {}
n = run.num("nets", sm.get("nets", len(conf.get("nets") or [])), "net-confidence.json summary.nets")
c, v, i = (run.num(f"nets at level {k}", int(lv.get(k) or 0), f"net-confidence.json summary.levels.{k}") for k in ("copper", "via", "inferred"))
rows = [(say_num(n), "nets"), (say_num(c), "joined by copper seen in the photos", col["copper"]), (say_num(v), "joined through vias", col["via"]), (say_num(i), "rest on inference", col["inferred"])]
note = "A net takes the level of the weakest link joining its pins."
if isinstance(sm.get("unnamedIslands"), (int, float)):
note += f" {say_num(run.num('isolated islands of copper with no net', sm['unnamedIslands'], 'net-confidence.json summary.unnamedIslands'))} isolated islands of copper carry no net (grey)."
beats.append(beat("A net is only as proven as the weakest link joining its pins.", f"nets-{run.sides()[0]}", opt=1.6))
line = f"Of {plural(n, 'net')}, {say_num(c)} {'is' if c == 1 else 'are'} joined by copper seen in the photos, " + (f"{say_num(v)} through vias, " if v else "") + f"and {say_num(i)} rest on inference."
else:
run.note("no reverse-flow/nets/ folder: the copper is coloured by its own evidence rung, read from copper.json")
cnt = {}
for it in (cu.get("zones") or []) + (cu.get("traces") or []):
r = ((it.get("netEvidence") or {}).get("rung")) or "none"
cnt[r] = cnt.get(r, 0) + 1
run.num("pieces of copper by evidence rung", cnt, "copper.json zones[] and traces[] netEvidence.rung")
n = run.num("nets", len(nets_doc), "nets.json nets")
total = sum(cnt.values())
rows = [(say_num(n), "nets named"), (say_num(cnt.get("copper", 0)), "pieces of copper tied to a net by copper seen joining a pad", col["copper"]),
(say_num(cnt.get("via", 0)), "tied through a via", col["via"]), (say_num(cnt.get("inferred", 0)), "tied by inference", col["inferred"]),
(say_num(cnt.get("none", 0) + cnt.get("group", 0)), "carry no net", col["none"])]
note = "Each piece of copper is coloured by how its net is known."
beats.append(beat(f"{plural(n, 'net')} are named. Each piece of copper is coloured by how its net is known.", f"nets-{run.sides()[0]}", opt=1.6))
line = f"Of {say_num(total)} pieces of copper, {say_num(cnt.get('copper', 0))} are tied to a net by copper seen at a pad, and {say_num(cnt.get('none', 0) + cnt.get('group', 0))} carry no net."
for k, side in enumerate(run.sides()):
p, _ = plates[side]
cam = cam_safe(p)
cam1 = push(cam, 1.1)
shots.append(Shot(f"nets-{side}", [View(p, VP, cam, cam1)], [side_panel(rows, "Nets by confidence", note), face_chip(side)]))
beats.append(beat(line, shots[-1].key))
for s in shots[:-1]:
s.min_len = 2.2
# one or two nets lit, from the program's own per-net pictures
if conf and run.has("nets/sections.json"):
pics = (run.j("nets/sections.json") or {}).get("pictures") or {}
rows_n = [r for r in conf.get("nets") or [] if pics.get(r.get("net")) and run.has(os.path.join("nets", pics[r["net"]]))]
ground = lambda r: str(r.get("net", "")).upper() in ("GND", "GROUND", "0V", "VSS")
numbered = lambda r: bool(re.match(r"^(N|NET|UNK)[_-]?\d+$", str(r.get("net", "")), re.I))
best = sorted([r for r in rows_n if r.get("level") == "copper" and int(r.get("pins") or 0) >= 2 and not ground(r)], key=lambda r: (numbered(r), -int(r.get("pinsSeen") or 0)))
weak = sorted([r for r in rows_n if r.get("level") == "inferred" and int(r.get("pins") or 0) >= 2 and not ground(r) and (r.get("weakestLink") or {}).get("what")], key=lambda r: (numbered(r), -int(r.get("pins") or 0)))
picks = [(r, 2.3) for r in best[:1]] + [(r, 3.1) for r in weak[:1]]
for r, opt in picks:
img = run.img(os.path.join("nets", pics[r["net"]]))
if img is None:
continue
p = Plate(img)
vp = (128, 132, 1664, 936)
key = f"net-{len(shots)}"
shots.append(Shot(key, [View(p, vp, cam_fit(p, vp[2], vp[3], margin=0.0))], [], min_len=2.6 if opt < 3 else 0))
name = clean(r["net"])
run.num(f"net {name}: pins", int(r.get("pins") or 0), f"net-confidence.json nets[{name}].pins")
run.num(f"net {name}: pins seen joined", int(r.get("pinsSeen") or 0), f"net-confidence.json nets[{name}].pinsSeen")
if r.get("level") == "copper":
beats.append(beat(f"One net lit: {_spoken_net(name)}. All {say_num(int(r.get('pins') or 0))} of its pins are seen joined by copper." if int(r.get("pinsSeen") or 0) == int(r.get("pins") or 0)
else f"One net lit: {_spoken_net(name)}, proven by copper.", key, opt=opt))
else:
beats.append(beat(f"And one that is not proven: {_spoken_net(name)}. {say_num(int(r.get('pinsSeen') or 0))} of its {say_num(int(r.get('pins') or 0))} pins are seen joined; the rest is inference.", key, opt=opt))
return Scene("nets", "Nets by confidence", shots, beats, weight=1.3)
def _spoken_net(name):
"""A net name as a voice can say it: signs and underscores spelled out."""
s = name.replace("+", "plus ").replace("_", " ").replace("/", " ")
return re.sub(r"\s+", " ", s).strip()
# ---------------------------------------------------------------- 8 schematic
def schematic(run):
files = sorted(glob.glob(run.p("schematic/*.png")))
if not files:
raise Skip("no rendered schematic sheet under reverse-flow/schematic: `report` writes them when KiCad's command line answers")
sm = ((run.j("checks/schematic.json") or {}).get("summary")) or {}
sty = sm.get("style") or {}
n_sheets = run.num("schematic sheets", len(files), "reverse-flow/schematic/*.png")
rows = [(say_num(n_sheets), "sheets")]
have = all(isinstance(sty.get(k), (int, float)) for k in ("symbols", "wires", "wiresDashed"))
if have:
sym = run.num("symbols", sty["symbols"], "checks/schematic.json summary.style.symbols")
wires = run.num("wires", sty["wires"], "checks/schematic.json summary.style.wires")
dashed = run.num("dashed wires", sty["wiresDashed"], "checks/schematic.json summary.style.wiresDashed")
rows += [(say_num(sym), "symbols"), (say_num(wires - dashed), "wires drawn solid: the pad was seen joined to its net's copper"), (say_num(dashed), "wires drawn dashed: inferred")]
if isinstance(sty.get("unknownMarkers"), (int, float)):
rows.append((say_num(run.num("pins of unknown net", sty["unknownMarkers"], "checks/schematic.json summary.style.unknownMarkers")), "pins marked ?: their net is not known"))
note = ""
erc, nl = sm.get("erc") or {}, sm.get("netlist") or {}
if erc.get("ran") and isinstance(erc.get("errors"), (int, float)):
note += f"KiCad's rule check: {plural(run.num('ERC errors', erc['errors'], 'checks/schematic.json summary.erc.errors'), 'error')}. "
if nl.get("ran"):
note += "KiCad's netlist equals nets.json." if nl.get("equal") else "KiCad's netlist does NOT equal nets.json."
run.num("KiCad netlist equals nets.json", bool(nl.get("equal")), "checks/schematic.json summary.netlist.equal")
sheets = []
for f in files:
im = run.img(f, record=False)
if im is None:
continue
ink = (im.min(axis=2) < 200)
sheets.append((f, im, int(ink.sum())))
if not sheets:
raise Skip("the schematic pictures could not be read")
root = sheets[0]
busiest = max(sheets[1:], key=lambda s: s[2]) if len(sheets) > 1 else sheets[0]
vp = (36, 132, PANEL_X - 72, H - 132 - 40)
shots, beats = [], []
def paper(im):
return Plate((im.astype(np.float32) * 0.93).astype(np.uint8))
if root is not busiest:
run.used(root[0])
p = paper(root[1])
cam = cam_fit(p, vp[2], vp[3], margin=0.0)
shots.append(Shot("root", [View(p, vp, cam, push(cam, 1.05))], [side_panel(rows, "The schematic", note), bottom_left(st.label_chip("ROOT SHEET: THE HIERARCHY"), lift=6)]))
run.used(busiest[0])
p = paper(busiest[1])
ink = busiest[1].min(axis=2) < 200
# the drawing frame is ink too: look for the busiest window inside it
ys, xs = np.nonzero(ink[::4, ::4])
rect = densest(np.stack([xs * 4, ys * 4], axis=1) if len(xs) else [], ink.shape, p.w / 1.7, p.w / 1.7 * vp[3] / vp[2])
cam = cam_fit(p, vp[2], vp[3], margin=0.0)
name = os.path.splitext(os.path.basename(busiest[0]))[0].split("-", 1)[-1].replace("_", " ").upper()
shots.append(Shot("sheet", [View(p, vp, cam, cam_rect(rect, vp) if rect else push(cam, 1.3))], [side_panel(rows, "The schematic", note), bottom_left(st.label_chip(st.fit(f"SHEET: {name}", 26, 900)), lift=6)]))
if have:
beats.append(beat(f"The schematic comes from the same parts and nets: {plural(n_sheets, 'sheet')}, {plural(sym, 'symbol')}.", shots[0].key))
beats.append(beat(f"{plural(wires - dashed, 'wire')} {'is' if wires - dashed == 1 else 'are'} solid, seen joined in copper. {say_num(dashed)} are dashed, inferred.", "sheet", opt=1.4))
if isinstance(sty.get("unknownMarkers"), (int, float)) and sty["unknownMarkers"]:
beats.append(beat(f"{plural(sty['unknownMarkers'], 'pin')} carry a question mark: their net is not known.", "sheet", opt=3.0))
else:
beats.append(beat(f"The schematic comes from the same parts and nets, on {plural(n_sheets, 'sheet')}.", shots[0].key))
beats.append(beat("A solid wire was seen joined in copper. A dashed wire is inferred.", "sheet", opt=1.4))
shots[-1].min_len = 2.4
return Scene("schematic", "The schematic", shots, beats, weight=0.9)
# ---------------------------------------------------------------- 9 scores
VERDICT_WORDS = {
"TARGET MET": ("The review reached its target.", "TARGET MET"),
"STALLED": ("The review loop stalled short of its target: a full pass raised nothing.", "STALLED"),
"STOPPED": ("The review stopped at its pass limit, short of its target.", "STOPPED"),
"CONTINUE": ("The review has not reached its target, and still names work to do.", "NOT REACHED"),
"NO REVIEW PASS": ("No review pass was run on these figures.", "NO REVIEW"),
}
def _limit_name(limit):
"""The name of the ceiling that holds a figure down: the text before the first colon of the program's limit line."""
return clean(str(limit).split(":")[0])
def scores(run):
s = run.facts.get("scores")
if not s or not s.get("categories"):
raise Skip("the program gave no scores (facts.json has none)")
src = "computed by the program at build time from the run's files (facts.json scores)"
photo = run.num("photo score", s["photo"], src)
bench = run.num("bench score", s["bench"], src)
target = run.num("target", s["target"], src)
verdict = run.num("review verdict", s.get("verdict") or "NO REVIEW PASS", src)
run.num("confidence line", s.get("line"), src)
cats = [c for c in s["categories"] if c.get("photo")]
for c in s["categories"]:
for kind in ("photo", "bench"):
if c.get(kind):
run.num(f"{c['name']}: {kind} figure", c[kind]["score"], src)
low = min(cats, key=lambda c: c["photo"]["score"]) if cats else None
top = run.face("top")
ground = st.blurred_ground(top, 0.22) if top is not None else None
c = st.Canvas(W, H)
fmt = lambda v: say_num(v, 1)
# the two scores
x0 = 96
for k, (lab, val, sub) in enumerate((("PHOTO SCORE", photo, f"what photos can show | target {fmt(target)}"), ("BENCH SCORE", bench, "what needs the real board or documents"))):
y = 150 + k * 330
c.rect(x0, y, x0 + 640, y + 290, PANEL + (235,), radius=18, outline=LINE, width=2)
c.text(x0 + 36, y + 28, lab, 28, SOFT, True)
wv = c.text(x0 + 36, y + 66, fmt(val), 150, ACCENT if k == 0 else INK, True)
c.text(x0 + 36 + wv + 16, y + 158, "of 100", 36, SOFT)
c.lines(x0 + 36, y + 236, st.wrap(sub, 25, 570, max_lines=1), 25, SOFT)
words, chip = VERDICT_WORDS.get(verdict, (f"The review's verdict is {verdict}.", verdict))
ok = verdict == "TARGET MET"
c.rect(x0, 820, x0 + 640, 900, (ACCENT if ok else CHIP) + (255,), radius=14, outline=None if ok else st.WARN, width=3)
c.text(x0 + 320, 860, f"REVIEW: {chip}", 34, CHIP if ok else st.WARN, True, anchor="mm")
# the categories
bx, bw = 820, 1000
c.rect(bx, 150, bx + bw, 900, PANEL + (235,), radius=18, outline=LINE, width=2)
c.text(bx + 36, 178, "PHOTO FIGURE BY CATEGORY", 28, SOFT, True)
rows = sorted(cats, key=lambda q: q["photo"]["score"])[:8]
bar_x, bar_w = bx + 290, bw - 290 - 150
step = min(64, int(440 / max(1, len(rows))))
for k, q in enumerate(rows):
y = 240 + k * step
v = float(q["photo"]["score"])
c.text(bx + 36, y, st.fit(q["name"], 30, 240), 30, INK)
c.rect(bar_x, y + 6, bar_x + bar_w, y + 32, (38, 44, 56), radius=6)
if v > 0:
c.rect(bar_x, y + 6, bar_x + max(8, bar_w * min(100.0, v) / 100.0), y + 32, ACCENT, radius=6)
c.text(bar_x + bar_w + 22, y, fmt(v), 30, INK, True)
tx = bar_x + bar_w * min(100.0, float(target)) / 100.0
c.d.line((tx, 228, tx, 240 + len(rows) * step - 12), fill=INK + (255,), width=3)
c.text(tx, 240 + len(rows) * step - 6, f"target {fmt(target)}", 24, SOFT, anchor="ma")
if low:
y = 240 + len(rows) * step + 44
c.text(bx + 36, y, f"HELD DOWN BY: {low['name'].upper()}", 26, st.WARN if photo < target else SOFT, True)
c.lines(bx + 36, y + 42, st.wrap(clean(low["photo"]["limit"]), 26, bw - 72, max_lines=max(1, int((900 - y - 60) / 34))), 26, INK, gap=1.3)
views = []
shot = Shot("scores", views, [Layer(c.array(), 0, 0, fade=0)], ground=ground)
beats = [beat("The scores are computed from measurements and independent audits. None is typed in.", "scores", opt=2.5),
beat(f"The photo score, what photos can show, is {fmt(photo)} out of 100. The target is {fmt(target)}.", "scores")]
if low and photo < target:
beats.append(beat(f"It is held down by {low['name']}: {_limit_name(low['photo']['limit'])}.", "scores", opt=1.0))
beats.append(beat(f"The bench score, what needs the real board, is {fmt(bench)}.", "scores"))
beats.append(beat(words, "scores"))
return Scene("scores", "The scores", [shot], beats, weight=1.0)
# ---------------------------------------------------------------- 10 tests and what is still unknown
def tests(run):
tests = (run.j("tests.json") or {}).get("tests") or []
if not tests:
raise Skip("no tests.json: the tests were not generated")
n = run.num("tests", len(tests), "tests.json tests")
settled = run.num("open items the tests settle", len({s for t in tests for s in (t.get("settles") or [])}), "tests.json tests[].settles, distinct")
rows = [(say_num(n), "tests listed"), (say_num(settled), "open items they would settle")]
beats = [beat("What photos cannot show is listed with a test, not guessed.", "list", opt=2.7), beat(f"{plural(n, 'test')} would settle {plural(settled, 'open item')}.", "list")]
layers = run.j("layers.json") or {}
tag = str(layers.get("tag") or "UNKNOWN").upper()
prop = layers.get("proposed")
if isinstance(prop, (int, float)) and tag != "UNKNOWN":
run.num("layer count stated", int(prop), "layers.json proposed")
run.num("layer count tag", tag, "layers.json tag")
how = {"DATASHEET": "stated from a document, not seen in the photos", "MEASURED": "measured", "SEEN": "seen", "INFERRED": "inferred, not proven"}.get(tag, f"tagged {tag}")
rows.append((f"{int(prop)} layers", f"{how} ({tag})"))
beats.append(beat(f"The layer count, {int(prop)}, is {how}.", "list"))
else:
run.num("layer count tag", tag, "layers.json tag")
rows.append(("unknown", "the layer count: photos of two faces cannot show it"))
beats.append(beat("The layer count is unknown: photos of the two faces cannot show it.", "list"))
hidden = run.facts.get("hidden")
if isinstance(hidden, int) and hidden > 0:
run.num("connections no photo shows", hidden, "counted by the program from nets.json at build time (facts.json hidden)")
rows.append((say_num(hidden), "connections no photo shows, left unrouted"))
beats.append(beat(f"{plural(hidden, 'connection')} that no photo shows {'is' if hidden == 1 else 'are'} left unrouted.", "list", opt=1.7))
plan = run.j("bench/plan.json")
if plan:
cont = run.num("bench plan: continuity probes", len(plan.get("continuity") or []), "bench/plan.json continuity")
vals = run.num("bench plan: part values to measure", len(plan.get("values") or []), "bench/plan.json values")
rows.append((say_num(cont), "continuity probes in the bench plan"))
beats.append(beat(f"The bench plan lists {plural(cont, 'continuity probe')} and {plural(vals, 'part value')} to measure on the real board.", "list", opt=3.4))
# the list itself, as a tall page the camera moves down
order = sorted(tests, key=lambda t: (0 if re.match(r"T-(layers|via|pour|block)", str(t.get("id"))) else 1))
show = order[:44]
rh, pw = 62, PANEL_X
page = st.Canvas(pw, 150 + rh * len(show) + 120, BG + (255,))
for k, t in enumerate(show):
y = 150 + k * rh
if k % 2 == 0:
page.rect(36, y - 8, pw - 36, y + rh - 14, PANEL + (255,), radius=8)
page.text(60, y, st.fit(clean(t.get("id") or ""), 28, 300, True), 28, ACCENT, True)
page.text(380, y, st.fit(clean(t.get("method") or ""), 28, pw - 380 - 60), 28, INK)
if len(tests) > len(show):
page.text(60, 150 + rh * len(show) + 10, f"and {say_num(len(tests) - len(show))} more in reverse-flow/tests.json", 28, SOFT)
p = Plate(np.ascontiguousarray(page.array()[:, :, :3]))
cw = float(pw)
cam0 = (pw / 2, H / 2, cw)
cam1 = (pw / 2, max(H / 2, min(p.h - H / 2, H / 2 + rh * 14)), cw)
run.used(run.p("tests.json"))
band = np.zeros((150, PANEL_X, 4), np.uint8)
band[:, :, :3] = BG
band[:, :, 3] = np.clip((150 - np.arange(150)) / 34.0, 0, 1)[:, None] * 255
shot = Shot("list", [View(p, VP, cam0, cam1)], [Layer(band, 0, 0, fade=0), side_panel(rows[:5], "Tests and unknowns", "Every INFERRED or UNKNOWN item has a test that would settle it.")])
return Scene("tests", "Tests and unknowns", [shot], beats, weight=1.0)
# ---------------------------------------------------------------- 11 the end card
def end(run):
top = run.face("top")
ground = st.blurred_ground(top, 0.25) if top is not None else None
c = st.Canvas(W, H)
c.text(120, 230, "IN THE RUN FOLDER", 30, ACCENT, True)
y = 300
k = run.facts.get("kicad") or {}
items = []
if k.get("pcb"):
items.append((run.rel(k["pcb"]), "the KiCad copy"))
if run.has("report.md"):
items.append(("reverse-flow/report.md", "the report"))
if os.path.isdir(run.p("evidence")):
items.append(("reverse-flow/evidence/", "the pictures every measurement can be checked against"))
if run.has("tests.json"):
items.append(("reverse-flow/tests.json", "the tests for what is not known"))
items.append(("reverse-flow/video/video.json", "every scene of this video, with the file each figure came from"))
for path, what in items[:5]:
c.text(120, y, st.fit(path, 40, W - 240, True), 40, INK, True)
c.text(120, y + 54, st.fit(what, 28, W - 240), 28, SOFT)
y += 122
steps = [s for s in run.facts.get("steps") or [] if s.get("state") != "done"]
beats = [beat("Every figure here was read from the run's own files.", "end")]
if steps:
names = ", ".join(s["name"] for s in steps[:4])
run.num("steps not done", [s["name"] for s in steps], "the flow's step states at build time (facts.json steps)")
c.rect(120, H - 190, W - 120, H - 110, CHIP + (240,), radius=12, outline=st.WARN, width=3)
c.text(150, H - 150, st.fit(f"THIS RUN IS NOT COMPLETE. Not done or stale: {names}", 30, W - 300, True), 30, st.WARN, True, anchor="lm")
beats.append(beat(f"This run is not complete: the {names.replace(', ', ' and ') if len(steps) == 2 else names} step{'s are' if len(steps) > 1 else ' is'} not done on the files as they stand.", "end"))
else:
beats.append(beat("The KiCad copy, the report and the evidence pictures are in the run folder.", "end", opt=3.8))
return Scene("end", "In the run folder", [Shot("end", [], [Layer(c.array(), 0, 0, fade=0)], ground=ground)], beats, weight=0.4)
SEQUENCE = [("title", title), ("photos", photos), ("outline", outline), ("parts", parts), ("blocks", blocks), ("copper", copper),
("kicad", kicad), ("nets", nets), ("schematic", schematic), ("scores", scores), ("tests", tests), ("end", end)]