Download src/pipeline.py from IamBatman07/Diagram-Structure-Extractor: direct link, hf CLI and curl.
- Browser
- Download file 13.2 kB
-
https://huggingface.co/spaces/IamBatman07/Diagram-Structure-Extractor/resolve/main/src/pipeline.py
- Command line
-
hf download hf://spaces/IamBatman07/Diagram-Structure-Extractor/src/pipeline.py
-
curl -L -o pipeline.py https://huggingface.co/spaces/IamBatman07/Diagram-Structure-Extractor/resolve/main/src/pipeline.py
13.2 kB
| """DiagraMine pipeline orchestrator. | |
| Wires the Day-3 Phase-2 champions into one configurable, data-driven pipeline: | |
| text -> PaddleOCR (champion: F1 0.962) | |
| box -> Canny + contours (champion: F1 1.000) | |
| arrow -> directed lines (champion: F1 1.000; any-angle Hough, arrowhead-oriented) | |
| icon -> template matching (champion: F1 1.000 on curated library) | |
| (F1 from results/phase2_leaderboard.csv.) | |
| Each stage is selectable via `PipelineConfig`, and "none" skips it. Every stage | |
| is wrapped so that a failure degrades to an empty result rather than a crash β that is what keeps the | |
| final `ExtractionResult` schema-valid 100% of the time (the reliability metric | |
| DiagraMine beats Claude Vision on). | |
| Usage: | |
| python -m src.pipeline <image_path> [--out DIR] [--text paddleocr] | |
| [--box canny_contours] [--arrow directed_lines] | |
| [--icon template_matching] [--gate] | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import json | |
| import os | |
| import time | |
| from typing import Callable, Dict | |
| import cv2 | |
| import numpy as np | |
| from src.box_detection import icon_nodes | |
| from src.graph import builder | |
| from src.schemas import ( | |
| ArrowElement, | |
| BoxElement, | |
| ExtractionResult, | |
| IconElement, | |
| PipelineConfig, | |
| Relationship, | |
| StageRuntimes, | |
| TextElement, | |
| ) | |
| # ββ Lazy detector registry ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Importing detector modules eagerly would load EasyOCR/CLIP/torch weights on | |
| # every `import pipeline`. Resolve them lazily so callers only pay for the | |
| # detectors they actually select. | |
| def _skip(image_path: str) -> dict: | |
| """The "none" detector: a skipped stage yields no items (see `_safe`).""" | |
| return {} | |
| def _text_detect(name: str) -> Callable[[str], dict]: | |
| if name == "none": | |
| return _skip | |
| if name == "easyocr": | |
| from src.text_detection import easyocr_detector as m | |
| elif name == "paddleocr": | |
| from src.text_detection import paddle_detector as m | |
| elif name == "tesseract": | |
| from src.text_detection import tesseract_detector as m | |
| else: | |
| raise ValueError(f"unknown text detector: {name}") | |
| return m.detect | |
| def _box_detect(name: str) -> Callable[[str], dict]: | |
| if name == "none": | |
| return _skip | |
| if name == "canny_contours": | |
| from src.box_detection import canny_contours_detector as m | |
| elif name == "hough": | |
| from src.box_detection import hough_detector as m | |
| elif name == "yolo": | |
| from src.box_detection import yolo_detector as m | |
| else: | |
| raise ValueError(f"unknown box detector: {name}") | |
| return m.detect | |
| def _arrow_detect(name: str) -> Callable[[str], dict]: | |
| if name == "none": | |
| return _skip | |
| if name == "directed_lines": | |
| from src.arrow_detection import directed_lines_detector as m | |
| elif name == "hough_lines": | |
| from src.arrow_detection import hough_lines_detector as m | |
| elif name == "pixel_scan": | |
| from src.arrow_detection import pixel_scan_detector as m | |
| elif name == "cnn": | |
| from src.arrow_detection import cnn_detector as m | |
| else: | |
| raise ValueError(f"unknown arrow detector: {name}") | |
| return m.detect | |
| def _icon_detect(name: str) -> Callable[[str], dict]: | |
| if name == "none": | |
| return _skip | |
| if name == "template_matching": | |
| from src.icon_detection import template_detector as m | |
| elif name == "clip": | |
| from src.icon_detection import clip_detector as m | |
| elif name == "hsv": | |
| from src.icon_detection import hsv_detector as m | |
| else: | |
| raise ValueError(f"unknown icon detector: {name}") | |
| return m.detect | |
| def _safe(resolve: Callable[[str], Callable[[str], dict]], name: str, | |
| image_path: str, key: str) -> tuple[list, float, str | None]: | |
| """Load and run a detector, returning (items, runtime, error). On failure | |
| return an empty list so the overall result stays schema-valid. Loading is | |
| inside the guard too: detector modules import heavy libraries lazily, and | |
| an import that fails (a missing package, a DLL conflict) must degrade the | |
| same way as a detector that raises.""" | |
| try: | |
| out = resolve(name)(image_path) | |
| return out.get(key, []), float(out.get("runtime_seconds", 0.0)), None | |
| except Exception as e: # noqa: BLE001 β reliability over strictness here | |
| return [], 0.0, f"{type(e).__name__}: {e}" | |
| def extract(image_path: str, config: PipelineConfig | None = None) -> ExtractionResult: | |
| """Run the full pipeline on one image and return a validated ExtractionResult.""" | |
| config = config or PipelineConfig() | |
| if not os.path.isfile(image_path): | |
| raise FileNotFoundError(image_path) | |
| img = cv2.imread(image_path) | |
| if img is None: | |
| raise ValueError(f"cv2 could not read image: {image_path}") | |
| h_img, w_img = img.shape[:2] | |
| t0 = time.perf_counter() | |
| raw_texts, rt_text, err_text = _safe(_text_detect, config.text_detector, image_path, "texts") | |
| raw_boxes, rt_box, err_box = _safe(_box_detect, config.box_detector, image_path, "boxes") | |
| raw_arrows, rt_arrow, err_arrow = _safe(_arrow_detect, config.arrow_detector, image_path, "arrows") | |
| raw_icons, rt_icon, err_icon = _safe(_icon_detect, config.icon_detector, image_path, "icons") | |
| # Icon nodes (a database cylinder over its caption) that contour box | |
| # detection misses; part of the box stage, so skipped when it is "none". | |
| if config.box_detector != "none" and not err_box: | |
| try: | |
| raw_boxes = raw_boxes + icon_nodes.find(img, raw_texts, raw_boxes) | |
| except Exception as e: # noqa: BLE001 β degrade like any other stage | |
| err_box = f"icon nodes: {type(e).__name__}: {e}" | |
| # Label boxes from contained text, then build relationships data-driven. | |
| g0 = time.perf_counter() | |
| raw_boxes = builder.label_boxes(raw_boxes, raw_texts) | |
| raw_rels = builder.build_relationships( | |
| raw_arrows, raw_boxes, | |
| outside_box_gate=config.outside_box_gate, | |
| max_dist=config.rel_max_dist, | |
| ray_intersection=config.rel_ray_intersection, | |
| max_proj=config.rel_max_proj, | |
| ) | |
| rt_graph = time.perf_counter() - g0 | |
| regions = [b for b in raw_boxes if b.get("is_region")] | |
| components = [b for b in raw_boxes if not b.get("is_region")] | |
| result = ExtractionResult( | |
| image=os.path.basename(image_path), | |
| width=int(w_img), | |
| height=int(h_img), | |
| detectors={ | |
| "text": config.text_detector, | |
| "box": config.box_detector, | |
| "arrow": config.arrow_detector, | |
| "icon": config.icon_detector, | |
| "outside_box_gate": config.outside_box_gate, | |
| "rel_max_dist": config.rel_max_dist, | |
| "rel_ray_intersection": config.rel_ray_intersection, | |
| "errors": {k: v for k, v in { | |
| "text": err_text, "box": err_box, | |
| "arrow": err_arrow, "icon": err_icon, | |
| }.items() if v}, | |
| }, | |
| texts=[TextElement(**t) for t in raw_texts], | |
| boxes=[BoxElement(**b) for b in components], | |
| regions=[BoxElement(**b) for b in regions], | |
| arrows=[ArrowElement(**a) for a in raw_arrows], | |
| icons=[IconElement(**i) for i in raw_icons], | |
| relationships=[Relationship(**r) for r in raw_rels], | |
| runtimes=StageRuntimes(text=rt_text, box=rt_box, arrow=rt_arrow, | |
| icon=rt_icon, graph=round(rt_graph, 3)), | |
| runtime_seconds=round(time.perf_counter() - t0, 3), | |
| ) | |
| return result | |
| # ββ Rendering / output writers βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def annotate(image_path: str, result: ExtractionResult) -> np.ndarray: | |
| """Draw the detection overlay: regions=orange, boxes=green, text=blue, | |
| arrows=red, icons=magenta.""" | |
| img = cv2.imread(image_path) | |
| for r in result.regions: | |
| cv2.rectangle(img, (r.x, r.y), (r.x + r.w, r.y + r.h), (0, 165, 255), 3) | |
| for b in result.boxes: | |
| cv2.rectangle(img, (b.x, b.y), (b.x + b.w, b.y + b.h), (0, 200, 0), 2) | |
| if b.label: | |
| cv2.putText(img, b.label[:30], (b.x + 3, b.y - 5), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 200, 0), 1, cv2.LINE_AA) | |
| for t in result.texts: | |
| cv2.rectangle(img, (t.x, t.y), (t.x + t.w, t.y + t.h), (255, 100, 0), 1) | |
| for a in result.arrows: | |
| pts = np.array(a.points or [[a.x1, a.y1], [a.x2, a.y2]], dtype=np.int32) | |
| cv2.polylines(img, [pts], False, (0, 0, 255), 2) | |
| # Tips only where heads were detected: at (x2, y2), and at the tail | |
| # too for a double-headed connector. An end without a head is a dot, | |
| # so a line with no detected direction shows none. | |
| tips = ([(pts[-2], pts[-1])] if a.has_head or a.bidirectional else []) + \ | |
| ([(pts[1], pts[0])] if a.bidirectional else []) | |
| for (bx, by), (tx, ty) in tips: | |
| cv2.arrowedLine(img, (int(bx), int(by)), (int(tx), int(ty)), (0, 0, 255), 2, tipLength=min( | |
| 0.5, 14.0 / max(1.0, float(np.hypot(tx - bx, ty - by))))) | |
| if not a.bidirectional: | |
| cv2.circle(img, (a.x1, a.y1), 4, (0, 0, 255), -1) | |
| if not (a.has_head or a.bidirectional): | |
| cv2.circle(img, (a.x2, a.y2), 4, (0, 0, 255), -1) | |
| for ic in result.icons: | |
| cv2.rectangle(img, (ic.x, ic.y), (ic.x + ic.w, ic.y + ic.h), (255, 0, 255), 2) | |
| cv2.putText(img, ic.label[:20], (ic.x, ic.y - 5), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.35, (255, 0, 255), 1, cv2.LINE_AA) | |
| return img | |
| def write_csv(result: ExtractionResult, output_path: str) -> None: | |
| with open(output_path, "w", newline="", encoding="utf-8") as f: | |
| w = csv.writer(f) | |
| w.writerow(["=== TEXT LABELS ==="]) | |
| w.writerow(["Text", "X", "Y", "Width", "Height", "Confidence"]) | |
| for t in result.texts: | |
| w.writerow([t.text, t.x, t.y, t.w, t.h, round(t.conf, 3)]) | |
| w.writerow([]) | |
| w.writerow(["=== BOXES ==="]) | |
| w.writerow(["Label", "X", "Y", "Width", "Height", "Entity Type"]) | |
| for b in result.boxes: | |
| w.writerow([b.label, b.x, b.y, b.w, b.h, b.entity_type]) | |
| w.writerow([]) | |
| w.writerow(["=== RELATIONSHIPS ==="]) | |
| w.writerow(["Source", "Target", "Line Style", "Direction", "Relationship", "Bidirectional", "Detected"]) | |
| for r in result.relationships: | |
| w.writerow([r.source, r.target, r.line_style, r.direction, r.relationship, r.bidirectional, r.detected]) | |
| def run(image_path: str, output_dir: str, config: PipelineConfig | None = None) -> ExtractionResult: | |
| """Full run: extract + write annotated PNG, relationship graph, JSON, CSV.""" | |
| config = config or PipelineConfig() | |
| os.makedirs(output_dir, exist_ok=True) | |
| result = extract(image_path, config) | |
| stem = os.path.splitext(os.path.basename(image_path))[0] | |
| cv2.imwrite(os.path.join(output_dir, f"{stem}_annotated.png"), annotate(image_path, result)) | |
| builder.draw_graph([r.model_dump() for r in result.relationships], | |
| os.path.join(output_dir, f"{stem}_graph.png"), | |
| layout=config.graph_layout) | |
| with open(os.path.join(output_dir, f"{stem}_structure.json"), "w", encoding="utf-8") as f: | |
| json.dump(result.model_dump(), f, indent=2, ensure_ascii=False) | |
| write_csv(result, os.path.join(output_dir, f"{stem}_data.csv")) | |
| return result | |
| def _build_config(args: argparse.Namespace) -> PipelineConfig: | |
| return PipelineConfig( | |
| text_detector=args.text, | |
| box_detector=args.box, | |
| arrow_detector=args.arrow, | |
| icon_detector=args.icon, | |
| outside_box_gate=args.gate, | |
| graph_layout=args.layout, | |
| ) | |
| def main() -> None: | |
| p = argparse.ArgumentParser(description="DiagraMine modular extraction pipeline") | |
| p.add_argument("image", help="path to the diagram image") | |
| p.add_argument("--out", default=".", help="output directory") | |
| p.add_argument("--text", default="paddleocr", choices=["paddleocr", "easyocr", "tesseract", "none"]) | |
| p.add_argument("--box", default="canny_contours", choices=["canny_contours", "hough", "yolo", "none"]) | |
| p.add_argument("--arrow", default="directed_lines", | |
| choices=["directed_lines", "hough_lines", "pixel_scan", "cnn", "none"]) | |
| p.add_argument("--icon", default="template_matching", choices=["template_matching", "clip", "hsv", "none"]) | |
| p.add_argument("--gate", action="store_true", help="enable the outside-box arrow gate (off by default)") | |
| p.add_argument("--layout", default="kamada_kawai", choices=["kamada_kawai", "spring"]) | |
| args = p.parse_args() | |
| result = run(args.image, args.out, _build_config(args)) | |
| print(json.dumps(result.summary(), indent=2)) | |
| print(f"[pipeline] detectors={result.detectors} runtime={result.runtime_seconds}s") | |
| print(f"[pipeline] outputs written to {os.path.abspath(args.out)}") | |
| if __name__ == "__main__": | |
| main() | |