MasterSayn commited on
Commit
015baa0
·
verified ·
1 Parent(s): 0d721a6

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. Dockerfile +1 -1
  2. gradio_api.py +677 -0
  3. requirements.txt +2 -0
Dockerfile CHANGED
@@ -6,4 +6,4 @@ COPY requirements.txt .
6
  RUN pip install --no-cache-dir -r requirements.txt
7
  COPY . .
8
  EXPOSE 7860
9
- CMD ["streamlit", "run", "app.py"]
 
6
  RUN pip install --no-cache-dir -r requirements.txt
7
  COPY . .
8
  EXPOSE 7860
9
+ CMD ["python", "gradio_api.py"]
gradio_api.py ADDED
@@ -0,0 +1,677 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import cv2
3
+ import numpy as np
4
+ import math
5
+ import time
6
+ import gc
7
+ import torch
8
+ import fitz # PyMuPDF
9
+ from PIL import Image
10
+ import gradio as gr
11
+ from paddleocr import PaddleOCR
12
+ from google import genai
13
+ from google.genai import types
14
+ from pydantic import BaseModel
15
+
16
+ # --- 1. Structured Output Definitions ---
17
+ class BoundingBox(BaseModel):
18
+ box_2d: list[int]
19
+ label: str
20
+
21
+ # API Key configuration
22
+ API_KEY = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6K7DUqbPAcdRwV1Y-xYSH4qZOwvnUsmi1330DwbiWVSOg")
23
+
24
+ # Caching models for reuse
25
+ _PADDLE_OCR = None
26
+ def get_paddle_ocr():
27
+ global _PADDLE_OCR
28
+ if _PADDLE_OCR is None:
29
+ print("[API] Initializing PaddleOCR...")
30
+ _PADDLE_OCR = PaddleOCR(use_angle_cls=True, lang='de')
31
+ return _PADDLE_OCR
32
+
33
+ _TROCR_PROCESSOR = None
34
+ _TROCR_MODEL = None
35
+ def get_trocr():
36
+ global _TROCR_PROCESSOR, _TROCR_MODEL
37
+ if _TROCR_PROCESSOR is None or _TROCR_MODEL is None:
38
+ print("[API] Initializing TrOCR...")
39
+ import logging as transformers_logging
40
+ transformers_logging.getLogger("transformers").setLevel(transformers_logging.ERROR)
41
+
42
+ onnx_path = "trocr_onnx"
43
+ if os.path.exists(onnx_path):
44
+ from transformers import TrOCRProcessor
45
+ from optimum.onnxruntime import ORTModelForVision2Seq
46
+ _TROCR_PROCESSOR = TrOCRProcessor.from_pretrained(onnx_path)
47
+ _TROCR_MODEL = ORTModelForVision2Seq.from_pretrained(onnx_path, provider="CPUExecutionProvider")
48
+ else:
49
+ from transformers import TrOCRProcessor, VisionEncoderDecoderModel
50
+ _TROCR_PROCESSOR = TrOCRProcessor.from_pretrained('microsoft/trocr-base-handwritten')
51
+ _TROCR_MODEL = VisionEncoderDecoderModel.from_pretrained('microsoft/trocr-base-handwritten')
52
+ return _TROCR_PROCESSOR, _TROCR_MODEL
53
+
54
+ def recursive_xy_cut(boxes_with_data):
55
+ if len(boxes_with_data) <= 1:
56
+ return boxes_with_data
57
+
58
+ gap_threshold = 5 # Mindestabstand
59
+
60
+ # 1. Horizontale Lücken berechnen
61
+ y_intervals = sorted([(b[0][1], b[0][3]) for b in boxes_with_data])
62
+ max_h_gap = 0
63
+ h_gap_y = None
64
+ max_y = y_intervals[0][1]
65
+ for i in range(1, len(y_intervals)):
66
+ if y_intervals[i][0] > max_y:
67
+ gap = y_intervals[i][0] - max_y
68
+ if gap > max_h_gap and gap > gap_threshold:
69
+ max_h_gap = gap
70
+ h_gap_y = (max_y + y_intervals[i][0]) / 2
71
+ max_y = max(max_y, y_intervals[i][1])
72
+
73
+ # 2. Vertikale Lücken berechnen
74
+ x_intervals = sorted([(b[0][0], b[0][2]) for b in boxes_with_data])
75
+ max_v_gap = 0
76
+ v_gap_x = None
77
+ max_x = x_intervals[0][1]
78
+ for i in range(1, len(x_intervals)):
79
+ if x_intervals[i][0] > max_x:
80
+ gap = x_intervals[i][0] - max_x
81
+ if gap > max_v_gap and gap > gap_threshold:
82
+ max_v_gap = gap
83
+ v_gap_x = (max_x + x_intervals[i][0]) / 2
84
+ max_x = max(max_x, x_intervals[i][1])
85
+
86
+ # 3. Entlang der GRÖSSTEN Lücke schneiden!
87
+ if max_h_gap == 0 and max_v_gap == 0:
88
+ return sorted(boxes_with_data, key=lambda b: (b[0][1], b[0][0]))
89
+
90
+ if max_v_gap > max_h_gap:
91
+ left_boxes = [b for b in boxes_with_data if (b[0][0]+b[0][2])/2 < v_gap_x]
92
+ right_boxes = [b for b in boxes_with_data if b not in left_boxes]
93
+ if len(left_boxes) > 0 and len(right_boxes) > 0:
94
+ return recursive_xy_cut(left_boxes) + recursive_xy_cut(right_boxes)
95
+
96
+ if h_gap_y is not None:
97
+ top_boxes = [b for b in boxes_with_data if (b[0][1]+b[0][3])/2 < h_gap_y]
98
+ bottom_boxes = [b for b in boxes_with_data if b not in top_boxes]
99
+ if len(top_boxes) > 0 and len(bottom_boxes) > 0:
100
+ return recursive_xy_cut(top_boxes) + recursive_xy_cut(bottom_boxes)
101
+
102
+ return sorted(boxes_with_data, key=lambda b: (b[0][1], b[0][0]))
103
+
104
+ def process_pdf(input_file_path, mode, progress=gr.Progress()):
105
+ if not input_file_path:
106
+ raise gr.Error("Bitte lade ein PDF hoch.")
107
+
108
+ print(f"[API] Processing {input_file_path} in mode: {mode}")
109
+ progress(0, desc="Initialisiere Datei und Modelle...")
110
+
111
+ # Load required clients and models
112
+ client = None
113
+ if "Gemini" in mode:
114
+ client = genai.Client(api_key=API_KEY)
115
+
116
+ paddle_ocr = None
117
+ if "PaddleOCR" in mode:
118
+ paddle_ocr = get_paddle_ocr()
119
+
120
+ trocr_processor, trocr_model = None, None
121
+ if "Lokal Deep" in mode:
122
+ trocr_processor, trocr_model = get_trocr()
123
+
124
+ # Load PDF
125
+ doc = fitz.open(input_file_path)
126
+ num_pages = len(doc)
127
+
128
+ output_filename = f"searchable_{os.path.basename(input_file_path)}"
129
+ output_path = os.path.join(os.path.dirname(input_file_path), output_filename)
130
+
131
+ for page_num in range(num_pages):
132
+ progress((page_num) / num_pages, desc=f"Verarbeite Seite {page_num + 1} von {num_pages}...")
133
+ page = doc.load_page(page_num)
134
+
135
+ # ==========================================
136
+ # MODUS 1: Schnell (Gemini Full-Page)
137
+ # ==========================================
138
+ if mode == "Schnell (Gemini Full-Page)":
139
+ zoom = 150 / 72
140
+ mat = fitz.Matrix(zoom, zoom)
141
+ pix = page.get_pixmap(matrix=mat)
142
+ img_bytes = pix.tobytes("png")
143
+
144
+ prompt = """Du bist ein extrem präzises OCR-System für mathematische Vorlesungsskripte.
145
+ Extrahiere absolut JEDEN Text (sowohl handgeschrieben als auch Maschinenschrift / gedruckten Text).
146
+ Verpasse kein einziges mathematisches Symbol, keinen Bruch und keinen Index.
147
+ WICHTIG FÜR FORMELN: Wandle ALLE mathematischen Formeln zwingend in eine saubere, einzeilige und logisch lesbare Schreibweise um!
148
+ - Nutze Klammern und Schrägstriche für Brüche: (A)/(B)
149
+ - Nutze '^' für Exponenten und '_' für Indizes: x^(SV), q_BM
150
+ - Nutze korrekte Unicode-Sonderzeichen für alles andere: Wurzeln (√), Integrale (∫), Summen (∑), griechische Buchstaben (α, β, γ, μ) etc.
151
+ - ACHTUNG BEI EINHEITEN: Wenn Einheiten in eckigen Klammern [...] neben einer Formel stehen, behalte die eckigen Klammern UNBEDINGT bei! Füge KEIN Multiplikationszeichen '*' dazwischen ein. Einheiten sind reine Beschriftungen, keine Faktoren!
152
+ - Versuche NICHT, das optische 2D-Layout von Formeln mit mehrzeiligen Leerzeichen nachzuahmen!
153
+ Fasse zusammenhängende Sätze, Absätze oder komplette mathematische Formeln in EINER GEMEINSAMEN BoundingBox zusammen.
154
+ Zerstückele Formeln oder Brüche NICHT in Einzelteile! Eine komplette Formel = Eine BoundingBox.
155
+ Ignoriere Hintergrundmuster wie Punktraster komplett.
156
+ Gib für jeden Textblock/jede Formel eine BoundingBox zurück. box_2d ist [ymin, xmin, ymax, xmax] von 0 bis 1000."""
157
+
158
+ max_retries = 10
159
+ current_model = 'gemini-3.1-flash-lite'
160
+ response = None
161
+ for attempt in range(max_retries):
162
+ try:
163
+ response = client.models.generate_content(
164
+ model=current_model,
165
+ contents=[prompt, types.Part.from_bytes(data=img_bytes, mime_type='image/png')],
166
+ config=types.GenerateContentConfig(
167
+ response_mime_type="application/json",
168
+ response_schema=list[BoundingBox],
169
+ temperature=0.0
170
+ )
171
+ )
172
+ break
173
+ except Exception as e:
174
+ error_msg = str(e)
175
+ is_retriable = any(code in error_msg for code in ["503", "429", "500", "502", "504", "Quota", "exhausted"])
176
+ if is_retriable and attempt < max_retries - 1:
177
+ import re
178
+ wait_time = 5
179
+ match = re.search(r"'retryDelay':\s*'(\d+(?:\.\d+)?)s'", error_msg)
180
+ if match:
181
+ wait_time = int(float(match.group(1))) + 5
182
+ if "503" in error_msg or "unavailable" in error_msg.lower():
183
+ current_model = 'gemini-2.5-flash'
184
+ print(f"[API] API error. Waiting {wait_time}s... (Attempt {attempt+1}/{max_retries})")
185
+ time.sleep(wait_time)
186
+ else:
187
+ raise e
188
+
189
+ if response and response.parsed:
190
+ font = fitz.Font("helv")
191
+ descender = font.descender
192
+
193
+ boxes_with_data = []
194
+ for box in response.parsed:
195
+ ymin, xmin, ymax, xmax = box.box_2d
196
+ x0 = (xmin / 1000) * page.rect.width
197
+ y0 = (ymin / 1000) * page.rect.height
198
+ x1 = (xmax / 1000) * page.rect.width
199
+ y1 = (ymax / 1000) * page.rect.height
200
+ boxes_with_data.append(([x0, y0, x1, y1], box))
201
+
202
+ sorted_data = recursive_xy_cut(boxes_with_data)
203
+
204
+ for coords, box in sorted_data:
205
+ text = box.label
206
+ if not text.strip() or text.strip() in [".", "..."]:
207
+ continue
208
+
209
+ x0, y0, x1, y1 = coords
210
+ rect = fitz.Rect(x0, y0, x1, y1)
211
+
212
+ fontsize = rect.height
213
+ text_length = fitz.get_text_length(text, fontname="helv", fontsize=fontsize)
214
+ scale_x = rect.width / text_length if text_length > 0 else 1.0
215
+
216
+ y_baseline = rect.y1 + (descender * fontsize)
217
+ point = fitz.Point(rect.x0, y_baseline)
218
+ matrix = fitz.Matrix(scale_x, 1.0)
219
+
220
+ try:
221
+ page.insert_text(point, text, fontsize=fontsize, fontname="helv", render_mode=3, morph=(point, matrix))
222
+ except ValueError:
223
+ clean_text = text.encode("latin-1", "ignore").decode("latin-1")
224
+ if clean_text.strip():
225
+ try:
226
+ page.insert_text(point, clean_text, fontsize=fontsize, fontname="helv", render_mode=3, morph=(point, matrix))
227
+ except Exception:
228
+ pass
229
+
230
+ if page_num < num_pages - 1:
231
+ time.sleep(1)
232
+ gc.collect()
233
+
234
+ # ==========================================
235
+ # MODUS 2: Präzise (True Hybrid OCR)
236
+ # ==========================================
237
+ elif mode == "Präzise (Hybrid: PaddleOCR + Gemini)":
238
+ zoom = 150 / 72
239
+ mat = fitz.Matrix(zoom, zoom)
240
+ pix = page.get_pixmap(matrix=mat)
241
+ img_bytes = pix.tobytes("png")
242
+
243
+ img_np = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, pix.n)
244
+ if pix.n == 4:
245
+ img_np = cv2.cvtColor(img_np, cv2.COLOR_RGBA2BGR)
246
+ else:
247
+ img_np = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
248
+
249
+ result = paddle_ocr.ocr(img_np)
250
+ paddle_boxes = [(line[0], line[1][0]) for line in result[0]] if result and result[0] else []
251
+
252
+ prompt = """Du bist ein extrem präzises OCR-System für mathematische Vorlesungsskripte.
253
+ Extrahiere absolut JEDEN Text (sowohl handgeschrieben als auch Maschinenschrift / gedruckten Text).
254
+ Verpasse kein einziges mathematisches Symbol, keinen Bruch und keinen Index.
255
+ WICHTIG FÜR FORMELN: Wandle ALLE mathematischen Formeln zwingend in eine saubere, einzeilige und logisch lesbare Schreibweise um!
256
+ - Nutze Klammern und Schrägstriche für Brüche: (A)/(B)
257
+ - Nutze '^' für Exponenten und '_' für Indizes: x^(SV), q_BM
258
+ - Nutze korrekte Unicode-Sonderzeichen für alles andere: Wurzeln (√), Integrale (∫), Summen (∑), griechische Buchstaben (α, β, γ, μ) etc.
259
+ - ACHTUNG BEI EINHEITEN: Wenn Einheiten in eckigen Klammern [...] neben einer Formel stehen, behalte die eckigen Klammern UNBEDINGT bei! Füge KEIN Multiplikationszeichen '*' dazwischen ein. Einheiten sind reine Beschriftungen, keine Faktoren!
260
+ - Versuche NICHT, das optische 2D-Layout von Formeln mit mehrzeiligen Leerzeichen nachzuahmen!
261
+ WICHTIG FÜR DAS LAYOUT (ABSOLUT KRITISCH!):
262
+ 1. NORMALE TEXTZEILEN: Du MUSST für JEDE physische Textzeile im Bild eine EIGENE, separate BoundingBox erstellen!
263
+ - Es ist STRENGSTENS VERBOTEN, mehrere Zeilen zu einem Absatz zusammenzufassen!
264
+ - Auch wenn eine Textzeile Variablen (wie f_A) enthält, ist sie eine normale Zeile und darf NICHT mit der Zeile darunter zusammengefasst werden.
265
+ 2. MEHRZEILIGE BRÜCHE: NUR WIRKLICHE mehrzeilige Formeln (Zähler über Nenner) MÜSSEN in EINER gemeinsamen BoundingBox zusammengefasst werden.
266
+ Ignoriere Hintergrundmuster wie Punktraster komplett.
267
+ Gib für jeden Textblock/jede Formel eine BoundingBox zurück. box_2d ist [ymin, xmin, ymax, xmax] von 0 bis 1000."""
268
+
269
+ max_retries = 10
270
+ current_model = 'gemini-3.1-flash-lite'
271
+ response = None
272
+ for attempt in range(max_retries):
273
+ try:
274
+ response = client.models.generate_content(
275
+ model=current_model,
276
+ contents=[prompt, types.Part.from_bytes(data=img_bytes, mime_type='image/png')],
277
+ config=types.GenerateContentConfig(
278
+ response_mime_type="application/json",
279
+ response_schema=list[BoundingBox],
280
+ temperature=0.0
281
+ )
282
+ )
283
+ break
284
+ except Exception as e:
285
+ error_msg = str(e)
286
+ is_retriable = any(code in error_msg for code in ["503", "429", "500", "502", "504", "Quota", "exhausted"])
287
+ if is_retriable and attempt < max_retries - 1:
288
+ import re
289
+ wait_time = 5
290
+ match = re.search(r"'retryDelay':\s*'(\d+(?:\.\d+)?)s'", error_msg)
291
+ if match:
292
+ wait_time = int(float(match.group(1))) + 5
293
+ if "503" in error_msg or "unavailable" in error_msg.lower():
294
+ current_model = 'gemini-2.5-flash'
295
+ print(f"[API] API error. Waiting {wait_time}s... (Attempt {attempt+1}/{max_retries})")
296
+ time.sleep(wait_time)
297
+ else:
298
+ raise e
299
+
300
+ if response and response.parsed:
301
+ boxes_with_data = []
302
+ g_rects = []
303
+ for g_box in response.parsed:
304
+ ymin, xmin, ymax, xmax = g_box.box_2d
305
+ x0 = (xmin / 1000) * page.rect.width
306
+ y0 = (ymin / 1000) * page.rect.height
307
+ x1 = (xmax / 1000) * page.rect.width
308
+ y1 = (ymax / 1000) * page.rect.height
309
+ g_rects.append(fitz.Rect(x0, y0, x1, y1))
310
+
311
+ assigned_p_boxes_per_g_idx = {i: [] for i in range(len(response.parsed))}
312
+
313
+ for pb_data in paddle_boxes:
314
+ p_box, p_text = pb_data
315
+ p_xmin = min(p[0] for p in p_box) / zoom
316
+ p_ymin = min(p[1] for p in p_box) / zoom
317
+ p_xmax = max(p[0] for p in p_box) / zoom
318
+ p_ymax = max(p[1] for p in p_box) / zoom
319
+ p_rect = fitz.Rect(p_xmin, p_ymin, p_xmax, p_ymax)
320
+
321
+ best_g_idx = -1
322
+ max_overlap = 0
323
+ for idx, g_rect in enumerate(g_rects):
324
+ overlap = g_rect.intersect(p_rect).get_area()
325
+ if overlap > max_overlap:
326
+ max_overlap = overlap
327
+ best_g_idx = idx
328
+
329
+ if best_g_idx != -1 and max_overlap > 0.1 * p_rect.get_area():
330
+ assigned_p_boxes_per_g_idx[best_g_idx].append(pb_data)
331
+
332
+ for idx, g_box in enumerate(response.parsed):
333
+ g_text = g_box.label
334
+ assigned_p_boxes = assigned_p_boxes_per_g_idx[idx]
335
+
336
+ if assigned_p_boxes:
337
+ assigned_p_boxes.sort(key=lambda b: min(p[1] for p in b[0]))
338
+
339
+ clustered_p_boxes = []
340
+ for pb_tuple in assigned_p_boxes:
341
+ pb, pt = pb_tuple
342
+ y_center = (min(p[1] for p in pb) + max(p[1] for p in pb)) / 2
343
+
344
+ added_to_cluster = False
345
+ for cluster in clustered_p_boxes:
346
+ c_y_center = cluster['y_center']
347
+ if abs(y_center - c_y_center) < (10 / zoom):
348
+ cluster['boxes'].append(pb_tuple)
349
+ all_y = [min(p[1] for b in cluster['boxes'] for p in b[0]), max(p[1] for b in cluster['boxes'] for p in b[0])]
350
+ cluster['y_center'] = sum(all_y) / 2
351
+ added_to_cluster = True
352
+ break
353
+
354
+ if not added_to_cluster:
355
+ clustered_p_boxes.append({'y_center': y_center, 'boxes': [pb_tuple]})
356
+
357
+ for cluster in clustered_p_boxes:
358
+ cluster['boxes'].sort(key=lambda b: min(p[0] for p in b[0]))
359
+
360
+ math_chars = sum(1 for c in g_text if c in ['=', '/', '^', '[', ']'])
361
+ is_formula = (math_chars >= 4 and "=" in g_text)
362
+
363
+ if not is_formula:
364
+ g_words = g_text.split()
365
+ word_idx = 0
366
+
367
+ for c_idx, cluster in enumerate(clustered_p_boxes):
368
+ cluster_boxes = cluster['boxes']
369
+ cluster_word_count = sum(max(1, len(pt.split())) for pb, pt in cluster_boxes)
370
+ chunk = g_words[word_idx : word_idx + cluster_word_count]
371
+ line_text = " ".join(chunk)
372
+ word_idx += cluster_word_count
373
+
374
+ if c_idx == len(clustered_p_boxes) - 1 and word_idx < len(g_words):
375
+ if line_text:
376
+ line_text += " "
377
+ line_text += " ".join(g_words[word_idx:])
378
+
379
+ if not line_text.strip():
380
+ continue
381
+
382
+ if len(cluster_boxes) == 1:
383
+ pb, pt = cluster_boxes[0]
384
+ p0 = [pb[0][0]/zoom, pb[0][1]/zoom]
385
+ p1 = [pb[1][0]/zoom, pb[1][1]/zoom]
386
+ p2 = [pb[2][0]/zoom, pb[2][1]/zoom]
387
+ p3 = [pb[3][0]/zoom, pb[3][1]/zoom]
388
+
389
+ dx = p1[0] - p0[0]
390
+ dy = p1[1] - p0[1]
391
+ dx_up = p0[0] - p3[0]
392
+ dy_up = p0[1] - p3[1]
393
+ angle_rad = math.atan2(dy, dx) if (dx != 0 or dy != 0) else 0
394
+ angle_deg = math.degrees(angle_rad)
395
+ else:
396
+ total_dx, total_dy = 0, 0
397
+ all_points = []
398
+ for pb, pt in cluster_boxes:
399
+ total_dx += pb[1][0] - pb[0][0]
400
+ total_dy += pb[1][1] - pb[0][1]
401
+ for p in pb:
402
+ all_points.append((p[0]/zoom, p[1]/zoom))
403
+
404
+ angle_rad = math.atan2(total_dy, total_dx) if (total_dx != 0 or total_dy != 0) else 0
405
+ angle_deg = math.degrees(angle_rad)
406
+
407
+ cos_a = math.cos(-angle_rad)
408
+ sin_a = math.sin(-angle_rad)
409
+
410
+ local_points = []
411
+ for px, py in all_points:
412
+ lx = px * cos_a - py * sin_a
413
+ ly = px * sin_a + py * cos_a
414
+ local_points.append((lx, ly))
415
+
416
+ min_lx = min(p[0] for p in local_points)
417
+ max_lx = max(p[0] for p in local_points)
418
+ min_ly = min(p[1] for p in local_points)
419
+ max_ly = max(p[1] for p in local_points)
420
+
421
+ lp0, lp1, lp2, lp3 = (min_lx, min_ly), (max_lx, min_ly), (max_lx, max_ly), (min_lx, max_ly)
422
+
423
+ cos_inv = math.cos(angle_rad)
424
+ sin_inv = math.sin(angle_rad)
425
+
426
+ merged_box = []
427
+ for lx, ly in [lp0, lp1, lp2, lp3]:
428
+ gx = lx * cos_inv - ly * sin_inv
429
+ gy = lx * sin_inv + ly * cos_inv
430
+ merged_box.append([gx, gy])
431
+
432
+ p0, p1, p2, p3 = merged_box
433
+ dx = p1[0] - p0[0]
434
+ dy = p1[1] - p0[1]
435
+ dx_up = p0[0] - p3[0]
436
+ dy_up = p0[1] - p3[1]
437
+
438
+ box_width_pdf = math.hypot(dx, dy)
439
+ box_height_pdf = math.hypot(dx_up, dy_up)
440
+
441
+ font = fitz.Font("helv")
442
+ shift_factor = -font.descender
443
+ base_x = p3[0] + dx_up * shift_factor
444
+ base_y = p3[1] + dy_up * shift_factor
445
+ pdf_baseline = fitz.Point(base_x, base_y)
446
+
447
+ merged_points = [p0, p1, p2, p3]
448
+ coords = [min(p[0] for p in merged_points), min(p[1] for p in merged_points), max(p[0] for p in merged_points), max(p[1] for p in merged_points)]
449
+ boxes_with_data.append((coords, ('text', line_text, pdf_baseline, box_width_pdf, box_height_pdf, angle_deg)))
450
+ else:
451
+ total_dx, total_dy = 0, 0
452
+ all_points = []
453
+ for pb, pt in assigned_p_boxes:
454
+ total_dx += pb[1][0] - pb[0][0]
455
+ total_dy += pb[1][1] - pb[0][1]
456
+ for p in pb:
457
+ all_points.append((p[0]/zoom, p[1]/zoom))
458
+
459
+ angle_rad = math.atan2(total_dy, total_dx) if (total_dx != 0 or total_dy != 0) else 0
460
+ angle_deg = math.degrees(angle_rad)
461
+
462
+ cos_a = math.cos(-angle_rad)
463
+ sin_a = math.sin(-angle_rad)
464
+
465
+ local_points = []
466
+ for px, py in all_points:
467
+ lx = px * cos_a - py * sin_a
468
+ ly = px * sin_a + py * cos_a
469
+ local_points.append((lx, ly))
470
+
471
+ min_lx = min(p[0] for p in local_points)
472
+ max_lx = max(p[0] for p in local_points)
473
+ min_ly = min(p[1] for p in local_points)
474
+ max_ly = max(p[1] for p in local_points)
475
+
476
+ lp0, lp1, lp2, lp3 = (min_lx, min_ly), (max_lx, min_ly), (max_lx, max_ly), (min_lx, max_ly)
477
+
478
+ cos_inv = math.cos(angle_rad)
479
+ sin_inv = math.sin(angle_rad)
480
+
481
+ merged_box = []
482
+ for lx, ly in [lp0, lp1, lp2, lp3]:
483
+ gx = lx * cos_inv - ly * sin_inv
484
+ gy = lx * sin_inv + ly * cos_inv
485
+ merged_box.append([gx, gy])
486
+
487
+ p0, p1, p2, p3 = merged_box
488
+ dx = p1[0] - p0[0]
489
+ dy = p1[1] - p0[1]
490
+ dx_up = p0[0] - p3[0]
491
+ dy_up = p0[1] - p3[1]
492
+
493
+ box_width_pdf = math.hypot(dx, dy)
494
+ box_height_pdf = math.hypot(dx_up, dy_up)
495
+
496
+ font = fitz.Font("helv")
497
+ shift_factor = -font.descender
498
+ base_x = p3[0] + dx_up * shift_factor
499
+ base_y = p3[1] + dy_up * shift_factor
500
+ pdf_baseline = fitz.Point(base_x, base_y)
501
+
502
+ coords = [min(p[0] for p in merged_box), min(p[1] for p in merged_box), max(p[0] for p in merged_box), max(p[1] for p in merged_box)]
503
+ flat_text = g_text.replace('\n', ' ')
504
+ boxes_with_data.append((coords, (flat_text, pdf_baseline, box_width_pdf, box_height_pdf, angle_deg)))
505
+ else:
506
+ ymin, xmin, ymax, xmax = g_box.box_2d
507
+ x0 = (xmin / 1000) * page.rect.width
508
+ y0 = (ymin / 1000) * page.rect.height
509
+ x1 = (xmax / 1000) * page.rect.width
510
+ y1 = (ymax / 1000) * page.rect.height
511
+ pdf_baseline = fitz.Point(x0, y1 - (y1-y0)*0.2)
512
+ boxes_with_data.append(([x0, y0, x1, y1], (g_text.replace('\n', ' '), pdf_baseline, x1-x0, y1-y0, 0)))
513
+
514
+ sorted_data = recursive_xy_cut(boxes_with_data)
515
+
516
+ for coords, data in sorted_data:
517
+ if len(data) == 5:
518
+ text, pdf_baseline, box_width_pdf, box_height_pdf, angle_deg = data
519
+ elif len(data) == 6:
520
+ _, text, pdf_baseline, box_width_pdf, box_height_pdf, angle_deg = data
521
+ else:
522
+ continue
523
+
524
+ fontsize = box_height_pdf
525
+ text_length = fitz.get_text_length(text, fontname="helv", fontsize=fontsize)
526
+ scale_x = box_width_pdf / text_length if text_length > 0 else 1.0
527
+
528
+ matrix = fitz.Matrix(scale_x, 1.0) * fitz.Matrix(-angle_deg)
529
+ try:
530
+ page.insert_text(pdf_baseline, text, fontsize=fontsize, fontname="helv", render_mode=3, morph=(pdf_baseline, matrix))
531
+ except ValueError:
532
+ clean_text = text.encode("latin-1", "ignore").decode("latin-1")
533
+ if clean_text.strip():
534
+ try:
535
+ page.insert_text(pdf_baseline, clean_text, fontsize=fontsize, fontname="helv", render_mode=3, morph=(pdf_baseline, matrix))
536
+ except Exception:
537
+ pass
538
+
539
+ if page_num < num_pages - 1:
540
+ time.sleep(1)
541
+ gc.collect()
542
+
543
+ # ==========================================
544
+ # MODUS 3: Lokal Deep (TrOCR)
545
+ # ==========================================
546
+ elif mode == "Lokal Deep (PaddleOCR + TrOCR)":
547
+ zoom = 3.0
548
+ mat = fitz.Matrix(zoom, zoom)
549
+ pix = page.get_pixmap(matrix=mat)
550
+
551
+ img_np = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, pix.n)
552
+ if pix.n == 4:
553
+ img_np = cv2.cvtColor(img_np, cv2.COLOR_RGBA2BGR)
554
+ else:
555
+ img_np = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
556
+
557
+ result = paddle_ocr.ocr(img_np)
558
+
559
+ if not result or not result[0]:
560
+ continue
561
+
562
+ page_data = result[0]
563
+ valid_lines = [l for l in page_data if l]
564
+ crops = []
565
+ valid_boxes = []
566
+
567
+ for line in valid_lines:
568
+ box = line[0]
569
+ x_coords = [int(p[0]) for p in box]
570
+ y_coords = [int(p[1]) for p in box]
571
+ x_min, x_max = max(0, min(x_coords) - 2), min(img_np.shape[1], max(x_coords) + 2)
572
+ y_min, y_max = max(0, min(y_coords) - 2), min(img_np.shape[0], max(y_coords) + 2)
573
+
574
+ crop_img = img_np[y_min:y_max, x_min:x_max]
575
+ if crop_img.size > 0:
576
+ crop_rgb = cv2.cvtColor(crop_img, cv2.COLOR_BGR2RGB)
577
+ crops.append(Image.fromarray(crop_rgb))
578
+ valid_boxes.append((box, line[1][0]))
579
+
580
+ BATCH_SIZE = 4
581
+ results = []
582
+
583
+ for b_idx in range(0, len(crops), BATCH_SIZE):
584
+ batch_crops = crops[b_idx:b_idx+BATCH_SIZE]
585
+ batch_fallbacks = [vb[1] for vb in valid_boxes[b_idx:b_idx+BATCH_SIZE]]
586
+ batch_texts = list(batch_fallbacks)
587
+
588
+ if batch_crops:
589
+ try:
590
+ pixel_values = trocr_processor(batch_crops, return_tensors="pt").pixel_values
591
+ generated_ids = trocr_model.generate(pixel_values, max_new_tokens=30)
592
+ texts = trocr_processor.batch_decode(generated_ids, skip_special_tokens=True)
593
+ for map_idx, txt in enumerate(texts):
594
+ batch_texts[map_idx] = txt
595
+ except Exception as e:
596
+ print(f"[API] TrOCR batch failed, using fallback: {e}")
597
+
598
+ results.extend(batch_texts)
599
+
600
+ boxes_with_data = []
601
+ for idx, text in enumerate(results):
602
+ if not text.strip(): continue
603
+ box = valid_boxes[idx][0]
604
+ xmin = min(p[0] for p in box)
605
+ ymin = min(p[1] for p in box)
606
+ xmax = max(p[0] for p in box)
607
+ ymax = max(p[1] for p in box)
608
+ boxes_with_data.append(([xmin, ymin, xmax, ymax], (box, text)))
609
+
610
+ sorted_data = recursive_xy_cut(boxes_with_data)
611
+
612
+ for coords, (box, text) in sorted_data:
613
+ p0, p1, p2, p3 = box
614
+ dx = p1[0] - p0[0]
615
+ dy = p1[1] - p0[1]
616
+ angle_deg = math.degrees(math.atan2(dy, dx))
617
+
618
+ dx_up = p0[0] - p3[0]
619
+ dy_up = p0[1] - p3[1]
620
+
621
+ font = fitz.Font("helv")
622
+ shift_factor = -font.descender
623
+
624
+ base_x = p3[0] + dx_up * shift_factor
625
+ base_y = p3[1] + dy_up * shift_factor
626
+
627
+ pdf_baseline = fitz.Point(base_x / zoom, base_y / zoom)
628
+ box_width_pdf = math.hypot(dx, dy) / zoom
629
+ box_height_pdf = math.hypot(dx_up, dy_up) / zoom
630
+
631
+ fontsize = box_height_pdf
632
+ text_length = fitz.get_text_length(text, fontname="helv", fontsize=fontsize)
633
+ scale_x = box_width_pdf / text_length if text_length > 0 else 1.0
634
+
635
+ matrix = fitz.Matrix(scale_x, 1.0) * fitz.Matrix(-angle_deg)
636
+ try:
637
+ page.insert_text(pdf_baseline, text, fontsize=fontsize, fontname="helv", render_mode=3, morph=(pdf_baseline, matrix))
638
+ except ValueError:
639
+ clean_text = text.encode("latin-1", "ignore").decode("latin-1")
640
+ if clean_text.strip():
641
+ try:
642
+ page.insert_text(pdf_baseline, clean_text, fontsize=fontsize, fontname="helv", render_mode=3, morph=(pdf_baseline, matrix))
643
+ except Exception:
644
+ pass
645
+
646
+ doc.save(output_path)
647
+ doc.close()
648
+ progress(1.0, desc="Fertig! PDF erfolgreich generiert.")
649
+ print(f"[API] Searchable PDF saved to {output_path}")
650
+ return output_path
651
+
652
+ # Gradio Interface
653
+ with gr.Blocks(title="OCR app API & Web Interface") as demo:
654
+ gr.Markdown("# 📄 Multi-Mode OCR API & Web UI")
655
+ gr.Markdown("Wähle eine PDF-Datei und einen Modus, um ein durchsuchbares PDF zu generieren. Dieser Space kann auch programmgesteuert aufgerufen werden.")
656
+
657
+ with gr.Row():
658
+ with gr.Column():
659
+ file_input = gr.File(label="PDF Datei hochladen", file_types=[".pdf"])
660
+ mode_input = gr.Radio(
661
+ choices=["Schnell (Gemini Full-Page)", "Präzise (Hybrid: PaddleOCR + Gemini)", "Lokal Deep (PaddleOCR + TrOCR)"],
662
+ value="Schnell (Gemini Full-Page)",
663
+ label="OCR Modus"
664
+ )
665
+ btn = gr.Button("🚀 OCR starten", variant="primary")
666
+
667
+ with gr.Column():
668
+ file_output = gr.File(label="Durchsuchbares PDF herunterladen")
669
+
670
+ btn.click(
671
+ fn=process_pdf,
672
+ inputs=[file_input, mode_input],
673
+ outputs=file_output
674
+ )
675
+
676
+ if __name__ == "__main__":
677
+ demo.queue().launch(server_name="0.0.0.0", server_port=7860)
requirements.txt CHANGED
@@ -10,3 +10,5 @@ pydantic
10
  torch
11
  transformers
12
  optimum
 
 
 
10
  torch
11
  transformers
12
  optimum
13
+ gradio
14
+ gradio_client