from __future__ import annotations import hashlib import json import shutil import subprocess import tempfile import xml.etree.ElementTree as ET import zipfile from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path import cv2 import numpy as np from pageparse.config import settings from pageparse.ingest import load_image from pageparse.ocr.printed import PrintedOCR import pageparse.preprocess as pp_module from pageparse.preprocess import preprocess, adaptive_preprocess def _get_uploads_dir() -> Path: here = Path(__file__).resolve().parent.parent.parent uploads_dir = here / "web" / "static" / "uploads" if not (here / "web" / "static").exists(): uploads_dir = Path.cwd() / "web" / "static" / "uploads" return uploads_dir def _try_handwriting_ocr(image): try: from pageparse.ocr.handwriting import HandwritingOCR ocr = HandwritingOCR() return ocr.recognize(image) except Exception: return "" def _ocr_image(img: np.ndarray, printed_ocr: PrintedOCR) -> str: try: return printed_ocr.recognize(img).strip() except Exception: return "" def _ollama_vision_ocr(path: Path) -> str: import base64 import urllib.error import urllib.request import os try: if os.environ.get("GEMINI_API_KEY"): b64 = base64.b64encode(path.read_bytes()).decode("utf-8") gemini_resp = _gemini_generate("This is a slice of handwritten text. Please transcribe the handwriting in this image word-for-word exactly without adding any comments.", b64) if gemini_resp: return gemini_resp img = cv2.imread(str(path)) if img is None: return "" h, w, c = img.shape if h > w and h >= 300: slice_h = h // 3 texts = [] for i in range(3): start = i * slice_h end = (i + 1) * slice_h if i < 2 else h sliced = img[start:end, :] _, buffer = cv2.imencode(".png", sliced) b64 = base64.b64encode(buffer).decode("utf-8") payload = { "model": settings.vision_model, "prompt": "This is a slice of handwritten text. Please transcribe the handwriting in this image word-for-word exactly without adding any comments.", "images": [b64], "stream": False, "options": {"temperature": 0.0}, } url = f"{settings.ollama_url}/api/generate" data = json.dumps(payload).encode("utf-8") req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=120) as response: res = json.loads(response.read().decode("utf-8")) text = res.get("response", "").strip() is_desc = any(phrase in text.lower() for phrase in ["image shows", "shows a page", "written in", "notebook or journal"]) if not text or is_desc: payload["prompt"] = "Transcribe the text in this image word-for-word. Output only the text." data = json.dumps(payload).encode("utf-8") req_fb = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req_fb, timeout=120) as response_fb: res_fb = json.loads(response_fb.read().decode("utf-8")) text = res_fb.get("response", "").strip() if text: if text.startswith('"') and text.endswith('"'): text = text[1:-1].strip() texts.append(text) return "\n\n".join(texts) else: b64 = base64.b64encode(path.read_bytes()).decode("utf-8") payload = { "model": settings.vision_model, "prompt": "This is a slice of handwritten text. Please transcribe the handwriting in this image word-for-word exactly without adding any comments.", "images": [b64], "stream": False, "options": {"temperature": 0.0}, } url = f"{settings.ollama_url}/api/generate" data = json.dumps(payload).encode("utf-8") req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=120) as response: res = json.loads(response.read().decode("utf-8")) text = res.get("response", "").strip() is_desc = any(phrase in text.lower() for phrase in ["image shows", "shows a page", "written in", "notebook or journal"]) if not text or is_desc: payload["prompt"] = "Transcribe the text in this image word-for-word. Output only the text." data = json.dumps(payload).encode("utf-8") req_fb = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req_fb, timeout=120) as response_fb: res_fb = json.loads(response_fb.read().decode("utf-8")) text = res_fb.get("response", "").strip() if text.startswith('"') and text.endswith('"'): text = text[1:-1].strip() return text except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace") if "does not support image input" not in body: print(f"Ollama vision HTTP error: {body}") except Exception as e: print(f"Ollama vision failed: {e}") return "" def _gemini_generate(prompt: str, base64_image: str = None, image_mime: str = "image/png") -> str: import os import json import urllib.request import urllib.error api_key = os.environ.get("GEMINI_API_KEY") if not api_key: return "" url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={api_key}" parts = [{"text": prompt}] if base64_image: parts.append({ "inlineData": { "mimeType": image_mime, "data": base64_image } }) payload = { "contents": [{"parts": parts}] } try: data = json.dumps(payload).encode("utf-8") req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=30) as response: res = json.loads(response.read().decode("utf-8")) return res["candidates"][0]["content"]["parts"][0]["text"].strip() except Exception as e: print(f"Gemini API call failed: {e}") return "" def _llm_complete_local(prompt: str, system: str = "", temperature: float = 0.3, max_tokens: int = 512) -> str: import urllib.request import json gemini_resp = _gemini_generate(f"{system}\n\n{prompt}" if system else prompt) if gemini_resp: return gemini_resp payload = { "model": settings.slm_model, "prompt": f"{system}\n\n{prompt}" if system else prompt, "stream": False, "options": {"temperature": temperature, "num_predict": max_tokens}, } url = f"{settings.ollama_url}/api/generate" try: data = json.dumps(payload).encode("utf-8") req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=90) as response: res = json.loads(response.read().decode("utf-8")) return res.get("response", "").strip() except Exception as e: print(f"Local LLM complete failed: {e}") return "" def _ollama_vision_explain(path: Path) -> str: import base64 import urllib.request import json try: b64 = base64.b64encode(path.read_bytes()).decode("utf-8") gemini_resp = _gemini_generate("Explain this image in very detail. Describe the layout, objects, text, colors, shapes, and overall context. Be as thorough as possible.", b64) if gemini_resp: return gemini_resp except Exception: pass model = settings.vision_model try: req = urllib.request.Request(f"{settings.ollama_url}/api/tags") with urllib.request.urlopen(req, timeout=5) as response: data = json.loads(response.read().decode("utf-8")) available_models = [m["name"] for m in data.get("models", [])] if model not in available_models: if f"{model}:latest" in available_models: model = f"{model}:latest" elif "moondream:latest" in available_models: model = "moondream:latest" elif "llava:7b" in available_models: model = "llava:7b" except Exception: pass try: b64 = base64.b64encode(path.read_bytes()).decode("utf-8") payload = { "model": model, "prompt": "Explain this image in very detail. Describe the layout, objects, text, colors, shapes, and overall context. Be as thorough as possible.", "images": [b64], "stream": False, "options": {"temperature": 0.2}, } url = f"{settings.ollama_url}/api/generate" data = json.dumps(payload).encode("utf-8") req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=120) as response: res = json.loads(response.read().decode("utf-8")) return res.get("response", "").strip() except Exception as e: print(f"Ollama vision explain failed: {e}") return "" def _explain_audio_transcript(transcript: str, filename: str) -> str: if not transcript or transcript == "[inaudible]": return f"This is an audio file named '{filename}'. No clear speech could be transcribed, so a detailed audio explanation is not available." prompt = ( "You are an expert audio analyst. Analyze the following transcript of an audio file and provide a very detailed explanation. " "Summarize the main topics discussed, analyze the speaker's likely intent or tone, point out key terms, and outline any action items.\n\n" f"Transcript:\n{transcript}\n\n" "Detailed Explanation:" ) return _llm_complete_local(prompt, system="You are a detailed audio analysis assistant.", max_tokens=512) def _explain_video_content(ocr_text: str, audio_transcript: str, filename: str) -> str: if (not ocr_text or ocr_text == "[UNCLEAR]") and (not audio_transcript or audio_transcript == "[inaudible]"): return f"This is a video file named '{filename}'. No speech or visual text could be extracted, so a detailed explanation is not available." prompt = ( "You are an expert video analyst. Analyze the extracted visual text (OCR from video frames) " "and the speech transcript of a video file, and provide a very detailed explanation. " "Describe the main themes, key information shown on screen, topics discussed in the audio, " "and synthesize the visual and verbal content into a coherent description of what the video is about.\n\n" f"Extracted Visual Text:\n{ocr_text}\n\n" f"Speech Transcript:\n{audio_transcript}\n\n" "Detailed Explanation:" ) return _llm_complete_local(prompt, system="You are a detailed video analysis assistant.", max_tokens=512) def process_image(path: Path, language: str = "English") -> tuple[str, str, str, list[dict], list[list[str]]]: uploads_dir = _get_uploads_dir() uploads_dir.mkdir(exist_ok=True, parents=True) timestamp = int(Path(path).stat().st_mtime) orig_name = f"original_{timestamp}_{path.name}" clean_name = f"cleaned_{timestamp}_{path.name}" orig_dest = uploads_dir / orig_name clean_dest = uploads_dir / clean_name if path.resolve() != orig_dest.resolve(): shutil.copy(str(path), str(orig_dest)) img = load_image(path) printed_ocr = PrintedOCR() barcodes = [] tables = [] try: barcodes = printed_ocr.detect_barcodes(img) except Exception: pass try: tables = printed_ocr.detect_tables(img) except Exception: pass explanation = _ollama_vision_explain(path) raw_text = _ollama_vision_ocr(path) if raw_text: cleaned = preprocess(img) cv2.imwrite(str(clean_dest), cleaned) else: gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if img.ndim == 3 else img strategies = [ ("adaptive + denoise", pp_module.preprocess), ("adaptive (auto-tuned)", lambda x: adaptive_preprocess(x)[0]), ("otsu", pp_module.preprocess_otsu), ("clahe + otsu", pp_module.preprocess_light), ("grayscale", lambda x: gray.copy()), ("grayscale 2x", lambda x: pp_module.preprocess_with_scale(img, 2.0)), ("grayscale 3x", lambda x: pp_module.preprocess_with_scale(img, 3.0)), ] results = [] for name, fn in strategies: try: cleaned = fn(img) text = _ocr_image(cleaned, printed_ocr) if text: results.append((len(text), text, cleaned)) except Exception: pass best_cleaned = None if results: results.sort(key=lambda x: x[0], reverse=True) _, raw_text, best_cleaned = results[0] else: raw_text = "" if best_cleaned is not None: cv2.imwrite(str(clean_dest), best_cleaned) else: cleaned_default = pp_module.preprocess(img) cv2.imwrite(str(clean_dest), cleaned_default) if not raw_text.strip(): raw_text = _try_handwriting_ocr(best_cleaned or pp_module.preprocess(img)) if not raw_text.strip(): raw_text = "[UNCLEAR]" combined_parts = [] if explanation: combined_parts.append("=========================\nIMAGE ANALYSIS & EXPLANATION\n=========================\n" + explanation) if raw_text and raw_text != "[UNCLEAR]": combined_parts.append("=========================\nEXTRACTED TEXT\n=========================\n" + raw_text) combined_text = "\n\n".join(combined_parts) if combined_parts else "[UNCLEAR]" return (combined_text, f"/static/uploads/{orig_name}", f"/static/uploads/{clean_name}", barcodes, tables) def process_audio(path: Path, language: str = "English") -> tuple[str, str]: uploads_dir = _get_uploads_dir() uploads_dir.mkdir(exist_ok=True, parents=True) timestamp = int(Path(path).stat().st_mtime) dest_name = f"audio_{timestamp}_{path.name}" dest_path = uploads_dir / dest_name if path.resolve() != dest_path.resolve(): shutil.copy(str(path), str(dest_path)) transcript = "" try: transcript = _transcribe_whisper(path) except Exception as e: print(f"Whisper transcription failed, falling back to Sphinx: {e}") try: transcript = _transcribe_sphinx(path) except Exception as e2: print(f"Sphinx also failed: {e2}") if not transcript: transcript = "[inaudible]" explanation = _explain_audio_transcript(transcript, path.name) combined_parts = [] if explanation: combined_parts.append("=========================\nAUDIO ANALYSIS & EXPLANATION\n=========================\n" + explanation) if transcript and transcript != "[inaudible]": combined_parts.append("=========================\nSPEECH TRANSCRIPT\n=========================\n" + transcript) combined_text = "\n\n".join(combined_parts) if combined_parts else "[inaudible]" return combined_text, f"/static/uploads/{dest_name}" def process_video(path: Path, language: str = "English") -> tuple[str, str]: uploads_dir = _get_uploads_dir() uploads_dir.mkdir(exist_ok=True, parents=True) timestamp = int(Path(path).stat().st_mtime) dest_name = f"video_{timestamp}_{path.name}" dest_path = uploads_dir / dest_name if path.resolve() != dest_path.resolve(): shutil.copy(str(path), str(dest_path)) transcript = "" try: transcript = _transcribe_whisper(path) except Exception as e: print(f"Whisper transcription failed, falling back to Sphinx: {e}") try: transcript = _transcribe_sphinx(path) except Exception as e2: print(f"Sphinx also failed: {e2}") if not transcript: transcript = "[inaudible]" ocr_texts = [] try: cap = cv2.VideoCapture(str(path)) fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 frame_interval = int(fps * 5) frame_count = 0 success = True printed_ocr = PrintedOCR() while success: success, frame = cap.read() if not success: break if frame_count % frame_interval == 0: try: text = printed_ocr.recognize(frame) if text.strip() and text.strip() not in ocr_texts: ocr_texts.append(text.strip()) except Exception: pass frame_count += 1 cap.release() except Exception: pass ocr_text = "\n".join(ocr_texts) if ocr_texts else "[UNCLEAR]" explanation = _explain_video_content(ocr_text, transcript, path.name) combined_parts = [] if explanation: combined_parts.append("=========================\nVIDEO ANALYSIS & EXPLANATION\n=========================\n" + explanation) if ocr_texts: combined_parts.append("=========================\nEXTRACTED VISUAL TEXT\n=========================\n" + ocr_text) if transcript and transcript != "[inaudible]": combined_parts.append("=========================\nSPEECH TRANSCRIPT\n=========================\n" + transcript) combined_text = "\n\n".join(combined_parts) if combined_parts else "[UNCLEAR]" return combined_text, f"/static/uploads/{dest_name}" def process_document(path: Path, language: str = "English") -> tuple[str, str]: uploads_dir = _get_uploads_dir() uploads_dir.mkdir(exist_ok=True, parents=True) timestamp = int(Path(path).stat().st_mtime) dest_name = f"doc_{timestamp}_{path.name}" dest_path = uploads_dir / dest_name if path.resolve() != dest_path.resolve(): shutil.copy(str(path), str(dest_path)) ext = path.suffix.lower() text = "" if ext == ".pdf": try: import pypdfium2 as pdfium pdf = pdfium.PdfDocument(str(path)) text_list = [] for page in pdf: textpage = page.get_textpage() t = textpage.get_text_range() if t: text_list.append(t) text = "\n".join(text_list).strip() except Exception: pass if not text.strip(): try: img = load_image(path) cleaned = preprocess(img) temp_dir = Path(tempfile.gettempdir()) temp_img_path = temp_dir / f"{path.stem}_page1.jpg" cv2.imwrite(str(temp_img_path), cleaned) printed = PrintedOCR() text = printed.recognize(cleaned) if not text.strip(): text = _try_handwriting_ocr(cleaned) if temp_img_path.exists(): temp_img_path.unlink() except Exception: pass if not text.strip(): text = "[UNCLEAR]" elif ext == ".docx": try: with zipfile.ZipFile(str(path)) as docx: xml_content = docx.read("word/document.xml") root = ET.fromstring(xml_content) namespaces = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"} texts = [node.text for node in root.findall(".//w:t", namespaces) if node.text] text = " ".join(texts) except Exception: pass else: try: text = path.read_text(encoding="utf-8") except Exception: try: text = path.read_text(encoding="latin1") except Exception: pass if not text.strip(): text = "[UNCLEAR]" return text, f"/static/uploads/{dest_name}" def process_spreadsheet(path: Path, language: str = "English") -> tuple[str, str]: import csv uploads_dir = _get_uploads_dir() uploads_dir.mkdir(exist_ok=True, parents=True) timestamp = int(Path(path).stat().st_mtime) dest_name = f"sheet_{timestamp}_{path.name}" dest_path = uploads_dir / dest_name if path.resolve() != dest_path.resolve(): shutil.copy(str(path), str(dest_path)) text = "" ext = path.suffix.lower() if ext == ".csv": try: with open(path, encoding="utf-8") as f: reader = csv.reader(f) rows = list(reader) if rows: text_lines = [] for r in rows: text_lines.append(" | ".join(r)) text = "CSV Spreadsheet Data:\n" + "\n".join(text_lines) except Exception: pass elif ext in (".xlsx", ".xls"): try: import pandas as pd df = pd.read_excel(str(path), engine="openpyxl" if ext == ".xlsx" else "xlrd") text = "Spreadsheet Data:\n" + df.to_string(index=False) except Exception as e: print(f"Spreadsheet read failed: {e}") if not text.strip(): text = "[UNCLEAR]" return text, f"/static/uploads/{dest_name}" def process_batch(paths: list[Path], language: str = "English", schema_type: str = "todo") -> list[dict]: results = [] with ThreadPoolExecutor(max_workers=settings.max_workers) as executor: future_map = {} for path in paths: ext = path.suffix.lower() image_exts = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif", ".webp"} if ext in image_exts: future = executor.submit(process_image, path, language) future_map[future] = ("image", path) elif ext in {".mp3", ".wav", ".m4a", ".ogg", ".flac"}: future = executor.submit(process_audio, path, language) future_map[future] = ("audio", path) elif ext in {".mp4", ".mkv", ".mov", ".avi"}: future = executor.submit(process_video, path, language) future_map[future] = ("video", path) elif ext in {".pdf", ".docx", ".txt"}: future = executor.submit(process_document, path, language) future_map[future] = ("document", path) elif ext in {".csv", ".xlsx", ".xls"}: future = executor.submit(process_spreadsheet, path, language) future_map[future] = ("spreadsheet", path) for future in as_completed(future_map): source_type, path = future_map[future] try: if source_type == "image": raw_text, image_url, cleaned_url, barcodes, tables = future.result() results.append({ "path": str(path), "source_type": source_type, "raw_text": raw_text, "image_url": image_url, "cleaned_url": cleaned_url, "barcodes": barcodes, "tables": tables, }) else: raw_text, file_url = future.result() results.append({ "path": str(path), "source_type": source_type, "raw_text": raw_text, "file_url": file_url, }) except Exception as e: results.append({ "path": str(path), "source_type": source_type, "error": str(e), }) return results