MasterSayn commited on
Commit
76f9d8b
·
verified ·
1 Parent(s): f7d8279

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. Dockerfile +9 -0
  2. app.py +718 -0
  3. packages.txt +3 -0
  4. requirements.txt +12 -0
Dockerfile ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10
2
+ WORKDIR /code
3
+ COPY packages.txt .
4
+ RUN apt-get update && xargs -a packages.txt apt-get install -y
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+ COPY . .
8
+ EXPOSE 7860
9
+ CMD ["streamlit", "run", "app.py", "--server.port", "7860", "--server.address", "0.0.0.0"]
app.py ADDED
@@ -0,0 +1,718 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import fitz # PyMuPDF
3
+ import cv2
4
+ import numpy as np
5
+ import io
6
+ import math
7
+ from PIL import Image
8
+ import time
9
+ import os
10
+
11
+ # WICHTIG: torch MUSS vor paddleocr importiert werden unter Windows
12
+ import torch
13
+ from paddleocr import PaddleOCR
14
+
15
+ from google import genai
16
+ from google.genai import types
17
+ from pydantic import BaseModel
18
+
19
+ # --- 1. Strukturierter Output definieren (Pydantic) ---
20
+ class OCRResult(BaseModel):
21
+ transcriptions: list[str]
22
+
23
+ class BoundingBox(BaseModel):
24
+ box_2d: list[int]
25
+ label: str
26
+
27
+ api_key = "AQ.Ab8RN6K7DUqbPAcdRwV1Y-xYSH4qZOwvnUsmi1330DwbiWVSOg"
28
+
29
+ # Cache die Modelle, damit sie nur bei Bedarf und nur einmal geladen werden
30
+ @st.cache_resource
31
+ def get_paddle_ocr():
32
+ return PaddleOCR(use_angle_cls=True, lang='de', show_log=False)
33
+
34
+ @st.cache_resource
35
+ def get_trocr():
36
+ import logging as transformers_logging
37
+ transformers_logging.getLogger("transformers").setLevel(transformers_logging.ERROR)
38
+
39
+ onnx_path = "trocr_onnx"
40
+ if os.path.exists(onnx_path):
41
+ from transformers import TrOCRProcessor
42
+ from optimum.onnxruntime import ORTModelForVision2Seq
43
+ processor = TrOCRProcessor.from_pretrained(onnx_path)
44
+ model = ORTModelForVision2Seq.from_pretrained(onnx_path, provider="DMLExecutionProvider")
45
+ return processor, model
46
+ else:
47
+ from transformers import TrOCRProcessor, VisionEncoderDecoderModel
48
+ processor = TrOCRProcessor.from_pretrained('microsoft/trocr-base-handwritten')
49
+ model = VisionEncoderDecoderModel.from_pretrained('microsoft/trocr-base-handwritten')
50
+ return processor, model
51
+
52
+ def recursive_xy_cut(boxes_with_data):
53
+ if len(boxes_with_data) <= 1:
54
+ return boxes_with_data
55
+
56
+ gap_threshold = 5 # Mindestabstand
57
+
58
+ # 1. Horizontale Lücken berechnen
59
+ y_intervals = sorted([(b[0][1], b[0][3]) for b in boxes_with_data])
60
+ max_h_gap = 0
61
+ h_gap_y = None
62
+ max_y = y_intervals[0][1]
63
+ for i in range(1, len(y_intervals)):
64
+ if y_intervals[i][0] > max_y:
65
+ gap = y_intervals[i][0] - max_y
66
+ if gap > max_h_gap and gap > gap_threshold:
67
+ max_h_gap = gap
68
+ h_gap_y = (max_y + y_intervals[i][0]) / 2
69
+ max_y = max(max_y, y_intervals[i][1])
70
+
71
+ # 2. Vertikale Lücken berechnen
72
+ x_intervals = sorted([(b[0][0], b[0][2]) for b in boxes_with_data])
73
+ max_v_gap = 0
74
+ v_gap_x = None
75
+ max_x = x_intervals[0][1]
76
+ for i in range(1, len(x_intervals)):
77
+ if x_intervals[i][0] > max_x:
78
+ gap = x_intervals[i][0] - max_x
79
+ if gap > max_v_gap and gap > gap_threshold:
80
+ max_v_gap = gap
81
+ v_gap_x = (max_x + x_intervals[i][0]) / 2
82
+ max_x = max(max_x, x_intervals[i][1])
83
+
84
+ # 3. Entlang der GRÖSSTEN Lücke schneiden!
85
+ if max_h_gap == 0 and max_v_gap == 0:
86
+ # Keine Lücken -> Zeilenweises sortieren (top to bottom, left to right)
87
+ return sorted(boxes_with_data, key=lambda b: (b[0][1], b[0][0]))
88
+
89
+ if max_v_gap > max_h_gap: # Spaltentrennung bevorzugen, wenn die vertikale Lücke größer ist
90
+ left_boxes = [b for b in boxes_with_data if (b[0][0]+b[0][2])/2 < v_gap_x]
91
+ right_boxes = [b for b in boxes_with_data if b not in left_boxes]
92
+ if len(left_boxes) > 0 and len(right_boxes) > 0:
93
+ return recursive_xy_cut(left_boxes) + recursive_xy_cut(right_boxes)
94
+
95
+ if h_gap_y is not None:
96
+ top_boxes = [b for b in boxes_with_data if (b[0][1]+b[0][3])/2 < h_gap_y]
97
+ bottom_boxes = [b for b in boxes_with_data if b not in top_boxes]
98
+ if len(top_boxes) > 0 and len(bottom_boxes) > 0:
99
+ return recursive_xy_cut(top_boxes) + recursive_xy_cut(bottom_boxes)
100
+
101
+ # Fallback
102
+ return sorted(boxes_with_data, key=lambda b: (b[0][1], b[0][0]))
103
+
104
+ def main():
105
+ st.set_page_config(page_title="Multi-Mode Math OCR", page_icon="⚙️")
106
+ st.title("⚙️ Multi-Mode OCR System")
107
+
108
+ st.info("Wähle unten deinen bevorzugten OCR-Modus aus. Jeder Modus hat seine eigenen Stärken in Bezug auf Geschwindigkeit und Ausrichtungspräzision.")
109
+
110
+ mode = st.radio(
111
+ "Wähle den Verarbeitungsmodus:",
112
+ ["Schnell (Gemini Full-Page)", "Präzise (Hybrid: PaddleOCR + Gemini)", "Lokal Deep (PaddleOCR + TrOCR)"],
113
+ index=0,
114
+ help="Schnell: Nimmt die ganze Seite auf einmal. Präzise: Zerschneidet die Seite für perfekte schräge Ausrichtung. Lokal: Ohne Cloud, nutzt lokales Modell (Microsoft)."
115
+ )
116
+
117
+ # Datei-Upload
118
+ uploaded_file = st.file_uploader("Ziehe dein PDF hierhin oder klicke zum Auswählen", type=["pdf"])
119
+
120
+ if uploaded_file is not None:
121
+ if st.button("🚀 OCR Starten"):
122
+ status_text = st.empty()
123
+ progress_bar = st.progress(0)
124
+
125
+ try:
126
+ # 1. API Clients und KI Modelle bedarfsgerecht laden
127
+ if "Gemini" in mode:
128
+ client = genai.Client(api_key=api_key)
129
+
130
+ if "PaddleOCR" in mode:
131
+ status_text.text("Lade PaddleOCR Modell (Geometrie-KI)...")
132
+ paddle_ocr = get_paddle_ocr()
133
+
134
+ if "Lokal Deep" in mode:
135
+ status_text.text("Lade TrOCR Modell (Microsoft Deep Handwriting)... Dies kann einen Moment dauern.")
136
+ trocr_processor, trocr_model = get_trocr()
137
+
138
+ # PDF laden
139
+ pdf_bytes = uploaded_file.read()
140
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
141
+
142
+ for page_num in range(len(doc)):
143
+ status_text.text(f"Verarbeite Seite {page_num + 1} von {len(doc)} (Modus: {mode})...")
144
+ page = doc.load_page(page_num)
145
+
146
+ # ==========================================
147
+ # MODUS 1: Schnell (Gemini Full-Page)
148
+ # ==========================================
149
+ if mode == "Schnell (Gemini Full-Page)":
150
+ zoom = 150 / 72
151
+ mat = fitz.Matrix(zoom, zoom)
152
+ pix = page.get_pixmap(matrix=mat)
153
+ img_bytes = pix.tobytes("png")
154
+
155
+ # Prompt überarbeitet: Formeln als einzeilige mathematische Strings!
156
+ prompt = """Du bist ein extrem präzises OCR-System für mathematische Vorlesungsskripte.
157
+ Extrahiere absolut JEDEN Text (sowohl handgeschrieben als auch Maschinenschrift / gedruckten Text).
158
+ Verpasse kein einziges mathematisches Symbol, keinen Bruch und keinen Index.
159
+ WICHTIG FÜR FORMELN: Wandle ALLE mathematischen Formeln zwingend in eine saubere, einzeilige und logisch lesbare Schreibweise um!
160
+ - Nutze Klammern und Schrägstriche für Brüche: (A)/(B)
161
+ - Nutze '^' für Exponenten und '_' für Indizes: x^(SV), q_BM
162
+ - Nutze korrekte Unicode-Sonderzeichen für alles andere: Wurzeln (√), Integrale (∫), Summen (∑), griechische Buchstaben (α, β, γ, μ) etc.
163
+ - 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!
164
+ - Versuche NICHT, das optische 2D-Layout von Formeln mit mehrzeiligen Leerzeichen nachzuahmen!
165
+ Fasse zusammenhängende Sätze, Absätze oder komplette mathematische Formeln in EINER GEMEINSAMEN BoundingBox zusammen.
166
+ Zerstückele Formeln oder Brüche NICHT in Einzelteile! Eine komplette Formel = Eine BoundingBox.
167
+ Ignoriere Hintergrundmuster wie Punktraster komplett.
168
+ Gib für jeden Textblock/jede Formel eine BoundingBox zurück. box_2d ist [ymin, xmin, ymax, xmax] von 0 bis 1000."""
169
+
170
+ max_retries = 5
171
+ for attempt in range(max_retries):
172
+ try:
173
+ response = client.models.generate_content(
174
+ model='gemini-2.5-flash',
175
+ contents=[prompt, types.Part.from_bytes(data=img_bytes, mime_type='image/png')],
176
+ config=types.GenerateContentConfig(
177
+ response_mime_type="application/json",
178
+ response_schema=list[BoundingBox],
179
+ temperature=0.0
180
+ )
181
+ )
182
+ break
183
+ except Exception as e:
184
+ error_msg = str(e)
185
+ if ("503" in error_msg or "429" in error_msg) and attempt < max_retries - 1:
186
+ import re
187
+ wait_time = 45
188
+ match = re.search(r"'retryDelay':\s*'(\d+(?:\.\d+)?)s'", error_msg)
189
+ if match:
190
+ wait_time = int(float(match.group(1))) + 5
191
+ status_text.text(f"⚠️ API-Limit (Free Tier) erreicht. Warte {wait_time} Sekunden... (Versuch {attempt+2}/{max_retries})")
192
+ time.sleep(wait_time)
193
+ else:
194
+ raise e
195
+
196
+ if response.parsed:
197
+ font = fitz.Font("helv")
198
+ descender = font.descender
199
+
200
+ boxes_with_data = []
201
+ for box in response.parsed:
202
+ ymin, xmin, ymax, xmax = box.box_2d
203
+ x0 = (xmin / 1000) * page.rect.width
204
+ y0 = (ymin / 1000) * page.rect.height
205
+ x1 = (xmax / 1000) * page.rect.width
206
+ y1 = (ymax / 1000) * page.rect.height
207
+ boxes_with_data.append(([x0, y0, x1, y1], box))
208
+
209
+ sorted_data = recursive_xy_cut(boxes_with_data)
210
+
211
+ for coords, box in sorted_data:
212
+ text = box.label
213
+ if not text.strip() or text.strip() == "." or text.strip() == "...":
214
+ continue
215
+
216
+ x0, y0, x1, y1 = coords
217
+ rect = fitz.Rect(x0, y0, x1, y1)
218
+
219
+ text_length = fitz.get_text_length(text, fontname="helv", fontsize=1)
220
+ if text_length > 0:
221
+ fontsize = rect.width / text_length
222
+ fontsize = min(fontsize, rect.height * 1.5)
223
+ else:
224
+ fontsize = rect.height
225
+
226
+ if fontsize <= 0:
227
+ continue
228
+
229
+ y_baseline = rect.y1 + (descender * fontsize)
230
+ point = fitz.Point(rect.x0, y_baseline)
231
+
232
+ try:
233
+ page.insert_text(point, text, fontsize=fontsize, fontname="helv", render_mode=3)
234
+ except ValueError:
235
+ clean_text = text.encode("latin-1", "ignore").decode("latin-1")
236
+ if clean_text.strip():
237
+ try:
238
+ page.insert_text(point, clean_text, fontsize=fontsize, fontname="helv", render_mode=3)
239
+ except Exception:
240
+ pass
241
+
242
+ # 5 Sekunden Pause, da Gemini extrem schnell ist und sonst das 15 RPM Limit knackt
243
+ if page_num < len(doc) - 1:
244
+ status_text.text(f"Seite {page_num + 1} fertig. Pausiere kurz (5s) wegen Free-Tier Limit...")
245
+ time.sleep(5)
246
+
247
+ # ==========================================
248
+ # MODUS 2: Präzise (True Hybrid OCR)
249
+ # ==========================================
250
+ elif mode == "Präzise (Hybrid: PaddleOCR + Gemini)":
251
+ zoom = 150 / 72
252
+ mat = fitz.Matrix(zoom, zoom)
253
+ pix = page.get_pixmap(matrix=mat)
254
+ img_bytes = pix.tobytes("png")
255
+
256
+ img_np = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, pix.n)
257
+ if pix.n == 4:
258
+ img_np = cv2.cvtColor(img_np, cv2.COLOR_RGBA2BGR)
259
+ else:
260
+ img_np = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
261
+
262
+ status_text.text(f"Seite {page_num + 1}: PaddleOCR Geometry Analyse...")
263
+ result = paddle_ocr.ocr(img_np, cls=True)
264
+ paddle_boxes = [(line[0], line[1][0]) for line in result[0]] if result and result[0] else []
265
+
266
+ status_text.text(f"Seite {page_num + 1}: Gemini Semantic Analyse...")
267
+ prompt = """Du bist ein extrem präzises OCR-System für mathematische Vorlesungsskripte.
268
+ Extrahiere absolut JEDEN Text (sowohl handgeschrieben als auch Maschinenschrift / gedruckten Text).
269
+ Verpasse kein einziges mathematisches Symbol, keinen Bruch und keinen Index.
270
+ WICHTIG FÜR FORMELN: Wandle ALLE mathematischen Formeln zwingend in eine saubere, einzeilige und logisch lesbare Schreibweise um!
271
+ - Nutze Klammern und Schrägstriche für Brüche: (A)/(B)
272
+ - Nutze '^' für Exponenten und '_' für Indizes: x^(SV), q_BM
273
+ - Nutze korrekte Unicode-Sonderzeichen für alles andere: Wurzeln (√), Integrale (∫), Summen (∑), griechische Buchstaben (α, β, γ, μ) etc.
274
+ - 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!
275
+ - Versuche NICHT, das optische 2D-Layout von Formeln mit mehrzeiligen Leerzeichen nachzuahmen!
276
+ WICHTIG FÜR DAS LAYOUT (ABSOLUT KRITISCH!):
277
+ 1. NORMALE TEXTZEILEN: Du MUSST für JEDE physische Textzeile im Bild eine EIGENE, separate BoundingBox erstellen!
278
+ - Es ist STRENGSTENS VERBOTEN, mehrere Zeilen zu einem Absatz zusammenzufassen!
279
+ - Auch wenn eine Textzeile Variablen (wie f_A) enthält, ist sie eine normale Zeile und darf NICHT mit der Zeile darunter zusammengefasst werden.
280
+ 2. MEHRZEILIGE BRÜCHE: NUR WIRKLICHE mehrzeilige Formeln (Zähler über Nenner) MÜSSEN in EINER gemeinsamen BoundingBox zusammengefasst werden.
281
+ Ignoriere Hintergrundmuster wie Punktraster komplett.
282
+ Gib für jeden Textblock/jede Formel eine BoundingBox zurück. box_2d ist [ymin, xmin, ymax, xmax] von 0 bis 1000."""
283
+
284
+ max_retries = 5
285
+ for attempt in range(max_retries):
286
+ try:
287
+ response = client.models.generate_content(
288
+ model='gemini-2.5-flash',
289
+ contents=[prompt, types.Part.from_bytes(data=img_bytes, mime_type='image/png')],
290
+ config=types.GenerateContentConfig(
291
+ response_mime_type="application/json",
292
+ response_schema=list[BoundingBox],
293
+ temperature=0.0
294
+ )
295
+ )
296
+ break
297
+ except Exception as e:
298
+ error_msg = str(e)
299
+ if ("503" in error_msg or "429" in error_msg) and attempt < max_retries - 1:
300
+ import re
301
+ wait_time = 45
302
+ match = re.search(r"'retryDelay':\s*'(\d+(?:\.\d+)?)s'", error_msg)
303
+ if match:
304
+ wait_time = int(float(match.group(1))) + 5
305
+ status_text.text(f"⚠️ API-Limit (Free Tier) erreicht. Warte {wait_time} Sekunden... (Versuch {attempt+2}/{max_retries})")
306
+ time.sleep(wait_time)
307
+ else:
308
+ raise e
309
+
310
+ if response.parsed:
311
+ boxes_with_data = []
312
+ for g_box in response.parsed:
313
+ g_text = g_box.label
314
+ if not g_text.strip() or g_text.strip() in [".", "..."]: continue
315
+
316
+ ymin, xmin, ymax, xmax = g_box.box_2d
317
+ x0 = (xmin / 1000) * page.rect.width
318
+ y0 = (ymin / 1000) * page.rect.height
319
+ x1 = (xmax / 1000) * page.rect.width
320
+ y1 = (ymax / 1000) * page.rect.height
321
+ g_rect = fitz.Rect(x0, y0, x1, y1)
322
+ g_rects = []
323
+ for g_box in response.parsed:
324
+ ymin, xmin, ymax, xmax = g_box.box_2d if not isinstance(g_box, dict) else g_box['box_2d']
325
+ x0 = (xmin / 1000) * page.rect.width
326
+ y0 = (ymin / 1000) * page.rect.height
327
+ x1 = (xmax / 1000) * page.rect.width
328
+ y1 = (ymax / 1000) * page.rect.height
329
+ g_rects.append(fitz.Rect(x0, y0, x1, y1))
330
+
331
+ assigned_p_boxes_per_g_idx = {i: [] for i in range(len(response.parsed))}
332
+
333
+ for pb_data in paddle_boxes:
334
+ p_box, p_text = pb_data
335
+ p_xmin = min(p[0] for p in p_box) / zoom
336
+ p_ymin = min(p[1] for p in p_box) / zoom
337
+ p_xmax = max(p[0] for p in p_box) / zoom
338
+ p_ymax = max(p[1] for p in p_box) / zoom
339
+ p_rect = fitz.Rect(p_xmin, p_ymin, p_xmax, p_ymax)
340
+
341
+ best_g_idx = -1
342
+ max_overlap = 0
343
+ for idx, g_rect in enumerate(g_rects):
344
+ overlap = g_rect.intersect(p_rect).get_area()
345
+ if overlap > max_overlap:
346
+ max_overlap = overlap
347
+ best_g_idx = idx
348
+
349
+ # Weise der Paddle-Box der Gemini-Box zu, die sie am meisten überdeckt
350
+ if best_g_idx != -1 and max_overlap > 0.1 * p_rect.get_area():
351
+ assigned_p_boxes_per_g_idx[best_g_idx].append(pb_data)
352
+
353
+ for idx, g_box in enumerate(response.parsed):
354
+ g_text = g_box.label if not isinstance(g_box, dict) else g_box['label']
355
+ assigned_p_boxes = assigned_p_boxes_per_g_idx[idx]
356
+
357
+ if assigned_p_boxes:
358
+ assigned_p_boxes.sort(key=lambda b: min(p[1] for p in b[0]))
359
+
360
+ g_lines = [line.strip() for line in g_text.split('\n') if line.strip()]
361
+
362
+ # Cluster paddle boxes that are on the same vertical line (y-threshold)
363
+ clustered_p_boxes = []
364
+ for pb_tuple in assigned_p_boxes:
365
+ pb, pt = pb_tuple
366
+ y_center = (min(p[1] for p in pb) + max(p[1] for p in pb)) / 2
367
+
368
+ added_to_cluster = False
369
+ for cluster in clustered_p_boxes:
370
+ c_y_center = cluster['y_center']
371
+ # Threshold: Wenn die y-Zentren nah beieinander liegen (z.B. < 10 Pixel / zoom), gehören sie zur gleichen Zeile
372
+ if abs(y_center - c_y_center) < (10 / zoom):
373
+ cluster['boxes'].append(pb_tuple)
374
+ # Update cluster center
375
+ 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])]
376
+ cluster['y_center'] = sum(all_y) / 2
377
+ added_to_cluster = True
378
+ break
379
+
380
+ if not added_to_cluster:
381
+ clustered_p_boxes.append({'y_center': y_center, 'boxes': [pb_tuple]})
382
+
383
+ # Sortiere Boxen innerhalb jedes Clusters von links nach rechts
384
+ for cluster in clustered_p_boxes:
385
+ cluster['boxes'].sort(key=lambda b: min(p[0] for p in b[0]))
386
+
387
+ # Ist es ein mehrzeiliger mathematischer Bruch?
388
+ # Brüche erkennt man daran, dass Gemini sie einzeilig ausgibt,
389
+ # aber PaddleOCR mehrere vertikale Zeilen (Zähler, Nenner) gefunden hat.
390
+ math_chars = sum(1 for c in g_text if c in ['=', '/', '^', '[', ']'])
391
+ is_formula = (math_chars >= 4 and "=" in g_text)
392
+
393
+ if not is_formula:
394
+ # Word-Count-Alignment
395
+ g_words = g_text.split()
396
+ word_idx = 0
397
+
398
+ for c_idx, cluster in enumerate(clustered_p_boxes):
399
+ cluster_boxes = cluster['boxes']
400
+
401
+ # Wie viele Wörter hat diese PaddleOCR-Reihe im Original?
402
+ cluster_word_count = sum(max(1, len(pt.split())) for pb, pt in cluster_boxes)
403
+
404
+ # Entsprechende Anzahl Wörter von Gemini nehmen
405
+ chunk = g_words[word_idx : word_idx + cluster_word_count]
406
+ line_text = " ".join(chunk)
407
+ word_idx += cluster_word_count
408
+
409
+ # Falls es die letzte Reihe ist, alle restlichen Wörter dranhängen (Sicherheit)
410
+ if c_idx == len(clustered_p_boxes) - 1 and word_idx < len(g_words):
411
+ if line_text:
412
+ line_text += " "
413
+ line_text += " ".join(g_words[word_idx:])
414
+
415
+ if not line_text.strip():
416
+ continue
417
+
418
+ if len(cluster_boxes) == 1:
419
+ pb, pt = cluster_boxes[0]
420
+ p0 = [pb[0][0]/zoom, pb[0][1]/zoom]
421
+ p1 = [pb[1][0]/zoom, pb[1][1]/zoom]
422
+ p2 = [pb[2][0]/zoom, pb[2][1]/zoom]
423
+ p3 = [pb[3][0]/zoom, pb[3][1]/zoom]
424
+
425
+ dx = p1[0] - p0[0]
426
+ dy = p1[1] - p0[1]
427
+ dx_up = p0[0] - p3[0]
428
+ dy_up = p0[1] - p3[1]
429
+ angle_rad = math.atan2(dy, dx) if (dx != 0 or dy != 0) else 0
430
+ angle_deg = math.degrees(angle_rad)
431
+ else:
432
+ # Verschmelze horizontale Boxen (z.B. Bulletpoint + Text) präzise
433
+ total_dx, total_dy = 0, 0
434
+ all_points = []
435
+ for pb, pt in cluster_boxes:
436
+ total_dx += pb[1][0] - pb[0][0]
437
+ total_dy += pb[1][1] - pb[0][1]
438
+ for p in pb:
439
+ all_points.append((p[0]/zoom, p[1]/zoom))
440
+
441
+ angle_rad = math.atan2(total_dy, total_dx) if (total_dx != 0 or total_dy != 0) else 0
442
+ angle_deg = math.degrees(angle_rad)
443
+
444
+ cos_a = math.cos(-angle_rad)
445
+ sin_a = math.sin(-angle_rad)
446
+
447
+ local_points = []
448
+ for px, py in all_points:
449
+ lx = px * cos_a - py * sin_a
450
+ ly = px * sin_a + py * cos_a
451
+ local_points.append((lx, ly))
452
+
453
+ min_lx = min(p[0] for p in local_points)
454
+ max_lx = max(p[0] for p in local_points)
455
+ min_ly = min(p[1] for p in local_points)
456
+ max_ly = max(p[1] for p in local_points)
457
+
458
+ lp0, lp1, lp2, lp3 = (min_lx, min_ly), (max_lx, min_ly), (max_lx, max_ly), (min_lx, max_ly)
459
+
460
+ cos_inv = math.cos(angle_rad)
461
+ sin_inv = math.sin(angle_rad)
462
+
463
+ merged_box = []
464
+ for lx, ly in [lp0, lp1, lp2, lp3]:
465
+ gx = lx * cos_inv - ly * sin_inv
466
+ gy = lx * sin_inv + ly * cos_inv
467
+ merged_box.append([gx, gy])
468
+
469
+ p0, p1, p2, p3 = merged_box
470
+ dx = p1[0] - p0[0]
471
+ dy = p1[1] - p0[1]
472
+ dx_up = p0[0] - p3[0]
473
+ dy_up = p0[1] - p3[1]
474
+
475
+ box_width_pdf = math.hypot(dx, dy)
476
+ box_height_pdf = math.hypot(dx_up, dy_up)
477
+
478
+ font = fitz.Font("helv")
479
+ shift_factor = -font.descender
480
+ base_x = p3[0] + dx_up * shift_factor
481
+ base_y = p3[1] + dy_up * shift_factor
482
+ pdf_baseline = fitz.Point(base_x, base_y)
483
+
484
+ merged_points = [p0, p1, p2, p3]
485
+ 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)]
486
+ boxes_with_data.append((coords, ('text', line_text, pdf_baseline, box_width_pdf, box_height_pdf, angle_deg)))
487
+ else:
488
+ # Es ist ein Bruch / Formel! Präzise Rotations-Geometrie für verschmolzene Boxen berechnen
489
+ total_dx, total_dy = 0, 0
490
+ all_points = []
491
+ for pb, pt in assigned_p_boxes:
492
+ total_dx += pb[1][0] - pb[0][0]
493
+ total_dy += pb[1][1] - pb[0][1]
494
+ for p in pb:
495
+ all_points.append((p[0]/zoom, p[1]/zoom))
496
+
497
+ angle_rad = math.atan2(total_dy, total_dx) if (total_dx != 0 or total_dy != 0) else 0
498
+ angle_deg = math.degrees(angle_rad)
499
+
500
+ cos_a = math.cos(-angle_rad)
501
+ sin_a = math.sin(-angle_rad)
502
+
503
+ local_points = []
504
+ for px, py in all_points:
505
+ lx = px * cos_a - py * sin_a
506
+ ly = px * sin_a + py * cos_a
507
+ local_points.append((lx, ly))
508
+
509
+ min_lx = min(p[0] for p in local_points)
510
+ max_lx = max(p[0] for p in local_points)
511
+ min_ly = min(p[1] for p in local_points)
512
+ max_ly = max(p[1] for p in local_points)
513
+
514
+ lp0, lp1, lp2, lp3 = (min_lx, min_ly), (max_lx, min_ly), (max_lx, max_ly), (min_lx, max_ly)
515
+
516
+ cos_inv = math.cos(angle_rad)
517
+ sin_inv = math.sin(angle_rad)
518
+
519
+ merged_box = []
520
+ for lx, ly in [lp0, lp1, lp2, lp3]:
521
+ gx = lx * cos_inv - ly * sin_inv
522
+ gy = lx * sin_inv + ly * cos_inv
523
+ merged_box.append([gx, gy])
524
+
525
+ box_width_pdf = math.hypot(dx, dy)
526
+ box_height_pdf = math.hypot(dx_up, dy_up)
527
+
528
+ font = fitz.Font("helv")
529
+ shift_factor = -font.descender
530
+ base_x = p3[0] + dx_up * shift_factor
531
+ base_y = p3[1] + dy_up * shift_factor
532
+ pdf_baseline = fitz.Point(base_x, base_y)
533
+
534
+ 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)]
535
+
536
+ # Formeln werden zu einer einzigen Zeile abgeflacht, um das Zerstückeln zu verhindern
537
+ flat_text = g_text.replace('\n', ' ')
538
+ boxes_with_data.append((coords, (flat_text, pdf_baseline, box_width_pdf, box_height_pdf, angle_deg)))
539
+ else:
540
+ # Fallback
541
+ ymin, xmin, ymax, xmax = g_box.box_2d if not isinstance(g_box, dict) else g_box['box_2d']
542
+ x0 = (xmin / 1000) * page.rect.width
543
+ y0 = (ymin / 1000) * page.rect.height
544
+ x1 = (xmax / 1000) * page.rect.width
545
+ y1 = (ymax / 1000) * page.rect.height
546
+ pdf_baseline = fitz.Point(x0, y1 - (y1-y0)*0.2)
547
+ boxes_with_data.append(([x0, y0, x1, y1], (g_text.replace('\n', ' '), pdf_baseline, x1-x0, y1-y0, 0)))
548
+
549
+ sorted_data = recursive_xy_cut(boxes_with_data)
550
+
551
+ for coords, data in sorted_data:
552
+ if len(data) == 5:
553
+ text, pdf_baseline, box_width_pdf, box_height_pdf, angle_deg = data
554
+ elif len(data) == 6:
555
+ _, text, pdf_baseline, box_width_pdf, box_height_pdf, angle_deg = data
556
+ else:
557
+ continue
558
+
559
+ text_length = fitz.get_text_length(text, fontname="helv", fontsize=1)
560
+ if text_length > 0:
561
+ fontsize = box_width_pdf / text_length
562
+ fontsize = min(fontsize, box_height_pdf * 1.8)
563
+ else:
564
+ fontsize = box_height_pdf
565
+
566
+ if fontsize <= 0: continue
567
+
568
+ matrix = fitz.Matrix(-angle_deg)
569
+ try:
570
+ page.insert_text(pdf_baseline, text, fontsize=fontsize, fontname="helv", render_mode=3, morph=(pdf_baseline, matrix))
571
+ except ValueError:
572
+ clean_text = text.encode("latin-1", "ignore").decode("latin-1")
573
+ if clean_text.strip():
574
+ try:
575
+ page.insert_text(pdf_baseline, clean_text, fontsize=fontsize, fontname="helv", render_mode=3, morph=(pdf_baseline, matrix))
576
+ except Exception:
577
+ pass
578
+
579
+ if page_num < len(doc) - 1:
580
+ status_text.text(f"Seite {page_num + 1} fertig. Pausiere kurz (5s)...")
581
+ time.sleep(5)
582
+
583
+ # ==========================================
584
+ # MODUS 3: Lokal Deep (TrOCR)
585
+ # ==========================================
586
+ elif mode == "Lokal Deep (PaddleOCR + TrOCR)":
587
+ zoom = 3.0 # Im alten Skript war der Zoom 3.0 für bessere Quali
588
+ mat = fitz.Matrix(zoom, zoom)
589
+ pix = page.get_pixmap(matrix=mat)
590
+
591
+ img_np = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, pix.n)
592
+ if pix.n == 4:
593
+ img_np = cv2.cvtColor(img_np, cv2.COLOR_RGBA2BGR)
594
+ else:
595
+ img_np = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
596
+
597
+ status_text.text(f"Seite {page_num + 1}: PaddleOCR Layout Analyse...")
598
+ result = paddle_ocr.ocr(img_np, cls=True)
599
+
600
+ if not result or not result[0]:
601
+ continue
602
+
603
+ page_data = result[0]
604
+ valid_lines = [l for l in page_data if l]
605
+ crops = []
606
+ valid_boxes = []
607
+
608
+ # First pass: crop all images
609
+ for line in valid_lines:
610
+ box = line[0]
611
+ x_coords = [int(p[0]) for p in box]
612
+ y_coords = [int(p[1]) for p in box]
613
+ x_min, x_max = max(0, min(x_coords) - 2), min(img_np.shape[1], max(x_coords) + 2)
614
+ y_min, y_max = max(0, min(y_coords) - 2), min(img_np.shape[0], max(y_coords) + 2)
615
+
616
+ crop_img = img_np[y_min:y_max, x_min:x_max]
617
+ if crop_img.size > 0:
618
+ crop_rgb = cv2.cvtColor(crop_img, cv2.COLOR_BGR2RGB)
619
+ crops.append(Image.fromarray(crop_rgb))
620
+ valid_boxes.append((box, line[1][0])) # PaddleOCR Fallback-Text speichern
621
+
622
+ # Batch Processing mit TrOCR
623
+ BATCH_SIZE = 4
624
+ results = []
625
+
626
+ status_text.text(f"Seite {page_num + 1}: TrOCR liest {len(crops)} Text-Schnipsel...")
627
+
628
+ for b_idx in range(0, len(crops), BATCH_SIZE):
629
+ batch_crops = crops[b_idx:b_idx+BATCH_SIZE]
630
+ batch_fallbacks = [vb[1] for vb in valid_boxes[b_idx:b_idx+BATCH_SIZE]]
631
+
632
+ batch_texts = list(batch_fallbacks)
633
+
634
+ if batch_crops:
635
+ try:
636
+ pixel_values = trocr_processor(batch_crops, return_tensors="pt").pixel_values
637
+ generated_ids = trocr_model.generate(pixel_values, max_new_tokens=30)
638
+ texts = trocr_processor.batch_decode(generated_ids, skip_special_tokens=True)
639
+ for map_idx, txt in enumerate(texts):
640
+ batch_texts[map_idx] = txt
641
+ except Exception as e:
642
+ print(f"TrOCR batch failed, using fallback. Error: {e}")
643
+
644
+ results.extend(batch_texts)
645
+
646
+ # Ins PDF stempeln
647
+ boxes_with_data = []
648
+ for idx, text in enumerate(results):
649
+ if not text.strip(): continue
650
+ box = valid_boxes[idx][0]
651
+ xmin = min(p[0] for p in box)
652
+ ymin = min(p[1] for p in box)
653
+ xmax = max(p[0] for p in box)
654
+ ymax = max(p[1] for p in box)
655
+ boxes_with_data.append(([xmin, ymin, xmax, ymax], (box, text)))
656
+
657
+ sorted_data = recursive_xy_cut(boxes_with_data)
658
+
659
+ for coords, (box, text) in sorted_data:
660
+ p0, p1, p2, p3 = box
661
+ dx = p1[0] - p0[0]
662
+ dy = p1[1] - p0[1]
663
+ angle_deg = math.degrees(math.atan2(dy, dx))
664
+
665
+ # Vektor von unten (p3) nach oben (p0)
666
+ dx_up = p0[0] - p3[0]
667
+ dy_up = p0[1] - p3[1]
668
+
669
+ font = fitz.Font("helv")
670
+ shift_factor = -font.descender # ca. 0.2
671
+
672
+ # Baseline-Punkt berechnen (leicht nach oben verschoben)
673
+ base_x = p3[0] + dx_up * shift_factor
674
+ base_y = p3[1] + dy_up * shift_factor
675
+
676
+ pdf_baseline = fitz.Point(base_x / zoom, base_y / zoom)
677
+ box_width_pdf = math.hypot(dx, dy) / zoom
678
+ box_height_pdf = math.hypot(dx_up, dy_up) / zoom
679
+
680
+ text_length = fitz.get_text_length(text, fontname="helv", fontsize=1)
681
+ if text_length > 0:
682
+ fontsize = box_width_pdf / text_length
683
+ fontsize = min(fontsize, box_height_pdf * 1.5)
684
+ else:
685
+ fontsize = box_height_pdf
686
+
687
+ if fontsize <= 0: continue
688
+
689
+ matrix = fitz.Matrix(-angle_deg)
690
+ try:
691
+ page.insert_text(pdf_baseline, text, fontsize=fontsize, fontname="helv", render_mode=3, morph=(pdf_baseline, matrix))
692
+ except ValueError:
693
+ clean_text = text.encode("latin-1", "ignore").decode("latin-1")
694
+ if clean_text.strip():
695
+ try:
696
+ page.insert_text(pdf_baseline, clean_text, fontsize=fontsize, fontname="helv", render_mode=3, morph=(pdf_baseline, matrix))
697
+ except Exception:
698
+ pass
699
+
700
+ progress_bar.progress((page_num + 1) / len(doc))
701
+
702
+ status_text.text("🎉 Verarbeitung komplett! PDF wird generiert...")
703
+
704
+ out_bytes = doc.tobytes()
705
+
706
+ st.success(f"Fertig! Dein PDF ({mode}) steht zum Download bereit.")
707
+ st.download_button(
708
+ label="📥 Fertiges PDF herunterladen",
709
+ data=out_bytes,
710
+ file_name=f"searchable_{uploaded_file.name}",
711
+ mime="application/pdf"
712
+ )
713
+
714
+ except Exception as e:
715
+ st.error(f"❌ Es ist ein Fehler aufgetreten: {e}")
716
+
717
+ if __name__ == "__main__":
718
+ main()
packages.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ libgl1-mesa-glx
2
+ libglib2.0-0
3
+ libgomp1
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit
2
+ PyMuPDF
3
+ opencv-python-headless
4
+ numpy
5
+ Pillow
6
+ paddlepaddle
7
+ paddleocr
8
+ google-genai
9
+ pydantic
10
+ torch
11
+ transformers
12
+ optimum