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.
← Commit history
Publish 0.4.0
25 files changed
+3467−235
Cargo.lock+1−1Cargo.toml+2−2README.md+44−2bin/adom-aiflow-reversedocs/method.md+86−4docs/status.md+15flows/flow.json+2−2package.json+1−1page.json+1−1skills/adom-aiflow-reverse/SKILL.md+1−1src/attrib.rs+72−16src/board.rs+265−1src/checks.rs+5−1src/confidence.rs+1286−79src/domes.rs+221−8src/fidelity.rs+37−9src/islands.rs+189−12src/kicad_out.rs+99−1src/main.rs+294−48src/passives.rs+416src/photolayers.rs+73−8src/photomat.rs+31−8src/prep.rs+149−16src/snap.rs+68−1src/trace.rs+109−13Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-aiflow-reverse"-version = "0.3.0"+version = "0.4.0" dependencies = [ "clap", "image",
Cargo.toml+2−2@@ -1,9 +1,9 @@-# adom-aiflow-reverse 0.3: reverse engineer a PCB from photos. The program measures (board outline,+# adom-aiflow-reverse 0.4: reverse engineer a PCB from photos. The program measures (board outline, # rectification, holes, part bodies, copper islands, vias, connectivity), generates the KiCad copy with the # photographed pours and tracks, and routes the rest on its own N-layer router; the AI names. [package] name = "adom-aiflow-reverse"-version = "0.3.0"+version = "0.4.0" edition = "2021" license = "MIT"
README.md+44−2@@ -8,9 +8,51 @@ Photos of a PCB in, a KiCad copy out: the parts identified, the functional block It is a standalone Rust program. It measures from the photos, generates the board, routes and checks. The AI running it supplies what takes judgement: what a part is, what a block does, what a net is called. Every claim carries an evidence tag: `SEEN`, `MEASURED`, `DATASHEET`, `INFERRED` or `UNKNOWN`. -**Status: under test on one board, not a finished result.** The review loop measures an overall confidence of 0 against a target of 90, and it reports STALLED. Copper and vias improved a lot, but pin nets barely moved, and the finer copper made routing harder. [docs/status.md](docs/status.md) lists every known gap.+**Status: under test on two boards, not a finished result.** On both boards the review loop measures an overall confidence of 0 against a target of 90, and it reports STALLED. Version 0.4.0 fixes what independent audits found on the second board, a Raspberry Pi Compute Module 5. The Driver board figures below are from 0.3.0: the Driver has not been run to completion or audited again on 0.4.0. [docs/status.md](docs/status.md) lists every known gap. -## Results on the Driver board+## Version 0.4.0: a second board, and what the audits found++The second board is a Raspberry Pi Compute Module 5, from ten photos. Audits of its first complete run judged more than half the placed parts wrong, so the program was corrected and the run repeated from the same photos.++| Fault the audits found | Change in 0.4.0 |+|---|---|+| Chip passives paired across neighbours | `detect parts` reads chip passives body first |+| `parts snap` moved small parts up to 0.95 mm onto neighbours | The reach follows the part's own size |+| Chip parts one size too large | The size is settled by which pads fit the photo's metal; ties go to the smaller part |+| Real vias beside pads dropped | A via stays when the photo shows board all round it; every dropped one is listed with its reason |+| Chip markings read as copper, dark spots in print read as vias | Nothing on a part's top is copper; one-face vias in print or on a part's top are dropped |+| Audit claims no photo could settle ("is on net X") | Claims state one join a crop can show; what shows no join counts as not correct |+| A KiCad check that did not run counted as 0 errors | It counts as unknown: no stackup closes on it and the layers check fails |+| Routing took most of the run time for no information | Routing stops after three stackups that leave the same connections open |++| CM5, independent audits | First complete run | 0.4.0 run |+|---|---|---|+| Placement, parts judged correct | 43 of 98 | 41 of 47 |+| Kept vias judged real | sample not completed | 43 of 51 |+| Copper points judged correct | sample voided | 61 of 101 |+| Copper and pad joins judged correct | could not be judged | 58 of 91 |++| CM5 against a sealed answer key (totals only) | Before | 0.4.0 run |+|---|---|---|+| Parts found, of 479 | 46% | 53% |+| Placed parts that are real | 91% | 98% |+| Median part position error | 0.225 mm | 0.107 mm |+| Through vias found | 63% | 89% |+| Copper match on bare mask, top / bottom | 58 / 31% | 58 / 32% |+| Pin pairs on one net that are right | 10% | 2% |++The answer key for the CM5 is another person's reconstruction of the board (schlae/cm5-reveng), not the maker's design. The CM5 cannot be rebuilt from photos: eight inner layers are unknown and every connection under its three ball grid parts is hidden.++**Known limits of 0.4.0.** The passive reader's colour thresholds were read from the CM5 photos; on the Driver board's darker photo it finds few parts and invents some, so check its candidates by eye. The via reader still accepts some dark spots that have no ring (8 of 51 sampled). Copper is read right at about 60% of audited points on the CM5, because a fine track is 4 pixels wide in the 40 px/mm working frame. Ties in pad fit going to the smaller part, and passive angles going to the nearest 45 degrees, are tested but not audited.++<table>+<tr>+<td width="104"><a href="runs/cm5-run6/README.md"><img src="runs/cm5-run6/cm5-run6-icon.jpg" width="112" alt="CM5 run"></a><br><sub> CM5 run </sub></td>+<td>The full result of the Compute Module 5 test: the report, the evidence pictures, the audit sheets and the KiCad copy. Read it to see what a dense ten-layer board gives from photos alone, and where each borrowed fact came from.<br><a href="runs/cm5-run6/README.md"><b>Open the run</b></a></td>+</tr>+</table>++## Results on the Driver board (0.3.0) The test case is a three-phase BLDC motor controller, from four photos and nothing else. All figures use KiCad's default rules.
bin/adom-aiflow-reverse⋯ 1 unchanged line ⋯
docs/method.md+86−4@@ -2,6 +2,32 @@ For anyone changing the copper tracing or the router. Each section gives what was tried, what happened and why the code is the way it is. +## A second board: what was tuned on one++Tried on the Raspberry Pi Compute Module 5 from public photos, which differ from the Driver photos in every way that matters: mask luma 130 against 44 and 64, flat studio light against a falloff of half across the board, no rows of holes, parts on both faces, and 24 px/mm against 46.6. This is what broke and what replaced it. Nothing here moves the Driver figures (`status.md`), because each new reading sits behind something the photo itself shows.++**The photos.** Two sets, not one board. `top-everypin` prints "2024 R4" and `top-cnx` "2024 R5". The bottoms differ too: "FCC ID: 2ABCB-CM5" and "Proant AB" against "2ABCB-RPICM5" and "Abracon", and date codes 2324 and 4024. Copper lines up between the sets on 98 percent of plain 2.5 mm tiles (median correlation 0.64 and 0.58) after a shift of 0.15 to 0.25 mm, and the tiles that do not are parallax on tall connectors in the angled shots, so what the photos show of the outer copper is the same design. They were still kept apart. The run uses everypin: its edges are 1.2 photo pixels wide where CNX's are 3.1, so its thin tracks stand 30 to 49 grey levels off the laminate against 6 to 11. CNX has more pixels and less resolution. Each pair is one layout: through features correlate at 0.228 mirrored the right way against 0.037 the wrong way, and the faces lie on each other at 0.021 mm RMS. Whether each pair is one physical unit, photos alone cannot say.++**The mask is measured.** `prep::MaskTone` reads the peak of the hue histogram, and the value, luma and G - R of the pixels around it, from each picture. The mask's brightest value is 1.1 times its 98th percentile, never under the 0.62 where mint plastic begins, so a dark mask reads exactly as before. `photomat::classify` multiplies thresholds that depend on how bright the mask photographs by `exposure()` (luma over 64) and `chroma()` (G - R over 45), and leaves those on black, white and gold alone, because those colours do not depend on the board. Both factors are 1 within a quarter of the Driver's values. On a bright mask the print's core does not lean blue as it does on the Driver's, and its own median becomes the bar.++**Which faces have parts** is measured too, by the share of the face that is moulding or ceramic bodies (`photomat::carries_parts`): 14 percent on the Driver top, 0.16 on its bottom, 3.7 on the CM5 bottom. The line is 1 percent.++**Framing through four holes.** `scale --holes 48x33 --board 55x40 --source "..."`. Four points give scale and perspective together, so each photo gets its own homography and the corners it implies replace the fitted ones in `prep.json`. Fitted corners were 0.14 to 0.27 mm off. Each hole is read from its gold ring's outer edge with a trimmed circle fit, because an angled view moves the opening's centre (0.11 to 0.12 mm here, 0.34 mm on CNX), and silk that runs into a ring makes the plain metal mask lose the hole, so `prep` also keeps a gold-only list. Px/mm differed by axis by 1.2 percent on everypin, which a single scale would have left as a warp. A symmetric hole pattern cannot say how the board was turned over, so the flip comes from what shows through both faces (`board::flip_by_detail`), with `--flip` for when that is not decisive. Where tiles correlate between faces, a quadratic through 21 of them registers the bottom to the top.++**Flat light.** Local levels exist to follow the light. Where the light does not move they follow the copper, and a wide field of laminate with thin tracks through it splits into its own two classes and reads as copper. `trace::light_spread` measures how far the copper level moves over 8 mm tiles: 0.32 and 0.56 on the Driver, 0.18 and 0.11 on everypin, with the line at 0.2. Below it the levels are one plane through the plain mask, started from the two modes of the green histogram (178 and 200, 181 and 204), with the long dark tail of shadows left out. Fitted as before, that tail took the laminate level to 142 and left nothing reading as laminate. The dim-patch rule in `settle` is off below the line too, since there is no poor light to excuse a dim patch.++**Over-sharpening.** The photo digs a dark band round anything bright, 25 percent deep and 5 frame pixels wide on the CM5. It read as gap, and it set the scale of every line threshold, which depend on the 99th percentile of the response, so real gaps fell under them. `trace::sharpening_halo` measures it from the median green against distance from hidden pixels, and treats it as unseen. It counts only if it dips at least 10 percent and recovers within 8 px. The Driver top dips too, but over 13 px, which is a cast shadow, so it is left alone.++**Metal parts.** On a face with parts, pale neutral pixels were transparent as silk. A lid and a can are also pale and neutral, so copper ran straight through them and joined the whole face into one island. Pale pixels are now print only below 1.2 mm of solid width.++**Vias under frontal light.** Neither face has a dome highlight: the shading agreement is 0.11 and 0.21, so `domes` rightly says flat. A via still shows, as a dark pit about 0.18 mm wide with a light ring from sharpening. `domes::read_pits` measures every dark spot and keeps round ones in mask, of the face's own median size. On their own those are not enough: on the Driver top the same test passes 684 specks at 0.11 mm. Real through vias line up with the other face and grain does not, so a face's pits count only if at least a fifth have something on the other face within 0.1 mm and that is five times what the same count finds with the other face mirrored (`domes::pits_confirmed`). The Driver top finds 3 of 684, under its chance level of 9. The CM5 top finds 242 of 358 against 14. Pit width is the hole's dimple, not the pad. `size_vias` tried to read pads from the fall of brightness round each via, and its 0.32 to 1.17 mm spread is not one pad size, so it now refuses a spread above double and sizes stay as read.++**The scorer.** `copper_read` failed in the same two ways. Otsu split inside the dark tail, and the envelope sat on the laminate wherever a window was mostly laminate, so everything scored as copper. When a bright photo's plain mask has two modes, the split is half way between them against the face's own level. That threshold is shared with the reader, so this copper score is less independent than the Driver's: it knows nothing of gap lines, and it does respond to a 0.25 mm shift (0.72 to 0.62, 0.76 to 0.58), but it cannot show a wrong level. `via_report` looks for small dark spots on a bright mask, where a bright-blob test finds nothing.++**Smaller things a second board needed.** `build` writes bottom-side parts, flipped as KiCad flips them. `copper trace` starts from the outline `scale` measured when nobody sketched a `copper.json`. `check <step> --skip-earlier "<why>"` runs one check without the part-naming steps before it, logs that, and does not pass the step. A corner radius under 8 px no longer hides the whole face.++**Still from one board.** `rounded_board` assumes a 2.6 mm corner. `PIT_*` bars are from everypin tile set A. Exposure factors are a straight line through one bright board. `parts` package sizes assume bodies read as moulding or tan, and bleached ceramics on a bright mask do not; closing flecked metal on a bright mask also turns large print into candidates.+ ## The visible layers come from the photos, and how they are scored `layers trace` writes `photo-layers.json`; `build` draws from it when it exists. `check fidelity` plots the board through KiCad's service, draws its gerbers and drill file at 40 px/mm (`gerber.rs`) and compares them with what each photo shows.@@ -65,6 +91,8 @@ Check any change by eye against `evidence/copper-islands-*.jpg`. Every island read ends up in `detect/copper-island-accounting.json`: kept as an item, merged into one, or not copper with a reason. Nothing is filtered for disagreeing with a rule: what the photo shows is drawn, and disagreements are reported (`trackPadConflicts` in `detect/copper-measured.json`). +**On a part's top.** Print on a chip, the step of a metal lid and light on a package read as small islands, and no copper can be seen there. An island is on a part's top when three things hold. At least 90 percent of it lies inside the body outline of one part placed on that face: the box of the footprint's fabrication-layer drawing at the part's place and rotation, drawn in by 0.25 mm, so terminals, leads and fillets at a body's edge stay out of it. The reader found copper over no more than a quarter of that outline: more means board shows there, through a connector or shield open in the middle, at a fiducial, or under an outline that is in the wrong place. And less than half of the island has the colour of the board's own copper, read off the face (the channel that stands furthest above grey in the median of everything read as copper is the mask's own, and a pixel has the board's colour when that channel stands a quarter as far above grey in it): that keeps copper beside a part when parts.json draws its outline too large. A two-pad part is left out. Such islands are taken out before pins and vias are joined, are drawn in the overlay as a gap is, and are listed in the accounting with the part (`onPartBodies` in its totals). `connect measure` takes the same islands out of its own trace. `copper regions` lists a region that is a part's whole top under `onParts`, by the outline as drawn and the same colour test. A board whose copper reads grey gives the colour test nothing, and nothing is taken off there.+ A thin island with a line down it that explains its area is a track, one per chain of its skeleton. `path_mm` is the centre line, kept within a quarter of a pixel, `widths_mm` the width measured at each vertex, `width_mm` their median. Everything else is a pour, drawn by its pixels: `rle_px` holds runs of image pixels at 40 px/mm (`[y, x0, x1, ...]`), and `rle` the same on the 20 px/mm grid the router rasters onto. No morphology and no margins: the only additions are the exact area of a pad the island joins, which the photo cannot show under its part, and pinholes smaller than 40 pixels. `polygon_mm` is an outline within half a pixel. KiCad needs each fill as a single polygon with its holes joined in by slits. `kicad_out::keyhole` does this left to right so slits never cross, and winds each hole against its outline. KiCad's triangulation reads a hole wound the same way as copper, and an even-odd check will not catch that. A pour read from the photo is written from `rle_px`, at image resolution, along the pixels' edges (`crack_loops`). Tracing through pixel centres left half a pixel off every outer edge.@@ -160,12 +188,12 @@ The review used to stop when a pass changed nothing, with levels (High, Medium, | values | each part's stated value | parts with a value so tagged / parts with a value | | blocks | each part's membership of its block | parts in a block so tagged / parts | | placement | each part's outlined pads against the photo | parts located by `snap.method` pads, body centre or hole centre / parts; layers against the photos |-| copper | random points of each face away from parts: `copper` where the copy has copper, `bare` where it has none | drawn mm2 / mm2 read as islands, lowest face; layers against the photos |+| copper | random points of each face where the photo can show copper, away from pad boxes and from anything but plain mask (see below): `copper` where the copy has copper, `bare` where it has none | drawn mm2 / mm2 read as islands, lowest face; layers against the photos | | vias | `kept` vias, and `dropped` candidates with no via within 0.3 mm | 100 - share of vias on pale pixels (more than a quarter of the square reaching 0.35 mm around the via with min(R,G,B) > 135, a rule from these photos and an upper bound, since a dome's highlight is pale too); layers against the photos |-| connectivity | `pin` net claims, `island` net claims | named pour area / pour area; named track length / track length; pins agreeing with their copper; connections KiCad finds closed; connections joined by seen copper, or the outside-evidence bound if higher |+| connectivity | for each `pin` with a net, the join of its pad to copper the copy drew; for each `island`, one join it is seen to make; pins and islands that show none counted as not correct (see below) | named pour area / pour area; named track length / track length; pins agreeing with their copper; connections KiCad finds closed; connections joined by seen copper, or the outside-evidence bound if higher | | layers | the layer count and each plane, outside evidence only | below 30 without it: `min(30, closed / connections at the proposed stackup)` | -Copper samples points rather than kept items, and vias sample what was dropped as well as what was kept, because a sample of kept items cannot notice what is missing. Connections joined by seen copper are `1 - connections routed / (pins - nets)`: what the router had to add is by definition not what the photos showed.+Copper samples points rather than kept items, the claim says what the photo shows at the point, and vias sample what was dropped as well as what was kept, because a sample of kept items cannot notice what is missing. Connections joined by seen copper are `1 - connections routed / (pins - nets)`: what the router had to add is by definition not what the photos showed. **Layers against the photos.** The owner's goal is that what the KiCad layers draw agrees with the photos wherever a photo shows that layer. `fidelity.json`, written by a separate program, limits copper, vias and placement. It is read by what its keys say and unknown fields are ignored: a figure (`score`, `agreement`, `fidelity`, `match`, `value`, `percent`, `pct`, `overall`) under a key named for the category counts, copper also takes any `*.Cu` key and placement `parts` or `footprints`; failing those, a top-level figure. The lowest figure found is the ceiling, on a 0 to 100 scale, or a fraction if every figure found is at most 1. With no file, no figure for the category, or a file older than what it scored (by the hashes in its `inputs`, `files` or `hashes`, otherwise by file time), the input is reported missing and the category is capped at 40. @@ -177,10 +205,64 @@ Copper samples points rather than kept items, and vias sample what was dropped a `audit verdict` records `ok`, `wrong` or `cannot_tell` per item, with who and when, in an append-only `verdicts.jsonl`. It refuses a verdict on the wrong sample, on an item not in the sample, on an item changed since the draw, and from a thread that the ledger shows produced the run. One answer per item, replaced only by one with a new evidence file. -Read back, nothing in the audit is trusted. The seed and every sampled item must be what the population gives, or the status is `tampered` and the audit counts for nothing. A sample counts only when complete, so bad answers cannot be skipped. A current sample cannot be redrawn, so a bad one cannot be thrown away; only a changed population or a voided sample allows a new draw. About a tenth of each sample (at least 3 items) is planted false claims: an empty spot called a via, a pour's interior called bare, a part with another's identity, outlines shifted by 2 mm and 1.5 mm. `ok` on one voids the sample.+Read back, nothing in the audit is trusted. The seed and every sampled item must be what the population gives, or the status is `tampered` and the audit counts for nothing. A sample counts only when complete, so bad answers cannot be skipped. A current sample cannot be redrawn, so a bad one cannot be thrown away; only a changed population or a voided sample allows a new draw. About a tenth of each sample (at least 3 items) is planted false claims: an empty spot called a via, a real copper point stated the other way round, a part with another's identity, outlines shifted by 2 mm and 1.5 mm, a join whose other end is drawn where there is none. `ok` on one voids the sample. **What this does not stop.** Anyone who can edit files can work out the controls from the seed, or write verdicts in another thread's name. The checks catch a careless auditor and a casual edit, not a determined forger. An outside evidence file is held to its hash, but the program cannot judge what the file shows. +### The copper audit++The first copper audit scored 9.4: 31 ok, 6 wrong, 79 cannot_tell of 116 crops. Its auditor could judge dotted pours, pads and dark grey bands and nothing else, so the score measured what a raw crop can show, not whether the copper was right. Four things changed. None of them makes a wrong item pass, and `cannot_tell` still counts against the score.++**The claim.** It says what the photo shows at the ring's centre: copper under the mask, or bare board with no copper under the mask. The auditor has the photo in front of them, not the copy.++**The panels.** Each crop has four, from `copper_crop`, whose only inputs are the photos and the position, so it cannot carry the copy's answer and it treats a planted claim as it treats a real one. Top left is the raw photo, 6 mm across. Top right is the same window stretched: the mean of the face's two photos, because their grain is independent noise (see "The two photos of a face"), blurred lightly, and each colour scaled between its 3rd and 97th percentile over plain mask in the 12 mm round the point. Scaling each colour on its own is what does the work: copper turns green, and laminate turns purple or dark blue, on both faces, with no threshold chosen for the point. Using plain mask alone stops silk, metal and parts setting the range, and a minimum span of 8 stops flat mask showing nothing but grain. Bottom left is 18 mm across, to follow a gap line to the ring. Bottom right is 3 mm across, for a centre near an edge. The tracer's lighting model is not used, because the panels must not lean on the method they check. The contact sheets show the top right panel.++**What is left out.** A point goes before the draw only when a part's pad box covers it, as before, or when the pixel or one within `fidelity::BESIDE_PX` is not plain mask, the rule `check fidelity` scores copper with. The class map comes from the photos each time and is never read from a file, the copy's copper is not an input, and a test holds that emptying the copy leaves the set unchanged. Dark mask, gap lines, track edges and bundles are all plain mask, so they stay. `audits/copper/exclusions.json` gives the count by material, by face and by the copy's answer, and how the rule changes the share of points in the regions read worst: per face, the quarter of 5 mm tiles where the copy disagrees most with `fidelity::copper_read`. `confidence` and the sheet state the figures. On the Driver board, the rule takes 1211 of 6694 points (18.1%: silk 778, exposed metal 227, part body 145, hole 61), 18.2% of `copper` and 17.8% of `bare`. Pad boxes take 2200 of 8894 before it. The worst tiles hold 19.0% of the points before the rule and 17.7% after. The tilt is mostly the top, 16.0% to 13.1%, where the worst tiles are crowded by parts and silk. The bottom goes from 20.5% to 19.6%.++**The controls.** A control used to be a point on the 0.05 mm grid deep inside a large pour, always called bare, so its coordinates, its id and its picture all gave it away, and an auditor who answered ok to every copper claim was never caught. A control is now a real point of the population, at the position the population gives it, stated the other way round. It has the id, position and record a real item would have, and only its answer is false. Controls come from the far half of each stratum's order, so extending a sample does not reach them.++A planted claim must be false beyond doubt, or an honest auditor voids the sample. The first rule asked only that the copy and `fidelity::copper_read` agree at nine points out to 0.3 mm. On the CM5 that planted two samples' worth of doubtful controls, and both samples were voided by auditors who had looked properly. Six of the 22 lay on a part's body, because the class map was built as if only the top carried parts (it now measures that with `photomat::carries_parts`, as `check fidelity` does). One lay in a letter of silk. One lay in a teal triangle inside a lighter stitched pour, which both readers call copper because they share a threshold, and which a person calls bare because the pour beside it is lighter. Two readers agreeing is not the same as the photo being plain.++`Planting::doubt` now gives the first reason a point cannot carry a planted claim:++- within 1.0 mm of the board edge, 0.8 mm of a part's pad box, or 0.9 mm of a via or a via candidate on either face;+- out to 0.6 mm round the point (`CONTROL_REACH_MM`; the ring is 0.4 mm), any pixel that is not plain mask `BESIDE_PX` clear of silk, metal, holes and bodies, or where the copy or the threshold scorer reads the other surface;+- fewer than 5 percent of either surface, by the scorer, in the plain mask of the 12 mm the panel is stretched over, so there is nothing to compare the point with;+- the green round the point not clear of the middle of the stretched panel: its 5th percentile under 0.7 of the way from the panel's dark level to its light level for copper, or its 95th over 0.3 for bare board. These are the levels the auditor's top right panel is drawn with;+- in any of red, green and blue where the two surfaces' medians in that window lie 8 levels apart, the median round the point less than 0.6 of the way from the other surface's median to its own. Round a point in the teal triangle the median is 123 in green, which is the copper median of its window, and 53 in red, which is the bare median (copper 75): green says copper and red says bare. Plain pours sit at their copper median in both.++None of the 22 controls planted on the CM5 passes: 6 for a part, 6 for a via, 3 for the edge, 3 for silk or a body, 2 for the copy's own edge, 1 for the scorer's, and the one in the teal triangle that voided the second sample for its level in the panel. Of 755 points looked at there, 20 pass, every one of them copper stated as bare: no bare board on that board is 1.2 mm wide and clear of everything else. Measuring which faces carry parts also takes 464 points on part bodies out of the population there (1975 to 1511), which the photo could never show copper at. `exclusions.json` gives the count by what the claim states and by reason refused, and `audit draw` says when only one way can be planted, because an auditor who calls every claim of the other kind ok is then not caught. A draw that cannot plant the number a sample needs refuses, says how many points passed and what refused the rest, and leaves the current sample as it was.++`audit preview [--n 24] --file <folder>` writes crops and a separate `key.json` for checking legibility, from points clear of the sample and the controls, outside the audit. On a blind tuning sample of 24 points judged by the method's author, raw panel first, then all four, 9 could be decided from the raw panel and 19 from all four, and 18 of those 19 agreed with the copy. Only an independent auditor's figure counts.++### The connectivity audit++An island's claim used to be its net: "outlined copper isl-bottom-292 is on net GND". No crop shows a net. On the CM5, 60 of 62 island claims came back `cannot_tell`, one `ok` and one `wrong`, so the island figure measured what a crop can show and nothing about the copper.++**The claim** is now one join the flow holds for that island, worded as what the crop shows: the copper outlined in pink reaches the pad boxed in blue with no break; it is one piece with the via in the blue ring; it is one piece with the copper outlined in blue, with no gap at the blue cross. The joins are read from what `connect attribute` wrote, and only where they are near enough for one crop to show both ends (`JOIN_REACH_MM`, 0.3 mm from the island's own drawn copper):++- a pad whose contact attribution kept as solid on this island's node, on this face, drawn in the outline and at the place the photo shows its metal (`detect/attrib/measure.json`, `core`);+- a valid via attached to this island's conductor on this face (`detect/attribution.json`, `vias`);+- copper stitched to one of the island's pieces, at the place the stitch was read (`detect/connect-graph.json`, `stitches`), and other copper drawn from the same piece.++An island usually has several. The one claimed is the one nearest a name, in the order of attribution's own ladder: a pad before a via before other copper. Among several of that kind a hash of the island's id decides, so nobody picks. The item's hash covers the island's shape and net and the other end's name and shape, so a changed join makes the verdict stale. The net is no longer in the words. No claim of this audit states a net: a photo shows joins, and a net's name rests on whatever its `source` cites.++**The crop** is centred on the join. Its first row shows the island as far as 12 mm allows, raw and marked, and its second row is 3 mm across, or wide enough to hold the pad or the via's ring. A track is outlined beside its edges, not drawn down its middle, so the mark does not cover the copper.++**Islands with no join.** An island that touches no pad, no valid via and no other copper within reach has no evidence a crop can show. It is not put forward as a claim, least of all a claim that it is on a named net, and it counts as not correct: the island stratum's figure is `100 x L x u x (islands with a join / islands)`. On the CM5 that is 1864 of 2335, so 0.798. If no island has a join, or the files `connect attribute` writes are missing or were measured on other copper (`copperSha`), the factor is 0.++**The island controls.** A join stated of another island's marks would prove nothing, so an island's false claim keeps its outline and words and has its other end drawn where there is none: a pad's box on plain mask clear of every part, a via's ring on plain mask with no via or candidate within 1.5 mm, a second copper outline wholly on a part's body. A face with no parts cannot carry the third kind.++**Pins.** A pin's claim was its net too: "pin J2.86 is on net GND". The first independent audit of the join claims (CM5 run 5) settled 56 of 61 island claims from the crops and returned `cannot_tell` on all 49 pin claims, because a boxed pad shows no net. A pin's claim is now the same kind of join, read from the pad's side: "pad boxed in pink (pin C112.2): is joined to the copper outlined in blue (isl-top-21) with no break between them". The evidence is the pad joins above: a contact attribution kept as solid (`connect-graph.json`, `contacts`, tier 1, on the pad's own face), to a drawn pour or track of that node within `JOIN_REACH_MM` of the pad's outline. The box is the pad where the copy read its metal, the crop is centred on it, and it has the close row. Copper read from the photo comes before a stub the copy drew to reach the pad; among several a hash of the pin decides. The net is not in the words and is in the item's hash, so a pin moved to another net is another claim.++**Pins with no join.** A pin with a net and no such join has nothing a photo can show: a connector pin named by a datasheet's pinout, a pad whose ring did not settle it, a pad joined only through a via. It is not put forward, and it counts as not correct: the pin figure is `100 x L x u x (pins with a join / pins with a net)`. The status line gives the count, and beside it how many of those unproven pins are on a net that cites a source outside the photos (a `source`, or a `nameSource` that is a citation and not `none`, an inference or one of attribution's own class words other than `datasheet`). Those are named, and named by something a photo cannot confirm, so they stay unproven here: only outside evidence on the board itself raises them. The outside-evidence bound on pins is scaled by the same share, since evidence on the pins judged says nothing of the rest.++**No cap above what an audit could reach.** With no finished audit a category sits at 40. For connectivity that cap is now `min(40, 100 x the lower of the two shares)`, so a copy in which nothing shows a join does not sit at 40 for want of an audit that could only give 0.++**The pin controls.** A pin's net swapped for another pin's was the old control. A join stated in another claim's words proves nothing, so a pin's false claim keeps its words and its copper and has its own pad's box drawn on plain mask clear of every part, where no pad is. Its crop is centred on that empty box, as a real pin's is on its pad. A draw plants a false pin join as often as a false island join, however many more islands there are.++**Pad boxes.** The box is the library pad's size at the place the copy read the pad's metal. Where a box spans two parts or sits on silk, the part is wrong, not the crop: on the CM5 run 3, C98 is an 0805 laid across the gap between two smaller capacitors, one of which is also in the copy as C66, and C43 is a silk colon read as an 0201. Those belong to placement and identity.+ ### The loop `review pass` re-measures, draws any audit that is missing, scores, records, and answers:
docs/status.md+15@@ -1,5 +1,20 @@ # Status +## 0.4.0 (2026-11-10)++Second board: a Raspberry Pi Compute Module 5 from ten photos, run to the end of the flow ([runs/cm5-run6](../runs/cm5-run6/README.md)). Overall confidence 0.0 of 100, STALLED. What changed in the program is listed in the README. The Driver sections below describe 0.3.0; the Driver has not been run to completion or audited on 0.4.0.++Known gaps added by this version:++- The body-first passive reader uses colour thresholds read from the CM5 photos. On the Driver's darker photo it found 20 passives, 4 of them invented, and missed most of the board. Its candidates are proposals; check them by eye. Thresholds relative to each photo are the fix.+- On the Driver, the changed snap reach lands 7 of 123 parts more than 0.3 mm from where 0.3.0 put them. Which position is right has not been checked.+- The via reader accepts dark spots with no ring: 8 of 51 sampled kept vias on the CM5 were false.+- Copper on the CM5 is right at about 60% of audited points. A 0.1 mm track is 4 pixels wide in the 40 px/mm working frame.+- No audit claim checks a net name any more. A pin or island with a correct local join on a wrong net scores as correct; named pins with no visible join are only counted against the figure.+- Planted copper claims go one way only on the CM5 (copper stated as bare), so an auditor who answers ok to every copper claim is not caught. The tool says so at draw time.+- An inferred inner plane does not close ground pieces in KiCad's count: the CM5 copy shows 499 missing connections at ten layers.+- Routing still takes most of a run when no stackup closes.+ Verified on one board only: the Driver motor controller (Driver 1 to 4 photos), rules `kicad-default`, version 0.3.0, run of 2026-11-06. **Not finished, and the 90 percent confidence target was not reached:** the loop answers STALLED at an overall 0.0, because overall is the lowest category. ## What it does on that board
flows/flow.json+2−2@@ -16,14 +16,14 @@ "scale.json", "board/top.jpg" ],- "do": "Run `scale`: it solves pixels per mm from the hole-row pitches (the scale at which every row lands on a standard pitch) and lists the candidates. Confirm it with a body whose size a datasheet gives: `scale --body <prep body id>=<length>x<width> --source \"<datasheet, package drawing>\"` (the body ids are on evidence/prep-<top photo>.jpg). It then writes both faces into one board frame (board/top.jpg, board/bottom.jpg, gridded copies for sketching) and aligns the bottom to the top on their shared holes."+ "do": "Run `scale`: it solves pixels per mm from the hole-row pitches (the scale at which every row lands on a standard pitch) and lists the candidates. Confirm it with a body whose size a datasheet gives: `scale --body <prep body id>=<length>x<width> --source \"<datasheet, package drawing>\"` (the body ids are on evidence/prep-<top photo>.jpg). It then writes both faces into one board frame (board/top.jpg, board/bottom.jpg, gridded copies for sketching) and aligns the bottom to the top on their shared holes. A board with no rows of holes and four mounting holes at a spacing a document gives: `scale --holes <x>x<y> --board <x>x<y> --source \"...\"` frames each photo through its own four holes and takes the flip from what shows through both faces." }, { "name": "parts", "done": [ "checks/parts.ok" ],- "do": "STEP 1: identify as many parts as possible. `detect parts` measures every body and hole row on both faces in board mm, matches a package by size, proposes a reference and rotation, and writes crops (evidence/parts/) and contact sheets. `parts init` turns the candidates into parts.json. Then read every crop: set the reference, value (`decode <marking>` reads resistor codes), marking, MPN, function, footprint where the guess is wrong, and the pin-1 mark for every part that fits one way; tag each field by how it is known. Dismiss each candidate that is not a part (silkscreen, logo, reflection) in parts.json `dismissed` with the reason. Run `check parts`."+ "do": "STEP 1: identify as many parts as possible. `detect parts` measures every body and hole row on both faces in board mm, matches a package by size, proposes a reference and rotation, and writes crops (evidence/parts/) and contact sheets. Chip capacitors and resistors from 0201 to 1206 are read body first (candidates with `method`: `body`, drawn as outlines on evidence/parts-map.jpg and parts-map-bottom.jpg): the place is the body's centre and the package is from the body's own size, so check the package against the crop; detect/parts.json `passives.leftOut` lists those left out as lying inside a bigger part. `parts init` turns the candidates into parts.json. Then read every crop: set the reference, value (`decode <marking>` reads resistor codes), marking, MPN, function, footprint where the guess is wrong, and the pin-1 mark for every part that fits one way; tag each field by how it is known. Dismiss each candidate that is not a part (silkscreen, logo, reflection) in parts.json `dismissed` with the reason. Run `check parts`." }, { "name": "blocks",
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "ai-flow-reverse-engineering", "type": "app",- "version": "0.3.0",+ "version": "0.4.0", "title": "AI Flow: Reverse Engineering", "description": "Reverse engineer a PCB from photos: identify the parts and functional blocks, read every pour, track, via, silkscreen and mask opening from the pictures into a KiCad copy, give each piece of copper a net with a record of how, route the connections nobody can see on its own N-layer router, jump the layer count until they close and the DRC passes, review in a loop with a measured confidence score per category from independent audits, and list the tests that settle every unknown. Every claim carries an evidence tag (seen, measured, datasheet, inferred, unknown). Rust source on this page; build it with ./build.sh.", "category": "Developer Tools",
page.json+1−1@@ -4,7 +4,7 @@ "type": "app", "title": "AI Flow: Reverse Engineering", "brief": "Photos of a PCB to a tagged KiCad copy with its traced copper, a layer-count proposal and the tests that settle what the photos cannot show.",- "version": "0.3.0",+ "version": "0.4.0", "discovery_triggers": [ "reverse engineer this board", "reverse engineer a pcb from photos",
skills/adom-aiflow-reverse/SKILL.md+1−1@@ -45,7 +45,7 @@ Surfaces: **wv**, a Hydrogen webview panel; **pup**, see the `pup` skill; the us Repeat `review pass` and do what it names until it says TARGET MET or STALLED, then `check review`. Scores come from measurements and audits: `confidence` prints each one's inputs and docs/method.md each formula. No typed level or edited score counts. -- **Audit:** `audit draw --category <c>` (a pass draws those it needs), then `audit verdict --category <c> --sample <seed> --item a001 --verdict ok|wrong|cannot_tell --who <auditor>`, or `--file` for a sheet. Claims are in `audits/<c>/sheet.md`, pictures in `sheet-*.jpg` and `crops/`.+- **Audit:** `audit draw --category <c>` (a pass draws those it needs), then `audit verdict --category <c> --sample <seed> --item a001 --verdict ok|wrong|cannot_tell --who <auditor>`, or `--file` for a sheet. Claims are in `audits/<c>/sheet.md`, pictures in `sheet-*.jpg` and `crops/`. A copper crop has four panels, explained on its sheet: judge copper from the crops. A connectivity claim states one join, pink to blue, and never a net: a pin's pad to copper the copy drew, or a piece of copper to a pad, a via or other copper. Its crop has a close row. Pins and islands that show no join are not put forward and count as not correct; `audit status` gives the counts. `audit preview --file <folder>` writes crops outside the audit to check legibility. - **Who audits:** a thread that did not produce the run, or a person. Give the auditor the sheet, not your conclusions. Answer `cannot_tell` rather than guess: it counts against the score, and so it should. - **Planted claims:** some claims are false on purpose. An `ok` on one voids the sample. Do not read `sample.json` for them. - **Outside evidence:** layers can only rise with `--evidence <file> --kind xray|edge-micrograph|...`. Photos never show an inner layer. Connectivity, values and identity say what would raise them too.
src/attrib.rs+72−16@@ -640,6 +640,45 @@ fn inferred_pins(n: &Value) -> Vec<String> { n[key].as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect() } +/// The words attribution itself puts in `nameSource` when a net states none.+const NAME_CLASSES: [&str; 4] = ["copper", "silkscreen", "datasheet", "circuit"];++/// Where a net's name came from. What whoever named the net stated stays as stated: a citation is not ours to replace with a word.+/// Only a net that states nothing, or holds a class word from an earlier run, gets a class, read from its `why` and `source`.+fn name_source(n: &Value) -> String {+ if let Some(s) = n["nameSource"].as_str().filter(|s| !s.is_empty() && !NAME_CLASSES.contains(s)) { return s.to_string(); }+ if n["fromCopper"].as_bool().unwrap_or(false) { return "copper".into(); }+ let t = format!("{} {}", n["why"].as_str().unwrap_or(""), n["source"].as_str().unwrap_or("")).to_lowercase();+ (if t.contains("silk") { "silkscreen" } else if t.contains("datasheet") || t.contains("drv8323") || t.contains("typical application") { "datasheet" } else { "circuit" }).into()+}++/// The nets that go on, each with its pins, and the names of those the copper emptied. A net is carried whole as it was read, so `source`+/// and every other field attribution does not own goes through; a net an earlier run made from copper is carried under its name the same+/// way, not rebuilt blank. When the copper moves every pin of one net onto another, the record of the net whose name survives is the one kept.+fn nets_carried(old: &[Value], by_name: &BTreeMap<String, Vec<String>>) -> (Vec<(Value, Vec<String>)>, Vec<String>) {+ let mut proto: Vec<Value> = old.iter().filter(|n| !n["fromCopper"].as_bool().unwrap_or(false)).cloned().collect();+ for name in by_name.keys() { if !proto.iter().any(|n| n["name"].as_str() == Some(name)) {+ proto.push(old.iter().find(|n| n["name"].as_str() == Some(name)).cloned().unwrap_or_else(|| json!({"name": name, "fromCopper": true, "tag": "INFERRED", "why": "no name in the pin map: pins joined by copper", "tests": ["T-pour-nets"]})));+ } }+ let (mut out, mut emptied) = (vec![], vec![]);+ for n in proto { let name = n["name"].as_str().unwrap_or("").to_string(); match by_name.get(&name) { Some(p) if !p.is_empty() => out.push((n, p.clone())), _ => emptied.push(name) } }+ (out, emptied)+}++/// Write on a net what attribution owns, and nothing else: its pins, the pin map's own claim, the tag the copper supports, the evidence+/// file when every pin is seen, the copper counts, and `nameSource` by `name_source`. Returns that name source.+fn stamp_net(n: &mut Value, pins: &[String], inferred: &[String], seen_all: bool, proven: usize, clash: usize, groups: &BTreeMap<usize, Vec<String>>) -> String {+ let ns = name_source(n);+ n["pins"] = json!(pins);+ if !n["fromCopper"].as_bool().unwrap_or(false) { n["inferredPins"] = json!(inferred); }+ n["tag"] = json!(if seen_all { "MEASURED" } else { "INFERRED" });+ if seen_all { n["evidence"] = json!(["net-evidence.json"]); }+ n["nameSource"] = json!(ns);+ n["copperEvidence"] = json!({"pinsSeen": proven, "pins": pins.len(), "pinClashes": clash, "groupsOfSeenPins": groups.len()});+ n["copperGroups"] = json!(groups.values().collect::<Vec<_>>());+ ns+}+ /// Stage two. Reads detect/attrib/, copper.json, nets.json and optionally a via list; writes copper.json and nets.json /// (nets, tags and evidence only), net-evidence.json, detect/attribution.json and detect/connect-graph.json. Reads no photo. pub fn attribute(flow: &Path, via_file: Option<&Path>) -> Result<Value, String> {@@ -1023,17 +1062,11 @@ pub fn attribute(flow: &Path, via_file: Option<&Path>) -> Result<Value, String> let mut names_seen: BTreeSet<String> = BTreeSet::new(); let mut by_name: BTreeMap<String, Vec<String>> = BTreeMap::new(); for (p, n) in &final_net { by_name.entry(n.clone()).or_default().push(p.clone()); }- let mut proto: Vec<Value> = old_nets.iter().filter(|n| !n["fromCopper"].as_bool().unwrap_or(false)).cloned().collect();- for name in by_name.keys() { if !proto.iter().any(|n| n["name"].as_str() == Some(name)) { proto.push(json!({"name": name, "fromCopper": true, "tag": "INFERRED", "why": "no name in the pin map: pins joined by copper", "tests": ["T-pour-nets"]})); } }- let mut emptied = vec![];- for mut n in proto {+ let (carried, emptied) = nets_carried(&old_nets, &by_name);+ for (mut n, pins) in carried { let name = n["name"].as_str().unwrap_or("").to_string(); let inferred = inferred_pins(&n);- let pins: Vec<String> = by_name.get(&name).cloned().unwrap_or_default();- if pins.is_empty() { emptied.push(name.clone()); continue; } names_seen.insert(name.clone());- let why_txt = n["why"].as_str().unwrap_or("").to_lowercase();- let name_source = if n["fromCopper"].as_bool().unwrap_or(false) { "copper" } else if why_txt.contains("silkscreen") || why_txt.contains("silk") { "silkscreen" } else if why_txt.contains("datasheet") || why_txt.contains("drv8323") || why_txt.contains("typical application") { "datasheet" } else { "circuit" }; let mut pin_rows = BTreeMap::new(); let mut groups: BTreeMap<usize, Vec<String>> = BTreeMap::new(); let (mut proven, mut clash) = (0, 0); for pin in &pins { let solid: Vec<usize> = solid_of.get(pin).cloned().unwrap_or_default();@@ -1052,13 +1085,7 @@ pub fn attribute(flow: &Path, via_file: Option<&Path>) -> Result<Value, String> let st = state_of(pin); if !st.is_empty() && st.iter().all(|s| *s == "isolated") { unsupported.push(json!({"net": name, "pin": pin, "why": "a gap closes around this pad and no track leaves it"})); } }- n["pins"] = json!(pins);- if !n["fromCopper"].as_bool().unwrap_or(false) { n["inferredPins"] = json!(inferred); }- n["tag"] = json!(if seen_all { "MEASURED" } else { "INFERRED" });- if seen_all { n["evidence"] = json!(["net-evidence.json"]); }- n["nameSource"] = json!(name_source);- n["copperEvidence"] = json!({"pinsSeen": proven, "pins": pins.len(), "pinClashes": clash, "groupsOfSeenPins": groups.len()});- n["copperGroups"] = json!(groups.values().collect::<Vec<_>>());+ let name_source = stamp_net(&mut n, &pins, &inferred, seen_all, proven, clash, &groups); let cop = net_copper.get(&name).cloned().unwrap_or_default(); pin_rows_all.insert(name.clone(), pin_rows.clone()); net_rows.push(json!({"net": name, "tag": n["tag"], "nameSource": name_source, "pins": pin_rows, "pinsSeen": proven, "pinsTotal": pins.len(), "pinClashes": clash, "groupsOfSeenPins": groups.len(), "groups": groups.values().collect::<Vec<_>>(),@@ -1152,7 +1179,7 @@ fn swap_suspects(pin_net: &HashMap<String, String>, contacts: &[Contact], o: &Ou } /// What identifies the copper a measurement belongs to: ids and shapes, never nets, so attribution does not make itself stale.-fn zones_key(c: &Value) -> String {+pub fn zones_key(c: &Value) -> String { let mut h: u64 = 0xcbf29ce484222325; let mut eat = |s: &str| { for b in s.as_bytes() { h ^= *b as u64; h = h.wrapping_mul(0x100000001b3); } }; for z in c["zones"].as_array().into_iter().flatten() { eat(z["id"].as_str().unwrap_or("")); eat(&z["areaMm2"].to_string()); }@@ -1198,6 +1225,35 @@ mod tests { fn pin(p: &str, n: &str, k: usize) -> Contact { Contact { pin: p.into(), net: Some(n.into()), piece: k, tier: Tier::Solid, weight: 1, kind: "pour", side: "top" } } fn via(id: &str, a: usize, b: usize, valid: bool) -> GVia { GVia { id: id.into(), top: Some(a), bottom: Some(b), valid, both: false } } #[test]+ fn a_net_keeps_what_attribution_does_not_own() {+ let cite = "CM5 datasheet, Table 4 and Figure 4";+ let mut n = json!({"name": "GPIO26", "pins": ["J1.24"], "tag": "DATASHEET", "nameSource": cite, "source": cite, "why": "connector pin named by the datasheet pinout", "tests": ["T-net-GPIO26"], "note": {"by": "hand"}});+ let before = n.clone();+ let ns = stamp_net(&mut n, &["J1.24".to_string(), "R5.1".to_string()], &["J1.24".to_string()], false, 1, 0, &BTreeMap::new());+ for k in ["source", "nameSource", "why", "tests", "note", "name"] { assert_eq!(n[k], before[k], "{k} is not attribution's to change"); }+ assert_eq!(ns, cite);+ assert_eq!(n["pins"], json!(["J1.24", "R5.1"])); assert_eq!(n["inferredPins"], json!(["J1.24"])); assert_eq!(n["tag"], "INFERRED"); assert_eq!(n["copperEvidence"]["pinsSeen"], 1);+ // a net that states nothing gets a class word, and a class word from an earlier run is rewritten, not taken for a statement+ let mut m = json!({"name": "SW", "pins": ["L1.1"], "why": "silkscreen label beside the pad"});+ assert_eq!(stamp_net(&mut m, &["L1.1".to_string()], &["L1.1".to_string()], false, 0, 0, &BTreeMap::new()), "silkscreen");+ m["why"] = json!("pin 3 in the datasheet's typical application");+ assert_eq!(stamp_net(&mut m, &["L1.1".to_string()], &["L1.1".to_string()], false, 0, 0, &BTreeMap::new()), "datasheet");+ }+ #[test]+ fn the_net_whose_name_survives_keeps_its_own_record() {+ let old = vec![json!({"name": "A", "pins": ["X.1"], "source": "sheet a"}), json!({"name": "B", "pins": ["Y.1"], "source": "sheet b"}), json!({"name": "C", "fromCopper": true, "pins": ["Z.1"], "source": "named by hand since", "why": "mine"})];+ // the copper moved A's only pin onto B, and C still holds its pin under its own name+ let by_name: BTreeMap<String, Vec<String>> = [("B".to_string(), vec!["X.1".to_string(), "Y.1".to_string()]), ("C".to_string(), vec!["Z.1".to_string()])].into_iter().collect();+ let (kept, emptied) = nets_carried(&old, &by_name);+ assert_eq!(emptied, vec!["A".to_string()]);+ assert_eq!(kept.len(), 2);+ assert_eq!(kept[0].0["source"], "sheet b"); assert_eq!(kept[0].1, vec!["X.1".to_string(), "Y.1".to_string()]);+ assert_eq!(kept[1].0["source"], "named by hand since", "a net made from copper is carried, not rebuilt blank"); assert_eq!(kept[1].0["why"], "mine");+ // a name with no net yet gets a new one+ let fresh: BTreeMap<String, Vec<String>> = [("D".to_string(), vec!["Q.1".to_string()])].into_iter().collect();+ assert_eq!(nets_carried(&[], &fresh).0[0].0["fromCopper"], true);+ }+ #[test] fn copper_touching_a_pad_is_the_top_rung() { let o = propagate(&[false], &[10.0], &[pin("C1.1", "GND", 0)], &[]); assert_eq!(o.pieces[0].net, "GND"); assert_eq!(o.pieces[0].rung, Rung::Copper);
src/board.rs+265−1@@ -27,6 +27,7 @@ pub fn prep_photo(path: &Path, side: &str, ev_dir: &Path) -> Result<Value, Strin let h = ((geom::dist(c[0], c[3]) + geom::dist(c[1], c[2])) / 2.0).round() as u32; let (r, _) = prep::rectify(&img, &c, w, h, false)?; let holes = prep::holes(&r, 20.0, 200.0);+ let gold_holes = prep::gold_holes(&r, 20.0, 200.0); let rows = prep::hole_rows(&holes); let bodies = if side == "top" { prep::bodies(&r, 4.0, 18.0) } else { vec![] }; let stem = path.file_stem().unwrap().to_string_lossy().to_string();@@ -40,7 +41,7 @@ pub fn prep_photo(path: &Path, side: &str, ev_dir: &Path) -> Result<Value, Strin crate::draw::line(&mut rv, (f(&holes[ids[0]], "x"), f(&holes[ids[0]], "y")), (f(&holes[ids[ids.len() - 1]], "x"), f(&holes[ids[ids.len() - 1]], "y")), [255, 255, 0], 3); } rv.save(ev_dir.join(format!("prep-{stem}.jpg"))).map_err(|e| e.to_string())?; Ok(json!({"file": path.file_name().unwrap().to_string_lossy(), "side": side, "corners": c.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>(),- "rectPx": [w, h], "sharpness": prep::sharpness(&r), "holes": holes, "rows": rows, "bodies": bodies,+ "rectPx": [w, h], "sharpness": prep::sharpness(&r), "holes": holes, "goldHoles": gold_holes, "rows": rows, "bodies": bodies, "evidence": [format!("evidence/outline-{stem}.jpg"), format!("evidence/prep-{stem}.jpg")]})) } @@ -119,9 +120,272 @@ pub fn align(top: &[Value], bot: &[Value]) -> (P, usize, f64) { (shift, p.len(), res) } +/// A photo framed through four mounting holes at a spacing a datasheet gives.+pub struct HoleFrame { /// where the board's corners lie in the photo, by those holes+ pub corners: [P; 4], pub holes: [P; 4], /// px/mm along the photo's x and y+ pub px_mm: (f64, f64), /// how far the corners fitted to the board's edges lie from these, mm, largest+ pub fitted_off_mm: f64, /// how far the opening's centre lies from the ring's, mm, largest: what an angled view moves+ pub opening_off_mm: f64, /// the RMS error of the ring circles, photo pixels, largest+ pub ring_fit_px: f64 }++/// The frame of one photo from its four mounting holes. A board with no rows of holes has only these for scale, and four points also give the perspective outright, so the frame need not+/// lean on corners fitted to edges. Holes are found by `prep` on the straightened picture; each centre is then read off the ring on the photo itself.+pub fn hole_frame(path: &Path, rec: &Value, spacing: (f64, f64), board: (f64, f64)) -> Result<HoleFrame, String> {+ let img = image::open(path).map_err(|e| format!("{}: {e}", path.display()))?.to_rgb8();+ // the holes were found on the picture straightened through the fitted corners, whatever an earlier `scale` has since written as corners+ let c = rec["cornersFitted"].as_array().or(rec["corners"].as_array()).ok_or("no corners in prep.json")?;+ let fitted: [P; 4] = [0, 1, 2, 3].map(|i| (c[i][0].as_f64().unwrap_or(0.0), c[i][1].as_f64().unwrap_or(0.0)));+ let (rw, rh) = (rec["rectPx"][0].as_f64().unwrap_or(0.0), rec["rectPx"][1].as_f64().unwrap_or(0.0));+ let rect: [P; 4] = [(0.0, 0.0), (rw, 0.0), (rw, rh), (0.0, rh)];+ let to_photo = geom::homography(&rect, &fitted).ok_or("degenerate corners")?;+ let holes = rec["goldHoles"].as_array().ok_or("no gold holes in prep.json: run prep again")?;+ let biggest = holes.iter().map(|h| f(h, "hole")).fold(0.0, f64::max);+ if biggest <= 0.0 { return Err("prep found no holes in this photo".into()); }+ // the hole of mounting size nearest each corner of the board+ let big: Vec<&Value> = holes.iter().filter(|h| f(h, "hole") >= 0.6 * biggest).collect();+ // where the stated spacing puts each hole, in the straightened picture+ let (sx, sy) = (rw / board.0, rh / board.1);+ let (ix, iy) = ((board.0 - spacing.0) / 2.0, (board.1 - spacing.1) / 2.0);+ let expected: [P; 4] = [(ix * sx, iy * sy), ((board.0 - ix) * sx, iy * sy), ((board.0 - ix) * sx, (board.1 - iy) * sy), (ix * sx, (board.1 - iy) * sy)];+ let reach = HOLE_REACH_MM * sx;+ let (mut pts, mut opening, mut worst_fit) = ([(0.0, 0.0); 4], [(0.0, 0.0); 4], 0f64);+ for (k, e) in expected.iter().enumerate() {+ let near = big.iter().filter(|h| geom::dist((f(h, "x"), f(h, "y")), *e) < reach).min_by(|a, b| geom::dist((f(a, "x"), f(a, "y")), *e).partial_cmp(&geom::dist((f(b, "x"), f(b, "y")), *e)).unwrap());+ match near {+ Some(h) => {+ let q = geom::apply(&to_photo, (f(h, "x"), f(h, "y")));+ let (centre, fit) = fit_ring(&img, q, f(h, "hole") / 2.0, f(h, "outer") / 2.0);+ if fit.is_finite() { worst_fit = worst_fit.max(fit); }+ pts[k] = centre; opening[k] = q;+ }+ // a ring joined to something beside it does not read as a ring in prep: the opening is still where the spacing says, and its ring is fitted from there.+ // The opening alone will not do: the camera sees the wall of the hole, which moves its middle toward the photo's centre, about 0.15 mm on the CM5 set.+ None => {+ let c = dark_opening(&img, &to_photo, *e, reach, biggest).ok_or_else(|| format!("no hole of mounting size within {HOLE_REACH_MM} mm of where the stated spacing puts hole {} ({} gold holes of that size found)", k + 1, big.len()))?;+ let q = geom::apply(&to_photo, c);+ let (centre, fit) = fit_ring(&img, q, biggest / 2.0, biggest * 1.15);+ if !fit.is_finite() { return Err(format!("hole {} has an opening but no ring that can be fitted", k + 1)); }+ worst_fit = worst_fit.max(fit);+ pts[k] = centre; opening[k] = q;+ }+ }+ }+ let (dx, dy) = ((geom::dist(pts[0], pts[1]) + geom::dist(pts[3], pts[2])) / 2.0, (geom::dist(pts[0], pts[3]) + geom::dist(pts[1], pts[2])) / 2.0);+ let (kx, ky) = (dx / spacing.0, dy / spacing.1);+ // an angled photo leaves some foreshortening, which the map takes out; beyond that these four are not the stated pattern+ if (kx - ky).abs() / kx.max(ky) > 0.05 { return Err(format!("these holes do not fit the stated spacing: {dx:.1} x {dy:.1} px gives {kx:.2} and {ky:.2} px/mm")); }+ let inset = ((board.0 - spacing.0) / 2.0, (board.1 - spacing.1) / 2.0);+ let at = [(inset.0, inset.1), (inset.0 + spacing.0, inset.1), (inset.0 + spacing.0, inset.1 + spacing.1), (inset.0, inset.1 + spacing.1)];+ let to_mm = geom::homography(&pts, &at).ok_or("degenerate holes")?;+ let back = geom::invert(&to_mm).ok_or("not invertible")?;+ let corners = [(0.0, 0.0), (board.0, 0.0), (board.0, board.1), (0.0, board.1)].map(|p| geom::apply(&back, p));+ let k = (kx + ky) / 2.0;+ let fitted_off_mm = (0..4).map(|i| geom::dist(corners[i], fitted[i]) / k).fold(0.0, f64::max);+ let opening_off_mm = (0..4).map(|i| geom::dist(pts[i], opening[i]) / k).fold(0.0, f64::max);+ Ok(HoleFrame { corners, holes: pts, px_mm: (kx, ky), fitted_off_mm, opening_off_mm, ring_fit_px: worst_fit })+}++/// The ring around an opening, by the ring's own colour first since all four must be measured the same way, then by the metal test when that finds no ring.+fn fit_ring(img: &RgbImage, q: P, hole_r: f64, ring_r: f64) -> (P, f64) {+ let warm = prep::ring_centre_by(img, q, hole_r, ring_r, prep::is_ring_gold);+ if warm.1.is_finite() { warm } else { prep::ring_centre(img, q, hole_r, ring_r) }+}++/// How far from its stated place a mounting hole may be found, in mm: the fitted corners a photo starts from are off by about a millimetre on rounded boards.+const HOLE_REACH_MM: f64 = 3.0;+/// An opening darker than this share of the window's brightness range, over what shows through a hole against a dark table. CM5 photos, one close-up set: openings under 15 percent.+const OPENING_DARK: f64 = 0.15;++/// The round dark opening nearest a stated hole position, searched in the straightened picture's coordinates. `None` when the table shows lighter than the board there, or nothing round and of mounting size is near.+fn dark_opening(img: &RgbImage, to_photo: &[f64; 9], at: P, reach: f64, hole_d: f64) -> Option<P> {+ let r = reach.ceil() as i64; let n = (2 * r + 1) as usize;+ let mut luma = vec![0f64; n * n];+ for v in 0..n { for u in 0..n {+ let q = geom::apply(to_photo, (at.0 + u as f64 - r as f64, at.1 + v as f64 - r as f64));+ let (x, y) = (q.0 as i64, q.1 as i64);+ luma[v * n + u] = if x < 0 || y < 0 || x >= img.width() as i64 || y >= img.height() as i64 { 255.0 } else { let p = img.get_pixel(x as u32, y as u32); 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64 };+ } }+ let lo = luma.iter().cloned().fold(f64::MAX, f64::min); let hi = luma.iter().cloned().fold(0.0, f64::max);+ let lim = lo + OPENING_DARK * (hi - lo);+ let mut seen = vec![false; n * n]; let mut best: Option<(f64, P)> = None;+ for s in 0..n * n {+ if seen[s] || luma[s] > lim { continue; }+ let (mut st, mut px) = (vec![s], vec![]); seen[s] = true;+ while let Some(i) = st.pop() {+ px.push(i); let (u, v) = (i % n, i / n);+ if u > 0 && !seen[i - 1] && luma[i - 1] <= lim { seen[i - 1] = true; st.push(i - 1); }+ if u + 1 < n && !seen[i + 1] && luma[i + 1] <= lim { seen[i + 1] = true; st.push(i + 1); }+ if v > 0 && !seen[i - n] && luma[i - n] <= lim { seen[i - n] = true; st.push(i - n); }+ if v + 1 < n && !seen[i + n] && luma[i + n] <= lim { seen[i + n] = true; st.push(i + n); }+ }+ let (us, vs): (Vec<f64>, Vec<f64>) = px.iter().map(|i| ((i % n) as f64, (i / n) as f64)).unzip();+ let (w, h) = (us.iter().cloned().fold(0.0, f64::max) - us.iter().cloned().fold(f64::MAX, f64::min) + 1.0, vs.iter().cloned().fold(0.0, f64::max) - vs.iter().cloned().fold(f64::MAX, f64::min) + 1.0);+ let a = px.len() as f64; let d = (w + h) / 2.0;+ // round, full, and about the size of the other mounting holes+ if !(0.8..1.25).contains(&(w / h)) || a < 0.68 * w * h || !(0.7 * hole_d..1.4 * hole_d).contains(&d) { continue; }+ let c = (at.0 - r as f64 + us.iter().sum::<f64>() / a, at.1 - r as f64 + vs.iter().sum::<f64>() / a);+ let off = geom::dist(c, at);+ if best.map_or(true, |b| off < b.0) { best = Some((off, c)); }+ }+ best.map(|b| b.1)+}++/// High-pass luminance and where it can be compared: plain mask, away from anything else. Via rims and the copper's edges survive it, parts and print do not.+fn plain_detail(img: &RgbImage) -> (crate::raster::Plane, crate::raster::Mask) {+ let tone = prep::mask_tone(img);+ let (w, h) = (img.width() as usize, img.height() as usize);+ let l = crate::raster::luma(img);+ let (a, b) = (crate::raster::blur(&l, 1, 3), crate::raster::blur(&l, 7, 3));+ let m = crate::raster::Mask { w, h, d: img.pixels().map(|p| (p[1] as f32 - p[0] as f32) > 0.3 * tone.gr50 && p[1] > p[2]).collect() }.erode(6.0);+ (crate::raster::Plane { w, h, d: a.d.iter().zip(&b.d).map(|(x, y)| x - y).collect() }, m)+}++/// Normalised correlation of `a` with `b` moved by (dx, dy), over pixels both show, on every second pixel. None with too few pixels.+fn ncc_at(a: &(crate::raster::Plane, crate::raster::Mask), b: &(crate::raster::Plane, crate::raster::Mask), area: (usize, usize, usize, usize), dx: i64, dy: i64) -> Option<f64> {+ let (w, h) = (a.0.w as i64, a.0.h as i64);+ let (mut sa, mut sb, mut saa, mut sbb, mut sab, mut n) = (0f64, 0f64, 0f64, 0f64, 0f64, 0f64);+ let mut y = area.1 as i64;+ while y < area.3 as i64 { let mut x = area.0 as i64; while x < area.2 as i64 {+ let (u, v) = (x + dx, y + dy);+ if u >= 0 && v >= 0 && u < w && v < h { let (i, j) = ((y * w + x) as usize, (v * w + u) as usize);+ if a.1.d[i] && b.1.d[j] { let (p, q) = (a.0.d[i] as f64, b.0.d[j] as f64); sa += p; sb += q; saa += p * p; sbb += q * q; sab += p * q; n += 1.0; } }+ x += 2; } y += 2; }+ if n < 300.0 { return None; }+ let d = (saa - sa * sa / n) * (sbb - sb * sb / n);+ if d <= 0.0 { None } else { Some((sab - sa * sb / n) / d.sqrt()) }+}++/// The best correlation over a small search of shifts, whole frame.+fn best_ncc(a: &(crate::raster::Plane, crate::raster::Mask), b: &(crate::raster::Plane, crate::raster::Mask), area: (usize, usize, usize, usize), reach: i64, step: i64) -> Option<(f64, i64, i64)> {+ let mut best: Option<(f64, i64, i64)> = None;+ let mut dy = -reach; while dy <= reach { let mut dx = -reach; while dx <= reach {+ if let Some(c) = ncc_at(a, b, area, dx, dy) { if best.map(|q| c > q.0).unwrap_or(true) { best = Some((c, dx, dy)); } }+ dx += step; } dy += step; }+ best+}++/// Which way the board was turned over, when the holes cannot say because their pattern is symmetric. Through features show on both faces, so the right mirror correlates and the+/// wrong one does not. Returns (axis, correlation of x, correlation of y), axis None when neither stands out: at least 0.1 and twice the other.+pub fn flip_by_detail(top: &RgbImage, bottom_x: &RgbImage, bottom_y: &RgbImage) -> (Option<String>, f64, f64) {+ let t = plain_detail(top); let area = (0, 0, t.0.w, t.0.h);+ let cx = best_ncc(&t, &plain_detail(bottom_x), area, 16, 2).map(|c| c.0).unwrap_or(0.0);+ let cy = best_ncc(&t, &plain_detail(bottom_y), area, 16, 2).map(|c| c.0).unwrap_or(0.0);+ let axis = if cx >= 0.1 && cx >= 2.0 * cy.max(0.0) { Some("x".to_string()) } else if cy >= 0.1 && cy >= 2.0 * cx.max(0.0) { Some("y".to_string()) } else { None };+ (axis, cx, cy)+}++/// Where each 5 mm tile of `top` lies in `bottom`, from the same through features: (tile centre, its place in `bottom`), tiles that correlate at `TILE_NCC` or better. For boards with too few shared holes to fit a warp.+pub fn tile_pairs(top: &RgbImage, bottom: &RgbImage) -> Vec<(P, P)> {+ let (a, b) = (plain_detail(top), plain_detail(bottom));+ let (w, h) = (a.0.w, a.0.h); let t = 200usize;+ let mut out = vec![];+ let mut y = 0; while y + t <= h { let mut x = 0; while x + t <= w {+ if let Some((c, dx, dy)) = best_ncc(&a, &b, (x, y, x + t, y + t), 16, 1) { if c >= TILE_NCC { let m = ((x + t / 2) as f64, (y + t / 2) as f64); out.push((m, (m.0 + dx as f64, m.1 + dy as f64))); } }+ x += t; } y += t; }+ out+}+/// Vias that show on both faces, paired top to bottom: the same positions on both sides of the board, so they hold the faces together more tightly than correlating tiles.+/// Pairs start wide for what four holes leave, then the fit pulls them in. None unless the pits line up well beyond chance. Pairs are (top, bottom) in board px.+pub fn pit_pairs(top: &RgbImage, bottom: &RgbImage) -> Option<Vec<(P, P)>> {+ let kept = |i: &RgbImage| -> Vec<P> { crate::domes::read_pits(i).pits.iter().filter(|p| p.keep).map(|p| (p.x as f64, p.y as f64)).collect() };+ let (a, b) = (kept(top), kept(bottom));+ let (_, _, decisive) = crate::domes::pits_confirmed(&a, &b, (top.width() as f64, top.height() as f64));+ if !decisive { return None; }+ let mut q: Option<crate::regframe::Quad> = None;+ let mut out: Vec<(P, P)> = vec![];+ for reach_mm in [0.3, 0.2, 0.15, 0.1, 0.1] {+ let moved = |t: P| q.as_ref().map_or(t, |f| f.apply(t));+ out = b.iter().filter_map(|o| a.iter().min_by(|x, y| geom::dist(moved(**x), *o).partial_cmp(&geom::dist(moved(**y), *o)).unwrap()).filter(|t| geom::dist(moved(**t), *o) < reach_mm * RES).map(|t| (*t, *o))).collect();+ q = crate::regframe::Quad::fit(&out).or(q);+ }+ (out.len() >= PIT_PAIRS).then_some(out)+}+/// Fewer through vias than this and a quadratic over the whole board is not well held. The CM5 close-up set gives 154.+const PIT_PAIRS: usize = 40;++/// On the CM5 photos, tiles of plain mask correlate between faces at 0.3 to 0.75 where vias go through and under 0.2 where they do not.+const TILE_NCC: f64 = 0.4;+ #[cfg(test)] mod tests { use super::*;+ #[test]+ fn four_holes_frame_a_tilted_photo() {+ // a 55 x 40 board at 24 px/mm with the top edge shrunk by perspective, holes as gold rings; the fitted corners are off on purpose+ let truth = [(100.0, 80.0), (1400.0, 90.0), (1420.0, 1060.0), (90.0, 1050.0)];+ let h = geom::homography(&[(0.0, 0.0), (55.0, 0.0), (55.0, 40.0), (0.0, 40.0)], &truth).unwrap();+ let hi = geom::invert(&h).unwrap();+ let img = RgbImage::from_fn(1500, 1150, |x, y| { let q = geom::apply(&hi, (x as f64 + 0.5, y as f64 + 0.5));+ if !(0.0..55.0).contains(&q.0) || !(0.0..40.0).contains(&q.1) { return image::Rgb([90, 58, 40]); }+ let d = [(3.5, 3.5), (51.5, 3.5), (51.5, 36.5), (3.5, 36.5)].iter().map(|c: &(f64, f64)| geom::dist(*c, q)).fold(f64::MAX, f64::min);+ if d < 1.25 { image::Rgb([60, 40, 30]) } else if d < 2.8 { image::Rgb([230, 210, 60]) } else { image::Rgb([50, 186, 140]) } });+ let dir = std::env::temp_dir().join(format!("hole-frame-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap();+ let path = dir.join("p.png"); img.save(&path).unwrap();+ let off = [(truth[0].0 + 6.0, truth[0].1 + 5.0), (truth[1].0 - 4.0, truth[1].1 + 7.0), (truth[2].0 + 3.0, truth[2].1 - 6.0), (truth[3].0 - 2.0, truth[3].1 - 5.0)];+ let (rw, rh) = (1320u32, 970u32);+ let (r, _) = prep::rectify(&img, &off, rw, rh, false).unwrap();+ let holes = prep::gold_holes(&r, 20.0, 200.0);+ assert_eq!(holes.len(), 4, "{holes:?}");+ let rec = json!({"corners": off.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>(), "rectPx": [rw, rh], "goldHoles": holes});+ let hf = hole_frame(&path, &rec, (48.0, 33.0), (55.0, 40.0)).unwrap();+ for i in 0..4 { assert!(geom::dist(hf.corners[i], truth[i]) < 2.0, "{i}: {:?} vs {:?}", hf.corners[i], truth[i]); }+ assert!(hf.fitted_off_mm > 0.1 && hf.fitted_off_mm < 1.0, "{}", hf.fitted_off_mm);+ std::fs::remove_dir_all(&dir).ok();+ }++ #[test]+ fn a_ring_joined_to_its_neighbour_is_found_by_its_opening() {+ // the same board with a gold bar running into the upper-right ring: that shape is too long to read as a ring, so three gold holes, against a dark table+ let truth = [(100.0, 80.0), (1400.0, 90.0), (1420.0, 1060.0), (90.0, 1050.0)];+ let h = geom::homography(&[(0.0, 0.0), (55.0, 0.0), (55.0, 40.0), (0.0, 40.0)], &truth).unwrap();+ let hi = geom::invert(&h).unwrap();+ let img = RgbImage::from_fn(1500, 1150, |x, y| { let q = geom::apply(&hi, (x as f64 + 0.5, y as f64 + 0.5));+ if !(0.0..55.0).contains(&q.0) || !(0.0..40.0).contains(&q.1) { return image::Rgb([20, 24, 22]); }+ let c = [(3.5, 3.5), (51.5, 3.5), (51.5, 36.5), (3.5, 36.5)];+ let (mut d, mut k) = (f64::MAX, 0); for (i, p) in c.iter().enumerate() { let e = geom::dist(*p, q); if e < d { d = e; k = i; } }+ let _ = k; if d < 1.25 { image::Rgb([12, 12, 12]) } else if d < 2.8 || ((3.0..4.0).contains(&q.1) && (25.0..50.0).contains(&q.0)) { image::Rgb([230, 170, 60]) } else { image::Rgb([50, 186, 140]) } });+ let dir = std::env::temp_dir().join(format!("hole-frame-joined-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap();+ let path = dir.join("p.png"); img.save(&path).unwrap();+ let (rw, rh) = (1320u32, 970u32);+ let (r, _) = prep::rectify(&img, &truth, rw, rh, false).unwrap();+ let holes = prep::gold_holes(&r, 20.0, 200.0);+ assert_eq!(holes.len(), 3, "{holes:?}");+ let rec = json!({"corners": truth.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>(), "rectPx": [rw, rh], "goldHoles": holes});+ let hf = hole_frame(&path, &rec, (48.0, 33.0), (55.0, 40.0)).unwrap();+ for i in 0..4 { assert!(geom::dist(hf.corners[i], truth[i]) < 2.0, "{i}: {:?} vs {:?}", hf.corners[i], truth[i]); }+ std::fs::remove_dir_all(&dir).ok();+ }++ #[test]+ fn vias_on_both_faces_recover_a_warp_between_them() {+ // dark pits with a pale ring of plain mask on both faces, the bottom's moved by a shift and a slow stretch+ let (w, h) = (1200u32, 800u32);+ let mut seed = 777u64; let mut next = move || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); (seed >> 33) as f64 };+ let dots: Vec<P> = (0..160).map(|_| (40.0 + next() % 1120.0, 40.0 + next() % 720.0)).collect();+ let moved = |p: P| (p.0 + 3.0 + 0.002 * p.0, p.1 - 2.0 + 0.001 * p.1);+ let draw = |pts: &[P]| { let mut grain = 99u64; RgbImage::from_fn(w, h, |x, y| { grain = grain.wrapping_mul(25214903917).wrapping_add(11); let n = ((grain >> 40) % 5) as u8;+ if pts.iter().any(|d| geom::dist(*d, (x as f64, y as f64)) < 3.5) { image::Rgb([24, 60, 34]) } else { image::Rgb([70 + n, 150 + n, 90 + n]) } }) };+ let (top, bottom) = (draw(&dots), draw(&dots.iter().map(|d| moved(*d)).collect::<Vec<_>>()));+ let prs = pit_pairs(&top, &bottom).expect("the pits should pair");+ assert!(prs.len() >= 100, "{}", prs.len());+ let q = crate::regframe::Quad::fit(&prs).unwrap();+ for d in dots.iter().step_by(20) { assert!(geom::dist(q.apply(*d), moved(*d)) < 1.0, "{d:?}"); }+ }++ #[test]+ fn the_mirror_that_correlates_is_the_flip() {+ // via rims scattered over plain mask, mirrored left to right on the bottom photo; the wrong mirror shares nothing+ let (w, h) = (1200u32, 800u32);+ let mut seed = 12345u64; let mut next = move || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); (seed >> 33) as i64 };+ let dots: Vec<(i64, i64)> = (0..400).map(|_| (next() % 1100 + 50, next() % 700 + 50)).collect();+ let paint = |flip: bool| RgbImage::from_fn(w, h, |x, y| { let u = if flip { w as i64 - 1 - x as i64 } else { x as i64 }; if dots.iter().any(|d| (d.0 - u).pow(2) + (d.1 - y as i64).pow(2) < 16) { image::Rgb([30, 130, 90]) } else { image::Rgb([50, 186, 140]) } });+ let (top, bx) = (paint(false), paint(true));+ let by = image::imageops::flip_vertical(&bx);+ let bm = image::imageops::flip_horizontal(&bx); // the bottom photo laid in the top view, right mirror+ let (axis, cx, _) = flip_by_detail(&top, &bm, &by);+ assert_eq!(axis.as_deref(), Some("x"), "{cx}");+ }+ #[test] fn driver_pitches_solve_to_one_scale() { // the Driver top photo: rows at 93.3 px (six of them) and one at 118.5 px
src/checks.rs+5−1@@ -58,7 +58,10 @@ pub fn prep(flow: &Path, state: &Value) -> Outcome { let done: Vec<String> = d["photos"].as_array().into_iter().flatten().map(|p| s(p, "file").to_string()).collect(); for p in state["photos"].as_array().into_iter().flatten() { if !done.iter().any(|f| f == s(p, "file")) { errs.push(format!("{} was registered but not measured: run `prep`", s(p, "file"))); } } let (m, o) = (d["flip"]["matches"].as_u64().unwrap_or(0), d["flip"]["other"].as_u64().unwrap_or(0));- if d["flip"].is_object() && m < 3 * o.max(1) { errs.push(format!("the bottom-to-top mapping is not clear ({m} holes agree one way, {o} the other): check the photos show the whole board")); }+ // a symmetric hole pattern agrees both ways: `scale --holes` settles the flip from what shows through, and says how in prep.json+ let settled = d["flip"]["how"].is_string();+ let symmetric = d["flip"].is_object() && m == o && d["photos"].as_array().into_iter().flatten().all(|p| p["goldHoles"].as_array().map(|a| a.len()).unwrap_or(0) >= 4);+ if d["flip"].is_object() && m < 3 * o.max(1) && !settled && !symmetric { errs.push(format!("the bottom-to-top mapping is not clear ({m} holes agree one way, {o} the other): check the photos show the whole board")); } if !errs.is_empty() { return Err(errs); } let mut hints = vec![]; for p in d["photos"].as_array().into_iter().flatten() { hints.push(format!("{} ({}): sharpness {:.0}, {} holes, {} rows", s(p, "file"), s(p, "side"), p["sharpness"].as_f64().unwrap_or(0.0), p["holes"].as_array().map(|a| a.len()).unwrap_or(0), p["rows"].as_array().map(|a| a.len()).unwrap_or(0))); }@@ -312,6 +315,7 @@ pub fn layers(flow: &Path, pcb: Option<&Path>) -> Outcome { let chosen = cands.iter().find(|k| k["layers"].as_u64() == Some(proposed)); if proposed > 0 && chosen.is_none() { errs.push(format!("{proposed} layers proposed but no candidate routed at {proposed}")); } if let Some(k) = chosen {+ if let Some(e) = k["drcError"].as_str() { errs.push(format!("KiCad's DRC did not run on the proposed {proposed}-layer route ({e}): its errors and missing connections are unknown. Run `connect route --layers {proposed}` again when the service answers")); } if k["open"].as_u64().unwrap_or(0) > 0 && s(&d, "openNote").is_empty() { errs.push(format!("the proposed {proposed}-layer route leaves {} connection(s) open: route more, propose more layers, or explain in `openNote` (and list them in tests)", k["open"])); }
src/confidence.rs+1286−79@@ -7,10 +7,12 @@ //! item, reduced to a Wilson 95 percent lower bound. Nothing typed sets a score: a verdict is one //! observation in a sample, and the bound decides what the sample as a whole is worth. use crate::kicad_out::{self, Grid, Pad};-use crate::raster::Mask;-use crate::{board, draw, items, ledger};+use crate::photomat::{self, Face};+use crate::raster::{self, Mask, Plane};+use crate::{board, draw, fidelity, items, ledger}; use image::RgbImage; use serde_json::{json, Value};+use std::cell::OnceCell; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -36,6 +38,45 @@ const CROP_PX: u32 = 480; const SHEET_CELL: u32 = 256; const SHEET_COLS: u32 = 6; const SHEET_ROWS: u32 = 4;+/// The copper claims, worded as what the photo shows: an auditor has the photo in front of them, not the copy.+const COPPER_CLAIM: &str = "the photo shows copper under the mask at the ring's centre";+const BARE_CLAIM: &str = "the photo shows bare board, no copper under the mask, at the ring's centre";+/// The three stretched panels of a copper crop: the window around the point, the wider view that shows the neighbouring gap+/// lines, and a close view for a point near an edge. Choices made on a tuning sample of the Driver photos, kept out of the audit.+const COPPER_WINDOW_MM: f64 = 6.0;+const COPPER_CONTEXT_MM: f64 = 18.0;+const COPPER_ZOOM_MM: f64 = 3.0;+/// The window the stretch's levels come from, wide enough to hold both copper and bare board in most places.+const STRETCH_REF_MM: f64 = 12.0;+/// A planted copper claim needs a point whose true answer is plain to a person, not only to the two readers. This far round the point+/// the copy, the threshold scorer and the plain mask all say the same; the crop's ring is 0.4 mm.+const CONTROL_REACH_MM: f64 = 0.6;+/// It keeps this far from the board edge, from a part's pad box, and from a via or via candidate seen on either face.+const CONTROL_EDGE_MM: f64 = 1.0;+const CONTROL_PART_MM: f64 = 0.8;+const CONTROL_VIA_MM: f64 = 0.9;+/// In the stretched panel the auditor judges from, where 0 is the 3rd and 1 the 97th percentile of green over plain mask in the window, the+/// green all round the point sits above the first (copper) or below the second (bare board): clear of the middle, where a person would hesitate.+const CONTROL_LIGHT: f32 = 0.7;+const CONTROL_DARK: f32 = 0.3;+/// Both surfaces must be in that window, each at least this share of its plain mask by the scorer, or there is nothing to compare the point with.+const CONTROL_BOTH: f64 = 0.05;+/// And the point has the colour of the surface it is, not only its green: in each of red, green and blue where the two surfaces' medians in the+/// window lie at least `CONTROL_TELLS` levels apart, the median round the point lies this far of the way from the other surface's median to its own.+/// A patch as green as copper and as little red as bare board is one a person calls either way.+const CONTROL_SIDE: f32 = 0.6;+const CONTROL_TELLS: f32 = 8.0;+/// A join counts for an island only when it lies this near the island's own drawn copper: its edge to the pad's outline, to the via's rim,+/// to the place two pieces meet, or to the other copper. A pad at the far end of a pour is nothing a crop of this island can show.+const JOIN_REACH_MM: f64 = 0.3;+/// The second row of a join crop, centred on the join, and how far from the join a crop's first row still tries to show the island.+const JOIN_ZOOM_MM: f64 = 3.0;+const JOIN_SHOW_MM: f64 = 5.0;+/// A track's outline and a via's ring are drawn this far outside the copper, so the mark runs beside it and not over it.+const MARK_CLEAR_MM: f64 = 0.08;+const OTHER_END: [u8; 3] = [0, 220, 255];+/// Tiles for comparing what is left out with where the copy and the scorer disagree most. 5 mm is the fidelity checkerboard's tile.+const WORST_TILE_PX: usize = 200; // ---- hashing, seeds and the bound ---- @@ -126,7 +167,7 @@ fn floor1(x: f64) -> f64 { (x.max(0.0) * 10.0 + 1e-9).floor() / 10.0 } // ---- the run's files ---- -struct Ctx { flow: PathBuf, parts: Vec<Value>, blocks: Vec<Value>, copper: Value, nets: Vec<Value>, layers: Value, domes: Vec<Value>, measured: Value, pads: Vec<Pad> }+struct Ctx { flow: PathBuf, parts: Vec<Value>, blocks: Vec<Value>, copper: Value, nets: Vec<Value>, layers: Value, domes: Vec<Value>, measured: Value, pads: Vec<Pad>, faces: OnceCell<Option<(Face, Face)>>, census: OnceCell<CopperCensus>, reads: OnceCell<HashMap<&'static str, (Mask, Mask)>>, views: OnceCell<HashMap<&'static str, CopperView>>, joins: OnceCell<Vec<(Cu, Vec<Join>)>> } fn read(flow: &Path, f: &str) -> Value { items::read_json(&flow.join(f)).unwrap_or(json!({})) } fn arr(v: &Value) -> Vec<Value> { v.as_array().cloned().unwrap_or_default() }@@ -142,8 +183,49 @@ impl Ctx { flow: flow.to_path_buf(), pads: kicad_out::pads_of(&kicad_out::cache_dir(flow), &parts), parts, blocks: arr(&read(flow, "blocks.json")["blocks"]), copper: read(flow, "copper.json"), nets: arr(&read(flow, "nets.json")["nets"]), layers: read(flow, "layers.json"), domes: arr(&read(flow, "detect/domes.json")["domes"]), measured: read(flow, "detect/copper-measured.json"),+ faces: OnceCell::new(), census: OnceCell::new(), reads: OnceCell::new(), views: OnceCell::new(), joins: OnceCell::new(), } }+ /// What each pixel of the two faces shows, read from the photos alone by the reader `check fidelity` uses, and as it uses it: whether a+ /// face carries parts is measured, not taken from its name. Computed here every time and never read from a file, so what is left out+ /// of the copper audit cannot be edited by hand.+ fn faces(&self) -> Option<&(Face, Face)> {+ self.faces.get_or_init(|| {+ let (t, b) = (image::open(self.flow.join(board_file("top"))).ok()?.to_rgb8(), image::open(self.flow.join(board_file("bottom"))).ok()?.to_rgb8());+ let (mut top, mut bot) = (photomat::classify(&t, photomat::carries_parts(&t)), photomat::classify(&b, photomat::carries_parts(&b)));+ photomat::link_holes(&mut top, &mut bot);+ Some((top, bot))+ }).as_ref()+ }+ fn face(&self, name: &str) -> Option<&Face> { self.faces().map(|f| if name == "top" { &f.0 } else { &f.1 }) }+ fn census(&self) -> &CopperCensus { self.census.get_or_init(|| copper_census(self)) }+ /// Per face, the copy's copper on the board grid and the threshold scorer's read at 40 px/mm. Slow, so only a draw asks.+ fn reads(&self) -> &HashMap<&'static str, (Mask, Mask)> {+ self.reads.get_or_init(|| {+ let mut m = HashMap::new();+ let Some((g, _)) = copper_grid(self) else { return m };+ for (face, layer) in [("top", "F.Cu"), ("bottom", "B.Cu")] {+ let (Some(f), Ok(img)) = (self.face(face), image::open(self.flow.join(board_file(face)))) else { continue };+ m.insert(face, (covered(self, &g, layer), fidelity::copper_read(&img.to_rgb8(), f)));+ }+ m+ })+ }+ /// Each face as the copper panels show it. Slow, so only a draw or a preview asks.+ fn views(&self) -> &HashMap<&'static str, CopperView> {+ self.views.get_or_init(|| ["top", "bottom"].into_iter().filter_map(|face| Some((face, copper_view(&self.flow, face, self.face(face)?)?))).collect())+ }+ /// Every drawn island with the joins the flow holds for it.+ fn joins(&self) -> &Vec<(Cu, Vec<Join>)> { self.joins.get_or_init(|| island_joins(self)) }+ /// How many islands have a join a crop can show, and how many islands there are.+ fn island_proof(&self) -> (usize, usize) { (self.joins().iter().filter(|j| !j.1.is_empty()).count(), self.joins().len()) }+ /// How many pins with a net have a join a crop can show, how many pins have a net, and how many of the rest are on a net that cites a source+ /// outside the photos: named, and not confirmed by anything a photo shows.+ fn pin_proof(&self) -> (usize, usize, usize) {+ let (joined, pins) = (pin_joins(self), net_pins(self));+ let with = pins.iter().filter(|p| joined.contains_key(p.0)).count();+ (with, pins.len(), pins.iter().filter(|p| !joined.contains_key(p.0) && cites_outside(p.1)).count())+ } fn part_pos(&self, p: &Value) -> (f64, f64) { xy(&p["pos_mm"]).unwrap_or((0.0, 0.0)) } fn part_pads(&self, r: &str) -> Vec<&Pad> { self.pads.iter().filter(|x| x.part == r).collect() } /// The box a part's pads cover, or a small square at its position when its footprint is not cached.@@ -173,9 +255,10 @@ fn points(v: &Value) -> Vec<(f64, f64)> { arr(v).iter().filter_map(xy).collect() // ---- what can be audited, per category ---- -/// One claim that can be checked. `class` says when two claims really differ, for planted controls.+/// One claim that can be checked. `class` says when two claims really differ, for planted controls. `shapes` are drawn in pink on the+/// marked panel and `other`, the other end of a claimed join, in blue. #[derive(Clone)]-struct PItem { id: String, stratum: String, class: String, subject: String, body: String, hash: String, face: String, at: (f64, f64), window: f64, ring: f64, shapes: Vec<(Vec<(f64, f64)>, bool)> }+struct PItem { id: String, stratum: String, class: String, subject: String, body: String, hash: String, face: String, at: (f64, f64), window: f64, ring: f64, shapes: Vec<(Vec<(f64, f64)>, bool)>, other: Vec<(Vec<(f64, f64)>, bool)> } impl PItem { fn claim(&self) -> String { format!("{}: {}", self.subject, self.body) } } @@ -190,7 +273,7 @@ fn part_item(c: &Ctx, p: &Value, stratum: &str, body: String, hash: String) -> P PItem { id: r.to_string(), stratum: stratum.to_string(), class: format!("{}|{}", r.trim_end_matches(|x: char| x.is_ascii_digit()), st(p, "package")), subject: r.to_string(), body, hash, face: if st(p, "side") == "bottom" { "bottom".into() } else { "top".into() },- at: ((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0), window: ((b.2 - b.0).max(b.3 - b.1) + 3.0).clamp(4.0, 16.0), ring: ((b.2 - b.0).max(b.3 - b.1) / 2.0 + 0.4).min(7.5), shapes: vec![],+ at: ((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0), window: ((b.2 - b.0).max(b.3 - b.1) + 3.0).clamp(4.0, 16.0), ring: ((b.2 - b.0).max(b.3 - b.1) / 2.0 + 0.4).min(7.5), shapes: vec![], other: vec![], } } @@ -219,31 +302,523 @@ fn covered(c: &Ctx, g: &Grid, layer: &str) -> Mask { m } +// ---- joins: what an island is seen to touch ----++/// One drawn piece of copper: a pour by its outline, or a track by its centre line and half its width.+struct Cu { key: String, id: String, layer: String, face: &'static str, net: String, pts: Vec<(f64, f64)>, closed: bool, half: f64, geo: String, bbox: (f64, f64, f64, f64) }++impl Cu {+ /// Clear distance from a point to this copper, 0 inside it.+ fn dist(&self, p: (f64, f64)) -> f64 {+ let b = self.bbox;+ let far = (b.0 - p.0).max(p.0 - b.2).max(b.1 - p.1).max(p.1 - b.3) - self.half;+ if far > 2.0 { return far; }+ let n = self.pts.len();+ if self.closed {+ if crate::connect::inside(&self.pts, p) { return 0.0; }+ (0..n).map(|i| seg_dist(p, self.pts[i], self.pts[(i + 1) % n])).fold(f64::MAX, f64::min)+ } else {+ (self.pts.windows(2).map(|w| seg_dist(p, w[0], w[1])).fold(f64::MAX, f64::min) - self.half).max(0.0)+ }+ }+ /// The gap between two pieces of copper and where they come nearest, tried from the vertices of each.+ fn gap(&self, o: &Cu) -> (f64, (f64, f64)) {+ let (a, b) = (self.bbox, o.bbox);+ let apart = (a.0 - b.2).max(b.0 - a.2).max(a.1 - b.3).max(b.1 - a.3) - self.half - o.half;+ let mut best = (apart.max(0.0), self.pts[0]);+ if apart > 2.0 { return best; }+ best.0 = f64::MAX;+ for p in &self.pts { let d = (o.dist(*p) - self.half).max(0.0); if d < best.0 { best = (d, *p); } }+ for p in &o.pts { let d = (self.dist(*p) - o.half).max(0.0); if d < best.0 { best = (d, *p); } }+ best+ }+ /// What a crop draws round it: the pour's own outline, or the track's centre line swollen to just past its edges.+ fn outline(&self) -> Vec<(f64, f64)> { if self.closed { self.pts.clone() } else { swell(&self.pts, self.half + MARK_CLEAR_MM) } }+}++/// A closed outline round a centre line, `half` to each side and past each end.+fn swell(path: &[(f64, f64)], half: f64) -> Vec<(f64, f64)> {+ let n = path.len();+ if n < 2 { return path.to_vec(); }+ let dir = |i: usize| { let (a, b) = (path[i.saturating_sub(1)], path[(i + 1).min(n - 1)]); let l = crate::geom::dist(a, b).max(1e-9); ((b.0 - a.0) / l, (b.1 - a.1) / l) };+ let side = |i: usize, s: f64| {+ let d = dir(i);+ let e = if i == 0 { -half } else if i == n - 1 { half } else { 0.0 };+ (path[i].0 + d.0 * e - d.1 * half * s, path[i].1 + d.1 * e + d.0 * half * s)+ };+ (0..n).map(|i| side(i, 1.0)).chain((0..n).rev().map(|i| side(i, -1.0))).collect()+}++fn circle_mm(at: (f64, f64), r: f64) -> Vec<(f64, f64)> { (0..24).map(|k| { let a = k as f64 * std::f64::consts::TAU / 24.0; (at.0 + r * a.cos(), at.1 + r * a.sin()) }).collect() }++/// One join the flow holds for an island: a pad, a via or other copper, where they meet, and the marks that show the other end.+#[derive(Clone)]+struct Join { kind: &'static str, name: String, at: (f64, f64), other: Vec<(Vec<(f64, f64)>, bool)>, geo: String }++/// The gap between a piece of copper and a pad's outline: 0 when either reaches into the other.+fn pad_gap(cu: &Cu, pad: &Pad) -> f64 {+ if cu.pts.iter().any(|p| crate::conduct::pad_inside(pad, p.0, p.1, 0.0)) { return 0.0; }+ let r = rect_of(pad);+ let mut best = cu.dist((pad.x, pad.y));+ for i in 0..4 {+ let (a, b) = (r[i], r[(i + 1) % 4]);+ let n = (crate::geom::dist(a, b) / 0.05).ceil().max(1.0) as usize;+ for k in 0..n { let t = k as f64 / n as f64; best = best.min(cu.dist((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t))); }+ }+ best+}++fn copper_join(o: &Cu, at: (f64, f64)) -> Join {+ // the other copper's outline, and a cross with an open middle where the two meet+ let mut other = vec![(o.outline(), true)];+ for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { other.push((vec![(at.0 + dx * 0.12, at.1 + dy * 0.12), (at.0 + dx * 0.3, at.1 + dy * 0.3)], false)); }+ Join { kind: "copper", name: o.id.clone(), at, other, geo: format!("{} {:.3} {:.3}", o.geo, at.0, at.1) }+}++/// Every drawn island with the joins `connect attribute` holds for it, read from its files (detect/attribution.json, detect/connect-graph.json,+/// detect/attrib/measure.json): a pad whose solid contact is this island's copper, a valid via sitting in its conductor, copper stitched to it, and+/// copper drawn from the same piece. A join counts only within `JOIN_REACH_MM` of the island's own drawn copper, so that one crop shows both ends.+/// Sorted, one per other end. Evidence measured on other copper than copper.json now holds counts for nothing.+fn island_joins(c: &Ctx) -> Vec<(Cu, Vec<Join>)> {+ let mut cus: Vec<Cu> = vec![];+ for (key, pre) in [("zones", "zone"), ("traces", "trace")] { for z in arr(&c.copper[key]) {+ let Some(face) = layer_face(st(&z, "layer")) else { continue };+ let closed = key == "zones";+ let geo = if closed { &z["polygon_mm"] } else { &z["path_mm"] };+ let pts = points(geo);+ if pts.len() < 2 { continue; }+ let bbox = pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));+ cus.push(Cu { key: format!("{pre}:{}", st(&z, "id")), id: st(&z, "id").into(), layer: st(&z, "layer").into(), face, net: st(&z, "net").into(), half: if closed { 0.0 } else { z["width_mm"].as_f64().unwrap_or(0.2) / 2.0 }, closed, geo: geo.to_string(), pts, bbox });+ } }+ let (att, graph, ms) = (read(&c.flow, "detect/attribution.json"), read(&c.flow, "detect/connect-graph.json"), read(&c.flow, "detect/attrib/measure.json"));+ let stale = ms["copperSha"].as_str().is_some_and(|s| s != crate::attrib::zones_key(&c.copper));+ let index: HashMap<&str, usize> = cus.iter().enumerate().map(|(i, k)| (k.id.as_str(), i)).collect();+ let n = |v: &Value| v.as_u64().map(|x| x as usize);+ // pieces of copper as the photo was read: which conductor and node each is in, and which drawn items lie on it+ let (mut own, mut piece): (HashMap<usize, Vec<usize>>, HashMap<usize, (Option<usize>, Option<usize>, Vec<usize>)>) = (HashMap::new(), HashMap::new());+ for (k, p) in arr(&att["pieces"]).iter().enumerate() {+ let k = n(&p["piece"]).unwrap_or(k);+ let on: Vec<usize> = arr(&p["items"]).iter().filter_map(|x| x.as_str().and_then(|s| index.get(s).copied())).collect();+ for i in &on { own.entry(*i).or_default().push(k); }+ piece.insert(k, (n(&p["conductor"]).or(n(&graph["conductorOfPiece"][k])), n(&p["node"]).or(n(&graph["nodeOfPiece"][k])), on));+ }+ // pads: the contacts attribution kept as solid, each at the place and in the outline the photo shows the pad's metal+ let mut shape: HashMap<String, Vec<Pad>> = HashMap::new();+ for p in arr(&ms["pads"]) {+ let f = |k: &str| p["core"][k].as_f64().unwrap_or(0.0);+ shape.entry(format!("{}.{}", st(&p, "part"), st(&p, "num"))).or_default().push(Pad { part: st(&p, "part").into(), num: st(&p, "num").into(), x: f("x"), y: f("y"), w: f("w"), h: f("h"), rot: f("rot"), round: false, drill: 0.0, layers: vec![], net: String::new() });+ }+ let seen_at: HashMap<(String, String), (f64, f64)> = arr(&att["padStates"]).iter().filter_map(|s| Some(((st(s, "pin").to_string(), st(s, "side").to_string()), (s["x"].as_f64()?, s["y"].as_f64()?)))).collect();+ let mut pads: HashMap<(String, usize), Vec<(String, Pad)>> = HashMap::new();+ for k in arr(&graph["contacts"]).iter().filter(|k| k["tier"].as_u64() == Some(1) && st(k, "kind") != "via-in-pad") {+ let (pin, side) = (st(k, "pin").to_string(), st(k, "side").to_string());+ let (Some(node), Some(at)) = (n(&k["node"]), seen_at.get(&(pin.clone(), side.clone()))) else { continue };+ let Some(p) = shape.get(&pin).and_then(|v| v.iter().min_by(|a, b| ((a.x - at.0).abs() + (a.y - at.1).abs()).partial_cmp(&((b.x - at.0).abs() + (b.y - at.1).abs())).unwrap_or(std::cmp::Ordering::Equal))) else { continue };+ pads.entry((side, node)).or_default().push((pin, p.clone()));+ }+ let place: HashMap<String, ((f64, f64), f64)> = arr(&c.copper["vias"]).iter().filter_map(|v| Some((st(v, "id").to_string(), (xy(&v["pos_mm"])?, v["diameter_mm"].as_f64().unwrap_or(0.6))))).collect();+ let mut vias: HashMap<(&str, usize), Vec<(&String, (f64, f64), f64)>> = HashMap::new();+ for (id, v) in att["vias"].as_object().into_iter().flatten() {+ let (true, Some((at, d))) = (v["valid"] == json!(true), place.get(id)) else { continue };+ for (side, key) in [("top", "topConductor"), ("bottom", "bottomConductor")] { if let Some(cd) = n(&v[key]) { vias.entry((side, cd)).or_default().push((id, *at, *d)); } }+ }+ let mut stitch: HashMap<usize, Vec<(usize, (f64, f64))>> = HashMap::new();+ for s in arr(&graph["stitches"]) {+ let (Some(a), Some(b), Some(at)) = (n(&s["pieces"][0]), n(&s["pieces"][1]), xy(&s["at"])) else { continue };+ stitch.entry(a).or_default().push((b, at));+ stitch.entry(b).or_default().push((a, at));+ }+ let mut found: Vec<Vec<Join>> = vec![];+ for (i, cu) in cus.iter().enumerate() {+ let mut joins: Vec<(f64, Join)> = vec![];+ let on: Vec<usize> = if stale { vec![] } else { own.get(&i).cloned().unwrap_or_default() };+ let conductors: std::collections::BTreeSet<usize> = on.iter().filter_map(|k| piece.get(k).and_then(|p| p.0)).collect();+ let nodes: std::collections::BTreeSet<usize> = on.iter().filter_map(|k| piece.get(k).and_then(|p| p.1)).collect();+ for node in &nodes { for (pin, pad) in pads.get(&(cu.face.to_string(), *node)).into_iter().flatten() {+ let gap = pad_gap(cu, pad);+ if gap > JOIN_REACH_MM { continue; }+ let boxed = Pad { w: pad.w + 2.0 * MARK_CLEAR_MM, h: pad.h + 2.0 * MARK_CLEAR_MM, ..pad.clone() };+ joins.push((gap, Join { kind: "pad", name: pin.clone(), at: (pad.x, pad.y), other: vec![(rect_of(&boxed), true)], geo: format!("{:.3} {:.3} {:.3} {:.3} {:.1}", pad.x, pad.y, pad.w, pad.h, pad.rot) }));+ } }+ for cd in &conductors { for (id, at, d) in vias.get(&(cu.face, *cd)).into_iter().flatten() {+ let gap = (cu.dist(*at) - d / 2.0).max(0.0);+ if gap > JOIN_REACH_MM { continue; }+ joins.push((gap, Join { kind: "via", name: (*id).clone(), at: *at, other: vec![(circle_mm(*at, d / 2.0 + 2.0 * MARK_CLEAR_MM), true)], geo: format!("{:.3} {:.3}", at.0, at.1) }));+ } }+ for k in &on {+ // a stitch joins two pieces: the other end is whichever drawn item of the other piece lies nearest the place they meet+ for (o, at) in stitch.get(k).into_iter().flatten() {+ let Some((d, j)) = piece.get(o).into_iter().flat_map(|p| p.2.iter()).filter(|j| **j != i && cus[**j].face == cu.face).map(|j| (cus[*j].dist(*at), *j)).min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)) else { continue };+ let gap = d.max(cu.dist(*at));+ if gap <= JOIN_REACH_MM { joins.push((gap, copper_join(&cus[j], *at))); }+ }+ for j in piece.get(k).into_iter().flat_map(|p| p.2.iter()).filter(|j| **j != i && cus[**j].face == cu.face) {+ let (gap, at) = cu.gap(&cus[*j]);+ if gap <= JOIN_REACH_MM { joins.push((gap, copper_join(&cus[*j], at))); }+ }+ }+ joins.sort_by(|a, b| (a.1.kind, &a.1.name).cmp(&(b.1.kind, &b.1.name)).then(a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)));+ joins.dedup_by(|a, b| a.1.kind == b.1.kind && a.1.name == b.1.name);+ found.push(joins.into_iter().map(|j| j.1).collect());+ }+ cus.into_iter().zip(found).collect()+}++/// The join an island's claim states: the one nearest a name, as attribution's own ladder ranks them, so a pad before a via before other copper.+/// Among several of that kind, one by a hash of the island's id: nobody picks.+fn claimed<'a>(cu: &Cu, joins: &'a [Join]) -> Option<&'a Join> {+ let kind = ["pad", "via", "copper"].into_iter().find(|k| joins.iter().any(|j| j.kind == *k))?;+ let of: Vec<&Join> = joins.iter().filter(|j| j.kind == kind).collect();+ Some(of[(seed_u64(&cu.key, "join") % of.len() as u64) as usize])+}++/// An island's claim: one join, worded as what the crop shows, with both ends marked. The crop is centred on the join.+fn join_item(cu: &Cu, j: &Join) -> PItem {+ let body = match j.kind {+ "pad" => format!("reaches the pad boxed in blue (pin {}) with no break between them", j.name),+ "via" => "is one piece with the via in the blue ring: no gap between the via and this copper".to_string(),+ _ => format!("is one piece with the copper outlined in blue ({}): no gap between them at the blue cross", j.name),+ };+ let far = |pts: &[(f64, f64)]| pts.iter().map(|p| (p.0 - j.at.0).abs().max((p.1 - j.at.1).abs())).fold(0.0, f64::max);+ let reach = j.other.iter().map(|s| far(&s.0)).fold(far(&cu.pts), f64::max).min(JOIN_SHOW_MM);+ PItem {+ id: cu.key.clone(), stratum: "island".into(), class: j.kind.into(), subject: format!("copper outlined in pink ({})", cu.id), body,+ hash: h(&[&cu.layer, &cu.net, &cu.geo, j.kind, &j.name, &j.geo]), face: cu.face.into(), at: j.at, window: (2.0 * reach + 2.0).clamp(6.0, 12.0), ring: 0.0,+ shapes: vec![(cu.outline(), true)], other: j.other.clone(),+ }+}++/// Every pin of every net whose part is on the board, once, with its net.+fn net_pins(c: &Ctx) -> Vec<(&str, &Value)> {+ let mut seen = HashSet::new();+ c.nets.iter().flat_map(|n| n["pins"].as_array().into_iter().flatten().filter_map(move |x| x.as_str().map(|pin| (pin, n))))+ .filter(|(pin, _)| pin.rsplit_once('.').is_some_and(|(r, _)| c.parts.iter().any(|p| st(p, "ref") == r)) && seen.insert(*pin)).collect()+}++/// The joins of pads to drawn copper, by pin: the pad joins `island_joins` found, read from the pad's side.+fn pin_joins(c: &Ctx) -> HashMap<&str, Vec<(&Cu, &Join)>> {+ let mut m: HashMap<&str, Vec<(&Cu, &Join)>> = HashMap::new();+ for (cu, joins) in c.joins() { for j in joins.iter().filter(|j| j.kind == "pad") { m.entry(j.name.as_str()).or_default().push((cu, j)); } }+ m+}++/// The copper a pin's claim names. Copper read from the photo comes before a stub the copy drew to reach the pad; among several, one by a hash of the pin: nobody picks.+fn pin_claimed<'a>(pin: &str, joins: &[(&'a Cu, &'a Join)]) -> Option<(&'a Cu, &'a Join)> {+ let read: Vec<&(&Cu, &Join)> = joins.iter().filter(|j| !j.0.id.contains("stub")).collect();+ let of = if read.is_empty() { joins.iter().collect() } else { read };+ of.get((seed_u64(pin, "join") % of.len().max(1) as u64) as usize).map(|j| **j)+}++/// A pin's claim: its pad, boxed where the photo shows its metal, is joined to one piece of copper the copy drew. The crop is centred on the pad.+fn pin_item(pin: &str, net: &str, cu: &Cu, j: &Join) -> PItem {+ let far = |pts: &[(f64, f64)]| pts.iter().map(|p| (p.0 - j.at.0).abs().max((p.1 - j.at.1).abs())).fold(0.0, f64::max);+ PItem {+ id: format!("pin:{pin}"), stratum: "pin".into(), class: "pin".into(), subject: format!("pad boxed in pink (pin {pin})"),+ body: format!("is joined to the copper outlined in blue ({}) with no break between them", cu.id),+ hash: h(&[pin, net, &j.geo, &cu.key, &cu.layer, &cu.geo]), face: cu.face.into(), at: j.at, window: (2.0 * far(&cu.pts).min(JOIN_SHOW_MM) + 2.0).clamp(6.0, 12.0), ring: 0.0,+ shapes: j.other.iter().take(1).cloned().collect(), other: vec![(cu.outline(), true)],+ }+}++/// Whether a net states a source outside the photos for what it is: a `source` or `nameSource` citation (a datasheet's pinout, say), as opposed to+/// nothing, the program's own measuring, or a reading of the photos.+fn cites_outside(n: &Value) -> bool {+ let stated = |k: &str| { let t = st(n, k).trim().to_lowercase(); !(t.is_empty() || t.starts_with("none") || t.starts_with("infer") || matches!(t.as_str(), "measured" | "router" | "copper" | "silkscreen" | "circuit")) };+ stated("source") || stated("nameSource")+}++/// A join claim made false beyond doubt: the claim keeps its words, and one end is drawn where there is none. For an island, the other end moves: a+/// pad's box onto plain mask clear of every part, where no pad can be; a via's ring onto plain mask with no via or via candidate within 1.5 mm; a+/// second copper outline wholly onto a part's body, where the photo shows no copper at all. For a pin, its own pad's box moves onto plain mask+/// clear of every part. Nobody looking can call any of these ok.+fn false_join(c: &Ctx, base: &PItem, r: &mut Rng) -> Option<PItem> {+ let pin = base.stratum == "pin";+ let f = c.face(&base.face)?;+ let (g, inside) = copper_grid(c)?;+ let near: Vec<(f64, f64)> = arr(&c.copper["vias"]).iter().chain(c.domes.iter()).filter_map(|v| xy(&v["pos_mm"])).collect();+ let boxes: Vec<(f64, f64, f64, f64)> = c.parts.iter().filter(|p| (st(p, "side") == "bottom") == (base.face == "bottom")).map(|p| c.part_box(p)).collect();+ // anywhere the whole mark still shows in the crop+ let room = base.window / 2.0 - 0.3;+ for _ in 0..400 {+ let (dx, dy) = ((r.unit() * 2.0 - 1.0) * room, (r.unit() * 2.0 - 1.0) * room);+ let moved: Vec<(Vec<(f64, f64)>, bool)> = if pin { &base.shapes } else { &base.other }.iter().map(|(p, k)| (p.iter().map(|q| (q.0 + dx, q.1 + dy)).collect(), *k)).collect();+ let mark = &moved.first()?.0;+ let b = mark.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));+ if b.0 < base.at.0 - room || b.2 > base.at.0 + room || b.1 < base.at.1 - room || b.3 > base.at.1 + room { continue; }+ let (u, v) = ((((b.0 + b.2) / 2.0 - g.x0) * g.res) as i64, (((b.1 + b.3) / 2.0 - g.y0) * g.res) as i64);+ if u < 0 || v < 0 || u as usize >= g.w || v as usize >= g.h || !inside.d[v as usize * g.w + u as usize] { continue; }+ let good = if base.class == "copper" {+ mark.iter().all(|q| photo_index(f, *q).map(|i| f.cls[i] == photomat::OBJECT).unwrap_or(false))+ } else {+ let ((x0, y0), (x1, y1)) = (board::to_px(b.0, b.1), board::to_px(b.2, b.3));+ let plain = x0 >= 0.0 && y0 >= 0.0 && (x1 as usize) < f.w && (y1 as usize) < f.h && (y0 as usize..=y1 as usize).all(|y| (x0 as usize..=x1 as usize).all(|x| f.cls[y * f.w + x] == photomat::MASK));+ plain && !boxes.iter().any(|p| b.2 >= p.0 - 0.5 && b.0 <= p.2 + 0.5 && b.3 >= p.1 - 0.5 && b.1 <= p.3 + 0.5)+ && !(base.class == "via" && near.iter().any(|n| (n.0 - (b.0 + b.2) / 2.0).powi(2) + (n.1 - (b.1 + b.3) / 2.0).powi(2) < 1.5 * 1.5))+ };+ // a pin's crop is centred on its box, false or real, so the empty box sits where a real pad would+ if good { let mut it = base.clone(); if pin { it.shapes = moved; it.at = ((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0); } else { it.other = moved; } return Some(it); }+ }+ None+}++const PAD_BOX: &str = "part's pad box";++/// A point of the board the copper audit leaves out, and what the photo shows there instead.+#[derive(Clone)]+struct LeftOut { face: String, at: (f64, f64), copy_has_copper: bool, why: &'static str }++/// Every candidate point of the copper audit: those a photo can settle, and those it cannot, with the reason.+#[derive(Default)]+struct CopperCensus { points: Vec<PItem>, left_out: Vec<LeftOut> }++impl CopperCensus {+ fn counts(&self) -> BTreeMap<&'static str, usize> {+ let mut m = BTreeMap::new();+ for l in &self.left_out { *m.entry(l.why).or_insert(0) += 1; }+ m+ }+ /// One line: the share left out and on which materials.+ fn line(&self) -> String {+ let (n, all) = (self.left_out.len(), self.left_out.len() + self.points.len());+ let by: Vec<String> = self.counts().iter().map(|(k, v)| format!("{k} {v}")).collect();+ format!("{n} of {all} points ({:.1}%) left out because the photo cannot show copper there: {}", pct(n as f64, all as f64), by.join(", "))+ }+}++/// The pixel at a point, when it lies on the photo.+fn photo_index(f: &Face, at: (f64, f64)) -> Option<usize> {+ let (x, y) = board::to_px(at.0, at.1);+ if x < 0.0 || y < 0.0 || x as usize >= f.w || y as usize >= f.h { return None; }+ Some(y as usize * f.w + x as usize)+}++/// What stops the photo showing the surface under the mask at this pixel, by the rule `check fidelity` scores copper with: the pixel,+/// or one within `fidelity::BESIDE_PX`, is not plain mask. The nearest such pixel names the material. The copy is not an input.+fn photo_blocked(f: &Face, at: (f64, f64)) -> Option<&'static str> {+ let Some(i) = photo_index(f, at) else { return Some("off the photo") };+ let (cx, cy) = ((i % f.w) as i64, (i / f.w) as i64);+ let r = fidelity::BESIDE_PX as i64;+ let mut best: Option<(i64, u8)> = None;+ for dy in -r..=r { for dx in -r..=r {+ let d = dx * dx + dy * dy;+ if d > r * r { continue; }+ let (x, y) = (cx + dx, cy + dy);+ if x < 0 || y < 0 || x >= f.w as i64 || y >= f.h as i64 { continue; }+ let k = f.cls[y as usize * f.w + x as usize];+ if k != photomat::MASK && best.map(|b| d < b.0).unwrap_or(true) { best = Some((d, k)); }+ } }+ best.map(|(_, k)| match k { photomat::SILK => "silk", photomat::GOLD | photomat::SILVER => "exposed metal", photomat::HOLE => "hole", _ => "part body" })+}++fn copper_body(on: bool) -> &'static str { if on { COPPER_CLAIM } else { BARE_CLAIM } }++fn copper_item(id: String, face: &str, at: (f64, f64), on: bool) -> PItem {+ let body = copper_body(on);+ PItem { id, stratum: if on { "copper".into() } else { "bare".into() }, class: body.into(), subject: format!("{face} point"), body: body.into(), hash: h(&[body]), face: face.into(), at, window: COPPER_WINDOW_MM, ring: 0.4, shapes: vec![], other: vec![] }+}+ /// Random points of the board, on each face, away from the board edge and from what a part hides. The /// answer to "what does the copy say here" catches copper that is missing as well as copper that is false,-/// which a sample of the kept items never can.-fn copper_points(c: &Ctx) -> Vec<PItem> {- let Some((g, inside)) = copper_grid(c) else { return vec![] };- let mut out = vec![];+/// which a sample of the kept items never can. A point goes only by what the photo shows there, the same rule for both+/// answers and with the copy's copper no input to it, so a region the flow reads badly stays in: dark mask, gap lines and track edges are all plain mask.+fn copper_census(c: &Ctx) -> CopperCensus {+ let Some((g, inside)) = copper_grid(c) else { return CopperCensus::default() };+ let mut out = CopperCensus::default(); for (face, layer) in [("top", "F.Cu"), ("bottom", "B.Cu")] { let m = covered(c, &g, layer); let hide: Vec<(f64, f64, f64, f64)> = c.parts.iter().filter(|p| st(p, "side") == face).map(|p| { let b = c.part_box(p); (b.0 - 0.3, b.1 - 0.3, b.2 + 0.3, b.3 + 0.3) }).collect(); let (cols, rows) = ((g.w as f64 / g.res) as usize, (g.h as f64 / g.res) as usize);+ let photo = c.face(face); for j in 0..rows { for i in 0..cols { let id = format!("pt:{face}:{i}:{j}"); let mut r = Rng(seed_u64(&id, "jitter")); let (x, y) = (g.x0 + i as f64 + r.unit(), g.y0 + j as f64 + r.unit()); let (u, v) = (((x - g.x0) * g.res) as usize, ((y - g.y0) * g.res) as usize); if u >= g.w || v >= g.h || !inside.d[v * g.w + u] { continue; }- if hide.iter().any(|b| x >= b.0 && x <= b.2 && y >= b.1 && y <= b.3) { continue; } let on = m.d[v * g.w + u];- let body = if on { "the copy has COPPER at the ring's centre" } else { "the copy has NO COPPER at the ring's centre" };- out.push(PItem { id, stratum: if on { "copper".into() } else { "bare".into() }, class: body.into(), subject: format!("{face} point"), body: body.into(), hash: h(&[body]), face: face.into(), at: (x, y), window: 6.0, ring: 0.4, shapes: vec![] });+ let why = if hide.iter().any(|b| x >= b.0 && x <= b.2 && y >= b.1 && y <= b.3) { Some(PAD_BOX) } else { photo.and_then(|f| photo_blocked(f, (x, y))) };+ match why {+ Some(why) => out.left_out.push(LeftOut { face: face.into(), at: (x, y), copy_has_copper: on, why }),+ None => out.points.push(copper_item(id, face, (x, y), on)),+ }+ } }+ }+ out+}++fn seg_dist(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l = dx * dx + dy * dy;+ let t = if l > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l).clamp(0.0, 1.0) } else { 0.0 };+ ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt()+}++/// What the planted-claim rule reads on one face: the copy's copper, the threshold scorer's, the face as the panels show it, and the parts' pad boxes.+struct PlantFace<'a> { copy: &'a Mask, scorer: &'a Mask, view: &'a CopperView, boxes: Vec<(f64, f64, f64, f64)> }++/// Where a copper claim may be planted. A control is a real point stated the other way round, so its true answer has to be certain, and certain to a+/// person looking at the crop: two readers that share a threshold can agree and both be wrong, as on a dark patch inside a lighter pour.+struct Planting<'a> { g: Grid, outline: Vec<(f64, f64)>, dots: Vec<(f64, f64)>, faces: HashMap<&'static str, PlantFace<'a>> }++impl<'a> Planting<'a> {+ fn new(c: &'a Ctx) -> Option<Planting<'a>> {+ let (g, _) = copper_grid(c)?;+ let dots = arr(&c.copper["vias"]).iter().chain(c.domes.iter()).filter_map(|v| xy(&v["pos_mm"])).collect();+ let mut faces = HashMap::new();+ for face in ["top", "bottom"] {+ let (Some((copy, scorer)), Some(view)) = (c.reads().get(face), c.views().get(face)) else { continue };+ let boxes = c.parts.iter().filter(|p| (st(p, "side") == "bottom") == (face == "bottom")).map(|p| c.part_box(p)).collect();+ faces.insert(face, PlantFace { copy, scorer, view, boxes });+ }+ Some(Planting { g, outline: points(&c.copper["outline"]["points_mm"]), dots, faces })+ }++ /// Why this point cannot carry a planted claim, or None where `on` (copper, or bare board) is plain to see: away from the board edge, parts and vias;+ /// out to `CONTROL_REACH_MM` nothing but plain mask, with the copy and the threshold scorer both reading `on` at every pixel; both surfaces in the+ /// panel's window to compare with; and the green all round the point clear of the middle of the stretched panel's range, on the side `on` says.+ fn doubt(&self, face: &str, at: (f64, f64), on: bool) -> Option<&'static str> {+ let Some(f) = self.faces.get(face) else { return Some("no reading of this face") };+ let n = self.outline.len();+ if (0..n).any(|i| seg_dist(at, self.outline[i], self.outline[(i + 1) % n]) < CONTROL_EDGE_MM) { return Some("near the board edge"); }+ if f.boxes.iter().any(|b| at.0 >= b.0 - CONTROL_PART_MM && at.0 <= b.2 + CONTROL_PART_MM && at.1 >= b.1 - CONTROL_PART_MM && at.1 <= b.3 + CONTROL_PART_MM) { return Some("near a part"); }+ if self.dots.iter().any(|d| (d.0 - at.0).powi(2) + (d.1 - at.1).powi(2) < CONTROL_VIA_MM * CONTROL_VIA_MM) { return Some("near a via or a via candidate"); }+ let (g, v) = (&self.g, f.view);+ let (w, h) = (v.plain.w as i64, v.plain.h as i64);+ let (cx, cy) = board::to_px(at.0, at.1);+ let r = (CONTROL_REACH_MM * board::RES).ceil() as i64;+ let mut round: Vec<usize> = vec![];+ for dy in -r..=r { for dx in -r..=r {+ if dx * dx + dy * dy > r * r { continue; }+ let (x, y) = (cx as i64 + dx, cy as i64 + dy);+ if x < 0 || y < 0 || x >= w || y >= h { return Some("off the photo"); }+ let i = (y * w + x) as usize;+ if !v.plain.d[i] { return Some("silk, metal, a hole or a part body nearby"); }+ let (mx, my) = board::to_mm(x as f64 + 0.5, y as f64 + 0.5);+ let (a, b) = (((mx - g.x0) * g.res) as i64, ((my - g.y0) * g.res) as i64);+ if (a >= 0 && b >= 0 && (a as usize) < g.w && (b as usize) < g.h && f.copy.d[b as usize * g.w + a as usize]) != on { return Some("the copy has the other surface nearby"); }+ if f.scorer.d.get(i).copied().unwrap_or(false) != on { return Some("the threshold scorer reads the other surface nearby"); }+ round.push(i); } }+ let px = window_px(v, at, STRETCH_REF_MM);+ let lit = |i: &usize| f.scorer.d.get(*i).copied().unwrap_or(false);+ let n_lit = px.iter().filter(|i| lit(i)).count();+ if (n_lit.min(px.len() - n_lit) as f64) < CONTROL_BOTH * px.len() as f64 { return Some("only one surface in view to compare with"); }+ let at_q = |mut vals: Vec<f32>, q: usize| -> f32 { let k = (vals.len() - 1) * q / 100; *vals.select_nth_unstable_by(k, |a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)).1 };+ let (lo, hi) = level(v, &px, 1);+ let shown = |q: usize| (at_q(round.iter().map(|i| v.rgb[1].d[*i]).collect(), q) - lo) / (hi - lo);+ if if on { shown(5) < CONTROL_LIGHT } else { shown(95) > CONTROL_DARK } { return Some("not clearly the lighter or the darker surface in the stretched panel"); }+ for k in 0..3 {+ // every third pixel of the window is plenty for a median+ let (copper, bare) = (at_q(px.iter().step_by(3).filter(|i| lit(i)).map(|i| v.rgb[k].d[*i]).collect(), 50), at_q(px.iter().step_by(3).filter(|i| !lit(i)).map(|i| v.rgb[k].d[*i]).collect(), 50));+ if (copper - bare).abs() < CONTROL_TELLS { continue; }+ let side = (at_q(round.iter().map(|i| v.rgb[k].d[*i]).collect(), 50) - bare) / (copper - bare);+ if if on { side < CONTROL_SIDE } else { side > 1.0 - CONTROL_SIDE } { return Some("not the colour of the surface it is, in red, green and blue alike"); }+ }+ None+ }++ fn rule() -> String {+ format!("a planted claim goes only where its true answer is plain to a person: {CONTROL_EDGE_MM} mm from the board edge, {CONTROL_PART_MM} mm from a part's pad box, {CONTROL_VIA_MM} mm from a via or via candidate; \+out to {CONTROL_REACH_MM} mm round the point only plain mask, with the copy and the threshold scorer agreeing at every pixel; each surface at least {:.0}% of the plain mask in the {STRETCH_REF_MM} mm the panel is stretched over; \+the stretched green round the point above {CONTROL_LIGHT} for copper or below {CONTROL_DARK} for bare board; and in each colour that tells the two surfaces apart by {CONTROL_TELLS} levels, the point at least {CONTROL_SIDE} of the way to its own surface's median", CONTROL_BOTH * 100.0)+ }+}++/// The planted claims a draw may use, and how the rest of the points looked at were refused.+#[derive(Default)]+struct Planted { pool: Vec<PItem>, looked: usize, eligible: usize, stated: BTreeMap<String, usize>, refused: BTreeMap<&'static str, usize> }++impl Planted {+ fn refusals(&self) -> String { self.refused.iter().map(|(k, v)| format!("{k} {v}")).collect::<Vec<_>>().join(", ") }+}++/// Planted copper claims: real points of the population, at the very position the population gives them, each stated the other way+/// round. So a control is drawn as real items are, on both faces and in both directions, and only its answer is false. It is planted only where+/// `Planting::doubt` finds nothing: a control whose own truth is doubtful would void an honest auditor's sample. They come from the far end+/// of each stratum's order, so extending a sample does not reach them. `used` are the ids already on the sheet.+fn copper_control_pool(c: &Ctx, pop: &[PItem], seed: &str, used: &HashSet<String>) -> Planted {+ let mut out = Planted::default();+ for s in ["bare", "copper"] { out.stated.insert(s.into(), 0); }+ let Some(rule) = Planting::new(c) else { return out };+ let by = strata(pop);+ let mut pick = |s: &str| -> Vec<PItem> {+ let Some(list) = by.get(s) else { return vec![] };+ let ids: Vec<String> = list.iter().map(|p| p.id.clone()).collect();+ let ord = order(&ids, seed, s);+ let at: HashMap<&str, &&PItem> = list.iter().map(|p| (p.id.as_str(), p)).collect();+ let mut keep = vec![];+ for p in ord.iter().rev().take(ord.len() / 2).filter_map(|id| at.get(id.as_str())) {+ out.looked += 1;+ match rule.doubt(&p.face, p.at, p.stratum == "copper") {+ Some(why) => *out.refused.entry(why).or_insert(0) += 1,+ None => { out.eligible += 1; *out.stated.entry(if p.stratum == "copper" { "bare" } else { "copper" }.to_string()).or_insert(0) += 1; if !used.contains(&p.id) { keep.push(copper_item(p.id.clone(), &p.face, p.at, p.stratum != "copper")); } }+ }+ }+ keep+ };+ let (mut wrong_bare, mut wrong_copper) = (pick("copper").into_iter(), pick("bare").into_iter());+ loop {+ let (a, b) = (wrong_bare.next(), wrong_copper.next());+ if a.is_none() && b.is_none() { break; }+ out.pool.extend(a); out.pool.extend(b); } out } +/// Whether what is left out hides the regions the flow reads worst. Those are, per face, the quarter of 5 mm tiles where the copy disagrees most+/// with the threshold scorer over plain mask. Pad boxes were out of the population before this rule, so the comparison is of the rule on what the pad boxes leave:+/// what share of the points lay in those tiles before it, and what share after. Written beside the sample, and stated on the sheet.+fn exclusion_report(c: &Ctx, planted: &Planted) -> Value {+ let census = c.census();+ let photo_rule = |l: &&LeftOut| l.why != PAD_BOX;+ let mut faces = serde_json::Map::new();+ let tile_of = |at: (f64, f64)| -> (usize, usize) { let (x, y) = board::to_px(at.0, at.1); (x.max(0.0) as usize / WORST_TILE_PX, y.max(0.0) as usize / WORST_TILE_PX) };+ let (mut before_all, mut after_all, mut in_before_all, mut in_after_all) = (0usize, 0usize, 0usize, 0usize);+ let grid = copper_grid(c);+ for face in ["top", "bottom"] {+ let (n, k) = (census.points.iter().filter(|p| p.face == face).count(), census.left_out.iter().filter(|l| l.face == face).count());+ let mut by = BTreeMap::new();+ for l in census.left_out.iter().filter(|l| l.face == face) { *by.entry(l.why).or_insert(0usize) += 1; }+ let mut d = json!({"candidates": n + k, "leftOut": k, "share": pct(k as f64, (n + k) as f64), "byMaterial": by});+ if let (Some((g, _)), Some(f), Some((copy, scorer))) = (&grid, c.face(face), c.reads().get(face)) {+ let plain = Mask { w: f.w, h: f.h, d: f.cls.iter().map(|k| *k == photomat::MASK).collect() }.erode(fidelity::BESIDE_PX);+ let (cols, rows) = (f.w / WORST_TILE_PX + 1, f.h / WORST_TILE_PX + 1);+ let (mut bad, mut all) = (vec![0usize; cols * rows], vec![0usize; cols * rows]);+ for y in 0..f.h { for x in 0..f.w {+ let i = y * f.w + x;+ if !plain.d[i] { continue; }+ let (mx, my) = board::to_mm(x as f64 + 0.5, y as f64 + 0.5);+ let (u, v) = (((mx - g.x0) * g.res) as i64, ((my - g.y0) * g.res) as i64);+ if u < 0 || v < 0 || u as usize >= g.w || v as usize >= g.h { continue; }+ let t = (y / WORST_TILE_PX) * cols + x / WORST_TILE_PX;+ all[t] += 1;+ if copy.d[v as usize * g.w + u as usize] != scorer.d[i] { bad[t] += 1; }+ } }+ // a tile needs a tenth of its area in plain mask to have a figure at all+ let mut tiles: Vec<(usize, usize, f64)> = (0..rows).flat_map(|r| (0..cols).map(move |k| (k, r))).filter(|(k, r)| all[r * cols + k] * 10 >= WORST_TILE_PX * WORST_TILE_PX).map(|(k, r)| (k, r, bad[r * cols + k] as f64 / all[r * cols + k] as f64)).collect();+ tiles.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));+ let worst: HashSet<(usize, usize)> = tiles.iter().take(tiles.len() / 4).map(|t| (t.0, t.1)).collect();+ let kept: Vec<(f64, f64)> = census.points.iter().filter(|p| p.face == face).map(|p| p.at).collect();+ let gone: Vec<(f64, f64)> = census.left_out.iter().filter(|l| l.face == face).filter(photo_rule).map(|l| l.at).collect();+ let (ka, ga) = (kept.iter().filter(|a| worst.contains(&tile_of(**a))).count(), gone.iter().filter(|a| worst.contains(&tile_of(**a))).count());+ d["worstReadTiles"] = json!({"tiles": worst.len(), "shareOfPointsBeforeTheRule": pct((ka + ga) as f64, (kept.len() + gone.len()) as f64), "shareOfPointsAfterTheRule": pct(ka as f64, kept.len() as f64), "ruleLeavesOutThere": pct(ga as f64, (ka + ga) as f64), "ruleLeavesOutElsewhere": pct((gone.len() - ga) as f64, (kept.len() + gone.len() - ka - ga) as f64)});+ before_all += kept.len() + gone.len(); after_all += kept.len(); in_before_all += ka + ga; in_after_all += ka;+ }+ faces.insert(face.into(), d);+ }+ let ans = |on: bool| { let (n, k) = (census.points.iter().filter(|p| (p.stratum == "copper") == on).count(), census.left_out.iter().filter(photo_rule).filter(|l| l.copy_has_copper == on).count()); json!({"pointsBeforeTheRule": n + k, "ruleLeavesOut": k, "share": pct(k as f64, (n + k) as f64)}) };+ let photo_out = census.left_out.iter().filter(photo_rule).count();+ let all = census.points.len() + census.left_out.len();+ json!({+ "rule": "a point goes when a part's pad box covers it, as before, or by what the photo shows: not plain mask within fidelity::BESIDE_PX, the rule `check fidelity` scores copper with. The copy's copper is no input.",+ "candidates": all, "leftOut": census.left_out.len(), "share": pct(census.left_out.len() as f64, all as f64),+ "photoRule": {"pointsBeforeTheRule": census.points.len() + photo_out, "leftOut": photo_out, "share": pct(photo_out as f64, (census.points.len() + photo_out) as f64)},+ "byMaterial": census.counts(), "byFace": faces, "byCopysAnswer": {"copper": ans(true), "bare": ans(false)},+ "worstReadTiles": {"meaning": "per face, the quarter of 5 mm tiles where the copy disagrees most with the threshold scorer over plain mask. If these hold a much smaller share of the points after the photo rule than before, the rule hides what the flow reads worst", "shareOfPointsBeforeTheRule": pct(in_before_all as f64, before_all as f64), "shareOfPointsAfterTheRule": pct(in_after_all as f64, after_all as f64)},+ "plantedClaims": {"pointsEligible": planted.eligible, "ofPointsLookedAt": planted.looked, "eligibleByWhatTheClaimStates": planted.stated, "refused": planted.refused, "rule": Planting::rule(), "meaning": "real points in the far half of each stratum's order whose true answer is plain to a person: only these can be stated falsely with confidence. `refused` counts the rest by the first test each failed"},+ })+}+ fn population(c: &Ctx, cat: &str) -> Vec<PItem> { let mut out = vec![]; match cat {@@ -272,47 +847,34 @@ fn population(c: &Ctx, cat: &str) -> Vec<PItem> { if it.shapes.is_empty() { it.shapes.push((rect_of(&Pad { part: r.into(), num: String::new(), x: it.at.0, y: it.at.1, w: 1.0, h: 1.0, rot: 0.0, round: false, drill: 0.0, layers: vec![], net: String::new() }), true)); } out.push(it); },- "copper" => out = copper_points(c),+ "copper" => out = c.census().points.clone(), "vias" => { let vias = arr(&c.copper["vias"]); let kept: Vec<(f64, f64)> = vias.iter().filter_map(|v| xy(&v["pos_mm"])).collect(); for v in &vias { let Some(at) = xy(&v["pos_mm"]) else { continue }; let body = "a real via at the ring's centre";- out.push(PItem { id: format!("via:{}", st(v, "id")), stratum: "kept".into(), class: "via".into(), subject: format!("via {}", st(v, "id")), body: body.into(), hash: h(&[&v["pos_mm"].to_string()]), face: via_face(v).into(), at, window: 4.0, ring: 0.6, shapes: vec![] });+ out.push(PItem { id: format!("via:{}", st(v, "id")), stratum: "kept".into(), class: "via".into(), subject: format!("via {}", st(v, "id")), body: body.into(), hash: h(&[&v["pos_mm"].to_string()]), face: via_face(v).into(), at, window: 4.0, ring: 0.6, shapes: vec![], other: vec![] }); } for d in &c.domes { let Some(at) = xy(&d["pos_mm"]) else { continue }; if kept.iter().any(|k| (k.0 - at.0).abs() < 0.3 && (k.1 - at.1).abs() < 0.3) { continue; } let (face, body) = (if st(d, "side") == "top" { "top" } else { "bottom" }, "NOT a via: the program dropped this candidate");- out.push(PItem { id: format!("dome:{face}:{}", d["pos_mm"]), stratum: "dropped".into(), class: "dropped".into(), subject: format!("{face} candidate"), body: body.into(), hash: h(&[body]), face: face.into(), at, window: 4.0, ring: 0.6, shapes: vec![] });+ out.push(PItem { id: format!("dome:{face}:{}", d["pos_mm"]), stratum: "dropped".into(), class: "dropped".into(), subject: format!("{face} candidate"), body: body.into(), hash: h(&[body]), face: face.into(), at, window: 4.0, ring: 0.6, shapes: vec![], other: vec![] }); } } "connectivity" => {- for n in &c.nets { for pin in arr(&n["pins"]).iter().filter_map(|x| x.as_str()) {- let Some((r, num)) = pin.rsplit_once('.') else { continue };- let Some(p) = c.parts.iter().find(|p| st(p, "ref") == r) else { continue };- let pad = c.pads.iter().find(|d| d.part == r && d.num == num);- let net = st(n, "name");- let mut it = part_item(c, p, "pin", format!("is on net {net}"), h(&[pin, net]));- it.id = format!("pin:{pin}"); it.subject = format!("pin {pin}"); it.class = net.to_string(); it.window = 6.0; it.ring = 0.0;- if let Some(d) = pad { it.at = (d.x, d.y); it.shapes = vec![(rect_of(d), true)]; }- out.push(it);- } }- for (key, pre) in [("zones", "zone"), ("traces", "trace")] { for z in arr(&c.copper[key]) {- let Some(face) = layer_face(st(&z, "layer")) else { continue };- let (pts, closed) = if key == "zones" { (points(&z["polygon_mm"]), true) } else { (points(&z["path_mm"]), false) };- if pts.len() < 2 { continue; }- let (x0, y0) = pts.iter().fold((f64::MAX, f64::MAX), |a, p| (a.0.min(p.0), a.1.min(p.1)));- let (x1, y1) = pts.iter().fold((f64::MIN, f64::MIN), |a, p| (a.0.max(p.0), a.1.max(p.1)));- let geo = if key == "zones" { z["polygon_mm"].to_string() } else { z["path_mm"].to_string() };- let net = st(&z, "net");- out.push(PItem { id: format!("{pre}:{}", st(&z, "id")), stratum: "island".into(), class: net.into(), subject: format!("outlined copper {}", st(&z, "id")), body: format!("is on net {net}"), hash: h(&[st(&z, "layer"), net, &geo]), face: face.into(), at: ((x0 + x1) / 2.0, (y0 + y1) / 2.0), window: ((x1 - x0).max(y1 - y0) + 4.0).clamp(6.0, 24.0), ring: 0.0, shapes: vec![(pts, closed)] });- } }+ // A pin's claim is the join of its pad to copper the copy drew, never its net: no crop shows a net. A pin with a net and no such join is put+ // forward as nothing: `pin_proof` counts it against the stratum.+ let joined = pin_joins(c);+ for (pin, n) in net_pins(c) { if let Some((cu, j)) = joined.get(pin).and_then(|l| pin_claimed(pin, l)) { out.push(pin_item(pin, st(n, "name"), cu, j)); } }+ // An island's claim is one join it is seen to make, never its net: no crop shows a net. An island with no join at all is put forward as+ // nothing: `island_proof` counts it against the stratum.+ for (cu, joins) in c.joins() { if let Some(j) = claimed(cu, joins) { out.push(join_item(cu, j)); } } } "layers" => {- if let Some(n) = c.layers["proposed"].as_u64() { out.push(PItem { id: "layers".into(), stratum: "all".into(), class: "count".into(), subject: "layer count".into(), body: format!("the board has {n} copper layers"), hash: h(&[&n.to_string()]), face: String::new(), at: (0.0, 0.0), window: 0.0, ring: 0.0, shapes: vec![] }); }- for p in arr(&c.layers["planes"]) { out.push(PItem { id: format!("plane:{}", st(&p, "layer")), stratum: "all".into(), class: "plane".into(), subject: format!("plane {}", st(&p, "layer")), body: format!("is a {} plane", st(&p, "net")), hash: h(&[st(&p, "layer"), st(&p, "net")]), face: String::new(), at: (0.0, 0.0), window: 0.0, ring: 0.0, shapes: vec![] }); }+ if let Some(n) = c.layers["proposed"].as_u64() { out.push(PItem { id: "layers".into(), stratum: "all".into(), class: "count".into(), subject: "layer count".into(), body: format!("the board has {n} copper layers"), hash: h(&[&n.to_string()]), face: String::new(), at: (0.0, 0.0), window: 0.0, ring: 0.0, shapes: vec![], other: vec![] }); }+ for p in arr(&c.layers["planes"]) { out.push(PItem { id: format!("plane:{}", st(&p, "layer")), stratum: "all".into(), class: "plane".into(), subject: format!("plane {}", st(&p, "layer")), body: format!("is a {} plane", st(&p, "net")), hash: h(&[st(&p, "layer"), st(&p, "net")]), face: String::new(), at: (0.0, 0.0), window: 0.0, ring: 0.0, shapes: vec![], other: vec![] }); } } _ => {} }@@ -340,7 +902,8 @@ fn derive_seed(cat: &str, pop_hash: &str, draw: u64) -> String { h(&[cat, pop_ha /// a real item's id, so a sheet does not show them apart from the rest. fn control(c: &Ctx, cat: &str, pop: &[PItem], r: &mut Rng) -> Option<PItem> { match cat {- "layers" => None,+ // planted by `copper_control_pool`+ "layers" | "copper" => None, "vias" => { let (g, inside) = copper_grid(c)?; let near: Vec<(f64, f64)> = arr(&c.copper["vias"]).iter().chain(c.domes.iter()).filter_map(|v| xy(&v["pos_mm"])).collect();@@ -360,30 +923,14 @@ fn control(c: &Ctx, cat: &str, pop: &[PItem], r: &mut Rng) -> Option<PItem> { } None }- "copper" => {- // deep inside a large pour, with copper in every direction: the claim of no copper is false- let (g, _) = copper_grid(c)?;- let zones: Vec<Value> = arr(&c.copper["zones"]).into_iter().filter(|z| z["areaMm2"].as_f64().unwrap_or(0.0) >= 20.0 && layer_face(st(z, "layer")).is_some()).collect();- if pop.is_empty() { return None; }- let base = &pop[r.below(pop.len())];- for _ in 0..50 {- if zones.is_empty() { return None; }- let z = &zones[r.below(zones.len())];- let m = kicad_out::zone_mask(&g, z, &points(&z["polygon_mm"]));- for _ in 0..400 {- let (u, v) = (r.below(g.w), r.below(g.h));- let reach = g.res as i64;- let all = [(0, 0), (reach, 0), (-reach, 0), (0, reach), (0, -reach)].iter().all(|(du, dv)| {- let (a, b) = (u as i64 + du, v as i64 + dv);- a >= 0 && b >= 0 && (a as usize) < g.w && (b as usize) < g.h && m.d[b as usize * g.w + a as usize]- });- if !all { continue; }- let mut it = base.clone();- let face = layer_face(st(z, "layer")).unwrap_or("top");- it.face = face.into(); it.at = g.mm(u as f64, v as f64); it.subject = format!("{face} point");- it.body = "the copy has NO COPPER at the ring's centre".into();- return Some(it);- }+ "connectivity" => {+ // A join stated in another claim's words proves nothing either way, so a false join has one end drawn where there is none. A pin's+ // as often as an island's, however many more islands there are: each stratum's claims are judged in their own right.+ for _ in 0..500 {+ let stratum = if r.below(2) == 0 { "pin" } else { "island" };+ let of: Vec<&PItem> = pop.iter().filter(|p| p.stratum == stratum).collect();+ if of.is_empty() { continue; }+ if let Some(k) = false_join(c, of[r.below(of.len())], r) { return Some(k); } } None }@@ -432,6 +979,7 @@ pub fn draw_sample(flow: &Path, cat: &str, n: Option<usize>, who: &str, target: let mut items_out: Vec<Value> = vec![]; let mut draw = 1u64; let mut extend = false;+ let mut archive = None; if let Some(o) = &old { let prev = o["draw"].as_u64().unwrap_or(1); let void = audit_state(&c, cat, &pop, target).status == "void";@@ -440,7 +988,7 @@ pub fn draw_sample(flow: &Path, cat: &str, n: Option<usize>, who: &str, target: draw = prev; items_out = arr(&o["items"]); } else {- for f in ["sample", "population"] { let _ = std::fs::rename(dir.join(format!("{f}.json")), dir.join(format!("{f}-{prev}.json"))); }+ archive = Some(prev); draw = prev + 1; } }@@ -463,7 +1011,27 @@ pub fn draw_sample(flow: &Path, cat: &str, n: Option<usize>, who: &str, target: let have_ctl = items_out.iter().filter(|i| is_control(&seed, i)).count(); let mut r = Rng(seed_u64(&seed, &format!("control{total_real}"))); let mut batch: Vec<(PItem, bool)> = new_real.iter().map(|p| ((*p).clone(), false)).collect();- for _ in have_ctl..controls_for(total_real) { if let Some(k) = control(&c, cat, &pop, &mut r) { batch.push((k, true)); } }+ let want_ctl = controls_for(total_real).saturating_sub(have_ctl);+ let mut planted = Planted::default();+ if cat == "copper" {+ // an id already on the sheet cannot come back under the other claim+ let used: HashSet<String> = items_out.iter().map(|i| st(i, "id").to_string()).chain(new_real.iter().map(|p| p.id.clone())).collect();+ planted = copper_control_pool(&c, &pop, &seed, &used);+ // too few places where a false claim is false beyond doubt: say so, and plant none that an honest auditor could call ok+ if planted.pool.len() < want_ctl {+ return Err(format!("copper: only {} of the {want_ctl} planted claims a sample of {total_real} needs can be placed where the photo is plain to a person ({} of {} points looked at pass; refused: {}). A sample with too few controls proves nothing and a doubtful control voids an honest audit, so none was drawn and nothing was changed",+ planted.pool.len(), planted.eligible, planted.looked, planted.refusals()));+ }+ batch.extend(planted.pool.iter().take(want_ctl).map(|k| (k.clone(), true)));+ } else {+ for _ in 0..want_ctl { if let Some(k) = control(&c, cat, &pop, &mut r) { batch.push((k, true)); } }+ }+ // the old sample is put away only now that the new one can be written+ if let Some(prev) = archive {+ for f in ["sample", "population"] { let _ = std::fs::rename(dir.join(format!("{f}.json")), dir.join(format!("{f}-{prev}.json"))); }+ // a smaller new sample must not leave the old sample's crops beside its own+ if let Ok(rd) = std::fs::read_dir(dir.join("crops")) { for e in rd.flatten() { let _ = std::fs::remove_file(e.path()); } }+ } for i in (1..batch.len()).rev() { batch.swap(i, r.below(i + 1)); } let mut next = items_out.len() + 1; for (p, ctl) in &batch {@@ -478,7 +1046,12 @@ pub fn draw_sample(flow: &Path, cat: &str, n: Option<usize>, who: &str, target: let doc = json!({"category": cat, "draw": draw, "seed": seed, "populationHash": pop_hash, "population": sizes, "counts": counts, "drawnBy": who, "drawnAt": ledger::now(), "items": items_out}); std::fs::write(dir.join("sample.json"), serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n").map_err(|e| e.to_string())?; let new_a: Vec<(String, &PItem)> = { let mut k = next - batch.len(); batch.iter().map(|(p, _)| { let a = format!("a{k:03}"); k += 1; (a, p) }).collect() };- write_crops(flow, cat, &new_a);+ if cat == "copper" {+ write_copper_crops(&c, &new_a);+ std::fs::write(dir.join("exclusions.json"), serde_json::to_string_pretty(&exclusion_report(&c, &planted)).map_err(|e| e.to_string())? + "\n").map_err(|e| e.to_string())?;+ } else {+ write_crops(flow, cat, &new_a);+ } write_sheets(flow, cat, &doc); Ok(doc) }@@ -501,20 +1074,148 @@ fn panel(src: &RgbImage, p: &PItem, size: u32, marks: bool) -> RgbImage { for v in 0..size { for u in 0..size { out.put_pixel(u, v, image::Rgb(sample_px(src, cx + (u as f64 - size as f64 / 2.0) * per, cy + (v as f64 - size as f64 / 2.0) * per))); } } if marks { let map = |q: (f64, f64)| { let (a, b) = board::to_px(q.0, q.1); (size as f64 / 2.0 + (a - cx) / per, size as f64 / 2.0 + (b - cy) / per) };- let mid = size as f64 / 2.0;- if p.ring > 0.0 {- let r = p.ring * board::RES / per;- draw::circle(&mut out, (mid, mid), r, [255, 230, 0], 1);- for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { draw::line(&mut out, (mid + dx * r, mid + dy * r), (mid + dx * (r + 14.0), mid + dy * (r + 14.0)), [255, 230, 0], 1); }- }+ if p.ring > 0.0 { mark_ring(&mut out, p.ring * board::RES / per); } for (pts, closed) in &p.shapes { let q: Vec<(f64, f64)> = pts.iter().map(|x| map(*x)).collect(); if *closed { draw::poly(&mut out, &q, [255, 60, 200], 1); } else { for w in q.windows(2) { draw::line(&mut out, w[0], w[1], [255, 60, 200], 1); } } }+ for (pts, closed) in &p.other {+ let q: Vec<(f64, f64)> = pts.iter().map(|x| map(*x)).collect();+ if *closed { draw::poly(&mut out, &q, OTHER_END, 1); } else { for w in q.windows(2) { draw::line(&mut out, w[0], w[1], OTHER_END, 1); } }+ } } out } +/// The yellow ring and its ticks at the middle of a panel. Nothing is drawn inside it.+fn mark_ring(out: &mut RgbImage, r: f64) {+ let mid = out.width() as f64 / 2.0;+ draw::circle(out, (mid, mid), r, [255, 230, 0], 1);+ for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { draw::line(out, (mid + dx * r, mid + dy * r), (mid + dx * (r + 14.0), mid + dy * (r + 14.0)), [255, 230, 0], 1); }+}++/// A face as the copper panels see it, and nothing else: the two photos of the face averaged and lightly blurred, because their grain is+/// independent sensor noise (see docs/method.md), and which pixels are plain mask. The copy is not an input, so no panel can carry its answer.+struct CopperView { raw: RgbImage, rgb: [Plane; 3], plain: Mask }++fn copper_view(flow: &Path, face: &str, f: &Face) -> Option<CopperView> {+ let raw = image::open(flow.join(board_file(face))).ok()?.to_rgb8();+ let second = image::open(flow.join(format!("board/{face}-second.png"))).ok().map(|i| i.to_rgb8()).filter(|i| i.dimensions() == raw.dimensions());+ let (w, h) = (raw.width() as usize, raw.height() as usize);+ let chan = |k: usize| -> Plane {+ let d = raw.pixels().enumerate().map(|(i, p)| match &second { Some(s) => (p[k] as f32 + s.get_pixel((i % w) as u32, (i / w) as u32)[k] as f32) / 2.0, None => p[k] as f32 }).collect();+ raster::blur(&Plane { w, h, d }, 1, 2)+ };+ let rgb = [chan(0), chan(1), chan(2)];+ let plain = Mask { w: f.w, h: f.h, d: f.cls.iter().map(|k| *k == photomat::MASK).collect() }.erode(fidelity::BESIDE_PX);+ Some(CopperView { raw, rgb, plain })+}++/// The plain mask pixels in the window round a point, or every pixel of the window when under 400 of it are plain.+fn window_px(v: &CopperView, at: (f64, f64), window: f64) -> Vec<usize> {+ let (cx, cy) = board::to_px(at.0, at.1);+ let half = window * board::RES / 2.0;+ let (w, h) = (v.plain.w as i64, v.plain.h as i64);+ let (x0, x1, y0, y1) = (((cx - half) as i64).clamp(0, w), ((cx + half) as i64).clamp(0, w), ((cy - half) as i64).clamp(0, h), ((cy + half) as i64).clamp(0, h));+ let pick = |only_plain: bool| -> Vec<usize> { let mut o = vec![]; for y in y0..y1 { for x in x0..x1 { let i = (y * w + x) as usize; if !only_plain || v.plain.d[i] { o.push(i); } } } o };+ let at_px = pick(true);+ if at_px.len() < 400 { pick(false) } else { at_px }+}++/// One channel's 3rd and 97th percentile over those pixels, with a span of at least 8 so flat mask stays flat instead of showing its grain.+fn level(v: &CopperView, px: &[usize], k: usize) -> (f32, f32) {+ let mut vals: Vec<f32> = px.iter().map(|i| v.rgb[k].d[*i]).collect();+ if vals.is_empty() { return (0.0, 255.0); }+ let mut at = |q: usize| { let n = (vals.len() - 1) * q / 100; *vals.select_nth_unstable_by(n, |a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)).1 };+ let (lo, hi) = (at(3), at(97));+ (lo, hi.max(lo + 8.0))+}++/// Each channel's levels over the plain mask in the window round a point, so silk, metal and parts cannot set the range. The same rule for every point.+fn stretch_levels(v: &CopperView, at: (f64, f64), window: f64) -> [(f32, f32); 3] {+ let px = window_px(v, at, window);+ [level(v, &px, 0), level(v, &px, 1), level(v, &px, 2)]+}++fn stretched_panel(v: &CopperView, at: (f64, f64), window: f64, levels: &[(f32, f32); 3], size: u32) -> RgbImage {+ let (cx, cy) = board::to_px(at.0, at.1);+ let per = window * board::RES / size as f64;+ let mut out = RgbImage::new(size, size);+ let (w, h) = (v.plain.w as f64, v.plain.h as f64);+ for b in 0..size { for a in 0..size {+ let (x, y) = (cx + (a as f64 - size as f64 / 2.0) * per, cy + (b as f64 - size as f64 / 2.0) * per);+ if x < 0.0 || y < 0.0 || x >= w - 1.0 || y >= h - 1.0 { continue; }+ let mut o = [0u8; 3];+ for k in 0..3 { let (lo, hi) = levels[k]; o[k] = (((v.rgb[k].sample(x as f32, y as f32) - lo) / (hi - lo)).clamp(0.0, 1.0) * 255.0) as u8; }+ out.put_pixel(a, b, image::Rgb(o));+ } }+ mark_ring(&mut out, 0.4 * board::RES / per);+ out+}++/// One copper crop, from the photos and the position only: a real item and a planted one get the same treatment because this+/// function cannot tell them apart. Top row, the raw photo and the stretched window; bottom row, the wider stretched view and the close one.+fn copper_crop(v: &CopperView, face: &str, at: (f64, f64)) -> RgbImage {+ let levels = stretch_levels(v, at, STRETCH_REF_MM);+ let wide = stretch_levels(v, at, COPPER_CONTEXT_MM);+ let it = copper_item(String::new(), face, at, true);+ let raw = panel(&v.raw, &it, CROP_PX, true);+ let mut all = RgbImage::new(CROP_PX * 2 + 8, CROP_PX * 2 + 8);+ image::imageops::replace(&mut all, &raw, 0, 0);+ image::imageops::replace(&mut all, &stretched_panel(v, at, COPPER_WINDOW_MM, &levels, CROP_PX), (CROP_PX + 8) as i64, 0);+ image::imageops::replace(&mut all, &stretched_panel(v, at, COPPER_CONTEXT_MM, &wide, CROP_PX), 0, (CROP_PX + 8) as i64);+ image::imageops::replace(&mut all, &stretched_panel(v, at, COPPER_ZOOM_MM, &levels, CROP_PX), (CROP_PX + 8) as i64, (CROP_PX + 8) as i64);+ all+}++fn save_jpeg(img: &RgbImage, path: &Path) -> Result<(), String> {+ let mut f = std::io::BufWriter::new(std::fs::File::create(path).map_err(|e| e.to_string())?);+ image::codecs::jpeg::JpegEncoder::new_with_quality(&mut f, 90).encode_image(img).map_err(|e| e.to_string())+}++fn write_copper_crops(c: &Ctx, list: &[(String, &PItem)]) {+ let dir = audit_dir(&c.flow, "copper").join("crops");+ for (face, view) in c.views() {+ for (a, p) in list.iter().filter(|(_, p)| p.face == *face) { let _ = save_jpeg(&copper_crop(view, face, p.at), &dir.join(format!("{a}.jpg"))); }+ }+}++/// Crops for looking at legibility, in a folder of the caller's and outside the audit: points of the current population that are not in the+/// sample's reach and not in the planted claims' reach, with the claims kept apart in `key.json`. No verdict can be recorded on them.+pub fn copper_preview(flow: &Path, n: usize, out: &Path) -> Result<usize, String> {+ let (Ok(a), Ok(b)) = (std::fs::canonicalize(flow.join("audits")), std::fs::create_dir_all(out).and_then(|_| std::fs::canonicalize(out))) else { return Err("cannot prepare the folders".into()) };+ if b.starts_with(&a) { return Err(format!("{} is inside the audit: a preview stays out of it", out.display())); }+ let c = Ctx::load(flow);+ let pop = population(&c, "copper");+ // the seed the next draw will take, or the current sample's if it is of this population, so these points stay clear of it+ let pop_hash = population_hash(&pop);+ let old = items::read_json(&audit_dir(flow, "copper").join("sample.json")).ok();+ let draw = old.as_ref().map(|o| { let d = o["draw"].as_u64().unwrap_or(1); if st(o, "populationHash") == pop_hash { d } else { d + 1 } }).unwrap_or(1);+ let seed = derive_seed("copper", &pop_hash, draw);+ let (mut key, mut made) = (vec![], 0usize);+ let mut picks: Vec<PItem> = vec![];+ for (s, list) in &strata(&pop) {+ let ids: Vec<String> = list.iter().map(|p| p.id.clone()).collect();+ let ord = order(&ids, &seed, s);+ let from = ord.len() / 3;+ for id in ord.iter().skip(from).take(n.div_ceil(2)) { if let Some(p) = list.iter().find(|p| &p.id == id) { picks.push((*p).clone()); } }+ }+ let mut r = Rng(seed_u64(&seed, "preview"));+ for i in (1..picks.len()).rev() { picks.swap(i, r.below(i + 1)); }+ for face in ["top", "bottom"] {+ let Some(view) = c.views().get(face) else { continue };+ for (i, p) in picks.iter().enumerate().filter(|(_, p)| p.face == face) {+ save_jpeg(&copper_crop(view, face, p.at), &out.join(format!("t{:02}.jpg", i + 1)))?;+ key.push(json!({"t": format!("t{:02}", i + 1), "face": p.face, "at_mm": [p.at.0, p.at.1], "claim": p.claim(), "stratum": p.stratum}));+ made += 1;+ }+ }+ std::fs::write(out.join("key.json"), serde_json::to_string_pretty(&key).map_err(|e| e.to_string())? + "\n").map_err(|e| e.to_string())?;+ let planted = copper_control_pool(&c, &pop, &seed, &HashSet::new());+ std::fs::write(out.join("exclusions.json"), serde_json::to_string_pretty(&exclusion_report(&c, &planted)).map_err(|e| e.to_string())? + "\n").map_err(|e| e.to_string())?;+ Ok(made)+}+ fn write_crops(flow: &Path, cat: &str, list: &[(String, &PItem)]) { let mut imgs: HashMap<&str, RgbImage> = HashMap::new(); for face in ["top", "bottom"] { if let Ok(i) = image::open(flow.join(board_file(face))) { imgs.insert(face, i.to_rgb8()); } }@@ -522,18 +1223,52 @@ fn write_crops(flow: &Path, cat: &str, list: &[(String, &PItem)]) { for (a, p) in list { let Some(src) = imgs.get(p.face.as_str()) else { continue }; let (raw, marked) = (panel(src, p, CROP_PX, false), panel(src, p, CROP_PX, true));- let mut both = RgbImage::new(CROP_PX * 2 + 8, CROP_PX);+ // a join gets a second row, close in on the place the two ends meet+ let join = !p.other.is_empty();+ let mut both = RgbImage::new(CROP_PX * 2 + 8, if join { CROP_PX * 2 + 8 } else { CROP_PX }); image::imageops::replace(&mut both, &raw, 0, 0); image::imageops::replace(&mut both, &marked, (CROP_PX + 8) as i64, 0);+ if join {+ // close enough to see the join, and wide enough to hold the pad or the via ring it is made to+ let mark: &[(f64, f64)] = if p.stratum == "pin" { &p.shapes[0].0 } else if p.class != "copper" { &p.other[0].0 } else { &[] };+ let across = 2.0 * mark.iter().map(|q| (q.0 - p.at.0).abs().max((q.1 - p.at.1).abs())).fold(0.0, f64::max) + 1.0;+ let close = PItem { window: across.clamp(JOIN_ZOOM_MM, p.window.max(JOIN_ZOOM_MM)), ..(*p).clone() };+ image::imageops::replace(&mut both, &panel(src, &close, CROP_PX, false), 0, (CROP_PX + 8) as i64);+ image::imageops::replace(&mut both, &panel(src, &close, CROP_PX, true), (CROP_PX + 8) as i64, (CROP_PX + 8) as i64);+ } let _ = both.save(dir.join(format!("{a}.jpg"))); } } +/// The copper sheet's instructions. The same for every item: nothing here depends on an item's answer.+fn copper_sheet_text(ex: &Value) -> String {+ let share = |v: &Value| format!("{:.1}%", v.as_f64().unwrap_or(0.0));+ let by: Vec<String> = ex["byMaterial"].as_object().into_iter().flatten().map(|(k, v)| format!("{k} {v}")).collect();+ format!("Each claim says what the photo shows under the solder mask at the centre of the yellow ring. Every crop has four panels, made the same way for every item from the photos alone:\n\n\+- **Top left:** the raw photo, 6 mm across.\n\+- **Top right:** the same 6 mm, stretched: the two photos of the face averaged, lightly blurred, each colour scaled between its 3rd and 97th percentile over plain mask in the 12 mm round the point. The sheets show this panel only; judge from the crops.\n\+- **Bottom left:** 18 mm across, stretched the same way over that window, to follow gap lines and tracks from the surrounding copper to the ring.\n\+- **Bottom right:** 3 mm across, on the same scale as the top right, for a centre close to an edge.\n\n\+How to read them: under the mask, copper is lighter and greener than bare board beside it, so compare the centre with the two sides of the nearest gap line, not with a fixed colour. The light changes across the board, and a surface can read dark in a dim area. A dark line with a thin bright line beside it is a raised copper edge. A dome at the centre is a via, which is copper.\n\n\+Answer `ok` if the panels show what the claim says, `wrong` if they show the other, and `cannot_tell` if they do not settle it, for instance when the centre lies on an edge and could be either side. `cannot_tell` counts against the score: do not guess. Some claims are planted and false.\n\n\+Points the photo cannot show copper at were left out before this sample was drawn: {} of {} ({}), by material {}. The tiles where the copy and a plain threshold read of the photo disagree most held {} of the points before the photo's materials were taken out and {} after.",+ ex["leftOut"], ex["candidates"], share(&ex["share"]), by.join(", "), share(&ex["worstReadTiles"]["shareOfPointsBeforeTheRule"]), share(&ex["worstReadTiles"]["shareOfPointsAfterTheRule"]))+}++/// The connectivity sheet's instructions. A copper claim states a join the crop shows, never a net.+const CONNECTIVITY_SHEET: &str = "Judge each claim from its crop, left raw and right marked; the sheets show the marked side only.\n\n\+Every claim states one join, and no claim says which net anything is on: you are not asked. Each crop has a second row, closer in on the join: 3 mm across, or wide enough to hold the pad. Pink is what the claim is about and blue is the other end.\n\n\+- **Pin claims** (`pad boxed in pink (pin R1.2) ...`): the pink box is the pin's pad where the copy read its metal, and blue outlines one piece of copper the copy drew; it may run on past the crop.\n\+- **Copper claims** (`copper outlined in pink ...`): pink outlines the copper, as the copy drew it. Blue is a box round a pad, a ring round a via, or the outline of other copper with a cross where the two meet.\n\n\+Judge that join at that place and nothing else. `ok` if the photo shows copper running from the one mark to the other with no gap line between them. `wrong` if a gap separates them, or if what a mark names is not there: no pad in the box, no via in the ring, no copper under the outline.\n\n\+Answer `cannot_tell` if the crop does not settle it. `cannot_tell` counts against the score: do not guess. Some claims are planted and false.";+ /// `sheet.md` (the claims, without saying which are controls) and contact sheets, 24 crops to a page. fn write_sheets(flow: &Path, cat: &str, doc: &Value) { let dir = audit_dir(flow, cat); let list = arr(&doc["items"]);- let mut md = format!("# Audit {cat}, sample {} (draw {})\n\nJudge each claim from its crop, left raw and right marked; the sheets show the marked side only. Answer `ok` if the claim is right, `wrong` if it is not, `cannot_tell` if the crop does not settle it. `cannot_tell` counts against the score: do not guess. Some claims are planted and false.\n\n| Item | Claim | Crop |\n|---|---|---|\n", st(doc, "seed"), doc["draw"]);+ let how = if cat == "copper" { copper_sheet_text(&items::read_json(&dir.join("exclusions.json")).unwrap_or(json!({}))) } else if cat == "connectivity" { CONNECTIVITY_SHEET.to_string() } else { "Judge each claim from its crop, left raw and right marked; the sheets show the marked side only. Answer `ok` if the claim is right, `wrong` if it is not, `cannot_tell` if the crop does not settle it. `cannot_tell` counts against the score: do not guess. Some claims are planted and false.".to_string() };+ let mut md = format!("# Audit {cat}, sample {} (draw {})\n\n{how}\n\n| Item | Claim | Crop |\n|---|---|---|\n", st(doc, "seed"), doc["draw"]); for it in &list { md += &format!("| {} | {} | {} |\n", st(it, "a"), st(it, "claim"), st(it, "crop")); } let _ = std::fs::write(dir.join("sheet.md"), md); let cells: Vec<&Value> = list.iter().filter(|i| !st(i, "crop").is_empty()).collect();@@ -675,6 +1410,8 @@ fn audit_state(c: &Ctx, cat: &str, pop: &[PItem], target: f64) -> AuditState { let mut low = f64::MAX; let mut stale_any = 0usize; let mut pin_ok_outside = (0usize, 0usize, 0usize);+ // islands and pins with no join a crop can show were never put forward as claims: unproven, so they count as not correct+ let proof: BTreeMap<&str, (usize, usize)> = if cat == "connectivity" { let p = c.pin_proof(); [("island", c.island_proof()), ("pin", (p.0, p.1))].into() } else { BTreeMap::new() }; for (s, cur) in &by { let drawn_hashes: HashMap<&str, &str> = drawn.iter().filter(|d| &d.0 == s).map(|d| (d.1.as_str(), d.2.as_str())).collect(); let unchanged = cur.iter().filter(|p| drawn_hashes.get(p.id.as_str()) == Some(&p.hash.as_str())).count();@@ -689,9 +1426,11 @@ fn audit_state(c: &Ctx, cat: &str, pop: &[PItem], target: f64) -> AuditState { } if s == "pin" { pin_ok_outside = (outside, n, cur.len()); } let lb = wilson_lower(k, n, cur.len());- let value = 100.0 * lb * share;+ let (with, all) = proof.get(s.as_str()).copied().unwrap_or((1, 1));+ let value = 100.0 * lb * share * with as f64 / all.max(1) as f64; let need = required_n(target, 0, cur.len()); out.strata.push(json!({"stratum": s, "population": cur.len(), "audited": n, "ok": k, "staleVerdicts": stale, "unchangedShare": (share * 1000.0).round() / 1000.0, "wilson95Lower": (lb * 1000.0).round() / 1000.0, "value": floor1(value), "needForTarget": need}));+ if proof.contains_key(s.as_str()) { let row = out.strata.last_mut().unwrap(); row["unproven"] = json!(all - with); row[if s == "pin" { "pins" } else { "islands" }] = json!(all); } if n == 0 { stale_any += stale.max(1); low = low.min(0.0); continue; } if stale > 0 || share < 1.0 { stale_any += stale.max(1); } if n < need { out.held = true; }@@ -700,7 +1439,14 @@ fn audit_state(c: &Ctx, cat: &str, pop: &[PItem], target: f64) -> AuditState { if stale_any > 0 { out.held = true; } if out.strata.iter().all(|x| x["audited"].as_u64() == Some(0)) { return AuditState { seed, done, total, ..none("stale", format!("every verdict in sample {} is on an item that has changed", st(&doc, "seed"))) }; } if stale_any > 0 { out.status = "scored, partly stale".into(); }- out.outside_pins = (pin_ok_outside.1 > 0).then(|| 100.0 * wilson_lower(pin_ok_outside.0, pin_ok_outside.1, pin_ok_outside.2));+ // and when not one island, or not one pin, has a join, there is no such stratum to hold the score down: say so, at nothing+ for (s, (_, all)) in proof.iter().filter(|p| p.1.0 == 0 && p.1.1 > 0) {+ out.strata.push(json!({"stratum": s, "population": 0, "audited": 0, "ok": 0, "staleVerdicts": 0, "unchangedShare": 1.0, "wilson95Lower": 0.0, "value": 0.0, "needForTarget": 0, "unproven": all, if *s == "pin" { "pins" } else { "islands" }: all}));+ low = 0.0;+ }+ // outside evidence on the pins judged says nothing of the pins that were never put forward+ let judged = proof.get("pin").map(|p| p.0 as f64 / p.1.max(1) as f64).unwrap_or(1.0);+ out.outside_pins = (pin_ok_outside.1 > 0).then(|| 100.0 * judged * wilson_lower(pin_ok_outside.0, pin_ok_outside.1, pin_ok_outside.2)); out.value = Some(low); out.detail = out.strata.iter().map(|x| format!("{} {} of {} ok from {} (bound {:.1}%, unchanged {:.0}%)", x["stratum"].as_str().unwrap_or(""), x["ok"], x["audited"], x["population"], x["wilson95Lower"].as_f64().unwrap_or(0.0) * 100.0, x["unchangedShare"].as_f64().unwrap_or(0.0) * 100.0)).collect::<Vec<_>>().join("; "); out@@ -874,6 +1620,7 @@ fn measured_ceilings(c: &Ctx, cat: &str, audit: &AuditState) -> Vec<Ceil> { if len(false) > 0.0 { v.push(Ceil { name: "copper with a net, by length".into(), value: pct(len(true), len(false)), inputs: format!("{:.0} of {:.0} mm of track has a net", len(true), len(false)), raise: "attribute the tracks of unknown net".into() }); } if joined > 0.0 { v.push(Ceil { name: "pins that agree with their copper".into(), value: pct(joined - clashes, joined), inputs: format!("{clashes:.0} of {joined:.0} joined pins disagree with the copper they touch; via net clashes: {}", rep["viaClashes"].as_u64().or_else(|| c.measured["viaClashes"].as_array().map(|a| a.len() as u64)).unwrap_or(0)), raise: "settle each pin clash: a wrong net or a misread island".into() }); } if let Some(o) = kicad_open { v.push(Ceil { name: "connections KiCad finds closed".into(), value: pct(needed - o, needed), inputs: format!("{o:.0} of {needed:.0} missing per KiCad at the proposed stackup"), raise: "close what the copy leaves open".into() }); }+ else if cand.as_ref().map(|k| k["drcError"].is_string()).unwrap_or(false) { v.push(Ceil { name: "connections KiCad finds closed".into(), value: 0.0, inputs: "KiCad's DRC did not run at the proposed stackup: unknown, which counts as none closed".into(), raise: "run `connect route` again when service-kicad answers".into() }); } v } "layers" => {@@ -894,8 +1641,17 @@ fn measured_ceilings(c: &Ctx, cat: &str, audit: &AuditState) -> Vec<Ceil> { fn score_one(c: &Ctx, cat: &str, target: f64) -> CatScore { let pop = population(c, cat);- let audit = audit_state(c, cat, &pop, target);- let cap = if cat == "layers" { PHOTO_ONLY_CAP } else { NO_AUDIT_CAP };+ let mut audit = audit_state(c, cat, &pop, target);+ if cat == "copper" { audit.detail = format!("{}; {}", audit.detail, c.census().line()); }+ // what no photo audit can reach: said in every state of the audit, and no cap may sit above it+ let mut reach = 1.0f64;+ if cat == "connectivity" {+ let ((with, all), (pw, pa, cited)) = (c.island_proof(), c.pin_proof());+ if with < all { audit.detail = format!("{}; {} of {all} islands show no join to a pad, a via or other copper, so they are unproven and count as not correct (the island figure is multiplied by {:.3})", audit.detail, all - with, with as f64 / all as f64); }+ if pw < pa { audit.detail = format!("{}; {} of {pa} pins with a net show no join of their pad to copper the copy drew, so they are unproven and count as not correct (the pin figure is multiplied by {:.3}); {cited} of those {} are on a net that cites a source outside the photos, which no photo confirms", audit.detail, pa - pw, pw as f64 / pa as f64, pa - pw); }+ for (w, a) in [(with, all), (pw, pa)] { if a > 0 { reach = reach.min(w as f64 / a as f64); } }+ }+ let cap = (if cat == "layers" { PHOTO_ONLY_CAP } else { NO_AUDIT_CAP }).min(100.0 * reach); let audit_raise = match audit.status.as_str() { "none" if cat == "layers" => "attach outside evidence with `audit verdict --evidence`".to_string(), "none" => format!("run the audit: `audit draw --category {cat}`, then an independent verdict on every crop"),@@ -910,7 +1666,7 @@ fn score_one(c: &Ctx, cat: &str, target: f64) -> CatScore { let value = audit.value.unwrap_or(cap); let audit_inputs = match &audit.value { Some(_) => format!("{}: {}", audit.status, audit.detail),- None => format!("no independent evidence ({}: {}): capped at {cap}", audit.status, audit.detail),+ None => format!("no independent evidence ({}: {}): capped at {}", audit.status, audit.detail, floor1(cap)), }; let mut ceilings = vec![Ceil { name: "independent audit".into(), value, inputs: audit_inputs, raise: audit_raise }]; ceilings.extend(measured_ceilings(c, cat, &audit));@@ -1083,6 +1839,13 @@ mod tests { let nets = vec![json!({"name": "A", "tag": "SEEN", "pins": refs[..100].iter().map(|r| format!("{r}.1")).collect::<Vec<_>>()}), json!({"name": "B", "tag": "SEEN", "pins": refs[100..].iter().map(|r| format!("{r}.1")).collect::<Vec<_>>()})]; write(&d, "nets.json", &json!({"nets": nets})); write(&d, "detect/copper-measured.json", &json!({"report": {"pinsJoined": 200, "pinClashes": 0}}));+ // what `connect attribute` would hold: each pour is one piece, with a via sitting in it+ write(&d, "detect/attribution.json", &json!({"pieces": [{"piece": 0, "side": "top", "conductor": 0, "node": 0, "items": ["z1"]}, {"piece": 1, "side": "bottom", "conductor": 1, "node": 1, "items": ["z2"]}],+ "vias": {"v20": {"valid": true, "topConductor": 0, "bottomConductor": null}, "v5": {"valid": true, "topConductor": 0, "bottomConductor": 1}},+ "padStates": refs.iter().enumerate().map(|(i, r)| json!({"pin": format!("{r}.1"), "side": "top", "x": 101.0 + (i % 20) as f64 * 0.9, "y": 121.0 + (i / 20) as f64 * 0.9})).collect::<Vec<_>>()}));+ // and each pin's pad is read on the top pour, with nothing between them+ write(&d, "detect/connect-graph.json", &json!({"contacts": refs.iter().map(|r| json!({"pin": format!("{r}.1"), "node": 0, "tier": 1, "kind": "pour", "side": "top"})).collect::<Vec<_>>()}));+ write(&d, "detect/attrib/measure.json", &json!({"pads": refs.iter().enumerate().map(|(i, r)| json!({"part": r, "num": "1", "core": {"x": 101.0 + (i % 20) as f64 * 0.9, "y": 121.0 + (i / 20) as f64 * 0.9, "w": 0.6, "h": 0.6, "rot": 0.0}})).collect::<Vec<_>>()})); write(&d, "layers.json", &json!({"proposed": 2, "candidates": [{"layers": 2, "open": 0, "connections": 0, "kicadOpen": 0}], "planes": []})); write(&d, "fidelity.json", &json!({"categories": {"copper": {"score": 97.0}, "vias": {"score": 97.0}, "placement": {"score": 97.0}}})); d@@ -1140,6 +1903,7 @@ mod tests { #[test] fn an_honest_full_audit_reaches_the_target_and_only_then() { let d = fixture("honest");+ two_level_photos(&d); for k in CATEGORIES { if k != "layers" { audit_honestly(&d, k, None); } } // layers need outside evidence: no verdict without a file, and none from the run's own photos let doc = draw_sample(&d, "layers", None, "tester", DEFAULT_TARGET).unwrap();@@ -1314,6 +2078,7 @@ mod tests { #[test] fn overall_is_the_lowest_and_a_loop_stalls_when_nothing_rises() { let d = fixture("stall");+ two_level_photos(&d); for k in ["identity", "values", "blocks", "placement", "copper", "vias", "connectivity"] { audit_honestly(&d, k, None); } let s = score_all(&d, DEFAULT_TARGET); assert!(s.cats.iter().filter(|c| c.category != "layers").all(|c| c.score >= 90.0), "{}", table(&s));@@ -1347,6 +2112,7 @@ mod tests { #[test] fn fidelity_limits_the_layers_scores_and_its_absence_caps_them() { let d = fixture("fidelity");+ two_level_photos(&d); for k in ["copper", "vias", "placement"] { audit_honestly(&d, k, None); } for k in ["copper", "vias", "placement"] { assert!(score(&d, k) >= 90.0, "{k}"); } // a low figure limits, whatever the audit says@@ -1373,6 +2139,447 @@ mod tests { std::fs::remove_dir_all(&d).ok(); } + /// The two faces of the fixture lit as the copy says: copper lighter than bare board, top lower half and bottom upper half.+ fn two_level_photos(d: &Path) {+ let (cu, bare) = (image::Rgb([30, 95, 45]), image::Rgb([18, 45, 28]));+ RgbImage::from_fn(1600, 1600, |_, y| if y >= 800 { cu } else { bare }).save(d.join("board/top.jpg")).unwrap();+ RgbImage::from_fn(1600, 1600, |_, y| if y < 800 { cu } else { bare }).save(d.join("board/bottom.jpg")).unwrap();+ }++ #[test]+ fn points_the_photo_cannot_show_are_left_out_by_material_and_reported() {+ let d = fixture("materials");+ write(&d, "parts.json", &json!({"parts": []}));+ let c = Ctx::load(&d);+ let before = c.census().points.len();+ assert!(c.census().left_out.is_empty(), "plain mask everywhere: {}", c.census().line());+ // a block of silk and a square of exposed gold on the bottom: everything else is plain mask+ let mut img = RgbImage::from_pixel(1600, 1600, image::Rgb([20, 60, 30]));+ for y in 300..500 { for x in 300..700 { img.put_pixel(x, y, image::Rgb([210, 215, 235])); } }+ for y in 900..1100 { for x in 900..1100 { img.put_pixel(x, y, image::Rgb([210, 170, 60])); } }+ img.save(d.join("board/bottom.jpg")).unwrap();+ let c = Ctx::load(&d);+ let n = c.census();+ assert!(n.left_out.iter().all(|l| l.face == "bottom"));+ let by = n.counts();+ assert!(by["silk"] >= 40, "{}", n.line());+ assert!(by["exposed metal"] >= 15, "{}", n.line());+ assert_eq!(n.points.len() + n.left_out.len(), before, "a point left out stays counted");+ for p in &n.points { if p.face == "bottom" { let (x, y) = board::to_px(p.at.0, p.at.1); assert!(!(x > 310.0 && x < 690.0 && y > 310.0 && y < 490.0), "{:?} lies on the silk", p.at); } }+ assert!(n.line().contains("left out") && n.line().contains("silk"));+ let s = score_all(&d, DEFAULT_TARGET);+ assert!(s.cats.iter().find(|x| x.category == "copper").unwrap().audit.detail.contains("left out"), "the score says what was left out");+ std::fs::remove_dir_all(&d).ok();+ }++ #[test]+ fn what_is_left_out_does_not_depend_on_the_copys_copper() {+ let d = fixture("nodepend");+ let mut img = RgbImage::from_pixel(1600, 1600, image::Rgb([20, 60, 30]));+ for y in 300..500 { for x in 300..700 { img.put_pixel(x, y, image::Rgb([210, 215, 235])); } }+ img.save(d.join("board/bottom.jpg")).unwrap();+ let ids = |d: &Path| { let c = Ctx::load(d); let mut l: Vec<String> = c.census().left_out.iter().map(|l| format!("{} {:?} {}", l.face, l.at, l.why)).collect(); l.sort(); l };+ let first = ids(&d);+ assert!(!first.is_empty());+ // the copy loses all its copper: answers change, what is left out does not+ let mut c = read(&d, "copper.json");+ c["zones"] = json!([]);+ c["vias"] = json!([]);+ write(&d, "copper.json", &c);+ assert_eq!(ids(&d), first);+ // and dark mask stays in: only what is not mask goes+ let dark = RgbImage::from_pixel(1600, 1600, image::Rgb([6, 22, 12]));+ dark.save(d.join("board/top.jpg")).unwrap();+ let c = Ctx::load(&d);+ assert!(c.census().points.iter().any(|p| p.face == "top"), "a dim face stays in the population");+ std::fs::remove_dir_all(&d).ok();+ }++ #[test]+ fn claims_say_what_the_photo_shows_and_the_sheet_explains_the_panels() {+ let d = fixture("wording");+ two_level_photos(&d);+ let doc = draw_sample(&d, "copper", Some(20), "tester", DEFAULT_TARGET).unwrap();+ for it in arr(&doc["items"]) { assert!(st(&it, "claim").contains("the photo shows"), "{}", st(&it, "claim")); assert!(!st(&it, "claim").contains("the copy")); }+ let md = std::fs::read_to_string(d.join("audits/copper/sheet.md")).unwrap();+ assert!(md.contains("Top right") && md.contains("Bottom left") && md.contains("cannot_tell") && md.contains("left out"));+ assert!(d.join("audits/copper/exclusions.json").is_file());+ std::fs::remove_dir_all(&d).ok();+ }++ #[test]+ fn copper_crops_come_from_the_photos_and_the_position_alone() {+ let d = fixture("panels");+ two_level_photos(&d);+ let doc = draw_sample(&d, "copper", Some(20), "tester", DEFAULT_TARGET).unwrap();+ // the copy changes completely: a crop rebuilt from the photos and the position alone is the same file, real or planted+ let mut k = read(&d, "copper.json");+ k["zones"] = json!([]);+ write(&d, "copper.json", &k);+ let c = Ctx::load(&d);+ let (top, bottom) = (copper_view(&d, "top", c.face("top").unwrap()).unwrap(), copper_view(&d, "bottom", c.face("bottom").unwrap()).unwrap());+ for it in arr(&doc["items"]) {+ let at = xy(&it["at_mm"]).unwrap();+ let f = d.join("again.jpg");+ save_jpeg(&copper_crop(if st(&it, "face") == "top" { &top } else { &bottom }, st(&it, "face"), at), &f).unwrap();+ assert_eq!(std::fs::read(&f).unwrap(), std::fs::read(d.join(st(&it, "crop"))).unwrap(), "{}", st(&it, "a"));+ }+ std::fs::remove_dir_all(&d).ok();+ }++ #[test]+ fn stretch_levels_ignore_what_is_not_mask_and_keep_flat_mask_flat() {+ let d = fixture("levels");+ let mut img = RgbImage::from_pixel(1600, 1600, image::Rgb([20, 60, 30]));+ for y in 700..760 { for x in 700..760 { img.put_pixel(x, y, image::Rgb([240, 240, 245])); } }+ img.save(d.join("board/top.jpg")).unwrap();+ let c = Ctx::load(&d);+ let v = copper_view(&d, "top", c.face("top").unwrap()).unwrap();+ let l = stretch_levels(&v, (118.0, 118.0), STRETCH_REF_MM);+ for k in 0..3 { assert!(l[k].1 - l[k].0 <= 14.0, "a bright block sets no level: {l:?}"); assert!(l[k].1 - l[k].0 >= 8.0, "{l:?}"); }+ std::fs::remove_dir_all(&d).ok();+ }++ #[test]+ fn planted_copper_claims_are_real_points_stated_the_other_way_round() {+ let d = fixture("planted");+ two_level_photos(&d);+ write(&d, "parts.json", &json!({"parts": []}));+ let doc = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap();+ let seed = st(&doc, "seed").to_string();+ let c = Ctx::load(&d);+ let pop = population(&c, "copper");+ let list = arr(&doc["items"]);+ let ctl: Vec<&Value> = list.iter().filter(|i| is_control(&seed, i)).collect();+ assert!(ctl.len() >= 3);+ let real: HashSet<&str> = list.iter().filter(|i| !is_control(&seed, i)).map(|i| st(i, "id")).collect();+ for it in &ctl {+ let p = pop.iter().find(|p| p.id == st(it, "id")).expect("a control is a point of the population");+ assert_eq!(xy(&it["at_mm"]).unwrap(), p.at, "at the population's own position, so its coordinates show nothing");+ assert_eq!(st(it, "face"), p.face);+ assert_ne!(st(it, "stratum"), p.stratum, "its claim is the other answer");+ assert_eq!(st(it, "hash"), copper_item(String::new(), &p.face, p.at, st(it, "stratum") == "copper").hash, "its record reads as a real item of that claim");+ assert!(st(it, "claim").ends_with(if st(it, "stratum") == "copper" { COPPER_CLAIM } else { BARE_CLAIM }));+ assert!(!real.contains(st(it, "id")), "a point is on the sheet once");+ }+ let ways: HashSet<&str> = ctl.iter().map(|i| st(i, "stratum")).collect();+ assert_eq!(ways.len(), 2, "planted both ways, so answering one claim ok every time is caught: {ways:?}");+ let ids: HashSet<&str> = ctl.iter().map(|i| st(i, "id")).collect();+ assert_eq!(ids.len(), ctl.len());+ // calling one ok voids the sample; calling them wrong or cannot_tell does not+ let ans: Vec<Answer> = list.iter().map(|it| Answer { a: st(it, "a").into(), verdict: if is_control(&seed, it) { "cannot_tell" } else { "ok" }.into(), note: String::new() }).collect();+ assert!(record_verdicts(&d, "copper", &seed, &ans, "auditor", None, None).unwrap().is_empty());+ let cu = score_all(&d, DEFAULT_TARGET);+ let cu = cu.cats.iter().find(|x| x.category == "copper").unwrap();+ assert!(cu.audit.status.starts_with("scored"), "{}", cu.audit.status);+ std::fs::remove_dir_all(&d).ok();+ let d = fixture("planted2");+ two_level_photos(&d);+ let doc = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap();+ let seed = st(&doc, "seed").to_string();+ let stamp: Vec<Answer> = arr(&doc["items"]).iter().map(|it| Answer { a: st(it, "a").into(), verdict: "ok".into(), note: String::new() }).collect();+ assert!(record_verdicts(&d, "copper", &seed, &stamp, "auditor", None, None).unwrap().is_empty());+ assert_eq!(score_all(&d, DEFAULT_TARGET).cats.iter().find(|x| x.category == "copper").unwrap().audit.status, "void");+ std::fs::remove_dir_all(&d).ok();+ }++ #[test]+ fn cannot_tell_still_counts_against_the_copper_score() {+ let d = fixture("cannot");+ two_level_photos(&d);+ let doc = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap();+ let seed = st(&doc, "seed").to_string();+ let ans: Vec<Answer> = arr(&doc["items"]).iter().enumerate().map(|(i, it)| Answer { a: st(it, "a").into(), verdict: if is_control(&seed, it) { "wrong" } else if i % 2 == 0 { "cannot_tell" } else { "ok" }.into(), note: String::new() }).collect();+ assert!(record_verdicts(&d, "copper", &seed, &ans, "auditor", None, None).unwrap().is_empty());+ assert!(score(&d, "copper") < 70.0, "{}", score(&d, "copper"));+ std::fs::remove_dir_all(&d).ok();+ }++ #[test]+ fn a_preview_stays_out_of_the_audit() {+ let d = fixture("preview");+ two_level_photos(&d);+ let doc = draw_sample(&d, "copper", Some(20), "tester", DEFAULT_TARGET).unwrap();+ let before = std::fs::read(d.join("audits/copper/sample.json")).unwrap();+ assert!(copper_preview(&d, 6, &d.join("audits/copper/x")).unwrap_err().contains("inside the audit"));+ assert_eq!(copper_preview(&d, 6, &d.join("tune")).unwrap(), 6);+ assert_eq!(std::fs::read(d.join("audits/copper/sample.json")).unwrap(), before);+ assert!(!d.join("audits/copper/verdicts.jsonl").exists());+ let key = read(&d, "tune/key.json");+ let sampled: HashSet<String> = arr(&doc["items"]).iter().map(|i| format!("{}", i["at_mm"])).collect();+ for k in arr(&key) { assert!(!sampled.contains(&format!("{}", k["at_mm"])), "preview points are not in the sample"); }+ std::fs::remove_dir_all(&d).ok();+ }++ #[test]+ fn a_planted_copper_claim_goes_only_where_the_photo_is_plain_to_a_person() {+ let d = fixture("plain");+ two_level_photos(&d);+ // on the bottom, inside the copper: a patch as green as copper and as little red as bare board, and beside it one half way between the two in green+ let mut img = image::open(d.join("board/bottom.jpg")).unwrap().to_rgb8();+ for y in 560..720 { for x in 320..480 { img.put_pixel(x, y, image::Rgb([18, 95, 45])); } }+ for y in 560..720 { for x in 1040..1200 { img.put_pixel(x, y, image::Rgb([24, 72, 37])); } }+ img.save(d.join("board/bottom.jpg")).unwrap();+ let c = Ctx::load(&d);+ let rule = Planting::new(&c).unwrap();+ // plain copper and plain bare board, each with the other in view, clear of everything+ assert_eq!(rule.doubt("top", (110.3, 123.3), true), None);+ assert_eq!(rule.doubt("top", (110.3, 116.7), false), None);+ assert_eq!(rule.doubt("bottom", (120.3, 116.7), true), None);+ // and every way a point can be less than plain+ assert_eq!(rule.doubt("top", (100.6, 123.3), true), Some("near the board edge"));+ assert_eq!(rule.doubt("top", (125.4, 123.5), true), Some("near a via or a via candidate"));+ assert_eq!(rule.doubt("top", (110.3, 114.6), false), Some("near a part"));+ assert_eq!(rule.doubt("top", (110.3, 120.3), true), Some("the copy has the other surface nearby"), "0.3 mm from the edge of the copper: inside the ring");+ assert_eq!(rule.doubt("bottom", (110.3, 105.3), true), Some("only one surface in view to compare with"));+ assert_eq!(rule.doubt("bottom", (110.0, 116.0), true), Some("not the colour of the surface it is, in red, green and blue alike"), "the copy and the scorer both read copper here, and a person would not be sure");+ assert!(rule.doubt("bottom", (128.0, 116.0), true).is_some(), "half way between the two surfaces is not plain");+ // silk beside the point+ let mut img = image::open(d.join("board/top.jpg")).unwrap().to_rgb8();+ for y in 900..960 { for x in 420..480 { img.put_pixel(x, y, image::Rgb([225, 228, 240])); } }+ img.save(d.join("board/top.jpg")).unwrap();+ let c = Ctx::load(&d);+ let rule = Planting::new(&c).unwrap();+ assert_eq!(rule.doubt("top", (110.3, 123.3), true), Some("silk, metal, a hole or a part body nearby"));+ // a draw plants only where the rule finds nothing, still both ways round, and as many as before+ let doc = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap();+ let seed = st(&doc, "seed").to_string();+ let list = arr(&doc["items"]);+ let ctl: Vec<&Value> = list.iter().filter(|i| is_control(&seed, i)).collect();+ assert_eq!(ctl.len(), controls_for(list.len() - ctl.len()));+ for it in &ctl { assert_eq!(rule.doubt(st(it, "face"), xy(&it["at_mm"]).unwrap(), st(it, "stratum") != "copper"), None, "{}", st(it, "a")); }+ assert_eq!(ctl.iter().map(|i| st(i, "stratum")).collect::<HashSet<_>>().len(), 2);+ let ex = read(&d, "audits/copper/exclusions.json");+ let planted = &ex["plantedClaims"];+ assert!(planted["pointsEligible"].as_u64().unwrap() >= ctl.len() as u64 && planted["refused"].as_object().unwrap().len() >= 3, "{planted}");+ assert_eq!(planted["eligibleByWhatTheClaimStates"]["bare"].as_u64().unwrap() + planted["eligibleByWhatTheClaimStates"]["copper"].as_u64().unwrap(), planted["pointsEligible"].as_u64().unwrap());+ std::fs::remove_dir_all(&d).ok();+ }++ #[test]+ fn a_draw_that_cannot_plant_plain_controls_refuses_and_changes_nothing() {+ // one flat surface: nowhere is a false claim false beyond doubt+ let d = fixture("noplain");+ let e = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap_err();+ assert!(e.contains("planted claims") && e.contains("plain to a person") && e.contains("only one surface in view"), "{e}");+ assert!(!d.join("audits/copper/sample.json").exists());+ // with a sample in hand, a draw that cannot be made leaves it and its crops alone+ two_level_photos(&d);+ let doc = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap();+ let before = std::fs::read(d.join("audits/copper/sample.json")).unwrap();+ let mut k = read(&d, "copper.json");+ k["zones"][0]["polygon_mm"] = json!([[100, 124], [140, 124], [140, 140], [100, 140]]);+ write(&d, "copper.json", &k);+ RgbImage::from_pixel(1600, 1600, image::Rgb([20, 60, 30])).save(d.join("board/top.jpg")).unwrap();+ RgbImage::from_pixel(1600, 1600, image::Rgb([20, 60, 30])).save(d.join("board/bottom.jpg")).unwrap();+ assert!(draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap_err().contains("nothing was changed"));+ assert_eq!(std::fs::read(d.join("audits/copper/sample.json")).unwrap(), before);+ assert!(!d.join("audits/copper/sample-1.json").exists());+ assert!(d.join(st(&arr(&doc["items"])[0], "crop")).is_file());+ std::fs::remove_dir_all(&d).ok();+ }++ /// A board of its own for the joins: a pour with a via in it and a sliver stitched to it, a track that ends on a pad, a pour whose pad is far+ /// from it, and a pour that touches nothing. A chip's body lies on the top photo.+ fn join_fixture(name: &str) -> PathBuf {+ let d = fixture(name);+ let sq = |x0: f64, y0: f64, x1: f64, y1: f64| json!([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]);+ write(&d, "copper.json", &json!({"outline": {"points_mm": [[100, 100], [140, 100], [140, 140], [100, 140]]},+ "zones": [{"id": "zA", "layer": "F.Cu", "net": "GND", "areaMm2": 100.0, "polygon_mm": sq(105.0, 115.0, 115.0, 125.0)}, {"id": "zB", "layer": "F.Cu", "net": "GND", "areaMm2": 8.0, "polygon_mm": sq(115.1, 118.0, 117.0, 122.0)},+ {"id": "zC", "layer": "F.Cu", "net": "UNK_ISO1", "areaMm2": 16.0, "polygon_mm": sq(130.0, 130.0, 134.0, 134.0)}, {"id": "zD", "layer": "F.Cu", "net": "N7", "areaMm2": 16.0, "polygon_mm": sq(120.0, 126.0, 124.0, 130.0)}],+ "traces": [{"id": "tE", "layer": "F.Cu", "net": "N5", "width_mm": 0.2, "path_mm": [[105.0, 133.0], [112.0, 133.0]]}],+ "vias": [{"id": "v1", "pos_mm": [110.0, 120.0], "diameter_mm": 0.6, "seen": "top", "tag": "MEASURED"}]}));+ write(&d, "detect/domes.json", &json!({"domes": []}));+ write(&d, "detect/attribution.json", &json!({"pieces": [{"piece": 0, "side": "top", "conductor": 0, "node": 0, "items": ["zA"]}, {"piece": 1, "side": "top", "conductor": 0, "node": 0, "items": ["zB"]},+ {"piece": 2, "side": "top", "conductor": 1, "node": 1, "items": ["zC"]}, {"piece": 3, "side": "top", "conductor": 2, "node": 2, "items": ["zD"]}, {"piece": 4, "side": "top", "conductor": 3, "node": 3, "items": ["tE"]}],+ "vias": {"v1": {"valid": true, "topConductor": 0, "bottomConductor": null}},+ "padStates": [{"pin": "R8.1", "side": "top", "x": 112.4, "y": 133.0, "contacts": [{"conductor": 3, "tier": 1, "kind": "track-end"}]}, {"pin": "R10.1", "side": "top", "x": 135.0, "y": 128.0, "contacts": [{"conductor": 2, "tier": 1, "kind": "pour"}]}]}));+ // five pins have a net: one ends a track, one is read on a pour 11 mm from its pad, three touch nothing. One net cites a datasheet+ write(&d, "nets.json", &json!({"nets": [{"name": "A", "tag": "INFERRED", "pins": ["R8.1", "R10.1", "C1.1"], "source": "Module datasheet, Table 4", "nameSource": "Module datasheet, Table 4"}, {"name": "N001", "tag": "INFERRED", "pins": ["R12.1", "C3.1"], "nameSource": "none: unnamed"}]}));+ write(&d, "detect/connect-graph.json", &json!({"contacts": [{"pin": "R8.1", "node": 3, "tier": 1, "kind": "track-end", "side": "top"}, {"pin": "R10.1", "node": 2, "tier": 1, "kind": "pour", "side": "top"}],+ "stitches": [{"pieces": [0, 1], "side": "top", "at": [115.05, 120.0], "kind": "no gap in the photo"}], "nodeOfPiece": [0, 0, 1, 2, 3], "conductorOfPiece": [0, 0, 1, 2, 3]}));+ write(&d, "detect/attrib/measure.json", &json!({"pads": [{"part": "R8", "num": "1", "core": {"x": 112.4, "y": 133.0, "w": 0.6, "h": 0.8, "rot": 0.0}}, {"part": "R10", "num": "1", "core": {"x": 135.0, "y": 128.0, "w": 0.6, "h": 0.8, "rot": 0.0}}]}));+ let mut top = RgbImage::from_pixel(1600, 1600, image::Rgb([20, 60, 30]));+ for y in 560..1040 { for x in 704..1000 { top.put_pixel(x, y, image::Rgb([12, 12, 14])); } }+ top.save(d.join("board/top.jpg")).unwrap();+ d+ }++ #[test]+ fn an_island_claims_a_join_the_crop_shows_and_never_a_net() {+ let d = join_fixture("joins");+ let c = Ctx::load(&d);+ let pop = population(&c, "connectivity");+ let isl: HashMap<&str, &PItem> = pop.iter().filter(|p| p.stratum == "island").map(|p| (p.id.as_str(), p)).collect();+ // three islands have a join within reach; the pour that touches nothing and the pour whose pad is 11 mm off are put forward as nothing+ assert_eq!(isl.keys().copied().collect::<std::collections::BTreeSet<_>>(), ["trace:tE", "zone:zA", "zone:zB"].into_iter().collect());+ assert_eq!(c.island_proof(), (3, 5));+ for p in isl.values() {+ assert!(!p.claim().contains("net "), "{}", p.claim());+ assert!(p.claim().starts_with("copper outlined in pink"), "{}", p.claim());+ // both ends are in the crop, and the other end is at the island's own copper+ for (pts, _) in p.shapes.iter().take(1).chain(p.other.iter()) { assert!(pts.iter().any(|q| (q.0 - p.at.0).abs().max((q.1 - p.at.1).abs()) < p.window / 2.0), "{}", p.id); }+ assert!(!p.other.is_empty());+ }+ assert_eq!(isl["trace:tE"].body, "reaches the pad boxed in blue (pin R8.1) with no break between them");+ assert_eq!(isl["zone:zB"].body, "is one piece with the copper outlined in blue (zA): no gap between them at the blue cross");+ assert_eq!(isl["zone:zB"].at, (115.05, 120.0), "centred where the stitch was read");+ // the pour has two joins, a via and the sliver: the via is nearer a name, so the via is its claim+ assert_eq!(c.joins().iter().find(|j| j.0.id == "zA").unwrap().1.iter().map(|j| j.kind).collect::<Vec<_>>(), ["copper", "via"]);+ assert_eq!(isl["zone:zA"].body, "is one piece with the via in the blue ring: no gap between the via and this copper");+ assert_eq!(isl["zone:zA"].at, (110.0, 120.0));+ // a track is outlined beside its edges, not down its middle+ let line = &isl["trace:tE"].shapes[0].0;+ assert!(line.iter().all(|q| (q.1 - 133.0).abs() > 0.15) && line.iter().any(|q| q.0 < 104.9) && line.iter().any(|q| q.0 > 112.1), "{line:?}");+ // the sample: each claim has a crop with a close row+ let doc = draw_sample(&d, "connectivity", None, "tester", DEFAULT_TARGET).unwrap();+ let seed = st(&doc, "seed").to_string();+ let list = arr(&doc["items"]);+ let real = |id: &str| list.iter().find(|i| st(i, "id") == id && !is_control(&seed, i)).unwrap().clone();+ assert_eq!(image::open(d.join(st(&real("trace:tE"), "crop"))).unwrap().height(), CROP_PX * 2 + 8);+ let md = std::fs::read_to_string(d.join("audits/connectivity/sheet.md")).unwrap();+ assert!(md.contains("blue is the other end") && md.contains("cannot_tell") && !md.contains("is on net"), "{md}");+ // every real claim right: the two islands with nothing to show still hold the island figure at 3 of 5+ answer(&d, "connectivity", &doc, |_, _| "ok");+ let s = score_all(&d, DEFAULT_TARGET);+ let con = s.cats.iter().find(|x| x.category == "connectivity").unwrap();+ let row = con.audit.strata.iter().find(|x| x["stratum"] == json!("island")).unwrap();+ assert_eq!((row["ok"].as_u64(), row["audited"].as_u64(), row["unproven"].as_u64(), row["islands"].as_u64()), (Some(3), Some(3), Some(2), Some(5)));+ assert_eq!(row["value"].as_f64(), Some(60.0));+ assert!(con.score <= 60.0 && con.audit.detail.contains("2 of 5 islands show no join"), "{} {}", con.score, con.audit.detail);+ // the pin on that track is judged from the same join, read from the pad's side: one of five pins, so a fifth+ let pin = real("pin:R8.1");+ assert_eq!(st(&pin, "claim"), "pad boxed in pink (pin R8.1): is joined to the copper outlined in blue (tE) with no break between them");+ assert_eq!(image::open(d.join(st(&pin, "crop"))).unwrap().height(), CROP_PX * 2 + 8);+ let row = con.audit.strata.iter().find(|x| x["stratum"] == json!("pin")).unwrap();+ assert_eq!((row["ok"].as_u64(), row["audited"].as_u64(), row["unproven"].as_u64(), row["pins"].as_u64(), row["value"].as_f64()), (Some(1), Some(1), Some(4), Some(5), Some(20.0)));+ assert_eq!(con.score, 20.0);+ assert!(con.audit.detail.contains("4 of 5 pins with a net show no join of their pad to copper the copy drew") && con.audit.detail.contains("2 of those 4 are on a net that cites a source outside the photos"), "{}", con.audit.detail);+ // the pad the track ends on is read somewhere else: that claim is another claim now, and its verdict is stale+ let mut ms = read(&d, "detect/attrib/measure.json");+ ms["pads"][0]["core"]["y"] = json!(133.1);+ write(&d, "detect/attrib/measure.json", &ms);+ assert!(record_verdict(&d, "connectivity", &seed, st(&real("trace:tE"), "a"), "ok", "auditor", "", None, None).unwrap_err().contains("stale"));+ assert!(record_verdict(&d, "connectivity", &seed, st(&pin, "a"), "ok", "auditor", "", None, None).unwrap_err().contains("stale"));+ assert!(score_all(&d, DEFAULT_TARGET).cats.iter().find(|x| x.category == "connectivity").unwrap().audit.status.contains("stale"));+ std::fs::remove_dir_all(&d).ok();+ }++ #[test]+ fn a_pin_claims_the_join_of_its_pad_to_drawn_copper_and_never_a_net() {+ let d = join_fixture("pins");+ let c = Ctx::load(&d);+ let pop = population(&c, "connectivity");+ let pins: Vec<&PItem> = pop.iter().filter(|p| p.stratum == "pin").collect();+ // five pins have a net and one has a join a crop can show: the one whose pad ends the track. The pin read on a pour 11 mm away, like the pour, is put forward as nothing+ assert_eq!(pins.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(), ["pin:R8.1"]);+ assert_eq!(c.pin_proof(), (1, 5, 2), "four unproven, two of them on the net that cites a datasheet");+ let p = pins[0];+ assert_eq!(p.claim(), "pad boxed in pink (pin R8.1): is joined to the copper outlined in blue (tE) with no break between them");+ assert!(!pop.iter().any(|p| p.claim().contains("is on net")));+ // the crop is centred on the pad; pink is the pad's box where its metal was read, blue the track outlined beside its edges+ assert_eq!(p.at, (112.4, 133.0));+ let boxed = |pts: &[(f64, f64)]| pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));+ let (pink, blue) = (boxed(&p.shapes[0].0), boxed(&p.other[0].0));+ assert!((pink.0 - 112.02).abs() < 0.01 && (pink.2 - 112.78).abs() < 0.01 && (pink.1 - 132.52).abs() < 0.01 && (pink.3 - 133.48).abs() < 0.01, "{pink:?}");+ assert!(blue.0 < 104.9 && blue.2 > 112.1 && (blue.1 - 132.82).abs() < 0.01 && (blue.3 - 133.18).abs() < 0.01, "{blue:?}");+ assert!(p.window >= 6.0 && blue.2 - p.at.0 < p.window / 2.0);+ // its record holds the net, so a pin moved to another net is another claim, though the words do not name it+ let mut nets = read(&d, "nets.json");+ nets["nets"][0]["name"] = json!("A2");+ write(&d, "nets.json", &nets);+ let again = population(&Ctx::load(&d), "connectivity");+ let q = again.iter().find(|x| x.id == "pin:R8.1").unwrap();+ assert_eq!(q.claim(), p.claim());+ assert_ne!(q.hash, p.hash);+ // what counts as citing a source outside the photos+ for (n, cites) in [(json!({"source": "Module datasheet, Table 4"}), true), (json!({"nameSource": "datasheet"}), true), (json!({"nameSource": "none: unnamed"}), false), (json!({"nameSource": "inference from the photos"}), false),+ (json!({"source": "measured"}), false), (json!({"nameSource": "circuit"}), false), (json!({}), false)] { assert_eq!(cites_outside(&n), cites, "{n}"); }+ std::fs::remove_dir_all(&d).ok();+ }++ #[test]+ fn what_shows_no_join_counts_as_not_correct_in_every_state_of_the_audit() {+ let d = fixture("nojoin");+ audit_honestly(&d, "connectivity", None);+ assert!(score(&d, "connectivity") >= 90.0);+ let con = |d: &Path| score_all(d, DEFAULT_TARGET).cats.into_iter().find(|x| x.category == "connectivity").unwrap();+ // no pin's pad is read on copper any more: the islands still have their vias, and every island claim is right, but the pins are unproven+ let graph = read(&d, "detect/connect-graph.json");+ write(&d, "detect/connect-graph.json", &json!({}));+ std::fs::remove_dir_all(d.join("audits/connectivity")).unwrap();+ let c = Ctx::load(&d);+ assert_eq!((c.island_proof(), c.pin_proof()), ((2, 2), (0, 200, 0)));+ assert!(population(&c, "connectivity").iter().all(|p| p.stratum == "island"), "no pin is put forward as a claim");+ audit_honestly(&d, "connectivity", None);+ let k = con(&d);+ assert_eq!(k.score, 0.0, "{}", k.audit.detail);+ assert!(k.audit.status.starts_with("scored") && k.audit.strata.iter().any(|x| x["stratum"] == json!("pin") && x["unproven"] == json!(200) && x["value"] == json!(0.0)), "{:?}", k.audit.strata);+ assert!(k.audit.detail.contains("200 of 200 pins with a net show no join"), "{}", k.audit.detail);+ // the joins were measured on other copper than the copy now holds: they are evidence of nothing, for islands and pins alike+ write(&d, "detect/connect-graph.json", &graph);+ write(&d, "detect/attrib/measure.json", &json!({"copperSha": "0000", "pads": []}));+ let c = Ctx::load(&d);+ assert_eq!((c.island_proof(), c.pin_proof()), ((0, 2), (0, 200, 0)));+ assert!(population(&c, "connectivity").is_empty(), "nothing is put forward as a claim");+ let k = con(&d);+ assert_eq!(k.score, 0.0, "{}", k.audit.detail);+ assert!(k.audit.detail.contains("2 of 2 islands show no join") && k.audit.detail.contains("unproven"), "{}", k.audit.detail);+ // and with no audit at all the cap does not sit above what an audit could reach+ std::fs::remove_dir_all(d.join("audits/connectivity")).unwrap();+ std::fs::remove_file(d.join("detect/attribution.json")).unwrap();+ let k = con(&d);+ assert_eq!((k.audit.status.as_str(), k.score), ("none", 0.0));+ assert!(draw_sample(&d, "connectivity", None, "tester", DEFAULT_TARGET).unwrap_err().contains("nothing to audit"));+ std::fs::remove_dir_all(&d).ok();+ }++ #[test]+ fn a_planted_join_has_its_other_end_drawn_where_there_is_none() {+ let d = join_fixture("falsejoin");+ let c = Ctx::load(&d);+ let pop = population(&c, "connectivity");+ let f = c.face("top").unwrap();+ let cls = |q: (f64, f64)| photo_index(f, q).map(|i| f.cls[i]);+ let boxed = |pts: &[(f64, f64)]| pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));+ let item = |id: &str, kind: &str| { let j = c.joins().iter().find(|j| j.0.id == id).unwrap(); join_item(&j.0, j.1.iter().find(|x| x.kind == kind).unwrap()) };+ let pin = pop.iter().find(|p| p.id == "pin:R8.1").unwrap().clone();+ for (base, seeds) in [(item("tE", "pad"), 1..9u64), (item("zA", "via"), 1..9), (item("zA", "copper"), 1..9), (pin, 1..9)] { for k in seeds {+ let it = false_join(&c, &base, &mut Rng(k)).unwrap_or_else(|| panic!("no false {} join", base.class));+ // the claim, its words and its record are the real item's: only one end has moved, the other end for an island and the pad's own box for a pin+ assert_eq!((&it.id, &it.hash, it.claim()), (&base.id, &base.hash, base.claim()));+ let (moved, was, kept, same) = if base.stratum == "pin" { (&it.shapes[0].0, &base.shapes[0].0, &it.other, &base.other) } else { (&it.other[0].0, &base.other[0].0, &it.shapes, &base.shapes) };+ assert_ne!(moved, was);+ assert_eq!(kept, same);+ let b = boxed(moved);+ assert!((b.0 - it.at.0).abs().max((b.2 - it.at.0).abs()).max((b.1 - it.at.1).abs()).max((b.3 - it.at.1).abs()) < it.window / 2.0, "the false end is in the crop");+ // an island's crop stays on its join; a pin's is centred on its box, so the empty box sits where a real pad would+ if base.stratum == "pin" { assert!((it.at.0 - (b.0 + b.2) / 2.0).abs() < 1e-9 && (it.at.1 - (b.1 + b.3) / 2.0).abs() < 1e-9); } else { assert_eq!(it.at, base.at); }+ match base.class.as_str() {+ // the second copper lies wholly on the chip's body+ "copper" => assert!(moved.iter().all(|q| cls(*q) == Some(photomat::OBJECT)), "{moved:?}"),+ kind => {+ // plain mask under all of it: no pad's metal, and for a via no via within 1.5 mm+ for q in [(b.0, b.1), (b.2, b.1), (b.2, b.3), (b.0, b.3), ((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0)] { assert_eq!(cls(q), Some(photomat::MASK)); }+ if kind == "via" { assert!(((b.0 + b.2) / 2.0 - 110.0).hypot((b.1 + b.3) / 2.0 - 120.0) >= 1.5); }+ for p in &c.parts { let pb = c.part_box(p); assert!(b.2 < pb.0 - 0.5 || b.0 > pb.2 + 0.5 || b.3 < pb.1 - 0.5 || b.1 > pb.3 + 0.5, "clear of {}", st(p, "ref")); }+ }+ }+ } }+ // what a draw plants, for an island or a pin, is such a claim, never another claim's words+ let mut r = Rng(7);+ let mut seen = HashSet::new();+ for _ in 0..60 {+ let Some(k) = control(&c, "connectivity", &pop, &mut r) else { continue };+ let real = pop.iter().find(|p| p.id == k.id && p.stratum == k.stratum).unwrap();+ assert_eq!(k.body, real.body);+ if k.stratum == "island" { assert_ne!(k.other[0].0, real.other[0].0); } else { assert_ne!(k.shapes[0].0, real.shapes[0].0); }+ seen.insert(k.stratum.clone());+ }+ assert_eq!(seen.len(), 2, "{seen:?}");+ std::fs::remove_dir_all(&d).ok();+ }+ #[test] fn a_report_cannot_claim_more_than_was_earned() { let d = fixture("claim");
src/domes.rs+221−8@@ -311,7 +311,7 @@ pub fn cand_json(c: &Cand, side: &str) -> Value { /// errors and the top -3.4, inside the limit and not a bump. This tests for domes, not for clearance rings: most vias sit in /// pours and have no ring. It is a conservative gate on calling a via inward-only. Returns the score and the count used. pub fn face_signature(img: &RgbImage, at_mm: &[(f64, f64)]) -> (f64, usize) {- let l = raster::luma(img); let (w, h) = (l.w as i64, l.h as i64);+ let l = raster::luma(img); let (w, h) = (l.w as i64, l.h as i64); let k = crate::prep::mask_tone(img).exposure(); let mut diffs: Vec<f64> = vec![]; for (x, y) in at_mm { let (u, v) = crate::board::to_px(*x, *y); let (u, v) = (u as i64, v as i64);@@ -319,7 +319,7 @@ pub fn face_signature(img: &RgbImage, at_mm: &[(f64, f64)]) -> (f64, usize) { let (mut a, mut an, mut b, mut bn, mut all) = (0f64, 0f64, 0f64, 0f64, vec![]); for dy in -24i64..=24 { for dx in -24i64..=24 { let d = dx * dx + dy * dy; if d > 576 { continue; } let q = l.at((u + dx) as usize, (v + dy) as usize); all.push(q); if d <= 64 { a += q as f64; an += 1.0; } else if d >= 144 { b += q as f64; bn += 1.0; } } } // silkscreen or a light part over the spot says nothing about the via under it- if median(&mut all) < VIEW_MAX { diffs.push(a / an - b / bn); }+ if median(&mut all) < VIEW_MAX * k { diffs.push(a / an - b / bn); } } if diffs.len() < 20 { return (0.0, diffs.len()); } let n = diffs.len() as f64; let m = diffs.iter().sum::<f64>() / n;@@ -340,9 +340,217 @@ pub fn hole_is_open(img: &RgbImage, x: f64, y: f64, drill: f64) -> bool { /// The middle of an open hole on the Driver photos is deep brown, under this 8 bit luma; a pin tip is well over it. const OPEN_HOLE_MAX: f32 = 70.0; +/// A via as a pit: what a tented via looks like under flat, frontal light, where nothing is lit from one side and no dome highlight forms. The mask sinks into the hole, so the via+/// reads as a small dark round spot, darker than the mask at any level of the copper, with the camera's sharpening often drawing a light ring round it (CM5 photos, both faces). Everything measured+/// on every candidate is kept, so a rejection can be checked.+#[derive(Clone, Debug)]+pub struct Pit { pub x: f64, pub y: f64, /// the half-depth blob's long and short axis, mm+ pub len: f32, pub wid: f32, /// the ring's median brightness (green) minus the darkest point, and that as a share of the ring+ pub depth: f32, pub share: f32, /// against the local grain+ pub z: f32, /// share of the ring that is plain mask, and the darkest point's G - R over the mask's own+ pub ring_mask: f32, pub green: f32, /// share of the ring that is pale: print or a part's end+ pub ring_pale: f32, pub runs_off: bool, pub keep: bool }++/// What a pit is, against nothing but this photo. The blob must close inside the window and be round (no gap line or track end), its surroundings must be mask (no part or print), and it must stay green at+/// its darkest (a gap between parts is neutral). The bars on roundness, ring, depth, strength and colour are read from the CM5 everypin photos, tile set A of the 5 mm checkerboard only, by how many pits on one face have a pit within+/// 0.1 mm on the other against the same count with one face mirrored top to bottom, which shows chance: 114 of 167 top pits against 1 at these values. The counter of a printed letter and the shadow beside a part's bright end are dark too, and the+/// pale share of the ring is what tells them, at some cost: it gives up 8 of 122 confirmed pits to lose 18 of 185. The size comes from the face's own population.+const PIT_ROUND: f32 = 2.0; const PIT_RING_MASK: f32 = 0.3; const PIT_MIN_SHARE: f32 = 0.12; const PIT_MIN_Z: f32 = 2.5; const PIT_GREEN: f32 = 0.05; const PIT_MAX_PALE: f32 = 0.4;+/// A population is a via field only if it is many of one size: this many at least, with the middle half of their widths within this share of the median.+const PIT_MIN_COUNT: usize = 40; const PIT_SPREAD: f32 = 0.5;+/// The size band round the population's median, as shares of it.+const PIT_BAND: (f32, f32) = (0.6, 1.6);++pub struct PitFace { pub pits: Vec<Pit>, pub median_mm: f64, pub spread: f64, pub shows: bool }++/// Read the pits on a face. `shows`: they form a population of one size, so this face shows its vias this way.+pub fn read_pits(img: &RgbImage) -> PitFace {+ let (w, h) = (img.width() as usize, img.height() as usize);+ let tone = crate::prep::mask_tone(img);+ let g = Plane { w, h, d: img.pixels().map(|p| p[1] as f32).collect() };+ let fine = raster::blur(&g, 1, 1);+ let (s, b) = (raster::blur(&g, 2, 2), raster::blur(&g, 8, 2));+ let dog = Plane { w, h, d: b.d.iter().zip(&s.d).map(|(x, y)| x - y).collect() };+ let mut abs: Vec<f32> = dog.d.iter().step_by(7).map(|v| v.abs()).collect();+ let clip = 3.0 * median(&mut abs);+ let noise = raster::blur(&Plane { w, h, d: dog.d.iter().map(|v| v.abs().min(clip)).collect() }, (1.5 * RES) as usize, 1);+ let r = 6usize;+ let mx = line_filter(&line_filter(&dog, 1, 0, 2 * r + 1, true), 0, 1, 2 * r + 1, true);+ let plain = |x: usize, y: usize| { let p = img.get_pixel(x as u32, y as u32); (p[1] as f32 - p[0] as f32) > 0.3 * tone.gr50 && p[1] > p[2] };+ let mut pits = vec![]; let win = 18i64;+ for y in 20..h.saturating_sub(20) { for x in 20..w.saturating_sub(20) {+ let i = y * w + x;+ if dog.d[i] < mx.d[i] || dog.d[i] <= 0.0 || dog.d[i] / (noise.d[i] + 1.0) < 2.5 { continue; }+ // the darkest point near the peak, and the ring round it+ let (mut cx, mut cy, mut low) = (x, y, f32::MAX);+ for dy in -4i64..=4 { for dx in -4i64..=4 { let (u, v) = ((x as i64 + dx) as usize, (y as i64 + dy) as usize); if fine.at(u, v) < low { low = fine.at(u, v); cx = u; cy = v; } } }+ let (mut ring, mut on, mut tot, mut pale) = (vec![], 0u32, 0u32, 0u32);+ for dy in -17i64..=17 { for dx in -17i64..=17 { let d2 = dx * dx + dy * dy; if !(144..=289).contains(&d2) { continue; } let (u, v) = (cx as i64 + dx, cy as i64 + dy); if u < 0 || v < 0 || u >= w as i64 || v >= h as i64 { continue; } ring.push(fine.at(u as usize, v as usize)); tot += 1; if plain(u as usize, v as usize) { on += 1; } let q = img.get_pixel(u as u32, v as u32); if q[0].min(q[1]).min(q[2]) > 135 { pale += 1; } } }+ let rm = median(&mut ring); let depth = rm - low;+ if depth <= 0.0 { continue; }+ let t = low + 0.5 * depth;+ let mut seen = vec![false; ((2 * win + 1) * (2 * win + 1)) as usize]; let idx = |dx: i64, dy: i64| ((dy + win) * (2 * win + 1) + dx + win) as usize;+ let mut st = vec![(0i64, 0i64)]; seen[idx(0, 0)] = true;+ let (mut n, mut sx, mut sy, mut sxx, mut syy, mut sxy, mut off) = (0f32, 0f32, 0f32, 0f32, 0f32, 0f32, false);+ while let Some((dx, dy)) = st.pop() {+ n += 1.0; let (fx, fy) = (dx as f32, dy as f32); sx += fx; sy += fy; sxx += fx * fx; syy += fy * fy; sxy += fx * fy;+ for (ax, ay) in [(1i64, 0i64), (-1, 0), (0, 1), (0, -1)] { let (nx, ny) = (dx + ax, dy + ay);+ if nx.abs() > win || ny.abs() > win { off = true; continue; }+ if seen[idx(nx, ny)] { continue; }+ let (u, v) = (cx as i64 + nx, cy as i64 + ny); if u < 0 || v < 0 || u >= w as i64 || v >= h as i64 { off = true; continue; }+ if fine.at(u as usize, v as usize) <= t { seen[idx(nx, ny)] = true; st.push((nx, ny)); } }+ }+ let (m0, m1) = (sx / n, sy / n); let (cxx, cyy, cxy) = (sxx / n - m0 * m0, syy / n - m1 * m1, sxy / n - m0 * m1);+ let tr = cxx + cyy; let det = (cxx * cyy - cxy * cxy).max(0.0); let disc = (tr * tr / 4.0 - det).max(0.0).sqrt();+ let (l1, l2) = ((tr / 2.0 + disc).max(0.0), (tr / 2.0 - disc).max(0.0));+ let centre = img.get_pixel(cx as u32, cy as u32);+ pits.push(Pit { x: cx as f64 + 0.5 + m0 as f64, y: cy as f64 + 0.5 + m1 as f64, len: 4.0 * l1.sqrt() / RES as f32, wid: 4.0 * l2.sqrt() / RES as f32, depth, share: depth / rm.max(1.0), z: dog.d[i] / (noise.d[i] + 1.0),+ ring_mask: on as f32 / tot.max(1) as f32, green: (centre[1] as f32 - centre[0] as f32) / tone.gr50.max(1.0), ring_pale: pale as f32 / tot.max(1) as f32, runs_off: off, keep: false });+ } }+ // close peaks of one spot: the deepest stands+ pits.sort_by(|a, b| b.depth.partial_cmp(&a.depth).unwrap());+ let mut uniq: Vec<Pit> = vec![];+ for p in pits { if !uniq.iter().any(|q| crate::geom::dist((q.x, q.y), (p.x, p.y)) < 6.0) { uniq.push(p); } }+ let passes = |p: &Pit| !p.runs_off && p.z >= PIT_MIN_Z && p.share >= PIT_MIN_SHARE && p.ring_mask >= PIT_RING_MASK && p.ring_pale <= PIT_MAX_PALE && p.green >= PIT_GREEN && p.wid > 0.0 && p.len / p.wid <= PIT_ROUND;+ let mut d: Vec<f32> = uniq.iter().filter(|p| passes(p)).map(|p| (p.len + p.wid) / 2.0).collect();+ d.sort_by(|a, b| a.partial_cmp(b).unwrap());+ let (med, q1, q3) = if d.is_empty() { (0.0, 0.0, 0.0) } else { (d[d.len() / 2], d[d.len() / 4], d[d.len() * 3 / 4]) };+ for p in uniq.iter_mut() { let dm = (p.len + p.wid) / 2.0; p.keep = !p.runs_off && p.z >= PIT_MIN_Z && p.share >= PIT_MIN_SHARE && p.ring_mask >= PIT_RING_MASK && p.ring_pale <= PIT_MAX_PALE && p.green >= PIT_GREEN && p.wid > 0.0 && p.len / p.wid <= PIT_ROUND && dm >= PIT_BAND.0 * med && dm <= PIT_BAND.1 * med; }+ let spread = if med > 0.0 { (q3 - q1) / med } else { 9.0 };+ // the same via seen in a pour's clearance fails the plain-mask ring above, and most vias under a BGA sit in one+ for r in round_pits(img) {+ match uniq.iter_mut().find(|q| crate::geom::dist((q.x, q.y), (r.x, r.y)) < 4.0) { Some(q) => q.keep = true, None => uniq.push(r) }+ }+ let n = uniq.iter().filter(|p| p.keep).count();+ PitFace { pits: uniq, median_mm: med as f64, spread: spread as f64, shows: n >= PIT_MIN_COUNT && spread <= PIT_SPREAD }+}++/// Bars for `round_pits`, in 8-bit green at 40 px/mm. Set on tile set A of the CM5 close-up set (2026-11-07) from where pairs across the faces stop rising while chance falls: 103 pairs against about 1 by chance.+/// A via's rim stands this far above its centre.+const ROUND_DEPTH: f32 = 22.0;+/// Spread round the circle as a share of that depth.+const ROUND_SPREAD: f32 = 0.9;+/// The centre's radius at half depth, px: a via is not a speck and not a part's shadow.+const ROUND_RADIUS: (usize, usize) = (2, 7);+/// Share of what surrounds it that is pale (print, a part's end) or black (a part).+const ROUND_PALE: f32 = 0.2; const ROUND_BLACK: f32 = 0.2;+/// Flooded out from the centre at half depth a via's core stays this small and this round; a gap between tracks runs away.+const ROUND_CORE_R: f32 = 8.0; const ROUND_CORE_ASPECT: f32 = 1.6;++/// Vias by what a via looks like whatever lies round it: a dark centre with a round rim. Unlike the test above this needs no plain mask nearby.+pub fn round_pits(img: &RgbImage) -> Vec<Pit> {+ let (w, h) = (img.width() as usize, img.height() as usize);+ let g = Plane { w, h, d: img.pixels().map(|p| p[1] as f32).collect() };+ let (coarse, fine) = (raster::blur(&g, 1, 2), raster::blur(&g, 1, 1));+ let neg = Plane { w, h, d: coarse.d.iter().map(|v| -*v).collect() };+ let lowest = line_filter(&line_filter(&neg, 1, 0, 9, true), 0, 1, 9, true);+ let rays: Vec<(f32, f32)> = (0..24).map(|k| { let a = k as f32 / 24.0 * std::f32::consts::TAU; (a.cos(), a.sin()) }).collect();+ let mut out = vec![];+ for y in 16..h.saturating_sub(16) { for x in 16..w.saturating_sub(16) {+ if neg.at(x, y) < lowest.at(x, y) { continue; }+ let (mut m, mut sd) = ([0f32; 15], [0f32; 15]);+ let (mut pale, mut black, mut seen) = (0u32, 0u32, 0u32);+ for r in 0..15usize {+ let (mut sum, mut sq) = (0f32, 0f32);+ for (c, si) in &rays {+ let (u, v) = ((x as f32 + r as f32 * c) as u32, (y as f32 + r as f32 * si) as u32);+ let p = img.get_pixel(u, v); let gv = p[1] as f32;+ sum += gv; sq += gv * gv;+ if (3..12).contains(&r) { seen += 1; if p[0] as f32 > 0.98 * gv || p[2] as f32 > 0.98 * gv { pale += 1; } if gv < 25.0 { black += 1; } }+ }+ let n = rays.len() as f32; m[r] = sum / n; sd[r] = (sq / n - m[r] * m[r]).max(0.0).sqrt();+ }+ let centre = (m[0] + m[1]) / 2.0;+ let depth = m[3..7].iter().cloned().fold(f32::MIN, f32::max) - centre;+ if depth <= ROUND_DEPTH { continue; }+ if sd[2..9].iter().sum::<f32>() / 7.0 / depth.max(1.0) >= ROUND_SPREAD { continue; }+ let half = centre + depth / 2.0;+ let rad = m.iter().position(|v| *v >= half).unwrap_or(0);+ if rad < ROUND_RADIUS.0 || rad > ROUND_RADIUS.1 { continue; }+ if pale as f32 / seen as f32 >= ROUND_PALE || black as f32 / seen as f32 >= ROUND_BLACK { continue; }+ let c = img.get_pixel(x as u32, y as u32);+ if c[1] as f32 - c[0] as f32 <= -5.0 { continue; }+ // flood the core at half depth+ if fine.at(x, y) > half { continue; }+ let win = 14i64; let idx = |dx: i64, dy: i64| ((dy + win) * (2 * win + 1) + dx + win) as usize;+ let mut mark = vec![false; ((2 * win + 1) * (2 * win + 1)) as usize]; mark[idx(0, 0)] = true;+ let mut st = vec![(0i64, 0i64)];+ let (mut rmax, mut x0, mut x1, mut y0, mut y1) = (0f32, 0i64, 0i64, 0i64, 0i64);+ while let Some((dx, dy)) = st.pop() {+ rmax = rmax.max(((dx * dx + dy * dy) as f32).sqrt()); x0 = x0.min(dx); x1 = x1.max(dx); y0 = y0.min(dy); y1 = y1.max(dy);+ for (ax, ay) in [(1i64, 0i64), (-1, 0), (0, 1), (0, -1)] { let (nx, ny) = (dx + ax, dy + ay);+ if nx.abs() > win || ny.abs() > win || mark[idx(nx, ny)] { continue; }+ if fine.at((x as i64 + nx) as usize, (y as i64 + ny) as usize) <= half { mark[idx(nx, ny)] = true; st.push((nx, ny)); } }+ }+ let (bw, bh) = ((x1 - x0 + 1) as f32, (y1 - y0 + 1) as f32);+ if rmax > ROUND_CORE_R || bw.max(bh) / bw.min(bh).max(1.0) > ROUND_CORE_ASPECT { continue; }+ let d = 2.0 * rad as f32 / RES as f32;+ out.push(Pit { x: x as f64 + 0.5, y: y as f64 + 0.5, len: d, wid: d, depth, share: depth / (centre + depth).max(1.0), z: 0.0, ring_mask: 0.0, green: 0.0, ring_pale: pale as f32 / seen as f32, runs_off: false, keep: true });+ } }+ out+}++/// Through vias show on both faces, so real pits line up with what the other face shows and grain does not. How many of `a` have something of `b` within 0.1 mm, how many would by chance (the same count with `b` mirrored+/// across the frame each way, the higher one), and whether that is decisive: at least a fifth of them and five times chance. CM5 everypin held-out tiles: about two thirds against under one percent. Driver top, whose+/// mask is flat with no via to see: 3 of 684 against 9, so its dark specks are grain. `wh` is the frame in pixels; `a` and `b` are in pixels.+pub fn pits_confirmed(a: &[(f64, f64)], b: &[(f64, f64)], wh: (f64, f64)) -> (usize, usize, bool) {+ let near = 0.1 * RES;+ let count = |other: &[(f64, f64)]| a.iter().filter(|p| other.iter().any(|q| crate::geom::dist(**p, *q) < near)).count();+ let hit = count(b);+ let mx: Vec<(f64, f64)> = b.iter().map(|q| (wh.0 - q.0, q.1)).collect();+ let my: Vec<(f64, f64)> = b.iter().map(|q| (q.0, wh.1 - q.1)).collect();+ let chance = count(&mx).max(count(&my));+ (hit, chance, hit * 5 >= a.len() && hit >= 5 * (chance + 1))+}++pub fn pit_json(p: &Pit, side: &str) -> Value {+ let (mx, my) = crate::board::to_mm(p.x, p.y); let r = |v: f32| (v as f64 * 1000.0).round() / 1000.0;+ json!({"side": side, "pos_mm": [(mx * 1000.0).round() / 1000.0, (my * 1000.0).round() / 1000.0], "len": r(p.len), "wid": r(p.wid), "depth": r(p.depth), "share": r(p.share), "z": r(p.z), "ringMask": r(p.ring_mask), "ringPale": r(p.ring_pale), "green": r(p.green), "runsOff": p.runs_off, "pit": p.keep})+}+ #[cfg(test)] mod tests { use super::*;+ #[test]+ fn pits_count_only_when_the_other_face_agrees() {+ let a: Vec<(f64, f64)> = (0..100).map(|k| (37.0 + (k * 211 % 1500) as f64, 23.0 + (k * 97 % 1100) as f64)).collect();+ let b: Vec<(f64, f64)> = a.iter().take(60).map(|p| (p.0 + 1.0, p.1 - 1.0)).collect();+ let (hit, _, sure) = pits_confirmed(&a, &b, (1600.0, 1200.0));+ assert_eq!(hit, 60); assert!(sure);+ let far: Vec<(f64, f64)> = a.iter().map(|p| (p.0 + 11.0, p.1 + 13.0)).collect();+ assert!(!pits_confirmed(&a, &far, (1600.0, 1200.0)).2, "nothing lines up");+ assert!(!pits_confirmed(&a, &[], (1600.0, 1200.0)).2, "one face cannot confirm itself");+ }++ #[test]+ fn a_via_in_a_clearance_is_read_and_a_gap_between_tracks_is_not() {+ // a dark centre, a pale pad and a dark halo; and beside it a long dark line+ let img = RgbImage::from_fn(300, 200, |x, y| {+ let d = (((x as f32 - 80.0).powi(2) + (y as f32 - 100.0).powi(2)) as f32).sqrt();+ if d < 3.5 { image::Rgb([20, 58, 30]) } else if d < 9.0 { image::Rgb([70, 140, 80]) } else if d < 14.0 { image::Rgb([24, 80, 50]) }+ else if (195..199).contains(&x) { image::Rgb([24, 80, 50]) } else { image::Rgb([70, 140, 80]) } });+ let p = round_pits(&img);+ assert_eq!(p.len(), 1, "{:?}", p.iter().map(|q| (q.x, q.y)).collect::<Vec<_>>());+ assert!((p[0].x - 80.5).abs() < 2.0 && (p[0].y - 100.5).abs() < 2.0);+ }++ #[test]+ fn pits_are_read_and_lines_are_not() {+ // a bright mask with 100 dark round spots, a dark gap line, and a grey part: the spots are vias, the line and part are not+ let (w, h) = (1600u32, 1200u32);+ let mut img = RgbImage::from_pixel(w, h, image::Rgb([60, 190, 140]));+ let mut at = vec![];+ for k in 0..100i64 { at.push((60 + (k % 10) * 90, 60 + (k / 10) * 90)); }+ for (cx, cy) in &at { for dy in -6i64..=6 { for dx in -6i64..=6 { let d = ((dx * dx + dy * dy) as f64).sqrt(); if d <= 5.0 { img.put_pixel((*cx + dx) as u32, (*cy + dy) as u32, image::Rgb([10, 70, 50])); } } } }+ for x in 1000..1500u32 { for y in 300..305u32 { img.put_pixel(x, y, image::Rgb([20, 100, 70])); } }+ for x in 1100..1300u32 { for y in 600..800u32 { img.put_pixel(x, y, image::Rgb([50, 50, 50])); } }+ let f = read_pits(&img);+ assert!(f.shows, "{} {}", f.median_mm, f.spread);+ let kept: Vec<&Pit> = f.pits.iter().filter(|p| p.keep).collect();+ assert_eq!(kept.len(), 100, "{}", kept.len());+ assert!(kept.iter().all(|p| p.x < 1000.0), "only the spots");+ }+ use image::Rgb; /// A mask-coloured photo with grain, for what is drawn on it. fn board(w: u32, h: u32, f: impl Fn(f64, f64) -> Option<f32>) -> RgbImage {@@ -383,13 +591,13 @@ mod tests { /// A via's pad: at each radius the median brightness round the via, so a track leaving it covers only a few sectors. Where the surroundings are laminate the median falls off the pad's edge, and the half /// height of that fall is the pad's radius. Returns the diameter in mm, or None where the surroundings are copper and nothing can be read.-fn pad_diameter(g: &crate::raster::Plane, x: f64, y: f64) -> Option<f64> {- const SECTORS: usize = 48; const MIN_STEP: f32 = 0.2; // the fall must be a fifth of a log unit, about half the Driver bottom's copper-to-laminate separation+fn pad_diameter(g: &crate::raster::Plane, x: f64, y: f64, min_step: f32) -> Option<f64> {+ const SECTORS: usize = 48; let at = |r: f64, k: usize| { let a = k as f64 / SECTORS as f64 * std::f64::consts::TAU; g.sample((x + r * a.cos()) as f32, (y + r * a.sin()) as f32) }; let med: Vec<f32> = (0..40).map(|r| { let mut v: Vec<f32> = (0..SECTORS).map(|k| at(r as f64, k)).collect(); v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[SECTORS / 2] }).collect(); let mid = |a: usize, b: usize| { let mut v = med[a..b].to_vec(); v.sort_by(|p, q| p.partial_cmp(q).unwrap()); v[v.len() / 2] }; let (inner, outer) = (mid(6, 11), mid(26, 40));- if inner - outer < MIN_STEP { return None; }+ if inner - outer < min_step { return None; } let half = (inner + outer) / 2.0; let k = (6..40).find(|i| med[*i] < half)?; let f = (med[k - 1] - half) / (med[k - 1] - med[k] + 1e-6);@@ -403,12 +611,17 @@ pub fn size_vias(img: &image::RgbImage, second: Option<&image::RgbImage>, vias: let (w, h) = (img.width() as usize, img.height() as usize); let mean = crate::raster::Plane { w, h, d: (0..w * h).map(|i| { let (x, y) = ((i % w) as u32, (i / w) as u32); let a = img.get_pixel(x, y)[1] as f32; let b = second.map(|s| s.get_pixel(x, y)[1] as f32).unwrap_or(a); ((a + b) / 2.0).max(2.0) }).collect() }; let lg = { let b = crate::raster::blur(&mean, 1, 2); crate::raster::Plane { w, h, d: b.d.iter().map(|v| v.ln()).collect() } };+ // the fall must be a fifth of a log unit, about half the Driver bottom's copper-to-laminate separation; a bright mask has its own separation (CM5: 0.16), and half of that is the same bar+ let tone = crate::prep::mask_tone(img);+ let min_step = if tone.bright() { let plain: Vec<f32> = (0..w * h).step_by(3).filter(|i| { let (x, y) = ((*i % w) as u32, (*i / w) as u32); let p = img.get_pixel(x, y); (p[1] as f32 - p[0] as f32) > 0.3 * tone.gr50 && p[1] > p[2] }).map(|i| lg.d[i]).collect(); crate::trace::two_modes(&plain).map(|m| 0.5 * (m.1 - m.0)).unwrap_or(0.2) } else { 0.2 }; let mut read = vec![]; for v in vias.as_array().into_iter().flatten() { let (Some(x), Some(y)) = (v["pos_mm"][0].as_f64(), v["pos_mm"][1].as_f64()) else { continue }; let (u, t) = crate::board::to_px(x, y); if u < 45.0 || t < 45.0 || u > w as f64 - 45.0 || t > h as f64 - 45.0 { continue; }- if let Some(d) = pad_diameter(&lg, u, t) { read.push(serde_json::json!({"id": v["id"], "shape": v["shape"], "padMm": (d * 1000.0).round() / 1000.0})); } }+ if let Some(d) = pad_diameter(&lg, u, t, min_step) { read.push(serde_json::json!({"id": v["id"], "shape": v["shape"], "padMm": (d * 1000.0).round() / 1000.0})); } } let mut ds: Vec<f64> = read.iter().filter_map(|r| r["padMm"].as_f64()).collect(); ds.sort_by(|a, b| a.partial_cmp(b).unwrap()); if ds.len() < 10 { return (serde_json::json!({"read": read, "note": "too few vias show their pad: sizes left as read"}), format!("{} vias show their pad: too few, sizes left as read", ds.len())); } let q = |p: f64| ds[((ds.len() - 1) as f64 * p) as usize];+ // one size is the premise: on the Driver bottom the tenth and ninetieth percentiles differ by 1.55 times. Where they differ by more than double, what fell off was not one pad size (CM5 bottom: 0.32 to 1.17 mm, tracks and pour edges among them), so nothing is written+ if q(0.9) > 2.0 * q(0.1) { return (serde_json::json!({"read": read, "p10Mm": q(0.1), "p90Mm": q(0.9), "note": "the falls read are not one size: sizes left as read"}), format!("{} vias show a fall, from {:.2} to {:.2} mm: not one pad size, sizes left as read", ds.len(), q(0.1), q(0.9))); } let med = q(0.5); let pad = (med * 20.0).round() / 20.0; let dr = drill.min(pad - 0.2).max(0.1); if let Some(a) = vias.as_array_mut() { for v in a.iter_mut() { v["diameter_mm"] = serde_json::json!(pad); v["drill_mm"] = serde_json::json!((dr * 1000.0).round() / 1000.0); v["sizeTags"] = serde_json::json!({"diameter": "PADS_ON_LAMINATE_MEDIAN", "drill": "ASSUMED"}); } } (serde_json::json!({"padMm": pad, "medianMm": med, "p10Mm": q(0.1), "p90Mm": q(0.9), "n": ds.len(), "read": read, "method": "half height of the fall of median brightness round each via on the bottom photos, at vias whose surroundings are laminate; see domes::pad_diameter"}), format!("via pads: {} vias show theirs, median {:.3} mm (10th to 90th percentile {:.3} to {:.3}), written as {pad:.2} mm", ds.len(), med, q(0.1), q(0.9)))@@ -421,8 +634,8 @@ mod pad_size_tests { fn a_pad_on_laminate_reads_its_diameter_and_one_in_copper_reads_nothing() { let (w, h) = (200usize, 200usize); let paint = |lit: f32, ground: f32| crate::raster::Plane { w, h, d: (0..w * h).map(|i| { let (x, y) = ((i % w) as f32 - 100.0, (i / w) as f32 - 100.0); if (x * x + y * y).sqrt() <= 10.0 { lit } else { ground } }).collect() };- let d = pad_diameter(&paint(4.3, 3.7), 100.0, 100.0).expect("a fall to read");+ let d = pad_diameter(&paint(4.3, 3.7), 100.0, 100.0, 0.2).expect("a fall to read"); assert!((d - 0.5).abs() < 0.05, "{d}");- assert!(pad_diameter(&paint(4.3, 4.3), 100.0, 100.0).is_none());+ assert!(pad_diameter(&paint(4.3, 4.3), 100.0, 100.0, 0.2).is_none()); } }
src/fidelity.rs+37−9@@ -106,6 +106,7 @@ pub fn copper_read(img: &image::RgbImage, fc: &Face) -> Mask { // a narrow gap's dip must survive this blur: at radius 3 the 5 px gaps between tracks read part copper, at radius 2 the read edges sit closer to the steepest fall of brightness // (share within 2 px on tile set A, Driver top 0.625 to 0.641, bottom 0.482 to 0.565) for a small loss of agreement between the two shots (top 0.949 to 0.937, bottom 0.954 to 0.953) let g = raster::blur(&Plane { w, h, d: img.pixels().map(|p| p[1] as f32).collect() }, 2, 2);+ let g_open = g.clone(); // A dome's shadow is a small dark blob on copper, not a gap. A gap is long. Closing along a line fills a dark feature shorter than the line in that direction, so the lowest of four // directional closings fills blobs and keeps long lines. 0.6 mm is the longest dome shadow on the Driver bottom, tile set A. let g = { let closed: Vec<Plane> = [(1i64, 0i64), (0, 1), (1, 1), (-1, 1)].iter().map(|(dx, dy)| { let up = crate::copper::line_filter(&g, *dx, *dy, CLOSE_PX, true); crate::copper::line_filter(&up, *dx, *dy, CLOSE_PX, false) }).collect(); Plane { w, h, d: (0..w * h).map(|i| closed.iter().map(|c| c.d[i]).fold(f32::MAX, f32::min)).collect() } };@@ -143,6 +144,15 @@ pub fn copper_read(img: &image::RgbImage, fc: &Face) -> Mask { let (mut wb, mut sb, mut best, mut bt) = (0f64, 0f64, 0f64, 128usize); for t in 0..256 { wb += hist[t] as f64; if wb == 0.0 { continue; } let wf = tot as f64 - wb; if wf == 0.0 { break; } sb += t as f64 * hist[t] as f64; let (mb, mf) = (sb / wb, (sum - sb) / wf); let bv = wb * wf * (mb - mf).powi(2); if bv > best { best = bv; bt = t; } } let thr = (bt as f32 + 1.0) / 256.0 * 1.6;+ // Otsu needs dark pixels to be laminate. On a bright sharpened photo a long tail of shadow and sharpening bands below the laminate pulls the split down under it, and then everything reads as copper (CM5 bottom:+ // photo 540 mm2 where the board draws 228). There the plain mask has two clear modes, and the split is half way between them. The Driver photos do not take this path.+ let tone = crate::prep::mask_tone(img);+ // (Read before the directional closing, which fills the narrow laminate between tracks and blurs the two modes together.)+ let modes = if tone.bright() { let lr: Vec<f32> = (0..w * h).step_by(3).filter(|i| plain.d[*i]).map(|i| g_open.d[i].max(1.0).ln()).collect(); crate::trace::two_modes(&lr) } else { None };+ let thr = modes.map(|m| ((m.0 + m.1) / 2.0).exp() / med.max(1.0)).unwrap_or(thr);+ // and with two modes over the whole face the light is flat, so the face's level is the reference: a window that is mostly laminate has its envelope on the laminate, and then the laminate reads as copper+ let ratio: Vec<f32> = if modes.is_some() { g.d.iter().map(|v| v / med.max(1.0)).collect() } else { ratio };+ if std::env::var("TRACE_DEBUG").is_ok() { eprintln!("copper_read: otsu {:.3}, split {thr:.3}, modes {:?}, env median {med:.0}, plain share {:.3}", (bt as f32 + 1.0) / 256.0 * 1.6, modes.map(|m| (m.0.exp(), m.1.exp())), plain.count() as f64 / (w * h) as f64); } Mask { w, h, d: ratio.iter().map(|r| *r > thr).collect() }.close(2.0).open(2.0) } @@ -242,9 +252,9 @@ fn pad_report(fc: &Face, exposed: &Mask, scored: &Mask, sel: &dyn Fn(usize, usiz json!({"blobs": v.len(), "iouMedian": r3(v[v.len() / 2]), "iouP10": r3(v[v.len() / 10]), "under05": v.iter().filter(|x| **x < 0.5).count()}) } -/// A crude, independent look at the via layer: small bright round domes on the mask of the bottom face.-fn via_report(fc: &Face, drilled: &[gerber::Hole]) -> Value {- let (a, b) = (raster::blur(&fc.y, 4, 2), raster::blur(&fc.y, 11, 2));+/// A crude, independent look at the via layer: small bright round domes on the mask of the bottom face. Under frontal light a via has no bright dome but a dark pit, so on a bright mask the same plain test looks for small dark spots.+fn via_report(fc: &Face, drilled: &[gerber::Hole], dark: bool) -> Value {+ let (a, b) = if dark { let (s, l) = (raster::blur(&fc.y, 2, 2), raster::blur(&fc.y, 8, 2)); (Plane { w: s.w, h: s.h, d: s.d.iter().map(|v| -*v).collect() }, Plane { w: l.w, h: l.h, d: l.d.iter().map(|v| -*v).collect() }) } else { (raster::blur(&fc.y, 4, 2), raster::blur(&fc.y, 11, 2)) }; let (w, h) = (fc.w, fc.h); let mut peaks: Vec<(f64, f64, f32)> = vec![]; for y in (12..h - 12).step_by(1) { for x in (12..w - 12).step_by(1) {@@ -264,13 +274,13 @@ fn via_report(fc: &Face, drilled: &[gerber::Hole]) -> Value { let (pr, rc) = (hit_v as f64 / vias.len().max(1) as f64, hit_p as f64 / pk.len().max(1) as f64); let f1 = if pr + rc > 0.0 { 2.0 * pr * rc / (pr + rc) } else { 0.0 }; let on_silk = drilled.iter().filter(|d| d.via).filter(|d| { let p = board::to_px(d.x, d.y); let (u, v) = (p.0 as i64, p.1 as i64); u >= 0 && v >= 0 && (u as usize) < w && (v as usize) < h && fc.cls[v as usize * w + u as usize] == photomat::SILK }).count();- json!({"viasInHeldOutTiles": vias.len(), "domesInHeldOutTiles": pk.len(), "viasOnADome": hit_v, "domesWithAVia": hit_p, "precision": r3(pr), "recall": r3(rc), "f1": r3(f1), "viasOnSilk": on_silk, "note": "domes found by a plain bright-blob test on the bottom face, not the flow's via detector: an independent floor, not a truth"})+ json!({"viasInHeldOutTiles": vias.len(), "domesInHeldOutTiles": pk.len(), "viasOnADome": hit_v, "domesWithAVia": hit_p, "precision": r3(pr), "recall": r3(rc), "f1": r3(f1), "viasOnSilk": on_silk, "note": if dark { "domes here are small dark spots found by a plain blob test on the bottom face, not the flow's via detector: an independent floor, not a truth" } else { "domes found by a plain bright-blob test on the bottom face, not the flow's via detector: an independent floor, not a truth" }}) } pub fn run(flow: &Path, pcb: &Path, ev: &Path) -> Result<(Value, Vec<String>), String> { let open = |n: &str| image::open(flow.join(n)).map(|i| i.to_rgb8()).map_err(|e| format!("{n}: {e}")); let (ti, bi) = (open("board/top.jpg")?, open("board/bottom.jpg")?);- let (mut top, mut bot) = (photomat::classify(&ti, true), photomat::classify(&bi, false));+ let (mut top, mut bot) = (photomat::classify(&ti, photomat::carries_parts(&ti)), photomat::classify(&bi, photomat::carries_parts(&bi))); photomat::link_holes(&mut top, &mut bot); let (w, h) = (top.w, top.h); let fr = Frame { w, h, res: board::RES, ox: board::ORIGIN, oy: board::ORIGIN };@@ -294,7 +304,7 @@ pub fn run(flow: &Path, pcb: &Path, ev: &Path) -> Result<(Value, Vec<String>), S }; let (t2, b2) = (others("top"), others("bottom")); let mut second_doc = Map::new();- let (ft2, fb2) = (t2.as_ref().map(|x| photomat::classify(&x.0, true)), b2.as_ref().map(|x| photomat::classify(&x.0, false)));+ let (ft2, fb2) = (t2.as_ref().map(|x| photomat::classify(&x.0, photomat::carries_parts(&x.0))), b2.as_ref().map(|x| photomat::classify(&x.0, photomat::carries_parts(&x.0)))); if let Some(x) = &t2 { second_doc.insert("top".into(), x.1.clone()); } if let Some(x) = &b2 { second_doc.insert("bottom".into(), x.1.clone()); } let round = {@@ -370,8 +380,12 @@ pub fn run(flow: &Path, pcb: &Path, ev: &Path) -> Result<(Value, Vec<String>), S layers_doc.get_mut(&format!("{fname} exposed metal")).unwrap()["perBlob"] = pad_report(fc, &exposed, &sc, &tile_b); } let outline = photolayers::outline_check(flow, &edge_text, &prep, &scale).unwrap_or_else(|e| json!({"error": e}));+ // the drawn outline against the stated board: a reader that loses an edge must not draw a board of another size+ let off_stated = pho_outline_off(flow, &scale); let lowest = |names: &[&str]| -> f64 { held.iter().filter(|(n, _)| names.contains(&n.as_str())).map(|(_, v)| *v).fold(1.0, f64::min) };- let vias = via_report(&bot, &plot.drill);+ let vias = via_report(&bot, &plot.drill, crate::prep::mask_tone(&bi).bright());+ // the blob test looks for lit domes; on a face lit evenly there are none to find (the CM5 close-up set), only pits+ let domes_lit = crate::items::read_json(&flow.join("detect/domes.json")).ok().and_then(|d| d["light"]["bottom"]["oneLight"].as_bool()).unwrap_or(true); let pho = photolayers::load(flow); let lib_share = pho.as_ref().and_then(|d| d["libraryPadsAgainstPhoto"]["share"].as_f64()); let cu_score = 100.0 * lowest(&["F.Cu", "B.Cu"]);@@ -383,11 +397,11 @@ pub fn run(flow: &Path, pcb: &Path, ev: &Path) -> Result<(Value, Vec<String>), S let below: Vec<String> = held.iter().filter(|x| x.1 < TARGET).map(|x| format!("{} {:.3}", x.0, x.1)).collect(); let inputs: Map<String, Value> = ["copper.json", "parts.json", photolayers::FILE].iter().filter(|f| flow.join(f).is_file()).map(|f| (f.to_string(), json!(if *f == "copper.json" { crate::attrib::geometry_hash(&flow.join(f)) } else { crate::sha256(&flow.join(f)) }))).collect(); let doc = json!({- "generatedBy": "check fidelity", "at": crate::ledger::now(), "board": pcb.display().to_string(), "pxPerMm": board::RES,+ "generatedBy": "check fidelity", "at": crate::ledger::now(), "board": pcb.display().to_string(), "pxPerMm": board::RES, "outlineOffStatedMm": off_stated, "inputs": inputs, "categories": { "copper": {"score": r2(cu_score), "formula": "100 x the lower IoU of F.Cu and B.Cu, each the lower of its held-out tiles and the second photo. Only bare mask counts: pixels under parts, silk or metal are in neither the numerator nor the denominator, so they cannot raise it. See layers.*.scoredShareOfBoard for how much of the board this covers."},- "vias": {"score": r2(via_score), "formula": "100 x F1 of written vias against bright domes on the bottom mask in the held-out tiles, from a plain blob test"},+ "vias": if domes_lit { json!({"score": r2(via_score), "formula": "100 x F1 of written vias against bright domes on the bottom mask in the held-out tiles, from a plain blob test"}) } else { json!({"score": Value::Null, "notMeasured": "the bottom photo shows no lit domes (one light across the face is not seen), so a blob test for domes measures nothing here; the category stays capped until another check covers it", "blobTestF1": r2(via_score)}) }, "placement": {"score": r2(place), "formula": "100 x the lowest of: F exposed metal IoU, F holes IoU (held-out, second photo), and the share of parts whose library pads sit within 0.25 mm of the pads the photo shows"} }, "layersWorst": r2(all), "targetIoU": TARGET, "belowTarget": below,@@ -415,6 +429,7 @@ pub fn check(flow: &Path, pcb: &Path) -> Result<crate::checks::Pass, Vec<String> hints.push("evidence/fidelity-<layer>.jpg: green both, red board only, cyan photo only, purple excluded".into()); let mut errs = problems; for b in doc["belowTarget"].as_array().into_iter().flatten() { errs.push(format!("below IoU {TARGET}: {}", b.as_str().unwrap_or(""))); }+ if let Some(o) = doc["outlineOffStatedMm"].as_f64() { if o > OUTLINE_TOL_MM { errs.push(format!("the drawn outline is {o:.2} mm from the stated {} x {} mm board at its worst corner: look at evidence/outline-*.jpg and photo-layers.json outline.edges", doc["scaleCheck"]["boardMm"][0], doc["scaleCheck"]["boardMm"][1])); } } if !errs.is_empty() { errs.extend(hints); return Err(errs); } Ok(crate::checks::Pass { summary: json!({"worstLayer": doc["layersWorst"], "target": TARGET}), hints, files: vec!["fidelity.json".into()] }) }@@ -442,3 +457,16 @@ mod tests { #[test] fn distance_is_zero_on_itself() { let m = blob(300, 300, 200); let s = Mask { w: 800, h: 800, d: vec![true; 640000] }; let (a, b, n) = boundary_dist(&m, &m, &s); assert_eq!((a, b), (0.0, 0.0)); assert!(n > 0); } }++/// Corners of the board drawn from the photos may sit this far from the stated board before `check fidelity` fails.+const OUTLINE_TOL_MM: f64 = 0.5;++/// The worst distance from a drawn outline corner to the stated board's corner, in mm.+fn pho_outline_off(flow: &std::path::Path, scale: &Value) -> Option<f64> {+ let d = photolayers::load(flow)?; let pts = d["outline"]["points_mm"].as_array()?;+ let (w, h) = (scale["board_mm"][0].as_f64()?, scale["board_mm"][1].as_f64()?);+ let o = board::ORIGIN; let want = [(o, o), (o + w, o), (o + w, o + h), (o, o + h)];+ let got: Vec<(f64, f64)> = pts.iter().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))).collect();+ if got.len() != 4 { return None; }+ Some((0..4).map(|i| ((got[i].0 - want[i].0).powi(2) + (got[i].1 - want[i].1).powi(2)).sqrt()).fold(0.0, f64::max))+}
src/islands.rs+189−12@@ -6,7 +6,7 @@ //! meeting at a via are reported and not merged, since a leak in the photo must not short two nets. A via with a //! closed clearance gap on both faces leads nowhere visible, but that only counts where both photos show vias at all. use crate::board;-use crate::kicad_out::Pad;+use crate::kicad_out::{Body, Pad}; use crate::raster::{self, Mask}; use crate::trace::Traced; use serde_json::{json, Value};@@ -22,10 +22,82 @@ const PALE_ONLY: f64 = 0.7; const SPECK_MM2: f64 = 0.08; /// A hole in a pour smaller than this is grain: 40 pixels is 0.025 mm2, less than a clearance around the smallest via. const PINHOLE_PX: usize = 40;+/// A part's top begins this far inside its body outline, mm. Terminals, leads and solder fillets sit at a body's edge and stand for the+/// pads under them, and an outline in parts.json is only as good as the part was placed: none of that is "on the body".+pub const BODY_INSET_MM: f64 = 0.25;+/// An island with this share of itself inside one part's drawn-in outline is on that part's top: print, a lid's step, a glint.+const ON_BODY: f64 = 0.9;+/// Where copper is read over more than this share of a drawn-in outline, the photo shows board there, not a package top: a connector or+/// shield open in the middle, bare copper that is itself the "part" (a fiducial), or an outline that is not where the part is. Nothing is taken off.+const BODY_OPEN: f64 = 0.25;+/// An island on a part's top has less than this share of itself in the colour of the board's own copper.+const BOARD_COLOURED: f64 = 0.5;+/// A pixel has the board's colour when the mask's own channel stands this share as far above grey in it as in the face's copper. On the CM5 photos+/// copper under the mask stands 0.09 and 0.07 above grey in green, its palest pours 0.03 to 0.045, print and lids 0.005 to 0.011 below.+const BOARD_TINT: f64 = 0.25;+/// A face whose copper stands less than this above neutral grey in every channel has no colour to tell a part's top from: nothing is taken off.+const NO_COLOUR: f64 = 0.01; /// A branch of a centre line shorter than this share of the local width is a corner of the outline, not copper of its own. const SPUR_SHARE: f64 = 2.0; -pub struct Face<'a> { pub side: &'a str, pub layer: &'a str, pub t: Traced }+pub struct Face<'a> { pub side: &'a str, pub layer: &'a str, pub t: Traced, /// `board_coloured` of this face's photo+ pub board_colour: Mask }++/// The pixels that have the colour of this face's own copper under its mask. The colour is read off the face, not assumed green: the median+/// share of red, green and blue over everything read as copper, and of those the channel that stands furthest above neutral grey is the+/// mask's own. A pixel has the board's colour when that channel stands at least `BOARD_TINT` as far above grey in it. White print, black+/// plastic and a metal lid do not, whatever the mask. On a face whose copper reads grey every pixel counts as board, so colour says nothing there.+pub fn board_coloured(img: &image::RgbImage, labels: &[u32]) -> Mask {+ let (w, h) = (img.width() as usize, img.height() as usize);+ let share = |p: &image::Rgb<u8>| { let t = (p[0] as f64 + p[1] as f64 + p[2] as f64).max(1.0); [p[0] as f64 / t - 1.0 / 3.0, p[1] as f64 / t - 1.0 / 3.0, p[2] as f64 / t - 1.0 / 3.0] };+ let mut ch: [Vec<f64>; 3] = [vec![], vec![], vec![]];+ for (i, p) in img.pixels().enumerate().step_by(7) { if labels[i] != 0 { let q = share(p); for k in 0..3 { ch[k].push(q[k]); } } }+ let mut d = [0f64; 3];+ for k in 0..3 { if !ch[k].is_empty() { ch[k].sort_by(|a, b| a.partial_cmp(b).unwrap()); d[k] = ch[k][ch[k].len() / 2]; } }+ let k = (0..3).max_by(|a, b| d[*a].partial_cmp(&d[*b]).unwrap()).unwrap();+ if d[k] < NO_COLOUR { return Mask { w, h, d: vec![true; w * h] }; }+ Mask { w, h, d: img.pixels().map(|p| share(p)[k] >= BOARD_TINT * d[k]).collect() }+}++/// Islands that lie on a part's own top, with the part: no copper can be seen there, so what was read is print or light on the package.+/// The rule, all three: at least `ON_BODY` of the island is inside the body outline of one part placed on this face, the outline drawn in by+/// `BODY_INSET_MM`; the reader found copper over no more than `BODY_OPEN` of that outline; and less than `BOARD_COLOURED` of the island has+/// the colour of the board's own copper (`coloured`, from `board_coloured`). A two-pad part has no top to speak of between its terminals and+/// is left out. Copper beside a part, under its terminals or reaching in from outside is not inside by that share and stays; so does board+/// seen through a part that is open in the middle, and board inside an outline that parts.json drew too large or in the wrong place.+pub fn on_bodies(labels: &[u32], coloured: &[bool], w: usize, h: usize, layer: &str, bodies: &[Body]) -> Vec<(u32, String)> { on_bodies_at(labels, coloured, w, h, layer, bodies, BODY_INSET_MM, BODY_OPEN) }++/// The same for the candidate pours of `copper regions`, where a part's top is one whole region reaching to its shadow: the outline as drawn,+/// and no limit on how much of it is filled. Board seen through an open part has the board's colour and stays.+pub fn regions_on_bodies(labels: &[u32], coloured: &[bool], w: usize, h: usize, layer: &str, bodies: &[Body]) -> Vec<(u32, String)> { on_bodies_at(labels, coloured, w, h, layer, bodies, 0.0, 1.0) }++fn on_bodies_at(labels: &[u32], coloured: &[bool], w: usize, h: usize, layer: &str, bodies: &[Body], inset: f64, open: f64) -> Vec<(u32, String)> {+ let mut total: HashMap<u32, (usize, usize)> = HashMap::new();+ let mut out: Vec<(u32, String)> = vec![];+ for b in bodies.iter().filter(|b| b.layer == layer && b.pads != 2) {+ let px: Vec<(f64, f64)> = b.corners().iter().map(|q| board::to_px(q.0, q.1)).collect();+ let (x0, y0, x1, y1) = px.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, q| (a.0.min(q.0), a.1.min(q.1), a.2.max(q.0), a.3.max(q.1)));+ if x1 < 0.0 || y1 < 0.0 { continue; }+ let (mut n, mut copper) = (0usize, 0usize); let mut inside: HashMap<u32, usize> = HashMap::new();+ for v in y0.max(0.0) as usize..=(y1 as usize).min(h.saturating_sub(1)) { for u in x0.max(0.0) as usize..=(x1 as usize).min(w.saturating_sub(1)) {+ let m = board::to_mm(u as f64 + 0.5, v as f64 + 0.5); if !b.holds(m.0, m.1, inset) { continue; }+ n += 1; let l = labels[v * w + u]; if l != 0 { copper += 1; *inside.entry(l).or_default() += 1; }+ } }+ if n == 0 || copper as f64 > open * n as f64 { continue; }+ if total.is_empty() { for (i, l) in labels.iter().enumerate() { if *l != 0 { let e = total.entry(*l).or_default(); e.0 += 1; if coloured[i] { e.1 += 1; } } } }+ for (l, c) in inside { let (n, board) = total[&l]; if c as f64 >= ON_BODY * n as f64 && (board as f64) < BOARD_COLOURED * n as f64 && !out.iter().any(|o| o.0 == l) { out.push((l, b.part.clone())); } }+ }+ out.sort();+ out+}++/// Take the islands on part tops out of a trace, so nothing later reads or draws them. Returns how many.+pub fn clear_bodies(t: &mut Traced, img: &image::RgbImage, layer: &str, bodies: &[Body]) -> usize {+ if !bodies.iter().any(|b| b.layer == layer && b.pads != 2) { return 0; }+ let on: std::collections::HashSet<u32> = on_bodies(&t.labels, &board_coloured(img, &t.labels).d, t.w, t.h, layer, bodies).into_iter().map(|o| o.0).collect();+ if !on.is_empty() { for l in t.labels.iter_mut() { if on.contains(l) { *l = 0; } } }+ on.len()+} fn ring(p: &Pad, d: f64, n: usize) -> Vec<(f64, f64)> { let (hw, hh) = (p.w / 2.0 + d, p.h / 2.0 + d);@@ -126,7 +198,9 @@ pub struct Options { pub track: f64, pub via: (f64, f64), pub clearance: f64, pu /// whether each face's photo shows vias at all (`domes::face_signature`): a face that does not cannot show one is isolated pub faces_show_vias: bool, /// pads whose hole the photo shows open: no pin, so no solder fillet round them- pub open_holes: Vec<(f64, f64)> }+ pub open_holes: Vec<(f64, f64)>,+ /// the placed parts' bodies (`kicad_out::bodies_of`): what is read on a part's top is not copper+ pub bodies: Vec<Body> } /// A via read from a photo. pub struct SeenDome { pub x: f64, pub y: f64, pub face: usize, pub d: f64, pub shape: String }@@ -135,11 +209,25 @@ pub struct SeenDome { pub x: f64, pub y: f64, pub face: usize, pub d: f64, pub s const PIN_RIM_MM: f64 = 0.45; /// The blur of a surface pad's rim, mm. const PAD_RIM_MM: f64 = 0.15;+/// A dome seen on one face with this share of silk at its centre and on a ring of `PRINT_RING_MM` round it lies in print.+const PRINT_SHARE: f64 = 0.3;+const PRINT_RING_MM: f64 = 0.12;+/// Radius of the ring looked at round a dome that falls on a library land: board there means a via beside the pad.+const OPEN_RING_MM: f64 = 0.2; /// The photo's edge is unreadable within this distance of the outline, mm. const PHOTO_EDGE_MM: f64 = 0.1; /// Everything measured: zones, tracks, vias, groups and what disagreed. `pin_net` is what the AI has inferred so far. pub fn measure(faces: &mut [Face], pads: &[Pad], domes: &[SeenDome], pin_net: &HashMap<String, String>, opt: &Options) -> Value {+ // What sits on a part's own top is not copper. It is taken out before anything reads the islands, so it joins no pin, carries no net through a via and is+ // not drawn; the overlay shows it as it shows a gap. The accounting keeps each such island as first read, with the part it is on.+ let mut on_top: HashMap<(usize, u32), String> = HashMap::new();+ for (fi, f) in faces.iter_mut().enumerate() {+ let on: HashMap<u32, String> = on_bodies(&f.t.labels, &f.board_colour.d, f.t.w, f.t.h, f.layer, &opt.bodies).into_iter().collect();+ if on.is_empty() { continue; }+ let t = &mut f.t;+ for i in 0..t.labels.len() { if let Some(part) = on.get(&t.labels[i]) { if t.steps.first[i] != 0 { on_top.entry((fi, t.steps.first[i])).or_insert_with(|| format!("on the body of {part}: print or light on a part's top, where no copper can be seen")); } t.labels[i] = 0; t.gap.d[i] = true; } }+ } let mut isl: Vec<Isl> = vec![]; let mut index: HashMap<(usize, u32), usize> = HashMap::new(); for (fi, f) in faces.iter().enumerate() { for r in &f.t.regions { index.insert((fi, r.id), isl.len()); isl.push(Isl { face: fi, id: r.id, net: None, pins: vec![], votes: HashMap::new(), lugs: HashMap::new(), mixed: false, area: r.area }); } } // pins onto islands, face by face@@ -186,15 +274,33 @@ pub fn measure(faces: &mut [Face], pads: &[Pad], domes: &[SeenDome], pin_net: &H for (i, t) in tops.iter().enumerate() { if !used_top[i] { let hidden = fb.map(|f| covered(&faces[f].t, t.x, t.y)).unwrap_or(false); vias.push((t.x, t.y, if hidden { "top, bottom hidden" } else { "top" }, t.d, t.shape.clone())); } } // A via's centre cannot lie on a pad of the face it shows on: dimples of soldered pads and pin bodies look like domes. // A pad on the far face says nothing: vias stitch up under parts and into their pads.- let on_pad = |x: f64, y: f64, layer: &str| pads.iter().any(|p| { if !p.on(layer) { return false; } let (dx, dy) = (x - p.x, y - p.y); let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin()); let (lx, ly) = (dx * c - dy * s, dx * s + dy * c); let open = opt.open_holes.iter().any(|h| crate::geom::dist(*h, (p.x, p.y)) < 0.01); let m = if p.drill > 0.0 && !open { PIN_RIM_MM } else { PAD_RIM_MM }; if p.round { (lx * lx + ly * ly).sqrt() < p.w.max(p.h) / 2.0 + m } else { lx.abs() < p.w / 2.0 + m && ly.abs() < p.h / 2.0 + m } });+ let on_pad = |x: f64, y: f64, layer: &str| pads.iter().find(|p| { if !p.on(layer) { return false; } let (dx, dy) = (x - p.x, y - p.y); let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin()); let (lx, ly) = (dx * c - dy * s, dx * s + dy * c); let open = opt.open_holes.iter().any(|h| crate::geom::dist(*h, (p.x, p.y)) < 0.01); let m = if p.drill > 0.0 && !open { PIN_RIM_MM } else { PAD_RIM_MM }; if p.round { (lx * lx + ly * ly).sqrt() < p.w.max(p.h) / 2.0 + m } else { lx.abs() < p.w / 2.0 + m && ly.abs() < p.h / 2.0 + m } }).map(|p| format!("{}.{}", p.part, p.num)); let mut uniq: Vec<(f64, f64, &'static str, f64, String)> = vec![]; let pad_side = |seen: &str| if seen.starts_with("top") { "F.Cu" } else { "B.Cu" };+ // every dome that does not become a via is kept with the reason, so the drop can be checked against the photo+ let mut via_drops = vec![];+ let mut drop = |v: &(f64, f64, &'static str, f64, String), why: &str, pad: Option<String>| via_drops.push(json!({"pos_mm": [v.0, v.1], "seen": v.2, "why": why, "pad": pad})); for v in vias.into_iter() {- if on_pad(v.0, v.1, pad_side(v.2)) { continue; }+ // seen on one face only, a dark spot in print or on a part's top is ink or a moulding mark, not a via+ if v.2 != "both-faces" {+ let face = if v.2.starts_with("top") { ft } else { fb };+ let inked = face.map(|f| { let t = &faces[f].t; let n = 12; let hit = (0..=n).filter(|k| { let (dx, dy) = if *k == n { (0.0, 0.0) } else { let a = *k as f64 / n as f64 * std::f64::consts::TAU; (PRINT_RING_MM * a.cos(), PRINT_RING_MM * a.sin()) };+ let (u, w_) = board::to_px(v.0 + dx, v.1 + dy); u >= 0.0 && w_ >= 0.0 && (u as usize) < t.w && (w_ as usize) < t.h && t.silk.d[w_ as usize * t.w + u as usize] }).count(); hit as f64 / (n + 1) as f64 }).unwrap_or(0.0);+ if inked >= PRINT_SHARE { drop(&v, "in silkscreen print", None); continue; }+ if let Some(b) = opt.bodies.iter().find(|b| b.layer == pad_side(v.2) && b.pads != 2 && b.holds(v.0, v.1, BODY_INSET_MM)) { drop(&v, "on a part's top", Some(b.part.clone())); continue; }+ }+ if let Some(pad) = on_pad(v.0, v.1, pad_side(v.2)) {+ // the library's land is drawn generous and a via may hug it: where the photo shows board all round the+ // spot it is not a dimple in solder, whatever the land pattern says. A plated pin keeps its wide rim.+ let face = if v.2.starts_with("top") { ft } else { fb };+ let plated = pads.iter().any(|p| p.drill > 0.0 && format!("{}.{}", p.part, p.num) == pad);+ let solder = face.map(|f| { let n = 16; (0..n).filter(|k| { let a = *k as f64 / n as f64 * std::f64::consts::TAU; covered(&faces[f].t, v.0 + OPEN_RING_MM * a.cos(), v.1 + OPEN_RING_MM * a.sin()) }).count() as f64 / n as f64 }).unwrap_or(1.0);+ if plated || solder >= 0.5 { drop(&v, "on a pad", Some(pad)); continue; }+ } let m = v.3 / 2.0 + PHOTO_EDGE_MM;- if v.0 - opt.edge.0 < m || opt.edge.2 - v.0 < m || v.1 - opt.edge.1 < m || opt.edge.3 - v.1 < m { continue; }+ if v.0 - opt.edge.0 < m || opt.edge.2 - v.0 < m || v.1 - opt.edge.1 < m || opt.edge.3 - v.1 < m { drop(&v, "at the photo edge", None); continue; } // the detector has already merged two looks at one via; this only guards a pair from both faces- if !uniq.iter().any(|u| crate::geom::dist((u.0, u.1), (v.0, v.1)) < 0.25) { uniq.push(v); }+ if !uniq.iter().any(|u| crate::geom::dist((u.0, u.1), (v.0, v.1)) < 0.25) { uniq.push(v); } else { drop(&v, "second look at a kept via", None); } } let vias = uniq; let dome_total = (tops.len(), bots.len());@@ -344,7 +450,7 @@ pub fn measure(faces: &mut [Face], pads: &[Pad], domes: &[SeenDome], pin_net: &H } let tracks_dropped = 0usize; // every island as first read: kept as an item, merged into one, or not copper, with the reason- let accounting = account(&*faces, &zone_of, &track_of, &index, &dropped);+ let accounting = account(&*faces, &zone_of, &track_of, &index, &dropped, &on_top); // via objects, and inner-layer evidence: a small isolated ring on both faces, joined to no track or pour let mut via_out = vec![]; let (mut inner, mut ringed) = (0, 0); let mut named = 0; for (vi, v) in vias.iter().enumerate() {@@ -381,7 +487,7 @@ pub fn measure(faces: &mut [Face], pads: &[Pad], domes: &[SeenDome], pin_net: &H let both = via_out.iter().filter(|v| v["seen"] == json!("both-faces")).count(); let report = json!({"islands": isl.len(), "absorbedDimples": 0, "tooSmallToDraw": small, "pours": zones.len(), "tracks": tracks.len(), "fragmentsDropped": tracks_dropped, "domes": {"top": dome_total.0, "bottom": dome_total.1}, "vias": {"total": via_out.len(), "bothFaces": both, "named": named, "inner": inner, "closedOnEveryFace": ringed, "facesShowVias": opt.faces_show_vias}, "islandsWithNet": isl.iter().filter(|i| i.net.is_some()).count(), "pinsJoined": joins.len(), "pinAdds": pin_adds.len(), "pinClashes": pin_clash.len(), "viaClashes": clashes.len(), "mixedIslands": mixed.len(), "trackPadConflicts": pad_conflicts.len(), "accounting": accounting["totals"]});- json!({"zones": zones, "traces": tracks, "vias": via_out, "groups": groups, "report": report, "pinAdds": pin_adds, "pinClashes": pin_clash, "viaClashes": clashes, "mixed": mixed, "joins": joins, "profile": profile, "accounting": accounting, "trackPadConflicts": pad_conflicts})+ json!({"zones": zones, "traces": tracks, "vias": via_out, "groups": groups, "report": report, "pinAdds": pin_adds, "pinClashes": pin_clash, "viaClashes": clashes, "mixed": mixed, "joins": joins, "profile": profile, "accounting": accounting, "trackPadConflicts": pad_conflicts, "viasDropped": via_drops}) } /// One centre line down a thin island: points in sub-image pixels, and the measured width in pixels at each.@@ -447,7 +553,7 @@ fn keep_vertices(p: &[(f64, f64)], w: &[f64], tol: f64) -> Vec<usize> { } /// The fate of every island as first read. The biggest first-read contributor to a final island owns it and is "kept"; others were merged into it.-fn account(faces: &[Face], zone_of: &HashMap<usize, String>, track_of: &HashMap<usize, Vec<String>>, index: &HashMap<(usize, u32), usize>, dropped: &HashMap<(usize, u32), String>) -> Value {+fn account(faces: &[Face], zone_of: &HashMap<usize, String>, track_of: &HashMap<usize, Vec<String>>, index: &HashMap<(usize, u32), usize>, dropped: &HashMap<(usize, u32), String>, on_top: &HashMap<(usize, u32), String>) -> Value { let (mut rows, mut kept, mut merged, mut not_copper) = (vec![], 0usize, 0usize, 0usize); let mut per_face = vec![]; for (fi, f) in faces.iter().enumerate() {@@ -470,7 +576,7 @@ fn account(faces: &[Face], zone_of: &HashMap<usize, String>, track_of: &HashMap< let live: Vec<(u32, usize)> = tg.iter().copied().filter(|x| !items_of(x.0).is_empty()).collect(); if live.is_empty() { fn_ += 1; row["fate"] = json!("not copper");- let why = tg.first().and_then(|x| dropped.get(&(fi, x.0)).cloned()).unwrap_or_else(|| format!("crumb of {n} pixels: too small to read a shape from"));+ let why = on_top.get(&(fi, a)).cloned().or_else(|| tg.first().and_then(|x| dropped.get(&(fi, x.0)).cloned())).unwrap_or_else(|| format!("crumb of {n} pixels: too small to read a shape from")); row["reason"] = json!(why); } else if owner.get(&live[0].0).map(|o| o.0) == Some(a) { fk += 1; row["fate"] = json!("kept"); row["items"] = json!(live.iter().flat_map(|x| items_of(x.0)).collect::<Vec<_>>());@@ -483,12 +589,83 @@ fn account(faces: &[Face], zone_of: &HashMap<usize, String>, track_of: &HashMap< kept += fk; merged += fm; not_copper += fn_; per_face.push(json!({"side": f.side, "read": fk + fm + fn_, "kept": fk, "merged": fm, "notCopper": fn_, "crumbs": t.steps.crumbs, "necksCut": t.steps.necks_cut, "pinchesCut": t.steps.pinches_cut, "joins": t.steps.joined, "joinsRefused": t.steps.refused_joins})); }- json!({"totals": {"read": kept + merged + not_copper, "kept": kept, "merged": merged, "notCopper": not_copper}, "perFace": per_face, "islands": rows})+ let on_parts = rows.iter().filter(|r| r["reason"].as_str().is_some_and(|w| w.starts_with("on the body of "))).count();+ json!({"totals": {"read": kept + merged + not_copper, "kept": kept, "merged": merged, "notCopper": not_copper, "onPartBodies": on_parts}, "perFace": per_face, "islands": rows}) } #[cfg(test)] mod tests { use super::*;+ fn body(part: &str, layer: &'static str, pads: usize) -> Body { Body { part: part.into(), layer, x: 105.0, y: 105.0, rot: 0.0, bx: (-3.0, -3.0, 3.0, 3.0), pads } }+ fn fill(l: &mut [u32], w: usize, id: u32, x0: f64, y0: f64, x1: f64, y1: f64) { let (a, b) = (board::to_px(x0, y0), board::to_px(x1, y1)); for v in a.1 as usize..b.1 as usize { for u in a.0 as usize..b.0 as usize { l[v * w + u] = id; } } }+ #[test]+ fn print_on_a_chip_is_not_copper_and_what_lies_beside_or_reaches_in_stays() {+ // a 6 mm chip at (105, 105) on a 10 mm board+ let (w, h) = (400, 400); let mut l = vec![0u32; w * h];+ fill(&mut l, w, 1, 104.0, 104.8, 104.5, 105.1); // a letter in the middle of the chip+ fill(&mut l, w, 2, 101.6, 104.0, 102.4, 106.0); // a terminal across the chip's edge at x = 102: its pad+ fill(&mut l, w, 3, 100.5, 100.5, 101.5, 109.5); // a pour beside the chip+ fill(&mut l, w, 4, 106.5, 107.0, 107.5, 109.8); // copper reaching in from outside+ fill(&mut l, w, 5, 107.0, 102.3, 107.4, 102.9); // a mark near the corner, still a quarter of a millimetre in+ let chip = [body("U5", "F.Cu", 0)];+ // nothing on the chip has the board's colour; everything off it does+ let mut col = vec![true; w * h]; for (i, v) in l.iter().enumerate() { if *v == 1 || *v == 5 { col[i] = false; } }+ assert_eq!(on_bodies(&l, &col, w, h, "F.Cu", &chip), vec![(1, "U5".to_string()), (5, "U5".to_string())]);+ // the other face has no such part, and a two-pad part has no top to read print on+ assert!(on_bodies(&l, &col, w, h, "B.Cu", &chip).is_empty());+ assert!(on_bodies(&l, &col, w, h, "F.Cu", &[body("C1", "F.Cu", 2)]).is_empty());+ // a chip with pads in its footprint is a chip all the same+ assert_eq!(on_bodies(&l, &col, w, h, "F.Cu", &[body("U1", "F.Cu", 48)]).len(), 2);+ // an outline drawn too large takes in board beside the real part: copper there has the board's colour and stays+ for (i, v) in l.iter().enumerate() { if *v == 5 { col[i] = true; } }+ assert_eq!(on_bodies(&l, &col, w, h, "F.Cu", &chip), vec![(1, "U5".to_string())]);+ }+ #[test]+ fn the_boards_colour_is_read_off_the_face() {+ // green mask over copper and laminate, a chip with white print, a warm metal lid; then the same board with a blue mask+ for (copper, laminate) in [([90u8, 150, 95], [50u8, 100, 55]), ([70, 95, 160], [40, 55, 110])] {+ let (w, h) = (60u32, 20u32);+ let img = image::RgbImage::from_fn(w, h, |x, _| image::Rgb(if x < 20 { copper } else if x < 30 { laminate } else if x < 40 { [200, 198, 196] } else if x < 50 { [165, 140, 125] } else { [30, 30, 32] }));+ let labels: Vec<u32> = (0..w * h).map(|i| if i % w < 20 { 1 } else if (30..40).contains(&(i % w)) { 2 } else { 0 }).collect();+ let m = board_coloured(&img, &labels);+ assert!(m.d[5] && m.d[25], "copper and laminate under the mask");+ assert!(!m.d[35] && !m.d[45] && !m.d[55], "print, a lid and black plastic");+ }+ // a board whose copper reads grey: colour cannot tell, so everything counts as board+ let grey = image::RgbImage::from_pixel(10, 10, image::Rgb([120, 121, 119]));+ assert!(board_coloured(&grey, &vec![1; 100]).d.iter().all(|v| *v));+ }+ #[test]+ fn copper_seen_inside_an_open_part_stays() {+ // the same outline round a connector open in the middle: board shows inside it, a pour over a third of it and a track end wholly inside+ let (w, h) = (400, 400); let mut l = vec![0u32; w * h];+ fill(&mut l, w, 1, 102.5, 102.5, 107.5, 104.5);+ fill(&mut l, w, 2, 104.0, 105.5, 104.5, 106.5);+ // colour left out of it: say none of it has the board's colour+ let col = vec![false; w * h];+ assert!(on_bodies(&l, &col, w, h, "F.Cu", &[body("J1", "F.Cu", 0)]).is_empty());+ // without the pour the track end alone would read as print+ let mut only = vec![0u32; w * h]; fill(&mut only, w, 2, 104.0, 105.5, 104.5, 106.5);+ assert_eq!(on_bodies(&only, &col, w, h, "F.Cu", &[body("J1", "F.Cu", 0)]).len(), 1);+ }+ #[test]+ fn a_candidate_region_that_is_a_chips_top_is_left_out() {+ // the gap lines enclose the whole chip and a little of its shadow; beside it a pour of the board's colour, and one inside an open part+ let (w, h) = (400, 400); let mut l = vec![0u32; w * h];+ fill(&mut l, w, 1, 101.9, 101.9, 108.1, 108.1);+ fill(&mut l, w, 2, 100.2, 100.2, 101.5, 109.8);+ let mut col = vec![false; w * h]; for (i, v) in l.iter().enumerate() { if *v == 2 { col[i] = true; } }+ assert_eq!(regions_on_bodies(&l, &col, w, h, "F.Cu", &[body("U5", "F.Cu", 0)]), vec![(1, "U5".to_string())]);+ assert!(regions_on_bodies(&l, &vec![true; w * h], w, h, "F.Cu", &[body("J1", "F.Cu", 0)]).is_empty(), "board seen through an open part");+ // the island rule does not take the same region: filled to the brim, the outline is not a package top to it+ assert!(on_bodies(&l, &col, w, h, "F.Cu", &[body("U5", "F.Cu", 0)]).is_empty());+ }+ #[test]+ fn a_turned_part_is_tested_in_its_own_frame() {+ // 2.6 by 2.0 mm drawn along x, placed at 90 degrees: it stands 2.6 mm tall on the board+ let b = Body { part: "L2".into(), layer: "F.Cu", x: 108.0, y: 123.7, rot: 90.0, bx: (-1.3, -1.0, 1.3, 1.0), pads: 0 };+ assert!(b.holds(108.0, 124.9, 0.0)); assert!(!b.holds(109.2, 123.7, 0.0)); assert!(!b.holds(108.0, 124.9, BODY_INSET_MM));+ } #[test] fn an_island_at_the_photos_edge_keeps_sane_coordinates() { let p = poly_mm(&[(0.0, 0.0), (41.0, 1.0)], -1.0, -1.0);
src/kicad_out.rs+99−1@@ -59,13 +59,31 @@ pub fn place(x: f64, y: f64, rot: f64, lx: f64, ly: f64) -> (f64, f64) { (x + lx * c + ly * s, y - lx * s + ly * c) } +/// Turn a library footprint over onto the bottom face, as KiCad's flip does: mirrored in local y with its angles negated, and every front layer swapped for the back one.+pub fn flip_footprint(n: &mut N) {+ fn swap(s: &str) -> Option<String> { let q = s.trim_matches('"'); if let Some(r) = q.strip_prefix("F.") { Some(format!("\"B.{r}\"")) } else { q.strip_prefix("B.").map(|r| format!("\"F.{r}\"")) } }+ fn walk(n: &mut N) {+ let head = n.head().to_string();+ if let N::L(v) = n {+ if matches!(head.as_str(), "layer" | "layers") { for c in v.iter_mut().skip(1) { if let N::A(s) = c { if let Some(t) = swap(s) { *s = t; } } } return; }+ if matches!(head.as_str(), "at" | "start" | "end" | "mid" | "center" | "xy") && v.len() >= 3 {+ if let N::A(s) = &mut v[2] { if let Ok(y) = s.parse::<f64>() { *s = format!("{}", -y); } }+ if head == "at" && v.len() >= 4 { if let N::A(s) = &mut v[3] { if let Ok(a) = s.parse::<f64>() { *s = format!("{}", -a); } } }+ return;+ }+ for c in v.iter_mut() { walk(c); }+ }+ }+ walk(n);+}+ /// One placed footprint node and its pads in board coordinates. pub fn placed(text: &str, id: &str, part: &Value, nets: &HashMap<String, String>, net_idx: &HashMap<String, usize>) -> Result<(N, Vec<Pad>), String> { let mut n = sexp::parse(text)?; let r = part["ref"].as_str().unwrap_or("?").to_string(); let (x, y) = pt(&part["pos_mm"]); let rot = fv(part, "rot"); let side = part["side"].as_str().unwrap_or("top");- if side == "bottom" { return Err(format!("{r}: bottom-side parts are not written yet in 0.2; place it on the top or add the flip")); }+ if side == "bottom" { flip_footprint(&mut n); } let items = n.items_mut(); items[1] = q(id); items.retain(|c| !matches!(c.head(), "version" | "generator" | "generator_version" | "embedded_fonts" | "uuid" | "at" | "tedit"));@@ -118,6 +136,52 @@ pub fn pads_of(cache: &Path, parts: &[Value]) -> Vec<Pad> { out } +/// A placed part's package as its footprint draws it on the fabrication layer: the box round those lines in the footprint's own+/// frame (turned over for a part on the bottom), with the part's place and rotation. `pads`: how many pad numbers the footprint has.+#[derive(Clone, Debug)]+pub struct Body { pub part: String, pub layer: &'static str, pub x: f64, pub y: f64, pub rot: f64, pub bx: (f64, f64, f64, f64), pub pads: usize }++impl Body {+ /// Whether a board point lies in the body drawn in by `inset` mm on every side: the inverse of `place`.+ pub fn holds(&self, x: f64, y: f64, inset: f64) -> bool {+ let (dx, dy) = (x - self.x, y - self.y); let (c, s) = (self.rot.to_radians().cos(), self.rot.to_radians().sin());+ let (lx, ly) = (dx * c - dy * s, dx * s + dy * c);+ lx >= self.bx.0 + inset && lx <= self.bx.2 - inset && ly >= self.bx.1 + inset && ly <= self.bx.3 - inset+ }+ pub fn corners(&self) -> [(f64, f64); 4] { let b = self.bx; [(b.0, b.1), (b.2, b.1), (b.2, b.3), (b.0, b.3)].map(|q| place(self.x, self.y, self.rot, q.0, q.1)) }+}++/// The box of what a placed footprint draws on its fabrication layer, in its own frame. None when it draws nothing there.+fn fab_box(n: &N) -> Option<(f64, f64, f64, f64)> {+ let mut pts: Vec<(f64, f64)> = vec![];+ let at = |c: &N, k: &str| c.child(k).and_then(|a| Some((a.num(0)?, a.num(1)?)));+ for c in n.items() {+ if !matches!(c.head(), "fp_rect" | "fp_line" | "fp_poly" | "fp_circle" | "fp_arc") || !c.child("layer").and_then(|l| l.arg(0)).map(|l| l.ends_with(".Fab")).unwrap_or(false) { continue; }+ if c.head() == "fp_circle" { if let (Some(m), Some(e)) = (at(c, "center"), at(c, "end")) { let r = crate::geom::dist(m, e); pts.push((m.0 - r, m.1 - r)); pts.push((m.0 + r, m.1 + r)); } continue; }+ for k in ["start", "mid", "end"] { if let Some(q) = at(c, k) { pts.push(q); } }+ if let Some(ps) = c.child("pts") { for q in ps.children("xy") { if let (Some(x), Some(y)) = (q.num(0), q.num(1)) { pts.push((x, y)); } } }+ }+ if pts.is_empty() { return None; }+ Some(pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, q| (b.0.min(q.0), b.1.min(q.1), b.2.max(q.0), b.3.max(q.1))))+}++/// The bodies of the placed parts whose footprints are cached or generated, as `pads_of` reads their pads. Never fetches.+pub fn bodies_of(cache: &Path, parts: &[Value]) -> Vec<Body> {+ let (none, idx): (HashMap<String, String>, HashMap<String, usize>) = (HashMap::new(), HashMap::new());+ let mut out = vec![];+ for p in parts {+ let id = p["footprint"].as_str().unwrap_or("");+ let local = id.starts_with("generated:") || id.split_once(':').map(|(lib, name)| cache.join(lib).join(format!("{name}.kicad_mod")).is_file()).unwrap_or(false);+ if !local { continue; }+ let Ok((n, ps)) = footprint_text(cache, id, p).and_then(|t| placed(&t, id, p, &none, &idx)) else { continue };+ let Some(bx) = fab_box(&n) else { continue };+ let nums: std::collections::BTreeSet<&str> = ps.iter().map(|q| q.num.as_str()).filter(|s| !s.is_empty()).collect();+ let (x, y) = pt(&p["pos_mm"]);+ out.push(Body { part: p["ref"].as_str().unwrap_or("?").to_string(), layer: if p["side"].as_str() == Some("bottom") { "B.Cu" } else { "F.Cu" }, x, y, rot: fv(p, "rot"), bx, pads: nums.len() });+ }+ out+}+ /// Raster of board copper at `res` px/mm over the board box. pub struct Grid { pub x0: f64, pub y0: f64, pub res: f64, pub w: usize, pub h: usize } impl Grid {@@ -540,6 +604,40 @@ pub fn board_inside(g: &Grid, outline: &[(f64, f64)], radius: f64) -> Mask { pub fn cache_dir(flow: &Path) -> PathBuf { flow.join("lib").join("fp") } +#[cfg(test)]+mod flip_tests {+ use super::*;+ use serde_json::json;+ #[test]+ fn a_body_is_the_fab_drawing_at_the_parts_place_on_either_face() {+ let dir = std::env::temp_dir().join(format!("bodies-{}", std::process::id())); std::fs::create_dir_all(dir.join("Read")).unwrap();+ std::fs::write(dir.join("Read/Body_3x2.kicad_mod"), r#"(footprint "Body_3x2" (layer "F.Cu") (attr smd)+ (property "Reference" "REF**" (at 0 -2) (layer "F.SilkS"))+ (fp_rect (start -1 -0.5) (end 2 1.5) (stroke (width 0.1) (type solid)) (fill none) (layer "F.Fab"))+ (fp_rect (start -1.5 -1) (end 2.5 2) (stroke (width 0.05) (type solid)) (fill none) (layer "F.CrtYd")))"#).unwrap();+ let parts = [json!({"ref": "U1", "footprint": "Read:Body_3x2", "pos_mm": [110.0, 110.0], "rot": 0, "side": "top"}), json!({"ref": "U2", "footprint": "Read:Body_3x2", "pos_mm": [120.0, 110.0], "rot": 0, "side": "bottom"}), json!({"ref": "U3", "footprint": "Read:Not_cached", "pos_mm": [0.0, 0.0], "rot": 0, "side": "top"})];+ let b = bodies_of(&dir, &parts);+ let _ = std::fs::remove_dir_all(&dir);+ assert_eq!(b.len(), 2, "a footprint that is not cached has no body to read");+ assert_eq!((b[0].layer, b[0].pads, b[0].bx), ("F.Cu", 0, (-1.0, -0.5, 2.0, 1.5)), "the courtyard is not the body");+ assert!(b[0].holds(111.9, 111.4, 0.0) && !b[0].holds(111.9, 109.4, 0.0));+ // turned over, the drawing is mirrored top to bottom+ assert_eq!(b[1].layer, "B.Cu");+ assert!(b[1].holds(121.9, 108.6, 0.0) && !b[1].holds(121.9, 111.4, 0.0));+ }+ #[test]+ fn a_bottom_part_lands_mirrored_on_the_back_layers() {+ let text = r#"(footprint "X" (layer "F.Cu") (fp_line (start 1 2) (end 3 4) (layer "F.SilkS")) (pad "1" smd rect (at -0.5 0.25 90) (size 0.6 0.5) (layers "F.Cu" "F.Mask" "F.Paste")))"#;+ let part = json!({"ref": "C1", "side": "bottom", "pos_mm": [110.0, 120.0], "rot": 0.0});+ let (n, pads) = placed(text, "L:X", &part, &HashMap::new(), &HashMap::new()).unwrap();+ assert_eq!(pads.len(), 1);+ assert!(pads[0].on("B.Cu") && !pads[0].on("F.Cu"), "{:?}", pads[0].layers);+ assert!((pads[0].x - 109.5).abs() < 1e-9 && (pads[0].y - 119.75).abs() < 1e-9, "{:?}", pads[0]);+ let body = format!("{n:?}");+ assert!(body.contains("B.SilkS") && !body.contains("F.SilkS"));+ }+}+ #[cfg(test)] mod tests { use super::*;
src/main.rs+294−48@@ -27,6 +27,7 @@ mod kicad_out; mod ledger; mod out; mod parts;+mod passives; mod photolayers; mod photomat; mod prep;@@ -79,8 +80,14 @@ enum Cmd { Ledger, /// Measure every photo: outline, straightened image, sharpness, holes, hole rows, part bodies, bottom-to-top mapping Prep,- /// Pixels per mm from hole pitches, confirmed by a datasheet body: `scale --body <id>=<L>x<W> --source "..."`; then the board frame- Scale { #[arg(long)] body: Option<String>, #[arg(long)] source: Option<String> },+ /// Pixels per mm from hole pitches, confirmed by a datasheet body: `scale --body <id>=<L>x<W> --source "..."`, or through four mounting holes at a stated spacing: `scale --holes <x>x<y> --board <x>x<y> --source "..."`; then the board frame+ Scale { #[arg(long)] body: Option<String>, #[arg(long)] source: Option<String>,+ /// The mounting holes' centre spacing in mm, `<x>x<y>`, for a board with no rows of holes: four holes frame each photo. Needs --board and --source+ #[arg(long)] holes: Option<String>,+ /// The board's size in mm, `<x>x<y>`, from the same source; the hole pattern is taken as centred on it+ #[arg(long)] board: Option<String>,+ /// Which way the board was turned over, `x` or `y`, when neither holes nor through features can say+ #[arg(long)] flip: Option<String> }, /// `detect parts [--bottom]`: part candidates with package matches, crops and contact sheets (--bottom: the bottom face has parts too) Detect { what: String, #[arg(long)] bottom: bool }, /// `parts init [--force]` (parts.json from the candidates), `parts snap` (measured position and rotation from each footprint's pads), `parts reconcile` (dismiss candidates that are fragments of placed parts)@@ -94,7 +101,9 @@ enum Cmd { /// Write the KiCad project (footprints, outline, fitted and filled pours, traces, vias): `build [--layers N]` Build { #[arg(long)] layers: Option<usize> }, /// Check a step: prep | parts | blocks | copper | mimic --pcb <board> | nets | layers [--pcb <board>] | fidelity [--pcb <board>] | review | tests- Check { what: String, #[arg(long)] pcb: Option<String> },+ Check { what: String, #[arg(long)] pcb: Option<String>,+ /// Run this check though earlier steps are not done, for testing a stage on its own; say why. The check is logged as such and does not count as passing that step+ #[arg(long)] skip_earlier: Option<String> }, /// `layers trace`: silk, mask openings, exposed pads, holes and outline read from the photos into photo-layers.json; the next `build` draws them Layers { what: String }, /// Save the KiCad project as it stands: `snapshot --name mimic --dir <project folder>`. Goes stale when copper.json or parts.json change; run it again then, and the old one is kept@@ -103,7 +112,7 @@ enum Cmd { Connect { what: String, #[arg(long)] vias: Option<String>, #[arg(long)] layers: Option<String>, #[arg(long, default_value_t = 480.0)] seconds: f64, #[arg(long, default_value_t = 3)] stall: usize, #[arg(long, default_value_t = 8)] max_layers: usize }, /// `review` or `review pass [--target 90] [--max-passes 20] [--note "..."]`: one pass of the loop: measure, score every category, draw the audits still needed, and say CONTINUE, TARGET MET, STALLED or STOPPED. `review measure` lists disagreements only Review { what: Option<String>, #[arg(long)] target: Option<f64>, #[arg(long)] max_passes: Option<usize>, #[arg(long)] note: Option<String> },- /// `audit draw --category <c> [--n <per stratum>]`, `audit show --category <c>`, `audit status`, `audit verdict --category <c> --sample <seed> --item <a001> --verdict ok|wrong|cannot_tell --who <auditor> [--note ..] [--evidence <file> --kind <xray|edge-micrograph|continuity-log|netlist|bom>]`, or `--file <[{item,verdict,note}]>` for a whole sheet+ /// `audit draw --category <c> [--n <per stratum>]`, `audit show --category <c>`, `audit status`, `audit verdict --category <c> --sample <seed> --item <a001> --verdict ok|wrong|cannot_tell --who <auditor> [--note ..] [--evidence <file> --kind <xray|edge-micrograph|continuity-log|netlist|bom>]`, or `--file <[{item,verdict,note}]>` for a whole sheet. `audit preview [--n 24] --file <folder>` writes copper crops outside the audit, to check legibility Audit { what: String, #[arg(long)] category: Option<String>, #[arg(long)] n: Option<usize>, #[arg(long)] sample: Option<String>, #[arg(long)] item: Option<String>, #[arg(long)] verdict: Option<String>, #[arg(long)] who: Option<String>, #[arg(long)] note: Option<String>, #[arg(long)] evidence: Option<String>, #[arg(long)] kind: Option<String>, #[arg(long)] file: Option<String> }, /// Every category's computed score and the inputs behind it (nothing here reads a typed level), plus evidence tag counts Confidence,@@ -207,11 +216,25 @@ fn primary<'a>(prep: &'a Value, side: &str) -> Option<&'a Value> { } /// Corner radius of the board outline: how far along each corner's diagonal the board starts.+/// The same where the table is not a warm wood: along the diagonal until mask starts. Framed through its holes the board's corner is exact, so this does not lean on the table's colour.+fn corner_radius_to_mask(top: &image::RgbImage) -> f64 {+ let tone = prep::mask_tone(top);+ let (w, h) = (top.width() as i64, top.height() as i64);+ let mut ds = vec![];+ for (cx, cy, sx, sy) in [(0i64, 0i64, 1i64, 1i64), (w - 1, 0, -1, 1), (w - 1, h - 1, -1, -1), (0, h - 1, 1, -1)] {+ let mut d = 0; while d < 200 && !prep::is_mask_green(top.get_pixel((cx + sx * d) as u32, (cy + sy * d) as u32), &tone) { d += 1; }+ ds.push(d as f64 * std::f64::consts::SQRT_2 / board::RES);+ }+ ds.sort_by(|a, b| a.partial_cmp(b).unwrap());+ (((ds[1] + ds[2]) / 2.0 / (std::f64::consts::SQRT_2 - 1.0)) * 10.0).round() / 10.0+}+ fn corner_radius(top: &image::RgbImage) -> f64 {+ let tone = prep::mask_tone(top); let (w, h) = (top.width() as i64, top.height() as i64); let mut ds = vec![]; for (cx, cy, sx, sy) in [(0i64, 0i64, 1i64, 1i64), (w - 1, 0, -1, 1), (w - 1, h - 1, -1, -1), (0, h - 1, 1, -1)] {- let mut d = 0; while d < 200 { let p = top.get_pixel((cx + sx * d) as u32, (cy + sy * d) as u32); let (hh, s, v) = raster::hsv(p); let wood = (0.0..45.0).contains(&hh) && s > 0.3 && v < 0.75 && !prep::is_mask_green(p); if !wood { break; } d += 1; }+ let mut d = 0; while d < 200 { let p = top.get_pixel((cx + sx * d) as u32, (cy + sy * d) as u32); let (hh, s, v) = raster::hsv(p); let wood = (0.0..45.0).contains(&hh) && s > 0.3 && v < 0.75 && !prep::is_mask_green(p, &tone); if !wood { break; } d += 1; } ds.push(d as f64 * std::f64::consts::SQRT_2 / board::RES); } ds.sort_by(|a, b| a.partial_cmp(b).unwrap());@@ -219,6 +242,91 @@ fn corner_radius(top: &image::RgbImage) -> f64 { ((med / (std::f64::consts::SQRT_2 - 1.0)) * 10.0).round() / 10.0 } +/// `scale --holes`: a board with four mounting holes at a stated spacing and no rows of holes. Each photo is framed through its own four holes, which fixes scale and perspective+/// together, and the corners those holes imply replace the ones fitted to edges in prep.json, so everything later reads one frame. The flip comes from the holes if they can say, and from through features if the pattern is symmetric.+fn scale_by_holes(p: &mut ledger::Project, fl: &flow::Flow, th: &str, prep_doc: &mut Value, hs: &str, bs: &str, source: &str, flip: Option<&str>) {+ let f = p.flow_dir.clone();+ if source.is_empty() { err("--source: name the document that gives the hole spacing and board size", &[]); }+ let dims = |s: &str, what: &str| -> (f64, f64) { s.split_once('x').and_then(|(a, b)| Some((a.parse::<f64>().ok()?, b.parse::<f64>().ok()?))).unwrap_or_else(|| err(&format!("{what}: expected <x>x<y> in mm, got {s}"), &[])) };+ let (spacing, size) = (dims(hs, "--holes"), dims(bs, "--board"));+ if spacing.0 >= size.0 || spacing.1 >= size.1 { err("the hole spacing must fit inside the board", &[]); }+ let top_rec = primary(prep_doc, "top").cloned().unwrap_or_else(|| err("no top photo measured", &[]));+ let bot_rec = primary(prep_doc, "bottom").cloned();+ let photo = |r: &Value| f.join("photos").join(r["file"].as_str().unwrap());+ let mut frames = serde_json::Map::new();+ let mut framed = |r: &Value, out: &mut serde_json::Map<String, Value>| -> [geom::P; 4] {+ let hf = board::hole_frame(&photo(r), r, spacing, size).unwrap_or_else(|e| err(&format!("{}: {e}", r["file"].as_str().unwrap_or("?")), &["Look at evidence/prep-<photo>.jpg: the four holes must be found.".into()]));+ let r3 = |x: f64| (x * 1000.0).round() / 1000.0;+ out.insert(r["file"].as_str().unwrap().to_string(), json!({"pxPerMm": [r3(hf.px_mm.0), r3(hf.px_mm.1)], "holesPx": hf.holes.iter().map(|q| json!([r3(q.0), r3(q.1)])).collect::<Vec<_>>(),+ "fittedCornersOffMm": r3(hf.fitted_off_mm), "openingOffRingMm": r3(hf.opening_off_mm), "ringFitRmsPx": r3(hf.ring_fit_px), "cornersPx": hf.corners.iter().map(|q| json!([r3(q.0), r3(q.1)])).collect::<Vec<_>>()}));+ hf.corners+ };+ let tc = framed(&top_rec, &mut frames);+ let bc = bot_rec.as_ref().map(|b| framed(b, &mut frames));+ let set_corners = |doc: &mut Value, file: &str, c: &[geom::P; 4]| { for ph in doc["photos"].as_array_mut().into_iter().flatten() { if ph["file"].as_str() == Some(file) { if ph["cornersFitted"].is_null() { ph["cornersFitted"] = ph["corners"].clone(); } ph["corners"] = json!(c.iter().map(|q| json!([q.0, q.1])).collect::<Vec<_>>()); } } };+ set_corners(prep_doc, top_rec["file"].as_str().unwrap(), &tc);+ if let (Some(b), Some(c)) = (&bot_rec, &bc) { set_corners(prep_doc, b["file"].as_str().unwrap(), c); }+ let tp = &frames[top_rec["file"].as_str().unwrap()]["pxPerMm"];+ let k = (tp[0].as_f64().unwrap_or(0.0) + tp[1].as_f64().unwrap_or(0.0)) / 2.0;+ let (wmm, hmm) = size;+ let top_img = board::to_frame(&photo(&top_rec), &tc, &board::frame_corners(wmm, hmm, "top", "x"), wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]));+ save(&top_img, &f.join("board/top.jpg"));+ let mut align = json!({"shift_mm": [0.0, 0.0], "pairs": 0, "residual_mm": Value::Null, "method": "each face framed through its own holes"});+ let mut axis = prep_doc["flip"]["axis"].as_str().unwrap_or("x").to_string();+ let mut h: Vec<String> = vec![];+ if let (Some(b), Some(c)) = (&bot_rec, &bc) {+ let bx = board::to_frame(&photo(b), c, &board::frame_corners(wmm, hmm, "bottom", "x"), wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]));+ let by = board::to_frame(&photo(b), c, &board::frame_corners(wmm, hmm, "bottom", "y"), wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]));+ let (m, o) = (prep_doc["flip"]["matches"].as_u64().unwrap_or(0), prep_doc["flip"]["other"].as_u64().unwrap_or(0));+ let (by_detail, cx, cy) = board::flip_by_detail(&top_img, &bx, &by);+ let (chosen, how) = match flip {+ Some(a) if a == "x" || a == "y" => (a.to_string(), "stated with --flip"),+ Some(a) => err(&format!("--flip {a}: expected x or y"), &[]),+ None => match by_detail { Some(a) if m.abs_diff(o) <= 1 => (a, "through features: the hole pattern is symmetric"), _ if m.abs_diff(o) > 1 => (axis.clone(), "the holes"),+ _ => err(&format!("cannot tell which way the board was turned over: the holes agree both ways and through features correlate {cx:.3} mirrored in x, {cy:.3} in y"), &["Look at both photos and give --flip x or --flip y, saying why in your report.".into()]) },+ };+ axis = chosen;+ h.push(format!("bottom turned over {} ({how}; plain mask detail correlates {cx:.3} mirrored in x, {cy:.3} in y)", if axis == "y" { "top-to-bottom" } else { "left-to-right" }));+ align["flip"] = json!({"axis": axis, "how": how, "detailNccX": (cx * 1000.0).round() / 1000.0, "detailNccY": (cy * 1000.0).round() / 1000.0});+ prep_doc["flip"] = json!({"axis": axis, "matches": m, "other": o, "how": how});+ let bdst = board::frame_corners(wmm, hmm, "bottom", &axis);+ let first = if axis == "y" { by } else { bx };+ // what four holes leave: lay the faces on each other through what shows on both, where there is enough of it+ let mut kept = board::tile_pairs(&top_img, &first);+ let tiles = kept.len();+ let fit = regframe::Quad::fit(&kept).and_then(|mut q| { for _ in 0..3 { let mut res: Vec<f64> = kept.iter().map(|x| geom::dist(q.apply(x.0), x.1)).collect(); res.sort_by(|a, b| a.partial_cmp(b).unwrap()); let lim = (res[res.len() / 2] * 3.0).max(1.5); kept.retain(|x| geom::dist(q.apply(x.0), x.1) <= lim); q = regframe::Quad::fit(&kept)?; } Some(q) });+ let by_tiles = fit.filter(|_| kept.len() >= 12).map(|q| { let mut r = regframe::report(&kept, &q, board::RES); r["tilesCorrelated"] = json!(tiles); r["through"] = json!("correlated 5 mm tiles"); (q, r) });+ // vias that show on both faces, where there are enough: use whichever holds better on what was left out of its fit+ let by_pits = board::pit_pairs(&top_img, &first).and_then(|prs| regframe::Quad::fit(&prs).map(|q| { let mut r = regframe::report(&prs, &q, board::RES); r["through"] = json!("vias showing on both faces"); (q, r) }));+ let loo = |r: &Value| r["leaveOneOutRmsMm"].as_f64().unwrap_or(f64::MAX);+ if let (Some(t), Some(v)) = (&by_tiles, &by_pits) { h.push(format!("face to face, left out of the fit: tiles {} mm RMS, vias {} mm RMS", t.1["leaveOneOutRmsMm"], v.1["leaveOneOutRmsMm"])); }+ let chosen_fit = match (by_tiles, by_pits) { (Some(t), Some(v)) => Some(if loo(&v.1) < loo(&t.1) { v } else { t }), (t, v) => t.or(v) };+ let bimg = match chosen_fit {+ Some((q, r)) => { h.push(format!("bottom laid on top through {} {}: {}", r["pairs"], r["through"].as_str().unwrap_or(""), r)); align["registration"] = r; regframe::warp(&photo(b), c, &bdst, Some(&q), first.width(), first.height(), 0).unwrap_or_else(|e| err(&e, &[])) }+ None => { h.push(format!("only {tiles} tiles correlate between the faces and too few vias show on both, so no warp: the bottom rests on its four holes alone")); first }+ };+ save(&bimg, &f.join("board/bottom.jpg"));+ save(&copper::grid_image(&bimg, 5.0), &f.join("board/bottom-grid.jpg"));+ wr(&f.join("detect/holes-bottom.json"), &json!({"holes": holes_mm(&bimg)}));+ }+ wr(&f.join("prep.json"), prep_doc);+ save(&copper::grid_image(&top_img, 5.0), &f.join("board/top-grid.jpg"));+ let top_holes = holes_mm(&top_img);+ let rows: Vec<Value> = prep::hole_rows(&top_holes.iter().map(|h| json!({"x": fv(h, "x"), "y": fv(h, "y"), "outer": fv(h, "outer_mm")})).collect::<Vec<_>>()).into_iter().map(|mut r| { r["pitch_mm"] = json!((fv(&r, "pitch") * 1000.0).round() / 1000.0); r }).collect();+ wr(&f.join("detect/holes-top.json"), &json!({"holes": top_holes, "rows": rows}));+ let radius = corner_radius_to_mask(&top_img);+ let doc = json!({"pxPerMm": (k * 1000.0).round() / 1000.0, "method": "four mounting holes at a stated spacing", "holes": {"spacingMm": [spacing.0, spacing.1], "boardMm": [size.0, size.1], "source": source, "perPhoto": frames}, "candidates": [],+ "board_mm": [wmm, hmm], "corner_mm": radius, "frame": {"origin": [board::ORIGIN, board::ORIGIN], "pxPerMm": board::RES, "bottomFlip": axis}, "alignment": align, "at": ledger::now()});+ wr(&f.join("scale.json"), &doc);+ p.log(th, "scale", doc.clone());+ reach(p, fl, "scale");+ h.insert(0, format!("{k:.2} px/mm on the top photo through its four holes, {source}; board {wmm} x {hmm} mm, corner radius about {radius} mm"));+ for (file, v) in &frames { h.push(format!("{file}: {} px/mm, the corners fitted to its edges were {} mm off, the opening's centre {} mm from the ring's", v["pxPerMm"], v["fittedCornersOffMm"], v["openingOffRingMm"])); }+ h.extend(views::record(&f, &p.state, "scale", "board frame written", th, None));+ h.extend(step_hint(fl, &f));+ ok("scale.json and the board frame written through the four holes (board/top.jpg, board/bottom.jpg, gridded copies)", &h);+}+ fn holes_mm(i: &image::RgbImage) -> Vec<Value> { prep::holes(i, 0.45 * board::RES, 4.5 * board::RES).into_iter().map(|h| { let (x, y) = board::to_mm(fv(&h, "x"), fv(&h, "y"));@@ -316,21 +424,40 @@ fn merge_routed(copper: &mut Value, rt: &Value) { /// Vias from the dome detector, written with the measurements that decided each candidate. fn write_domes(f: &std::path::Path) -> Vec<String> {- let (mut kept, mut all, mut lines) = (vec![], vec![], vec![]);+ let (mut kept, mut all, mut pit_all, mut lines) = (vec![], vec![], vec![], vec![]); let mut light = serde_json::Map::new();+ // each face first, because a flat face's pits are judged against what the other face shows+ let mut faces = vec![]; for side in ["top", "bottom"] { let file = f.join(format!("board/{side}.jpg")); if !file.is_file() { continue; }- let d = domes::measure(&img(&file));+ let i = img(&file);+ let d = domes::measure(&i); let coh = domes::face_coherence(&d); light.insert(side.to_string(), json!({"toward": [d.light.0, d.light.1], "coherence": coh, "oneLight": coh >= domes::FACE_COHERENCE})); let vs = domes::finish(&d); let flat = coh < domes::FACE_COHERENCE; lines.push(format!("{side}: {} candidates, {} read as domes, shading agreement {coh:.2}{}", d.cands.len(), vs.len(), if flat { ": this face is flat, so none of them count" } else { "" })); for c in &d.cands { all.push(domes::cand_json(c, side)); }- if !flat { for v in &vs { let (x, y) = board::to_mm(v.x, v.y); kept.push(json!({"side": side, "pos_mm": [(x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0], "diameter_mm": (v.d_mm * 1000.0).round() / 1000.0, "shape": v.shape, "z": (v.z * 100.0).round() / 100.0})); } }+ let pf = if flat { Some(domes::read_pits(&i)) } else { None };+ faces.push((side, flat, vs, pf, (i.width() as f64, i.height() as f64)));+ }+ let seen_on = |fc: &(&str, bool, Vec<domes::Dome>, Option<domes::PitFace>, (f64, f64))| -> Vec<(f64, f64)> { match &fc.3 { Some(pf) => pf.pits.iter().filter(|q| q.keep).map(|q| (q.x, q.y)).collect(), None => fc.2.iter().map(|v| (v.x, v.y)).collect() } };+ let spots: Vec<Vec<(f64, f64)>> = faces.iter().map(&seen_on).collect();+ for (k, (side, flat, vs, pf, wh)) in faces.iter().enumerate() {+ if !*flat { for v in vs { let (x, y) = board::to_mm(v.x, v.y); kept.push(json!({"side": side, "pos_mm": [(x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0], "diameter_mm": (v.d_mm * 1000.0).round() / 1000.0, "shape": v.shape, "z": (v.z * 100.0).round() / 100.0})); } continue; }+ // no one light means no dome highlights, but frontal light still shows a via as a dark pit, if the face has a population of them that lines up with the other face+ let Some(pf) = pf else { continue };+ let n = pf.pits.iter().filter(|q| q.keep).count();+ for q in &pf.pits { pit_all.push(domes::pit_json(q, side)); }+ let other: Vec<(f64, f64)> = spots.iter().enumerate().filter(|(j, _)| *j != k).flat_map(|(_, v)| v.iter().copied()).collect();+ let (hit, chance, sure) = domes::pits_confirmed(&spots[k], &other, *wh);+ let counts = pf.shows && sure;+ lines.push(format!("{side}: {} dark spots measured, {n} look like pits, median {:.3} mm, middle half within {:.2} of it; {hit} have a via on the other face within 0.1 mm, {chance} by chance{}", pf.pits.len(), pf.median_mm, pf.spread, if counts { ": this face shows its vias as pits" } else { ": they are not vias, so none of them count" }));+ if counts { for q in pf.pits.iter().filter(|q| q.keep) { let (x, y) = board::to_mm(q.x, q.y); kept.push(json!({"side": side, "pos_mm": [(x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0], "diameter_mm": (((q.len + q.wid) / 2.0) as f64 * 1000.0).round() / 1000.0, "shape": "pit", "z": (q.z as f64 * 100.0).round() / 100.0})); } } }- wr(&f.join("detect/domes.json"), &json!({"domes": kept, "light": light, "note": "vias read from the photos: position and diameter measured; shape says what was seen (ring: dark circle round a pad, arc: near arc of a via, blob: the dome itself, edge: a dome on a copper edge, its width the face's median)"}));+ wr(&f.join("detect/domes.json"), &json!({"domes": kept, "light": light, "note": "vias read from the photos: position and diameter measured; shape says what was seen (ring: dark circle round a pad, arc: near arc of a via, blob: the dome itself, edge: a dome on a copper edge, its width the face's median, pit: the dark spot a via makes under frontal light, its width the spot's, not the pad's)"})); wr(&f.join("detect/dome-candidates.json"), &json!({"candidates": all}));+ wr(&f.join("detect/pit-candidates.json"), &json!({"candidates": pit_all})); lines } @@ -377,6 +504,9 @@ fn drc_of(pcb: &Path, out: &Path) -> Value { match o { Ok(o) if o.status.success() => items::read_json(out).unwrap_or(json!({})), Ok(o) => json!({"error": String::from_utf8_lossy(&o.stderr).to_string()}), Err(e) => json!({"error": e.to_string()}) } } +/// Under this many known connections the routing test proposes nothing. The Driver had 253; the CM5 photos alone gave 8.+const MIN_CONNECTIONS: usize = 30;+ /// The proposal: the smallest stackup that closes; if none does, the smallest beyond which more layers stop /// helping. The same stackup routed again varies by about a quarter in open connections (6 layers on the Driver /// board: 25 to 34), so a smaller difference is not a reason for more layers.@@ -384,7 +514,20 @@ fn write_proposal(l: &mut Value, cands: &[Value], closed_at: Option<usize>) { let best_open = cands.iter().filter_map(|c| c["open"].as_u64()).min().unwrap_or(0) as f64; let best_cross = cands.iter().filter_map(|c| c["seenCrossingMm"].as_f64()).fold(f64::MAX, f64::min); let levelled = cands.iter().find(|c| c["open"].as_f64().unwrap_or(f64::MAX) <= best_open * 1.25 + 2.0 && c["seenCrossingMm"].as_f64().unwrap_or(f64::MAX) <= best_cross * 1.5 + 10.0).and_then(|c| c["layers"].as_u64()).map(|n| n as usize);- let pick = closed_at.or(levelled);+ // too few known connections and routing says nothing about the stackup: two layers close eight connections as well as ten do+ let known = cands.iter().filter_map(|c| c["connections"].as_u64()).max().unwrap_or(0);+ // and when no stackup closes more than another, what blocks the rest is not the layer count: routing says nothing about it+ let opens: Vec<u64> = cands.iter().filter_map(|c| c["open"].as_u64()).collect();+ let flat = closed_at.is_none() && opens.len() > 1 && opens.iter().max() == opens.iter().min();+ let pick = if (known as usize) < MIN_CONNECTIONS || flat { None } else { closed_at.or(levelled) };+ if pick.is_none() && flat && (known as usize) >= MIN_CONNECTIONS {+ l["proposed"] = Value::Null; l["tag"] = json!("UNKNOWN"); l["tests"] = json!(["T-layers"]);+ l["why"] = json!(format!("every stackup tried leaves the same {} of {known} connections open: more layers changed nothing, so the open ones are blocked by something else (hidden pins, missing footprints) and routing cannot tell the layer count. Measure the layers (cross-section, CT, or the edge under a microscope)", opens[0]));+ }+ if pick.is_none() && (known as usize) < MIN_CONNECTIONS {+ l["proposed"] = Value::Null; l["tag"] = json!("UNKNOWN"); l["tests"] = json!(["T-layers"]);+ l["why"] = json!(format!("only {known} connections are known, under {MIN_CONNECTIONS}: routing cannot tell one stackup from another. Name more pin nets, or measure the layers (cross-section, CT, or the edge under a microscope)"));+ } let trail: Vec<String> = cands.iter().map(|c| format!("{} layers: {} of {} connections open, {} new DRC errors, {} vias, {:.0} mm of track, {:.0} mm crossing seen copper", c["layers"], c["open"], c["connections"], c["drcErrors"], c["vias"], c["trackMm"].as_f64().unwrap_or(0.0), c["seenCrossingMm"].as_f64().unwrap_or(0.0))).collect(); if let Some(n) = pick { let ch = cands.iter().find(|c| c["layers"].as_u64() == Some(n as u64)).cloned().unwrap_or(json!({}));@@ -464,7 +607,10 @@ fn route_candidate(flow: &Path, n: usize, plane_nets: &[String], rl: &Value, sec let kicad_open = d["unconnected_items"].as_array().map(|a| a.len()).unwrap_or(0); wr(&dir.join("gate.json"), &json!({"newErrors": fresh.len(), "newErrorTypes": types, "inherited": known.len(), "kicadOpen": kicad_open, "new": fresh, "unconnected": d["unconnected_items"], "drcError": d["error"]})); std::fs::write(dir.join("route.log"), lines.join("\n") + "\n").ok();- let mut c = json!({"layers": n, "open": res["open"], "connections": res["connections"], "drcErrors": fresh.len(), "drcTypes": types, "inheritedErrors": known.len(), "kicadOpen": kicad_open,+ // a DRC that did not run says nothing: its counts are unknown, never zero+ let ran = d["error"].is_null() && d["violations"].is_array();+ let (drc_errors, kicad_open_v) = if ran { (json!(fresh.len()), json!(kicad_open)) } else { (Value::Null, Value::Null) };+ let mut c = json!({"layers": n, "open": res["open"], "connections": res["connections"], "drcErrors": drc_errors, "drcTypes": types, "inheritedErrors": known.len(), "kicadOpen": kicad_open_v, "drcError": if ran { Value::Null } else { json!(d["error"].as_str().unwrap_or("the DRC report has no violations list").trim()) }, "trackMm": res["trackMm"], "vias": res["vias"], "seenCrossingMm": res["seenCrossingMm"], "seenTracksDisplaced": displaced, "layerUse": res["layerUse"], "passes": res["passes"], "ripups": res["ripups"], "seconds": res["seconds"], "stop": res["stop"], "narrowedPower": res["narrowedPower"], "planes": planes.iter().map(|(l, nt)| json!({"layer": l, "net": nt, "tag": "INFERRED", "tests": ["T-layers"], "why": "chosen for this stackup: the largest power nets on the layers next to the outer faces"})).collect::<Vec<_>>(), "run": format!("reverse-flow/routes/L{n}"), "log": format!("reverse-flow/routes/L{n}/route.log"), "rules": rl["name"], "failed": res["failed"]});@@ -547,17 +693,23 @@ fn main() { if doc["flip"].is_object() { h.push(format!("bottom maps onto the top turned over {} ({} holes agree, {} the other way)", if doc["flip"]["axis"] == "y" { "top-to-bottom" } else { "left-to-right" }, doc["flip"]["matches"], doc["flip"]["other"])); } for c in cands.iter().take(3) { h.push(format!("scale candidate {} px/mm explains {} holes in {} rows", c["pxPerMm"], c["score"], c["rows"].as_array().unwrap().len())); } h.push("Run `check prep`, then `scale --body <id>=<L>x<W> --source \"...\"` with a body from evidence/prep-<top photo>.jpg whose size a datasheet gives.".into());+ if cands.is_empty() { h.push("No rows of holes here. With four mounting holes at a spacing a document gives: `scale --holes <x>x<y> --board <x>x<y> --source \"...\"`.".into()); } h.extend(views::record(&f, &p.state, "intake", "photos measured", &th, None)); ok("every photo measured: prep.json", &h); }- Cmd::Scale { body, source } => {+ Cmd::Scale { body, source, holes, board: board_arg, flip } => { let th = thread(&cli); let mut p = project(&cli); let f = p.flow_dir.clone();- let prep_doc = rd(&f.join("prep.json"));+ let mut prep_doc = rd(&f.join("prep.json"));+ if let Some(hs) = holes {+ scale_by_holes(&mut p, &fl, &th, &mut prep_doc, hs, board_arg.as_deref().unwrap_or_else(|| err("--holes needs --board <x>x<y>: the board's size from the same source", &[])), source.as_deref().unwrap_or(""), flip.as_deref());+ return;+ } let top = primary(&prep_doc, "top").cloned().unwrap_or_else(|| err("no top photo measured", &[])); let cands: Vec<Value> = prep_doc["scaleCandidates"].as_array().cloned().unwrap_or_default(); let Some(b) = body else { let mut h: Vec<String> = cands.iter().take(4).map(|c| format!("candidate {} px/mm: rows {}", c["pxPerMm"], c["rows"].as_array().unwrap().iter().map(|r| format!("{} mm", r["mm"])).collect::<Vec<_>>().join(", "))).collect(); h.push("scale --body <id>=<L>x<W> --source \"<datasheet>\" (ids on evidence/prep-<top photo>.jpg)".into());+ h.push("or, with no rows of holes: scale --holes <x>x<y> --board <x>x<y> --source \"<document>\", four mounting holes at a stated spacing".into()); err("scale needs a body whose size a datasheet gives, to confirm the pitch solution", &h) }; if source.as_deref().unwrap_or("").is_empty() { err("--source: name the datasheet and package drawing that give the body size", &[]); }@@ -626,19 +778,34 @@ fn main() { let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone(); if what != "parts" { err("detect parts", &[]); } let top = img(&f.join("board/top.jpg"));- let mut cands = parts::candidates(&prep::bodies(&top, (0.1 * board::RES) as f32, 0.4 * board::RES), "top", 0); let bot = if f.join("board/bottom.jpg").is_file() { Some(img(&f.join("board/bottom.jpg"))) } else { None };- if let Some(bi) = bot.as_ref().filter(|_| *bottom) {- let n = cands.len();+ let ht = rd(&f.join("detect/holes-top.json"));+ let (holes, rows) = (ht["holes"].as_array().unwrap(), ht["rows"].as_array().unwrap());+ let holes_bot: Vec<Value> = items::read_json(&f.join("detect/holes-bottom.json")).ok().and_then(|d| d["holes"].as_array().cloned()).unwrap_or_else(|| holes.clone());+ let (mut cands, mut left_out, mut replaced, mut body_first) = (vec![], vec![], 0, 0);+ for (side, face) in [("top", Some(&top)), ("bottom", bot.as_ref().filter(|_| *bottom))] {+ let Some(face) = face else { continue };+ let bodies = prep::bodies(face, (0.1 * board::RES) as f32, if side == "top" { 0.4 } else { 0.6 } * board::RES);+ // chip passives are read body first; what the bodies and holes of this face already show to be a bigger part keeps them out of it+ let zones = passives::zones(&bodies, if side == "top" { holes } else { &holes_bot }, rows, holes);+ let (chips, taken) = passives::find(face, &zones); // bottom parts: only bodies that match a package (the bottom face is mostly mask)- cands.extend(parts::candidates(&prep::bodies(bi, (0.1 * board::RES) as f32, 0.6 * board::RES), "bottom", n).into_iter().filter(|c| c["fits"] == json!(true)));+ let old: Vec<Value> = parts::candidates(&bodies, side, 0).into_iter().filter(|c| side == "top" || c["fits"] == json!(true)).collect();+ // where both describe one spot, the body-first passive wins+ let n = old.len(); let keep: Vec<Value> = old.into_iter().filter(|c| !passives::same_spot(c, side, &chips)).collect();+ replaced += n - keep.len(); body_first += chips.len(); cands.extend(keep);+ cands.extend(passives::candidates(&chips, side, 0));+ left_out.extend(taken.iter().map(|(p, what)| { let q = board::to_mm(p.centre().0, p.centre().1); json!({"side": side, "pos_mm": [(q.0 * 1000.0).round() / 1000.0, (q.1 * 1000.0).round() / 1000.0], "package": passives::PK[p.pk].0, "kind": p.kind, "inside": what}) })); }- let ht = rd(&f.join("detect/holes-top.json")); let n = cands.len();- cands.extend(parts::hole_parts(ht["holes"].as_array().unwrap(), ht["rows"].as_array().unwrap(), n));+ cands.extend(parts::hole_parts(holes, rows, n));+ // numbered in the order written: the two detectors and the faces each counted from their own start+ for (k, c) in cands.iter_mut().enumerate() { c["id"] = json!(format!("P{:03}", k + 1)); } std::fs::create_dir_all(f.join("evidence/parts")).ok();- let mut crops = vec![]; let mut map = top.clone();+ // one map per face searched: a bottom candidate is marked on the bottom photo, where it can be seen+ let mut crops = vec![]; let mut map = top.clone(); let mut map_bot = bot.clone().filter(|_| *bottom); for c in &cands {+ let map = match map_bot.as_mut() { Some(m) if c["side"] == json!("bottom") => m, _ => &mut map }; let pos = (c["pos_mm"][0].as_f64().unwrap(), c["pos_mm"][1].as_f64().unwrap()); let size = (c["size_mm"][0].as_f64().unwrap_or(3.0), c["size_mm"][1].as_f64().unwrap_or(3.0)); let src = if c["side"] == json!("bottom") { bot.as_ref().unwrap_or(&top) } else { &top };@@ -647,17 +814,22 @@ fn main() { save(&cr, &f.join(format!("evidence/parts/{id}.jpg"))); crops.push((id.clone(), cr)); let q = board::to_px(pos.0, pos.1);- draw::circle(&mut map, q, 6.0, if c["fits"] == json!(true) { [0, 255, 255] } else { [255, 80, 80] }, 2);- draw::label(&mut map, (q.0 + 6.0, q.1 - 14.0), id.trim_start_matches('P'), [255, 255, 0], 3);+ if c["method"] == json!("body") {+ // a body-first passive is drawn as its package outline: yellow for a tan body, magenta for a dark one+ draw::poly(map, &passives::corners(q, (size.0 * board::RES, size.1 * board::RES), -fv(c, "rot")), if c["kind"] == json!("tan") { [255, 255, 0] } else { [255, 0, 255] }, 1);+ } else { draw::circle(map, q, 6.0, if c["fits"] == json!(true) { [0, 255, 255] } else { [255, 80, 80] }, 2); }+ draw::label(map, (q.0 + 6.0, q.1 - 14.0), id.trim_start_matches('P'), [255, 255, 0], if c["method"] == json!("body") { 2 } else { 3 }); } save(&map, &f.join("evidence/parts-map.jpg"));+ if let Some(m) = &map_bot { save(m, &f.join("evidence/parts-map-bottom.jpg")); } for (k, chunk) in crops.chunks(48).enumerate() { save(&parts::contact_sheet(chunk, 220, 8), &f.join(format!("evidence/parts-sheet-{}.jpg", k + 1))); } let fits = cands.iter().filter(|c| c["fits"] == json!(true)).count();- wr(&f.join("detect/parts.json"), &json!({"candidates": cands, "at": ledger::now()}));- p.log(&th, "detect", json!({"what": "parts", "candidates": cands.len(), "fits": fits}));+ wr(&f.join("detect/parts.json"), &json!({"candidates": cands, "passives": {"method": "body", "found": body_first, "replaced": replaced, "leftOut": left_out}, "at": ledger::now()}));+ p.log(&th, "detect", json!({"what": "parts", "candidates": cands.len(), "fits": fits, "bodyFirst": body_first})); let mut h = vec!["Next: `parts init`, then read every crop and complete parts.json.".to_string()];+ h.push(format!("{body_first} chip passives read body first (\"method\": \"body\"): they took the place of {replaced} older candidates on the same spots; {} more were left out as lying inside a bigger part or on a hole (detect/parts.json, passives.leftOut).", left_out.len())); if !*bottom && bot.is_some() { h.push("The bottom face was not searched for parts: if board/bottom.jpg shows components, run `detect parts --bottom`.".into()); }- ok(&format!("{} candidates ({fits} match a package by size); crops in evidence/parts/, contact sheets evidence/parts-sheet-*.jpg, map evidence/parts-map.jpg", cands.len()), &h);+ ok(&format!("{} candidates ({fits} match a package by size); crops in evidence/parts/, contact sheets evidence/parts-sheet-*.jpg, map evidence/parts-map.jpg{}", cands.len(), if map_bot.is_some() { " and evidence/parts-map-bottom.jpg" } else { "" }), &h); } Cmd::Parts { what, force } => { let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();@@ -669,18 +841,35 @@ fn main() { let cands_all: Vec<Value> = items::read_json(&f.join("detect/parts.json")).ok().and_then(|d| d["candidates"].as_array().cloned()).unwrap_or_default(); for pt in doc["parts"].as_array_mut().into_iter().flatten() { let id = pt["footprint"].as_str().unwrap_or("").to_string(); if id.is_empty() || pt["snap"]["lock"] == json!(true) { continue; }- let Ok(text) = kicad_out::footprint_text(&kicad_out::cache_dir(&f), &id, pt) else { continue };- let pads = snap::pads(&text); if pads.is_empty() { continue; } let metal = if pt["side"] == json!("bottom") { match &metal_bot { Some(m) => m, None => continue } } else { &metal_top }; let (x, y, r) = (pt["pos_mm"][0].as_f64().unwrap_or(0.0), pt["pos_mm"][1].as_f64().unwrap_or(0.0), fv(pt, "rot"));+ // a chip part's size is settled by its lands: the next size down or up takes over when its pads+ // fit the photo's metal clearly better, so pads never hang over bare board or onto a neighbour+ let mut id = id; let mut sized = None; let mut size_fits: Option<Value> = None;+ if let Some((stem, k)) = snap::chip_size(&id) {+ let fit = |fid: &str| -> Option<f64> { let t = kicad_out::footprint_text(&kicad_out::cache_dir(&f), fid, pt).ok()?; let pd = snap::pads(&t); if pd.is_empty() { return None; }+ let c = if pt["anchor"] == json!("center") { Some(snap::pad_centre(&pd)) } else { None }; Some(snap::best_pose_at(metal, &pd, x, y, &[r], snap::reach(&pd), c).3) };+ if let Some(own) = fit(&id) {+ let mut best = (own, k); let mut fits = serde_json::Map::new(); fits.insert(snap::CHIP_SIZES[k][..4].to_string(), json!((own * 1000.0).round() / 1000.0));+ for j in [k.wrapping_sub(1), k + 1] { if j < snap::CHIP_SIZES.len() { if let Some(sc) = fit(&format!("{stem}{}", snap::CHIP_SIZES[j])) { fits.insert(snap::CHIP_SIZES[j][..4].to_string(), json!((sc * 1000.0).round() / 1000.0));+ let need = if j < k { snap::SIZE_MARGIN_DOWN } else { snap::SIZE_MARGIN }; if sc >= own + need && (best.1 == k || sc > best.0) { best = (sc, j); } } } }+ size_fits = Some(Value::Object(fits));+ if best.1 != k { sized = Some((id.clone(), own, best.0)); id = format!("{stem}{}", snap::CHIP_SIZES[best.1]); }+ }+ }+ if let Some(sf) = size_fits { pt["sizeFits"] = sf; }+ if let Some((was, a, b)) = &sized { pt["footprint"] = json!(id); pt["package"] = json!(&snap::CHIP_SIZES[snap::chip_size(&id).unwrap().1][..4]); pt["sizedByPads"] = json!({"was": was, "fit": (a * 1000.0).round() / 1000.0, "now": (b * 1000.0).round() / 1000.0}); }+ let Ok(text) = kicad_out::footprint_text(&kicad_out::cache_dir(&f), &id, pt) else { continue };+ let pads = snap::pads(&text); if pads.is_empty() { continue; } // never try a turn that maps the pads onto themselves: it is the same part, pin 1 stays the AI's let sym = snap::symmetric_turns(&pads); let turns: Vec<f64> = [0.0, 90.0, 180.0, 270.0].iter().filter(|d| **d == 0.0 || !sym.iter().any(|s| (s - **d).abs() < 1e-6)).map(|d| (r + d) % 360.0).collect(); let pc = snap::pad_centre(&pads); let centre = if pt["anchor"] == json!("center") { Some(pc) } else { None }; // the AI's rotation unless another turn is clearly better (0.1 higher and at least 0.3)- let own = snap::best_pose_at(metal, &pads, x, y, &turns[..1], 1.0, centre);- let any = snap::best_pose_at(metal, &pads, x, y, &turns, 1.0, centre);+ let reach = snap::reach(&pads);+ let own = snap::best_pose_at(metal, &pads, x, y, &turns[..1], reach, centre);+ let any = snap::best_pose_at(metal, &pads, x, y, &turns, reach, centre); let (mut nx, mut ny, nr, sc) = if any.3 > own.3 + 0.1 && any.3 >= 0.3 { any } else { own }; let mut method = "pads"; if sc < 0.3 {@@ -700,7 +889,7 @@ fn main() { } // a best pose at the edge of the search found nothing better inside it: keep the reading let edge = { let (c, s) = (r.to_radians().cos(), r.to_radians().sin()); let rm = if centre.is_some() { (x, y) } else { (x + pc.0 * c + pc.1 * s, y - pc.0 * s + pc.1 * c) };- let (c2, s2) = (nr.to_radians().cos(), nr.to_radians().sin()); geom::dist(rm, (nx + pc.0 * c2 + pc.1 * s2, ny - pc.0 * s2 + pc.1 * c2)) >= 0.95 };+ let (c2, s2) = (nr.to_radians().cos(), nr.to_radians().sin()); geom::dist(rm, (nx + pc.0 * c2 + pc.1 * s2, ny - pc.0 * s2 + pc.1 * c2)) >= reach - 0.05 }; if sc >= 0.3 && edge { let (c, s) = (r.to_radians().cos(), r.to_radians().sin()); let m = if centre.is_some() { (x, y) } else { (x + pc.0 * c + pc.1 * s, y - pc.0 * s + pc.1 * c) };@@ -754,7 +943,7 @@ fn main() { if let Some(h) = host { dismissed.push(json!({"candidate": id, "why": format!("part of {}", h.0)})); auto += 1; } else if size < 0.45 { dismissed.push(json!({"candidate": id, "why": "under 0.45 mm: smaller than any package here (texture, via, silkscreen dot)"})); auto += 1; } else if board_box.map(|(x0, y0, x1, y1)| cx - x0 < 0.8 || x1 - cx < 0.8 || cy - y0 < 0.8 || y1 - cy < 0.8).unwrap_or(false) { dismissed.push(json!({"candidate": id, "why": "within 0.8 mm of the board edge: edge shadow or background"})); auto += 1; }- else if c["size_mm"][1].as_f64().unwrap_or(1.0) < 0.35 { dismissed.push(json!({"candidate": id, "why": "under 0.35 mm wide: a silkscreen stroke or trace edge, not a package"})); auto += 1; }+ else if c["method"] != json!("body") && c["size_mm"][1].as_f64().unwrap_or(1.0) < 0.35 { dismissed.push(json!({"candidate": id, "why": "under 0.35 mm wide: a silkscreen stroke or trace edge, not a package"})); auto += 1; } else { rest.push(json!({"candidate": id, "pos_mm": c["pos_mm"], "size_mm": c["size_mm"], "colour": c["colour"]})); } } doc["dismissed"] = json!(dismissed);@@ -841,6 +1030,12 @@ fn main() { ok("domes measured on both faces: detect/domes.json, detect/dome-candidates.json", &lines); } "trace" => {+ // a run that never sketched any copper starts from the outline scale measured+ if !f.join("copper.json").is_file() {+ let sc = rd(&f.join("scale.json")); let (x, y) = (sc["board_mm"][0].as_f64().unwrap_or(0.0), sc["board_mm"][1].as_f64().unwrap_or(0.0));+ let (x0, y0) = (board::ORIGIN, board::ORIGIN);+ wr(&f.join("copper.json"), &json!({"outline": {"points_mm": [[x0, y0], [x0 + x, y0], [x0 + x, y0 + y], [x0, y0 + y]], "corner_mm": sc["corner_mm"], "evidence": ["board/top.jpg"], "tag": "MEASURED"}, "zones": [], "traces": [], "vias": [], "defaultEvidence": ["evidence/gaps-bottom.jpg"]}));+ } // every island of copper from the photos, turned into pours, tracks and vias, with what they connect let mut faces: Vec<islands::Face> = vec![]; let mut h = vec![]; let mut imgs = vec![];@@ -851,12 +1046,12 @@ fn main() { let board_px = { let o = rd(&f.join("copper.json")); let pts: Vec<(f64, f64)> = o["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default(); if pts.len() >= 3 { let (x0, y0, x1, y1) = pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1))); let (a, b) = board::to_px(x0, y0); let (c, d) = board::to_px(x1, y1); Some((a, b, c, d, o["outline"]["corner_mm"].as_f64().unwrap_or(0.0) * board::RES)) } else { None } }; let shot2 = second_shot(&f, side, &im); let dome_px = domes_of(&f, side);- let t = trace::trace(&im, &trace::Params { bodies: side == "top", board: board_px, second: shot2.as_ref(), domes: &dome_px, light: light_of(&f, side), ..Default::default() });+ let t = trace::trace(&im, &trace::Params { bodies: photomat::carries_parts(&im), board: board_px, second: shot2.as_ref(), domes: &dome_px, light: light_of(&f, side), ..Default::default() });+ faces.push(islands::Face { side, layer, board_colour: islands::board_coloured(&im, &t.labels), t }); imgs.push((side, im));- faces.push(islands::Face { side, layer, t }); } // for working on the reader: read the masks with TRACE_DUMP and stop before anything is written- if let Ok(dir) = std::env::var("TRACE_DUMP") { for (side, im) in &imgs { let c = photomat::classify(im, *side == "top"); let g = image::GrayImage::from_fn(c.w as u32, c.h as u32, |x, y| image::Luma([c.cls[y as usize * c.w + x as usize]])); let _ = g.save(format!("{dir}/{side}-cls.png")); } }+ if let Ok(dir) = std::env::var("TRACE_DUMP") { for (side, im) in &imgs { let c = photomat::classify(im, photomat::carries_parts(im)); let g = image::GrayImage::from_fn(c.w as u32, c.h as u32, |x, y| image::Luma([c.cls[y as usize * c.w + x as usize]])); let _ = g.save(format!("{dir}/{side}-cls.png")); } } if std::env::var("TRACE_STOP").is_ok() { return; } let parts = rd(&f.join("parts.json")); let nets_doc = items::read_json(&f.join("nets.json")).unwrap_or(json!({"nets": []}));@@ -867,7 +1062,7 @@ fn main() { let dd = rd(&f.join("detect/domes.json")); let domes: Vec<islands::SeenDome> = dd["domes"].as_array().into_iter().flatten().filter_map(|x| { let fi = faces.iter().position(|q| Some(q.side) == x["side"].as_str())?; Some(islands::SeenDome { x: x["pos_mm"][0].as_f64()?, y: x["pos_mm"][1].as_f64()?, face: fi, d: x["diameter_mm"].as_f64().unwrap_or(0.6), shape: x["shape"].as_str().unwrap_or("").to_string() }) }).collect(); // the photos of both faces have to show vias before one can be called isolated on both- let at: Vec<(f64, f64)> = domes.iter().filter(|d| d.shape == "blob" || d.shape == "ring").map(|d| (d.x, d.y)).collect();+ let at: Vec<(f64, f64)> = domes.iter().filter(|d| d.shape == "blob" || d.shape == "ring" || d.shape == "pit").map(|d| (d.x, d.y)).collect(); let sig: Vec<(String, f64, usize)> = imgs.iter().map(|(side, im)| { let (s, n) = domes::face_signature(im, &at); (side.to_string(), s, n) }).collect(); let faces_show_vias = sig.len() >= 2 && sig.iter().all(|s| s.1.abs() >= domes::SIGNATURE_MIN); for (side, s, n) in &sig { h.push(format!("{side} photo at {n} via positions: {s:.1} standard errors against the surroundings")); }@@ -875,7 +1070,7 @@ fn main() { // a drilled pad with a dark middle on the bottom photo has no pin in it let open_holes: Vec<(f64, f64)> = imgs.iter().find(|i| i.0 == "bottom").map(|(_, im)| pads.iter().filter(|q| q.drill > 0.0).filter(|q| domes::hole_is_open(im, q.x, q.y, q.drill)).map(|q| (q.x, q.y)).collect()).unwrap_or_default(); let rl = rules_of(&p.state);- let m = islands::measure(&mut faces, &pads, &domes, &pin_net, &islands::Options { track: rules::num(&rl, "track", 0.2), via: rules::via(&rl, "viaSignal"), clearance: rules::num(&rl, "clearance", 0.2), hole_hole: rules::num(&rl, "holeToHole", 0.25), edge_clear: rules::num(&rl, "edgeClearance", 0.5), faces_show_vias, open_holes, edge: { let o = rd(&f.join("copper.json")); let pts: Vec<(f64, f64)> = o["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default(); pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1))) } });+ let m = islands::measure(&mut faces, &pads, &domes, &pin_net, &islands::Options { track: rules::num(&rl, "track", 0.2), via: rules::via(&rl, "viaSignal"), clearance: rules::num(&rl, "clearance", 0.2), hole_hole: rules::num(&rl, "holeToHole", 0.25), edge_clear: rules::num(&rl, "edgeClearance", 0.5), faces_show_vias, open_holes, bodies: kicad_out::bodies_of(&kicad_out::cache_dir(&f), parts["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[])), edge: { let o = rd(&f.join("copper.json")); let pts: Vec<(f64, f64)> = o["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default(); pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1))) } }); for (fi, (side, im)) in imgs.iter().enumerate() { save(&trace::overlay(im, &faces[fi].t), &f.join(format!("evidence/copper-islands-{side}.jpg"))); } // the seen copper replaces the sketched copper; the old file is kept beside it let old = rd(&f.join("copper.json"));@@ -886,7 +1081,7 @@ fn main() { c["zones"] = m["zones"].clone(); c["traces"] = m["traces"].clone(); c["vias"] = vias_out; c["from"] = json!("islands"); wr(&f.join("copper.json"), &c); wr(&f.join("detect/copper-island-accounting.json"), &m["accounting"]);- wr(&f.join("detect/copper-measured.json"), &json!({"trackPadConflicts": m["trackPadConflicts"], "report": m["report"], "groups": m["groups"], "pinAdds": m["pinAdds"], "pinClashes": m["pinClashes"], "viaClashes": m["viaClashes"], "mixed": m["mixed"], "joins": m["joins"], "profile": m["profile"]}));+ wr(&f.join("detect/copper-measured.json"), &json!({"trackPadConflicts": m["trackPadConflicts"], "report": m["report"], "groups": m["groups"], "pinAdds": m["pinAdds"], "pinClashes": m["pinClashes"], "viaClashes": m["viaClashes"], "mixed": m["mixed"], "joins": m["joins"], "profile": m["profile"], "viasDropped": m["viasDropped"]})); // pins with no net yet that the copper names: seen, so they join that net let mut nd = nets_doc.clone(); let mut added = 0; if let Some(list) = nd["nets"].as_array_mut() { for a in m["pinAdds"].as_array().into_iter().flatten() {@@ -903,10 +1098,15 @@ fn main() { } "regions" => { let mut lines = vec![];+ let bodies = items::read_json(&f.join("parts.json")).ok().map(|p| kicad_out::bodies_of(&kicad_out::cache_dir(&f), p["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]))).unwrap_or_default(); for side in ["top", "bottom"] { let file = f.join(format!("board/{side}.jpg")); if !file.is_file() { continue; } let i = img(&file);- let (regs, _lab, _w, nd) = copper::regions(&i, side);+ let (mut regs, lab, lw, nd) = copper::regions(&i, side);+ // a region that is a part's own top is no candidate pour: it is listed apart, with its part+ let on: HashMap<String, String> = islands::regions_on_bodies(&lab, &islands::board_coloured(&i, &lab).d, lw, lab.len() / lw.max(1), if side == "top" { "F.Cu" } else { "B.Cu" }, &bodies).into_iter().map(|(l, p)| (format!("{side}:{l}"), p)).collect();+ let on_parts: Vec<Value> = regs.iter().filter_map(|r| on.get(r["id"].as_str().unwrap_or("")).map(|p| json!({"id": r["id"], "part": p, "area_mm2": r["area_mm2"], "centre_mm": r["centre_mm"]}))).collect();+ regs.retain(|r| !on.contains_key(r["id"].as_str().unwrap_or(""))); { let b = copper::line_barrier(&i); let mut bi = i.clone(); draw::tint(&mut bi, &b, [255, 0, 255], 0.8); save(&bi, &f.join(format!("evidence/copper-barrier-{side}.jpg"))); } let ds = copper::domes(&i); // evidence: region outlines (cyan likely copper: stitched; grey not), ids, domes@@ -921,8 +1121,8 @@ fn main() { } save(&ev, &f.join(format!("evidence/copper-regions-{side}.jpg"))); let stitched = regs.iter().filter(|r| r["domesPerMm2"].as_f64().unwrap_or(0.0) >= 0.2).count();- lines.push(format!("{side}: {} regions ({stitched} stitched with vias, likely copper), {nd} vias found; evidence/copper-regions-{side}.jpg", regs.len()));- wr(&f.join(format!("detect/copper-regions-{side}.json")), &json!({"side": side, "regions": regs, "note": "regions enclosed by straight dark gap lines; domesPerMm2 >= 0.2 marks a stitched pour. Use them in copper.json zones as \"regions\": [\"<side>:<id>\", ...]"}));+ lines.push(format!("{side}: {} regions ({stitched} stitched with vias, likely copper), {} on part bodies left out, {nd} vias found; evidence/copper-regions-{side}.jpg", regs.len(), on_parts.len()));+ wr(&f.join(format!("detect/copper-regions-{side}.json")), &json!({"side": side, "regions": regs, "onParts": on_parts, "note": "regions enclosed by straight dark gap lines; domesPerMm2 >= 0.2 marks a stitched pour. Use them in copper.json zones as \"regions\": [\"<side>:<id>\", ...]"})); } lines.extend(write_domes(&f)); let all_domes = rd(&f.join("detect/domes.json"))["domes"].as_array().cloned().unwrap_or_default();@@ -1109,7 +1309,7 @@ fn main() { h.extend(views::record(&f, &p.state, &cur, &format!("build ({n} layers)"), &th, None)); ok(&format!("{} ({n} copper layers, {} pads, {} zones filled)", abs(&pcb), b.pads.len(), b.fills.len()), &h); }- Cmd::Check { what, pcb } => {+ Cmd::Check { what, pcb, skip_earlier } => { let th = thread(&cli); let mut p = project(&cli); let fdir = p.flow_dir.clone(); let f = &fdir; let pcbp = pcb.as_ref().map(PathBuf::from).or_else(|| p.state["kicad"]["pcb"].as_str().map(PathBuf::from));@@ -1117,7 +1317,7 @@ fn main() { let idx = fl.steps.iter().position(|s| s.name == step).unwrap_or(0); if let Some(i) = fl.steps.iter().position(|s| s.name == step) { let behind: Vec<String> = fl.steps[..i].iter().filter(|s| !s.done.iter().all(|d| flow::exists(f, d))).map(|s| s.name.clone()).collect();- if !behind.is_empty() { err(&format!("check {what}: earlier step(s) not done or stale: {}", behind.join(", ")), &step_hint(&fl, f)); }+ if !behind.is_empty() && skip_earlier.is_none() { err(&format!("check {what}: earlier step(s) not done or stale: {}", behind.join(", ")), &step_hint(&fl, f)); } } let was_stale: Vec<String> = fl.steps.get(idx).map(|s| s.done.iter().flat_map(|d| flow::stale(f, d)).collect()).unwrap_or_default(); let r = match what.as_str() {@@ -1135,6 +1335,10 @@ fn main() { }; match r { Err(e) => { p.log(&th, "check", json!({"what": what, "ok": false, "errors": e})); err(&format!("check {what}: {} problem(s)", e.len()), &e) }+ Ok(pass) if skip_earlier.is_some() => {+ p.log(&th, "check", json!({"what": what, "ok": true, "earlierStepsSkipped": skip_earlier, "summary": pass.summary}));+ ok(&format!("check {what} passed on its own, earlier steps skipped, so it does not count: {}", pass.summary), &pass.hints);+ } Ok(pass) => { checks::write_ok(f, what, &pass).unwrap_or_else(|e| err(&e.to_string(), &[])); p.log(&th, "check", json!({"what": what, "ok": true, "summary": pass.summary}));@@ -1152,7 +1356,7 @@ fn main() { "classify" => { for side in ["top", "bottom"] { let im = img(&f.join(format!("board/{side}.jpg")));- let c = photomat::classify(&im, side == "top");+ let c = photomat::classify(&im, photomat::carries_parts(&im)); save(&photomat::false_colour(&im, &c), &f.join(format!("evidence/materials-{side}.jpg"))); } ok("materials written", &[]);@@ -1196,6 +1400,7 @@ fn main() { "measure" => { // what attribution needs from the photos, once: the copper and its nets are not touched let pads = build_board(&f, 2).pads;+ let bodies = kicad_out::bodies_of(&kicad_out::cache_dir(&f), rd(&f.join("parts.json"))["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[])); let mut faces: Vec<attrib::FaceIn> = vec![]; for (side, layer, file) in [("top", "F.Cu", "board/top.jpg"), ("bottom", "B.Cu", "board/bottom.jpg")] { if !f.join(file).is_file() { err(&format!("{file} is missing: attribution needs both faces"), &[]); }@@ -1203,7 +1408,9 @@ fn main() { let board_px = { let o = rd(&f.join("copper.json")); let pts: Vec<(f64, f64)> = o["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default(); if pts.len() >= 3 { let (x0, y0, x1, y1) = pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1))); let (a, b) = board::to_px(x0, y0); let (c, d) = board::to_px(x1, y1); Some((a, b, c, d, o["outline"]["corner_mm"].as_f64().unwrap_or(0.0) * board::RES)) } else { None } }; let shot2 = second_shot(&f, side, &im); let dome_px = domes_of(&f, side);- let t = trace::trace(&im, &trace::Params { bodies: side == "top", board: board_px, second: shot2.as_ref(), domes: &dome_px, light: light_of(&f, side), ..Default::default() });+ let mut t = trace::trace(&im, &trace::Params { bodies: photomat::carries_parts(&im), board: board_px, second: shot2.as_ref(), domes: &dome_px, light: light_of(&f, side), ..Default::default() });+ // the same islands `copper trace` kept: what it read on a part's top is not copper here either+ islands::clear_bodies(&mut t, &im, layer, &bodies); faces.push(attrib::FaceIn { side, layer, img: im, t }); } let dd = rd(&f.join("detect/domes.json"));@@ -1283,7 +1490,7 @@ fn main() { write_proposal(&mut l, &cands, closed_at); wr(&f.join("layers.json"), &l); p.log(&th, "connect-propose", json!({"proposed": l["proposed"]}));- ok(&format!("proposed {} layers", l["proposed"]), &[l["why"].as_str().unwrap_or("").to_string()]);+ ok(&match l["proposed"].as_u64() { Some(n) => format!("proposed {n} layers"), None => "no layer count proposed: too few known connections".to_string() }, &[l["why"].as_str().unwrap_or("").to_string()]); } "route" => { // layer counts: the floor the evidence allows, then two more at a time. A stackup that cannot@@ -1306,8 +1513,12 @@ fn main() { for h in views::record(&f, &p.state, "connect", &format!("{n}-layer route: {} open, {} new DRC errors", c["open"], c["drcErrors"]), &th, Some(&f.join(format!("routes/L{n}/board.kicad_pcb")))) { println!(" {h}"); } let closes = c["open"] == json!(0) && c["kicadOpen"] == json!(0) && c["drcErrors"] == json!(0); last_why = format!("{n} layers: {} open, {} new DRC errors, {} missing per KiCad ({})", c["open"], c["drcErrors"], c["kicadOpen"], c["stop"].as_str().unwrap_or(""));+ if let Some(e) = c["drcError"].as_str() { println!(" {n} layers: KiCad's DRC did not run ({e}): new errors and missing connections are unknown for this stackup, not zero"); } cands.push(c); if closes { closed_at = Some(n); break; }+ // a clean route that leaves exactly what the last count left: more layers cannot change it+ if cands.len() >= 2 { let (a, b) = (&cands[cands.len() - 2], &cands[cands.len() - 1]);+ if a["open"] == b["open"] && a["trackMm"] == b["trackMm"] && a["vias"] == b["vias"] && cands.len() >= 3 && cands[cands.len() - 3]["open"] == b["open"] { println!(" stopping: three stackups in a row leave the same {} open with the same track: more layers change nothing", b["open"]); break; } } println!(" jumping: {last_why}"); } let mut l = items::read_json(&f.join("layers.json")).unwrap_or(json!({}));@@ -1318,7 +1529,7 @@ fn main() { wr(&f.join("layers.json"), &l); p.log(&th, "connect-route", json!({"proposed": pick, "closed": closed_at, "trail": trail})); let mut h = trail.clone();- h.push(match closed_at { Some(n) => format!("proposed {n} layers"), None => format!("nothing closed: proposed {:?} with the rest listed in openNote", pick) });+ h.push(match closed_at { Some(n) => format!("proposed {n} layers"), None => match pick { Some(n) => format!("nothing closed: proposed {n} with the rest listed in openNote"), None => "no layer count proposed: see why in layers.json".to_string() } }); h.push("`build --layers <proposed>` writes the routed copy; then `check nets`, `check layers`.".into()); ok("routing results recorded in layers.json", &h); }@@ -1384,7 +1595,13 @@ fn main() { let c = cat(); let d = confidence::draw_sample(&f, &c, *n, &th, t).unwrap_or_else(|e| err(&e, &[])); p.log(&th, "audit-draw", json!({"category": c, "seed": d["seed"], "draw": d["draw"], "counts": d["counts"], "items": d["items"].as_array().map(|a| a.len())}));- ok(&format!("{c}: sample {} (draw {}): {} items, {} per stratum", d["seed"].as_str().unwrap_or(""), d["draw"], d["items"].as_array().map(|a| a.len()).unwrap_or(0), d["counts"]), &[format!("Claims in reverse-flow/audits/{c}/sheet.md, crops in crops/, contact sheets sheet-*.jpg. An auditor who did not produce this run judges every one."), "A sample counts only when complete. cannot_tell counts against the score; some claims are planted and false.".into()]);+ let mut hints = vec![format!("Claims in reverse-flow/audits/{c}/sheet.md, crops in crops/, contact sheets sheet-*.jpg. An auditor who did not produce this run judges every one."), "A sample counts only when complete. cannot_tell counts against the score; some claims are planted and false.".into()];+ // planted copper claims go only where the photo is plain: say when that leaves one way round with none+ if c == "copper" {+ let by = rd(&f.join("audits/copper/exclusions.json"))["plantedClaims"]["eligibleByWhatTheClaimStates"].clone();+ for (stated, other) in [("bare", "copper"), ("copper", "bare board")] { if by[stated].as_u64() == Some(0) { hints.push(format!("No point of this board is plain enough to plant a false `{stated}` claim on, so every planted claim states {other}: an auditor who calls every `{stated}` claim ok is not caught by this sample. audits/copper/exclusions.json says what refused the points.")); } }+ }+ ok(&format!("{c}: sample {} (draw {}): {} items, {} per stratum", d["seed"].as_str().unwrap_or(""), d["draw"], d["items"].as_array().map(|a| a.len()).unwrap_or(0), d["counts"]), &hints); } "show" => { let c = cat();@@ -1414,7 +1631,12 @@ fn main() { let cs = s.cats.iter().find(|x| x.category == c).unwrap(); ok(&format!("{total} verdict(s) recorded for {c}"), &[format!("{c} now {:.1}: audit {}: {}", cs.score, cs.audit.status, cs.audit.detail)]); }- _ => err("audit draw | show | status | verdict", &[]),+ "preview" => {+ let out = file.clone().unwrap_or_else(|| err("audit preview needs --file <folder>", &[]));+ let k = confidence::copper_preview(&f, n.unwrap_or(24), Path::new(&out)).unwrap_or_else(|e| err(&e, &[]));+ ok(&format!("{k} copper crops for checking legibility in {out}, outside the audit"), &["key.json holds their claims: read it only after judging. Nothing here counts as an audit.".into()]);+ }+ _ => err("audit draw | show | status | verdict | preview", &[]), } } Cmd::Confidence => {@@ -1510,3 +1732,27 @@ fn main() { } } }++#[cfg(test)]+mod proposal_tests {+ use super::*;+ #[test]+ fn eight_connections_propose_no_layer_count() {+ let c = |n: u64, open: u64| json!({"layers": n, "open": open, "connections": 8, "drcErrors": 0, "vias": 1, "trackMm": 5.0, "seenCrossingMm": 0.0});+ let mut l = json!({});+ write_proposal(&mut l, &[c(2, 1), c(4, 1), c(6, 1)], None);+ assert!(l["proposed"].is_null(), "{l}");+ assert_eq!(l["tag"], json!("UNKNOWN"));+ }+ #[test]+ fn no_layer_count_when_more_layers_change_nothing() {+ let c = |n: u64| json!({"layers": n, "open": 39, "connections": 54, "drcErrors": 0, "vias": 3, "trackMm": 12.0, "seenCrossingMm": 0.0});+ let mut l = json!({});+ write_proposal(&mut l, &[c(2), c(4), c(6), c(8), c(10)], None);+ assert!(l["proposed"].is_null(), "{l}");+ let d = |n: u64, open: u64| json!({"layers": n, "open": open, "connections": 253, "drcErrors": 0, "vias": 3, "trackMm": 12.0, "seenCrossingMm": 0.0});+ let mut m = json!({});+ write_proposal(&mut m, &[d(2, 47), d(4, 43), d(6, 45)], None);+ assert!(m["proposed"].as_u64().is_some(), "{m}");+ }+}
src/passives.rsadded+416@@ -0,0 +1,416 @@+//! Chip passives, body first. The body of a small capacitor (tan) or resistor (dark, reddish) is found as one connected blob and+//! fitted with an oriented rectangle. Which way the part lies is read from what lies just beyond the two pairs of opposite sides:+//! a solder end looks paler than this part's own tan body, or brighter than its own dark one. The package comes from the body's+//! own length and width, the place is the body's centre. So two parts side by side stay two parts: nothing is paired across a gap.+//! Thresholds are in OpenCV's 8-bit HSV (H 0..180, S and V 0..255), where they were read on the CM5 photos.+use crate::board::{to_mm, to_px, RES};+use crate::geom::{self, P};+use crate::prep::{self, Mat};+use image::RgbImage;+use serde_json::{json, Value};+use std::f64::consts::FRAC_PI_2;++/// (package, length, width, share of the length that shows as body between the ends), mm.+pub const PK: &[(&str, f64, f64, f64)] = &[("0201", 0.60, 0.30, 0.45), ("0402", 1.00, 0.50, 0.50), ("0603", 1.60, 0.80, 0.58), ("0805", 2.00, 1.25, 0.62), ("1206", 3.20, 1.60, 0.70)];+/// KiCad footprint name endings, in the order of `PK`.+const METRIC: [&str; 5] = ["0201_0603Metric", "0402_1005Metric", "0603_1608Metric", "0805_2012Metric", "1206_3216Metric"];+/// Two rectangles are one part when they share more than this much of the smaller one.+pub const OVERLAP: f64 = 0.12;+/// A body this many times a passive's own package area is another part. A part's own solder (both ends and their fillets, which the+/// older detector can read as one pale body) stays under four times its package; a pad, a pin row or a moulding it sits in is well over.+pub const BIG: f64 = 5.0;+/// A moulding fills at least this much of its rectangle. Shadows that run round several parts read as one dark body too, but fill less.+pub const SOLID: f64 = 2.0 / 3.0;++/// HSV of one pixel exactly as OpenCV's 8-bit BGR2HSV gives it: H 0..180, S and V 0..255, fixed point with a 12-bit shift.+pub fn hsv8(r: u8, g: u8, b: u8) -> (u8, u8, u8) {+ let (r, g, b) = (r as i32, g as i32, b as i32);+ let v = r.max(g).max(b); let d = v - r.min(g).min(b);+ if v == 0 { return (0, 0, 0); }+ let s = (d * ((255 << 12) as f64 / v as f64).round_ties_even() as i32 + (1 << 11)) >> 12;+ if d == 0 { return (0, s as u8, v as u8); }+ let h = if v == r { g - b } else if v == g { b - r + 2 * d } else { r - g + 4 * d };+ let h = (h * ((180 << 12) as f64 / (6.0 * d as f64)).round_ties_even() as i32 + (1 << 11)) >> 12;+ ((if h < 0 { h + 180 } else { h }) as u8, s as u8, v as u8)+}++/// The photo as the detector reads it: 8-bit HSV per pixel and whether the pixel is bare board (solder mask).+pub struct Px { pub w: usize, pub h: usize, pub hsv: Vec<[u8; 3]>, pub board: Vec<bool> }++impl Px {+ /// Board is what the flow's own classification calls solder mask in this photo (`prep::material` against the photo's `MaskTone`).+ pub fn of(img: &RgbImage) -> Px {+ let tone = prep::mask_tone(img);+ Px { w: img.width() as usize, h: img.height() as usize, hsv: img.pixels().map(|p| { let (h, s, v) = hsv8(p[0], p[1], p[2]); [h, s, v] }).collect(),+ board: img.pixels().map(|p| prep::material(p, &tone) == Mat::Mask).collect() }+ }+ /// A tan capacitor body.+ fn tan(&self, i: usize) -> bool { let [h, s, v] = self.hsv[i]; (4..=22).contains(&h) && s >= 78 && v >= 140 && !self.board[i] }+ /// A dark body; the shadow round a part is greener than this.+ fn dark(&self, i: usize) -> bool { let [h, _, v] = self.hsv[i]; v < 140 && h <= 16 && !self.board[i] }+ /// The pixels of a 6 x 5 grid of points: from `f0` to `f1` along (ux, uy) from (cx, cy), `half` to either side across. Points off the photo are left out.+ fn grid(&self, cx: f64, cy: f64, ux: f64, uy: f64, f0: f64, f1: f64, half: f64) -> Vec<usize> {+ let (vx, vy) = (-uy, ux); let mut o = Vec::with_capacity(30);+ for f in lin(f0, f1, 6) { for q in lin(-half, half, 5) {+ let (x, y) = ((cx + f * ux + q * vx).round_ties_even(), (cy + f * uy + q * vy).round_ties_even());+ if x >= 0.0 && y >= 0.0 && (x as usize) < self.w && (y as usize) < self.h { o.push(y as usize * self.w + x as usize); }+ } }+ o+ }+}++/// `n` evenly spaced values from `a` to `b`, both included (the last is `b` itself, as numpy's linspace has it).+fn lin(a: f64, b: f64, n: usize) -> impl Iterator<Item = f64> { let st = (b - a) / (n - 1) as f64; (0..n).map(move |k| if k == n - 1 { b } else { k as f64 * st + a }) }++fn median(mut v: Vec<f64>) -> f64 {+ if v.is_empty() { return 0.0; }+ v.sort_by(|a, b| a.partial_cmp(b).unwrap()); let n = v.len();+ if n % 2 == 1 { v[n / 2] } else { (v[n / 2 - 1] + v[n / 2]) / 2.0 }+}++/// Opening by a 2 x 2 square, which drops single pixels and one-pixel lines. Written as OpenCV runs it with its default anchor, so the+/// result lies one pixel down and to the right of the mask it came from; `Passive::centre` takes that pixel back out.+pub fn open2(m: &[bool], w: usize, h: usize) -> Vec<bool> {+ let at = |v: &[bool], x: i64, y: i64, out: bool| if x < 0 || y < 0 { out } else { v[y as usize * w + x as usize] };+ let pass = |v: &[bool], all: bool| -> Vec<bool> { (0..w * h).map(|i| { let (x, y) = ((i % w) as i64, (i / w) as i64);+ let q = [at(v, x - 1, y - 1, all), at(v, x, y - 1, all), at(v, x - 1, y, all), v[i]];+ if all { q.iter().all(|b| *b) } else { q.iter().any(|b| *b) } }).collect() };+ pass(&pass(m, true), false)+}++/// 4-connected blobs of a mask as lists of pixels, in the order their first pixel is met row by row.+pub fn blobs(m: &[bool], w: usize, h: usize) -> Vec<Vec<(u32, u32)>> {+ let mut seen = vec![false; w * h]; let mut out = vec![]; let mut st = vec![];+ for s in 0..w * h {+ if !m[s] || seen[s] { continue; }+ seen[s] = true; st.push(s); let mut px = vec![];+ while let Some(i) = st.pop() {+ let (x, y) = (i % w, i / w); px.push((x as u32, y as u32));+ let mut go = |j: usize| if m[j] && !seen[j] { seen[j] = true; st.push(j); };+ if x > 0 { go(i - 1); } if x + 1 < w { go(i + 1); } if y > 0 { go(i - w); } if y + 1 < h { go(i + w); }+ }+ out.push(px);+ }+ out+}++/// Minimum-area rectangle round a set of points: (centre, (w, h), angle in degrees), the angle in -90..0 and w the extent along it,+/// as OpenCV's minAreaRect reports. One side lies along an edge of the convex hull; each hull edge is tried with exact unit vectors+/// and no trigonometry, so an upright rectangle comes out with whole and half numbers.+pub fn min_area_rect(pts: &[P]) -> (P, (f64, f64), f64) {+ let hull = geom::hull(pts);+ if hull.len() < 3 {+ // a point or a line: no area, the length along the line+ let (a, b) = (hull.first().copied().unwrap_or((0.0, 0.0)), hull.last().copied().unwrap_or((0.0, 0.0)));+ return (((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0), (geom::dist(a, b), 0.0), -90.0);+ }+ let mut best: Option<(f64, f64, P, (f64, f64), f64)> = None; // area, turn from upright, centre, size, angle+ for i in 0..hull.len() {+ let (a, b) = (hull[i], hull[(i + 1) % hull.len()]);+ let l = geom::dist(a, b); let (ux, uy) = ((b.0 - a.0) / l, (b.1 - a.1) / l);+ let (mut u0, mut u1, mut v0, mut v1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);+ for q in &hull { let (u, v) = (q.0 * ux + q.1 * uy, -q.0 * uy + q.1 * ux); u0 = u0.min(u); u1 = u1.max(u); v0 = v0.min(v); v1 = v1.max(v); }+ let (eu, ev) = (u1 - u0, v1 - v0); let (mu, mv) = ((u0 + u1) / 2.0, (v0 + v1) / 2.0);+ let c = (mu * ux - mv * uy, mu * uy + mv * ux);+ let a180 = uy.atan2(ux).to_degrees().rem_euclid(180.0);+ let (size, ang) = if a180 < 90.0 { ((ev, eu), a180 - 90.0) } else { ((eu, ev), a180 - 180.0) };+ let turn = (90.0 - a180.rem_euclid(90.0)).rem_euclid(90.0);+ // of rectangles of one area OpenCV's rotating calipers keep the last they come to, turning away from upright+ let better = match &best { None => true, Some(k) => { let tol = 1e-9 * k.0.max(1.0); eu * ev < k.0 - tol || (eu * ev <= k.0 + tol && turn > k.1) } };+ if better { best = Some((eu * ev, turn, c, size, ang)); }+ }+ let k = best.unwrap(); (k.2, k.3, k.4)+}++/// The four corners of a rectangle given as (centre, (length, width), degrees).+pub fn corners(c: P, size: (f64, f64), deg: f64) -> [P; 4] {+ let (co, si) = (deg.to_radians().cos(), deg.to_radians().sin()); let (a, b) = (size.0 / 2.0, size.1 / 2.0);+ [(-a, -b), (a, -b), (a, b), (-a, b)].map(|(u, v)| (c.0 + u * co - v * si, c.1 + u * si + v * co))+}++/// Area two convex polygons share: the first is cut by each side of the second in turn.+pub fn shared_area(a: &[P], b: &[P]) -> f64 {+ let turn = |p: &[P]| (0..p.len()).map(|i| { let (q, r) = (p[i], p[(i + 1) % p.len()]); q.0 * r.1 - r.0 * q.1 }).sum::<f64>();+ let sign = if turn(b) >= 0.0 { 1.0 } else { -1.0 };+ let mut poly: Vec<P> = a.to_vec();+ for i in 0..b.len() {+ let (p, q) = (b[i], b[(i + 1) % b.len()]);+ let side = |r: P| sign * ((q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0));+ let mut next = vec![];+ for k in 0..poly.len() {+ let (c, d) = (poly[k], poly[(k + 1) % poly.len()]); let (sc, sd) = (side(c), side(d));+ if sc >= 0.0 { next.push(c); }+ if (sc > 0.0 && sd < 0.0) || (sc < 0.0 && sd > 0.0) { let t = sc / (sc - sd); next.push((c.0 + t * (d.0 - c.0), c.1 + t * (d.1 - c.1))); }+ }+ poly = next; if poly.len() < 3 { return 0.0; }+ }+ geom::polygon_area(&poly)+}++/// A body blob: its rectangle in pixels, w along the angle (radians).+#[derive(Clone, Copy, Debug)]+pub struct Body { pub cx: f64, pub cy: f64, pub w: f64, pub h: f64, pub ang: f64 }++/// Blobs of a mask that could be a body: between `amin` and `amax` pixels, at least 3 pixels across, filling `fill` of their rectangle.+pub fn bodies(mask: &[bool], w: usize, h: usize, amin: usize, amax: usize, fill: f64) -> Vec<Body> {+ let mut out = vec![];+ for b in blobs(&open2(mask, w, h), w, h) {+ if b.len() < amin || b.len() > amax { continue; }+ let pts: Vec<P> = b.iter().map(|q| (q.0 as f64, q.1 as f64)).collect();+ let (c, (bw, bh), ang) = min_area_rect(&pts);+ if bw.min(bh) < 3.0 || b.len() as f64 / (bw * bh + 1.0) < fill { continue; }+ out.push(Body { cx: c.0, cy: c.1, w: bw, h: bh, ang: ang.to_radians() });+ }+ out+}++/// One chip passive. `x`, `y` are in the opened mask's pixels (see `open2`); `rot` is the image angle of its length, clockwise on screen.+#[derive(Clone, Debug)]+pub struct Passive { pub kind: &'static str, pub x: f64, pub y: f64, pub rot: f64, pub pk: usize, pub q: f64, pub body_mm: (f64, f64), pub cost: f64 }++impl Passive {+ /// The body centre in board-image pixels, a pixel's middle at +0.5: the opening's one pixel taken back out.+ pub fn centre(&self) -> P { (self.x - 0.5, self.y - 0.5) }+ /// The package outline in board-image pixels.+ pub fn outline(&self) -> [P; 4] { corners(self.centre(), (PK[self.pk].1 * RES, PK[self.pk].2 * RES), self.rot) }+ pub fn area_mm2(&self) -> f64 { PK[self.pk].1 * PK[self.pk].2 }+}++/// A body's read angle within this many degrees of square or diagonal is taken as square or diagonal.+const SQUARE_WITHIN: f64 = 22.5;++/// The body gives the place and the width; the ends only say which way the part lies. An end is judged against this part's own+/// body: less colour than a tan body, brighter than a dark one. Both ends must show along one axis and not along the other, and the+/// body's size must be near a package's; otherwise the body is no part.+pub fn part(b: &Body, px: &Px, kind: &'static str) -> Option<Passive> {+ let tan = kind == "tan"; let mut ax = vec![];+ for (l, w, ang) in [(b.w, b.h, b.ang), (b.h, b.w, b.ang + FRAC_PI_2)] { // l = body size along the tried axis+ let (ux, uy) = (ang.cos(), ang.sin()); let half = (w * 0.38).max(2.0);+ let own = px.grid(b.cx, b.cy, ux, uy, -l * 0.3, l * 0.3, w * 0.3);+ let bs = median(own.iter().map(|i| px.hsv[*i][1] as f64).collect()); let bv = median(own.iter().map(|i| px.hsv[*i][2] as f64).collect());+ let end = |i: usize| { let [_, s, v] = px.hsv[i]; !px.board[i] && v > 120 && if tan { (s as f64) < bs - 15.0 } else { v as f64 > bv + 35.0 } };+ let e = [1.0, -1.0].map(|s: f64| { let g = px.grid(b.cx, b.cy, s * ux, s * uy, l / 2.0 + 1.5, l / 2.0 + 7.0, half); g.iter().filter(|i| end(**i)).count() as f64 / g.len().max(1) as f64 });+ ax.push((e[0].min(e[1]), e[0].max(e[1]), l, w, ang));+ }+ let i = if ax[0].0 >= ax[1].0 { 0 } else { 1 }; let (me, _, l, w, ang) = ax[i]; let other = ax[1 - i].1;+ if me < 0.22 || me - other < 0.12 { return None; }+ let (wid, ln) = (w / RES, l / RES);+ // the body shows a little narrower than the part+ let cost = |q: &(&str, f64, f64, f64)| (wid * 1.15 / q.2).ln().abs() + (ln / (q.3 * q.1)).ln().abs();+ let pk = (0..PK.len()).fold(0, |a, k| if cost(&PK[k]) < cost(&PK[a]) { k } else { a });+ if cost(&PK[pk]) > 0.75 { return None; }+ // a body a few pixels across gives a rough angle: parts sit square or on the diagonal, so the angle goes to the nearest 45+ let mut rot = ang.to_degrees().rem_euclid(180.0); let near = (rot / 45.0).round() * 45.0;+ if (rot - near).abs() <= SQUARE_WITHIN { rot = near.rem_euclid(180.0); }+ Some(Passive { kind, x: b.cx, y: b.cy, rot, pk, q: me - other + if tan { 0.5 } else { 0.0 }, body_mm: (ln, wid), cost: cost(&PK[pk]) })+}++/// Where a chip passive cannot be: a bigger part or a plated hole's pad, in board-image pixels. `area_mm2` is that part's own size.+#[derive(Clone, Debug)]+pub struct Zone { pub c: P, pub half: (f64, f64), pub deg: f64, pub round: bool, pub area_mm2: f64, pub what: String }++impl Zone {+ pub fn holds(&self, p: P) -> bool {+ let (co, si) = (self.deg.to_radians().cos(), self.deg.to_radians().sin()); let (dx, dy) = (p.0 - self.c.0, p.1 - self.c.1);+ let (u, v) = (dx * co + dy * si, -dx * si + dy * co);+ if self.round { (u / self.half.0).powi(2) + (v / self.half.1).powi(2) <= 1.0 } else { u.abs() <= self.half.0 && v.abs() <= self.half.1 }+ }+ /// Whether this zone keeps a passive out: its centre lies in the zone and the zone is `BIG` times its package.+ pub fn takes(&self, p: &Passive) -> bool { self.area_mm2 >= BIG * p.area_mm2() && self.holds(p.centre()) }+}++/// Zones from what the flow has already measured on one face, in place of a hand-made list:+/// - every body of `prep::bodies` that is not ceramic, by its own rectangle without the solder it touches (that solder is often a+/// neighbour's end): dark moulding and coloured plastic when `SOLID`, and pale bodies, which are bare pads, pin rows, solder domes and cans;+/// - every plated hole's pad, round unless the hole list says square (`holes`, this face's detect/holes-*.json, board mm): parts do stand+/// in the corners of the square round a large ring, so a square pad that is not flagged square keeps its corners open;+/// - every row of holes at a connector pitch, half a pitch to either side: a header or terminal block stands on it. `rows` index+/// `row_holes`, the top face's holes, because rows are read on the top only and a hole is in one place on both faces.+/// A tan body makes no zone: a tan blob that large is several capacitors side by side, which are the parts looked for.+pub fn zones(bodies: &[Value], holes: &[Value], rows: &[Value], row_holes: &[Value]) -> Vec<Zone> {+ let f = |v: &Value, k: &str| v[k].as_f64().unwrap_or(0.0); let mut z = vec![];+ for b in bodies {+ let (colour, c) = (b["colour"].as_str().unwrap_or(""), &b["body"]); let (l, w) = (f(c, "long"), f(c, "short"));+ if colour == "tan" || l * w <= 0.0 || (colour != "white" && f(b, "area") / (l * w) < SOLID) { continue; }+ z.push(Zone { c: (f(c, "x"), f(c, "y")), half: (l / 2.0, w / 2.0), deg: f(c, "angle"), round: false, area_mm2: l * w / (RES * RES), what: format!("{colour} body") });+ }+ for h in holes { let r = f(h, "outer_mm").max(f(h, "hole_mm")) / 2.0;+ let sq = h["square"] == json!(true);+ z.push(Zone { c: to_px(f(h, "x"), f(h, "y")), half: (r * RES, r * RES), deg: 0.0, round: !sq, area_mm2: if sq { 4.0 } else { std::f64::consts::PI } * r * r, what: "hole pad".into() }); }+ for r in rows {+ let ids: Vec<usize> = r["holes"].as_array().into_iter().flatten().filter_map(|x| x.as_u64().map(|x| x as usize)).filter(|i| *i < row_holes.len()).collect();+ let pitch = f(r, "pitch_mm");+ if ids.len() < 2 || !crate::board::PITCHES.iter().any(|p| ((pitch - p) / p).abs() < 0.03) { continue; }+ let (a, b) = (&row_holes[ids[0]], &row_holes[ids[ids.len() - 1]]);+ let (p, q) = (to_px(f(a, "x"), f(a, "y")), to_px(f(b, "x"), f(b, "y")));+ z.push(Zone { c: ((p.0 + q.0) / 2.0, (p.1 + q.1) / 2.0), half: (geom::dist(p, q) / 2.0 + pitch * RES / 2.0, pitch * RES / 2.0), deg: (q.1 - p.1).atan2(q.0 - p.0).to_degrees(), round: false, area_mm2: f64::MAX, what: "hole row".into() });+ }+ z+}++/// Every body that passes `part` on one board image, before any is dropped.+pub fn found(px: &Px) -> Vec<Passive> {+ let mut cand = vec![];+ for (kind, amin, amax, fill) in [("tan", 14, 9000, 0.55), ("dark", 30, 12000, 0.68)] {+ let mask: Vec<bool> = (0..px.w * px.h).map(|i| if kind == "tan" { px.tan(i) } else { px.dark(i) }).collect();+ cand.extend(bodies(&mask, px.w, px.h, amin, amax, fill).iter().filter_map(|b| part(b, px, kind)));+ }+ cand+}++/// Chip passives on one board image, best first. A candidate a zone takes is left out, and of two that overlap the better one stays.+/// Returns the passives and, for the record, what the zones took: (passive, zone).+pub fn find(img: &RgbImage, zones: &[Zone]) -> (Vec<Passive>, Vec<(Passive, String)>) {+ let mut cand = found(&Px::of(img));+ cand.sort_by(|a, b| b.q.partial_cmp(&a.q).unwrap());+ let (mut kept, mut boxes, mut taken): (Vec<Passive>, Vec<[P; 4]>, Vec<(Passive, String)>) = (vec![], vec![], vec![]);+ for p in cand {+ if let Some(z) = zones.iter().find(|z| z.takes(&p)) { taken.push((p, z.what.clone())); continue; }+ let r = p.outline();+ if kept.iter().zip(&boxes).any(|(k, kr)| shared_area(&r, kr) > OVERLAP * p.area_mm2().min(k.area_mm2()) * RES * RES) { continue; }+ kept.push(p); boxes.push(r);+ }+ (kept, taken)+}++/// Whether a candidate of the older detector (`parts::candidates`) describes the spot of one of these passives: its rectangle shares more+/// than `OVERLAP` of the smaller of the two, and it is not a part `BIG` times the passive's size that merely stands next to it.+pub fn same_spot(c: &Value, side: &str, found: &[Passive]) -> bool {+ if c["side"] != json!(side) { return false; }+ let g = |k: &str, i: usize| c[k][i].as_f64(); let (Some(x), Some(y), Some(l), Some(w)) = (g("pos_mm", 0), g("pos_mm", 1), g("size_mm", 0), g("size_mm", 1)) else { return false };+ // the candidate's rot is KiCad's, counter-clockwise; the image angle runs the other way+ let r = corners(to_px(x, y), (l * RES, w * RES), -c["rot"].as_f64().unwrap_or(0.0));+ found.iter().any(|p| l * w < BIG * p.area_mm2() && shared_area(&r, &p.outline()) > OVERLAP * (l * w).min(p.area_mm2()) * RES * RES)+}++/// Passives as candidates for detect/parts.json, shaped like `parts::candidates` rows: `size_mm` is the package, `body_mm` the measured body.+pub fn candidates(found: &[Passive], side: &str, start: usize) -> Vec<Value> {+ let r3 = |v: f64| (v * 1000.0).round() / 1000.0; let r2 = |v: f64| (v * 100.0).round() / 100.0;+ found.iter().enumerate().map(|(k, p)| {+ let (name, l, w, _) = PK[p.pk]; let c = p.centre(); let pos = to_mm(c.0, c.1);+ let fp = format!("{}_{}", if p.kind == "tan" { "Capacitor_SMD:C" } else { "Resistor_SMD:R" }, METRIC[p.pk]);+ // KiCad rotation is counter-clockwise on screen; the image angle runs clockwise+ let rot = (-p.rot).rem_euclid(180.0) + 0.0;+ json!({"id": format!("P{:03}", start + k + 1), "side": side, "pos_mm": [r3(pos.0), r3(pos.1)], "rot": (rot * 10.0).round() / 10.0,+ "size_mm": [l, w], "body_mm": [r2(p.body_mm.0), r2(p.body_mm.1)], "colour": p.kind, "kind": p.kind, "fits": true,+ "prefix": if p.kind == "tan" { "C" } else { "R" }, "package": name, "footprint": fp,+ "matches": [{"package": name, "footprint": fp, "error": r3(p.cost)}], "score": r2(p.q), "method": "body", "source": "body-first"})+ }).collect()+}++#[cfg(test)]+mod tests {+ use super::*;+ use image::Rgb;++ const MASK: Rgb<u8> = Rgb([50, 186, 140]); const SOLDER: Rgb<u8> = Rgb([200, 200, 200]);+ const TAN: Rgb<u8> = Rgb([190, 140, 100]); const DARK: Rgb<u8> = Rgb([60, 40, 35]);++ fn fill(img: &mut RgbImage, x: std::ops::Range<u32>, y: std::ops::Range<u32>, c: Rgb<u8>) { for v in y { for u in x.clone() { img.put_pixel(u, v, c); } } }+ /// A chip part lying along x: a solder end, the body, a solder end.+ fn chip(img: &mut RgbImage, x0: u32, y0: u32, end: u32, body: u32, wid: u32, c: Rgb<u8>) {+ fill(img, x0..x0 + end, y0..y0 + wid, SOLDER); fill(img, x0 + end..x0 + end + body, y0..y0 + wid, c); fill(img, x0 + end + body..x0 + 2 * end + body, y0..y0 + wid, SOLDER);+ }++ #[test]+ fn hsv_is_opencvs() {+ // (r, g, b) and what cv2.cvtColor(..., COLOR_BGR2HSV) gives for it (OpenCV 5.0.0)+ for ((r, g, b), want) in [((255, 0, 0), (0, 255, 255)), ((0, 255, 0), (60, 255, 255)), ((0, 0, 255), (120, 255, 255)), ((200, 100, 50), (10, 191, 200)), ((50, 186, 140), (80, 186, 186)),+ ((110, 60, 30), (11, 185, 110)), ((200, 200, 200), (0, 0, 200)), ((99, 43, 17), (10, 211, 99)), ((160, 120, 90), (13, 112, 160)), ((253, 254, 255), (105, 2, 255)),+ ((3, 2, 1), (15, 170, 3)), ((78, 77, 77), (0, 3, 78)), ((10, 200, 10), (60, 242, 200)), ((128, 10, 250), (135, 245, 250)), ((0, 0, 0), (0, 0, 0))] {+ assert_eq!(hsv8(r, g, b), want, "rgb {r} {g} {b}");+ }+ }++ #[test]+ fn rectangle_round_a_turned_body() {+ // every whole point inside a 40 x 16 rectangle turned 30 degrees about (100, 80)+ let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); let mut pts = vec![];+ for y in 40..120 { for x in 60..140 { let (dx, dy) = (x as f64 - 100.0, y as f64 - 80.0); let (u, v) = (dx * c + dy * s, -dx * s + dy * c);+ if u.abs() <= 20.0 && v.abs() <= 8.0 { pts.push((x as f64, y as f64)); } } }+ let (ctr, (w, h), ang) = min_area_rect(&pts);+ assert!(geom::dist(ctr, (100.0, 80.0)) < 0.6, "{ctr:?}");+ assert!((w - 16.0).abs() < 1.3 && (h - 40.0).abs() < 1.3, "{w} x {h}");+ assert!((ang + 60.0).abs() < 2.5, "{ang}"); // w lies along the angle: the short side at -60, the long one at 30+ // an upright block of pixels comes out exact, as OpenCV has it: ((4.5, 2), (4, 9), -90)+ let up: Vec<P> = (0..5).flat_map(|y| (0..10).map(move |x| (x as f64, y as f64))).collect();+ assert_eq!(min_area_rect(&up), ((4.5, 2.0), (4.0, 9.0), -90.0));+ }++ #[test]+ fn opening_and_blobs() {+ // a 2 x 2 block stays (one pixel down and right, OpenCV's default anchor), a single pixel and a one-pixel line go+ let (w, h) = (8, 8); let mut m = vec![false; w * h];+ for (x, y) in [(2, 2), (3, 2), (2, 3), (3, 3), (5, 5), (6, 2), (6, 3), (6, 4)] { m[y * w + x] = true; }+ let o = open2(&m, w, h);+ let on: Vec<(usize, usize)> = (0..w * h).filter(|i| o[*i]).map(|i| (i % w, i / w)).collect();+ assert_eq!(on, vec![(3, 3), (4, 3), (3, 4), (4, 4)]);+ // blobs join along sides only: two blocks that touch at a corner are two+ let mut m = vec![false; w * h];+ for (x, y) in [(0, 0), (1, 0), (0, 1), (1, 1), (2, 2), (3, 2), (2, 3), (3, 3)] { m[y * w + x] = true; }+ let b = blobs(&m, w, h);+ assert_eq!((b.len(), b[0].len(), b[1].len()), (2, 4, 4));+ }++ #[test]+ fn shared_area_of_rectangles() {+ let a = corners((10.0, 10.0), (8.0, 4.0), 0.0);+ assert!((shared_area(&a, &a) - 32.0).abs() < 1e-9);+ assert!((shared_area(&a, &corners((14.0, 10.0), (8.0, 4.0), 0.0)) - 16.0).abs() < 1e-9);+ assert!((shared_area(&a, &corners((10.0, 10.0), (2.0, 2.0), 10.0)) - 4.0).abs() < 1e-9);+ assert!((shared_area(&a, &corners((10.0, 10.0), (4.0, 8.0), 90.0)) - 32.0).abs() < 1e-9);+ assert_eq!(shared_area(&a, &corners((30.0, 10.0), (8.0, 4.0), 45.0)), 0.0);+ }++ #[test]+ fn two_parts_in_a_row_are_two_parts() {+ // two 0402 capacitors end to end along x, 0.2 mm apart, and an upright 0603 resistor: 40 px per mm+ let mut img = RgbImage::from_pixel(400, 300, MASK);+ chip(&mut img, 100, 100, 10, 20, 20, TAN); chip(&mut img, 148, 100, 10, 20, 20, TAN);+ fill(&mut img, 300..332, 100..112, SOLDER); fill(&mut img, 300..332, 112..152, DARK); fill(&mut img, 300..332, 152..164, SOLDER);+ let (found, taken) = find(&img, &[]);+ assert!(taken.is_empty());+ let got: Vec<(&str, &str, P, f64)> = found.iter().map(|p| (p.kind, PK[p.pk].0, p.centre(), p.rot)).collect();+ assert_eq!(found.len(), 3, "{got:?}");+ // each capacitor on its own body, none across the gap at x = 144 where two solder ends face each other+ for want in [(120.0, 110.0), (168.0, 110.0)] { assert!(got.iter().any(|g| g.0 == "tan" && g.1 == "0402" && geom::dist(g.2, want) < 0.01 && g.3 == 0.0), "{want:?} in {got:?}"); }+ assert!(got.iter().all(|g| (g.2 .0 - 144.0).abs() > 10.0), "{got:?}");+ assert!(got.iter().any(|g| g.0 == "dark" && g.1 == "0603" && geom::dist(g.2, (316.0, 132.0)) < 0.01 && g.3 == 90.0), "{got:?}");+ // as candidates: board mm, the package's size, and the fields the flow reads+ let c = candidates(&found, "top", 7);+ let a = c.iter().find(|c| c["pos_mm"] == json!([103.0, 102.75])).expect("the first capacitor at its body centre");+ assert_eq!((a["package"].as_str(), a["kind"].as_str(), a["method"].as_str(), a["side"].as_str(), a["prefix"].as_str()), (Some("0402"), Some("tan"), Some("body"), Some("top"), Some("C")));+ assert_eq!((a["size_mm"].clone(), a["rot"].as_f64(), a["fits"].clone(), a["footprint"].as_str()), (json!([1.0, 0.5]), Some(0.0), json!(true), Some("Capacitor_SMD:C_0402_1005Metric")));+ assert!(a["score"].as_f64().unwrap() > 1.0 && a["body_mm"][0].as_f64().unwrap() < 0.5);+ assert_eq!(c.iter().map(|c| c["id"].as_str().unwrap()).collect::<Vec<_>>(), ["P008", "P009", "P010"]);+ // the older detector's pairing of the two facing ends is on the same spot and loses; a big part beside them is not+ let old = |x: f64, y: f64, l: f64, w: f64| json!({"side": "top", "pos_mm": [x, y], "size_mm": [l, w], "rot": 0.0});+ assert!(same_spot(&old(103.6, 102.75, 0.7, 0.5), "top", &found));+ assert!(!same_spot(&old(103.6, 102.75, 0.7, 0.5), "bottom", &found));+ assert!(!same_spot(&old(104.0, 103.9, 5.0, 3.0), "top", &found));+ }++ #[test]+ fn a_passive_inside_a_bigger_part_is_left_out() {+ let mut img = RgbImage::from_pixel(400, 300, MASK);+ chip(&mut img, 100, 100, 10, 20, 20, TAN); chip(&mut img, 250, 100, 10, 20, 20, TAN);+ let body = |colour: &str, x: f64, y: f64, l: f64, w: f64, area: f64| json!({"x": x, "y": y, "long": l + 12.0, "short": w + 12.0, "angle": 0.0, "colour": colour, "area": area,+ "body": {"x": x, "y": y, "long": l, "short": w, "angle": 0.0}});+ // a pale body 3 x 2 mm over the first capacitor (a pin row, a bare pad): the capacitor is its marking, not a part+ let z = zones(&[body("white", 120.0, 110.0, 120.0, 80.0, 9000.0)], &[], &[], &[]);+ let (found, taken) = find(&img, &z);+ assert_eq!((found.len(), taken.len(), taken[0].1.as_str()), (1, 1, "white body"));+ assert!(geom::dist(found[0].centre(), (270.0, 110.0)) < 0.01);+ // not a zone: the part's own solder read as a pale body (under 5 packages), a tan blob, shadows that fill half their rectangle,+ // a moulding that only touches the part with its solder, and a hole row at no connector pitch+ let holes = [json!({"x": 100.0, "y": 100.0, "outer_mm": 2.0, "hole_mm": 1.0, "square": false}), json!({"x": 116.0, "y": 100.0, "outer_mm": 2.0, "hole_mm": 1.0, "square": false})];+ let z = zones(&[body("white", 120.0, 110.0, 52.0, 30.0, 1500.0), body("tan", 120.0, 110.0, 120.0, 80.0, 9000.0), body("black", 120.0, 110.0, 120.0, 80.0, 4800.0), body("black", 180.0, 110.0, 76.0, 80.0, 6000.0)],+ &[], &[json!({"holes": [0, 1], "pitch_mm": 16.0})], &holes);+ assert_eq!(find(&img, &z).0.len(), 2);+ // a zone: a solid moulding over it, a hole's pad round it, a header row through it+ assert_eq!(find(&img, &zones(&[body("black", 120.0, 110.0, 120.0, 80.0, 9000.0)], &[], &[], &[])).0.len(), 1);+ let pad = [json!({"x": 103.0, "y": 102.75, "outer_mm": 2.0, "hole_mm": 1.0, "square": false}), json!({"x": 105.54, "y": 102.75, "outer_mm": 2.0, "hole_mm": 1.0, "square": false})];+ assert_eq!(find(&img, &zones(&[], &pad[..1], &[], &[])).1[0].1, "hole pad");+ assert_eq!(find(&img, &zones(&[], &[], &[json!({"holes": [0, 1], "pitch_mm": 2.54})], &pad)).1[0].1, "hole row");+ }+}
src/photolayers.rs+73−8@@ -142,9 +142,47 @@ fn holes_of(top: &Face, bot: &Face) -> Vec<HoleM> { } /// One board edge read from a photo with a margin around it: the place wood turns to board, every 5 px, as a line.-fn edge_line(img: &image::RgbImage, m: i64, along_x: bool, near: bool, wf: f64, hf: f64) -> Option<(f64, f64, usize, usize, f64)> {+/// Under this share of samples agreeing on a line, an edge is not read. The CM5 dark-mat photos with the wood test gave under 0.02 on every edge; read edges on the Driver give over 0.8.+const EDGE_MIN_SHARE: f64 = 0.3;++/// What the table and the board look like in this photo: the median colour of the margin outside the frame and of a band inside the edges.+/// The table can be wood, a dark mat or anything else; each pixel then goes with whichever it is nearer to.+fn side_model(img: &image::RgbImage, m: i64, wf: f64, hf: f64) -> ([f32; 3], [f32; 3]) { let (w, h) = (img.width() as i64, img.height() as i64);- let woodness = |x: i64, y: i64| -> f32 { if x < 0 || y < 0 || x >= w || y >= h { return 0.0; } let mut s = 0.0; for dy in -1..=1 { for dx in -1..=1 { let (u, v) = ((x + dx).clamp(0, w - 1), (y + dy).clamp(0, h - 1)); let p = img.get_pixel(u as u32, v as u32); s += p[0] as f32 - p[1] as f32; } } s / 9.0 };+ let (mut out, mut inn): (Vec<[f32; 3]>, Vec<[f32; 3]>) = (vec![], vec![]);+ let (x1, y1) = (m + wf as i64, m + hf as i64);+ let mut y = 0; while y < h { let mut x = 0; while x < w {+ let p = img.get_pixel(x as u32, y as u32); let c = [p[0] as f32, p[1] as f32, p[2] as f32];+ let outside = x < m - 12 || y < m - 12 || x > x1 + 12 || y > y1 + 12;+ let d_in = (x - m).min(y - m).min(x1 - x).min(y1 - y);+ // the warp leaves pure black past the photo's own edge: that is no table+ if outside && c.iter().sum::<f32>() > 6.0 { out.push(c); } else if (40..=120).contains(&d_in) { inn.push(c); }+ x += 3; } y += 3; }+ let med = |v: &mut Vec<[f32; 3]>| -> [f32; 3] { if v.is_empty() { return [0.0; 3]; } let mut r = [0.0; 3]; for k in 0..3 { let mut c: Vec<f32> = v.iter().map(|q| q[k]).collect(); c.sort_by(|a, b| a.partial_cmp(b).unwrap()); r[k] = c[c.len() / 2]; } r };+ (med(&mut out), med(&mut inn))+}++/// Mean colour of the 3 x 3 round a point, nearer the board's colour than the table's.+fn board_like(img: &image::RgbImage, x: i64, y: i64, model: &([f32; 3], [f32; 3])) -> Option<bool> {+ let (w, h) = (img.width() as i64, img.height() as i64);+ if x < 1 || y < 1 || x + 1 >= w || y + 1 >= h { return None; }+ let mut c = [0f32; 3]; for dy in -1..=1 { for dx in -1..=1 { let p = img.get_pixel((x + dx) as u32, (y + dy) as u32); for k in 0..3 { c[k] += p[k] as f32 / 9.0; } } }+ if c.iter().sum::<f32>() <= 6.0 { return None; }+ let d = |q: &[f32; 3]| (0..3).map(|k| (c[k] - q[k]).powi(2)).sum::<f32>();+ Some(d(&model.1) < d(&model.0))+}++/// Both tests read each edge, wood (table redder than the board) and the photo's own colour model; the one more samples agree on stands.+/// Where wood reads well, as on the Driver photos, the result is the wood reading.+fn edge_line(img: &image::RgbImage, m: i64, along_x: bool, near: bool, wf: f64, hf: f64) -> Option<(f64, f64, usize, usize, f64)> {+ let model = side_model(img, m, wf, hf);+ let wood = edge_line_by(img, m, along_x, near, wf, hf, &|x, y| { let (w, h) = (img.width() as i64, img.height() as i64); if x < 0 || y < 0 || x >= w || y >= h { return None; } let mut s = 0.0; for dy in -1..=1 { for dx in -1..=1 { let (u, v) = ((x + dx).clamp(0, w - 1), (y + dy).clamp(0, h - 1)); let p = img.get_pixel(u as u32, v as u32); s += p[0] as f32 - p[1] as f32; } } Some(s / 9.0 < 15.0) }, false);+ let own = edge_line_by(img, m, along_x, near, wf, hf, &|x, y| board_like(img, x, y, &model), true);+ match (wood, own) { (Some(a), Some(b)) => Some(if a.2 >= b.2 { a } else { b }), (a, b) => a.or(b) }+}++fn edge_line_by(img: &image::RgbImage, m: i64, along_x: bool, near: bool, wf: f64, hf: f64, like: &dyn Fn(i64, i64) -> Option<bool>, need_run: bool) -> Option<(f64, f64, usize, usize, f64)> {+ let _ = img; let span = if along_x { wf } else { hf }; let mut pts = vec![]; let (ends, wall) = (120.0, if along_x { if near { 0.0 } else { hf } } else if near { 0.0 } else { wf });@@ -158,9 +196,13 @@ fn edge_line(img: &image::RgbImage, m: i64, along_x: bool, near: bool, wf: f64, while s < 40.0 { let q = wall + dir * s; let (x, y) = if along_x { (t + m as f64, q + m as f64) } else { (q + m as f64, t + m as f64) };- let wn = woodness(x as i64, y as i64);- if let Some(pw) = prev { if pw >= 15.0 && wn < 15.0 { found = Some(q); break; } }- prev = Some(wn);+ let now = like(x as i64, y as i64);+ // table, then board three pixels running: the edge+ if prev == Some(false) && now == Some(true) {+ let run = !need_run || (1..=3).all(|k| { let q2 = q + dir * k as f64; let (x2, y2) = if along_x { (t + m as f64, q2 + m as f64) } else { (q2 + m as f64, t + m as f64) }; like(x2 as i64, y2 as i64) == Some(true) });+ if run { found = Some(q); break; }+ }+ prev = now; s += 1.0; } if let Some(q) = found { pts.push((t, q)); }@@ -205,6 +247,9 @@ fn edges_of_file(flow: &Path, file: &str, side: &str, q: Option<&crate::regframe if let Some(l) = edge_line(&img, m, ax, near, fw, fh) { out.push((name.to_string(), l)); } } // each corner: where the fitted edges meet, then along the diagonal to the first board pixel+ let model = side_model(&img, m, fw, fh);+ // the corner walk uses the wood test where wood reads this photo's edges well, the photo's own colour model elsewhere+ let wood_ok = { let wl = |p: &image::Rgb<u8>| p[0] as f32 - p[1] as f32; let _ = wl; let mut n = 0; for (ax, near) in [(true, true), (true, false), (false, true), (false, false)] { if let Some(l) = edge_line_by(&img, m, ax, near, fw, fh, &|x, y| { let (w, h) = (img.width() as i64, img.height() as i64); if x < 0 || y < 0 || x >= w || y >= h { return None; } let mut s = 0.0; for dy in -1..=1 { for dx in -1..=1 { let (u, v) = ((x + dx).clamp(0, w - 1), (y + dy).clamp(0, h - 1)); let p = img.get_pixel(u as u32, v as u32); s += p[0] as f32 - p[1] as f32; } } Some(s / 9.0 < 15.0) }, false) { if l.2 as f64 >= 0.6 * l.3 as f64 { n += 1; } } } n >= 2 }; let line = |n: &str| out.iter().find(|e| e.0 == n).map(|e| (e.1 .0, e.1 .1)); let mut radii = vec![]; if let (Some(t), Some(bo), Some(lf), Some(r)) = (line("top"), line("bottom"), line("left"), line("right")) {@@ -213,7 +258,8 @@ fn edges_of_file(flow: &Path, file: &str, side: &str, q: Option<&crate::regframe let mut d = 0.0; let mut hit = None; while d < 120.0 { let (u, v) = (c.0 + sx * d * std::f64::consts::FRAC_1_SQRT_2 + m as f64, c.1 + sy * d * std::f64::consts::FRAC_1_SQRT_2 + m as f64);- if u >= 1.0 && v >= 1.0 && (u as u32) + 1 < img.width() && (v as u32) + 1 < img.height() { let mut wn = 0.0; for dy in 0..3u32 { for dx in 0..3u32 { let p = img.get_pixel(u as u32 + dx - 1, v as u32 + dy - 1); wn += p[0] as f64 - p[1] as f64; } } if wn / 9.0 < 15.0 { hit = Some(d); break; } }+ if wood_ok { if u >= 1.0 && v >= 1.0 && (u as u32) + 1 < img.width() && (v as u32) + 1 < img.height() { let mut wn = 0.0; for dy in 0..3u32 { for dx in 0..3u32 { let p = img.get_pixel(u as u32 + dx - 1, v as u32 + dy - 1); wn += p[0] as f64 - p[1] as f64; } } if wn / 9.0 < 15.0 { hit = Some(d); break; } } }+ else if board_like(&img, u as i64, v as i64, &model) == Some(true) { hit = Some(d); break; } d += 1.0; } if let Some(h) = hit { radii.push(((h / (std::f64::consts::SQRT_2 - 1.0)) / board::RES * 100.0).round() / 100.0); }@@ -387,7 +433,7 @@ fn outer_ring_px(px: &[usize], w: usize) -> Option<(Vec<P>, P)> { pub fn trace(flow: &Path, pads: &[Pad], ev: &Path) -> Result<Value, String> { let open = |n: &str| image::open(flow.join(n)).map(|i| i.to_rgb8()).map_err(|e| e.to_string()); let (ti, bi) = (open("board/top.jpg")?, open("board/bottom.jpg")?);- let (mut top, mut bot) = (photomat::classify(&ti, true), photomat::classify(&bi, false));+ let (mut top, mut bot) = (photomat::classify(&ti, photomat::carries_parts(&ti)), photomat::classify(&bi, photomat::carries_parts(&bi))); photomat::link_holes(&mut top, &mut bot); photomat::false_colour(&ti, &top).save(ev.join("materials-top.jpg")).map_err(|e| e.to_string())?; photomat::false_colour(&bi, &bot).save(ev.join("materials-bottom.jpg")).map_err(|e| e.to_string())?;@@ -402,10 +448,14 @@ pub fn trace(flow: &Path, pads: &[Pad], ev: &Path) -> Result<Value, String> { for name in ["top", "bottom", "left", "right"] { let (a, b) = (et.iter().find(|e| e.0 == name).map(|e| e.1), eb.iter().find(|e| e.0 == name).map(|e| e.1)); let share = |x: Option<(f64, f64, usize, usize, f64)>| x.map(|l| l.2 as f64 / l.3 as f64).unwrap_or(0.0);- let pick = if share(a) >= 0.6 { a } else if share(b) > share(a) { b } else { a };+ let mut pick = if share(a) >= 0.6 { a } else if share(b) > share(a) { b } else { a };+ // neither photo reads this edge: the frame came from the stated board, so its edge stands, and the document says so+ let stated = share(a).max(share(b)) < EDGE_MIN_SHARE;+ if stated { let wall = match name { "top" | "left" => 0.0, "bottom" => top.h as f64, _ => top.w as f64 }; pick = Some((0.0, wall, 0, 1, 0.0)); } let at_mid = |x: Option<(f64, f64, usize, usize, f64)>, mid: f64| x.map(|l| ((l.0 * mid + l.1) / board::RES * 1000.0).round() / 1000.0); let mid = if name == "top" || name == "bottom" { top.w as f64 / 2.0 } else { top.h as f64 / 2.0 }; edge_doc.insert(name.into(), json!({"topPhotoMm": at_mid(a, mid), "bottomPhotoMm": at_mid(b, mid), "topInlierShare": share(a), "bottomInlierShare": share(b), "tiltMradTop": a.map(|l| (l.0 * 1e6).round() / 1e3), "tiltMradBottom": b.map(|l| (l.0 * 1e6).round() / 1e3), "bowMmTop": a.map(|l| (l.4 * 1000.0).round() / 1000.0), "bowMmBottom": b.map(|l| (l.4 * 1000.0).round() / 1000.0)}));+ if stated { edge_doc.get_mut(name).unwrap()["stated"] = json!(true); } if let Some(l) = pick { edge.insert(name, (l.0, l.1)); } } // corners where the fitted edges meet, in board px@@ -699,6 +749,21 @@ pub fn extras_footprint(doc: &Value) -> Option<(String, Vec<Pad>)> { #[cfg(test)] mod tests {+ #[test]+ fn an_edge_reads_on_a_dark_mat_as_well_as_on_wood() {+ // a straightened board with a 60 px margin; the table is a dark teal mat in one, wood in the other+ for table in [image::Rgb([28u8, 52, 50]), image::Rgb([150, 100, 60])] {+ let (m, fw, fh) = (60i64, 800.0, 600.0);+ let img = image::RgbImage::from_fn((fw as i64 + 2 * m) as u32, (fh as i64 + 2 * m) as u32, |x, y| {+ let (x, y) = (x as i64 - m, y as i64 - m);+ if x >= 3 && y >= -2 && x < fw as i64 && y < fh as i64 { image::Rgb([60, 150, 80]) } else { table } });+ let top = edge_line(&img, m, true, true, fw, fh).expect("top edge");+ let left = edge_line(&img, m, false, true, fw, fh).expect("left edge");+ assert!(top.2 as f64 / top.3 as f64 > 0.8 && (top.1 + 2.0).abs() < 1.5, "top {top:?}");+ assert!(left.2 as f64 / left.3 as f64 > 0.8 && (left.1 - 3.0).abs() < 1.5, "left {left:?}");+ }+ }+ use super::*; #[test] fn contours_wind_outers_and_holes_oppositely() {
src/photomat.rs+31−8@@ -56,39 +56,62 @@ fn keep_comps(m: &Mask, f: impl Fn(&raster::Component) -> bool) -> Mask { Mask { w: m.w, h: m.h, d: lab.iter().map(|l| ok[*l as usize]).collect() } } +/// Whether a face carries parts, measured: how much of it is black moulding or tan ceramic bodies, on the sizes `classify` calls a body. Measured: Driver top 14 percent, Driver bottom 0.16 (its dark+/// joints and specks), CM5 top 10 and 14, CM5 bottom 3.7 and 1.4 (everypin and CNX). A face cannot be told by which side it is: the CM5 bottom has hundreds of parts and the Driver bottom none. The line is 1 percent.+pub fn carries_parts(img: &RgbImage) -> bool {+ let (w, h) = (img.width() as usize, img.height() as usize);+ let (r, g, b) = (median3(img, 0), median3(img, 1), median3(img, 2));+ let y = Plane { w, h, d: (0..w * h).map(|i| 0.299 * r.d[i] + 0.587 * g.d[i] + 0.114 * b.d[i]).collect() };+ let (br, gr) = (raster::blur(&diff(&b, &r), 2, 1), raster::blur(&diff(&g, &r), 2, 1));+ let black = Mask { w, h, d: (0..w * h).map(|i| gr.d[i] < 12.0 && gr.d[i] > -14.0 && br.d[i].abs() < 26.0 && y.d[i] < 62.0).collect() }.open(2.0).close(4.0);+ let tan = Mask { w, h, d: (0..w * h).map(|i| -br.d[i] > 45.0 && y.d[i] > 85.0 && raster::hsv(&image::Rgb([r.d[i] as u8, g.d[i] as u8, b.d[i] as u8])).0 < 33.0).collect() }.close(2.0);+ let (_, bc) = raster::components(&black); let (_, tc) = raster::components(&tan);+ let bodies: f64 = bc.iter().filter(|c| c.area as f64 > 0.5 * MM2).map(|c| c.area as f64).sum::<f64>() + tc.iter().filter(|c| c.area as f64 > 0.12 * MM2).map(|c| c.area as f64).sum::<f64>();+ bodies >= 0.01 * (w * h) as f64+}+ /// `bodies`: this face carries parts. A face with none (the Driver bottom) has no plastic to find, and its lit mask /// reads near neutral, so the body rules would only do harm there.+///+/// Thresholds that depend on how bright the mask photographs scale with `k` (luma) and `kg` (G - R), measured on this photo and 1 on the Driver photos they were read from: the CM5 mask reads luma 130+/// where the Driver's reads 44 and 64. Thresholds on black, white and gold do not scale: those colours are the same on any board. pub fn classify(img: &RgbImage, bodies: bool) -> Face { let (w, h) = (img.width() as usize, img.height() as usize);+ let tone = crate::prep::mask_tone(img);+ let (k, kg) = (tone.exposure(), tone.chroma()); let (r, g, b) = (median3(img, 0), median3(img, 1), median3(img, 2)); let y = Plane { w, h, d: (0..w * h).map(|i| 0.299 * r.d[i] + 0.587 * g.d[i] + 0.114 * b.d[i]).collect() }; let raw_br = diff(&b, &r); let (br, gr) = (raster::blur(&raw_br, 2, 1), raster::blur(&diff(&g, &r), 2, 1)); let idx = |pred: &dyn Fn(usize) -> bool| Mask { w, h, d: (0..w * h).map(|i| pred(i)).collect() }; // mask: green over red, not bright. From these photos: G - R > 10.- let masky = idx(&|i| gr.d[i] > 10.0 && y.d[i] < 140.0 && br.d[i] > -5.0);+ let masky = idx(&|i| gr.d[i] > 10.0 * kg && y.d[i] < 140.0 * k && br.d[i] > -5.0); let ym = masked_mean(&y, &masky, 60);- // the silk's brightness: pale, bluish, not green. 60th percentile, so glints do not raise it.- let silk_level = percentile((0..w * h).filter(|i| y.d[*i] > 150.0 && br.d[*i] > 6.0 && gr.d[*i] < 20.0).map(|i| y.d[i]).collect(), 0.6).max(120.0);+ // the silk's brightness: pale, bluish, not green. 60th percentile, so glints do not raise it. Silk is white under any exposure, but a bright mask must not count as pale.+ let silk_level = percentile((0..w * h).filter(|i| y.d[*i] > (150.0f32).max(1.25 * tone.y50) && br.d[*i] > if tone.bright() { -14.0 } else { 6.0 } && gr.d[*i] < 20.0 * kg.min(1.5)).map(|i| y.d[i]).collect(), 0.6).max(120.0); let bright = idx(&|i| y.d[i] > ym.d[i] + 0.5 * (silk_level - ym.d[i]));+ let hi_pre = 0.85 * silk_level; // gold plating and anything yellow or tan: R far over B. From these photos: R - B > 45, Y > 85. let warm = idx(&|i| -br.d[i] > 45.0 && y.d[i] > 85.0); let hue_of = |i: usize| raster::hsv(&image::Rgb([r.d[i] as u8, g.d[i] as u8, b.d[i] as u8])); // tan ceramic: hue under 33 degrees; gold plating sits at 40 to 55.- let tan = idx(&|i| warm.d[i] && hue_of(i).0 < 33.0 && y.d[i] < 175.0);+ let tan = idx(&|i| warm.d[i] && hue_of(i).0 < 33.0 && y.d[i] < 175.0 * k); // the bottom's plating reads pale in this light: bright and warmer than any silk is still metal- let pale_warm = idx(&|i| bright.d[i] && -br.d[i] > 14.0 && gr.d[i] <= 26.0);+ let pale_warm = idx(&|i| bright.d[i] && -br.d[i] > 14.0 && gr.d[i] <= 26.0 * kg); // (only blobs big enough to be plating: a fringe on a silk letter and a glint on a dome are also warm) let gold_any = if bodies { warm.andnot(&tan) } else { warm.andnot(&tan).or(&keep_comps(&pale_warm, |c| c.area as f64 > 0.4 * MM2)) }; // black moulding: neutral and dark. Laminate under mask stays green here (G - R about 27). let neutral = idx(&|i| gr.d[i] < 12.0 && gr.d[i] > -14.0 && br.d[i].abs() < 26.0); let black = idx(&|i| neutral.d[i] && y.d[i] < 62.0); // bright green plastic: far brighter than the mask around it and still green.- let mint = idx(&|i| gr.d[i] > 30.0 && y.d[i] > ym.d[i] * 1.7 && y.d[i] > 80.0);+ let mint = idx(&|i| gr.d[i] > 30.0 * kg && y.d[i] > ym.d[i] * 1.7 && y.d[i] > 80.0 * k); // silk candidates: bright, not warm, not strongly green; loose on chroma because lateral colour fringes thin lines- let cand = idx(&|i| bright.d[i] && !warm.d[i] && gr.d[i] <= 26.0 && br.d[i] >= if bodies { -30.0 } else { -14.0 });+ let cand = idx(&|i| bright.d[i] && !warm.d[i] && gr.d[i] <= 26.0 * kg && br.d[i] >= if bodies { -30.0 } else { -14.0 }); // not green: everything that is not mask has G close to R, whatever its brightness let nongreen = idx(&|i| gr.d[i] / (g.d[i] + r.d[i] + 20.0) * 1.0 < 0.08);+ // On a bright mask a thin print's lit core does not lean blue as the Driver's does (median B - R -9 to +7 on the CM5 photos, against 6 to 30), and its glints have no bluer tint to tell them by: the print's own+ // median is the bar, less a margin of 10 for lateral colour.+ let silk_chroma_bar = if tone.bright() { percentile((0..w * h).step_by(3).filter(|i| y.d[*i] > hi_pre && gr.d[*i] < 20.0 * kg.min(1.5)).map(|i| raw_br.d[i]).collect(), 0.5) - 10.0 } else { 0.0 }; let (lab, cs) = raster::components(&cand); let n = cs.len() + 1; // per component: share over 85% of the silk level, chroma of the bright core, thickest point, and the ring@@ -126,7 +149,7 @@ pub fn classify(img: &RgbImage, bodies: bool) -> Face { // photos). Where the mask around is bright the glints turn bluer too, so the bar rises with it. // On a bare face a thin line needs more: real silk lines read about 24 there and copper-edge glints stay under 10 (tile // set A). Letters are saved beside it by the solid-print rule below.- let bar = if !bodies && dmax[l] < 6.0 { 10.0f32 } else { 4.0 } + 0.2 * (ring - 60.0).max(0.0);+ let bar = if tone.bright() { silk_chroma_bar } else if !bodies && dmax[l] < 6.0 { 10.0f32 } else { 4.0 } + 0.2 * (ring - 60.0).max(0.0); let bluish = med >= bar; // a part's bright end beside its body fails this; from these photos 15% for solid blobs and 40% for thin lines let on_mask = !bodies || ring_off < if dmax[l] >= 6.0 { 0.15 } else { 0.4 };
src/prep.rs+149−16@@ -6,16 +6,66 @@ use crate::raster::{self, Mask, Plane}; use image::{imageops, Rgb, RgbImage}; use serde_json::{json, Value}; -/// Solder-mask green, as opposed to the surface the board lies on.-/// (Bright mint-green parts such as terminal blocks are excluded by the value limit: they can hang over an edge.)-pub fn is_mask_green(p: &Rgb<u8>) -> bool { let (h, s, v) = raster::hsv(p); (65.0..200.0).contains(&h) && s > 0.18 && v > 0.07 && v < 0.62 }+/// The solder mask as one photo shows it, measured on that photo. Exposure and mask colour differ by a factor of three between+/// boards (value median 0.23 to 0.31 on the Driver photos, 0.61 to 0.73 on the CM5 photos, same hue near 165), so nothing that+/// depends on them can be a constant.+#[derive(Clone, Copy, Debug)]+pub struct MaskTone {+ /// the peak of the hue histogram in the green family, degrees+ pub hue: f32,+ /// median value of the pixels around that hue+ pub v50: f32,+ /// the brightest value still mask: 1.1 x the 98th percentile of those pixels. It never falls under 0.62, where bright mint plastic+ /// such as a terminal block begins on the dark masks measured so far, because plastic can hang over a board's edge.+ pub v_hi: f32,+ /// median luma and median G - R of the pixels around that hue, 8 bit+ pub y50: f32, pub gr50: f32,+}++impl MaskTone {+ /// How many times brighter than the darkest exposure the luma thresholds of `photomat` were read on (Driver photos, mask luma 44 and 64). 1 on those photos.+ /// Within a quarter of that it is the same exposure: the Driver faces differ by more than that between themselves.+ pub fn exposure(&self) -> f32 { let k = self.y50 / MASK_Y_READ_AT; if k < 1.25 { 1.0 } else { k } }+ /// The same for how green the mask is: G - R over what the Driver photos read.+ pub fn chroma(&self) -> f32 { let k = self.gr50 / MASK_GR_READ_AT; if k < 1.25 { 1.0 } else { k } }+ /// The same for value: 0.31 is the brighter Driver face's median, and within a quarter of it nothing moves.+ pub fn value_scale(&self) -> f32 { let k = self.v50 / 0.31; if k < 1.25 { 1.0 } else { k } }+ /// A mask photographed well above the range the Driver's thresholds were read on.+ pub fn bright(&self) -> bool { self.exposure() > 1.0 }+}+/// Median G - R of the mask on the Driver photos, 8 bit, the larger of the two faces (`MaskTone::gr50` on board/top.jpg and board/bottom.jpg, 2026-11-06).+pub const MASK_GR_READ_AT: f32 = 45.0;++/// The brighter of the two Driver faces the `photomat` thresholds were read on: median mask luma 64 (2026-11-06, `MaskTone::y50` on board/bottom.jpg).+pub const MASK_Y_READ_AT: f32 = 64.0;++/// Measure the mask in a photo. The green family is a wide bracket, 65 to 200 degrees, that only keeps a brown or grey table out of the histogram.+pub fn mask_tone(img: &RgbImage) -> MaskTone {+ let mut hist = [0u32; 27];+ let step = ((img.width() as usize * img.height() as usize) / 400_000).max(1);+ let hsvs: Vec<(f32, f32, f32, &Rgb<u8>)> = img.pixels().step_by(step).map(|p| { let (h, s, v) = raster::hsv(p); (h, s, v, p) }).collect();+ for (h, s, v, _) in &hsvs { if (65.0..200.0).contains(h) && *s > 0.18 && *v > 0.07 { hist[((h - 65.0) / 5.0) as usize] += 1; } }+ let peak = hist.iter().enumerate().max_by_key(|x| *x.1).map(|x| x.0).unwrap_or(20);+ let hue = 65.0 + 5.0 * peak as f32 + 2.5;+ let (mut vs, mut ys, mut grs) = (vec![], vec![], vec![]);+ for (h, s, v, p) in &hsvs { if (h - hue).abs() < 25.0 && *s > 0.18 && *v > 0.07 { vs.push(*v); ys.push(0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32); grs.push(p[1] as f32 - p[0] as f32); } }+ let at = |v: &mut Vec<f32>, q: f64| -> f32 { if v.is_empty() { return 0.0; } v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[((v.len() - 1) as f64 * q) as usize] };+ let v50 = at(&mut vs, 0.5);+ let v_hi = (at(&mut vs, 0.98) * 1.1).clamp(0.62, 1.001);+ MaskTone { hue, v50, v_hi, y50: at(&mut ys, 0.5), gr50: at(&mut grs, 0.5) }+}++/// Solder-mask green in this photo, as opposed to the surface the board lies on.+/// (Bright mint-green parts such as terminal blocks are excluded by the measured value limit: they can hang over an edge.)+pub fn is_mask_green(p: &Rgb<u8>, t: &MaskTone) -> bool { let (h, s, v) = raster::hsv(p); (65.0..200.0).contains(&h) && s > 0.18 && v > 0.07 && v < t.v_hi } /// The four outer corners (TL, TR, BR, BL) of the board in photo pixels, from straight-line fits to the /// four edges. Corner radii and anything hanging over an edge are left out of the fits. pub fn board_corners(img: &RgbImage) -> Result<[P; 4], String> { let k = 4u32; let small = imageops::resize(img, img.width() / k, img.height() / k, imageops::FilterType::Triangle);- let m = raster::mask_from(&small, is_mask_green).close(3.0);+ let tone = mask_tone(img);+ let m = raster::mask_from(&small, |p| is_mask_green(p, &tone)).close(3.0); let (lab, cs) = raster::components(&m); let big = cs.iter().max_by_key(|c| c.area).ok_or("no green board found in the photo")?; if big.area < (m.w * m.h) / 10 { return Err("the green region is under 10 percent of the photo: is the board in frame?".into()); }@@ -65,16 +115,23 @@ pub fn sharpness(img: &RgbImage) -> f64 { s2 / n - (s / n).powi(2) } +pub fn is_gold(p: &Rgb<u8>) -> bool { let (h, s, v) = raster::hsv(p); (25.0..72.0).contains(&h) && s > 0.2 && v > 0.38 }+ pub fn is_metal(p: &Rgb<u8>) -> bool {- let (h, s, v) = raster::hsv(p);- ((25.0..72.0).contains(&h) && s > 0.2 && v > 0.38) || (s < 0.2 && v > 0.62)+ let (_, s, v) = raster::hsv(p);+ is_gold(p) || (s < 0.2 && v > 0.62) } /// Plated holes in a straightened image: metal annuli (gold or solder) enclosing a round opening. /// Returns (centre, hole diameter, outer size, square?) in that image's pixels.-pub fn holes(img: &RgbImage, min_d: f64, max_d: f64) -> Vec<Value> {- let m = raster::mask_from(img, is_metal).close(1.5);- let (lab, cs) = raster::components(&m);+pub fn holes(img: &RgbImage, min_d: f64, max_d: f64) -> Vec<Value> { holes_of_mask(&raster::mask_from(img, is_metal).close(1.5), min_d, max_d) }++/// The same with gold only. White print reads as metal above, so a silkscreen line that runs into a ring joins them and the ring is lost, as one mounting hole on the CM5 top is;+/// and a logo's counter reads as a hole. A mounting hole's ring is gold, so this list is what framing through mounting holes reads.+pub fn gold_holes(img: &RgbImage, min_d: f64, max_d: f64) -> Vec<Value> { holes_of_mask(&raster::mask_from(img, is_gold).close(1.5), min_d, max_d) }++fn holes_of_mask(m: &Mask, min_d: f64, max_d: f64) -> Vec<Value> {+ let (lab, cs) = raster::components(m); let mut out = vec![]; for c in &cs { let (bw, bh) = ((c.x1 - c.x0 + 1) as f64, (c.y1 - c.y0 + 1) as f64);@@ -119,6 +176,80 @@ mod tests { let c = board_corners(&img).unwrap(); for i in 0..4 { assert!(geom::dist(c[i], quad[i]) < 6.0, "{i}: {:?} vs {:?}", c[i], quad[i]); } }++ #[test]+ fn a_bright_mask_is_measured_not_refused() {+ // the same picture with the mask the CM5 photos show: value 0.73, where a fixed limit of 0.62 refused every photo+ let mut img = RgbImage::from_pixel(800, 600, Rgb([90, 58, 40]));+ for y in 80..520 { for x in 100..700 { img.put_pixel(x, y, Rgb([50, 186, 140])); } }+ let t = mask_tone(&img);+ assert!((t.v50 - 0.73).abs() < 0.01 && t.v_hi > 0.75, "{t:?}");+ let c = board_corners(&img).unwrap();+ assert!(geom::dist(c[0], (100.0, 80.0)) < 6.0 && geom::dist(c[2], (700.0, 520.0)) < 6.0, "{c:?}");+ assert_eq!(material(&Rgb([50, 186, 140]), &t), Mat::Mask);+ }++ #[test]+ fn a_dark_mask_keeps_bright_plastic_out() {+ let mut img = RgbImage::from_pixel(800, 600, Rgb([110, 60, 30]));+ for y in 80..520 { for x in 100..700 { img.put_pixel(x, y, Rgb([20, 70, 50])); } }+ for y in 200..220 { for x in 300..340 { img.put_pixel(x, y, Rgb([120, 230, 170])); } }+ let t = mask_tone(&img);+ assert!((t.v_hi - 0.62).abs() < 1e-3, "{t:?}");+ assert_eq!(material(&Rgb([120, 230, 170]), &t), Mat::BrightGreen);+ }++ #[test]+ fn a_ring_centre_ignores_what_touches_the_ring() {+ // a gold ring round a dark opening; a gold blob touches it on one side and a wall shows in one side of the opening+ let (cx, cy) = (200.0f64, 200.0f64);+ let mut img = RgbImage::from_pixel(400, 400, Rgb([50, 186, 140]));+ for y in 0..400u32 { for x in 0..400u32 { let d = ((x as f64 - cx).powi(2) + (y as f64 - cy).powi(2)).sqrt();+ if d < 30.0 { img.put_pixel(x, y, if (x as f64) < cx - 15.0 { Rgb([180, 170, 120]) } else { Rgb([60, 40, 30]) }); }+ else if d < 70.0 { img.put_pixel(x, y, Rgb([230, 210, 60])); }+ else if x > 260 && x < 300 && (y as f64 - cy).abs() < 20.0 { img.put_pixel(x, y, Rgb([230, 210, 60])); } } }+ let (q, _) = ring_centre(&img, (cx + 4.0, cy - 3.0), 30.0, 70.0);+ assert!(geom::dist(q, (cx, cy)) < 1.2, "{q:?}");+ }+}++/// The centre of a mounting hole's metal ring in photo pixels, from the ring's outer edge. A photo from an angle shows the far wall in the opening, so the opening's own+/// centre moves towards the camera's side; the ring lies flat on the board. Walks out from `from` in 72 directions to where metal ends, and fits a circle, dropping what touches the ring.+/// Returns the centre and its RMS fit error in pixels. Falls back on `from` when too little of the edge is seen.+pub fn ring_centre(img: &RgbImage, from: P, hole_r: f64, ring_r: f64) -> (P, f64) { ring_centre_by(img, from, hole_r, ring_r, is_metal) }++/// A mounting ring's own colour, tighter than `is_gold`: redder than green and well above blue, and lit. A ring fit needs this because solder and pale parts that touch a ring pass `is_gold` and pull the fit off centre. Green mask has green above red, so it never passes.+pub fn is_ring_gold(p: &Rgb<u8>) -> bool { let (r, g, b) = (p[0] as f32, p[1] as f32, p[2] as f32); r > 1.1 * g && g > 1.3 * b && r > 70.0 }++pub fn ring_centre_by(img: &RgbImage, from: P, hole_r: f64, ring_r: f64, pred: fn(&Rgb<u8>) -> bool) -> (P, f64) {+ let (w, h) = (img.width() as i64, img.height() as i64);+ let metal_at = |x: f64, y: f64| -> Option<bool> { let (u, v) = (x as i64, y as i64); if u < 0 || v < 0 || u >= w || v >= h { None } else { Some(pred(img.get_pixel(u as u32, v as u32))) } };+ let mut pts: Vec<P> = vec![];+ for k in 0..72 {+ let a = k as f64 / 72.0 * std::f64::consts::TAU; let (co, si) = (a.cos(), a.sin());+ let mut r = hole_r * 1.2; let mut last = None;+ while r < ring_r * 1.4 { match metal_at(from.0 + co * r, from.1 + si * r) { Some(true) => { last = Some(r); } Some(false) => { if let Some(l) = last { if r - l > 3.0 { break; } } } None => break } r += 1.0; }+ if let Some(l) = last { pts.push((from.0 + co * l, from.1 + si * l)); }+ }+ let circle = |p: &[P]| -> Option<(f64, f64, f64)> {+ if p.len() < 8 { return None; }+ let n = p.len() as f64; let (mx, my) = (p.iter().map(|q| q.0).sum::<f64>() / n, p.iter().map(|q| q.1).sum::<f64>() / n);+ let (mut suu, mut svv, mut suv, mut suuu, mut svvv, mut suvv, mut svuu) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);+ for q in p { let (u, v) = (q.0 - mx, q.1 - my); suu += u * u; svv += v * v; suv += u * v; suuu += u * u * u; svvv += v * v * v; suvv += u * v * v; svuu += v * u * u; }+ let det = suu * svv - suv * suv; if det.abs() < 1e-6 { return None; }+ let (a, b) = (0.5 * (suuu + suvv), 0.5 * (svvv + svuu));+ let (uc, vc) = ((a * svv - b * suv) / det, (b * suu - a * suv) / det);+ Some((uc + mx, vc + my, (uc * uc + vc * vc + (suu + svv) / n).sqrt()))+ };+ let mut kept = pts.clone(); let mut fit = None;+ for _ in 0..5 {+ let Some(c) = circle(&kept) else { break };+ fit = Some(c);+ let mut res: Vec<f64> = pts.iter().map(|q| (geom::dist(*q, (c.0, c.1)) - c.2).abs()).collect(); res.sort_by(|a, b| a.partial_cmp(b).unwrap());+ let lim = (res[res.len() / 2] * 2.5).max(1.0);+ kept = pts.iter().copied().filter(|q| (geom::dist(*q, (c.0, c.1)) - c.2).abs() <= lim).collect();+ }+ match fit { Some(c) if kept.len() >= 24 => { let e = (kept.iter().map(|q| (geom::dist(*q, (c.0, c.1)) - c.2).powi(2)).sum::<f64>() / kept.len() as f64).sqrt(); ((c.0, c.1), e) } _ => (from, f64::NAN) } } fn hx(v: &Value) -> (f64, f64) { (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0)) }@@ -183,15 +314,15 @@ pub enum Mat { Mask, Silk, Metal, Gold, Black, Tan, Yellow, BrightGreen, White, /// Thresholds measured on the Driver photos (10th/50th/90th percentiles, 2026-11-03): solder mask /// s 0.57/0.78/1.00 v 0.13/0.20/0.29; black moulding s 0.10/0.26/0.60 v 0.06/0.13/0.22 (QFN s 0.18, /// v 0.16); shadows between parts s 0.58 (mask-like); tan ceramic h 20-32 s 0.64-0.93 v 0.33-0.46;-/// terminations s 0.06-0.35 v 0.17-0.56.-pub fn material(p: &Rgb<u8>) -> Mat {+/// terminations s 0.06-0.35 v 0.17-0.56. The line between mask and bright plastic is the photo's own (`MaskTone::v_hi`), and so is the top of tan ceramic, which photographs at value 0.85 to 0.95 under the CM5's exposure.+pub fn material(p: &Rgb<u8>, tone: &MaskTone) -> Mat { let (h, s, v) = raster::hsv(p); if v < 0.30 && s < 0.45 { return Mat::Black; }- if (110.0..200.0).contains(&h) && s >= 0.45 && v < 0.62 { return Mat::Mask; }- if (80.0..170.0).contains(&h) && s > 0.3 && v >= 0.62 { return Mat::BrightGreen; }+ if (110.0..200.0).contains(&h) && s >= 0.45 && v < tone.v_hi { return Mat::Mask; }+ if (80.0..170.0).contains(&h) && s > 0.3 && v >= tone.v_hi { return Mat::BrightGreen; } if (40.0..65.0).contains(&h) && s > 0.55 && v > 0.55 { return Mat::Yellow; } if (25.0..60.0).contains(&h) && (0.25..0.55).contains(&s) && v > 0.45 { return Mat::Gold; }- if (8.0..45.0).contains(&h) && s > 0.45 && (0.25..0.8).contains(&v) { return Mat::Tan; }+ if (8.0..45.0).contains(&h) && s > 0.45 && v >= 0.25 && v < (0.8 * tone.value_scale()).min(1.001) { return Mat::Tan; } if s < 0.2 && v > 0.75 { return Mat::Silk; } if s < 0.35 && v >= 0.3 { return Mat::Metal; } Mat::Other@@ -202,7 +333,8 @@ pub fn material(p: &Rgb<u8>) -> Mat { /// removes silkscreen strokes and noise. Each body is grown by the metal it touches (terminations, /// leads) and measured as an oriented rectangle, with and without that metal. pub fn bodies(img: &RgbImage, silk_px: f32, min_px: f64) -> Vec<Value> {- let mats: Vec<Mat> = img.pixels().map(material).collect();+ let tone = mask_tone(img);+ let mats: Vec<Mat> = img.pixels().map(|p| material(p, &tone)).collect(); let (w, h) = (img.width() as usize, img.height() as usize); let mk = |f: &dyn Fn(Mat) -> bool| crate::raster::Mask { w, h, d: mats.iter().map(|m| f(*m)).collect() }; // black moulding carries laser marking and highlights: close small gaps and fill what it encloses@@ -224,7 +356,8 @@ pub fn bodies(img: &RgbImage, silk_px: f32, min_px: f64) -> Vec<Value> { ("tan", mk(&|m| m == Mat::Tan).open(silk_px)), ("yellow", mk(&|m| m == Mat::Yellow).open(silk_px)), ("bright-green", mk(&|m| m == Mat::BrightGreen).open(silk_px)),- ("white", mk(&|m| matches!(m, Mat::Silk | Mat::White)).open(silk_px * 2.5)), // only large white plastic survives+ // only large white plastic survives; a metal can's brushed face reads in flecks, so on a bright mask it is closed first or it breaks into dozens of bodies+ ("white", { let w = mk(&|m| matches!(m, Mat::Silk | Mat::White)); if tone.bright() { w.close(silk_px * 2.0) } else { w } }.open(silk_px * 2.5)), ]; let metal = mk(&|m| matches!(m, Mat::Metal | Mat::Gold | Mat::Silk)); let mut lab = vec![0u32; w * h];
src/snap.rs+68−1@@ -23,7 +23,8 @@ pub fn pads(text: &str) -> Vec<LPad> { /// Metal likelihood per pixel (0..1): exposed pads, solder, gold, leads; lightly blurred. pub fn metal_plane(img: &RgbImage) -> Plane {- let m = Plane { w: img.width() as usize, h: img.height() as usize, d: img.pixels().map(|p| match crate::prep::material(p) {+ let tone = crate::prep::mask_tone(img);+ let m = Plane { w: img.width() as usize, h: img.height() as usize, d: img.pixels().map(|p| match crate::prep::material(p, &tone) { crate::prep::Mat::Metal | crate::prep::Mat::Gold => 1.0, crate::prep::Mat::Silk => 0.6, _ => 0.0 }).collect() }; raster::blur(&m, 1, 2) }@@ -59,6 +60,26 @@ pub fn pad_centre(pads: &[LPad]) -> (f64, f64) { ((x0 + x1) / 2.0, (y0 + y1) / 2.0) } +/// Chip sizes in the KiCad libraries, small to large.+pub const CHIP_SIZES: [&str; 5] = ["0201_0603Metric", "0402_1005Metric", "0603_1608Metric", "0805_2012Metric", "1206_3216Metric"];+/// The next size up must fit the metal this much better (pads on metal minus ring on metal) to take over.+pub const SIZE_MARGIN: f64 = 0.08;+/// The next size down takes over as soon as it fits as well: pads that hang over bare board or a neighbour are the worse error.+pub const SIZE_MARGIN_DOWN: f64 = 0.0;+/// A two-pad chip footprint of a standard size: (name up to the size, index into `CHIP_SIZES`).+pub fn chip_size(id: &str) -> Option<(String, usize)> {+ if !(id.starts_with("Capacitor_SMD:C_") || id.starts_with("Resistor_SMD:R_") || id.starts_with("Inductor_SMD:L_")) { return None; }+ CHIP_SIZES.iter().position(|z| id.ends_with(z)).map(|k| (id[..id.len() - CHIP_SIZES[k].len()].to_string(), k))+}++/// How far a part may be moved to find its pads: under half its own short side, so a small part in a+/// row can never be carried onto its neighbour's pads; never more than 1 mm.+pub fn reach(pads: &[LPad]) -> f64 {+ let (x0, y0, x1, y1) = pads.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, q| (a.0.min(q.x - q.w / 2.0), a.1.min(q.y - q.h / 2.0), a.2.max(q.x + q.w / 2.0), a.3.max(q.y + q.h / 2.0)));+ if pads.is_empty() { return 1.0; }+ (0.45 * (x1 - x0).min(y1 - y0)).clamp(0.12, 1.0)+}+ /// As `best_pose`; with `centre` (the pads' centre in the footprint frame), (x, y) is where the part's /// middle was read on the photo, and each turn searches around the origin that puts the middle there. pub fn best_pose_at(metal: &Plane, pads: &[LPad], x: f64, y: f64, turns: &[f64], reach: f64, centre: Option<(f64, f64)>) -> (f64, f64, f64, f64) {@@ -108,3 +129,49 @@ pub fn symmetric_turns(pads: &[LPad]) -> Vec<f64> { } out }++#[cfg(test)]+mod tests {+ use super::*;+ fn two(w: f64, h: f64, pitch: f64) -> Vec<LPad> { [-1.0, 1.0].iter().map(|s| LPad { x: s * pitch / 2.0, y: 0.0, w, h, rot: 0.0, round: false, drill: 0.0 }).collect() }+ #[test]+ fn a_chip_size_is_read_from_its_name() {+ assert_eq!(chip_size("Capacitor_SMD:C_0402_1005Metric"), Some(("Capacitor_SMD:C_".to_string(), 1)));+ assert_eq!(chip_size("Resistor_SMD:R_1206_3216Metric"), Some(("Resistor_SMD:R_".to_string(), 4)));+ assert_eq!(chip_size("Package_SO:SOIC-8_3.9x4.9mm_P1.27mm"), None);+ assert_eq!(chip_size("Capacitor_SMD:C_0402_1005Metric_Pad0.74x0.62mm_HandSolder"), None);+ }+ #[test]+ fn pads_of_the_right_size_fit_better_than_the_next_size_up() {+ // metal of an 0402 on its lands: two blocks 0.5 wide, from 0.2 to 0.75 mm either side of the middle+ let mut m = Plane { w: 400, h: 400, d: vec![0.0; 160000] };+ let (bx, by) = (crate::board::ORIGIN + 5.0, crate::board::ORIGIN + 5.0);+ for s in [-1.0, 1.0] { let (u0, v0) = to_px(bx + s * 0.2, by - 0.25); let (u1, v1) = to_px(bx + s * 0.75, by + 0.25);+ for y in v0 as usize..=v1 as usize { for x in (u0.min(u1) as usize)..=(u0.max(u1) as usize) { m.d[y * 400 + x] = 1.0f32; } } }+ let small = two(0.56, 0.62, 0.96); let big = two(0.875, 0.95, 1.55);+ let a = best_pose_at(&m, &small, bx, by, &[0.0], reach(&small), None).3;+ let b = best_pose_at(&m, &big, bx, by, &[0.0], reach(&big), None).3;+ assert!(a >= b + SIZE_MARGIN, "0402 pads {a:.2} against 0603 pads {b:.2}");+ }+ #[test]+ fn reach_follows_the_part() {+ // an 0201 (pads 0.3 x 0.3, 0.6 apart) may move well under the 0.6 mm to the next part of a row+ let r = reach(&two(0.3, 0.3, 0.6)); assert!(r < 0.2, "{r}");+ // a wide part keeps the full millimetre+ let r = reach(&two(1.5, 6.0, 5.0)); assert!((r - 1.0).abs() < 1e-9, "{r}");+ }+ #[test]+ fn a_small_part_is_not_carried_onto_its_neighbour() {+ // two 0201 land patterns 0.7 mm apart; the reading sits 0.1 mm off the left one+ let mut m = Plane { w: 400, h: 400, d: vec![0.0; 160000] };+ let mut pad = |cx: f64, cy: f64| { let (u, v) = to_px(cx, cy); for y in (v as i64 - 6)..=(v as i64 + 6) { for x in (u as i64 - 6)..=(u as i64 + 6) { m.d[y as usize * 400 + x as usize] = 1.0f32; } } };+ let (bx, by) = (crate::board::ORIGIN + 3.0, crate::board::ORIGIN + 3.0);+ // left part: only half its metal shows; right part: bright and whole+ pad(bx - 0.3, by); pad(bx + 0.4, by + 0.7); pad(bx + 1.0, by + 0.7);+ let pads = two(0.3, 0.3, 0.6);+ let far = best_pose_at(&m, &pads, bx + 0.1, by + 0.1, &[0.0], 1.0, None);+ let near = best_pose_at(&m, &pads, bx + 0.1, by + 0.1, &[0.0], reach(&pads), None);+ assert!((far.1 - by).abs() > 0.5, "with a 1 mm reach the search leaves for the neighbour: {far:?}");+ assert!((near.1 - by).abs() < 0.2 && (near.0 - bx).abs() < 0.2, "with its own reach it stays: {near:?}");+ }+}
src/trace.rs+109−13@@ -85,8 +85,65 @@ fn poly_eval(c: &[f64], x: f64, y: f64, deg: usize, t: &mut Vec<f64>) -> f64 { p /// two-means. Where the split is real, those are the levels. Where it is only grain, the window is one class: it takes the class /// its brightness is nearer, keeps the separation from the coarser scale, and so the levels follow the light across a wide /// uniform area. The coarsest scale falls back on a polynomial fitted to the whole face.-pub fn two_levels(lg: &Plane, excl: &Mask) -> (Plane, Plane) {- let (w, h) = (lg.w, lg.h); const DEG: usize = 4; let sc = 3000.0;+pub fn two_levels(lg: &Plane, excl: &Mask) -> (Plane, Plane) { two_levels_at(lg, excl, &[360, 180, 90], 4) }++/// How much the light changes over a face: the spread of the copper level (85th percentile of log green, seen pixels) over 8 mm tiles, 90th less 10th percentile.+/// Reads 0.32 and 0.56 on the Driver top and bottom, whose light falls by up to half across the board, and 0.18 and 0.11 on the studio-lit CM5 everypin photos (`TRACE_DEBUG` prints it).+pub fn light_spread(lg: &Plane, excl: &Mask) -> f32 {+ let (w, h) = (lg.w, lg.h); let t = 320usize; let mut lv = vec![];+ let mut y = 0; while y < h { let mut x = 0; while x < w {+ let mut v = vec![];+ for yy in (y..(y + t).min(h)).step_by(3) { for xx in (x..(x + t).min(w)).step_by(3) { if !excl.at(xx, yy) { v.push(lg.at(xx, yy)); } } }+ if v.len() > 800 { let k = v.len() * 85 / 100; let (_, e, _) = v.select_nth_unstable_by(k, |a, b| a.partial_cmp(b).unwrap()); lv.push(*e); }+ x += t; } y += t; }+ if lv.len() < 4 { return 9.0; }+ lv.sort_by(|a, b| a.partial_cmp(b).unwrap());+ lv[lv.len() * 9 / 10] - lv[lv.len() / 10]+}+/// The two modes of log green over plain mask, when it has two: the highest bin of a 0.01 wide smoothed histogram, and the highest on the other side at least `MODES_APART` away with a valley between+/// no higher than 80 percent of the lower peak, and at least 5 percent of the higher, so a stray pile-up in a dark tail is not a mode. The CM5 faces read 172 and 202, 177 and 207 (separation 0.16).+pub fn two_modes(v: &[f32]) -> Option<(f32, f32)> {+ if v.len() < 1000 { return None; }+ let (lo, hi) = v.iter().fold((f32::MAX, f32::MIN), |a, x| (a.0.min(*x), a.1.max(*x)));+ let n = (((hi - lo) / 0.01) as usize + 1).min(800);+ let mut h = vec![0f32; n]; for x in v { h[(((x - lo) / 0.01) as usize).min(n - 1)] += 1.0; }+ let s: Vec<f32> = (0..n).map(|i| { let (a, b) = (i.saturating_sub(2), (i + 3).min(n)); h[a..b].iter().sum::<f32>() / (b - a) as f32 }).collect();+ let p1 = (0..n).max_by(|a, b| s[*a].partial_cmp(&s[*b]).unwrap())?;+ let apart = (MODES_APART / 0.01) as usize;+ let cands: Vec<usize> = (0..n).filter(|i| i.abs_diff(p1) >= apart && (*i == 0 || s[*i] >= s[*i - 1]) && (*i + 1 == n || s[*i] >= s[*i + 1])).collect();+ let p2 = cands.into_iter().filter(|i| { let (a, b) = (p1.min(*i), p1.max(*i)); let valley = s[a..=b].iter().copied().fold(f32::MAX, f32::min); s[*i] >= 0.05 * s[p1] && valley <= 0.8 * s[*i].min(s[p1]) }).max_by(|a, b| s[*a].partial_cmp(&s[*b]).unwrap())?;+ let val = |i: usize| lo + (i as f32 + 0.5) * 0.01;+ Some((val(p1.min(p2)), val(p1.max(p2))))+}+const MODES_APART: f32 = 0.08;++/// Half the width at which pale and neutral stops being print, frame pixels: 0.6 mm. The strokes of the largest legend on the CM5 top are 0.5 mm wide, its metal parts 3 mm and up.+const PALE_BODY_HALF: f32 = 24.0;++/// How far a sharpened photo's dark band round bright things reaches, frame pixels, 0 when it has none to speak of. Median green of seen pixels against distance from what is hidden. A band counts at a dip of 10 percent+/// below the plateau that comes back to within 4 percent by `SHARP_MAX_PX`: a filter's band is narrow, while a cast shadow, such as those round the Driver's tall parts, runs on for 13 px and more and is not this.+pub fn sharpening_halo(green: &Plane, hidden: &Mask, pale: &Mask) -> usize {+ let dist = hidden.distance_to_set();+ let mut bins: Vec<Vec<f32>> = vec![vec![]; 25];+ for i in 0..green.d.len() { let d = dist.d[i]; if d >= 1.0 && d < 25.0 && !pale.d[i] { bins[d as usize].push(green.d[i]); } }+ let med: Vec<f32> = bins.iter_mut().map(|v| median(v)).collect();+ let mut tail: Vec<f32> = med[18..25].to_vec(); let plateau = median(&mut tail);+ if plateau <= 0.0 { return 0; }+ let low = med[1..12].iter().copied().filter(|v| *v > 0.0).fold(f32::MAX, f32::min);+ if low > 0.90 * plateau { return 0; }+ let reach = (1..18).rev().find(|d| med[*d] > 0.0 && med[*d] < 0.96 * plateau).unwrap_or(0);+ if reach > SHARP_MAX_PX { 0 } else { reach }+}+/// CM5 bottom, everypin: back within 4 percent at 5 px, 3 photo pixels at 24 px/mm.+const SHARP_MAX_PX: usize = 8;++/// Below this spread the light is flat enough that a level needs no following at fine scales. Local levels exist to follow the light, and where the light does not move they+/// follow the copper instead: a wide field of laminate with thin tracks through it splits into its own two classes and reads as copper (CM5 bottom, at a 2.25 mm window).+pub const FLAT_LIGHT: f32 = 0.2;++/// `deg`: the polynomial's degree. A flat light needs a plane at most, and a higher degree swings about where parts leave no plain mask to hold it.+pub fn two_levels_at(lg: &Plane, excl: &Mask, scales: &[usize], deg: usize) -> (Plane, Plane) {+ #[allow(non_snake_case)] let DEG = deg; let (w, h) = (lg.w, lg.h); let sc = 3000.0; let mut sm: Vec<(f64, f64, f64)> = vec![]; for y in (0..h).step_by(6) { for x in (0..w).step_by(6) { if !excl.at(x, y) { sm.push((x as f64 / sc, y as f64 / sc, lg.at(x, y) as f64)); } } } let mut t = vec![];@@ -110,7 +167,7 @@ pub fn two_levels(lg: &Plane, excl: &Mask) -> (Plane, Plane) { let floor = (sep.get(sep.len() / 2).copied().unwrap_or(0.2) * SEPARATION_FLOOR).max(0.08); for i in 0..w * h { if cop.d[i] - lam.d[i] < floor { let m = (cop.d[i] + lam.d[i]) / 2.0; cop.d[i] = m + floor / 2.0; lam.d[i] = m - floor / 2.0; } } let wt = Plane { w, h, d: excl.d.iter().map(|e| if *e { 0.0 } else { 1.0 }).collect() };- for r in [360usize, 180, 90] {+ for &r in scales { // two-means in the window, started from the levels so far let (mut u, mut d) = (cop.clone(), lam.clone()); let bw = blur(&wt, r, 1);@@ -234,7 +291,7 @@ fn line_filter(n: &Plane, skip: &Mask) -> (Plane, Vec<u8>) { /// A class is only as good as the step at its edge. A wide patch read as laminate with no real step to what surrounds it, and a /// small patch read as copper with none, were put there by the threshold wandering through grain, not by anything in the photo.-fn settle(seed: &mut [i8], lg: &Plane, cop: &Plane, lam: &Plane, lines: &Mask, hidden: &Mask, unseen: &Mask, w: usize, h: usize) {+fn settle(seed: &mut [i8], lg: &Plane, cop: &Plane, lam: &Plane, lines: &Mask, hidden: &Mask, unseen: &Mask, flat_light: bool, w: usize, h: usize) { let step_of = |m: &Mask, sign: f32| -> (Vec<u32>, Vec<f32>, Vec<usize>) { let (lab, cs) = crate::raster::components(m); let mut out = vec![f32::MAX; cs.len() + 1]; let mut areas = vec![0usize; cs.len() + 1]; let near = m.dilate(3.0); let far = m.dilate(9.0);@@ -252,8 +309,8 @@ fn settle(seed: &mut [i8], lg: &Plane, cop: &Plane, lam: &Plane, lines: &Mask, h let (lab, _, _) = step_of(&wide, 1.0); // Laminate has copper on some side of it, brighter by the separation of the levels. A dim patch with nothing much brighter around it, however dark the gap on its other // side, is copper in poor light: the 90th percentile of its surroundings is the side that counts, so a dark gap beside it cannot lower its step (Driver bottom, PROG box:- // a 10 percent drop at a silk line and a real gap inside the box). Gap lines along most of its edge still mark a lane between tracks.- { let n = lab.iter().copied().max().unwrap_or(0) as usize + 1;+ // a 10 percent drop at a silk line and a real gap inside the box). Gap lines along most of its edge still mark a lane between tracks. Under flat light there is no poor light to excuse a dim patch.+ if !flat_light { let n = lab.iter().copied().max().unwrap_or(0) as usize + 1; let mut ring: Vec<Vec<f32>> = vec![vec![]; n]; let (mut a, mut ca, mut sp) = (vec![0f32; n], vec![0f32; n], vec![0f32; n]); let far = wide.dilate(10.0); let near = wide.dilate(3.0); let mut spread = lab.clone();@@ -458,9 +515,22 @@ pub fn trace(img: &RgbImage, pr: &Params) -> Traced { // Wide bright things (silk blocks, pads, parts) light the mask beside them, and a window with them in reads its copper too high: a whole pour in a dim box then sits on the mid-point // (Driver bottom, PROG box: value 0.5 throughout). Thin glints are left in: they are everywhere along copper edges. 8 px is where the lift has fallen off on these photos, tile set A. let excl = excl.or(&Mask { w, h, d: (0..w * h).map(|i| gs.d[i] > wide.d[i] * 1.2).collect() }.open(2.5).dilate(8.0));- let (cop, lam) = two_levels(&lg_fit, &excl);- // pale: silk, tin, domes and glints. They are not read, only filled in from what is around them.- let pale = Mask { w, h, d: (0..w * h).map(|i| gs.d[i].max(1.0).ln() - cop.d[i] > 0.30 && sat[i] < 0.38).collect() };+ let spread = light_spread(&lg_fit, &excl);+ let (cop, lam) = if spread < FLAT_LIGHT {+ // Under flat light one fit over the face is all the levels need, and it must see only plain mask: dark green shadow beside parts is not laminate, and it took the lower level with it (CM5 top: separation 0.42 where copper over laminate is 0.21).+ let parts = Mask { w, h, d: (0..w * h).map(|i| { let (s, v) = (sat[i], val[i]); (s < 0.27 && v > 0.30) || (rs.d[i] > gs.d[i] + 10.0 && v > 0.35) || (v < 0.30 && s < 0.42) }).collect() }.open(2.0).dilate(16.0);+ let plain = excl.or(&parts);+ let samples: Vec<f32> = (0..w * h).step_by(3).filter(|i| !plain.d[*i]).map(|i| lg_fit.d[i]).collect();+ // what is darker than the laminate mode by more than half the way to copper is a shadow or a pit, so it stays out of the fit: the mode, not the mean of a long dark tail, is the laminate's level+ match two_modes(&samples) {+ Some((lo, hi)) => { if debug { eprintln!("green modes: laminate {:.0}, copper {:.0}", lo.exp(), hi.exp()); } let limit = lo - 0.5 * (hi - lo); two_levels_at(&lg_fit, &Mask { w, h, d: (0..w * h).map(|i| plain.d[i] || lg_fit.d[i] < limit).collect() }, &[], 1) }+ None => two_levels_at(&lg_fit, &plain, &[], 1),+ }+ } else { two_levels(&lg_fit, &excl) };+ if debug { let at = |p: &Plane| { let mut v: Vec<f32> = p.d.iter().step_by(97).copied().collect(); v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[v.len() / 2].exp() }; eprintln!("light spread {spread:.3}; copper green {:.0}, laminate green {:.0}", at(&cop), at(&lam)); }+ // pale: silk, tin, domes and glints. They are not read, only filled in from what is around them. A copper level near the top of the range leaves no room for pale things to stand+ // 0.30 above it (CM5: copper's green reads 200, so white print is 0.24 up), so the step is at most half the room left.+ let pale = Mask { w, h, d: (0..w * h).map(|i| gs.d[i].max(1.0).ln() - cop.d[i] > (0.5 * (255f32.ln() - cop.d[i]).max(0.0)).min(0.30) && sat[i] < 0.38).collect() }; let pale_wide = pale.open(3.2); let halo = pale_wide.dilate(3.0); let gold = Mask { w, h, d: (0..w * h).map(|i| rs.d[i] > gs.d[i] + 10.0 && val[i] > 0.45).collect() }.open(2.0);@@ -472,14 +542,21 @@ pub fn trace(img: &RgbImage, pr: &Params) -> Traced { }; // silk on a face with parts stays transparent: copper runs under it let silk_top = Mask { w, h, d: (0..w * h).map(|i| sat[i] < 0.2 && val[i] > 0.55).collect() }.close(2.0).dilate(4.0);+ // ...but print is a thin thing: anything pale and neutral wider than 1.2 mm solid is a metal can, a lid or a plastic body, and copper does not run through a part+ let silk_top = silk_top.andnot(&silk_top.erode(PALE_BODY_HALF + 4.0).dilate(PALE_BODY_HALF + 8.0)); let mut hidden = Mask { w, h, d: (0..w * h).map(|i| opaque.d[i] && !(pr.bodies && silk_top.d[i])).collect() }; if let Some((x0, y0, x1, y1, r)) = pr.board {+ let r = r.max(8.0); // off the board and the photo's edge band: not copper we can say anything about for y in 0..h { for x in 0..w { let (fx, fy) = (x as f64, y as f64); let (cx, cy) = (fx.clamp(x0 + r, x1 - r), fy.clamp(y0 + r, y1 - r)); let d = ((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt(); if d > r - 8.0 { hidden.d[y * w + x] = true; } } } }+ // A sharpened photo digs a dark band round anything bright: it is not a gap, so it is not read, and left in it also sets the scale of every line threshold (`sharpening_halo`).+ let sharp_px = sharpening_halo(&gp, &hidden, &pale);+ if debug { eprintln!("sharpening halo {sharp_px} px"); }+ let sharp = if sharp_px > 0 { hidden.dilate(sharp_px as f32) } else { Mask::new(w, h) }; // pale (and hidden) pixels are not looked at by the class or line filters- let unseen = Mask { w, h, d: (0..w * h).map(|i| hidden.d[i] || pale.d[i] || halo.d[i] || (!pr.bodies && gold.d[i])).collect() };+ let unseen = Mask { w, h, d: (0..w * h).map(|i| hidden.d[i] || pale.d[i] || halo.d[i] || sharp.d[i] || (!pr.bodies && gold.d[i])).collect() }; // brightness clipped between the two levels and a little beyond: a glint or a dome must not outweigh an edge. One plane per shot. let clip_of = |im: &RgbImage| -> Plane { let g = Plane { w, h, d: im.pixels().map(|p| p[1] as f32).collect() }; let b = blur(&g, 1, 1); Plane { w, h, d: (0..w * h).map(|i| { let (c, l) = (cop.d[i], lam.d[i]); let d = (c - l).max(0.05); b.d[i].max(1.0).ln().clamp(l - 0.5 * d, c + 0.25 * d) }).collect() } };@@ -490,7 +567,7 @@ pub fn trace(img: &RgbImage, pr: &Params) -> Traced { // gap lines, on the clipped brightness; the filter is linear, so the mean of the two responses is the response to the mean of the shots let filter_on = |c: &Plane| -> Plane { let mut n = Plane::new(w, h, 80.0); for i in 0..w * h { if !unseen.d[i] { n.d[i] = (c.d[i] - cop.d[i]).exp() * 80.0; } } n }; // a gap line's pixels are dimmed by it, so the filter need not run on pixels at or above the copper level- let line_skip = Mask { w, h, d: (0..w * h).map(|i| hidden.d[i] || halo.d[i] || value.d[i] > 1.0).collect() };+ let line_skip = Mask { w, h, d: (0..w * h).map(|i| hidden.d[i] || halo.d[i] || sharp.d[i] || value.d[i] > 1.0).collect() }; let (resp, ang, resp_a, resp_b) = match &clip_b { Some(cb) => { let ((ra, aa), (rb, _)) = (line_filter(&filter_on(&clip_a), &line_skip), line_filter(&filter_on(cb), &line_skip)); // the direction goes with the stronger shot; the response is the mean over the two at that pixel@@ -498,6 +575,13 @@ pub fn trace(img: &RgbImage, pr: &Params) -> Traced { None => { let (r, a) = line_filter(&filter_on(&clip_a), &line_skip); (r, a, None, None) } }; let p99 = { let mut v: Vec<f32> = resp.d.iter().step_by(5).copied().filter(|x| *x > 0.0).collect(); v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v.get(v.len() * 99 / 100).copied().unwrap_or(1.0) };+ if debug {+ let clear = hidden.or(&pale_wide).dilate(16.0);+ let at = |v: &mut Vec<f32>, q: f64| -> f32 { if v.is_empty() { return 0.0; } v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[((v.len() - 1) as f64 * q) as usize] };+ let mut flat: Vec<f32> = (0..w * h).filter(|i| !clear.d[*i] && value.d[*i] > 0.8 && value.d[*i] < 1.2).map(|i| resp.d[i]).collect();+ let mut plain: Vec<f32> = (0..w * h).filter(|i| !clear.d[*i]).map(|i| resp.d[i]).collect();+ eprintln!("resp p99 {p99}; flat copper n {} p50 {:.2} p90 {:.2} p99 {:.2}; clear of parts n {} p50 {:.2} p90 {:.2} p99 {:.2}; sep {:.3}", flat.len(), at(&mut flat, 0.5), at(&mut flat, 0.9), at(&mut flat, 0.99), plain.len(), at(&mut plain, 0.5), at(&mut plain, 0.9), at(&mut plain, 0.99), { let mut d: Vec<f32> = (0..w * h).step_by(97).map(|i| cop.d[i] - lam.d[i]).collect(); at(&mut d, 0.5) });+ } let (hi, lo) = (pr.hi * p99, pr.lo * p99); let mut ridge = vec![false; w * h]; for y in 3..h - 3 { for x in 3..w - 3 { let i = y * w + x; let v = resp.d[i]; if v <= lo { continue; }@@ -534,7 +618,7 @@ pub fn trace(img: &RgbImage, pr: &Params) -> Traced { } // the class of every pixel: a seen pixel by its value, a pale one by its nearest seen neighbours let mut seed = vec![0i8; w * h];- for i in 0..w * h { if hidden.d[i] { seed[i] = -1; } else if !pr.bodies && gold.d[i] { seed[i] = 1; } else if !pale.d[i] && !halo.d[i] { seed[i] = if value.d[i] > CLASS_AT || undershoot.d[i] { 1 } else { -1 }; } }+ for i in 0..w * h { if hidden.d[i] { seed[i] = -1; } else if !pr.bodies && gold.d[i] { seed[i] = 1; } else if !pale.d[i] && !halo.d[i] && !sharp.d[i] { seed[i] = if value.d[i] > CLASS_AT || undershoot.d[i] { 1 } else { -1 }; } } // grain: laminate specks with no gap line through them are not laminate { let dark = Mask { w, h, d: seed.iter().zip(&hidden.d).map(|(s, o)| *s == -1 && !*o).collect() }; let (dl, dc) = crate::raster::components(&dark);@@ -547,7 +631,7 @@ pub fn trace(img: &RgbImage, pr: &Params) -> Traced { let encloses = |c: &crate::raster::Component| -> bool { let (sw, sh) = (c.x1 - c.x0 + 3, c.y1 - c.y0 + 3); let mut m = Mask::new(sw, sh); for y in c.y0..=c.y1 { for x in c.x0..=c.x1 { if dl[y * w + x] == c.label { m.set(x - c.x0 + 1, y - c.y0 + 1, true); } } } m.fill_holes().count() > c.area + 20 }; let small: Vec<bool> = { let mut s = vec![false; dc.len() + 1]; for c in &dc { let l = c.label as usize; s[l] = (c.area < 60 && !has_line[l]) || (c.area < SHADOW_PX && pale_touch[l] && !has_line[l] && !encloses(c)) || (c.area < SHADOW_PX && !leaves[l] && !encloses(c)); } s }; for i in 0..w * h { if dl[i] != 0 && small[dl[i] as usize] { seed[i] = 1; } } }- settle(&mut seed, &lg_fit, &cop, &lam, &lines, &hidden, &unseen, w, h);+ settle(&mut seed, &lg_fit, &cop, &lam, &lines, &hidden, &unseen, spread < FLAT_LIGHT, w, h); // a wide pale piece (a legend, a connector) takes the class around it as a whole, when that is clear; a mixed one goes by nearest { let (pl, pc) = crate::raster::components(&pale_wide); let ring = pale_wide.dilate(6.0).andnot(&halo);@@ -697,6 +781,18 @@ mod tests { assert_eq!(both.labels[100 * 400 + 200], both.labels[140 * 400 + 200], "the two sides of a scratch one shot lacks are one piece"); assert!(both.labels[100 * 400 + 200] != 0); }+ #[test]+ fn two_modes_finds_laminate_and_copper_past_a_dark_tail() {+ // copper at ln 205, laminate at ln 175, and a long tail of shadow below that+ let mut v = vec![];+ for k in 0..6000 { v.push((205.0f32).ln() + 0.012 * ((k % 21) as f32 - 10.0) / 10.0); }+ for k in 0..4000 { v.push((175.0f32).ln() + 0.012 * ((k % 21) as f32 - 10.0) / 10.0); }+ for k in 0..2000 { v.push((100.0f32).ln() + 0.5 * (k as f32 / 2000.0)); }+ let (lo, hi) = two_modes(&v).unwrap();+ assert!((lo.exp() - 175.0).abs() < 4.0 && (hi.exp() - 205.0).abs() < 4.0, "{} {}", lo.exp(), hi.exp());+ assert!(two_modes(&v[..6000]).is_none(), "one mode is not two");+ }+ #[test] fn thinning_leaves_a_line() { let (w, h) = (20, 10); let mut m = vec![false; w * h];