File size: 23,402 Bytes
71efe81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
"""
legend_parser.py
Parse a screenshot of a window/door schedule legend table into a pandas DataFrame.

Usage:
    from legend_parser import parse_legend_image
    df = parse_legend_image("path/to/schedule_screenshot.png")

The parser uses EasyOCR to locate all text bounding boxes, clusters them
into rows by vertical proximity, then sorts each row left-to-right to
reconstruct the column structure.  No table lines need to be detected β€”
the spatial layout of text alone is sufficient.
"""

import re
from pathlib import Path

import cv2
import easyocr
import numpy as np
import pandas as pd

# Re-use the shared reader if already initialised, otherwise create one here.
try:
    from classical_cv_detector import _READER as _reader
except ImportError:
    _reader = easyocr.Reader(["en"], gpu=False, verbose=False)


# ── Core table-extraction logic ───────────────────────────────────────────────

def _extract_text_boxes(image: np.ndarray) -> list[dict]:
    """
    Run EasyOCR on the image and return a list of
    {text, x, y, w, h, cx, cy} dicts sorted top-to-bottom.
    """
    results = _reader.readtext(image, detail=1, paragraph=False)
    boxes = []
    for (pts, text, conf) in results:
        text = text.strip()
        if not text:
            continue
        xs = [p[0] for p in pts]
        ys = [p[1] for p in pts]
        x, y = int(min(xs)), int(min(ys))
        w, h = int(max(xs) - x), int(max(ys) - y)
        boxes.append({
            "text": text,
            "x": x, "y": y, "w": w, "h": h,
            "cx": x + w // 2,
            "cy": y + h // 2,
            "conf": conf,
        })
    return sorted(boxes, key=lambda b: b["cy"])


def _cluster_rows(boxes: list[dict], row_gap: int | None = None) -> list[list[dict]]:
    """
    Group boxes into rows.  Two boxes belong to the same row if their
    vertical centres are within `row_gap` pixels of each other.
    If row_gap is None it is estimated as the median text height.
    """
    if not boxes:
        return []

    if row_gap is None:
        median_h = float(np.median([b["h"] for b in boxes]))
        row_gap  = max(4, int(median_h * 0.8))

    rows: list[list[dict]] = []
    current_row: list[dict] = [boxes[0]]
    ref_cy = boxes[0]["cy"]

    for box in boxes[1:]:
        if abs(box["cy"] - ref_cy) <= row_gap:
            current_row.append(box)
        else:
            rows.append(sorted(current_row, key=lambda b: b["cx"]))
            current_row = [box]
            ref_cy = box["cy"]

    if current_row:
        rows.append(sorted(current_row, key=lambda b: b["cx"]))

    return rows


# Patterns used during column assignment
_MEAS_RE     = re.compile(r"\d+'\-\d+\"")          # dimension: 3'-6"
_TYPEMARK_RE = re.compile(r"^[A-Z]\d{1,2}$")       # type mark: A1, J2
_TEXT_ONLY_RE = re.compile(r"^[A-Za-z@/\s]+$")     # pure text (no digits/quotes)

# Known OCR substitutions in the TYPE MARK column
_TYPEMARK_FIXES = {
    "41": "H1",   # 4 mis-scanned as H
    "HI": "H1",
    "H|": "H1",
    "Il": "J1",
    "J|": "J1",
}

def _correct_typemark(text: str) -> str:
    """Apply known single-character OCR corrections to a type-mark candidate."""
    upper = text.upper().strip()
    if upper in _TYPEMARK_FIXES:
        return _TYPEMARK_FIXES[upper]
    # Single stray letter that looks like it could be the letter part of a code
    # e.g. "R" in a row where F-series codes surround it β†’ keep as-is; the
    # caller will drop it if it doesn't match ^[A-Z]\d{1,2}$ after full parsing.
    return text


def _assign_columns(
    rows: list[list[dict]],
    col_names: list[str],
    col_x_centers: list[int],
) -> list[dict]:
    """
    Content-aware column assignment:
    - Boxes matching a dimension pattern (3\'βˆ’6\") β†’ W or H only
    - Boxes matching a type-mark pattern (A1, J2) β†’ TYPE MARK only
    - Pure-text boxes (no digits or quote marks) β†’ never assigned to W or H;
      routed to the nearest text column instead.
    - Everything else β†’ nearest column by X distance.

    This prevents OCR fragments like "INTERIOR" or "OPERABLE" from landing
    in the W/H columns when they appear at an ambiguous X position.
    """
    dim_cols  = [c for c in col_names if c in ("W", "H")]
    text_cols = [c for c in col_names if c not in ("W", "H")]

    records = []
    for row in rows:
        record: dict[str, list[str]] = {c: [] for c in col_names}
        for box in row:
            text = box["text"]
            if "TYPE MARK" in col_names and _TYPEMARK_RE.match(text):
                record["TYPE MARK"].append(_correct_typemark(text))
            elif _MEAS_RE.search(text) and dim_cols:
                # Assign to the W or H column whose centre is nearest
                d_xs  = [col_x_centers[col_names.index(c)] for c in dim_cols]
                col   = dim_cols[int(np.argmin([abs(box["cx"] - x) for x in d_xs]))]
                record[col].append(text)
            elif _TEXT_ONLY_RE.match(text) and text_cols:
                # Never put pure text into a dimension column
                t_xs = [col_x_centers[col_names.index(c)] for c in text_cols]
                col  = text_cols[int(np.argmin([abs(box["cx"] - x) for x in t_xs]))]
                record[col].append(text)
            else:
                dists = [abs(box["cx"] - cx) for cx in col_x_centers]
                col   = col_names[int(np.argmin(dists))]
                record[col].append(text)
        records.append({k: " ".join(v) for k, v in record.items()})
    return records


# ── Column detection ──────────────────────────────────────────────────────────

def _detect_columns(
    header_row: list[dict],
    all_rows: list[list[dict]],
) -> tuple[list[str], list[int]]:
    """
    Infer column names and their X-centre positions from the header row,
    falling back to equal-spacing if the header can't be parsed.

    For window/door schedules the expected columns are:
      TYPE MARK | DESCRIPTION | W | H | COMMENTS
    """
    # Try to read standard schedule headers from the detected header row text
    header_text = " ".join(b["text"].upper() for b in header_row)

    standard = {
        "TYPE MARK": ["TYPE", "MARK"],
        "DESCRIPTION": ["DESCRIPTION", "DESC"],
        "W": ["W"],
        "H": ["H"],
        "COMMENTS": ["COMMENT", "NOTE", "REMARKS"],
    }

    # Match each box in the header row to a standard column name
    col_names: list[str] = []
    col_x_centers: list[int] = []

    for box in header_row:
        t = box["text"].upper()
        matched = None
        for name, keywords in standard.items():
            if any(k in t for k in keywords):
                matched = name
                break
        if matched and matched not in col_names:
            col_names.append(matched)
            col_x_centers.append(box["cx"])

    # Fallback: generic column numbering based on equally-spaced boxes
    if len(col_names) < 3:
        col_names     = [f"col_{i}" for i in range(len(header_row))]
        col_x_centers = [b["cx"] for b in header_row]

    return col_names, col_x_centers


# ── Shared header-row detection (used by both the diamond and hexagon parsers) ─

def _find_header_span(
    rows: list[list[dict]],
    is_header: "callable[[list[dict]], bool]",
    max_span: int = 2,
) -> tuple[int, int]:
    """
    Scan `rows` for the column-header row(s), returning (start, end) such
    that rows[start:end] (concatenated) is the header and rows[end:] is the
    data.

    Some schedule exports wrap column headers onto two lines (e.g. "TYPE"
    alone on one line, with "MARK" and the rest of the header row below it β€”
    see Original's "Test Legend.png"). Assuming the header is always exactly
    one row previously made column detection silently fail whenever a
    schedule wrapped its header this way β€” `_detect_columns` would see only
    one header box, decide it didn't recognise enough columns, and fall back
    to generic col_0/col_1/... names, permanently losing the type-mark
    column.

    Tries each row index in turn as a candidate header start, first alone
    then merged with the following row (up to `max_span` rows), and returns
    the first span for which `is_header(merged_boxes)` returns True. Falls
    back to treating row 0 alone as the header if nothing matches (the old
    fixed-position behaviour), so a legend image with an unrecognisable
    header still parses as before rather than raising.
    """
    for i in range(len(rows)):
        for span in range(1, max_span + 1):
            if i + span > len(rows):
                break
            merged = [b for r in rows[i:i + span] for b in r]
            if is_header(merged):
                return i, i + span
    return 0, 1


# ── Public API ────────────────────────────────────────────────────────────────

def parse_legend_image(
    image_path: str | Path,
    type_col: str = "TYPE MARK",
    skip_title_rows: int = 1,
    manual_codes: list[str] | None = None,
) -> pd.DataFrame:
    """
    Parse a schedule legend screenshot into a DataFrame.

    Parameters
    ----------
    image_path      Path to the image file (PNG, JPG, etc.).
    type_col        Name of the column that contains the type-mark code
                    (e.g. 'A1', 'J2').  Used to filter out non-data rows.
    skip_title_rows Number of title rows above the column-header row to skip
                    (e.g. the 'WINDOW SCHEDULE' banner).
    manual_codes    Optional list of type-mark codes to add if OCR missed them
                    (e.g. ['E4', 'F2', 'J1']).  Added as rows with empty
                    description/dimension fields for validation purposes.

    Returns
    -------
    pd.DataFrame with one row per legend entry and a column for each
    detected table column.  Rows where the type-mark cell is empty or
    does not look like a code (letter + digit) are dropped.
    Prints a warning for any row whose detected type-mark looks like an
    OCR misread (single letter, all-digit string, etc.).
    """
    img_path = Path(image_path)
    if not img_path.exists():
        raise FileNotFoundError(f"Image not found: {img_path}")

    image = cv2.imread(str(img_path))
    if image is None:
        raise ValueError(f"Could not read image: {img_path}")

    # Upscale small images for better OCR accuracy
    h, w = image.shape[:2]
    if max(h, w) < 1200:
        scale  = 1200 / max(h, w)
        image  = cv2.resize(image, None, fx=scale, fy=scale,
                            interpolation=cv2.INTER_CUBIC)

    boxes = _extract_text_boxes(image)
    rows  = _cluster_rows(boxes)

    if len(rows) < skip_title_rows + 2:
        raise ValueError("Too few text rows detected β€” check the image quality.")

    # Auto-detect the header row(s) (searching from skip_title_rows onward),
    # merging up to 2 consecutive rows so a header wrapped onto two lines
    # (e.g. "TYPE" alone above "MARK | DESCRIPTION | W | H | COMMENTS") is
    # still recognised as one header instead of two under-populated ones.
    search_rows = rows[skip_title_rows:]
    rel_start, rel_end = _find_header_span(
        search_rows,
        is_header=lambda boxes: len(_detect_columns(boxes, [])[0]) >= 3,
    )
    header_row = [b for r in search_rows[rel_start:rel_end] for b in r]
    data_rows  = search_rows[rel_end:]

    col_names, col_x_centers = _detect_columns(header_row, data_rows)

    records = _assign_columns(data_rows, col_names, col_x_centers)
    df      = pd.DataFrame(records, columns=col_names)

    # Drop rows where the type-mark cell is empty or is just a number
    if type_col in df.columns:
        # Apply type-mark corrections before filtering
        df[type_col] = df[type_col].map(
            lambda v: _correct_typemark(v) if isinstance(v, str) else v
        )

        # If TYPE MARK has extra words (e.g. "D1 1x2 FIXED"), the first token
        # is the real code and the rest belong in DESCRIPTION.
        def _split_typemark(row):
            tm = str(row.get(type_col, "")).strip()
            if " " in tm:
                parts = tm.split(None, 1)
                if re.match(r"^[A-Z]\d", parts[0]):
                    row = row.copy()
                    row[type_col] = parts[0]
                    desc_col = "DESCRIPTION" if "DESCRIPTION" in row else type_col
                    if desc_col != type_col:
                        existing = str(row.get(desc_col, "")).strip()
                        row[desc_col] = (parts[1] + " " + existing).strip()
            return row

        df = df.apply(_split_typemark, axis=1)

        # Surface rows dropped here instead of silently discarding them -- a
        # row skipped because its type-mark cell was empty or unreadable
        # (e.g. a missing OCR glyph, or a digit/letter misread like
        # "D2" -> "02") is exactly the kind of failure that previously only
        # surfaced after a multi-hour detection run had already finished.
        # Printing the row's other cell contents lets a human immediately
        # recognise which legend entry silently vanished.
        mask    = df[type_col].str.match(r"^[A-Z]\d", na=False)
        dropped = df[~mask]
        for _, r in dropped.iterrows():
            other = {c: r[c] for c in df.columns if c != type_col and str(r[c]).strip()}
            if other or str(r[type_col]).strip():
                print(f"  ⚠  Legend row skipped (unrecognisable {type_col}="
                      f"{r[type_col]!r}): other cells = {other}")

        df = df[mask].copy()
        df = df.reset_index(drop=True)

    # Warn about single-character or all-digit type marks that survived filtering
    # (they are likely OCR misreads that the correction pass did not cover)
    if type_col in df.columns:
        suspicious = df[~df[type_col].str.match(r"^[A-Z]\d{1,2}$", na=False)][type_col]
        if not suspicious.empty:
            print(f"  ⚠  Suspicious type marks (may be OCR misreads): "
                  f"{sorted(suspicious.tolist())} β€” check manually.")

    # Merge any manually-supplied codes that OCR missed
    if manual_codes:
        existing = set(df[type_col].tolist()) if type_col in df.columns else set()
        new_rows = [
            {c: (code if c == type_col else "") for c in df.columns}
            for code in manual_codes
            if re.match(r"^[A-Z]\d{1,2}$", code) and code not in existing
        ]
        if new_rows:
            df = pd.concat([df, pd.DataFrame(new_rows)], ignore_index=True)
            df = df.sort_values(type_col).reset_index(drop=True)
            print(f"  β„Ή  Manually added codes: {[r[type_col] for r in new_rows]}")

    return df


def valid_codes(df: pd.DataFrame, type_col: str = "TYPE MARK") -> set[str]:
    """
    Return the set of valid type-mark codes extracted from a legend DataFrame:
    a letter followed by 1-2 digits (matches shapes.diamond.CODE_RE and
    _TYPEMARK_RE above) -- e.g. "A1" or "J12".
    """
    if type_col not in df.columns:
        raise KeyError(f"Column '{type_col}' not found in DataFrame.")
    codes = df[type_col].dropna().str.strip()
    return {c for c in codes if re.match(r"^[A-Z]\d{1,2}$", c)}


# ── Hexagon / single-letter legend parsing ────────────────────────────────────

# Hexagon legend headers that identify the code column ("Window Letter", "Letter", etc.)
_HEX_CODE_HEADERS = {"WINDOW LETTER", "WINDOW NUMBER", "LETTER", "WIN LETTER", "TYPE", "MARK"}


def _detect_hexagon_columns(
    header_row: list[dict],
) -> tuple[str | None, int | None]:
    """
    Find the code column in a hexagon window-schedule header row.
    Returns (column_label, x_center) or (None, None) if not found.
    """
    for box in header_row:
        text_upper = box["text"].upper().strip()
        # Accept "LETTER", "WINDOW LETTER", "WIN LETTER" or "TYPE" as the code column
        if any(kw in text_upper for kw in _HEX_CODE_HEADERS):
            return text_upper, box["cx"]
    # Fallback: the first column (leftmost box in the header row) is often the code
    if header_row:
        return "LETTER", header_row[0]["cx"]
    return None, None


def parse_hexagon_legend_image(
    image_path,
    skip_title_rows: int = 1,
    manual_codes=None,
) -> pd.DataFrame:
    """
    Parse a hexagon/window-schedule legend image into a DataFrame.

    Hexagon schedules use single capital letters (A, B, C …) as codes,
    unlike diamond schedules which use letter+digit codes (A1, J2 …).

    Returns a DataFrame with a 'WINDOW LETTER' column containing the
    single-letter codes found in the schedule, plus any additional columns
    the OCR can identify (Width, Height, Count, etc.).
    """
    from pathlib import Path as _Path

    img_path = _Path(image_path)
    if not img_path.exists():
        raise FileNotFoundError(f"Image not found: {img_path}")

    image = cv2.imread(str(img_path))
    if image is None:
        raise ValueError(f"Could not read image: {img_path}")

    h, w = image.shape[:2]
    if max(h, w) < 1200:
        scale = 1200 / max(h, w)
        image = cv2.resize(image, None, fx=scale, fy=scale,
                           interpolation=cv2.INTER_CUBIC)

    boxes = _extract_text_boxes(image)
    rows  = _cluster_rows(boxes)

    if len(rows) < 2:
        raise ValueError("Too few text rows detected β€” check the image quality.")

    # Auto-detect the header row(s) (searching from skip_title_rows onward),
    # merging up to 2 consecutive rows so a header wrapped onto two lines is
    # still recognised as one header rather than missed entirely -- shares
    # _find_header_span with parse_legend_image (see there for why the
    # multi-row merge matters).
    search_rows = rows[skip_title_rows:]
    rel_start, rel_end = _find_header_span(
        search_rows,
        is_header=lambda boxes: any(
            kw in " ".join(b["text"].upper() for b in boxes) for kw in _HEX_CODE_HEADERS
        ),
    )
    if rel_end >= len(search_rows):
        raise ValueError("Could not locate column-header row in the legend image.")

    header_row = [b for r in search_rows[rel_start:rel_end] for b in r]
    data_rows  = search_rows[rel_end:]

    _, code_x = _detect_hexagon_columns(header_row)

    # Extract the leftmost token from each data row; keep it when it's a letter
    # optionally followed by up to 2 digits (possibly misread by OCR as
    # lowercase) β€” matches shapes.hexagon.CODE_RE. Most hexagon schedules
    # (Acama, Lexington) use a bare letter; Colorado Grand Oaks uses
    # letter+digit codes (W1..W11), so a single-letter-only pattern here
    # would truncate "W1" down to "W" and make every letter+digit code in
    # that legend fail to validate.
    _HEX_CODE_RE = re.compile(r"^[A-Z][0-9]{0,2}$")
    records: list[dict] = []
    for row in data_rows:
        if not row:
            continue
        # The code is the leftmost box (nearest to code_x if we found a header col)
        if code_x is not None:
            row_sorted = sorted(row, key=lambda b: abs(b["cx"] - code_x))
        else:
            row_sorted = sorted(row, key=lambda b: b["cx"])

        code_text = row_sorted[0]["text"].strip().upper()
        # Accept multi-char OCR fragments where the code may have been merged
        # with the next token (e.g. "A2'-6\"" β†’ "A2", "W1'-6\"" β†’ "W1"), but
        # only when the character immediately after the matched prefix is
        # NOT itself a letter -- otherwise this would truncate an unrelated
        # word into a false code (e.g. "PICTURE" -> "P", or an OCR digit/
        # letter confusion like "W4" misread as "WL" -> "W", "W10" misread
        # as "W1O" -> "W1"). In those letter-follows cases the row's leading
        # cell isn't a real code at all (or is unrecoverably garbled), so it
        # must be dropped rather than guessed at.
        if not _HEX_CODE_RE.match(code_text):
            m = re.match(r"^[A-Z][0-9]{0,2}", code_text)
            if m and not (m.end() < len(code_text) and code_text[m.end()].isalpha()):
                code_text = m.group(0)
            else:
                # Surface the skip instead of silently discarding the row --
                # this is exactly the kind of failure (missing OCR glyph,
                # digit/letter misread like "W1"->"WI") that previously only
                # showed up after a multi-hour detection run had finished.
                other = [b["text"] for b in row_sorted[1:]]
                print(f"  ⚠  Legend row skipped (unrecognisable code {code_text!r}): "
                      f"other cells = {other}")
                continue   # skip rows with no recognisable (or unrecoverably garbled) code

        records.append({"WINDOW LETTER": code_text})

    df = pd.DataFrame(records).drop_duplicates("WINDOW LETTER").reset_index(drop=True)

    # Merge manually-supplied codes
    if manual_codes:
        existing = set(df["WINDOW LETTER"].tolist())
        new_rows = [
            {"WINDOW LETTER": c.upper()}
            for c in manual_codes
            if re.match(r"^[A-Z][0-9]{0,2}$", c.upper()) and c.upper() not in existing
        ]
        if new_rows:
            df = pd.concat([df, pd.DataFrame(new_rows)], ignore_index=True)
            df = df.sort_values("WINDOW LETTER").reset_index(drop=True)

    return df


def valid_codes_hexagon(df: pd.DataFrame, type_col: str = "WINDOW LETTER") -> set[str]:
    """
    Return the set of valid hexagon codes from a legend DataFrame: a letter
    optionally followed by up to 2 digits (matches shapes.hexagon.CODE_RE) β€”
    covers both bare-letter schedules (Acama, Lexington) and letter+digit
    schedules (Colorado Grand Oaks' W1..W11).
    """
    if type_col not in df.columns:
        raise KeyError(f"Column '{type_col}' not found in DataFrame.")
    codes = df[type_col].dropna().str.strip().str.upper()
    return {c for c in codes if re.match(r"^[A-Z][0-9]{0,2}$", c)}


# ── CLI entry point ───────────────────────────────────────────────────────────

if __name__ == "__main__":
    import argparse, sys

    parser = argparse.ArgumentParser(
        description="Parse a schedule legend screenshot into a CSV table."
    )
    parser.add_argument("image",  help="Path to the legend screenshot")
    parser.add_argument("--out",  default=None,
                        help="Save parsed table to this CSV path")
    parser.add_argument("--skip", type=int, default=1,
                        help="Title rows to skip above the header (default 1)")
    args = parser.parse_args()

    try:
        df = parse_legend_image(args.image, skip_title_rows=args.skip)
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)

    print(df.to_string(index=False))
    print(f"\nValid codes: {sorted(valid_codes(df))}")

    if args.out:
        df.to_csv(args.out, index=False)
        print(f"\nSaved to '{args.out}'")