Files
gta6/tools/extract/tests/test_annotations_remove.py
T
2026-09-15 21:05:18 +02:00

223 lines
10 KiB
Python

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,
mark_generated, remove_annotations)
from leonida.classify import CLASSES, classify
PARK, ROAD, URBAN = (192, 216, 128), (80, 80, 80), (216, 216, 216)
WHITE, OUTLINE, BUILDING = (248, 248, 248), (80, 88, 96), (176, 176, 176)
def picture(h=200, w=300, colour=URBAN):
img = np.zeros((h, w, 3), dtype=np.uint8)
img[:] = colour
return img
def entry(kind, rows, cols, index=1, name="Test"):
"""Eintrag, dessen Rahmen genau die Pixelzeilen rows und -spalten cols umfasst (halboffen)."""
(r0, r1), (c0, c1) = rows, cols
return Annotation(index, name, kind, (-1000.0 + c0, 2500.0 - (r1 - 1), -1000.0 + c1 - 1, 2500.0 - r0), None)
def letter(img, rows, cols, outline=3):
(r0, r1), (c0, c1) = rows, cols
img[r0 - outline:r1 + outline, c0 - outline:c1 + outline] = OUTLINE
img[r0:r1, c0:c1] = WHITE
def test_candidates_follow_the_colour_rules():
colours = {"white": WHITE, "urban": URBAN, "red": (248, 112, 112), "speculative": (160, 72, 72),
"magenta": (200, 0, 200), "black": (20, 20, 20), "water": (48, 144, 192), "road": ROAD,
"park": PARK, "light": (230, 230, 230), "building": (176, 176, 176)}
img = np.array([list(colours.values())], dtype=np.uint8)
names = list(colours)
def hits(kind):
return {name for name, hit in zip(names, candidates(img, kind)[0]) if hit}
assert hits("text_white") == {"white"}
assert hits("text_red") == {"red"}
assert hits("marker") == {"magenta", "black"}
assert hits("symbol") == {"white", "magenta", "black"}
assert hits("street_name") == {"white", "urban", "light"}
def test_white_text_is_masked_together_with_its_outline():
img = picture(colour=PARK)
img[:, 140:160] = ROAD
letter(img, (80, 120), (120, 180))
found = annotation_mask(img, entry("text_white", (60, 141), (100, 201)))
mask = np.zeros(img.shape[:2], dtype=bool)
mask[found.rows, found.cols] = found.mask
assert found.applied and found.warning is None
assert mask[77:123, 117:183].all() # Füllung und Kontur
assert not mask[80:120, 107].any() # 10 m neben der Kontur ist wieder Karte
def test_text_filling_most_of_its_frame_after_growing_is_not_overfull():
img = picture()
for left in range(60, 240, 30):
letter(img, (60, 140), (left, left + 12)) # sechs Buchstabenstriche, Rahmen mit 10 m Luft
found = annotation_mask(img, entry("text_white", (47, 154), (47, 244)))
assert found.mask.mean() > 0.6
assert found.applied and found.warning is None
def test_street_names_do_not_warn_about_urban_ground_at_the_frame_edge():
img = picture()
img[:, 140:160] = ROAD
img[80:120, 146:150] = WHITE # helle Schrift auf der Fahrbahn
found = annotation_mask(img, entry("street_name", (70, 131), (130, 171)))
assert found.applied and found.warning is None
assert found.mask[20, 17] and not found.mask[20, 2]
def test_colour_touching_the_frame_edge_stays_and_is_reported():
img = picture()
letter(img, (80, 120), (60, 90))
letter(img, (80, 120), (180, 240)) # ragt über den rechten Rahmenrand
found = annotation_mask(img, entry("text_white", (60, 141), (40, 211)))
assert found.applied
assert found.warning == "angeschnitten: Rahmen prüfen"
assert found.mask[30, 35] # Buchstabe innen
assert not found.mask[30, 160] # Buchstabe am Rand bleibt
def test_letter_counters_are_filled_but_large_enclosed_areas_stay():
img = picture(420, 420)
img[20:80, 20:80] = WHITE
img[41:59, 41:59] = URBAN # 18 x 18 m großes „O"-Innere
img[150:290, 150:290] = WHITE
img[195:245, 195:245] = URBAN # 50 x 50 m: echte Fläche
small = annotation_mask(img, entry("text_white", (0, 130), (0, 130)))
large = annotation_mask(img, entry("text_white", (110, 331), (110, 331)))
assert small.applied and large.applied
assert small.mask[50, 50]
assert not large.mask[220 - 110, 220 - 110]
def test_region_masks_exactly_its_polygon():
img = picture()
triangle = ((-950.0, 2450.0), (-850.0, 2450.0), (-950.0, 2350.0))
found = annotation_mask(img, Annotation(1, "Rest", "region", None, triangle))
assert found.applied
assert abs(int(found.mask.sum()) - 5000) <= 200
mask = np.zeros(img.shape[:2], dtype=bool)
mask[found.rows, found.cols] = found.mask
assert mask[60, 60] and not mask[140, 140]
def test_empty_and_overfull_frames_are_skipped_with_a_warning():
park = annotation_mask(picture(colour=PARK), entry("text_white", (50, 100), (50, 100)))
assert not park.applied and park.warning == "leer: keine Pixel der Art text_white im Rahmen"
img = picture()
img[52:98, 52:98] = WHITE
full = annotation_mask(img, entry("text_white", (50, 100), (50, 100)))
assert not full.applied and full.warning.startswith("randvoll: Farbpixel füllen")
def test_grid_mask_puts_a_stripe_on_every_line():
mask, lines = grid_mask((30, 30), Grid(10, 2))
assert lines == 8
for index in (0, 9, 10, 19, 20, 29):
assert mask[:, index].all() and mask[index, :].all()
assert not mask[5, 5] and not mask[15, 25]
def test_removal_without_entries_changes_nothing():
labels = classify(picture())
removal = remove_annotations(picture(), labels, AnnotationList())
assert (removal.labels == labels).all() and removal.generated == []
assert removal.report() == "Beschriftungen: 0 angewendet, 0 übersprungen · Gitternetz: 0 Linien"
def test_removal_rebuilds_a_road_under_white_text():
img = picture()
img[:, 140:160] = ROAD
letter(img, (80, 120), (60, 240))
removal = remove_annotations(img, classify(img), AnnotationList([entry("text_white", (60, 141), (40, 261))]))
assert (removal.labels[80:120, 141:159] == CLASSES["strasse"]).all()
assert not (removal.labels[80:120, 60:137] == CLASSES["strasse"]).any()
report = removal.report()
assert "#1 Test · A1 · Maske" in report and "Straßen 1 verbunden" in report
def test_the_grid_is_closed_before_anything_is_rebuilt():
img = picture()
img[:, 98:102] = (140, 140, 140) # abgedunkelte Gitterlinie über Stadtfläche
labels = classify(img)
assert (labels[:, 99] == CLASSES["gebaeude"]).all()
removal = remove_annotations(img, labels, AnnotationList(grid=Grid(100, 6)))
assert (removal.labels == CLASSES["stadt"]).all()
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
letter(img, (80, 120), (60, 150))
letter(img, (80, 120), (155, 240))
first = entry("text_white", (60, 141), (40, 153), index=1, name="Links")
second = entry("text_white", (60, 141), (152, 261), index=2, name="Rechts")
report = remove_annotations(img, classify(img), AnnotationList([first, second])).report()
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)])
assert marked == 1
assert [h["source"] for h in heights] == ["annotation", "district"]