feat(extract): Pixel-Klassifizierung mit Lab-Palette
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d40d96a800
commit
593ccf217f
2 files changed
+79
No files matched your search
@@ -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()}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user