| """ |
| 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 |
|
|
| |
| try: |
| from classical_cv_detector import _READER as _reader |
| except ImportError: |
| _reader = easyocr.Reader(["en"], gpu=False, verbose=False) |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
| _MEAS_RE = re.compile(r"\d+'\-\d+\"") |
| _TYPEMARK_RE = re.compile(r"^[A-Z]\d{1,2}$") |
| _TEXT_ONLY_RE = re.compile(r"^[A-Za-z@/\s]+$") |
|
|
| |
| _TYPEMARK_FIXES = { |
| "41": "H1", |
| "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] |
| |
| |
| |
| 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: |
| |
| 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: |
| |
| 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 |
|
|
|
|
| |
|
|
| 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 |
| """ |
| |
| 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"], |
| } |
|
|
| |
| 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"]) |
|
|
| |
| 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 |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| 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}") |
|
|
| |
| 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.") |
|
|
| |
| |
| |
| |
| 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) |
|
|
| |
| if type_col in df.columns: |
| |
| df[type_col] = df[type_col].map( |
| lambda v: _correct_typemark(v) if isinstance(v, str) else v |
| ) |
|
|
| |
| |
| 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) |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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) |
|
|
| |
| |
| 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.") |
|
|
| |
| 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)} |
|
|
|
|
| |
|
|
| |
| _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() |
| |
| if any(kw in text_upper for kw in _HEX_CODE_HEADERS): |
| return text_upper, box["cx"] |
| |
| 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.") |
|
|
| |
| |
| |
| |
| |
| 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) |
|
|
| |
| |
| |
| |
| |
| |
| |
| _HEX_CODE_RE = re.compile(r"^[A-Z][0-9]{0,2}$") |
| records: list[dict] = [] |
| for row in data_rows: |
| if not row: |
| continue |
| |
| 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() |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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: |
| |
| |
| |
| |
| other = [b["text"] for b in row_sorted[1:]] |
| print(f" β Legend row skipped (unrecognisable code {code_text!r}): " |
| f"other cells = {other}") |
| continue |
|
|
| records.append({"WINDOW LETTER": code_text}) |
|
|
| df = pd.DataFrame(records).drop_duplicates("WINDOW LETTER").reset_index(drop=True) |
|
|
| |
| 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)} |
|
|
|
|
| |
|
|
| 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}'") |
|
|