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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
//! Photo intake for a set nobody sorted: `init --photos <folder or files>` records the photos as they came, and `stitch`
//! (run by `prep` when it has not been run) sorts them into faces, close-up tiles and overviews, picks the top, and
//! builds one mosaic per face at the close-ups' own resolution. The image registration is Python with OpenCV: the script
//! is `tools/stitch.py`, compiled into the binary, written to `reverse-flow/stitch/stitch.py` and run from there, and
//! the call (command line, script hash, exit status) goes into the run ledger. Its results land where the flow already
//! looks: the mosaics become the run's photos (`photos/top.jpg`, `photos/bottom.jpg`), so `prep`, `scale` and every later
//! step run on them unchanged. `stitch.json` and the evidence pictures say what was done and what is missing.
use crate::ledger;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
/// The stitcher, as shipped in tools/.
pub const SCRIPT: &str = include_str!("../tools/stitch.py");
/// Where the unsorted photos are kept, and where the stitcher works, inside the flow folder.
pub const RAW_DIR: &str = "photos-raw";
pub const WORK_DIR: &str = "stitch";
fn is_photo(p: &Path) -> bool {
matches!(p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(), Some("jpg") | Some("jpeg") | Some("png"))
}
/// A file name split into text and number runs, so that Board2.10 sorts after Board2.9.
fn natural_key(name: &str) -> Vec<(String, u64)> {
let mut out = vec![]; let (mut text, mut num, mut in_num) = (String::new(), String::new(), false);
for c in name.chars() {
if c.is_ascii_digit() { in_num = true; num.push(c); }
else { if in_num { out.push((std::mem::take(&mut text), num.parse().unwrap_or(0))); num.clear(); in_num = false; } text.push(c.to_ascii_lowercase()); }
}
out.push((text, num.parse().unwrap_or(0)));
out
}
/// The photos behind `--photos`: each argument is a photo or a folder of photos (jpg, jpeg, png; not searched deeper).
/// Sorted by name with numbers in order, no file twice.
pub fn collect(args: &[String]) -> Result<Vec<PathBuf>, String> {
let mut out: Vec<PathBuf> = vec![];
for a in args {
let p = Path::new(a);
if p.is_dir() {
let mut found: Vec<PathBuf> = std::fs::read_dir(p).map_err(|e| format!("{a}: {e}"))?.flatten().map(|e| e.path()).filter(|q| q.is_file() && is_photo(q)).collect();
if found.is_empty() { return Err(format!("{a}: no jpg, jpeg or png photos in this folder")); }
out.append(&mut found);
} else if p.is_file() {
if !is_photo(p) { return Err(format!("{a}: not a jpg, jpeg or png")); }
out.push(p.to_path_buf());
} else { return Err(format!("{a}: not a file or folder")); }
}
out.sort_by_key(|p| natural_key(&p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()));
out.dedup();
let mut names = std::collections::HashSet::new();
for p in &out { if !names.insert(p.file_name().map(|n| n.to_os_string())) { return Err(format!("two photos are both named {}: rename one", p.file_name().unwrap().to_string_lossy())); } }
Ok(out)
}
/// `init --photos`: copy the photos as they came and return the state entry that says they are not sorted yet.
pub fn record(flow_dir: &Path, files: &[PathBuf]) -> Result<Value, String> {
let raw = flow_dir.join(RAW_DIR);
std::fs::create_dir_all(&raw).map_err(|e| e.to_string())?;
let mut recs = vec![];
for src in files {
let name = src.file_name().unwrap().to_string_lossy().to_string();
let dst = raw.join(&name);
std::fs::copy(src, &dst).map_err(|e| format!("{}: {e}", src.display()))?;
recs.push(json!({"file": name, "source": src.display().to_string(), "sha256": crate::sha256(&dst)}));
}
Ok(json!({"unsorted": recs, "stitched": false}))
}
/// The run was started with unsorted photos and they have not been stitched yet.
pub fn pending(state: &Value) -> bool {
state["intake"]["unsorted"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && state["intake"]["stitched"] != json!(true)
}
/// What `next` adds while the photos wait to be sorted.
pub fn hint(flow_dir: &Path) -> Vec<String> {
let state: Value = std::fs::read_to_string(flow_dir.join("state.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));
if pending(&state) {
vec![format!("{} photos are not sorted into faces yet: `prep` does it first (or run `stitch` alone), then read stitch.json and stitch/*-sources.jpg, *-seams.jpg", state["intake"]["unsorted"].as_array().map(|a| a.len()).unwrap_or(0))]
} else { vec![] }
}
/// The run's photo list after stitching: one mosaic per face that was built.
pub fn photos_from_report(report: &Value) -> Vec<Value> {
let mut out = vec![];
for side in ["top", "bottom"] {
let f = &report["faces"][side];
if !f.is_object() { continue; }
let from: Vec<Value> = f["tiles"].as_array().into_iter().flatten().map(|t| t["file"].clone()).collect();
out.push(json!({"file": format!("{side}.jpg"), "side": side, "source": format!("{WORK_DIR}/{side}.jpg"), "mosaicOf": from, "overview": f["overview"]}));
}
out
}
/// One line per face for the hints, and what the report says is missing.
pub fn summary(report: &Value) -> Vec<String> {
let mut h = vec![];
for side in ["top", "bottom"] {
let f = &report["faces"][side];
if !f.is_object() { continue; }
let n = f["tiles"].as_array().map(|a| a.len()).unwrap_or(0);
let px = |v: &Value| v.as_f64().map(|x| format!("{x} px")).unwrap_or("not measured".into());
h.push(format!("{side}: {n} close-up tile(s){}, {} x {} px; close-ups cover {}% of the board, overview fill (low resolution) {}%, not seen {}%; tiles disagree in their overlaps by {} at the median, {} at the 95th percentile ({} cells)",
f["overview"].as_str().map(|o| format!(" and overview {o}")).unwrap_or_default(), f["size"][0], f["size"][1],
f["coverage"]["closeUpsPct"], f["coverage"]["overviewLowResolutionPct"], f["coverage"]["notSeenPct"], px(&f["overlap"]["medPx"]), px(&f["overlap"]["p95Px"]), f["overlap"]["cells"]));
for n in f["notes"].as_array().into_iter().flatten() { if let Some(s) = n.as_str() { h.push(format!("{side}: {s}")); } }
}
if let Some(d) = report["topDecision"].as_object() {
h.push(format!("top chosen by: {}{}", d["rule"].as_str().unwrap_or(""), d.get("confidence").and_then(|c| c.as_str()).map(|c| format!(" ({c})")).unwrap_or_default()));
}
for u in report["unplaced"].as_array().into_iter().flatten() {
h.push(format!("left out: {} ({})", u["photos"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "), u["why"].as_str().unwrap_or("")));
}
for m in report["missing"].as_array().into_iter().flatten() { if let Some(s) = m.as_str() { h.push(format!("missing: {s}")); } }
if let Some(s) = report["facesAssumed"].as_str() { h.push(format!("assumed: {s}")); }
h
}
/// The board's outline in a mosaic, as `stitch` recorded it (stitch.json next to photos/, `boardBoxPx` of that face):
/// the four corners TL, TR, BR, BL in the mosaic's pixels. `prep` falls back on it when its own colour test finds no
/// green board, which is every red or blue one. It is a box round the outline cut from the overview, so it can sit a
/// millimetre or two outside a board that lies turned in the mosaic.
pub fn board_box(photo: &Path, side: &str) -> Option<[crate::geom::P; 4]> {
let report = crate::items::read_json(&photo.parent()?.parent()?.join("stitch.json")).ok()?;
let face = &report["faces"][side];
if face["file"].as_str() != photo.file_name()?.to_str() { return None; }
let b: Vec<f64> = face["boardBoxPx"].as_array()?.iter().filter_map(|v| v.as_f64()).collect();
if b.len() != 4 || b[2] - b[0] < 16.0 || b[3] - b[1] < 16.0 { return None; }
Some([(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])])
}
fn python() -> String { std::env::var("ADOM_REVERSE_PYTHON").unwrap_or_else(|_| "python3".into()) }
/// Sort and stitch the run's unsorted photos. Writes stitch/ (mosaics, stitch.json, evidence), copies the mosaics into
/// photos/, sets the run's photo list, and logs the call. Returns the hints for the caller's answer.
pub fn run(p: &mut ledger::Project, thread: &str) -> Result<Vec<String>, String> {
let f = p.flow_dir.clone();
let unsorted: Vec<String> = p.state["intake"]["unsorted"].as_array().into_iter().flatten().filter_map(|r| r["file"].as_str().map(str::to_string)).collect();
if unsorted.is_empty() { return Err("this run was not started with `init --photos`: there is nothing to sort".into()); }
let work = f.join(WORK_DIR);
std::fs::create_dir_all(&work).map_err(|e| e.to_string())?;
let script = work.join("stitch.py");
std::fs::write(&script, SCRIPT).map_err(|e| format!("{}: {e}", script.display()))?;
let mut cmd = std::process::Command::new(python());
cmd.arg(&script).arg("--out").arg(&work);
let top_photo = p.state["intake"]["topPhoto"].as_str().map(str::to_string);
if let Some(t) = &top_photo { cmd.arg("--top-photo").arg(t); }
for name in &unsorted { cmd.arg(f.join(RAW_DIR).join(name)); }
cmd.env("STITCH_THREADS", "4");
let line = format!("{} {}/stitch.py --out {}{} {}", python(), WORK_DIR, WORK_DIR, top_photo.as_ref().map(|t| format!(" --top-photo {t}")).unwrap_or_default(), unsorted.iter().map(|n| format!("{RAW_DIR}/{n}")).collect::<Vec<_>>().join(" "));
let started = ledger::now();
let out = cmd.output().map_err(|e| format!("could not start {}: {e}. The stitcher needs python3 with numpy and OpenCV (cv2 with SIFT); set ADOM_REVERSE_PYTHON to choose the interpreter", python()))?;
std::fs::write(work.join("stitch.log"), [out.stdout.as_slice(), b"\n--- stderr ---\n", out.stderr.as_slice()].concat()).ok();
let call = json!({"command": line, "cwd": "reverse-flow", "script": format!("{WORK_DIR}/stitch.py"), "scriptSha256": crate::sha256(&script), "started": started, "ended": ledger::now(),
"exit": out.status.code(), "log": format!("{WORK_DIR}/stitch.log")});
if !out.status.success() {
p.log(thread, "stitch", json!({"call": call, "ok": false}));
let tail: Vec<String> = String::from_utf8_lossy(&out.stderr).lines().rev().take(4).map(str::to_string).collect::<Vec<_>>().into_iter().rev().collect();
return Err(format!("the stitcher failed (exit {:?}): {}. Full output in {}/{WORK_DIR}/stitch.log", out.status.code(), tail.join(" | "), f.display()));
}
let report = crate::items::read_json(&work.join("stitch.json"))?;
let mut photos = photos_from_report(&report);
if photos.is_empty() { return Err("the stitcher built no face: see stitch/stitch.json".into()); }
std::fs::create_dir_all(f.join("photos")).ok();
for ph in photos.iter_mut() {
let name = ph["file"].as_str().unwrap().to_string();
let dst = f.join("photos").join(&name);
std::fs::copy(work.join(&name), &dst).map_err(|e| format!("{name}: {e}"))?;
ph["sha256"] = json!(crate::sha256(&dst));
}
std::fs::copy(work.join("stitch.json"), f.join("stitch.json")).map_err(|e| e.to_string())?;
p.state["photos"] = json!(photos);
p.state["intake"]["stitched"] = json!(true);
p.state["intake"]["report"] = json!("stitch.json");
p.save().map_err(|e| e.to_string())?;
p.log(thread, "stitch", json!({"call": call, "ok": true, "photos": p.state["photos"], "topDecision": report["topDecision"], "unplaced": report["unplaced"]}));
let mut h = summary(&report);
h.push(format!("stitched by `{line}` (Python with OpenCV; the script is the one shipped in tools/stitch.py, its hash is in the ledger)"));
h.push("Look before going on: stitch/top-sources.jpg and bottom-sources.jpg (which photo each part of the mosaic came from), stitch/*-seams.jpg (the joins at full size), stitch/*-overlap.jpg (where the tiles disagree).".into());
if photos.len() == 1 { h.push("Only one face was built: bottom copper, via rings and layer evidence will be unknown. Ask for photos of the other face.".into()); }
Ok(h)
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("intake-{name}-{}", std::process::id())); std::fs::remove_dir_all(&d).ok(); std::fs::create_dir_all(&d).unwrap(); d }
#[test]
fn photos_come_from_folders_and_lists_in_number_order() {
let d = tmp("collect");
for n in ["Board2.10.jpeg", "Board2.9.JPG", "Board2.1.JPG", "notes.txt", "Board2.11.png"] { std::fs::write(d.join(n), "x").unwrap(); }
let got: Vec<String> = collect(&[d.display().to_string()]).unwrap().iter().map(|p| p.file_name().unwrap().to_string_lossy().to_string()).collect();
assert_eq!(got, ["Board2.1.JPG", "Board2.9.JPG", "Board2.10.jpeg", "Board2.11.png"]);
// a list of files gives the same, and a file named twice counts once
let list: Vec<String> = ["Board2.10.jpeg", "Board2.1.JPG", "Board2.1.JPG"].iter().map(|n| d.join(n).display().to_string()).collect();
assert_eq!(collect(&list).unwrap().len(), 2);
assert!(collect(&[d.join("notes.txt").display().to_string()]).is_err());
assert!(collect(&[d.join("missing.jpg").display().to_string()]).is_err());
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn an_empty_folder_is_an_error_not_an_empty_run() {
let d = tmp("empty");
assert!(collect(&[d.display().to_string()]).unwrap_err().contains("no jpg"));
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn unsorted_photos_wait_until_they_are_stitched() {
assert!(!pending(&json!({"photos": [{"file": "a.jpg", "side": "top"}]}))); // a run started with --top
let mut st = json!({"photos": [], "intake": {"unsorted": [{"file": "a.jpg"}], "stitched": false}});
assert!(pending(&st));
st["intake"]["stitched"] = json!(true);
assert!(!pending(&st));
}
#[test]
fn next_says_so_while_the_photos_wait() {
let d = tmp("hint");
std::fs::write(d.join("state.json"), json!({"intake": {"unsorted": [{"file": "a.jpg"}, {"file": "b.jpg"}], "stitched": false}}).to_string()).unwrap();
let h = hint(&d);
assert_eq!(h.len(), 1);
assert!(h[0].starts_with("2 photos are not sorted"));
std::fs::write(d.join("state.json"), json!({"photos": [{"file": "a.jpg", "side": "top"}]}).to_string()).unwrap();
assert!(hint(&d).is_empty());
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn the_mosaics_become_the_runs_photos_with_their_faces() {
let rep = json!({"faces": {"bottom": {"tiles": [{"file": "b1.jpg"}], "overview": null}, "top": {"tiles": [{"file": "t1.jpg"}, {"file": "t2.jpg"}], "overview": "o.jpg"}}});
let ph = photos_from_report(&rep);
assert_eq!(ph.len(), 2);
assert_eq!(ph[0]["file"], "top.jpg"); assert_eq!(ph[0]["side"], "top"); assert_eq!(ph[0]["mosaicOf"], json!(["t1.jpg", "t2.jpg"])); assert_eq!(ph[0]["overview"], "o.jpg");
assert_eq!(ph[1]["file"], "bottom.jpg"); assert_eq!(ph[1]["side"], "bottom");
// one face only: one photo, and it is the top
let one = photos_from_report(&json!({"faces": {"top": {"tiles": [{"file": "t.jpg"}]}}}));
assert_eq!(one.len(), 1); assert_eq!(one[0]["side"], "top");
}
#[test]
fn the_summary_says_what_is_missing_and_never_hides_low_resolution() {
let rep = json!({"faces": {"top": {"tiles": [{"file": "t1.jpg"}], "overview": null, "size": [4032, 3024],
"coverage": {"closeUpsPct": 100.0, "overviewLowResolutionPct": 0.0, "notSeenPct": 0.0}, "overlap": {"cells": 0, "medPx": null, "p95Px": null},
"notes": ["the board runs out of this photo on the left side(s)"]}},
"topDecision": {"rule": "only one face was photographed: it is written as the top", "confidence": "none"}, "missing": ["no photo of the other face"],
"unplaced": [{"photos": ["x.jpg"], "why": "matches no other photo"}]});
let h = summary(&rep).join("\n");
assert!(h.contains("not measured"));
assert!(h.contains("runs out of this photo"));
assert!(h.contains("missing: no photo of the other face"));
assert!(h.contains("left out: x.jpg"));
}
#[test]
fn stitch_is_the_first_thing_next_and_auto_run_on_unsorted_photos() {
let d = tmp("auto");
std::fs::write(d.join("state.json"), json!({"photos": [], "intake": {"unsorted": [{"file": "a.jpg"}], "stitched": false}}).to_string()).unwrap();
let fl = crate::flow::load();
let now = crate::auto::now_lines(&d, &fl);
assert!(now[0].starts_with("now: `stitch`"), "{now:?}");
// once stitched, the photos are measured
std::fs::write(d.join("state.json"), json!({"photos": [{"file": "top.jpg", "side": "top"}], "intake": {"unsorted": [{"file": "a.jpg"}], "stitched": true}}).to_string()).unwrap();
assert!(crate::auto::now_lines(&d, &fl)[0].starts_with("now: `prep`"));
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn a_mosaic_hands_its_board_box_to_prep() {
let d = tmp("box");
std::fs::create_dir_all(d.join("photos")).unwrap();
std::fs::write(d.join("stitch.json"), json!({"faces": {"top": {"file": "top.jpg", "boardBoxPx": [300, 200, 7000, 5000]}, "bottom": {"file": "bottom.jpg"}}}).to_string()).unwrap();
let c = board_box(&d.join("photos/top.jpg"), "top").unwrap();
assert_eq!(c, [(300.0, 200.0), (7000.0, 200.0), (7000.0, 5000.0), (300.0, 5000.0)]);
assert!(board_box(&d.join("photos/bottom.jpg"), "bottom").is_none()); // no box recorded for that face
assert!(board_box(&d.join("photos/other.jpg"), "top").is_none()); // not the mosaic
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn the_shipped_script_is_the_one_compiled_in() {
assert!(SCRIPT.starts_with("#!/usr/bin/env python3"));
assert!(SCRIPT.contains("def main():"));
// the measurement must copy each cell: OpenCV 5.0.0's phaseCorrelate writes into its inputs
assert!(SCRIPT.contains(".copy(), gb[y:y + S, x:x + S].copy()"));
}
/// Whether this machine can run the stitcher at all.
fn have_cv() -> bool {
std::process::Command::new(python()).args(["-c", "import cv2, numpy; cv2.SIFT_create()"]).output().map(|o| o.status.success()).unwrap_or(false)
}
/// End to end on a made-up board: four overlapping tiles and a smaller overview of each face, no labels. The stitcher
/// must put the tiles of each face together, call the face with the dark bodies the top, and agree in the overlaps.
#[test]
fn a_made_up_board_is_sorted_and_stitched_with_no_labels() {
if !have_cv() { eprintln!("skipped: no python3 with OpenCV here"); return; }
let d = tmp("e2e");
let gen = d.join("gen.py");
std::fs::write(&gen, include_str!("../tools/stitch_fixture.py")).unwrap();
let o = std::process::Command::new(python()).arg(&gen).arg(d.join("in")).output().unwrap();
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let files = collect(&[d.join("in").display().to_string()]).unwrap();
assert_eq!(files.len(), 10);
let flow_dir = d.join("reverse-flow");
std::fs::create_dir_all(&flow_dir).unwrap();
let intake = record(&flow_dir, &files).unwrap();
let mut p = ledger::Project::create(&d, "reverse-flow", json!({"photos": [], "intake": intake})).unwrap();
assert!(pending(&p.state));
let hints = run(&mut p, "test").unwrap_or_else(|e| panic!("{e}"));
assert!(!pending(&p.state));
assert!(hints.iter().any(|h| h.starts_with("top: 4 close-up tile(s)")), "{hints:?}");
let rep = crate::items::read_json(&flow_dir.join("stitch.json")).unwrap();
// the fixture's names say which face each tile is: the stitcher never saw that
for (side, want) in [("top", "a"), ("bottom", "b")] {
let f = &rep["faces"][side];
let tiles: Vec<&str> = f["tiles"].as_array().unwrap().iter().map(|t| t["file"].as_str().unwrap()).collect();
assert_eq!(tiles.len(), 4, "{side}: {tiles:?}");
assert!(tiles.iter().all(|t| t.starts_with(want)), "{side}: {tiles:?}");
assert!(f["overview"].as_str().unwrap().starts_with(want));
assert!(f["overlap"]["cells"].as_u64().unwrap() > 20, "{side}: {}", f["overlap"]);
assert!(f["overlap"]["medPx"].as_f64().unwrap() < 1.0, "{side}: {}", f["overlap"]);
assert!(f["coverage"]["closeUpsPct"].as_f64().unwrap() > 90.0, "{side}: {}", f["coverage"]);
assert!(flow_dir.join("photos").join(format!("{side}.jpg")).is_file());
}
assert_eq!(p.state["photos"].as_array().unwrap().len(), 2);
let ledger_text = std::fs::read_to_string(flow_dir.join("run.jsonl")).unwrap();
assert!(ledger_text.contains("\"ev\":\"stitch\"") && ledger_text.contains("stitch/stitch.py --out stitch"));
std::fs::remove_dir_all(&d).ok();
}
}