fix(import): Foto-Generations-Shift beim Stammbaum-Import beheben

Fotos liegen in den Stammbaum-xlsx je nach Datei links, rechts oder auf der
Namenszelle. Die alte Seiten-Erkennung hing an festen Spalten (col 1 / col 4):
Blätter ohne solche Anker wurden als Rechts-Layout fehlgedeutet, wodurch jedes
Foto eine Generation zum Probanden hin verrutschte. Folge: Kazuya trug das Foto
seines Vaters (Wilbur), Wilbur das seines Großvaters (Elay), Elay hatte keins.

Fix (extract._attach_photos): beide Interpretationen (links/rechts) durchrechnen
und die wählen, die jedes Foto am nächsten an die Namensspalte seines Tiers legt
(minimale horizontale Fehlausrichtung). Diagnose über alle 42 Dateien: keine ist
echt rechts-seitig; ~21 waren fehlklassifiziert. Selbstjustierend statt fester
Spalten-Annahme.

Regressionstest in test_extract.py: Kazuya ohne Foto, Wilbur/Elay mit ihren
eigenen (gated auf vorhandene Quelldatei).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:49:38 +02:00
parent 070a896072
commit 18efab6996
3 changed files with 57 additions and 20 deletions

View File

@@ -341,14 +341,22 @@ def _attach_photos(z, sheets, animals, fname):
if not anchors:
return
# Detect left_style: whether photo is to the left or to the right of the name cell
a_count = sum(1 for a in anchors if a[1] == 1)
d_count = sum(1 for a in anchors if a[1] == 4)
has_proband_in_d = any(a[1] == 4 and 50 <= a[2] <= 70 for a in anchors)
left_style = a_count > 0 or (d_count > 0 and not has_proband_in_d)
by_gen = {}
for a in animals:
by_gen.setdefault(a["_gen"], []).append(a)
media_dir = os.path.join(OUT, "photos")
col_offset = 0 if any(a["_col"] == 2 for a in animals) else 3
def get_anchor_gen(colnum, offset=0):
effective_col = colnum - offset
# A photo sits one column either side of (or on) its animal's name cell;
# the generation is read from the photo's column. Whether photos are LEFT or
# RIGHT of the name varies, and the old fixed-column heuristic (col 1 / col 4)
# misclassified sheets that have neither, shifting every photo one generation
# toward the proband (e.g. „Stammbaum von Kazuya“ / „Picus Son“: Kazuya wore
# his father's photo, the father his grandfather's). Instead, try BOTH
# interpretations and keep the one that places photos closest to their
# assigned animal's name column (minimal horizontal misalignment).
def get_anchor_gen(colnum, left_style):
effective_col = colnum - col_offset
if left_style:
if effective_col <= 3: return 0
if effective_col <= 6: return 1
@@ -364,18 +372,26 @@ def _attach_photos(z, sheets, animals, fname):
if effective_col <= 16: return 4
return 5
by_gen = {}
for a in animals:
by_gen.setdefault(a["_gen"], []).append(a)
media_dir = os.path.join(OUT, "photos")
col_offset = 0 if any(a["_col"] == 2 for a in animals) else 3
for i, (sp, col, row, media) in enumerate(anchors):
g = get_anchor_gen(col, col_offset)
cands = by_gen.get(g, [])
if not cands:
# fall back to nearest animal by row across all gens
cands = animals
target = min(cands, key=lambda a: abs((a["_row"] - row) - 10)) if cands else None
def targets_for(left_style):
out = []
for (sp, col, row, media) in anchors:
cands = by_gen.get(get_anchor_gen(col, left_style), []) or animals
out.append(min(cands, key=lambda a: abs((a["_row"] - row) - 10)) if cands else None)
return out
def mean_col_offset(targets):
vals = [abs(col - t["_col"]) for (sp, col, row, m), t in zip(anchors, targets) if t]
return sum(vals) / len(vals) if vals else 0.0
left_targets = targets_for(True)
right_targets = targets_for(False)
targets = (
left_targets
if mean_col_offset(left_targets) <= mean_col_offset(right_targets)
else right_targets
)
for (sp, col, row, media), target in zip(anchors, targets):
if not target:
continue
ext = os.path.splitext(media)[1] or ".img"

View File

@@ -10,7 +10,7 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 476
- Konflikte zur Klärung: **7**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **380**
- Fotos zugeordnet: **441**
- Fotos zugeordnet: **435**
- Würfe aus der Wurfchronik: **752**
- Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 168, nur über Geburtsdatum: 102; mehrdeutig: 17)
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)

View File

@@ -396,6 +396,27 @@ if os.path.exists(danako_path):
else:
print("\nWarning: Danako stammbaum file not found, skipping integration checks.")
# --- Stammbaum von Kazuya: photo generation-shift regression (PHOTO-LEFT-STYLE) ---
# This sheet has neither col-1 nor col-4 image anchors, so the old fixed-column
# heuristic misread it as right-style and shifted every photo one generation
# toward the proband: Kazuya wore his father's (Wilbur's) photo, Wilbur wore the
# grandfather's (Elay's). The fix picks the layout that places photos closest to
# their animal's name column → photos land on the correct generation.
kazuya_path = r"C:\Users\gulum\dev\Sttammbäume\Stammbaum von Kazuya.xlsx"
if os.path.exists(kazuya_path):
print(f"\nFound Kazuya stammbaum, running photo generation-shift validation...")
kz = {a["name"]: a for a in e.extract_stammbaum(kazuya_path)}
if "Kazuya" in kz:
check("Kazuya (proband) has NO photo of his own", kz["Kazuya"]["photos"] == [])
if "Wilbur" in kz:
check("Wilbur (father) gets his own photo (image7), not the grandfather's",
kz["Wilbur"]["photos"] == ["photos/wilbur-19032017/image7.jpeg"])
if "Elay" in kz:
check("Elay (grandfather) gets his own photo (image2)",
kz["Elay"]["photos"] == ["photos/elay-16032016/image2.jpeg"])
else:
print("\nWarning: Kazuya stammbaum file not found, skipping photo-shift checks.")
if failed:
print(f"\n{failed} test(s) FAILED")
sys.exit(1)