heerjtdev commited on
Commit
35de6cd
·
1 Parent(s): 8c4d3b2

Update working_yolo_pipeline.py

Browse files
Files changed (1) hide show
  1. working_yolo_pipeline.py +1345 -7
working_yolo_pipeline.py CHANGED
@@ -1546,6 +1546,1345 @@
1546
 
1547
 
1548
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1549
  import json
1550
  import argparse
1551
  import os
@@ -1836,7 +3175,6 @@ def calculate_vertical_gap_coverage(word_data: list, sep_x: int, page_height: fl
1836
 
1837
 
1838
 
1839
-
1840
  def calculate_x_gutters(word_data: list, params: Dict, page_height: float) -> List[int]:
1841
  """
1842
  Calculates X-axis histogram and validates using BRIDGING DENSITY and Vertical Coverage.
@@ -1859,7 +3197,7 @@ def calculate_x_gutters(word_data: list, params: Dict, page_height: float) -> Li
1859
 
1860
  # Histogram Setup
1861
  bin_size = params.get('cluster_bin_size', 5)
1862
- smoothing = params.get('cluster_smoothing', 5)
1863
  min_width = params.get('cluster_min_width', 20)
1864
  threshold_percentile = params.get('cluster_threshold_percentile', 85)
1865
 
@@ -1898,15 +3236,15 @@ def calculate_x_gutters(word_data: list, params: Dict, page_height: float) -> Li
1898
 
1899
  # THRESHOLD: If bridging blocks > 8% of page height, REJECT.
1900
  # This allows for page numbers or headers (usually < 5%) to cross, but NOT paragraphs.
1901
- if bridging_ratio > 0.08:
1902
- print(f" ❌ Separator X={x_coord} REJECTED: Bridging Ratio {bridging_ratio:.1%} (>8%) cuts through text.")
1903
  continue
1904
 
1905
  # --- CHECK 2: VERTICAL GAP COVERAGE (The "Clean Split" Check) ---
1906
  # The gap must exist cleanly for > 65% of the text height.
1907
  coverage = calculate_vertical_gap_coverage(word_data, x_coord, page_height, gutter_width=min_width)
1908
 
1909
- if coverage >= 0.65:
1910
  final_separators.append(x_coord)
1911
  print(f" -> Separator X={x_coord} ACCEPTED (Coverage: {coverage:.1%}, Bridging: {bridging_ratio:.1%})")
1912
  else:
@@ -2033,8 +3371,8 @@ def preprocess_and_ocr_page(original_img: np.ndarray, model, pdf_path: str,
2033
  page_height_pdf = fitz_page.rect.height
2034
 
2035
  column_detection_params = {
2036
- 'cluster_bin_size': 5, 'cluster_smoothing': 5,
2037
- 'cluster_min_width': 20, 'cluster_threshold_percentile': 85,
2038
  }
2039
 
2040
  separators = calculate_x_gutters(masked_word_data, column_detection_params, page_height_pdf)
 
1546
 
1547
 
1548
 
1549
+
1550
+
1551
+
1552
+
1553
+
1554
+
1555
+
1556
+
1557
+
1558
+
1559
+
1560
+
1561
+
1562
+
1563
+
1564
+
1565
+
1566
+
1567
+
1568
+
1569
+
1570
+
1571
+
1572
+
1573
+
1574
+
1575
+
1576
+
1577
+ # import json
1578
+ # import argparse
1579
+ # import os
1580
+ # import re
1581
+ # import torch
1582
+ # import torch.nn as nn
1583
+ # from TorchCRF import CRF
1584
+ # from transformers import LayoutLMv3TokenizerFast, LayoutLMv3Model, LayoutLMv3Config
1585
+ # from typing import List, Dict, Any, Optional, Union, Tuple
1586
+ # import fitz # PyMuPDF
1587
+ # import numpy as np
1588
+ # import cv2
1589
+ # from ultralytics import YOLO
1590
+ # import glob
1591
+ # import pytesseract
1592
+ # from PIL import Image
1593
+ # from scipy.signal import find_peaks
1594
+ # from scipy.ndimage import gaussian_filter1d
1595
+ # import sys
1596
+ # import io
1597
+ # import base64
1598
+ # import tempfile
1599
+ # import time
1600
+ # import shutil
1601
+
1602
+ # # ============================================================================
1603
+ # # --- CONFIGURATION AND CONSTANTS ---
1604
+ # # ============================================================================
1605
+
1606
+ # # NOTE: Update these paths to match your environment before running!
1607
+ # WEIGHTS_PATH = 'YOLO_MATH/yolo_split_data/runs/detect/math_figure_detector_v3/weights/best.pt'
1608
+ # DEFAULT_LAYOUTLMV3_MODEL_PATH = "layoutlmv3_trained_20251118.pth"
1609
+
1610
+ # # DIRECTORY CONFIGURATION
1611
+ # OCR_JSON_OUTPUT_DIR = './ocr_json_output_final'
1612
+ # FIGURE_EXTRACTION_DIR = './figure_extraction'
1613
+ # TEMP_IMAGE_DIR = './temp_pdf_images'
1614
+
1615
+ # # Detection parameters
1616
+ # CONF_THRESHOLD = 0.2
1617
+ # TARGET_CLASSES = ['figure', 'equation']
1618
+ # IOU_MERGE_THRESHOLD = 0.4
1619
+ # IOA_SUPPRESSION_THRESHOLD = 0.7
1620
+ # LINE_TOLERANCE = 15
1621
+
1622
+ # # Global counters for sequential numbering across the entire PDF
1623
+ # GLOBAL_FIGURE_COUNT = 0
1624
+ # GLOBAL_EQUATION_COUNT = 0
1625
+
1626
+ # # LayoutLMv3 Labels
1627
+ # ID_TO_LABEL = {
1628
+ # 0: "O",
1629
+ # 1: "B-QUESTION", 2: "I-QUESTION",
1630
+ # 3: "B-OPTION", 4: "I-OPTION",
1631
+ # 5: "B-ANSWER", 6: "I-ANSWER",
1632
+ # 7: "B-SECTION_HEADING", 8: "I-SECTION_HEADING",
1633
+ # 9: "B-PASSAGE", 10: "I-PASSAGE"
1634
+ # }
1635
+ # NUM_LABELS = len(ID_TO_LABEL)
1636
+
1637
+
1638
+ # # ============================================================================
1639
+ # # --- PERFORMANCE OPTIMIZATION: OCR CACHE ---
1640
+ # # ============================================================================
1641
+
1642
+ # class OCRCache:
1643
+ # """Caches OCR results per page to avoid redundant Tesseract runs."""
1644
+
1645
+ # def __init__(self):
1646
+ # self.cache = {}
1647
+
1648
+ # def get_key(self, pdf_path: str, page_num: int) -> str:
1649
+ # return f"{pdf_path}:{page_num}"
1650
+
1651
+ # def has_ocr(self, pdf_path: str, page_num: int) -> bool:
1652
+ # return self.get_key(pdf_path, page_num) in self.cache
1653
+
1654
+ # def get_ocr(self, pdf_path: str, page_num: int) -> Optional[list]:
1655
+ # return self.cache.get(self.get_key(pdf_path, page_num))
1656
+
1657
+ # def set_ocr(self, pdf_path: str, page_num: int, ocr_data: list):
1658
+ # self.cache[self.get_key(pdf_path, page_num)] = ocr_data
1659
+
1660
+ # def clear(self):
1661
+ # self.cache.clear()
1662
+
1663
+
1664
+ # # Global OCR cache instance
1665
+ # _ocr_cache = OCRCache()
1666
+
1667
+
1668
+ # # ============================================================================
1669
+ # # --- PHASE 1: YOLO/OCR PREPROCESSING FUNCTIONS ---
1670
+ # # ============================================================================
1671
+
1672
+ # def calculate_iou(box1, box2):
1673
+ # x1_a, y1_a, x2_a, y2_a = box1
1674
+ # x1_b, y1_b, x2_b, y2_b = box2
1675
+ # x_left = max(x1_a, x1_b)
1676
+ # y_top = max(y1_a, y1_b)
1677
+ # x_right = min(x2_a, x2_b)
1678
+ # y_bottom = min(y2_a, y2_b)
1679
+ # intersection_area = max(0, x_right - x_left) * max(0, y_bottom - y_top)
1680
+ # box_a_area = (x2_a - x1_a) * (y2_a - y1_a)
1681
+ # box_b_area = (x2_b - x1_b) * (y2_b - y1_b)
1682
+ # union_area = float(box_a_area + box_b_area - intersection_area)
1683
+ # return intersection_area / union_area if union_area > 0 else 0
1684
+
1685
+
1686
+ # def calculate_ioa(box1, box2):
1687
+ # x1_a, y1_a, x2_a, y2_a = box1
1688
+ # x1_b, y1_b, x2_b, y2_b = box2
1689
+ # x_left = max(x1_a, x1_b)
1690
+ # y_top = max(y1_a, y1_b)
1691
+ # x_right = min(x2_a, x2_b)
1692
+ # y_bottom = min(y2_a, y2_b)
1693
+ # intersection_area = max(0, x_right - x_left) * max(0, y_bottom - y_top)
1694
+ # box_a_area = (x2_a - x1_a) * (y2_a - y1_a)
1695
+ # return intersection_area / box_a_area if box_a_area > 0 else 0
1696
+
1697
+
1698
+ # def merge_overlapping_boxes(detections, iou_threshold):
1699
+ # if not detections: return []
1700
+ # detections.sort(key=lambda d: d['conf'], reverse=True)
1701
+ # merged_detections = []
1702
+ # is_merged = [False] * len(detections)
1703
+ # for i in range(len(detections)):
1704
+ # if is_merged[i]: continue
1705
+ # current_box = detections[i]['coords']
1706
+ # current_class = detections[i]['class']
1707
+ # merged_x1, merged_y1, merged_x2, merged_y2 = current_box
1708
+ # for j in range(i + 1, len(detections)):
1709
+ # if is_merged[j] or detections[j]['class'] != current_class: continue
1710
+ # other_box = detections[j]['coords']
1711
+ # iou = calculate_iou(current_box, other_box)
1712
+ # if iou > iou_threshold:
1713
+ # merged_x1 = min(merged_x1, other_box[0])
1714
+ # merged_y1 = min(merged_y1, other_box[1])
1715
+ # merged_x2 = max(merged_x2, other_box[2])
1716
+ # merged_y2 = max(merged_y2, other_box[3])
1717
+ # is_merged[j] = True
1718
+ # merged_detections.append({
1719
+ # 'coords': (merged_x1, merged_y1, merged_x2, merged_y2),
1720
+ # 'y1': merged_y1, 'class': current_class, 'conf': detections[i]['conf']
1721
+ # })
1722
+ # return merged_detections
1723
+
1724
+
1725
+ # def merge_yolo_into_word_data(raw_word_data: list, yolo_detections: list, scale_factor: float) -> list:
1726
+ # """
1727
+ # Filters out raw words that are inside YOLO boxes and replaces them with
1728
+ # a single solid 'placeholder' block for the column detector.
1729
+ # """
1730
+ # if not yolo_detections:
1731
+ # return raw_word_data
1732
+
1733
+ # # 1. Convert YOLO boxes (Pixels) to PDF Coordinates (Points)
1734
+ # pdf_space_boxes = []
1735
+ # for det in yolo_detections:
1736
+ # x1, y1, x2, y2 = det['coords']
1737
+ # pdf_box = (
1738
+ # x1 / scale_factor,
1739
+ # y1 / scale_factor,
1740
+ # x2 / scale_factor,
1741
+ # y2 / scale_factor
1742
+ # )
1743
+ # pdf_space_boxes.append(pdf_box)
1744
+
1745
+ # # 2. Filter out raw words that are inside YOLO boxes
1746
+ # cleaned_word_data = []
1747
+ # for word_tuple in raw_word_data:
1748
+ # wx1, wy1, wx2, wy2 = word_tuple[1], word_tuple[2], word_tuple[3], word_tuple[4]
1749
+ # w_center_x = (wx1 + wx2) / 2
1750
+ # w_center_y = (wy1 + wy2) / 2
1751
+
1752
+ # is_inside_yolo = False
1753
+ # for px1, py1, px2, py2 in pdf_space_boxes:
1754
+ # if px1 <= w_center_x <= px2 and py1 <= w_center_y <= py2:
1755
+ # is_inside_yolo = True
1756
+ # break
1757
+
1758
+ # if not is_inside_yolo:
1759
+ # cleaned_word_data.append(word_tuple)
1760
+
1761
+ # # 3. Add the YOLO boxes themselves as "Solid Words"
1762
+ # for i, (px1, py1, px2, py2) in enumerate(pdf_space_boxes):
1763
+ # dummy_entry = (f"BLOCK_{i}", px1, py1, px2, py2)
1764
+ # cleaned_word_data.append(dummy_entry)
1765
+
1766
+ # return cleaned_word_data
1767
+
1768
+
1769
+ # def calculate_vertical_gap_coverage(word_data: list, sep_x: int, page_height: float, gutter_width: int = 10) -> float:
1770
+ # """
1771
+ # Calculates what percentage of the page's vertical text span is 'cleanly split' by the separator.
1772
+ # A valid column split should split > 65% of the page verticality.
1773
+ # """
1774
+ # if not word_data:
1775
+ # return 0.0
1776
+
1777
+ # # Determine the vertical span of the actual text content
1778
+ # y_coords = [w[2] for w in word_data] + [w[4] for w in word_data] # y1 and y2
1779
+ # min_y, max_y = min(y_coords), max(y_coords)
1780
+ # total_text_height = max_y - min_y
1781
+
1782
+ # if total_text_height <= 0:
1783
+ # return 0.0
1784
+
1785
+ # # Create a boolean array representing the Y-axis (1 pixel per unit)
1786
+ # gap_open_mask = np.ones(int(total_text_height) + 1, dtype=bool)
1787
+
1788
+ # zone_left = sep_x - (gutter_width / 2)
1789
+ # zone_right = sep_x + (gutter_width / 2)
1790
+ # offset_y = int(min_y)
1791
+
1792
+ # for _, x1, y1, x2, y2 in word_data:
1793
+ # # Check if this word horizontally interferes with the separator
1794
+ # if x2 > zone_left and x1 < zone_right:
1795
+ # y_start_idx = max(0, int(y1) - offset_y)
1796
+ # y_end_idx = min(len(gap_open_mask), int(y2) - offset_y)
1797
+ # if y_end_idx > y_start_idx:
1798
+ # gap_open_mask[y_start_idx:y_end_idx] = False
1799
+
1800
+ # open_pixels = np.sum(gap_open_mask)
1801
+ # coverage_ratio = open_pixels / len(gap_open_mask)
1802
+
1803
+ # return coverage_ratio
1804
+
1805
+
1806
+ # # def calculate_x_gutters(word_data: list, params: Dict, page_height: float) -> List[int]:
1807
+ # # """Calculates X-axis histogram and validates using BRIDGING CHECK and Vertical Coverage."""
1808
+ # # if not word_data: return []
1809
+
1810
+ # # x_points = []
1811
+ # # for _, x1, _, x2, *rest in word_data:
1812
+ # # x_points.extend([x1, x2])
1813
+
1814
+ # # if not x_points: return []
1815
+ # # max_x = max(x_points)
1816
+
1817
+ # # bin_size = params.get('cluster_bin_size', 5)
1818
+ # # smoothing = params.get('cluster_smoothing', 5)
1819
+ # # min_width = params.get('cluster_min_width', 20)
1820
+ # # threshold_percentile = params.get('cluster_threshold_percentile', 85)
1821
+
1822
+ # # num_bins = int(np.ceil(max_x / bin_size))
1823
+ # # hist, bin_edges = np.histogram(x_points, bins=num_bins, range=(0, max_x))
1824
+
1825
+ # # smoothed_hist = gaussian_filter1d(hist.astype(float), sigma=smoothing)
1826
+ # # inverted_signal = np.max(smoothed_hist) - smoothed_hist
1827
+
1828
+ # # peaks, properties = find_peaks(
1829
+ # # inverted_signal,
1830
+ # # height=np.max(inverted_signal) - np.percentile(smoothed_hist, threshold_percentile),
1831
+ # # distance=min_width / bin_size
1832
+ # # )
1833
+
1834
+ # # if not peaks.size: return []
1835
+
1836
+ # # separator_x_coords = [int(bin_edges[p]) for p in peaks]
1837
+ # # final_separators = []
1838
+
1839
+ # # for x_coord in separator_x_coords:
1840
+ # # # 1. BRIDGING CHECK: The "Do Not Cut Words" Constraint
1841
+ # # # Count how many words/blocks physically cross this specific X coordinate.
1842
+ # # bridging_count = 0
1843
+ # # for _, wx1, _, wx2, _ in word_data:
1844
+ # # # Strictly check if a word physically sits on this line
1845
+ # # if wx1 < x_coord and wx2 > x_coord:
1846
+ # # bridging_count += 1
1847
+
1848
+ # # # Strict Threshold: If more than 2 items (allow for noise) cross, REJECT.
1849
+ # # if bridging_count > 2:
1850
+ # # print(f" ❌ Separator X={x_coord} REJECTED: Cuts through {bridging_count} words/blocks.")
1851
+ # # continue
1852
+
1853
+ # # # 2. VERTICAL COVERAGE CHECK
1854
+ # # # The gap must exist for > 65% of the text height of the page.
1855
+ # # coverage = calculate_vertical_gap_coverage(word_data, x_coord, page_height, gutter_width=min_width)
1856
+
1857
+ # # if coverage >= 0.65:
1858
+ # # final_separators.append(x_coord)
1859
+ # # print(f" -> Separator X={x_coord} ACCEPTED (Coverage: {coverage:.1%}, Bridging: {bridging_count})")
1860
+ # # else:
1861
+ # # print(f" ❌ Separator X={x_coord} REJECTED (Coverage: {coverage:.1%}, Bridging: {bridging_count})")
1862
+
1863
+ # # return sorted(final_separators)
1864
+
1865
+
1866
+
1867
+
1868
+ # def calculate_x_gutters(word_data: list, params: Dict, page_height: float) -> List[int]:
1869
+ # """
1870
+ # Calculates X-axis histogram and validates using BRIDGING DENSITY and Vertical Coverage.
1871
+ # """
1872
+ # if not word_data: return []
1873
+
1874
+ # x_points = []
1875
+ # # Use only word_data elements 1 (x1) and 3 (x2)
1876
+ # for item in word_data:
1877
+ # x_points.extend([item[1], item[3]])
1878
+
1879
+ # if not x_points: return []
1880
+ # max_x = max(x_points)
1881
+
1882
+ # # 1. Determine total text height for ratio calculation
1883
+ # y_coords = [item[2] for item in word_data] + [item[4] for item in word_data]
1884
+ # min_y, max_y = min(y_coords), max(y_coords)
1885
+ # total_text_height = max_y - min_y
1886
+ # if total_text_height <= 0: return []
1887
+
1888
+ # # Histogram Setup
1889
+ # bin_size = params.get('cluster_bin_size', 5)
1890
+ # smoothing = params.get('cluster_smoothing', 5)
1891
+ # min_width = params.get('cluster_min_width', 20)
1892
+ # threshold_percentile = params.get('cluster_threshold_percentile', 85)
1893
+
1894
+ # num_bins = int(np.ceil(max_x / bin_size))
1895
+ # hist, bin_edges = np.histogram(x_points, bins=num_bins, range=(0, max_x))
1896
+ # smoothed_hist = gaussian_filter1d(hist.astype(float), sigma=smoothing)
1897
+ # inverted_signal = np.max(smoothed_hist) - smoothed_hist
1898
+
1899
+ # peaks, properties = find_peaks(
1900
+ # inverted_signal,
1901
+ # height=np.max(inverted_signal) - np.percentile(smoothed_hist, threshold_percentile),
1902
+ # distance=min_width / bin_size
1903
+ # )
1904
+
1905
+ # if not peaks.size: return []
1906
+ # separator_x_coords = [int(bin_edges[p]) for p in peaks]
1907
+ # final_separators = []
1908
+
1909
+ # for x_coord in separator_x_coords:
1910
+ # # --- CHECK 1: BRIDGING DENSITY (The "Cut Through" Check) ---
1911
+ # # Calculate the total vertical height of words that physically cross this line.
1912
+ # bridging_height = 0
1913
+ # bridging_count = 0
1914
+
1915
+ # for item in word_data:
1916
+ # wx1, wy1, wx2, wy2 = item[1], item[2], item[3], item[4]
1917
+
1918
+ # # Check if this word physically sits on top of the separator line
1919
+ # if wx1 < x_coord and wx2 > x_coord:
1920
+ # word_h = wy2 - wy1
1921
+ # bridging_height += word_h
1922
+ # bridging_count += 1
1923
+
1924
+ # # Calculate Ratio: How much of the page's text height is blocked by these crossing words?
1925
+ # bridging_ratio = bridging_height / total_text_height
1926
+
1927
+ # # THRESHOLD: If bridging blocks > 8% of page height, REJECT.
1928
+ # # This allows for page numbers or headers (usually < 5%) to cross, but NOT paragraphs.
1929
+ # if bridging_ratio > 0.08:
1930
+ # print(f" ❌ Separator X={x_coord} REJECTED: Bridging Ratio {bridging_ratio:.1%} (>8%) cuts through text.")
1931
+ # continue
1932
+
1933
+ # # --- CHECK 2: VERTICAL GAP COVERAGE (The "Clean Split" Check) ---
1934
+ # # The gap must exist cleanly for > 65% of the text height.
1935
+ # coverage = calculate_vertical_gap_coverage(word_data, x_coord, page_height, gutter_width=min_width)
1936
+
1937
+ # if coverage >= 0.65:
1938
+ # final_separators.append(x_coord)
1939
+ # print(f" -> Separator X={x_coord} ACCEPTED (Coverage: {coverage:.1%}, Bridging: {bridging_ratio:.1%})")
1940
+ # else:
1941
+ # print(f" ❌ Separator X={x_coord} REJECTED (Coverage: {coverage:.1%}, Bridging: {bridging_ratio:.1%})")
1942
+
1943
+ # return sorted(final_separators)
1944
+
1945
+
1946
+ # def get_word_data_for_detection(page: fitz.Page, pdf_path: str, page_num: int,
1947
+ # top_margin_percent=0.10, bottom_margin_percent=0.10) -> list:
1948
+ # """Extract word data with OCR caching to avoid redundant Tesseract runs."""
1949
+ # word_data = page.get_text("words")
1950
+
1951
+ # if len(word_data) > 0:
1952
+ # word_data = [(w[4], w[0], w[1], w[2], w[3]) for w in word_data]
1953
+ # else:
1954
+ # if _ocr_cache.has_ocr(pdf_path, page_num):
1955
+ # word_data = _ocr_cache.get_ocr(pdf_path, page_num)
1956
+ # else:
1957
+ # try:
1958
+ # pix = page.get_pixmap(matrix=fitz.Matrix(3, 3))
1959
+ # img_bytes = pix.tobytes("png")
1960
+ # img = Image.open(io.BytesIO(img_bytes))
1961
+ # data = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
1962
+ # full_word_data = []
1963
+ # for i in range(len(data['level'])):
1964
+ # if data['text'][i].strip():
1965
+ # x1, y1 = data['left'][i] / 3, data['top'][i] / 3
1966
+ # x2, y2 = x1 + data['width'][i] / 3, y1 + data['height'][i] / 3
1967
+ # full_word_data.append((data['text'][i], x1, y1, x2, y2))
1968
+ # word_data = full_word_data
1969
+ # _ocr_cache.set_ocr(pdf_path, page_num, word_data)
1970
+ # except Exception:
1971
+ # return []
1972
+
1973
+ # # Apply margin filtering
1974
+ # page_height = page.rect.height
1975
+ # y_min = page_height * top_margin_percent
1976
+ # y_max = page_height * (1 - bottom_margin_percent)
1977
+ # return [d for d in word_data if d[2] >= y_min and d[4] <= y_max]
1978
+
1979
+
1980
+ # def pixmap_to_numpy(pix: fitz.Pixmap) -> np.ndarray:
1981
+ # img_data = pix.samples
1982
+ # img = np.frombuffer(img_data, dtype=np.uint8).reshape(pix.height, pix.width, pix.n)
1983
+ # if pix.n == 4: img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)
1984
+ # elif pix.n == 3: img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
1985
+ # return img
1986
+
1987
+
1988
+ # def extract_native_words_and_convert(fitz_page, scale_factor: float = 2.0) -> list:
1989
+ # raw_word_data = fitz_page.get_text("words")
1990
+ # converted_ocr_output = []
1991
+ # DEFAULT_CONFIDENCE = 99.0
1992
+
1993
+ # for x1, y1, x2, y2, word, *rest in raw_word_data:
1994
+ # if not word.strip(): continue
1995
+ # x1_pix = int(x1 * scale_factor)
1996
+ # y1_pix = int(y1 * scale_factor)
1997
+ # x2_pix = int(x2 * scale_factor)
1998
+ # y2_pix = int(y2 * scale_factor)
1999
+ # converted_ocr_output.append({
2000
+ # 'type': 'text',
2001
+ # 'word': word,
2002
+ # 'confidence': DEFAULT_CONFIDENCE,
2003
+ # 'bbox': [x1_pix, y1_pix, x2_pix, y2_pix],
2004
+ # 'y0': y1_pix, 'x0': x1_pix
2005
+ # })
2006
+ # return converted_ocr_output
2007
+
2008
+
2009
+ # def preprocess_and_ocr_page(original_img: np.ndarray, model, pdf_path: str,
2010
+ # page_num: int, fitz_page: fitz.Page,
2011
+ # pdf_name: str) -> Tuple[List[Dict[str, Any]], Optional[int]]:
2012
+ # """
2013
+ # OPTIMIZED FLOW:
2014
+ # 1. Run YOLO to find Equations/Tables.
2015
+ # 2. Mask raw text with YOLO boxes.
2016
+ # 3. Run Column Detection on the MASKED data.
2017
+ # 4. Proceed with OCR and Output.
2018
+ # """
2019
+ # global GLOBAL_FIGURE_COUNT, GLOBAL_EQUATION_COUNT
2020
+
2021
+ # start_time_total = time.time()
2022
+
2023
+ # if original_img is None:
2024
+ # print(f" ❌ Invalid image for page {page_num}.")
2025
+ # return None, None
2026
+
2027
+ # # ====================================================================
2028
+ # # --- STEP 1: YOLO DETECTION (MOVED TO FIRST STEP) ---
2029
+ # # ====================================================================
2030
+ # start_time_yolo = time.time()
2031
+ # results = model.predict(source=original_img, conf=CONF_THRESHOLD, imgsz=640, verbose=False)
2032
+
2033
+ # relevant_detections = []
2034
+ # if results and results[0].boxes:
2035
+ # for box in results[0].boxes:
2036
+ # class_id = int(box.cls[0])
2037
+ # class_name = model.names[class_id]
2038
+ # if class_name in TARGET_CLASSES:
2039
+ # x1, y1, x2, y2 = box.xyxy[0].cpu().numpy().astype(int)
2040
+ # relevant_detections.append(
2041
+ # {'coords': (x1, y1, x2, y2), 'y1': y1, 'class': class_name, 'conf': float(box.conf[0])}
2042
+ # )
2043
+
2044
+ # merged_detections = merge_overlapping_boxes(relevant_detections, IOU_MERGE_THRESHOLD)
2045
+ # print(f" [LOG] YOLO found {len(merged_detections)} objects in {time.time() - start_time_yolo:.3f}s.")
2046
+
2047
+ # # ====================================================================
2048
+ # # --- STEP 2: PREPARE DATA FOR COLUMN DETECTION (MASKING) ---
2049
+ # # ====================================================================
2050
+ # raw_words_for_layout = get_word_data_for_detection(
2051
+ # fitz_page, pdf_path, page_num,
2052
+ # top_margin_percent=0.10, bottom_margin_percent=0.10
2053
+ # )
2054
+
2055
+ # masked_word_data = merge_yolo_into_word_data(raw_words_for_layout, merged_detections, scale_factor=2.0)
2056
+
2057
+ # # ====================================================================
2058
+ # # --- STEP 3: COLUMN DETECTION (MASKED + COVERAGE + BRIDGING CHECK) ---
2059
+ # # ====================================================================
2060
+ # page_width_pdf = fitz_page.rect.width
2061
+ # page_height_pdf = fitz_page.rect.height
2062
+
2063
+ # column_detection_params = {
2064
+ # 'cluster_bin_size': 5, 'cluster_smoothing': 5,
2065
+ # 'cluster_min_width': 20, 'cluster_threshold_percentile': 85,
2066
+ # }
2067
+
2068
+ # separators = calculate_x_gutters(masked_word_data, column_detection_params, page_height_pdf)
2069
+
2070
+ # page_separator_x = None
2071
+ # if separators:
2072
+ # central_min = page_width_pdf * 0.35
2073
+ # central_max = page_width_pdf * 0.65
2074
+ # central_separators = [s for s in separators if central_min <= s <= central_max]
2075
+
2076
+ # if central_separators:
2077
+ # center_x = page_width_pdf / 2
2078
+ # page_separator_x = min(central_separators, key=lambda x: abs(x - center_x))
2079
+ # print(f" ✅ Column Split Confirmed at X={page_separator_x:.1f}")
2080
+ # else:
2081
+ # print(" ⚠️ Gutter found off-center. Ignoring.")
2082
+ # else:
2083
+ # print(" -> Single Column Layout Confirmed.")
2084
+
2085
+ # # ====================================================================
2086
+ # # --- STEP 4: COMPONENT EXTRACTION (Save Images) ---
2087
+ # # ====================================================================
2088
+ # start_time_components = time.time()
2089
+ # component_metadata = []
2090
+ # fig_count_page = 0
2091
+ # eq_count_page = 0
2092
+
2093
+ # for detection in merged_detections:
2094
+ # x1, y1, x2, y2 = detection['coords']
2095
+ # class_name = detection['class']
2096
+
2097
+ # if class_name == 'figure':
2098
+ # GLOBAL_FIGURE_COUNT += 1
2099
+ # counter = GLOBAL_FIGURE_COUNT
2100
+ # component_word = f"FIGURE{counter}"
2101
+ # fig_count_page += 1
2102
+ # elif class_name == 'equation':
2103
+ # GLOBAL_EQUATION_COUNT += 1
2104
+ # counter = GLOBAL_EQUATION_COUNT
2105
+ # component_word = f"EQUATION{counter}"
2106
+ # eq_count_page += 1
2107
+ # else:
2108
+ # continue
2109
+
2110
+ # component_crop = original_img[y1:y2, x1:x2]
2111
+ # component_filename = f"{pdf_name}_page{page_num}_{class_name}{counter}.png"
2112
+ # cv2.imwrite(os.path.join(FIGURE_EXTRACTION_DIR, component_filename), component_crop)
2113
+
2114
+ # y_midpoint = (y1 + y2) // 2
2115
+ # component_metadata.append({
2116
+ # 'type': class_name, 'word': component_word,
2117
+ # 'bbox': [int(x1), int(y1), int(x2), int(y2)],
2118
+ # 'y0': int(y_midpoint), 'x0': int(x1)
2119
+ # })
2120
+
2121
+ # # ====================================================================
2122
+ # # --- STEP 5: HYBRID OCR (Native Text + Cached Tesseract Fallback) ---
2123
+ # # ====================================================================
2124
+ # raw_ocr_output = []
2125
+ # scale_factor = 2.0
2126
+
2127
+ # try:
2128
+ # raw_ocr_output = extract_native_words_and_convert(fitz_page, scale_factor=scale_factor)
2129
+ # except Exception as e:
2130
+ # print(f" ❌ Native text extraction failed: {e}")
2131
+
2132
+ # if not raw_ocr_output:
2133
+ # if _ocr_cache.has_ocr(pdf_path, page_num):
2134
+ # print(f" ⚡ Using cached Tesseract OCR for page {page_num}")
2135
+ # cached_word_data = _ocr_cache.get_ocr(pdf_path, page_num)
2136
+ # for word_tuple in cached_word_data:
2137
+ # word_text, x1, y1, x2, y2 = word_tuple
2138
+ # x1_pix = int(x1 * scale_factor)
2139
+ # y1_pix = int(y1 * scale_factor)
2140
+ # x2_pix = int(x2 * scale_factor)
2141
+ # y2_pix = int(y2 * scale_factor)
2142
+ # raw_ocr_output.append({
2143
+ # 'type': 'text', 'word': word_text, 'confidence': 95.0,
2144
+ # 'bbox': [x1_pix, y1_pix, x2_pix, y2_pix],
2145
+ # 'y0': y1_pix, 'x0': x1_pix
2146
+ # })
2147
+ # else:
2148
+ # try:
2149
+ # pil_img = Image.fromarray(cv2.cvtColor(original_img, cv2.COLOR_BGR2RGB))
2150
+ # hocr_data = pytesseract.image_to_data(pil_img, output_type=pytesseract.Output.DICT)
2151
+ # for i in range(len(hocr_data['level'])):
2152
+ # text = hocr_data['text'][i].strip()
2153
+ # if text and hocr_data['conf'][i] > -1:
2154
+ # x1 = int(hocr_data['left'][i])
2155
+ # y1 = int(hocr_data['top'][i])
2156
+ # x2 = x1 + int(hocr_data['width'][i])
2157
+ # y2 = y1 + int(hocr_data['height'][i])
2158
+ # raw_ocr_output.append({
2159
+ # 'type': 'text', 'word': text, 'confidence': float(hocr_data['conf'][i]),
2160
+ # 'bbox': [x1, y1, x2, y2], 'y0': y1, 'x0': x1
2161
+ # })
2162
+ # except Exception as e:
2163
+ # print(f" ❌ Tesseract OCR Error: {e}")
2164
+
2165
+ # # ====================================================================
2166
+ # # --- STEP 6: OCR CLEANING AND MERGING ---
2167
+ # # ====================================================================
2168
+ # items_to_sort = []
2169
+
2170
+ # for ocr_word in raw_ocr_output:
2171
+ # is_suppressed = False
2172
+ # for component in component_metadata:
2173
+ # ioa = calculate_ioa(ocr_word['bbox'], component['bbox'])
2174
+ # if ioa > IOA_SUPPRESSION_THRESHOLD:
2175
+ # is_suppressed = True
2176
+ # break
2177
+ # if not is_suppressed:
2178
+ # items_to_sort.append(ocr_word)
2179
+
2180
+ # items_to_sort.extend(component_metadata)
2181
+
2182
+ # # ====================================================================
2183
+ # # --- STEP 7: LINE-BASED SORTING ---
2184
+ # # ====================================================================
2185
+ # items_to_sort.sort(key=lambda x: (x['y0'], x['x0']))
2186
+ # lines = []
2187
+
2188
+ # for item in items_to_sort:
2189
+ # placed = False
2190
+ # for line in lines:
2191
+ # y_ref = min(it['y0'] for it in line)
2192
+ # if abs(y_ref - item['y0']) < LINE_TOLERANCE:
2193
+ # line.append(item)
2194
+ # placed = True
2195
+ # break
2196
+ # if not placed and item['type'] in ['equation', 'figure']:
2197
+ # for line in lines:
2198
+ # y_ref = min(it['y0'] for it in line)
2199
+ # if abs(y_ref - item['y0']) < 20:
2200
+ # line.append(item)
2201
+ # placed = True
2202
+ # break
2203
+ # if not placed:
2204
+ # lines.append([item])
2205
+
2206
+ # for line in lines:
2207
+ # line.sort(key=lambda x: x['x0'])
2208
+
2209
+ # final_output = []
2210
+ # for line in lines:
2211
+ # for item in line:
2212
+ # data_item = {"word": item["word"], "bbox": item["bbox"], "type": item["type"]}
2213
+ # if 'tag' in item: data_item['tag'] = item['tag']
2214
+ # final_output.append(data_item)
2215
+
2216
+ # return final_output, page_separator_x
2217
+
2218
+
2219
+ # def run_single_pdf_preprocessing(pdf_path: str, preprocessed_json_path: str) -> Optional[str]:
2220
+ # global GLOBAL_FIGURE_COUNT, GLOBAL_EQUATION_COUNT
2221
+
2222
+ # GLOBAL_FIGURE_COUNT = 0
2223
+ # GLOBAL_EQUATION_COUNT = 0
2224
+ # _ocr_cache.clear()
2225
+
2226
+ # print("\n" + "=" * 80)
2227
+ # print("--- 1. STARTING OPTIMIZED YOLO/OCR PREPROCESSING PIPELINE ---")
2228
+ # print("=" * 80)
2229
+
2230
+ # if not os.path.exists(pdf_path):
2231
+ # print(f"❌ FATAL ERROR: Input PDF not found at {pdf_path}.")
2232
+ # return None
2233
+
2234
+ # os.makedirs(os.path.dirname(preprocessed_json_path), exist_ok=True)
2235
+ # os.makedirs(FIGURE_EXTRACTION_DIR, exist_ok=True)
2236
+
2237
+ # model = YOLO(WEIGHTS_PATH)
2238
+ # pdf_name = os.path.splitext(os.path.basename(pdf_path))[0]
2239
+
2240
+ # try:
2241
+ # doc = fitz.open(pdf_path)
2242
+ # print(f"✅ Opened PDF: {pdf_name} ({doc.page_count} pages)")
2243
+ # except Exception as e:
2244
+ # print(f"❌ ERROR loading PDF file: {e}")
2245
+ # return None
2246
+
2247
+ # all_pages_data = []
2248
+ # total_pages_processed = 0
2249
+ # mat = fitz.Matrix(2.0, 2.0)
2250
+
2251
+ # print("\n[STEP 1.2: ITERATING PAGES - IN-MEMORY PROCESSING]")
2252
+
2253
+ # for page_num_0_based in range(doc.page_count):
2254
+ # page_num = page_num_0_based + 1
2255
+ # print(f" -> Processing Page {page_num}/{doc.page_count}...")
2256
+
2257
+ # fitz_page = doc.load_page(page_num_0_based)
2258
+
2259
+ # try:
2260
+ # pix = fitz_page.get_pixmap(matrix=mat)
2261
+ # original_img = pixmap_to_numpy(pix)
2262
+ # except Exception as e:
2263
+ # print(f" ❌ Error converting page {page_num} to image: {e}")
2264
+ # continue
2265
+
2266
+ # final_output, page_separator_x = preprocess_and_ocr_page(
2267
+ # original_img,
2268
+ # model,
2269
+ # pdf_path,
2270
+ # page_num,
2271
+ # fitz_page,
2272
+ # pdf_name
2273
+ # )
2274
+
2275
+ # if final_output is not None:
2276
+ # page_data = {
2277
+ # "page_number": page_num,
2278
+ # "data": final_output,
2279
+ # "column_separator_x": page_separator_x
2280
+ # }
2281
+ # all_pages_data.append(page_data)
2282
+ # total_pages_processed += 1
2283
+ # else:
2284
+ # print(f" ❌ Skipped page {page_num} due to processing error.")
2285
+
2286
+ # doc.close()
2287
+
2288
+ # if all_pages_data:
2289
+ # try:
2290
+ # with open(preprocessed_json_path, 'w') as f:
2291
+ # json.dump(all_pages_data, f, indent=4)
2292
+ # print(f"\n ✅ Combined structured OCR JSON saved to: {os.path.basename(preprocessed_json_path)}")
2293
+ # except Exception as e:
2294
+ # print(f"❌ ERROR saving combined JSON output: {e}")
2295
+ # return None
2296
+ # else:
2297
+ # print("❌ WARNING: No page data generated. Halting pipeline.")
2298
+ # return None
2299
+
2300
+ # print("\n" + "=" * 80)
2301
+ # print(f"--- YOLO/OCR PREPROCESSING COMPLETE ({total_pages_processed} pages processed) ---")
2302
+ # print("=" * 80)
2303
+
2304
+ # return preprocessed_json_path
2305
+
2306
+
2307
+ # # ============================================================================
2308
+ # # --- PHASE 2: LAYOUTLMV3 INFERENCE FUNCTIONS ---
2309
+ # # ============================================================================
2310
+
2311
+ # class LayoutLMv3ForTokenClassification(nn.Module):
2312
+ # def __init__(self, num_labels: int = NUM_LABELS):
2313
+ # super().__init__()
2314
+ # self.num_labels = num_labels
2315
+ # config = LayoutLMv3Config.from_pretrained("microsoft/layoutlmv3-base", num_labels=num_labels)
2316
+ # self.layoutlmv3 = LayoutLMv3Model.from_pretrained("microsoft/layoutlmv3-base", config=config)
2317
+ # self.classifier = nn.Linear(config.hidden_size, num_labels)
2318
+ # self.crf = CRF(num_labels)
2319
+ # self.init_weights()
2320
+
2321
+ # def init_weights(self):
2322
+ # nn.init.xavier_uniform_(self.classifier.weight)
2323
+ # if self.classifier.bias is not None: nn.init.zeros_(self.classifier.bias)
2324
+
2325
+ # def forward(self, input_ids: torch.Tensor, bbox: torch.Tensor, attention_mask: torch.Tensor, labels: Optional[torch.Tensor] = None):
2326
+ # outputs = self.layoutlmv3(input_ids=input_ids, bbox=bbox, attention_mask=attention_mask, return_dict=True)
2327
+ # sequence_output = outputs.last_hidden_state
2328
+ # emissions = self.classifier(sequence_output)
2329
+ # mask = attention_mask.bool()
2330
+ # if labels is not None:
2331
+ # loss = -self.crf(emissions, labels, mask=mask).mean()
2332
+ # return loss
2333
+ # else:
2334
+ # return self.crf.viterbi_decode(emissions, mask=mask)
2335
+
2336
+ # def _merge_integrity(all_token_data: List[Dict[str, Any]],
2337
+ # column_separator_x: Optional[int]) -> List[List[Dict[str, Any]]]:
2338
+ # """Splits the token data objects into column chunks based on a separator."""
2339
+ # if column_separator_x is None:
2340
+ # print(" -> No column separator. Treating as one chunk.")
2341
+ # return [all_token_data]
2342
+
2343
+ # left_column_tokens, right_column_tokens = [], []
2344
+ # for token_data in all_token_data:
2345
+ # bbox_raw = token_data['bbox_raw_pdf_space']
2346
+ # center_x = (bbox_raw[0] + bbox_raw[2]) / 2
2347
+ # if center_x < column_separator_x:
2348
+ # left_column_tokens.append(token_data)
2349
+ # else:
2350
+ # right_column_tokens.append(token_data)
2351
+
2352
+ # chunks = [c for c in [left_column_tokens, right_column_tokens] if c]
2353
+ # print(f" -> Data split into {len(chunks)} column chunk(s) using separator X={column_separator_x}.")
2354
+ # return chunks
2355
+
2356
+ # def run_inference_and_get_raw_words(pdf_path: str, model_path: str,
2357
+ # preprocessed_json_path: str,
2358
+ # column_detection_params: Optional[Dict] = None) -> List[Dict[str, Any]]:
2359
+ # print("\n" + "=" * 80)
2360
+ # print("--- 2. STARTING LAYOUTLMV3 INFERENCE PIPELINE (Raw Word Output) ---")
2361
+ # print("=" * 80)
2362
+
2363
+ # tokenizer = LayoutLMv3TokenizerFast.from_pretrained("microsoft/layoutlmv3-base")
2364
+ # device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
2365
+ # print(f" -> Using device: {device}")
2366
+
2367
+ # try:
2368
+ # model = LayoutLMv3ForTokenClassification(num_labels=NUM_LABELS)
2369
+ # checkpoint = torch.load(model_path, map_location=device)
2370
+ # model_state = checkpoint.get('model_state_dict', checkpoint)
2371
+ # fixed_state_dict = {key.replace('layoutlm.', 'layoutlmv3.'): value for key, value in model_state.items()}
2372
+ # model.load_state_dict(fixed_state_dict)
2373
+ # model.to(device)
2374
+ # model.eval()
2375
+ # print(f"✅ LayoutLMv3 Model loaded successfully from {os.path.basename(model_path)}.")
2376
+ # except Exception as e:
2377
+ # print(f"❌ FATAL ERROR during LayoutLMv3 model loading: {e}")
2378
+ # return []
2379
+
2380
+ # try:
2381
+ # with open(preprocessed_json_path, 'r', encoding='utf-8') as f:
2382
+ # preprocessed_data = json.load(f)
2383
+ # print(f"✅ Loaded preprocessed data with {len(preprocessed_data)} pages.")
2384
+ # except Exception:
2385
+ # print("❌ Error loading preprocessed JSON.")
2386
+ # return []
2387
+
2388
+ # try:
2389
+ # doc = fitz.open(pdf_path)
2390
+ # except Exception:
2391
+ # print("❌ Error loading PDF.")
2392
+ # return []
2393
+
2394
+ # final_page_predictions = []
2395
+ # CHUNK_SIZE = 500
2396
+
2397
+ # for page_data in preprocessed_data:
2398
+ # page_num_1_based = page_data['page_number']
2399
+ # page_num_0_based = page_num_1_based - 1
2400
+ # page_raw_predictions = []
2401
+ # print(f"\n *** Processing Page {page_num_1_based} ({len(page_data['data'])} raw tokens) ***")
2402
+
2403
+ # fitz_page = doc.load_page(page_num_0_based)
2404
+ # page_width, page_height = fitz_page.rect.width, fitz_page.rect.height
2405
+ # print(f" -> Page dimensions: {page_width:.0f}x{page_height:.0f} (PDF points).")
2406
+
2407
+ # all_token_data = []
2408
+ # scale_factor = 2.0
2409
+
2410
+ # for item in page_data['data']:
2411
+ # raw_yolo_bbox = item['bbox']
2412
+ # bbox_pdf = [
2413
+ # int(raw_yolo_bbox[0] / scale_factor), int(raw_yolo_bbox[1] / scale_factor),
2414
+ # int(raw_yolo_bbox[2] / scale_factor), int(raw_yolo_bbox[3] / scale_factor)
2415
+ # ]
2416
+ # normalized_bbox = [
2417
+ # max(0, min(1000, int(1000 * bbox_pdf[0] / page_width))),
2418
+ # max(0, min(1000, int(1000 * bbox_pdf[1] / page_height))),
2419
+ # max(0, min(1000, int(1000 * bbox_pdf[2] / page_width))),
2420
+ # max(0, min(1000, int(1000 * bbox_pdf[3] / page_height)))
2421
+ # ]
2422
+ # all_token_data.append({
2423
+ # "word": item['word'],
2424
+ # "bbox_raw_pdf_space": bbox_pdf,
2425
+ # "bbox_normalized": normalized_bbox,
2426
+ # "item_original_data": item
2427
+ # })
2428
+
2429
+ # if not all_token_data: continue
2430
+
2431
+ # column_separator_x = page_data.get('column_separator_x', None)
2432
+ # if column_separator_x is not None:
2433
+ # print(f" -> Using SAVED column separator: X={column_separator_x}")
2434
+ # else:
2435
+ # print(" -> No column separator found. Assuming single chunk.")
2436
+
2437
+ # token_chunks = _merge_integrity(all_token_data, column_separator_x)
2438
+ # total_chunks = len(token_chunks)
2439
+
2440
+ # for chunk_idx, chunk_tokens in enumerate(token_chunks):
2441
+ # if not chunk_tokens: continue
2442
+
2443
+ # chunk_words = [t['word'] for t in chunk_tokens]
2444
+ # chunk_normalized_bboxes = [t['bbox_normalized'] for t in chunk_tokens]
2445
+
2446
+ # total_sub_chunks = (len(chunk_words) + CHUNK_SIZE - 1) // CHUNK_SIZE
2447
+ # for i in range(0, len(chunk_words), CHUNK_SIZE):
2448
+ # sub_chunk_idx = i // CHUNK_SIZE + 1
2449
+ # sub_words = chunk_words[i:i + CHUNK_SIZE]
2450
+ # sub_bboxes = chunk_normalized_bboxes[i:i + CHUNK_SIZE]
2451
+ # sub_tokens_data = chunk_tokens[i:i + CHUNK_SIZE]
2452
+
2453
+ # print(f" -> Chunk {chunk_idx + 1}/{total_chunks}, Sub-chunk {sub_chunk_idx}/{total_sub_chunks}: {len(sub_words)} words. Running Inference...")
2454
+
2455
+ # encoded_input = tokenizer(
2456
+ # sub_words, boxes=sub_bboxes, truncation=True, padding="max_length",
2457
+ # max_length=512, return_tensors="pt"
2458
+ # )
2459
+ # input_ids = encoded_input['input_ids'].to(device)
2460
+ # bbox = encoded_input['bbox'].to(device)
2461
+ # attention_mask = encoded_input['attention_mask'].to(device)
2462
+
2463
+ # with torch.no_grad():
2464
+ # predictions_int_list = model(input_ids, bbox, attention_mask)
2465
+
2466
+ # if not predictions_int_list: continue
2467
+ # predictions_int = predictions_int_list[0]
2468
+ # word_ids = encoded_input.word_ids()
2469
+ # word_idx_to_pred_id = {}
2470
+
2471
+ # for token_idx, word_idx in enumerate(word_ids):
2472
+ # if word_idx is not None and word_idx < len(sub_words):
2473
+ # if word_idx not in word_idx_to_pred_id:
2474
+ # word_idx_to_pred_id[word_idx] = predictions_int[token_idx]
2475
+
2476
+ # for current_word_idx in range(len(sub_words)):
2477
+ # pred_id_or_tensor = word_idx_to_pred_id.get(current_word_idx, 0)
2478
+ # pred_id = pred_id_or_tensor.item() if torch.is_tensor(pred_id_or_tensor) else pred_id_or_tensor
2479
+ # predicted_label = ID_TO_LABEL[pred_id]
2480
+ # original_token = sub_tokens_data[current_word_idx]
2481
+ # page_raw_predictions.append({
2482
+ # "word": original_token['word'],
2483
+ # "bbox": original_token['bbox_raw_pdf_space'],
2484
+ # "predicted_label": predicted_label,
2485
+ # "page_number": page_num_1_based
2486
+ # })
2487
+
2488
+ # if page_raw_predictions:
2489
+ # final_page_predictions.append({
2490
+ # "page_number": page_num_1_based,
2491
+ # "data": page_raw_predictions
2492
+ # })
2493
+ # print(f" *** Page {page_num_1_based} Finalized: {len(page_raw_predictions)} labeled words. ***")
2494
+
2495
+ # doc.close()
2496
+ # print("\n" + "=" * 80)
2497
+ # print("--- LAYOUTLMV3 INFERENCE COMPLETE ---")
2498
+ # print("=" * 80)
2499
+ # return final_page_predictions
2500
+
2501
+
2502
+ # def create_label_studio_span(page_results, start_idx, end_idx, label):
2503
+ # entity_words = [page_results[i]['word'] for i in range(start_idx, end_idx + 1)]
2504
+ # entity_bboxes = [page_results[i]['bbox'] for i in range(start_idx, end_idx + 1)]
2505
+ # x0 = min(bbox[0] for bbox in entity_bboxes)
2506
+ # y0 = min(bbox[1] for bbox in entity_bboxes)
2507
+ # x1 = max(bbox[2] for bbox in entity_bboxes)
2508
+ # y1 = max(bbox[3] for bbox in entity_bboxes)
2509
+ # all_words_on_page = [r['word'] for r in page_results]
2510
+ # start_char = len(" ".join(all_words_on_page[:start_idx]))
2511
+ # if start_idx != 0: start_char += 1
2512
+ # end_char = start_char + len(" ".join(entity_words))
2513
+ # span_text = " ".join(entity_words)
2514
+ # return {
2515
+ # "from_name": "label", "to_name": "text", "type": "labels",
2516
+ # "value": {
2517
+ # "start": start_char, "end": end_char, "text": span_text,
2518
+ # "labels": [label],
2519
+ # "bbox": {"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0}
2520
+ # }, "score": 0.99
2521
+ # }
2522
+
2523
+ # def convert_raw_predictions_to_label_studio(page_data_list, output_path: str):
2524
+ # final_tasks = []
2525
+ # print("\n[PHASE: LABEL STUDIO CONVERSION]")
2526
+ # for page_data in page_data_list:
2527
+ # page_num = page_data['page_number']
2528
+ # page_results = page_data['data']
2529
+ # if not page_results: continue
2530
+ # original_words = [r['word'] for r in page_results]
2531
+ # text_string = " ".join(original_words)
2532
+ # results = []
2533
+ # current_entity_label = None
2534
+ # current_entity_start_word_index = None
2535
+
2536
+ # for i, pred_item in enumerate(page_results):
2537
+ # label = pred_item['predicted_label']
2538
+ # tag_only = label.split('-', 1)[-1] if '-' in label else label
2539
+ # if label.startswith('B-'):
2540
+ # if current_entity_label:
2541
+ # results.append(create_label_studio_span(page_results, current_entity_start_word_index, i - 1, current_entity_label))
2542
+ # current_entity_label = tag_only
2543
+ # current_entity_start_word_index = i
2544
+ # elif label.startswith('I-') and current_entity_label == tag_only:
2545
+ # continue
2546
+ # else:
2547
+ # if current_entity_label:
2548
+ # results.append(create_label_studio_span(page_results, current_entity_start_word_index, i - 1, current_entity_label))
2549
+ # current_entity_label = None
2550
+ # current_entity_start_word_index = None
2551
+ # if current_entity_label:
2552
+ # results.append(create_label_studio_span(page_results, current_entity_start_word_index, len(page_results) - 1, current_entity_label))
2553
+
2554
+ # final_tasks.append({
2555
+ # "data": {
2556
+ # "text": text_string, "original_words": original_words,
2557
+ # "original_bboxes": [r['bbox'] for r in page_results]
2558
+ # },
2559
+ # "annotations": [{"result": results}],
2560
+ # "meta": {"page_number": page_num}
2561
+ # })
2562
+ # with open(output_path, "w", encoding='utf-8') as f:
2563
+ # json.dump(final_tasks, f, indent=2, ensure_ascii=False)
2564
+ # print(f"\n✅ Label Studio tasks saved to {output_path}.")
2565
+
2566
+
2567
+ # # ============================================================================
2568
+ # # --- PHASE 3: BIO TO STRUCTURED JSON DECODER ---
2569
+ # # ============================================================================
2570
+
2571
+ # def convert_bio_to_structured_json_relaxed(input_path: str, output_path: str) -> Optional[List[Dict[str, Any]]]:
2572
+ # print("\n" + "=" * 80)
2573
+ # print("--- 3. STARTING BIO TO STRUCTURED JSON DECODING ---")
2574
+ # print("=" * 80)
2575
+ # try:
2576
+ # with open(input_path, 'r', encoding='utf-8') as f:
2577
+ # predictions_by_page = json.load(f)
2578
+ # except Exception as e:
2579
+ # print(f"❌ Error loading raw prediction file: {e}")
2580
+ # return None
2581
+
2582
+ # predictions = []
2583
+ # for page_item in predictions_by_page:
2584
+ # if isinstance(page_item, dict) and 'data' in page_item:
2585
+ # predictions.extend(page_item['data'])
2586
+
2587
+ # structured_data = []
2588
+ # current_item = None
2589
+ # current_option_key = None
2590
+ # current_passage_buffer = []
2591
+ # current_text_buffer = []
2592
+ # first_question_started = False
2593
+ # last_entity_type = None
2594
+ # just_finished_i_option = False
2595
+ # is_in_new_passage = False
2596
+
2597
+ # def finalize_passage_to_item(item, passage_buffer):
2598
+ # if passage_buffer:
2599
+ # passage_text = re.sub(r'\s{2,}', ' ', ' '.join(passage_buffer)).strip()
2600
+ # if item.get('passage'): item['passage'] += ' ' + passage_text
2601
+ # else: item['passage'] = passage_text
2602
+ # passage_buffer.clear()
2603
+
2604
+ # for item in predictions:
2605
+ # word = item['word']
2606
+ # label = item['predicted_label']
2607
+ # entity_type = label[2:].strip() if label.startswith(('B-', 'I-')) else None
2608
+ # current_text_buffer.append(word)
2609
+ # previous_entity_type = last_entity_type
2610
+ # is_passage_label = (entity_type == 'PASSAGE')
2611
+
2612
+ # if not first_question_started:
2613
+ # if label != 'B-QUESTION' and not is_passage_label:
2614
+ # just_finished_i_option = False
2615
+ # is_in_new_passage = False
2616
+ # continue
2617
+ # if is_passage_label:
2618
+ # current_passage_buffer.append(word)
2619
+ # last_entity_type = 'PASSAGE'
2620
+ # just_finished_i_option = False
2621
+ # is_in_new_passage = False
2622
+ # continue
2623
+
2624
+ # if label == 'B-QUESTION':
2625
+ # if not first_question_started:
2626
+ # header_text = ' '.join(current_text_buffer[:-1]).strip()
2627
+ # if header_text or current_passage_buffer:
2628
+ # metadata_item = {'type': 'METADATA', 'passage': ''}
2629
+ # finalize_passage_to_item(metadata_item, current_passage_buffer)
2630
+ # if header_text: metadata_item['text'] = header_text
2631
+ # structured_data.append(metadata_item)
2632
+ # first_question_started = True
2633
+ # current_text_buffer = [word]
2634
+
2635
+ # if current_item is not None:
2636
+ # finalize_passage_to_item(current_item, current_passage_buffer)
2637
+ # current_item['text'] = ' '.join(current_text_buffer[:-1]).strip()
2638
+ # structured_data.append(current_item)
2639
+ # current_text_buffer = [word]
2640
+
2641
+ # current_item = {
2642
+ # 'question': word, 'options': {}, 'answer': '', 'passage': '', 'text': ''
2643
+ # }
2644
+ # current_option_key = None
2645
+ # last_entity_type = 'QUESTION'
2646
+ # just_finished_i_option = False
2647
+ # is_in_new_passage = False
2648
+ # continue
2649
+
2650
+ # if current_item is not None:
2651
+ # if is_in_new_passage:
2652
+ # current_item['new_passage'] += f' {word}'
2653
+ # if label.startswith('B-') or (label.startswith('I-') and entity_type != 'PASSAGE'):
2654
+ # is_in_new_passage = False
2655
+ # if label.startswith(('B-', 'I-')): last_entity_type = entity_type
2656
+ # continue
2657
+ # is_in_new_passage = False
2658
+
2659
+ # if label.startswith('B-'):
2660
+ # if entity_type in ['QUESTION', 'OPTION', 'ANSWER', 'SECTION_HEADING']:
2661
+ # finalize_passage_to_item(current_item, current_passage_buffer)
2662
+ # current_passage_buffer = []
2663
+ # last_entity_type = entity_type
2664
+ # if entity_type == 'PASSAGE':
2665
+ # if previous_entity_type == 'OPTION' and just_finished_i_option:
2666
+ # current_item['new_passage'] = word
2667
+ # is_in_new_passage = True
2668
+ # else:
2669
+ # current_passage_buffer.append(word)
2670
+ # elif entity_type == 'OPTION':
2671
+ # current_option_key = word
2672
+ # current_item['options'][current_option_key] = word
2673
+ # just_finished_i_option = False
2674
+ # elif entity_type == 'ANSWER':
2675
+ # current_item['answer'] = word
2676
+ # current_option_key = None
2677
+ # just_finished_i_option = False
2678
+ # elif entity_type == 'QUESTION':
2679
+ # current_item['question'] += f' {word}'
2680
+ # just_finished_i_option = False
2681
+
2682
+ # elif label.startswith('I-'):
2683
+ # if entity_type == 'QUESTION':
2684
+ # current_item['question'] += f' {word}'
2685
+ # elif entity_type == 'PASSAGE':
2686
+ # if previous_entity_type == 'OPTION' and just_finished_i_option:
2687
+ # current_item['new_passage'] = word
2688
+ # is_in_new_passage = True
2689
+ # else:
2690
+ # if not current_passage_buffer: last_entity_type = 'PASSAGE'
2691
+ # current_passage_buffer.append(word)
2692
+ # elif entity_type == 'OPTION' and current_option_key is not None:
2693
+ # current_item['options'][current_option_key] += f' {word}'
2694
+ # just_finished_i_option = True
2695
+ # elif entity_type == 'ANSWER':
2696
+ # current_item['answer'] += f' {word}'
2697
+ # just_finished_i_option = (entity_type == 'OPTION')
2698
+
2699
+ # elif label == 'O':
2700
+ # if last_entity_type == 'QUESTION':
2701
+ # current_item['question'] += f' {word}'
2702
+ # just_finished_i_option = False
2703
+
2704
+ # if current_item is not None:
2705
+ # finalize_passage_to_item(current_item, current_passage_buffer)
2706
+ # current_item['text'] = ' '.join(current_text_buffer).strip()
2707
+ # structured_data.append(current_item)
2708
+
2709
+ # for item in structured_data:
2710
+ # item['text'] = re.sub(r'\s{2,}', ' ', item['text']).strip()
2711
+ # if 'new_passage' in item:
2712
+ # item['new_passage'] = re.sub(r'\s{2,}', ' ', item['new_passage']).strip()
2713
+
2714
+ # try:
2715
+ # with open(output_path, 'w', encoding='utf-8') as f:
2716
+ # json.dump(structured_data, f, indent=2, ensure_ascii=False)
2717
+ # except Exception: pass
2718
+
2719
+ # return structured_data
2720
+
2721
+ # def correct_misaligned_options(structured_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
2722
+ # print("\n" + "=" * 80)
2723
+ # print("--- 5. STARTING POST-PROCESSING: OPTION ALIGNMENT CORRECTION ---")
2724
+ # print("=" * 80)
2725
+ # tag_pattern = re.compile(r'(EQUATION\d+|FIGURE\d+)')
2726
+ # corrected_count = 0
2727
+ # for item in structured_data:
2728
+ # if item.get('type') in ['METADATA']: continue
2729
+ # options = item.get('options')
2730
+ # if not options or len(options) < 2: continue
2731
+ # option_keys = list(options.keys())
2732
+ # for i in range(len(option_keys) - 1):
2733
+ # current_key = option_keys[i]
2734
+ # next_key = option_keys[i + 1]
2735
+ # current_value = options[current_key].strip()
2736
+ # next_value = options[next_key].strip()
2737
+ # is_current_empty = current_value == current_key
2738
+ # content_in_next = next_value.replace(next_key, '', 1).strip()
2739
+ # tags_in_next = tag_pattern.findall(content_in_next)
2740
+ # has_two_tags = len(tags_in_next) == 2
2741
+ # if is_current_empty and has_two_tags:
2742
+ # tag_to_move = tags_in_next[0]
2743
+ # options[current_key] = f"{current_key} {tag_to_move}".strip()
2744
+ # options[next_key] = f"{next_key} {tags_in_next[1]}".strip()
2745
+ # corrected_count += 1
2746
+ # print(f"✅ Option alignment correction finished. Total corrections: {corrected_count}.")
2747
+ # return structured_data
2748
+
2749
+ # # ============================================================================
2750
+ # # --- PHASE 4: IMAGE EMBEDDING (Base64) ---
2751
+ # # ============================================================================
2752
+
2753
+ # def get_base64_for_file(filepath: str) -> str:
2754
+ # try:
2755
+ # with open(filepath, 'rb') as f:
2756
+ # return base64.b64encode(f.read()).decode('utf-8')
2757
+ # except Exception as e:
2758
+ # print(f" ❌ Error encoding file {filepath}: {e}")
2759
+ # return ""
2760
+
2761
+ # def embed_images_as_base64_in_memory(structured_data: List[Dict[str, Any]], figure_extraction_dir: str) -> List[Dict[str, Any]]:
2762
+ # print("\n" + "=" * 80)
2763
+ # print("--- 4. STARTING IMAGE EMBEDDING (Base64) ---")
2764
+ # print("=" * 80)
2765
+ # if not structured_data: return []
2766
+ # image_files = glob.glob(os.path.join(figure_extraction_dir, "*.png"))
2767
+ # image_lookup = {}
2768
+ # tag_regex = re.compile(r'(figure|equation)(\d+)', re.IGNORECASE)
2769
+ # for filepath in image_files:
2770
+ # filename = os.path.basename(filepath)
2771
+ # match = re.search(r'_(figure|equation)(\d+)\.png$', filename, re.IGNORECASE)
2772
+ # if match:
2773
+ # key = f"{match.group(1).upper()}{match.group(2)}"
2774
+ # image_lookup[key] = filepath
2775
+ # print(f" -> Found {len(image_lookup)} image components.")
2776
+ # final_structured_data = []
2777
+ # for item in structured_data:
2778
+ # text_fields = [item.get('question', ''), item.get('passage', '')]
2779
+ # if 'options' in item:
2780
+ # for opt_val in item['options'].values(): text_fields.append(opt_val)
2781
+ # if 'new_passage' in item: text_fields.append(item['new_passage'])
2782
+ # unique_tags_to_embed = set()
2783
+ # for text in text_fields:
2784
+ # if not text: continue
2785
+ # for match in tag_regex.finditer(text):
2786
+ # tag = match.group(0).upper()
2787
+ # if tag in image_lookup: unique_tags_to_embed.add(tag)
2788
+ # for tag in sorted(list(unique_tags_to_embed)):
2789
+ # filepath = image_lookup[tag]
2790
+ # base64_code = get_base64_for_file(filepath)
2791
+ # base_key = tag.replace(' ', '').lower()
2792
+ # item[base_key] = base64_code
2793
+ # final_structured_data.append(item)
2794
+ # print(f"✅ Image embedding complete.")
2795
+ # return final_structured_data
2796
+
2797
+ # # ============================================================================
2798
+ # # --- MAIN FUNCTION ---
2799
+ # # ============================================================================
2800
+
2801
+ # def run_document_pipeline(input_pdf_path: str, layoutlmv3_model_path: str, label_studio_output_path: str) -> Optional[List[Dict[str, Any]]]:
2802
+ # if not os.path.exists(input_pdf_path): return None
2803
+
2804
+ # print("\n" + "#" * 80)
2805
+ # print("### STARTING OPTIMIZED FULL DOCUMENT ANALYSIS PIPELINE ###")
2806
+ # print("#" * 80)
2807
+
2808
+ # pdf_name = os.path.splitext(os.path.basename(input_pdf_path))[0]
2809
+ # temp_pipeline_dir = os.path.join(tempfile.gettempdir(), f"pipeline_run_{pdf_name}_{os.getpid()}")
2810
+ # os.makedirs(temp_pipeline_dir, exist_ok=True)
2811
+
2812
+ # preprocessed_json_path = os.path.join(temp_pipeline_dir, f"{pdf_name}_preprocessed.json")
2813
+ # raw_output_path = os.path.join(temp_pipeline_dir, f"{pdf_name}_raw_predictions.json")
2814
+ # structured_intermediate_output_path = os.path.join(temp_pipeline_dir, f"{pdf_name}_structured_intermediate.json")
2815
+
2816
+ # final_result = None
2817
+ # try:
2818
+ # # Phase 1: Preprocessing with YOLO First + Masking
2819
+ # preprocessed_json_path_out = run_single_pdf_preprocessing(input_pdf_path, preprocessed_json_path)
2820
+ # if not preprocessed_json_path_out: return None
2821
+
2822
+ # # Phase 2: Inference
2823
+ # page_raw_predictions_list = run_inference_and_get_raw_words(
2824
+ # input_pdf_path, layoutlmv3_model_path, preprocessed_json_path_out
2825
+ # )
2826
+ # if not page_raw_predictions_list: return None
2827
+
2828
+ # with open(raw_output_path, 'w', encoding='utf-8') as f:
2829
+ # json.dump(page_raw_predictions_list, f, indent=4)
2830
+
2831
+ # # Phase 3: Decoding
2832
+ # structured_data_list = convert_bio_to_structured_json_relaxed(
2833
+ # raw_output_path, structured_intermediate_output_path
2834
+ # )
2835
+ # if not structured_data_list: return None
2836
+ # structured_data_list = correct_misaligned_options(structured_data_list)
2837
+
2838
+ # try:
2839
+ # convert_raw_predictions_to_label_studio(page_raw_predictions_list, label_studio_output_path)
2840
+ # except Exception as e:
2841
+ # print(f"❌ Error during Label Studio conversion: {e}")
2842
+
2843
+ # # Phase 4: Embedding
2844
+ # final_result = embed_images_as_base64_in_memory(structured_data_list, FIGURE_EXTRACTION_DIR)
2845
+
2846
+ # except Exception as e:
2847
+ # print(f"❌ FATAL ERROR: {e}")
2848
+ # import traceback
2849
+ # traceback.print_exc()
2850
+ # return None
2851
+
2852
+ # finally:
2853
+ # try:
2854
+ # for f in glob.glob(os.path.join(temp_pipeline_dir, '*')):
2855
+ # os.remove(f)
2856
+ # os.rmdir(temp_pipeline_dir)
2857
+ # except Exception: pass
2858
+
2859
+ # print("\n" + "#" * 80)
2860
+ # print("### OPTIMIZED PIPELINE EXECUTION COMPLETE ###")
2861
+ # print("#" * 80)
2862
+ # return final_result
2863
+
2864
+ # if __name__ == "__main__":
2865
+ # parser = argparse.ArgumentParser(description="Complete Pipeline")
2866
+ # parser.add_argument("--input_pdf", type=str, required=True, help="Input PDF")
2867
+ # parser.add_argument("--layoutlmv3_model_path", type=str, default=DEFAULT_LAYOUTLMV3_MODEL_PATH, help="Model Path")
2868
+ # parser.add_argument("--ls_output_path", type=str, default=None, help="Label Studio Output Path")
2869
+ # args = parser.parse_args()
2870
+
2871
+ # pdf_name = os.path.splitext(os.path.basename(args.input_pdf))[0]
2872
+ # final_output_path = os.path.abspath(f"{pdf_name}_final_output_embedded.json")
2873
+ # ls_output_path = os.path.abspath(args.ls_output_path if args.ls_output_path else f"{pdf_name}_label_studio_tasks.json")
2874
+
2875
+ # final_json_data = run_document_pipeline(args.input_pdf, args.layoutlmv3_model_path, ls_output_path)
2876
+
2877
+ # if final_json_data:
2878
+ # with open(final_output_path, 'w', encoding='utf-8') as f:
2879
+ # json.dump(final_json_data, f, indent=2, ensure_ascii=False)
2880
+ # print(f"\n✅ Final Data Saved: {final_output_path}")
2881
+ # else:
2882
+ # print("\n❌ Pipeline Failed.")
2883
+ # sys.exit(1)
2884
+
2885
+
2886
+
2887
+
2888
  import json
2889
  import argparse
2890
  import os
 
3175
 
3176
 
3177
 
 
3178
  def calculate_x_gutters(word_data: list, params: Dict, page_height: float) -> List[int]:
3179
  """
3180
  Calculates X-axis histogram and validates using BRIDGING DENSITY and Vertical Coverage.
 
3197
 
3198
  # Histogram Setup
3199
  bin_size = params.get('cluster_bin_size', 5)
3200
+ smoothing = params.get('cluster_smoothing', 1)
3201
  min_width = params.get('cluster_min_width', 20)
3202
  threshold_percentile = params.get('cluster_threshold_percentile', 85)
3203
 
 
3236
 
3237
  # THRESHOLD: If bridging blocks > 8% of page height, REJECT.
3238
  # This allows for page numbers or headers (usually < 5%) to cross, but NOT paragraphs.
3239
+ if bridging_ratio > 0.00:
3240
+ print(f" ❌ Separator X={x_coord} REJECTED: Bridging Ratio {bridging_ratio:.1%} (>15%) cuts through text.")
3241
  continue
3242
 
3243
  # --- CHECK 2: VERTICAL GAP COVERAGE (The "Clean Split" Check) ---
3244
  # The gap must exist cleanly for > 65% of the text height.
3245
  coverage = calculate_vertical_gap_coverage(word_data, x_coord, page_height, gutter_width=min_width)
3246
 
3247
+ if coverage >= 0.80:
3248
  final_separators.append(x_coord)
3249
  print(f" -> Separator X={x_coord} ACCEPTED (Coverage: {coverage:.1%}, Bridging: {bridging_ratio:.1%})")
3250
  else:
 
3371
  page_height_pdf = fitz_page.rect.height
3372
 
3373
  column_detection_params = {
3374
+ 'cluster_bin_size': 2, 'cluster_smoothing': 2,
3375
+ 'cluster_min_width': 10, 'cluster_threshold_percentile': 85,
3376
  }
3377
 
3378
  separators = calculate_x_gutters(masked_word_data, column_detection_params, page_height_pdf)