Spaces:
Runtime error
Runtime error
Download normalization.py from ngohel58/Drawing_Extraction: direct link, hf CLI and curl.
- Browser
- Download file 7.71 kB
-
https://huggingface.co/spaces/ngohel58/Drawing_Extraction/resolve/main/normalization.py
- Command line
-
hf download hf://spaces/ngohel58/Drawing_Extraction/normalization.py
-
curl -L -o normalization.py https://huggingface.co/spaces/ngohel58/Drawing_Extraction/resolve/main/normalization.py
7.71 kB
| from __future__ import annotations | |
| import hashlib | |
| import re | |
| from collections import defaultdict | |
| from decimal import Decimal, InvalidOperation | |
| from typing import Any | |
| from .schema import Category, Characteristic, ReviewStatus | |
| TECHNICAL_LINE = re.compile( | |
| r"(?:[⌀Ø∅⌓⌖⏥⌯∥⊥±°]|\bM\s*\d|\bR\s*\d|\bRa\s*\d|" | |
| r"\d+(?:\.\d+)?\s*[±+\-/]|H\d\b|6H\b|THRU|TYP|MAX|MIN)", | |
| re.IGNORECASE, | |
| ) | |
| def enrich_characteristic(item: Characteristic) -> Characteristic: | |
| text = " ".join(part for part in (item.requirement, item.source_text) if part).strip() | |
| if not item.requirement: | |
| item.requirement = item.source_text | |
| if item.category == Category.OTHER: | |
| item.category = infer_category(text) | |
| if item.quantity is None: | |
| quantity = re.search(r"(?<!\d)(\d{1,3})\s*[Xx](?!\w)", text) | |
| if quantity: | |
| item.quantity = int(quantity.group(1)) | |
| if not item.thread: | |
| thread = re.search( | |
| r"\bM\s*\d+(?:\.\d+)?\s*[×xX]\s*\d+(?:\.\d+)?(?:\s*[-–]\s*[0-9A-Za-z]+)?", | |
| text, | |
| ) | |
| if thread: | |
| item.thread = re.sub(r"\s+", "", thread.group(0)).replace("x", "×").replace("X", "×") | |
| if not item.surface_finish: | |
| finish = re.search(r"\bR[azq]\s*\d+(?:\.\d+)?(?:\s*µ?m)?", text, re.I) | |
| if finish: | |
| item.surface_finish = finish.group(0) | |
| if not item.nominal: | |
| nominal = _extract_nominal(text) | |
| if nominal is not None: | |
| item.nominal = nominal | |
| _extract_tolerances(item, text) | |
| _calculate_limits(item) | |
| if not item.record_id: | |
| seed = f"{item.page}|{item.balloon_id}|{item.requirement}|{item.source_text}" | |
| item.record_id = hashlib.sha1(seed.encode("utf-8")).hexdigest()[:12] | |
| return item | |
| def infer_category(text: str) -> Category: | |
| normalized = text.strip() | |
| if re.search(r"\bM\s*\d+(?:\.\d+)?\s*[×xX]", normalized): | |
| return Category.THREAD | |
| if re.search(r"\bR[azq]\s*\d", normalized, re.I): | |
| return Category.SURFACE_FINISH | |
| if any(symbol in normalized for symbol in ("⌖", "⏥", "⌯", "∥", "⊥", "◎", "○")): | |
| return Category.GD_AND_T | |
| if re.search(r"[⌀Ø∅]\s*\d", normalized): | |
| return Category.DIAMETER | |
| if re.search(r"(?:^|\s)R\s*\d", normalized, re.I): | |
| return Category.RADIUS | |
| if "°" in normalized: | |
| return Category.ANGLE | |
| if re.search(r"\d", normalized) and any( | |
| token in normalized for token in ("±", "+", "-", "/", "MAX", "MIN") | |
| ): | |
| return Category.LINEAR_DIMENSION | |
| if re.search(r"MATERIAL|GRADE|HARDNESS|HBW|HRC", normalized, re.I): | |
| return Category.MATERIAL | |
| if normalized: | |
| return Category.NOTE | |
| return Category.OTHER | |
| def records_from_ocr_text(text: str, page: int = 1) -> list[Characteristic]: | |
| """Conservative fallback for OCR output; unknown associations remain unassigned.""" | |
| records: list[Characteristic] = [] | |
| counter = 1 | |
| for raw_line in text.splitlines(): | |
| line = re.sub(r"\s+", " ", raw_line).strip(" |\t") | |
| if len(line) < 2 or not TECHNICAL_LINE.search(line): | |
| continue | |
| balloon = "" | |
| requirement = line | |
| matched = re.match( | |
| r"^(?:BALLOON\s*)?[#(]?\s*(\d{1,4})\s*[)\]:.\-]\s*(.+)$", | |
| line, | |
| re.I, | |
| ) | |
| if matched: | |
| balloon, requirement = matched.group(1), matched.group(2) | |
| else: | |
| balloon = f"UNASSIGNED-{counter:03d}" | |
| counter += 1 | |
| record = Characteristic( | |
| balloon_id=balloon, | |
| page=page, | |
| requirement=requirement, | |
| source_text=line, | |
| confidence=0.35 if balloon.startswith("UNASSIGNED") else 0.5, | |
| status=( | |
| ReviewStatus.NEEDS_MAPPING | |
| if balloon.startswith("UNASSIGNED") | |
| else ReviewStatus.NEEDS_REVIEW | |
| ), | |
| ) | |
| records.append(enrich_characteristic(record)) | |
| return records | |
| def validate_characteristics(records: list[Characteristic]) -> list[Characteristic]: | |
| groups: dict[tuple[int, str], list[Characteristic]] = defaultdict(list) | |
| for record in records: | |
| enrich_characteristic(record) | |
| record.validation_issues = list(dict.fromkeys(record.validation_issues)) | |
| if not record.balloon_id: | |
| record.validation_issues.append("Balloon identifier was not read.") | |
| record.status = ReviewStatus.NEEDS_MAPPING | |
| if record.balloon_id.upper().startswith("UNASSIGNED"): | |
| record.validation_issues.append("Requirement is not mapped to a balloon.") | |
| record.status = ReviewStatus.NEEDS_MAPPING | |
| if not record.requirement: | |
| record.validation_issues.append("Requirement text is empty or unreadable.") | |
| if record.category in { | |
| Category.LINEAR_DIMENSION, | |
| Category.DIAMETER, | |
| Category.RADIUS, | |
| Category.ANGLE, | |
| } and not record.nominal: | |
| record.validation_issues.append("Nominal value requires confirmation.") | |
| if not record.source_text: | |
| record.validation_issues.append("No verbatim source text was preserved.") | |
| # AI results never become approved merely because confidence is high. | |
| if record.status == ReviewStatus.VERIFIED: | |
| record.status = ReviewStatus.NEEDS_REVIEW | |
| groups[(record.page, record.balloon_id)].append(record) | |
| for grouped in groups.values(): | |
| unique_requirements = { | |
| _key(record.requirement) for record in grouped if record.requirement.strip() | |
| } | |
| if len(unique_requirements) > 1: | |
| for record in grouped: | |
| record.validation_issues.append( | |
| "Conflicting requirements were extracted for the same balloon." | |
| ) | |
| return records | |
| def _extract_nominal(text: str) -> str | None: | |
| patterns = [ | |
| r"[⌀Ø∅]\s*([+-]?\d+(?:\.\d+)?)", | |
| r"(?:^|\s)R\s*([+-]?\d+(?:\.\d+)?)", | |
| r"(?<![A-Za-z\d])([+-]?\d+(?:\.\d+)?)(?=\s*(?:±|\+|\-|°|$))", | |
| ] | |
| for pattern in patterns: | |
| match = re.search(pattern, text, re.I) | |
| if match: | |
| return match.group(1) | |
| return None | |
| def _extract_tolerances(item: Characteristic, text: str) -> None: | |
| if item.upper_tolerance or item.lower_tolerance: | |
| return | |
| bilateral = re.search(r"±\s*(\d+(?:\.\d+)?)", text) | |
| if bilateral: | |
| item.upper_tolerance = bilateral.group(1) | |
| item.lower_tolerance = f"-{bilateral.group(1)}" | |
| return | |
| unilateral = re.search( | |
| r"\+\s*(\d+(?:\.\d+)?)\s*(?:/|\s)\s*-\s*(\d+(?:\.\d+)?)", | |
| text, | |
| ) | |
| if unilateral: | |
| item.upper_tolerance = unilateral.group(1) | |
| item.lower_tolerance = f"-{unilateral.group(2)}" | |
| def _calculate_limits(item: Characteristic) -> None: | |
| if item.upper_limit or item.lower_limit: | |
| return | |
| nominal = _decimal(item.nominal) | |
| upper = _decimal(item.upper_tolerance) | |
| lower = _decimal(item.lower_tolerance) | |
| if nominal is None or upper is None or lower is None: | |
| return | |
| item.upper_limit = _decimal_text(nominal + upper) | |
| item.lower_limit = _decimal_text(nominal + lower) | |
| def _decimal(value: Any) -> Decimal | None: | |
| if value in (None, ""): | |
| return None | |
| text = str(value).strip().replace("+", "") | |
| if not re.fullmatch(r"-?\d+(?:\.\d+)?", text): | |
| return None | |
| try: | |
| return Decimal(text) | |
| except InvalidOperation: | |
| return None | |
| def _decimal_text(value: Decimal) -> str: | |
| return format(value.normalize(), "f") | |
| def _key(value: str) -> str: | |
| return re.sub(r"\W+", "", value.casefold()) | |