Spaces:
Sleeping
Sleeping
File size: 36,349 Bytes
d618a6e b54d77d d618a6e cd10e70 d34c94f b54d77d cd10e70 d618a6e b54d77d 3453432 f9d6ac3 cd10e70 d618a6e cd10e70 d618a6e f9d6ac3 d618a6e 3453432 b54d77d d618a6e b54d77d d618a6e b54d77d d618a6e f9d6ac3 d618a6e b54d77d d618a6e f9d6ac3 d618a6e cd10e70 d618a6e cd10e70 d618a6e cd10e70 d618a6e d34c94f d618a6e d34c94f d618a6e d34c94f b54d77d cd10e70 d618a6e f9d6ac3 d618a6e | 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 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 | """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
|