diff --git a/tools/extract/leonida/classify.py b/tools/extract/leonida/classify.py new file mode 100644 index 0000000..af1e12f --- /dev/null +++ b/tools/extract/leonida/classify.py @@ -0,0 +1,43 @@ +import numpy as np +from scipy import ndimage as ndi +from skimage.color import rgb2lab + +PALETTE = { + "wasser": (44, 103, 164), + "land": (192, 216, 128), + "gruen": (139, 195, 74), + "sand": (232, 216, 136), + "stadt": (216, 216, 216), + "gebaeude": (176, 176, 176), + "strasse": (80, 80, 80), + "weg": (192, 152, 96), + "spekulativ": (198, 96, 96), +} +CLASSES = {name: i for i, name in enumerate(PALETTE)} +UNKNOWN = 255 +MAX_DELTA_E = 14.0 # darüber gilt ein Pixel als unbekannt (Beschriftung, Icon, Kante) + + +def classify(rgb: np.ndarray, report_unknown: bool = False): + """Ordnet jedes Pixel der nächsten Palettenfarbe zu; Ausreißer werden aufgefüllt. + + Mit report_unknown=True kommt zusätzlich der Anteil der Pixel zurück, die vor dem + Auffüllen zu keiner Legendenfarbe passten. Aufgabe 6 bricht darüber bei über 5 % ab. + """ + lab = rgb2lab(rgb.astype(np.float32) / 255.0) + ref = rgb2lab(np.array([list(PALETTE.values())], dtype=np.float32) / 255.0)[0] + dist = np.linalg.norm(lab[:, :, None, :] - ref[None, None, :, :], axis=3) + labels = dist.argmin(axis=2).astype(np.uint8) + unknown = dist.min(axis=2) > MAX_DELTA_E + share = float(unknown.mean()) + if unknown.any(): + # Nächstes bekanntes Pixel übernehmen. + idx = ndi.distance_transform_edt(unknown, return_distances=False, return_indices=True) + labels = labels[tuple(idx)] + return (labels, share) if report_unknown else labels + + +def class_shares(labels: np.ndarray) -> dict[str, float]: + total = labels.size + counts = np.bincount(labels.ravel(), minlength=len(PALETTE)) + return {name: float(counts[i]) / total for name, i in CLASSES.items()} diff --git a/tools/extract/tests/test_classify.py b/tools/extract/tests/test_classify.py new file mode 100644 index 0000000..b562ec0 --- /dev/null +++ b/tools/extract/tests/test_classify.py @@ -0,0 +1,36 @@ +import numpy as np +from leonida.classify import CLASSES, PALETTE, class_shares, classify + +def block(color, size=8): + return np.full((size, size, 3), color, dtype=np.uint8) + +def test_pure_colors_hit_their_class(): + for name, color in PALETTE.items(): + labels = classify(block(color)) + assert (labels == CLASSES[name]).all(), name + +def test_jpeg_noise_still_classifies(): + rng = np.random.default_rng(0) + noisy = block(PALETTE["strasse"], 32).astype(np.int16) + noisy += rng.integers(-10, 11, noisy.shape) # JPEG-Rauschen + labels = classify(np.clip(noisy, 0, 255).astype(np.uint8)) + assert (labels == CLASSES["strasse"]).mean() > 0.98 + +def test_unknown_pixels_are_filled_from_neighbours(): + img = block(PALETTE["stadt"], 16) + img[7:9, 7:9] = (255, 0, 255) # Beschriftung, in keiner Klasse + labels = classify(img) + assert (labels == CLASSES["stadt"]).all() + +def test_reports_the_share_of_unknown_pixels(): + img = block(PALETTE["stadt"], 16) + img[7:9, 7:9] = (255, 0, 255) # 4 von 256 Pixeln passen zu keiner Farbe + labels, share = classify(img, report_unknown=True) + assert (labels == CLASSES["stadt"]).all() + assert abs(share - 4 / 256) < 1e-9 + +def test_shares_sum_to_one(): + img = np.concatenate([block(PALETTE["wasser"], 8), block(PALETTE["land"], 8)], axis=1) + shares = class_shares(classify(img)) + assert abs(sum(shares.values()) - 1.0) < 1e-9 + assert abs(shares["wasser"] - 0.5) < 1e-9