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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
"""The look of the run video: one dark ground, one accent colour, and the text and panel helpers every scene uses.
Everything is drawn with PIL into RGBA numpy arrays. Sizes are in pixels at 1920x1080. No text is smaller than MIN_TEXT,
so it stays readable when the video is played in a small window.
"""
import os
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter
W, H = 1920, 1080
FPS = 30
BG = (13, 16, 22) # the ground
PANEL = (21, 25, 33) # the figures panel
CHIP = (10, 12, 17) # dark chips over photos
LINE = (52, 60, 74) # hairlines
INK = (238, 241, 246) # main text
SOFT = (166, 176, 190) # secondary text
ACCENT = (255, 184, 48) # the one accent colour
WARN = (240, 96, 64) # only for "not reached" marks
MIN_TEXT = 24
PANEL_X = 1340 # the evidence picture is left of this, the figures panel right of it
MARGIN = 48
# How well a net is proven, in the ladder's order. Same colours as the program's own net pictures (src/netpaint.rs, RUNGS).
RUNG_COLOURS = {
"copper": (86, 180, 233), "via": (0, 205, 150), "inferred": (245, 170, 20),
"group": (220, 125, 190), "none": (190, 190, 190), "conflict": (240, 80, 20),
}
RUNG_WORDS = {
"copper": "joined by copper seen in the photos", "via": "joined through vias", "inferred": "rests on inference",
"group": "unnamed copper joined to other unnamed copper", "none": "isolated copper with no net", "conflict": "two nets meet",
}
# Blocks are told apart by hue (Okabe-Ito, lifted for a dark ground). This is data colour, not the accent.
BLOCK_HUES = [(86, 180, 233), (245, 170, 20), (0, 205, 150), (220, 125, 190), (240, 228, 66), (70, 130, 255), (240, 80, 20), (200, 200, 200),
(150, 220, 120), (255, 150, 150), (150, 150, 255), (120, 220, 220)]
_FONT_DIRS = ["/usr/share/fonts/truetype/dejavu", "/usr/share/fonts/dejavu", "/usr/share/fonts/TTF", "/usr/share/fonts/truetype/liberation",
"/usr/share/fonts/truetype/noto", "/Library/Fonts", "/System/Library/Fonts/Supplemental", "C:/Windows/Fonts"]
_FONT_NAMES = {False: ["DejaVuSans.ttf", "LiberationSans-Regular.ttf", "NotoSans-Regular.ttf", "Arial.ttf", "arial.ttf"],
True: ["DejaVuSans-Bold.ttf", "LiberationSans-Bold.ttf", "NotoSans-Bold.ttf", "Arial Bold.ttf", "arialbd.ttf"]}
_cache = {}
FONT_USED = {}
def _font_file(bold):
env = os.environ.get("ADOM_AIFLOW_VIDEO_FONT_BOLD" if bold else "ADOM_AIFLOW_VIDEO_FONT")
if env and os.path.isfile(env):
return env
for d in _FONT_DIRS:
for n in _FONT_NAMES[bold]:
p = os.path.join(d, n)
if os.path.isfile(p):
return p
return None
def font(size, bold=False):
"""A font at this size. A system sans when one is found, else PIL's built-in scalable font, so a build never fails for a font."""
key = (int(size), bold)
if key not in _cache:
f = _font_file(bold)
FONT_USED["bold" if bold else "regular"] = f or "PIL built-in"
_cache[key] = ImageFont.truetype(f, int(size)) if f else ImageFont.load_default(int(size))
return _cache[key]
def text_w(s, size, bold=False):
return font(size, bold).getlength(s)
def wrap(s, size, max_w, bold=False, max_lines=None):
"""Break text into lines no wider than max_w. With max_lines, the last line ends in three dots when text is left over."""
words, lines, cur = s.split(), [], ""
for w in words:
t = (cur + " " + w).strip()
if text_w(t, size, bold) <= max_w or not cur:
cur = t
else:
lines.append(cur)
cur = w
if cur:
lines.append(cur)
if max_lines and len(lines) > max_lines:
lines = lines[:max_lines]
last = lines[-1]
while last and text_w(last + " ...", size, bold) > max_w:
last = last.rsplit(" ", 1)[0] if " " in last else last[:-1]
lines[-1] = last + " ..."
return lines
def fit(s, size, max_w, bold=False):
"""One line, cut with three dots when it is too long."""
if text_w(s, size, bold) <= max_w:
return s
while s and text_w(s + "...", size, bold) > max_w:
s = s[:-1]
return s.rstrip() + "..."
class Canvas:
"""An RGBA picture to draw a panel or a chip on, then hand to the renderer as a layer."""
def __init__(self, w, h, fill=(0, 0, 0, 0)):
self.im = Image.new("RGBA", (int(w), int(h)), fill)
self.d = ImageDraw.Draw(self.im)
self.w, self.h = int(w), int(h)
def text(self, x, y, s, size, colour=INK, bold=False, anchor="la"):
self.d.text((x, y), s, font=font(size, bold), fill=tuple(colour) + (255,), anchor=anchor)
return text_w(s, size, bold)
def lines(self, x, y, lines, size, colour=INK, bold=False, gap=1.28):
for k, ln in enumerate(lines):
self.text(x, y + k * size * gap, ln, size, colour, bold)
return y + len(lines) * size * gap
def rect(self, x0, y0, x1, y1, fill, radius=0, outline=None, width=1):
fill = tuple(fill) + (255,) if fill is not None and len(fill) == 3 else fill
outline = tuple(outline) + (255,) if outline is not None and len(outline) == 3 else outline
self.d.rounded_rectangle((x0, y0, x1, y1), radius=radius, fill=fill, outline=outline, width=width)
def paste(self, rgb, x, y):
self.im.paste(Image.fromarray(rgb), (int(x), int(y)))
def array(self):
return np.asarray(self.im).copy()
def chapter_chip(index, title):
"""The chapter title, top-left, on a dark chip so it reads over any photo. Returns (rgba, x, y)."""
size = 34
num = f"{index:02d}"
tw = text_w(num, size, True) + 18 + text_w(title, size, True)
c = Canvas(tw + 56, 68)
c.rect(0, 0, c.w - 1, c.h - 1, CHIP + (232,), radius=14, outline=LINE + (255,), width=2)
x = 28
x += c.text(x, 34, num, size, ACCENT, True, anchor="lm") + 18
c.text(x, 34, title, size, INK, True, anchor="lm")
return c.array(), MARGIN, 40
def label_chip(s, size=26, colour=INK):
"""A small dark chip with one line, for naming what a picture is."""
c = Canvas(text_w(s, size, True) + 36, size + 26)
c.rect(0, 0, c.w - 1, c.h - 1, CHIP + (225,), radius=10, outline=LINE + (255,), width=1)
c.text(18, c.h / 2, s, size, colour, True, anchor="lm")
return c.array()
def _panel_rows(rows, title, w, h, extra, k):
c = Canvas(w, h, PANEL + (255,))
c.d.line((0, 0, 0, h), fill=LINE + (255,), width=2)
x0, inner = 44, w - 44 - 40
y = 150
if title:
c.text(x0, y, title.upper(), 26, SOFT, True)
y += 54
lab_size = max(26, int(28 * k))
for row in rows:
fig, lab = row[0], row[1]
colour = row[2] if len(row) > 2 else ACCENT
if fig is None:
y += 10 * k
c.text(x0, y, lab.upper(), 24, SOFT, True)
y += 44 * k
continue
size = int(76 * k)
while size > 40 and text_w(fig, size, True) > inner:
size -= 4
c.text(x0, y, fit(fig, size, inner, True), size, colour, True)
y += size * 1.16
y = c.lines(x0, y, wrap(lab, lab_size, inner, max_lines=3), lab_size, INK) + 26 * k
if extra:
y = extra(c, y)
return c, y
def panel(rows, title=None, note=None, w=W - PANEL_X, h=H, extra=None):
"""The figures panel on the right: big numbers with a label each, an optional note at the foot.
rows: (figure, label) or (figure, label, colour); a figure of None makes the label a small heading. extra(canvas, y)
may draw more under the rows and returns the new y. Everything is kept inside the panel: figures shrink to its width,
labels wrap, and the whole column is drawn smaller when it would run into the note or off the foot.
"""
inner = w - 44 - 40
note_lines = wrap(note, 25, inner, max_lines=7) if note else []
note_h = len(note_lines) * 25 * 1.3
for k in (1.0, 0.9, 0.8, 0.72, 0.64):
c, y = _panel_rows(rows, title, w, h, extra, k)
if y + 12 + note_h <= h - 48:
break
if note_lines and y + 12 + note_h <= h - 48:
c.lines(44, max(y + 12, h - 56 - note_h), note_lines, 25, SOFT, gap=1.3)
return c.array()
def blurred_ground(rgb, dim=0.32):
"""A photo turned into a quiet ground for a card: scaled to cover the frame, blurred and darkened."""
im = Image.fromarray(rgb)
s = max(W / im.width, H / im.height)
im = im.resize((int(im.width * s) + 1, int(im.height * s) + 1), Image.BILINEAR)
x, y = (im.width - W) // 2, (im.height - H) // 2
im = im.crop((x, y, x + W, y + H)).filter(ImageFilter.GaussianBlur(18))
a = np.asarray(im).astype(np.float32) * dim + np.array(BG, np.float32) * (1 - dim) * 0.9
return np.clip(a, 0, 255).astype(np.uint8)