diff --git a/.gitattributes b/.gitattributes index b18f05c..fe1f55a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,3 +12,9 @@ mods/**/*.flac filter=lfs diff=lfs merge=lfs -text mods/**/*.mp3 filter=lfs diff=lfs merge=lfs -text mods/**/*.bin filter=lfs diff=lfs merge=lfs -text mods/**/*.dds filter=lfs diff=lfs merge=lfs -text + +# The detectors of the imagery detection (`models/`, see models/LICENSES.md). +# Two files and a hundred and forty megabytes: weights, not text, and they +# change only when `tools/vision/export_models.py` is run against another +# model — LFS is what keeps that out of every clone's history. +models/**/*.onnx filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index f1fa3e0..05eb52e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,12 +9,12 @@ # Configuration written on first start (may hold your own access keys) /imagery.ron -# The model registry of the imagery detection, and the weights it points at. -# Neither may travel with the repository: the file names local paths, and a -# detector is tens of megabytes under a licence of its own (README, *Detecting -# from the aerial imagery*). +# The model registry of the imagery detection: written on first start, and it +# names local paths, so it belongs to the machine rather than to the repository. +# The weights it points at *do* travel — `models/` is tracked, through Git LFS, +# and `models/LICENSES.md` records what each one costs (README, *Detecting from +# the aerial imagery*). /ai.ron -/models/ # Operating system Thumbs.db diff --git a/MODS.md b/MODS.md index 41752fb..1f113ad 100644 --- a/MODS.md +++ b/MODS.md @@ -92,9 +92,16 @@ places by carrying the tag its `ai.ron` entry names, and nothing else has to be either side. Tag what should be placed automatically, and leave the tag off the entry that should not — a wreck, a museum piece, the one that is already standing somewhere by hand. +**Trees are tagged the same way and planted rather than placed.** A model class marked +`kind: Tree` names a tag like any other — `laubbaum`, `nadelbaum`, `strauch` on the shipped +`mods/trees` — and every entry carrying it is a species the detection may plant where it +read a crown. What it plants there is a row in the module's own tree list, so a tree mod +needs nothing beyond the tags it already carries for the forest brush. + **Give those entries a `footprint`.** One tag holds more than one size of thing — -`lorry` in `mods/cars` is a 4.82 m Transporter and a 6.30 m Sprinter — and the detection -measures every find it makes. With the sizes stated it will not put the Sprinter in a +`lorry` in `mods/cars` is a 4.82 m Transporter and a 6.30 m Sprinter, `laubbaum` in +`mods/trees` is everything from a nine-metre crown to an eighteen-metre one — and the +detection measures every find it makes. With the sizes stated it will not put the Sprinter in a space that has room for the Transporter; without them it picks between the two by coin, and half the vans in a car park stand a metre and a half out of their bays. The choice among what does fit stays the random-but-stable one, so a row of spaces is still a @@ -1586,6 +1593,12 @@ footprint: Some((length: 6.30, width: 2.35)), Width is over whatever sticks out: a van's mirrors are what touches the car in the next bay. A mast, a board or a hut states neither and is never measured. +**For a plant the footprint is the crown**, broadest span first, because the crown is the +whole of what a photograph shows of a tree: the detection compares the disc it measured +against this number to choose the species, and then grows the one it chose the rest of the +way. `tools/trees/import_mantissa.py` writes it out of the built mesh, so what the file +says is what the tree actually spans. + A **crossed-quad impostor as the coarsest level wants a late hand-over**, not an early one. Two quads at a right angle are the least that works — a single fixed billboard vanishes the moment the camera looks along it — and the pair has a seam: whichever blade diff --git a/PLAN.md b/PLAN.md index 4e5c1d3..7e23d1c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -671,12 +671,22 @@ is what foggy weather draws. CRS reprojection happens here (ch. 4.2). No MaSzyna .scn importer (effort > benefit, different country). - **Reading the imagery (`vision`):** what OSM and the registers do not carry is in the photograph the editor already drapes over the ground — the cars in the station car park, - the lorries at the goods shed. A **local** model reads it: an ONNX runtime compiled into - the editor, a registry of models as data (`ai.ron`), and placement by *tag*, so the next - detector is an entry in a file and a mod rather than a change to the editor. It is always + the lorries at the goods shed, the wood behind them. A **local** model reads it: an ONNX + runtime compiled into the editor, a registry of models as data (`ai.ron`), and placement + by *tag*, so the next detector is an entry in a file and a mod rather than a change to + the editor. The two detectors the feature is built around — vehicles and tree crowns — + **ship with the game** in `models/`, because a feature that does nothing until the user + goes and finds weights for it is a feature most users never see; they are converted from + published pre-trained models by `tools/vision/`, and what each costs in licence terms is + written down beside them. It is always bounded — a corridor along the track or an area drawn in the viewport, and a clearance from the rails that holds in both — because an unbounded run is neither affordable nor reviewable. Whatever it finds is a proposal until it is committed, as one undo step. + A class says whether its finds are *placed* against the track or *planted* on the ground + (ch. 14): a tree becomes an ordinary row in the module's tree list, in a species drawn by + the tag and grown to the crown that was measured, and is edited afterwards — picked, + marked, deleted — exactly like a tree a person planted. Nothing the model does is harder + to undo than the same thing done by hand. ### 15.1 Two editors, not one Building a line and building a vehicle share nothing: one is geodata, the other a model diff --git a/README.md b/README.md index 2e7f282..be02b6f 100644 --- a/README.md +++ b/README.md @@ -328,7 +328,7 @@ or the whole corridor. | `ai-driver` | AI train driver, look-ahead (ch. 11) | | `imagery` | Aerial imagery tiles: providers, Web Mercator maths, cache, fetching (ch. 15) | | `fields` | Farmland from the state agricultural registers (InVeKoS): which state a place is in, the WFS clients, crop code mapping, geometry clean-up, phenology | -| `vision` | Reading the aerial imagery with a local model: the model registry, a pure-Rust ONNX runtime, the walk over the imagery, and the car parks a crowd of cars implies (ch. 15) | +| `vision` | Reading the aerial imagery with a local model: the model registry, a pure-Rust ONNX runtime, the walk over the imagery, what a tree crown is made of, and the car parks a crowd of cars implies (ch. 15) | | `world-render` | Rendering shared by app and route editor: terrain tiles and splatting, vegetation, farmland, track objects, floating-origin anchoring | | `app` | Bevy app: rendering, cameras, input, HUD (ch. 12), sound on kira's mixer — spatial tracks, distance and cab-wall filtering, Doppler, reverb (ch. 13); multiplayer and the dedicated server on lightyear (ch. 20); text in Fira Sans and Fira Mono (`fonts/`, SIL OFL 1.1) | | `editor-ui` | Shared look and feel of the desktop editors: colors, typography (Inter), spacing, form widgets | @@ -974,6 +974,81 @@ it — and the flag is for the case where nobody is sitting in front of it. rectangle along the rows they stand in and paved with an unmarked asphalt area, which is an ordinary road afterwards and can be dragged about like one. +#### Trees + +A crown detector run over the same imagery plants the wood that is actually there. A class +of a model can say it is a *tree* (`kind: Tree` in `ai.ron`), and then a find does not +become an object bolted to the track graph — it becomes an ordinary row in the module's tree +list, at the place the crown was, in a species from the installed tree mods, and **grown to +the size the crown was measured at**. A twelve-metre crown gets a twelve-metre tree: the +species is drawn from those whose own crown is within half again either way (`mods/trees` +ships every species as three individuals — young, grown, old) and the one that is drawn is +then scaled the rest of the way, within a band, because a spruce squeezed to a third of +itself is not a young spruce but a spruce seen through the wrong end of a telescope. + +**The crown detector ships with the game**: `models/deepforest-tree.onnx`, which is +[DeepForest](https://deepforest.readthedocs.io/) — the crown model of the field, a +RetinaNet trained on the NEON airborne survey and MIT licensed. It reads a window at five +centimetres a pixel, which is not the resolution it was trained on but the scale at which a +crown arrives the number of pixels across it expects; the finer the imagery a provider +gives, the better the crowns come out. + +**What kind of tree** comes from the model where the model knows. Every crown detector +published today is single-class — DeepForest included, because *tree* is what aerial +training sets are labelled with — so where a class names a `conifer` tag as well, the crown +itself is asked: needles are dark, blue-green and hard-shadowed on one side, because a +conifer is a cone; broadleaf foliage is a flatter, yellower dome, and in autumn frankly +orange. Both tests have to agree before a crown is called a fir, so the guess leans to the +commoner tree. It is a guess and the editor says so — a model with species classes of its +own leaves `conifer` empty and is never second-guessed. + +**Which trees, in the end, is a question the photograph cannot always answer, and the +dialog says so.** *Species* offers "as detected" — the model's class, and the crown's own +look where the model knows only one — or any **stand** the installed mods describe +(`stand-nadelwald`, `stand-laubwald`, `stand-mischwald`, …, the same stands the forest brush +plants from). Naming one overrules the guess for the whole run, and the size goes with it: +the stand is then planted the way the forest brush plants it — any of its members, at its +own size give or take a third — because a crown detector reading a provider's imagery +reports the sunlit top of a young conifer rather than its spread, and a spruce wood planted +at that measurement would be a wood of saplings. Where the species come from the model, the +crown decides both the member and the size, which is the point of measuring it. + +Everything that follows is what follows for any other tree. An AI-planted wood is drawn by +the same vegetation instancer, is **picked by the select tool** like a tree planted by hand, +joins a multi-selection through Ctrl-click or the select circle, and goes with one Delete — +single trees or a whole marked stand. A run is one undo step, and nothing about a wood the +model found is harder to take back than a wood a person drew. A second run over the same +ground changes nothing rather than doubling it: **Keep trees apart** is the distance at +which a crown counts as already planted. + +```ron +( + id: "deepforest", + name: "DeepForest (Baumkronen)", + file: "models/deepforest-tree.onnx", + input: (width: 768, height: 768, mean: (0.485, 0.456, 0.406), std: (0.229, 0.224, 0.225)), + head: Retina(confidence: 0.3, iou: 0.1), + classes: [ + ( + name: "Tree", + kind: Tree, + place: "laubbaum", // the tag a broadleaf crown is planted from + conifer: "nadelbaum", // …and the one a dark, shadowed cone gets + span: Some((2.5, 26.0)),// crowns this class covers [m] + ), + ], + ground_sample: 0.05, +) +``` + +`Retina` is the second output layout the editor reads, and it is there because every crown +model worth having is one: a torchvision RetinaNet emits logits per anchor and offsets per +anchor, with **no boxes at all** — an offset is measured from an anchor, and the anchors are +not in the file. So the shipped model contains the backbone and the head and nothing else, +and the editor rebuilds the anchor grid from the input size. `tools/vision/README.md` says +why that is the better half of the bargain, and `Boxes` remains what an Ultralytics-exported +crown detector of your own would use. + #### Models The models are **data, not code**: `ai.ron`, written next to `imagery.ron` the first time @@ -1004,7 +1079,9 @@ windows turned into dark glass where that is the better picture, and four levels each. A detector for level crossings, containers or solar farms is therefore an entry in this file and a mod with objects tagged for it, and no Rust at all. `size` is the real footprint of the class: a "car" eleven metres long is two cars the model ran together, and -it is dropped rather than placed. +it is dropped rather than placed. Where a factor of two around one size is the wrong rule — +a crown is anything from a three-metre thorn to a twenty-five-metre oak and both are right — +the class states the range outright as `span` instead. **Which object, of the ones carrying the tag, is decided by how long the find is.** One tag holds more than one size of thing — `lorry` here is a 4.82 m Transporter and a 6.30 m @@ -1015,11 +1092,29 @@ candidate. Among what does fit the choice stays what it always was, decided by t itself, so the same imagery always draws the same car and a row of bays is still a row of different ones rather than five copies of the largest estate. -**The weights ship with nobody.** Every detector worth using for aerial work is either too -big to travel with a route editor or licensed so it may not. `ai.ron` says where each one's -`.onnx` is expected and the dialog says plainly when it is not there yet. The model this was -built for is Ultralytics' YOLOv8-OBB trained on DOTA v1 (AGPL-3.0) — an *oriented* head, -which matters: it says which way each car points, and a car park is nothing but that. +**Two detectors ship with the game**, in `models/`, so a fresh clone can read its own +imagery with nothing fetched and nothing signed up to: + +| file | finds | from | licence | +| --- | --- | --- | --- | +| `models/yolov8n-obb.onnx` | cars, lorries | Ultralytics YOLOv8n-OBB on DOTA v1 | **AGPL-3.0** | +| `models/deepforest-tree.onnx` | tree crowns | DeepForest (Weecology), NEON survey | MIT | + +They are Git LFS objects — `git lfs pull` on an old clone — and they are converted from the +published pre-trained models by `tools/vision/export_models.py`, which is the whole of what +was done to them. + +**The two licences are not the same, and the car one is the loud one.** Shipping +`yolov8n-obb.onnx` means redistributing an AGPL-3.0 work; the EUPL this repository is under +names AGPL-3.0 among its compatible licences, so the combination is provided for, but the +combined work then travels under the AGPL. DOTA itself is released for academic research. +If that does not suit how you intend to distribute this, **delete the file** — the editor +then reports the weights as not installed and everything else, the tree detector included, +carries on. [`models/LICENSES.md`](models/LICENSES.md) has the full record and +[`tools/vision/README.md`](tools/vision/README.md) how to put another detector in its place. + +Bringing your own is the same two steps it always was — an entry in `ai.ron`, and the file +where the entry says: ```bash pip install ultralytics diff --git a/STATUS.md b/STATUS.md index a6d9dcf..48fb05e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1318,6 +1318,114 @@ As of 2026-08-31 · `cargo test --workspace`: **1136 tests green** · clippy and ids were not renamed. The final 20,000-tree/8 km runtime check holds 140 fps (7.1 ms) in the debug build. `tools/trees/bench_forest.mjs` remains the runtime forest test. +- **The detectors ship with the game (2026-09-02, `models/`, `tools/vision/`, + `vision::onnx`):** the imagery detection used to be a feature that did nothing until + the user went and found weights for it. Two models are now in the repository, under + Git LFS: `yolov8n-obb.onnx` for the vehicles (12.7 MB) and `deepforest-tree.onnx` + for the crowns (129 MB). `tools/vision/export_models.py` is the whole of what was + done to them — download the published pre-trained model, convert to ONNX — so + either can be rebuilt or replaced from one command. + **The two licences are not the same and the difference is not cosmetic.** The tree + detector is DeepForest (Weecology), **MIT**, and travels without conditions. The car + detector is Ultralytics YOLOv8n-OBB on DOTA, **AGPL-3.0**: shipping it means + redistributing an AGPL work, and while the EUPL names AGPL-3.0 among its compatible + licences so the combination is provided for, the combined work then travels under the + AGPL. Deleting that one file is supported and breaks nothing — the editor reports it + as not installed, and the trees, the imports and the rest carry on. + `models/LICENSES.md` says all of this in the place somebody will look. + **A second output layout had to be understood to get there** (`Head::Retina`). Every + crown detector worth having is a torchvision RetinaNet rather than an Ultralytics + export, and what such a model emits is two tensors — logits per anchor and offsets + per anchor — with *no boxes in them at all*, because an offset is measured from an + anchor and the anchors are not in the file. Exporting the Python postprocessing + instead would have meant a graph of dynamic shapes, non-maximum suppression and + control flow through a small pure-Rust runtime; the editor already scales the window, + subtracts the ImageNet mean and settles overlaps, so only the backbone and the head + are exported and the **anchor grid is rebuilt in Rust**. It is the one place in the + crate that reproduces somebody else's arithmetic rather than reading their output, + and it is tested against torchvision's own numbers — including the anchor whose half + width is 50.5 and which rounds to 50, because Python rounds halves to even and Rust + does not. A pixel there is a metre and a half of ground, silently. + The model is exported at **768 × 768 and read at 0.05 m per pixel**. Not 800, which is + what DeepForest's own transform resizes to: its coarsest feature map is 7 cells where + dividing 800 by the stride of 128 says 6, and at 768 every level divides exactly so + the grid follows from the input size alone. Not 0.1 either, which is the resolution of + the survey it was trained on — DeepForest doubles the image before the network sees + it, so five centimetres is the scale at which a crown arrives the number of pixels + across the model expects. + Checked against the source: on DeepForest's own test image the Python package finds 55 + crowns and this pipeline finds 50 at a confidence of 0.3, with the boxes they share + agreeing to about a pixel — a tenth of a metre on that survey. + +- **Trees from the imagery (2026-09-02, `vision::canopy`, `route_editor::ai`, + `mods/trees`, `tools/trees/`):** the same walk over the same photograph now plants + woods as well as parking cars. A class of a model says which of the two it is + (`Placement`, one word in `ai.ron`), and a **tree class does not become an object + bolted to the track graph** — it becomes an ordinary row in the module's tree list. + That is the whole reason for the distinction: a tree in that list is drawn by the + vegetation instancer, is picked by the select tool, joins a multi-selection through + Ctrl-click or the select circle, and goes with one Delete, exactly like a tree planted + by hand or baked by the forest brush. Nothing about a wood the model found is harder + to take back than a wood a person drew. + **The size is the point, not a check on the detection.** A car is a car within a + factor of two of every other car, so `size` and its rule of two are enough; a crown is + anything from a three-metre thorn at the fence to a twenty-five-metre oak in the + forecourt and both are right, so a class may state its `span` outright and have the + factor of two stand aside. What is measured is then spent rather than discarded: the + species is drawn from those whose own crown is within half again either way — every + species in `mods/trees` ships as three individuals, the same tree young, grown and old + — and the one drawn is scaled the rest of the way, clamped to 0.6…1.5. The clamp is + the point and not a safety net: a thirty-metre spruce squeezed to a third is not a + young spruce, its trunk stays as thick as a mature one and its needles come out the + size of branches, and past the band the species was the wrong choice and the size is + the lesser of the two errors. So the 84 tree objects gained a `footprint`, measured off + the built `crown_LOD0` mesh by the importer rather than taken from the catalogue's + ratio, because what the file should say is what the tree actually spans. + **What kind of tree comes from the model where the model knows.** Every crown detector + published today is single-class — aerial sets are labelled *tree* and nothing more — + so where a class also names a `conifer` tag, `vision::canopy` asks the crown itself. + Two features, both **ratios**, because the imagery of one province is a stop brighter + than the next and a rule in absolute brightness would hold on one provider and be + nonsense on the other: the relative brightness spread inside the crown (a conifer is a + cone, lit hard on one side and shadowed on the other; a broadleaf is a scattering dome) + and the red channel against the blue (needles are blue-green, foliage is yellow-green + and in autumn frankly orange). Both have to agree before a crown is called a fir, so + the guess leans to the commoner tree beside a German line — and an autumn oak, which is + as shadowed as any spruce, is saved by its colour. It is a guess and the crate says so: + a model with species classes of its own leaves `conifer` empty and is never + second-guessed. + **What the crown cannot say, the builder does.** The first version of the species split + was two thresholds picked by reasoning about how a conifer is lit, and measuring them on + the imagery a provider actually gives said they were wrong: a spruce plantation in the + Sauerland came out 93 % broadleaf. The measurement is in `vision::canopy` — over the + detected crowns of that wood, broadleaves read a contrast of 0.10 (p90 0.19) against + 0.22 for the spruce big enough to resolve and 0.13 for the spruce that is not, with the + warmth barely separating anything in summer. So the pivot moved from 0.25 to 0.20, the + warmth became what it is actually good for — vetoing the orange of an autumn crown, which + is shadowed exactly like a fir — and the honest conclusion was written into the module + head: at nineteen centimetres a pixel this is a hint, not a finding, and it needs crowns + of three metres and more to mean much. + Beside it the dialog gained **Species**: "as detected", or any stand the installed mods + describe. Naming one overrules the guess for the whole run — and takes the size with it, + which is the part that is not obvious. A crown detector on a provider's imagery reports + the sunlit top of a young conifer, about two metres; planting a spruce wood at that + measurement gives a wood of saplings, and choosing the member that suits two metres gives + a wood of junipers, because `mods/trees` has no young conifers. So a named stand is + planted the way the forest brush plants one — any member, at its own size give or take a + third — while a run that takes the species from the model still lets the crown decide + both. `--detect-run --stand nadelwald` is the same thing for a script. + Duplicate finds are settled at a **third** of the crown rather than the cars' three + fifths — two twelve-metre limes whose centres are eight metres apart are two limes, and + the car rule would have deleted every second tree of a closed wood — and a run is cut + to the module envelope like the forest brush's own bake. The occupancy test that keeps + a second run from doubling the first became a **grid** (`ai::Occupied`): a car park + never noticed fifty finds against a hundred objects, a wood is tens of thousands of + rows against thousands of finds, and the list it replaced would have been a hundred + million distance tests on the main thread with the editor frozen mid-click. The dialog + shows only the rows the chosen model has a use for — a crown detector has no car parks + to pave — and the progress bar, the Stop, the report before Commit and the one undo + step are the ones that were already there. + - **Toolbox boxes and the right-hand panel (2026-08-24, route editor):** the editor's frame now matches the World Editor's. The toolbox became three card-framed boxes of paired icon columns — the categories, the active category's tools, and the active diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index f0a6ddc..1c08672 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -38,6 +38,27 @@ SOFTWARE. The `editor-ui` and `screenshot` skills are project-specific and covered by the EUPL like the rest of the repository. +## Detectors in `models/` + +The two weight files the imagery detection runs are pre-trained detectors from +other projects, converted to ONNX by `tools/vision/export_models.py` and +otherwise unmodified. They are under **different licences**, and +[`models/LICENSES.md`](models/LICENSES.md) records both in full. + +`models/deepforest-tree.onnx` is **DeepForest** (Weecology, University of +Florida) under the **MIT** licence, from +, trained on the NSF NEON +airborne survey. Cite Weinstein et al., *Remote Sensing* 11(11):1309 (2019). + +`models/yolov8n-obb.onnx` is **Ultralytics YOLOv8n-OBB** trained on DOTA v1.0, +under the **GNU AGPL v3.0**. Redistributing the game with this file in it means +redistributing an AGPL-3.0 work: the EUPL v1.2 names AGPL-3.0 among its +compatible licences, so the combination is provided for, but the combined work +then travels under the AGPL. DOTA itself is released for academic research. +Deleting the file is supported and breaks nothing — the editor then reports the +weights as not installed and everything else, the tree detector included, +carries on. + ## Star catalogue in `crates/world-render/src/stars.bin` The night sky is the real one. `stars.bin` holds the naked-eye stars — right diff --git a/crates/i18n/locales/de/main.ftl b/crates/i18n/locales/de/main.ftl index a7d3b40..1ec1544 100644 --- a/crates/i18n/locales/de/main.ftl +++ b/crates/i18n/locales/de/main.ftl @@ -2704,6 +2704,13 @@ ai-keep-clear = Abstand vom Gleis ai-keep-clear-hint = Näher als das kommt nichts an eine Schiene, gleich welcher Bereich gewählt ist. ai-place-objects = Gefundenes setzen ai-place-objects-hint = Ein Objekt aus den installierten Mods mit dem Tag des Modells — ein Auto für ein Auto, ein Lkw für einen Lkw. +ai-place-trees = Bäume pflanzen +ai-place-trees-hint = Was das Modell als Baumkrone liest, wird zu einem Baum aus den installierten Baum-Mods — in der Art, die das Modell genannt hat, und auf die gemessene Krone gewachsen. Danach einzeln auswählbar und löschbar wie jeder andere Baum. +ai-species = Baumarten +ai-species-hint = Woraus die Kronen werden. "Wie erkannt" nimmt die Klasse des Modells und, wo das Modell nur eine kennt, das Aussehen der Krone selbst — was bei der Auflösung eines Bilddienstes ein Hinweis ist und kein Befund. Ein Bestand überstimmt das für den ganzen Lauf. +ai-species-detected = Wie erkannt +ai-tree-spacing = Abstand der Bäume +ai-tree-spacing-hint = Wie nah ein neuer Baum an einen schon stehenden heranrücken darf. Damit ändert ein zweiter Lauf über denselben Wald nichts, statt ihn zu verdoppeln. ai-pave-lots = Parkplätze pflastern ai-pave-lots-hint = Autos in Reihen sind ein Parkplatz; er wird als markierungsfreie Asphaltfläche gepflastert, die sich danach wie eine Straße bearbeiten lässt. ai-min-lot-cars = Autos je Parkplatz @@ -2712,11 +2719,12 @@ ai-start = Starten ai-not-ready = Ein installiertes Modell und einen Bereich wählen ai-loading = Modell wird geladen ai-running = Ausschnitt { $window } von { $windows } — { $found } gefunden, { $tiles } Kacheln -ai-found = { $found } gefunden, auf { $lots } Parkplätzen +ai-found = { $found } gefunden +ai-found-lots = auf { $lots } Parkplätzen ai-cost = { $windows } Ausschnitte, { $tiles } Kacheln ai-no-objects = kein Objekt mit Tag { $tag } installiert ai-objects-installed = { $count } zur Auswahl -status-ai-placed = { $objects } Objekte gesetzt, { $lots } Parkplätze gepflastert +status-ai-placed = { $objects } Objekte gesetzt, { $trees } Bäume gepflanzt, { $lots } Parkplätze gepflastert status-ai-area-draw = Ecken des Bereichs klicken, Enter oder Rechtsklick schließt ihn status-ai-area-set = Bereich mit { $corners } Ecken gesetzt — „Aus Luftbild erkennen“ erneut öffnen status-ai-area-points = Ein Bereich braucht drei Ecken diff --git a/crates/i18n/locales/en/main.ftl b/crates/i18n/locales/en/main.ftl index 63bd415..4a01e65 100644 --- a/crates/i18n/locales/en/main.ftl +++ b/crates/i18n/locales/en/main.ftl @@ -2701,6 +2701,13 @@ ai-keep-clear = Keep clear of track ai-keep-clear-hint = Nothing is placed closer than this to a rail, whichever area was chosen. ai-place-objects = Place what is found ai-place-objects-hint = An object from the installed mods carrying the model's own tag — a car for a car, a lorry for a lorry. +ai-place-trees = Plant the trees +ai-place-trees-hint = What the model reads as a tree crown becomes a tree of the installed tree mods, in the species the model named and grown to the crown it measured. Selectable and deletable one by one afterwards, like any other tree. +ai-species = Species +ai-species-hint = Which trees the crowns become. "As detected" takes the model's own class and, where the model knows only one, the look of the crown itself — which at the resolution an imagery provider gives is a hint rather than a finding. Naming a stand overrules it for the whole run. +ai-species-detected = As detected +ai-tree-spacing = Keep trees apart +ai-tree-spacing-hint = How close a new tree may come to one that already stands there. It is what makes a second run over the same wood change nothing instead of doubling it. ai-pave-lots = Pave the car parks ai-pave-lots-hint = Cars standing in rows are a car park; it is paved as an unmarked asphalt area you can edit like a road afterwards. ai-min-lot-cars = Cars per car park @@ -2709,11 +2716,12 @@ ai-start = Run ai-not-ready = Pick an installed model, and an area to run it on ai-loading = Loading the model ai-running = Window { $window } of { $windows } — { $found } found, { $tiles } tiles -ai-found = { $found } found, in { $lots } car parks +ai-found = { $found } found +ai-found-lots = in { $lots } car parks ai-cost = { $windows } windows, { $tiles } tiles ai-no-objects = no object tagged { $tag } installed ai-objects-installed = { $count } to choose from -status-ai-placed = { $objects } objects placed, { $lots } car parks paved +status-ai-placed = { $objects } objects placed, { $trees } trees planted, { $lots } car parks paved status-ai-area-draw = Click the corners of the area, Enter or right-click closes it status-ai-area-set = Area of { $corners } corners set — open Detect from imagery again status-ai-area-points = An area needs three corners diff --git a/crates/route-editor/src/ai.rs b/crates/route-editor/src/ai.rs index 70d0c22..7cb7773 100644 --- a/crates/route-editor/src/ai.rs +++ b/crates/route-editor/src/ai.rs @@ -27,12 +27,22 @@ //! //! Car parks are not detected — they are inferred from the cars, and paved //! with the road the module already has (see [`vision::parking`]). +//! +//! Trees are the same walk with a different answer at the end of it. A class +//! marked [`Placement::Tree`] does not become an object against the track: it +//! becomes a row in the line's own tree list, at the place the crown was, in a +//! species carrying the tag the model gave it, grown to the size the crown was +//! measured at. That matters beyond tidiness — a tree in that list is drawn by +//! the vegetation instancer, is picked by the select tool, joins a +//! multi-selection and goes with one Delete, exactly like a tree planted by +//! hand or baked by the forest brush. Nothing about a wood the model found is +//! any harder to take back than a wood a person drew. use crate::tools::{EditorState, Selection}; use crate::{AiPath, Line, TrackObjects}; use bevy::prelude::*; use bevy_egui::{EguiContexts, egui}; -use content::route::{CenterLine, ObjectSource, RoadPoint, RoadSource, RoadSurface}; +use content::route::{CenterLine, ObjectSource, RoadPoint, RoadSource, RoadSurface, TreeSource}; use editor_ui::{colors, space}; use i18n::t; use std::path::PathBuf; @@ -41,7 +51,7 @@ use std::sync::mpsc::{Receiver, TryRecvError}; use std::sync::{Arc, Mutex}; use track_model::Footprint; use track_model::TrackNetwork; -use vision::{GeoDetection, Lot, Progress, Region, Shape, VisionConfig}; +use vision::{GeoDetection, Lot, Placement, Progress, Region, Shape, VisionConfig}; use world_coords::{EcefPos, EnuFrame, geo}; /// Width of the dialog [px] — the road import's, so the two look alike. @@ -68,6 +78,22 @@ const LOT_TAGS: [&str; 2] = [LOT_TAG, "ai"]; /// row of lorries at a goods shed is not one, and neither is anything else a /// model may learn to find later. const CAR: &str = "car"; +/// How far from the crown that was measured a species may be and still be +/// planted on it, as a factor either way. +/// +/// Wider than the cars' rule, and deliberately: a car park is a grid of +/// stated sizes, a wood is not. Half again in each direction spans the three +/// individuals every species in `mods/trees` ships — the same species young, +/// grown and old — so a crown of any size finds something to be. +const SPREAD: f64 = 1.5; +/// How far a planted tree may be grown or shrunk from its own size to stand as +/// wide as the crown says. +/// +/// The clamp is the point rather than a safety net. A thirty-metre spruce +/// squeezed to a third is not a young spruce: the trunk stays as thick as a +/// mature one and the needles come out the size of branches. Past the band the +/// species was the wrong choice and the size is the lesser error of the two. +const GROWTH: (f64, f64) = (0.6, 1.5); /// Which ground the run covers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -92,6 +118,23 @@ pub struct AiOptions { pub keep_clear: f64, /// Place what the model finds. pub place_objects: bool, + /// Plant the trees it finds — the tree classes of the model, into the + /// line's own tree list. + pub place_trees: bool, + /// Which species the crowns become: `None` is what the model said, and + /// `Some("nadelwald")` is the stand of that name whatever it said. + /// + /// The escape hatch for the one thing the imagery cannot settle. A crown + /// detector says *tree*; whether it is a fir or a lime is read off the + /// crown's own look ([`vision::canopy`]) and at the resolution a provider + /// gives that is a hint, not a finding. A builder looking at the same + /// photograph can see it at a glance, and this is where they say so — + /// once, for the run, instead of correcting four hundred trees. + pub species: Option, + /// How close a planted tree may come to a tree that already stands there + /// [m]. What makes a second run over the same wood a no-op rather than a + /// doubling of it. + pub tree_spacing: f64, /// Pave the car parks the cars stand in. pub pave_lots: bool, /// How many cars make a car park. @@ -109,6 +152,12 @@ impl Default for AiOptions { // Clear of the six-foot, the cable route and the cess. keep_clear: 8.0, place_objects: true, + place_trees: true, + species: None, + // A crown is metres across; two trunks three metres apart are two + // trees anywhere but in a hedge, and a hedge is not what a crown + // detector finds. + tree_spacing: 3.0, pave_lots: true, min_lot_cars: 6, } @@ -317,7 +366,7 @@ pub fn headless( true })?; - let (placed, paved) = commit(line, &report, &options, objects, true); + let placed = commit(line, &report, &options, objects, true); let path = line .path .clone() @@ -327,19 +376,21 @@ pub fn headless( line.dirty = false; let what: Vec = placed + .by_tag .iter() .map(|(tag, count)| format!("{count} × {tag}")) .collect(); Ok(format!( - "{} found, {} placed ({}), {} car parks paved — written to {path}", + "{} found, {} placed and {} planted ({}), {} car parks paved — written to {path}", report.outcome.found.len(), - placed.values().sum::(), + placed.objects, + placed.trees, if what.is_empty() { "nothing installed to place them with".to_string() } else { what.join(", ") }, - paved, + placed.lots, )) } @@ -594,15 +645,17 @@ const FILLS: f64 = 0.6; /// An object without a footprint is not measured and stays eligible /// throughout. Most scenery has none, and a mod that never states its sizes /// should behave as it did before there was anywhere to state them. -fn fitting<'a>( - candidates: &'a [(String, Option)], - detection: &GeoDetection, +/// +/// `band` is how big the thing that goes there may be, and it is the one part +/// of this that differs between a car park and a wood — see [`band_for`]. +fn fitting( + candidates: &[(String, Option)], + band: (f64, f64), seed: u64, -) -> Option<&'a str> { +) -> Option<&str> { let length = |c: &(String, Option)| c.1.map_or(f64::NAN, |f| f.length); let unmeasured = |c: &&(String, Option)| c.1.is_none(); - let room = detection.length + SLACK; - let floor = detection.length * FILLS; + let (floor, room) = band; let suits: Vec<&(String, Option)> = candidates .iter() @@ -630,6 +683,122 @@ fn fitting<'a>( .map(|(name, _)| name.as_str()) } +/// How big a thing may be to go on this find. +/// +/// The two kinds are measured against different things and the difference is +/// real. A bay is a *space*, and what goes in it must not stick out of it: +/// the room is what was measured plus the slack the measurement is worth, and +/// nothing may be so much smaller that it looks lost. A crown is not a space +/// but the thing itself — the tree that goes there should be about that wide, +/// too big and too small are the same error, and the range is symmetrical +/// because a wood is. +fn band_for(detection: &GeoDetection) -> (f64, f64) { + match detection.kind { + Placement::Object => (detection.length * FILLS, detection.length + SLACK), + Placement::Tree => (detection.length / SPREAD, detection.length * SPREAD), + } +} + +/// How much bigger or smaller than its own size a tree is planted, so that it +/// stands as wide as the crown in the photograph — see [`GROWTH`]. +/// +/// A species that states no crown is planted at its own size: `mods/trees` +/// states one for every tree it has, and a mod that states none is saying it +/// does not know, not that its trees are a metre across. +fn grown(crown: f64, model: Option) -> f64 { + match model { + Some(model) if model.length > 0.1 => (crown / model.length).clamp(GROWTH.0, GROWTH.1), + _ => 1.0, + } +} + +/// One find as a row in the line's own tree list. +/// +/// No track reference and no heading. A tree stands at a place on the ground, +/// and which way round it stands is a matter of taste — but not of chance: the +/// turn is drawn from the place, so the same imagery always plants the same +/// wood, and re-running a corridor after widening it does not spin every tree +/// a builder has already looked at. +fn tree_for(detection: &GeoDetection, object: &str, scale: f64, seed: u64) -> TreeSource { + TreeSource { + object: object.to_string(), + lat: detection.lat, + lon: detection.lon, + // A different part of the hash than the species pick uses, so that the + // two do not move together — a wood in which every lime faces north + // and every fir south is a wood nobody believes. + yaw_deg: (seed >> 32) as f64 % 360.0, + scale, + } +} + +/// The size a tree is planted at where nothing measured it: seven tenths to +/// one and three tenths of the model's own, which is the spread the forest +/// brush bakes a wood with. +fn natural(seed: u64) -> f64 { + 0.7 + ((seed >> 16) & 0xffff) as f64 / 65_535.0 * 0.6 +} + +/// Where something already stands, in cells big enough that only the +/// neighbouring ones have to be looked at. +/// +/// The list this replaced was walked in full for every find, which a car park +/// never noticed: fifty cars against a hundred objects is nothing. A wood is +/// tens of thousands of rows and a run over a forested corridor finds +/// thousands more, and the same code would be a hundred million distance tests +/// on the main thread with the editor frozen in front of the user in the +/// middle of a click. +/// +/// ECEF throughout, and every point put in at the same height: the cells are +/// then a metric box grid, the distance is the ordinary one, and a tree read +/// off a photograph can be compared with a tree already in the file without +/// either of them having to know how far below the rails the ellipsoid is. +struct Occupied { + /// The distance the grid answers about, and the size of a cell — the two + /// are the same number on purpose, because that is what makes the + /// neighbouring twenty-seven cells the whole of the search. + within: f64, + at: std::collections::HashMap<(i64, i64, i64), Vec>, +} + +impl Occupied { + fn new(within: f64) -> Self { + Self { + within: within.max(0.5), + at: Default::default(), + } + } + + fn key(&self, p: EcefPos) -> (i64, i64, i64) { + ( + (p.0.x / self.within).floor() as i64, + (p.0.y / self.within).floor() as i64, + (p.0.z / self.within).floor() as i64, + ) + } + + fn add(&mut self, p: EcefPos) { + self.at.entry(self.key(p)).or_default().push(p); + } + + fn taken(&self, p: EcefPos) -> bool { + let (x, y, z) = self.key(p); + for dx in -1..=1 { + for dy in -1..=1 { + for dz in -1..=1 { + let Some(cell) = self.at.get(&(x + dx, y + dy, z + dz)) else { + continue; + }; + if cell.iter().any(|q| q.distance(p) < self.within) { + return true; + } + } + } + } + false + } +} + /// The compass bearing of the track at a pose \[deg\], from north through /// east — the same convention `crates/vision` gives a find its heading in, so /// the two can be subtracted. @@ -748,36 +917,69 @@ fn road_for(lot: &Lot) -> RoadSource { } } -/// Writes the report into the line. Returns what was placed, per tag, and how -/// many car parks were paved. +/// What a commit did — for the status line, for the log of a headless run, +/// and for the report the user reads before deciding. +#[derive(Default)] +pub struct Placed { + /// How many went in per tag. Objects and trees together: the tag is what + /// the model named and what the user recognises them by, and whether a + /// `laubbaum` became a row in one list or the other is this module's + /// business rather than the reader's. + pub by_tag: std::collections::BTreeMap, + pub objects: usize, + pub trees: usize, + pub lots: usize, +} + +/// Every installed object carrying a tag, worked out once per tag and kept. +/// +/// `laubbaum` on the shipped mods is sixty entries and a run finds thousands +/// of crowns; scanning the catalogue for each of them is the same answer +/// several thousand times. +#[derive(Default)] +struct Pool(std::collections::BTreeMap)>>); + +impl Pool { + fn of(&mut self, objects: &TrackObjects, tag: &str) -> &[(String, Option)] { + self.0 + .entry(tag.to_string()) + .or_insert_with(|| tagged(objects, tag)) + } +} + +/// Writes the report into the line. fn commit( line: &mut Line, report: &Report, options: &AiOptions, objects: &TrackObjects, snap: bool, -) -> (std::collections::BTreeMap, usize) { - let mut placed: std::collections::BTreeMap = Default::default(); +) -> Placed { + let mut placed = Placed::default(); + let mut pool = Pool::default(); + if options.place_objects { // Where something already stands, nothing is added: a second run over // ground that has been done is then a no-op rather than a doubling. - let taken: Vec = line - .source - .objects - .iter() - .filter_map(|o| crate::tools::object_pos(&line.net, o)) - .collect(); - let mut candidates: std::collections::BTreeMap)>> = - Default::default(); + // What this run places counts as standing too, from the moment it is + // decided on. + let mut taken = Occupied::new(OCCUPIED); + for object in &line.source.objects { + if let Some(p) = crate::tools::object_pos(&line.net, object) { + taken.add(p); + } + } let mut fresh = Vec::new(); - for detection in &report.outcome.found { - let names = candidates - .entry(detection.place.clone()) - .or_insert_with(|| tagged(objects, &detection.place)); + for detection in report.outcome.found.iter().filter(|d| is_object(d)) { + let names = pool.of(objects, &detection.place); if names.is_empty() { continue; } - let Some(name) = fitting(names, detection, seed(detection.lat, detection.lon)) else { + let Some(name) = fitting( + names, + band_for(detection), + seed(detection.lat, detection.lon), + ) else { continue; }; let Some(object) = object_for(line, detection, name.to_string(), snap) else { @@ -786,22 +988,106 @@ fn commit( let Some(at) = crate::tools::object_pos(&line.net, &object) else { continue; }; - if taken.iter().any(|p| p.distance(at) < OCCUPIED) { + if taken.taken(at) { continue; } - *placed.entry(detection.place.clone()).or_insert(0) += 1; + taken.add(at); + *placed.by_tag.entry(detection.place.clone()).or_insert(0) += 1; + placed.objects += 1; fresh.push(object); } line.source.objects.extend(fresh); } - let mut paved = 0; + + if options.place_trees { + // The same rule as the objects', measured against the trees that are + // already there — including the ones this loop plants, so a stand of + // crowns the model reported twice does not become two woods in one. + let spacing = options.tree_spacing.max(0.0); + let mut standing = Occupied::new(spacing); + if spacing > 0.0 { + for tree in &line.source.trees { + standing.add(geo::to_ecef_deg(tree.lat, tree.lon, 0.0)); + } + } + let mut fresh = Vec::new(); + for detection in report.outcome.found.iter().filter(|d| !is_object(d)) { + // A wood is cut to the module's own outline, exactly as the forest + // brush's bake is: the run's area is drawn on the map by hand and + // can reach over the edge of what this module portrays. + if !line.source.envelope_contains(detection.lat, detection.lon) { + continue; + } + let at = geo::to_ecef_deg(detection.lat, detection.lon, 0.0); + if spacing > 0.0 && standing.taken(at) { + continue; + } + let tag = match &options.species { + Some(stand) => format!("{}{stand}", crate::STAND_TAG), + None => detection.place.clone(), + }; + let species = pool.of(objects, &tag); + if species.is_empty() { + continue; + } + let seed = seed(detection.lat, detection.lon); + // Which tree goes here and how big, and there are two answers + // because there are two people being believed. + // + // Where the species come from the **model**, the crown decides + // both: the member whose own crown suits the one that was + // measured, grown the rest of the way. That is the whole point of + // measuring it. + // + // Where a **builder has named a stand**, they have overruled the + // photograph about what the wood is — and the size has to go with + // it. A crown detector reading a provider's imagery reports the + // sunlit top of a young spruce as two metres across; planting a + // spruce wood at that would be a wood of saplings, and picking + // the member that suits two metres would be a wood of junipers. + // So a named stand is planted the way the forest brush plants + // one: any of its members, at its own size, give or take a third. + let (name, scale) = match &options.species { + Some(_) => { + let pick = species[(seed as usize) % species.len()].0.as_str(); + (pick, natural(seed)) + } + None => { + let Some(name) = fitting(species, band_for(detection), seed) else { + continue; + }; + let crown = species + .iter() + .find(|(candidate, _)| candidate == name) + .and_then(|(_, footprint)| *footprint); + (name, grown(detection.length, crown)) + } + }; + // Only now: a crown whose tag no mod carries plants nothing, and + // must not hold the ground against the tree of another tag beside + // it either. + if spacing > 0.0 { + standing.add(at); + } + *placed.by_tag.entry(tag).or_insert(0) += 1; + placed.trees += 1; + fresh.push(tree_for(detection, name, scale, seed)); + } + line.source.trees.extend(fresh); + } + if options.pave_lots { for lot in &report.lots { line.source.roads.push(road_for(lot)); - paved += 1; + placed.lots += 1; } } - (placed, paved) + placed +} + +/// Whether a find is placed against the track rather than planted on it. +fn is_object(detection: &GeoDetection) -> bool { + detection.kind == Placement::Object } // --------------------------------------------------------------------------- @@ -832,6 +1118,13 @@ pub fn draw( if request.detect_imagery { request.detect_imagery = false; dialog.open = true; + // An area drawn on the map is an answer to "where" already given. A + // builder who has just ringed a copse and opened this should not have + // to say so a second time in a radio button — and the corridor is one + // click away for whoever wanted that instead. + if state.ai_area.as_ref().is_some_and(|a| a.len() >= 3) { + dialog.options.area = Area::Selection; + } } if !dialog.open { return Ok(()); @@ -905,7 +1198,7 @@ pub fn draw( } else if dialog.report.is_some() { close |= finished_panel(ui, dialog, &mut line, &mut state, &mut overlay, &objects); } else { - let answer = settings(ui, dialog, &line, &state, &config_dir); + let answer = settings(ui, dialog, &line, &state, &config_dir, &objects); close |= answer.close; begin = answer.start; draw_area = answer.draw_area; @@ -944,6 +1237,7 @@ fn settings( line: &Line, state: &EditorState, config_dir: &std::path::Path, + objects: &TrackObjects, ) -> Answer { let mut answer = Answer::default(); ui.label(t!("ai-intro")); @@ -958,6 +1252,16 @@ fn settings( .iter() .find(|m| m.id == dialog.options.model) .cloned(); + // Only what this model can do. A crown detector has no car parks to pave + // and a car model has nothing to plant, and a row that decides nothing is + // a question the user has to answer for no reason. + let plants = chosen + .as_ref() + .is_some_and(|m| m.placing().any(|(_, class)| class.is_tree())); + let places = chosen + .as_ref() + .is_none_or(|m| m.placing().any(|(_, class)| !class.is_tree())) + || !plants; editor_ui::form_grid("ai-form") .num_columns(2) @@ -1013,25 +1317,84 @@ fn settings( crate::ui::row(ui, "ai-keep-clear", |ui| { editor_ui::field(ui, &mut dialog.options.keep_clear, 0.5, 0.0..=200.0, "m"); }); - crate::ui::row(ui, "ai-place-objects", |ui| { - ui.checkbox(&mut dialog.options.place_objects, ""); - }); - crate::ui::row(ui, "ai-pave-lots", |ui| { - ui.checkbox(&mut dialog.options.pave_lots, ""); - }); - if dialog.options.pave_lots { - crate::ui::row(ui, "ai-min-lot-cars", |ui| { - let mut cars = dialog.options.min_lot_cars as f64; - editor_ui::field(ui, &mut cars, 1.0, 3.0..=60.0, ""); - dialog.options.min_lot_cars = cars.round() as usize; + if places { + crate::ui::row(ui, "ai-place-objects", |ui| { + ui.checkbox(&mut dialog.options.place_objects, ""); + }); + } + if plants { + crate::ui::row(ui, "ai-place-trees", |ui| { + ui.checkbox(&mut dialog.options.place_trees, ""); + }); + if dialog.options.place_trees { + // What the crowns become. The model finds where a tree is + // and how wide; which tree it is, is the one thing a + // photograph at this resolution cannot settle, so the + // stands of the installed mods are offered beside it. + crate::ui::row(ui, "ai-species", |ui| { + let stands = objects.stands(); + let label = match &dialog.options.species { + Some(stand) => stand.clone(), + None => t!("ai-species-detected"), + }; + egui::ComboBox::from_id_salt("ai-species-pick") + .selected_text(label) + .width(space::FIELD * 2.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut dialog.options.species, + None, + t!("ai-species-detected"), + ); + for (stand, members) in &stands { + ui.selectable_value( + &mut dialog.options.species, + Some(stand.clone()), + format!("{stand} ({})", members.len()), + ); + } + }); + }); + crate::ui::row(ui, "ai-tree-spacing", |ui| { + editor_ui::field( + ui, + &mut dialog.options.tree_spacing, + 0.5, + 0.0..=50.0, + "m", + ); + }); + } + } + if places { + crate::ui::row(ui, "ai-pave-lots", |ui| { + ui.checkbox(&mut dialog.options.pave_lots, ""); }); + if dialog.options.pave_lots { + crate::ui::row(ui, "ai-min-lot-cars", |ui| { + let mut cars = dialog.options.min_lot_cars as f64; + editor_ui::field(ui, &mut cars, 1.0, 3.0..=60.0, ""); + dialog.options.min_lot_cars = cars.round() as usize; + }); + } } }); // What the model will do on this module, said before it is started. ui.add_space(space::S); if let Some(model) = &chosen { - let tags: Vec = model.placing().map(|(_, c)| c.place.clone()).collect(); + // Both tags of a tree class. What a crown detector plants is a + // broadleaf *or* a fir depending on how the crown reads, and a line + // that named only the first would be telling half the truth about + // which mods have to be installed for the run to do anything. + let mut tags: Vec = Vec::new(); + for (_, class) in model.placing() { + for tag in [&class.place, &class.conifer] { + if !tag.is_empty() && !tags.contains(tag) { + tags.push(tag.clone()); + } + } + } ui.small(t!("ai-model-places", tags = tags.join(", "))); if !model.note.is_empty() { ui.small(&model.note); @@ -1116,13 +1479,12 @@ fn finished_panel( *counts.entry(detection.place.as_str()).or_insert(0) += 1; } ui.label( - egui::RichText::new(t!( - "ai-found", - found = report.outcome.found.len(), - lots = report.lots.len() - )) - .color(colors::TEXT_STRONG), + egui::RichText::new(t!("ai-found", found = report.outcome.found.len())) + .color(colors::TEXT_STRONG), ); + if !report.lots.is_empty() { + ui.label(t!("ai-found-lots", lots = report.lots.len())); + } ui.small(t!( "ai-cost", windows = report.outcome.windows, @@ -1137,8 +1499,10 @@ fn finished_panel( // The rule that keeps finds out of the running lanes can only hold to the // roads that are in the file. Without them a car park and a carriageway // are the same grey rectangle in a photograph, and the run says so rather - // than quietly parking a lorry in the fast lane. - if streets(line).is_empty() { + // than quietly parking a lorry in the fast lane. Nothing to say on a run + // that only planted trees: a tree is not put in a lane by a missing road, + // it is kept out of one by the crown not being there in the photograph. + if report.outcome.found.iter().any(is_object) && streets(line).is_empty() { ui.colored_label(colors::WARN, t!("ai-no-roads")); } @@ -1179,12 +1543,21 @@ fn finished_panel( if apply { let report = dialog.report.take().expect("checked above"); let snap = state.place_snap_to_terrain; - let (placed, paved) = commit(line, &report, &dialog.options, objects, snap); - let total: usize = placed.values().sum(); - overlay.status = t!("status-ai-placed", objects = total, lots = paved); + let placed = commit(line, &report, &dialog.options, objects, snap); + overlay.status = t!( + "status-ai-placed", + objects = placed.objects, + trees = placed.trees, + lots = placed.lots + ); line.dirty = true; line.needs_rebuild = true; + // The indices of everything below the new rows are unchanged, but a + // multi-selection made before a run means something else after one — + // and a wood of two thousand fresh trees is not what anybody wants + // held when they next press Delete. state.selection = Selection::None; + state.marked.clear(); return true; } if again { @@ -1204,6 +1577,7 @@ mod tests { GeoDetection { class: 0, place: "car".into(), + kind: Placement::Object, score: 0.9, lat, lon, @@ -1229,7 +1603,7 @@ mod tests { for step in 0..64 { let seeded = seed(51.0 + step as f64 * 1e-4, 7.0); assert_eq!( - fitting(&pool, &found, seeded), + fitting(&pool, band_for(&found), seeded), Some("cars:transporter"), "a 6.30 m van in a 5.00 m space", ); @@ -1242,7 +1616,13 @@ mod tests { let mut found = detection(51.0, 7.0, 0.0); found.length = 6.4; let drawn: std::collections::BTreeSet<&str> = (0..64) - .filter_map(|step| fitting(&pool, &found, seed(51.0 + step as f64 * 1e-4, 7.0))) + .filter_map(|step| { + fitting( + &pool, + band_for(&found), + seed(51.0 + step as f64 * 1e-4, 7.0), + ) + }) .collect(); assert_eq!(drawn.len(), 2, "both fit, so both should turn up"); } @@ -1260,12 +1640,18 @@ mod tests { let mut found = detection(51.0, 7.0, 0.0); found.length = 4.5; let drawn: std::collections::BTreeSet<&str> = (0..64) - .filter_map(|step| fitting(&pool, &found, seed(51.0 + step as f64 * 1e-4, 7.0))) + .filter_map(|step| { + fitting( + &pool, + band_for(&found), + seed(51.0 + step as f64 * 1e-4, 7.0), + ) + }) .collect(); assert_eq!(drawn.len(), 3, "all three fit within the slack"); // And the same place twice is the same car. - let once = fitting(&pool, &found, seed(51.0, 7.0)); - assert_eq!(once, fitting(&pool, &found, seed(51.0, 7.0))); + let once = fitting(&pool, band_for(&found), seed(51.0, 7.0)); + assert_eq!(once, fitting(&pool, band_for(&found), seed(51.0, 7.0))); } #[test] @@ -1274,7 +1660,13 @@ mod tests { let mut found = detection(51.0, 7.0, 0.0); found.length = 2.0; let drawn: std::collections::BTreeSet<&str> = (0..64) - .filter_map(|step| fitting(&pool, &found, seed(51.0 + step as f64 * 1e-4, 7.0))) + .filter_map(|step| { + fitting( + &pool, + band_for(&found), + seed(51.0 + step as f64 * 1e-4, 7.0), + ) + }) .collect(); assert_eq!(drawn, ["mod:hut"].into_iter().collect()); } @@ -1287,7 +1679,7 @@ mod tests { let mut found = detection(51.0, 7.0, 0.0); found.length = 2.0; assert_eq!( - fitting(&pool, &found, seed(51.0, 7.0)), + fitting(&pool, band_for(&found), seed(51.0, 7.0)), Some("cars:transporter"), ); } @@ -1301,7 +1693,7 @@ mod tests { let mut found = detection(51.0, 7.0, 0.0); found.length = 12.0; assert_eq!( - fitting(&pool, &found, seed(51.0, 7.0)), + fitting(&pool, band_for(&found), seed(51.0, 7.0)), Some("cars:kastenwagen"), ); } @@ -1309,7 +1701,7 @@ mod tests { #[test] fn nothing_installed_places_nothing() { let found = detection(51.0, 7.0, 0.0); - assert_eq!(fitting(&[], &found, 7), None); + assert_eq!(fitting(&[], band_for(&found), 7), None); } #[test] @@ -1347,6 +1739,506 @@ mod tests { } } + // ----------------------------------------------------------------- + // Trees + // ----------------------------------------------------------------- + + /// A crown as the walk reports one: round, so the two axes agree, and with + /// no heading worth the name. + fn tree_crown(lat: f64, lon: f64, across: f64) -> GeoDetection { + GeoDetection { + class: 0, + place: "laubbaum".into(), + kind: Placement::Tree, + score: 0.9, + lat, + lon, + length: across, + width: across * 0.95, + heading: 0.0, + } + } + + fn species(name: &str, crown: f64) -> (String, Option) { + ( + name.into(), + Some(Footprint { + length: crown, + width: crown * 0.97, + }), + ) + } + + /// The three individuals `mods/trees` ships of one species, and a fir for + /// the tag that is not asked for. + fn tree_catalogue() -> TrackObjects { + let object = |crown: f64, tags: &[&str]| track_model::TrackObject { + name: "x".into(), + model: "trees/assets/x.gltf".into(), + lateral_offset: 0.0, + yaw_deg: 0.0, + height: 0.0, + autumn_model: None, + winter_model: None, + lod_distances: Vec::new(), + footprint: Some(Footprint { + length: crown, + width: crown * 0.97, + }), + tags: tags.iter().map(|t| (*t).to_string()).collect(), + }; + let mut map = std::collections::BTreeMap::new(); + // Tagged as the shipped mod tags them: the kind, and the stands the + // species belongs to. + let broadleaf = ["laubbaum", "stand-laubwald", "stand-mischwald"]; + let conifer = ["nadelbaum", "stand-nadelwald", "stand-mischwald"]; + map.insert("trees:rotbuche_a".into(), object(9.0, &broadleaf)); + map.insert("trees:rotbuche_b".into(), object(13.0, &broadleaf)); + map.insert("trees:rotbuche_c".into(), object(18.0, &broadleaf)); + map.insert("trees:fichte_a".into(), object(11.0, &conifer)); + TrackObjects { map } + } + + fn report_of(found: Vec) -> Report { + Report { + outcome: vision::Outcome { + found, + windows: 1, + blank: 0, + tiles: 1, + }, + lots: Vec::new(), + } + } + + fn tree_options() -> AiOptions { + AiOptions { + place_objects: true, + place_trees: true, + pave_lots: false, + ..Default::default() + } + } + + /// The whole point of the tree kind: a crown does not become an object + /// bolted to an edge of the track graph, it becomes a row in the tree list + /// — which is the list the select tool picks from and Delete empties. + #[test] + fn a_crown_becomes_a_tree_and_not_an_object() { + let mut line = straight_line(); + let report = report_of(vec![tree_crown(51.0005, 7.0005, 12.0)]); + let placed = commit( + &mut line, + &report, + &tree_options(), + &tree_catalogue(), + false, + ); + assert_eq!(placed.trees, 1); + assert_eq!(placed.objects, 0); + assert_eq!(placed.by_tag.get("laubbaum"), Some(&1)); + assert!(line.source.objects.is_empty(), "nothing against the track"); + let tree = &line.source.trees[0]; + assert!(tree.object.starts_with("trees:rotbuche"), "{}", tree.object); + assert!((tree.lat - 51.0005).abs() < 1e-9 && (tree.lon - 7.0005).abs() < 1e-9); + assert!((0.0..360.0).contains(&tree.yaw_deg)); + } + + /// A twelve-metre crown is planted as a twelve-metre tree, whichever of + /// the species the place happened to draw. + #[test] + fn a_tree_is_grown_to_the_crown_that_was_measured() { + let objects = tree_catalogue(); + for step in 0..48 { + let mut line = straight_line(); + let found = tree_crown(51.0 + step as f64 * 2e-4, 7.001, 12.0); + commit( + &mut line, + &report_of(vec![found]), + &tree_options(), + &objects, + false, + ); + let tree = &line.source.trees[0]; + let model = objects.map[&tree.object].footprint.unwrap().length; + let standing = model * tree.scale; + assert!( + (standing - 12.0).abs() < 0.5, + "{} at {:.2} stands {standing:.1} m across, not 12", + tree.object, + tree.scale, + ); + } + } + + /// Beyond the growth band the species was simply the wrong choice, and a + /// tree squeezed to a third of itself looks like one. + #[test] + fn a_tree_is_never_squeezed_past_recognition() { + assert!( + (grown( + 12.0, + Some(Footprint { + length: 12.0, + width: 12.0 + }) + ) - 1.0) + .abs() + < 1e-9 + ); + assert_eq!( + grown( + 2.0, + Some(Footprint { + length: 18.0, + width: 18.0 + }) + ), + GROWTH.0 + ); + assert_eq!( + grown( + 40.0, + Some(Footprint { + length: 9.0, + width: 9.0 + }) + ), + GROWTH.1 + ); + // A mod that states no crown is not guessed at. + assert_eq!(grown(12.0, None), 1.0); + } + + /// A builder who names a stand has overruled the photograph about what the + /// wood is, and the size goes with it: any member of the stand, at its own + /// size give or take a third. Planting the stand at the size the crowns + /// measured would be a spruce wood of saplings — a crown detector on a + /// provider's imagery reports the sunlit top of a young conifer, not its + /// spread. + #[test] + fn a_named_stand_is_planted_the_way_the_forest_brush_plants_one() { + let objects = tree_catalogue(); + let options = AiOptions { + species: Some("nadelwald".into()), + ..tree_options() + }; + let mut line = straight_line(); + // Crowns of two metres — smaller than anything in the catalogue. + let found: Vec = (0..24) + .map(|i| tree_crown(51.0 + i as f64 * 3e-4, 7.004, 2.0)) + .collect(); + let placed = commit(&mut line, &report_of(found), &options, &objects, false); + assert_eq!(placed.trees, 24); + assert_eq!( + placed.by_tag.keys().collect::>(), + vec!["stand-nadelwald"], + "counted under the stand that was asked for, not the model's tag" + ); + for tree in &line.source.trees { + assert_eq!( + tree.object, "trees:fichte_a", + "the stand's only member, whatever the crown measured" + ); + assert!( + (0.7..=1.3).contains(&tree.scale), + "planted at its own size, not shrunk to a two-metre crown: {}", + tree.scale + ); + } + + // Without the override the same crowns take the model's tag, and the + // measurement decides — which for a two-metre crown means the smallest + // broadleaf there is, shrunk as far as it may be. + let mut line = straight_line(); + let found: Vec = (0..24) + .map(|i| tree_crown(51.0 + i as f64 * 3e-4, 7.004, 2.0)) + .collect(); + commit( + &mut line, + &report_of(found), + &tree_options(), + &objects, + false, + ); + assert!( + line.source + .trees + .iter() + .all(|t| t.object == "trees:rotbuche_a" && t.scale == GROWTH.0), + "the shortest of the tag, at the floor of the growth band" + ); + } + + /// The imagery has not changed, so the wood must not either: a second run + /// over ground that was done adds nothing. + #[test] + fn a_second_run_over_the_same_wood_plants_nothing() { + let mut line = straight_line(); + let objects = tree_catalogue(); + let found: Vec = (0..12) + .map(|i| tree_crown(51.0 + i as f64 * 1e-4, 7.002, 10.0)) + .collect(); + let first = commit( + &mut line, + &report_of(found.clone()), + &tree_options(), + &objects, + false, + ); + assert_eq!(first.trees, 12); + let again = commit( + &mut line, + &report_of(found), + &tree_options(), + &objects, + false, + ); + assert_eq!(again.trees, 0, "every crown already has its tree"); + assert_eq!(line.source.trees.len(), 12); + } + + /// Two crowns a metre apart are one tree; the spacing is what says so, and + /// turning it off plants both. + #[test] + fn two_crowns_on_the_same_spot_become_one_tree() { + let objects = tree_catalogue(); + let pair = vec![ + tree_crown(51.0, 7.003, 10.0), + tree_crown(51.000009, 7.003, 10.0), + ]; + let mut line = straight_line(); + let placed = commit( + &mut line, + &report_of(pair.clone()), + &tree_options(), + &objects, + false, + ); + assert_eq!(placed.trees, 1); + + let mut line = straight_line(); + let options = AiOptions { + tree_spacing: 0.0, + ..tree_options() + }; + let placed = commit(&mut line, &report_of(pair), &options, &objects, false); + assert_eq!(placed.trees, 2, "no spacing asked for, none applied"); + } + + /// A drawn area can reach over the edge of what the module portrays, and + /// the forest brush cuts its wood to the envelope for the same reason. + #[test] + fn a_crown_outside_the_envelope_is_not_planted() { + let mut line = straight_line(); + line.source.envelope = vec![ + content::route::EnvelopePoint { + lat: 51.0, + lon: 7.0, + }, + content::route::EnvelopePoint { + lat: 51.0, + lon: 7.01, + }, + content::route::EnvelopePoint { + lat: 51.01, + lon: 7.01, + }, + content::route::EnvelopePoint { + lat: 51.01, + lon: 7.0, + }, + ]; + let report = report_of(vec![ + tree_crown(51.005, 7.005, 10.0), + tree_crown(51.5, 7.005, 10.0), + ]); + let placed = commit( + &mut line, + &report, + &tree_options(), + &tree_catalogue(), + false, + ); + assert_eq!(placed.trees, 1, "only the one inside the module"); + assert!(line.source.trees[0].lat < 51.01); + } + + /// A run whose tag no installed mod carries plants nothing at all, rather + /// than a tree called "nadelbaum". + #[test] + fn a_tag_no_mod_carries_plants_nothing() { + let mut line = straight_line(); + let mut found = tree_crown(51.0005, 7.0005, 12.0); + found.place = "mangrove".into(); + let placed = commit( + &mut line, + &report_of(vec![found]), + &tree_options(), + &tree_catalogue(), + false, + ); + assert_eq!(placed.trees, 0); + assert!(line.source.trees.is_empty()); + } + + /// Re-running a corridor after widening it must not reshuffle the wood a + /// builder has already looked at. + #[test] + fn the_same_place_always_plants_the_same_tree() { + let objects = tree_catalogue(); + let plant = || { + let mut line = straight_line(); + let report = report_of(vec![tree_crown(51.0007, 7.0009, 11.0)]); + commit(&mut line, &report, &tree_options(), &objects, false); + line.source.trees[0].clone() + }; + assert_eq!(plant(), plant()); + } + + /// The species is drawn by the place from those that suit the crown — so a + /// small crown never draws the big individual, and the pick is still not + /// always the same tree. + #[test] + fn the_crown_decides_which_of_the_species_is_planted() { + let pool = vec![ + species("trees:rotbuche_a", 9.0), + species("trees:rotbuche_b", 13.0), + species("trees:rotbuche_c", 18.0), + ]; + let mut small = tree_crown(51.0, 7.0, 8.0); + small.length = 8.0; + let drawn: std::collections::BTreeSet<&str> = (0..64) + .filter_map(|step| { + let mut at = small.clone(); + at.lat = 51.0 + step as f64 * 1e-4; + fitting(&pool, band_for(&at), seed(at.lat, at.lon)) + }) + .collect(); + assert!( + !drawn.contains("trees:rotbuche_c"), + "an 18 m crown has no business on an 8 m one: {drawn:?}", + ); + assert!(drawn.contains("trees:rotbuche_a")); + } + + /// The band is the one thing that differs between a bay and a crown, and + /// it differs in both directions: a bay may not be overfilled, a crown may + /// be met from either side. + #[test] + fn a_crown_is_measured_symmetrically_and_a_bay_is_not() { + let (floor, room) = band_for(&tree_crown(51.0, 7.0, 10.0)); + assert!((floor - 10.0 / SPREAD).abs() < 1e-9); + assert!((room - 10.0 * SPREAD).abs() < 1e-9); + let car = band_for(&detection(51.0, 7.0, 0.0)); + assert!( + car.1 < 4.4 + 1.0, + "a bay gives half a metre, not half again" + ); + } + + /// The registry and the mod have to be talking about the same woods. A + /// class that says it covers crowns from two and a half metres to + /// twenty-six has to find something to plant at either end of that, or the + /// run quietly does nothing over half its own range — and the tags it + /// names have to be tags the installed trees actually carry. + /// + /// Reads `mods/trees` itself, like the pylon tests read `mods/pylons`: + /// the point is the shipped data, and a fixture would only prove that the + /// fixture agrees with itself. + #[test] + fn the_shipped_trees_cover_the_span_the_registry_claims() { + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../mods/trees/objects"); + let mut map = std::collections::BTreeMap::new(); + for entry in std::fs::read_dir(dir).expect("mods/trees ships with the repository") { + let path = entry.unwrap().path(); + if path.extension().and_then(|e| e.to_str()) != Some("ron") { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + let object = track_model::TrackObject::from_ron(&text) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let stem = path.file_stem().unwrap().to_str().unwrap(); + let crown = object + .footprint + .unwrap_or_else(|| panic!("{stem} states no crown, so it can never be planted")) + .length; + assert!( + (0.5..=40.0).contains(&crown), + "{stem} states a crown of {crown} m" + ); + map.insert(format!("trees:{stem}"), object); + } + let objects = TrackObjects { map }; + assert_eq!(objects.map.len(), 84, "the whole catalogue was read"); + + for id in ["tree-crowns", "tree-species"] { + let spec = VisionConfig::default().model_by_id(id).unwrap().clone(); + for (_, class) in spec.placing() { + let (low, high) = class.span.expect("a tree class states its span"); + for tag in [&class.place, &class.conifer] { + if tag.is_empty() { + continue; + } + let pool = tagged(&objects, tag); + assert!(!pool.is_empty(), "{id}: no tree tagged {tag} is installed"); + let crowns: Vec = + pool.iter().filter_map(|c| c.1).map(|f| f.length).collect(); + let (smallest, largest) = ( + crowns.iter().copied().fold(f64::MAX, f64::min), + crowns.iter().copied().fold(0.0, f64::max), + ); + let mut crown = low; + while crown <= high { + let mut found = tree_crown(51.0, 7.0, crown); + found.place = tag.clone(); + let name = fitting(&pool, band_for(&found), seed(crown, 7.0)) + .unwrap_or_else(|| panic!("{id}/{tag}: nothing to plant on {crown} m")); + let model = pool.iter().find(|(c, _)| c == name).unwrap().1; + let standing = model.unwrap().length * grown(crown, model); + let error = (standing - crown).abs() / crown; + // Inside what the tag actually offers, the size that + // was measured is the size that goes in — the species + // is drawn from those that suit and then scaled the + // rest of the way. + let limit = if (smallest..=largest).contains(&crown) { + 0.01 + } else { + // Outside it, the nearest the mod can manage: a + // one-metre bush where the smallest installed is + // two is planted at its floor, not skipped. + 1.0 / 3.0 + }; + assert!( + error <= limit, + "{id}/{tag}: a {crown:.2} m crown was planted as {name} \ + standing {standing:.2} m ({:.0}% out)", + error * 100.0, + ); + crown += 0.25; + } + } + } + } + } + + /// The grid has to answer for a neighbour that fell on the other side of a + /// cell edge — the failure a hand-rolled bucket makes, and the one that + /// shows up as a doubled tree every few metres. + #[test] + fn the_occupancy_grid_sees_across_its_own_cell_edges() { + let mut taken = Occupied::new(3.0); + let at = |lat: f64, lon: f64| geo::to_ecef_deg(lat, lon, 0.0); + taken.add(at(51.0, 7.0)); + // A metre north, whichever cell that lands in. + const METRE: f64 = 1.0 / 111_132.0; + for step in 0..200 { + let probe = at(51.0 + step as f64 * METRE * 0.05, 7.0); + let apart = at(51.0, 7.0).distance(probe); + assert_eq!(taken.taken(probe), apart < 3.0, "{apart:.2} m apart",); + } + } + /// Otherwise a second run over ground this dialog has already paved could /// not put a car in the bays it found the first time. #[test] diff --git a/crates/route-editor/src/main.rs b/crates/route-editor/src/main.rs index 68cc208..c7af568 100644 --- a/crates/route-editor/src/main.rs +++ b/crates/route-editor/src/main.rs @@ -6,7 +6,8 @@ //! trainsim-route-editor [line.ron] [--imagery ] [--frames N] [--height M] //! [--window WxH] [--drawer [objects|signal-types|signal-models|track-types]] //! [--ai ] [--detect] [--at KM] -//! [--detect-run [--corridor M] [--keep-clear M] [--model ]] +//! [--detect-run [--corridor M] [--keep-clear M] [--model ] +//! [--stand ]] //! ``` //! //! Without a line file the example line is loaded. The overlay configuration is created @@ -407,6 +408,12 @@ fn main() { if let Some(id) = flag("--model") { options.model = id; } + // `--stand nadelwald`: plant every crown from that stand instead of + // from what the crown looked like. A script rebuilding a module over + // known ground knows which wood it is; the detector does not. + if let Some(stand) = flag("--stand") { + options.species = Some(stand); + } options }); let detect_run_wanted = detect_run.is_some(); diff --git a/crates/route-editor/src/tools.rs b/crates/route-editor/src/tools.rs index 38c4a83..387700f 100644 --- a/crates/route-editor/src/tools.rs +++ b/crates/route-editor/src/tools.rs @@ -4813,6 +4813,68 @@ mod tests { /// The circle selection marks the point things inside — a device /// included — and the bulk delete removes them all in one step, with /// the signal references cleaned up so the result still compiles. + /// A wood the imagery detection planted is a wood of ordinary trees, and + /// this is how a builder takes back the part of it the model got wrong: + /// sweep the select circle over it, press Delete, and pick off what is + /// left one tree at a time. Nothing here knows where the trees came from, + /// which is the point of planting them into this list in the first place. + #[test] + fn trees_are_caught_by_the_circle_and_picked_off_one_by_one() { + let source = content::musterbahn(); + let net = source.compile().unwrap().net; + let mut doc = Line { + source, + net, + path: None, + dirty: false, + needs_rebuild: false, + terrain_change: Default::default(), + recenter: false, + issues: Vec::new(), + }; + doc.source.trees.clear(); + // A stand of four, three of them within a few metres of each other and + // one well away — the shape of a detection run over a copse. + const METRE: f64 = 1.0 / 111_132.0; + for (index, north) in [0.0, 4.0, 8.0, 400.0].into_iter().enumerate() { + doc.source.trees.push(TreeSource { + object: format!("trees:rotbuche_{}", ["a", "b", "c", "a"][index]), + lat: 51.0 + north * METRE, + lon: 7.0, + yaw_deg: 30.0 * index as f64, + scale: 1.0, + }); + } + let marks = Marks::default(); + let mut state = EditorState::default(); + + // The circle takes the three that stand together and leaves the fourth. + let centre = marks.tree(1, &doc.source.trees[1]); + mark_circle(&mut state, &doc, &marks, centre, 10.0); + let caught: Vec = state + .marked + .iter() + .filter(|m| matches!(m, Mark::Tree(_))) + .copied() + .collect(); + assert_eq!(caught.len(), 3, "the copse, not the tree across the field"); + assert!(caught.contains(&Mark::Tree(0)) && caught.contains(&Mark::Tree(2))); + + delete_marked(&mut doc, &mut state); + assert_eq!(doc.source.trees.len(), 1); + assert!(state.marked.is_empty()); + assert!( + doc.source.trees[0].lat > 51.003, + "the one that was left is the far one" + ); + + // And a single tree goes on its own, through the same Delete. + state.selection = Selection::Tree(0); + delete_selection(&mut doc, &mut state); + assert!(doc.source.trees.is_empty()); + assert_eq!(state.selection, Selection::None); + } + #[test] fn circle_selection_marks_and_deletes() { let source = content::musterbahn(); diff --git a/crates/vision/src/canopy.rs b/crates/vision/src/canopy.rs new file mode 100644 index 0000000..1679504 --- /dev/null +++ b/crates/vision/src/canopy.rs @@ -0,0 +1,304 @@ +//! What kind of tree a crown is, read off the crown itself. +//! +//! A model that finds tree crowns says *tree*. Every published one does: the +//! aerial sets they are trained on are labelled with one class, because +//! drawing a box round a crown is a job a student can do from the photograph +//! and naming the species is not. But a module built out of one class is a +//! module planted with one kind of tree, and a spruce slope done in lime is +//! the first thing anybody notices about a wood. +//! +//! So where the model cannot say, the pixels are asked. Two things about a +//! crown separate a fir from a lime, and both are **ratios**, which is what +//! makes them worth anything: the imagery of one province is a stop brighter +//! than the next, and a rule written in absolute brightness would hold on one +//! provider and be nonsense on the other. +//! +//! * **Contrast.** A conifer is a cone. It is lit on one side and shadowed on +//! the other, and between the two there is a hard edge — inside one crown the +//! brightness varies enormously. A broadleaf is a dome of small leaves that +//! scatters light in every direction, and reads far flatter. +//! * **Warmth.** Needles are blue-green and dark. Broadleaf foliage is +//! yellow-green, and in autumn frankly orange. The red channel against the +//! blue says which, and says it without knowing how bright the day was. +//! +//! **It is a hint and not a finding, and the measurement says so.** Over a +//! mixed wood in the Sauerland on the imagery a provider actually gives — +//! 19 cm a pixel, enlarged to the 5 cm the model reads at — the two +//! populations overlap badly: +//! +//! | | contrast | warmth | +//! | --- | --- | --- | +//! | broadleaf crowns | median 0.10, p90 0.19 | median 0.01 | +//! | spruce under 2 m across | median 0.13 | median 0.00 | +//! | spruce of 3 m and more | median 0.22 | median 0.04 | +//! +//! The reason is resolution rather than the idea: a two-metre crown is ten +//! native pixels across, and the shadow that makes a cone a cone is not in +//! ten pixels. Where the crowns are big enough to resolve, the separation is +//! real (0.22 against 0.10); where they are not, this is close to a coin. +//! Warmth barely separates anything on summer imagery and is kept for what it +//! is genuinely good at: vetoing the orange of an autumn crown, which is +//! shadowed like a fir and is not one. +//! +//! So the crate offers it and never insists on it. A class with species of its +//! own ([`crate::ClassSpec::conifer`] left empty) never consults it, and the +//! editor lets a builder overrule the lot by naming the stand to plant from — +//! which is the honest division of labour, because at this resolution a person +//! looking at the photograph can see what the arithmetic cannot. + +use crate::detect::Detection; + +/// The share of the box that is sampled, as a radius. +/// +/// Well inside it. A detector's box is drawn round the *whole* crown and +/// therefore contains its edge, and the edge of a crown is half ground: grass, +/// track ballast, the roof of the shed behind it. A third of the way out is +/// all crown whatever the box did, and a crown is uniform enough that the +/// middle is representative of it. +const CORE: f32 = 0.35; + +/// Fewest pixels a verdict is given on. Below it, a crown a handful of pixels +/// across, where the contrast is the resampling and not the tree. +const ENOUGH: usize = 24; + +/// Relative brightness spread above which a crown reads as a lit-and-shadowed +/// cone rather than a dome. +/// +/// Measured rather than guessed, and the first version of this file guessed +/// 0.25 — which on real imagery put a plantation of spruce at seven per cent +/// conifer, because 0.25 is what a crown reads at when the photograph is sharp +/// enough to hold its shadow. Between the p90 of the broadleaves (0.19) and the +/// median of the spruce big enough to resolve (0.22) there is one number, and +/// this is it. The overlap either side of it is real; see the module head. +const SHADOWED: f64 = 0.20; + +/// Warmth above which a crown cannot be needles. +/// +/// A veto, not a test: on summer imagery both kinds sit within a few +/// hundredths of nought and this never fires. What it is for is the autumn +/// broadleaf, which is orange, is shadowed exactly like a fir, and would +/// otherwise be planted as one — and the dead spruce of a bark-beetle stand, +/// which is browner still. +const WARM: f64 = 0.10; + +/// What one crown looks like, in the two numbers that survive the exposure. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Crown { + /// Standard deviation of the brightness over its mean — how hard the + /// crown is lit on one side and shadowed on the other. + pub contrast: f64, + /// `(R − B)` over the sum of the channels: how far from blue-green towards + /// yellow and orange the foliage is. + pub warmth: f64, + /// Pixels the two were measured on. + pub samples: usize, +} + +impl Crown { + /// Whether this reads as a needle-leaf tree. + /// + /// Both, not either. The contrast is what carries the decision and the + /// warmth is what stops it: a broadleaf standing alone over a mown field + /// is high-contrast too, because its own shadow is in the box, and an + /// autumn crown is high-contrast and orange. + pub fn conifer(&self) -> bool { + self.contrast > SHADOWED && self.warmth < WARM + } +} + +/// Reads the crown of `at` out of the window it was found in. +/// +/// `pixels` is the RGB8 buffer the detector was given, so the box is in the +/// coordinates the detection already carries and nothing has to be mapped. +/// `None` where the box is too small to say anything about, which is the +/// honest answer for a crown eight pixels across. +pub fn read(pixels: &[u8], width: u32, height: u32, at: &Detection) -> Option { + let radius = (at.w.min(at.h) * CORE).max(1.0); + let (left, right) = (at.cx - radius, at.cx + radius); + let (top, bottom) = (at.cy - radius, at.cy + radius); + let x0 = left.floor().max(0.0) as u32; + let y0 = top.floor().max(0.0) as u32; + let x1 = (right.ceil() as i64).clamp(0, width as i64) as u32; + let y1 = (bottom.ceil() as i64).clamp(0, height as i64) as u32; + + let mut samples = 0usize; + let mut sum = 0.0; + let mut squares = 0.0; + let mut warmth = 0.0; + for y in y0..y1 { + for x in x0..x1 { + // The disc, not its bounding square: the corners of the square + // are outside the crown by a fifth of its width. + let (dx, dy) = (x as f32 + 0.5 - at.cx, y as f32 + 0.5 - at.cy); + if dx * dx + dy * dy > radius * radius { + continue; + } + let at = ((y as usize * width as usize) + x as usize) * 3; + let (r, g, b) = ( + *pixels.get(at)? as f64, + *pixels.get(at + 1)? as f64, + *pixels.get(at + 2)? as f64, + ); + // Rec. 709, the luminance every display works in. + let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; + sum += luma; + squares += luma * luma; + // The +1 is for the black pixel: a hole in the imagery is nought + // over nought otherwise, and it would come out as warm. + warmth += (r - b) / (r + g + b + 1.0); + samples += 1; + } + } + if samples < ENOUGH { + return None; + } + let n = samples as f64; + let mean = sum / n; + if mean <= 1.0 { + // Black. A hole in the coverage, or a shadow so deep there is nothing + // in it to read — either way not a crown anybody can name. + return None; + } + let variance = (squares / n - mean * mean).max(0.0); + Some(Crown { + contrast: variance.sqrt() / mean, + warmth: warmth / n, + samples, + }) +} + +/// The tag a find of this class gets: the class's own, or its conifer tag +/// where the crown reads as needles. +/// +/// The one entry point the walk uses, so the rule that a class with species of +/// its own is never second-guessed lives in one place: an empty +/// [`crate::ClassSpec::conifer`] returns `place` without ever looking at a +/// pixel. +pub fn tag_for( + class: &crate::ClassSpec, + pixels: &[u8], + width: u32, + height: u32, + at: &Detection, +) -> String { + if class.conifer.is_empty() { + return class.place.clone(); + } + match read(pixels, width, height, at) { + Some(crown) if crown.conifer() => class.conifer.clone(), + _ => class.place.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A window with one crown painted into it: `shade` is how much darker the + /// shadowed half is, so a cone and a dome differ by that alone. + fn window(colour: (u8, u8, u8), shade: f64) -> (Vec, u32) { + const SIZE: u32 = 64; + let mut pixels = vec![0u8; (SIZE * SIZE * 3) as usize]; + for y in 0..SIZE { + for x in 0..SIZE { + let lit = if x < SIZE / 2 { 1.0 } else { 1.0 - shade }; + let at = ((y * SIZE + x) * 3) as usize; + pixels[at] = (colour.0 as f64 * lit) as u8; + pixels[at + 1] = (colour.1 as f64 * lit) as u8; + pixels[at + 2] = (colour.2 as f64 * lit) as u8; + } + } + (pixels, SIZE) + } + + fn found(size: f32) -> Detection { + Detection { + class: 0, + score: 0.9, + cx: 32.0, + cy: 32.0, + w: size, + h: size, + angle: 0.0, + } + } + + #[test] + fn a_dark_blue_green_cone_reads_as_a_conifer() { + let (pixels, size) = window((46, 62, 48), 0.55); + let crown = read(&pixels, size, size, &found(40.0)).unwrap(); + assert!(crown.conifer(), "{crown:?}"); + } + + #[test] + fn a_flat_yellow_green_dome_reads_as_a_broadleaf() { + let (pixels, size) = window((86, 116, 54), 0.12); + let crown = read(&pixels, size, size, &found(40.0)).unwrap(); + assert!(!crown.conifer(), "{crown:?}"); + assert!(crown.warmth > WARM, "yellow-green is warm: {crown:?}"); + } + + /// An oak in autumn is orange and strongly shadowed — it fails the + /// contrast test and would be a fir on brightness alone. The colour is + /// what saves it, which is why both tests have to agree. + #[test] + fn an_autumn_crown_stays_a_broadleaf_however_shadowed() { + let (pixels, size) = window((168, 120, 58), 0.6); + let crown = read(&pixels, size, size, &found(40.0)).unwrap(); + assert!(crown.contrast > SHADOWED, "shadowed: {crown:?}"); + assert!(!crown.conifer(), "but not a fir: {crown:?}"); + } + + /// The claim the whole module rests on: a stop of exposure either way is + /// the same tree. Both numbers are ratios of the channels against each + /// other, so neither moves with the brightness of the day. + /// + /// Near enough rather than exactly: the guard against a black pixel puts a + /// one in the denominator of the warmth, which is a hundredth of a percent + /// at these levels and the price of not dividing by nought in a hole in + /// the imagery. + #[test] + fn a_brighter_photograph_is_the_same_tree() { + let (pixels, size) = window((40, 54, 42), 0.5); + let dim = read(&pixels, size, size, &found(40.0)).unwrap(); + let brighter: Vec = pixels.iter().map(|b| b.saturating_mul(2)).collect(); + let bright = read(&brighter, size, size, &found(40.0)).unwrap(); + assert!((dim.contrast - bright.contrast).abs() < 1e-6, "{dim:?}"); + assert!((dim.warmth - bright.warmth).abs() < 1e-3, "{dim:?}"); + assert_eq!(dim.conifer(), bright.conifer()); + } + + #[test] + fn a_crown_of_a_handful_of_pixels_is_not_named() { + let (pixels, size) = window((46, 62, 48), 0.55); + assert!(read(&pixels, size, size, &found(4.0)).is_none()); + // Nor is a hole in the imagery. + let black = vec![0u8; (size * size * 3) as usize]; + assert!(read(&black, size, size, &found(40.0)).is_none()); + } + + /// A class that names its own species is never second-guessed, and one + /// that cannot be read falls back to the broadleaf tag rather than to + /// nothing. + #[test] + fn a_model_that_knows_its_species_is_left_alone() { + let (pixels, size) = window((46, 62, 48), 0.55); + let told = crate::ClassSpec::tree("conifer", "nadelbaum", "", (2.0, 22.0)); + assert_eq!( + tag_for(&told, &pixels, size, size, &found(40.0)), + "nadelbaum" + ); + + let guessing = crate::ClassSpec::tree("tree", "laubbaum", "nadelbaum", (2.0, 26.0)); + assert_eq!( + tag_for(&guessing, &pixels, size, size, &found(40.0)), + "nadelbaum", + "a dark cone is a fir" + ); + assert_eq!( + tag_for(&guessing, &pixels, size, size, &found(4.0)), + "laubbaum", + "and a crown too small to read is the class's own tag" + ); + } +} diff --git a/crates/vision/src/detect.rs b/crates/vision/src/detect.rs index c60f56b..b3a2ee2 100644 --- a/crates/vision/src/detect.rs +++ b/crates/vision/src/detect.rs @@ -15,7 +15,7 @@ //! are settled in metres on the ground ([`merge`]) rather than in pixels, //! because that is the one frame both windows agree in. -use crate::model::ModelSpec; +use crate::model::{ModelSpec, Placement}; use crate::region::Region; use crate::sheet::Sheet; @@ -40,8 +40,14 @@ pub struct Detection { pub struct GeoDetection { /// Index into [`ModelSpec::classes`]. pub class: usize, - /// Tag of the objects that may be placed here — [`crate::ClassSpec::place`]. + /// Tag of the objects that may be placed here — [`crate::ClassSpec::place`], + /// or the class's conifer tag where the crown read as one + /// ([`crate::canopy`]). pub place: String, + /// Whether this is placed against the track or planted on the ground — + /// [`crate::ClassSpec::kind`]. Carried on the find rather than looked up + /// again, so that everything downstream can work from the finds alone. + pub kind: Placement, pub score: f32, pub lat: f64, pub lon: f64, @@ -172,7 +178,15 @@ pub fn run( ); let raw = detector.detect(&scaled, spec.input.width, spec.input.height)?; for detection in suppress(raw, spec.head.iou()) { - if let Some(geo) = place(&detection, spec, sheet, x, y, window_w, window_h) { + // The scaled window goes with it: a crown is named from the pixels + // it covers, and this is the last point at which they are to hand. + let at = At { + left: x, + top: y, + width: window_w, + height: window_h, + }; + if let Some(geo) = place(&detection, spec, sheet, at, &scaled) { found.push(geo); } } @@ -189,15 +203,23 @@ pub fn run( }) } +/// Where a window sits on the sheet and how much ground it covers, in sheet +/// pixels — the frame a detection is mapped out of. +#[derive(Debug, Clone, Copy)] +struct At { + left: i64, + top: i64, + width: i64, + height: i64, +} + /// One detection from the model's own pixels onto the map. fn place( detection: &Detection, spec: &ModelSpec, sheet: &Sheet, - left: i64, - top: i64, - window_w: i64, - window_h: i64, + at: At, + pixels: &[u8], ) -> Option { let class = spec.classes.get(detection.class)?; if class.place.is_empty() || detection.score < spec.confidence_of(detection.class) { @@ -214,10 +236,10 @@ fn place( // in pixel *indices*, where a whole number is the middle of a pixel; the // two conventions agree once both are put in the same one, and the test // below is what says so.) - let fx = window_w as f64 / spec.input.width.max(1) as f64; - let fy = window_h as f64 / spec.input.height.max(1) as f64; - let px = left as f64 + detection.cx as f64 * fx; - let py = top as f64 + detection.cy as f64 * fy; + let fx = at.width as f64 / spec.input.width.max(1) as f64; + let fy = at.height as f64 / spec.input.height.max(1) as f64; + let px = at.left as f64 + detection.cx as f64 * fx; + let py = at.top as f64 + detection.cy as f64 * fy; let (lat, lon) = sheet.lat_lon_at(px, py); let metres = sheet.meters_per_pixel(lat); let w = detection.w as f64 * fx * metres; @@ -228,7 +250,14 @@ fn place( } Some(GeoDetection { class: detection.class, - place: class.place.clone(), + place: crate::canopy::tag_for( + class, + pixels, + spec.input.width, + spec.input.height, + detection, + ), + kind: class.kind, score: detection.score, lat, lon, @@ -299,6 +328,22 @@ fn iou(a: &Detection, b: &Detection) -> f32 { } } +/// How close two finds of the same size may be before the second is taken for +/// the first seen again, as a share of the smaller one. +/// +/// Cars stand in bays, and two of them are never closer than most of a car +/// length; anything nearer than that came from a window seam. +const APART: f64 = 0.6; + +/// The same for trees, and much smaller, because a wood is not a car park. +/// +/// Crowns in a closed stand interlock: two twelve-metre limes whose centres +/// are eight metres apart are two limes, and the car rule would quietly delete +/// every second tree of a wood. What a seam produces is a metre or two of +/// disagreement, so a third of the crown is still far more than a duplicate +/// ever is and far less than a neighbour. +const APART_TREES: f64 = 0.35; + /// Settles the duplicates two overlapping windows produced, in metres. /// /// Distance rather than overlap: what the seam produces is the *same* car @@ -310,7 +355,12 @@ pub fn merge(mut found: Vec) -> Vec { let mut kept: Vec = Vec::new(); for candidate in found { let duplicate = kept.iter().any(|k| { - let limit = k.length.min(candidate.length) * 0.6; + let share = if k.kind == Placement::Tree || candidate.kind == Placement::Tree { + APART_TREES + } else { + APART + }; + let limit = k.length.min(candidate.length) * share; distance(k.lat, k.lon, candidate.lat, candidate.lon) < limit.max(1.0) }); if !duplicate { @@ -446,6 +496,7 @@ mod tests { let car = |lat: f64, lon: f64, score: f32| GeoDetection { class: 10, place: "car".into(), + kind: Placement::Object, score, lat, lon, @@ -560,8 +611,15 @@ mod tests { let length = (4.5 / metres) as f32; let mut car = detection(middle, middle, length, length / 2.4, 0.0); car.class = 0; - let found = place(&car, &spec, &sheet, left, top, window_w, window_h) - .expect("a car in the middle of the window"); + let grey = vec![128u8; (spec.input.width * spec.input.height * 3) as usize]; + let at = At { + left, + top, + width: window_w, + height: window_h, + }; + let found = + place(&car, &spec, &sheet, at, &grey).expect("a car in the middle of the window"); let (want_lat, want_lon) = sheet.lat_lon_at( left as f64 + window_w as f64 / 2.0, top as f64 + window_h as f64 / 2.0, @@ -602,6 +660,164 @@ mod tests { } } + /// A wood: one crown per window, in the middle, the size of a mature + /// broadleaf. + struct Wood { + metres: f64, + } + + impl Detector for Wood { + fn detect( + &mut self, + _pixels: &[u8], + width: u32, + height: u32, + ) -> Result, String> { + // The tree spec is trained at 0.1 m/px, so a crown is ten pixels + // to the metre in the model's own input. + let across = (self.metres * 10.0) as f32; + Ok(vec![Detection { + class: 0, + score: 0.9, + cx: width as f32 / 2.0, + cy: height as f32 / 2.0, + w: across, + h: across, + angle: 0.0, + }]) + } + } + + /// A sheet of one flat colour, so that the walk can be run over a wood + /// that reads as needles and one that reads as leaves. + fn sheet_of(colour: [u8; 3], shade: f64) -> Sheet { + Sheet::new(19, 256, 64, move |id| { + let mut pixels = vec![255u8; 256 * 256 * 4]; + for y in 0..256 { + for x in 0..256 { + // Lit and shadowed in stripes rather than halves, so that + // a crown anywhere in the tile straddles both — the + // contrast a cone has and a dome does not. + let lit = if (x / 8) % 2 == 0 { 1.0 } else { 1.0 - shade }; + let at = (y * 256 + x) * 4; + for c in 0..3 { + pixels[at + c] = (colour[c] as f64 * lit) as u8; + } + } + } + Some(DecodedTile { + tile: id, + width: 256, + height: 256, + pixels, + }) + }) + } + + fn tree_spec() -> ModelSpec { + VisionConfig::default() + .model_by_id("tree-crowns") + .expect("the registry ships a crown detector") + .clone() + } + + /// The tree end of the walk, from the imagery to what the editor plants: + /// the find is a tree rather than an object, it is as wide as the crown in + /// the picture, and the species tag came from what the crown looks like. + #[test] + fn a_wood_comes_out_as_trees_of_the_size_that_was_seen() { + let track = vec![vec![(51.0, 7.0), (51.0, 7.004)]]; + let region = Region::new(Shape::Corridor { radius: 60.0 }, &track, 8.0, 32); + let spec = tree_spec(); + + // Dark, blue-green and hard-shadowed: a spruce plantation. + let mut sheet = sheet_of([46, 62, 48], 0.55); + let outcome = run( + &mut sheet, + &mut Wood { metres: 12.0 }, + &spec, + ®ion, + &mut |_| true, + ) + .unwrap(); + assert!(!outcome.found.is_empty(), "the corridor was walked"); + for tree in &outcome.found { + assert_eq!(tree.kind, Placement::Tree, "planted, not placed"); + assert_eq!(tree.place, "nadelbaum", "a dark cone is a fir"); + assert!( + (tree.length - 12.0).abs() < 1.5, + "{} m of crown, not 12", + tree.length + ); + assert!(region.contains(tree.lat, tree.lon)); + } + + // The same wood in flat yellow-green: limes. + let mut sheet = sheet_of([86, 116, 54], 0.1); + let leafy = run( + &mut sheet, + &mut Wood { metres: 12.0 }, + &spec, + ®ion, + &mut |_| true, + ) + .unwrap(); + assert!(leafy.found.iter().all(|t| t.place == "laubbaum")); + assert_eq!( + leafy.found.len(), + outcome.found.len(), + "the same crowns either way — only the species differs" + ); + } + + /// The span in the class is what says so: a crown of half a metre is not a + /// tree, and one the size of a football pitch is several run together. + #[test] + fn a_crown_of_an_impossible_size_is_dropped() { + let track = vec![vec![(51.0, 7.0), (51.0, 7.004)]]; + let region = Region::new(Shape::Corridor { radius: 60.0 }, &track, 8.0, 32); + let spec = tree_spec(); + for metres in [0.6, 40.0] { + let mut sheet = sheet_of([86, 116, 54], 0.1); + let outcome = run( + &mut sheet, + &mut Wood { metres }, + &spec, + ®ion, + &mut |_| true, + ) + .unwrap(); + assert!( + outcome.found.is_empty(), + "{metres} m of crown was taken for a tree" + ); + } + } + + /// Two neighbouring crowns of a closed wood are two trees. The car rule + /// would have merged them, which is how a wood loses every second tree. + #[test] + fn two_crowns_of_a_closed_wood_stay_two_trees() { + let tree = |lat: f64, lon: f64| GeoDetection { + class: 0, + place: "laubbaum".into(), + kind: Placement::Tree, + score: 0.9, + lat, + lon, + length: 12.0, + width: 12.0, + heading: 0.0, + }; + const METRE: f64 = 1.0 / 111_132.0; + // Eight metres apart: two limes whose crowns touch. + let two = merge(vec![tree(51.0, 7.0), tree(51.0 + 8.0 * METRE, 7.0)]); + assert_eq!(two.len(), 2); + // One metre apart: the same lime, seen in both windows over a seam. + let one = merge(vec![tree(51.0, 7.0), tree(51.0 + METRE, 7.0)]); + assert_eq!(one.len(), 1); + } + #[test] fn stopping_keeps_what_was_found_so_far() { let track = vec![vec![(51.0, 7.0), (51.0, 7.02)]]; diff --git a/crates/vision/src/lib.rs b/crates/vision/src/lib.rs index 5a787bb..b7f963a 100644 --- a/crates/vision/src/lib.rs +++ b/crates/vision/src/lib.rs @@ -29,14 +29,26 @@ //! Region ──▶ sheet::Sheet ──▶ detect::run ──▶ Vec ──▶ parking::lots //! (tiles, lazily) (windows, (metres, (clusters, //! NMS) headings) rectangles) +//! │ │ +//! canopy::tag_for Placement::Tree +//! (fir or lime) (crown, not heading) //! ``` //! +//! A find is one of two things ([`Placement`]), and the difference runs +//! through the whole crate. An **object** — a car, a lorry — is placed against +//! the track and what matters about it is which way it points. A **tree** is +//! planted on the ground and points nowhere; what matters about it is how wide +//! its crown is, because that is what decides which of the installed trees +//! goes there and how big it is grown. Both come out of the same walk over the +//! same imagery, and which one a class is, is one word in `ai.ron`. +//! //! What this crate does *not* do is decide what a detection becomes. A car in //! a photograph is a `GeoDetection` with a role of `"car"`; which model from //! which mod is placed there, at what distance from the rails, and whether it //! goes into the line file at all is the editor's business //! (`route-editor/src/ai.rs`). +pub mod canopy; pub mod detect; pub mod model; #[cfg(feature = "onnx")] @@ -45,8 +57,9 @@ pub mod parking; pub mod region; pub mod sheet; +pub use canopy::Crown; pub use detect::{Detection, Detector, GeoDetection, Outcome, Progress, run}; -pub use model::{ClassSpec, Head, InputSpec, Layout, ModelSpec, VisionConfig}; +pub use model::{ClassSpec, Head, InputSpec, Layout, ModelSpec, Placement, VisionConfig}; pub use parking::{Lot, lots}; pub use region::{Region, Shape}; pub use sheet::Sheet; diff --git a/crates/vision/src/model.rs b/crates/vision/src/model.rs index fb59b51..e2e5040 100644 --- a/crates/vision/src/model.rs +++ b/crates/vision/src/model.rs @@ -1,10 +1,18 @@ //! The model registry: what a model is, as a file the user can edit. //! //! `ai.ron` sits next to `imagery.ron` and is written on first start, exactly -//! like the imagery configuration. It lists models, not weights — the weights -//! are big, and most of them are licensed in a way that forbids shipping them -//! with a game. Each entry says where its `.onnx` file is expected, and the -//! editor says plainly when it is not there yet ([`ModelSpec::missing`]). +//! like the imagery configuration. Two of its entries — the car detector and +//! the tree crown detector — name weights that **ship with the game**, in +//! `models/`, so that the feature works on a fresh clone with nothing fetched. +//! The rest are descriptions of models a user may bring, and for those the +//! entry says where the `.onnx` file is expected and the editor says plainly +//! when it is not there yet ([`ModelSpec::missing`]). +//! +//! What ships is still a *description* plus a file, never a model built into +//! the binary: swapping either detector is editing this file and dropping an +//! `.onnx` beside it. The two that ship are converted from published +//! pre-trained detectors by `tools/vision/export_models.py`, and they do not +//! share a licence — `models/LICENSES.md` is the record. //! //! A [`ModelSpec`] is deliberately mechanical: how the picture goes in //! ([`InputSpec`]), what shape comes out ([`Head`]), and what each class means @@ -96,6 +104,18 @@ pub enum Head { /// [rad]. This is the head worth having for parked cars — it says which /// way each one points, and a car park is nothing but that. Oriented { confidence: f32, iou: f32 }, + /// An anchored dense head — torchvision's RetinaNet, which is what the + /// tree crown model is. Two tensors, `[1, anchors, classes]` of logits + /// and `[1, anchors, 4]` of offsets, and **no boxes in them at all**: an + /// offset is measured from the anchor it belongs to, and the anchors are + /// not in the file. They are rebuilt from the input size, which is why + /// this head exists as a variant rather than as another shape the + /// Ultralytics decoder copes with. + /// + /// It is here because it is what the shipped tree detector needs, and + /// that in turn because the only crown models worth having are published + /// this way — the whole DeepForest family is torchvision underneath. + Retina { confidence: f32, iou: f32 }, } impl Default for Head { @@ -110,33 +130,58 @@ impl Default for Head { impl Head { pub fn confidence(self) -> f32 { match self { - Head::Boxes { confidence, .. } | Head::Oriented { confidence, .. } => confidence, + Head::Boxes { confidence, .. } + | Head::Oriented { confidence, .. } + | Head::Retina { confidence, .. } => confidence, } } pub fn iou(self) -> f32 { match self { - Head::Boxes { iou, .. } | Head::Oriented { iou, .. } => iou, + Head::Boxes { iou, .. } | Head::Oriented { iou, .. } | Head::Retina { iou, .. } => iou, } } - /// Rows of the output tensor that come before the class scores. + /// Rows of the output tensor that come before the class scores — for the + /// two Ultralytics layouts, which are the ones read row by row. pub fn box_rows(self) -> usize { match self { - Head::Boxes { .. } => 4, - Head::Oriented { .. } => 4, + Head::Boxes { .. } | Head::Oriented { .. } | Head::Retina { .. } => 4, } } /// Rows after the class scores. pub fn tail_rows(self) -> usize { match self { - Head::Boxes { .. } => 0, + Head::Boxes { .. } | Head::Retina { .. } => 0, Head::Oriented { .. } => 1, } } } +/// What a find of a class becomes on the module. +/// +/// The two are different lists in the line file and different things to a +/// builder. An object is placed against the track and carries a heading — a +/// car points somewhere. A tree stands on the ground, points nowhere, and +/// carries a size instead: what a photograph says about a tree is how wide its +/// crown is, and that is the number that decides which of the installed trees +/// is planted and how big it is grown. +/// +/// A model says which of the two each of its classes is, so nothing in the +/// pipeline below the registry has to know what a tree is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum Placement { + /// Scenery placed against the track — `ObjectSource` in the line file. + #[default] + Object, + /// A tree in the line's own tree list, grown to the crown that was found — + /// `TreeSource`. Selectable and deletable one by one afterwards like any + /// other tree, whether it was planted by hand, by the forest brush or by + /// this. + Tree, +} + /// What one of the model's classes is worth on a module. /// /// `place` is the hinge of the whole design: it is a **tag**, and the editor @@ -161,6 +206,26 @@ pub struct ClassSpec { /// Confidence this class needs, where it should differ from the head's. #[serde(default, skip_serializing_if = "Option::is_none")] pub confidence: Option, + /// What a find of this class becomes — see [`Placement`]. + #[serde(default)] + pub kind: Placement, + /// Tag used instead of `place` where the crown reads as needle-leaf + /// ([`crate::canopy`]). Empty leaves `place` standing for every find, + /// which is what a model with species classes of its own wants — there the + /// model has already said what the tree is, and a guess from the pixels + /// would only overrule it. + #[serde(default)] + pub conifer: String, + /// Sizes accepted [m] on the long axis, where the factor of two around + /// `size` is the wrong rule. + /// + /// Trees need it and cars do not. Every car is within a factor of two of + /// every other car; a crown is anything from a three-metre thorn at the + /// fence to a twenty-five-metre oak in the station forecourt, and both are + /// right. Stating the range outright is honest about a class where the + /// size is not a check on the detection but the point of it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub span: Option<(f64, f64)>, } impl ClassSpec { @@ -171,6 +236,9 @@ impl ClassSpec { place: String::new(), size: (0.0, 0.0), confidence: None, + kind: Placement::Object, + conifer: String::new(), + span: None, } } @@ -180,6 +248,30 @@ impl ClassSpec { place: place.into(), size, confidence: None, + kind: Placement::Object, + conifer: String::new(), + span: None, + } + } + + /// A class whose finds are planted: the tag for a broadleaf, the tag for a + /// needle-leaf where the crown reads as one, and the crown diameters the + /// class covers [m]. + /// + /// `conifer` may be empty, and is for the single-class crown detectors + /// that most published tree models are: they say *tree* and nothing more, + /// and the split has to come from the pixels or not at all. + pub fn tree(name: &str, place: &str, conifer: &str, span: (f64, f64)) -> Self { + Self { + name: name.into(), + place: place.into(), + // The middle of the range is what an average crown of the class + // is, which is all `size` is ever asked for once `span` is set. + size: ((span.0 + span.1) / 2.0, (span.0 + span.1) / 2.0), + confidence: None, + kind: Placement::Tree, + conifer: conifer.into(), + span: Some(span), } } @@ -190,11 +282,19 @@ impl ClassSpec { /// in adjacent bays bleed into each other — and rejecting on it would /// throw away half a full car park. pub fn plausible(&self, length: f64) -> bool { + if let Some((low, high)) = self.span { + return length >= low && length <= high; + } if self.size.0 <= 0.0 { return true; } length >= self.size.0 * 0.5 && length <= self.size.0 * 2.0 } + + /// Whether finds of this class are planted rather than placed. + pub fn is_tree(&self) -> bool { + self.kind == Placement::Tree + } } /// One model the editor can run. @@ -345,10 +445,17 @@ impl VisionConfig { /// The models the editor knows how to talk to out of the box. /// -/// None of them ships with the game: every one is a file the user exports or -/// downloads, and `note` says from where. What ships is the description — the -/// input size, the head, the class list in the right order — because that is -/// the part that is tedious to get right and silently wrong when it is not. +/// **Two of them ship with the game** — the car detector and the tree crown +/// detector, in `models/` — so that the feature works on a fresh clone with +/// nothing fetched and nothing signed up to. They are converted from published +/// pre-trained detectors by `tools/vision/export_models.py`, and what they +/// cost in licence terms is written down in `models/LICENSES.md`: the car one +/// is AGPL-3.0 and the tree one MIT. +/// +/// The rest are descriptions without weights, for a user bringing their own +/// detector. What ships for those is the part that is tedious to get right and +/// silently wrong when it is not — the input size, the head, the class list in +/// the model's own order. pub fn predefined_models() -> Vec { vec![ // The one the whole feature was built for. DOTA is aerial imagery with @@ -386,8 +493,9 @@ pub fn predefined_models() -> Vec { ], ground_sample: 0.3, overlap: 0.2, - note: "Ultralytics yolov8n-obb, trained on DOTAv1 (AGPL-3.0). \ - Export: yolo export model=yolov8n-obb.pt format=onnx imgsz=1024" + note: "Ships with the game. Ultralytics yolov8n-obb on DOTAv1, \ + AGPL-3.0 — see models/LICENSES.md. Rebuilt by \ + tools/vision/export_models.py --only cars" .into(), }, // The same family without rotation — for anyone who already has a @@ -417,6 +525,130 @@ pub fn predefined_models() -> Vec { three classes; adjust `classes` to the model's own order." .into(), }, + // The tree detector that ships. DeepForest is the crown model of the + // field — a torchvision RetinaNet trained on the NEON airborne survey, + // MIT licensed, and the reason [`Head::Retina`] exists at all. + // + // Only the backbone and the head are in the file: the resize, the + // normalisation and the suppression that the Python package does + // around them are this crate's own work already, and what it cannot do + // is guess anchors. Those are rebuilt from the input size. + ModelSpec { + id: "deepforest".into(), + name: "DeepForest (Baumkronen)".into(), + file: "models/deepforest-tree.onnx".into(), + input: InputSpec { + // A multiple of the coarsest feature stride, so the anchor + // grid follows from the size by division. DeepForest's own + // 800 is not, and its coarsest map is a cell wider than the + // division would say. + width: 768, + height: 768, + layout: Layout::Nchw, + order: ChannelOrder::Rgb, + scale: 1.0 / 255.0, + // ImageNet, which is what the backbone was trained under and + // what DeepForest's own transform subtracts. + mean: [0.485, 0.456, 0.406], + std: [0.229, 0.224, 0.225], + }, + head: Head::Retina { + confidence: 0.3, + // Crowns touch and overlap in a closed wood, so the box that + // survives has to be allowed to sit close to its neighbour. + // DeepForest's own default is 0.05, which is stricter still. + iou: 0.1, + }, + classes: vec![ClassSpec::tree( + "Tree", + "laubbaum", + "nadelbaum", + (2.5, 26.0), + )], + // Five centimetres, which is *not* the resolution of the survey it + // was trained on: DeepForest reads ten-centimetre imagery and + // doubles it before the network sees anything, so five is the + // scale a crown has to arrive at to be the number of pixels across + // the model expects. Where the provider cannot go that fine the + // window is enlarged instead, and the crowns come out softer — + // finer imagery is the single biggest thing that helps this model. + ground_sample: 0.05, + overlap: 0.3, + note: "Ships with the game. DeepForest (Weecology, MIT) trained on \ + the NEON airborne survey; backbone and head only, anchors \ + rebuilt by the editor. Rebuilt by \ + tools/vision/export_models.py --only trees" + .into(), + }, + // Tree crowns. Every published crown detector is single-class — it + // says *tree* and nothing else, because that is what the aerial + // training sets are labelled with. What kind of tree it is comes from + // the crown itself (`canopy`), and how big it is from the box, which + // is the whole of what a photograph can say about a tree. + ModelSpec { + id: "tree-crowns".into(), + name: "YOLO (Baumkronen)".into(), + file: "models/tree-crowns.onnx".into(), + input: InputSpec { + width: 640, + height: 640, + ..Default::default() + }, + head: Head::Boxes { + // Higher than the cars want. A wood is thousands of crowns + // and a false one is a tree in the middle of a meadow, which + // is more conspicuous than a car park with a gap in it. + confidence: 0.35, + iou: 0.4, + }, + classes: vec![ClassSpec::tree( + "tree", + "laubbaum", + "nadelbaum", + (2.5, 26.0), + )], + // Ten centimetres is what the aerial crown sets are labelled at, + // and a crown is a much bigger thing than a car — the resolution + // is spent on telling two touching crowns apart. + ground_sample: 0.1, + // More than the cars: crowns of a closed wood run into each other + // across a window edge far more often than parked cars do. + overlap: 0.3, + note: "Any single-stage crown detector exported from Ultralytics — \ + train one on an aerial crown set (NEON/DeepForest labels, \ + Zenodo urban tree crowns). Export: yolo export model=crowns.pt \ + format=onnx imgsz=640" + .into(), + }, + // The same thing from a model that was taught the difference. Where + // it exists it is the better answer, and the entry is here to say so: + // `conifer` stays empty, and the pixel guess is never consulted. + ModelSpec { + id: "tree-species".into(), + name: "YOLO (Baumarten)".into(), + file: "models/tree-species.onnx".into(), + input: InputSpec { + width: 640, + height: 640, + ..Default::default() + }, + head: Head::Boxes { + confidence: 0.35, + iou: 0.4, + }, + classes: vec![ + ClassSpec::tree("broadleaf", "laubbaum", "", (2.5, 26.0)), + ClassSpec::tree("conifer", "nadelbaum", "", (2.0, 22.0)), + // A hedge or a thicket: low, wide, and never a standard tree. + ClassSpec::tree("shrub", "strauch", "", (1.0, 6.0)), + ], + ground_sample: 0.1, + overlap: 0.3, + note: "A crown detector with species classes of its own; adjust \ + `classes` to the model's order and the tags of the tree mods \ + you have installed." + .into(), + }, ] } @@ -474,6 +706,62 @@ mod tests { assert!(ClassSpec::ignored("x").plausible(100.0)); } + /// The registry is a file the user has been editing since before there + /// were trees in it, and one written then has to keep working: every field + /// the tree classes added defaults to what a car class always meant. + #[test] + fn a_class_written_before_the_trees_still_reads() { + let old: ClassSpec = + ron::from_str(r#"(name: "small vehicle", place: "car", size: (4.4, 1.8))"#).unwrap(); + assert_eq!(old.kind, Placement::Object); + assert!(old.conifer.is_empty()); + assert_eq!(old.span, None); + assert!(!old.is_tree()); + assert!(old.plausible(4.0)); + } + + #[test] + fn a_tree_class_says_it_is_one_and_survives_the_file() { + let config = VisionConfig::default(); + let crowns = config.model_by_id("tree-crowns").unwrap(); + assert_eq!(crowns.classes.len(), 1); + let tree = &crowns.classes[0]; + assert!(tree.is_tree()); + assert_eq!(tree.place, "laubbaum"); + assert_eq!( + tree.conifer, "nadelbaum", + "the split has to come from the pixels" + ); + + // A model with species of its own never consults them. + let species = config.model_by_id("tree-species").unwrap(); + assert_eq!(species.placing().count(), 3); + assert!(species.classes.iter().all(|c| c.conifer.is_empty())); + assert!(species.classes.iter().all(|c| c.is_tree())); + + let text = ron::ser::to_string_pretty(&config, ron::ser::PrettyConfig::default()).unwrap(); + assert_eq!(ron::from_str::(&text).unwrap(), config); + } + + /// The whole reason `span` exists: a crown of three metres and a crown of + /// twenty-four are both trees, and the factor of two around one size would + /// have thrown away whichever end it was not centred on. + #[test] + fn a_stated_span_replaces_the_factor_of_two() { + let tree = ClassSpec::tree("tree", "laubbaum", "nadelbaum", (2.5, 26.0)); + assert!(tree.plausible(2.5) && tree.plausible(26.0)); + assert!(tree.plausible(3.0), "a thorn at the fence"); + assert!(tree.plausible(24.0), "and an oak in the forecourt"); + assert!(!tree.plausible(2.0), "below it, the shadow of a bush"); + assert!( + !tree.plausible(40.0), + "and above it, half a wood the model ran together" + ); + // Without a span the old rule still holds. + let car = ClassSpec::placed("small vehicle", "car", (4.4, 1.8)); + assert!(car.plausible(4.4) && !car.plausible(11.0)); + } + #[test] fn a_class_may_raise_the_confidence_over_the_heads() { let mut spec = VisionConfig::default().models[0].clone(); diff --git a/crates/vision/src/onnx.rs b/crates/vision/src/onnx.rs index c869eb8..0fdfe12 100644 --- a/crates/vision/src/onnx.rs +++ b/crates/vision/src/onnx.rs @@ -8,12 +8,21 @@ //! a corridor through a module is a few dozen windows, and a few dozen //! windows is under a minute. //! -//! What is decoded here is the Ultralytics export layout, because that is -//! what a user can actually obtain: one tensor, `[1, rows, anchors]`, four -//! rows of box, one row per class, and — for an oriented model — a last row -//! of angle. Both the row-major and the transposed export are read, since -//! which one comes out depends on the exporter's version and getting it -//! wrong is silent nonsense rather than an error. +//! Two export layouts are decoded, because between them they are what can +//! actually be obtained. +//! +//! The **Ultralytics** one is a single tensor, `[1, rows, anchors]`: four rows +//! of box, one row per class, and — for an oriented model — a last row of +//! angle. Both the row-major and the transposed export are read, since which +//! one comes out depends on the exporter's version and getting it wrong is +//! silent nonsense rather than an error. +//! +//! The **torchvision RetinaNet** one ([`Head::Retina`]) is two tensors and no +//! boxes: logits per anchor, offsets per anchor, and the anchors themselves +//! nowhere in the file. They are a fixed grid that follows from the input +//! size, and [`anchors`] rebuilds it — the only place in this crate where a +//! detail of somebody else's library is reproduced rather than read, and so +//! the one place with a test that checks the numbers against theirs. //! //! [tract]: https://github.com/sonos/tract @@ -80,6 +89,16 @@ impl Detector for OnnxDetector { .plan .run(tvec!(tensor.into())) .map_err(|e| e.to_string())?; + if let Head::Retina { .. } = self.head { + let (logits, offsets) = retina_outputs(&outputs, self.classes)?; + return decode_retina( + logits, + offsets, + (self.input.width, self.input.height), + self.classes, + self.floor, + ); + } let first = outputs.first().ok_or("the model returned nothing")?; let view = first .to_plain_array_view::() @@ -127,7 +146,193 @@ fn tensor_from(pixels: &[u8], input: &InputSpec) -> Result { Tensor::from_shape(&shape, &data).map_err(|e| e.to_string()) } -/// Turns the output tensor into boxes. +// --------------------------------------------------------------------------- +// The anchored dense head (torchvision RetinaNet) +// --------------------------------------------------------------------------- + +/// Feature strides of a RetinaNet on a ResNet-FPN backbone, finest first. +const RETINA_STRIDES: [u32; 5] = [8, 16, 32, 64, 128]; +/// The anchor base size at each of those levels. +const RETINA_BASES: [f32; 5] = [32.0, 64.0, 128.0, 256.0, 512.0]; +/// Aspect ratios — height over width — repeated at every level. +const RETINA_ASPECTS: [f32; 3] = [0.5, 1.0, 2.0]; +/// `ln(1000 / 16)`: torchvision clamps the size offsets to this before the +/// exponential, so that one absurd logit cannot produce a box the size of a +/// county. Reproduced here because a box that differs from theirs is a box in +/// the wrong place. +const RETINA_CLIP: f32 = 4.135_166_5; + +/// Which of the two output tensors is which. +/// +/// The exporter names them, tract hands them back in graph order, and nothing +/// guarantees the two agree. The offsets are the tensor four wide, which +/// settles it for every class count but four — and where a model really did +/// have four classes, the declared order (logits first) decides, which is what +/// every exporter of this family emits. +fn retina_outputs<'a>( + outputs: &'a [TValue], + classes: usize, +) -> Result<(&'a [f32], &'a [f32]), String> { + let (first, second) = match (outputs.first(), outputs.get(1)) { + (Some(first), Some(second)) => (first, second), + _ => return Err("an anchored head returns two tensors, this model returned one".into()), + }; + let width = |t: &TValue| t.shape().last().copied().unwrap_or(0); + let (logits, offsets) = if classes != 4 && width(first) == 4 { + (second, first) + } else { + (first, second) + }; + let plain = |t: &'a TValue| -> Result<&'a [f32], String> { + t.to_plain_array_view::() + .map_err(|e| e.to_string())? + .to_slice() + .ok_or_else(|| "the model returned a tensor that is not laid out plainly".to_string()) + }; + Ok((plain(logits)?, plain(offsets)?)) +} + +/// Half to even, the rounding Python and PyTorch do and Rust does not. +/// +/// It matters in exactly one place and it matters there absolutely: the middle +/// anchor of the second level is 101 wide, its half is 50.5, and torchvision +/// rounds that to 50 where `f32::round` gives 51. Every box decoded from that +/// anchor would be a pixel out, which is a metre and a half of ground. +fn round_half_even(v: f32) -> f32 { + let rounded = v.round(); + if (v - v.trunc()).abs() == 0.5 && rounded % 2.0 != 0.0 { + rounded - v.signum() + } else { + rounded + } +} + +/// The nine base anchors of one level, in corner form about the origin. +fn base_anchors(base: f32) -> [[f32; 4]; 9] { + // The base size and it times the cube roots of two, each cut to a whole + // number before anything else happens — `int(x * 2 ** (1/3))`. + let sizes = [ + base, + (base * 2f32.powf(1.0 / 3.0)).trunc(), + (base * 2f32.powf(2.0 / 3.0)).trunc(), + ]; + let mut out = [[0.0f32; 4]; 9]; + for (a, aspect) in RETINA_ASPECTS.iter().enumerate() { + let high = aspect.sqrt(); + let wide = 1.0 / high; + for (s, size) in sizes.iter().enumerate() { + let half_w = round_half_even(wide * size / 2.0); + let half_h = round_half_even(high * size / 2.0); + // Aspect-major, then size: the order torchvision flattens them in, + // and the order the head's own rows are in. + out[a * 3 + s] = [-half_w, -half_h, half_w, half_h]; + } + } + out +} + +/// The anchor grid the offsets are measured from, in corner form. +/// +/// Reproduced from `torchvision.models.detection.anchor_utils.AnchorGenerator` +/// with the RetinaNet defaults — the ones the shipped tree model was trained +/// with. `expected` is what the model's own output says there should be, and +/// disagreeing with it is an error rather than a guess: a grid that is one +/// level short still decodes, into boxes that are quietly nonsense. +/// +/// The input has to be a multiple of the coarsest stride. Otherwise the +/// network's feature maps are rounded *up* where this rounds down, the counts +/// disagree, and the error says so. +fn anchors(width: u32, height: u32, expected: usize) -> Result, String> { + let coarsest = RETINA_STRIDES[RETINA_STRIDES.len() - 1]; + if !width.is_multiple_of(coarsest) || !height.is_multiple_of(coarsest) { + return Err(format!( + "an anchored head needs an input that is a multiple of {coarsest}, not {width}x{height}" + )); + } + let mut out = Vec::with_capacity(expected); + for (level, stride) in RETINA_STRIDES.iter().enumerate() { + let base = base_anchors(RETINA_BASES[level]); + for y in 0..(height / stride) { + for x in 0..(width / stride) { + let (sx, sy) = ((x * stride) as f32, (y * stride) as f32); + for anchor in base { + out.push([ + anchor[0] + sx, + anchor[1] + sy, + anchor[2] + sx, + anchor[3] + sy, + ]); + } + } + } + } + if out.len() != expected { + return Err(format!( + "the model has {expected} anchors, an anchored head on {width}x{height} has {} — \ + check `input` in ai.ron against the model's own", + out.len() + )); + } + Ok(out) +} + +/// Logits and offsets into boxes in the input's own pixels. +fn decode_retina( + logits: &[f32], + offsets: &[f32], + input: (u32, u32), + classes: usize, + floor: f32, +) -> Result, String> { + let classes = classes.max(1); + let count = logits.len() / classes; + if offsets.len() != count * 4 { + return Err(format!( + "{count} anchors of logits against {} of offsets", + offsets.len() / 4 + )); + } + let grid = anchors(input.0, input.1, count)?; + // The threshold, moved to the other side of the sigmoid: comparing logits + // is one subtraction where the sigmoid is an exponential, and all but a + // handful of a hundred thousand anchors are rejected here. + let cut = if floor <= 0.0 { + f32::NEG_INFINITY + } else if floor >= 1.0 { + f32::INFINITY + } else { + (floor / (1.0 - floor)).ln() + }; + + let mut found = Vec::new(); + for (index, anchor) in grid.iter().enumerate() { + let mut best = (0usize, f32::NEG_INFINITY); + for class in 0..classes { + let logit = logits[index * classes + class]; + if logit > best.1 { + best = (class, logit); + } + } + if best.1 < cut { + continue; + } + let (width, height) = (anchor[2] - anchor[0], anchor[3] - anchor[1]); + let (cx, cy) = (anchor[0] + width / 2.0, anchor[1] + height / 2.0); + let offset = &offsets[index * 4..index * 4 + 4]; + found.push(Detection { + class: best.0, + score: 1.0 / (1.0 + (-best.1).exp()), + cx: offset[0] * width + cx, + cy: offset[1] * height + cy, + w: offset[2].min(RETINA_CLIP).exp() * width, + h: offset[3].min(RETINA_CLIP).exp() * height, + angle: 0.0, + }); + } + Ok(found) +} + +/// Turns the output tensor into boxes — the Ultralytics layouts. /// /// Split out from the runtime so it can be tested without a model file — the /// decoding is where the mistakes are, and a mistake here places every car in @@ -177,7 +382,9 @@ fn decode( } let angle = match head { Head::Oriented { .. } => at(head.box_rows() + classes, anchor), - Head::Boxes { .. } => 0.0, + // An anchored head never reaches here — it is decoded against its + // own grid — and it has no angle in any case. + Head::Boxes { .. } | Head::Retina { .. } => 0.0, }; found.push(Detection { class: best.0, @@ -194,6 +401,120 @@ fn decode( #[cfg(test)] mod tests { + /// The anchor grid against torchvision's own, at the size the shipped + /// tree model runs at. + /// + /// Every number here was printed by + /// `torchvision.models.detection.anchor_utils.AnchorGenerator` on the + /// model that ships (see `tools/vision/export_models.py`). It is the one + /// place in this crate that reproduces somebody else's arithmetic instead + /// of reading their output, and an anchor a pixel out is a crown a metre + /// and a half from where it stands — silently, with no error anywhere. + #[test] + fn the_anchor_grid_is_torchvisions() { + let grid = anchors(768, 768, 110_484).expect("the grid the model was exported for"); + assert_eq!(grid.len(), 110_484); + assert_eq!( + &grid[..5], + &[ + [-23.0, -11.0, 23.0, 11.0], + [-28.0, -14.0, 28.0, 14.0], + [-35.0, -18.0, 35.0, 18.0], + [-16.0, -16.0, 16.0, 16.0], + [-20.0, -20.0, 20.0, 20.0], + ] + ); + // The second level begins after the first level's 96 × 96 cells, and + // its middle anchor is the one the rounding turns on: 101 wide, half + // of it 50.5, and torchvision makes that 50. + let level1 = 96 * 96 * 9; + assert_eq!( + &grid[level1..level1 + 9], + &[ + [-45.0, -23.0, 45.0, 23.0], + [-57.0, -28.0, 57.0, 28.0], + [-71.0, -36.0, 71.0, 36.0], + [-32.0, -32.0, 32.0, 32.0], + [-40.0, -40.0, 40.0, 40.0], + [-50.0, -50.0, 50.0, 50.0], + [-23.0, -45.0, 23.0, 45.0], + [-28.0, -57.0, 28.0, 57.0], + [-36.0, -71.0, 36.0, 71.0], + ] + ); + // One from the middle of the second level, and the last five — which + // are only right if every stride, every grid and the order of the + // levels are. + assert_eq!(grid[50_000], [639.0, 431.0, 689.0, 481.0]); + assert_eq!( + &grid[grid.len() - 5..], + &[ + [318.0, 318.0, 962.0, 962.0], + [234.0, 234.0, 1046.0, 1046.0], + [459.0, 278.0, 821.0, 1002.0], + [412.0, 184.0, 868.0, 1096.0], + [353.0, 66.0, 927.0, 1214.0], + ] + ); + // And a fingerprint over all of them, to the precision a float sum of + // this size has. + let sum: f64 = grid.iter().flatten().map(|v| *v as f64).sum(); + assert!( + (sum - 167_132_160.0).abs() < 200.0, + "{sum} against torchvision's 167132160" + ); + } + + #[test] + fn an_input_the_grid_cannot_be_built_for_is_refused() { + // Not a multiple of the coarsest stride: the network's own feature + // maps round up where this rounds down. + let err = anchors(800, 800, 120_087).unwrap_err(); + assert!(err.contains("multiple of 128"), "{err}"); + // The right size, the wrong count — a model with more levels, or an + // `input` in `ai.ron` that is not the one it was exported for. + let err = anchors(768, 768, 99).unwrap_err(); + assert!(err.contains("99 anchors"), "{err}"); + } + + /// Half to even, where Rust rounds half away from zero. + #[test] + fn the_rounding_is_pythons() { + assert_eq!(round_half_even(50.5), 50.0); + assert_eq!(round_half_even(51.5), 52.0); + assert_eq!(round_half_even(-50.5), -50.0); + assert_eq!(round_half_even(22.6), 23.0); + assert_eq!(round_half_even(11.3), 11.0); + } + + /// An offset of nothing leaves the box on its anchor; the score comes + /// through a sigmoid; and everything under the floor is gone before a box + /// is built at all. + #[test] + fn an_anchored_box_is_its_anchor_plus_the_offset() { + // One class, and the first anchor of a 128 × 128 input: 46 by 22 at + // the origin. + let cells = 16 * 16 + 8 * 8 + 4 * 4 + 2 * 2 + 1; + let grid = anchors(128, 128, cells * 9).unwrap(); + let anchors_n = grid.len(); + let mut logits = vec![-20.0f32; anchors_n]; + let mut offsets = vec![0.0f32; anchors_n * 4]; + logits[0] = 0.0; // sigmoid 0.5 + let found = decode_retina(&logits, &offsets, (128, 128), 1, 0.25).unwrap(); + assert_eq!(found.len(), 1, "everything else is below the floor"); + let box0 = found[0]; + assert!((box0.score - 0.5).abs() < 1e-6); + assert!((box0.cx - 0.0).abs() < 1e-6 && (box0.cy - 0.0).abs() < 1e-6); + assert!((box0.w - 46.0).abs() < 1e-4 && (box0.h - 22.0).abs() < 1e-4); + + // And an offset moves it by a share of the anchor's own size. + offsets[0] = 0.5; // half an anchor width east + offsets[2] = std::f32::consts::LN_2; // twice as wide + let found = decode_retina(&logits, &offsets, (128, 128), 1, 0.25).unwrap(); + assert!((found[0].cx - 23.0).abs() < 1e-4, "{}", found[0].cx); + assert!((found[0].w - 92.0).abs() < 1e-3, "{}", found[0].w); + } + use super::*; /// Three anchors of an oriented head with two classes: rows are diff --git a/crates/vision/src/parking.rs b/crates/vision/src/parking.rs index ac9a8ad..62b4246 100644 --- a/crates/vision/src/parking.rs +++ b/crates/vision/src/parking.rs @@ -210,6 +210,7 @@ mod tests { cars.push(GeoDetection { class: 0, place: "car".into(), + kind: crate::Placement::Object, score: 0.9, // Bays 2.6 m apart along a row, rows 6 m apart. lat: 51.0 + row as f64 * 6.0 * METRE_LAT, diff --git a/crates/vision/tests/shipped.rs b/crates/vision/tests/shipped.rs new file mode 100644 index 0000000..cfd23d8 --- /dev/null +++ b/crates/vision/tests/shipped.rs @@ -0,0 +1,136 @@ +//! The weights that ship, against the descriptions that ship with them. +//! +//! `ai.ron`'s entries say how big the input is, what head the model has and how +//! many classes it has — and every one of those is a number that can disagree +//! with the file without anything complaining. A wrong input size decodes into +//! boxes in the wrong place; a wrong class count reads the score of one class +//! as the box of another. Both are silent. This runs the two shipped models +//! once each and checks that what comes out is the shape the registry claims. +//! +//! It is skipped where the weights are not there. That is not laziness: they +//! are Git LFS objects, a clone without `git lfs pull` has the pointer files +//! instead, and a test that failed then would be reporting on the clone rather +//! than on the code. What it must never do is pass quietly on a pointer file, +//! so the size is checked before anything else. + +#![cfg(feature = "onnx")] + +use std::path::{Path, PathBuf}; +use vision::model::{Head, VisionConfig}; + +/// Where `models/` is, from this crate. +fn models() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// The weights, or `None` with a word about why — a missing file and a Git LFS +/// pointer left unfetched are different things and deserve different words. +fn weights(path: &Path) -> Option { + let Ok(size) = std::fs::metadata(path).map(|m| m.len()) else { + eprintln!("skipped: {} is not there", path.display()); + return None; + }; + // An LFS pointer is a few hundred bytes of text; the smallest real model + // here is twelve megabytes. + if size < 1_000_000 { + eprintln!( + "skipped: {} is {size} bytes — a Git LFS pointer, not the weights. \ + Run `git lfs pull`.", + path.display() + ); + return None; + } + Some(path.to_path_buf()) +} + +/// A flat grey window of the size the model asks for. +fn window(width: u32, height: u32) -> Vec { + vec![128u8; (width * height * 3) as usize] +} + +/// The car detector: one tensor, four rows of box, fifteen classes and an +/// angle. If the export ever came out with a different class list, the +/// registry's fifteen would silently read the wrong rows. +#[test] +fn the_shipped_car_detector_runs_and_fits_its_entry() { + let config = VisionConfig::default(); + let spec = config.model_by_id("dota-obb").expect("the registry has it"); + let Some(path) = weights(&spec.path(&models())) else { + return; + }; + assert!(matches!(spec.head, Head::Oriented { .. })); + assert_eq!(spec.classes.len(), 15); + let mut detector = vision::load_detector(spec, &path).expect("loads"); + let found = detector + .detect( + &window(spec.input.width, spec.input.height), + spec.input.width, + spec.input.height, + ) + .expect("a grey window decodes"); + // Grey is not a car park, so what matters is that it decoded at all: a + // mismatched class count is an error out of `decode`, not an empty list. + assert!( + found.iter().all(|d| d.class < 15), + "a class outside the registry's list" + ); +} + +/// The tree detector: two tensors and an anchor grid that has to come out at +/// exactly the length the file's own output says. This is the test that would +/// fail if the model were ever re-exported at another input size and the +/// registry not moved with it. +#[test] +fn the_shipped_tree_detector_runs_and_fits_its_grid() { + let config = VisionConfig::default(); + let spec = config + .model_by_id("deepforest") + .expect("the registry has it"); + let Some(path) = weights(&spec.path(&models())) else { + return; + }; + assert!(matches!(spec.head, Head::Retina { .. })); + assert_eq!(spec.classes.len(), 1); + assert_eq!( + (spec.input.width, spec.input.height), + (768, 768), + "the grid is rebuilt from this, and the file was exported for it" + ); + let mut detector = vision::load_detector(spec, &path).expect("loads"); + let found = detector + .detect( + &window(spec.input.width, spec.input.height), + spec.input.width, + spec.input.height, + ) + .expect("the anchor grid matches the model's own output"); + // A flat grey field has no crowns in it. Anything found here at the + // registry's own threshold would mean the decoding is producing boxes out + // of noise, which is worse than finding nothing. + assert!( + found.is_empty(), + "{} crowns in a flat grey window", + found.len() + ); +} + +/// Both entries name a file under `models/`, which is the directory that +/// ships. A path that pointed somewhere else would work on the machine it was +/// written on and nowhere else. +#[test] +fn the_shipped_entries_point_into_the_shipped_directory() { + let config = VisionConfig::default(); + for id in ["dota-obb", "deepforest"] { + let spec = config.model_by_id(id).expect(id); + assert_eq!( + spec.file.parent().and_then(|p| p.to_str()), + Some("models"), + "{id} points at {}", + spec.file.display() + ); + assert!( + !spec.note.is_empty(), + "{id} says nothing about where it is from" + ); + } +} diff --git a/models/LICENSES.md b/models/LICENSES.md new file mode 100644 index 0000000..1e355a7 --- /dev/null +++ b/models/LICENSES.md @@ -0,0 +1,86 @@ +# Detector licences + +The two `.onnx` files in this directory are the weights the route editor's +imagery detection runs (README, *Detecting from the aerial imagery*). Neither +was trained here: both are published, pre-trained detectors, converted to ONNX +by [`tools/vision/export_models.py`](../tools/vision/export_models.py) and +otherwise unmodified. Each keeps the licence of the work it came from, and the +two are **not the same licence** — read both before redistributing the game. + +## `deepforest-tree.onnx` — tree crowns — MIT + +The crown detector is **DeepForest** by the Weecology lab (Ben Weinstein, Sergio +Marconi, Ethan White et al., University of Florida), published under the +[MIT licence](https://github.com/weecology/DeepForest/blob/main/LICENSE). The +weights are the `weecology/deepforest-tree` release on Hugging Face +(), a torchvision RetinaNet +with a ResNet-50 FPN backbone trained on the airborne survey of the +[NSF NEON](https://www.neonscience.org/) observatory network, whose data are +published for free use with attribution. + +Please cite the work if you build on it: + +> Weinstein, B.G., Marconi, S., Bohlman, S., Zare, A., White, E. (2019). +> Individual tree-crown detection in RGB imagery using semi-supervised deep +> learning neural networks. *Remote Sensing*, 11(11), 1309. + +MIT permits use, modification and commercial distribution as long as the +copyright notice and the licence text travel with it: + +``` +MIT License + +Copyright (c) 2019 Weecology + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +What is in the file is the backbone and the detection head at a fixed input of +768 × 768. The resize, the normalisation, the anchor decoding and the +non-maximum suppression that the Python package performs around them are the +editor's own (`crates/vision/src/onnx.rs`), which is why the file holds no +boxes — only logits and offsets against an anchor grid that is rebuilt from the +input size. + +## `yolov8n-obb.onnx` — parked cars and lorries — AGPL-3.0 + +The vehicle detector is **Ultralytics YOLOv8n-OBB**, trained by Ultralytics on +**DOTA v1.0**, and it is published under the +[GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.html). +That is a copyleft licence with a network clause, and it is the reason this +file is called out here rather than merely listed: + +* **Shipping it makes the AGPL apply to it.** Anyone who redistributes this + repository, or a game built from it, redistributes an AGPL-3.0 work and takes + on the obligations that go with it. The EUPL v1.2 that covers the rest of the + repository names AGPL-3.0 among its compatible licences (EUPL Appendix), so + the combination is provided for — but the result is that the combined work + travels under the AGPL, not that the AGPL file quietly becomes EUPL. +* **The training data has terms of its own.** DOTA is released for academic + research. Weights trained on it inherit that context, whatever the licence on + the code that produced them. + +If neither is acceptable for how you intend to distribute this — a closed +release, or a commercial one — then **delete `yolov8n-obb.onnx`**. Nothing +breaks: the editor lists the model, says the weights are not installed, and the +tree detector, the imports and everything else carry on. Put a detector of your +own in its place by naming it in `ai.ron`; the registry is a file, not code +(README, *Models*). + +The tree detector is unaffected by any of this. It is MIT. diff --git a/models/deepforest-tree.onnx b/models/deepforest-tree.onnx new file mode 100644 index 0000000..16db62b --- /dev/null +++ b/models/deepforest-tree.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da97f8a38dc8f834a4b5ba607c1b17e9b2548cdbf84b63cfe56f88fe09bdcf9e +size 128909246 diff --git a/models/yolov8n-obb.onnx b/models/yolov8n-obb.onnx new file mode 100644 index 0000000..b2229d3 --- /dev/null +++ b/models/yolov8n-obb.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20fe344e9f6e39b18d26e9f69b2756dae16e6390319f6988622aaa5db5340f15 +size 12664893 diff --git a/mods/trees/objects/bergahorn_a.ron b/mods/trees/objects/bergahorn_a.ron index 00fa80b..29614ea 100644 --- a/mods/trees/objects/bergahorn_a.ron +++ b/mods/trees/objects/bergahorn_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/bergahorn_a_herbst.gltf"), winter_model: Some("trees/assets/bergahorn_a_winter.gltf"), lod_distances: [70, 400, 800, 2500], + footprint: Some((length: 15.3, width: 14.8)), tags: ["laubbaum", "stand-laubwald", "stand-mischwald", "stand-allee", "stand-bergwald"], ) diff --git a/mods/trees/objects/bergahorn_b.ron b/mods/trees/objects/bergahorn_b.ron index 87adef2..815a016 100644 --- a/mods/trees/objects/bergahorn_b.ron +++ b/mods/trees/objects/bergahorn_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/bergahorn_b_herbst.gltf"), winter_model: Some("trees/assets/bergahorn_b_winter.gltf"), lod_distances: [88, 400, 800, 2500], + footprint: Some((length: 18.7, width: 18)), tags: ["laubbaum", "stand-laubwald", "stand-mischwald", "stand-allee", "stand-bergwald"], ) diff --git a/mods/trees/objects/bergahorn_c.ron b/mods/trees/objects/bergahorn_c.ron index e56b366..48af556 100644 --- a/mods/trees/objects/bergahorn_c.ron +++ b/mods/trees/objects/bergahorn_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/bergahorn_c_herbst.gltf"), winter_model: Some("trees/assets/bergahorn_c_winter.gltf"), lod_distances: [105, 450, 900, 2500], + footprint: Some((length: 22.8, width: 22.1)), tags: ["laubbaum", "stand-laubwald", "stand-mischwald", "stand-allee", "stand-bergwald"], ) diff --git a/mods/trees/objects/bergulme_a.ron b/mods/trees/objects/bergulme_a.ron index 4d8ccf6..42a3e0c 100644 --- a/mods/trees/objects/bergulme_a.ron +++ b/mods/trees/objects/bergulme_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/bergulme_a_herbst.gltf"), winter_model: Some("trees/assets/bergulme_a_winter.gltf"), lod_distances: [63, 400, 800, 2200], + footprint: Some((length: 12.7, width: 11.7)), tags: ["laubbaum", "stand-laubwald", "stand-auwald"], ) diff --git a/mods/trees/objects/bergulme_b.ron b/mods/trees/objects/bergulme_b.ron index 95ee31b..27e4251 100644 --- a/mods/trees/objects/bergulme_b.ron +++ b/mods/trees/objects/bergulme_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/bergulme_b_herbst.gltf"), winter_model: Some("trees/assets/bergulme_b_winter.gltf"), lod_distances: [80, 400, 800, 2200], + footprint: Some((length: 16.2, width: 15.3)), tags: ["laubbaum", "stand-laubwald", "stand-auwald"], ) diff --git a/mods/trees/objects/bergulme_c.ron b/mods/trees/objects/bergulme_c.ron index e7caae7..08f93eb 100644 --- a/mods/trees/objects/bergulme_c.ron +++ b/mods/trees/objects/bergulme_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/bergulme_c_herbst.gltf"), winter_model: Some("trees/assets/bergulme_c_winter.gltf"), lod_distances: [98, 420, 840, 2200], + footprint: Some((length: 19.8, width: 19)), tags: ["laubbaum", "stand-laubwald", "stand-auwald"], ) diff --git a/mods/trees/objects/douglasie_a.ron b/mods/trees/objects/douglasie_a.ron index 06d8805..bfc56e3 100644 --- a/mods/trees/objects/douglasie_a.ron +++ b/mods/trees/objects/douglasie_a.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/douglasie_a_winter.gltf"), lod_distances: [105, 450, 900, 2500], + footprint: Some((length: 12, width: 11.4)), tags: ["nadelbaum", "stand-nadelwald", "stand-mischwald"], ) diff --git a/mods/trees/objects/douglasie_b.ron b/mods/trees/objects/douglasie_b.ron index 91cc94d..f7fe539 100644 --- a/mods/trees/objects/douglasie_b.ron +++ b/mods/trees/objects/douglasie_b.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/douglasie_b_winter.gltf"), lod_distances: [131, 562, 1125, 2500], + footprint: Some((length: 15, width: 14.7)), tags: ["nadelbaum", "stand-nadelwald", "stand-mischwald"], ) diff --git a/mods/trees/objects/douglasie_c.ron b/mods/trees/objects/douglasie_c.ron index 08cea84..f2770e7 100644 --- a/mods/trees/objects/douglasie_c.ron +++ b/mods/trees/objects/douglasie_c.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/douglasie_c_winter.gltf"), lod_distances: [158, 675, 1350, 2500], + footprint: Some((length: 18, width: 16.7)), tags: ["nadelbaum", "stand-nadelwald", "stand-mischwald"], ) diff --git a/mods/trees/objects/eberesche_a.ron b/mods/trees/objects/eberesche_a.ron index e7389e6..8009d6a 100644 --- a/mods/trees/objects/eberesche_a.ron +++ b/mods/trees/objects/eberesche_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/eberesche_a_herbst.gltf"), winter_model: Some("trees/assets/eberesche_a_winter.gltf"), lod_distances: [45, 400, 800, 1600], + footprint: Some((length: 4.7, width: 4.3)), tags: ["laubbaum", "stand-boeschung", "stand-bahndamm", "stand-mischwald", "stand-pionier"], ) diff --git a/mods/trees/objects/eberesche_b.ron b/mods/trees/objects/eberesche_b.ron index af16509..553267a 100644 --- a/mods/trees/objects/eberesche_b.ron +++ b/mods/trees/objects/eberesche_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/eberesche_b_herbst.gltf"), winter_model: Some("trees/assets/eberesche_b_winter.gltf"), lod_distances: [45, 400, 800, 1600], + footprint: Some((length: 6.9, width: 6.3)), tags: ["laubbaum", "stand-boeschung", "stand-bahndamm", "stand-mischwald", "stand-pionier"], ) diff --git a/mods/trees/objects/eberesche_c.ron b/mods/trees/objects/eberesche_c.ron index 69d3cb8..ed88c8b 100644 --- a/mods/trees/objects/eberesche_c.ron +++ b/mods/trees/objects/eberesche_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/eberesche_c_herbst.gltf"), winter_model: Some("trees/assets/eberesche_c_winter.gltf"), lod_distances: [49, 400, 800, 1600], + footprint: Some((length: 9.4, width: 8.6)), tags: ["laubbaum", "stand-boeschung", "stand-bahndamm", "stand-mischwald", "stand-pionier"], ) diff --git a/mods/trees/objects/esche_a.ron b/mods/trees/objects/esche_a.ron index 87b855f..a2af757 100644 --- a/mods/trees/objects/esche_a.ron +++ b/mods/trees/objects/esche_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/esche_a_herbst.gltf"), winter_model: Some("trees/assets/esche_a_winter.gltf"), lod_distances: [77, 400, 800, 2500], + footprint: Some((length: 14.2, width: 14.1)), tags: ["laubbaum", "stand-laubwald", "stand-auwald", "stand-mischwald"], ) diff --git a/mods/trees/objects/esche_b.ron b/mods/trees/objects/esche_b.ron index a8f3ce4..67f0845 100644 --- a/mods/trees/objects/esche_b.ron +++ b/mods/trees/objects/esche_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/esche_b_herbst.gltf"), winter_model: Some("trees/assets/esche_b_winter.gltf"), lod_distances: [98, 420, 840, 2500], + footprint: Some((length: 18.1, width: 16.7)), tags: ["laubbaum", "stand-laubwald", "stand-auwald", "stand-mischwald"], ) diff --git a/mods/trees/objects/esche_c.ron b/mods/trees/objects/esche_c.ron index f251182..4d8c78a 100644 --- a/mods/trees/objects/esche_c.ron +++ b/mods/trees/objects/esche_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/esche_c_herbst.gltf"), winter_model: Some("trees/assets/esche_c_winter.gltf"), lod_distances: [119, 510, 1020, 2500], + footprint: Some((length: 21.5, width: 19.6)), tags: ["laubbaum", "stand-laubwald", "stand-auwald", "stand-mischwald"], ) diff --git a/mods/trees/objects/fichte_a.ron b/mods/trees/objects/fichte_a.ron index b3ed1cf..79c084b 100644 --- a/mods/trees/objects/fichte_a.ron +++ b/mods/trees/objects/fichte_a.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/fichte_a_winter.gltf"), lod_distances: [91, 400, 800, 2500], + footprint: Some((length: 10.9, width: 10.7)), tags: ["nadelbaum", "stand-nadelwald", "stand-mischwald", "stand-bergwald"], ) diff --git a/mods/trees/objects/fichte_b.ron b/mods/trees/objects/fichte_b.ron index 3146ae5..ae2df56 100644 --- a/mods/trees/objects/fichte_b.ron +++ b/mods/trees/objects/fichte_b.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/fichte_b_winter.gltf"), lod_distances: [112, 480, 960, 2500], + footprint: Some((length: 13.4, width: 12.4)), tags: ["nadelbaum", "stand-nadelwald", "stand-mischwald", "stand-bergwald"], ) diff --git a/mods/trees/objects/fichte_c.ron b/mods/trees/objects/fichte_c.ron index 1c4ff4b..99b8ba3 100644 --- a/mods/trees/objects/fichte_c.ron +++ b/mods/trees/objects/fichte_c.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/fichte_c_winter.gltf"), lod_distances: [133, 570, 1140, 2500], + footprint: Some((length: 16, width: 15.1)), tags: ["nadelbaum", "stand-nadelwald", "stand-mischwald", "stand-bergwald"], ) diff --git a/mods/trees/objects/hainbuche_a.ron b/mods/trees/objects/hainbuche_a.ron index 053b467..ab1cc32 100644 --- a/mods/trees/objects/hainbuche_a.ron +++ b/mods/trees/objects/hainbuche_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/hainbuche_a_herbst.gltf"), winter_model: Some("trees/assets/hainbuche_a_winter.gltf"), lod_distances: [49, 400, 800, 2000], + footprint: Some((length: 9.6, width: 9.1)), tags: ["laubbaum", "stand-laubwald", "stand-mischwald", "stand-hecke"], ) diff --git a/mods/trees/objects/hainbuche_b.ron b/mods/trees/objects/hainbuche_b.ron index 8bf2b17..f41fa76 100644 --- a/mods/trees/objects/hainbuche_b.ron +++ b/mods/trees/objects/hainbuche_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/hainbuche_b_herbst.gltf"), winter_model: Some("trees/assets/hainbuche_b_winter.gltf"), lod_distances: [63, 400, 800, 2000], + footprint: Some((length: 12.3, width: 11.3)), tags: ["laubbaum", "stand-laubwald", "stand-mischwald", "stand-hecke"], ) diff --git a/mods/trees/objects/hainbuche_c.ron b/mods/trees/objects/hainbuche_c.ron index 21c12b4..5360c08 100644 --- a/mods/trees/objects/hainbuche_c.ron +++ b/mods/trees/objects/hainbuche_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/hainbuche_c_herbst.gltf"), winter_model: Some("trees/assets/hainbuche_c_winter.gltf"), lod_distances: [77, 400, 800, 2000], + footprint: Some((length: 15.1, width: 14.2)), tags: ["laubbaum", "stand-laubwald", "stand-mischwald", "stand-hecke"], ) diff --git a/mods/trees/objects/hasel_a.ron b/mods/trees/objects/hasel_a.ron index 5b928fb..63f731c 100644 --- a/mods/trees/objects/hasel_a.ron +++ b/mods/trees/objects/hasel_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/hasel_a_herbst.gltf"), winter_model: Some("trees/assets/hasel_a_winter.gltf"), lod_distances: [45, 400, 800, 900], + footprint: Some((length: 3.2, width: 3.2)), tags: ["strauch", "stand-hecke", "stand-laubwald", "stand-boeschung"], ) diff --git a/mods/trees/objects/hasel_b.ron b/mods/trees/objects/hasel_b.ron index cfeb215..689421b 100644 --- a/mods/trees/objects/hasel_b.ron +++ b/mods/trees/objects/hasel_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/hasel_b_herbst.gltf"), winter_model: Some("trees/assets/hasel_b_winter.gltf"), lod_distances: [45, 400, 800, 900], + footprint: Some((length: 4.8, width: 4.5)), tags: ["strauch", "stand-hecke", "stand-laubwald", "stand-boeschung"], ) diff --git a/mods/trees/objects/hasel_c.ron b/mods/trees/objects/hasel_c.ron index 69e794b..e1ca12c 100644 --- a/mods/trees/objects/hasel_c.ron +++ b/mods/trees/objects/hasel_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/hasel_c_herbst.gltf"), winter_model: Some("trees/assets/hasel_c_winter.gltf"), lod_distances: [45, 400, 800, 900], + footprint: Some((length: 6.4, width: 5.8)), tags: ["strauch", "stand-hecke", "stand-laubwald", "stand-boeschung"], ) diff --git a/mods/trees/objects/holunder_a.ron b/mods/trees/objects/holunder_a.ron index 9f2d04c..9b2fb55 100644 --- a/mods/trees/objects/holunder_a.ron +++ b/mods/trees/objects/holunder_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/holunder_a_herbst.gltf"), winter_model: Some("trees/assets/holunder_a_winter.gltf"), lod_distances: [45, 400, 800, 900], + footprint: Some((length: 3.5, width: 3.3)), tags: ["strauch", "stand-hecke", "stand-bahndamm", "stand-boeschung", "stand-stadt"], ) diff --git a/mods/trees/objects/holunder_b.ron b/mods/trees/objects/holunder_b.ron index b1599e8..9bcfb6e 100644 --- a/mods/trees/objects/holunder_b.ron +++ b/mods/trees/objects/holunder_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/holunder_b_herbst.gltf"), winter_model: Some("trees/assets/holunder_b_winter.gltf"), lod_distances: [45, 400, 800, 900], + footprint: Some((length: 5.3, width: 5)), tags: ["strauch", "stand-hecke", "stand-bahndamm", "stand-boeschung", "stand-stadt"], ) diff --git a/mods/trees/objects/holunder_c.ron b/mods/trees/objects/holunder_c.ron index 87e1e0e..6892a77 100644 --- a/mods/trees/objects/holunder_c.ron +++ b/mods/trees/objects/holunder_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/holunder_c_herbst.gltf"), winter_model: Some("trees/assets/holunder_c_winter.gltf"), lod_distances: [45, 400, 800, 900], + footprint: Some((length: 6.9, width: 6.9)), tags: ["strauch", "stand-hecke", "stand-bahndamm", "stand-boeschung", "stand-stadt"], ) diff --git a/mods/trees/objects/kiefer_a.ron b/mods/trees/objects/kiefer_a.ron index c31bf10..00a5f50 100644 --- a/mods/trees/objects/kiefer_a.ron +++ b/mods/trees/objects/kiefer_a.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/kiefer_a_winter.gltf"), lod_distances: [70, 400, 800, 2500], + footprint: Some((length: 11.6, width: 10.5)), tags: ["nadelbaum", "stand-nadelwald", "stand-mischwald", "stand-heide"], ) diff --git a/mods/trees/objects/kiefer_b.ron b/mods/trees/objects/kiefer_b.ron index ee1dd39..514a3bd 100644 --- a/mods/trees/objects/kiefer_b.ron +++ b/mods/trees/objects/kiefer_b.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/kiefer_b_winter.gltf"), lod_distances: [91, 400, 800, 2500], + footprint: Some((length: 14.9, width: 13.8)), tags: ["nadelbaum", "stand-nadelwald", "stand-mischwald", "stand-heide"], ) diff --git a/mods/trees/objects/kiefer_c.ron b/mods/trees/objects/kiefer_c.ron index 523928f..0b7ab0e 100644 --- a/mods/trees/objects/kiefer_c.ron +++ b/mods/trees/objects/kiefer_c.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/kiefer_c_winter.gltf"), lod_distances: [112, 480, 960, 2500], + footprint: Some((length: 18.4, width: 17.4)), tags: ["nadelbaum", "stand-nadelwald", "stand-mischwald", "stand-heide"], ) diff --git a/mods/trees/objects/laerche_a.ron b/mods/trees/objects/laerche_a.ron index 35f60e0..d768bf4 100644 --- a/mods/trees/objects/laerche_a.ron +++ b/mods/trees/objects/laerche_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/laerche_a_herbst.gltf"), winter_model: Some("trees/assets/laerche_a_winter.gltf"), lod_distances: [84, 400, 800, 2500], + footprint: Some((length: 9.9, width: 9.2)), tags: ["nadelbaum", "stand-nadelwald", "stand-bergwald", "stand-mischwald"], ) diff --git a/mods/trees/objects/laerche_b.ron b/mods/trees/objects/laerche_b.ron index b84d3c6..195219e 100644 --- a/mods/trees/objects/laerche_b.ron +++ b/mods/trees/objects/laerche_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/laerche_b_herbst.gltf"), winter_model: Some("trees/assets/laerche_b_winter.gltf"), lod_distances: [105, 450, 900, 2500], + footprint: Some((length: 12.5, width: 11.8)), tags: ["nadelbaum", "stand-nadelwald", "stand-bergwald", "stand-mischwald"], ) diff --git a/mods/trees/objects/laerche_c.ron b/mods/trees/objects/laerche_c.ron index 3a079c0..cdc0b11 100644 --- a/mods/trees/objects/laerche_c.ron +++ b/mods/trees/objects/laerche_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/laerche_c_herbst.gltf"), winter_model: Some("trees/assets/laerche_c_winter.gltf"), lod_distances: [126, 540, 1080, 2500], + footprint: Some((length: 15.1, width: 13.7)), tags: ["nadelbaum", "stand-nadelwald", "stand-bergwald", "stand-mischwald"], ) diff --git a/mods/trees/objects/pyramidenpappel_a.ron b/mods/trees/objects/pyramidenpappel_a.ron index a5a4fb1..f7a4b67 100644 --- a/mods/trees/objects/pyramidenpappel_a.ron +++ b/mods/trees/objects/pyramidenpappel_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/pyramidenpappel_a_herbst.gltf"), winter_model: Some("trees/assets/pyramidenpappel_a_winter.gltf"), lod_distances: [77, 400, 800, 2500], + footprint: Some((length: 5, width: 4.7)), tags: ["laubbaum", "stand-allee", "stand-feld", "stand-bahndamm"], ) diff --git a/mods/trees/objects/pyramidenpappel_b.ron b/mods/trees/objects/pyramidenpappel_b.ron index 13346f6..9c39aba 100644 --- a/mods/trees/objects/pyramidenpappel_b.ron +++ b/mods/trees/objects/pyramidenpappel_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/pyramidenpappel_b_herbst.gltf"), winter_model: Some("trees/assets/pyramidenpappel_b_winter.gltf"), lod_distances: [98, 420, 840, 2500], + footprint: Some((length: 6.4, width: 6.1)), tags: ["laubbaum", "stand-allee", "stand-feld", "stand-bahndamm"], ) diff --git a/mods/trees/objects/pyramidenpappel_c.ron b/mods/trees/objects/pyramidenpappel_c.ron index ad48f39..6d8f429 100644 --- a/mods/trees/objects/pyramidenpappel_c.ron +++ b/mods/trees/objects/pyramidenpappel_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/pyramidenpappel_c_herbst.gltf"), winter_model: Some("trees/assets/pyramidenpappel_c_winter.gltf"), lod_distances: [119, 510, 1020, 2500], + footprint: Some((length: 7.7, width: 7.5)), tags: ["laubbaum", "stand-allee", "stand-feld", "stand-bahndamm"], ) diff --git a/mods/trees/objects/robinie_a.ron b/mods/trees/objects/robinie_a.ron index 7e46af2..37b4525 100644 --- a/mods/trees/objects/robinie_a.ron +++ b/mods/trees/objects/robinie_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/robinie_a_herbst.gltf"), winter_model: Some("trees/assets/robinie_a_winter.gltf"), lod_distances: [45, 400, 800, 2200], + footprint: Some((length: 7.8, width: 7.5)), tags: ["laubbaum", "stand-bahndamm", "stand-boeschung", "stand-pionier", "stand-stadt"], ) diff --git a/mods/trees/objects/robinie_b.ron b/mods/trees/objects/robinie_b.ron index 841df23..b0f17f9 100644 --- a/mods/trees/objects/robinie_b.ron +++ b/mods/trees/objects/robinie_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/robinie_b_herbst.gltf"), winter_model: Some("trees/assets/robinie_b_winter.gltf"), lod_distances: [63, 400, 800, 2200], + footprint: Some((length: 11.8, width: 11.5)), tags: ["laubbaum", "stand-bahndamm", "stand-boeschung", "stand-pionier", "stand-stadt"], ) diff --git a/mods/trees/objects/robinie_c.ron b/mods/trees/objects/robinie_c.ron index b516d18..64de833 100644 --- a/mods/trees/objects/robinie_c.ron +++ b/mods/trees/objects/robinie_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/robinie_c_herbst.gltf"), winter_model: Some("trees/assets/robinie_c_winter.gltf"), lod_distances: [84, 400, 800, 2200], + footprint: Some((length: 15.4, width: 14)), tags: ["laubbaum", "stand-bahndamm", "stand-boeschung", "stand-pionier", "stand-stadt"], ) diff --git a/mods/trees/objects/rosskastanie_a.ron b/mods/trees/objects/rosskastanie_a.ron index 2ce7691..c3b0dca 100644 --- a/mods/trees/objects/rosskastanie_a.ron +++ b/mods/trees/objects/rosskastanie_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/rosskastanie_a_herbst.gltf"), winter_model: Some("trees/assets/rosskastanie_a_winter.gltf"), lod_distances: [56, 400, 800, 2200], + footprint: Some((length: 13.5, width: 13.1)), tags: ["laubbaum", "stand-allee", "stand-stadt", "stand-park"], ) diff --git a/mods/trees/objects/rosskastanie_b.ron b/mods/trees/objects/rosskastanie_b.ron index 8d0a89d..39d5743 100644 --- a/mods/trees/objects/rosskastanie_b.ron +++ b/mods/trees/objects/rosskastanie_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/rosskastanie_b_herbst.gltf"), winter_model: Some("trees/assets/rosskastanie_b_winter.gltf"), lod_distances: [74, 400, 800, 2200], + footprint: Some((length: 17.4, width: 15.8)), tags: ["laubbaum", "stand-allee", "stand-stadt", "stand-park"], ) diff --git a/mods/trees/objects/rosskastanie_c.ron b/mods/trees/objects/rosskastanie_c.ron index 1099111..2eee47f 100644 --- a/mods/trees/objects/rosskastanie_c.ron +++ b/mods/trees/objects/rosskastanie_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/rosskastanie_c_herbst.gltf"), winter_model: Some("trees/assets/rosskastanie_c_winter.gltf"), lod_distances: [91, 400, 800, 2200], + footprint: Some((length: 21.5, width: 21.4)), tags: ["laubbaum", "stand-allee", "stand-stadt", "stand-park"], ) diff --git a/mods/trees/objects/rotbuche_a.ron b/mods/trees/objects/rotbuche_a.ron index b90a727..f8cb41d 100644 --- a/mods/trees/objects/rotbuche_a.ron +++ b/mods/trees/objects/rotbuche_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/rotbuche_a_herbst.gltf"), winter_model: Some("trees/assets/rotbuche_a_winter.gltf"), lod_distances: [84, 400, 800, 2500], + footprint: Some((length: 17.1, width: 16.6)), tags: ["laubbaum", "stand-laubwald", "stand-mischwald", "stand-allee"], ) diff --git a/mods/trees/objects/rotbuche_b.ron b/mods/trees/objects/rotbuche_b.ron index 438195e..0ab9e25 100644 --- a/mods/trees/objects/rotbuche_b.ron +++ b/mods/trees/objects/rotbuche_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/rotbuche_b_herbst.gltf"), winter_model: Some("trees/assets/rotbuche_b_winter.gltf"), lod_distances: [102, 435, 870, 2500], + footprint: Some((length: 20.3, width: 18.4)), tags: ["laubbaum", "stand-laubwald", "stand-mischwald", "stand-allee"], ) diff --git a/mods/trees/objects/rotbuche_c.ron b/mods/trees/objects/rotbuche_c.ron index 7be377d..435a3b8 100644 --- a/mods/trees/objects/rotbuche_c.ron +++ b/mods/trees/objects/rotbuche_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/rotbuche_c_herbst.gltf"), winter_model: Some("trees/assets/rotbuche_c_winter.gltf"), lod_distances: [119, 510, 1020, 2500], + footprint: Some((length: 23.8, width: 23.6)), tags: ["laubbaum", "stand-laubwald", "stand-mischwald", "stand-allee"], ) diff --git a/mods/trees/objects/salweide_a.ron b/mods/trees/objects/salweide_a.ron index 92d7e25..e7bb61e 100644 --- a/mods/trees/objects/salweide_a.ron +++ b/mods/trees/objects/salweide_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/salweide_a_herbst.gltf"), winter_model: Some("trees/assets/salweide_a_winter.gltf"), lod_distances: [45, 400, 800, 1200], + footprint: Some((length: 4.8, width: 4.4)), tags: ["strauch", "stand-boeschung", "stand-bahndamm", "stand-pionier", "stand-bach"], ) diff --git a/mods/trees/objects/salweide_b.ron b/mods/trees/objects/salweide_b.ron index 27f3bfa..9fdcc97 100644 --- a/mods/trees/objects/salweide_b.ron +++ b/mods/trees/objects/salweide_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/salweide_b_herbst.gltf"), winter_model: Some("trees/assets/salweide_b_winter.gltf"), lod_distances: [45, 400, 800, 1200], + footprint: Some((length: 7.4, width: 6.8)), tags: ["strauch", "stand-boeschung", "stand-bahndamm", "stand-pionier", "stand-bach"], ) diff --git a/mods/trees/objects/salweide_c.ron b/mods/trees/objects/salweide_c.ron index 4e1c9c6..6950fee 100644 --- a/mods/trees/objects/salweide_c.ron +++ b/mods/trees/objects/salweide_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/salweide_c_herbst.gltf"), winter_model: Some("trees/assets/salweide_c_winter.gltf"), lod_distances: [45, 400, 800, 1200], + footprint: Some((length: 9.8, width: 9.2)), tags: ["strauch", "stand-boeschung", "stand-bahndamm", "stand-pionier", "stand-bach"], ) diff --git a/mods/trees/objects/sandbirke_a.ron b/mods/trees/objects/sandbirke_a.ron index d15844b..c1f1e9a 100644 --- a/mods/trees/objects/sandbirke_a.ron +++ b/mods/trees/objects/sandbirke_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/sandbirke_a_herbst.gltf"), winter_model: Some("trees/assets/sandbirke_a_winter.gltf"), lod_distances: [49, 400, 800, 2200], + footprint: Some((length: 6.9, width: 6.3)), tags: ["laubbaum", "stand-mischwald", "stand-heide", "stand-boeschung", "stand-pionier"], ) diff --git a/mods/trees/objects/sandbirke_b.ron b/mods/trees/objects/sandbirke_b.ron index d0c818c..247594c 100644 --- a/mods/trees/objects/sandbirke_b.ron +++ b/mods/trees/objects/sandbirke_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/sandbirke_b_herbst.gltf"), winter_model: Some("trees/assets/sandbirke_b_winter.gltf"), lod_distances: [66, 400, 800, 2200], + footprint: Some((length: 9.1, width: 8.3)), tags: ["laubbaum", "stand-mischwald", "stand-heide", "stand-boeschung", "stand-pionier"], ) diff --git a/mods/trees/objects/sandbirke_c.ron b/mods/trees/objects/sandbirke_c.ron index bad8d70..3a33b2a 100644 --- a/mods/trees/objects/sandbirke_c.ron +++ b/mods/trees/objects/sandbirke_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/sandbirke_c_herbst.gltf"), winter_model: Some("trees/assets/sandbirke_c_winter.gltf"), lod_distances: [84, 400, 800, 2200], + footprint: Some((length: 11.9, width: 10.8)), tags: ["laubbaum", "stand-mischwald", "stand-heide", "stand-boeschung", "stand-pionier"], ) diff --git a/mods/trees/objects/schlehe_a.ron b/mods/trees/objects/schlehe_a.ron index 967532d..6be83a1 100644 --- a/mods/trees/objects/schlehe_a.ron +++ b/mods/trees/objects/schlehe_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/schlehe_a_herbst.gltf"), winter_model: Some("trees/assets/schlehe_a_winter.gltf"), lod_distances: [45, 400, 800, 700], + footprint: Some((length: 2.5, width: 2.3)), tags: ["strauch", "stand-hecke", "stand-feld", "stand-boeschung", "stand-bahndamm"], ) diff --git a/mods/trees/objects/schlehe_b.ron b/mods/trees/objects/schlehe_b.ron index 9163229..5d462e2 100644 --- a/mods/trees/objects/schlehe_b.ron +++ b/mods/trees/objects/schlehe_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/schlehe_b_herbst.gltf"), winter_model: Some("trees/assets/schlehe_b_winter.gltf"), lod_distances: [45, 400, 800, 700], + footprint: Some((length: 3.8, width: 3.7)), tags: ["strauch", "stand-hecke", "stand-feld", "stand-boeschung", "stand-bahndamm"], ) diff --git a/mods/trees/objects/schlehe_c.ron b/mods/trees/objects/schlehe_c.ron index 77fca5f..bde6e73 100644 --- a/mods/trees/objects/schlehe_c.ron +++ b/mods/trees/objects/schlehe_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/schlehe_c_herbst.gltf"), winter_model: Some("trees/assets/schlehe_c_winter.gltf"), lod_distances: [45, 400, 800, 700], + footprint: Some((length: 5.1, width: 4.7)), tags: ["strauch", "stand-hecke", "stand-feld", "stand-boeschung", "stand-bahndamm"], ) diff --git a/mods/trees/objects/schwarzerle_a.ron b/mods/trees/objects/schwarzerle_a.ron index cd60b27..544708b 100644 --- a/mods/trees/objects/schwarzerle_a.ron +++ b/mods/trees/objects/schwarzerle_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/schwarzerle_a_herbst.gltf"), winter_model: Some("trees/assets/schwarzerle_a_winter.gltf"), lod_distances: [49, 400, 800, 2000], + footprint: Some((length: 6.6, width: 6.3)), tags: ["laubbaum", "stand-auwald", "stand-bach", "stand-mischwald"], ) diff --git a/mods/trees/objects/schwarzerle_b.ron b/mods/trees/objects/schwarzerle_b.ron index cb60976..a34b12c 100644 --- a/mods/trees/objects/schwarzerle_b.ron +++ b/mods/trees/objects/schwarzerle_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/schwarzerle_b_herbst.gltf"), winter_model: Some("trees/assets/schwarzerle_b_winter.gltf"), lod_distances: [66, 400, 800, 2000], + footprint: Some((length: 9, width: 8.8)), tags: ["laubbaum", "stand-auwald", "stand-bach", "stand-mischwald"], ) diff --git a/mods/trees/objects/schwarzerle_c.ron b/mods/trees/objects/schwarzerle_c.ron index f819bda..ed4f675 100644 --- a/mods/trees/objects/schwarzerle_c.ron +++ b/mods/trees/objects/schwarzerle_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/schwarzerle_c_herbst.gltf"), winter_model: Some("trees/assets/schwarzerle_c_winter.gltf"), lod_distances: [84, 400, 800, 2000], + footprint: Some((length: 11.2, width: 10.2)), tags: ["laubbaum", "stand-auwald", "stand-bach", "stand-mischwald"], ) diff --git a/mods/trees/objects/silberweide_a.ron b/mods/trees/objects/silberweide_a.ron index 88d0eb1..3bd15f1 100644 --- a/mods/trees/objects/silberweide_a.ron +++ b/mods/trees/objects/silberweide_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/silberweide_a_herbst.gltf"), winter_model: Some("trees/assets/silberweide_a_winter.gltf"), lod_distances: [49, 400, 800, 2200], + footprint: Some((length: 12.3, width: 11.2)), tags: ["laubbaum", "stand-auwald", "stand-bach", "stand-feld"], ) diff --git a/mods/trees/objects/silberweide_b.ron b/mods/trees/objects/silberweide_b.ron index 77bf4c5..ef76133 100644 --- a/mods/trees/objects/silberweide_b.ron +++ b/mods/trees/objects/silberweide_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/silberweide_b_herbst.gltf"), winter_model: Some("trees/assets/silberweide_b_winter.gltf"), lod_distances: [66, 400, 800, 2200], + footprint: Some((length: 16.7, width: 16.6)), tags: ["laubbaum", "stand-auwald", "stand-bach", "stand-feld"], ) diff --git a/mods/trees/objects/silberweide_c.ron b/mods/trees/objects/silberweide_c.ron index 5f1571d..7b7f3a6 100644 --- a/mods/trees/objects/silberweide_c.ron +++ b/mods/trees/objects/silberweide_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/silberweide_c_herbst.gltf"), winter_model: Some("trees/assets/silberweide_c_winter.gltf"), lod_distances: [84, 400, 800, 2200], + footprint: Some((length: 21.1, width: 19.4)), tags: ["laubbaum", "stand-auwald", "stand-bach", "stand-feld"], ) diff --git a/mods/trees/objects/spitzahorn_a.ron b/mods/trees/objects/spitzahorn_a.ron index a6eccca..a8c62a0 100644 --- a/mods/trees/objects/spitzahorn_a.ron +++ b/mods/trees/objects/spitzahorn_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/spitzahorn_a_herbst.gltf"), winter_model: Some("trees/assets/spitzahorn_a_winter.gltf"), lod_distances: [56, 400, 800, 2200], + footprint: Some((length: 12.5, width: 12.1)), tags: ["laubbaum", "stand-laubwald", "stand-allee", "stand-stadt"], ) diff --git a/mods/trees/objects/spitzahorn_b.ron b/mods/trees/objects/spitzahorn_b.ron index 6013036..451f2f1 100644 --- a/mods/trees/objects/spitzahorn_b.ron +++ b/mods/trees/objects/spitzahorn_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/spitzahorn_b_herbst.gltf"), winter_model: Some("trees/assets/spitzahorn_b_winter.gltf"), lod_distances: [74, 400, 800, 2200], + footprint: Some((length: 16.1, width: 15.5)), tags: ["laubbaum", "stand-laubwald", "stand-allee", "stand-stadt"], ) diff --git a/mods/trees/objects/spitzahorn_c.ron b/mods/trees/objects/spitzahorn_c.ron index 5b1c624..8eb8f95 100644 --- a/mods/trees/objects/spitzahorn_c.ron +++ b/mods/trees/objects/spitzahorn_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/spitzahorn_c_herbst.gltf"), winter_model: Some("trees/assets/spitzahorn_c_winter.gltf"), lod_distances: [91, 400, 800, 2200], + footprint: Some((length: 20.3, width: 19.7)), tags: ["laubbaum", "stand-laubwald", "stand-allee", "stand-stadt"], ) diff --git a/mods/trees/objects/stieleiche_a.ron b/mods/trees/objects/stieleiche_a.ron index 8dba41f..25a2d54 100644 --- a/mods/trees/objects/stieleiche_a.ron +++ b/mods/trees/objects/stieleiche_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/stieleiche_a_herbst.gltf"), winter_model: Some("trees/assets/stieleiche_a_winter.gltf"), lod_distances: [63, 400, 800, 2500], + footprint: Some((length: 16.7, width: 15.4)), tags: ["laubbaum", "stand-laubwald", "stand-mischwald", "stand-allee", "stand-feld"], ) diff --git a/mods/trees/objects/stieleiche_b.ron b/mods/trees/objects/stieleiche_b.ron index 4288563..40dffca 100644 --- a/mods/trees/objects/stieleiche_b.ron +++ b/mods/trees/objects/stieleiche_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/stieleiche_b_herbst.gltf"), winter_model: Some("trees/assets/stieleiche_b_winter.gltf"), lod_distances: [80, 400, 800, 2500], + footprint: Some((length: 21, width: 19.1)), tags: ["laubbaum", "stand-laubwald", "stand-mischwald", "stand-allee", "stand-feld"], ) diff --git a/mods/trees/objects/stieleiche_c.ron b/mods/trees/objects/stieleiche_c.ron index 533389b..fdd80a9 100644 --- a/mods/trees/objects/stieleiche_c.ron +++ b/mods/trees/objects/stieleiche_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/stieleiche_c_herbst.gltf"), winter_model: Some("trees/assets/stieleiche_c_winter.gltf"), lod_distances: [98, 420, 840, 2500], + footprint: Some((length: 26.2, width: 23.9)), tags: ["laubbaum", "stand-laubwald", "stand-mischwald", "stand-allee", "stand-feld"], ) diff --git a/mods/trees/objects/vogelkirsche_a.ron b/mods/trees/objects/vogelkirsche_a.ron index a92892d..89b5e7f 100644 --- a/mods/trees/objects/vogelkirsche_a.ron +++ b/mods/trees/objects/vogelkirsche_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/vogelkirsche_a_herbst.gltf"), winter_model: Some("trees/assets/vogelkirsche_a_winter.gltf"), lod_distances: [45, 400, 800, 2000], + footprint: Some((length: 7.1, width: 6.7)), tags: ["laubbaum", "stand-laubwald", "stand-feld", "stand-allee"], ) diff --git a/mods/trees/objects/vogelkirsche_b.ron b/mods/trees/objects/vogelkirsche_b.ron index 1197135..87159b0 100644 --- a/mods/trees/objects/vogelkirsche_b.ron +++ b/mods/trees/objects/vogelkirsche_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/vogelkirsche_b_herbst.gltf"), winter_model: Some("trees/assets/vogelkirsche_b_winter.gltf"), lod_distances: [56, 400, 800, 2000], + footprint: Some((length: 9.4, width: 8.6)), tags: ["laubbaum", "stand-laubwald", "stand-feld", "stand-allee"], ) diff --git a/mods/trees/objects/vogelkirsche_c.ron b/mods/trees/objects/vogelkirsche_c.ron index 12a2d19..0cbefac 100644 --- a/mods/trees/objects/vogelkirsche_c.ron +++ b/mods/trees/objects/vogelkirsche_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/vogelkirsche_c_herbst.gltf"), winter_model: Some("trees/assets/vogelkirsche_c_winter.gltf"), lod_distances: [70, 400, 800, 2000], + footprint: Some((length: 11.7, width: 11.1)), tags: ["laubbaum", "stand-laubwald", "stand-feld", "stand-allee"], ) diff --git a/mods/trees/objects/wacholder_a.ron b/mods/trees/objects/wacholder_a.ron index 2f58fa1..1a5a2a2 100644 --- a/mods/trees/objects/wacholder_a.ron +++ b/mods/trees/objects/wacholder_a.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/wacholder_a_winter.gltf"), lod_distances: [45, 400, 800, 700], + footprint: Some((length: 2.1, width: 2)), tags: ["strauch", "nadelbaum", "stand-heide", "stand-boeschung"], ) diff --git a/mods/trees/objects/wacholder_b.ron b/mods/trees/objects/wacholder_b.ron index cc1eff4..ef4ecb5 100644 --- a/mods/trees/objects/wacholder_b.ron +++ b/mods/trees/objects/wacholder_b.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/wacholder_b_winter.gltf"), lod_distances: [45, 400, 800, 700], + footprint: Some((length: 3.5, width: 2.7)), tags: ["strauch", "nadelbaum", "stand-heide", "stand-boeschung"], ) diff --git a/mods/trees/objects/wacholder_c.ron b/mods/trees/objects/wacholder_c.ron index a05cbe6..996ef13 100644 --- a/mods/trees/objects/wacholder_c.ron +++ b/mods/trees/objects/wacholder_c.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/wacholder_c_winter.gltf"), lod_distances: [45, 400, 800, 700], + footprint: Some((length: 4.9, width: 4.8)), tags: ["strauch", "nadelbaum", "stand-heide", "stand-boeschung"], ) diff --git a/mods/trees/objects/weissdorn_a.ron b/mods/trees/objects/weissdorn_a.ron index d7804c7..12bf2b1 100644 --- a/mods/trees/objects/weissdorn_a.ron +++ b/mods/trees/objects/weissdorn_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/weissdorn_a_herbst.gltf"), winter_model: Some("trees/assets/weissdorn_a_winter.gltf"), lod_distances: [45, 400, 800, 900], + footprint: Some((length: 3, width: 2.7)), tags: ["strauch", "stand-hecke", "stand-feld", "stand-boeschung"], ) diff --git a/mods/trees/objects/weissdorn_b.ron b/mods/trees/objects/weissdorn_b.ron index 9b30271..d037c34 100644 --- a/mods/trees/objects/weissdorn_b.ron +++ b/mods/trees/objects/weissdorn_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/weissdorn_b_herbst.gltf"), winter_model: Some("trees/assets/weissdorn_b_winter.gltf"), lod_distances: [45, 400, 800, 900], + footprint: Some((length: 4.9, width: 4.6)), tags: ["strauch", "stand-hecke", "stand-feld", "stand-boeschung"], ) diff --git a/mods/trees/objects/weissdorn_c.ron b/mods/trees/objects/weissdorn_c.ron index db80252..030237d 100644 --- a/mods/trees/objects/weissdorn_c.ron +++ b/mods/trees/objects/weissdorn_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/weissdorn_c_herbst.gltf"), winter_model: Some("trees/assets/weissdorn_c_winter.gltf"), lod_distances: [45, 400, 800, 900], + footprint: Some((length: 6.7, width: 6.3)), tags: ["strauch", "stand-hecke", "stand-feld", "stand-boeschung"], ) diff --git a/mods/trees/objects/weisstanne_a.ron b/mods/trees/objects/weisstanne_a.ron index 21cb8bc..3550794 100644 --- a/mods/trees/objects/weisstanne_a.ron +++ b/mods/trees/objects/weisstanne_a.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/weisstanne_a_winter.gltf"), lod_distances: [98, 420, 840, 2500], + footprint: Some((length: 10.6, width: 9.8)), tags: ["nadelbaum", "stand-nadelwald", "stand-bergwald"], ) diff --git a/mods/trees/objects/weisstanne_b.ron b/mods/trees/objects/weisstanne_b.ron index 890a519..5436337 100644 --- a/mods/trees/objects/weisstanne_b.ron +++ b/mods/trees/objects/weisstanne_b.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/weisstanne_b_winter.gltf"), lod_distances: [122, 525, 1050, 2500], + footprint: Some((length: 13.3, width: 12.6)), tags: ["nadelbaum", "stand-nadelwald", "stand-bergwald"], ) diff --git a/mods/trees/objects/weisstanne_c.ron b/mods/trees/objects/weisstanne_c.ron index e4b491b..91d697a 100644 --- a/mods/trees/objects/weisstanne_c.ron +++ b/mods/trees/objects/weisstanne_c.ron @@ -6,5 +6,6 @@ autumn_model: None, winter_model: Some("trees/assets/weisstanne_c_winter.gltf"), lod_distances: [147, 630, 1260, 2500], + footprint: Some((length: 16, width: 15.7)), tags: ["nadelbaum", "stand-nadelwald", "stand-bergwald"], ) diff --git a/mods/trees/objects/winterlinde_a.ron b/mods/trees/objects/winterlinde_a.ron index 203ac66..ba187a1 100644 --- a/mods/trees/objects/winterlinde_a.ron +++ b/mods/trees/objects/winterlinde_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/winterlinde_a_herbst.gltf"), winter_model: Some("trees/assets/winterlinde_a_winter.gltf"), lod_distances: [70, 400, 800, 2500], + footprint: Some((length: 14.2, width: 13)), tags: ["laubbaum", "stand-laubwald", "stand-allee", "stand-stadt"], ) diff --git a/mods/trees/objects/winterlinde_b.ron b/mods/trees/objects/winterlinde_b.ron index 6f5d485..91aaf89 100644 --- a/mods/trees/objects/winterlinde_b.ron +++ b/mods/trees/objects/winterlinde_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/winterlinde_b_herbst.gltf"), winter_model: Some("trees/assets/winterlinde_b_winter.gltf"), lod_distances: [88, 400, 800, 2500], + footprint: Some((length: 17.7, width: 16.6)), tags: ["laubbaum", "stand-laubwald", "stand-allee", "stand-stadt"], ) diff --git a/mods/trees/objects/winterlinde_c.ron b/mods/trees/objects/winterlinde_c.ron index ea21942..3c12405 100644 --- a/mods/trees/objects/winterlinde_c.ron +++ b/mods/trees/objects/winterlinde_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/winterlinde_c_herbst.gltf"), winter_model: Some("trees/assets/winterlinde_c_winter.gltf"), lod_distances: [105, 450, 900, 2500], + footprint: Some((length: 21, width: 19.3)), tags: ["laubbaum", "stand-laubwald", "stand-allee", "stand-stadt"], ) diff --git a/mods/trees/objects/zitterpappel_a.ron b/mods/trees/objects/zitterpappel_a.ron index b9334c0..718658a 100644 --- a/mods/trees/objects/zitterpappel_a.ron +++ b/mods/trees/objects/zitterpappel_a.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/zitterpappel_a_herbst.gltf"), winter_model: Some("trees/assets/zitterpappel_a_winter.gltf"), lod_distances: [56, 400, 800, 2200], + footprint: Some((length: 7.8, width: 7.4)), tags: ["laubbaum", "stand-pionier", "stand-mischwald", "stand-boeschung"], ) diff --git a/mods/trees/objects/zitterpappel_b.ron b/mods/trees/objects/zitterpappel_b.ron index 5cf3040..4f0338f 100644 --- a/mods/trees/objects/zitterpappel_b.ron +++ b/mods/trees/objects/zitterpappel_b.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/zitterpappel_b_herbst.gltf"), winter_model: Some("trees/assets/zitterpappel_b_winter.gltf"), lod_distances: [74, 400, 800, 2200], + footprint: Some((length: 10.4, width: 9.9)), tags: ["laubbaum", "stand-pionier", "stand-mischwald", "stand-boeschung"], ) diff --git a/mods/trees/objects/zitterpappel_c.ron b/mods/trees/objects/zitterpappel_c.ron index 54f974e..a2264f2 100644 --- a/mods/trees/objects/zitterpappel_c.ron +++ b/mods/trees/objects/zitterpappel_c.ron @@ -6,5 +6,6 @@ autumn_model: Some("trees/assets/zitterpappel_c_herbst.gltf"), winter_model: Some("trees/assets/zitterpappel_c_winter.gltf"), lod_distances: [91, 400, 800, 2200], + footprint: Some((length: 12.9, width: 12.5)), tags: ["laubbaum", "stand-pionier", "stand-mischwald", "stand-boeschung"], ) diff --git a/tools/trees/README.md b/tools/trees/README.md index 0a287a0..991374c 100644 --- a/tools/trees/README.md +++ b/tools/trees/README.md @@ -50,6 +50,16 @@ the actual Mantissa leaves. It removes underground root meshes, scales each form to the catalogue height/crown and decimates connected wood for the near LOD. It does not synthesize trunks, branches, needles or crowns. +## What the object file says + +Beside the model, the seasons and the LOD distances, every `objects/*.ron` carries the +**crown as a `footprint`**, broadest span first, measured off the finished `crown_LOD0` +mesh rather than taken from the catalogue's height ratio — what the file states is what +the tree actually spans. It is what the route editor's imagery detection compares a crown +measured off an aerial photograph against, to decide which species to plant there and how +far to grow it (README, *Detecting from the aerial imagery*). A re-import rewrites it with +the geometry, so the two can never drift apart. + ## LOD and materials Every glTF has exactly these nodes: diff --git a/tools/trees/import_mantissa.py b/tools/trees/import_mantissa.py index c30c427..f2d5b9f 100644 --- a/tools/trees/import_mantissa.py +++ b/tools/trees/import_mantissa.py @@ -1022,7 +1022,24 @@ def render_impostor(entry: dict, variant: str, season: str, branch: dict, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) -def write_object(entry: dict, variant_index: int, height: float) -> None: +def crown_extent(*meshes: dict) -> tuple[float, float]: + """How wide the built crown actually is [m], broadest span first. + + The catalogue states the crown as a share of the height; what goes into the + object file is what the finished mesh spans, because that is the number the + imagery detection compares a crown measured off a photograph against + (`crates/vision`). Measured on LOD0, wood and foliage together. + """ + points = [point for mesh in meshes for point in mesh["positions"]] + if not points: + return (0.0, 0.0) + x = max(point[0] for point in points) - min(point[0] for point in points) + z = max(point[2] for point in points) - min(point[2] for point in points) + return (round(max(x, z), 1), round(min(x, z), 1)) + + +def write_object(entry: dict, variant_index: int, height: float, + crown: tuple[float, float]) -> None: variant = VARIANTS[variant_index] name = f"{entry['name']} {variant.upper()}" model = f"trees/assets/{entry['id']}_{variant}.gltf" @@ -1045,6 +1062,7 @@ def write_object(entry: dict, variant_index: int, height: float) -> None: autumn_model: {autumn}, winter_model: {winter}, lod_distances: [{lod0}, {lod1}, {lod2}, {cull}], + footprint: Some((length: {crown[0]:g}, width: {crown[1]:g})), tags: [{tags}], ) ''' @@ -1119,7 +1137,7 @@ def build_tree(entry: dict, variant_index: int, axis: AxisMap, branches: list[di write_gltf(entry, variant, season, near_primitives, medium_primitives, buffer, rich_billboard, far_billboard, bin_name) - write_object(entry, variant_index, height) + write_object(entry, variant_index, height, crown_extent(branch, foliage)) triangles = [len(branch["indices"]) // 3 + len(foliage["indices"]) // 3, len(medium_branch["indices"]) // 3 + len(medium_foliage["indices"]) // 3, len(rich_billboard_mesh["indices"]) // 3, diff --git a/tools/vision/README.md b/tools/vision/README.md new file mode 100644 index 0000000..9e32d28 --- /dev/null +++ b/tools/vision/README.md @@ -0,0 +1,77 @@ +# The detectors in `models/` + +The route editor reads the aerial photograph with two local models — one for +the vehicles standing about a station, one for tree crowns (README, *Detecting +from the aerial imagery*). Both **ship with the game**, and both are converted +here from published, pre-trained detectors. Nothing is trained in this +directory. + +``` +Ultralytics yolov8n-obb.pt ──┐ +(DOTA v1, AGPL-3.0) ├── export_models.py ──▶ models/yolov8n-obb.onnx +weecology/deepforest-tree ───┘ models/deepforest-tree.onnx +(NEON crowns, MIT) (Git LFS) +``` + +```bash +uv venv --python 3.12 cache/vision/venv +VIRTUAL_ENV=cache/vision/venv uv pip install torch torchvision onnx \ + onnxruntime ultralytics deepforest +cache/vision/venv/bin/python tools/vision/export_models.py +cache/vision/venv/bin/python tools/vision/export_models.py --only trees +``` + +The downloads land in `cache/vision/` (gitignored) and the finished weights in +`models/`. Rebuilding is only needed to move to another detector or another +input size; what is in the repository is this script's output, unmodified. + +## Why the tree model is exported the way it is + +DeepForest is a torchvision RetinaNet, and the whole of it — resize, normalise, +decode, non-maximum suppression — would export as a graph full of dynamic +shapes and control flow, which is exactly the kind of graph a small pure-Rust +runtime is bad at. It does not need to. The editor already scales the window to +the model's resolution, subtracts the ImageNet mean and settles overlapping +boxes; those are `InputSpec` and `suppress` in `crates/vision`. The one thing it +cannot do is guess anchors. + +So only the **backbone and the head** are exported, at a fixed 768 × 768, and +the anchor grid is rebuilt in Rust (`crates/vision/src/onnx.rs`, `Head::Retina`). +The script prints the grid torchvision produces at the end of a run; those +numbers are what the Rust test asserts against, down to the anchor whose half +width is 50.5 and which rounds to 50 because Python rounds halves to even. + +**768 and not 800.** DeepForest's own transform resizes to 800, whose coarsest +feature map is 7 cells wide where dividing 800 by the stride of 128 says 6. At +768 every level divides exactly, so the grid follows from the input size alone +and there is nothing to special-case. + +**0.05 m per pixel.** That is not the resolution DeepForest was trained on — it +reads ten-centimetre imagery and doubles it before the network sees anything. +Five centimetres is the scale at which a crown arrives the number of pixels +across that the model expects, which is what `ground_sample` in `ai.ron` means. +Where the imagery provider cannot go that fine, the window is enlarged instead +and the crowns come out softer; finer imagery is the single biggest thing that +improves this model's results. + +## Checking a rebuild + +`export_models.py` runs each file once through onnxruntime and prints the input +and output shapes, which catches a broken export. What it cannot catch is a +*different* export — a model that runs and finds nothing. Two things do: + +* `cargo test -p vision` — the anchor grid against torchvision's own numbers, + and, when the weights are present, that both files load and run + (`crates/vision/tests/shipped.rs`). +* Running the detection over ground you know, in the editor, and looking at it. + +The reference figures, for the tree model on DeepForest's own test image +(`OSBS_029.png`, 400 × 400): the Python package finds 55 crowns, this pipeline +finds 50 at a confidence of 0.3, and the boxes they share agree to about a +pixel — a tenth of a metre on that survey. + +## Licences + +Not the same for the two, and it matters: MIT for the trees, **AGPL-3.0** for +the cars. [`models/LICENSES.md`](../../models/LICENSES.md) has both in full, +including what shipping the AGPL one means for a release and how to drop it. diff --git a/tools/vision/export_models.py b/tools/vision/export_models.py new file mode 100644 index 0000000..601698b --- /dev/null +++ b/tools/vision/export_models.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Builds the detectors in `models/` — the weights the imagery detection runs. + +Two models ship with the game, and this is where they come from. Neither is +trained here: both are published, pre-trained detectors, converted into the one +format the editor's runtime reads (ONNX, run by tract in process). What this +script does is the conversion, and it does it reproducibly, because a weights +file nobody can rebuild is a weights file nobody can check. + + models/yolov8n-obb.onnx parked cars and lorries (Ultralytics, AGPL-3.0) + models/deepforest-tree.onnx tree crowns (DeepForest, MIT) + +The tree export is the interesting half. DeepForest is a torchvision RetinaNet, +and the whole of it — resize, normalise, decode, non-maximum suppression — +would export as a graph full of dynamic shapes and control flow. It does not +need to: the editor already scales the window, subtracts the mean and settles +overlaps, and what it cannot do is guess anchors. So only the backbone and the +head are exported, at a fixed size, and the anchor grid is rebuilt in Rust +(`crates/vision/src/onnx.rs`). The numbers this script prints at the end are +what the test over there asserts against. + + uv venv --python 3.12 cache/vision/venv + VIRTUAL_ENV=cache/vision/venv uv pip install torch torchvision onnx \\ + onnxruntime ultralytics deepforest + cache/vision/venv/bin/python tools/vision/export_models.py [--only cars|trees] + +Rebuilding is only necessary to move to another detector or another input size. +The models in the repository are the output of this script, unmodified. +""" + +import argparse +import shutil +import sys +import warnings +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MODELS = ROOT / "models" +CACHE = ROOT / "cache" / "vision" + +# The size the tree model is exported for. A multiple of the coarsest feature +# stride (128), which is what lets the anchor grid be rebuilt by integer +# division; 800, which is what DeepForest's own transform resizes to, is not — +# its coarsest feature map is 7 cells for a stride that would say 6. +TREE_INPUT = 768 + +# What the car detector was trained at. Ultralytics' DOTA weights are 1024, and +# a smaller export would put the cars at the wrong number of pixels across. +CAR_INPUT = 1024 + + +def export_cars() -> Path: + """YOLOv8n-OBB on DOTA v1 → ONNX. + + Ultralytics exports the layout the editor already reads, so this is one + call. The licence is not one line, though: these weights are AGPL-3.0, and + shipping them is a decision recorded in `models/LICENSES.md`. + """ + from ultralytics import YOLO + + CACHE.mkdir(parents=True, exist_ok=True) + weights = CACHE / "yolov8n-obb.pt" + if not weights.is_file(): + print(f"fetching {weights.name} …") + YOLO("yolov8n-obb.pt") # downloads into the working directory + downloaded = Path("yolov8n-obb.pt") + if downloaded.is_file(): + shutil.move(str(downloaded), weights) + model = YOLO(str(weights)) + exported = Path(model.export(format="onnx", imgsz=CAR_INPUT, opset=17)) + target = MODELS / "yolov8n-obb.onnx" + MODELS.mkdir(parents=True, exist_ok=True) + shutil.move(str(exported), target) + return target + + +def export_trees() -> Path: + """DeepForest's NEON crown model → ONNX, as two head tensors. + + What comes out is `cls_logits [1, anchors, 1]` and + `bbox_regression [1, anchors, 4]`. Neither holds a box: an offset is + measured from the anchor it belongs to, and the anchors are rebuilt on the + Rust side from the input size alone. + """ + import torch + import torch.nn as nn + from deepforest import main as deepforest + + class Raw(nn.Module): + def __init__(self, net): + super().__init__() + self.net = net + + def forward(self, x): + out = self.net.head(list(self.net.backbone(x).values())) + return out["cls_logits"], out["bbox_regression"] + + trained = deepforest.deepforest() + trained.load_model("weecology/deepforest-tree") + net = trained.model.eval() + raw = Raw(net).eval() + + MODELS.mkdir(parents=True, exist_ok=True) + target = MODELS / "deepforest-tree.onnx" + sample = torch.rand(1, 3, TREE_INPUT, TREE_INPUT) + torch.onnx.export( + raw, + (sample,), + str(target), + input_names=["images"], + output_names=["cls_logits", "bbox_regression"], + opset_version=17, + dynamo=False, + ) + fingerprint(net, sample) + return target + + +def fingerprint(net, sample) -> None: + """Prints the anchor grid the Rust side has to reproduce. + + `crates/vision/src/onnx.rs` rebuilds this grid, and its test asserts these + exact numbers. They are printed rather than written to a file because they + belong in the test, where a reader can see what is being claimed. + """ + import torch + import torchvision + + features = list(net.backbone(sample).values()) + images = torchvision.models.detection.image_list.ImageList( + sample, [(TREE_INPUT, TREE_INPUT)] + ) + anchors = net.anchor_generator(images, features)[0] + rows = lambda block: [[round(v, 1) for v in row] for row in block.tolist()] + level1 = 96 * 96 * 9 + print("\nanchor grid, for the test in crates/vision/src/onnx.rs:") + print(f" count {anchors.shape[0]}") + print(f" first five {rows(anchors[:5])}") + print(f" level 1 {rows(anchors[level1:level1 + 9])}") + print(f" at 50000 {rows(anchors[50_000:50_001])}") + print(f" last five {rows(anchors[-5:])}") + print(f" sum {float(anchors.sum()):.1f}") + + +def check(path: Path) -> None: + """Runs the exported file once, so a broken export fails here.""" + import numpy as np + import onnxruntime as ort + + session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"]) + shape = session.get_inputs()[0].shape + dummy = np.zeros([d if isinstance(d, int) else 1 for d in shape], dtype=np.float32) + outputs = session.run(None, {session.get_inputs()[0].name: dummy}) + size = path.stat().st_size / 1e6 + print( + f"{path.relative_to(ROOT)}: {size:.1f} MB, input {shape}, " + f"outputs {[list(o.shape) for o in outputs]}" + ) + + +def main() -> None: + warnings.filterwarnings("ignore") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--only", choices=["cars", "trees"], help="build one of them") + args = parser.parse_args() + + built = [] + if args.only in (None, "cars"): + built.append(export_cars()) + if args.only in (None, "trees"): + built.append(export_trees()) + print() + for path in built: + check(path) + print( + "\nThe weights are tracked with Git LFS (.gitattributes) and licensed " + "as `models/LICENSES.md` records." + ) + + +if __name__ == "__main__": + sys.exit(main())