84 lines
3.8 KiB
Python
84 lines
3.8 KiB
Python
import numpy as np
|
|
from scipy import ndimage as ndi
|
|
from skimage.color import rgb2lab
|
|
|
|
PALETTE = {
|
|
"wasser": (48, 144, 192), # gemessen im Mosaik; tiles.py nennt die Kompositionsfarbe
|
|
"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 = 22.0 # darüber unbekannt; deckt den Tiefenverlauf des Wassers mit ab
|
|
|
|
|
|
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()}
|
|
|
|
|
|
NATURAL_CLASSES = ("gruen", "sand", "land")
|
|
LABEL_MAX_NATURAL = 0.02 # Anteil natuerlicher Merkmale, ab dem eine Flaeche als echt gilt
|
|
# Groesser als jeder gemessene Buchstabe (4.876-14.760 px am realen Mosaik) und weit kleiner
|
|
# als die Hauptlandmasse (5.852.263 px): ohne diese Untergrenze waere in einer Szene ohne
|
|
# echte Hauptlandmasse die groesste - und einzige - Komponente automatisch "Festland" und
|
|
# damit nie loeschbar, auch wenn sie selbst ein Schriftzug ist (siehe
|
|
# test_letter_shaped_island_becomes_water, wo genau das die einzige Komponente ist).
|
|
MAINLAND_MIN_SIZE = 15_000
|
|
|
|
|
|
def drop_map_labels(labels: np.ndarray) -> np.ndarray:
|
|
"""Beschriftungen der Originalkarte auf Wasser zuruecksetzen.
|
|
|
|
Die Schriftzuege der Karte werden pixelweise als stadt/strasse/gebaeude klassifiziert und
|
|
sind farblich von echten Flaechen nicht zu unterscheiden. Trennbar sind sie erst als
|
|
Region: ein Schriftzug im Wasser bildet eine eigene, vom Festland getrennte Nicht-Wasser-
|
|
Komponente ohne jedes natuerliche Merkmal, waehrend eine echte Insel Gruen oder Sand
|
|
traegt. Die groesste Komponente ist das Festland und bleibt unangetastet, sofern sie
|
|
ueberhaupt gross genug ist, um ueberhaupt als Festland infrage zu kommen (MAINLAND_MIN_SIZE);
|
|
sonst wird auch sie nur ueber den Anteil natuerlicher Merkmale beurteilt.
|
|
"""
|
|
solid = labels != CLASSES["wasser"]
|
|
if not solid.any():
|
|
return labels
|
|
components, count = ndi.label(solid, structure=np.ones((3, 3)))
|
|
sizes = ndi.sum(solid, components, range(1, count + 1))
|
|
largest = int(np.argmax(sizes)) + 1
|
|
mainland = largest if sizes[largest - 1] >= MAINLAND_MIN_SIZE else 0
|
|
natural = np.zeros(labels.shape, dtype=bool)
|
|
for name in NATURAL_CLASSES:
|
|
natural |= labels == CLASSES[name]
|
|
shares = ndi.mean(natural.astype(np.float32), components, range(1, count + 1))
|
|
drop = np.zeros(count + 1, dtype=bool)
|
|
for index in range(1, count + 1):
|
|
drop[index] = index != mainland and shares[index - 1] <= LABEL_MAX_NATURAL
|
|
out = labels.copy()
|
|
out[drop[components]] = CLASSES["wasser"]
|
|
return out
|