abhivsh commited on
Commit
fc794a2
Β·
verified Β·
1 Parent(s): 4c402db

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +2144 -0
  2. styles.css +66 -0
app.py ADDED
@@ -0,0 +1,2144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py β€” POWERGRID Document Auditor
3
+ ======================================
4
+ Single-file Gradio app for AI-powered engineering drawing comparison.
5
+ Designed for POWERGRID (765/400/132kV AIS/GIS vendor drawing audits).
6
+
7
+ Pipeline:
8
+ Stage 1 β€” Global Alignment : Phase Correlation + ORB/RANSAC homography
9
+ Stage 2 β€” Region Extraction : Content-aware morphology (no pretrained detector)
10
+ Stage 3 β€” Semantic Matching : ResNet50 embeddings + cosine similarity (position-agnostic)
11
+ Stage 4 β€” Siamese Comparison: ResNet50 patch embeddings (pixel diff + SSIM + semantic distance)
12
+
13
+ Run locally:
14
+ python app.py
15
+ """
16
+
17
+ # ══════════════════════════════════════════════════════════════════════
18
+ # IMPORTS
19
+ # ══════════════════════════════════════════════════════════════════════
20
+
21
+ import base64
22
+ import io
23
+ import logging
24
+ import os
25
+ import time
26
+ from dataclasses import dataclass
27
+ from typing import Dict, List, Optional, Tuple
28
+
29
+ import cv2
30
+ import fitz # PyMuPDF
31
+ import gradio as gr
32
+ import numpy as np
33
+ import torch
34
+ import torch.nn as nn
35
+ import torch.nn.functional as F
36
+ from PIL import Image
37
+ from scipy.optimize import linear_sum_assignment
38
+ from skimage.metrics import structural_similarity as ssim
39
+ from torchvision import models, transforms
40
+
41
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # ── Logo: embed as base64 so it works on HuggingFace Spaces (no static folder) ──
45
+ def _load_logo_b64(filename: str = "logo_0.png") -> str:
46
+ """Return a data-URI string for the logo, or empty string if file not found."""
47
+ logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
48
+ if os.path.exists(logo_path):
49
+ with open(logo_path, "rb") as f:
50
+ b64 = base64.b64encode(f.read()).decode("utf-8")
51
+ ext = filename.rsplit(".", 1)[-1].lower()
52
+ mime = "image/png" if ext == "png" else f"image/{ext}"
53
+ return f"data:{mime};base64,{b64}"
54
+ return ""
55
+
56
+ _LOGO_URI = _load_logo_b64("logo_0.png")
57
+
58
+
59
+ # ══════════════════════════════════════════════════════════════════════
60
+ # DATA STRUCTURES
61
+ # ══════════════════════════════════════════════════════════════════════
62
+
63
+ @dataclass
64
+ class Region:
65
+ """A detected layout region (axis-aligned bounding box)."""
66
+ x: int
67
+ y: int
68
+ w: int
69
+ h: int
70
+ label: str = "text_block" # text_block | figure | table | margin
71
+
72
+ @property
73
+ def bbox(self) -> Tuple[int, int, int, int]:
74
+ return (self.x, self.y, self.x + self.w, self.y + self.h)
75
+
76
+ @property
77
+ def area(self) -> int:
78
+ return self.w * self.h
79
+
80
+ @property
81
+ def center(self) -> Tuple[float, float]:
82
+ return (self.x + self.w / 2.0, self.y + self.h / 2.0)
83
+
84
+ def iou(self, other: "Region") -> float:
85
+ xa = max(self.x, other.x)
86
+ ya = max(self.y, other.y)
87
+ xb = min(self.x + self.w, other.x + other.w)
88
+ yb = min(self.y + self.h, other.y + other.h)
89
+ inter = max(0, xb - xa) * max(0, yb - ya)
90
+ union = self.area + other.area - inter
91
+ return inter / union if union > 0 else 0.0
92
+
93
+
94
+ @dataclass
95
+ class MatchedPair:
96
+ """A matched region pair between old and new documents."""
97
+ region_old: Region
98
+ region_new: Region
99
+ match_score: float
100
+ position_cost: float
101
+ appearance_cost: float
102
+ pixel_diff: float = 0.0
103
+ ssim_score: float = 1.0
104
+ semantic_diff: float = 0.0
105
+ total_change: float = 0.0
106
+
107
+
108
+ @dataclass
109
+ class ComparisonResult:
110
+ """Full comparison result for one document page."""
111
+ matched_pairs: List[MatchedPair]
112
+ unmatched_old: List[Region]
113
+ unmatched_new: List[Region]
114
+ global_transform: Optional[np.ndarray]
115
+ total_change_pct: float
116
+ img_old_aligned: Optional[np.ndarray] = None # aligned OLD, same coord-space as NEW
117
+
118
+
119
+ # ══════════════════════════════════════════════════════════════════════
120
+ # STAGE 1 β€” GLOBAL ALIGNER
121
+ # ══════════════════════════════════════════════════════════════════════
122
+
123
+ class GlobalAligner:
124
+ def __init__(self, orb_features: int = 2000, ransac_threshold: float = 5.0):
125
+ self.orb_features = orb_features
126
+ self.ransac_threshold = ransac_threshold
127
+
128
+ def _phase_correlation_shift(self, gray1: np.ndarray, gray2: np.ndarray) -> Tuple[float, float]:
129
+ f1 = np.fft.fft2(gray1.astype(np.float32))
130
+ f2 = np.fft.fft2(gray2.astype(np.float32))
131
+ denom = np.abs(f1 * np.conj(f2)) + 1e-10
132
+ cross = (f1 * np.conj(f2)) / denom
133
+ corr = np.fft.ifft2(cross).real
134
+ y_shift, x_shift = np.unravel_index(np.argmax(corr), corr.shape)
135
+ h, w = gray1.shape
136
+ if y_shift > h // 2:
137
+ y_shift -= h
138
+ if x_shift > w // 2:
139
+ x_shift -= w
140
+ return float(-x_shift), float(-y_shift)
141
+
142
+ def _orb_affine(self, gray_old: np.ndarray, gray_new: np.ndarray) -> Optional[np.ndarray]:
143
+ orb = cv2.ORB_create(nfeatures=self.orb_features)
144
+ kp1, des1 = orb.detectAndCompute(gray_old, None)
145
+ kp2, des2 = orb.detectAndCompute(gray_new, None)
146
+ if des1 is None or des2 is None or len(kp1) < 10 or len(kp2) < 10:
147
+ return None
148
+ bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
149
+ matches = sorted(bf.match(des1, des2), key=lambda m: m.distance)
150
+ if len(matches) < 10:
151
+ return None
152
+ top_k = min(200, len(matches))
153
+ # src = OLD keypoints, dst = NEW keypoints
154
+ # → M maps OLD→NEW (forward transform), which is what warpAffine expects:
155
+ # warpAffine(img_old, M, size) correctly places OLD pixels at their NEW positions.
156
+ # BUG that was here: src/dst were swapped (kp2/NEW as src, kp1/OLD as dst),
157
+ # giving M that mapped NEW→OLD. warpAffine then doubled the displacement
158
+ # instead of correcting it, causing the full-image red/cyan fringe seen in
159
+ # the Alignment Check view.
160
+ src_pts = np.float32([kp1[m.queryIdx].pt for m in matches[:top_k]]).reshape(-1, 1, 2)
161
+ dst_pts = np.float32([kp2[m.trainIdx].pt for m in matches[:top_k]]).reshape(-1, 1, 2)
162
+ M, mask = cv2.estimateAffinePartial2D(
163
+ src_pts, dst_pts, method=cv2.RANSAC,
164
+ ransacReprojThreshold=self.ransac_threshold,
165
+ )
166
+ return M
167
+
168
+ def align(self, img_old: np.ndarray, img_new: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
169
+ g_old = cv2.cvtColor(img_old, cv2.COLOR_RGB2GRAY)
170
+ g_new = cv2.cvtColor(img_new, cv2.COLOR_RGB2GRAY)
171
+ dx, dy = self._phase_correlation_shift(g_old, g_new)
172
+ M = self._orb_affine(g_old, g_new)
173
+ if M is None:
174
+ M = np.array([[1.0, 0.0, dx], [0.0, 1.0, dy]], dtype=np.float32)
175
+ h, w = img_old.shape[:2]
176
+ aligned = cv2.warpAffine(
177
+ img_old, M, (w, h),
178
+ flags=cv2.INTER_LINEAR,
179
+ borderMode=cv2.BORDER_CONSTANT,
180
+ borderValue=(255, 255, 255),
181
+ )
182
+ return aligned, M
183
+
184
+
185
+ # ══════════════════════════════════════════════════════════════════════
186
+ # STAGE 2 β€” LAYOUT REGION EXTRACTOR
187
+ # ══════════════════════════════════════════════════════════════════════
188
+
189
+ class LayoutRegionExtractor:
190
+ def __init__(
191
+ self,
192
+ min_area_ratio: float = 0.0003,
193
+ max_area_ratio: float = 0.92,
194
+ dilation_kernel: Tuple[int, int] = (8, 2),
195
+ dilation_iters: int = 2,
196
+ merge_iou_threshold: float = 0.40,
197
+ ):
198
+ self.min_area_ratio = min_area_ratio
199
+ self.max_area_ratio = max_area_ratio
200
+ self.dilation_kernel = dilation_kernel
201
+ self.dilation_iters = dilation_iters
202
+ self.merge_iou_threshold = merge_iou_threshold
203
+
204
+ def _binarise(self, gray: np.ndarray) -> np.ndarray:
205
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
206
+ _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
207
+ return binary
208
+
209
+ def _dilate(self, binary: np.ndarray) -> np.ndarray:
210
+ k = cv2.getStructuringElement(cv2.MORPH_RECT, self.dilation_kernel)
211
+ dilated = cv2.dilate(binary, k, iterations=self.dilation_iters)
212
+ k_line = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 1))
213
+ dilated = cv2.dilate(dilated, k_line, iterations=1)
214
+ k_vert = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 8))
215
+ return cv2.morphologyEx(dilated, cv2.MORPH_CLOSE, k_vert)
216
+
217
+ def _classify(self, patch_gray: np.ndarray, w: int, h: int) -> str:
218
+ aspect = w / max(h, 1)
219
+ _, binary = cv2.threshold(patch_gray, 127, 255, cv2.THRESH_BINARY_INV)
220
+ density = np.sum(binary > 0) / max(w * h, 1)
221
+ if density < 0.02:
222
+ contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
223
+ if len(contours) < 3:
224
+ return "margin"
225
+ if aspect > 4.0 and density > 0.06:
226
+ return "text_block"
227
+ if 0.4 < aspect < 2.8 and density < 0.25:
228
+ return "figure"
229
+ if density > 0.18 and aspect > 1.0:
230
+ return "table"
231
+ return "text_block"
232
+
233
+ def _merge_overlapping(self, regions: List[Region]) -> List[Region]:
234
+ changed = True
235
+ while changed:
236
+ changed = False
237
+ used = [False] * len(regions)
238
+ merged: List[Region] = []
239
+ for i, r1 in enumerate(regions):
240
+ if used[i]:
241
+ continue
242
+ x0, y0 = r1.x, r1.y
243
+ x1, y1 = r1.x + r1.w, r1.y + r1.h
244
+ for j, r2 in enumerate(regions):
245
+ if i == j or used[j]:
246
+ continue
247
+ expanded = Region(x0, y0, x1 - x0, y1 - y0)
248
+ if expanded.iou(r2) > self.merge_iou_threshold:
249
+ x0 = min(x0, r2.x)
250
+ y0 = min(y0, r2.y)
251
+ x1 = max(x1, r2.x + r2.w)
252
+ y1 = max(y1, r2.y + r2.h)
253
+ used[j] = True
254
+ changed = True
255
+ merged.append(Region(x0, y0, x1 - x0, y1 - y0))
256
+ used[i] = True
257
+ regions = merged
258
+ return regions
259
+
260
+ def extract(self, img_rgb: np.ndarray) -> List[Region]:
261
+ h, w = img_rgb.shape[:2]
262
+ page_area = h * w
263
+ gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
264
+ binary = self._binarise(gray)
265
+ dilated = self._dilate(binary)
266
+ contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
267
+ candidates: List[Region] = []
268
+ for cnt in contours:
269
+ rx, ry, rw, rh = cv2.boundingRect(cnt)
270
+ area = rw * rh
271
+ if area < page_area * self.min_area_ratio:
272
+ continue
273
+ if area > page_area * self.max_area_ratio:
274
+ continue
275
+ patch = gray[ry: ry + rh, rx: rx + rw]
276
+ label = self._classify(patch, rw, rh)
277
+ if label == "margin":
278
+ continue
279
+ candidates.append(Region(rx, ry, rw, rh, label=label))
280
+ regions = self._merge_overlapping(candidates)
281
+ regions.sort(key=lambda r: (r.y // 50, r.x))
282
+ logger.info("LayoutExtractor: %d regions detected", len(regions))
283
+ return regions
284
+
285
+
286
+ # ══════════════════════════════════════════════════════════════════════
287
+ # STAGE 3 β€” SEMANTIC RETRIEVAL MATCHER (position-agnostic)
288
+ # ══════════════════════════════════════════════════════════════════════
289
+
290
+ class SemanticRetrievalMatcher:
291
+ """
292
+ Layout-shift-robust document comparison via semantic retrieval.
293
+
294
+ Strategy
295
+ --------
296
+ For every region in the NEW page:
297
+ 1. Extract the patch image from the NEW document.
298
+ 2. Encode it with the shared ResNet50 backbone β†’ 128-d L2-normalised vector.
299
+ Simultaneously encode every OLD region patch.
300
+ Build an (N_new Γ— N_old) cosine-similarity matrix.
301
+ Run scipy.linear_sum_assignment on βˆ’similarity (maximise similarity).
302
+ Accept a pair only when similarity β‰₯ min_similarity.
303
+
304
+ This means a region that has *moved* (different x/y) but is otherwise
305
+ identical will still get similarity β‰ˆ 1.0 and be matched correctly.
306
+ """
307
+
308
+ def __init__(
309
+ self,
310
+ encoder: "_SiameseEncoder",
311
+ device: torch.device,
312
+ min_similarity: float = 0.50,
313
+ thumbnail_size: Tuple[int, int] = (224, 224),
314
+ ):
315
+ self.encoder = encoder
316
+ self.device = device
317
+ self.min_similarity = min_similarity
318
+ self._transform = transforms.Compose([
319
+ transforms.Resize(thumbnail_size),
320
+ transforms.ToTensor(),
321
+ transforms.Normalize(mean=[0.485, 0.456, 0.406],
322
+ std=[0.229, 0.224, 0.225]),
323
+ ])
324
+
325
+ # ------------------------------------------------------------------
326
+ def _patch(self, region: Region, img: np.ndarray) -> np.ndarray:
327
+ """Crop a region from the image; returns white 64Γ—64 if empty."""
328
+ p = img[region.y: region.y + region.h, region.x: region.x + region.w]
329
+ if p.size == 0:
330
+ p = np.full((64, 64, 3), 255, dtype=np.uint8)
331
+ return p
332
+
333
+ def _embed(self, patches: List[np.ndarray]) -> torch.Tensor:
334
+ """
335
+ Batch-encode a list of patches β†’ (N, 128) normalised embedding tensor.
336
+ Runs entirely on self.device with no gradient.
337
+ """
338
+ tensors = [
339
+ self._transform(Image.fromarray(p)) for p in patches
340
+ ]
341
+ batch = torch.stack(tensors).to(self.device) # (N, 3, 224, 224)
342
+ with torch.no_grad():
343
+ embeddings, _ = self.encoder.encode(batch) # (N, 128) β€” already L2-normed
344
+ return embeddings
345
+
346
+ # ------------------------------------------------------------------
347
+ def match(
348
+ self,
349
+ regions_old: List[Region],
350
+ regions_new: List[Region],
351
+ img_old: np.ndarray,
352
+ img_new: np.ndarray,
353
+ ) -> Tuple[List[MatchedPair], List[Region], List[Region]]:
354
+ n_old, n_new = len(regions_old), len(regions_new)
355
+ if n_old == 0 or n_new == 0:
356
+ return [], list(regions_old), list(regions_new)
357
+
358
+ # ── 1. Encode both sets of patches ─────────────────────────
359
+ patches_old = [self._patch(r, img_old) for r in regions_old]
360
+ patches_new = [self._patch(r, img_new) for r in regions_new]
361
+
362
+ emb_old = self._embed(patches_old) # (n_old, 128)
363
+ emb_new = self._embed(patches_new) # (n_new, 128)
364
+
365
+ # ── 2. Cosine similarity matrix: rows=NEW, cols=OLD ─────────
366
+ # L2-normed β†’ dot product == cosine similarity
367
+ sim_mat = torch.mm(emb_new, emb_old.T).cpu().numpy() # (n_new, n_old)
368
+
369
+ # ── 3. Hungarian assignment on βˆ’similarity ──────────────────
370
+ row_ind, col_ind = linear_sum_assignment(-sim_mat) # maximise sim
371
+
372
+ matched_pairs: List[MatchedPair] = []
373
+ matched_old_idx: set = set()
374
+ matched_new_idx: set = set()
375
+
376
+ for ri, ci in zip(row_ind, col_ind):
377
+ sim = float(sim_mat[ri, ci])
378
+ if sim < self.min_similarity:
379
+ continue # below threshold β†’ treat as unmatched
380
+ matched_pairs.append(MatchedPair(
381
+ region_old = regions_old[ci],
382
+ region_new = regions_new[ri],
383
+ match_score = sim,
384
+ position_cost = 0.0, # no position penalty
385
+ appearance_cost= max(0.0, 1.0 - sim),
386
+ ))
387
+ matched_old_idx.add(ci)
388
+ matched_new_idx.add(ri)
389
+
390
+ unmatched_old = [regions_old[i] for i in range(n_old) if i not in matched_old_idx]
391
+ unmatched_new = [regions_new[j] for j in range(n_new) if j not in matched_new_idx]
392
+
393
+ logger.info(
394
+ "SemanticRetrieval: %d matched | %d deleted | %d added "
395
+ "(min_sim=%.2f)",
396
+ len(matched_pairs), len(unmatched_old), len(unmatched_new),
397
+ self.min_similarity,
398
+ )
399
+ return matched_pairs, unmatched_old, unmatched_new
400
+
401
+
402
+ # ══════════════════════════════════════════════════════════════════════
403
+ # STAGE 4 β€” SIAMESE PATCH COMPARATOR
404
+ # ══════════════════════════════════════════════════════════════════════
405
+
406
+ class _SiameseEncoder(nn.Module):
407
+ def __init__(self):
408
+ super().__init__()
409
+ resnet = models.resnet50(weights=models.ResNet50_Weights.DEFAULT)
410
+ self.features = nn.Sequential(*list(resnet.children())[:-2])
411
+ self.pool = resnet.avgpool
412
+ self.embed = nn.Sequential(
413
+ nn.Linear(2048, 512), nn.ReLU(),
414
+ nn.Linear(512, 128),
415
+ )
416
+
417
+ def encode(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
418
+ feat_map = self.features(x)
419
+ pooled = torch.flatten(self.pool(feat_map), 1)
420
+ embed = F.normalize(self.embed(pooled), p=2, dim=1)
421
+ return embed, feat_map
422
+
423
+ def forward(self, x1: torch.Tensor, x2: torch.Tensor):
424
+ e1, f1 = self.encode(x1)
425
+ e2, f2 = self.encode(x2)
426
+ return e1, e2, f1, f2
427
+
428
+
429
+ class SiamesePatchComparator:
430
+ def __init__(
431
+ self,
432
+ device: Optional[torch.device] = None,
433
+ encoder: Optional[_SiameseEncoder] = None, # ← shared encoder
434
+ ):
435
+ if device is None:
436
+ if torch.cuda.is_available():
437
+ device = torch.device("cuda")
438
+ elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
439
+ device = torch.device("mps")
440
+ else:
441
+ device = torch.device("cpu")
442
+ self.device = device
443
+ # Reuse the encoder from SemanticRetrievalMatcher if provided β€”
444
+ # avoids loading ResNet50 weights a second time.
445
+ if encoder is not None:
446
+ self.model = encoder
447
+ logger.info("SiamesePatchComparator: reusing shared encoder on %s", device)
448
+ else:
449
+ self.model = _SiameseEncoder().to(device).eval()
450
+ logger.info("SiamesePatchComparator: created new encoder on %s", device)
451
+ self.transform = transforms.Compose([
452
+ transforms.Resize((224, 224)),
453
+ transforms.ToTensor(),
454
+ transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
455
+ ])
456
+
457
+ def _to_tensor(self, patch_rgb: np.ndarray) -> torch.Tensor:
458
+ return self.transform(Image.fromarray(patch_rgb)).unsqueeze(0).to(self.device)
459
+
460
+ def compare(self, patch_old: np.ndarray, patch_new: np.ndarray) -> Dict[str, object]:
461
+ g_old = cv2.cvtColor(patch_old, cv2.COLOR_RGB2GRAY).astype(np.float32)
462
+ g_new = cv2.cvtColor(patch_new, cv2.COLOR_RGB2GRAY).astype(np.float32)
463
+ diff_map = np.abs(g_old - g_new)
464
+ # Threshold of 8 (was 15) β€” CAD drawings have fine lines and small
465
+ # text; a dimension change may shift only a handful of pixels slightly.
466
+ changed_pixels = np.sum(diff_map > 8.0)
467
+ pixel_diff = float(changed_pixels) / max(g_old.size, 1)
468
+ ssim_val = float(ssim(g_old, g_new, data_range=255.0))
469
+ ssim_cost = max(0.0, 1.0 - ssim_val)
470
+ with torch.no_grad():
471
+ t1 = self._to_tensor(patch_old)
472
+ t2 = self._to_tensor(patch_new)
473
+ e1, e2, _, _ = self.model(t1, t2)
474
+ l2_dist = float(F.pairwise_distance(e1, e2).item())
475
+ semantic_diff = min(l2_dist / 10.0, 1.0)
476
+ total = 0.30 * pixel_diff + 0.40 * ssim_cost + 0.30 * semantic_diff
477
+
478
+ return {
479
+ "pixel_diff": pixel_diff,
480
+ "ssim_score": ssim_val,
481
+ "semantic_diff":semantic_diff,
482
+ "total_change": min(float(total), 1.0),
483
+ }
484
+
485
+ def compare_pair(self, pair: MatchedPair, img_old: np.ndarray, img_new: np.ndarray) -> MatchedPair:
486
+ ro, rn = pair.region_old, pair.region_new
487
+ patch_old = img_old[ro.y: ro.y + ro.h, ro.x: ro.x + ro.w]
488
+ patch_new = img_new[rn.y: rn.y + rn.h, rn.x: rn.x + rn.w]
489
+ if patch_old.size == 0 or patch_new.size == 0:
490
+ return pair
491
+ target_h = max(patch_old.shape[0], patch_new.shape[0])
492
+ target_w = max(patch_old.shape[1], patch_new.shape[1])
493
+
494
+ def _pad_white(patch: np.ndarray, th: int, tw: int) -> np.ndarray:
495
+ canvas = np.full((th, tw, patch.shape[2]), 255, dtype=np.uint8)
496
+ canvas[:patch.shape[0], :patch.shape[1]] = patch
497
+ return canvas
498
+
499
+ patch_old_p = _pad_white(patch_old, target_h, target_w)
500
+ patch_new_p = _pad_white(patch_new, target_h, target_w)
501
+ metrics = self.compare(patch_old_p, patch_new_p)
502
+ pair.pixel_diff = metrics["pixel_diff"]
503
+ pair.ssim_score = metrics["ssim_score"]
504
+ pair.semantic_diff = metrics["semantic_diff"]
505
+ pair.total_change = metrics["total_change"]
506
+ return pair
507
+
508
+
509
+ # ══════════════════════════════════════════════════════════════════════
510
+ # VISUALISER
511
+ # ══════════════════════════════════════════════════════════════════════
512
+
513
+ class Visualiser:
514
+ @staticmethod
515
+ def draw_alignment_check(
516
+ img_old_aligned: np.ndarray,
517
+ img_new: np.ndarray,
518
+ ) -> np.ndarray:
519
+ """
520
+ Red-cyan overlay β€” Alignment Check tab.
521
+
522
+ How to read it
523
+ --------------
524
+ OLD aligned β†’ Red channel
525
+ NEW doc β†’ Green + Blue channels (= Cyan)
526
+
527
+ β€’ Lines present at the SAME pixel in both β†’ gray (Rβ‰ˆGβ‰ˆB)
528
+ β€’ Lines in OLD that drifted β†’ RED fringe
529
+ β€’ Lines in NEW that drifted β†’ CYAN fringe
530
+ β€’ White background on both β†’ white
531
+
532
+ If the overlay looks mostly gray/white with no fringes, alignment is
533
+ good. Red/cyan colour fringes indicate residual misalignment.
534
+ """
535
+ g_old = cv2.cvtColor(img_old_aligned, cv2.COLOR_RGB2GRAY)
536
+ g_new = cv2.cvtColor(img_new, cv2.COLOR_RGB2GRAY)
537
+ # Stack: R = old, G = new, B = new β†’ cyan for new, red for old
538
+ return np.stack([g_old, g_new, g_new], axis=2)
539
+
540
+
541
+ # ══════════════════════════════════════════════════════════════════════
542
+ # HELPER β€” unmatched region visual-change check
543
+ # ══════════════════════════════════════════════════════════════════════
544
+
545
+ # Mean-abs pixel diff below this threshold β†’ region is visually identical
546
+ # despite not being paired by the matcher; excluded from the change score.
547
+ _UNMATCHED_PIXEL_THR: float = 12.0 # on 0–255 grayscale scale
548
+
549
+
550
+ def _region_mean_diff(
551
+ r: Region,
552
+ img_a: np.ndarray,
553
+ candidates: List[Region],
554
+ img_b: np.ndarray,
555
+ thumb: int = 64,
556
+ ) -> float:
557
+ """
558
+ Return the *minimum* mean-abs-diff (grayscale, 0–255) between region `r`
559
+ in `img_a` and the spatially closest candidate region in `img_b`.
560
+
561
+ "Spatially closest" = smallest Euclidean centre-to-centre distance.
562
+ If there are no candidates, return 255.0 (maximally different).
563
+ """
564
+ if not candidates:
565
+ return 255.0
566
+ pa = img_a[r.y: r.y + r.h, r.x: r.x + r.w]
567
+ if pa.size == 0:
568
+ return 255.0
569
+ ga = cv2.resize(cv2.cvtColor(pa, cv2.COLOR_RGB2GRAY), (thumb, thumb)).astype(np.float32)
570
+
571
+ cx_r, cy_r = r.center
572
+ # Sort candidates by centre distance β€” only check the 3 nearest for speed
573
+ candidates_sorted = sorted(
574
+ candidates,
575
+ key=lambda c: (c.center[0] - cx_r) ** 2 + (c.center[1] - cy_r) ** 2,
576
+ )[:3]
577
+
578
+ best = 255.0
579
+ for cand in candidates_sorted:
580
+ pb = img_b[cand.y: cand.y + cand.h, cand.x: cand.x + cand.w]
581
+ if pb.size == 0:
582
+ continue
583
+ gb = cv2.resize(
584
+ cv2.cvtColor(pb, cv2.COLOR_RGB2GRAY), (thumb, thumb)
585
+ ).astype(np.float32)
586
+ diff = float(np.mean(np.abs(ga - gb)))
587
+ if diff < best:
588
+ best = diff
589
+ return best
590
+
591
+
592
+ def _is_truly_changed(
593
+ r: Region,
594
+ candidates: List[Region],
595
+ img_a: np.ndarray,
596
+ img_b: np.ndarray,
597
+ ) -> bool:
598
+ """
599
+ Return True only when region `r` (from img_a) is visually *different*
600
+ from its nearest spatial counterpart in candidates (from img_b).
601
+
602
+ Used to distinguish "matcher failed to pair identical regions" from
603
+ "content was genuinely added or deleted."
604
+ """
605
+ return _region_mean_diff(r, img_a, candidates, img_b) >= _UNMATCHED_PIXEL_THR
606
+
607
+
608
+ # ══════════════════════════════════════════════════════════════════════
609
+ # MAIN PIPELINE
610
+ # ══════════════════════════════════════════════════════════════════════
611
+
612
+ class CoarseToFinePipeline:
613
+ def __init__(
614
+ self,
615
+ align: bool = True,
616
+ device: Optional[torch.device] = None,
617
+ region_extractor: Optional[LayoutRegionExtractor] = None,
618
+ matcher=None, # SemanticRetrievalMatcher or HungarianRegionMatcher
619
+ comparator: Optional[SiamesePatchComparator] = None,
620
+ min_similarity: float = 0.50, # used only when matcher=None (auto-build)
621
+ ):
622
+ # Resolve device once here so both sub-modules share it
623
+ if device is None:
624
+ if torch.cuda.is_available():
625
+ device = torch.device("cuda")
626
+ elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
627
+ device = torch.device("mps")
628
+ else:
629
+ device = torch.device("cpu")
630
+ self._device = device
631
+
632
+ self.aligner = GlobalAligner() if align else None
633
+ self.extractor = region_extractor or LayoutRegionExtractor()
634
+
635
+ if matcher is not None:
636
+ # Caller supplied a custom matcher β€” use it as-is
637
+ self.matcher = matcher
638
+ self.comparator = comparator or SiamesePatchComparator(device=device)
639
+ else:
640
+ # ── Default path: shared ResNet50 encoder ──────────────
641
+ # Build the encoder once; hand the same object to both
642
+ # SemanticRetrievalMatcher (Stage 3) and SiamesePatchComparator (Stage 4).
643
+ # This halves model-load time and GPU/CPU RAM usage.
644
+ shared_encoder = _SiameseEncoder().to(device).eval()
645
+ logger.info("Pipeline: shared ResNet50 encoder on %s", device)
646
+
647
+ self.matcher = SemanticRetrievalMatcher(
648
+ encoder = shared_encoder,
649
+ device = device,
650
+ min_similarity = min_similarity,
651
+ )
652
+ self.comparator = comparator or SiamesePatchComparator(
653
+ device = device,
654
+ encoder = shared_encoder, # ← reuse, no second load
655
+ )
656
+
657
+ def compare(self, img_old: np.ndarray, img_new: np.ndarray, verbose: bool = True) -> ComparisonResult:
658
+ timings: Dict[str, float] = {}
659
+ t = time.time()
660
+ M = None
661
+ if self.aligner is not None:
662
+ img_old_aligned, M = self.aligner.align(img_old, img_new)
663
+ else:
664
+ img_old_aligned = img_old.copy()
665
+ timings["alignment"] = time.time() - t
666
+
667
+ t = time.time()
668
+ regions_old = self.extractor.extract(img_old_aligned)
669
+ regions_new = self.extractor.extract(img_new)
670
+ timings["extraction"] = time.time() - t
671
+
672
+ t = time.time()
673
+ matched, unmatched_old, unmatched_new = self.matcher.match(
674
+ regions_old, regions_new, img_old_aligned, img_new)
675
+ timings["matching"] = time.time() - t
676
+
677
+ t = time.time()
678
+ for i, pair in enumerate(matched):
679
+ matched[i] = self.comparator.compare_pair(pair, img_old_aligned, img_new)
680
+ timings["siamese"] = time.time() - t
681
+
682
+ if verbose:
683
+ logger.info("Timings β†’ align: %.2fs | extract: %.2fs | match: %.2fs | siamese: %.2fs",
684
+ timings["alignment"], timings["extraction"],
685
+ timings["matching"], timings["siamese"])
686
+
687
+ h, w = img_new.shape[:2]
688
+ # ── Change % calculation (two-part fix) ────────────────────────
689
+ #
690
+ # Part A β€” pixel-diff gate on unmatched regions
691
+ # Unmatched regions are NOT automatically "added/deleted".
692
+ # They may simply be regions the matcher failed to pair even though
693
+ # the content is identical. We compare each unmatched region to its
694
+ # nearest spatial counterpart in the opposite list; only those whose
695
+ # pixel diff exceeds _UNMATCHED_PIXEL_THR are counted as truly changed.
696
+ #
697
+ # Part B β€” normalise against full page area (not just detected regions)
698
+ # Using content_area as denominator collapses to 100% when all regions
699
+ # are unmatched. Using h*w gives a stable baseline independent of
700
+ # how many regions were detected or matched.
701
+
702
+ truly_deleted = [
703
+ r for r in unmatched_old
704
+ if _is_truly_changed(r, unmatched_new, img_old_aligned, img_new)
705
+ ]
706
+ truly_added = [
707
+ r for r in unmatched_new
708
+ if _is_truly_changed(r, unmatched_old, img_new, img_old_aligned)
709
+ ]
710
+
711
+ page_area = max(h * w, 1) # Part B denominator
712
+ changed_area = sum(p.region_new.area for p in matched if p.total_change > 0.05)
713
+ deleted_area = sum(r.area for r in truly_deleted)
714
+ added_area = sum(r.area for r in truly_added)
715
+ total_pct = min(100.0 * (changed_area + added_area + deleted_area) / page_area, 100.0)
716
+
717
+ return ComparisonResult(
718
+ matched_pairs=matched,
719
+ unmatched_old=unmatched_old,
720
+ unmatched_new=unmatched_new,
721
+ global_transform=M,
722
+ total_change_pct=total_pct,
723
+ img_old_aligned=img_old_aligned,
724
+ )
725
+
726
+
727
+ # ══════════════════════════════════════════════════════════════════════
728
+ # GRADIO APP β€” HELPERS
729
+ # ══════════════════════════════════════════════════════════════════════
730
+
731
+ def _pick_device() -> torch.device:
732
+ if torch.cuda.is_available():
733
+ return torch.device("cuda")
734
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
735
+ return torch.device("mps")
736
+ return torch.device("cpu")
737
+
738
+
739
+ def _page_to_rgb(doc: fitz.Document, idx: int, dpi: int) -> np.ndarray:
740
+ pix = doc[idx].get_pixmap(dpi=dpi)
741
+ return np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
742
+
743
+
744
+ def _build_output_pdf(page_results: list, output_path: str,
745
+ process_dpi: int = 400) -> str:
746
+ """
747
+ Build the output PDF at full pixel depth.
748
+
749
+ PyMuPDF page dimensions are in points (1 pt = 1/72 inch).
750
+ The overlay images are rendered at process_dpi. To preserve every
751
+ pixel without resampling, set the page size so that 1 image pixel = 1 pt
752
+ scaled by (72 / process_dpi):
753
+ page_width_pts = img_width_px * 72 / process_dpi
754
+ page_height_pts = img_height_px * 72 / process_dpi
755
+ insert_image() maps the image 1:1 onto the page rect, so no
756
+ downsampling or upsampling occurs β€” full pixel depth is preserved.
757
+ """
758
+ doc_out = fitz.open()
759
+ for pr in page_results:
760
+ img = pr["align_check"].convert("RGB")
761
+ px_w, px_h = img.size
762
+ # Convert pixel dimensions to PDF points at the process DPI
763
+ pt_w = px_w * 72.0 / process_dpi
764
+ pt_h = px_h * 72.0 / process_dpi
765
+ page_out = doc_out.new_page(width=pt_w, height=pt_h)
766
+ buf = io.BytesIO()
767
+ img.save(buf, format="PNG", optimize=True) # lossless β€” no JPEG ringing
768
+ buf.seek(0)
769
+ page_out.insert_image(page_out.rect, stream=buf.read())
770
+ doc_out.save(output_path, deflate=True, garbage=4, clean=True)
771
+ doc_out.close()
772
+ return output_path
773
+
774
+
775
+ # ══════════════════════════════════════════════════════════════════════
776
+ # SPECIFIC-REGION HELPER β€” semantic global search in OLD document
777
+ # ══════════════════════════════════════════════════════════════════════
778
+
779
+ # ImageNet normalisation reused from SemanticRetrievalMatcher
780
+ _REGION_TRANSFORM = transforms.Compose([
781
+ transforms.Resize((224, 224)),
782
+ transforms.ToTensor(),
783
+ transforms.Normalize(mean=[0.485, 0.456, 0.406],
784
+ std=[0.229, 0.224, 0.225]),
785
+ ])
786
+
787
+
788
+ def _embed_patch(patch_rgb: np.ndarray,
789
+ encoder: "_SiameseEncoder",
790
+ device: torch.device) -> torch.Tensor:
791
+ """Encode a single RGB numpy patch β†’ (128,) L2-normalised embedding."""
792
+ t = _REGION_TRANSFORM(Image.fromarray(patch_rgb)).unsqueeze(0).to(device)
793
+ with torch.no_grad():
794
+ emb, _ = encoder.encode(t) # (1, 128)
795
+ return emb[0] # (128,)
796
+
797
+
798
+ def _find_matching_region_in_old(
799
+ new_crop: np.ndarray,
800
+ img_old_full: np.ndarray,
801
+ encoder: "_SiameseEncoder",
802
+ device: torch.device,
803
+ ) -> Tuple[int, int, int, int]:
804
+ """
805
+ Locate where new_crop (user-selected patch from NEW page) sits inside
806
+ img_old_full (the complete OLD page).
807
+
808
+ Method β€” Semantic sliding-window search
809
+ ----------------------------------------
810
+ 1. Encode new_crop with the shared ResNet50 encoder β†’ 128-d embedding.
811
+ 2. Slide a window across img_old_full at multiple scales (Β±30 % of the
812
+ crop size, preserving aspect ratio). Step = 50 % of window size so
813
+ adjacent windows overlap and the true location is never missed.
814
+ 3. Encode every window patch and compute cosine similarity with the
815
+ query embedding. Pick the window with the highest similarity.
816
+ 4. Clamp the winning box to page bounds and return it.
817
+
818
+ Why semantic (not pixel-level):
819
+ β€’ ResNet50 encodes *what* is in a region (shapes, structure, symbols),
820
+ not pixel values. Two revisions of the same table/panel/diagram will
821
+ have near-identical embeddings even if text values changed slightly.
822
+ β€’ Scale-invariant: the multi-scale sweep handles content that was
823
+ enlarged or shrunk between revisions.
824
+ β€’ Position-invariant: the full-page sweep finds content anywhere on the
825
+ OLD page regardless of how far it moved.
826
+
827
+ Returns (x1, y1, x2, y2) in img_old_full pixel space.
828
+ """
829
+ crop_h, crop_w = new_crop.shape[:2]
830
+ old_h, old_w = img_old_full.shape[:2]
831
+
832
+ def _clamp_box(bx: int, by: int, bw: int, bh: int
833
+ ) -> Tuple[int, int, int, int]:
834
+ bx = max(0, min(bx, old_w - 1))
835
+ by = max(0, min(by, old_h - 1))
836
+ bw = max(1, min(bw, old_w - bx))
837
+ bh = max(1, min(bh, old_h - by))
838
+ return bx, by, bx + bw, by + bh
839
+
840
+ # ── Step 1: encode the query (NEW crop) ──────────────────────────
841
+ q_emb = _embed_patch(new_crop, encoder, device) # (128,)
842
+
843
+ # ── Step 2: build candidate windows across scales ────────────────
844
+ # Scales relative to the crop's own size. For a 400-DPI page a crop
845
+ # that is, say, 600 px wide is tested at 420 … 780 px widths.
846
+ scales = (0.70, 0.85, 1.00, 1.15, 1.30)
847
+ aspect = crop_h / max(crop_w, 1)
848
+
849
+ candidates: List[Tuple[int, int, int, int]] = [] # (x, y, w, h)
850
+
851
+ for sc in scales:
852
+ win_w = max(32, int(crop_w * sc))
853
+ win_h = max(32, int(crop_h * sc))
854
+ if win_w > old_w or win_h > old_h:
855
+ continue
856
+ step_x = max(1, win_w // 2)
857
+ step_y = max(1, win_h // 2)
858
+ for y in range(0, old_h - win_h + 1, step_y):
859
+ for x in range(0, old_w - win_w + 1, step_x):
860
+ candidates.append((x, y, win_w, win_h))
861
+
862
+ logger.info(
863
+ "_find_matching_region_in_old: %d candidate windows across %d scales",
864
+ len(candidates), len(scales),
865
+ )
866
+
867
+ if not candidates:
868
+ # Entire crop is bigger than the old page β€” return full page
869
+ logger.warning("_find_matching_region_in_old: crop >= page; returning full page box.")
870
+ return _clamp_box(0, 0, old_w, old_h)
871
+
872
+ # ── Step 3: batch-encode all windows, find best cosine similarity ─
873
+ # Process in mini-batches of 64 to avoid OOM on large pages.
874
+ BATCH = 64
875
+ best_sim: float = -1.0
876
+ best_box: Tuple[int, int, int, int] = candidates[0]
877
+
878
+ for start in range(0, len(candidates), BATCH):
879
+ batch_cands = candidates[start: start + BATCH]
880
+ patches = []
881
+ for (cx, cy, cw, ch) in batch_cands:
882
+ patch = img_old_full[cy: cy + ch, cx: cx + cw]
883
+ patches.append(patch)
884
+
885
+ tensors = [
886
+ _REGION_TRANSFORM(Image.fromarray(p)) for p in patches
887
+ ]
888
+ batch_t = torch.stack(tensors).to(device) # (B, 3, 224, 224)
889
+ with torch.no_grad():
890
+ embs, _ = encoder.encode(batch_t) # (B, 128)
891
+
892
+ # Cosine similarity: q_emb is already L2-normed, embs are L2-normed
893
+ sims = (embs @ q_emb).cpu().numpy() # (B,)
894
+
895
+ idx = int(sims.argmax())
896
+ if sims[idx] > best_sim:
897
+ best_sim = float(sims[idx])
898
+ best_box = batch_cands[idx]
899
+
900
+ bx, by, bw, bh = best_box
901
+ x1o, y1o, x2o, y2o = _clamp_box(bx, by, bw, bh)
902
+
903
+ logger.info(
904
+ "_find_matching_region_in_old: best cosine=%.4f OLD box (%d,%d)–(%d,%d)",
905
+ best_sim, x1o, y1o, x2o, y2o,
906
+ )
907
+ return (x1o, y1o, x2o, y2o)
908
+
909
+
910
+ # ══════════════════════════════════════════════════════════════════════
911
+ # CORE PROCESSING
912
+ # ══════════════════════════════════════════════════════════════════════
913
+
914
+ def run_comparison(
915
+ pdf_old_file,
916
+ pdf_new_file,
917
+ skip_old_p1: bool,
918
+ skip_new_p1: bool,
919
+ enable_align: bool,
920
+ compare_mode: str,
921
+ page_old_input: int,
922
+ page_new_input: int,
923
+ page_compare_mode: str = "Full Page",
924
+ region_coords=None,
925
+ display_dpi: int = 72,
926
+ range_old_from: int = 1,
927
+ range_old_to: int = 1,
928
+ range_new_from: int = 1,
929
+ range_new_to: int = 1,
930
+ progress=gr.Progress(),
931
+ ):
932
+ dpi = 400 # process DPI β€” higher = more pixel depth in overlay output
933
+
934
+ if pdf_old_file is None or pdf_new_file is None:
935
+ raise gr.Error("Please upload both Previous Revision and New Document PDF files.")
936
+
937
+ device = _pick_device()
938
+
939
+ pipeline = CoarseToFinePipeline(
940
+ align = enable_align,
941
+ device = device,
942
+ min_similarity = 0.50,
943
+ )
944
+
945
+ progress(0, desc="Opening PDF files …")
946
+ doc_old = fitz.open(pdf_old_file.name)
947
+ doc_new = fitz.open(pdf_new_file.name)
948
+
949
+ # ── Build the list of (old_page_idx, new_page_idx) pairs to process ──
950
+ if compare_mode == "Specific Pages":
951
+ # Convert 1-based user input to 0-based index
952
+ old_idx_req = int(page_old_input or 1) - 1
953
+ new_idx_req = int(page_new_input or 1) - 1
954
+ # Clamp to valid range
955
+ old_idx_req = max(0, min(old_idx_req, len(doc_old) - 1))
956
+ new_idx_req = max(0, min(new_idx_req, len(doc_new) - 1))
957
+ page_pairs = [(old_idx_req, new_idx_req)]
958
+
959
+ elif compare_mode == "Page Range":
960
+ # Convert 1-based user input to 0-based, clamp to valid range
961
+ of = max(0, int(range_old_from or 1) - 1)
962
+ ot = max(0, int(range_old_to or 1) - 1)
963
+ nf = max(0, int(range_new_from or 1) - 1)
964
+ nt = max(0, int(range_new_to or 1) - 1)
965
+ of = min(of, len(doc_old) - 1)
966
+ ot = min(ot, len(doc_old) - 1)
967
+ nf = min(nf, len(doc_new) - 1)
968
+ nt = min(nt, len(doc_new) - 1)
969
+ if of > ot:
970
+ of, ot = ot, of
971
+ if nf > nt:
972
+ nf, nt = nt, nf
973
+ old_indices = list(range(of, ot + 1))
974
+ new_indices = list(range(nf, nt + 1))
975
+ num_pairs = min(len(old_indices), len(new_indices))
976
+ if len(old_indices) != len(new_indices):
977
+ gr.Warning(
978
+ f"Range length mismatch: Previous Revision has {len(old_indices)} pages, "
979
+ f"New Document has {len(new_indices)} pages in the selected range. "
980
+ f"Processing {num_pairs} page pairs."
981
+ )
982
+ page_pairs = list(zip(old_indices[:num_pairs], new_indices[:num_pairs]))
983
+
984
+ else:
985
+ # Full document mode
986
+ old_start = 1 if skip_old_p1 else 0
987
+ new_start = 1 if skip_new_p1 else 0
988
+ old_pages = len(doc_old) - old_start
989
+ new_pages = len(doc_new) - new_start
990
+ num_pages = min(old_pages, new_pages)
991
+
992
+ if skip_old_p1:
993
+ gr.Info("Skipping cover page of Previous Revision.")
994
+ if skip_new_p1:
995
+ gr.Info("Skipping cover page of New Document.")
996
+ if old_pages != new_pages:
997
+ gr.Warning(
998
+ f"Page count mismatch: Previous Revision={old_pages}, New Document={new_pages}. "
999
+ f"Processing {num_pages} pages."
1000
+ )
1001
+ page_pairs = [(pg + old_start, pg + new_start) for pg in range(num_pages)]
1002
+
1003
+ num_pairs = len(page_pairs)
1004
+ page_results = []
1005
+
1006
+ for i, (old_idx, new_idx) in enumerate(page_pairs):
1007
+ progress(i / num_pairs, desc=f"Processing page {i + 1} / {num_pairs} …")
1008
+ img_old = _page_to_rgb(doc_old, old_idx, dpi)
1009
+ img_new = _page_to_rgb(doc_new, new_idx, dpi)
1010
+
1011
+ # ── Normalise page dimensions before any cropping ─────────────
1012
+ # Both pages must have the same native DPI dimensions so that the
1013
+ # same pixel box selects the same physical region in both docs.
1014
+ if img_old.shape != img_new.shape:
1015
+ img_old = cv2.resize(img_old, (img_new.shape[1], img_new.shape[0]))
1016
+
1017
+ # ── Specific-region crop ──────────────────────────────────────
1018
+ # The user drew a box on the NEW-doc preview (at display_dpi).
1019
+ # Steps:
1020
+ # 1. Scale the drag coordinates from preview pixels β†’ process DPI pixels.
1021
+ # 2. Crop the same pixel box from BOTH old and new pages.
1022
+ # (Engineering drawings keep the same layout between revisions β€”
1023
+ # same position = same physical area. The ORB aligner inside
1024
+ # pipeline.compare() handles any sub-pixel drift between the two.)
1025
+ # 3. Replace img_old / img_new with the two crops β†’ overlay is
1026
+ # scoped to only the selected region.
1027
+ if (compare_mode == "Specific Pages"
1028
+ and page_compare_mode == "Specific Region"
1029
+ and region_coords):
1030
+ rx = region_coords.get("x", 0)
1031
+ ry = region_coords.get("y", 0)
1032
+ rw = region_coords.get("width", img_new.shape[1])
1033
+ rh = region_coords.get("height", img_new.shape[0])
1034
+
1035
+ # (rx, ry, rw, rh) are in *natural image pixels* at preview DPI
1036
+ # (72 DPI). The JS correctly accounts for object-fit: contain
1037
+ # letterbox offsets before scaling to natural coordinates.
1038
+ # Scale to process-DPI pixel space:
1039
+ sf = dpi / float(display_dpi or 72)
1040
+ x1 = max(0, int(rx * sf))
1041
+ y1 = max(0, int(ry * sf))
1042
+ x2 = min(img_new.shape[1], int((rx + rw) * sf))
1043
+ y2 = min(img_new.shape[0], int((ry + rh) * sf))
1044
+
1045
+ logger.info(
1046
+ "Specific Region: display_dpi=%d sf=%.3f "
1047
+ "natural-box (%d,%d,%d,%d) β†’ process-px (%d,%d)–(%d,%d) "
1048
+ "img_new=%dx%d",
1049
+ display_dpi or 72, sf, rx, ry, rw, rh,
1050
+ x1, y1, x2, y2,
1051
+ img_new.shape[1], img_new.shape[0],
1052
+ )
1053
+
1054
+ if x2 > x1 and y2 > y1:
1055
+ # Step 1 β€” crop the selected region from NEW page
1056
+ img_new_crop = img_new[y1:y2, x1:x2]
1057
+
1058
+ # Step 2 β€” crop the SAME coordinates from OLD page first
1059
+ # (engineering drawings keep the same layout between
1060
+ # revisions β€” same pixel box = same physical area).
1061
+ img_old_crop = img_old[y1:y2, x1:x2]
1062
+
1063
+ # Step 3 β€” check whether the direct crop actually has
1064
+ # matching content. If the content moved significantly
1065
+ # between revisions, fall back to a semantic search.
1066
+ # Compare grayscale SSIM of the two crops β€” a score
1067
+ # below 0.30 suggests the regions don't correspond.
1068
+ g_new_check = cv2.cvtColor(img_new_crop, cv2.COLOR_RGB2GRAY)
1069
+ g_old_check = cv2.cvtColor(img_old_crop, cv2.COLOR_RGB2GRAY)
1070
+ # Resize to same shape for SSIM (should already be same)
1071
+ if g_old_check.shape != g_new_check.shape:
1072
+ g_old_check = cv2.resize(g_old_check,
1073
+ (g_new_check.shape[1], g_new_check.shape[0]))
1074
+ direct_ssim = ssim(g_new_check, g_old_check)
1075
+
1076
+ logger.info(
1077
+ "Specific Region: direct crop SSIM=%.4f box (%d,%d)–(%d,%d)",
1078
+ direct_ssim, x1, y1, x2, y2,
1079
+ )
1080
+
1081
+ if direct_ssim < 0.30:
1082
+ # Content has moved β€” fall back to semantic search
1083
+ logger.info("Direct crop SSIM too low (%.4f < 0.30), "
1084
+ "falling back to semantic search.", direct_ssim)
1085
+ ox1, oy1, ox2, oy2 = _find_matching_region_in_old(
1086
+ new_crop = img_new_crop,
1087
+ img_old_full = img_old,
1088
+ encoder = pipeline.matcher.encoder,
1089
+ device = device,
1090
+ )
1091
+ logger.info(
1092
+ "Specific Region: semantic search "
1093
+ "NEW (%d,%d)–(%d,%d) β†’ OLD (%d,%d)–(%d,%d)",
1094
+ x1, y1, x2, y2, ox1, oy1, ox2, oy2,
1095
+ )
1096
+ img_old_raw = img_old[oy1:oy2, ox1:ox2]
1097
+ nh, nw = img_new_crop.shape[:2]
1098
+ if img_old_raw.shape[:2] != (nh, nw):
1099
+ img_old_crop = cv2.resize(
1100
+ img_old_raw, (nw, nh),
1101
+ interpolation=cv2.INTER_LINEAR,
1102
+ )
1103
+ else:
1104
+ img_old_crop = img_old_raw
1105
+
1106
+ # Step 4 β€” overlay is scoped to the selected region only
1107
+ img_old = img_old_crop
1108
+ img_new = img_new_crop
1109
+
1110
+ result = pipeline.compare(img_old, img_new)
1111
+
1112
+ old_aligned_for_check = (
1113
+ result.img_old_aligned if result.img_old_aligned is not None
1114
+ else img_old
1115
+ )
1116
+ align_check = Visualiser.draw_alignment_check(old_aligned_for_check, img_new)
1117
+
1118
+ page_results.append({
1119
+ "page": i + 1,
1120
+ "result": result,
1121
+ "align_check": Image.fromarray(align_check),
1122
+ "original": Image.fromarray(img_old),
1123
+ "revised": Image.fromarray(img_new),
1124
+ "total_change_pct": result.total_change_pct,
1125
+ })
1126
+
1127
+ doc_old.close()
1128
+ doc_new.close()
1129
+
1130
+ progress(0.95, desc="Generating report PDF …")
1131
+ output_pdf = _build_output_pdf(page_results, "ctf_output.pdf", process_dpi=dpi)
1132
+
1133
+ progress(1.0, desc="Done!")
1134
+ return page_results, output_pdf
1135
+
1136
+
1137
+ def get_page_view(page_num, pages_data, view_mode, rotation: int = 0,
1138
+ nudge_x: int = 0, nudge_y: int = 0, nudge_scale: float = 1.0):
1139
+ if not pages_data:
1140
+ return None
1141
+ idx = int(page_num) - 1
1142
+ idx = max(0, min(idx, len(pages_data) - 1))
1143
+ pr = pages_data[idx]
1144
+ key_map = {
1145
+ "Auto-Overlay": "align_check",
1146
+ "Previous Revision": "original",
1147
+ "New Document": "revised",
1148
+ }
1149
+ img = pr.get(key_map.get(view_mode, "align_check"))
1150
+ if img is None:
1151
+ return None
1152
+
1153
+ # Manual fine-tune: only applies to Auto-Overlay view
1154
+ ns = float(nudge_scale) if nudge_scale else 1.0
1155
+ if view_mode == "Auto-Overlay" and (nudge_x != 0 or nudge_y != 0 or abs(ns - 1.0) > 1e-4):
1156
+ img = _apply_nudge_overlay(pr, nudge_x, nudge_y, ns)
1157
+
1158
+ if img is not None and rotation % 360 != 0:
1159
+ img = img.rotate(rotation, expand=True)
1160
+ return img
1161
+
1162
+
1163
+ def _apply_nudge_overlay(pr: dict, dx: int, dy: int, scale: float = 1.0) -> Image.Image:
1164
+ """
1165
+ Re-render the Auto-Overlay with the NEW (red) layer shifted by (dx, dy) pixels
1166
+ and scaled by `scale` around the image centre.
1167
+
1168
+ Cyan channel stays fixed (Previous Revision aligned).
1169
+ Red channel = New Doc with nudge translate + scale applied.
1170
+ """
1171
+ if pr.get("align_check") is None:
1172
+ return None
1173
+
1174
+ # Extract channels from the stored align_check image
1175
+ align_check_arr = np.array(pr["align_check"].convert("RGB"))
1176
+ g_old_aligned = align_check_arr[:, :, 0] # cyan source (Previous Revision)
1177
+ g_new_orig = align_check_arr[:, :, 1] # red source (New Doc)
1178
+
1179
+ h, w = g_old_aligned.shape
1180
+ cx, cy = w / 2.0, h / 2.0
1181
+
1182
+ # Build combined affine: scale about centre + translate
1183
+ # M = T(cx,cy) Β· S(scale) Β· T(-cx,-cy) Β· T(dx,dy)
1184
+ scale = float(scale) if scale and scale > 0 else 1.0
1185
+ # Combined 2Γ—3 affine matrix
1186
+ M = np.float32([
1187
+ [scale, 0, dx + cx * (1 - scale)],
1188
+ [0, scale, dy + cy * (1 - scale)],
1189
+ ])
1190
+
1191
+ g_new_transformed = cv2.warpAffine(
1192
+ g_new_orig, M, (w, h),
1193
+ flags=cv2.INTER_LINEAR,
1194
+ borderMode=cv2.BORDER_CONSTANT,
1195
+ borderValue=255,
1196
+ )
1197
+
1198
+ # Stack: R=old_aligned (cyan base), G=new_transformed, B=new_transformed (β†’ red fringe)
1199
+ overlay = np.stack([g_old_aligned, g_new_transformed, g_new_transformed], axis=2)
1200
+ return Image.fromarray(overlay.astype(np.uint8))
1201
+
1202
+
1203
+ # ══════════════════════════════════════════════════════════════════════
1204
+ # GRADIO UI
1205
+ # ══════════════════════════════════════════════════════════════════════
1206
+
1207
+ with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "styles.css"),
1208
+ encoding="utf-8") as _css_f:
1209
+ _CSS = _css_f.read()
1210
+
1211
+ _THEME = gr.themes.Base(
1212
+ primary_hue=gr.themes.colors.blue,
1213
+ neutral_hue=gr.themes.colors.gray,
1214
+ font=[gr.themes.GoogleFont("Inter"), "sans-serif"],
1215
+ )
1216
+
1217
+ # Gradio 6+: theme & css are passed to launch(), not Blocks()
1218
+ with gr.Blocks(title="POWERGRID Document Auditor") as demo:
1219
+
1220
+ # ── Header ─────────────────────────────────────────────────────────
1221
+ _logo_tag = (
1222
+ f'<img src="{_LOGO_URI}" alt="POWERGRID Logo" />'
1223
+ if _LOGO_URI else
1224
+ '<span style="font-size:1.4rem;font-weight:900;color:#003087;letter-spacing:-1px;">PG</span>'
1225
+ )
1226
+ gr.HTML(f"""
1227
+ <div id="app-header">
1228
+ <div id="app-header-inner">
1229
+ <div id="app-header-logo">{_logo_tag}</div>
1230
+ <div id="app-header-text">
1231
+ <h1>POWERGRID Document Auditor</h1>
1232
+ <p>Power Grid Corporation of India Limited &nbsp;&mdash;&nbsp; AI-Powered Document Comparison</p>
1233
+ </div>
1234
+ </div>
1235
+ </div>
1236
+ """)
1237
+
1238
+ # (JS injected via demo.load below β€” see end of Blocks context)
1239
+
1240
+ # ── Shared State ───────────────────────────────────────────────────
1241
+ pages_state = gr.State(value=None)
1242
+ rotation_state = gr.State(value=0)
1243
+ nudge_x_state = gr.State(value=0) # manual X offset for red (New Doc) layer
1244
+ nudge_y_state = gr.State(value=0) # manual Y offset for red (New Doc) layer
1245
+ nudge_scale_state = gr.State(value=1.0) # manual scale for red (New Doc) layer
1246
+ region_coords_state = gr.State(value=None) # {x,y,width,height} in preview px; None = full page
1247
+ display_dpi_state = gr.State(value=72) # DPI used when rendering the region preview
1248
+
1249
+ # ── Layout ─────────────────────────────────────────────────────────
1250
+ with gr.Row(equal_height=False):
1251
+
1252
+ # ════════════════════════════════════════════════════════════
1253
+ # LEFT PANE β€” inputs
1254
+ # ════════════════════════════════════════════════════════════
1255
+ with gr.Column(scale=1, min_width=290, elem_id="left-panel"):
1256
+
1257
+ gr.HTML('<div class="section-label">Documents</div>')
1258
+ pdf_old = gr.File(label="Previous Revision PDF", file_types=[".pdf"])
1259
+ skip_old_p1 = gr.Checkbox(
1260
+ value=False,
1261
+ label="Skip cover page of Previous Revision",
1262
+ interactive=False,
1263
+ elem_classes=["skip-cb"],
1264
+ )
1265
+
1266
+ gr.HTML('<div class="section-divider"></div>')
1267
+ pdf_new = gr.File(label="Revised (New) PDF", file_types=[".pdf"])
1268
+ skip_new_p1 = gr.Checkbox(
1269
+ value=False,
1270
+ label="Skip cover page of New Revision",
1271
+ interactive=False,
1272
+ elem_classes=["skip-cb"],
1273
+ )
1274
+
1275
+ gr.HTML('<div class="section-divider"></div>')
1276
+ gr.HTML('<div class="section-label">Options</div>')
1277
+ enable_align = gr.Checkbox(
1278
+ value=True,
1279
+ label="Auto-align pages before comparing",
1280
+ info="Enable if documents were scanned or printed at different positions or scales.",
1281
+ )
1282
+
1283
+ gr.HTML('<div class="section-divider"></div>')
1284
+ gr.HTML('<div class="section-label">Compare Mode</div>')
1285
+ compare_mode = gr.Radio(
1286
+ choices=["Full Document", "Specific Pages", "Page Range"],
1287
+ value="Full Document",
1288
+ label="Compare Mode",
1289
+ show_label=False,
1290
+ elem_id="compare-mode-radio",
1291
+ )
1292
+ with gr.Row(visible=False, elem_id="specific-pages-row") as specific_pages_row:
1293
+ page_old_input = gr.Number(
1294
+ value=1, minimum=1, step=1, precision=0,
1295
+ label="Prev. Revision Page",
1296
+ elem_id="page-old-input",
1297
+ )
1298
+ page_new_input = gr.Number(
1299
+ value=1, minimum=1, step=1, precision=0,
1300
+ label="New Document Page",
1301
+ elem_id="page-new-input",
1302
+ )
1303
+
1304
+ # ── Page Range inputs ─────────────────────────────────────
1305
+ with gr.Column(visible=False, elem_id="page-range-col") as page_range_col:
1306
+ gr.HTML('<div class="range-heading">Previous Revision</div>')
1307
+ with gr.Row(elem_classes=["range-num-row"]):
1308
+ range_old_from = gr.Number(
1309
+ value=1, minimum=1, step=1, precision=0,
1310
+ label="From",
1311
+ elem_id="range-old-from",
1312
+ elem_classes=["range-num"],
1313
+ )
1314
+ range_old_to = gr.Number(
1315
+ value=1, minimum=1, step=1, precision=0,
1316
+ label="To",
1317
+ elem_id="range-old-to",
1318
+ elem_classes=["range-num"],
1319
+ )
1320
+ gr.HTML('<div class="range-heading">New Document</div>')
1321
+ with gr.Row(elem_classes=["range-num-row"]):
1322
+ range_new_from = gr.Number(
1323
+ value=1, minimum=1, step=1, precision=0,
1324
+ label="From",
1325
+ elem_id="range-new-from",
1326
+ elem_classes=["range-num"],
1327
+ )
1328
+ range_new_to = gr.Number(
1329
+ value=1, minimum=1, step=1, precision=0,
1330
+ label="To",
1331
+ elem_id="range-new-to",
1332
+ elem_classes=["range-num"],
1333
+ )
1334
+
1335
+ # Sub-options shown when "Specific Pages" is selected
1336
+ with gr.Column(visible=False, elem_id="region-col") as region_col:
1337
+ page_compare_mode = gr.Radio(
1338
+ choices=["Full Page", "Specific Region"],
1339
+ value="Full Page",
1340
+ label="Page Comparison",
1341
+ show_label=True,
1342
+ elem_id="page-compare-mode-radio",
1343
+ )
1344
+
1345
+ # Region selection β€” gr.Image shows the page; canvas overlay captures bbox drag
1346
+ with gr.Column(visible=False, elem_id="region-preview-col") as region_preview_col:
1347
+ region_readout = gr.HTML(
1348
+ value='<div id="region-readout">No region selected β€” full page will be used</div>',
1349
+ elem_id="region-readout",
1350
+ )
1351
+ # gr.Image: Python pushes the page PIL image here (always visible in DOM)
1352
+ region_page_img = gr.Image(
1353
+ value=None,
1354
+ label=None,
1355
+ show_label=False,
1356
+ type="pil",
1357
+ interactive=False,
1358
+ elem_id="region-page-img",
1359
+ height=380,
1360
+ )
1361
+ # Coords textbox: JS→Python bridge — visible but CSS-collapsed to 0px
1362
+ region_coords_txt = gr.Textbox(
1363
+ value="",
1364
+ label=None,
1365
+ show_label=False,
1366
+ elem_id="region-coords-txt",
1367
+ elem_classes=["region-coords-hidden"],
1368
+ )
1369
+ clear_region_btn = gr.Button(
1370
+ "βœ• Clear Region",
1371
+ size="sm",
1372
+ elem_id="clear-region-btn",
1373
+ )
1374
+
1375
+ gr.HTML('<div class="section-divider"></div>')
1376
+ run_btn = gr.Button("Run Audit", variant="primary", size="lg", elem_id="run-btn")
1377
+
1378
+ gr.HTML('<div class="section-divider"></div>')
1379
+ gr.HTML('<div class="section-label">Fine-Tune Alignment</div>')
1380
+
1381
+ # ── MacBook-style arrow key D-pad ─────────────────────────
1382
+ # Row 1: [ β–² ] (centred, half-row)
1383
+ with gr.Row(equal_height=True, elem_id="nudge-row-top"):
1384
+ gr.HTML('<div style="flex:1;min-width:0"></div>')
1385
+ nudge_up_btn = gr.Button("β–²", elem_id="nudge-up", min_width=44, scale=0)
1386
+ gr.HTML('<div style="flex:1;min-width:0"></div>')
1387
+
1388
+ # Row 2: [ β—€ ][ β–Ό ][ β–Ά ]
1389
+ with gr.Row(equal_height=True, elem_id="nudge-row-bot"):
1390
+ nudge_left_btn = gr.Button("β—€", elem_id="nudge-left", min_width=44, scale=0)
1391
+ nudge_down_btn = gr.Button("β–Ό", elem_id="nudge-down", min_width=44, scale=0)
1392
+ nudge_right_btn = gr.Button("β–Ά", elem_id="nudge-right", min_width=44, scale=0)
1393
+
1394
+ gr.HTML(
1395
+ '<p class="nudge-tip">'
1396
+ 'Use arrows or drag on the overlay preview to shift the New Document layer. '
1397
+ 'Run Audit resets alignment.</p>'
1398
+ )
1399
+
1400
+ nudge_step = gr.Number(
1401
+ value=1, minimum=1, maximum=100, step=1,
1402
+ label="Step Size (px)", precision=0,
1403
+ elem_id="nudge-step",
1404
+ )
1405
+ nudge_scale = gr.Number(
1406
+ value=1.0, minimum=0.10, maximum=10.0, step=0.005,
1407
+ label="Scale β€” Red Layer", precision=3,
1408
+ elem_id="nudge-scale",
1409
+ )
1410
+
1411
+ # Hidden bridge: JS writes "dx,dy" here on drag-end β†’ Python handler updates state
1412
+ drag_nudge_txt = gr.Textbox(
1413
+ value="",
1414
+ label=None,
1415
+ show_label=False,
1416
+ elem_id="drag-nudge-txt",
1417
+ elem_classes=["region-coords-hidden"],
1418
+ )
1419
+ nudge_readout = gr.HTML(
1420
+ value='<div id="nudge-readout-wrap">x&nbsp;=&nbsp;+0 px<br>y&nbsp;=&nbsp;+0 px<br>scale&nbsp;=&nbsp;1.000</div>',
1421
+ elem_id="nudge-readout",
1422
+ )
1423
+
1424
+ # ════════════════════════════════════════════════════════════
1425
+ # RIGHT PANE β€” results
1426
+ # ════════════════════════════════════════════════════════════
1427
+ with gr.Column(scale=3, elem_id="right-panel"):
1428
+
1429
+ # ── Toolbar: view tabs | rotation buttons ──
1430
+ with gr.Row(elem_id="toolbar-row"):
1431
+ view_mode = gr.Radio(
1432
+ choices=["Auto-Overlay", "Previous Revision", "New Document"],
1433
+ value="Auto-Overlay",
1434
+ label="View",
1435
+ show_label=False,
1436
+ scale=1,
1437
+ min_width=320,
1438
+ elem_id="view-mode-radio",
1439
+ )
1440
+ gr.HTML('<div class="toolbar-sep"></div>')
1441
+ rot_left_btn = gr.Button("β†Ί", scale=0, elem_id="rot-left", min_width=38)
1442
+ rot_right_btn = gr.Button("↻", scale=0, elem_id="rot-right", min_width=38)
1443
+
1444
+ # ── Page slider (shown only after audit runs) ──────────────
1445
+ page_slider = gr.Slider(
1446
+ minimum=1, maximum=1, value=1, step=1,
1447
+ label="Page",
1448
+ visible=False,
1449
+ elem_id="page-slider",
1450
+ )
1451
+
1452
+ # Hidden state
1453
+ page_num_state = gr.State(value=1)
1454
+
1455
+ result_image = gr.Image(
1456
+ label="",
1457
+ type="pil",
1458
+ height=720,
1459
+ interactive=False,
1460
+ elem_id="result-image",
1461
+ )
1462
+
1463
+ gr.HTML("""
1464
+ <div id="legend-bar" style="display:flex; gap:18px; flex-wrap:wrap; align-items:center;">
1465
+ <span style="font-size:0.60rem;font-weight:700;color:#8BA0BB;text-transform:uppercase;
1466
+ letter-spacing:0.11em;white-space:nowrap;flex-shrink:0;">Overlay Legend</span>
1467
+ <span style="display:flex;align-items:center;gap:6px;">
1468
+ <span style="width:12px;height:12px;border-radius:3px;background:#7A7A7A;
1469
+ flex-shrink:0;display:inline-block;box-shadow:0 1px 2px rgba(0,0,0,0.15);"></span>
1470
+ <span style="font-size:0.75rem;color:#4A6585;white-space:nowrap;">
1471
+ <b style="color:#0F1C2E;font-weight:600;">Gray</b>&nbsp;&mdash;&nbsp;Unchanged</span>
1472
+ </span>
1473
+ <span style="display:flex;align-items:center;gap:6px;">
1474
+ <span style="width:12px;height:12px;border-radius:3px;background:#00BBBB;
1475
+ flex-shrink:0;display:inline-block;box-shadow:0 1px 2px rgba(0,0,0,0.15);"></span>
1476
+ <span style="font-size:0.75rem;color:#4A6585;white-space:nowrap;">
1477
+ <b style="color:#007070;font-weight:600;">Cyan</b>&nbsp;&mdash;&nbsp;Previous Revision</span>
1478
+ </span>
1479
+ <span style="display:flex;align-items:center;gap:6px;">
1480
+ <span style="width:12px;height:12px;border-radius:3px;background:#EE3333;
1481
+ flex-shrink:0;display:inline-block;box-shadow:0 1px 2px rgba(0,0,0,0.15);"></span>
1482
+ <span style="font-size:0.75rem;color:#4A6585;white-space:nowrap;">
1483
+ <b style="color:#BB0000;font-weight:600;">Red</b>&nbsp;&mdash;&nbsp;New Document</span>
1484
+ </span>
1485
+ </div>
1486
+ """)
1487
+
1488
+ audit_summary = gr.HTML(
1489
+ value="",
1490
+ elem_id="audit-summary",
1491
+ visible=False,
1492
+ )
1493
+
1494
+ with gr.Row():
1495
+ pdf_output = gr.File(label="⬇️ Download Result PDF")
1496
+
1497
+ # ══════════════════════════════════════════════════════════════════
1498
+ # EVENT HANDLERS
1499
+ # ══════════════════════════════════════════════════════════════════
1500
+
1501
+ def on_pdf_upload(pdf_file):
1502
+ """Disable skip-cover-page checkbox when uploaded PDF has only 1 page."""
1503
+ if pdf_file is None:
1504
+ return gr.update(interactive=False, value=False)
1505
+ try:
1506
+ doc = fitz.open(pdf_file.name)
1507
+ n = len(doc)
1508
+ doc.close()
1509
+ if n <= 1:
1510
+ return gr.update(interactive=False, value=False)
1511
+ else:
1512
+ return gr.update(interactive=True)
1513
+ except Exception:
1514
+ return gr.update(interactive=True)
1515
+
1516
+ def _readout_html(nx: int, ny: int, ns: float = 1.0) -> str:
1517
+ return (
1518
+ f'<div id="nudge-readout-wrap">'
1519
+ f'x&nbsp;=&nbsp;{nx:+d}&thinsp;px<br>'
1520
+ f'y&nbsp;=&nbsp;{ny:+d}&thinsp;px<br>'
1521
+ f'scale&nbsp;=&nbsp;{ns:.3f}'
1522
+ f'</div>'
1523
+ )
1524
+
1525
+ def on_compare_mode_change(mode):
1526
+ """Show/hide the specific-page number inputs, range inputs, and region sub-options."""
1527
+ show_specific = (mode == "Specific Pages")
1528
+ show_range = (mode == "Page Range")
1529
+ return (
1530
+ gr.update(visible=show_specific), # specific_pages_row
1531
+ gr.update(visible=show_specific), # region_col
1532
+ gr.update(visible=show_range), # page_range_col
1533
+ )
1534
+
1535
+ def on_load_preview(pdf_new_f, pg_new):
1536
+ """Render the New Doc page at 72 DPI and return as PIL image for inline display."""
1537
+ if pdf_new_f is None:
1538
+ raise gr.Error("Please upload the New Document PDF first.")
1539
+ preview_dpi = 72
1540
+ doc = fitz.open(pdf_new_f.name)
1541
+ idx = max(0, int(pg_new or 1) - 1)
1542
+ idx = min(idx, len(doc) - 1)
1543
+ arr = _page_to_rgb(doc, idx, preview_dpi)
1544
+ doc.close()
1545
+ pil_img = Image.fromarray(arr)
1546
+ readout = '<div id="region-readout">Draw a box on the image below to select a region</div>'
1547
+ # returns: pil_img, coords_txt_reset, coords_state_reset, display_dpi, readout
1548
+ return pil_img, "", None, preview_dpi, readout
1549
+
1550
+ def on_region_coords_change(coords_txt):
1551
+ """Parse 'x,y,w,h' string written by JS canvas into region_coords_state dict."""
1552
+ if not coords_txt or coords_txt.strip() == "":
1553
+ return None, '<div id="region-readout">No region selected β€” full page will be used</div>'
1554
+ try:
1555
+ parts = [float(v) for v in coords_txt.strip().split(",")]
1556
+ x, y, w, h = int(parts[0]), int(parts[1]), int(parts[2]), int(parts[3])
1557
+ if w < 5 or h < 5:
1558
+ return None, '<div id="region-readout">Region too small β€” drag a larger area</div>'
1559
+ coords = {"x": x, "y": y, "width": w, "height": h}
1560
+ readout = (
1561
+ f'<div id="region-readout">'
1562
+ f'βœ… Region: ({x}, {y}) β†’ ({x+w}, {y+h})'
1563
+ f'&nbsp;|&nbsp;{w}&times;{h} px'
1564
+ f'</div>'
1565
+ )
1566
+ return coords, readout
1567
+ except Exception:
1568
+ return None, '<div id="region-readout">Invalid region β€” drag again</div>'
1569
+
1570
+ def on_clear_region():
1571
+ """Reset region β€” clear coords textbox and state (image stays, JS clears the overlay)."""
1572
+ return "", None, '<div id="region-readout">Draw a box on the image below to select a region</div>'
1573
+
1574
+ def _build_audit_summary(page_results: list) -> str:
1575
+ """Analyse each page's alignment-check overlay for red/cyan mismatches.
1576
+
1577
+ Overlay encoding (from draw_alignment_check):
1578
+ R = grayscale of OLD aligned
1579
+ G = B = grayscale of NEW document
1580
+
1581
+ Pixel interpretation:
1582
+ R β‰ˆ G β‰ˆ 255 β†’ white background (both blank)
1583
+ R β‰ˆ G (and < 245) β†’ gray = unchanged content
1584
+ R << G (low R, high G)β†’ CYAN = content only in OLD (deleted)
1585
+ R >> G (high R, low G)β†’ RED = content only in NEW (added)
1586
+ |R - G| moderate β†’ fringe from drift / modification
1587
+
1588
+ Detection uses three complementary checks so even small isolated
1589
+ changes (like a single function block on a large drawing page)
1590
+ are reliably caught:
1591
+ 1. Ratio check β€” fraction of content pixels with |R-G| > 20
1592
+ 2. Absolute area β€” raw count of mismatch pixels exceeds a
1593
+ fixed threshold (catches small but clear changes)
1594
+ 3. Strong colour β€” any cluster of pure-cyan or pure-red pixels
1595
+ (catches single added/deleted elements)
1596
+ """
1597
+ changed_pages: list[int] = []
1598
+ for pr in page_results:
1599
+ ac = pr.get("align_check")
1600
+ if ac is None:
1601
+ continue
1602
+ arr = np.array(ac) # (H, W, 3) uint8
1603
+ r_ch = arr[:, :, 0].astype(np.float32) # old (red channel)
1604
+ g_ch = arr[:, :, 1].astype(np.float32) # new (cyan component)
1605
+
1606
+ # Background: both channels near white
1607
+ is_bg = (r_ch > 240) & (g_ch > 240)
1608
+ not_bg = ~is_bg
1609
+
1610
+ diff = np.abs(r_ch - g_ch)
1611
+
1612
+ # Check 1 β€” ratio of mismatched content pixels (low threshold)
1613
+ mismatch = (diff > 20) & not_bg
1614
+ n_content = max(not_bg.sum(), 1)
1615
+ ratio = mismatch.sum() / n_content
1616
+
1617
+ # Check 2 β€” absolute pixel area (even 500 changed pixels matter
1618
+ # on a 400-DPI page that can be 3300Γ—4700 = 15M pixels)
1619
+ abs_count = mismatch.sum()
1620
+
1621
+ # Check 3 β€” strong cyan or red: pixels where one channel is
1622
+ # dark content (<180) and the other is near-white (>220),
1623
+ # indicating an element exists in only one revision.
1624
+ strong_cyan = (r_ch < 180) & (g_ch > 220) # old has ink, new is blank
1625
+ strong_red = (r_ch > 220) & (g_ch < 180) # new has ink, old is blank
1626
+ n_strong = strong_cyan.sum() + strong_red.sum()
1627
+
1628
+ is_changed = (
1629
+ ratio > 0.001 # >0.1 % of content pixels
1630
+ or abs_count > 500 # at least 500 px changed
1631
+ or n_strong > 200 # at least 200 px of pure add/delete
1632
+ )
1633
+ if is_changed:
1634
+ changed_pages.append(pr["page"])
1635
+
1636
+ if not changed_pages:
1637
+ return (
1638
+ '<div style="padding:10px 14px;background:#e6f9e6;border-radius:8px;'
1639
+ 'border:1px solid #8bc78b;margin:8px 0;font-size:0.9rem;">'
1640
+ '<b>Audit Summary:</b> No significant changes detected on any page.'
1641
+ '</div>'
1642
+ )
1643
+ pages_str = ", ".join(str(p) for p in changed_pages)
1644
+ return (
1645
+ f'<div style="padding:10px 14px;background:#fff3e0;border-radius:8px;'
1646
+ f'border:1px solid #e0a040;margin:8px 0;font-size:0.9rem;">'
1647
+ f'<b>Audit Summary β€” Changes Detected:</b><br>'
1648
+ f'Page(s): <b>{pages_str}</b>'
1649
+ f'<br><span style="font-size:0.8rem;color:#666;">'
1650
+ f'({len(changed_pages)} of {len(page_results)} page(s) have red/cyan mismatches)</span>'
1651
+ f'</div>'
1652
+ )
1653
+
1654
+ def on_run(pdf_old_f, pdf_new_f, skip_old, skip_new, align,
1655
+ cmp_mode, pg_old, pg_new,
1656
+ pg_cmp_mode, region_coords, display_dpi,
1657
+ r_old_from, r_old_to, r_new_from, r_new_to,
1658
+ progress=gr.Progress()):
1659
+ page_results, pdf_path = run_comparison(
1660
+ pdf_old_f, pdf_new_f, skip_old, skip_new, align,
1661
+ cmp_mode, pg_old, pg_new,
1662
+ pg_cmp_mode, region_coords, display_dpi,
1663
+ r_old_from, r_old_to, r_new_from, r_new_to,
1664
+ progress
1665
+ )
1666
+ n_pages = len(page_results)
1667
+ first_img = page_results[0]["align_check"] if page_results else None
1668
+ summary_html = _build_audit_summary(page_results)
1669
+ return (
1670
+ page_results,
1671
+ 0, # rotation reset
1672
+ 0, # nudge_x reset
1673
+ 0, # nudge_y reset
1674
+ 1.0, # nudge_scale reset
1675
+ 1, # page_num reset to 1
1676
+ pdf_path,
1677
+ first_img,
1678
+ _readout_html(0, 0, 1.0),
1679
+ gr.update(visible=n_pages > 1, minimum=1, maximum=n_pages, value=1),
1680
+ gr.update(value=summary_html, visible=True),
1681
+ )
1682
+
1683
+ def on_view_change(view, pg, pages_data, rot, nx, ny, ns):
1684
+ return get_page_view(pg, pages_data, view, 0, nx, ny, ns), 0
1685
+
1686
+ def on_rot_left(pg, pages_data, view, rot, nx, ny, ns):
1687
+ new_rot = (rot + 90) % 360
1688
+ return get_page_view(pg, pages_data, view, new_rot, nx, ny, ns), new_rot
1689
+
1690
+ def on_rot_right(pg, pages_data, view, rot, nx, ny, ns):
1691
+ new_rot = (rot - 90) % 360
1692
+ return get_page_view(pg, pages_data, view, new_rot, nx, ny, ns), new_rot
1693
+
1694
+ def on_pg_slide(pg, pages_data, view, rot, nx, ny, ns):
1695
+ pg = int(pg or 1)
1696
+ img = get_page_view(pg, pages_data, view, rot, nx, ny, ns)
1697
+ return img, pg
1698
+
1699
+ # ── Nudge handlers (arrow buttons + scale change) ─────────────────
1700
+ def on_nudge(direction: str, pg, pages_data, view, rot, nx, ny, ns, step):
1701
+ step = int(step or 1)
1702
+ if direction == "left": nx -= step
1703
+ elif direction == "right": nx += step
1704
+ elif direction == "up": ny -= step
1705
+ elif direction == "down": ny += step
1706
+ img = get_page_view(pg, pages_data, view, rot, nx, ny, ns)
1707
+ return img, nx, ny, ns, _readout_html(nx, ny, ns)
1708
+
1709
+ def on_scale_change(sc, pg, pages_data, view, rot, nx, ny):
1710
+ ns = float(sc) if sc else 1.0
1711
+ img = get_page_view(pg, pages_data, view, rot, nx, ny, ns)
1712
+ return img, ns, _readout_html(nx, ny, ns)
1713
+
1714
+ # ── Drag-to-nudge handler ─────────────────────────────────────────
1715
+ def on_drag_nudge(drag_txt, pg, pages_data, view, rot, old_nx, old_ny, ns):
1716
+ """Parse 'dx,dy' from JS drag overlay β†’ accumulate into nudge state."""
1717
+ if not drag_txt or drag_txt.strip() == "":
1718
+ return (
1719
+ get_page_view(pg, pages_data, view, rot, old_nx, old_ny, ns),
1720
+ old_nx, old_ny, _readout_html(old_nx, old_ny, ns),
1721
+ )
1722
+ try:
1723
+ parts = drag_txt.strip().split(",")
1724
+ dx, dy = int(float(parts[0])), int(float(parts[1]))
1725
+ except Exception:
1726
+ return (
1727
+ get_page_view(pg, pages_data, view, rot, old_nx, old_ny, ns),
1728
+ old_nx, old_ny, _readout_html(old_nx, old_ny, ns),
1729
+ )
1730
+ nx = old_nx + dx
1731
+ ny = old_ny + dy
1732
+ img = get_page_view(pg, pages_data, view, rot, nx, ny, ns)
1733
+ return img, nx, ny, _readout_html(nx, ny, ns)
1734
+
1735
+ pdf_old.change(fn=on_pdf_upload, inputs=[pdf_old], outputs=[skip_old_p1])
1736
+ pdf_new.change(fn=on_pdf_upload, inputs=[pdf_new], outputs=[skip_new_p1])
1737
+
1738
+ # Show / hide specific-page inputs and region sub-options when compare mode changes
1739
+ compare_mode.change(
1740
+ fn=on_compare_mode_change,
1741
+ inputs=[compare_mode],
1742
+ outputs=[specific_pages_row, region_col, page_range_col],
1743
+ )
1744
+
1745
+ # Show / hide the region preview block AND auto-load the preview
1746
+ # _preview_outputs: [region_page_img, region_coords_txt, coords_state, display_dpi_state, region_readout]
1747
+ _preview_outputs = [region_page_img, region_coords_txt,
1748
+ region_coords_state, display_dpi_state, region_readout]
1749
+
1750
+ def on_page_compare_mode_change(sub_mode, pdf_new_f, pg_new):
1751
+ show = (sub_mode == "Specific Region")
1752
+ col_update = gr.update(visible=show)
1753
+ if show:
1754
+ try:
1755
+ pil_img, ctxt, coords, dpi, rdout = on_load_preview(pdf_new_f, pg_new)
1756
+ return col_update, pil_img, ctxt, coords, dpi, rdout
1757
+ except Exception:
1758
+ pass
1759
+ blank_readout = '<div id="region-readout">No region selected β€” full page will be used</div>'
1760
+ return col_update, None, "", None, 72, blank_readout
1761
+
1762
+ page_compare_mode.change(
1763
+ fn=on_page_compare_mode_change,
1764
+ inputs=[page_compare_mode, pdf_new, page_new_input],
1765
+ outputs=[region_preview_col] + _preview_outputs,
1766
+ )
1767
+
1768
+ # Re-load preview when the New Doc page number changes (if Specific Region is active)
1769
+ def on_page_new_change(pg_new, pdf_new_f, sub_mode):
1770
+ if sub_mode == "Specific Region" and pdf_new_f is not None:
1771
+ try:
1772
+ return on_load_preview(pdf_new_f, pg_new)
1773
+ except Exception:
1774
+ pass
1775
+ blank_readout = '<div id="region-readout">No region selected β€” full page will be used</div>'
1776
+ return None, "", None, 72, blank_readout
1777
+
1778
+ page_new_input.change(
1779
+ fn=on_page_new_change,
1780
+ inputs=[page_new_input, pdf_new, page_compare_mode],
1781
+ outputs=_preview_outputs,
1782
+ )
1783
+
1784
+ # JS canvas overlay writes "x,y,w,h" into region_coords_txt when drag ends β†’ parse to dict
1785
+ region_coords_txt.change(
1786
+ fn=on_region_coords_change,
1787
+ inputs=[region_coords_txt],
1788
+ outputs=[region_coords_state, region_readout],
1789
+ show_progress="hidden",
1790
+ show_progress_on=[],
1791
+ )
1792
+
1793
+ # Clear region button β€” clear coords, JS overlay self-clears on next poll
1794
+ clear_region_btn.click(
1795
+ fn=on_clear_region,
1796
+ inputs=None,
1797
+ outputs=[region_coords_txt, region_coords_state, region_readout],
1798
+ )
1799
+
1800
+ run_btn.click(
1801
+ fn=on_run,
1802
+ inputs=[pdf_old, pdf_new, skip_old_p1, skip_new_p1, enable_align,
1803
+ compare_mode, page_old_input, page_new_input,
1804
+ page_compare_mode, region_coords_state, display_dpi_state,
1805
+ range_old_from, range_old_to, range_new_from, range_new_to],
1806
+ outputs=[pages_state, rotation_state, nudge_x_state, nudge_y_state, nudge_scale_state,
1807
+ page_num_state,
1808
+ pdf_output, result_image, nudge_readout, page_slider,
1809
+ audit_summary],
1810
+ )
1811
+
1812
+ # View-mode tab change
1813
+ view_mode.change(
1814
+ fn=on_view_change,
1815
+ inputs=[view_mode, page_num_state, pages_state, rotation_state,
1816
+ nudge_x_state, nudge_y_state, nudge_scale_state],
1817
+ outputs=[result_image, rotation_state],
1818
+ show_progress="hidden",
1819
+ show_progress_on=[],
1820
+ )
1821
+
1822
+ # Rotation buttons
1823
+ rot_left_btn.click(
1824
+ fn=on_rot_left,
1825
+ inputs=[page_num_state, pages_state, view_mode, rotation_state,
1826
+ nudge_x_state, nudge_y_state, nudge_scale_state],
1827
+ outputs=[result_image, rotation_state],
1828
+ show_progress="hidden",
1829
+ show_progress_on=[],
1830
+ )
1831
+ rot_right_btn.click(
1832
+ fn=on_rot_right,
1833
+ inputs=[page_num_state, pages_state, view_mode, rotation_state,
1834
+ nudge_x_state, nudge_y_state, nudge_scale_state],
1835
+ outputs=[result_image, rotation_state],
1836
+ show_progress="hidden",
1837
+ show_progress_on=[],
1838
+ )
1839
+
1840
+ # Page slider
1841
+ page_slider.change(
1842
+ fn=on_pg_slide,
1843
+ inputs=[page_slider, pages_state, view_mode,
1844
+ rotation_state, nudge_x_state, nudge_y_state, nudge_scale_state],
1845
+ outputs=[result_image, page_num_state],
1846
+ show_progress="hidden",
1847
+ show_progress_on=[],
1848
+ )
1849
+
1850
+ # ── Nudge arrow buttons ───────────────────────────────────────────
1851
+ _nudge_inputs = [page_num_state, pages_state, view_mode, rotation_state,
1852
+ nudge_x_state, nudge_y_state, nudge_scale_state, nudge_step]
1853
+ _nudge_outputs = [result_image, nudge_x_state, nudge_y_state,
1854
+ nudge_scale_state, nudge_readout]
1855
+
1856
+ nudge_left_btn.click(
1857
+ fn=lambda *a: on_nudge("left", *a), inputs=_nudge_inputs, outputs=_nudge_outputs,
1858
+ show_progress="hidden", show_progress_on=[])
1859
+ nudge_right_btn.click(
1860
+ fn=lambda *a: on_nudge("right", *a), inputs=_nudge_inputs, outputs=_nudge_outputs,
1861
+ show_progress="hidden", show_progress_on=[])
1862
+ nudge_up_btn.click(
1863
+ fn=lambda *a: on_nudge("up", *a), inputs=_nudge_inputs, outputs=_nudge_outputs,
1864
+ show_progress="hidden", show_progress_on=[])
1865
+ nudge_down_btn.click(
1866
+ fn=lambda *a: on_nudge("down", *a), inputs=_nudge_inputs, outputs=_nudge_outputs,
1867
+ show_progress="hidden", show_progress_on=[])
1868
+
1869
+ # ── Scale number input (live update on change) ────────────────────
1870
+ nudge_scale.change(
1871
+ fn=on_scale_change,
1872
+ inputs=[nudge_scale, page_num_state, pages_state, view_mode,
1873
+ rotation_state, nudge_x_state, nudge_y_state],
1874
+ outputs=[result_image, nudge_scale_state, nudge_readout],
1875
+ show_progress="hidden",
1876
+ show_progress_on=[],
1877
+ )
1878
+
1879
+ # ── Drag-to-nudge: JS writes "dx,dy" into drag_nudge_txt on drag-end ──
1880
+ drag_nudge_txt.change(
1881
+ fn=on_drag_nudge,
1882
+ inputs=[drag_nudge_txt, page_num_state, pages_state, view_mode,
1883
+ rotation_state, nudge_x_state, nudge_y_state, nudge_scale_state],
1884
+ outputs=[result_image, nudge_x_state, nudge_y_state, nudge_readout],
1885
+ show_progress="hidden",
1886
+ show_progress_on=[],
1887
+ )
1888
+
1889
+ # ── Inline canvas JS β€” overlays a transparent draw canvas on the gr.Image ──
1890
+ _INLINE_CANVAS_JS = """
1891
+ () => {
1892
+ let _overlay = null, _ctx = null;
1893
+ let _dragging = false, _sx = 0, _sy = 0, _sel = null;
1894
+ let _lastCoords = '';
1895
+
1896
+ function getImgEl() {
1897
+ // The rendered <img> inside the gr.Image component
1898
+ const wrap = document.getElementById('region-page-img');
1899
+ return wrap ? wrap.querySelector('img') : null;
1900
+ }
1901
+
1902
+ function getCoordsEl() {
1903
+ const wrap = document.getElementById('region-coords-txt');
1904
+ return wrap ? wrap.querySelector('textarea') : null;
1905
+ }
1906
+
1907
+ // ── object-fit: contain geometry ──────────────────────────────────
1908
+ // The <img> element may be larger than the rendered image due to
1909
+ // CSS object-fit: contain (Gradio default). We need the rendered
1910
+ // image rect WITHIN the element to correctly map coordinates.
1911
+ function getImageFit() {
1912
+ const img = getImgEl();
1913
+ if (!img) return null;
1914
+ const natW = img.naturalWidth, natH = img.naturalHeight;
1915
+ const elmW = img.clientWidth, elmH = img.clientHeight;
1916
+ if (!natW || !natH || !elmW || !elmH) return null;
1917
+ const fitScale = Math.min(elmW / natW, elmH / natH);
1918
+ const rendW = natW * fitScale, rendH = natH * fitScale;
1919
+ // offset of rendered image within the element
1920
+ const offX = (elmW - rendW) / 2;
1921
+ const offY = (elmH - rendH) / 2;
1922
+ return { offX, offY, rendW, rendH, fitScale, natW, natH, elmW, elmH };
1923
+ }
1924
+
1925
+ function syncOverlay() {
1926
+ if (!_overlay) return;
1927
+ const img = getImgEl();
1928
+ if (!img || !img.src || img.src.startsWith('data:image/gif')) return;
1929
+ const r = img.getBoundingClientRect();
1930
+ const pr = img.parentElement.getBoundingClientRect();
1931
+ _overlay.style.left = (r.left - pr.left) + 'px';
1932
+ _overlay.style.top = (r.top - pr.top) + 'px';
1933
+ _overlay.style.width = r.width + 'px';
1934
+ _overlay.style.height = r.height + 'px';
1935
+ if (_overlay.width !== Math.round(r.width) || _overlay.height !== Math.round(r.height)) {
1936
+ _overlay.width = Math.round(r.width);
1937
+ _overlay.height = Math.round(r.height);
1938
+ redraw();
1939
+ }
1940
+ }
1941
+
1942
+ function toCanvas(cx, cy) {
1943
+ const r = _overlay.getBoundingClientRect();
1944
+ return { x: (cx - r.left) * _overlay.width / r.width,
1945
+ y: (cy - r.top) * _overlay.height / r.height };
1946
+ }
1947
+
1948
+ function redraw() {
1949
+ if (!_ctx || !_overlay.width) return;
1950
+ _ctx.clearRect(0, 0, _overlay.width, _overlay.height);
1951
+ if (_sel) {
1952
+ _ctx.strokeStyle = '#00BBBB';
1953
+ _ctx.lineWidth = Math.max(2, _overlay.width / 400);
1954
+ _ctx.strokeRect(_sel.x, _sel.y, _sel.w, _sel.h);
1955
+ _ctx.fillStyle = 'rgba(0,187,187,0.15)';
1956
+ _ctx.fillRect(_sel.x, _sel.y, _sel.w, _sel.h);
1957
+ }
1958
+ }
1959
+
1960
+ function pushCoords() {
1961
+ const el = getCoordsEl();
1962
+ if (!el || !_sel) return;
1963
+ const fit = getImageFit();
1964
+ if (!fit) return;
1965
+
1966
+ // Convert selection from overlay/canvas coords β†’ natural image coords.
1967
+ // Subtract the object-fit letterbox offset, then divide by fitScale.
1968
+ const imgX = (_sel.x - fit.offX) / fit.fitScale;
1969
+ const imgY = (_sel.y - fit.offY) / fit.fitScale;
1970
+ const imgW = _sel.w / fit.fitScale;
1971
+ const imgH = _sel.h / fit.fitScale;
1972
+
1973
+ // Clamp to natural image bounds
1974
+ const nx = Math.max(0, Math.round(imgX));
1975
+ const ny = Math.max(0, Math.round(imgY));
1976
+ const nw = Math.min(fit.natW - nx, Math.max(1, Math.round(imgW)));
1977
+ const nh = Math.min(fit.natH - ny, Math.max(1, Math.round(imgH)));
1978
+
1979
+ console.log('[RegionSelect] element:', fit.elmW, 'x', fit.elmH,
1980
+ ' rendered:', Math.round(fit.rendW), 'x', Math.round(fit.rendH),
1981
+ ' offset:', Math.round(fit.offX), Math.round(fit.offY),
1982
+ ' natural:', fit.natW, 'x', fit.natH,
1983
+ ' fitScale:', fit.fitScale.toFixed(4),
1984
+ ' sel(canvas):', Math.round(_sel.x), Math.round(_sel.y),
1985
+ Math.round(_sel.w), Math.round(_sel.h),
1986
+ ' β†’ natural:', nx, ny, nw, nh);
1987
+
1988
+ const val = nx + ',' + ny + ',' + nw + ',' + nh;
1989
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
1990
+ setter.call(el, val);
1991
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1992
+ }
1993
+
1994
+ function setupOverlay() {
1995
+ const imgWrap = document.getElementById('region-page-img');
1996
+ if (!imgWrap) return false;
1997
+ // Make sure parent is positioned
1998
+ const parent = imgWrap.querySelector('.image-container') || imgWrap;
1999
+ if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative';
2000
+
2001
+ if (!_overlay) {
2002
+ _overlay = document.createElement('canvas');
2003
+ _overlay.id = 'region-draw-overlay';
2004
+ _overlay.style.cssText = 'position:absolute;top:0;left:0;cursor:crosshair;z-index:10;pointer-events:all;';
2005
+ parent.appendChild(_overlay);
2006
+ _ctx = _overlay.getContext('2d');
2007
+
2008
+ _overlay.addEventListener('mousedown', function(e) {
2009
+ const p = toCanvas(e.clientX, e.clientY);
2010
+ _sx = p.x; _sy = p.y; _sel = null; _dragging = true; e.preventDefault();
2011
+ });
2012
+ _overlay.addEventListener('mousemove', function(e) {
2013
+ if (!_dragging) return;
2014
+ const p = toCanvas(e.clientX, e.clientY);
2015
+ _sel = { x: Math.min(_sx, p.x), y: Math.min(_sy, p.y),
2016
+ w: Math.abs(p.x - _sx), h: Math.abs(p.y - _sy) };
2017
+ redraw(); e.preventDefault();
2018
+ });
2019
+ _overlay.addEventListener('mouseup', function(e) {
2020
+ if (!_dragging) return; _dragging = false;
2021
+ if (!_sel || _sel.w < 5 || _sel.h < 5) { _sel = null; redraw(); return; }
2022
+ redraw(); pushCoords(); e.preventDefault();
2023
+ });
2024
+ }
2025
+ return true;
2026
+ }
2027
+
2028
+ // Poll every 300ms: sync overlay size, watch for cleared coords
2029
+ setInterval(function() {
2030
+ setupOverlay();
2031
+ syncOverlay();
2032
+
2033
+ // Clear overlay when coords textbox is wiped by Clear button
2034
+ const el = getCoordsEl();
2035
+ if (el) {
2036
+ const cur = el.value;
2037
+ if (cur !== _lastCoords) {
2038
+ _lastCoords = cur;
2039
+ if (cur === '') { _sel = null; redraw(); }
2040
+ }
2041
+ }
2042
+ }, 300);
2043
+
2044
+ // ═══════════════════════════════════════════════════════════════
2045
+ // DRAG-TO-NUDGE on result-image (Auto-Overlay view)
2046
+ // Click-drag shifts the New Document (red) layer relative to
2047
+ // the Previous Revision (cyan) layer.
2048
+ // ═══════════════════════════════════════════════════════════════
2049
+ let _dragOverlay = null;
2050
+ let _dragActive = false, _dsx = 0, _dsy = 0;
2051
+
2052
+ function getDragNudgeEl() {
2053
+ const wrap = document.getElementById('drag-nudge-txt');
2054
+ return wrap ? wrap.querySelector('textarea') : null;
2055
+ }
2056
+
2057
+ function getResultImg() {
2058
+ const wrap = document.getElementById('result-image');
2059
+ return wrap ? wrap.querySelector('img') : null;
2060
+ }
2061
+
2062
+ function setupDragOverlay() {
2063
+ const resultWrap = document.getElementById('result-image');
2064
+ if (!resultWrap) return;
2065
+ const parent = resultWrap.querySelector('.image-container') || resultWrap;
2066
+ if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative';
2067
+
2068
+ if (_dragOverlay) return; // already set up
2069
+ _dragOverlay = document.createElement('div');
2070
+ _dragOverlay.id = 'drag-nudge-overlay';
2071
+ _dragOverlay.style.cssText =
2072
+ 'position:absolute;top:0;left:0;width:100%;height:100%;' +
2073
+ 'cursor:grab;z-index:10;pointer-events:all;';
2074
+ parent.appendChild(_dragOverlay);
2075
+
2076
+ _dragOverlay.addEventListener('mousedown', function(e) {
2077
+ _dsx = e.clientX; _dsy = e.clientY;
2078
+ _dragActive = true;
2079
+ _dragOverlay.style.cursor = 'grabbing';
2080
+ e.preventDefault();
2081
+ });
2082
+ window.addEventListener('mousemove', function(e) {
2083
+ if (!_dragActive) return;
2084
+ e.preventDefault();
2085
+ });
2086
+ window.addEventListener('mouseup', function(e) {
2087
+ if (!_dragActive) return;
2088
+ _dragActive = false;
2089
+ _dragOverlay.style.cursor = 'grab';
2090
+ const dxPx = e.clientX - _dsx;
2091
+ const dyPx = e.clientY - _dsy;
2092
+ if (Math.abs(dxPx) < 2 && Math.abs(dyPx) < 2) return;
2093
+ // Scale display pixels to natural image pixels
2094
+ const img = getResultImg();
2095
+ if (!img) return;
2096
+ const rect = img.getBoundingClientRect();
2097
+ const scaleX = img.naturalWidth / rect.width;
2098
+ const scaleY = img.naturalHeight / rect.height;
2099
+ const ndx = Math.round(dxPx * scaleX);
2100
+ const ndy = Math.round(dyPx * scaleY);
2101
+ // Write to hidden textbox β†’ triggers Gradio .change()
2102
+ const el = getDragNudgeEl();
2103
+ if (!el) return;
2104
+ const setter = Object.getOwnPropertyDescriptor(
2105
+ HTMLTextAreaElement.prototype, 'value').set;
2106
+ setter.call(el, ndx + ',' + ndy);
2107
+ el.dispatchEvent(new Event('input', { bubbles: true }));
2108
+ e.preventDefault();
2109
+ });
2110
+ }
2111
+
2112
+ // Set up drag overlay with polling (result-image may not exist on first load)
2113
+ setInterval(function() { setupDragOverlay(); }, 500);
2114
+ }
2115
+ """
2116
+ demo.load(fn=None, js=_INLINE_CANVAS_JS)
2117
+
2118
+
2119
+ # ══════════════════════════════════════════════════════════════════════
2120
+ # ENTRY POINT
2121
+ # ══════════════════════════════════════════════════════════════════════
2122
+
2123
+ if __name__ == "__main__":
2124
+ import socket as _socket
2125
+ def _find_free_port(start: int = 7860, end: int = 7880) -> int:
2126
+ for p in range(start, end + 1):
2127
+ with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s:
2128
+ try:
2129
+ s.bind(("", p))
2130
+ return p
2131
+ except OSError:
2132
+ continue
2133
+ return start # fallback β€” Gradio will error with a clear message
2134
+
2135
+ _port = _find_free_port()
2136
+ print(f"\nπŸš€ POWERGRID Document Auditor β†’ http://localhost:{_port}\n")
2137
+ demo.queue(default_concurrency_limit=20).launch(
2138
+ server_name="0.0.0.0",
2139
+ server_port=_port,
2140
+ share=False,
2141
+ show_error=True,
2142
+ theme=_THEME,
2143
+ css=_CSS,
2144
+ )
styles.css CHANGED
@@ -823,3 +823,69 @@ footer { display: none !important; }
823
  min-height: 28px;
824
  line-height: 1.4;
825
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
823
  min-height: 28px;
824
  line-height: 1.4;
825
  }
826
+
827
+ /* ── Page Range ──────────────────────────────────────────────────── */
828
+ #page-range-col {
829
+ gap: 4px !important;
830
+ padding: 6px 14px 10px !important;
831
+ background: var(--surface, #F4F6FA) !important;
832
+ border: 1px solid var(--border, #E2E8F0) !important;
833
+ border-radius: 10px !important;
834
+ margin-top: 4px !important;
835
+ }
836
+
837
+ .range-heading {
838
+ font-size: 0.72rem;
839
+ font-weight: 700;
840
+ color: var(--pg-navy, #00205B);
841
+ text-transform: uppercase;
842
+ letter-spacing: 0.06em;
843
+ margin: 6px 0 2px 0;
844
+ }
845
+ .range-heading:first-child { margin-top: 0; }
846
+
847
+ /* Two-column "From / To" row */
848
+ .range-num-row {
849
+ display: flex !important;
850
+ align-items: flex-end !important;
851
+ gap: 10px !important;
852
+ padding: 0 !important;
853
+ margin: 0 !important;
854
+ background: transparent !important;
855
+ border: none !important;
856
+ box-shadow: none !important;
857
+ flex-wrap: nowrap !important;
858
+ }
859
+
860
+ .range-num {
861
+ flex: 1 1 0 !important;
862
+ min-width: 0 !important;
863
+ }
864
+
865
+ /* Labels styled small */
866
+ .range-num label, .range-num .label-wrap span {
867
+ font-size: 0.67rem !important;
868
+ font-weight: 600 !important;
869
+ color: var(--text-2, #6b7b8d) !important;
870
+ text-transform: uppercase !important;
871
+ letter-spacing: 0.06em !important;
872
+ }
873
+
874
+ /* Inputs β€” macOS-style compact fields */
875
+ .range-num input[type=number] {
876
+ text-align: center !important;
877
+ padding: 5px 8px !important;
878
+ font-size: 0.88rem !important;
879
+ font-weight: 500 !important;
880
+ height: 34px !important;
881
+ border-radius: 8px !important;
882
+ border: 1px solid #C0D4E8 !important;
883
+ background: #fff !important;
884
+ color: var(--pg-navy, #00205B) !important;
885
+ transition: border-color 0.15s, box-shadow 0.15s;
886
+ }
887
+ .range-num input[type=number]:focus {
888
+ border-color: var(--pg-blue, #1565C0) !important;
889
+ box-shadow: 0 0 0 3px rgba(21, 101, 192, 0.12) !important;
890
+ outline: none !important;
891
+ }