This commit is contained in:
knso committed 2026-09-15 21:05:18 +02:00
1 parent 6a056262a8
commit a55dca5fb6
11 files changed
+132 -25

No files matched your search

@@ -173,8 +173,8 @@ Spieler.
- **Autos:** vier SUVs an Straßenpunkten nahe dem Start, Konfiguration aus der Showcase
übernommen. Ihre Chunks bleiben geladen, weil jedes Fahrzeug in jedem Frame Physik rechnet.
- **Figuren:** Modul aus `showcase.js` nach `src/characters/`, inklusive Delta-Retargeting
und ans Mesh angepasster Hitbox. Start ist der Rasen am Südrand des Parks von Vice Beach bei X 1600, Y 690 (three.js: x = 1600, z = -690) — ursprünglich X 1699, Y 743 auf einer Buchstabenkontur von „(MIAMI BEACH)“, versetzt mit dem Beschriftungs-Plan —,
abgeleitet aus dem Schwerpunkt der 259 Ocean-Beach-Landmarks im Ausschnitt.
und ans Mesh angepasster Hitbox. Start ist der Rasen am Südrand des Parks von Vice Beach bei X 1600, Y 690 (three.js: x = 1600, z = -690) — ursprünglich X 1699, Y 743, abgeleitet aus dem Schwerpunkt der 259 Ocean-Beach-Landmarks im Ausschnitt,
auf einer Buchstabenkontur von „(MIAMI BEACH)“, versetzt mit dem Beschriftungs-Plan.
- **Debug-Panel:** Figurenauswahl, Chunk-Grenzen, Hitbox, Kartenbild auf dem Boden,
Sichtweite, Höhenfaktor, Sprung zu einem der 54 Wahrzeichen.
- **Namensnennung im HUD:** Kartendaten und Landmarks von map.gtadb.org (CC BY 4.0,
@@ -41,10 +41,10 @@ Gemessen an der Vorlage (`tools/extract/cache`, YANIS v15) und den ausgelieferte
## Einbau in die Pipeline
```
Kacheln → classify → drop_map_labels → remove_annotations → Farbanteil-Prüfung → Land, Straßen, Gebäude, Höhen, Palmen → Markierung ergänzter Gebäude → Chunks → Minimap
Kacheln → classify → drop_map_labels → remove_annotations → drop_map_labels → Farbanteil-Prüfung → Land, Straßen, Gebäude, Höhen, Palmen → Markierung ergänzter Gebäude → Chunks → Minimap
```
`remove_annotations` ändert ausschließlich das Klassenbild — welches Pixel Wasser, Land, Park, Sand, Stadtfläche, Gebäude, Straße, Weg oder spekulativ ist. Alles danach bleibt Code von heute. `drop_map_labels` bleibt als Absicherung für Schrift im offenen Wasser.
`remove_annotations` ändert ausschließlich das Klassenbild — welches Pixel Wasser, Land, Park, Sand, Stadtfläche, Gebäude, Straße, Weg oder spekulativ ist. Alles danach bleibt Code von heute. `drop_map_labels` bleibt als Absicherung für Schrift im offenen Wasser und läuft nach `remove_annotations` ein zweites Mal, für Inselreste, die erst das Entfernen im Wasser hinterlässt.
| Einheit | Aufgabe |
|---|---|
@@ -95,6 +95,8 @@ reason = "knallrotes Gebäude in A10, laut Nutzer echt"
Der Rahmen soll die Beschriftung mit mindestens 8 m Luft umschließen; `--propose` schlägt Rahmen mit 10 m Luft vor. Wo keine Farbregel sicher greift — blasse Schrift, winzige Straßennamen in Gebäudegrau, Piktogramme aus dünnen dunklen Strichen —, bekommt die Stelle einen engen `region`-Rahmen: dann wird der ganze Rahmen neu aufgebaut.
Interstate-Schilder mit rotem Banner stehen als `region` in der Liste, weil keine Farbregel das dunkelrote Banner sicher trifft (ΔE 20,5 zu `spekulativ`) und der Rest sonst als spekulatives Gebäude auf der Fahrbahn stünde.
## Erkennung je Art
Je Eintrag entsteht eine Maske in fünf Schritten:
@@ -121,9 +123,11 @@ Die **Gitternetz-Maske** sind die Streifen aus `[grid]`, auf den Ausschnitt besc
Reihenfolge in `inpaint.py`:
1. **Gitternetz:** Jedes Pixel der Gitternetz-Maske, das in keiner Eintragsmaske liegt, übernimmt die Klasse des nächsten Pixels außerhalb aller Masken, gleich welcher Klasse. Gefüllt wird in Streifen von 10 m beiderseits jeder Linie.
2. **Eintragsmasken:** Alle Eintragsmasken werden vereinigt und in zusammenhängende Masken zerlegt; überlappende Einträge landen so in derselben Maske. Je Maske:
2. **Eintragsmasken:** Alle Eintragsmasken werden vereinigt und in zusammenhängende Masken zerlegt; überlappende Einträge landen so in derselben Maske. Zuerst wird jede Maske aufgebaut:
- **dünn** (größter Abstand eines Maskenpixels zum Maskenrand ≤ 2 m — Straßennamen, Zeigerlinien): wie das Gitternetz.
- **sonst:** Straßen und Wege → Flächen → Gebäude schließen in einem Fenster aus dem Hüllrechteck der Maske plus 60 m, danach Gebäude ergänzen im Nachbarschaftsfenster (plus 150 m).
- **sonst:** Straßen und Wege → Flächen → Gebäude schließen in einem Fenster aus dem Hüllrechteck der Maske plus 60 m.
Erst danach werden für alle nicht dünnen Masken, in derselben Reihenfolge, Gebäude ergänzt im Nachbarschaftsfenster (plus 150 m). Sonst zählten die Buchstabenkonturen noch nicht aufgebauter Masken als Straße und teilten Blöcke, und wo Gebäude entstehen, hinge von der Reihenfolge der Masken ab.
### Straßen und Wege
@@ -156,7 +156,7 @@ Die Namensnennung steht unten links und reicht auf schmalen Fenstern bis in die
- Ein Test, der die Namen mit der tatsächlichen Lage verbindet statt nur mit den Indizes: der Downtown-Bezugspunkt aus `tools/extract/data/districts.json` bei Welt `(−511, 254)` ergibt über `chunkForWorld` `(i = 1, j = 8)` und damit `B9`; der höchste Turm, das Southeast Financial Center bei `(−461, −460)`, ergibt `C12`. An der bestehenden Funktion nachgerechnet. (Korrigiert nach Task 4: `(−511, 254)` ist der Mittelpunkt der westlichen Hochhäuser, nicht der Hochhauskern selbst.)
**Ohne Browser (vitest), für `mapMath.js`:**
- `worldToMap` und `mapToWorld` an bekannten Punkten: dem früheren Spawn `(1699, 3, −743)` und alle vier Ecken des Ausschnitts; dazu Hin- und Rückweg ergibt wieder den Ausgangspunkt. Ein Test, der eine gespiegelte Achse sicher fängt.
- `worldToMap` und `mapToWorld` an bekannten Punkten: dem früheren Spawn `(1699, 3, −743)` und allen vier Ecken des Ausschnitts; dazu Hin- und Rückweg ergibt wieder den Ausgangspunkt. Ein Test, der eine gespiegelte Achse sicher fängt.
- Zoomgrenzen: nie kleiner als „ganzer Ausschnitt", nie größer als vierfach; Verschiebung bleibt innerhalb des Kartenrands.
- Klick gegen Ziehen an der 4-px-Schwelle, knapp darunter und knapp darüber, und ein Ziehen, das zum Startpunkt zurückkehrt.
- `nearestWalkable` an einer künstlichen Maske: begehbarer Treffer landet in der Mitte seines Pixels; ein Gebäudepixel neben einer Straße findet die Straße; nur Wasser im Umkreis ergibt kein Ergebnis; ein Treffer knapp außerhalb von 20 m zählt nicht.
+7 -7
View File
@@ -38,7 +38,7 @@ box = [[-885, 2045], [-833, 1995]]
[[annotation]]
name = "Schild 97 in B2"
kind = "symbol"
kind = "region"
box = [[-551, 2199], [-497, 2147]]
[[annotation]]
@@ -153,7 +153,7 @@ box = [[-861, 1572], [-797, 1521]]
[[annotation]]
name = "Schild 97 in B4"
kind = "symbol"
kind = "region"
box = [[-652, 1601], [-592, 1539]]
[[annotation]]
@@ -238,7 +238,7 @@ box = [[1440, 1336], [1515, 1289]]
[[annotation]]
name = "Schild 97 in A6"
kind = "symbol"
kind = "region"
box = [[-869, 1230], [-817, 1184]]
[[annotation]]
@@ -278,7 +278,7 @@ box = [[-1000, 830], [-945, 774]]
[[annotation]]
name = "Schild 97 in A7"
kind = "symbol"
kind = "region"
box = [[-945, 933], [-893, 883]]
[[annotation]]
@@ -338,7 +338,7 @@ box = [[-670, 530], [-613, 490]]
[[annotation]]
name = "Schild 404 in B8"
kind = "symbol"
kind = "region"
box = [[-626, 618], [-567, 575]]
[[annotation]]
@@ -478,7 +478,7 @@ box = [[1905, -60], [1934, -131]]
[[annotation]]
name = "Schild 397 in B12"
kind = "symbol"
kind = "region"
box = [[-685, -346], [-625, -397]]
[[annotation]]
@@ -493,7 +493,7 @@ box = [[-313, -398], [-169, -433]]
[[annotation]]
name = "Schild 397 in D12"
kind = "symbol"
kind = "region"
box = [[-146, -410], [-87, -460]]
[[annotation]]
+11 -3
View File
@@ -24,7 +24,7 @@ from skimage.morphology import disk
from .classify import PALETTE
from .coords import SLICE, chunk_for_world, chunk_name
from .inpaint import RebuildStats, fill_nearest, is_thin, rebuild
from .inpaint import RebuildStats, add_buildings, fill_nearest, is_thin, rebuild
KINDS = ("text_white", "text_red", "marker", "symbol", "street_name", "region")
MARGIN = {"text_white": 7, "text_red": 7, "marker": 3, "symbol": 4, "street_name": 1, "region": 0}
@@ -406,7 +406,7 @@ def remove_annotations(mosaic: np.ndarray, labels: np.ndarray, annotations: Anno
owners.setdefault(int(cid), entry)
touched.append(ids)
generated = []
generated, rebuilt = [], []
h, w = labels.shape
for cid, sl in enumerate(ndi.find_objects(components), start=1):
if sl is None:
@@ -419,9 +419,17 @@ def remove_annotations(mosaic: np.ndarray, labels: np.ndarray, annotations: Anno
continue
mask = np.zeros(labels.shape, dtype=bool)
mask[sl] = piece
result = rebuild(labels, mask, union)
result = rebuild(labels, mask, union, add=False)
owner = owners[cid]
owner.stats = result.stats if owner.stats is None else _merged(owner.stats, result.stats)
rebuilt.append((cid, sl))
# Ergänzt wird erst, wenn alle Masken aufgebaut sind: sonst zählten Buchstabenkonturen noch nicht
# aufgebauter Masken als Straße und teilten Blöcke, und das Ergebnis hinge von der Reihenfolge ab.
for cid, sl in rebuilt:
mask = np.zeros(labels.shape, dtype=bool)
mask[sl] = components[sl] == cid
result = add_buildings(labels, mask, union)
owners[cid].stats = _merged(owners[cid].stats, result.stats)
generated += [Polygon([(SLICE["x0"] + c, SLICE["y1"] - r) for r, c in corners])
for corners in result.buildings]
for entry, ids in zip(entries, touched):
+18 -4
View File
@@ -90,12 +90,14 @@ def is_thin(mask: np.ndarray) -> bool:
return float(ndi.distance_transform_edt(np.pad(mask, 1)).max()) <= THIN_DEPTH
def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None) -> RebuildResult:
def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None,
add: bool = True) -> RebuildResult:
"""Baut das Klassenbild unter einer zusammenhängenden Maske wieder auf.
blocked sind alle Pixel, die nicht als Quelle taugen — alle Beschriftungsmasken; ohne
Angabe nur diese Maske. Reihenfolge wie in der Spec: Straßen und Wege, Flächen,
Gebäude schließen, Gebäude ergänzen.
Gebäude schließen, Gebäude ergänzen. Mit add=False endet der Aufbau nach dem Schließen;
ergänzt wird dann über add_buildings, sobald alle Masken aufgebaut sind.
"""
blocked = mask if blocked is None else blocked
stats = RebuildStats()
@@ -107,6 +109,14 @@ def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = N
_stamp(L, M, painted, road, points, halves)
_fill_areas(L, M & ~painted, B)
_close_buildings(L, M, B, painted, stats)
corners = _add_buildings(labels, mask, blocked, stats) if add else []
return RebuildResult(stats, corners)
def add_buildings(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None) -> RebuildResult:
"""Ergänzt Gebäude unter einer schon aufgebauten Maske; die Statistik trägt nur added und note."""
blocked = mask if blocked is None else blocked
stats = RebuildStats()
corners = _add_buildings(labels, mask, blocked, stats)
return RebuildResult(stats, corners)
@@ -535,13 +545,17 @@ def _window(mask, margin):
def _rectangle(rows, cols) -> Polygon:
"""Gedrehtes Hüllrechteck um Pixel (Zeilen, Spalten) als Polygon in (Spalte, Zeile), mit Pixelkante."""
"""Gedrehtes Hüllrechteck um Pixel (Zeilen, Spalten) als Polygon in (Spalte, Zeile), mit Pixelkante.
Liegen die Pixel auf einer Linie, ist das Hüllrechteck eine Linie; eckige Enden machen daraus
wieder ein Rechteck mit vier Ecken statt einer Kapsel.
"""
points = MultiPoint(np.column_stack([cols, rows]).astype(float))
with warnings.catch_warnings():
# Siehe buildings.regularize(): harmlose GEOS-Warnung bei achsenparallelen Formen.
warnings.filterwarnings("ignore", message=r".*oriented_envelope.*", category=RuntimeWarning)
envelope = points.minimum_rotated_rectangle
return envelope.buffer(0.5, join_style="mitre")
return envelope.buffer(0.5, cap_style="square", join_style="mitre")
def _sides(rect: Polygon) -> tuple[float, float]:
+1
View File
@@ -25,6 +25,7 @@ def main() -> None:
labels = drop_map_labels(labels)
removal = remove_annotations(mosaic, labels, load_annotations(Path("data/annotations.toml")))
labels = removal.labels
labels = drop_map_labels(labels) # Inselreste, die erst das Entfernen im Wasser hinterlässt
print(removal.report())
if unknown_share > 0.05:
raise SystemExit(f"{unknown_share:.1%} der Pixel unbekannt. Palette kalibrieren, nicht die Grenze anheben.")
+51 -1
View File
@@ -1,4 +1,5 @@
import numpy as np
from scipy import ndimage as ndi
from shapely.geometry import box
from leonida.annotations import (Annotation, AnnotationList, Grid, annotation_mask, candidates, grid_mask,
@@ -6,7 +7,7 @@ from leonida.annotations import (Annotation, AnnotationList, Grid, annotation_ma
from leonida.classify import CLASSES, classify
PARK, ROAD, URBAN = (192, 216, 128), (80, 80, 80), (216, 216, 216)
WHITE, OUTLINE = (248, 248, 248), (80, 88, 96)
WHITE, OUTLINE, BUILDING = (248, 248, 248), (80, 88, 96), (176, 176, 176)
def picture(h=200, w=300, colour=URBAN):
@@ -154,6 +155,24 @@ def test_the_grid_is_closed_before_anything_is_rebuilt():
assert removal.grid_lines == 7 # Spalten 0, 100, 200, 300 und Zeilen 0, 100, 200
def test_a_grid_line_through_an_entry_leaves_no_strip_in_it():
img = picture(colour=PARK)
img[:, 98:102] = ROAD # Gitterlinie, als Straße gelesen, quer durch den Schriftzug
letter(img, (80, 120), (60, 140))
annotations = AnnotationList([entry("text_white", (60, 141), (40, 161))], grid=Grid(100, 6))
removal = remove_annotations(img, classify(img), annotations)
assert (removal.labels == CLASSES["land"]).all() # erst danach aufgebaut, liefe dort ein Straßenstreifen
def test_a_thin_mask_takes_the_nearest_class_and_leaves_no_slit():
img = picture()
img[60:140, 100:200] = BUILDING
line = entry("region", (99, 102), (90, 211), name="Zeigerlinie") # 3 m breit, quer über das Gebäude
removal = remove_annotations(img, classify(img), AnnotationList([line]))
assert (removal.labels[60:140, 100:200] == CLASSES["gebaeude"]).all()
assert (removal.labels[99:102, 90:100] == CLASSES["stadt"]).all()
def test_entries_sharing_one_mask_are_reported_together():
img = picture()
img[:, 140:160] = ROAD
@@ -165,6 +184,37 @@ def test_entries_sharing_one_mask_are_reported_together():
assert "#2 Rechts · A1 · Maske" in report and "gemeinsam mit #1" in report
class ReversedComponents:
"""scipy.ndimage für annotations.py, nur nummeriert label() die Flächen rückwärts."""
def __getattr__(self, name):
return getattr(ndi, name)
@staticmethod
def label(image, structure=None):
numbers, count = ndi.label(image, structure=structure)
return np.where(numbers > 0, count + 1 - numbers, 0), count
def test_added_buildings_do_not_depend_on_the_order_of_the_masks(monkeypatch):
img = picture(300, 400)
img[20:40, :] = ROAD
img[260:280, :] = ROAD
img[:, 20:40] = ROAD
img[:, 360:380] = ROAD
for top in (60, 100, 140, 180):
for left in (60, 120):
img[top:top + 20, left:left + 30] = BUILDING # links sichtbar bebaut
letter(img, (43, 257), (180, 190)) # Kontur in Straßenfarbe teilt den Block bis zum Aufbau
annotations = AnnotationList([entry("text_white", (30, 271), (165, 206), index=1, name="Balken"),
entry("region", (60, 241), (220, 341), index=2, name="Rest")])
forward = remove_annotations(img, classify(img), annotations)
monkeypatch.setattr("leonida.annotations.ndi", ReversedComponents())
backward = remove_annotations(img, classify(img), annotations)
assert forward.generated # im verdeckten Teil rechts entstehen Gebäude
assert (forward.labels == backward.labels).all()
def test_buildings_mostly_inside_added_rectangles_are_marked():
heights = [{"height": 9.0, "source": "district"}, {"height": 12.0, "source": "district"}]
marked = mark_generated([box(1, 1, 9, 9), box(6, 0, 16, 10)], heights, [box(0, 0, 10, 10)])
+32 -2
View File
@@ -1,8 +1,9 @@
import numpy as np
import pytest
from scipy import ndimage as ndi
from leonida.classify import CLASSES
from leonida.inpaint import fill_nearest, is_thin, rebuild
from leonida.inpaint import _rectangle, _sides, fill_nearest, is_thin, rebuild
STADT, PARK, WATER = CLASSES["stadt"], CLASSES["gruen"], CLASSES["wasser"]
ROAD, PATH, BUILDING = CLASSES["strasse"], CLASSES["weg"], CLASSES["gebaeude"]
@@ -133,6 +134,18 @@ def test_a_path_stays_a_path_and_the_street_wins_the_crossing():
assert (labels[120:138, 146:149] == PATH).all()
def test_a_path_runs_through_to_a_stub_too_short_for_a_direction():
labels = city()
labels[:80, 145:150] = PATH
labels[120:134, 155:160] = PATH # kurzes, versetztes Stück: kein Straßenende
mask = box_mask(labels.shape, slice(80, 120), slice(100, 200))
scribble(labels, mask)
result = rebuild(labels, mask)
assert (result.stats.connected, result.stats.extended, result.stats.dead_ends) == (1, 0, 0)
paths, count = ndi.label(labels == PATH)
assert count == 1 # oberer Weg und Stück hängen zusammen
def test_park_and_water_under_text_stay_what_they_are():
labels = city()
labels[:, :150] = PARK
@@ -203,9 +216,12 @@ def test_a_hidden_block_part_gets_the_density_of_the_visible_part():
def test_added_buildings_keep_setback_gaps_and_stay_in_the_hidden_part():
labels, mask = block_city()
scribble(labels, mask)
before = labels.copy()
result = rebuild(labels, mask)
assert (labels[~mask] == before[~mask]).all()
for corners in result.buildings: # Maske: Pixel 60–240 × 220–340 samt Pixelkante
assert all(59.5 <= r <= 240.5 and 219.5 <= c <= 340.5 for r, c in corners)
new = (labels == BUILDING) & mask
assert not (new & ~mask).any()
road_distance = ndi.distance_transform_edt(~np.isin(labels, [ROAD, PATH]))
assert road_distance[new].min() >= 15 # Straßenabstand der Nachbarn, auf 15 m begrenzt
pieces, count = ndi.label(new)
@@ -223,6 +239,14 @@ def test_no_buildings_are_added_in_a_park():
assert result.stats.added == 0
def test_no_buildings_are_added_in_water():
labels, mask = block_city()
labels[40:260, 200:360] = WATER
scribble(labels, mask)
result = rebuild(labels, mask)
assert result.stats.added == 0
def test_without_three_neighbours_nothing_is_added():
labels, mask = block_city()
labels[labels == BUILDING] = STADT
@@ -242,3 +266,9 @@ def test_rebuild_is_deterministic():
a, b = rebuild(first, mask), rebuild(second, mask)
assert (first == second).all()
assert a.buildings == b.buildings
def test_pixels_on_one_line_give_a_rectangle_with_four_corners():
rect = _rectangle(np.full(10, 5), np.arange(10, 20)) # 10 Pixel in einer Zeile
assert len(rect.exterior.coords) == 5 # vier Ecken und der Schlusspunkt
assert _sides(rect) == pytest.approx((1.0, 10.0))
BIN
View File
Binary file not shown.