Files
gta6/tools/extract/leonida/classify.py
T

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