Thirty years of a council’s planning applications sit in scans, and nobody knows where the sites are. This workflow reads them, traces the plot the applicant drew, puts it on the national map, and grades its own answer.
Code on GitHub ·planextracte2e.geonarvis.com
Each folder of scans holds one drawing with a red line round the site. The council needs that shape and it isn't stored anywhere. Doing it by hand takes about ten minutes a case, and there are tens of thousands of cases.
It reads every page, cuts out the plans, gets vision models to trace the red line, and places the result on the Ordnance Survey map using the scale, north arrow and street names printed on the drawing.
One polygon per application in national grid coordinates, with a verdict and the evidence behind it. Cases the workflow can't settle are labelled as such and go to a person.
Five models from three vendors, each answering through a typed schema. Three of them vote on where the plot is, and their disagreement feeds the confidence score.
A confidence model validated on 97 held-out pages puts each trace in a band. Only the top band, 88% right, runs without a person.
The address is matched against the official register, the drawing’s scale gives the site a size in metres, and the shape has to agree with the map before it counts.
Five rules, applied in a set order. An address that can’t be verified rules out a match, whatever the shape score says.
Template matching, FFT correlation and least squares do the measuring. Every score is a number that can be checked.
One command, pinned environments, unit tests, a nightly regression, and an evaluation gate on every push.
None of this is specific to maps. The same shape works for any messy document: find the part that matters, extract it, check it against a system of record, apply fixed rules, and send the rest to a person.
Every rule in the decision order was added after a specific case failed. This is the loop that added them.
Claude Code ran the loop, with access to the repository, the single-case command line and the overlays each stage writes. A person chose which failure to work on and accepted or rejected each patch.
Two separate questions. Every proportion is shown with its 95% interval, because the samples are small.
| band | blind | right | fitted | right |
|---|---|---|---|---|
| high | 24 | 88%21/24 | 21 | 86%18/21 |
| medium | 27 | 67%18/27 | 17 | 35%6/17 |
| low | 36 | 22%8/36 | 20 | 20%4/20 |
| verdict | right | partly | wrong | unsure | no_answer | all |
|---|---|---|---|---|---|---|
| matches | 8 | 1 | 9 | |||
| similar | 1 | 2 | 1 | 4 | ||
| different | 5 | 5 | ||||
| unverified | 1 | 1 | ||||
| no ring | 1 | 1 |
| geocode | right |
|---|---|
| probable | 1/1 |
| unverified | 0/2 |
| verified | 8/14 |
When the address register confirms the address, the answer is usually right. When it doesn’t, the workflow reports the case as unverified.
# the harness, on the run behind this page python3 eval/run.py # both halves, writes eval/report.json python3 eval/run.py --geo OUT/geo.json # placement on another run python3 eval/compare.py eval/before.json eval/after.json # did a change help? fails on a new false accept
pytest: alignment, union fitting, geocode QC parsing, north-arrow rules, records loading. Runs in seconds without a GPU.benchmark/fixtures/, so stage D runs with no GPU, no key and no network. It fails if a plan called a match was the wrong land, if any verdict loses a band against the committed baseline, or if the IoU falls more than 0.05. CI runs it twice and compares the fingerprint.eval/run.py --skip-trace on every push, no GPU or key: the job fails if a polygon called a match was the wrong land. Nightly, eval/compare.py fails on a new false accept.ci/baseline.json. A downgrade fails the job; the baseline moves only by a deliberate commit.units on ubuntu every push; smoke and nightly regression on the GPU runner; release on a tag.requirements/*.txt; the units job needs only base.txt. The smoke case runs from cached stage A/B output, so it spends nothing.Stage A — every raw page





Stage B · C — each plan, in pipeline order










Stage D — on the map



Stage A — every raw page


Stage B · C — each plan, in pipeline order









Stage D — on the map


Stage A — every raw page


Stage B · C — each plan, in pipeline order





Stage D — on the map


Stage A — every raw page


Stage B · C — each plan, in pipeline order










Stage D — on the map



Stage A — every raw page



Stage B · C — each plan, in pipeline order










Stage D — on the map



Stage A — every raw page



Stage B · C — each plan, in pipeline order















Stage D — on the map




Stage A — every raw page



Stage B · C — each plan, in pipeline order





Stage D — on the map


Stage A — every raw page







Stage B · C — each plan, in pipeline order





Stage D — on the map


Stage A — every raw page




Stage B · C — each plan, in pipeline order





Stage D — on the map


Stage A — every raw page








Stage B · C — each plan, in pipeline order










Stage D — on the map



Stage A — every raw page







Stage B · C — each plan, in pipeline order










Stage D — on the map



Stage A — every raw page











Stage B · C — each plan, in pipeline order





Stage D — on the map


By eye, 15 of 20 rings enclose the right plot. Trace bands, measured blind on 97 unseen pages: high 88% right · medium 67% · low 22%. Below high goes to review.
| Case folder | Pages in | Caption pages | Plans filed | Unique | Rings | Confidence bands | API cost |
|---|---|---|---|---|---|---|---|
| 00_00024_FUL | 5 | 1 | 2 | 2 | 2 | high, high | $0.061 |
| 01_00559_FUL | 2 | 1 | 2 | 2 | 1 | none, high | $0.058 |
| 02_02212_FUL | 2 | 1 | 1 | 1 | 1 | medium | $0.047 |
| 06_01326_FUL | 2 | 1 | 2 | 2 | 2 | high, high | $0.047 |
| 07_02248_COU | 3 | 1 | 2 | 2 | 2 | low, high | $0.060 |
| 01_01633_FUL | 3 | 2 | 4 | 3 | 3 | medium, medium, medium | $0.043 |
| 01_01032_FUL | 3 | 1 | 1 | 1 | 1 | high | $0.025 |
| 01_01878_FUL | 7 | 1 | 1 | 1 | 1 | high | $0.015 |
| 01_01301_FUL | 4 | 1 | 1 | 1 | 1 | low | $0.023 |
| 06_00740_FUL | 8 | 2 | 2 | 2 | 2 | low, medium | $0.041 |
| 83_00511_P | 7 | 1 | 2 | 2 | 2 | high, low | $0.028 |
| 03_01073_FUL | 11 | 1 | 1 | 1 | 1 | high | $0.018 |
| all | 57 | 14 | 21 | 20 | 19 | 10 high · 11 auto-usable | $0.466 |
| Case | Plan | Geocode | Hit m² | Hit parcel, shape only | Final IoU | Decided by | Fitted to | Verdict |
|---|---|---|---|---|---|---|---|---|
| 00_00024_FUL | location | verified | 625.7 | 0.81 | 0.80 real size | geocode + real size | hit parcel | matches |
| 00_00024_FUL | site | verified | 625.7 | 0.88 | 0.88 shape only | geocode | hit parcel | matches |
| 01_00559_FUL | location | 0.7 | 461.2 | — | — | — | — | no ring |
| 01_00559_FUL | block | verified | 461.2 | 0.82 | 0.83 real size | geocode + real size | hit parcel | matches |
| 02_02212_FUL | location | verified | 571.8 | 0.26 | 0.84 real size | drawing scale + real size | 2 building part(s) at real size | matches |
| 06_01326_FUL | block | verified | 7206.1 | 0.69 | 0.25 real size | drawing scale + real size | metric fit, 3 areas | different |
| 06_01326_FUL | location | verified | 7206.1 | 0.74 | 0.88 real size | drawing scale + real size | metric fit, 39 areas | matches |
| 07_02248_COU | block | unverified | 505.9 | 0.83 | 0.81 shape only | landmarks | hit parcel | matches |
| 07_02248_COU | location | unverified | 505.9 | 0.62 | 0.66 shape only | landmarks | 4 sites | similar |
| 01_01633_FUL | site/block | verified | 5538.0 | 0.47 | 0.20 real size | drawing scale + real size | metric fit, 2 areas | different |
| 01_01633_FUL | site | verified | 5538.0 | 0.50 | 0.58 real size | geocode | line network, 4 areas | different |
| 01_01633_FUL | block | verified | 5538.0 | 0.47 | 0.60 real size | drawing scale + real size | metric fit, 1 areas | different |
| 01_01032_FUL | site | verified | 194.6 | 0.97 | 0.97 real size | geocode + real size | hit parcel | matches |
| 01_01878_FUL | site | verified | 454.0 | 0.99 | 0.99 shape only | geocode | hit parcel | matches |
| 01_01301_FUL | location | verified | 264.3 | 0.88 | 0.88 real size | geocode + real size | hit parcel | matches |
| 06_00740_FUL | block | verified | 137.0 | 0.43 | 0.80 real size | geocode | line network, 3 areas | similar |
| 06_00740_FUL | block | verified | 137.0 | 0.65 | 0.65 shape only | geocode | hit parcel | different |
| 83_00511_P | location | unverified | 441.7 | 0.36 | 0.68 real size | drawing scale + real size | metric fit, 2 areas | similar |
| 83_00511_P | block | unverified | 441.7 | 0.31 | 0.78 shape only | unverified | 1 building part(s) | unverified |
| 03_01073_FUL | block | probable | 125.3 | 0.70 | 0.72 real size | geocode + real size | hit parcel | similar |
One command per batch; every stage resumes.
Interactive map: webgis.html next to this page (local; needs the OS key).
# the whole workflow, one command (github.com/jiaazhaoo/e2e-plan-extract) python3 -m e2e_plan_extract check python3 -m e2e_plan_extract run --cases 00_00024_FUL 01_00559_FUL --records records.xlsx --out OUT --budget-usd 5 python3 -m e2e_plan_extract run --all --records records.xlsx --out OUT --stages ABCNDER # E adds terra-on-OS python3 -m e2e_plan_extract report --out OUT --verdicts verdicts.json