Spaces:
Sleeping
Sleeping
| """Heat code extraction for MTR (Material Test Report) PDFs. | |
| Public API: | |
| extract_heat_codes(file_bytes, filename=None) -> list[str] | |
| Returns a deduplicated, order-preserving list of heat codes found | |
| in the document. Empty list if none found β caller's responsibility | |
| to route to the no-heat-code fallback. | |
| Strategy (in order; results unioned, then filtered + deduped): | |
| 1. pdfplumber table extraction β look for columns whose header matches | |
| a heat-code label and pull the column's non-empty values. | |
| 2. Label-anchored regex on extracted text β handles inline "Heat No.: ABC123" | |
| and similar patterns. | |
| 3. Filename hint β if the filename contains a plausible heat-code-shaped | |
| token AND that token also appears in document text, include it. | |
| 4. OCR fallback for image-only PDFs β runs at 300 DPI with psm 6, then | |
| reruns strategies 2+3 on the OCR text. | |
| Filtering rejects: | |
| - ASTM/SA/EN/ISO/ASME spec numbers ("A105", "SA-312", "EN 10204", "ASME B16.5") | |
| - Standalone 4-digit years (2020β2030) | |
| - Common ALL-CAPS technical words (MAX, MIN, PASS, FAIL, etc.) | |
| - Template placeholders ("__", "___", "β", "-") | |
| - Single characters or 1β3 char tokens (too short to be heat codes) | |
| Designed to drop into `app/lib/utils/heat_code.py` in the email automation | |
| repo with no changes β public API and imports are repo-compatible. | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import logging | |
| import re | |
| from typing import Iterable | |
| logger = logging.getLogger(__name__) | |
| # βββ Label patterns ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Synonyms for "heat code" that may appear as labels in MTRs. Case-insensitive. | |
| # Order matters only for logging; matching is union. | |
| _LABEL_PATTERNS: tuple[str, ...] = ( | |
| r"heat\s*code", | |
| r"heat\s*no\.?", | |
| r"heat\s*nr\.?", | |
| r"heat\s*#", | |
| r"heat\s*number", | |
| r"lot\s*no\.?", # Bonney Forge and other small-mill MTRs | |
| r"lot\s*number", | |
| ) | |
| # Matches a single label occurrence, capturing label and trailing token. | |
| # Heat code token: 4-15 chars, alphanumeric with optional dashes. | |
| _LABEL_ANCHORED_RE = re.compile( | |
| r"(?i)(?P<label>" + "|".join(_LABEL_PATTERNS) + r")" | |
| r"\s*[:\-]?\s*" | |
| r"(?P<code>[A-Z0-9][A-Z0-9\-]{2,14})", | |
| ) | |
| # Standalone heat-code shape (used for filename/table validation). | |
| # Must start with alphanumeric, 4-15 chars total, alphanumeric + dashes. | |
| _CODE_SHAPE_RE = re.compile(r"^[A-Z0-9][A-Z0-9\-]{3,14}$", re.IGNORECASE) | |
| # Pipe-spec patterns to reject (e.g., "40S", "304L", "80SS" β digit-led + short). | |
| # Heat codes like "1ZZWN" survive because they have multiple letters in a row. | |
| _PIPE_SPEC_RE = re.compile(r"^\d{1,4}[A-Z]{1,2}$", re.IGNORECASE) | |
| # Phone number pattern: e.g. "910-738-8904". Common false positive when | |
| # OCR picks up the vendor's contact info from the document header/footer. | |
| _PHONE_RE = re.compile(r"^\d{3}-\d{3}-\d{4}$") | |
| # Sales order pattern: single letter followed by 6+ digits, often with | |
| # multiple leading zeros (e.g., "W000009810" is a WOI sales order #). | |
| # Real heat codes don't follow this shape β they have at most 4 digits | |
| # after a single letter, or use dashes when longer. | |
| _ORDER_NUMBER_RE = re.compile(r"^[A-Z]\d{6,}$", re.IGNORECASE) | |
| # Tokens to reject regardless of context. | |
| _SPEC_PREFIXES: tuple[str, ...] = ( | |
| "ASTM", "ASME", "SA-", "EN", "ISO", "DIN", "API", "AISI", "JIS", "NACE", | |
| "MSS", "MR0", | |
| ) | |
| # All-caps words that look code-like but are technical jargon. | |
| _REJECT_WORDS: frozenset[str] = frozenset({ | |
| "MAX", "MIN", "AVG", "STD", "NA", "N/A", "TBD", "TBC", "ACCEPT", "REJECT", | |
| "PASS", "FAIL", "PASSED", "FAILED", "OK", "ACCEPTED", | |
| "REMARK", "REMARKS", "NOTE", "NOTES", "TOTAL", "VALUE", "RESULT", | |
| "TYPE", "GRADE", "SIZE", "DESCRIPTION", "QTY", "QUANTITY", | |
| "TEMP", "SPEC", "SPECIFICATION", "REV", "DATE", "PAGE", "BLANK", "NONE", | |
| "LADLE", "PRODUCT", "MILL", "NORMALIZED", "ANNEALED", "TEMPERED", | |
| "CUSTOMER", "ITEM", "ORDER", "PO", "INVOICE", "QUANTITY", | |
| "HEAT", "TREATMENT", "COMPOSITION", "ANALYSIS", | |
| "BUNDLE", "DRAWING", "MFG", "MFGID", "POR", "TAG", | |
| "DIMENSION", "DIMENSIONAL", "VISUAL", "CHEMICAL", "MECHANICAL", "HARDNESS", | |
| "TENSILE", "YIELD", "ELONG", "ELONGATION", "REDUCTION", | |
| "RESULT", "RESULTS", "EXAMINATION", "INSPECTION", "MATERIAL", | |
| "TENSION", "LOCATION", "ABBREVIATION", "WELDED", "SEAMLESS", | |
| "MARKING", "CERTIFIED", "CERTIFICATE", "REPORT", | |
| }) | |
| # Spec number patterns to reject (e.g., A312, SA-105, A105-21). | |
| _SPEC_NUM_RE = re.compile(r"^[A-Z]{1,4}-?\d{2,5}(-\d{1,3})?$", re.IGNORECASE) | |
| # Year-like standalone tokens (2010-2039). Expanded from 2020-2030 because | |
| # MTRs reference older standards years like "ISO 9001:2015" and Tesseract | |
| # may misread small text producing tokens in this range. | |
| _YEAR_RE = re.compile(r"^(20[0-3]\d)$") | |
| # Material-spec pattern: "A106B", "A105N", "A234B" β letter A + 3 digits + | |
| # single trailing letter. ASTM material grade specs. Distinct from real | |
| # heat codes like "A8633" (letter + 4 digits, no trailing letter). | |
| _MATERIAL_SPEC_RE = re.compile(r"^A\d{3}[A-Z]$", re.IGNORECASE) | |
| # Standards-prefix patterns: anywhere in the document text, if a token | |
| # follows one of these prefixes, that token is a standards reference, NOT | |
| # a heat code. Examples: "ISO 15156", "NACE MR 0103", "ASTM A312", "EN 10204". | |
| # The capture group is the standards number we want to reject. | |
| _STANDARDS_REFERENCE_RES: tuple[re.Pattern[str], ...] = ( | |
| re.compile(r"\bISO\s+(\d{3,6})\b", re.IGNORECASE), | |
| re.compile(r"\bNACE\s*MR\s*(\d{3,6})\b", re.IGNORECASE), | |
| re.compile(r"\bMR\s*(\d{3,6})\b", re.IGNORECASE), | |
| # ASTM revisions can carry a single trailing letter (e.g. "A370-12a"), | |
| # so allow an optional lowercase letter at the end of the revision suffix. | |
| re.compile(r"\bASTM\s+([A-Z]?\d{3,5}[A-Z]?(?:-\d{1,3}[a-zA-Z]?)?)\b", re.IGNORECASE), | |
| # Capture the SA prefix when present so "SA105-21" makes it into the | |
| # reject set (not just "105-21"). Otherwise "ASME SA105-21" in the doc | |
| # text wouldn't filter "SA105-21" as an extracted candidate. | |
| re.compile(r"\bASME\s+((?:SA-?)?[A-Z]?\d{2,5}[A-Z]?(?:[.\-]\d{1,4}[a-zA-Z]?)?)\b", re.IGNORECASE), | |
| re.compile(r"\bEN\s+(\d{3,6}(?::\d{4})?)\b", re.IGNORECASE), | |
| re.compile(r"\bDIN\s+(\d{3,6})\b", re.IGNORECASE), | |
| re.compile(r"\bAPI\s+(\d+L?)\b", re.IGNORECASE), | |
| re.compile(r"\bAISI\s+(\d{3,4}[A-Z]?)\b", re.IGNORECASE), | |
| re.compile(r"\bJIS\s+(\w+)\b", re.IGNORECASE), | |
| re.compile(r"\bMSS\s*SP\s*(\d{1,4})\b", re.IGNORECASE), | |
| re.compile(r"\bPED\s+(\d{4}/\d{2}/[A-Z]+)\b", re.IGNORECASE), | |
| # "ISO 9001:2015" β both numbers are bad | |
| re.compile(r"(\d{4}):(\d{4})"), | |
| # "A106/A106M-18" or "SA106/SA106M" β slash-separated material specs | |
| re.compile(r"\b([A-Z]?\d{3,4}[A-Z]?(?:-\d{1,3})?)/([A-Z]?\d{3,4}[A-Z]?(?:-\d{1,3})?)\b"), | |
| ) | |
| # βββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def extract_heat_codes(file_bytes: bytes, filename: str | None = None) -> list[str]: | |
| """Extract heat codes from an MTR PDF. | |
| Returns a deduplicated, ordered list. Empty if no codes can be confidently | |
| extracted β caller files to a `_no_heat_code/` fallback path. | |
| """ | |
| candidates: list[tuple[str, str]] = [] # (code, source) for logging | |
| # Strategy 1: pdfplumber tables (text-layer PDFs) | |
| try: | |
| for code in _from_pdfplumber_tables(file_bytes): | |
| candidates.append((code, "table")) | |
| except Exception as e: | |
| logger.debug("pdfplumber table extraction failed: %s", e) | |
| # Strategy 2: column-position aware extraction (text-layer) | |
| # Handles MTRs where the heat label is a column header and the value sits | |
| # directly below it in the same X-range (DSI, 24L0604, A8633, KB419-1). | |
| try: | |
| for code in _from_column_position(file_bytes): | |
| candidates.append((code, "column")) | |
| except Exception as e: | |
| logger.debug("column-position extraction failed: %s", e) | |
| # Strategy 3: label-anchored regex on text-layer extract | |
| text_layer = _text_layer(file_bytes) | |
| for code in _from_label_anchored(text_layer): | |
| candidates.append((code, "text_label")) | |
| # Strategy 4: filename hint | |
| if filename: | |
| for code in _from_filename(filename, text_layer): | |
| candidates.append((code, "filename")) | |
| # Strategy 5: OCR fallback if we found nothing or text layer is thin | |
| if not candidates or len(text_layer) < 500: | |
| try: | |
| ocr_text = _ocr_text(file_bytes) | |
| for code in _from_label_anchored(ocr_text): | |
| candidates.append((code, "ocr_label")) | |
| for code in _from_ocr_column_heuristic(ocr_text): | |
| candidates.append((code, "ocr_column")) | |
| if filename: | |
| for code in _from_filename(filename, ocr_text): | |
| candidates.append((code, "ocr_filename")) | |
| except Exception as e: | |
| logger.debug("OCR fallback failed: %s", e) | |
| # Strategy 6: targeted cell-region OCR. Last-resort recovery for MTRs | |
| # where the heat code sits in a small bordered table cell that standard | |
| # OCR can't read at default DPI (e.g. WOI MTRs with 1ZZWN). We use | |
| # pytesseract's image_to_data at 600 DPI to find the bbox of the | |
| # "HEAT" / "CODE" / "LOT" label words, crop a generous region adjacent | |
| # to each label, apply aggressive preprocessing (autocontrast, | |
| # contrast x3, double sharpen), and OCR each crop with psm 6. | |
| # Skip if we already have plausible-looking candidates AND text_layer | |
| # was thick (cheap escape hatch β the strategy is slow). | |
| if not candidates or len(text_layer) < 500: | |
| try: | |
| for code in _from_targeted_cell_ocr(file_bytes): | |
| candidates.append((code, "ocr_cell")) | |
| except Exception as e: | |
| logger.debug("Targeted cell-region OCR failed: %s", e) | |
| # Strategy 7: EasyOCR (deep-learning OCR). Final fallback for documents | |
| # where Tesseract genuinely cannot read the heat code at any DPI / PSM | |
| # combination β typically small or thin-font codes inside multi-column | |
| # tables (e.g., Flotite's G18M). EasyOCR uses a CRNN-based recognizer | |
| # that handles such cases better than Tesseract's classical pipeline. | |
| # The dependency is heavy (~2GB with torch) so we lazy-import and only | |
| # invoke when (a) cheaper strategies haven't produced clean results | |
| # and (b) the document looks image-only. Falls back gracefully if | |
| # easyocr isn't installed in the runtime β code still works without it. | |
| if (not candidates or len(text_layer) < 500) and _easyocr_should_run(candidates): | |
| try: | |
| for code in _from_easyocr(file_bytes): | |
| candidates.append((code, "easyocr")) | |
| except Exception as e: | |
| logger.debug("EasyOCR fallback failed: %s", e) | |
| # OCR commonly confuses the digit '0' with the letter 'O' (e.g. "JN021-1" | |
| # becomes "JNO21-1"). None of our 14 confirmed real heat codes uses the | |
| # letter O β every one has digit 0 in positions where O would appear. So | |
| # for OCR-sourced candidates we replace Oβ0 in-place. This avoids creating | |
| # duplicate folders ("JNO21-1" AND "JN021-1") and produces the canonical | |
| # form. Text-layer candidates are left alone β they have the correct chars. | |
| for idx in range(len(candidates)): | |
| code, source = candidates[idx] | |
| if source.startswith("ocr") and ("O" in code or "o" in code): | |
| normalized = code.replace("O", "0").replace("o", "0") | |
| if normalized != code: | |
| candidates[idx] = (normalized, f"{source}_o2zero") | |
| # Build the set of tokens that appear as standards-references in the | |
| # document text. Combine text_layer + ocr_text if both were collected. | |
| combined_text = text_layer | |
| if not candidates or len(text_layer) < 500: | |
| # OCR fallback may have produced text we should also scan. | |
| try: | |
| combined_text = (text_layer or "") + "\n" + _ocr_text(file_bytes) | |
| except Exception: | |
| pass | |
| standards_tokens = _collect_standards_numbers(combined_text) | |
| # Filter + dedupe (case-insensitive on code; preserve first-seen casing) | |
| filtered = _filter_and_dedupe(candidates, standards_tokens) | |
| if logger.isEnabledFor(logging.DEBUG): | |
| logger.debug( | |
| "Heat code extraction: %d raw candidates β %d after filter. Final: %s", | |
| len(candidates), len(filtered), filtered, | |
| ) | |
| return filtered | |
| # βββ Strategy implementations ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _text_layer(file_bytes: bytes) -> str: | |
| """Extract embedded text layer via pdfplumber. '' if image-only.""" | |
| try: | |
| import pdfplumber | |
| with pdfplumber.open(io.BytesIO(file_bytes)) as pdf: | |
| return "\n".join((page.extract_text() or "") for page in pdf.pages[:3]) | |
| except Exception: | |
| return "" | |
| def _ocr_text(file_bytes: bytes) -> str: | |
| """OCR first 3 pages at 300 DPI with multiple page-segmentation modes | |
| to maximize chances of catching small/isolated heat-code text. | |
| psm 6 β uniform block of text. Good for regular tables. | |
| psm 11 β sparse text. Good for small isolated values in form boxes | |
| (e.g., MTR W000009810's "1ZZWN" in the small HEAT CODE cell). | |
| psm 4 β single column. Good for column-aligned tables (Flotite). | |
| Outputs are concatenated; downstream code is order-insensitive and | |
| dedupes candidates. | |
| """ | |
| from pdf2image import convert_from_bytes | |
| import pytesseract | |
| imgs = convert_from_bytes(file_bytes, dpi=300, first_page=1, last_page=3) | |
| parts: list[str] = [] | |
| for img in imgs: | |
| for psm in ("6", "11", "4"): | |
| try: | |
| parts.append(pytesseract.image_to_string(img, config=f"--psm {psm}")) | |
| except Exception as e: | |
| logger.debug("OCR psm=%s failed: %s", psm, e) | |
| return "\n".join(parts) | |
| def _from_pdfplumber_tables(file_bytes: bytes) -> Iterable[str]: | |
| """Find tables whose header column matches a heat label; yield column values.""" | |
| import pdfplumber | |
| label_re = re.compile(r"(?i)" + "|".join(_LABEL_PATTERNS)) | |
| with pdfplumber.open(io.BytesIO(file_bytes)) as pdf: | |
| for page in pdf.pages[:3]: | |
| for table in page.extract_tables() or []: | |
| if not table or len(table) < 2: | |
| continue | |
| # Find heat-label column index by scanning header rows. | |
| heat_col_idx: int | None = None | |
| for row in table[: min(3, len(table))]: | |
| for idx, cell in enumerate(row): | |
| if cell and label_re.search(str(cell)): | |
| heat_col_idx = idx | |
| break | |
| if heat_col_idx is not None: | |
| break | |
| if heat_col_idx is None: | |
| continue | |
| # Pull values from data rows in that column. | |
| for row in table: | |
| if heat_col_idx >= len(row): | |
| continue | |
| cell = row[heat_col_idx] | |
| if not cell: | |
| continue | |
| for token in _tokenize_cell(str(cell)): | |
| if _CODE_SHAPE_RE.match(token): | |
| yield token | |
| def _from_column_position(file_bytes: bytes) -> Iterable[str]: | |
| """Find the X-position of a heat-label word, then yield tokens whose | |
| x-center sits within Β±half the label's width on the lines directly below. | |
| Handles MTRs where the heat label is a column header (in a wide | |
| whitespace-aligned table) and the data row sits below, e.g. DSI: | |
| Part Specif. HEAT No C Si Mn ... | |
| BODY ASTM A216 WCB-2021 VC9387 0.213 ... | |
| """ | |
| import pdfplumber | |
| label_re = re.compile(r"(?i)" + "|".join(_LABEL_PATTERNS)) | |
| with pdfplumber.open(io.BytesIO(file_bytes)) as pdf: | |
| for page in pdf.pages[:3]: | |
| words = page.extract_words(use_text_flow=True) or [] | |
| if not words: | |
| continue | |
| # Group words by approximate line (round y0 to nearest 2pt). | |
| lines: dict[int, list[dict]] = {} | |
| for w in words: | |
| key = round(w["top"] / 2) | |
| lines.setdefault(key, []).append(w) | |
| sorted_keys = sorted(lines.keys()) | |
| # Look for a label word; capture its x-range; scan lines below. | |
| for i, key in enumerate(sorted_keys): | |
| line_words = lines[key] | |
| # Reconstruct the line text to test label patterns against | |
| # multi-word labels like "HEAT No" or "Heat Code". | |
| # Match individual words first, then consider their pairings. | |
| for j, word in enumerate(line_words): | |
| pair_text = word["text"] | |
| if j + 1 < len(line_words): | |
| pair_text = pair_text + " " + line_words[j + 1]["text"] | |
| if not label_re.search(pair_text): | |
| continue | |
| # X-range of the label (use bbox of just this word, or | |
| # span both words if the match was over the pair). | |
| x0 = word["x0"] | |
| x1 = line_words[j + 1]["x1"] if ( | |
| j + 1 < len(line_words) and label_re.search(pair_text) | |
| and not label_re.search(word["text"]) | |
| ) else word["x1"] | |
| label_center = (x0 + x1) / 2 | |
| half_width = max((x1 - x0) / 2, 20.0) # min 20pt to be forgiving | |
| # Scan up to 20 lines below for tokens whose center is within | |
| # half-width of the label. Multi-row MTR tables (KB419-1 | |
| # case has 6 rows; some have more) need a generous lookahead | |
| # or we miss data rows farther down the table. | |
| for next_key in sorted_keys[i + 1 : i + 21]: | |
| for nw in lines[next_key]: | |
| nw_center = (nw["x0"] + nw["x1"]) / 2 | |
| if abs(nw_center - label_center) <= half_width: | |
| tok = nw["text"].strip().strip(",;|") | |
| if _CODE_SHAPE_RE.match(tok): | |
| yield tok | |
| # Cached EasyOCR reader. Module-level so we pay the model-loading cost once | |
| # per process, not per MTR. ~50-100MB RAM + ~200MB models downloaded on first | |
| # call (cached in ~/.EasyOCR/ thereafter). | |
| _EASYOCR_READER = None | |
| def _easyocr_should_run(candidates: list) -> bool: | |
| """Decide whether the EasyOCR fallback is worth the time. Run if we | |
| have very few candidates so far (zero or one) β this is the case for | |
| image-only docs where Tesseract is struggling. Skip for docs that | |
| already look healthy.""" | |
| return len(candidates) <= 2 | |
| def _from_easyocr(file_bytes: bytes) -> Iterable[str]: | |
| """Deep-learning OCR fallback via EasyOCR. Recovers heat codes that | |
| Tesseract cannot read at any configuration (typically small / thin | |
| fonts in multi-column tables). | |
| Lazy-imports easyocr to avoid the heavy torch dependency cost when | |
| the runtime doesn't have it installed. The reader instance is cached | |
| at module scope so subsequent MTRs in the same process don't pay the | |
| model-load cost (~3-5 seconds first call, instant after). | |
| """ | |
| global _EASYOCR_READER | |
| try: | |
| import easyocr | |
| from pdf2image import convert_from_bytes | |
| import numpy as np | |
| except ImportError: | |
| logger.debug("EasyOCR not available β install easyocr to enable") | |
| return | |
| try: | |
| if _EASYOCR_READER is None: | |
| # CPU-only, English-only. Set verbose=False to suppress the | |
| # noisy progress bars during model loading. | |
| _EASYOCR_READER = easyocr.Reader(["en"], gpu=False, verbose=False) | |
| logger.info("EasyOCR reader initialized") | |
| imgs = convert_from_bytes( | |
| file_bytes, dpi=400, first_page=1, last_page=2 | |
| ) | |
| for img in imgs: | |
| arr = np.array(img) | |
| # detail=0 returns just the text strings (no boxes/scores). | |
| results = _EASYOCR_READER.readtext(arr, detail=0) | |
| for text in results: | |
| if not text: | |
| continue | |
| for tok in _tokenize_cell(text): | |
| if _CODE_SHAPE_RE.match(tok): | |
| yield tok | |
| except Exception as e: | |
| logger.debug("EasyOCR run failed: %s", e) | |
| def _from_targeted_cell_ocr(file_bytes: bytes) -> Iterable[str]: | |
| """Targeted cell-region OCR for MTRs where the heat code sits in a | |
| small bordered table cell that default-DPI Tesseract can't read. | |
| Approach: | |
| 1. Render the PDF at 600 DPI (3-4x larger than _ocr_text). | |
| 2. Use image_to_data to find the bbox of every "HEAT" / "CODE" / | |
| "LOT" / "NO." label word. | |
| 3. For each label box, crop a generous adjacent region (~1200Γ400 | |
| pixels at 600 DPI = ~2x2 inch chunk to the right/below the | |
| label). | |
| 4. Apply aggressive preprocessing: grayscale β autocontrast β | |
| contrast x3 β double sharpen. Recovers small/faint text that | |
| default OCR misses. | |
| 5. OCR each crop with psm 6 and yield heat-code-shaped tokens. | |
| Proven to recover '1ZZWN' from MTR W000009810 which was unreadable | |
| by every other strategy. | |
| """ | |
| try: | |
| from pdf2image import convert_from_bytes | |
| from PIL import ImageEnhance, ImageFilter, ImageOps | |
| import pytesseract | |
| except ImportError: | |
| return | |
| # Render at 600 DPI. Cap at 2 pages β performance vs coverage tradeoff. | |
| try: | |
| imgs = convert_from_bytes(file_bytes, dpi=600, first_page=1, last_page=2) | |
| except Exception: | |
| return | |
| # Code-indicator words. We only crop when "HEAT" is followed by one | |
| # of these in the next few words, OR when the word itself is one of | |
| # these (e.g., a standalone "CODE" header in some MTRs). This avoids | |
| # spuriously cropping around "HEAT TREATMENT" which sits adjacent to | |
| # mechanical-properties values in many MTR layouts. | |
| code_indicators = {"CODE", "NO", "NO.", "NR", "NR.", "NUMBER", "#"} | |
| lot_indicators = {"LOT"} | |
| for img in imgs: | |
| try: | |
| # Find word-level bboxes. | |
| data = pytesseract.image_to_data( | |
| img, output_type=pytesseract.Output.DICT | |
| ) | |
| except Exception: | |
| continue | |
| n = len(data.get("text", [])) | |
| for i in range(n): | |
| word = (data["text"][i] or "").strip().upper() | |
| if not word: | |
| continue | |
| # Determine if this is a valid heat-code label position. | |
| is_heat_label = False | |
| if word == "HEAT": | |
| # Check next 1-2 words for a code indicator on the same line. | |
| for k in range(1, 3): | |
| if i + k >= n: | |
| break | |
| nxt = (data["text"][i + k] or "").strip().upper().rstrip(".") | |
| if not nxt: | |
| continue | |
| # Allow column-aligned lookahead β same approximate row. | |
| if abs(int(data["top"][i + k]) - int(data["top"][i])) > 30: | |
| break | |
| if nxt in code_indicators or nxt in {"CODE", "NO", "NR", "NUMBER"}: | |
| is_heat_label = True | |
| break | |
| # Stop searching after first non-matching word. | |
| break | |
| elif word in lot_indicators: | |
| # "LOT NO." style label β check next word for NO/NUMBER | |
| if i + 1 < n: | |
| nxt = (data["text"][i + 1] or "").strip().upper().rstrip(".") | |
| if nxt in {"NO", "NUMBER", "#"}: | |
| is_heat_label = True | |
| elif word == "CODE": | |
| # Standalone CODE only valid if preceded by HEAT. | |
| if i > 0: | |
| prev = (data["text"][i - 1] or "").strip().upper() | |
| if prev == "HEAT": | |
| # Already handled by the "HEAT" branch above; skip | |
| # to avoid duplicate crops around the same label. | |
| continue | |
| if not is_heat_label: | |
| continue | |
| try: | |
| x0 = int(data["left"][i]) | |
| y0 = int(data["top"][i]) | |
| # Generous crop: ~1200Γ400 px at 600 DPI is ~2 inches. | |
| x1 = min(img.width, x0 + 1200) | |
| y1 = min(img.height, y0 + 400) | |
| crop = img.crop((x0, y0, x1, y1)) | |
| # Aggressive preprocessing. | |
| g = crop.convert("L") | |
| g = ImageOps.autocontrast(g, cutoff=1) | |
| g = ImageEnhance.Contrast(g).enhance(3.0) | |
| g = g.filter(ImageFilter.SHARPEN) | |
| g = g.filter(ImageFilter.SHARPEN) | |
| # OCR with psm 6 (uniform block of text). | |
| txt = pytesseract.image_to_string(g, config="--psm 6") | |
| except Exception: | |
| continue | |
| # Yield heat-code-shaped tokens from the crop. | |
| for tok in _tokenize_cell(txt): | |
| if _CODE_SHAPE_RE.match(tok): | |
| yield tok | |
| def _from_ocr_column_heuristic(ocr_text: str) -> Iterable[str]: | |
| """For OCR text: when a line contains a heat label, yield code-shaped | |
| tokens from the next 3 lines. Cheap-and-pretty-good for the cases where | |
| the OCR preserves line breaks between header and data rows. | |
| """ | |
| if not ocr_text: | |
| return | |
| label_re = re.compile(r"(?i)" + "|".join(_LABEL_PATTERNS)) | |
| # Section headers that mark the end of a "heat code" data region. | |
| # When we hit a line containing one of these (and no heat-code-shaped | |
| # tokens), stop scanning β we've left the heat code table and entered | |
| # a different section (mechanical properties, chemical analysis, etc.) | |
| # that contains tensile-strength numbers, yield values, etc. that look | |
| # heat-code-shaped but are not. | |
| section_break_re = re.compile( | |
| r"\b(?:MECHANICAL|CHEMICAL|TENSILE|HARDNESS|IMPACT|HYDROSTATIC|" | |
| r"PROPERTIES|COMPOSITIONS?|TREATMENT|ANALYSIS|BEND|EDDY|MICROSTRUCTURE|" | |
| r"REMARKS?|NOTES?|" | |
| # Address-block markers: catches lines like "4815 West 5th Street | |
| # Lumberton NC 28358" that contain ZIP codes / street numbers OCR'd | |
| # as heat-code-shaped tokens. | |
| r"STREET|AVENUE|ROAD|BOULEVARD|DRIVE|HIGHWAY|" | |
| r"WEBSITE|EMAIL|PHONE|FAX|TEL)\b", | |
| re.IGNORECASE, | |
| ) | |
| lines = ocr_text.splitlines() | |
| for i, line in enumerate(lines): | |
| if not label_re.search(line): | |
| continue | |
| # Look at the next 20 non-empty lines OR until we hit a section | |
| # break. Multi-row MTR tables can have 6+ data rows (KB419-1 case); | |
| # a tight lookahead drops rows past the first few. The section- | |
| # break stop prevents this wider lookahead from leaking into | |
| # downstream sections like mechanical/chemical properties. | |
| looked = 0 | |
| for j in range(i + 1, min(i + 40, len(lines))): | |
| nxt = lines[j].strip() | |
| if not nxt: | |
| continue | |
| # If this line contains a section-break keyword, we've left the | |
| # heat-code data region β stop scanning unconditionally. Words | |
| # like "CHEMICAL", "MECHANICAL", "TENSILE" themselves match the | |
| # code-shape regex but are clearly section markers, not heat | |
| # codes. | |
| if section_break_re.search(nxt): | |
| break | |
| looked += 1 | |
| if looked > 20: | |
| break | |
| for tok in _tokenize_cell(nxt): | |
| if _CODE_SHAPE_RE.match(tok): | |
| yield tok | |
| def _from_label_anchored(text: str) -> Iterable[str]: | |
| """Yield candidates following 'Heat No.', 'Lot No.', etc.""" | |
| if not text: | |
| return | |
| for m in _LABEL_ANCHORED_RE.finditer(text): | |
| yield m.group("code") | |
| def _from_filename(filename: str, doc_text: str) -> Iterable[str]: | |
| """If filename contains a heat-code-shaped token that appears (exactly or | |
| fuzzily) in document text, yield it. | |
| Fuzzy match handles OCR-mangled docs where e.g. "SD55822" in the filename | |
| shows up as "S055822" in the OCR text (Dβ0 confusion). Uses simple | |
| same-length / one-edit-away check (good enough for OCR drift). | |
| """ | |
| # Split filename on whitespace and underscore ONLY β preserve dashes so | |
| # codes like "Y211213D40-1" and "KB419-1" survive intact. | |
| base = filename.rsplit(".", 1)[0] # drop .pdf | |
| tokens = re.split(r"[\s_]+", base) | |
| text_upper = doc_text.upper() if doc_text else "" | |
| for tok in tokens: | |
| if not _CODE_SHAPE_RE.match(tok): | |
| continue | |
| tok_upper = tok.upper() | |
| if tok_upper in text_upper: | |
| yield tok | |
| elif _fuzzy_token_in_text(tok_upper, text_upper, max_edits=2): | |
| yield tok | |
| def _fuzzy_token_in_text(token: str, text: str, max_edits: int = 2) -> bool: | |
| """True if `text` contains a same-length window within `max_edits` of `token`. | |
| Cheap-and-good-enough: scans every same-length substring and counts char | |
| differences. O(len(text) * len(token)) but n is small (n β€ 15) and we | |
| only call it as a fallback. | |
| """ | |
| n = len(token) | |
| if n == 0 or len(text) < n: | |
| return False | |
| for i in range(len(text) - n + 1): | |
| window = text[i : i + n] | |
| diff = sum(1 for a, b in zip(token, window) if a != b) | |
| if diff <= max_edits: | |
| return True | |
| return False | |
| def _tokenize_cell(cell_text: str) -> list[str]: | |
| """Split a table cell into candidate tokens, preserving dashes.""" | |
| return [t.strip() for t in re.split(r"[\s,;|]+", cell_text) if t.strip()] | |
| def _collect_standards_numbers(doc_text: str) -> set[str]: | |
| """Scan document text for standards references (ISO 15156, NACE MR 0103, | |
| ASTM A312, EN 10204, etc.) and return the set of standard-number tokens | |
| that should never be treated as heat codes. | |
| Returned tokens are uppercased and stripped of leading non-alphanumerics | |
| so they can be compared directly against `code.upper()` in the filter. | |
| """ | |
| bad: set[str] = set() | |
| if not doc_text: | |
| return bad | |
| for pat in _STANDARDS_REFERENCE_RES: | |
| for m in pat.finditer(doc_text): | |
| for grp in m.groups(): | |
| if not grp: | |
| continue | |
| tok = re.sub(r"^[^A-Za-z0-9]+", "", grp).upper() | |
| if tok: | |
| bad.add(tok) | |
| # Also reject the no-dash form (e.g., "A106-18" β also drop "A106") | |
| no_dash = tok.split("-")[0] | |
| if no_dash != tok: | |
| bad.add(no_dash) | |
| return bad | |
| def _filter_and_dedupe( | |
| candidates: list[tuple[str, str]], | |
| standards_tokens: set[str] | None = None, | |
| ) -> list[str]: | |
| """Apply rejection rules + dedupe case-insensitively, preserving order. | |
| Also drops substring duplicates: if 'KB419' and 'KB419-1' are both present, | |
| keep only the longer one (avoid over-counting when codes get split). | |
| `standards_tokens` is an optional set of uppercased tokens that were | |
| detected as standards references in the document text β these are rejected | |
| regardless of their shape.""" | |
| standards_tokens = standards_tokens or set() | |
| seen: dict[str, str] = {} # upper -> original-cased | |
| for code, _source in candidates: | |
| code = code.strip() | |
| upper = code.upper() | |
| if upper in seen: | |
| continue | |
| if upper in standards_tokens: | |
| continue | |
| if not _is_plausible_heat_code(code): | |
| continue | |
| seen[upper] = code | |
| # Substring dedup: drop any code that is a strict prefix OR strict suffix | |
| # of another. Prefix case handles e.g. KB419 / KB419-1 splits. Suffix case | |
| # handles OCR-truncation: "N021-1" is the suffix of "JN021-1" β happens | |
| # when Tesseract drops the leading letter of a heat code. | |
| uppers = list(seen.keys()) | |
| keep = set(uppers) | |
| for u in uppers: | |
| for other in uppers: | |
| if u == other or len(other) <= len(u): | |
| continue | |
| if other.startswith(u) or other.endswith(u): | |
| keep.discard(u) | |
| break | |
| return [seen[u] for u in uppers if u in keep] | |
| def _is_plausible_heat_code(code: str) -> bool: | |
| upper = code.upper() | |
| if not _CODE_SHAPE_RE.match(code): | |
| return False | |
| if upper in _REJECT_WORDS: | |
| return False | |
| if _YEAR_RE.match(upper): | |
| return False | |
| # Pure-numeric tokens with 8 or more digits look like dates (YYYYMMDD) | |
| # or order/quantity values, not heat codes. The largest pure-numeric | |
| # real heat code we've seen is 5 digits (10720); 8+ digits is always | |
| # something else. | |
| if upper.isdigit() and len(upper) >= 8: | |
| return False | |
| # Pure-numeric tokens starting with '0' are not heat codes β they're | |
| # OCR artifacts from chemical-analysis decimal values (".035" read as | |
| # "0035") or from index/sequence numbers. None of our 14 confirmed | |
| # heat codes starts with a digit-0; pure-numeric reals (10720, etc.) | |
| # all start with 1β9. | |
| if upper.isdigit() and upper.startswith("0"): | |
| return False | |
| # Pure-numeric 4-digit tokens are not heat codes in our confirmed | |
| # samples β the smallest pure-numeric real code is 10720 (5 digits). | |
| # 4-digit pure numerics are typically mechanical test values | |
| # (3400 PSI, 2500 PSI, etc.) or quantities. | |
| if upper.isdigit() and len(upper) == 4: | |
| return False | |
| # Phone numbers: 910-738-8904 style. Common false positive when OCR | |
| # picks up the vendor's contact info from a document header/footer. | |
| if _PHONE_RE.match(upper): | |
| return False | |
| # Sales order numbers: single letter + 6+ digits, often W000... style. | |
| # Real heat codes max out at letter + 4 digits before a dash. | |
| if _ORDER_NUMBER_RE.match(upper): | |
| return False | |
| # Material-spec pattern (A106B, A105N, A234B) β letter A + 3 digits + | |
| # single trailing letter. Real heat codes like A8633 have 4 digits and | |
| # no trailing letter, so they're not affected. | |
| if _MATERIAL_SPEC_RE.match(upper): | |
| return False | |
| # Pipe-spec rejection (40S, 304L, 80SS-style schedule/grade designators). | |
| if _PIPE_SPEC_RE.match(upper): | |
| return False | |
| # Spec numbers β only reject when the token explicitly starts with a known | |
| # spec prefix (ASTM, ASME, SA-, EN, ISO, etc.). Pure shape ("D460", "KB419-1") | |
| # overlaps with real heat codes; we don't reject those on shape alone. | |
| if any(upper.startswith(p) for p in _SPEC_PREFIXES): | |
| return False | |
| # Reject tokens with no digits at all. Real heat codes always contain at | |
| # least one digit (verified across all 14 confirmed samples). This catches | |
| # both pure-alpha tokens (DIMENSION) and alpha+dash tokens (CON-REDUCER, | |
| # WP304-S, etc.) that the per-char isalpha() check would miss. | |
| digits = [c for c in upper if c.isdigit()] | |
| if not digits: | |
| return False | |
| # Reject tokens whose ONLY digit(s) are zero(s). This is the OCR-mangled- | |
| # English-word signature: Tesseract reads letter O as digit 0, so words | |
| # like ACCORDING, BELOW, FROM, DONE, COMPOSITION, etc. become ACC0RDING, | |
| # BEL0W, FR0M, D0NE, C0MP0SITI0N. None of our 14 confirmed real heat codes | |
| # have all-zero digits; they always have at least one 1β9 digit. | |
| if all(d == "0" for d in digits): | |
| return False | |
| return True | |