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}'")
|