diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/.env.example b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/.env.example new file mode 100644 index 00000000..d75d8fb2 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/.env.example @@ -0,0 +1,44 @@ +# ============================================================ +# RenAIssance — environment template. Copy to `.env` and fill in. +# `.env` is gitignored; never commit real secrets. +# ============================================================ + +# --- AI / OCR provider keys (passed per-request to providers) --- +GEMINI_API_KEY= +OPENAI_API_KEY= +DEEPSEEK_API_KEY= +QWEN_API_KEY= + +# HuggingFace token for the "Local fine-tuned (Spanish)" corrector. Its base +# model (google/gemma-3-4b-it) is GATED, so the first-use download needs a +# token whose HF account has accepted the licence at +# huggingface.co/google/gemma-3-4b-it. Only needed if you use that provider. +HF_TOKEN= + +# ============================================================ +# User tracking / auth (BACKEND-ONLY — never exposed to the browser) +# ============================================================ + +# Local auth store (per instance). Leave UNSET to use SQLite on the storage +# volume (the default; works offline). Advanced: set a postgresql:// URL to use +# your own Postgres for local accounts. +# DATABASE_URL= + +# Signs session cookies. Generate with: +# python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY= + +# Bearer token for GET /api/admin/users (this instance's local users). +# Leave blank to DISABLE the admin endpoint (returns 404). +ADMIN_TOKEN= + +# Deployed origin. Only effect: session cookies get the Secure flag on https. +PUBLIC_BASE_URL=http://localhost:5173 + +# --- Central tracking (Supabase REST + PUBLISHABLE key) --- +# Safe to be public: the central `users` table has an INSERT-only RLS policy, +# so this key can only add a signup row. Published images bake these in via the +# Dockerfile; set them here for local/dev runs. NEVER put the Postgres password +# or the service_role/secret key here. +SUPABASE_URL= +SUPABASE_PUBLISHABLE_KEY= diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/.github/workflows/docker-build.yml b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/.github/workflows/docker-build.yml new file mode 100644 index 00000000..1cd8a579 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/.github/workflows/docker-build.yml @@ -0,0 +1,315 @@ +# ============================================================ +# RenAIssance — Docker Build & Push +# Builds backend + frontend in parallel +# Versioning: vX.Y with rollover at .9 (e.g. v3.9 → v4.0) +# Tags pushed: image:vX.Y and image:latest +# ============================================================ + +name: Docker Build & Push + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write # required to create and push git tags + +env: + DOCKERHUB_USERNAME: saarthakg004 + BACKEND_IMAGE: saarthakg004/renaissance-backend + FRONTEND_IMAGE: saarthakg004/renaissance-frontend + +jobs: + # ── Compute next version (no tag push yet) ───────────────── + # The git tag is only created AFTER both images build successfully. + # This prevents a failed build from consuming a version number. + version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.semver.outputs.version }} + steps: + - name: Checkout (full history + all tags) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Compute next version with rollover + id: semver + run: | + git fetch --tags --force + + # Consider tags that match exactly v. + LATEST=$(git tag --list 'v*' \ + | grep -E '^v[0-9]+\.[0-9]+$' \ + | sort -V \ + | tail -n1) + + if [ -z "$LATEST" ]; then + # No version tags yet + NEXT="v1.0" + else + MAJOR=$(echo "$LATEST" | sed -E 's/^v([0-9]+)\.[0-9]+$/\1/') + MINOR=$(echo "$LATEST" | sed -E 's/^v[0-9]+\.([0-9]+)$/\1/') + + # Rollover after .9 + if [ "$MINOR" -ge 9 ]; then + NEXT="v$((MAJOR + 1)).0" + else + NEXT="v${MAJOR}.$((MINOR + 1))" + fi + fi + + echo "version=$NEXT" >> "$GITHUB_OUTPUT" + echo "Computed next version: $NEXT" + + # ── CPU-safe test suite (gates the image builds) ──────────── + # Runs on a GPU-less GitHub runner, so it installs the CPU torch wheel and + # the CPU paddle wheel — the exact deps the CPU image ships — then runs the + # backend pytest suite. Real-GPU paths are covered by backend/tests/smoke_gpu.py + # (run manually on a GPU host) and the container-smoke job below. + test: + runs-on: ubuntu-latest + needs: version + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install backend deps (CPU) + test tooling + run: | + python -m pip install --upgrade pip + # CPU torch FIRST so requirements.txt finds it satisfied (no CUDA wheel). + pip install --index-url https://download.pytorch.org/whl/cpu torch==2.5.1 + pip install -r backend/requirements.txt + # CPU paddle (no-deps; runtime deps already provided by requirements.txt). + pip install --no-deps \ + --index-url https://www.paddlepaddle.org.cn/packages/stable/cpu/ \ + paddlepaddle==3.3.0 + pip install pytest==8.3.4 pytest-cov==6.0.0 + + - name: Run tests + run: pytest backend/tests -q + + # ── Build backend (gpu + cpu variants from one Dockerfile) ── + build-backend: + runs-on: ubuntu-latest + needs: [version, test] + strategy: + fail-fast: false + matrix: + include: + - variant: gpu + runtime_base: ubuntu:24.04 + - variant: cpu + runtime_base: ubuntu:24.04 + # Folder ID of the public Google Drive folder containing crnn/ and trocr/. + # Anyone-with-the-link access is sufficient for gdown to traverse and + # download. If this folder is rotated or made private, replace the ID and + # either keep public-with-link access or switch to a service account. + env: + WEIGHTS_DRIVE_FOLDER_ID: 1NRbCcUhcr5wJOCarBNHRP6jlnLuzDVXC + steps: + - name: Checkout + uses: actions/checkout@v4 + + # GitHub-hosted runners ship with ~14 GB free after actions/checkout. + # The torch + paddle wheel download alone is ~3 GB, the built image is + # ~19 GB, and the weights add another ~1.4 GB. Reclaim ~30 GB up front + # by purging pre-installed toolchains that this job does not use. + - name: Reclaim runner disk + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ + /usr/local/share/boost "$AGENT_TOOLSDIRECTORY" || true + df -h / + + # Pull recognition-model weights from Google Drive into the backend + # build context so the Dockerfile's `COPY . .` bakes them in at + # /app/models/weights/. Weights are gitignored (kept out of the repo + # because of GitHub's 100 MB file-size limit), so Drive is the source + # of truth — update the folder there and rebuild to ship new weights. + - name: Fetch recognition-model weights from Google Drive + run: | + # gdown 6.x dropped --remaining-ok; its folder downloader is also + # capped at 50 files per folder (ours has 2 subfolders, so fine). + python3 -m pip install --quiet --upgrade 'gdown>=5.2.0' + mkdir -p backend/models/weights + # Retry twice on transient 429/5xx — Drive occasionally throttles + # anonymous public downloads. + for attempt in 1 2 3; do + if gdown --folder \ + "https://drive.google.com/drive/folders/${WEIGHTS_DRIVE_FOLDER_ID}" \ + -O backend/models/weights; then + break + fi + echo "gdown attempt $attempt failed — retrying in 10s…" + sleep 10 + done + echo "Fetched weights:" + find backend/models/weights -maxdepth 3 -type f -printf ' %p %s bytes\n' | head -20 + # Sanity: at least one .safetensors or .pth must exist, otherwise + # we would silently ship an image with an empty weights dir. + if ! find backend/models/weights \ + \( -name '*.safetensors' -o -name '*.pth' -o -name '*.pt' \) \ + | grep -q .; then + echo "::error::No model weights found under backend/models/weights after gdown" + exit 1 + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Tags per variant: always :- and :latest-. + # The gpu leg additionally publishes the bare : / :latest as a + # backward-compatible alias (the default variant is gpu). + - name: Compute backend tags + id: tags + run: | + V="${{ needs.version.outputs.version }}" + VAR="${{ matrix.variant }}" + TAGS="${BACKEND_IMAGE}:${V}-${VAR} + ${BACKEND_IMAGE}:latest-${VAR}" + if [ "$VAR" = "gpu" ]; then + TAGS="${TAGS} + ${BACKEND_IMAGE}:${V} + ${BACKEND_IMAGE}:latest" + fi + { echo "tags<> "$GITHUB_OUTPUT" + + - name: Build & push backend (${{ matrix.variant }}) + uses: docker/build-push-action@v6 + with: + context: ./backend + file: ./backend/Dockerfile + push: true + build-args: | + VARIANT=${{ matrix.variant }} + RUNTIME_BASE=${{ matrix.runtime_base }} + tags: ${{ steps.tags.outputs.tags }} + # GitHub Actions cache for BuildKit layers (scoped per variant). + cache-from: type=gha,scope=backend-${{ matrix.variant }} + cache-to: type=gha,scope=backend-${{ matrix.variant }},mode=max + # BuildKit provenance for supply chain security + provenance: false + + # ── Build frontend ────────────────────────────────────────── + build-frontend: + runs-on: ubuntu-latest + needs: [version, test] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build & push frontend + uses: docker/build-push-action@v6 + with: + context: ./frontend + file: ./frontend/Dockerfile + push: true + tags: | + ${{ env.FRONTEND_IMAGE }}:${{ needs.version.outputs.version }} + ${{ env.FRONTEND_IMAGE }}:latest + cache-from: type=gha,scope=frontend + cache-to: type=gha,scope=frontend,mode=max + provenance: false + + # ── Container boot smoke (CPU image) ──────────────────────── + # GitHub runners have no GPU, so we boot the freshly-published CPU image and + # assert /api/health returns 200. This proves the image actually starts end + # to end (entrypoint preflight + uvicorn) on a GPU-less host. + container-smoke: + runs-on: ubuntu-latest + needs: [version, build-backend] + steps: + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Boot CPU image and probe health + run: | + IMG="${BACKEND_IMAGE}:${{ needs.version.outputs.version }}-cpu" + docker run -d --name rb -p 8000:8000 "$IMG" + ok=0 + for i in $(seq 1 36); do + if curl -fsS http://localhost:8000/api/health >/dev/null; then + echo "Healthy after ~$((i*5))s"; ok=1; break + fi + sleep 5 + done + echo "----- container logs (tail) -----" + docker logs rb 2>&1 | tail -60 || true + docker rm -f rb >/dev/null 2>&1 || true + [ "$ok" = "1" ] || { echo "::error::CPU image did not become healthy"; exit 1; } + + # ── Tag + Summary (only runs when builds + smoke succeed) ─── + # Creating the git tag here ensures a failed build never wastes + # a version number — re-running the workflow will recompute the + # same version and retry the tag push. + summary: + runs-on: ubuntu-latest + needs: [version, test, build-backend, build-frontend, container-smoke] + if: always() + steps: + - name: Checkout (for tag push) + if: needs.build-backend.result == 'success' && needs.build-frontend.result == 'success' && needs.container-smoke.result == 'success' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create and push version tag + if: needs.build-backend.result == 'success' && needs.build-frontend.result == 'success' && needs.container-smoke.result == 'success' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git fetch --tags --force + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Authenticate remote with token + git remote set-url origin \ + "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}" + + # Only push the tag if it does not already exist + if git rev-parse "${{ needs.version.outputs.version }}" >/dev/null 2>&1; then + echo "Tag ${{ needs.version.outputs.version }} already exists — skipping." + else + git tag "${{ needs.version.outputs.version }}" + git push origin "${{ needs.version.outputs.version }}" + echo "Pushed tag ${{ needs.version.outputs.version }}" + fi + + - name: Build summary + run: | + echo "## Docker Build Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Version: \`${{ needs.version.outputs.version }}\` (backend tags: \`-gpu\` and \`-cpu\`)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY + echo "| tests | ${{ needs.test.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| build-backend (gpu+cpu) | ${{ needs.build-backend.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| build-frontend | ${{ needs.build-frontend.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| container-smoke (cpu) | ${{ needs.container-smoke.result }} |" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/.gitignore b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/.gitignore new file mode 100644 index 00000000..35730b31 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/.gitignore @@ -0,0 +1,34 @@ +frontend/node_modules +frontend/dist +.env +__pycache__ +*.pyc +improvements.txt +RenAIssanceExperimental +# Runtime data dir at the repo root ONLY (datasets/transcripts/users.db). +# Anchored with a leading slash so it does NOT also match the source package +# backend/app/storage/, which must be committed and shipped in the image. +/storage/ +# Large model weights — tracked out-of-band, not in git. CI fetches them from +# Google Drive via gdown at image-build time (see .github/workflows). +backend/models/weights/ +# Belt-and-suspenders: never commit a weight file from anywhere in the tree, +# whatever folder it lands in. GitHub rejects files over 100 MB anyway. +*.safetensors +*.pt +*.pth +*.pdparams +*.onnx +*.ckpt +*.h5 +*.tflite +.claude + +# Local-only authoring artifacts (blog drafts, diagrams) — not app code. +/assets/ +/blog_*.md + +# Test / native-run artifacts +.pytest_cache/ +backend/.pytest-storage/ +backend/.venv-native/ \ No newline at end of file diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/PRESENTATION.md b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/PRESENTATION.md new file mode 100644 index 00000000..12238728 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/PRESENTATION.md @@ -0,0 +1,324 @@ +# RenAIssance — Presentation Content & Technical Summary + +An end-to-end OCR system for historical (early-modern Spanish) documents. +This document contains a one-page technical summary followed by detailed, +slide-by-slide content for a 7-slide deck. Every metric is drawn from the +project's own training and evaluation notebooks; sources are noted so you can +cite them. + +--- + +## Technical Summary (one page) + +RenAIssance is a full-stack web application that turns scanned historical books +into clean, editable, exportable text. It was built and evaluated on early +modern Spanish printed books, whose broken glyphs, bleed-through, warped pages +and archaic typography defeat off-the-shelf OCR. + +The product is a five-step wizard: **Upload → Select pages → Preprocess → +Text detection → OCR & export.** Under the hood it combines four ideas: + +1. **A configurable image-cleanup pipeline** — ten OpenCV operations with live + before/after preview, including a new piecewise deskew that corrects pages + whose top is straight but whose bottom is warped, plus one-click "book type" + presets of pretested pipelines. +2. **Layout-aware text detection** built on PaddleOCR PP-OCRv5, with automatic + model-tier selection that reads free VRAM/RAM at runtime and picks server + models, mobile models, or CPU accordingly. +3. **A choice of recognition engines** behind one interface — cloud vision + models (Gemini, ChatGPT) and two locally fine-tuned models (a CNN-LSTM/CRNN + and a TrOCR transformer), swappable via a Strategy + Factory pattern. +4. **An optional AI correction layer** — a `gemma-3-4b-it` model fine-tuned with + QLoRA into a Spanish OCR corrector that fixes the residual character and word + errors the recognizer leaves behind. + +**Headline results.** The fine-tuned CRNN reaches roughly **2% character error +and 9% word error** at the line level on held-out books. The fine-tuned TrOCR +reaches **6.1% CER / 21.7% WER**, and post-training quantization to fp16 halves +its memory and triples its speed with no accuracy loss. The Gemma corrector cuts +raw OCR word error by nearly half (**WER 20.9% → 11.5%**, **CER 4.65% → 3.2%**). + +**Engineering.** The whole system ships as Docker images with a one-command +launcher that auto-detects the GPU and starts the right variant. A single +Dockerfile builds both GPU and CPU images; a CI pipeline runs tests, builds both +variants, smoke-tests the running container, and publishes on every push. Model +weights are fetched from Google Drive at build time so the git repository stays +small. + +**Stack.** React 18 + Vite + Tailwind (frontend); FastAPI + Python 3.11 +(backend); PaddleOCR 3.0, PyTorch 2.5, Transformers 4.46, OpenCV 4.10 (ML/CV); +Docker + GitHub Actions (delivery). + +--- + +# Slide Deck + +--- + +## Slide 1 — The Problem and the Product + +**Title:** RenAIssance: Reading What Machines Cannot + +**The problem.** +- Historical books hold centuries of knowledge that is effectively unsearchable. +- Early-modern Spanish print breaks every assumption modern OCR makes: worn and + broken glyphs, ink bleed-through, non-standard spelling and abbreviations, + warped and skewed pages, decorative typography, and inconsistent scans. +- General-purpose OCR (Tesseract, stock cloud OCR) produces error rates too high + to be useful for scholars. + +**The product.** +- A full-stack web app that takes a PDF or image and produces clean, editable, + exportable text. +- Designed for domain experts, not engineers: everything happens in a guided + five-step wizard in the browser. +- **Upload → Select pages → Preprocess → Text detection → OCR & export.** +- Output formats: TXT, DOCX, PDF. + +**Talking point.** The project is not a single model. It is a *pipeline* where +each stage removes a different class of error, and where the user stays in +control at every step. + +> [IMAGE: The five-step wizard, one screenshot per step, left to right.] + +--- + +## Slide 2 — System Architecture + +**Title:** One Interface, Many Engines + +**Frontend (React 18 + Vite + Tailwind).** +- A single-page wizard that manages global state and routes between the five + steps. +- PDF.js renders and slices uploaded PDFs in the browser. +- Live before/after preview during preprocessing. + +**Backend (FastAPI + Python 3.11).** +- Ten routers: health, OCR, preprocess, export, layout detection, dataset, + recognition, LLM post-process, storage, auth. +- **Strategy + Factory pattern** for OCR engines: one `BaseOCRProvider` + interface, an `OCRFactory` registry, and independent implementations behind + it. Adding an engine means implementing the interface and registering it, + nothing else changes. +- API keys are passed per request from the browser and never stored on disk. + +**The provider factory (key design idea).** +- The application talks to *one* interface. +- Behind it sit Gemini, ChatGPT, the local CRNN, and the local TrOCR. +- DeepSeek and Qwen are implemented and registered but hidden in the UI, ready + to enable. + +> [IMAGE: provider-factory.svg — Application → OCRFactory → {Gemini, ChatGPT, +> Local CRNN, Local TrOCR}, with DeepSeek/Qwen greyed behind.] + +**Talking point.** This is what makes the system future-proof: new OCR or +correction engines plug in without touching the wizard, the API, or the other +engines. + +--- + +## Slide 3 — Stage 1: Preprocessing + +**Title:** Cleaning the Page Before the Machine Reads It + +**A configurable OpenCV pipeline.** +- Ten operations, each toggleable and tunable, run in sequence with a live + before/after preview: normalize, grayscale, deskew, denoise, contrast, + sharpen, threshold, morphology, remove blobs, remove noise (speckles). + +**Highlight 1 — Piecewise deskew.** +- Standard deskew rotates the whole page by one angle. Real books warp: the top + of a page can be straight while the bottom is skewed. +- The new deskew measures skew in horizontal **bands** using projection + profiles. If the bands agree (spread below 1°) it rotates once; if they + disagree it blends per-band rotations seam-free, using triangular weights that + form a partition of unity so there are no visible discontinuities. +- Modes: auto / global / piecewise, with a bands control. + +**Highlight 2 — Book-type presets.** +- A "Recommendation" dropdown offers pretested pipelines per book type. +- Selecting a type *loads* the pipeline into the editor without running it, so + the user can inspect and tune it before applying. +- First shipped preset: **PORCONES → Remove Ink Speckles (20px).** + +**Talking point.** Preprocessing is where most OCR wins or loses. Giving experts +a tuned starting point plus live feedback is worth more than any single clever +filter. + +> [IMAGE: The three-panel preprocess editor with a warped page corrected by +> piecewise deskew, before on the left, after on the right.] + +--- + +## Slide 4 — Stage 2: Text Detection + +**Title:** Finding the Text, Adapting to the Machine + +**Layout-aware detection on PaddleOCR PP-OCRv5.** +- Detects text regions per page before recognition, so recognition runs on clean + line/word crops rather than the whole page. +- The detector was fine-tuned on hand-labelled page bounding boxes from the + target books (PP-OCRv5 server detector), improving region proposals on + dense historical layouts. + +**Adaptive model-tier selection (engineering highlight).** +- At runtime the system reads free VRAM/RAM and picks a tier automatically: + - Enough free VRAM → **server** models on GPU (best accuracy). + - Less → **mobile** models on GPU (smaller footprint). + - No usable GPU → **CPU**, still fully functional. +- The same code runs on a workstation with an RTX GPU and on a Mac laptop with + no GPU at all. + +**Talking point.** The system does not assume a fixed machine. It measures the +hardware it wakes up on and scales the models to fit, which is what lets one +Docker image serve both a lab GPU box and a reviewer's laptop. + +> [IMAGE: A detected page with bounding boxes overlaid on each text line.] + +--- + +## Slide 5 — Stage 3: Text Recognition and Results + +**Title:** Two Local Models, Measured Honestly + +**Two locally fine-tuned recognizers (plus cloud options).** +- **CRNN (CNN-LSTM):** a lightweight ResNet CNN backbone → BiLSTM → CTC decoder, + fine-tuned on a Spanish line dataset. Fast, small, strong on clean printed + lines. +- **TrOCR:** a vision-encoder / text-decoder transformer fine-tuned from + `microsoft/trocr-base-printed`. Heavier but more robust on degraded text. +- Cloud vision models (Gemini, ChatGPT) available through the same interface. + +**Line-level accuracy on held-out books** (document-level 10% split, same books +held out across all experiments): + +| Model | CER | WER | +|---|---|---| +| CRNN (fine-tuned CNN-LSTM) | ~2.0% | ~9.0% | +| TrOCR (fine-tuned, fp32) | 6.1% | 21.7% | + +*Source: training/validation logs, notebooks 01 and 03; TrOCR fp32 from the +quantization study, notebook 04.* + +**TrOCR quantization study** (post-training, notebook 04) — accuracy vs memory +vs latency: + +| Precision | CER | WER | Size | vs fp32 size | ms/line | +|---|---|---|---|---|---| +| fp32 | 6.11% | 21.65% | 1274 MB | 100% | 197 | +| **fp16** | **6.08%** | **21.59%** | **637 MB** | **50%** | **63** | +| int8 | 6.29% | 21.62% | 471 MB | 37% | 246 | +| 4-bit NF4 | 7.46% | 24.62% | 337 MB | 26% | 127 | +| 4-bit FP4 | 9.25% | 29.64% | 337 MB | 26% | 126 | + +**Takeaway.** fp16 is a free win: half the memory, roughly 3× faster, and CER +actually a hair *better* than fp32. 4-bit shrinks the model to a quarter but +costs real accuracy, so it is only worth it under hard memory limits. + +> [IMAGE: Bar chart of the quantization table — CER and size per precision.] + +--- + +## Slide 6 — Stage 4: AI Post-Processing + +**Title:** Teaching a Language Model to Fix OCR + +**The idea.** +- Even the best recognizer leaves residual errors: a confused character, a split + or merged word, a wrong diacritic. +- A language model that knows Spanish and knows *how OCR fails* can correct these + from context. + +**What was built.** +- `gemma-3-4b-it` fine-tuned with **QLoRA** (4-bit NF4 base, LoRA rank 16) into a + line-by-line Spanish OCR corrector. +- Trained on OCR-vs-ground-truth pairs generated from **two** engines (the + pretrained Paddle recognizer and the fine-tuned CRNN), so it learns to fix real + model mistakes rather than synthetic noise. +- Ships as a small LoRA adapter on top of the base model; runs locally on an + NVIDIA GPU. + +**Results — the corrector nearly halves word error:** + +| Stage | CER | WER | +|---|---|---| +| Raw OCR | 4.65% | 20.9% | +| **+ Fine-tuned Gemma corrector** | **3.2%** | **11.5%** | + +- Relative reduction: **CER −31%, WER −45%.** +- **Critical finding:** the *off-the-shelf* (zero-shot) model made results + *worse* — it rewrote text it did not understand. Fine-tuning on real OCR error + pairs is what turned it into a corrector instead of a paraphraser. + +**Also available as cloud correctors:** Gemini, OpenAI, DeepSeek, Qwen, through +the same post-processing interface. + +> [IMAGE: Before/after text sample — raw OCR line above, corrected line below, +> with the fixes highlighted.] + +**Talking point.** This is the difference between "a big model" and "the right +model." A general LLM hurt accuracy; the same model, fine-tuned on the actual +failure modes, delivered the single largest error reduction in the pipeline. + +--- + +## Slide 7 — Delivery, Impact, and Takeaways + +**Title:** From Research Notebooks to a Product Anyone Can Run + +**Packaged for real use.** +- One-command launcher (`./run.sh` / `run.ps1`) auto-detects the GPU and starts + the correct image variant. +- A single Dockerfile builds **both** GPU and CPU images; the app picks + CUDA / Apple MPS / CPU at runtime, so it runs on a lab GPU box, a Windows PC, + or a Mac laptop unchanged. +- CI on every push: run tests → build GPU + CPU images → smoke-test the live + container's health endpoint → publish images and tag only on full success. +- Model weights are fetched from Google Drive at build time, keeping the git + repo small. + +**What the numbers add up to.** +- Preprocessing removes skew and noise before the model ever sees the page. +- A fine-tuned detector + adaptive PP-OCRv5 finds the text on any hardware. +- The fine-tuned CRNN reaches ~2% CER on clean lines; TrOCR is the robust + fallback, and fp16 makes it cheap to run. +- The Gemma corrector cuts word error nearly in half on top of that. +- Each stage attacks a different error source, and they compound. + +**Engineering takeaways.** +- Design for pluggability (Factory pattern) so new engines cost nothing to add. +- Measure the hardware at runtime instead of assuming it. +- Bigger models are not automatically better — fine-tuning on the real failure + distribution is what wins. +- Reproducibility and one-command deployment are features, not afterthoughts. + +**Closing line.** RenAIssance turns pages that machines could not read into +searchable, editable text, and it does so as a product that a historian can run +on a laptop, not a script that only works in the author's notebook. + +> [IMAGE: The final OCR & Export screen with a finished transcription and the +> TXT / DOCX / PDF export buttons.] + +--- + +## Appendix — Metric Provenance (for Q&A) + +- **CRNN line-level (~2.0% CER / ~9.0% WER):** best validation checkpoint, + notebook 01 pipeline run and notebook 03 recognition fine-tuning (fine-tunes + `latin_PP-OCRv5_mobile_rec`). Training CER reached ~1.3%. +- **TrOCR (6.1% CER / 21.7% WER):** fp32 baseline in the quantization study, + notebook 04; consistent with notebook 01 best validation CER of ~7%. +- **Quantization table:** notebook 04 stored outputs, verbatim (fp16 / int8 / + 4-bit FP4 / 4-bit NF4 vs fp32, on the same validation lines). +- **Gemma corrector (CER 4.65% → 3.2%, WER 20.9% → 11.5%):** post-processing + fine-tuning experiment, notebook 05 (QLoRA `gemma-3-4b-it`), evaluated raw vs + zero-shot vs fine-tuned per engine. +- **Evaluation protocol:** document-level `GroupShuffleSplit(test_size=0.10, + random_state=42)` so the same books are held out across every experiment; CER + and WER computed by `src/evals/metrics.py`. + +*Note: the CRNN and TrOCR error rates are line-level on held-out lines. A single +end-to-end full-page number is much higher for any engine because it compounds +detection, reading-order, and recognition errors across a whole page; quote the +line-level figures for model quality and describe the full pipeline +qualitatively.* diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/README.md b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/README.md new file mode 100644 index 00000000..f129f0a9 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/README.md @@ -0,0 +1,200 @@ +# RenAIssance + +RenAIssance is a full-stack web app for reading historical documents. You upload a PDF or image, clean up the scan, detect the text regions, and transcribe the text with your choice of engine. It was built for early modern Spanish books, but the pipeline works on any printed page. + +The workflow is a five step wizard: Upload, Select pages, Preprocess, Text detection, and OCR & export. + +## Features + +- **OCR engines:** Gemini, ChatGPT, a local CRNN, and a local TrOCR (fine-tuned from `microsoft/trocr-base-printed`). +- **Optional AI cleanup:** correct raw OCR with Gemini, OpenAI, DeepSeek, Qwen, or a Spanish fine-tuned corrector (a `gemma-3-4b-it` LoRA adapter). The fine-tuned corrector needs an NVIDIA GPU. +- **Export:** download the transcription as TXT, DOCX, or PDF. + +## What you need + +- Docker version 24 or newer, with Compose v2. +- About 15 GB of free disk for the images and model weights. +- A Gemini or OpenAI API key if you want to use those engines. You enter the key in the app, not in a file. +- An NVIDIA GPU with driver 560 or newer and the NVIDIA Container Toolkit if you want GPU acceleration. This is optional. Without a GPU the app runs on the CPU image, which works everywhere and is only slower. + +## Run it + +Clone the repository and run the launcher for your system. It detects your GPU, checks your machine before downloading anything, and starts the correct image automatically. + +### Linux and Windows (WSL) + +```bash +git clone https://github.com//RenAIssance.git +cd RenAIssance +./run.sh +``` + +Then open http://localhost:5173 in your browser. The first run downloads the images and can take several minutes. Some models download the first time you use them, and the app shows a progress bar while that happens. + +Useful flags: + +```bash +./run.sh --cpu # force the CPU image +./run.sh --build # build from source instead of pulling published images +./run.sh --down # stop everything (your saved data is kept) +``` + +### Windows (PowerShell) + +```powershell +.\run.ps1 # auto-detect GPU or CPU +.\run.ps1 -Cpu # force the CPU image +.\run.ps1 -Down # stop +``` + +## Running on a Mac + +Docker on macOS cannot reach the GPU, so on a Mac the app always runs on the **CPU image**. This is expected and everything works, it is just slower than on an NVIDIA machine. The local Spanish fine-tuned corrector is the only feature that requires a GPU, so on a Mac use Gemini or OpenAI for the AI cleanup step instead. + +Follow these steps. + +1. Install Docker Desktop for Mac from https://www.docker.com/products/docker-desktop and start it. Wait until the whale icon in the menu bar stops animating, which means Docker is ready. +2. Install Git if you do not have it. The simplest way is to run `git --version` in the Terminal, which offers to install the developer tools if Git is missing. +3. Clone the repository and enter the folder: + + ```bash + git clone https://github.com//RenAIssance.git + cd RenAIssance + ``` + +4. Start the app. The launcher sees there is no NVIDIA GPU and picks the CPU image for you: + + ```bash + ./run.sh + ``` + + If you prefer to be explicit, run `./run.sh --cpu`. + +5. Wait for the download to finish. The first run pulls a few gigabytes, so give it a few minutes on a normal connection. +6. Open http://localhost:5173 in your browser. The app is ready. +7. To stop the app later, run `./run.sh --down`. Your transcripts and datasets are saved and will still be there next time. + +### Optional: use the Apple GPU (Apple Silicon) + +Docker cannot use the Apple GPU, but you can run the app directly on your Mac to get GPU acceleration for the local TrOCR and CRNN engines through Apple's Metal (MPS). Text detection still runs on the CPU because PaddleOCR has no Metal backend. + +You need Homebrew, Python 3.11, and Node.js: + +```bash +brew install python@3.11 node +./run-native.sh +``` + +The script creates a local Python environment, installs everything, and starts the app on http://localhost:5173. The first run is slow because it installs the dependencies. + +## Using the app + +1. **Upload** a PDF or an image (PNG, JPG, TIFF, or BMP). +2. **Select** the pages you want from the thumbnail grid. +3. **Preprocess** the pages. Toggle and tune the cleanup operations and watch the before and after preview. You can also load a pre-tested pipeline for a known book type. +4. **Detect** the text regions on each page. +5. **Read and export.** Pick an engine, transcribe, edit anything that needs fixing, optionally run the AI cleanup, and download the result. + +The app opens on a simple sign up screen that asks for a name, an email, and an institution. It exists only for lightweight usage tracking and never asks for an API key. You enter provider keys later, in the reading step, and only if you use a cloud engine. + +## Compose files + +The launcher just selects the right Compose file. You can run them directly if you prefer. + +| Host | File | +|------|------| +| NVIDIA GPU, published images | `docker-compose.images.yml` | +| No GPU or Mac, published images | `docker-compose.images.cpu.yml` | +| NVIDIA GPU, build from source | `docker-compose.yml` | +| No GPU or Mac, build from source | `docker-compose.cpu.yml` | + +```bash +docker compose -f docker-compose.images.cpu.yml up -d # run (Mac / no GPU) +docker compose -f docker-compose.images.cpu.yml down # stop, data is kept +``` + +## Manual Docker commands + +If you would rather not use the launcher or Compose, you can pull and run the published images by hand. The commands below use the GPU backend. On a Mac or any machine without an NVIDIA GPU, remove the `--gpus all` line from the backend command and everything still works on the CPU. + +### Install (run once) + +Pull the images and create the network and the volumes that hold your data. The volumes persist forever, so you only create them once. + +```bash +docker pull saarthakg004/renaissance-backend:latest +docker pull saarthakg004/renaissance-frontend:latest + +docker network create renaissance 2>/dev/null || true +docker volume create paddle_models 2>/dev/null || true +docker volume create renaissance_storage 2>/dev/null || true +``` + +### Run + +Start the backend, then the frontend. The `--network-alias backend` on the backend is required, because the frontend proxies API calls to `http://backend:8000` and must be able to find it by that name. + +```bash +docker run -d --name renaissance-backend \ + --gpus all \ + --network renaissance --network-alias backend \ + -p 8000:8000 \ + -v paddle_models:/paddle_models \ + -v renaissance_storage:/app/storage \ + --restart unless-stopped \ + saarthakg004/renaissance-backend:latest + +docker run -d --name renaissance-frontend \ + --network renaissance \ + -p 5173:8080 \ + --restart unless-stopped \ + saarthakg004/renaissance-frontend:latest +``` + +Wait about 30 seconds for the backend to start, then open http://localhost:5173. + +### Stop and delete the containers (your data is kept) + +This removes only the containers. The volumes, and therefore your datasets and transcripts, stay on disk. Run the two `docker run` commands above again to start fresh containers with the same data. + +```bash +docker rm -f renaissance-backend renaissance-frontend +``` + +### Delete everything, including your data + +This is the full cleanup. It removes the containers, the network, and the volumes. Everything you saved is deleted. + +```bash +docker rm -f renaissance-backend renaissance-frontend +docker network rm renaissance +docker volume rm paddle_models renaissance_storage +``` + +## Development and tests + +```bash +# Backend tests (CPU-safe, no GPU needed). CI runs these on every push. +cd backend +pip install -r requirements-dev.txt +pytest tests -q + +# Frontend dev server +cd frontend +npm install +npm run dev +``` + +Model weights are not stored in Git. Continuous integration downloads them from Google Drive when it builds the images, so a normal clone stays small. + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| The launcher says your machine does not meet the GPU requirements | Install or repair the NVIDIA driver (560 or newer) and the Container Toolkit, or run `./run.sh --cpu` to use the CPU image. | +| The GPU image logs that it started without a usable CUDA device | The container was launched without GPU access. Use the launcher, or run the CPU image. The server still starts, only PaddleOCR detection is unavailable. | +| The local Spanish corrector fails on a Mac | It needs an NVIDIA GPU. Use Gemini or OpenAI for the cleanup step instead. | +| Port 8000 or 5173 is already in use | Stop the other program using it, or remap the port in the Compose file. | +| The frontend says it cannot reach the backend | The backend is still starting. The first health check can take about 30 seconds. | + +View logs with `docker logs -f renaissance-backend`. diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/.dockerignore b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/.dockerignore new file mode 100644 index 00000000..432cbf28 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/.dockerignore @@ -0,0 +1,24 @@ +__pycache__ +*.pyc +*.pyo +.env +.git +.gitignore +.venv +venv +*.md +Dockerfile +.dockerignore + +# Test + dev scaffolding that never runs inside the container. +tests/ +*.log +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# NOTE: models/weights/ is intentionally NOT excluded. The recognition code +# (app/api/recognition.py) resolves models relative to the repo root, so the +# weights must be present inside the image for CRNN / TrOCR to load. If we +# later decide to download weights lazily at runtime (same pattern as PaddleX), +# we can exclude models/weights/ and drop ~1.4 GB from the image. diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/Dockerfile b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/Dockerfile new file mode 100644 index 00000000..daf7e5b5 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/Dockerfile @@ -0,0 +1,300 @@ +# ============================================ +# RenAIssance OCR Backend +# Multi-stage build — one Dockerfile, two variants (GPU / CPU) +# +# Build the GPU image (default): +# docker build -t renaissance-backend:gpu ./backend +# Build the CPU image (Mac / no-NVIDIA hosts): +# docker build --build-arg VARIANT=cpu \ +# --build-arg RUNTIME_BASE=ubuntu:24.04 \ +# -t renaissance-backend:cpu ./backend +# +# The two variants differ ONLY in the torch + paddle wheels and the runtime +# base image; all application code and other pins are identical. The app +# already selects cuda/mps/cpu at runtime, so the same code runs on either. +# +# Stage 1 — ubuntu:24.04 (+Python 3.11 deadsnakes) +# Installs all Python deps into /opt/venv (variant-specific wheels) +# +# Stage 2 — ${RUNTIME_BASE} (+Python 3.11 deadsnakes) +# ubuntu:24.04 for BOTH variants. The GPU variant does NOT need +# the multi-GB nvidia/cuda runtime base: torch ships its CUDA +# user-space libs as nvidia-*-cu12 pip wheels and paddle RPATHs +# into the same site-packages/nvidia directory, while the host +# driver (libcuda.so.1) is injected at `docker run --gpus all` +# time by the NVIDIA Container Toolkit. Dropping the CUDA base +# saves ~3.5 GB of system CUDA libraries nothing ever loads. +# +# WHY the same base OS for both stages: +# Native extensions (.so wheels) are linked against the host distro's +# glibc, OpenSSL and libstdc++. Copying a venv from Debian 12 into +# Ubuntu 24.04 (or vice-versa) causes "GLIBC_2.38 not found" / +# "OPENSSL_3.3.0 not found" errors. Both stages are Ubuntu 24.04, +# so they share the builder's ABI. +# +# GPU variant requirements on the host: +# - NVIDIA driver >= 560 (supports CUDA 12.6) +# - NVIDIA Container Toolkit installed +# - Run: docker run --gpus all -p 8000:8000 +# ============================================ + +# Global build args (consumed in the stages below; re-declared per stage). +# RUNTIME_BASE stays overridable but ubuntu:24.04 is correct for both +# variants — see the Stage 2 note above. +ARG VARIANT=gpu +ARG RUNTIME_BASE=ubuntu:24.04 + +############################ +# Stage 1 — dependency builder +############################ +FROM ubuntu:24.04 AS builder + +ARG VARIANT + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +# Install Python 3.11 from deadsnakes PPA + build tools. +# Ubuntu 24.04 ships Python 3.12 by default; using 3.11 here to match +# paddlepaddle-gpu ABI and keep numpy/other pinned versions stable. +RUN apt-get update && apt-get install -y --no-install-recommends \ + software-properties-common \ + ca-certificates \ + && add-apt-repository -y ppa:deadsnakes/ppa \ + && apt-get install -y --no-install-recommends \ + python3.11 \ + python3.11-dev \ + python3.11-venv \ + python3-pip \ + build-essential \ + libgl1 \ + libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +# Create a Python 3.11 virtual environment — all packages go here. +RUN python3.11 -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +WORKDIR /build +COPY requirements.txt . + +# Install Python dependencies in two steps to sidestep a hard conflict +# between torch and paddle over their nvidia-*-cu12 pins (torch pins one set +# of CUDA user-space libs, paddle pins another; pip's resolver cannot satisfy +# both simultaneously). Both wheels bundle their own CUDA libs internally, so +# the duplicate site-packages nvidia-* entries are not needed at runtime — +# installing paddle with --no-deps avoids the conflict. +# +# Variant split: +# gpu: torch 2.5.1 CUDA build (PyPI default) + paddlepaddle-gpu 3.3.0 (cu126) +# cpu: torch 2.5.1 CPU build (pytorch cpu index) + paddlepaddle 3.3.0 (cpu). +# torch CPU is installed FIRST so `-r requirements.txt` finds it already +# satisfied and does not pull the multi-GB CUDA wheel. +# +# BuildKit cache mount keeps ~/.cache/pip warm across builds so rebuilds +# after a requirements.txt edit don't re-download multi-GB PyTorch/Paddle +# wheels. Enable with: DOCKER_BUILDKIT=1 docker build ... (default in +# modern Docker). +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --upgrade pip && \ + if [ "$VARIANT" = "cpu" ]; then \ + pip install --index-url https://download.pytorch.org/whl/cpu torch==2.5.1 && \ + pip install -r requirements.txt && \ + pip install --no-deps \ + --index-url https://www.paddlepaddle.org.cn/packages/stable/cpu/ \ + paddlepaddle==3.3.0 ; \ + else \ + pip install -r requirements.txt && \ + pip install --no-deps \ + --extra-index-url https://www.paddlepaddle.org.cn/packages/stable/cu126/ \ + paddlepaddle-gpu==3.3.0 ; \ + fi + +# NOTE: no font pre-download step. paddlex 3.0 downloaded fonts into its own +# root-owned install dir (failing at runtime for non-root appuser), which is +# why older builds baked them in. paddlex 3.4 changed this: fonts are lazy and +# cached under PADDLE_PDX_CACHE_HOME (=/paddle_models, a writable named volume), +# so they download on first use to a writable location — same pattern as models. + +# Slim the venv before copying it into the runtime stage. +# +# 1. Deduplicate OpenCV: requirements pin opencv-python-headless but +# paddleocr → paddlex ALSO pulls opencv-contrib-python, so the image +# ends up with two full OpenCV builds sharing one cv2/ directory. +# paddlex verifies its [ocr] extra by installed-package METADATA, so +# contrib must be the one that stays (removing it breaks PPStructureV3 +# creation with a "requires additional dependencies" error even though +# the cv2 module itself would import fine). Force-reinstall contrib +# LAST so the shared cv2/ directory is complete. Saves ~170 MB. +RUN pip uninstall -y opencv-python-headless opencv-python 2>/dev/null; \ + pip install --no-deps --force-reinstall --no-cache-dir opencv-contrib-python==4.10.0.84 +# +# 1b. paddleocr hard-depends on the LangChain/OpenAI client stack for its +# PP-ChatOCR document-chat pipelines, which this app never uses (we +# only run PPStructureV3 + PaddleOCR detection). The paddlex [ocr] +# dependency check passes without them — verified by running layout +# detection after removal. Saves ~150 MB and a large dep surface. +RUN pip uninstall -y \ + langchain langchain-community langchain-core langchain-openai \ + langchain-text-splitters langsmith openai tiktoken \ + dataclasses-json marshmallow typing-inspect jsonpatch \ + requests-toolbelt 2>/dev/null || true +# +# 2. Inference-only cuts (GPU variant; the CPU wheels never install these): +# - triton: JIT compiler backend for torch.compile — this app only runs +# eager inference, torch imports it lazily and works without it. +# (NCCL must stay: libtorch_cuda.so declares DT_NEEDED libnccl.so.2, +# so `import torch` fails without it even for single-GPU inference.) +# - C/C++ headers (torch/include, nvidia/**/include, triton headers are +# gone with triton): only needed to compile extensions, never shipped. +# - *.a static libs: link-time only. +# 3. Strip all __pycache__ dirs and *.pyc/*.pyo files. Python regenerates +# them lazily on first import — the builder-time bytecode for torch + +# paddle + transformers alone is several hundred MB of dead weight. +# We intentionally keep pip and setuptools installed; paddlex +# introspects pkg_resources at runtime. +RUN SP=/opt/venv/lib/python3.11/site-packages && \ + rm -rf "$SP/triton" "$SP"/triton-*.dist-info \ + "$SP/torch/include" "$SP/torch/test" && \ + find "$SP/nvidia" -maxdepth 2 -type d -name include -exec rm -rf {} + 2>/dev/null; \ + find /opt/venv -type f \( -name '*.a' -o -name '*.pyc' -o -name '*.pyo' \) -delete && \ + find /opt/venv -type d -name '__pycache__' -prune -exec rm -rf {} + + + +############################ +# Stage 2 — runtime (CUDA for gpu, plain Ubuntu for cpu) +############################ +FROM ${RUNTIME_BASE} + +ARG VARIANT + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app \ + HOME=/home/appuser \ + # Surfaced to the entrypoint preflight + the app so it can tailor messages. + RENAISSANCE_VARIANT=${VARIANT} \ + # venv from builder; takes precedence over any system Python + PATH="/opt/venv/bin:$PATH" \ + # PaddleX / PaddleOCR model cache — mapped to a named Docker volume. + # PADDLE_PDX_CACHE_HOME is the variable paddlex actually reads + # (paddlex/utils/cache.py); the previous PADDLE_PDX_MODEL_CACHE_HOME + # was only read by our own code, so models silently re-downloaded to + # the ephemeral ~/.paddlex on EVERY container recreation instead of + # persisting in the volume. + PADDLE_PDX_CACHE_HOME=/paddle_models \ + # HuggingFace cache for the fine-tuned corrector's base model + # (google/gemma-3-4b-it, ~8 GB, GATED — needs HF_TOKEN at runtime). Point it + # at the SAME persisted named volume as the paddle models so first-use + # downloads survive container recreation instead of re-downloading — same + # fix the paddle weights got. The LoRA adapter itself is baked into the + # image (models/weights/postprocess), only the base weights download. + HF_HOME=/paddle_models/hf \ + # Disable experimental Paddle PIR to avoid ConvertPirAttribute errors + FLAGS_enable_pir_api=0 \ + FLAGS_enable_pir_in_executor=0 \ + # Plain-Ubuntu base: tell the NVIDIA Container Toolkit what to inject + # when the container is started with --gpus / a device reservation. + # (The nvidia/cuda images set these for you; ubuntu:24.04 does not. + # Harmless for the cpu variant — without a device request the toolkit + # never engages.) + NVIDIA_VISIBLE_DEVICES=all \ + NVIDIA_DRIVER_CAPABILITIES=compute,utility + +# Central user-tracking endpoint, baked into the published image so every +# self-hosted instance reports signups to the one shared Supabase table. +# These are PUBLIC-by-design values: the publishable (anon) key can only add a +# signup row (INSERT-only Row Level Security) — it cannot read/edit/delete any +# data, and the Postgres password / service_role key are never included here. +# Override at runtime with -e SUPABASE_URL / -e SUPABASE_PUBLISHABLE_KEY. +ENV SUPABASE_URL=https://hljxjbcarzbsbvwffmzu.supabase.co \ + SUPABASE_PUBLISHABLE_KEY=sb_publishable_wKkx48mKpDZBk9h3gwDfOw_c6MxHIiz + +# Install ONLY the Python 3.11 runtime (no -dev, no pip) plus the shared +# libraries that native extensions need at runtime. +# IMPORTANT: same deadsnakes PPA + same Python 3.11 as the builder so every +# .so in /opt/venv resolves its symbols correctly. One combined RUN keeps the +# apt cache clean-up in the same layer as the install. +RUN apt-get update && apt-get install -y --no-install-recommends \ + software-properties-common \ + ca-certificates \ + && add-apt-repository -y ppa:deadsnakes/ppa \ + && apt-get install -y --no-install-recommends \ + python3.11 \ + python3.11-venv \ + libgl1 \ + libglib2.0-0 \ + libgomp1 \ + && apt-get purge -y --auto-remove software-properties-common \ + && rm -rf /var/lib/apt/lists/* + +# Copy the fully-built venv from the builder. +# Both stages are Ubuntu 24.04, so glibc / OpenSSL / libstdc++ are identical +# — no ABI mismatch possible. +COPY --from=builder /opt/venv /opt/venv + +# Create non-root user and the model-cache directory up front so that the +# subsequent COPY --chown writes into correct ownership in a single layer +# (avoids a recursive chown over /app afterwards). +# +# At runtime /paddle_models is shadowed by a named Docker volume so +# downloaded models persist across container restarts (lazy-loaded on +# first use). We used to pre-download PaddleOCR weights here, but the +# named volume mounted at /paddle_models shadows this path at runtime, +# so the baked-in models were discarded anyway — bloating the image by +# ~1.5 GB for nothing. Lazy first-run download is the correct pattern. +RUN groupadd --system appgroup && \ + useradd --system --gid appgroup --home /home/appuser appuser && \ + mkdir -p /home/appuser /paddle_models /app/storage/transcripts /app/storage/datasets && \ + chown -R appuser:appgroup /home/appuser /paddle_models /app/storage + +############################ +# App setup +############################ +WORKDIR /app +COPY --chown=appuser:appgroup . . + +# Normalize the TrOCR checkpoint's processor assets. transformers 4.53 reads +# the shipped processor_config.json/tokenizer.json natively, so this is now a +# belt-and-suspenders step: it re-derives the processor from the base model +# (microsoft/trocr-base-printed, shared vocabulary) so the directory is +# guaranteed loadable by the pinned versions regardless of how the checkpoint +# was serialized. No-op if the trocr dir is absent. Ownership is reset so +# appuser can read the regenerated files at runtime. +# +# HF_HOME is overridden for this ONE step: it normally points into +# /paddle_models (the named volume), and this RUN executes as root — a root +# -owned /paddle_models/hf baked into the image seeds every fresh volume +# root-owned, so appuser could never download the corrector's base model at +# runtime (PermissionError in huggingface_hub). Keep build-time HF writes out +# of the volume path. +RUN HF_HOME=/tmp/hf-build python /app/scripts/normalize_trocr_config.py && \ + rm -rf /tmp/hf-build && \ + chown -R appuser:appgroup /app/models/weights/trocr /home/appuser /paddle_models && \ + chmod +x /app/scripts/entrypoint.sh + +USER appuser + +EXPOSE 8000 +VOLUME ["/app/storage"] + +HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1 + +# Entrypoint runs a non-blocking preflight (logs a clear message if a GPU +# image is started without GPU access, or if RAM is low) then exec's the CMD. +# It NEVER aborts boot: Gemini OCR, preprocessing and export work without a +# GPU; only PaddleOCR layout detection requires one. The hard "specs do not +# meet requirements" gate lives in the host launcher (run.sh / run.ps1), +# which checks before pulling the multi-GB image. +ENTRYPOINT ["/app/scripts/entrypoint.sh"] + +# Single worker on purpose: PaddleOCR server-class models (~1.5 GB each, +# loaded fresh per detection call and freed after) would otherwise be +# duplicated per-worker — doubling RAM / VRAM footprint and OOM-killing +# the container on larger pages. Detection is already serialized at the +# app level (asyncio lock in layout_detection router), so extra workers +# add no throughput for GPU-bound work. +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--no-access-log"] diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/__init__.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/__init__.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/auth.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/auth.py new file mode 100644 index 00000000..b5cb5d06 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/auth.py @@ -0,0 +1,201 @@ +"""Signup / login / logout / me routes, plus a token-gated admin user list. + +Sessions are a signed httpOnly cookie — no JWT, no refresh tokens. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from fastapi import APIRouter, BackgroundTasks, Cookie, Depends, Header, HTTPException, Response +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from ..auth.db import get_db +from ..auth.models import User +from ..auth.tracking import track_user_now, update_tracked_profile +from ..auth.schemas import ( + AuthResponse, + LoginRequest, + ProfileUpdateRequest, + SignupRequest, + UserOut, +) +from ..auth.security import ( + SESSION_COOKIE, + create_session_token, + hash_password, + read_session_token, + verify_password, +) +from ..core.config import ADMIN_TOKEN, PUBLIC_BASE_URL, SESSION_MAX_AGE + +router = APIRouter() + +# Secure flag only on https, otherwise the cookie wouldn't stick on localhost. +_COOKIE_SECURE = PUBLIC_BASE_URL.lower().startswith("https") + + +def _set_session_cookie(response: Response, user_id: int) -> None: + response.set_cookie( + key=SESSION_COOKIE, + value=create_session_token(user_id), + max_age=SESSION_MAX_AGE, + httponly=True, + secure=_COOKIE_SECURE, + samesite="lax", + path="/", + ) + + +def current_user( + ren_session: str | None = Cookie(default=None), + db: Session = Depends(get_db), +) -> User: + """Dependency: resolve the logged-in user or raise 401.""" + user_id = read_session_token(ren_session or "") + if user_id is None: + raise HTTPException(status_code=401, detail="Not authenticated") + user = db.get(User, user_id) + if user is None: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +# ── Signup ──────────────────────────────────────────────────────────────── + +@router.post("/api/auth/signup", response_model=AuthResponse) +def signup( + payload: SignupRequest, + response: Response, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), +): + username = payload.username.strip() + email = str(payload.email).strip().lower() + + existing = db.scalar( + select(User).where((User.username == username) | (User.email == email)) + ) + if existing is not None: + field = "username" if existing.username == username else "email" + raise HTTPException(status_code=409, detail=f"That {field} is already registered") + + user = User( + username=username, + email=email, + name=payload.name.strip(), + institute=(payload.institute or "").strip() or "personal", + password_hash=hash_password(payload.password), + # No email verification — this is usage tracking, not a security gate. + is_verified=True, + ) + + db.add(user) + db.commit() + db.refresh(user) + + # Push to central tracking after the response goes out. If it fails, + # tracked_at stays NULL and the retry loop picks it up later. + background_tasks.add_task(track_user_now, user.id) + + _set_session_cookie(response, user.id) + return AuthResponse(user=UserOut(**user.public_dict())) + + +# ── Login ─────────────────────────────────────────────────────────────────── + +@router.post("/api/auth/login", response_model=AuthResponse) +def login(payload: LoginRequest, response: Response, db: Session = Depends(get_db)): + ident = payload.username.strip() + # Allow logging in with either username or email. + user = db.scalar( + select(User).where((User.username == ident) | (User.email == ident.lower())) + ) + if user is None or not verify_password(payload.password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid username or password") + + user.last_login = datetime.now(timezone.utc) + db.commit() + db.refresh(user) + + _set_session_cookie(response, user.id) + return AuthResponse(user=UserOut(**user.public_dict())) + + +# ── Logout ────────────────────────────────────────────────────────────────── + +@router.post("/api/auth/logout") +def logout(response: Response): + response.delete_cookie(SESSION_COOKIE, path="/") + return {"ok": True} + + +# ── Current user ───────────────────────────────────────────────────────────── + +@router.get("/api/auth/me", response_model=UserOut) +def me(user: User = Depends(current_user)): + return UserOut(**user.public_dict()) + + +@router.patch("/api/auth/me", response_model=UserOut) +def update_profile( + payload: ProfileUpdateRequest, + background_tasks: BackgroundTasks, + user: User = Depends(current_user), + db: Session = Depends(get_db), +): + """Update name / email / institute (username is fixed). + + Saved locally, then best-effort synced to Supabase keyed on the old email. + """ + old_email = user.email + new_email = str(payload.email).strip().lower() + + if new_email != user.email: + clash = db.scalar( + select(User).where(User.email == new_email, User.id != user.id) + ) + if clash is not None: + raise HTTPException(status_code=409, detail="That email is already registered") + + user.name = payload.name.strip() + user.institute = (payload.institute or "").strip() or "personal" + user.email = new_email + db.commit() + db.refresh(user) + + background_tasks.add_task( + update_tracked_profile, + old_email, + user.username, + user.name, + user.email, + user.institute, + ) + return UserOut(**user.public_dict()) + + +# ── Admin: user tracking view ──────────────────────────────────────────────── + +@router.get("/api/admin/users") +def admin_users( + x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"), + db: Session = Depends(get_db), +): + # Disabled unless an ADMIN_TOKEN is configured — never left open. + if not ADMIN_TOKEN: + raise HTTPException(status_code=404, detail="Not found") + if not x_admin_token or x_admin_token != ADMIN_TOKEN: + raise HTTPException(status_code=401, detail="Invalid admin token") + + total = db.scalar(select(func.count()).select_from(User)) or 0 + verified = db.scalar( + select(func.count()).select_from(User).where(User.is_verified.is_(True)) + ) or 0 + users = db.scalars(select(User).order_by(User.created_at.desc())).all() + return { + "total_users": total, + "verified_users": verified, + "users": [u.public_dict() for u in users], + } diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/dataset.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/dataset.py new file mode 100644 index 00000000..60862f01 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/dataset.py @@ -0,0 +1,254 @@ +"""Parse transcripts and export OCR training datasets as ZIPs.""" + +import logging +import re +import traceback +import urllib.parse +from typing import List, Optional + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from ..services.transcript_parser import parse_transcript_bytes, parse_transcript +from ..services.dataset_builder import ( + align_boxes_with_transcript, + build_dataset_zip, + build_detection_dataset_zip, +) +from ..storage.storage_manager import save_detection_dataset, save_recognition_dataset + + +router = APIRouter(prefix="/api/dataset", tags=["dataset"]) +logger = logging.getLogger(__name__) + + +# ── Schemas ───────────────────────────────────────────────────────── + +class TranscriptParseResponse(BaseModel): + success: bool + pages: dict # {page_key: [lines]} + page_count: int + total_lines: int + error: Optional[str] = None + + +class AlignmentRequest(BaseModel): + boxes: list # list of 4-point polygons + lines: list # list of transcript strings + + +class AlignmentResponse(BaseModel): + success: bool + pairs: list # [(box, text), ...] + num_boxes: int + num_lines: int + num_pairs: int + warning: Optional[str] = None + + +class PageDataItem(BaseModel): + page_key: str + image_data: str # base64 data URL + boxes: list # list of 4-pt polygons + lines: list # list of transcript strings + + +class DatasetExportRequest(BaseModel): + pages: List[PageDataItem] + book_name: str = "dataset" + + +# ── Endpoints ─────────────────────────────────────────────────────── + +@router.post("/parse-transcript", response_model=TranscriptParseResponse) +async def parse_transcript_endpoint( + file: UploadFile = File(...), +): + """Parse an uploaded TXT/DOCX/PDF/Markdown transcript into page -> lines.""" + try: + data = await file.read() + filename = file.filename or "" + content_type = file.content_type or "" + + pages = parse_transcript_bytes(data, filename, content_type) + + total_lines = sum(len(v) for v in pages.values()) + + return TranscriptParseResponse( + success=True, + pages=pages, + page_count=len(pages), + total_lines=total_lines, + ) + except Exception as e: + traceback.print_exc() + return TranscriptParseResponse( + success=False, + pages={}, + page_count=0, + total_lines=0, + error=str(e), + ) + + +@router.post("/parse-transcript-text", response_model=TranscriptParseResponse) +async def parse_transcript_text_endpoint( + text: str = Form(...), +): + """Same as above, for text pasted straight into the UI.""" + try: + pages = parse_transcript(text) + total_lines = sum(len(v) for v in pages.values()) + + return TranscriptParseResponse( + success=True, + pages=pages, + page_count=len(pages), + total_lines=total_lines, + ) + except Exception as e: + traceback.print_exc() + return TranscriptParseResponse( + success=False, + pages={}, + page_count=0, + total_lines=0, + error=str(e), + ) + + +@router.post("/align", response_model=AlignmentResponse) +async def align_endpoint(request: AlignmentRequest): + """Pair one page's boxes with its transcript lines, flagging any mismatch.""" + try: + pairs, num_boxes, num_lines = align_boxes_with_transcript( + request.boxes, request.lines + ) + + warning = None + if num_boxes != num_lines: + warning = ( + f"Mismatch: {num_boxes} bounding boxes vs {num_lines} transcript lines. " + f"Only {len(pairs)} pairs will be used." + ) + + return AlignmentResponse( + success=True, + pairs=[{"box": box, "text": text} for box, text in pairs], + num_boxes=num_boxes, + num_lines=num_lines, + num_pairs=len(pairs), + warning=warning, + ) + except Exception as e: + traceback.print_exc() + return AlignmentResponse( + success=False, + pairs=[], + num_boxes=0, + num_lines=0, + num_pairs=0, + warning=str(e), + ) + + +@router.post("/export") +async def export_dataset(request: DatasetExportRequest): + """Build and stream a recognition dataset ZIP (line crops + labels).""" + if not request.pages: + raise HTTPException(status_code=400, detail="No pages provided.") + + try: + pages_data = [p.dict() for p in request.pages] + + zip_buffer = build_dataset_zip( + pages_data=pages_data, + book_name=request.book_name, + ) + + try: + save_recognition_dataset( + pages_data=pages_data, + source="dataset export", + book_name=request.book_name, + ) + except Exception as save_err: + logger.warning("Recognition dataset persistence failed: %s", save_err) + + filename = f"{request.book_name}_dataset.zip" + # Content-Disposition must be latin-1; the RFC 5987 form carries the + # real name for clients that understand it. + ascii_filename = re.sub(r'[^\x20-\x7E]', '_', filename) + utf8_filename = urllib.parse.quote(filename) + content_disp = ( + f'attachment; filename="{ascii_filename}"; ' + f"filename*=UTF-8''{utf8_filename}" + ) + + return StreamingResponse( + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": content_disp}, + ) + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Dataset generation failed: {str(e)}") + + +# ── Detection-only dataset export ────────────────────────────────── + +class DetectionPageItem(BaseModel): + page_key: str + image_data: str # base64 data URL + boxes: list # list of 4-pt polygons + + +class DetectionExportRequest(BaseModel): + pages: List[DetectionPageItem] + book_name: str = "dataset" + # "txt" → one "x1 y1 x2 y2" per line; "json" → array of [x1,y1,x2,y2]. + bbox_format: str = "txt" + + +@router.post("/export-detection") +async def export_detection_dataset(request: DetectionExportRequest): + """Build and stream a detection dataset ZIP. Boxes only, no transcript.""" + if not request.pages: + raise HTTPException(status_code=400, detail="No pages provided.") + + try: + pages_data = [p.dict() for p in request.pages] + + zip_buffer = build_detection_dataset_zip( + pages_data=pages_data, + book_name=request.book_name, + bbox_format=request.bbox_format, + ) + + try: + save_detection_dataset( + pages_data=pages_data, + source="dataset export", + book_name=request.book_name, + bbox_format=request.bbox_format, + ) + except Exception as save_err: + logger.warning("Detection dataset persistence failed: %s", save_err) + + filename = f"{request.book_name}_detection_dataset.zip" + ascii_filename = re.sub(r'[^\x20-\x7E]', '_', filename) + utf8_filename = urllib.parse.quote(filename) + content_disp = ( + f'attachment; filename="{ascii_filename}"; ' + f"filename*=UTF-8''{utf8_filename}" + ) + + return StreamingResponse( + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": content_disp}, + ) + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Detection dataset generation failed: {str(e)}") diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/export.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/export.py new file mode 100644 index 00000000..fe72c8e4 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/export.py @@ -0,0 +1,43 @@ +"""Download the combined transcript as txt / docx / pdf.""" + +from fastapi import APIRouter +from fastapi.responses import StreamingResponse + +from ..schemas.ocr import ExportRequest +from ..services.export import build_txt_export, build_docx_export, build_pdf_export + + +router = APIRouter() + + +@router.post("/api/export/txt") +async def export_txt(request: ExportRequest): + buffer = build_txt_export(request.transcripts) + + return StreamingResponse( + buffer, + media_type="text/plain; charset=utf-8", + headers={"Content-Disposition": "attachment; filename=transcript_full.txt"} + ) + + +@router.post("/api/export/docx") +async def export_docx(request: ExportRequest): + buffer = build_docx_export(request.transcripts) + + return StreamingResponse( + buffer, + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + headers={"Content-Disposition": "attachment; filename=transcript_full.docx"} + ) + + +@router.post("/api/export/pdf") +async def export_pdf(request: ExportRequest): + buffer = build_pdf_export(request.transcripts) + + return StreamingResponse( + buffer, + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=transcript_full.pdf"} + ) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/health.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/health.py new file mode 100644 index 00000000..5ac35dd2 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/health.py @@ -0,0 +1,11 @@ +"""Health check.""" + +from datetime import datetime +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/api/health") +async def health_check(): + return {"status": "healthy", "timestamp": datetime.now().isoformat()} diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/layout_detection.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/layout_detection.py new file mode 100644 index 00000000..bb9a3802 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/layout_detection.py @@ -0,0 +1,188 @@ +"""POST /api/detect/layout-aware-lines — image in, text-line polygons out. + +One page at a time per worker: the models are ~1.5 GB each and loaded fresh per +call, so two concurrent pages double peak memory and get the worker OOM-killed. +Budget 8 GB VRAM (GPU) or 8 GB RAM (CPU, much slower). +""" + +import asyncio +import time +import traceback + +import cv2 +import numpy as np +from fastapi import APIRouter, File, Form, UploadFile + +from ..services.layout_detection import ( + models_cached, + run_layout_aware_detection, + select_tier, +) + +router = APIRouter() + + +@router.get("/api/detect/models-status") +async def detection_models_status(use_gpu: bool = True): + """Are the detection models on disk yet? + + The UI checks this first so it can warn about the one-time ~15-20 min + download. Filesystem check only — never imports paddle. + """ + return {"models_ready": models_cached(use_gpu)} + +# FastAPI runs async handlers concurrently; without this two parallel pages +# both load paddle models and fight over VRAM. +_detection_lock = asyncio.Lock() + + +@router.post("/api/detect/layout-aware-lines") +async def detect_layout_aware_lines( + image: UploadFile = File(...), + use_gpu: bool = Form(False), + region_padding: int = Form(50), + layout_expand: int = Form(2), + score_thresh: float = Form(0.5), + upscale_min_h: int = Form(60), + nms_iou_thresh: float = Form(0.3), + gap_multiplier: float = Form(2.0), + debug_dir: str = Form(""), +): + """Detect text lines in an uploaded page. + + -> {lines: [4-point polygons], count, processing_time_ms, tier}. + """ + start = time.time() + + try: + contents = await image.read() + nparr = np.frombuffer(contents, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + del contents + + if img is None: + return { + "error": "Could not decode the uploaded image.", + "lines": [], + "count": 0, + "processing_time_ms": 0, + } + + # The sync work goes to a thread so /api/health stays responsive + # while a page is in flight. + async with _detection_lock: + gpu_fallback = False + resource_warnings = [] + + # Probe inside the lock so the readings match what we're about to + # allocate. Keeps 4-6 GB laptop GPUs off the server-class models. + tier = await asyncio.to_thread(select_tier, use_gpu) + print(f"[LayoutAPI] tier selected: {tier['tier']} on {tier['device']}" + f" — {tier['reason']}") + effective_use_gpu = tier["device"] == "gpu" + + try: + lines, resource_warnings = await asyncio.to_thread( + run_layout_aware_detection, + img, + use_gpu=effective_use_gpu, + layout_model_name=tier["layout_model"], + det_model_name=tier["det_model"], + rec_model_name=tier["rec_model"], + region_padding=region_padding, + layout_expand=layout_expand, + score_thresh=score_thresh, + upscale_min_h=upscale_min_h, + nms_iou_thresh=nms_iou_thresh, + gap_multiplier=gap_multiplier, + debug_dir=debug_dir, + ) + except (ValueError, RuntimeError) as gpu_err: + err_msg = str(gpu_err) + if "Out-of-memory" in err_msg or "out of memory" in err_msg.lower(): + elapsed = int((time.time() - start) * 1000) + return { + "error": err_msg, + "lines": [], + "count": 0, + "processing_time_ms": elapsed, + "resource_warnings": resource_warnings, + "tier": tier, + "resource_requirements": ( + "Minimum 8 GB GPU VRAM required for GPU mode. " + "Minimum 8 GB free RAM required for CPU mode." + ), + } + if effective_use_gpu: + # Crashed for some non-OOM reason — retry on CPU. + print(f"[LayoutAPI] GPU failed, falling back to CPU: {gpu_err}") + gpu_fallback = True + cpu_tier = await asyncio.to_thread(select_tier, False) + tier = cpu_tier + lines, resource_warnings = await asyncio.to_thread( + run_layout_aware_detection, + img, + use_gpu=False, + layout_model_name=cpu_tier["layout_model"], + det_model_name=cpu_tier["det_model"], + rec_model_name=cpu_tier["rec_model"], + region_padding=region_padding, + layout_expand=layout_expand, + score_thresh=score_thresh, + upscale_min_h=upscale_min_h, + nms_iou_thresh=nms_iou_thresh, + gap_multiplier=gap_multiplier, + debug_dir=debug_dir, + ) + else: + raise + + del img + elapsed = int((time.time() - start) * 1000) + + resp = { + "lines": lines, + "count": len(lines), + "processing_time_ms": elapsed, + "tier": tier, + } + if resource_warnings: + resp["resource_warnings"] = resource_warnings + if gpu_fallback: + resp["warning"] = "GPU failed mid-run. Fell back to CPU." + elif use_gpu and tier["device"] == "cpu": + resp["warning"] = ( + "Not enough free GPU VRAM for detection. " + f"Ran on CPU instead. {tier['reason']}" + ) + elif use_gpu and tier["tier"] == "mobile": + resp["warning"] = ( + f"Low GPU VRAM — using lighter mobile models. {tier['reason']}" + ) + if use_gpu and not gpu_fallback: + resp["resource_requirements"] = ( + "Minimum 8 GB GPU VRAM recommended for GPU mode. " + "Minimum 8 GB free RAM recommended for CPU mode." + ) + return resp + + except Exception as exc: + traceback.print_exc() + elapsed = int((time.time() - start) * 1000) + resp = { + "error": f"Line detection failed: {str(exc)}", + "lines": [], + "count": 0, + "processing_time_ms": elapsed, + } + # Warnings gathered before the failure often explain it. + try: + if resource_warnings: + resp["resource_warnings"] = resource_warnings + resp["resource_requirements"] = ( + "Minimum 8 GB GPU VRAM required for GPU mode. " + "Minimum 8 GB free RAM required for CPU mode." + ) + except NameError: + pass + return resp diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/llm_postprocess.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/llm_postprocess.py new file mode 100644 index 00000000..3acc4cc7 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/llm_postprocess.py @@ -0,0 +1,110 @@ +"""Optional LLM cleanup pass over OCR text. Providers live in the factory.""" + +import traceback +from typing import Optional + +from fastapi import APIRouter, Header, HTTPException +from pydantic import BaseModel + +from ..services.llm_processing.factory import ( + post_process, + provider_requires_key, + LLM_PROVIDERS, +) +from ..services.llm_processing.local_client import base_model_cached +from ..services.llm_processing.prompt_templates import list_templates + + +router = APIRouter(prefix="/api/llm", tags=["llm"]) + + +# ── Schemas ───────────────────────────────────────────────────────── + +class PostProcessRequest(BaseModel): + text: str + provider: str = "gemini" + model: str = "gemini-2.5-flash" + template: str = "full_cleanup" + + +class PostProcessResponse(BaseModel): + success: bool + processed_text: Optional[str] = None + error: Optional[str] = None + model_used: Optional[str] = None + provider_used: Optional[str] = None + + +# ── Endpoints ─────────────────────────────────────────────────────── + +@router.get("/providers") +async def get_providers(): + """List post-processing providers + their text models for the UI.""" + return {"providers": LLM_PROVIDERS} + + +@router.get("/templates") +async def get_templates(): + """List available post-processing prompt templates.""" + return {"templates": list_templates()} + + +@router.get("/local-status") +async def local_model_status(): + """Are the local corrector's base weights on disk yet? + + Same contract as /api/detect/models-status: the UI checks this first so it + can warn about the one-time ~8 GB gated download instead of looking hung. + """ + return {"models_ready": base_model_cached()} + + +@router.post("/post-process", response_model=PostProcessResponse) +async def post_process_endpoint( + request: PostProcessRequest, + # X-Gemini-API-Key stays accepted so the older Gemini flow keeps working. + x_llm_api_key: Optional[str] = Header(None, alias="X-LLM-API-Key"), + x_gemini_api_key: Optional[str] = Header(None, alias="X-Gemini-API-Key"), +): + """Post-process OCR text using the selected LLM provider.""" + if not request.text or not request.text.strip(): + return PostProcessResponse( + success=True, + processed_text=request.text, + model_used=request.model, + provider_used=request.provider, + ) + + api_key = x_llm_api_key or x_gemini_api_key + if provider_requires_key(request.provider) and (not api_key or not api_key.strip()): + raise HTTPException( + status_code=401, + detail="Missing API key (X-LLM-API-Key header).", + ) + + try: + result = post_process( + provider=request.provider, + api_key=api_key, + text=request.text, + model=request.model, + template_name=request.template, + ) + return PostProcessResponse( + success=True, + processed_text=result, + model_used=request.model, + provider_used=request.provider, + ) + except ValueError as e: + # Unknown/disabled provider — the caller's fault, not ours. + return PostProcessResponse(success=False, error=str(e)) + except Exception as e: + traceback.print_exc() + error_msg = str(e) + error_lower = error_msg.lower() + if "api key" in error_lower or "authenticate" in error_lower or "401" in error_lower: + return PostProcessResponse(success=False, error="Invalid API key") + if "quota" in error_lower or "rate" in error_lower or "429" in error_lower: + return PostProcessResponse(success=False, error="Rate limited — please try again later") + return PostProcessResponse(success=False, error=error_msg) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/ocr.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/ocr.py new file mode 100644 index 00000000..5c91fec4 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/ocr.py @@ -0,0 +1,244 @@ +"""OCR endpoints. Providers come from OCRFactory, shared work from ocr_helpers. + +Only Gemini is rate-limited server-side (free-tier sliding window). +""" + +import asyncio +import time + +from fastapi import APIRouter, File, UploadFile, HTTPException, Header, Form +from typing import Optional + +from ..core.config import MIN_API_KEY_LENGTH, MAX_BATCH_SIZE +from ..core.rate_limiter import rate_limiter +from ..schemas.ocr import ( + OCRResponse, + GeminiOCRRequest, + BatchOCRRequest, + BatchOCRResponse, + BatchOCRResultItem, + ChatGPTOCRRequest, + DeepSeekOCRRequest, + QwenOCRRequest, +) +from ..services.ocr.factory import OCRFactory +from .ocr_helpers import ( + parse_base64_image, + validate_model, + validate_api_key_format, + check_rate_limit, + run_ocr, +) + + +router = APIRouter() + + +def _provider(name: str): + return OCRFactory.get_provider(name) + + +# ── Model listing ─────────────────────────────────────────────── + +@router.get("/api/models") +async def get_gemini_models(): + """Available Gemini models.""" + p = _provider("gemini") + return {"models": p.MODELS, "default": p.DEFAULT_MODEL} + + +@router.get("/api/chatgpt-models") +async def get_chatgpt_models(): + """Available ChatGPT models.""" + p = _provider("chatgpt") + return {"models": p.MODELS, "default": p.DEFAULT_MODEL} + + +@router.get("/api/deepseek-models") +async def get_deepseek_models(): + """Available DeepSeek models.""" + p = _provider("deepseek") + return {"models": p.MODELS, "default": p.DEFAULT_MODEL} + + +@router.get("/api/qwen-models") +async def get_qwen_models(): + """Available Qwen models.""" + p = _provider("qwen") + return {"models": p.MODELS, "default": p.DEFAULT_MODEL} + + +# ── Key check + rate limit (Gemini free-tier) ─────────────────── + +@router.post("/api/validate-key") +async def validate_api_key( + x_gemini_api_key: str = Header(..., alias="X-Gemini-API-Key"), +): + """Format check only — the key is really verified on the first OCR call.""" + validate_api_key_format(x_gemini_api_key, MIN_API_KEY_LENGTH) + return { + "valid": True, + "message": "API key format is valid. It will be verified on first use.", + } + + +@router.get("/api/rate-limit-status") +async def get_rate_limit_status(): + """Current rate-limit status (Gemini free-tier sliding window).""" + return rate_limiter.get_status() + + +# ── Gemini ────────────────────────────────────────────────────── + +@router.post("/api/gemini-ocr-page", response_model=OCRResponse) +async def gemini_ocr_page( + image: UploadFile = File(...), + model: str = Form(default="gemini-3.1-flash-lite"), + custom_prompt: Optional[str] = Form(default=None), + x_gemini_api_key: str = Header(..., alias="X-Gemini-API-Key"), +): + """Process a single uploaded image file with Gemini OCR.""" + check_rate_limit() + provider = _provider("gemini") + validate_model(model, provider.MODEL_IDS) + validate_api_key_format(x_gemini_api_key) + + image_bytes = await image.read() + content_type = image.content_type or "image/png" + if content_type not in ("image/png", "image/jpeg", "image/jpg", "image/webp"): + content_type = "image/png" + + result = run_ocr(provider, x_gemini_api_key, image_bytes, model, content_type, custom_prompt) + if result.success: + rate_limiter.record_request() + return result + + +@router.post("/api/gemini-ocr-base64", response_model=OCRResponse) +async def gemini_ocr_base64( + image_data: str = Form(...), + model: str = Form(default="gemini-3.1-flash-lite"), + custom_prompt: Optional[str] = Form(default=None), + x_gemini_api_key: str = Header(..., alias="X-Gemini-API-Key"), +): + """Process a base64-encoded image with Gemini OCR (form data).""" + check_rate_limit() + provider = _provider("gemini") + validate_model(model, provider.MODEL_IDS) + + image_bytes, mime_type = parse_base64_image(image_data) + result = run_ocr(provider, x_gemini_api_key, image_bytes, model, mime_type, custom_prompt) + if result.success: + rate_limiter.record_request() + return result + + +@router.post("/api/gemini-ocr-json", response_model=OCRResponse) +async def gemini_ocr_json( + request: GeminiOCRRequest, + x_gemini_api_key: str = Header(..., alias="X-Gemini-API-Key"), +): + """Process a base64-encoded image with Gemini OCR (JSON body).""" + check_rate_limit() + provider = _provider("gemini") + validate_model(request.model, provider.MODEL_IDS) + + image_bytes, mime_type = parse_base64_image(request.image_data) + result = run_ocr(provider, x_gemini_api_key, image_bytes, request.model, mime_type, request.custom_prompt) + if result.success: + rate_limiter.record_request() + return result + + +@router.post("/api/gemini-ocr-batch", response_model=BatchOCRResponse) +async def gemini_ocr_batch( + request: BatchOCRRequest, + x_gemini_api_key: str = Header(..., alias="X-Gemini-API-Key"), +): + """Process multiple images concurrently with Gemini OCR (max batch size 4).""" + if not request.items: + raise HTTPException(status_code=400, detail="Empty batch request") + if len(request.items) > MAX_BATCH_SIZE: + raise HTTPException( + status_code=400, + detail=f"Batch size exceeds maximum of {MAX_BATCH_SIZE}. Got {len(request.items)} items.", + ) + + check_rate_limit(required_slots=len(request.items)) + provider = _provider("gemini") + validate_model(request.model, provider.MODEL_IDS) + + batch_start = time.time() + + async def _process_item(item) -> BatchOCRResultItem: + item_start = time.time() + try: + image_bytes, mime_type = parse_base64_image(item.image_data) + transcript = await asyncio.get_event_loop().run_in_executor( + None, + lambda ib=image_bytes, mt=mime_type: provider.transcribe( + x_gemini_api_key, ib, request.model, mt, request.custom_prompt + ), + ) + return BatchOCRResultItem( + page_index=item.page_index, + success=True, + transcript=transcript, + processing_time_ms=int((time.time() - item_start) * 1000), + ) + except Exception as exc: + return BatchOCRResultItem( + page_index=item.page_index, + success=False, + error=str(exc), + processing_time_ms=int((time.time() - item_start) * 1000), + ) + + results = await asyncio.gather(*[_process_item(i) for i in request.items]) + rate_limiter.record_requests(len(request.items)) + + successful = sum(1 for r in results if r.success) + return BatchOCRResponse( + results=list(results), + total_processing_time_ms=int((time.time() - batch_start) * 1000), + successful_count=successful, + failed_count=len(results) - successful, + ) + + +# ── Other providers (no server-side rate limiting) ────────────── + +@router.post("/api/chatgpt-ocr-json", response_model=OCRResponse) +async def chatgpt_ocr_json( + request: ChatGPTOCRRequest, + x_api_key: str = Header(..., alias="X-API-Key"), +): + """Process a base64-encoded image with ChatGPT OCR.""" + provider = _provider("chatgpt") + validate_model(request.model, provider.MODEL_IDS) + image_bytes, mime_type = parse_base64_image(request.image_data) + return run_ocr(provider, x_api_key, image_bytes, request.model, mime_type, request.custom_prompt) + + +@router.post("/api/deepseek-ocr-json", response_model=OCRResponse) +async def deepseek_ocr_json( + request: DeepSeekOCRRequest, + x_api_key: str = Header(..., alias="X-API-Key"), +): + """Process a base64-encoded image with DeepSeek OCR.""" + provider = _provider("deepseek") + validate_model(request.model, provider.MODEL_IDS) + image_bytes, mime_type = parse_base64_image(request.image_data) + return run_ocr(provider, x_api_key, image_bytes, request.model, mime_type, request.custom_prompt) + + +@router.post("/api/qwen-ocr-json", response_model=OCRResponse) +async def qwen_ocr_json( + request: QwenOCRRequest, + x_api_key: str = Header(..., alias="X-API-Key"), +): + """Process a base64-encoded image with Qwen OCR.""" + provider = _provider("qwen") + validate_model(request.model, provider.MODEL_IDS) + image_bytes, mime_type = parse_base64_image(request.image_data) + return run_ocr(provider, x_api_key, image_bytes, request.model, mime_type, request.custom_prompt) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/ocr_helpers.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/ocr_helpers.py new file mode 100644 index 00000000..6651dcf6 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/ocr_helpers.py @@ -0,0 +1,157 @@ +"""Shared bits every OCR endpoint uses: base64 parsing, validation, +rate-limit checks, and one wrapper that turns provider calls into OCRResponse.""" + +import base64 +import time +from typing import Optional + +from fastapi import HTTPException + +try: + import httpx +except ImportError: # only Gemini setups can skip it + httpx = None # type: ignore + +from ..core.rate_limiter import rate_limiter +from ..schemas.ocr import OCRResponse +from ..services.ocr.base import BaseOCRProvider + + +# ── Base64 parsing ────────────────────────────────────────────── + + +def parse_base64_image(image_data: str) -> tuple[bytes, str]: + """Decode raw base64 or a data: URL. Returns (image_bytes, mime_type).""" + if "," in image_data: + header, encoded = image_data.split(",", 1) + mime_type = ( + header.split(":")[1].split(";")[0] + if ":" in header + else "image/png" + ) + else: + encoded = image_data + mime_type = "image/png" + + return base64.b64decode(encoded), mime_type + + +# ── Validation helpers ────────────────────────────────────────── + + +def validate_model(model: str, valid_ids: list[str]) -> None: + """400 if the model isn't one we know about.""" + if model not in valid_ids: + raise HTTPException( + status_code=400, + detail=f"Invalid model. Available models: {valid_ids}", + ) + + +def validate_api_key_format(api_key: str, min_length: int = 10) -> None: + """401 if the key is missing or implausibly short.""" + if not api_key or len(api_key) < min_length: + raise HTTPException( + status_code=401, + detail="Invalid or missing API key", + ) + + +# ── Rate-limit helpers ────────────────────────────────────────── + + +def check_rate_limit(required_slots: int = 1) -> None: + """429 with a JSON body when the sliding window has no room.""" + if required_slots <= 1: + can_proceed, wait_time = rate_limiter.can_proceed() + if not can_proceed: + raise HTTPException( + status_code=429, + detail={ + "error": "rate_limited", + "message": f"Rate limit exceeded. Please wait {wait_time} seconds.", + "wait_seconds": wait_time, + }, + ) + else: + available = rate_limiter.get_available_slots() + if available < required_slots: + status = rate_limiter.get_status() + raise HTTPException( + status_code=429, + detail={ + "error": "rate_limited", + "message": ( + f"Not enough rate limit slots. " + f"Need {required_slots}, have {available}." + ), + "wait_seconds": status.get("wait_seconds", 60), + "available_slots": available, + }, + ) + + +# ── Unified OCR execution ────────────────────────────────────── + + +def run_ocr( + provider: BaseOCRProvider, + api_key: str, + image_bytes: bytes, + model: str, + mime_type: str, + custom_prompt: Optional[str] = None, +) -> OCRResponse: + """Run the provider and normalise whatever it throws into an OCRResponse. + + httpx errors (ChatGPT/DeepSeek/Qwen) and Google SDK errors (Gemini) both + land here and get mapped onto the same 401/429 shapes. + """ + start_time = time.time() + + try: + transcript = provider.transcribe(api_key, image_bytes, model, mime_type, custom_prompt) + processing_time = int((time.time() - start_time) * 1000) + return OCRResponse( + success=True, + transcript=transcript, + model_used=model, + processing_time_ms=processing_time, + ) + + except Exception as exc: + processing_time = int((time.time() - start_time) * 1000) + + if httpx is not None and isinstance(exc, httpx.HTTPStatusError): + if exc.response.status_code == 401: + raise HTTPException(status_code=401, detail="Invalid API key") + if exc.response.status_code == 429: + raise HTTPException( + status_code=429, detail="API rate limit exceeded" + ) + + error_msg = str(exc) + if "API_KEY_INVALID" in error_msg or "401" in error_msg: + raise HTTPException(status_code=401, detail="Invalid API key") + # A daily quota is not our short sliding-window limit — it won't clear + # for hours, so tag it separately and let the UI say so. + if "RESOURCE_EXHAUSTED" in error_msg or "QUOTA_EXCEEDED" in error_msg or "quota" in error_msg.lower(): + raise HTTPException( + status_code=429, + detail={ + "error": "quota_exceeded", + "message": "Daily Gemini quota reached. Try again tomorrow, pick a different model, or use another API key.", + }, + ) + if "429" in error_msg: + raise HTTPException( + status_code=429, + detail={"error": "rate_limited", "wait_seconds": 20}, + ) + + return OCRResponse( + success=False, + error=error_msg, + model_used=model, + processing_time_ms=processing_time, + ) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/preprocess.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/preprocess.py new file mode 100644 index 00000000..210797e1 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/preprocess.py @@ -0,0 +1,134 @@ +"""Preprocessing endpoints — run the OpenCV pipeline over a base64 image.""" + +import os +import sys +import time +import base64 +import numpy as np +import cv2 + +from fastapi import APIRouter, HTTPException + +from ..schemas.ocr import PreprocessRequest + +# `preprocessing` is a sibling of `app`, not a submodule of it. +backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if backend_dir not in sys.path: + sys.path.insert(0, backend_dir) + +from preprocessing import run_pipeline, OP_REGISTRY, validate_pipeline_config + + +router = APIRouter() + + +@router.get("/api/preprocess/operations") +async def get_available_operations(): + """Operation names + one-line descriptions for the UI.""" + return { + "operations": list(OP_REGISTRY.keys()), + "descriptions": { + "normalize": "Normalize image brightness and contrast levels", + "grayscale": "Convert image to grayscale", + "deskew": "Automatically correct image rotation/skew", + "denoise": "Remove noise while preserving text edges", + "contrast": "Enhance contrast using CLAHE", + "sharpen": "Sharpen text edges for clearer text", + "threshold": "Convert to binary (black and white)", + "morph": "Morphological operations (open, close, dilate, erode, gradient, tophat, blackhat)", + "remove_blobs": "Remove large ink blobs from scanned documents", + "remove_noise": "Remove small speckles and scanning dust", + } + } + + +@router.post("/api/preprocess") +async def preprocess_image_endpoint(request: PreprocessRequest): + """Run the pipeline over one image and hand back a base64 data URL. + + preview_mode swaps in faster, rougher algorithms for live tweaking. + """ + start_time = time.time() + + try: + validation = validate_pipeline_config(request.operations) + if not validation["valid"]: + raise HTTPException( + status_code=400, + detail={ + "error": "invalid_operations", + "message": "Invalid pipeline configuration", + "errors": validation["errors"] + } + ) + + image_data = request.image_data + if "," in image_data: # data:image/png;base64,... + header, encoded = image_data.split(",", 1) + mime_type = header.split(":")[1].split(";")[0] if ":" in header else "image/png" + else: + encoded = image_data + mime_type = "image/png" + + image_bytes = base64.b64decode(encoded) + nparr = np.frombuffer(image_bytes, np.uint8) + image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + + if image is None: + raise HTTPException( + status_code=400, + detail={"error": "invalid_image", "message": "Could not decode image"} + ) + + result = run_pipeline( + image=image, + steps=request.operations, + continue_on_error=True, + preview_mode=request.preview_mode, + ) + + if result.image is not None: + # Answer in whatever format we were handed. + if "jpeg" in mime_type or "jpg" in mime_type: + encode_param = [cv2.IMWRITE_JPEG_QUALITY, 95] + _, buffer = cv2.imencode('.jpg', result.image, encode_param) + output_mime = "image/jpeg" + else: + _, buffer = cv2.imencode('.png', result.image) + output_mime = "image/png" + + encoded_result = base64.b64encode(buffer).decode('utf-8') + result_data_url = f"data:{output_mime};base64,{encoded_result}" + else: + result_data_url = None + + processing_time = int((time.time() - start_time) * 1000) + + return { + "success": result.success, + "processed_image": result_data_url, + "processing_time_ms": processing_time, + "progress_info": result.progress_info, + "errors": [ + {"step": e["step"], "error": e["error"]} + for e in result.errors + ] if result.errors else [], + } + + except HTTPException: + raise + except Exception as e: + processing_time = int((time.time() - start_time) * 1000) + return { + "success": False, + "processed_image": None, + "processing_time_ms": processing_time, + "error": str(e), + } + + +@router.post("/api/preprocess/validate") +async def validate_operations(operations: list): + """Dry-run check of a pipeline config. Returns {valid, errors}.""" + validation = validate_pipeline_config(operations) + return validation diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/recognition.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/recognition.py new file mode 100644 index 00000000..40d2dcbf --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/recognition.py @@ -0,0 +1,169 @@ +"""Recognition API Router — local (CRNN / TrOCR) model discovery and line OCR.""" + +import base64 +import os +import time + +import cv2 +import numpy as np +from fastapi import APIRouter, HTTPException + +from ..schemas.recognition import ( + LocalModelInfo, + LocalModelsResponse, + LocalRecognizeRequest, + LocalRecognizeResponse, + LocalRecognizeResult, +) +from ..services.recognition.crnn_inference import ( + crop_polygon_gray, + discover_models as discover_crnn_models, + get_recognizer as get_crnn_recognizer, +) +from ..services.recognition.trocr_inference import ( + crop_polygon_rgb, + discover_models as discover_trocr_models, + get_recognizer as get_trocr_recognizer, +) + +router = APIRouter() + +# Weights live in a different place in dev vs the container, so take whichever +# of the two candidate dirs actually exists. +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +_BACKEND_ROOT = os.path.abspath(os.path.join(_THIS_DIR, "..", "..")) +_REPO_ROOT = os.path.abspath(os.path.join(_BACKEND_ROOT, "..")) + +_CANDIDATE_DIRS = [ + os.path.join(_BACKEND_ROOT, "models", "weights"), # container layout + os.path.join(_REPO_ROOT, "backend", "models", "weights"), # dev layout +] +_MODEL_SEARCH_DIR = next( + (p for p in _CANDIDATE_DIRS if os.path.isdir(p)), + _CANDIDATE_DIRS[0], +) + +# Rescanning the weights dir per request is pointless; refresh clears this. +_model_cache: list[dict] | None = None + + +def _get_models() -> list[dict]: + global _model_cache + if _model_cache is None: + _model_cache = [ + *discover_crnn_models(os.path.join(_MODEL_SEARCH_DIR, "crnn")), + *discover_trocr_models(os.path.join(_MODEL_SEARCH_DIR, "trocr")), + ] + return _model_cache + + +@router.get("/api/local-recognition-models", response_model=LocalModelsResponse) +async def list_local_models(): + """Return all available local OCR model checkpoints.""" + models = _get_models() + return LocalModelsResponse( + models=[LocalModelInfo(**m) for m in models] + ) + + +@router.post("/api/local-recognition-models/refresh") +async def refresh_local_models(): + """Force rescan of model directory.""" + global _model_cache + _model_cache = None + models = _get_models() + return {"count": len(models), "models": [m["name"] for m in models]} + + +def _decode_image(image_data: str) -> np.ndarray: + """Decode a base64 (optionally data-URL prefixed) image to BGR numpy.""" + if "," in image_data: + image_data = image_data.split(",", 1)[1] + raw = base64.b64decode(image_data) + arr = np.frombuffer(raw, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_COLOR) + if img is None: + raise ValueError("Failed to decode image") + return img + + +@router.post("/api/local-recognize", response_model=LocalRecognizeResponse) +async def local_recognize(request: LocalRecognizeRequest): + """Recognise the text in each line box with a local CRNN or TrOCR model. + + model_id is namespaced: "crnn:best_crnn", "trocr:default". + """ + start = time.time() + + models = _get_models() + model_info = next((m for m in models if m["id"] == request.model_id), None) + if model_info is None: + raise HTTPException( + status_code=404, + detail=f"Model '{request.model_id}' not found. Available: {[m['id'] for m in models]}", + ) + + try: + image_bgr = _decode_image(request.image_data) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid image data: {e}") + + model_type = model_info["model_type"] + + try: + if model_type == "crnn": + recognizer = get_crnn_recognizer(model_info["path"]) + elif model_type == "trocr": + recognizer = get_trocr_recognizer(model_info["path"]) + else: + raise RuntimeError(f"Unsupported model type: {model_type}") + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to load model '{request.model_id}': {e}", + ) + + results: list[LocalRecognizeResult] = [] + + if len(request.boxes) > 0: + crops = [] + for i, box in enumerate(request.boxes): + try: + if model_type == "trocr": + crop = crop_polygon_rgb(image_bgr, box) + else: + crop = crop_polygon_gray(image_bgr, box) + crops.append((i, crop)) + except Exception: + results.append(LocalRecognizeResult(box_index=i, text="")) + + if crops: + try: + images = [c[1] for c in crops] + texts = recognizer.predict_batch(images) + for (idx, _), text in zip(crops, texts): + results.append(LocalRecognizeResult(box_index=idx, text=text)) + except Exception: + # Batch failed — retry one at a time so one bad crop doesn't + # cost us the whole page. + for idx, img in crops: + try: + if hasattr(recognizer, "predict"): + text = recognizer.predict(img) + else: + text = recognizer.predict_batch([img])[0] + except Exception: + text = "" + results.append(LocalRecognizeResult(box_index=idx, text=text)) + + results.sort(key=lambda r: r.box_index) + + elapsed_ms = int((time.time() - start) * 1000) + + return LocalRecognizeResponse( + results=results, + processing_time_ms=elapsed_ms, + model_used=request.model_id, + model_type=model_type, + device=recognizer.device, + ) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/storage.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/storage.py new file mode 100644 index 00000000..19ca7018 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/api/storage.py @@ -0,0 +1,169 @@ +"""Persistent storage API for transcripts and datasets.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from ..storage.storage_manager import ( + delete_entry, + ensure_storage_layout, + get_transcript_detail, + get_dataset_detail, + list_entries, + resolve_storage_root, + save_detection_dataset, + save_recognition_dataset, + save_transcript_session, + zip_entry, +) + +router = APIRouter(prefix="/api/storage", tags=["storage"]) + + +class SaveTranscriptRequest(BaseModel): + transcripts: dict[str, str] = Field(default_factory=dict) + transcript_images: dict[str, str] = Field(default_factory=dict) + source: str = "ocr upload" + mode: str = "recognition" + book_name: str = "transcript" + model_info: dict[str, Any] = Field(default_factory=dict) + + +class SaveDatasetPageItem(BaseModel): + page_key: str + image_data: str + boxes: list = Field(default_factory=list) + lines: list = Field(default_factory=list) + + +class SaveDatasetRequest(BaseModel): + pages: list[SaveDatasetPageItem] = Field(default_factory=list) + source: str = "dataset generation" + book_name: str = "dataset" + bbox_format: str = "txt" + mode: str = "recognition" + model_info: dict[str, Any] = Field(default_factory=dict) + + +@router.get("/health") +async def storage_health(): + paths = ensure_storage_layout() + return { + "ok": True, + "root": resolve_storage_root(), + "paths": paths, + } + + +@router.get("/overview") +async def storage_overview(): + return { + "transcripts": list_entries("transcripts"), + "datasets": list_entries("datasets"), + } + + +@router.get("/transcripts") +async def list_transcripts(): + return {"items": list_entries("transcripts")} + + +@router.get("/datasets") +async def list_datasets(): + return {"items": list_entries("datasets")} + + +@router.get("/transcripts/{session_id}") +async def transcript_detail(session_id: str): + try: + return get_transcript_detail(session_id) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Transcript session not found") + + +@router.get("/datasets/{dataset_id}") +async def dataset_detail(dataset_id: str): + try: + return get_dataset_detail(dataset_id) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Dataset not found") + + +@router.post("/transcripts") +async def save_transcript(request: SaveTranscriptRequest): + non_empty = { + k: v + for k, v in request.transcripts.items() + if isinstance(v, str) and v.strip() + } + if not non_empty: + raise HTTPException(status_code=400, detail="No transcript pages to save") + + metadata = save_transcript_session( + transcripts=non_empty, + source=request.source, + mode=request.mode, + transcript_images=request.transcript_images, + book_name=request.book_name, + model_info=request.model_info, + ) + return {"success": True, "item": metadata} + + +@router.post("/datasets") +async def save_dataset(request: SaveDatasetRequest): + if not request.pages: + raise HTTPException(status_code=400, detail="No pages provided") + + pages_data = [page.model_dump() for page in request.pages] + + if request.mode == "detection": + metadata = save_detection_dataset( + pages_data=pages_data, + source=request.source, + book_name=request.book_name, + bbox_format=request.bbox_format, + model_info=request.model_info, + ) + else: + metadata = save_recognition_dataset( + pages_data=pages_data, + source=request.source, + book_name=request.book_name, + model_info=request.model_info, + ) + + return {"success": True, "item": metadata} + + +@router.get("/download/{kind}/{entry_id}") +async def download_entry(kind: str, entry_id: str): + if kind not in {"transcripts", "datasets"}: + raise HTTPException(status_code=400, detail="Unsupported storage kind") + + try: + buffer, filename = zip_entry(kind, entry_id) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Stored item not found") + + return StreamingResponse( + buffer, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.delete("/{kind}/{entry_id}") +async def remove_entry(kind: str, entry_id: str): + if kind not in {"transcripts", "datasets"}: + raise HTTPException(status_code=400, detail="Unsupported storage kind") + + deleted = delete_entry(kind, entry_id) + if not deleted: + raise HTTPException(status_code=404, detail="Stored item not found") + + return {"success": True, "deleted": entry_id} diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/__init__.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/__init__.py new file mode 100644 index 00000000..ad525ab4 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/__init__.py @@ -0,0 +1,4 @@ +"""Minimal auth: account storage, password hashing, signed-cookie sessions. + +No OAuth, no JWT, no RBAC — this exists to track who uses the app. +""" diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/db.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/db.py new file mode 100644 index 00000000..70fe316c --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/db.py @@ -0,0 +1,71 @@ +"""Engine + session wiring. SQLite by default, Postgres if DATABASE_URL says so.""" + +from __future__ import annotations + +import os + +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +from ..core.config import DATABASE_URL + +Base = declarative_base() + + +def _normalize_url(url: str) -> str: + # SQLAlchemy 2.x wants "postgresql://"; Supabase/Heroku hand out "postgres://". + if url.startswith("postgres://"): + return "postgresql://" + url[len("postgres://"):] + return url + + +def _make_engine(url: str): + # check_same_thread=False is needed for SQLite under FastAPI's threadpool, + # and rejected by Postgres — hence the branch. + if url.startswith("sqlite"): + path = url.split("///", 1)[-1] + if path and path != ":memory:": + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + return create_engine( + url, + connect_args={"check_same_thread": False}, + pool_pre_ping=True, + ) + return create_engine(url, pool_pre_ping=True) + + +engine = _make_engine(_normalize_url(DATABASE_URL)) +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + + +def init_db() -> None: + """Create missing tables. Safe on every startup.""" + from . import models # noqa: F401 — registers the models on Base + + Base.metadata.create_all(bind=engine) + _ensure_columns() + + +def _ensure_columns() -> None: + """Hand-rolled migration: create_all never ALTERs, so older users.db files + are missing `tracked_at`. Existing rows get NULL and the retry loop fills them.""" + from sqlalchemy import inspect, text + + inspector = inspect(engine) + try: + columns = {c["name"] for c in inspector.get_columns("users")} + except Exception: + return # table not present yet — create_all will have made it + + if "tracked_at" not in columns: + with engine.begin() as conn: + conn.execute(text("ALTER TABLE users ADD COLUMN tracked_at TIMESTAMP")) + + +def get_db(): + """FastAPI dependency: one session per request.""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/models.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/models.py new file mode 100644 index 00000000..0a1e7071 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/models.py @@ -0,0 +1,48 @@ +"""User model — the single table backing user tracking.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from .db import Base + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + name: Mapped[str] = mapped_column(String(128), nullable=False) + institute: Mapped[str | None] = mapped_column(String(255), nullable=True) + + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + + is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utc_now, nullable=False) + last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + # NULL until the signup lands in central Supabase tracking, so the local + # DB doubles as the durable retry queue. + tracked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + def public_dict(self) -> dict: + """Serializable view, minus the password hash.""" + return { + "id": self.id, + "username": self.username, + "email": self.email, + "name": self.name, + "institute": self.institute, + "is_verified": self.is_verified, + "created_at": self.created_at.isoformat() if self.created_at else None, + "last_login": self.last_login.isoformat() if self.last_login else None, + } diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/schemas.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/schemas.py new file mode 100644 index 00000000..d432130c --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/schemas.py @@ -0,0 +1,40 @@ +"""Pydantic request/response models for auth.""" + +from __future__ import annotations + +from pydantic import BaseModel, EmailStr, Field + + +class SignupRequest(BaseModel): + username: str = Field(..., min_length=3, max_length=64) + password: str = Field(..., min_length=6, max_length=128) + name: str = Field(..., min_length=1, max_length=128) + email: EmailStr + # Frontend pre-fills "personal", so this always has a value. + institute: str = Field(default="personal", min_length=1, max_length=255) + + +class LoginRequest(BaseModel): + username: str = Field(..., min_length=1, max_length=255) + password: str = Field(..., min_length=1, max_length=128) + + +class ProfileUpdateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=128) + email: EmailStr + institute: str = Field(default="personal", min_length=1, max_length=255) + + +class UserOut(BaseModel): + id: int + username: str + email: str + name: str + institute: str | None = None + is_verified: bool + created_at: str | None = None + last_login: str | None = None + + +class AuthResponse(BaseModel): + user: UserOut diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/security.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/security.py new file mode 100644 index 00000000..a378a8cf --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/security.py @@ -0,0 +1,46 @@ +"""Password hashing and signed-cookie sessions. + +bcrypt for passwords, itsdangerous for opaque signed cookies. No JWT. +""" + +from __future__ import annotations + +import bcrypt +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer + +from ..core.config import SECRET_KEY, SESSION_MAX_AGE + +_session_signer = URLSafeTimedSerializer(SECRET_KEY, salt="ren-session") + +SESSION_COOKIE = "ren_session" + + +# ── Passwords ───────────────────────────────────────────────────────────── + +def hash_password(plain: str) -> str: + return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + try: + return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) + except (ValueError, TypeError): + return False + + +# ── Session cookie ────────────────────────────────────────────────────────── + +def create_session_token(user_id: int) -> str: + """Sign the user id into an opaque, tamper-proof token.""" + return _session_signer.dumps({"uid": user_id}) + + +def read_session_token(token: str) -> int | None: + """Return the user id from a valid, unexpired token, else None.""" + if not token: + return None + try: + data = _session_signer.loads(token, max_age=SESSION_MAX_AGE) + return int(data["uid"]) + except (BadSignature, SignatureExpired, KeyError, ValueError, TypeError): + return None diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/tracking.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/tracking.py new file mode 100644 index 00000000..01124d93 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/auth/tracking.py @@ -0,0 +1,152 @@ +"""Push signups to a central Supabase table, using the local DB as the queue. + +`tracked_at IS NULL` means "not sent yet". We try once on signup, and a retry +loop sweeps the rest — so an outage (or no config at all) loses nothing, even +across restarts. Never blocks signup, never sends the password hash. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone +from urllib.parse import quote + +import httpx +from sqlalchemy import select + +from ..core.config import ( + APP_INSTANCE_ID, + SUPABASE_PUBLISHABLE_KEY, + SUPABASE_URL, + TRACKING_RETRY_INTERVAL, +) +from .db import SessionLocal +from .models import User + +logger = logging.getLogger("renaissance.auth.tracking") + + +def tracking_enabled() -> bool: + return bool(SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY) + + +def _post_signup(user: User) -> bool: + """POST one signup. True on success, including "already there". + + Plain insert, not PostgREST upsert — upsert needs read rights and trips the + insert-only RLS policy. Duplicates just 409 and we treat that as done. + """ + url = f"{SUPABASE_URL.rstrip('/')}/rest/v1/users" + headers = { + "apikey": SUPABASE_PUBLISHABLE_KEY, + "Authorization": f"Bearer {SUPABASE_PUBLISHABLE_KEY}", + "Content-Type": "application/json", + "Prefer": "return=minimal", + } + payload = { + "username": user.username, + "name": user.name, + "email": user.email, + "institute": user.institute, + "instance_id": APP_INSTANCE_ID, + } + try: + resp = httpx.post(url, headers=headers, json=payload, timeout=8.0) + except Exception as exc: # noqa: BLE001 + logger.warning("[tracking] insert failed (will retry): %s", exc) + return False + + # 409 = already recorded, probably from another instance. Not an error. + if resp.status_code < 300 or resp.status_code == 409: + return True + logger.warning( + "[tracking] Supabase returned %s (will retry): %s", + resp.status_code, + resp.text[:300], + ) + return False + + +def _flush_pending_sync() -> int: + """Push every not-yet-tracked user. Returns how many were pushed.""" + if not tracking_enabled(): + return 0 + + pushed = 0 + db = SessionLocal() + try: + pending = db.scalars(select(User).where(User.tracked_at.is_(None))).all() + for user in pending: + if _post_signup(user): + user.tracked_at = datetime.now(timezone.utc) + db.commit() + pushed += 1 + else: + db.rollback() # leave tracked_at NULL -> retried next cycle + finally: + db.close() + + if pushed: + logger.info("[tracking] Pushed %d signup(s) to Supabase", pushed) + return pushed + + +def track_user_now(user_id: int) -> None: + """Try to push one signup right away. On failure the retry loop gets it.""" + if not tracking_enabled(): + logger.info("[tracking] Supabase not configured — skipping (id=%s)", user_id) + return + db = SessionLocal() + try: + user = db.get(User, user_id) + if user is None or user.tracked_at is not None: + return + if _post_signup(user): + user.tracked_at = datetime.now(timezone.utc) + db.commit() + logger.info("[tracking] Recorded signup %s centrally", user.email) + finally: + db.close() + + +def update_tracked_profile( + old_email: str, username: str, name: str, email: str, institute: str | None +) -> None: + """PATCH the central row after a profile edit. Never raises. + + Matched by the OLD email. If the row was never pushed, this matches nothing + and the pending insert carries the new values anyway. + """ + if not tracking_enabled(): + return + url = f"{SUPABASE_URL.rstrip('/')}/rest/v1/users?email=eq.{quote(old_email)}" + headers = { + "apikey": SUPABASE_PUBLISHABLE_KEY, + "Authorization": f"Bearer {SUPABASE_PUBLISHABLE_KEY}", + "Content-Type": "application/json", + "Prefer": "return=minimal", + } + payload = {"username": username, "name": name, "email": email, "institute": institute} + try: + resp = httpx.patch(url, headers=headers, json=payload, timeout=8.0) + if resp.status_code < 300: + logger.info("[tracking] Synced profile update for %s", email) + else: + logger.warning( + "[tracking] Profile sync returned %s: %s", + resp.status_code, + resp.text[:300], + ) + except Exception as exc: # noqa: BLE001 + logger.warning("[tracking] Profile sync failed: %s", exc) + + +async def retry_loop() -> None: + """Flush unsent signups on an interval. First pass runs immediately.""" + while True: + try: + await asyncio.to_thread(_flush_pending_sync) + except Exception as exc: # noqa: BLE001 — loop must never die + logger.warning("[tracking] retry loop error: %s", exc) + await asyncio.sleep(TRACKING_RETRY_INTERVAL) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/core/__init__.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/core/config.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/core/config.py new file mode 100644 index 00000000..0fdee8c0 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/core/config.py @@ -0,0 +1,91 @@ +"""Config constants and env vars, loaded once at import.""" + +import os + +# Local (non-Docker) runs need .env loaded by hand. In Docker compose already +# injected these, and load_dotenv never overrides existing vars, so it's a no-op. +try: + from dotenv import load_dotenv + + _cfg_dir = os.path.dirname(os.path.abspath(__file__)) + for _env_candidate in ( + os.path.join(_cfg_dir, "..", "..", "..", ".env"), # repo root + os.path.join(_cfg_dir, "..", "..", ".env"), # backend/ + ): + if os.path.isfile(_env_candidate): + load_dotenv(_env_candidate) + break +except ImportError: + pass + + +def _default_storage_root() -> str: + """Resolve storage root for both local and containerized runs.""" + this_dir = os.path.dirname(os.path.abspath(__file__)) + backend_root = os.path.abspath(os.path.join(this_dir, "..", "..")) + repo_root_candidate = os.path.abspath(os.path.join(backend_root, "..")) + + is_repo_layout = ( + os.path.isdir(os.path.join(repo_root_candidate, "backend")) + and os.path.isdir(os.path.join(repo_root_candidate, "frontend")) + ) + + if is_repo_layout: + return os.path.join(repo_root_candidate, "storage") + return os.path.join(backend_root, "storage") + +# FastAPI app metadata +APP_TITLE = "RenAIssance OCR API" +APP_VERSION = "2.0.0" + +# CORS origins allowed by the backend +CORS_ORIGINS = [ + "http://localhost:5173", + "http://localhost:5174", + "http://127.0.0.1:5173", +] + +# API-key format validation +MIN_API_KEY_LENGTH = 20 + +# Batch OCR +MAX_BATCH_SIZE = 4 + +# Persistent storage root for "My Files" +STORAGE_ROOT = os.getenv("STORAGE_ROOT", _default_storage_root()) + + +# ── Auth (all backend-only; the browser only ever sees a signed cookie) ── + +# Local accounts. SQLite on the storage volume by default — zero setup, works +# offline. Point at a postgresql:// URL if you'd rather use Postgres. +DATABASE_URL = os.getenv( + "DATABASE_URL", + f"sqlite:///{os.path.join(STORAGE_ROOT, 'users.db')}", +) + +# Signs session cookies. Set a stable value in production — the random fallback +# means every restart logs everyone out. +SECRET_KEY = os.getenv("SECRET_KEY") or os.urandom(32).hex() + +# Guards GET /api/admin/users. Unset = endpoint returns 404 instead of opening up. +ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "") + +# Deployed origin — only used to decide whether the session cookie is Secure. +PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://localhost:5173") + +# Session cookie lifetime (seconds). Default 30 days. +SESSION_MAX_AGE = int(os.getenv("SESSION_MAX_AGE", str(30 * 24 * 60 * 60))) + + +# ── Central signup tracking (Supabase REST) ────────────────────────────── +# The publishable key is safe to ship: the table has an INSERT-only RLS policy, +# so a leak can add a row and nothing else. Leave empty to disable tracking. +SUPABASE_URL = os.getenv("SUPABASE_URL", "") +SUPABASE_PUBLISHABLE_KEY = os.getenv("SUPABASE_PUBLISHABLE_KEY", "") + +# Optional label so you can tell which deployment a signup came from. +APP_INSTANCE_ID = os.getenv("APP_INSTANCE_ID", "") or os.getenv("HOSTNAME", "unknown") + +# How often (seconds) to retry signups that haven't reached Supabase yet. +TRACKING_RETRY_INTERVAL = int(os.getenv("TRACKING_RETRY_INTERVAL", "120")) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/core/font_registry.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/core/font_registry.py new file mode 100644 index 00000000..4431d39b --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/core/font_registry.py @@ -0,0 +1,28 @@ +"""Register DejaVu Sans so PDF export can render non-ASCII characters.""" + +import os +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont + +try: + font_paths = [ + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', # Linux (Debian/Ubuntu) + '/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf', # Linux (Fedora/RHEL) + '/usr/share/fonts/TTF/DejaVuSans.ttf', # Linux (Arch) + 'C:/Windows/Fonts/DejaVuSans.ttf', # Windows + '/System/Library/Fonts/Supplemental/DejaVuSans.ttf', # macOS + '/Library/Fonts/DejaVuSans.ttf', # macOS alternative + ] + + UNICODE_FONT_REGISTERED = False + for font_path in font_paths: + if os.path.exists(font_path): + pdfmetrics.registerFont(TTFont('DejaVuSans', font_path)) + UNICODE_FONT_REGISTERED = True + break + + if not UNICODE_FONT_REGISTERED: + print("Warning: DejaVu Sans font not found. PDF export may have limited Unicode support.") +except Exception as e: + UNICODE_FONT_REGISTERED = False + print(f"Warning: Could not register Unicode font for PDF: {e}") diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/core/rate_limiter.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/core/rate_limiter.py new file mode 100644 index 00000000..16e7eae9 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/core/rate_limiter.py @@ -0,0 +1,71 @@ +"""Sliding-window rate limiter, sized for the Gemini free tier.""" + +import time + + +class RateLimiter: + """5 requests/minute by default; batches book several slots at once.""" + + def __init__(self, max_requests: int = 5, window_seconds: int = 60): + self.max_requests = max_requests + self.window_seconds = window_seconds + self.request_times: list[float] = [] + + def _clean_old_requests(self): + """Remove requests outside the sliding window""" + cutoff = time.time() - self.window_seconds + self.request_times = [t for t in self.request_times if t > cutoff] + + def get_available_slots(self) -> int: + """Get number of requests that can be made right now""" + self._clean_old_requests() + return max(0, self.max_requests - len(self.request_times)) + + def can_proceed(self) -> tuple[bool, int]: + """Check if at least one request can proceed. Returns (can_proceed, wait_time_seconds)""" + self._clean_old_requests() + + if len(self.request_times) < self.max_requests: + return True, 0 + + # Calculate wait time until oldest request expires + oldest = min(self.request_times) + wait_time = int(oldest + self.window_seconds - time.time()) + 1 + return False, max(0, wait_time) + + def record_request(self): + """Record that a request was made""" + self.request_times.append(time.time()) + + def record_requests(self, count: int): + """Record multiple requests at once (for batch processing)""" + now = time.time() + for _ in range(count): + self.request_times.append(now) + + def get_status(self) -> dict: + """Get current rate limit status""" + self._clean_old_requests() + available = self.max_requests - len(self.request_times) + + if available > 0: + return { + "ready": True, + "wait_seconds": 0, + "available_slots": available, + "requests_in_window": len(self.request_times) + } + + # Calculate wait time + oldest = min(self.request_times) if self.request_times else time.time() + wait_time = int(oldest + self.window_seconds - time.time()) + 1 + + return { + "ready": False, + "wait_seconds": max(0, wait_time), + "available_slots": 0, + "requests_in_window": len(self.request_times) + } + + +rate_limiter = RateLimiter(max_requests=5, window_seconds=60) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/main.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/main.py new file mode 100644 index 00000000..765c63da --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/main.py @@ -0,0 +1,73 @@ +"""FastAPI app wiring: mount every router, start the signup-tracking loop. + +Logic lives under app.api / app.services / app.core / app.schemas / app.utils. +""" + +import os +import sys + +# `preprocessing` is a sibling of `app`, not a submodule of it. +backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if backend_dir not in sys.path: + sys.path.insert(0, backend_dir) + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .core.config import APP_TITLE, APP_VERSION, CORS_ORIGINS +from .api.health import router as health_router +from .api.ocr import router as ocr_router +from .api.preprocess import router as preprocess_router +from .api.export import router as export_router +from .api.layout_detection import router as layout_detection_router +from .api.dataset import router as dataset_router +from .api.recognition import router as recognition_router +from .api.llm_postprocess import router as llm_router +from .api.storage import router as storage_router +from .api.auth import router as auth_router +from .auth.db import init_db +from .auth.tracking import retry_loop, tracking_enabled + +app = FastAPI(title=APP_TITLE, version=APP_VERSION) + + +@app.on_event("startup") +async def _startup() -> None: + init_db() # no-op once the tables exist + # Pushes signups that never reached Supabase, including ones from while it + # was down or unconfigured. + if tracking_enabled(): + import asyncio + + app.state.tracking_task = asyncio.create_task(retry_loop()) + + +@app.on_event("shutdown") +async def _shutdown() -> None: + task = getattr(app.state, "tracking_task", None) + if task is not None: + task.cancel() + +app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(health_router) +app.include_router(ocr_router) +app.include_router(preprocess_router) +app.include_router(export_router) +app.include_router(layout_detection_router) +app.include_router(dataset_router) +app.include_router(recognition_router) +app.include_router(llm_router) +app.include_router(storage_router) +app.include_router(auth_router) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000, reload=True) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/schemas/__init__.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/schemas/ocr.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/schemas/ocr.py new file mode 100644 index 00000000..93a6c0f9 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/schemas/ocr.py @@ -0,0 +1,75 @@ +"""Request/response models for the OCR, preprocess and export endpoints.""" + +from typing import Optional +from pydantic import BaseModel + + +class OCRResponse(BaseModel): + success: bool + transcript: Optional[str] = None + error: Optional[str] = None + model_used: str + processing_time_ms: int + + + +class ExportRequest(BaseModel): + transcripts: dict # {page_number: transcript_text} + format: str # "txt", "docx", "pdf" + + +class PreprocessRequest(BaseModel): + image_data: str # Base64 encoded image with data URL prefix + operations: list # List of {op, params, enabled} dicts + preview_mode: bool = False # Use faster algorithms for preview + + +# One per provider, each with its own default model. + +class GeminiOCRRequest(BaseModel): + image_data: str + model: str = "gemini-3.1-flash-lite" + custom_prompt: Optional[str] = None + + +class ChatGPTOCRRequest(BaseModel): + image_data: str + model: str = "gpt-4o" + custom_prompt: Optional[str] = None + +class DeepSeekOCRRequest(BaseModel): + image_data: str + model: str = "deepseek-chat" + custom_prompt: Optional[str] = None + + +class QwenOCRRequest(BaseModel): + image_data: str + model: str = "qwen-vl-max" + custom_prompt: Optional[str] = None + + +class BatchOCRItem(BaseModel): + page_index: int + image_data: str + + +class BatchOCRRequest(BaseModel): + items: list[BatchOCRItem] + model: str = "gemini-3.1-flash-lite" + custom_prompt: Optional[str] = None + + +class BatchOCRResultItem(BaseModel): + page_index: int + success: bool + transcript: Optional[str] = None + error: Optional[str] = None + processing_time_ms: int = 0 + + +class BatchOCRResponse(BaseModel): + results: list[BatchOCRResultItem] + total_processing_time_ms: int + successful_count: int + failed_count: int diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/schemas/recognition.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/schemas/recognition.py new file mode 100644 index 00000000..cfa30439 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/schemas/recognition.py @@ -0,0 +1,40 @@ +"""Pydantic schemas for local (CRNN / TrOCR) recognition APIs.""" + +from typing import Optional +from pydantic import BaseModel + + +class LocalModelInfo(BaseModel): + """Single available local OCR model.""" + id: str + name: str + model_type: str + path: str + + +class LocalModelsResponse(BaseModel): + """Response from /api/local-recognition-models.""" + models: list[LocalModelInfo] + + +class LocalRecognizeRequest(BaseModel): + """Request body for /api/local-recognize.""" + image_data: str # base64-encoded page image + boxes: list[list[list[float]]] # list of polygons, each polygon = list of [x,y] points + model_id: str # model id (e.g. "crnn:best_crnn") + + +class LocalRecognizeResult(BaseModel): + """Single box recognition result.""" + box_index: int + text: str + + +class LocalRecognizeResponse(BaseModel): + """Response from /api/local-recognize.""" + results: list[LocalRecognizeResult] + processing_time_ms: int + model_used: str + model_type: str + device: str + error: Optional[str] = None diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/__init__.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/dataset_builder.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/dataset_builder.py new file mode 100644 index 00000000..f99bee73 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/dataset_builder.py @@ -0,0 +1,311 @@ +"""Build line-level OCR training datasets as ZIPs. + +Boxes get sorted into reading order, paired with transcript lines, cropped, and +zipped. One page in memory at a time — a book's worth of full-res scans won't fit. +""" + +import csv +import io +import re +import zipfile +from typing import Any, Dict, List, Tuple + +import cv2 +import numpy as np + + +# ── Alignment ─────────────────────────────────────────────────────── + +def _box_centroid(box: List[List[float]]) -> Tuple[float, float]: + """Compute centroid of a 4-point polygon.""" + pts = np.array(box, dtype=np.float32) + return float(pts[:, 0].mean()), float(pts[:, 1].mean()) + + +def sort_boxes_reading_order(boxes: List[List[List[float]]]) -> List[int]: + """Indices of `boxes` in reading order: top to bottom, left to right.""" + if not boxes: + return [] + + centroids = [_box_centroid(b) for b in boxes] + + ys = np.array([c[1] for c in centroids]) + if len(ys) == 0: + return [] + + # Row threshold scales with the typical line height, so it survives both + # thumbnail-sized and 6000px scans. + heights = [] + for b in boxes: + pts = np.array(b, dtype=np.float32) + heights.append(float(pts[:, 1].max() - pts[:, 1].min())) + med_h = float(np.median(heights)) if heights else 20.0 + row_thresh = max(med_h * 0.5, 10.0) + + order = sorted(range(len(centroids)), key=lambda i: centroids[i][1]) + + rows: List[List[int]] = [] + current_row: List[int] = [order[0]] + current_y = centroids[order[0]][1] + + for idx in order[1:]: + cy = centroids[idx][1] + if abs(cy - current_y) < row_thresh: + current_row.append(idx) + else: + rows.append(current_row) + current_row = [idx] + current_y = cy + rows.append(current_row) + + result = [] + for row in rows: + row.sort(key=lambda i: centroids[i][0]) + result.extend(row) + + return result + + +def align_boxes_with_transcript( + boxes: List[List[List[float]]], + lines: List[str], +) -> Tuple[List[Tuple[List[List[float]], str]], int, int]: + """Zip boxes (in reading order) with transcript lines. + + -> ([(box, text), ...], num_boxes, num_lines). Extra of either is dropped, + and the counts let the caller warn about the mismatch. + """ + sorted_indices = sort_boxes_reading_order(boxes) + sorted_boxes = [boxes[i] for i in sorted_indices] + + num_pairs = min(len(sorted_boxes), len(lines)) + pairs = [(sorted_boxes[i], lines[i]) for i in range(num_pairs)] + + return pairs, len(sorted_boxes), len(lines) + + +# ── Cropping ──────────────────────────────────────────────────────── + +def crop_line_image( + image: np.ndarray, + box: List[List[float]], + padding: int = 4, +) -> np.ndarray: + """Crop one line out of the page, with a few pixels of breathing room.""" + pts = np.array(box, dtype=np.float32) + x_min, y_min = int(pts[:, 0].min()), int(pts[:, 1].min()) + x_max, y_max = int(pts[:, 0].max()), int(pts[:, 1].max()) + + h, w = image.shape[:2] + x1 = max(0, x_min - padding) + y1 = max(0, y_min - padding) + x2 = min(w, x_max + padding) + y2 = min(h, y_max + padding) + + if x2 <= x1 or y2 <= y1: + return np.zeros((1, 1, 3), dtype=np.uint8) + + return image[y1:y2, x1:x2].copy() + + +# ── Filename helper ───────────────────────────────────────────────── + +def _safe_label(text: str, max_len: int = 30) -> str: + """Turn transcript text into something safe to use as a filename.""" + safe = re.sub(r"[^\w\s-]", "", text) + safe = re.sub(r"\s+", "_", safe.strip()) + return safe[:max_len] if safe else "line" + + +# ── Dataset Export ────────────────────────────────────────────────── + +def build_dataset_zip( + pages_data: List[Dict[str, Any]], + book_name: str = "dataset", +) -> io.BytesIO: + """ZIP of line crops plus a labels.csv. + + pages_data entries: {page_key, image_data (base64 data URL), boxes, lines}. + """ + import base64 + + buf = io.BytesIO() + csv_rows: List[Tuple[str, str]] = [] # (image_path, text) + + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for page in pages_data: + page_key = page["page_key"] + boxes = page["boxes"] + lines = page["lines"] + + page_num_match = re.search(r"(\d+)", str(page_key)) + page_dir = f"page_{int(page_num_match.group(1))}" if page_num_match else f"page_{page_key}" + + img_b64 = page["image_data"] + if "," in img_b64: + img_b64 = img_b64.split(",", 1)[1] + img_bytes = base64.b64decode(img_b64) + nparr = np.frombuffer(img_bytes, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + + if img is None: + continue + + pairs, _, _ = align_boxes_with_transcript(boxes, lines) + # Two lines with the same text would collide on filename. + seen_labels: Dict[str, int] = {} + + for idx, (box, text) in enumerate(pairs): + crop = crop_line_image(img, box) + label = _safe_label(text, max_len=80) + count = seen_labels.get(label, 0) + 1 + seen_labels[label] = count + label_with_suffix = label if count == 1 else f"{label}_{count}" + + img_filename = f"{book_name}/{page_dir}/{label_with_suffix}.png" + + _, png_data = cv2.imencode(".png", crop) + zf.writestr(img_filename, png_data.tobytes()) + + csv_rows.append((img_filename, text)) + + del crop + + del img, nparr, img_bytes + + # BOM so Excel opens the accented text correctly. + csv_buf = io.StringIO() + writer = csv.writer(csv_buf) + writer.writerow(["image", "text"]) + for path, text in csv_rows: + writer.writerow([path, text]) + csv_bytes = b'\xef\xbb\xbf' + csv_buf.getvalue().encode('utf-8') + zf.writestr(f"{book_name}/labels.csv", csv_bytes) + + buf.seek(0) + return buf + + +# ── Detection-only Dataset Export ────────────────────────────────── + +def _polygon_to_xyxy(box: List[List[float]]) -> Tuple[int, int, int, int]: + """Axis-aligned integer-pixel rect around a 4-point polygon.""" + pts = np.asarray(box, dtype=np.float32) + return ( + int(pts[:, 0].min()), + int(pts[:, 1].min()), + int(pts[:, 0].max()), + int(pts[:, 1].max()), + ) + + +def build_detection_dataset_zip( + pages_data: List[Dict[str, Any]], + book_name: str = "dataset", + bbox_format: str = "txt", +) -> io.BytesIO: + """ZIP of full page images plus their line boxes — no transcript needed. + + bbox_format: "txt" (x1 y1 x2 y2 per line), "json" (array of rects), + "yolo" (normalised cx cy w h + classes.txt), or "coco" (one annotations.json). + """ + import base64 + import json + + fmt = (bbox_format or "txt").lower() + if fmt not in ("txt", "json", "yolo", "coco"): + raise ValueError(f"Unsupported bbox_format: {bbox_format!r} (use 'txt', 'json', 'yolo', or 'coco')") + + buf = io.BytesIO() + + # COCO wants one file for the whole set, so accumulate across pages. + coco_images: List[Dict[str, Any]] = [] + coco_annotations: List[Dict[str, Any]] = [] + coco_image_id = 0 + coco_ann_id = 0 + + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for page in pages_data: + page_key = page["page_key"] + boxes = page.get("boxes", []) + + img_b64 = page["image_data"] + if "," in img_b64: + img_b64 = img_b64.split(",", 1)[1] + img_bytes = base64.b64decode(img_b64) + nparr = np.frombuffer(img_bytes, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + if img is None: + continue + + h, w = img.shape[:2] + stem = f"page_{page_key}" + jpg_name = f"{stem}.jpg" + _, jpg_data = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 95]) + zf.writestr(f"{book_name}/images/{jpg_name}", jpg_data.tobytes()) + + xyxy_boxes = [_polygon_to_xyxy(b) for b in boxes] + + if fmt == "txt": + lines = "\n".join( + f"{x1} {y1} {x2} {y2}" for (x1, y1, x2, y2) in xyxy_boxes + ) + payload = (lines + "\n").encode("utf-8") if lines else b"" + zf.writestr(f"{book_name}/bboxes/{stem}.txt", payload) + elif fmt == "json": + payload = json.dumps( + [[x1, y1, x2, y2] for (x1, y1, x2, y2) in xyxy_boxes], + indent=2, + ).encode("utf-8") + zf.writestr(f"{book_name}/bboxes/{stem}.json", payload) + elif fmt == "yolo": + yolo_lines = [] + for (x1, y1, x2, y2) in xyxy_boxes: + bw = max(0, x2 - x1) + bh = max(0, y2 - y1) + cx = (x1 + x2) / 2.0 + cy = (y1 + y2) / 2.0 + yolo_lines.append( + f"0 {cx / w:.6f} {cy / h:.6f} {bw / w:.6f} {bh / h:.6f}" + ) + payload = ("\n".join(yolo_lines) + "\n").encode("utf-8") if yolo_lines else b"" + zf.writestr(f"{book_name}/labels/{stem}.txt", payload) + elif fmt == "coco": + coco_image_id += 1 + coco_images.append({ + "id": coco_image_id, + "file_name": jpg_name, + "width": int(w), + "height": int(h), + }) + for (x1, y1, x2, y2) in xyxy_boxes: + coco_ann_id += 1 + bw = max(0, x2 - x1) + bh = max(0, y2 - y1) + coco_annotations.append({ + "id": coco_ann_id, + "image_id": coco_image_id, + "category_id": 1, + "bbox": [int(x1), int(y1), int(bw), int(bh)], + "area": int(bw * bh), + "iscrowd": 0, + "segmentation": [], + }) + + del img, nparr, img_bytes + + if fmt == "yolo": + zf.writestr(f"{book_name}/classes.txt", b"text\n") + elif fmt == "coco": + coco = { + "images": coco_images, + "annotations": coco_annotations, + "categories": [{"id": 1, "name": "text", "supercategory": "text"}], + } + zf.writestr( + f"{book_name}/annotations.json", + json.dumps(coco, indent=2).encode("utf-8"), + ) + + buf.seek(0) + return buf diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/export.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/export.py new file mode 100644 index 00000000..d4848414 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/export.py @@ -0,0 +1,161 @@ +"""Render the per-page transcripts into one txt / docx / pdf buffer.""" + +import io +import re +from docx import Document +from docx.shared import Pt +from docx.enum.text import WD_ALIGN_PARAGRAPH +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer +from reportlab.lib.units import inch + +from ..core.font_registry import UNICODE_FONT_REGISTERED + + +def _page_sort_key(x: str): + """Sort key that handles numeric ('5') and split page keys ('5a', '5b').""" + match = re.match(r'^(\d+)(.*)', x) + if match: + return (int(match.group(1)), match.group(2)) + return (0, x) + + +def _page_display_label(key: str) -> str: + return f"Page {key}" + + +def build_combined_transcript(transcripts: dict) -> str: + """One string, pages in order, separated by a rule.""" + pages = sorted(transcripts.keys(), key=_page_sort_key) + + sections = [] + separator = '\u2500' * 20 + for page in pages: + text = transcripts[page] + label = _page_display_label(page) + section = f"{label}\n{separator}\n{text}" + sections.append(section) + + return "\n\n".join(sections) + + +def build_txt_export(transcripts: dict) -> io.BytesIO: + """UTF-8 with a BOM, so Windows Notepad/Excel don't mangle accents.""" + combined = build_combined_transcript(transcripts) + utf8_bom = b'\xef\xbb\xbf' + buffer = io.BytesIO(utf8_bom + combined.encode('utf-8')) + buffer.seek(0) + return buffer + + +def build_docx_export(transcripts: dict) -> io.BytesIO: + doc = Document() + + title = doc.add_heading("Combined Transcript", 0) + title.alignment = WD_ALIGN_PARAGRAPH.CENTER + + doc.add_paragraph() # spacer + + pages = sorted(transcripts.keys(), key=_page_sort_key) + + for i, page in enumerate(pages): + text = transcripts[page] + + doc.add_heading(_page_display_label(page), level=1) + + separator = doc.add_paragraph("─" * 40) + separator.runs[0].font.size = Pt(10) + + for para_text in text.split('\n'): + if para_text.strip(): + para = doc.add_paragraph(para_text) + para.style.font.size = Pt(11) + + if i < len(pages) - 1: + doc.add_page_break() + + buffer = io.BytesIO() + doc.save(buffer) + buffer.seek(0) + return buffer + + +def build_pdf_export(transcripts: dict) -> io.BytesIO: + """Needs DejaVu Sans registered, or accents fall back to Helvetica.""" + buffer = io.BytesIO() + + doc = SimpleDocTemplate( + buffer, + pagesize=letter, + rightMargin=inch, + leftMargin=inch, + topMargin=inch, + bottomMargin=inch + ) + + styles = getSampleStyleSheet() + + font_name = 'DejaVuSans' if UNICODE_FONT_REGISTERED else 'Helvetica' + + title_style = ParagraphStyle( + 'CustomTitle', + parent=styles['Heading1'], + fontName=font_name, + fontSize=18, + spaceAfter=30, + alignment=1 # center + ) + + page_header_style = ParagraphStyle( + 'PageHeader', + parent=styles['Heading2'], + fontName=font_name, + fontSize=14, + spaceAfter=6, + textColor='#1e40af' + ) + + body_style = ParagraphStyle( + 'CustomBody', + parent=styles['Normal'], + fontName=font_name, + fontSize=11, + spaceAfter=6, + leading=14 + ) + + separator_style = ParagraphStyle( + 'Separator', + parent=styles['Normal'], + fontName=font_name, + fontSize=10, + spaceAfter=12, + textColor='#6b7280' + ) + + story = [] + story.append(Paragraph("Combined Transcript", title_style)) + story.append(Spacer(1, 20)) + + pages = sorted(transcripts.keys(), key=_page_sort_key) + + for page in pages: + text = transcripts[page] + + story.append(Paragraph(_page_display_label(page), page_header_style)) + story.append(Paragraph("─" * 50, separator_style)) + + # reportlab parses a mini-HTML in Paragraph, so escape the text first. + for line in text.split('\n'): + if line.strip(): + safe_line = line.replace('&', '&').replace('<', '<').replace('>', '>') + story.append(Paragraph(safe_line, body_style)) + else: + story.append(Spacer(1, 6)) + + story.append(Spacer(1, 30)) + + doc.build(story) + buffer.seek(0) + return buffer diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/layout_detection.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/layout_detection.py new file mode 100644 index 00000000..72c2d290 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/layout_detection.py @@ -0,0 +1,929 @@ +"""Layout-aware text-line detection with PaddleOCR. + +Two stages: detect page regions (PP-DocLayout), then run text detection inside +each text region. Models are built fresh per call and freed after — keeping them +resident blew past 8 GB VRAM. Budget 8 GB VRAM (GPU) or 8 GB RAM (CPU, slow). +""" + +import gc +import os +import time +from typing import List, Tuple, Dict, Any + +# Paddle's PIR path throws ConvertPirAttribute2RuntimeAttribute on these models. +os.environ["FLAGS_enable_pir_api"] = "0" +os.environ["FLAGS_enable_pir_in_executor"] = "0" + +import cv2 +import numpy as np +import psutil + +# paddlepaddle-gpu links libcuda.so.1 at import time, so importing it in a +# container without GPU access kills the server at boot. Import on first use +# instead and return a readable error if it fails. +_paddle = None +_create_model = None +_PaddleOCR = None +_PADDLE_IMPORT_ERROR: str = "" + + +def _ensure_paddle() -> None: + """Import paddle/paddleocr on first use. Raises RuntimeError if unavailable.""" + global _paddle, _create_model, _PaddleOCR, _PADDLE_IMPORT_ERROR + if _paddle is not None: + return + try: + import paddle as _p + from paddleocr import PaddleOCR as _O + # Single layout model, not PPStructureV3 — that pipeline downloads every + # sub-model it knows (tables, seals, formulas) even with the flags off. + from paddlex import create_model as _cm + _paddle = _p + _create_model = _cm + _PaddleOCR = _O + except Exception as exc: + _PADDLE_IMPORT_ERROR = str(exc) + raise RuntimeError( + f"PaddlePaddle failed to load: {exc}. " + "Start the container with GPU access: docker run --gpus all ... " + "and ensure CUDA 12.6 drivers are installed on the host. " + "GPU paddle install: " + "pip install paddlepaddle-gpu==3.3.0 " + "-i https://www.paddlepaddle.org.cn/packages/stable/cu126/" + ) from exc + + +# ── Tuning constants ────────────────────────────────────────────────── +TEXT_LABELS = {"text", "paragraph_title", "doc_title", "header"} +REGION_PADDING = 50 +LAYOUT_EXPAND = 2 +SCORE_THRESH = 0.5 +UPSCALE_MIN_H = 60 +NMS_IOU_THRESH = 0.3 +GAP_MULTIPLIER = 2.0 + +# Thresholds for the warnings shown to the user. +MIN_GPU_VRAM_GB = 8.0 +MIN_RAM_GB = 8.0 +LOW_RAM_GB = 4.0 + +# Tier ladder (env-overridable): enough free VRAM → server models on GPU, less → +# mobile on GPU, less still → CPU, picking server vs mobile there by free RAM. +TIER_SERVER_MIN_VRAM_GB = float(os.environ.get("TIER_SERVER_MIN_VRAM_GB", 6.0)) +TIER_MOBILE_MIN_VRAM_GB = float(os.environ.get("TIER_MOBILE_MIN_VRAM_GB", 2.0)) +TIER_CPU_SERVER_MIN_RAM_GB = float(os.environ.get("TIER_CPU_SERVER_MIN_RAM_GB", 8.0)) + +SERVER_MODELS = { + "layout": "PP-DocLayout_plus-L", + "det": "PP-OCRv5_server_det", + "rec": "PP-OCRv5_server_rec", +} +MOBILE_MODELS = { + "layout": "PP-DocLayout-S", + "det": "PP-OCRv5_mobile_det", + "rec": "PP-OCRv5_mobile_rec", +} + + +def _model_cache_root() -> str: + """Where PaddleX caches downloaded models (/official_models/). + + /paddle_models in Docker via PADDLE_PDX_CACHE_HOME, ~/.paddlex locally. + """ + root = ( + os.environ.get("PADDLE_PDX_MODEL_CACHE_HOME") + or os.environ.get("PADDLE_PDX_CACHE_HOME") + ) + if root: + return root + return os.path.join(os.path.expanduser("~"), ".paddlex") + + +def models_cached(use_gpu: bool = True) -> bool: + """True when this tier's models are already on disk. + + The UI uses this to warn about the multi-GB first-run download. A directory + only counts if it exists AND is non-empty (a killed download leaves an empty one). + """ + root = _model_cache_root() + names = SERVER_MODELS if use_gpu else MOBILE_MODELS + bases = [os.path.join(root, "official_models"), root] + for model_name in names.values(): + present = False + for base in bases: + d = os.path.join(base, model_name) + try: + if os.path.isdir(d) and any(os.scandir(d)): + present = True + break + except OSError: + continue + if not present: + return False + return True + + +def select_tier(use_gpu: bool) -> Dict[str, Any]: + """Pick device + model tier from currently-free VRAM/RAM. + + -> {device, tier, layout_model, det_model, rec_model, reason, + free_vram_gb, free_ram_gb}. `reason` is shown to the user as-is. + """ + _ensure_paddle() + info = check_system_resources(use_gpu) + free_vram = info["gpu_vram_free_gb"] + free_ram = info["available_ram_gb"] + + if use_gpu and info["gpu_available"]: + if free_vram >= TIER_SERVER_MIN_VRAM_GB: + return { + "device": "gpu", "tier": "server", + "layout_model": SERVER_MODELS["layout"], + "det_model": SERVER_MODELS["det"], + "rec_model": SERVER_MODELS["rec"], + "reason": ( + f"{free_vram:.1f} GB free VRAM — using server models on GPU." + ), + "free_vram_gb": free_vram, "free_ram_gb": free_ram, + } + if free_vram >= TIER_MOBILE_MIN_VRAM_GB: + return { + "device": "gpu", "tier": "mobile", + "layout_model": MOBILE_MODELS["layout"], + "det_model": MOBILE_MODELS["det"], + "rec_model": MOBILE_MODELS["rec"], + "reason": ( + f"{free_vram:.1f} GB free VRAM (< {TIER_SERVER_MIN_VRAM_GB:.0f} GB) " + "— using mobile models on GPU for lower memory footprint." + ), + "free_vram_gb": free_vram, "free_ram_gb": free_ram, + } + # Not enough VRAM even for mobile — fall through to CPU. + cpu_reason_prefix = ( + f"{free_vram:.1f} GB free VRAM (< {TIER_MOBILE_MIN_VRAM_GB:.0f} GB) " + "— falling back to CPU. " + ) + else: + cpu_reason_prefix = "" # CPU asked for, or no GPU at all + + if free_ram >= TIER_CPU_SERVER_MIN_RAM_GB: + return { + "device": "cpu", "tier": "server", + "layout_model": SERVER_MODELS["layout"], + "det_model": SERVER_MODELS["det"], + "rec_model": SERVER_MODELS["rec"], + "reason": ( + f"{cpu_reason_prefix}{free_ram:.1f} GB free RAM — " + "using server models on CPU (slow but accurate)." + ), + "free_vram_gb": free_vram, "free_ram_gb": free_ram, + } + return { + "device": "cpu", "tier": "mobile", + "layout_model": MOBILE_MODELS["layout"], + "det_model": MOBILE_MODELS["det"], + "rec_model": MOBILE_MODELS["rec"], + "reason": ( + f"{cpu_reason_prefix}{free_ram:.1f} GB free RAM " + f"(< {TIER_CPU_SERVER_MIN_RAM_GB:.0f} GB) — " + "using mobile models on CPU to avoid OOM." + ), + "free_vram_gb": free_vram, "free_ram_gb": free_ram, + } + + +# ── Resource check ──────────────────────────────────────────────────── + +def check_system_resources(use_gpu: bool) -> Dict[str, Any]: + """Inspect GPU VRAM (device 0) and system RAM. + + -> {warnings, gpu_available, gpu_vram_gb, gpu_vram_free_gb, + available_ram_gb, total_ram_gb}. GPU numbers are 0 when there's no GPU. + """ + warnings: List[str] = [] + + gpu_available = False + gpu_vram_gb = 0.0 + gpu_vram_free_gb = 0.0 + + try: + if _paddle is not None: + gpu_available = bool(_paddle.device.is_compiled_with_cuda()) + if gpu_available: + props = _paddle.device.cuda.get_device_properties(0) + gpu_vram_gb = props.total_memory / (1024 ** 3) + reserved_bytes = _paddle.device.cuda.memory_reserved(0) + gpu_vram_free_gb = max(0.0, gpu_vram_gb - reserved_bytes / (1024 ** 3)) + except Exception: + pass + + if use_gpu: + if not gpu_available: + warnings.append( + "GPU not available in this environment. " + "Running on CPU instead — this is much slower and requires >= 8 GB RAM." + ) + elif gpu_vram_gb < MIN_GPU_VRAM_GB: + warnings.append( + f"WARNING: GPU VRAM is {gpu_vram_gb:.1f} GB, but a minimum of " + f"{MIN_GPU_VRAM_GB:.0f} GB is recommended for the server-class models " + "(PP-DocLayout_plus-L + PP-OCRv5_server_det). " + "You may experience out-of-memory errors or degraded performance." + ) + elif gpu_vram_free_gb < 4.0: + warnings.append( + f"WARNING: Only {gpu_vram_free_gb:.1f} GB of VRAM is currently free. " + "Other processes may be consuming GPU memory. " + "Consider freeing GPU memory before running detection." + ) + + mem = psutil.virtual_memory() + total_ram_gb = mem.total / (1024 ** 3) + available_ram_gb = mem.available / (1024 ** 3) + + if available_ram_gb < LOW_RAM_GB: + warnings.append( + f"CRITICAL: Only {available_ram_gb:.1f} GB of RAM is available " + f"(total: {total_ram_gb:.1f} GB). " + "The system may freeze or be killed by the OOM killer during detection. " + f"At least {MIN_RAM_GB:.0f} GB of free RAM is strongly recommended." + ) + elif not use_gpu and available_ram_gb < MIN_RAM_GB: + warnings.append( + f"WARNING: Running on CPU with only {available_ram_gb:.1f} GB of free RAM. " + f"At least {MIN_RAM_GB:.0f} GB RAM is recommended for CPU-mode detection " + "with server-class models. Processing may be very slow." + ) + + return { + "warnings": warnings, + "gpu_available": gpu_available, + "gpu_vram_gb": gpu_vram_gb, + "gpu_vram_free_gb": gpu_vram_free_gb, + "available_ram_gb": available_ram_gb, + "total_ram_gb": total_ram_gb, + } + + +def _free_memory(): + """Aggressively free Python + GPU memory.""" + gc.collect() + try: + if _paddle is not None and _paddle.device.is_compiled_with_cuda(): + _paddle.device.cuda.empty_cache() + except Exception: + pass + + +# ── Layout helpers ──────────────────────────────────────────────────── + +def _resize_for_layout(image: np.ndarray, + max_side: int = 1500) -> Tuple[np.ndarray, float]: + h, w = image.shape[:2] + mx = max(h, w) + if mx <= max_side: + return image, 1.0 + s = max_side / mx + return cv2.resize(image, (int(w * s), int(h * s)), + interpolation=cv2.INTER_AREA), s + + +def _box_area(box): + return max(0, box[2] - box[0]) * max(0, box[3] - box[1]) + + +def _iou_xyxy(a, b): + ix1, iy1 = max(a[0], b[0]), max(a[1], b[1]) + ix2, iy2 = min(a[2], b[2]), min(a[3], b[3]) + inter = max(0, ix2 - ix1) * max(0, iy2 - iy1) + if inter == 0: + return 0.0 + return inter / float(_box_area(a) + _box_area(b) - inter) + + +def _suppress_overlapping(layout_boxes, iou_thresh=0.3): + filtered = [] + for box in layout_boxes: + keep = True + for kept in filtered: + if box["label"] != kept["label"]: + continue + if _iou_xyxy(box["bbox"], kept["bbox"]) > iou_thresh: + if _box_area(box["bbox"]) < _box_area(kept["bbox"]): + filtered.remove(kept) + else: + keep = False + break + if keep: + filtered.append(box) + return filtered + + +def _remove_margin_boxes(layout_boxes, page_width): + out = [] + for b in layout_boxes: + x1, _, x2, _ = b["bbox"] + cx = (x1 + x2) / 2 + if cx < page_width * 0.12 or cx > page_width * 0.88: + continue + out.append(b) + return out + + +def _merge_title_blocks(layout_boxes, vertical_thresh=70): + merged = [] + for label in ("doc_title", "text"): + boxes = sorted( + [b for b in layout_boxes if b["label"] == label], + key=lambda x: x["bbox"][1], + ) + cur = None + for b in boxes: + if cur is None: + cur = b.copy(); continue + if b["bbox"][1] - cur["bbox"][3] < vertical_thresh: + cur["bbox"] = [ + min(cur["bbox"][0], b["bbox"][0]), + min(cur["bbox"][1], b["bbox"][1]), + max(cur["bbox"][2], b["bbox"][2]), + max(cur["bbox"][3], b["bbox"][3]), + ] + else: + merged.append(cur); cur = b.copy() + if cur: + merged.append(cur) + for b in layout_boxes: + if b["label"] not in ("doc_title", "text"): + merged.append(b) + return merged + + +# ── Text-line helpers ───────────────────────────────────────────────── + +def _poly_bounds(poly): + pts = np.asarray(poly, dtype=np.float32) + xmn, ymn = pts[:, 0].min(), pts[:, 1].min() + xmx, ymx = pts[:, 0].max(), pts[:, 1].max() + return xmn, ymn, xmx, ymx, (xmn+xmx)*0.5, (ymn+ymx)*0.5, xmx-xmn, ymx-ymn + + +def _nms(boxes, scores, iou_thresh=NMS_IOU_THRESH): + if not boxes: + return [] + order = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True) + keep, suppressed = [], set() + for i in order: + if i in suppressed: + continue + keep.append(i) + bi = _poly_bounds(boxes[i])[:4] + for j in order: + if j in suppressed or j == i: + continue + bj = _poly_bounds(boxes[j])[:4] + if _iou_xyxy(bi, bj) > iou_thresh: + suppressed.add(j) + return keep + + +def _resolve_vertical_overlaps(boxes, thresh=0.30): + if len(boxes) < 2: + return boxes + rects = [ + [float(b[:, 0].min()), float(b[:, 1].min()), + float(b[:, 0].max()), float(b[:, 1].max())] + for b in boxes + ] + order = sorted(range(len(rects)), key=lambda i: rects[i][1]) + rects = [rects[i] for i in order] + for i in range(len(rects)): + yi0, yi1 = rects[i][1], rects[i][3] + hi = yi1 - yi0 + if hi <= 0: + continue + for j in range(i + 1, len(rects)): + yj0, yj1 = rects[j][1], rects[j][3] + ov_top, ov_bot = max(yi0, yj0), min(yi1, yj1) + ov = ov_bot - ov_top + if ov <= 0: + break + hj = yj1 - yj0 + if hj <= 0: + continue + if ov / min(hi, hj) > thresh: + mid = (ov_top + ov_bot) / 2.0 + rects[i][3] = mid + rects[j][1] = mid + yi1 = mid + result = [] + for r in rects: + xmn, ymn, xmx, ymx = r + if xmx > xmn and ymx > ymn: + result.append(np.array( + [[xmn, ymn], [xmx, ymn], [xmx, ymx], [xmn, ymx]], + dtype=np.float32, + )) + return result + + +def _merge_into_lines(raw_boxes, img_w, img_h, gap_multiplier=GAP_MULTIPLIER): + if not raw_boxes: + return [] + bounds = [_poly_bounds(b) for b in raw_boxes] + heights = np.array([b[7] for b in bounds], dtype=np.float32) + widths = np.array([b[6] for b in bounds], dtype=np.float32) + med_h = float(np.median(heights)) + med_w = float(np.median(widths)) + h_thresh = 0.5 * med_h + gap_limit = gap_multiplier * med_w + MIN_W, MIN_H, MAX_H = 10.0, 10.0, 6.0 * med_h + + print(f"[_merge_into_lines] {len(raw_boxes)} raw boxes | " + f"med_h={med_h:.1f} med_w={med_w:.1f} | " + f"h_thresh={h_thresh:.1f} gap_limit={gap_limit:.1f} MAX_H={MAX_H:.1f} | " + f"img_w={img_w} img_h={img_h}") + print(f"[_merge_into_lines] height stats: " + f"min={heights.min():.1f} p25={float(np.percentile(heights,25)):.1f} " + f"p50={med_h:.1f} p75={float(np.percentile(heights,75)):.1f} max={heights.max():.1f}") + print(f"[_merge_into_lines] sample box bounds (first 3): " + f"{[b[:4] for b in bounds[:3]]}") + + filtered = [ + (b, bnd) for b, bnd in zip(raw_boxes, bounds) + if bnd[6] >= MIN_W and MIN_H <= bnd[7] <= MAX_H + ] + print(f"[_merge_into_lines] after h/w filter: {len(filtered)} boxes remain") + if not filtered: + return [] + filtered.sort(key=lambda x: x[1][5]) + + lines = [] + for box, bnd in filtered: + cy = bnd[5] + assigned = False + for line in lines: + line_cy = sum(b[1][5] for b in line) / len(line) + if abs(cy - line_cy) < h_thresh: + line.append((box, bnd)) + assigned = True + break + if not assigned: + lines.append([(box, bnd)]) + + merged = [] + for line in lines: + line.sort(key=lambda x: x[1][0]) + groups = [[line[0]]] + for item in line[1:]: + if item[1][0] - groups[-1][-1][1][2] <= gap_limit: + groups[-1].append(item) + else: + groups.append([item]) + for grp in groups: + xmn = max(0, min(b[1][0] for b in grp)) + ymn = max(0, min(b[1][1] for b in grp)) + xmx = min(img_w, max(b[1][2] for b in grp)) + ymx = min(img_h, max(b[1][3] for b in grp)) + if xmx > xmn and ymx > ymn: + merged.append(np.array( + [[xmn, ymn], [xmx, ymn], [xmx, ymx], [xmn, ymx]], + dtype=np.float32, + )) + + merged = _resolve_vertical_overlaps(merged) + merged.sort(key=lambda b: (float(b[:, 1].min()), float(b[:, 0].min()))) + return merged + + +def _filter_boxes_by_page_size(boxes, img_h, img_w): + """Drop boxes too small to be a text line, scaled to the median box height. + + Thresholds used to come from page area, which threw away every real line on + big scans (6048x4536) because page_area * 0.0001 dwarfed a line box. + """ + if not boxes: + return [] + + heights = np.array( + [float(np.asarray(b, dtype=np.float32)[:, 1].max() + - np.asarray(b, dtype=np.float32)[:, 1].min()) + for b in boxes], + dtype=np.float32, + ) + median_height = float(np.median(heights)) + + min_area = max(20.0, median_height * median_height * 0.5) + min_width = max(5.0, median_height * 0.5) + min_height = max(5.0, median_height * 0.5) + + print(f"[_filter_boxes_by_page_size] img_h={img_h} img_w={img_w} " + f"boxes={len(boxes)} median_height={median_height:.1f} " + f"min_area={min_area:.1f} min_width={min_width:.1f} " + f"min_height={min_height:.1f}") + + kept = [] + for i, box in enumerate(boxes): + pts = np.asarray(box, dtype=np.float32) + w = float(pts[:, 0].max() - pts[:, 0].min()) + h = float(pts[:, 1].max() - pts[:, 1].min()) + area = w * h + print(f" BOX BEFORE FILTER: width={w:.1f} height={h:.1f} area={area:.1f}") + keep = (w >= min_width) and (h >= min_height) and (area >= min_area) + print(f" box[{i}]: x=[{pts[:,0].min():.0f},{pts[:,0].max():.0f}] " + f"y=[{pts[:,1].min():.0f},{pts[:,1].max():.0f}] " + f"w={w:.1f} h={h:.1f} area={area:.1f} " + f"({'KEEP' if keep else 'DROP'})") + if keep: + kept.append(box) + return kept + + +# ── Debug visualisation ─────────────────────────────────────────────── + +def _save_debug_image( + image: np.ndarray, + boxes, + path: str, + color=(0, 255, 0), + thickness: int = 2, +) -> None: + """Draw boxes on a copy of the image and write it to path. + + Accepts either 4-point polygons or dicts with a 'bbox' [x1,y1,x2,y2] key. + """ + dir_part = os.path.dirname(path) + if dir_part: + os.makedirs(dir_part, exist_ok=True) + + vis = image.copy() + for box in boxes: + if isinstance(box, dict): + x1, y1, x2, y2 = [int(c) for c in box["bbox"]] + cv2.rectangle(vis, (x1, y1), (x2, y2), color, thickness) + else: + pts = np.asarray(box, dtype=np.int32).reshape((-1, 1, 2)) + cv2.polylines(vis, [pts], True, color, thickness) + cv2.imwrite(path, vis) + print(f"[debug] saved {path} ({len(boxes)} boxes)") + + +# ── Core detection ──────────────────────────────────────────────────── + +def detect_layout(image: np.ndarray, device: str = "gpu", + max_layout_side: int = 1500, + model_name: str = "PP-DocLayout_plus-L") -> list: + """Run layout detection and return cleaned layout boxes.""" + resized, scale = _resize_for_layout(image, max_layout_side) + + print(f"[detect_layout] image={image.shape}, resized={resized.shape}, " + f"scale={scale:.3f}, model={model_name}, device={device}") + + _ensure_paddle() + pipeline = None + try: + _free_memory() # drop stale allocations before loading a multi-GB model + pipeline = _create_model(model_name=model_name, device=device) + results = pipeline.predict(resized, batch_size=1) + except (MemoryError, RuntimeError) as exc: + # Paddle reports GPU OOM as a plain RuntimeError. + _free_memory() + if pipeline is not None: + try: + del pipeline + except Exception: + pass + raise RuntimeError( + f"Out-of-memory during layout detection ({device.upper()}). " + "Ensure at least 8 GB GPU VRAM is available for GPU mode, " + "or at least 8 GB free RAM for CPU mode. " + f"Original error: {exc}" + ) from exc + + layout_boxes = [] + for res in results: + for b in res["boxes"]: + label = b["label"] + score = float(b["score"]) + x1, y1, x2, y2 = b["coordinate"] + layout_boxes.append({ + "label": label, + "bbox": [int(x1 / scale), int(y1 / scale), + int(x2 / scale), int(y2 / scale)], + "score": score, + }) + + print(f"[detect_layout] raw layout boxes: {len(layout_boxes)}") + for b in layout_boxes: + print(f" {b['label']:20s} score={b['score']:.2f} bbox={b['bbox']}") + + layout_boxes = _suppress_overlapping(layout_boxes) + layout_boxes = _remove_margin_boxes(layout_boxes, image.shape[1]) + layout_boxes = _merge_title_blocks(layout_boxes) + + print(f"[detect_layout] after post-processing: {len(layout_boxes)} boxes") + + del pipeline, results + _free_memory() + + return layout_boxes + + +def detect_text_lines(image: np.ndarray, layout: list, + device: str = "gpu", + det_model_name: str = "PP-OCRv5_server_det", + rec_model_name: str = "PP-OCRv5_server_rec", + region_padding: int = REGION_PADDING, + layout_expand: int = LAYOUT_EXPAND, + score_thresh: float = SCORE_THRESH, + upscale_min_h: int = UPSCALE_MIN_H, + nms_iou_thresh: float = NMS_IOU_THRESH, + gap_multiplier: float = GAP_MULTIPLIER, + debug_dir: str = "") -> list: + """Detect line-level bounding boxes for all text regions.""" + + print(f"[detect_text_lines] image={image.shape}, " + f"{len(layout)} layout regions, det_model={det_model_name}, " + f"rec_model={rec_model_name}") + + _ensure_paddle() + ocr = None + try: + _free_memory() + ocr = _PaddleOCR( + text_detection_model_name=det_model_name, + text_recognition_model_name=rec_model_name, + use_doc_orientation_classify=False, + use_doc_unwarping=False, + use_textline_orientation=False, + device=device, + lang="en", + ) + except (MemoryError, RuntimeError) as exc: + _free_memory() + raise RuntimeError( + f"Out-of-memory while loading text-detection model ({device.upper()}). " + "Ensure at least 8 GB GPU VRAM is available for GPU mode, " + "or at least 8 GB free RAM for CPU mode. " + f"Original error: {exc}" + ) from exc + + img_h, img_w = image.shape[:2] + all_raw, all_scores = [], [] + + for region in layout: + if region["label"] not in TEXT_LABELS: + continue + x1, y1, x2, y2 = [int(c) for c in region["bbox"]] + x1e = max(0, x1 - layout_expand) + y1e = max(0, y1 - layout_expand) + x2e = min(img_w, x2 + layout_expand) + y2e = min(img_h, y2 + layout_expand) + if x2e <= x1e or y2e <= y1e: + continue + + crop = image[y1e:y2e, x1e:x2e] + if crop.size == 0: + continue + + sc = 1.0 + if crop.shape[0] < upscale_min_h: + sc = 2.0 + crop = cv2.resize(crop, None, fx=sc, fy=sc, + interpolation=cv2.INTER_CUBIC) + + pad_scaled = int(region_padding * sc) + padded = cv2.copyMakeBorder( + crop, pad_scaled, pad_scaled, pad_scaled, pad_scaled, + borderType=cv2.BORDER_CONSTANT, value=(255, 255, 255), + ) + del crop + + ox = x1e - region_padding + oy = y1e - region_padding + + try: + crop_results = ocr.predict(padded) + except (MemoryError, RuntimeError) as exc: + del padded + if ocr is not None: + try: + del ocr + except Exception: + pass + _free_memory() + raise RuntimeError( + f"Out-of-memory during text-line detection on region {region['bbox']} " + f"({device.upper()}). " + "Ensure at least 8 GB GPU VRAM (GPU mode) or 8 GB free RAM (CPU mode). " + f"Original error: {exc}" + ) from exc + del padded + + n = 0 + _logged_raw = 0 + for res in crop_results: + polys = res.get("dt_polys", []) + scores = res.get("dt_scores", [1.0] * len(polys)) + for poly, score in zip(polys, scores): + if score < score_thresh: + continue + arr = np.array(poly, dtype=np.float32) + if _logged_raw < 2: + print(f" [raw poly {_logged_raw}] shape={arr.shape} " + f"raw_coords={arr.tolist()} sc={sc} ox={ox} oy={oy}") + arr /= sc + arr[:, 0] += ox + arr[:, 1] += oy + if _logged_raw < 2: + print(f" [raw poly {_logged_raw}] after_transform: " + f"x=[{arr[:,0].min():.0f},{arr[:,0].max():.0f}] " + f"y=[{arr[:,1].min():.0f},{arr[:,1].max():.0f}]") + _logged_raw += 1 + if arr[:, 0].max() < x1e or arr[:, 0].min() > x2e: + continue + if arr[:, 1].max() < y1e or arr[:, 1].min() > y2e: + continue + all_raw.append(arr) + all_scores.append(float(score)) + n += 1 + del crop_results + + print(f" [{region['label']}] bbox={region['bbox']} " + f"scale={sc:.1f}x -> {n} raw boxes") + + print(f"[detect_text_lines] total raw boxes: {len(all_raw)}") + + if debug_dir: + _save_debug_image( + image, all_raw, + os.path.join(debug_dir, "page_raw_text_boxes.png"), + color=(0, 0, 255), + ) + + keep_idx = _nms(all_raw, all_scores, iou_thresh=nms_iou_thresh) + all_raw = [all_raw[i] for i in keep_idx] + print(f"[detect_text_lines] after NMS: {len(all_raw)}") + + if debug_dir: + _save_debug_image( + image, all_raw, + os.path.join(debug_dir, "page_after_nms.png"), + color=(0, 165, 255), + ) + + if all_raw: + print(f"[detect_text_lines] coordinate sanity: " + f"img_w={img_w} img_h={img_h}") + for idx, b in enumerate(all_raw[:5]): + pts = np.asarray(b, dtype=np.float32) + bw = float(pts[:, 0].max() - pts[:, 0].min()) + bh = float(pts[:, 1].max() - pts[:, 1].min()) + print(f" [pre-merge box {idx}] " + f"x=[{pts[:,0].min():.0f},{pts[:,0].max():.0f}] " + f"y=[{pts[:,1].min():.0f},{pts[:,1].max():.0f}] " + f"w={bw:.1f} h={bh:.1f} area={bw*bh:.1f}") + + merged = _merge_into_lines(all_raw, img_w, img_h, gap_multiplier=gap_multiplier) + del all_raw + + before = len(merged) + merged = _filter_boxes_by_page_size(merged, img_h, img_w) + print(f"[detect_text_lines] after page filter: {len(merged)} " + f"(removed {before - len(merged)})") + + if debug_dir: + _save_debug_image( + image, merged, + os.path.join(debug_dir, "page_final_lines.png"), + color=(0, 255, 0), + ) + + del ocr + _free_memory() + + return merged + + +# ── Public orchestrator ─────────────────────────────────────────────── + +def run_layout_aware_detection( + image: np.ndarray, + use_gpu: bool = False, + layout_model_name: str = "PP-DocLayout_plus-L", + det_model_name: str = "PP-OCRv5_server_det", + rec_model_name: str = "PP-OCRv5_server_rec", + region_padding: int = REGION_PADDING, + layout_expand: int = LAYOUT_EXPAND, + score_thresh: float = SCORE_THRESH, + upscale_min_h: int = UPSCALE_MIN_H, + nms_iou_thresh: float = NMS_IOU_THRESH, + gap_multiplier: float = GAP_MULTIPLIER, + debug_dir: str = "", +) -> Tuple[List[List[List[float]]], List[str]]: + """Layout detection then text-line detection. + + -> (4-point polygons in page coordinates, resource warnings). Show the + warnings to the user — they explain why a run is slow or about to OOM. + """ + # Silently drop to CPU rather than fail if paddle can't use the GPU. + cuda_compiled = False + try: + _ensure_paddle() + cuda_compiled = bool(_paddle.device.is_compiled_with_cuda()) + except RuntimeError as _load_err: + if use_gpu: + use_gpu = False + print(f"[run_layout_aware_detection] Paddle failed to load — " + f"forcing CPU (will likely be slow): {_load_err}") + + if use_gpu and not cuda_compiled: + use_gpu = False + print( + "[run_layout_aware_detection] WARNING: use_gpu=True requested but " + "PaddlePaddle is not compiled with CUDA. " + "Falling back to CPU automatically." + ) + + device = "gpu" if use_gpu else "cpu" + + res_info = check_system_resources(use_gpu) + resource_warnings: List[str] = res_info["warnings"] + + # Goes first — it explains every other warning below it. + if not cuda_compiled and device == "cpu": + cuda_warning = ( + "GPU was requested but PaddlePaddle in this environment is CPU-only " + "(not compiled with CUDA). Running on CPU instead. " + "To enable GPU acceleration, CUDA 12.6 driver on the host is required " + "and the container must be started with --gpus all. Install GPU paddle: " + "pip install paddlepaddle-gpu==3.3.0 " + "-i https://www.paddlepaddle.org.cn/packages/stable/cu126/" + ) + resource_warnings.insert(0, cuda_warning) + + print(f"\n{'='*60}") + print(f"[run_layout_aware_detection] START") + print(f" image shape : {image.shape}") + print(f" device : {device} (cuda_compiled={cuda_compiled})") + print(f" layout_model: {layout_model_name}") + print(f" det_model : {det_model_name}") + print(f" rec_model : {rec_model_name}") + print(f" region_padding : {region_padding}") + print(f" layout_expand : {layout_expand}") + print(f" score_thresh : {score_thresh}") + print(f" upscale_min_h : {upscale_min_h}") + print(f" nms_iou_thresh : {nms_iou_thresh}") + print(f" gap_multiplier : {gap_multiplier}") + print(f" gpu_available : {res_info['gpu_available']} " + f"vram={res_info['gpu_vram_gb']:.1f} GB free={res_info['gpu_vram_free_gb']:.1f} GB") + print(f" ram_available : {res_info['available_ram_gb']:.1f} GB " + f"total={res_info['total_ram_gb']:.1f} GB") + for w in resource_warnings: + print(f" [RESOURCE WARNING] {w}") + print(f"{'='*60}") + + t0 = time.time() + + layout = detect_layout(image, device=device, max_layout_side=1500, + model_name=layout_model_name) + + if not layout: + print("[run_layout_aware_detection] No layout regions found!") + return [], resource_warnings + + if debug_dir: + _save_debug_image( + image, layout, + os.path.join(debug_dir, "page_layout_boxes.png"), + color=(255, 0, 0), + ) + + text_regions = [r for r in layout if r["label"] in TEXT_LABELS] + print(f"\n[run_layout_aware_detection] " + f"{len(text_regions)} text regions / {len(layout)} total") + + if not text_regions: + print("[run_layout_aware_detection] No TEXT regions in layout!") + return [], resource_warnings + + merged = detect_text_lines(image, layout, device=device, + det_model_name=det_model_name, + rec_model_name=rec_model_name, + region_padding=region_padding, + layout_expand=layout_expand, + score_thresh=score_thresh, + upscale_min_h=upscale_min_h, + nms_iou_thresh=nms_iou_thresh, + gap_multiplier=gap_multiplier, + debug_dir=debug_dir) + + lines = [box.tolist() for box in merged] + + elapsed = time.time() - t0 + print(f"\n[run_layout_aware_detection] DONE — " + f"{len(lines)} lines in {elapsed:.1f}s") + + _free_memory() + + return lines, resource_warnings + diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/__init__.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/factory.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/factory.py new file mode 100644 index 00000000..61f092e1 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/factory.py @@ -0,0 +1,128 @@ +"""Registry + dispatch for text-cleanup LLM providers. + +Gemini uses the google-genai SDK; OpenAI/DeepSeek/Qwen share one +chat-completions client. `local_es` is our GPU-only Spanish fine-tune. +""" + +from .gemini_client import post_process_text as _gemini_post_process +from .openai_compat_client import post_process_text_openai_compat +from .local_client import ( + post_process_text_finetuned, + FINETUNED_BASE_MODEL, +) + + +# OpenAI-compatible chat-completions endpoints (same URLs as the OCR providers). +_OPENAI_COMPAT_ENDPOINTS = { + "openai": "https://api.openai.com/v1/chat/completions", + "deepseek": "https://api.deepseek.com/v1/chat/completions", + "qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", +} + + +# Feeds the frontend dropdowns via GET /api/llm/providers. These are TEXT +# models — Qwen uses qwen-plus/turbo/max here, not the qwen-vl-* OCR models. +LLM_PROVIDERS = [ + { + "id": "gemini", + "name": "Gemini", + "enabled": True, + "requires_key": True, + "default_model": "gemini-2.5-flash", + "models": [ + {"id": "gemini-2.5-flash", "name": "Gemini 2.5 Flash"}, + {"id": "gemini-2.5-pro", "name": "Gemini 2.5 Pro"}, + ], + }, + { + "id": "openai", + "name": "OpenAI", + "enabled": True, + "requires_key": True, + "default_model": "gpt-5-mini", + "models": [ + {"id": "gpt-5-mini", "name": "GPT-5 Mini"}, + {"id": "gpt-5.2", "name": "GPT-5.2"}, + ], + }, + { + "id": "deepseek", + "name": "DeepSeek", + "enabled": True, + "requires_key": True, + "default_model": "deepseek-chat", + "models": [ + {"id": "deepseek-chat", "name": "DeepSeek Chat"}, + {"id": "deepseek-reasoner", "name": "DeepSeek Reasoner"}, + ], + }, + { + "id": "qwen", + "name": "Qwen", + "enabled": True, + "requires_key": True, + "default_model": "qwen-plus", + "models": [ + {"id": "qwen-plus", "name": "Qwen Plus"}, + {"id": "qwen-turbo", "name": "Qwen Turbo"}, + {"id": "qwen-max", "name": "Qwen Max"}, + ], + }, + { + # GPU-only, and the gated base download needs HF_TOKEN. See local_client. + "id": "local_es", + "name": "Local fine-tuned (Spanish)", + "enabled": True, + "requires_key": False, + "default_model": FINETUNED_BASE_MODEL, + "models": [ + {"id": FINETUNED_BASE_MODEL, "name": "Gemma-3-4B Spanish (fine-tuned)"}, + ], + "note": "Fine-tuned on Spanish historical OCR; runs 4-bit on the server GPU (gated base downloaded on first use — needs HF_TOKEN).", + }, +] + +_ENABLED_PROVIDER_IDS = {p["id"] for p in LLM_PROVIDERS if p["enabled"]} +_KEYLESS_PROVIDER_IDS = { + p["id"] for p in LLM_PROVIDERS if not p.get("requires_key", True) +} + + +def provider_requires_key(provider: str) -> bool: + """True unless the provider runs locally (no API key needed).""" + return (provider or "").lower() not in _KEYLESS_PROVIDER_IDS + + +def post_process( + provider: str, + api_key: str | None, + text: str, + model: str, + template_name: str = "full_cleanup", +) -> str: + """Route the request to `provider`. Raises ValueError if it's unknown or off.""" + provider = (provider or "gemini").lower() + + if provider not in _ENABLED_PROVIDER_IDS: + if any(p["id"] == provider for p in LLM_PROVIDERS): + raise ValueError( + f"Provider '{provider}' is not available yet." + ) + valid = ", ".join(sorted(_ENABLED_PROVIDER_IDS)) + raise ValueError( + f"Unknown LLM provider: '{provider}'. Valid providers: {valid}" + ) + + if provider == "local_es": + return post_process_text_finetuned(text, model, template_name) + + if provider == "gemini": + return _gemini_post_process(api_key, text, model, template_name) + + return post_process_text_openai_compat( + endpoint=_OPENAI_COMPAT_ENDPOINTS[provider], + api_key=api_key, + text=text, + model=model, + template_name=template_name, + ) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/gemini_client.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/gemini_client.py new file mode 100644 index 00000000..f221bab6 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/gemini_client.py @@ -0,0 +1,33 @@ +"""Gemini text post-processing — same google-genai pattern as the OCR provider.""" + +from google import genai + +from .prompt_templates import get_template + + +DEFAULT_MODEL = "gemini-2.5-flash" + + +def post_process_text( + api_key: str, + text: str, + model: str = DEFAULT_MODEL, + template_name: str = "full_cleanup", +) -> str: + """Clean up OCR text with Gemini. template_name keys into prompt_templates.""" + if not text or not text.strip(): + return text + + client = genai.Client(api_key=api_key) + prompt = get_template(template_name) + text + + response = client.models.generate_content( + model=model, + contents=[prompt], + ) + + result = response.text + if not result or not result.strip(): + raise ValueError("LLM returned an empty response") + + return result.strip() diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/local_client.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/local_client.py new file mode 100644 index 00000000..fd47a7dd --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/local_client.py @@ -0,0 +1,297 @@ +"""The `local_es` corrector: gemma-3-4b-it + our shipped Spanish LoRA adapter. + +Runs in 4-bit QLoRA (same config it was trained with, ~3.5 GB VRAM) and corrects +one line at a time, matching training. GPU only — 4-bit needs CUDA. + +gemma-3 is gated on HuggingFace, so the first base-weights download needs +HF_TOKEN set and the licence accepted at huggingface.co/google/gemma-3-4b-it. +""" + +import os +from pathlib import Path +from threading import Lock + +# AutoModelForCausalLM resolves this to the multimodal Gemma3 class; text-only +# generation works fine and matches how the adapter was trained. +FINETUNED_BASE_MODEL = "google/gemma-3-4b-it" +FINETUNED_ADAPTER_SUBDIR = "postprocess/gemma-3-4b-it-lora" + +# Must stay byte-identical to the prompt the adapter was fine-tuned with. +_FINETUNED_SYSTEM_PROMPT = ( + "You are an expert in early-modern Spanish (15th-19th century) palaeography " + "and OCR correction. The user gives you one raw OCR line from a historical " + "Spanish document. Return ONLY the corrected line, nothing else. " + "Fix glyph confusions (rn→m, 0→o, 1/I→l, ſ→s), restore diacritics " + "(á é í ó ú ñ ç), and merge or split wrongly tokenised words. " + "Preserve the original early-modern spelling: never modernise, translate, " + "or add content. If the line is already correct, return it unchanged." +) + +# (tokenizer, model, device) cache. The lock stops two requests from kicking off +# simultaneous multi-GB loads. +_CACHE: dict = {} +_LOAD_LOCK = Lock() + + +def _adapter_dir() -> str: + """Shipped LoRA adapter dir; same path in dev and in the container.""" + backend_root = Path(__file__).resolve().parents[3] + return str(backend_root / "models" / "weights" / FINETUNED_ADAPTER_SUBDIR) + + +def base_model_cached() -> bool: + """True when the gated base weights are already on disk. + + Mirrors layout_detection.models_cached: the UI probes this so it can warn + about the one-time ~8 GB download instead of looking hung. Filesystem only + — never imports torch/transformers, so it stays cheap to call. + + huggingface_hub only creates a snapshot symlink once a blob has finished + downloading, so a half-done shard leaves a dangling/absent entry and + .exists() correctly reports False. + """ + hf_home = os.environ.get("HF_HOME") or str(Path.home() / ".cache" / "huggingface") + repo = ( + Path(hf_home) / "hub" + / ("models--" + FINETUNED_BASE_MODEL.replace("/", "--")) + ) + snapshots = repo / "snapshots" + if not snapshots.is_dir(): + return False + + for snap in snapshots.iterdir(): + # Sharded checkpoints (gemma-3-4b is 2 shards): every file named in the + # index must be present, or we would flash "ready" mid-download. + index = snap / "model.safetensors.index.json" + if index.is_file(): + try: + import json + + shards = set(json.loads(index.read_text())["weight_map"].values()) + except (OSError, ValueError, KeyError): + continue + if shards and all((snap / s).exists() for s in shards): + return True + elif (snap / "model.safetensors").exists(): + return True + return False + + +def _load(model_name: str, *, adapter_dir: str | None = None, quantize: bool = False): + """Load and cache a tokenizer + causal LM. + + adapter_dir applies a local PEFT LoRA on top of the base; quantize loads + 4-bit nf4 when CUDA is present, which keeps the 4B model near 3 GB VRAM. + """ + key = f"{model_name}::{adapter_dir or ''}::{'q4' if quantize else 'full'}" + cached = _CACHE.get(key) + if cached is not None: + return cached + + with _LOAD_LOCK: + cached = _CACHE.get(key) + if cached is not None: + return cached + + import torch + from transformers import AutoModelForCausalLM, AutoTokenizer + + from app.utils.torch_device import select_torch_device + + device = select_torch_device() + + # Tokenizer comes from the BASE model, never the adapter dir. LoRA + # doesn't touch the vocabulary, and the adapter's tokenizer.json was + # serialized by a newer `tokenizers` than the one pinned in the image. + tokenizer = AutoTokenizer.from_pretrained(model_name) + if tokenizer.pad_token is None: + tokenizer.pad_token = tokenizer.eos_token + + # NEVER fp16 here. gemma-3 is bf16-trained and its activations overflow + # fp16: the logits go NaN, generate() emits nothing but , and the + # caller below keeps every original line — a silent no-op that looks + # like "the model had no corrections to make" rather than a failure. + # bf16 needs Ampere or newer, so older cards take the fp32 path (slower, + # but correct). + if device == "cuda": + dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float32 + else: + dtype = torch.float32 + + load_kwargs: dict = {} + if quantize and device == "cuda": + from transformers import BitsAndBytesConfig + + load_kwargs["quantization_config"] = BitsAndBytesConfig( + load_in_4bit=True, + bnb_4bit_quant_type="nf4", + bnb_4bit_use_double_quant=True, + bnb_4bit_compute_dtype=dtype, + ) + load_kwargs["device_map"] = "auto" + # torch_dtype, not dtype — the image pins transformers 4.53. Still a + # valid alias in 5.x, so this works both places. + load_kwargs["torch_dtype"] = dtype + + # One local LLM resident at a time, evicted BEFORE the new load — + # otherwise switching providers holds both on an 8 GB GPU and OOMs. + # Safe because post-processing runs one request at a time. + _evict_all_except(key) + + model = AutoModelForCausalLM.from_pretrained(model_name, **load_kwargs) + + if adapter_dir: + from peft import PeftModel + + model = PeftModel.from_pretrained(model, adapter_dir) + + # device_map="auto" already placed the quantized model. + if "device_map" not in load_kwargs: + model.to(device) + model.eval() + + _CACHE[key] = (tokenizer, model, device) + return _CACHE[key] + + +def _evict_all_except(keep_key: str) -> None: + """Free every cached local LLM except `keep_key` (frees VRAM/RAM).""" + import gc + + import torch + + for k in [k for k in _CACHE if k != keep_key]: + _, model, device = _CACHE.pop(k) + del model + gc.collect() + if device == "cuda": + torch.cuda.empty_cache() + + +def post_process_text_finetuned( + text: str, + model: str | None = None, # ignored; adapter is fixed + template_name: str | None = None, # ignored; per-line system prompt is fixed + batch_size: int = 16, +) -> str: + """Correct OCR text with the Spanish-finetuned gemma LoRA. + + Line by line, because that's how the adapter was trained — a whole page is + out of distribution. Blank lines are kept so paragraphs survive. Raises on + CPU-only hosts; the API providers still work there. + """ + if not text or not text.strip(): + return text + + import torch + + from app.utils.torch_device import select_torch_device + + if select_torch_device() != "cuda": + raise ValueError( + "The local fine-tuned Spanish model requires a GPU (4-bit QLoRA). " + "Use a cloud provider (Gemini/OpenAI) on CPU-only hosts." + ) + + adapter_dir = _adapter_dir() + if not os.path.isdir(adapter_dir): + raise ValueError(f"Fine-tuned adapter not found at {adapter_dir}") + + try: + tokenizer, llm, device = _load( + FINETUNED_BASE_MODEL, adapter_dir=adapter_dir, quantize=True + ) + except Exception as exc: + # Turn huggingface_hub's GatedRepoError/401 into something actionable. + msg = str(exc).lower() + if "gated" in msg or "401" in msg or "authoriz" in msg or "token" in msg: + raise ValueError( + f"Could not download the gated base model '{FINETUNED_BASE_MODEL}'. " + "Set HF_TOKEN in the environment and accept the licence at " + "huggingface.co/google/gemma-3-4b-it." + ) from exc + raise + tokenizer.padding_side = "left" # decoder-only batched generation + + # generate() stops a batch only once EVERY row has emitted a stop token, so + # both knobs below are about not letting one long row bill the other 15. + stop_ids = llm.generation_config.eos_token_id + stop_ids = set(stop_ids) if isinstance(stop_ids, (list, tuple)) else {stop_ids} + + lines = text.split("\n") + idx = [i for i, ln in enumerate(lines) if ln.strip()] + out = list(lines) + answered = 0 + + # Batch lines of similar length together: rows are left-padded to the + # longest in their batch, and a batch runs as many steps as its slowest row + # needs. Mixing a 5-token line with a 60-token one makes the short ones pay + # for the long one at both ends. Results are written back to out[i] by + # original index, so the visual order the frontend relies on is untouched. + line_lens = dict(zip( + idx, + (len(t) for t in tokenizer( + [lines[i] for i in idx], add_special_tokens=False + ).input_ids), + )) + idx.sort(key=line_lens.__getitem__) + + for start in range(0, len(idx), batch_size): + chunk_idx = idx[start:start + batch_size] + prompts = [ + tokenizer.apply_chat_template( + [ + {"role": "system", "content": _FINETUNED_SYSTEM_PROMPT}, + {"role": "user", "content": lines[i]}, + ], + tokenize=False, + add_generation_prompt=True, + ) + for i in chunk_idx + ] + enc = tokenizer(prompts, return_tensors="pt", padding=True).to(device) + # A corrected line runs about as long as the line that went in — this is + # glyph repair, not translation. Sizing the ceiling to the batch's + # longest input (+ slack for restored diacritics and split words) beats + # a flat 160, which one rambling row would otherwise drag all 16 rows to. + # ponytail: 1.5x + 16 is generous rather than tuned; the guard below + # catches anything that does overrun, so a too-tight cap can only cost a + # correction, never truncate a line. + cap = min(160, int(max(line_lens[i] for i in chunk_idx) * 1.5) + 16) + with torch.no_grad(): + gen = llm.generate( + **enc, + max_new_tokens=cap, + do_sample=False, + pad_token_id=tokenizer.pad_token_id, + ) + new = gen[:, enc["input_ids"].shape[1]:] + decoded = tokenizer.batch_decode(new, skip_special_tokens=True) + for i, corrected, row in zip(chunk_idx, decoded, new): + # No stop token means generation was cut off at the cap, so what we + # have is half a line. Keep the original rather than write a + # truncated one — a silently chopped line is worse than an uncorrected + # one, and the frontend has no way to tell them apart. + if stop_ids.isdisjoint(row.tolist()): + continue + # One input line must stay one output line — the frontend maps + # results back onto boxes by index. Never blank a line out either. + corrected = " ".join(corrected.split()).strip() + if corrected: + out[i] = corrected + answered += 1 + + # Keeping the original line when the model returns nothing is right per + # line, but if it answered NOTHING at all it is broken (NaN logits from a + # bad dtype do exactly this) — and silently echoing the input back looks + # like a clean run to the user. Fail loudly instead. + if idx and answered == 0: + raise ValueError( + "Fine-tuned model returned no output for any line — it may have " + "failed to load correctly." + ) + + result = "\n".join(out).strip() + if not result: + raise ValueError("Fine-tuned model returned an empty response") + return result diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/openai_compat_client.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/openai_compat_client.py new file mode 100644 index 00000000..45f2f524 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/openai_compat_client.py @@ -0,0 +1,46 @@ +"""One client for OpenAI, DeepSeek and Qwen. + +All three speak the same /chat/completions schema, so only the endpoint differs. +""" + +import httpx + +from .prompt_templates import get_template + + +def post_process_text_openai_compat( + endpoint: str, + api_key: str, + text: str, + model: str, + template_name: str = "full_cleanup", +) -> str: + """Clean up OCR text. endpoint is the provider's full chat-completions URL.""" + if not text or not text.strip(): + return text + + prompt = get_template(template_name) + text + + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + # Output is about as long as the input page, so this is plenty. + "max_tokens": 4096, + } + + response = httpx.post( + endpoint, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=payload, + timeout=120.0, + ) + response.raise_for_status() + result = response.json()["choices"][0]["message"]["content"] + + if not result or not result.strip(): + raise ValueError("LLM returned an empty response") + + return result.strip() diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/prompt_templates.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/prompt_templates.py new file mode 100644 index 00000000..596be831 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/llm_processing/prompt_templates.py @@ -0,0 +1,87 @@ +"""Prompt templates for LLM cleanup of OCR text. + +Line count is sacred: one line per detected text region, mapped back by +position. Every template gets _LINE_PRESERVATION appended to enforce that. +(local_es skips these — it corrects one line at a time anyway.) +""" + +_LINE_PRESERVATION = ( + "\n\nCRITICAL — DO NOT CHANGE THE NUMBER OF LINES:\n" + "The transcript below has exactly one line per text region detected on the " + "page. That line count is fixed and correct — it comes from the layout " + "detection step, not from you. Rules:\n" + "- Return EXACTLY the same number of lines, in the same order.\n" + "- Each input line maps to one output line. Correct text only WITHIN a line.\n" + "- NEVER merge two lines into one, split one line into two, reorder, add, or " + "delete a line — not even blank lines.\n" + "- If a line seems to end mid-word or mid-sentence, leave it as-is on its " + "own line; the next line continues it.\n" + "- Read the whole page for context (to disambiguate glyphs and words), but " + "edit each line independently.\n" + "- Output ONLY the corrected lines, separated by single newlines. No " + "numbering, no commentary, no code fences.\n\n" + "Transcript (one line per detected region):\n\n" +) + +TEMPLATES = { + "full_cleanup": { + "name": "Full Cleanup", + "description": "Fix spelling, formatting, and OCR artifacts in one pass", + "prompt": ( + "You are an OCR post-processing assistant for historical Spanish " + "documents. Clean up the OCR-extracted transcript:\n" + "- Fix obvious OCR errors and misspellings\n" + "- Fix incorrect character substitutions (e.g., 'rn' misread as 'm', '1' as 'l', long-s 'ſ' as 's')\n" + "- Restore diacritics that OCR dropped (á é í ó ú ñ ç)\n" + "- Normalize inconsistent spacing within a line\n" + "- Preserve the original early-modern spelling and meaning\n" + "- Do NOT translate, modernise, rephrase, add or remove content" + ), + }, + "spelling_correction": { + "name": "Spelling Correction", + "description": "Fix only spelling errors and character misrecognitions", + "prompt": ( + "You are a spelling correction assistant for OCR output of " + "historical Spanish documents. Fix ONLY spelling errors and " + "character misrecognitions. Do not change wording, do not " + "translate or modernise, do not add or remove content." + ), + }, + "formatting": { + "name": "Format & Structure", + "description": "Normalize spacing within lines (line count stays fixed)", + "prompt": ( + "You are a text formatting assistant for OCR output.\n" + "- Normalize spacing within each line (remove extra spaces, fix missing spaces)\n" + "- Do NOT change any words or fix spelling\n" + "- Do NOT add or remove content" + ), + }, + "historical_normalization": { + "name": "Historical Text Normalization", + "description": "Normalize archaic spellings and historical typography", + "prompt": ( + "You are a historical text normalization assistant.\n" + "- Normalize long-s (ſ) to modern 's'\n" + "- Expand common ligatures (ff→ff, fi→fi, fl→fl, ffi→ffi, ffl→ffl)\n" + "- Normalize archaic letter forms while preserving meaning\n" + "- Fix OCR errors specific to historical typefaces\n" + "- Do NOT modernise vocabulary or grammar" + ), + }, +} + + +def get_template(name: str) -> str: + """Template prompt + the line contract. Callers append the transcript after.""" + entry = TEMPLATES.get(name, TEMPLATES["full_cleanup"]) + return entry["prompt"] + _LINE_PRESERVATION + + +def list_templates() -> list: + """Template metadata for the UI dropdown.""" + return [ + {"id": key, "name": val["name"], "description": val["description"]} + for key, val in TEMPLATES.items() + ] diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/__init__.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/__init__.py new file mode 100644 index 00000000..2f397227 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/__init__.py @@ -0,0 +1,4 @@ +from .factory import OCRFactory +from .base import BaseOCRProvider + +__all__ = ['OCRFactory', 'BaseOCRProvider'] diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/base.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/base.py new file mode 100644 index 00000000..da5c968c --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/base.py @@ -0,0 +1,21 @@ +"""Interface every OCR provider implements.""" + +from abc import ABC, abstractmethod +from typing import Optional + + +class BaseOCRProvider(ABC): + """Base class for all OCR providers.""" + + # Subclasses must fill these in. + MODELS: list = [] + DEFAULT_MODEL: str = "" + MODEL_IDS: list = [] + + @abstractmethod + def transcribe(self, api_key: str, image_bytes: bytes, model_name: str, mime_type: str = "image/png", custom_prompt: Optional[str] = None) -> str: + """OCR the image and return the text. + + custom_prompt replaces utils.prompt.OCR_PROMPT when the user supplies one. + """ + ... diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/chatgpt.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/chatgpt.py new file mode 100644 index 00000000..0a3f3f66 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/chatgpt.py @@ -0,0 +1,69 @@ +"""OpenAI vision OCR over plain httpx.""" + +import base64 +import httpx +from typing import Optional + +from .base import BaseOCRProvider +from ...utils.prompt import OCR_PROMPT + + +CHATGPT_MODELS = [ + { + "id": "gpt-5.2", + "name": "GPT-5.2", + "description": "Latest and most capable multimodal model" + }, + { + "id": "gpt-5-mini", + "name": "GPT-5 Mini", + "description": "Smaller, faster, and more affordable" + }, +] + +CHATGPT_MODEL_IDS = [m["id"] for m in CHATGPT_MODELS] +CHATGPT_DEFAULT_MODEL = "gpt-5.2" + + +class ChatGPTProvider(BaseOCRProvider): + MODELS = CHATGPT_MODELS + DEFAULT_MODEL = CHATGPT_DEFAULT_MODEL + MODEL_IDS = CHATGPT_MODEL_IDS + + def transcribe(self, api_key: str, image_bytes: bytes, model_name: str, mime_type: str = "image/png", custom_prompt: Optional[str] = None) -> str: + image_b64 = base64.b64encode(image_bytes).decode("utf-8") + data_url = f"data:{mime_type};base64,{image_b64}" + prompt = custom_prompt if custom_prompt else OCR_PROMPT + + payload = { + "model": model_name, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": data_url} + }, + { + "type": "text", + "text": prompt + } + ] + } + ], + "max_tokens": 4096, + } + + response = httpx.post( + "https://api.openai.com/v1/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=payload, + timeout=120.0, + ) + response.raise_for_status() + result = response.json() + return result["choices"][0]["message"]["content"] diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/deepseek.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/deepseek.py new file mode 100644 index 00000000..13d3ec37 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/deepseek.py @@ -0,0 +1,69 @@ +"""DeepSeek OCR over its OpenAI-compatible endpoint.""" + +import base64 +import httpx +from typing import Optional + +from .base import BaseOCRProvider +from ...utils.prompt import OCR_PROMPT + + +DEEPSEEK_MODELS = [ + { + "id": "deepseek-chat", + "name": "DeepSeek Chat", + "description": "DeepSeek general chat model" + }, + { + "id": "deepseek-reasoner", + "name": "DeepSeek Reasoner", + "description": "DeepSeek reasoning model" + }, +] + +DEEPSEEK_MODEL_IDS = [m["id"] for m in DEEPSEEK_MODELS] +DEEPSEEK_DEFAULT_MODEL = "deepseek-chat" + + +class DeepSeekProvider(BaseOCRProvider): + MODELS = DEEPSEEK_MODELS + DEFAULT_MODEL = DEEPSEEK_DEFAULT_MODEL + MODEL_IDS = DEEPSEEK_MODEL_IDS + + def transcribe(self, api_key: str, image_bytes: bytes, model_name: str, mime_type: str = "image/png", custom_prompt: Optional[str] = None) -> str: + image_b64 = base64.b64encode(image_bytes).decode("utf-8") + data_url = f"data:{mime_type};base64,{image_b64}" + prompt = custom_prompt if custom_prompt else OCR_PROMPT + + payload = { + "model": model_name, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": data_url} + }, + { + "type": "text", + "text": prompt + } + ] + } + ], + "max_tokens": 4096, + } + + response = httpx.post( + "https://api.deepseek.com/v1/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=payload, + timeout=120.0, + ) + response.raise_for_status() + result = response.json() + return result["choices"][0]["message"]["content"] diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/factory.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/factory.py new file mode 100644 index 00000000..3a37b71e --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/factory.py @@ -0,0 +1,30 @@ +"""Look up an OCR provider by name.""" + +from .base import BaseOCRProvider +from .gemini import GeminiProvider +from .chatgpt import ChatGPTProvider +from .deepseek import DeepSeekProvider +from .qwen import QwenProvider + + +_PROVIDERS = { + "gemini": GeminiProvider, + "chatgpt": ChatGPTProvider, + "deepseek": DeepSeekProvider, + "qwen": QwenProvider, +} + + +class OCRFactory: + @staticmethod + def get_provider(name: str) -> BaseOCRProvider: + """Instantiate a provider by name. ValueError if it isn't one of ours.""" + provider_cls = _PROVIDERS.get(name.lower()) + if provider_cls is None: + valid = ", ".join(_PROVIDERS.keys()) + raise ValueError(f"Unknown OCR provider: '{name}'. Valid providers: {valid}") + return provider_cls() + + @staticmethod + def list_providers() -> list[str]: + return list(_PROVIDERS.keys()) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/gemini.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/gemini.py new file mode 100644 index 00000000..d4c843dd --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/gemini.py @@ -0,0 +1,109 @@ +"""Gemini OCR via the google-genai SDK.""" + +import time + +from google import genai +from google.genai import types +from google.genai import errors as genai_errors +from typing import Optional + +from .base import BaseOCRProvider +from ...utils.prompt import OCR_PROMPT + +# 5xx from Gemini ("high demand") usually clears in a second. 4xx won't, so +# only ServerError is retried below. +_MAX_RETRIES = 2 +_RETRY_BACKOFF_S = (2,) + + +AVAILABLE_MODELS = [ + { + "id": "gemini-3.1-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "Fast and light, best free-tier quota (recommended)" + }, + { + "id": "gemini-3-flash-preview", + "name": "Gemini 3 Flash", + "description": "Fast, high quality for most documents" + }, + { + "id": "gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "Higher quality, good balance of speed and accuracy" + }, + { + "id": "gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro", + "description": "Most capable, best for hard pages (low daily quota)" + }, + { + "id": "gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "description": "Stable fallback when 3.x models are busy" + }, +] + +DEFAULT_MODEL = "gemini-3.1-flash-lite" +MODEL_IDS = [m["id"] for m in AVAILABLE_MODELS] + +# Without this a thinking model can hang indefinitely, burning free-tier quota. +REQUEST_TIMEOUT_MS = 120_000 + + +def get_gemini_client(api_key: str): + + return genai.Client( + api_key=api_key, + http_options=types.HttpOptions(timeout=REQUEST_TIMEOUT_MS), + ) + + +class GeminiProvider(BaseOCRProvider): + """Gemini OCR provider using Google GenAI SDK.""" + + MODELS = AVAILABLE_MODELS + DEFAULT_MODEL = DEFAULT_MODEL + MODEL_IDS = MODEL_IDS + + def _generate(self, client, model_name: str, image_bytes: bytes, mime_type: str, prompt: str): + """One generation, retrying transient 5xx.""" + # Gemini 3 thinks by default and can burn the whole token budget + # reasoning about a dense page, returning no text at all. Cap it. + # thinking_level only exists in newer SDKs, hence the field check. + config = None + if model_name.startswith("gemini-3") and "thinking_level" in types.ThinkingConfig.model_fields: + config = types.GenerateContentConfig( + thinking_config=types.ThinkingConfig(thinking_level="low") + ) + + for attempt in range(_MAX_RETRIES): + try: + return client.models.generate_content( + model=model_name, + contents=[ + types.Part.from_bytes(data=image_bytes, mime_type=mime_type), + prompt + ], + config=config, + ) + except genai_errors.ServerError: + if attempt >= _MAX_RETRIES - 1: + raise + time.sleep(_RETRY_BACKOFF_S[attempt]) + + def transcribe(self, api_key: str, image_bytes: bytes, model_name: str, mime_type: str = "image/png", custom_prompt: Optional[str] = None) -> str: + client = get_gemini_client(api_key) + prompt = custom_prompt if custom_prompt else OCR_PROMPT + + response = self._generate(client, model_name, image_bytes, mime_type, prompt) + + text = response.text + if not text: + # Empty means the budget went to thinking or the output was + # blocked. Say so — a blank transcript looks like a silent failure. + reason = getattr(response, "prompt_feedback", None) or getattr( + response, "candidates", None + ) + raise ValueError(f"Gemini returned no text (finish info: {reason})") + return text diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/qwen.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/qwen.py new file mode 100644 index 00000000..2a847da7 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/ocr/qwen.py @@ -0,0 +1,79 @@ +"""Qwen VL OCR over DashScope's OpenAI-compatible endpoint.""" + +import base64 +import httpx +from typing import Optional + +from .base import BaseOCRProvider +from ...utils.prompt import OCR_PROMPT + + +QWEN_MODELS = [ + { + "id": "qwen-vl-max", + "name": "Qwen VL Max", + "description": "Most capable Qwen vision-language model" + }, + { + "id": "qwen-vl-ocr", + "name": "Qwen VL OCR", + "description": "Qwen model optimized for OCR tasks" + }, + { + "id": "qwen2.5-vl-72b-instruct", + "name": "Qwen2.5 VL 72B", + "description": "Large Qwen 2.5 vision-language model" + }, + { + "id": "qwen2.5-vl-7b-instruct", + "name": "Qwen2.5 VL 7B", + "description": "Efficient Qwen 2.5 vision model" + }, +] + +QWEN_MODEL_IDS = [m["id"] for m in QWEN_MODELS] +QWEN_DEFAULT_MODEL = "qwen-vl-max" + + +class QwenProvider(BaseOCRProvider): + MODELS = QWEN_MODELS + DEFAULT_MODEL = QWEN_DEFAULT_MODEL + MODEL_IDS = QWEN_MODEL_IDS + + def transcribe(self, api_key: str, image_bytes: bytes, model_name: str, mime_type: str = "image/png", custom_prompt: Optional[str] = None) -> str: + image_b64 = base64.b64encode(image_bytes).decode("utf-8") + data_url = f"data:{mime_type};base64,{image_b64}" + prompt = custom_prompt if custom_prompt else OCR_PROMPT + + payload = { + "model": model_name, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": data_url} + }, + { + "type": "text", + "text": prompt + } + ] + } + ], + "max_tokens": 4096, + } + + response = httpx.post( + "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=payload, + timeout=120.0, + ) + response.raise_for_status() + result = response.json() + return result["choices"][0]["message"]["content"] diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/recognition/__init__.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/recognition/__init__.py new file mode 100644 index 00000000..af537a81 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/recognition/__init__.py @@ -0,0 +1 @@ +# Recognition services — local line-level OCR (CRNN / TrOCR) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/recognition/crnn_inference.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/recognition/crnn_inference.py new file mode 100644 index 00000000..0bbad15d --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/recognition/crnn_inference.py @@ -0,0 +1,276 @@ +"""CRNN line-level OCR. + +The architecture and helpers are copy-pasted from RenAIssanceExperimental on +purpose, so the backend doesn't depend on that repo at runtime. +""" + +from __future__ import annotations + +import os +import logging +from typing import Optional + +import cv2 +import numpy as np +import torch +import torch.nn as nn +from PIL import Image + +from app.utils.torch_device import select_torch_device + +logger = logging.getLogger(__name__) + + +# ── Model architecture ──────────────────────────────────────────────── + + +class ResBlock(nn.Module): + """Basic residual block with optional projection shortcut.""" + + def __init__(self, in_c: int, out_c: int, stride: int = 1): + super().__init__() + self.conv1 = nn.Conv2d(in_c, out_c, 3, padding=1, stride=stride, bias=False) + self.bn1 = nn.BatchNorm2d(out_c) + self.relu = nn.ReLU(inplace=True) + self.conv2 = nn.Conv2d(out_c, out_c, 3, padding=1, bias=False) + self.bn2 = nn.BatchNorm2d(out_c) + + self.shortcut = nn.Sequential() + if stride != 1 or in_c != out_c: + self.shortcut = nn.Sequential( + nn.Conv2d(in_c, out_c, 1, stride=stride, bias=False), + nn.BatchNorm2d(out_c), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + out = self.relu(self.bn1(self.conv1(x))) + out = self.bn2(self.conv2(out)) + return self.relu(out + self.shortcut(x)) + + +class ResNetCNN(nn.Module): + """Lightweight ResNet-style CNN backbone. (B,1,H,W) → (B,256,1,W')""" + + def __init__(self): + super().__init__() + self.stem = nn.Sequential( + nn.Conv2d(1, 64, kernel_size=3, padding=1, bias=False), + nn.BatchNorm2d(64), + nn.ReLU(inplace=True), + ) + self.stage1 = nn.Sequential(ResBlock(64, 64), ResBlock(64, 64), nn.MaxPool2d(2, 2)) + self.stage2 = nn.Sequential(ResBlock(64, 128), ResBlock(128, 128), nn.MaxPool2d(2, 2)) + self.stage3 = nn.Sequential(ResBlock(128, 256), ResBlock(256, 256), nn.MaxPool2d((2, 1))) + self.stage4 = nn.Sequential(ResBlock(256, 256), nn.MaxPool2d((2, 1))) + self.height_collapse = nn.AdaptiveAvgPool2d((1, None)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = self.stem(x) + x = self.stage1(x) + x = self.stage2(x) + x = self.stage3(x) + x = self.stage4(x) + x = self.height_collapse(x) + return x + + +class CRNN(nn.Module): + """ResNet CNN → BiLSTM → Linear → CTC log-softmax.""" + + def __init__( + self, + vocab_size: int, + lstm_hidden: int = 256, + lstm_layers: int = 2, + dropout: float = 0.3, + ): + super().__init__() + self.cnn = ResNetCNN() + self.lstm = nn.LSTM( + input_size=256, + hidden_size=lstm_hidden, + num_layers=lstm_layers, + bidirectional=True, + batch_first=True, + dropout=dropout if lstm_layers > 1 else 0.0, + ) + self.dropout = nn.Dropout(dropout) + self.classifier = nn.Linear(lstm_hidden * 2, vocab_size) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = self.cnn(x) # (B, 256, 1, W') + x = x.squeeze(2) # (B, 256, W') + x = x.permute(0, 2, 1) # (B, W', 256) + x, _ = self.lstm(x) # (B, W', H*2) + x = self.dropout(x) + x = self.classifier(x) # (B, W', V) + return x.log_softmax(-1) + + +# ── Helpers ─────────────────────────────────────────────────────────── + + +def resize_keep_ratio(img: Image.Image, height: int = 64) -> Image.Image: + """Resize a PIL image to a fixed height, preserving the aspect ratio.""" + w, h = img.size + new_w = max(1, int(w * (height / h))) + return img.resize((new_w, height), Image.BILINEAR) + + +def ctc_greedy_decode( + log_probs: torch.Tensor, + idx2char: dict, + blank: int = 0, +) -> list[str]: + """Greedy best-path CTC decode. (B, T, V) → list[str]""" + preds = log_probs.argmax(-1).cpu().numpy() + results = [] + for seq in preds: + chars, prev = [], None + for idx in seq: + if idx != blank and idx != prev: + chars.append(idx2char.get(int(idx), "")) + prev = idx + results.append("".join(chars)) + return results + + +def crop_polygon_gray(image_bgr: np.ndarray, poly_pts) -> Image.Image: + """Crop a polygon region from a BGR image and return as grayscale PIL.""" + pts = np.array(poly_pts, dtype=np.float32) + x1, y1 = pts.min(axis=0).astype(int) + x2, y2 = pts.max(axis=0).astype(int) + x1 = max(0, x1) + y1 = max(0, y1) + x2 = min(image_bgr.shape[1], x2) + y2 = min(image_bgr.shape[0], y2) + if x2 <= x1 or y2 <= y1: + return Image.new("L", (8, 8), 255) # degenerate box + crop = image_bgr[y1:y2, x1:x2] + return Image.fromarray(cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)) + + +# ── Model discovery ─────────────────────────────────────────────────── + + +def discover_models(search_dir: str) -> list[dict]: + """Find CRNN .pth/.pt checkpoints under search_dir. + + Returns [{"id": "crnn:", "name", "model_type", "path"}, ...]. + """ + models = [] + if not os.path.isdir(search_dir): + logger.warning("Model search directory does not exist: %s", search_dir) + return models + + for root, _dirs, files in os.walk(search_dir): + for fname in files: + if fname.endswith((".pth", ".pt")): + if "crnn" not in fname.lower(): + continue + abs_path = os.path.abspath(os.path.join(root, fname)) + stem = os.path.splitext(fname)[0] + models.append( + { + "id": f"crnn:{stem}", + "name": f"CRNN - {stem}", + "model_type": "crnn", + "path": abs_path, + } + ) + return sorted(models, key=lambda item: item["name"].lower()) + + +# ── Recognizer ──────────────────────────────────────────────────────── + + +class CRNNRecognizer: + """Loads the checkpoint on first predict, then keeps it in memory.""" + + def __init__(self, model_path: str, device: Optional[str] = None): + self.model_path = model_path + self._model: Optional[CRNN] = None + self._idx2char: Optional[dict] = None + self._img_height: int = 64 + + if device is None: + self.device = select_torch_device() + else: + self.device = device + + logger.info( + "CRNNRecognizer created (model=%s, device=%s, lazy-load)", + os.path.basename(model_path), + self.device, + ) + + def _ensure_loaded(self): + if self._model is not None: + return + + logger.info("Loading CRNN checkpoint: %s", self.model_path) + ckpt = torch.load(self.model_path, map_location=self.device, weights_only=False) + + self._idx2char = ckpt["idx2char"] + self._img_height = ckpt.get("img_height", 64) + + self._model = CRNN( + vocab_size=len(ckpt["vocab"]), + lstm_hidden=ckpt.get("lstm_hidden", 256), + lstm_layers=ckpt.get("lstm_layers", 2), + ).to(self.device) + self._model.load_state_dict(ckpt["model_state_dict"]) + self._model.eval() + + logger.info( + "CRNN loaded — vocab=%d, img_height=%d, device=%s", + len(ckpt["vocab"]), + self._img_height, + self.device, + ) + + def predict(self, image: Image.Image) -> str: + """Recognise text from a single grayscale line image.""" + self._ensure_loaded() + img = resize_keep_ratio(image.convert("L"), self._img_height) + arr = np.asarray(img, dtype=np.float32) / 255.0 + inp = torch.from_numpy(arr).unsqueeze(0).unsqueeze(0).to(self.device) + with torch.no_grad(): + log_probs = self._model(inp) + return ctc_greedy_decode(log_probs, self._idx2char)[0] + + def predict_batch(self, images: list[Image.Image]) -> list[str]: + """Recognise text from a batch of grayscale line images.""" + if not images: + return [] + + self._ensure_loaded() + + resized = [resize_keep_ratio(img.convert("L"), self._img_height) for img in images] + + tensors = [] + for img in resized: + arr = np.asarray(img, dtype=np.float32) / 255.0 + tensors.append(torch.from_numpy(arr).unsqueeze(0)) + + # Lines vary in width after the fixed-height resize, so pad to the widest. + max_w = max(t.shape[-1] for t in tensors) + padded = [torch.nn.functional.pad(t, (0, max_w - t.shape[-1])) for t in tensors] + batch = torch.stack(padded).to(self.device) + + with torch.no_grad(): + log_probs = self._model(batch) + + return ctc_greedy_decode(log_probs, self._idx2char) + + +# ── Global model cache ──────────────────────────────────────────────── + +_recognizer_cache: dict[str, CRNNRecognizer] = {} + + +def get_recognizer(model_path: str) -> CRNNRecognizer: + """Get (or create and cache) a CRNNRecognizer for the given path.""" + if model_path not in _recognizer_cache: + _recognizer_cache[model_path] = CRNNRecognizer(model_path) + return _recognizer_cache[model_path] diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/recognition/trocr_inference.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/recognition/trocr_inference.py new file mode 100644 index 00000000..b4fc5647 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/recognition/trocr_inference.py @@ -0,0 +1,128 @@ +"""TrOCR recognizer for line-level OCR inference.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Optional + +import cv2 +import numpy as np +import torch +from PIL import Image +from transformers import TrOCRProcessor, VisionEncoderDecoderModel + +from app.utils.torch_device import select_torch_device + +logger = logging.getLogger(__name__) + + +def discover_models(search_dir: str) -> list[dict]: + """Discover TrOCR checkpoints from local HuggingFace-style directories.""" + models: list[dict] = [] + root = Path(search_dir) + if not root.is_dir(): + logger.warning("TrOCR model search directory does not exist: %s", search_dir) + return models + + for dirpath, _dirnames, filenames in os.walk(root): + files = set(filenames) + if "config.json" not in files: + continue + has_weights = "model.safetensors" in files or "pytorch_model.bin" in files + if not has_weights: + continue + + abs_dir = os.path.abspath(dirpath) + rel = os.path.relpath(abs_dir, root) + display_name = rel.replace(os.sep, " / ") if rel != "." else Path(abs_dir).name + model_id = f"trocr:{rel.replace(os.sep, '/')}" if rel != "." else "trocr:default" + models.append( + { + "id": model_id, + "name": f"TrOCR - {display_name}", + "model_type": "trocr", + "path": abs_dir, + } + ) + + return sorted(models, key=lambda item: item["name"].lower()) + + +def crop_polygon_rgb(image_bgr: np.ndarray, poly_pts) -> Image.Image: + """Crop a polygon region from a BGR image and return RGB PIL image.""" + pts = np.array(poly_pts, dtype=np.float32) + x1, y1 = pts.min(axis=0).astype(int) + x2, y2 = pts.max(axis=0).astype(int) + x1 = max(0, x1) + y1 = max(0, y1) + x2 = min(image_bgr.shape[1], x2) + y2 = min(image_bgr.shape[0], y2) + if x2 <= x1 or y2 <= y1: + return Image.new("RGB", (8, 8), (255, 255, 255)) + crop = image_bgr[y1:y2, x1:x2] + rgb = cv2.cvtColor(crop, cv2.COLOR_BGR2RGB) + return Image.fromarray(rgb) + + +class TrOCRRecognizer: + """Lazy-loaded, cached TrOCR recognizer.""" + + def __init__(self, model_dir: str, device: Optional[str] = None): + self.model_dir = model_dir + self._processor: Optional[TrOCRProcessor] = None + self._model: Optional[VisionEncoderDecoderModel] = None + + if device is None: + self.device = select_torch_device() + else: + self.device = device + + logger.info( + "TrOCRRecognizer created (model_dir=%s, device=%s, lazy-load)", + os.path.basename(model_dir), + self.device, + ) + + def _ensure_loaded(self): + if self._processor is not None and self._model is not None: + return + + logger.info("Loading TrOCR checkpoint: %s", self.model_dir) + self._processor = TrOCRProcessor.from_pretrained(self.model_dir, local_files_only=True) + # fp16 on GPU: ~half the memory, ~3x faster, no meaningful CER change + # (see checkpoints/trocr_quantization/). CPU has no fp16 kernels. + dtype = torch.float16 if self.device == "cuda" else torch.float32 + self._model = VisionEncoderDecoderModel.from_pretrained( + self.model_dir, + local_files_only=True, + torch_dtype=dtype, + ).to(self.device) + self._model.eval() + + def predict_batch(self, images: list[Image.Image]) -> list[str]: + if not images: + return [] + self._ensure_loaded() + + rgb_images = [img.convert("RGB") for img in images] + pixel_values = self._processor(images=rgb_images, return_tensors="pt").pixel_values.to( + self.device, dtype=self._model.dtype + ) + + with torch.no_grad(): + generated_ids = self._model.generate(pixel_values, max_new_tokens=96) + + texts = self._processor.batch_decode(generated_ids, skip_special_tokens=True) + return [t.strip() for t in texts] + + +_recognizer_cache: dict[str, TrOCRRecognizer] = {} + + +def get_recognizer(model_dir: str) -> TrOCRRecognizer: + """Get (or create and cache) a TrOCR recognizer for the given directory.""" + if model_dir not in _recognizer_cache: + _recognizer_cache[model_dir] = TrOCRRecognizer(model_dir) + return _recognizer_cache[model_dir] diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/transcript_parser.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/transcript_parser.py new file mode 100644 index 00000000..b1ed9a64 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/services/transcript_parser.py @@ -0,0 +1,167 @@ +"""Split a transcript file (TXT/DOCX/PDF/MD) into {page_key: [lines]}. + +Page markers are matched loosely — "PDF p1", "--- Page 4 - left ---", +"[Page 5]", "Page 6", "p1-right" all work, case-insensitively. +""" + +import io +import re +from typing import Dict, List, Optional + + +# Optional " left"/" right" suffix, with any of space/-/–/— as the separator. +_SIDE_SUFFIX = r"(?:[\s\-–—]+(?Pleft|right))?" + +_PAGE_PATTERNS = [ + # "PDF p1", "PDF p2 - left", "PDF p2 left" + re.compile( + r"^\s*PDF\s+p\s*(?P\d+)" + _SIDE_SUFFIX + r"\s*$", + re.IGNORECASE, + ), + # "--- Page 4 ---" or "--- page 4 left ---" or "--- page 4 - right ---" + re.compile( + r"^\s*-{2,}\s*[Pp]age\s+(?P\d+)" + _SIDE_SUFFIX + r"\s*-*\s*$", + re.IGNORECASE, + ), + # "[Page 5]" or "[Page 5 - left]" or "[Page 5 right]" + re.compile( + r"^\s*\[\s*[Pp]age\s+(?P\d+)" + _SIDE_SUFFIX + r"\s*\]\s*$", + re.IGNORECASE, + ), + # Bare "Page 6" / "Page 6 left" / "page 6 - right" + re.compile( + r"^\s*[Pp]age\s+(?P\d+)" + _SIDE_SUFFIX + r"\s*$", + re.IGNORECASE, + ), + # Shorthand "p1" / "p1 left" / "p1-right" + re.compile( + r"^\s*[Pp](?P\d+)" + _SIDE_SUFFIX + r"\s*$", + re.IGNORECASE, + ), +] + + +def _match_page_marker(line: str) -> Optional[str]: + """-> '3' / '3_left' if the line is a page marker, else None.""" + for pat in _PAGE_PATTERNS: + m = pat.match(line) + if m: + page_num = m.group("num") + try: + side = m.group("side") + except IndexError: + side = None + if side: + return f"{page_num}_{side.lower()}" + return page_num + return None + + +# ── Text extraction per format ────────────────────────────────────── + +def _extract_text_from_txt(data: bytes) -> str: + text = data.decode("utf-8-sig", errors="replace") + return text + + +def _extract_text_from_docx(data: bytes) -> str: + from docx import Document + + doc = Document(io.BytesIO(data)) + return "\n".join(p.text for p in doc.paragraphs) + + +def _extract_text_from_pdf(data: bytes) -> str: + try: + import fitz # PyMuPDF + except ImportError: + raise RuntimeError( + "PyMuPDF (fitz) is required to parse PDF transcripts. " + "Install it with: pip install PyMuPDF" + ) + doc = fitz.open(stream=data, filetype="pdf") + pages_text = [] + for page in doc: + pages_text.append(page.get_text()) + doc.close() + return "\n".join(pages_text) + + +def _extract_text_from_markdown(data: bytes) -> str: + return data.decode("utf-8-sig", errors="replace") + + +_EXTRACTORS = { + "text/plain": _extract_text_from_txt, + "text/markdown": _extract_text_from_markdown, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": _extract_text_from_docx, + "application/pdf": _extract_text_from_pdf, +} + +# Used when the browser sends a useless content-type. +_EXT_MAP = { + ".txt": "text/plain", + ".md": "text/markdown", + ".markdown": "text/markdown", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".pdf": "application/pdf", +} + + +def extract_text(data: bytes, filename: str = "", content_type: str = "") -> str: + """Get raw text out of the uploaded bytes, by content-type or extension.""" + mime = content_type.lower().split(";")[0].strip() if content_type else "" + + if mime not in _EXTRACTORS: + import os + ext = os.path.splitext(filename)[1].lower() + mime = _EXT_MAP.get(ext, "text/plain") + + extractor = _EXTRACTORS.get(mime, _extract_text_from_txt) + return extractor(data) + + +# ── Split into pages ──────────────────────────────────────────────── + +def parse_transcript( + raw_text: str, + default_page: str = "1", +) -> Dict[str, List[str]]: + """Split raw text on page markers into {page_key: [lines]}. + + No markers at all -> everything lands under default_page. Blank lines go. + """ + pages: Dict[str, List[str]] = {} + lines = raw_text.splitlines() + has_any_marker = any(_match_page_marker(line) is not None for line in lines) + + # With markers present, anything before the first one is preface — drop it. + current_key: Optional[str] = None if has_any_marker else default_page + + for raw_line in lines: + marker = _match_page_marker(raw_line) + if marker is not None: + current_key = marker + continue + + if current_key is None: # still in the preface + continue + + cleaned = raw_line.strip() + if not cleaned: + continue + if re.match(r"^end\s+of\s+extract\s*$", cleaned, re.IGNORECASE): + continue + pages.setdefault(current_key, []).append(cleaned) + + return pages + + +def parse_transcript_bytes( + data: bytes, + filename: str = "", + content_type: str = "", +) -> Dict[str, List[str]]: + """extract_text + parse_transcript in one call.""" + raw = extract_text(data, filename, content_type) + return parse_transcript(raw) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/storage/__init__.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/storage/__init__.py new file mode 100644 index 00000000..6e4bdb8c --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/storage/__init__.py @@ -0,0 +1,27 @@ +"""Persistent storage package for transcripts and datasets.""" + +from .storage_manager import ( + save_transcript_session, + save_recognition_dataset, + save_detection_dataset, + list_entries, + get_transcript_detail, + get_dataset_detail, + delete_entry, + zip_entry, + resolve_storage_root, + ensure_storage_layout, +) + +__all__ = [ + "save_transcript_session", + "save_recognition_dataset", + "save_detection_dataset", + "list_entries", + "get_transcript_detail", + "get_dataset_detail", + "delete_entry", + "zip_entry", + "resolve_storage_root", + "ensure_storage_layout", +] diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/storage/file_indexer.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/storage/file_indexer.py new file mode 100644 index 00000000..ac7ff04e --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/storage/file_indexer.py @@ -0,0 +1,52 @@ +"""Utilities for storage indexing, IDs, and metadata loading.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + + +def ensure_dir(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + return path + + +def next_numeric_id(parent: Path, prefix: str) -> str: + """Return the next ID like `prefix_001` based on existing directories.""" + ensure_dir(parent) + pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)$") + max_id = 0 + + for child in parent.iterdir(): + if not child.is_dir(): + continue + match = pattern.match(child.name) + if match: + max_id = max(max_id, int(match.group(1))) + + return f"{prefix}_{max_id + 1:03d}" + + +def read_metadata(entry_dir: Path) -> dict[str, Any]: + metadata_file = entry_dir / "metadata.json" + if not metadata_file.exists(): + return {} + + try: + return json.loads(metadata_file.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + + +def safe_rmtree(path: Path) -> None: + if not path.exists() or not path.is_dir(): + return + + for child in path.iterdir(): + if child.is_dir(): + safe_rmtree(child) + else: + child.unlink(missing_ok=True) + path.rmdir() diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/storage/storage_manager.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/storage/storage_manager.py new file mode 100644 index 00000000..e13406dd --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/storage/storage_manager.py @@ -0,0 +1,630 @@ +"""Storage manager for persistent transcripts and datasets.""" + +from __future__ import annotations + +import base64 +import csv +import io +import json +import os +import re +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import cv2 +import numpy as np + +from ..core.config import STORAGE_ROOT +from ..services.dataset_builder import align_boxes_with_transcript, crop_line_image +from .file_indexer import ensure_dir, next_numeric_id, read_metadata, safe_rmtree + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _safe_label(text: str, max_len: int = 64) -> str: + safe = re.sub(r"[^A-Za-z0-9\s_-]", "", text or "") + safe = re.sub(r"\s+", "_", safe.strip()) + return safe[:max_len] if safe else "line" + + +def _safe_name(text: str, fallback: str = "session") -> str: + safe = re.sub(r"[^A-Za-z0-9\s_-]", "", text or "") + safe = re.sub(r"\s+", " ", safe).strip() + return safe[:96] if safe else fallback + + +def _page_sort_key(page_key: str) -> tuple[int, str]: + match = re.search(r"(\d+)", str(page_key)) + if match: + return int(match.group(1)), str(page_key) + return 10**9, str(page_key) + + +def _decode_data_url(image_data: str) -> np.ndarray | None: + payload = image_data.split(",", 1)[1] if "," in image_data else image_data + raw = base64.b64decode(payload) + arr = np.frombuffer(raw, dtype=np.uint8) + return cv2.imdecode(arr, cv2.IMREAD_COLOR) + + +def _image_ext_from_data_url(image_data: str) -> str: + match = re.match(r"^data:image/([a-zA-Z0-9.+-]+);base64,", image_data or "") + if not match: + return "png" + subtype = match.group(1).lower() + if subtype in {"jpg", "jpeg"}: + return "jpg" + if subtype in {"png", "webp"}: + return subtype + return "png" + + +def _decode_data_url_bytes(image_data: str) -> bytes | None: + if not image_data: + return None + try: + payload = image_data.split(",", 1)[1] if "," in image_data else image_data + return base64.b64decode(payload) + except Exception: + return None + + +def _guess_image_mime_from_suffix(suffix: str) -> str: + ext = suffix.lower().lstrip(".") + if ext in {"jpg", "jpeg"}: + return "image/jpeg" + if ext == "webp": + return "image/webp" + return "image/png" + + +def get_storage_paths() -> dict[str, Path]: + root = ensure_dir(Path(STORAGE_ROOT)) + transcripts = ensure_dir(root / "transcripts") + datasets = ensure_dir(root / "datasets") + return { + "root": root, + "transcripts": transcripts, + "datasets": datasets, + } + + +def _build_tags( + model_info: dict[str, Any] | None, + mode: str | None = None, + fmt: str | None = None, +) -> list[str]: + """ + Derive short, human-readable chips from the pipeline/model info so the + My Files UI can show at a glance what produced an entry: which + preprocessing ran, the detection / layout / OCR models, and whether LLM + post-processing was applied. + """ + info = model_info or {} + tags: list[str] = [] + + if mode: + tags.append(str(mode)) + + pre = info.get("preprocessing") + if isinstance(pre, (list, tuple)) and pre: + shown = ", ".join(str(p) for p in pre[:4]) + if len(pre) > 4: + shown += f" +{len(pre) - 4}" + tags.append(f"Preprocess: {shown}") + elif pre in (None, [], (), ""): + tags.append("Preprocess: none") + + if info.get("detection_model"): + tags.append(f"Detect: {info['detection_model']}") + if info.get("layout_model"): + tags.append(f"Layout: {info['layout_model']}") + + if info.get("ocr_model") or info.get("ocr_provider"): + provider = info.get("ocr_provider") or "ocr" + ocr_model = info.get("ocr_model") or "model" + tags.append(f"OCR: {provider}/{ocr_model}") + + llm = info.get("llm_postprocess") + if isinstance(llm, dict) and llm.get("used"): + provider = llm.get("provider") or "llm" + llm_model = llm.get("model") or "model" + tags.append(f"LLM: {provider}/{llm_model}") + elif isinstance(llm, dict): + tags.append("LLM: none") + + if fmt: + tags.append(f"Format: {fmt}") + + return tags + + +def save_transcript_session( + transcripts: dict[str, str], + source: str = "ocr upload", + mode: str = "recognition", + transcript_images: dict[str, str] | None = None, + book_name: str | None = None, + model_info: dict[str, Any] | None = None, +) -> dict[str, Any]: + paths = get_storage_paths() + session_id = next_numeric_id(paths["transcripts"], "session") + session_dir = ensure_dir(paths["transcripts"] / session_id) + + written_pages = 0 + written_images = 0 + sorted_pages = sorted(transcripts.keys(), key=_page_sort_key) + image_map = transcript_images or {} + + for page_key in sorted_pages: + text = (transcripts.get(page_key) or "").strip() + if not text: + continue + match = re.search(r"(\d+)", str(page_key)) + if match: + page_name = str(int(match.group(1))) + else: + page_name = re.sub(r"[^A-Za-z0-9_-]", "_", str(page_key)) + (session_dir / f"page{page_name}.txt").write_text(text, encoding="utf-8") + + image_data = image_map.get(page_key) or image_map.get(str(page_key)) + image_bytes = _decode_data_url_bytes(image_data or "") + if image_bytes: + ext = _image_ext_from_data_url(image_data or "") + (session_dir / f"page{page_name}.{ext}").write_bytes(image_bytes) + written_images += 1 + + written_pages += 1 + + display_name = _safe_name(book_name or "", fallback=session_id) + + metadata = { + "id": session_id, + "type": "transcript", + "created_at": _utc_now_iso(), + "num_files": written_pages, + "num_pages": written_pages, + "num_images": written_images, + "source": source, + "mode": mode, + "name": display_name, + "book_name": display_name, + "model_info": model_info or {}, + "tags": _build_tags(model_info, mode), + } + (session_dir / "metadata.json").write_text( + json.dumps(metadata, indent=2), + encoding="utf-8", + ) + + return metadata + + +def save_recognition_dataset( + pages_data: list[dict[str, Any]], + source: str, + book_name: str, + model_info: dict[str, Any] | None = None, +) -> dict[str, Any]: + paths = get_storage_paths() + dataset_id = next_numeric_id(paths["datasets"], "dataset") + dataset_dir = ensure_dir(paths["datasets"] / dataset_id) + images_dir = ensure_dir(dataset_dir / "images") + labels_dir = ensure_dir(dataset_dir / "labels") + + csv_rows: list[tuple[str, str]] = [] + total_samples = 0 + + for page in pages_data: + page_key = str(page.get("page_key", "unknown")) + boxes = page.get("boxes", []) + lines = page.get("lines", []) + img = _decode_data_url(page.get("image_data", "")) + if img is None: + continue + + aligned, _, _ = align_boxes_with_transcript(boxes, lines) + seen_labels: dict[str, int] = {} + + for idx, (box, text) in enumerate(aligned, start=1): + crop = crop_line_image(img, box) + label = _safe_label(text) + seen_labels[label] = seen_labels.get(label, 0) + 1 + suffix = seen_labels[label] + sample_name = f"page_{page_key}_line_{idx:04d}_{label}_{suffix}" + image_name = f"{sample_name}.png" + + ok, png_data = cv2.imencode(".png", crop) + if not ok: + continue + + (images_dir / image_name).write_bytes(png_data.tobytes()) + csv_rows.append((f"images/{image_name}", text)) + total_samples += 1 + + labels_csv = io.StringIO() + writer = csv.writer(labels_csv) + writer.writerow(["image", "text"]) + for image_path, text in csv_rows: + writer.writerow([image_path, text]) + (labels_dir / "labels.csv").write_text(labels_csv.getvalue(), encoding="utf-8") + + metadata = { + "id": dataset_id, + "type": "dataset", + "created_at": _utc_now_iso(), + "num_files": total_samples, + "num_samples": total_samples, + "source": source, + "mode": "recognition", + "dataset_type": "recognition", + "format": "png+csv", + "book_name": book_name, + "model_info": model_info or {}, + "tags": _build_tags(model_info, "recognition"), + } + (dataset_dir / "metadata.json").write_text( + json.dumps(metadata, indent=2), + encoding="utf-8", + ) + + return metadata + + +def _polygon_to_xyxy(box: list[list[float]]) -> tuple[int, int, int, int]: + pts = np.asarray(box, dtype=np.float32) + return ( + int(pts[:, 0].min()), + int(pts[:, 1].min()), + int(pts[:, 0].max()), + int(pts[:, 1].max()), + ) + + +def save_detection_dataset( + pages_data: list[dict[str, Any]], + source: str, + book_name: str, + bbox_format: str = "txt", + model_info: dict[str, Any] | None = None, +) -> dict[str, Any]: + fmt = (bbox_format or "txt").lower() + if fmt not in {"txt", "json", "yolo", "coco"}: + fmt = "txt" + + paths = get_storage_paths() + dataset_id = next_numeric_id(paths["datasets"], "dataset") + dataset_dir = ensure_dir(paths["datasets"] / dataset_id) + images_dir = ensure_dir(dataset_dir / "images") + bboxes_dir = ensure_dir(dataset_dir / ("labels" if fmt == "yolo" else "bboxes")) + + coco_images: list[dict[str, Any]] = [] + coco_annotations: list[dict[str, Any]] = [] + coco_image_id = 0 + coco_ann_id = 0 + total_samples = 0 + + for page in pages_data: + page_key = str(page.get("page_key", "unknown")) + boxes = page.get("boxes", []) + img = _decode_data_url(page.get("image_data", "")) + if img is None: + continue + + h, w = img.shape[:2] + stem = f"page_{page_key}" + image_name = f"{stem}.jpg" + ok, jpg_data = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 95]) + if ok: + (images_dir / image_name).write_bytes(jpg_data.tobytes()) + + xyxy_boxes = [_polygon_to_xyxy(b) for b in boxes] + total_samples += len(xyxy_boxes) + + if fmt == "txt": + lines = "\n".join(f"{x1} {y1} {x2} {y2}" for (x1, y1, x2, y2) in xyxy_boxes) + (bboxes_dir / f"{stem}.txt").write_text((lines + "\n") if lines else "", encoding="utf-8") + elif fmt == "json": + (bboxes_dir / f"{stem}.json").write_text( + json.dumps([[x1, y1, x2, y2] for (x1, y1, x2, y2) in xyxy_boxes], indent=2), + encoding="utf-8", + ) + elif fmt == "yolo": + yolo_lines = [] + for (x1, y1, x2, y2) in xyxy_boxes: + bw = max(0, x2 - x1) + bh = max(0, y2 - y1) + cx = (x1 + x2) / 2.0 + cy = (y1 + y2) / 2.0 + yolo_lines.append(f"0 {cx / w:.6f} {cy / h:.6f} {bw / w:.6f} {bh / h:.6f}") + (bboxes_dir / f"{stem}.txt").write_text(("\n".join(yolo_lines) + "\n") if yolo_lines else "", encoding="utf-8") + elif fmt == "coco": + coco_image_id += 1 + coco_images.append({ + "id": coco_image_id, + "file_name": image_name, + "width": int(w), + "height": int(h), + }) + for (x1, y1, x2, y2) in xyxy_boxes: + coco_ann_id += 1 + bw = max(0, x2 - x1) + bh = max(0, y2 - y1) + coco_annotations.append({ + "id": coco_ann_id, + "image_id": coco_image_id, + "category_id": 1, + "bbox": [int(x1), int(y1), int(bw), int(bh)], + "area": int(bw * bh), + "iscrowd": 0, + "segmentation": [], + }) + + if fmt == "yolo": + (dataset_dir / "classes.txt").write_text("text\n", encoding="utf-8") + elif fmt == "coco": + (bboxes_dir / "annotations.json").write_text( + json.dumps( + { + "images": coco_images, + "annotations": coco_annotations, + "categories": [{"id": 1, "name": "text", "supercategory": "text"}], + }, + indent=2, + ), + encoding="utf-8", + ) + + metadata = { + "id": dataset_id, + "type": "dataset", + "created_at": _utc_now_iso(), + "num_files": total_samples, + "num_samples": total_samples, + "source": source, + "mode": "detection", + "dataset_type": "detection", + "format": fmt, + "book_name": book_name, + "model_info": model_info or {}, + "tags": _build_tags(model_info, "detection", fmt), + } + (dataset_dir / "metadata.json").write_text( + json.dumps(metadata, indent=2), + encoding="utf-8", + ) + + return metadata + + +def list_entries(kind: str) -> list[dict[str, Any]]: + paths = get_storage_paths() + if kind not in {"transcripts", "datasets"}: + return [] + + entries = [] + for child in paths[kind].iterdir(): + if not child.is_dir(): + continue + metadata = read_metadata(child) + if not metadata: + continue + metadata["name"] = metadata.get("name") or child.name + metadata["id"] = metadata.get("id") or child.name + entries.append(metadata) + + entries.sort(key=lambda item: item.get("created_at", ""), reverse=True) + return entries + + +def get_transcript_detail(session_id: str) -> dict[str, Any]: + paths = get_storage_paths() + session_dir = paths["transcripts"] / session_id + if not session_dir.exists() or not session_dir.is_dir(): + raise FileNotFoundError(session_id) + + metadata = read_metadata(session_dir) + pages = [] + for page_file in sorted(session_dir.glob("page*.txt")): + image_data = None + for ext in (".png", ".jpg", ".jpeg", ".webp"): + image_file = session_dir / f"{page_file.stem}{ext}" + if image_file.exists() and image_file.is_file(): + mime = _guess_image_mime_from_suffix(image_file.suffix) + payload = base64.b64encode(image_file.read_bytes()).decode("utf-8") + image_data = f"data:{mime};base64,{payload}" + break + + pages.append({ + "name": page_file.name, + "content": page_file.read_text(encoding="utf-8"), + "image_data": image_data, + }) + + return { + "metadata": metadata, + "pages": pages, + } + + +def get_dataset_detail(dataset_id: str, preview_limit: int = 18) -> dict[str, Any]: + paths = get_storage_paths() + dataset_dir = paths["datasets"] / dataset_id + if not dataset_dir.exists() or not dataset_dir.is_dir(): + raise FileNotFoundError(dataset_id) + + metadata = read_metadata(dataset_dir) + if not metadata: + metadata = {"id": dataset_id} + + dataset_type = metadata.get("dataset_type") or metadata.get("mode") or "recognition" + samples: list[dict[str, Any]] = [] + + def _to_data_url(image_path: Path) -> str | None: + if not image_path.exists() or not image_path.is_file(): + return None + mime = _guess_image_mime_from_suffix(image_path.suffix) + payload = base64.b64encode(image_path.read_bytes()).decode("utf-8") + return f"data:{mime};base64,{payload}" + + if dataset_type == "recognition": + labels_csv = dataset_dir / "labels" / "labels.csv" + if labels_csv.exists() and labels_csv.is_file(): + with labels_csv.open("r", encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh) + for row in reader: + if len(samples) >= preview_limit: + break + image_rel = (row.get("image") or "").strip() + text = (row.get("text") or "").strip() + image_path = dataset_dir / image_rel if image_rel else None + image_data = _to_data_url(image_path) if image_path else None + samples.append( + { + "name": Path(image_rel).name if image_rel else "sample", + "image_data": image_data, + "text": text, + "annotation": text, + } + ) + else: + images_dir = dataset_dir / "images" + fmt = (metadata.get("format") or "txt").lower() + annotation_by_stem: dict[str, str] = {} + + if fmt in {"txt", "yolo"}: + labels_dir = dataset_dir / ("labels" if fmt == "yolo" else "bboxes") + for label_file in labels_dir.glob("page_*.txt"): + try: + text = label_file.read_text(encoding="utf-8").strip() + lines = [line for line in text.splitlines() if line.strip()] + if not lines: + annotation_by_stem[label_file.stem] = "No boxes" + else: + annotation_by_stem[label_file.stem] = "\n".join(lines) + except Exception: + annotation_by_stem[label_file.stem] = "boxes available" + elif fmt == "json": + labels_dir = dataset_dir / "bboxes" + for label_file in labels_dir.glob("page_*.json"): + try: + arr = json.loads(label_file.read_text(encoding="utf-8")) + if not isinstance(arr, list) or not arr: + annotation_by_stem[label_file.stem] = "No boxes" + else: + annotation_by_stem[label_file.stem] = "\n".join( + [f"[{', '.join(map(str, box))}]" for box in arr] + ) + except Exception: + annotation_by_stem[label_file.stem] = "boxes available" + elif fmt == "coco": + ann_file = dataset_dir / "bboxes" / "annotations.json" + if ann_file.exists() and ann_file.is_file(): + try: + coco = json.loads(ann_file.read_text(encoding="utf-8")) + image_id_to_name = { + item.get("id"): Path(item.get("file_name", "")).stem + for item in coco.get("images", []) + } + values: dict[str, list[str]] = {} + for ann in coco.get("annotations", []): + stem = image_id_to_name.get(ann.get("image_id")) + if not stem: + continue + bbox = ann.get("bbox", []) + if isinstance(bbox, list) and len(bbox) == 4: + bbox_line = f"[{bbox[0]}, {bbox[1]}, {bbox[2]}, {bbox[3]}]" + else: + bbox_line = str(bbox) + if stem not in values: + values[stem] = [] + values[stem].append(bbox_line) + annotation_by_stem = { + stem: ("\n".join(lines) if lines else "No boxes") + for stem, lines in values.items() + } + except Exception: + annotation_by_stem = {} + + image_files = sorted(images_dir.glob("page_*.*")) if images_dir.exists() else [] + for image_file in image_files: + if len(samples) >= preview_limit: + break + image_data = _to_data_url(image_file) + samples.append( + { + "name": image_file.name, + "image_data": image_data, + "text": "", + "annotation": annotation_by_stem.get(image_file.stem, "annotation available"), + } + ) + + return { + "metadata": metadata, + "samples": samples, + } + + +def zip_entry(kind: str, entry_id: str) -> tuple[io.BytesIO, str]: + paths = get_storage_paths() + if kind not in {"transcripts", "datasets"}: + raise FileNotFoundError(kind) + + root_dir = paths[kind] + entry_dir = root_dir / entry_id + if not entry_dir.exists() or not entry_dir.is_dir(): + raise FileNotFoundError(entry_id) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + if kind == "transcripts": + base_prefix = Path(entry_id) + for file_path in sorted(entry_dir.glob("page*.txt")): + if file_path.is_file(): + arcname = base_prefix / "transcripts" / file_path.name + zf.write(file_path, arcname=str(arcname)) + + for ext in ("*.png", "*.jpg", "*.jpeg", "*.webp"): + for image_file in sorted(entry_dir.glob(f"page*{ext[1:]}")): + if image_file.is_file(): + arcname = base_prefix / "images" / image_file.name + zf.write(image_file, arcname=str(arcname)) + + metadata_file = entry_dir / "metadata.json" + if metadata_file.exists() and metadata_file.is_file(): + zf.write(metadata_file, arcname=str(base_prefix / "metadata.json")) + else: + for file_path in entry_dir.rglob("*"): + if file_path.is_file(): + arcname = file_path.relative_to(root_dir) + zf.write(file_path, arcname=str(arcname)) + + buf.seek(0) + return buf, f"{entry_id}.zip" + + +def delete_entry(kind: str, entry_id: str) -> bool: + paths = get_storage_paths() + if kind not in {"transcripts", "datasets"}: + return False + + entry_dir = paths[kind] / entry_id + if not entry_dir.exists() or not entry_dir.is_dir(): + return False + + safe_rmtree(entry_dir) + return True + + +def ensure_storage_layout() -> dict[str, str]: + paths = get_storage_paths() + return {k: str(v) for k, v in paths.items()} + + +def resolve_storage_root() -> str: + return os.fspath(Path(STORAGE_ROOT)) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/utils/__init__.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/utils/prompt.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/utils/prompt.py new file mode 100644 index 00000000..4eeedb5e --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/utils/prompt.py @@ -0,0 +1,101 @@ +"""The OCR prompt every provider sends. Tuned on Spanish print — edit with care.""" + +OCR_PROMPT = """ +--- + +You are performing **high-precision historical OCR transcription**. + +Your task is to transcribe **only the main body text** of the page with maximum fidelity and zero commentary. + +--- + +### PRIMARY OBJECTIVE + +Produce a clean diplomatic transcription of the main content exactly as printed, with standardized long-ſ normalization. + +--- + +### TRANSCRIPTION RULES + +1. Preserve original line breaks exactly. +2. Preserve paragraph spacing exactly. +3. Preserve original spelling (do NOT modernize or normalize spelling). +4. Preserve capitalization exactly as shown. +5. Preserve punctuation and special characters exactly. +6. Convert the long-ſ (ſ) to a standard "s" in all cases. +7. Preserve ligatures as standard character equivalents: + + * “ff” → “ff” + * “fi” → “fi” + * “fl” → “fl” + * “ffi” → “ffi” + * “ffl” → “ffl” +8. Preserve hyphenated line-break words exactly as printed. +9. Do NOT merge, reflow, or restructure lines. +10. Do NOT summarize. +11. Do NOT explain. +12. Output only the transcription text. + +--- + +### LAYOUT RULES + +* If the page has multiple columns, transcribe column-by-column from left to right. +* Preserve visible indentation. +* Preserve headings and section titles as plain text. +* Maintain original line structure even if it breaks mid-sentence. + +--- + +### CONTENT FILTERING RULES + +Include: + +* Main body text +* Headings and subheadings +* Page numbers only if embedded within the body text flow + +Exclude completely (without marking omission): + +* Marginal notes +* Side notes +* Running headers +* Running footers +* Catchwords +* Page signatures +* Printer marks +* Decorative elements +* Stamps +* Handwritten annotations + +Do NOT indicate omissions. Simply ignore excluded material. + +--- + +### RECONSTRUCTION RULES + +* If a word is partially faded but context makes reconstruction highly probable, output the reconstructed word normally. +* Prefer historically and linguistically plausible reconstructions. +* If multiple interpretations are possible, choose the most contextually probable one. +* If text cannot be reconstructed with high confidence, omit that word silently rather than inserting markers. + +Never insert: + +* Brackets of any kind +* Uncertainty markers +* Editorial comments +* Added punctuation not present in the original + +--- + +### OUTPUT REQUIREMENTS + +* Output only the transcription. +* No metadata. +* No explanations. +* No uncertainty markers. +* No additional formatting beyond faithful line preservation. + +The output must be clean, normalized (ſ → s), and suitable for direct OCR training use. + +""" \ No newline at end of file diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/utils/torch_device.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/utils/torch_device.py new file mode 100644 index 00000000..f9a3aa46 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/app/utils/torch_device.py @@ -0,0 +1,20 @@ +"""One place to pick the torch device: CUDA > MPS > CPU. + +MPS only kicks in on a native macOS run — Docker there has no Metal passthrough. +PaddleOCR ignores this entirely; it has no Metal backend. +""" + +from __future__ import annotations + +import torch + + +def select_torch_device() -> str: + if torch.cuda.is_available(): + return "cuda" + # getattr: some torch builds have no mps attribute at all. + mps = getattr(torch.backends, "mps", None) + if mps is not None and mps.is_available(): + return "mps" + return "cpu" + diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/main.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/main.py new file mode 100644 index 00000000..b76da332 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/main.py @@ -0,0 +1,19 @@ +""" +RenAIssance OCR Backend — launcher script. + +Delegates to the modular FastAPI application in app.main. +All business logic lives under the ``app`` package. +""" + +import os +import sys + +# Ensure the backend directory is on sys.path so that +# the ``preprocessing`` package (a sibling of ``app``) is importable. +backend_dir = os.path.dirname(os.path.abspath(__file__)) +if backend_dir not in sys.path: + sys.path.insert(0, backend_dir) + +if __name__ == "__main__": + import uvicorn + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/preprocessing/__init__.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/preprocessing/__init__.py new file mode 100644 index 00000000..06964521 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/preprocessing/__init__.py @@ -0,0 +1,15 @@ +"""OpenCV preprocessing ops for historical-document OCR. + +The ops themselves live in operations.py and are reached through OP_REGISTRY; +pipeline.py runs an ordered list of them. +""" + +from .operations import OP_REGISTRY +from .pipeline import run_pipeline, PipelineExecutor, validate_pipeline_config + +__all__ = [ + 'OP_REGISTRY', + 'run_pipeline', + 'PipelineExecutor', + 'validate_pipeline_config', +] diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/preprocessing/operations.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/preprocessing/operations.py new file mode 100644 index 00000000..87cb87fb --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/preprocessing/operations.py @@ -0,0 +1,766 @@ +"""OpenCV preprocessing ops for OCR. + +Every op is (image, params, progress?) -> image and copes with both grayscale +and colour input. OP_REGISTRY at the bottom is what the pipeline looks up. +""" + +import cv2 +import numpy as np +from typing import Dict, Any, Optional, Callable + + +ProgressCallbackType = Optional[Callable[[float, str], None]] + + +# ── Basic ───────────────────────────────────────────────────────────── + +def normalize_image( + img: np.ndarray, + params: Dict[str, Any], + progress: ProgressCallbackType = None +) -> np.ndarray: + """Histogram-stretch brightness/contrast. params: strength 0-100 (50).""" + if progress: + progress(0.1, "Analyzing histogram") + + strength = params.get("strength", 50) / 100.0 + + if len(img.shape) == 2: + normalized = _normalize_channel(img, strength) + else: + channels = cv2.split(img) + normalized_channels = [] + for i, ch in enumerate(channels): + if progress: + progress(0.2 + (i * 0.25), f"Normalizing channel {i+1}") + normalized_channels.append(_normalize_channel(ch, strength)) + normalized = cv2.merge(normalized_channels) + + if progress: + progress(1.0, "Normalize complete") + + return normalized + + +def _normalize_channel(channel: np.ndarray, strength: float) -> np.ndarray: + """Blend the channel toward a full 0-255 stretch by `strength`.""" + min_val = np.min(channel) + max_val = np.max(channel) + + if max_val == min_val: + return channel + + full_normalized = cv2.normalize(channel, None, 0, 255, cv2.NORM_MINMAX) + + if strength >= 1.0: + return full_normalized + + return cv2.addWeighted( + channel.astype(np.float32), 1 - strength, + full_normalized.astype(np.float32), strength, + 0 + ).astype(np.uint8) + + +def to_grayscale( + img: np.ndarray, + params: Dict[str, Any], + progress: ProgressCallbackType = None +) -> np.ndarray: + """Convert to grayscale. No params; already-gray input passes through.""" + if progress: + progress(0.2, "Converting to grayscale") + + if len(img.shape) == 2: + result = img + elif len(img.shape) == 3 and img.shape[2] == 1: + result = img.squeeze() # single channel, but still 3D + else: + result = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + if progress: + progress(1.0, "Grayscale complete") + + return result + + +# Below this spread (degrees) between the most- and least-skewed band, the page +# is skewed uniformly and a single rotation is cleaner than a piecewise warp. +_PIECEWISE_MIN_SPREAD = 1.0 + + +def deskew_image( + img: np.ndarray, + params: Dict[str, Any], + progress: ProgressCallbackType = None +) -> np.ndarray: + """Straighten a rotated scan, handling skew that drifts down the page. + + params: maxAngle in degrees (15); mode 'auto'|'global'|'piecewise' ('auto'); + bands 2-12 (4) for the piecewise path. + + A single rotation cannot fix a page whose top is straight but whose lower + half tilts (common near a book's spine, or when the sheet lifts off the + platen). 'auto' measures the skew separately in horizontal bands: if it is + roughly constant it rotates once, otherwise it corrects each band by its own + angle and blends the bands so there is no visible seam. + """ + if progress: + progress(0.1, "Preparing deskew analysis") + + max_angle = params.get("maxAngle", 15) + mode = str(params.get("mode", "auto")).lower() + num_bands = max(2, min(12, int(params.get("bands", 4)))) + + is_color = len(img.shape) == 3 + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if is_color else img.copy() + + if progress: + progress(0.25, "Detecting skew angle") + + # Whole-page angle: the fallback for uniform skew and for bands with no ink. + global_angle = _detect_skew_contour(gray) + if global_angle is None or abs(global_angle) > max_angle: + global_angle = _detect_skew_hough(gray) + if global_angle is None: + global_angle = 0.0 + global_angle = max(-max_angle, min(max_angle, global_angle)) + + if mode == "global": + return _apply_global_rotation(img, global_angle, progress) + + if progress: + progress(0.45, "Measuring skew per band") + + band_angles = _band_skew_angles(gray, num_bands, max_angle, global_angle) + spread = max(band_angles) - min(band_angles) + + # Uniform skew (or an explicit request for one rotation): rotate once. + if mode == "auto" and spread < _PIECEWISE_MIN_SPREAD: + return _apply_global_rotation(img, float(np.median(band_angles)), progress) + + if progress: + progress(0.6, "Applying piecewise deskew") + + result = _piecewise_deskew(img, band_angles) + + if progress: + progress(1.0, "Deskew complete") + + return result + + +def _apply_global_rotation( + img: np.ndarray, angle: float, progress: ProgressCallbackType = None +) -> np.ndarray: + """Rotate the whole image about its centre by `angle` (already clamped).""" + if abs(angle) < 0.1: + if progress: + progress(1.0, "No significant skew detected") + return img + + if progress: + progress(0.6, f"Rotating by {angle:.2f}°") + + h, w = img.shape[:2] + center = (w // 2, h // 2) + M = cv2.getRotationMatrix2D(center, angle, 1.0) + + # Replicate the border instead of filling black — cleaner page edges. + rotated = cv2.warpAffine( + img, M, (w, h), + flags=cv2.INTER_CUBIC, + borderMode=cv2.BORDER_REPLICATE + ) + + if progress: + progress(1.0, "Deskew complete") + + return rotated + + +def _detect_skew_contour(gray: np.ndarray) -> Optional[float]: + """Skew angle from the min-area rect around the largest contour.""" + _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) + contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + if not contours: + return None + + largest = max(contours, key=cv2.contourArea) + rect = cv2.minAreaRect(largest) + angle = rect[-1] + + # minAreaRect reports in (-90, 0]; fold it into (-45, 45]. + if angle < -45: + angle += 90 + elif angle > 45: + angle -= 90 + + return angle + + +def _detect_skew_hough(gray: np.ndarray) -> Optional[float]: + """Skew angle from the median slope of near-horizontal Hough lines.""" + edges = cv2.Canny(gray, 50, 150, apertureSize=3) + lines = cv2.HoughLinesP( + edges, 1, np.pi / 180, + threshold=100, + minLineLength=gray.shape[1] // 4, + maxLineGap=10 + ) + + if lines is None or len(lines) == 0: + return None + + angles = [] + for line in lines: + x1, y1, x2, y2 = line[0] + if x2 != x1: + angle = np.degrees(np.arctan2(y2 - y1, x2 - x1)) + if abs(angle) < 45: # text lines, not vertical rules + angles.append(angle) + + if not angles: + return None + + return np.median(angles) + + +def _band_skew_angles( + gray: np.ndarray, num_bands: int, max_angle: float, fallback_angle: float +) -> list[float]: + """Estimate the correction angle for each horizontal band, top to bottom. + + Runs on a downscaled copy (angles are scale-invariant) so estimating many + bands stays cheap. Bands with too little ink to trust inherit the median of + the confident bands (or the whole-page angle), then the series is lightly + smoothed so one noisy band cannot kink the correction. + """ + scale = min(1.0, 1000.0 / max(gray.shape[:2])) + small = ( + cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) + if scale < 1.0 else gray + ) + _, binary = cv2.threshold(small, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) + + h = binary.shape[0] + raw: list[Optional[float]] = [] + for i in range(num_bands): + y0 = i * h // num_bands + y1 = (i + 1) * h // num_bands + raw.append(_projection_profile_angle(binary[y0:y1], max_angle)) + + return _fill_band_angles(raw, fallback_angle) + + +def _projection_profile_angle( + binary_band: np.ndarray, max_angle: float +) -> Optional[float]: + """Angle (in [-max_angle, max_angle]) that makes this band's lines horizontal. + + Classic projection-profile deskew: at the correct angle the rows of text + line up, so the horizontal ink projection has the sharpest peaks and valleys. + Score each candidate by the summed squared row-to-row change in that + projection and keep the best, coarse (1°) then refined (0.25°). Returns None + when the band holds too little ink to give a trustworthy answer. + """ + if binary_band.size == 0 or int(binary_band.sum()) < 255 * 50: + return None + + h, w = binary_band.shape + + def score(angle: float) -> float: + M = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0) + rot = cv2.warpAffine( + binary_band, M, (w, h), + flags=cv2.INTER_NEAREST, + borderMode=cv2.BORDER_CONSTANT, borderValue=0, + ) + proj = rot.sum(axis=1, dtype=np.float64) + d = np.diff(proj) + return float(np.dot(d, d)) + + coarse = np.arange(-max_angle, max_angle + 0.5, 1.0) + best = max(coarse, key=score) + fine = np.arange(best - 1.0, best + 1.0 + 1e-6, 0.25) + best = max(fine, key=score) + return float(max(-max_angle, min(max_angle, best))) + + +def _fill_band_angles( + raw: list[Optional[float]], fallback_angle: float +) -> list[float]: + """Replace unconfident (None) bands with a sensible default, then smooth.""" + confident = [a for a in raw if a is not None] + default = float(np.median(confident)) if confident else float(fallback_angle) + filled = [a if a is not None else default for a in raw] + + # 3-tap moving average: a single outlier band bends the correction less. + smoothed = [] + for i in range(len(filled)): + window = filled[max(0, i - 1):i + 2] + smoothed.append(float(np.mean(window))) + return smoothed + + +def _piecewise_deskew(img: np.ndarray, band_angles: list[float]) -> np.ndarray: + """Rotate each horizontal band by its own angle and blend the bands. + + Each band is corrected by rotating the whole image about that band's centre, + then every output row is a linear blend of the two nearest band rotations. + The blend weights form a partition of unity (they sum to 1 per row), so the + transition between bands is gradual and leaves no seam. + """ + h, w = img.shape[:2] + n = len(band_angles) + cx = w / 2.0 + + # Band-index coordinate for each output row: band i's centre sits at p = i. + p = np.clip(np.arange(h) / h * n - 0.5, 0.0, n - 1) + + channel_shape = () if img.ndim == 2 else (img.shape[2],) + acc = np.zeros((h, w) + channel_shape, dtype=np.float32) + + for i, angle in enumerate(band_angles): + weight = np.clip(1.0 - np.abs(p - i), 0.0, 1.0) # triangular + if not weight.any(): + continue + cy = (i + 0.5) * h / n + M = cv2.getRotationMatrix2D((cx, cy), float(angle), 1.0) + rot = cv2.warpAffine( + img, M, (w, h), + flags=cv2.INTER_CUBIC, + borderMode=cv2.BORDER_REPLICATE, + ).astype(np.float32) + acc += rot * weight.reshape(h, *([1] * len(channel_shape))) + + return np.clip(acc, 0, 255).astype(np.uint8) + + +# ── Enhancement ─────────────────────────────────────────────────────── + +def denoise_image( + img: np.ndarray, + params: Dict[str, Any], + progress: ProgressCallbackType = None +) -> np.ndarray: + """Denoise without smearing text edges. + + params: method 'nlm' (best, slow) | 'bilateral' | 'gaussian' (fastest), + strength 1-20 (10). + """ + if progress: + progress(0.1, "Preparing denoising") + + method = params.get("method", "nlm") + strength = params.get("strength", 10) + img = img.astype(np.uint8) + + if progress: + progress(0.2, f"Applying {method} denoising") + + if method == "bilateral": + d = max(5, min(15, int(strength))) + sigma_color = strength * 7.5 + sigma_space = strength * 7.5 + result = cv2.bilateralFilter(img, d, sigma_color, sigma_space) + + elif method == "gaussian": + ksize = max(3, int(strength) | 1) # kernel must be odd + result = cv2.GaussianBlur(img, (ksize, ksize), 0) + + else: # nlm + h = max(3, min(30, strength)) + template_window = 7 + search_window = 21 + + if len(img.shape) == 3: + result = cv2.fastNlMeansDenoisingColored( + img, None, h, h, template_window, search_window + ) + else: + result = cv2.fastNlMeansDenoising( + img, None, h, template_window, search_window + ) + + if progress: + progress(1.0, "Denoise complete") + + return result + + +def clahe_contrast( + img: np.ndarray, + params: Dict[str, Any], + progress: ProgressCallbackType = None +) -> np.ndarray: + """CLAHE — local contrast without blowing up noise. + + params: clipLimit (2.0), tileSize (8). + """ + if progress: + progress(0.1, "Preparing CLAHE") + + clip_limit = params.get("clipLimit", 2.0) + tile_size = params.get("tileSize", 8) + tile_size = max(2, min(16, int(tile_size))) + + clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(tile_size, tile_size)) + + if progress: + progress(0.3, "Applying CLAHE") + + if len(img.shape) == 2: + result = clahe.apply(img) + else: + # Colour: only touch luminance, so hues stay put. + lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) + l, a, b = cv2.split(lab) + + if progress: + progress(0.5, "Enhancing luminance") + + l = clahe.apply(l) + lab = cv2.merge([l, a, b]) + result = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR) + + if progress: + progress(1.0, "Contrast enhancement complete") + + return result + + +def sharpen_image( + img: np.ndarray, + params: Dict[str, Any], + progress: ProgressCallbackType = None +) -> np.ndarray: + """Unsharp-mask the text edges. + + params: amount 0-100 (50), radius in px 0.5-3 (1). + """ + if progress: + progress(0.1, "Preparing sharpening") + + amount = params.get("amount", 50) / 100.0 + radius = params.get("radius", 1.0) + + if amount <= 0: + if progress: + progress(1.0, "No sharpening applied") + return img + + ksize = max(3, int(radius * 2) | 1) # kernel must be odd + + if progress: + progress(0.3, "Creating blur mask") + + if len(img.shape) == 3: + blurred = cv2.GaussianBlur(img, (ksize, ksize), radius) + else: + blurred = cv2.GaussianBlur(img, (ksize, ksize), radius) + + if progress: + progress(0.6, "Applying unsharp mask") + + # original + amount * (original - blurred) — adds the high frequencies back. + sharpened = cv2.addWeighted( + img.astype(np.float32), 1.0 + amount, + blurred.astype(np.float32), -amount, + 0 + ) + + result = np.clip(sharpened, 0, 255).astype(np.uint8) + + if progress: + progress(1.0, "Sharpen complete") + + return result + + +# ── Binarization ────────────────────────────────────────────────────── + +def threshold_image( + img: np.ndarray, + params: Dict[str, Any], + progress: ProgressCallbackType = None +) -> np.ndarray: + """Binarize to pure black/white. + + params: method 'otsu' | 'adaptive' | 'sauvola' (best on degraded scans), + blockSize (15) and k (0.5) for the local methods. + """ + if progress: + progress(0.1, "Preparing binarization") + + method = params.get("method", "otsu") + block_size = params.get("blockSize", 15) + k = params.get("k", 0.5) + block_size = max(3, int(block_size) | 1) # must be odd, >= 3 + + if len(img.shape) == 3: + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + else: + gray = img.copy() + + if progress: + progress(0.3, f"Applying {method} thresholding") + + if method == "adaptive": + result = cv2.adaptiveThreshold( + gray, 255, + cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY, + block_size, 8 + ) + + elif method == "sauvola": + result = _sauvola_threshold(gray, block_size, k, progress) + + else: # otsu + _, result = cv2.threshold( + gray, 0, 255, + cv2.THRESH_BINARY + cv2.THRESH_OTSU + ) + + if progress: + progress(1.0, "Binarization complete") + + return result + + +def _sauvola_threshold( + gray: np.ndarray, + window_size: int, + k: float, + progress: ProgressCallbackType = None +) -> np.ndarray: + """Sauvola: T(x,y) = mean * (1 + k * (std / R - 1)), R = 128 for 8-bit. + + Means and variances come from box filters, so it stays O(n) per pixel. + """ + if progress: + progress(0.4, "Computing local statistics") + + mean = cv2.blur(gray.astype(np.float64), (window_size, window_size)) + sq_mean = cv2.blur(gray.astype(np.float64) ** 2, (window_size, window_size)) + + variance = sq_mean - mean ** 2 + variance = np.maximum(variance, 0) # float error can push this negative + std = np.sqrt(variance) + + if progress: + progress(0.7, "Computing threshold map") + + R = 128.0 + threshold = mean * (1.0 + k * (std / R - 1.0)) + + if progress: + progress(0.9, "Applying threshold") + + result = np.zeros_like(gray) + result[gray > threshold] = 255 + + return result.astype(np.uint8) + + +# ── Morphology ──────────────────────────────────────────────────────── + +def morph_operations( + img: np.ndarray, + params: Dict[str, Any], + progress: ProgressCallbackType = None +) -> np.ndarray: + """Morphological cleanup of text and artifacts. + + params: operation open|close|dilate|erode|gradient|tophat|blackhat ('open'), + kernelSize 1-9 (2), kernelShape ellipse|rect|cross, iterations 1-10 (1). + """ + if progress: + progress(0.1, "Preparing morphological operation") + + operation = params.get("operation", "open") + k = max(1, min(9, int(params.get("kernelSize", 2)))) + shape_name = params.get("kernelShape", "ellipse") + iterations = max(1, min(10, int(params.get("iterations", 1)))) + + shape_map = { + "ellipse": cv2.MORPH_ELLIPSE, + "rect": cv2.MORPH_RECT, + "cross": cv2.MORPH_CROSS, + } + shape = shape_map.get(shape_name, cv2.MORPH_ELLIPSE) + kernel = cv2.getStructuringElement(shape, (k, k)) + + if progress: + progress(0.3, f"Applying {operation} (k={k}, iter={iterations})") + + # dilate/erode aren't morphologyEx ops, so they're handled separately below. + morph_ops = { + "open": cv2.MORPH_OPEN, + "close": cv2.MORPH_CLOSE, + "gradient": cv2.MORPH_GRADIENT, + "tophat": cv2.MORPH_TOPHAT, + "blackhat": cv2.MORPH_BLACKHAT, + } + + if operation in morph_ops: + result = cv2.morphologyEx( + img, morph_ops[operation], kernel, iterations=iterations + ) + elif operation == "dilate": + result = cv2.dilate(img, kernel, iterations=iterations) + elif operation == "erode": + result = cv2.erode(img, kernel, iterations=iterations) + else: + result = cv2.morphologyEx( + img, cv2.MORPH_OPEN, kernel, iterations=iterations + ) + + if progress: + progress(1.0, "Morphological operation complete") + + return result + + +# ── Blob & noise removal ────────────────────────────────────────────── + +def remove_large_blobs( + img: np.ndarray, + params: Dict[str, Any], + progress: ProgressCallbackType = None +) -> np.ndarray: + """Punch out big ink blobs, keeping the letters they touch. + + Only the eroded inner core gets painted white — erasing the whole connected + component would take neighbouring characters with it. + + params: minArea (3000), minSolidity (0.55), maxAspectRatio (4.0), + erosionRatio (0.35 — higher removes less, safer). + """ + if progress: + progress(0.1, "Preparing blob detection") + + min_area = int(params.get("minArea", 3000)) + min_solidity = float(params.get("minSolidity", 0.55)) + max_aspect_ratio = float(params.get("maxAspectRatio", 4.0)) + erosion_ratio = float(params.get("erosionRatio", 0.35)) + + gray = img if len(img.shape) == 2 else cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + _, binary = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY) + + # Invert first: connectedComponents wants ink as white foreground. + inverted = cv2.bitwise_not(binary) + num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( + inverted, connectivity=8 + ) + + if progress: + progress(0.3, f"Analyzing {num_labels - 1} components") + + result = binary.copy() + + for lbl in range(1, num_labels): + area = stats[lbl, cv2.CC_STAT_AREA] + + if area <= min_area: # small enough to just be a character + continue + + w = stats[lbl, cv2.CC_STAT_WIDTH] + h = stats[lbl, cv2.CC_STAT_HEIGHT] + aspect = max(w, h) / max(min(w, h), 1) + + if aspect > max_aspect_ratio: # long and thin — a stroke or page border + continue + + # Ragged outline means text; a real blob is close to its convex hull. + component_mask = np.uint8(labels == lbl) * 255 + contours, _ = cv2.findContours( + component_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + if not contours: + continue + + hull_area = cv2.contourArea(cv2.convexHull(contours[0])) + solidity = float(area) / hull_area if hull_area > 0 else 0.0 + + if solidity < min_solidity: + continue + + k_radius = max(3, int(erosion_ratio * (area ** 0.5))) + k_size = 2 * k_radius + 1 + kern = cv2.getStructuringElement( + cv2.MORPH_ELLIPSE, (k_size, k_size) + ) + core = cv2.erode(component_mask, kern, iterations=1) + result[core == 255] = 255 # white == background + + if progress: + pct = 0.3 + 0.6 * (lbl / max(num_labels - 1, 1)) + progress(min(pct, 0.9), f"Processing blob {lbl}") + + if progress: + progress(1.0, "Blob removal complete") + + return result + + +def remove_small_noise( + img: np.ndarray, + params: Dict[str, Any], + progress: ProgressCallbackType = None +) -> np.ndarray: + """Drop speckles and scanner dust. params: maxArea (20).""" + if progress: + progress(0.1, "Preparing noise detection") + + max_area = max(1, int(params.get("maxArea", 20))) + + gray = img if len(img.shape) == 2 else cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + _, binary = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY) + + inverted = cv2.bitwise_not(binary) + num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( + inverted, connectivity=8 + ) + + if progress: + progress(0.4, f"Filtering {num_labels - 1} components (threshold={max_area})") + + keep_mask = np.zeros_like(inverted) + for lbl in range(1, num_labels): + area = stats[lbl, cv2.CC_STAT_AREA] + if area >= max_area: + keep_mask[labels == lbl] = 255 + + result = cv2.bitwise_not(keep_mask) + + if progress: + progress(1.0, "Small noise removal complete") + + return result + + +# ── Registry ────────────────────────────────────────────────────────── + +OP_REGISTRY = { + "normalize": normalize_image, + "grayscale": to_grayscale, + "deskew": deskew_image, + "denoise": denoise_image, + "contrast": clahe_contrast, + "sharpen": sharpen_image, + "threshold": threshold_image, + "morph": morph_operations, + "remove_blobs": remove_large_blobs, + "remove_noise": remove_small_noise, +} + + +def get_operation(name: str): + return OP_REGISTRY.get(name) + + +def list_operations() -> list[str]: + return list(OP_REGISTRY.keys()) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/preprocessing/pipeline.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/preprocessing/pipeline.py new file mode 100644 index 00000000..699c4a92 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/preprocessing/pipeline.py @@ -0,0 +1,211 @@ +"""Runs an ordered list of preprocessing ops over one image. + +Reports progress per step and either stops or keeps going on failure. +""" + +import traceback +from typing import Dict, Any, Optional, Callable, List +from dataclasses import dataclass, field +import numpy as np + +from .operations import OP_REGISTRY, get_operation +from .progress import ProgressCallback, ProgressAggregator, ProgressInfo + + +@dataclass +class PipelineStep: + """One step in the pipeline.""" + op: str + params: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class PipelineResult: + """Result of pipeline execution""" + success: bool + image: Optional[np.ndarray] + progress_info: Dict[str, Any] + errors: List[Dict[str, Any]] = field(default_factory=list) + + +class PipelineExecutor: + """Runs the steps in order, timing each one and reporting progress.""" + + def __init__( + self, + on_progress: Optional[Callable[[ProgressInfo], None]] = None, + continue_on_error: bool = False, + ): + self.on_progress = on_progress + self.continue_on_error = continue_on_error + self.aggregator = ProgressAggregator() + + def execute( + self, + image: np.ndarray, + steps: List[Dict[str, Any]], + preview_mode: bool = False, + ) -> PipelineResult: + """Run `steps` ([{op, params, enabled}, ...]) over `image`. + + preview_mode swaps in faster, lower-quality variants of some ops. + """ + self.aggregator.start() + + active_steps = [ + PipelineStep(op=s.get("op", ""), params=s.get("params", {})) + for s in steps + if s.get("enabled", True) + ] + + if not active_steps: + self.aggregator.finish() + return PipelineResult( + success=True, + image=image, + progress_info=self.aggregator.get_summary(), + ) + + total_steps = len(active_steps) + current_image = image.copy() + errors = [] + + for i, step in enumerate(active_steps): + step_name = step.op + self.aggregator.step_started(step_name, i, total_steps) + op_func = get_operation(step_name) + + if op_func is None: + error_info = { + "step": step_name, + "index": i, + "error": f"Unknown operation: {step_name}", + } + errors.append(error_info) + self.aggregator.step_completed(success=False, error=error_info["error"]) + + if not self.continue_on_error: + self.aggregator.finish() + return PipelineResult( + success=False, + image=current_image, + progress_info=self.aggregator.get_summary(), + errors=errors, + ) + continue + + progress = ProgressCallback( + on_progress=self._handle_step_progress, + step_name=step_name, + step_index=i, + total_steps=total_steps, + ) + + try: + params = step.params.copy() + if preview_mode: + params = self._adjust_params_for_preview(step_name, params) + + current_image = op_func(current_image, params, progress) + + self.aggregator.step_completed(success=True) + + except Exception as e: + error_msg = str(e) + error_trace = traceback.format_exc() + + error_info = { + "step": step_name, + "index": i, + "error": error_msg, + "traceback": error_trace, + } + errors.append(error_info) + self.aggregator.step_completed(success=False, error=error_msg) + + if not self.continue_on_error: + self.aggregator.finish() + return PipelineResult( + success=False, + image=current_image, + progress_info=self.aggregator.get_summary(), + errors=errors, + ) + + self.aggregator.finish() + + return PipelineResult( + success=len(errors) == 0, + image=current_image, + progress_info=self.aggregator.get_summary(), + errors=errors, + ) + + def _handle_step_progress(self, info: ProgressInfo): + """Handle progress from individual step""" + self.aggregator.update_progress(info) + if self.on_progress: + self.on_progress(info) + + def _adjust_params_for_preview( + self, + op_name: str, + params: Dict[str, Any] + ) -> Dict[str, Any]: + """Trade quality for speed in preview mode.""" + adjusted = params.copy() + + # NLM denoising is far too slow for live preview; bilateral is close enough. + if op_name == "denoise": + if adjusted.get("method") == "nlm": + adjusted["method"] = "bilateral" + adjusted["strength"] = min(adjusted.get("strength", 10), 10) + + return adjusted + + +def run_pipeline( + image: np.ndarray, + steps: List[Dict[str, Any]], + on_progress: Optional[Callable[[ProgressInfo], None]] = None, + continue_on_error: bool = False, + preview_mode: bool = False, +) -> PipelineResult: + """One-shot PipelineExecutor. + + steps looks like [{"op": "grayscale", "params": {}, "enabled": True}, ...]. + """ + executor = PipelineExecutor( + on_progress=on_progress, + continue_on_error=continue_on_error, + ) + + return executor.execute(image, steps, preview_mode=preview_mode) + + +def validate_pipeline_config(steps: List[Dict[str, Any]]) -> Dict[str, Any]: + """Check a step list before running it. Returns {valid, errors}.""" + errors = [] + + if not isinstance(steps, list): + return {"valid": False, "errors": ["Pipeline must be a list of steps"]} + + for i, step in enumerate(steps): + if not isinstance(step, dict): + errors.append(f"Step {i}: must be a dictionary") + continue + + op = step.get("op") + if not op: + errors.append(f"Step {i}: missing 'op' field") + elif op not in OP_REGISTRY: + errors.append(f"Step {i}: unknown operation '{op}'") + + params = step.get("params") + if params is not None and not isinstance(params, dict): + errors.append(f"Step {i}: 'params' must be a dictionary") + + return { + "valid": len(errors) == 0, + "errors": errors, + } diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/preprocessing/progress.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/preprocessing/progress.py new file mode 100644 index 00000000..c4b17e28 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/preprocessing/progress.py @@ -0,0 +1,121 @@ +"""Progress reporting for the preprocessing pipeline. + +Ops call a ProgressCallback with 0..1; the aggregator turns per-step reports +into the overall percentage + timing summary the API returns. +""" + +from typing import Callable, Optional, Dict, Any +from dataclasses import dataclass +import time + + +@dataclass +class ProgressInfo: + """One progress report from a single operation.""" + step: str + percent: float + message: str = "" + step_index: int = 0 + total_steps: int = 0 + elapsed_ms: int = 0 + + +class ProgressCallback: + """Scales one operation's 0..1 progress into overall pipeline percent.""" + + def __init__( + self, + on_progress: Optional[Callable[[ProgressInfo], None]] = None, + step_name: str = "", + step_index: int = 0, + total_steps: int = 1, + ): + self.on_progress = on_progress + self.step_name = step_name + self.step_index = step_index + self.total_steps = total_steps + self.start_time = time.time() + + def __call__(self, percent: float, message: str = ""): + """percent is 0.0–1.0 within this step; every step weighs the same.""" + elapsed_ms = int((time.time() - self.start_time) * 1000) + + step_contribution = 1.0 / self.total_steps if self.total_steps > 0 else 1.0 + overall_percent = (self.step_index + percent) * step_contribution * 100 + + info = ProgressInfo( + step=self.step_name, + percent=min(100, max(0, overall_percent)), + message=message, + step_index=self.step_index, + total_steps=self.total_steps, + elapsed_ms=elapsed_ms, + ) + + if self.on_progress: + self.on_progress(info) + + +class ProgressAggregator: + """Collects per-step timing/status for the API's progress_info payload.""" + + def __init__(self): + self.steps: list[Dict[str, Any]] = [] + self.start_time: Optional[float] = None + self.end_time: Optional[float] = None + self.current_percent: float = 0 + + def start(self): + """Mark pipeline start""" + self.start_time = time.time() + self.steps = [] + + def step_started(self, step_name: str, index: int, total: int): + """Mark step started""" + self.steps.append({ + "step": step_name, + "index": index, + "total": total, + "start_time": time.time(), + "end_time": None, + "duration_ms": None, + "success": None, + "error": None, + }) + + def step_completed(self, success: bool = True, error: Optional[str] = None): + """Mark current step completed""" + if self.steps: + step = self.steps[-1] + step["end_time"] = time.time() + step["duration_ms"] = int((step["end_time"] - step["start_time"]) * 1000) + step["success"] = success + step["error"] = error + + def update_progress(self, info: ProgressInfo): + """Handle progress update from callback""" + self.current_percent = info.percent + + def finish(self): + """Mark pipeline finished""" + self.end_time = time.time() + + def get_summary(self) -> Dict[str, Any]: + """Get progress summary for API response""" + total_ms = 0 + if self.start_time and self.end_time: + total_ms = int((self.end_time - self.start_time) * 1000) + + return { + "total_duration_ms": total_ms, + "steps": [ + { + "step": s["step"], + "duration_ms": s["duration_ms"], + "success": s["success"], + "error": s["error"], + } + for s in self.steps + ], + "final_percent": self.current_percent, + } diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/requirements-dev.txt b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/requirements-dev.txt new file mode 100644 index 00000000..0d5f8b4c --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/requirements-dev.txt @@ -0,0 +1,5 @@ +# Dev/test-only dependencies (never installed into the runtime image). +# Install on top of requirements.txt: pip install -r requirements-dev.txt +-r requirements.txt +pytest==8.3.4 +pytest-cov==6.0.0 diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/requirements.txt b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/requirements.txt new file mode 100644 index 00000000..1efda2e8 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/requirements.txt @@ -0,0 +1,88 @@ +# ============================================================ +# RenAIssance OCR Backend — Python dependencies +# Pinned for Python 3.11 + Ubuntu 24.04 + CUDA 12.6 +# ============================================================ + +# --- Web framework --- +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +python-multipart==0.0.12 +pydantic==2.10.4 + +# --- HTTP client --- +httpx==0.28.1 + +# --- Lightweight user tracking / auth --- +# DATABASE_URL-driven: SQLite by default (on the storage volume), or any +# postgresql:// URL (Neon/Supabase) with no code change. psycopg2-binary is +# only used when a Postgres URL is supplied; it is harmless otherwise. +SQLAlchemy==2.0.36 +psycopg2-binary==2.9.10 +bcrypt==4.2.1 +itsdangerous==2.2.0 +email-validator==2.2.0 + +# --- AI / OCR providers --- +google-genai==1.0.0 +protobuf==5.29.2 + +# --- Document export --- +python-docx==1.1.2 +reportlab==4.2.5 + +# --- Image processing --- +Pillow==11.1.0 +opencv-python-headless==4.10.0.84 +# numpy 1.24.4: satisfies paddlex 3.4 (numpy>=1.24), paddlepaddle-gpu 3.3.0, +# transformers 4.53 (>=1.17) and opencv-python-headless. Kept at 1.24.4 to +# avoid a numpy 2.x ABI bump across the pinned native wheels. +numpy==1.24.4 + +# --- Utilities --- +python-dotenv==1.0.1 +psutil==6.1.0 + +# --- paddlepaddle-gpu runtime deps (installed with --no-deps in Dockerfile) --- +# These are the non-CUDA runtime deps that paddle 3.3.0 would normally pull +# transitively. Torch/FastAPI/pydantic already bring filelock, typing_extensions, +# networkx, numpy, Pillow, protobuf, httpx — the only one missing is opt_einsum. +opt_einsum==3.3.0 + +# --- Local recognition + post-processing models (CRNN / TrOCR / Qwen3 LoRA) --- +torch==2.5.1 +# transformers 4.53.x: needed for the Qwen3 architecture (the fine-tuned +# Spanish post-processing adapter is Qwen3-4B, added to transformers in 4.51). +# It requires tokenizers>=0.21,<0.22 — which is why paddleocr is bumped to 3.4 +# below (paddlex 3.0.0 hard-pinned tokenizers==0.19.1; 3.4.x relaxed it to +# >=0.19, so 0.21 satisfies both). Still supports the same +# VisionEncoderDecoderModel / TrOCRProcessor APIs the recognition service uses. +transformers==4.53.3 +tokenizers==0.21.2 +# safetensors must be >=0.6.0 to satisfy paddlepaddle-gpu==3.3.0; +# transformers 4.53.3 only requires >=0.4.3, so 0.6.x works for both. +safetensors==0.6.2 +# QLoRA post-processing: base Qwen3-4B loaded 4-bit + LoRA adapter (peft), +# 4-bit kernels (bitsandbytes, GPU-only), device placement (accelerate). +peft==0.19.1 +bitsandbytes==0.49.2 +accelerate==1.10.1 + +# --- PDF parsing --- +PyMuPDF==1.25.1 + +# --- Layout-aware GPU detection --- +# Requires: NVIDIA driver >= 560 (CUDA 12.6) on host, --gpus all on docker run. +# +# NOTE: paddlepaddle-gpu is NOT listed here — it is installed as a separate +# --no-deps step in the Dockerfile. Reason: paddlepaddle-gpu==3.3.0 hard-pins +# nvidia-nccl-cu12==2.25.1 (among several other nvidia-*-cu12 ==X.Y.Z pins) +# while no torch release on PyPI pins that exact nccl. Letting pip resolve +# both packages together is impossible under the modern resolver. Both +# frameworks bundle their own CUDA user-space libraries under torch/lib/ +# and paddle.libs/ respectively, so the nvidia-*-cu12 site-packages copies +# are redundant at runtime — installing paddle with --no-deps is safe. +# +# Bumped 3.0.0 -> 3.4.0: 3.0.0's paddlex hard-pinned tokenizers==0.19.1, which +# blocks the transformers 4.53 needed for Qwen3. paddlex 3.4.x relaxes it to +# tokenizers>=0.19. Runs on the same paddlepaddle-gpu 3.3.0 wheel. +paddleocr==3.4.0 \ No newline at end of file diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/scripts/entrypoint.sh b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/scripts/entrypoint.sh new file mode 100755 index 00000000..64871318 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/scripts/entrypoint.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# ============================================================================ +# RenAIssance backend container entrypoint. +# +# Runs a non-blocking preflight check (RAM + GPU/CUDA availability for the +# image variant) that only LOGS — it never aborts boot, so the server always +# starts and the non-GPU features (Gemini OCR, preprocessing, export) keep +# working even if the GPU image was started without `--gpus all`. +# +# The hard "your machine does not meet the requirements" gate lives in the +# host launcher (run.sh / run.ps1), which checks before pulling the image. +# ============================================================================ +set -euo pipefail + +# Best-effort: a failing preflight must not prevent the server from starting. +python /app/scripts/preflight.py || true + +# Hand off to the CMD (uvicorn ...). +exec "$@" diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/scripts/normalize_trocr_config.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/scripts/normalize_trocr_config.py new file mode 100644 index 00000000..141b630c --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/scripts/normalize_trocr_config.py @@ -0,0 +1,85 @@ +"""Drop a known-good processor next to the fine-tuned TrOCR weights. + +We fine-tuned from microsoft/trocr-base-printed, so its tokenizer and image +stats match exactly. Copying them in at build time makes the checkpoint load +regardless of which transformers version serialized the original processor. +No-op when the weights aren't present, so dev builds still work. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from transformers import TrOCRProcessor + +# The base model the user fine-tuned from (see +# RenAIssanceExperimental/experimentation.ipynb). +BASE_MODEL_ID = "microsoft/trocr-base-printed" + +TROCR_DIR = Path("/app/models/weights/trocr") + +# Files that belong to the fine-tuned weights — we must not overwrite these +# with the base model's copies. +FINETUNED_ARTIFACTS = { + "config.json", + "generation_config.json", + "model.safetensors", + "pytorch_model.bin", +} + + +def main() -> None: + if not TROCR_DIR.is_dir(): + print(f"[normalize_trocr] {TROCR_DIR} does not exist — skipping") + return + + weights = TROCR_DIR / "model.safetensors" + if not weights.is_file(): + print(f"[normalize_trocr] no model.safetensors in {TROCR_DIR} — skipping") + return + + print(f"[normalize_trocr] pulling processor assets from {BASE_MODEL_ID}") + processor = TrOCRProcessor.from_pretrained(BASE_MODEL_ID) + + # Save all processor assets (preprocessor_config.json + vocab.json + + # merges.txt + tokenizer_config.json + special_tokens_map.json + + # tokenizer.json) into a staging directory, then copy only the files we + # don't already have — this prevents clobbering the fine-tuned weights. + staging = TROCR_DIR / ".processor_staging" + if staging.exists(): + shutil.rmtree(staging) + staging.mkdir() + processor.save_pretrained(staging) + + copied: list[str] = [] + replaced: list[str] = [] + for src in staging.iterdir(): + if src.name in FINETUNED_ARTIFACTS: + continue + dst = TROCR_DIR / src.name + if dst.exists(): + replaced.append(src.name) + else: + copied.append(src.name) + shutil.copy2(src, dst) + + shutil.rmtree(staging) + + # The newer-format bundled processor_config.json confuses 4.44.x's loader + # once preprocessor_config.json is in place ("multiple values for + # image_processor"). Remove it — the dir is self-consistent without it. + stale = TROCR_DIR / "processor_config.json" + if stale.exists(): + stale.unlink() + replaced.append("processor_config.json (removed)") + + if copied: + print(f"[normalize_trocr] copied: {sorted(copied)}") + if replaced: + print(f"[normalize_trocr] replaced: {sorted(replaced)}") + print("[normalize_trocr] done") + + +if __name__ == "__main__": + main() diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/scripts/preflight.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/scripts/preflight.py new file mode 100755 index 00000000..554f1cda --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/scripts/preflight.py @@ -0,0 +1,80 @@ +"""Startup warnings about missing GPU access or low RAM. Always exits 0. + +The hard spec gate lives in run.sh / run.ps1; this is just the in-container net. +""" + +from __future__ import annotations + +import os +import sys + +VARIANT = os.environ.get("RENAISSANCE_VARIANT", "gpu") +MIN_RECOMMENDED_RAM_GB = 8.0 + + +def log(msg: str) -> None: + print(f"[preflight] {msg}", flush=True) + + +def check_ram() -> None: + try: + import psutil + + total_gb = psutil.virtual_memory().total / (1024 ** 3) + if total_gb < MIN_RECOMMENDED_RAM_GB: + log( + f"WARNING: detected {total_gb:.1f} GB RAM; " + f">= {MIN_RECOMMENDED_RAM_GB:.0f} GB is recommended. Large pages may " + "run slowly or hit out-of-memory during detection/recognition." + ) + except Exception: + # psutil should always be present, but never let this block boot. + pass + + +def check_paddle() -> None: + """Probe Paddle/CUDA. For the GPU variant the import itself fails when the + container has no GPU access (paddlepaddle-gpu links libcuda.so.1 at import).""" + try: + import paddle # noqa: PLC0415 + + cuda_runtime = False + try: + cuda_runtime = paddle.device.cuda.device_count() > 0 + except Exception: + cuda_runtime = False + + if VARIANT == "gpu": + if cuda_runtime: + log("GPU image: CUDA device detected — PaddleOCR detection runs on GPU.") + else: + log( + "GPU image started WITHOUT a usable CUDA device. PaddleOCR layout " + "detection needs a GPU and will be unavailable. Re-run via the " + "launcher (./run.sh) or add `--gpus all`, or use the CPU image " + "(./run.sh --cpu). Gemini OCR, preprocessing and export still work." + ) + else: + log("CPU image: PaddleOCR and torch will run on CPU.") + except Exception as exc: + if VARIANT == "gpu": + log( + "GPU image: PaddlePaddle could not initialize CUDA " + f"({exc.__class__.__name__}). This usually means the container was " + "started without GPU access. PaddleOCR layout detection will be " + "unavailable; re-run via ./run.sh (or add `--gpus all`), or use the " + "CPU image (./run.sh --cpu). Other features still work." + ) + else: + log(f"Paddle import check skipped ({exc.__class__.__name__}).") + + +def main() -> int: + log(f"variant={VARIANT}") + check_ram() + check_paddle() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/conftest.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/conftest.py new file mode 100644 index 00000000..ff12f302 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/conftest.py @@ -0,0 +1,31 @@ +"""Shared pytest fixtures + path setup for the backend test suite. + +All tests here are CPU-safe (no GPU required) so they run unchanged in CI on +GPU-less runners. Real-GPU coverage lives in tests/smoke_gpu.py (run manually). +""" + +import os +import sys +from pathlib import Path + +import numpy as np +import pytest + +# Make `app` and `preprocessing` importable, mirroring app.main's path setup. +BACKEND_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BACKEND_DIR)) + +# Never touch real user data: point storage at a throwaway dir. +os.environ.setdefault("STORAGE_ROOT", str(BACKEND_DIR / ".pytest-storage")) + + +@pytest.fixture +def sample_color_image() -> np.ndarray: + """A deterministic 64x64 BGR uint8 image.""" + rng = np.random.default_rng(0) + return rng.integers(0, 256, size=(64, 64, 3), dtype=np.uint8) + + +@pytest.fixture +def sample_transcripts() -> dict: + return {"1": "Hello world", "2": "Second page of text"} diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/smoke_gpu.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/smoke_gpu.py new file mode 100644 index 00000000..0a76c2d9 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/smoke_gpu.py @@ -0,0 +1,62 @@ +"""Manual GPU check — run by hand, not part of CI. + +`python backend/tests/smoke_gpu.py` on a real NVIDIA box. Exits non-zero if +torch or Paddle can't see CUDA. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +def main() -> int: + ok = True + + # --- torch --- + import torch + + from app.utils.torch_device import select_torch_device + + dev = select_torch_device() + print(f"[smoke] torch device : {dev}") + if torch.cuda.is_available(): + x = torch.ones(1024, 1024, device="cuda") + y = (x @ x).sum().item() + print(f"[smoke] torch CUDA matmul : {y:.0f} (ok)") + else: + print("[smoke] torch.cuda.is_available() == False") + ok = False + + # --- paddle --- + try: + import paddle + + compiled = paddle.device.is_compiled_with_cuda() + count = paddle.device.cuda.device_count() if compiled else 0 + print(f"[smoke] paddle cuda build : {compiled}, devices: {count}") + if not (compiled and count > 0): + ok = False + except Exception as exc: # pragma: no cover - environment dependent + print(f"[smoke] paddle import FAILED: {exc}") + ok = False + + # --- tier selection --- + try: + import app.services.layout_detection as ld + + tier = ld.select_tier(use_gpu=True) + print(f"[smoke] selected tier : device={tier['device']} tier={tier['tier']}") + print(f"[smoke] reason : {tier['reason']}") + if tier["device"] != "gpu": + ok = False + except Exception as exc: # pragma: no cover + print(f"[smoke] select_tier FAILED : {exc}") + ok = False + + print("[smoke] RESULT:", "PASS" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_deskew_piecewise.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_deskew_piecewise.py new file mode 100644 index 00000000..346dbb30 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_deskew_piecewise.py @@ -0,0 +1,67 @@ +"""Piecewise deskew: the estimator finds a tilted band's angle, and a page +whose skew grows toward the bottom is detected as variable (not uniform).""" + +import os +import sys + +import cv2 +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from preprocessing.operations import ( # noqa: E402 + _band_skew_angles, + _projection_profile_angle, + deskew_image, +) + + +def _ruled_page(h=480, w=640, line_gap=24): + """White page with evenly spaced horizontal black 'text' lines.""" + img = np.full((h, w), 255, np.uint8) + for y in range(line_gap, h - line_gap, line_gap): + cv2.line(img, (40, y), (w - 40, y), 0, 3) + return img + + +def _rotate(img, angle): + h, w = img.shape[:2] + M = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0) + return cv2.warpAffine(img, M, (w, h), borderValue=255) + + +def test_projection_profile_recovers_tilt(): + # Lines tilted by +6 deg need a -6 deg rotation to flatten, so that is what + # the estimator should report. + tilted = _rotate(_ruled_page(), 6.0) + _, binary = cv2.threshold(tilted, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) + angle = _projection_profile_angle(binary, max_angle=15) + assert angle is not None + assert abs(angle - (-6.0)) <= 1.0, f"expected ~-6, got {angle}" + + +def test_variable_skew_detected_as_nonuniform(): + # Straight top, 8-degree-tilted bottom, stitched into one page. + page = _ruled_page() + h = page.shape[0] + page[h // 2:, :] = _rotate(page, 8.0)[h // 2:, :] + + angles = _band_skew_angles(page, num_bands=4, max_angle=15, fallback_angle=0.0) + spread = max(angles) - min(angles) + assert spread >= 1.0, f"variable skew should register a spread, got {angles}" + # Bottom bands must be corrected harder than the (straight) top band. + assert abs(angles[-1]) > abs(angles[0]) + 1.0, angles + + +def test_deskew_runs_and_preserves_shape(): + page = cv2.cvtColor(_ruled_page(), cv2.COLOR_GRAY2BGR) + out = deskew_image(page, {"mode": "auto", "maxAngle": 15, "bands": 4}) + assert out.shape == page.shape + assert out.dtype == np.uint8 + + +if __name__ == "__main__": + test_projection_profile_recovers_tilt() + test_variable_skew_detected_as_nonuniform() + test_deskew_runs_and_preserves_shape() + print("ok") diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_export.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_export.py new file mode 100644 index 00000000..f700a435 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_export.py @@ -0,0 +1,32 @@ +"""Export builders must emit non-empty, well-formed TXT/DOCX/PDF buffers.""" + +from app.services.export import ( + build_combined_transcript, + build_docx_export, + build_pdf_export, + build_txt_export, +) + + +def test_combined_transcript_includes_all_pages(sample_transcripts): + combined = build_combined_transcript(sample_transcripts) + assert "Hello world" in combined + assert "Second page of text" in combined + + +def test_txt_export_has_bom_and_content(sample_transcripts): + data = build_txt_export(sample_transcripts).getvalue() + assert data.startswith(b"\xef\xbb\xbf") # UTF-8 BOM + assert "Hello world".encode("utf-8") in data + + +def test_docx_export_is_nonempty_zip(sample_transcripts): + data = build_docx_export(sample_transcripts).getvalue() + assert len(data) > 0 + # .docx is a zip container — starts with the PK signature. + assert data[:2] == b"PK" + + +def test_pdf_export_has_pdf_magic(sample_transcripts): + data = build_pdf_export(sample_transcripts).getvalue() + assert data[:4] == b"%PDF" diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_health.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_health.py new file mode 100644 index 00000000..1d9fccef --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_health.py @@ -0,0 +1,20 @@ +"""Health route, plus importing app.main as a check on the whole import graph. + +Real HTTP health is covered by the CI container smoke job. +""" + +import asyncio + +from app.api.health import health_check +from app.main import app + + +def test_app_registers_health_route(): + paths = {getattr(route, "path", None) for route in app.routes} + assert "/api/health" in paths + + +def test_health_payload_is_healthy(): + body = asyncio.run(health_check()) + assert body["status"] == "healthy" + assert "timestamp" in body diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_layout_resource_logic.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_layout_resource_logic.py new file mode 100644 index 00000000..72ee7fff --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_layout_resource_logic.py @@ -0,0 +1,64 @@ +"""Unit-test the device/tier selection branches in layout_detection without a +real Paddle install or GPU — Paddle and resource probing are monkeypatched.""" + +import app.services.layout_detection as ld + + +def _resources(**overrides): + base = { + "warnings": [], + "gpu_available": False, + "gpu_vram_gb": 0.0, + "gpu_vram_free_gb": 0.0, + "available_ram_gb": 16.0, + "total_ram_gb": 32.0, + } + base.update(overrides) + return base + + +def _patch(monkeypatch, **resource_overrides): + monkeypatch.setattr(ld, "_ensure_paddle", lambda: None) + monkeypatch.setattr( + ld, "check_system_resources", lambda use_gpu: _resources(**resource_overrides) + ) + + +def test_gpu_server_tier(monkeypatch): + _patch(monkeypatch, gpu_available=True, gpu_vram_free_gb=10.0) + tier = ld.select_tier(use_gpu=True) + assert tier["device"] == "gpu" + assert tier["tier"] == "server" + assert tier["layout_model"] == ld.SERVER_MODELS["layout"] + + +def test_gpu_mobile_tier(monkeypatch): + # Between MOBILE (2) and SERVER (6) free VRAM → mobile on GPU. + _patch(monkeypatch, gpu_available=True, gpu_vram_free_gb=3.0) + tier = ld.select_tier(use_gpu=True) + assert tier["device"] == "gpu" + assert tier["tier"] == "mobile" + + +def test_low_vram_falls_back_to_cpu(monkeypatch): + # Below MOBILE_MIN_VRAM → CPU; ample RAM → server models on CPU. + _patch(monkeypatch, gpu_available=True, gpu_vram_free_gb=1.0, available_ram_gb=16.0) + tier = ld.select_tier(use_gpu=True) + assert tier["device"] == "cpu" + assert tier["tier"] == "server" + + +def test_cpu_low_ram_uses_mobile(monkeypatch): + _patch(monkeypatch, gpu_available=False, available_ram_gb=2.0) + tier = ld.select_tier(use_gpu=False) + assert tier["device"] == "cpu" + assert tier["tier"] == "mobile" + + +def test_check_resources_without_paddle_reports_no_gpu(monkeypatch): + # Simulate paddle not yet imported → no GPU detected, no crash. + monkeypatch.setattr(ld, "_paddle", None) + info = ld.check_system_resources(use_gpu=True) + assert info["gpu_available"] is False + assert info["gpu_vram_gb"] == 0.0 + assert any("GPU not available" in w for w in info["warnings"]) diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_llm_postprocess_factory.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_llm_postprocess_factory.py new file mode 100644 index 00000000..cb03b928 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_llm_postprocess_factory.py @@ -0,0 +1,40 @@ +"""LLM post-processing factory wiring (no network, no model load).""" + +import pytest + +from app.services.llm_processing import factory +from app.services.llm_processing.local_client import ( + post_process_text_finetuned, + FINETUNED_BASE_MODEL, +) + + +def test_local_es_enabled_and_keyless(): + es = next(p for p in factory.LLM_PROVIDERS if p["id"] == "local_es") + assert es["enabled"] is True + assert es["requires_key"] is False + assert es["default_model"] == FINETUNED_BASE_MODEL + # keyless providers must not demand an API key + assert factory.provider_requires_key("local_es") is False + + +def test_local_es_routes_to_finetuned(monkeypatch): + called = {} + + def fake(text, model, template_name): + called["args"] = (text, model, template_name) + return "CLEANED" + + monkeypatch.setattr(factory, "post_process_text_finetuned", fake) + out = factory.post_process("local_es", None, "raw text", model="x", template_name="t") + assert out == "CLEANED" + assert called["args"] == ("raw text", "x", "t") + + +def test_finetuned_requires_gpu_on_cpu(monkeypatch): + # On a CPU-only host the fine-tuned provider must fail clearly, never OOM. + # The function imports select_torch_device at call time, so patch the source. + import app.utils.torch_device as td + monkeypatch.setattr(td, "select_torch_device", lambda: "cpu") + with pytest.raises(ValueError, match="requires a GPU"): + post_process_text_finetuned("some ocr line") diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_local_client_batching.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_local_client_batching.py new file mode 100644 index 00000000..4c52cb8e --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_local_client_batching.py @@ -0,0 +1,122 @@ +"""Batching behaviour of the local fine-tuned corrector — no GPU, no model load. + +Fakes the tokenizer/model so the length-sorted batching, the per-batch +max_new_tokens cap and the cut-off guard are all exercised on CPU in CI. +""" + +from types import SimpleNamespace + +import torch + +from app.services.llm_processing import local_client + + +STOP, PAD = 106, 0 # , — the real gemma-3 ids +PROMPT_BASE, OUT_BASE = 1000, 2000 + + +class _Enc(dict): + """Stands in for BatchEncoding: unpacks as **kwargs, indexes, .to(device).""" + + def to(self, device): + return self + + +class FakeTokenizer: + """One id per prompt/output string; word counts for the length probe.""" + + pad_token_id = PAD + padding_side = "right" + + def __init__(self): + self.prompts: list[str] = [] + self.outputs: list[str] = [] + + def apply_chat_template(self, messages, tokenize=False, add_generation_prompt=True): + return messages[-1]["content"] + + def __call__(self, text, return_tensors=None, padding=False, add_special_tokens=True): + if return_tensors is None: + # Length probe: token count == word count. + return SimpleNamespace(input_ids=[t.split() for t in text]) + ids = [] + for t in text: + ids.append(PROMPT_BASE + len(self.prompts)) + self.prompts.append(t) + return _Enc( + input_ids=torch.tensor(ids).unsqueeze(1), + attention_mask=torch.ones(len(ids), 1, dtype=torch.long), + ) + + def batch_decode(self, rows, skip_special_tokens=True): + return [ + " ".join(self.outputs[i - OUT_BASE] for i in row.tolist() if i >= OUT_BASE) + for row in rows + ] + + +class FakeModel: + generation_config = SimpleNamespace(eos_token_id=[1, STOP]) + + def __init__(self, tok, correct, no_stop=()): + self.tok, self.correct, self.no_stop = tok, correct, set(no_stop) + self.caps: list[int] = [] + self.batches: list[list[str]] = [] + + def generate(self, input_ids=None, attention_mask=None, max_new_tokens=None, + do_sample=None, pad_token_id=None): + self.caps.append(max_new_tokens) + prompts = [self.tok.prompts[i - PROMPT_BASE] for i in input_ids[:, 0].tolist()] + self.batches.append(prompts) + rows = [] + for p in prompts: + oid = OUT_BASE + len(self.tok.outputs) + self.tok.outputs.append(self.correct(p)) + # No stop token == generation hit the cap mid-line. + rows.append([oid] if p in self.no_stop else [oid, STOP]) + width = max(len(r) for r in rows) + padded = [r + [PAD] * (width - len(r)) for r in rows] + return torch.cat([input_ids, torch.tensor(padded)], dim=1) + + +def _run(monkeypatch, tmp_path, text, correct=str.upper, no_stop=(), batch_size=2): + tok = FakeTokenizer() + model = FakeModel(tok, correct, no_stop) + + import app.utils.torch_device as td + monkeypatch.setattr(td, "select_torch_device", lambda: "cuda") + monkeypatch.setattr(local_client, "_adapter_dir", lambda: str(tmp_path)) + monkeypatch.setattr(local_client, "_load", lambda *a, **k: (tok, model, "cpu")) + + out = local_client.post_process_text_finetuned(text, batch_size=batch_size) + return out, model + + +def test_batches_group_by_length_and_cap_scales(monkeypatch, tmp_path): + long_a, long_b = " ".join("ab" * 1 for _ in range(10)), " ".join("cd" for _ in range(10)) + text = "\n".join(["x", long_a, "y", long_b]) + + out, model = _run(monkeypatch, tmp_path, text) + + # Short lines batch with short, long with long — never one of each. + assert [len(b) for b in model.batches] == [2, 2] + assert {len(p.split()) for p in model.batches[0]} == {1} + assert {len(p.split()) for p in model.batches[1]} == {10} + + # Cap is derived per batch (1.5x + 16), not the flat 160 it used to be. + assert sorted(model.caps) == [17, 31] + + # Original line order survives the sort — the frontend maps back by index. + assert out.split("\n") == ["X", long_a.upper(), "Y", long_b.upper()] + + +def test_blank_lines_are_preserved(monkeypatch, tmp_path): + out, _ = _run(monkeypatch, tmp_path, "one\n\ntwo") + assert out.split("\n") == ["ONE", "", "TWO"] + + +def test_cut_off_line_keeps_the_original(monkeypatch, tmp_path): + # A row with no stop token was truncated at the cap; writing that back would + # silently chop the line, so the raw line must survive instead. + out, _ = _run(monkeypatch, tmp_path, "keep me\ncorrect me", no_stop=["keep me"]) + assert out.split("\n") == ["keep me", "CORRECT ME"] diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_local_model_cache.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_local_model_cache.py new file mode 100644 index 00000000..641834cf --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_local_model_cache.py @@ -0,0 +1,63 @@ +"""base_model_cached must not say "ready" while shards are still downloading. + +huggingface_hub writes config.json / tokenizer.* / the shard index up front and +the multi-GB weights last, so a snapshot-dir-exists check reports ready almost +immediately and the UI never shows its one-time-download warning. +""" + +import json + +from app.services.llm_processing.local_client import ( + FINETUNED_BASE_MODEL, + base_model_cached, +) + +SHARDS = ["model-00001-of-00002.safetensors", "model-00002-of-00002.safetensors"] + + +def _snapshot(tmp_path, monkeypatch): + """An HF cache laid out like a real one, with only the small files present.""" + monkeypatch.setenv("HF_HOME", str(tmp_path)) + snap = ( + tmp_path / "hub" + / ("models--" + FINETUNED_BASE_MODEL.replace("/", "--")) + / "snapshots" / "abc123" + ) + snap.mkdir(parents=True) + for name in ("config.json", "tokenizer.json", "tokenizer_config.json"): + (snap / name).write_text("{}") + (snap / "model.safetensors.index.json").write_text( + json.dumps({"weight_map": {f"layer.{i}": s for i, s in enumerate(SHARDS)}}) + ) + return snap + + +def test_missing_cache_is_not_ready(tmp_path, monkeypatch): + monkeypatch.setenv("HF_HOME", str(tmp_path)) + assert base_model_cached() is False + + +def test_index_without_weights_is_not_ready(tmp_path, monkeypatch): + """The exact mid-download state — the bug a dir-exists check misses.""" + _snapshot(tmp_path, monkeypatch) + assert base_model_cached() is False + + +def test_partial_shards_are_not_ready(tmp_path, monkeypatch): + snap = _snapshot(tmp_path, monkeypatch) + (snap / SHARDS[0]).write_bytes(b"weights") + assert base_model_cached() is False + + +def test_all_shards_present_is_ready(tmp_path, monkeypatch): + snap = _snapshot(tmp_path, monkeypatch) + for name in SHARDS: + (snap / name).write_bytes(b"weights") + assert base_model_cached() is True + + +def test_unsharded_checkpoint_is_ready(tmp_path, monkeypatch): + snap = _snapshot(tmp_path, monkeypatch) + (snap / "model.safetensors.index.json").unlink() + (snap / "model.safetensors").write_bytes(b"weights") + assert base_model_cached() is True diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_ocr_factory.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_ocr_factory.py new file mode 100644 index 00000000..9b9cda9c --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_ocr_factory.py @@ -0,0 +1,79 @@ +"""OCRFactory strategy/registry behavior (no network — only construction).""" + +import pytest + +from app.services.ocr.base import BaseOCRProvider +from app.services.ocr.factory import OCRFactory + + +def test_list_providers_contains_all(): + names = set(OCRFactory.list_providers()) + assert {"gemini", "chatgpt", "deepseek", "qwen"} <= names + + +@pytest.mark.parametrize("name", ["gemini", "chatgpt", "deepseek", "qwen", "GEMINI"]) +def test_get_provider_returns_base_instance(name): + provider = OCRFactory.get_provider(name) + assert isinstance(provider, BaseOCRProvider) + + +def test_unknown_provider_raises_valueerror(): + with pytest.raises(ValueError): + OCRFactory.get_provider("not-a-real-provider") + + +def test_gemini_default_model_is_gemini_3(): + from app.services.ocr import gemini + + assert gemini.DEFAULT_MODEL == "gemini-3.1-flash-lite" + assert gemini.DEFAULT_MODEL in gemini.MODEL_IDS + + +def test_gemini_retries_transient_server_error(monkeypatch): + """A 503 ServerError should be retried, not surfaced on the first hit.""" + from app.services.ocr import gemini + from google.genai import errors as genai_errors + + class _FakeResp: # shape APIError expects for a non-requests response + body_segments = [{"error": {"message": "busy", "status": "UNAVAILABLE"}}] + + class _Ok: + text = "hello world" + + calls = {"n": 0} + + class _Client: + class models: + @staticmethod + def generate_content(**kwargs): + calls["n"] += 1 + if calls["n"] < 2: + raise genai_errors.ServerError(503, _FakeResp()) + return _Ok() + + monkeypatch.setattr(gemini, "get_gemini_client", lambda api_key: _Client()) + monkeypatch.setattr(gemini.time, "sleep", lambda s: None) # no real backoff + + out = OCRFactory.get_provider("gemini").transcribe("key", b"img", "gemini-3.1-flash-lite") + assert out == "hello world" + assert calls["n"] == 2 # failed once, succeeded on retry + + +def test_gemini_empty_response_raises(monkeypatch): + """A thinking model that returns no text must error, not yield ''.""" + from app.services.ocr import gemini + + class _Resp: + text = None + prompt_feedback = "BLOCKED" + + class _Client: + class models: + @staticmethod + def generate_content(**kwargs): + return _Resp() + + monkeypatch.setattr(gemini, "get_gemini_client", lambda api_key: _Client()) + provider = OCRFactory.get_provider("gemini") + with pytest.raises(ValueError): + provider.transcribe("key", b"img", "gemini-3.1-flash-lite") diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_preprocess_ops.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_preprocess_ops.py new file mode 100644 index 00000000..32d6db9d --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_preprocess_ops.py @@ -0,0 +1,42 @@ +"""Every preprocessing op and the PipelineExecutor must run on a synthetic +image and return a valid array — guards against op-registry regressions.""" + +import numpy as np +import pytest + +from preprocessing.operations import OP_REGISTRY, list_operations +from preprocessing.pipeline import run_pipeline + + +def test_registry_matches_list(): + assert set(list_operations()) == set(OP_REGISTRY.keys()) + # 7 documented OpenCV ops at minimum (normalize, grayscale, deskew, + # denoise, contrast, sharpen, binarize/threshold). + assert len(OP_REGISTRY) >= 7 + + +@pytest.mark.parametrize("op_name", sorted(OP_REGISTRY.keys())) +def test_each_op_runs_with_default_params(op_name, sample_color_image): + fn = OP_REGISTRY[op_name] + out = fn(sample_color_image, {}) + assert isinstance(out, np.ndarray) + assert out.ndim in (2, 3) + assert out.size > 0 + + +def test_pipeline_executes_multi_step(sample_color_image): + steps = [ + {"op": "grayscale", "params": {}, "enabled": True}, + {"op": "normalize", "params": {}, "enabled": True}, + {"op": "threshold", "params": {}, "enabled": True}, + ] + result = run_pipeline(sample_color_image, steps) + assert result.success + assert isinstance(result.image, np.ndarray) + assert result.image.size > 0 + + +def test_pipeline_skips_disabled_steps(sample_color_image): + steps = [{"op": "grayscale", "params": {}, "enabled": False}] + result = run_pipeline(sample_color_image, steps) + assert result.success diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_torch_device.py b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_torch_device.py new file mode 100644 index 00000000..60bdcdc1 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/backend/tests/test_torch_device.py @@ -0,0 +1,28 @@ +"""Device selection must always land on a real device, cuda > mps > cpu.""" + +from app.utils.torch_device import select_torch_device + + +def test_select_returns_known_device(): + assert select_torch_device() in ("cuda", "mps", "cpu") + + +def test_cuda_preferred_when_available(monkeypatch): + import app.utils.torch_device as td + + monkeypatch.setattr(td.torch.cuda, "is_available", lambda: True) + assert td.select_torch_device() == "cuda" + + +def test_mps_used_when_no_cuda(monkeypatch): + import app.utils.torch_device as td + + monkeypatch.setattr(td.torch.cuda, "is_available", lambda: False) + + class _FakeMps: + @staticmethod + def is_available(): + return True + + monkeypatch.setattr(td.torch.backends, "mps", _FakeMps, raising=False) + assert td.select_torch_device() == "mps" diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/docker-compose.cpu.yml b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/docker-compose.cpu.yml new file mode 100644 index 00000000..26ccfd75 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/docker-compose.cpu.yml @@ -0,0 +1,59 @@ +# ============================================================================ +# RenAIssance — CPU variant, LOCAL BUILD (no NVIDIA GPU required) +# +# docker compose -f docker-compose.cpu.yml up --build +# +# Use this on Mac (Docker on macOS has no Metal passthrough) or any host +# without an NVIDIA GPU + Container Toolkit. The launcher (./run.sh --cpu / +# run.ps1) selects this file automatically. It is a STANDALONE file (not an +# overlay) on purpose: a `deploy.devices: nvidia` reservation cannot be removed +# by an override merge, and requesting it on a CPU host makes `compose up` fail. +# +# Builds backend with VARIANT=cpu (CPU paddle + CPU torch on an Ubuntu base). +# To use the Apple GPU on a Mac, run natively instead — see ./run-native.sh. +# ============================================================================ + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + args: + VARIANT: cpu + RUNTIME_BASE: ubuntu:24.04 + image: renaissance-backend:local-cpu + container_name: renaissance-backend + env_file: + - .env + environment: + STORAGE_ROOT: /app/storage + volumes: + - storage:/app/storage + - paddle_models:/paddle_models + ports: + - "8000:8000" + # No GPU reservation — this variant runs entirely on CPU. + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 10s + start_period: 90s + retries: 5 + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + image: renaissance-frontend:local + container_name: renaissance-frontend + depends_on: + backend: + condition: service_started + ports: + - "5173:8080" + restart: unless-stopped + +volumes: + storage: + paddle_models: diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/docker-compose.images.cpu.yml b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/docker-compose.images.cpu.yml new file mode 100644 index 00000000..47c2d9d0 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/docker-compose.images.cpu.yml @@ -0,0 +1,48 @@ +# ============================================================================ +# RenAIssance — CPU variant, PUBLISHED images (no local build, no NVIDIA GPU) +# +# docker compose -f docker-compose.images.cpu.yml pull +# docker compose -f docker-compose.images.cpu.yml up +# +# Pulls and runs the published CPU image. The launcher (./run.sh --cpu / +# run.ps1 on a host without an NVIDIA GPU) selects this file automatically. +# Standalone (not an overlay) so no GPU device reservation is ever requested. +# ============================================================================ + +services: + backend: + image: saarthakg004/renaissance-backend:latest-cpu + container_name: renaissance-backend + env_file: + - path: .env + required: false + environment: + STORAGE_ROOT: /app/storage + volumes: + - storage:/app/storage + - paddle_models:/paddle_models + ports: + - "8000:8000" + # No GPU reservation — CPU-only image. + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 10s + start_period: 90s + retries: 5 + restart: unless-stopped + + frontend: + # The frontend image is variant-agnostic (static bundle + nginx). + image: saarthakg004/renaissance-frontend:latest + container_name: renaissance-frontend + depends_on: + backend: + condition: service_started + ports: + - "5173:8080" + restart: unless-stopped + +volumes: + storage: + paddle_models: diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/docker-compose.images.yml b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/docker-compose.images.yml new file mode 100644 index 00000000..c434e5be --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/docker-compose.images.yml @@ -0,0 +1,67 @@ +# ============================================================================ +# RenAIssance — run the PUBLISHED images (no local build) +# +# docker compose -f docker-compose.images.yml pull # get latest images +# docker compose -f docker-compose.images.yml up # run +# docker compose -f docker-compose.images.yml down # stop +# +# Use this on a deployment / self-host host where you just want to pull and +# run. For local development against your source tree, use docker-compose.yml +# (which builds from ./backend and ./frontend). +# +# The service is intentionally named `backend` so the frontend's nginx can +# reach it at http://backend:8000 (the bundled nginx.conf proxies /api/ there). +# Compose handles the network + DNS automatically — no --network-alias needed. +# +# GPU: requires NVIDIA driver >= 560 (CUDA 12.6 capable) and the NVIDIA +# Container Toolkit on the host. See the README. +# ============================================================================ + +services: + backend: + # GPU variant. `:latest` is kept as an alias of `:latest-gpu` for backward + # compatibility, but the launcher pins the explicit tag so the variant is + # never ambiguous. CPU hosts use docker-compose.images.cpu.yml instead. + image: saarthakg004/renaissance-backend:latest-gpu + container_name: renaissance-backend + # Optional .env (SECRET_KEY, ADMIN_TOKEN, DATABASE_URL overrides). Not + # required — Supabase tracking creds are baked in, so compose starts without it. + env_file: + - path: .env + required: false + environment: + # Pin storage to the mounted named volume regardless of layout. + STORAGE_ROOT: /app/storage + volumes: + - storage:/app/storage + - paddle_models:/paddle_models + ports: + - "8000:8000" + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 10s + start_period: 90s + retries: 5 + restart: unless-stopped + + frontend: + image: saarthakg004/renaissance-frontend:latest + container_name: renaissance-frontend + depends_on: + backend: + condition: service_started + ports: + - "5173:8080" + restart: unless-stopped + +volumes: + storage: + paddle_models: diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/docker-compose.yml b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/docker-compose.yml new file mode 100644 index 00000000..56b1ea0f --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/docker-compose.yml @@ -0,0 +1,77 @@ +# ============================================================================ +# RenAIssance — full-stack orchestration +# +# docker compose up --build # first run / after dependency changes +# docker compose up # subsequent runs +# docker compose down # stop (named volumes are preserved) +# +# Topology +# frontend (nginx) : host :5173 -> container :8080 +# reverse-proxies /api/ -> backend:8000 (same origin, +# so no CORS and no hardcoded backend URL in the bundle) +# backend (FastAPI): host :8000 -> container :8000 (also exposed directly +# for debugging; the app itself only needs the proxy) +# +# GPU: requires NVIDIA driver >= 560, CUDA 12.6 capable, and the NVIDIA +# Container Toolkit on the host (paddlepaddle-gpu has no CPU fallback in this +# image). See backend/Dockerfile for details. +# ============================================================================ + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + args: + VARIANT: gpu + # Plain Ubuntu for both variants — torch/paddle bring their own + # CUDA user-space libs and the host driver is injected by the NVIDIA + # Container Toolkit (see backend/Dockerfile Stage 2 note). + RUNTIME_BASE: ubuntu:24.04 + image: renaissance-backend:local + container_name: renaissance-backend + # Provider keys and auth secrets come from the repo-root .env at runtime. + # .env is gitignored and dockerignored, so nothing is baked into the image. + env_file: + - .env + environment: + # Pin the storage root so it always lands on the mounted named volume + # regardless of the in-container directory layout. + STORAGE_ROOT: /app/storage + volumes: + - storage:/app/storage # "My Files": transcripts + datasets + - paddle_models:/paddle_models # lazily-downloaded PaddleOCR weights + ports: + - "8000:8000" + # NVIDIA GPU passthrough (Compose spec device reservation). + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 10s + start_period: 90s # model normalization + first import is slow + retries: 5 + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + image: renaissance-frontend:local + container_name: renaissance-frontend + depends_on: + backend: + condition: service_started + ports: + - "5173:8080" + restart: unless-stopped + +volumes: + storage: + paddle_models: diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/.dockerignore b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/.dockerignore new file mode 100644 index 00000000..ebc6803e --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +.gitignore +*.md +Dockerfile +.dockerignore diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/Dockerfile b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/Dockerfile new file mode 100644 index 00000000..0fa6867d --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/Dockerfile @@ -0,0 +1,61 @@ +# ============================================ +# RenAIssance OCR — Frontend Dockerfile +# Multi-stage: Node 20 build → Nginx Alpine slim serve +# ============================================ + +# ---- Stage 1: Build ---- +FROM node:20-alpine AS builder + +# Do NOT set NODE_ENV=production here — vite and other build tools +# live in devDependencies and must be installed for the build to succeed. +# NODE_ENV=production is set only in the runtime stage. + +WORKDIR /app + +# Copy manifests first — layer cache for npm install +COPY package.json package-lock.json ./ + +# Install ALL dependencies (including devDeps) needed for the build. +# BuildKit cache mount keeps ~/.npm warm across builds so rebuilds after a +# lockfile edit don't re-download every tarball. +RUN --mount=type=cache,target=/root/.npm \ + npm ci --prefer-offline --no-audit --no-fund + +# Copy source and build +COPY . . + +RUN npm run build + + +# ---- Stage 2: Serve ---- +FROM nginx:1.30-alpine-slim AS runtime + +ENV NODE_ENV=production + +# Install curl for runtime health checks. +RUN apk add --no-cache curl + +# Remove default config +RUN rm /etc/nginx/conf.d/default.conf + +# Copy custom nginx SPA config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy only the built static assets +COPY --from=builder /app/dist /usr/share/nginx/html + +# Non-root nginx +RUN chown -R nginx:nginx /usr/share/nginx/html && \ + chown -R nginx:nginx /var/cache/nginx && \ + chown -R nginx:nginx /var/log/nginx && \ + touch /var/run/nginx.pid && \ + chown nginx:nginx /var/run/nginx.pid + +USER nginx + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl -fsS http://localhost:8080/ >/dev/null || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/index.html b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/index.html new file mode 100644 index 00000000..1deb63ab --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + OCR Preprocess Studio + + + + + +
+ + + diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/nginx.conf b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/nginx.conf new file mode 100644 index 00000000..390998aa --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/nginx.conf @@ -0,0 +1,74 @@ +server { + listen 8080; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # ESM modules — Vite emits the bundled PDF.js worker as `.mjs`. Browsers + # refuse to load module scripts/workers unless served with a JS MIME type; + # nginx's default mime.types maps `.js` correctly but not `.mjs`. Re-include + # the default mappings before overriding, or this block would replace them. + include /etc/nginx/mime.types; + types { + application/javascript mjs; + } + + # Reverse-proxy API calls to the FastAPI backend container. The frontend + # issues same-origin `/api/...` requests (see src/config.js), so there is + # no CORS and no hardcoded host. `^~` makes this prefix win over the + # static-asset regex below. Using a variable + Docker's embedded DNS + # resolver means nginx still starts if the backend is briefly down and + # recovers automatically when it comes back (instead of failing config + # load with "host not found in upstream"). + location ^~ /api/ { + resolver 127.0.0.11 valid=30s ipv6=off; + set $backend_upstream http://backend:8000; + # $request_uri keeps the original path + query string intact, which + # the variable form of proxy_pass would otherwise drop. + proxy_pass $backend_upstream$request_uri; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # OCR / layout detection / model loading can take minutes. + proxy_connect_timeout 60s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + + # Matches backend MAX_UPLOAD_SIZE (100 MB) for PDF/image uploads. + client_max_body_size 100m; + + # Stream responses instead of buffering large OCR payloads to disk. + proxy_buffering off; + } + + # SPA fallback — all routes serve index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets aggressively + location ~* \.(js|mjs|css|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_min_length 1024; + gzip_types + text/plain + text/css + application/json + application/javascript + text/javascript + text/xml + application/xml + image/svg+xml; +} diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/package-lock.json b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/package-lock.json new file mode 100644 index 00000000..f0831586 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/package-lock.json @@ -0,0 +1,2924 @@ +{ + "name": "ocr-preprocess-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ocr-preprocess-ui", + "version": "1.0.0", + "dependencies": { + "lucide-react": "^0.303.0", + "pdfjs-dist": "^4.0.379", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "vite": "^5.0.8" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.89.tgz", + "integrity": "sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.89", + "@napi-rs/canvas-darwin-arm64": "0.1.89", + "@napi-rs/canvas-darwin-x64": "0.1.89", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.89", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.89", + "@napi-rs/canvas-linux-arm64-musl": "0.1.89", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.89", + "@napi-rs/canvas-linux-x64-gnu": "0.1.89", + "@napi-rs/canvas-linux-x64-musl": "0.1.89", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.89", + "@napi-rs/canvas-win32-x64-msvc": "0.1.89" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.89.tgz", + "integrity": "sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.89.tgz", + "integrity": "sha512-k29cR/Zl20WLYM7M8YePevRu2VQRaKcRedYr1V/8FFHkyIQ8kShEV+MPoPGi+znvmd17Eqjy2Pk2F2kpM2umVg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.89.tgz", + "integrity": "sha512-iUragqhBrA5FqU13pkhYBDbUD1WEAIlT8R2+fj6xHICY2nemzwMUI8OENDhRh7zuL06YDcRwENbjAVxOmaX9jg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.89.tgz", + "integrity": "sha512-y3SM9sfDWasY58ftoaI09YBFm35Ig8tosZqgahLJ2WGqawCusGNPV9P0/4PsrLOCZqGg629WxexQMY25n7zcvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.89.tgz", + "integrity": "sha512-NEoF9y8xq5fX8HG8aZunBom1ILdTwt7ayBzSBIwrmitk7snj4W6Fz/yN/ZOmlM1iyzHDNX5Xn0n+VgWCF8BEdA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.89.tgz", + "integrity": "sha512-UQQkIEzV12/l60j1ziMjZ+mtodICNUbrd205uAhbyTw0t60CrC/EsKb5/aJWGq1wM0agvcgZV72JJCKfLS6+4w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.89.tgz", + "integrity": "sha512-1/VmEoFaIO6ONeeEMGoWF17wOYZOl5hxDC1ios2Bkz/oQjbJJ8DY/X22vWTmvuUKWWhBVlo63pxLGZbjJU/heA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.89.tgz", + "integrity": "sha512-ebLuqkCuaPIkKgKH9q4+pqWi1tkPOfiTk5PM1LKR1tB9iO9sFNVSIgwEp+SJreTSbA2DK5rW8lQXiN78SjtcvA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.89.tgz", + "integrity": "sha512-w+5qxHzplvA4BkHhCaizNMLLXiI+CfP84YhpHm/PqMub4u8J0uOAv+aaGv40rYEYra5hHRWr9LUd6cfW32o9/A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.89.tgz", + "integrity": "sha512-DmyXa5lJHcjOsDC78BM3bnEECqbK3xASVMrKfvtT/7S7Z8NGQOugvu+L7b41V6cexCd34mBWgMOsjoEBceeB1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.89", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.89.tgz", + "integrity": "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.303.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.303.0.tgz", + "integrity": "sha512-B0B9T3dLEFBYPCUlnUS1mvAhW1craSbF9HO+JfBjAtpFUJ7gMIqmEwNSclikY3RiN2OnCkj/V1ReAQpaHae8Bg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pdfjs-dist": { + "version": "4.10.38", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz", + "integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.65" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/package.json b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/package.json new file mode 100644 index 00000000..b369309b --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "ocr-preprocess-ui", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.303.0", + "pdfjs-dist": "^4.0.379", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "vite": "^5.0.8" + } +} diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/postcss.config.js b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/postcss.config.js new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/public/vite.svg b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/public/vite.svg new file mode 100644 index 00000000..384ad2a1 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/public/vite.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/App.jsx b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/App.jsx new file mode 100644 index 00000000..851298d8 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/App.jsx @@ -0,0 +1,563 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { FileText, Database, BookOpen } from 'lucide-react'; +import Stepper from './components/Stepper'; +import DatasetStepper from './components/DatasetStepper'; +import HomePage from './pages/HomePage'; +import MyFilesPage from './pages/MyFilesPage'; +import CombinedUploadPage from './pages/CombinedUploadPage'; +import PageMatchReviewPage from './pages/PageMatchReviewPage'; +import DatasetGenerationPage from './pages/DatasetGenerationPage'; +import UploadPage from './features/upload/pages/UploadPage'; +import SelectPage from './features/upload/pages/SelectPage'; +import PreprocessPage from './features/preprocess/pages/PreprocessPage'; +import TextDetectionPage from './features/ocr/pages/TextDetectionPage'; +import TextRecognitionPage from './features/ocr/pages/TextRecognitionPage'; +import LayoutAwareDetectionPage from './components/LayoutAwareDetectionPanel'; +import { usePdfPreview } from './hooks/usePdfPreview'; +import { revokeObjectUrls } from './utils/imageUrl'; +import AuthPage from './features/auth/AuthPage'; +import ProfilePage from './features/auth/ProfilePage'; +import { fetchMe, logout as apiLogout } from './features/auth/authApi'; + + +function App() { + // ── Auth gate ── + const [user, setUser] = useState(null); + const [authChecked, setAuthChecked] = useState(false); + const [showProfile, setShowProfile] = useState(false); + + // null (home) | 'ocr' | 'dataset' | 'files' + const [mode, setMode] = useState(null); + + // ── Shared state ── + const [currentStep, setCurrentStep] = useState(1); + const [files, setFiles] = useState(null); + const [selectedPages, setSelectedPages] = useState([]); + const [processedImages, setProcessedImages] = useState({}); + const [detectionMethod, setDetectionMethod] = useState(null); + const [detectionProvider, setDetectionProvider] = useState(null); + + // ── Dataset state ── + // 'recognition' needs a transcript and runs match review; 'detection' is + // boxes only, so it skips both. + const [datasetMode, setDatasetMode] = useState('recognition'); + const [parsedTranscript, setParsedTranscript] = useState(null); + const [allPagesBoxes, setAllPagesBoxes] = useState({}); + const [alignedTranscriptByPage, setAlignedTranscriptByPage] = useState({}); + const [isProcessingBook, setIsProcessingBook] = useState(false); + // Survives step 4 <-> 5 navigation. + const [detectionCache, setDetectionCache] = useState({ pages: {}, alignment: {} }); + + const [ocrDetectionCache, setOcrDetectionCache] = useState({ pages: {}, alignment: {} }); + + // Both end up as My Files metadata/tags. + const [preprocessing, setPreprocessing] = useState([]); + const [detectionModelInfo, setDetectionModelInfo] = useState({}); + + const { + pages, + isLoading, + error, + progress, + extractPages, + loadImages, + reset: resetPdfPreview, + } = usePdfPreview(); + + const handleFilesSelected = useCallback((selectedFiles) => { + setFiles(selectedFiles); + setSelectedPages([]); + }, []); + + // ── Dataset: combined upload ── + const handleCombinedUploadNext = useCallback(async (bookFiles, transcript, mode = 'recognition') => { + if (!bookFiles || bookFiles.length === 0) return; + setIsProcessingBook(true); + setDatasetMode(mode); + setParsedTranscript(mode === 'detection' ? null : transcript); + try { + const isPdf = bookFiles[0].type === 'application/pdf'; + let loadedPages; + if (isPdf) { + loadedPages = await extractPages(bookFiles[0]); + } else { + loadedPages = await loadImages(bookFiles); + } + // Detection mode uses step 2 as a Select-Pages step, so preselect + // everything and let the user deselect. Recognition mode picks in review. + if (mode === 'detection' || !isPdf) { + setSelectedPages((loadedPages || []).map((p) => p.pageNumber)); + } + setFiles(bookFiles); + setCurrentStep(2); + } catch (err) { + console.error('Failed to process book files:', err); + } finally { + setIsProcessingBook(false); + } + }, [extractPages, loadImages]); + + // ── Dataset: after match review ── + const handleMatchReviewNext = useCallback((matchedPageNumbers, updatedTranscript) => { + setSelectedPages(matchedPageNumbers); + if (updatedTranscript) setParsedTranscript(updatedTranscript); + setCurrentStep(3); + }, []); + + + const handleUploadNext = useCallback(async () => { + if (!files || files.length === 0) return; + + const isPdf = files[0].type === 'application/pdf'; + + if (isPdf) { + try { + await extractPages(files[0]); + setCurrentStep(2); + } catch (err) { + console.error('Failed to extract PDF:', err); + } + } else { + try { + const loadedPages = await loadImages(files); + setSelectedPages(loadedPages.map((p) => p.pageNumber)); + setCurrentStep(2); + } catch (err) { + console.error('Failed to load images:', err); + } + } + }, [files, extractPages, loadImages, mode]); + + const handleSelectionChange = useCallback((newSelection) => { + setSelectedPages(newSelection); + }, []); + + const handleStepClick = useCallback((stepId) => { + if (stepId <= currentStep) { + setCurrentStep(stepId); + } + }, [currentStep]); + + const goToStep = useCallback((step) => { + setCurrentStep(step); + }, []); + + const handleReset = useCallback(() => { + setMode(null); + setCurrentStep(1); + setFiles(null); + setSelectedPages([]); + // Object URLs leak unless we revoke them before the next book. + revokeObjectUrls(Object.values(processedImages)); + setProcessedImages({}); + setDetectionMethod(null); + setDetectionProvider(null); + setDatasetMode('recognition'); + setParsedTranscript(null); + setAllPagesBoxes({}); + setAlignedTranscriptByPage({}); + setIsProcessingBook(false); + setOcrDetectionCache({ pages: {}, alignment: {} }); + setDetectionCache({ pages: {}, alignment: {} }); + setPreprocessing([]); + setDetectionModelInfo({}); + resetPdfPreview(); + }, [resetPdfPreview, processedImages]); + + // ── Mode selection ── + const handleSelectMode = useCallback((selectedMode) => { + setMode(selectedMode); + setCurrentStep(1); + }, []); + + // ── Check for an existing session on load ── + useEffect(() => { + let active = true; + fetchMe().then((u) => { + if (active) { + setUser(u); + setAuthChecked(true); + } + }); + return () => { + active = false; + }; + }, []); + + const handleLogout = useCallback(async () => { + try { + await apiLogout(); + } finally { + handleReset(); + setUser(null); + } + }, [handleReset]); + + // Must be logged in before anything else renders. + if (!authChecked) { + return ( +
+
+
+ ); + } + + if (!user) { + return ; + } + + // No mode picked yet -> home. + if (!mode) { + return ( + <> + setShowProfile(true)} + /> + {showProfile && ( + setShowProfile(false)} + onUpdated={setUser} + /> + )} + + ); + } + + if (mode === 'files') { + return ; + } + + // ── Dataset mode: upload -> match review -> preprocess -> detect -> export ── + if (mode === 'dataset') { + + // ── Full-screen steps ── + if (currentStep === 3) { + const backStep = 2; + return ( +
+ goToStep(backStep)} + onNext={() => goToStep(4)} + onProcessedImagesChange={setProcessedImages} + onPipelineChange={setPreprocessing} + /> +
+ ); + } + + if (currentStep === 4) { + return ( +
+ goToStep(3)} + onHome={handleReset} + datasetMode={true} + initialDetectedPages={detectionCache.pages} + initialAlignmentByPage={detectionCache.alignment} + onStateChange={(cache) => setDetectionCache(cache)} + onDatasetNext={({ boxesByPage, alignedTranscriptByPage: alignedMap, modelInfo }) => { + setAllPagesBoxes(boxesByPage || {}); + setAlignedTranscriptByPage(alignedMap || {}); + if (modelInfo) setDetectionModelInfo(modelInfo); + goToStep(5); + }} + /> +
+ ); + } + + if (currentStep === 5) { + return ( +
+ 0 ? alignedTranscriptByPage : parsedTranscript} + allPagesBoxes={allPagesBoxes} + preprocessing={preprocessing} + modelInfo={detectionModelInfo} + onBack={() => goToStep(4)} + onHome={handleReset} + bookName={files?.[0]?.name?.replace(/\.[^.]+$/, '') || 'dataset'} + forceMode={datasetMode === 'detection' ? 'detection' : null} + /> +
+ ); + } + + // ── Steps 1 & 2 share a layout ── + return ( +
+ {/* ── Compact header ─────────────────────────────────────── */} +
+
+ {/* Logo */} +
+
+ +
+
+

Dataset Generator

+

OCR training data pipeline

+
+
+ + {/* Stepper — centered, full width */} +
+ +
+ + {/* Home */} + +
+
+ + {/* ── Error/progress banner ──────────────────────────────── */} + {(error || (isLoading && progress > 0)) && ( +
+ {error && ( +
+ Error: {error} +
+ )} + {isLoading && progress > 0 && ( +
+
+ Extracting pages… + {progress}% +
+
+
+
+
+ )} +
+ )} + + {/* ── Page content (full remaining height) ──────────────── */} +
+ {currentStep === 1 && ( + + )} + {currentStep === 2 && datasetMode === 'detection' && ( + goToStep(1)} + onNext={() => goToStep(3)} + isLoading={isLoading} + /> + )} + {currentStep === 2 && datasetMode !== 'detection' && ( + goToStep(1)} + onNext={handleMatchReviewNext} + /> + )} +
+
+ ); + } + + // ── OCR mode ── + return ( +
+ {currentStep === 3 ? ( +
+ goToStep(2)} + onNext={() => goToStep(4)} + onProcessedImagesChange={setProcessedImages} + onPipelineChange={setPreprocessing} + /> +
+ ) : currentStep === 6 ? ( +
+ goToStep(4)} + onHome={handleReset} + initialDetectedPages={ocrDetectionCache.pages} + initialAlignmentByPage={ocrDetectionCache.alignment} + onStateChange={(cache) => { + setOcrDetectionCache(cache); + }} + /> +
+ ) : currentStep === 5 ? ( +
+ { + const original = pages.find(p => p.pageNumber === pageNum); + const preprocessed = processedImages[pageNum]; + return { + pageNumber: pageNum, + originalPageNumber: original?.originalPageNumber || null, + isSplit: original?.isSplit || false, + splitSide: original?.splitSide || null, + original: original?.thumbnail || original, + processed: preprocessed || original?.thumbnail || original, + }; + }) + } + onBack={() => goToStep(4)} + onHome={handleReset} + onComplete={(message) => { + alert(message || 'OCR processing complete! Transcripts have been saved.'); + }} + /> +
+ ) : ( + <> + {/* Header */} +
+
+
+
+ +
+
+

+ OCR Preprocess Studio +

+

+ Prepare documents for text extraction +

+
+
+ + +
+
+ + {/* Main content */} +
+ + + {error && ( +
+

Error

+

{error}

+
+ )} + + {isLoading && progress > 0 && ( +
+
+ + Processing... + + {progress}% +
+
+
+
+
+ )} + +
+ {currentStep === 1 && ( + + )} + + {currentStep === 2 && ( + goToStep(1)} + onNext={() => goToStep(3)} + isLoading={isLoading} + /> + )} + + {currentStep === 4 && ( + goToStep(3)} + onNext={(method, provider) => { + setDetectionMethod(method); + setDetectionProvider(provider); + if (method === 'layout-aware') { + goToStep(6); + } else { + goToStep(5); + } + }} + /> + )} +
+
+ + {/* Footer */} +
+
+

+ OCR Preprocess Studio + | + RenAIssance Project +

+
+
+ + )} +
+ ); +} + +export default App; diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/assets/home-image.png b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/assets/home-image.png new file mode 100644 index 00000000..23972576 Binary files /dev/null and b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/assets/home-image.png differ diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/components/BBoxEditor.jsx b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/components/BBoxEditor.jsx new file mode 100644 index 00000000..3481525c --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/components/BBoxEditor.jsx @@ -0,0 +1,837 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { + X, + Check, + Undo2, + Redo2, + ZoomIn, + ZoomOut, + Maximize2, + Plus, + MousePointer2, + Trash2, + ChevronLeft, + ChevronRight, + Copy, + RotateCw, +} from 'lucide-react'; + +// ── Constants ── +const HANDLE_RADIUS = 6; // corner handle radius in canvas coords +const MIN_BOX_SIZE = 10; +const MAX_UNDO = 40; + +// ── Helpers ── +let _uid = 0; +function uid() { return `bb-${Date.now()}-${_uid++}`; } + +// "2_left" -> "2L", "3_right" -> "3R". +function shortPageLabel(pageNumber) { + return String(pageNumber).replace('_left', 'L').replace('_right', 'R'); +} + +// The resize/rotate math assumes [TL, TR, BR, BL] vertex order. +function orderQuad(poly) { + const byY = [...poly].sort((a, b) => a[1] - b[1]); + const [tl, tr] = byY.slice(0, 2).sort((a, b) => a[0] - b[0]); // top: left, right + const [bl, br] = byY.slice(2, 4).sort((a, b) => a[0] - b[0]); // bottom: left, right + return [[tl[0], tl[1]], [tr[0], tr[1]], [br[0], br[1]], [bl[0], bl[1]]]; +} + +// PaddleOCR polygon -> editor box. 4-point quads keep their rotation; anything +// else collapses to its bounding rect. +function polyToBox(poly, id) { + if (!poly || poly.length === 0) return null; + let pts; + if (poly.length === 4) { + pts = orderQuad(poly); + } else { + const xs = poly.map(p => p[0]); + const ys = poly.map(p => p[1]); + const x1 = Math.min(...xs), y1 = Math.min(...ys); + const x2 = Math.max(...xs), y2 = Math.max(...ys); + pts = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]; + } + return { id: id ?? uid(), points: pts, selected: false }; +} + +function boxToPoly(b) { return b.points; } + +// Axis-aligned quad from two corners (draw mode). +function makeRectBox(x1, y1, x2, y2) { + return { + id: uid(), + points: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]], + selected: true, + }; +} + +// Point-in-convex-polygon. +function pointInPolygon(px, py, pts) { + let inside = false; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + const xi = pts[i][0], yi = pts[i][1]; + const xj = pts[j][0], yj = pts[j][1]; + if (((yi > py) !== (yj > py)) && (px < (xj - xi) * (py - yi) / (yj - yi) + xi)) inside = !inside; + } + return inside; +} + +// Which corner was hit (0-3), or -1. +function hitCorner(pts, px, py, r) { + for (let i = 0; i < pts.length; i++) { + if (Math.hypot(px - pts[i][0], py - pts[i][1]) <= r) return i; + } + return -1; +} + +// Drag a corner, keeping the box axis-aligned. The opposite corner is the anchor. +function resizeRectCorner(pts, cornerIdx, nx, ny) { + const opposites = [2, 3, 0, 1]; + const oIdx = opposites[cornerIdx]; + const ox = pts[oIdx][0], oy = pts[oIdx][1]; + const x1 = Math.min(nx, ox), y1 = Math.min(ny, oy); + const x2 = Math.max(nx, ox), y2 = Math.max(ny, oy); + return [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]; +} + +function rectSize(pts) { + const xs = pts.map(p => p[0]), ys = pts.map(p => p[1]); + return { w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) }; +} + +function rotatePoint(px, py, cx, cy, angle) { + const cos = Math.cos(angle), sin = Math.sin(angle); + return [cx + (px - cx) * cos - (py - cy) * sin, cy + (px - cx) * sin + (py - cy) * cos]; +} + +function getBoxCenter(pts) { + return [pts.reduce((s, p) => s + p[0], 0) / 4, pts.reduce((s, p) => s + p[1], 0) / 4]; +} + +// Rotation handle sits off the TL-TR edge midpoint, along the outward normal. +function getRotationHandlePos(pts, offsetDist) { + const mx = (pts[0][0] + pts[1][0]) / 2; + const my = (pts[0][1] + pts[1][1]) / 2; + const [cx, cy] = getBoxCenter(pts); + const dx = mx - cx, dy = my - cy; + const len = Math.hypot(dx, dy) || 1; + return [mx + (dx / len) * offsetDist, my + (dy / len) * offsetDist]; +} + +function hitRotationHandle(pts, px, py, r, offsetDist) { + const [hx, hy] = getRotationHandlePos(pts, offsetDist); + return Math.hypot(px - hx, py - hy) <= r; +} + +// Corner resize that survives rotation — the angle is preserved and the +// opposite corner stays put. +function resizeRotatedCorner(pts, cornerIdx, nx, ny) { + const opposites = [2, 3, 0, 1]; + const oIdx = opposites[cornerIdx]; + const [opx, opy] = pts[oIdx]; + const ncx = (opx + nx) / 2, ncy = (opy + ny) / 2; + const edgeDx = pts[1][0] - pts[0][0], edgeDy = pts[1][1] - pts[0][1]; + const angle = Math.atan2(edgeDy, edgeDx); + const cosA = Math.cos(-angle), sinA = Math.sin(-angle); + const lox = (opx - ncx) * cosA - (opy - ncy) * sinA; + const loy = (opx - ncx) * sinA + (opy - ncy) * cosA; + const lnx = (nx - ncx) * cosA - (ny - ncy) * sinA; + const lny = (nx - ncx) * sinA + (ny - ncy) * cosA; + const hw = Math.abs(lnx - lox) / 2, hh = Math.abs(lny - loy) / 2; + if (hw * 2 < MIN_BOX_SIZE || hh * 2 < MIN_BOX_SIZE) return pts; + const local = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]; + const cosR = Math.cos(angle), sinR = Math.sin(angle); + return local.map(([lx, ly]) => [ncx + lx * cosR - ly * sinR, ncy + lx * sinR + ly * cosR]); +} + +// ── BBoxEditor — multi-page, supports angled bounding boxes ── +export default function BBoxEditor({ pages: pagesProp = [], onSave, onCancel, initialPageNumber = null }) { + + // ── Multi-page ───────────────────────────────────────────────────────── + // Frozen at mount. A batch detection running behind this modal reorders the + // parent's list, which would slide currentIdx onto a different page and save + // edits under the wrong page number. + const pagesRef = useRef(pagesProp); + const pages = pagesRef.current; + const [currentIdx, setCurrentIdx] = useState(() => { + if (initialPageNumber == null) return 0; + const idx = pagesRef.current.findIndex(p => String(p.pageNumber) === String(initialPageNumber)); + return idx === -1 ? 0 : idx; + }); + const pageBoxesRef = useRef({}); + const [modifiedPages, setModifiedPages] = useState(new Set()); + const currentPage = pages[currentIdx]; + + // ── Refs ─────────────────────────────────────────────────────────────── + const bgCanvasRef = useRef(null); + const overlayCanvasRef = useRef(null); + const containerRef = useRef(null); + const outerRef = useRef(null); + // Scrolled into view on nav so later pages stay reachable. + const activePageBtnRef = useRef(null); + + // Focus on open so arrow keys work straight away. + useEffect(() => { outerRef.current?.focus(); }, []); + + useEffect(() => { + activePageBtnRef.current?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' }); + }, [currentIdx]); + // ── Box state ────────────────────────────────────────────────────────── + const [boxes, setBoxes] = useState([]); + const [undoStack, setUndoStack] = useState([]); + const [redoStack, setRedoStack] = useState([]); + const boxesRef = useRef([]); + useEffect(() => { boxesRef.current = boxes; }, [boxes]); + + // ── View ──────────────────────────────────────────────────────────────── + const [zoom, setZoom] = useState(1); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [imageSize, setImageSize] = useState({ w: 0, h: 0 }); + const [imageReady, setImageReady] = useState(false); + + const zoomRef = useRef(zoom); + const panRef = useRef(pan); + const imageSizeRef = useRef(imageSize); + + // In the render body on purpose: effects fire too late. + zoomRef.current = zoom; + panRef.current = pan; + imageSizeRef.current = imageSize; + + // 'select' | 'draw' + const [mode, setMode] = useState('select'); + + // One of: move | corner | rotate | draw | pan, each with its own payload. + const dragRef = useRef(null); + const isPanRef = useRef(false); + const panStartRef = useRef({ x: 0, y: 0, panX: 0, panY: 0 }); + + const selectedId = useMemo(() => boxes.find(b => b.selected)?.id ?? null, [boxes]); + + // ── Page management ── + const saveCurrentPage = useCallback(() => { + if (!currentPage) return; + pageBoxesRef.current[currentPage.pageNumber] = boxesRef.current.map(b => ({ ...b, selected: false })); + }, [currentPage]); + + const loadPage = useCallback((page) => { + setImageReady(false); + setUndoStack([]); + setRedoStack([]); + + const saved = pageBoxesRef.current[page.pageNumber]; + const initialBoxes = saved ?? (page.polygons || []).map(poly => polyToBox(poly)).filter(Boolean); + if (!saved) pageBoxesRef.current[page.pageNumber] = initialBoxes; + setBoxes(initialBoxes); + // Synced here, not in an effect — flipping pages fast would otherwise + // save the previous page's boxes under the next page's number. + boxesRef.current = initialBoxes; + + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + const w = img.naturalWidth, h = img.naturalHeight; + setImageSize({ w, h }); + + const bg = bgCanvasRef.current; + if (bg) { bg.width = w; bg.height = h; bg.getContext('2d').drawImage(img, 0, 0); } + + if (containerRef.current) { + const cw = containerRef.current.clientWidth, ch = containerRef.current.clientHeight; + const fitZ = Math.min(cw / w, ch / h, 1) * 0.9; + const fitPan = { x: (cw - w * fitZ) / 2, y: (ch - h * fitZ) / 2 }; + setZoom(fitZ); setPan(fitPan); + zoomRef.current = fitZ; panRef.current = fitPan; + } + setImageReady(true); + }; + img.src = page.imageSrc; + }, []); + + useEffect(() => { if (pages.length > 0) loadPage(pages[currentIdx] || pages[0]); }, []); // eslint-disable-line + + const goToPage = useCallback((idx) => { + if (idx < 0 || idx >= pages.length || idx === currentIdx) return; + saveCurrentPage(); + setCurrentIdx(idx); + loadPage(pages[idx]); + }, [currentIdx, pages, saveCurrentPage, loadPage]); + + // ── Overlay drawing ── + const drawOverlay = useCallback((boxList, drawState) => { + const canvas = overlayCanvasRef.current; + const { w, h } = imageSizeRef.current; + if (!canvas || w === 0) return; + + if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, w, h); + + const z = zoomRef.current; + const lw = Math.max(1.5, 2 / z); + const cr = Math.max(4, HANDLE_RADIUS / z); // corner handle radius in canvas coords + + for (const b of boxList) { + const sel = b.selected; + const pts = b.points; + + ctx.strokeStyle = sel ? '#f97316' : '#22c55e'; + ctx.lineWidth = lw; + ctx.fillStyle = sel ? 'rgba(249,115,22,0.12)' : 'rgba(34,197,94,0.08)'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(pts[0][0], pts[0][1]); + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + if (sel) { + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#f97316'; + ctx.lineWidth = lw; + for (const [px, py] of pts) { + ctx.beginPath(); + ctx.rect(px - cr, py - cr, cr * 2, cr * 2); + ctx.fill(); ctx.stroke(); + } + + const rotOffset = Math.max(8, 30 / z); + const [rhx, rhy] = getRotationHandlePos(pts, rotOffset); + const tmx = (pts[0][0] + pts[1][0]) / 2; + const tmy = (pts[0][1] + pts[1][1]) / 2; + ctx.setLineDash([3 / z, 2 / z]); + ctx.strokeStyle = '#f97316'; + ctx.lineWidth = lw; + ctx.beginPath(); ctx.moveTo(tmx, tmy); ctx.lineTo(rhx, rhy); ctx.stroke(); + ctx.setLineDash([]); + const rhr = cr * 0.95; + ctx.beginPath(); ctx.arc(rhx, rhy, rhr, 0, Math.PI * 2); + ctx.fillStyle = '#ffffff'; ctx.fill(); + ctx.strokeStyle = '#f97316'; ctx.lineWidth = lw; ctx.stroke(); + ctx.strokeStyle = '#f97316'; + ctx.lineWidth = Math.max(1, lw * 0.8); + ctx.beginPath(); + ctx.arc(rhx, rhy, rhr * 0.5, -Math.PI * 0.8, Math.PI * 0.2); + ctx.stroke(); + } + } + + if (drawState) { + const { startX, startY, currentX, currentY } = drawState; + const x1 = Math.min(startX, currentX), y1 = Math.min(startY, currentY); + const x2 = Math.max(startX, currentX), y2 = Math.max(startY, currentY); + ctx.strokeStyle = '#3b82f6'; + ctx.lineWidth = lw; + ctx.setLineDash([6 / z, 4 / z]); + ctx.fillStyle = 'rgba(59,130,246,0.10)'; + ctx.beginPath(); ctx.rect(x1, y1, x2 - x1, y2 - y1); + ctx.fill(); ctx.stroke(); + ctx.setLineDash([]); + } + }, []); + + // imageSize is in the deps so we redraw once the image loads. + useEffect(() => { + if (imageSize.w > 0) drawOverlay(boxes, null); + }, [boxes, imageSize, drawOverlay]); + + // ── Undo / Redo ── + const pushUndo = useCallback((prevBoxes) => { + const snap = prevBoxes.map(b => ({ ...b, points: b.points.map(p => [...p]), selected: false })); + setUndoStack(s => { const n = [...s, snap]; return n.length > MAX_UNDO ? n.slice(-MAX_UNDO) : n; }); + setRedoStack([]); + setModifiedPages(prev => new Set([...prev, currentPage?.pageNumber])); + }, [currentPage]); + + const handleUndo = useCallback(() => { + setUndoStack(s => { + if (!s.length) return s; + const prev = s[s.length - 1]; + setRedoStack(r => [...r, boxes.map(b => ({ ...b, points: b.points.map(p => [...p]), selected: false }))]); + setBoxes(prev); return s.slice(0, -1); + }); + }, [boxes]); + + const handleRedo = useCallback(() => { + setRedoStack(r => { + if (!r.length) return r; + const next = r[r.length - 1]; + setUndoStack(s => [...s, boxes.map(b => ({ ...b, points: b.points.map(p => [...p]), selected: false }))]); + setBoxes(next); return r.slice(0, -1); + }); + }, [boxes]); + + // ── Coordinates ── + const screenToImage = useCallback((clientX, clientY) => { + if (!containerRef.current) return { x: 0, y: 0 }; + const rect = containerRef.current.getBoundingClientRect(); + return { + x: (clientX - rect.left - panRef.current.x) / zoomRef.current, + y: (clientY - rect.top - panRef.current.y) / zoomRef.current, + }; + }, []); + + // ── Pointer events ── + const handlePointerDown = useCallback((e) => { + // Middle-click or ctrl+left-click pans. + if (e.button === 1 || (e.button === 0 && (e.ctrlKey || e.metaKey))) { + isPanRef.current = true; + panStartRef.current = { x: e.clientX, y: e.clientY, panX: panRef.current.x, panY: panRef.current.y }; + e.preventDefault(); return; + } + if (e.button !== 0) return; + + const { x: imgX, y: imgY } = screenToImage(e.clientX, e.clientY); + const z = zoomRef.current; + const cr = Math.max(4, HANDLE_RADIUS / z); + const rr = Math.max(5, (HANDLE_RADIUS + 2) / z); + + // ── Draw mode ── + if (mode === 'draw') { + dragRef.current = { type: 'draw', startX: imgX, startY: imgY, currentX: imgX, currentY: imgY }; + return; + } + + // ── Select mode: handles first, then bodies, then empty space ── + const selBox = boxes.find(b => b.selected); + + if (selBox) { + const rotOffset = Math.max(8, 30 / z); + if (hitRotationHandle(selBox.points, imgX, imgY, rr * 1.8, rotOffset)) { + const center = getBoxCenter(selBox.points); + pushUndo(boxes); + dragRef.current = { + type: 'rotate', + id: selBox.id, + origPts: selBox.points.map(p => [...p]), + center, + startAngle: Math.atan2(imgY - center[1], imgX - center[0]), + }; + return; + } + } + + if (selBox) { + const ci = hitCorner(selBox.points, imgX, imgY, cr * 1.5); + if (ci !== -1) { + pushUndo(boxes); + dragRef.current = { + type: 'corner', + id: selBox.id, + cornerIdx: ci, + origPts: selBox.points.map(p => [...p]), + }; + return; + } + } + + // Top-most box wins. + const hit = [...boxes].reverse().find(b => pointInPolygon(imgX, imgY, b.points)); + if (hit) { + pushUndo(boxes); + dragRef.current = { + type: 'move', + id: hit.id, + origPts: hit.points.map(p => [...p]), + startX: imgX, startY: imgY, + }; + setBoxes(prev => prev.map(b => ({ ...b, selected: b.id === hit.id }))); + return; + } + + setBoxes(prev => prev.map(b => ({ ...b, selected: false }))); + dragRef.current = null; + }, [mode, boxes, screenToImage, pushUndo]); + + const handlePointerMove = useCallback((e) => { + if (isPanRef.current) { + const dx = e.clientX - panStartRef.current.x; + const dy = e.clientY - panStartRef.current.y; + const np = { x: panStartRef.current.panX + dx, y: panStartRef.current.panY + dy }; + setPan(np); panRef.current = np; return; + } + + const drag = dragRef.current; + if (!drag) return; + const { x: imgX, y: imgY } = screenToImage(e.clientX, e.clientY); + + if (drag.type === 'draw') { + drag.currentX = imgX; drag.currentY = imgY; + drawOverlay(boxesRef.current, drag); return; + } + + if (drag.type === 'move') { + const dx = imgX - drag.startX, dy = imgY - drag.startY; + setBoxes(prev => prev.map(b => { + if (b.id !== drag.id) return b; + return { ...b, points: drag.origPts.map(([px, py]) => [px + dx, py + dy]) }; + })); + return; + } + + if (drag.type === 'rotate') { + const [cx, cy] = drag.center; + const currentAngle = Math.atan2(imgY - cy, imgX - cx); + // 0.35x so the box turns gently, not wildly. + const delta = (currentAngle - drag.startAngle) * 0.35; + setBoxes(prev => prev.map(b => { + if (b.id !== drag.id) return b; + const newPts = drag.origPts.map(([px, py]) => rotatePoint(px, py, cx, cy, delta)); + return { ...b, points: newPts }; + })); + return; + } + + if (drag.type === 'corner') { + setBoxes(prev => prev.map(b => { + if (b.id !== drag.id) return b; + const newPts = resizeRotatedCorner(drag.origPts, drag.cornerIdx, imgX, imgY); + return { ...b, points: newPts }; + })); + return; + } + }, [screenToImage, drawOverlay]); + + const handlePointerUp = useCallback(() => { + isPanRef.current = false; + const drag = dragRef.current; + dragRef.current = null; + if (!drag) return; + + if (drag.type === 'draw') { + const x1 = Math.min(drag.startX, drag.currentX), y1 = Math.min(drag.startY, drag.currentY); + const x2 = Math.max(drag.startX, drag.currentX), y2 = Math.max(drag.startY, drag.currentY); + if (x2 - x1 >= MIN_BOX_SIZE && y2 - y1 >= MIN_BOX_SIZE) { + pushUndo(boxesRef.current); + const nb = makeRectBox(x1, y1, x2, y2); + setBoxes(prev => [...prev.map(b => ({ ...b, selected: false })), nb]); + setModifiedPages(prev => new Set([...prev, currentPage?.pageNumber])); + } else { + drawOverlay(boxesRef.current, null); + } + } + + if (drag.type === 'move' || drag.type === 'corner' || drag.type === 'rotate') { + setModifiedPages(prev => new Set([...prev, currentPage?.pageNumber])); + } + }, [pushUndo, drawOverlay, currentPage]); + + useEffect(() => { + const up = () => handlePointerUp(); + window.addEventListener('mouseup', up); + return () => window.removeEventListener('mouseup', up); + }, [handlePointerUp]); + + // ── Scroll zoom ── + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const onWheel = e => { + e.preventDefault(); + // ~5% per tick — 10% overshoots constantly. + const f = e.deltaY < 0 ? 1.05 : 1 / 1.05; + setZoom(z => { const nz = Math.max(0.05, Math.min(8, z * f)); zoomRef.current = nz; return nz; }); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, []); + + // ── Keyboard ── + const handleDelete = useCallback(() => { + if (!selectedId) return; + // Land the selection on the box above the deleted one. + const sorted = [...boxes].sort((a, b) => { + const topA = Math.min(...a.points.map(p => p[1])); + const topB = Math.min(...b.points.map(p => p[1])); + return topA - topB; + }); + const delIdx = sorted.findIndex(b => b.id === selectedId); + const prevId = delIdx > 0 ? sorted[delIdx - 1].id : null; + pushUndo(boxes); + setBoxes(prev => + prev + .filter(b => b.id !== selectedId) + .map(b => ({ ...b, selected: prevId !== null && b.id === prevId })) + ); + setModifiedPages(prev => new Set([...prev, currentPage?.pageNumber])); + }, [selectedId, boxes, pushUndo, currentPage]); + + const handleDuplicate = useCallback(() => { + if (!selectedId) return; + const src = boxes.find(b => b.id === selectedId); + if (!src) return; + pushUndo(boxes); + const dup = { + id: uid(), + points: src.points.map(([px, py]) => [px + 12, py + 12]), + selected: true, + }; + setBoxes(prev => [...prev.map(b => ({ ...b, selected: false })), dup]); + setModifiedPages(prev => new Set([...prev, currentPage?.pageNumber])); + }, [selectedId, boxes, pushUndo, currentPage]); + + useEffect(() => { + const onKey = e => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + if (e.key === 'n' || e.key === 'N') { setMode(m => m === 'draw' ? 'select' : 'draw'); return; } + if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId) { e.preventDefault(); handleDelete(); return; } + if ((e.key === 'd' || e.key === 'D') && selectedId) { e.preventDefault(); handleDuplicate(); return; } + if (e.key === 'Escape') { setMode('select'); setBoxes(prev => prev.map(b => ({ ...b, selected: false }))); dragRef.current = null; return; } + if (e.ctrlKey || e.metaKey) { + if (e.key === 'z') { e.preventDefault(); handleUndo(); } + else if (e.key === 'y' || (e.shiftKey && e.key === 'z')) { e.preventDefault(); handleRedo(); } + } + // Up/Down cycle boxes in top-to-bottom order. + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + const sorted = [...boxesRef.current].sort((a, b) => { + const topA = Math.min(...a.points.map(p => p[1])); + const topB = Math.min(...b.points.map(p => p[1])); + return topA - topB; + }); + if (sorted.length === 0) return; + const selIdx = sorted.findIndex(b => b.selected); + let nextIdx; + if (selIdx === -1) { + nextIdx = e.key === 'ArrowDown' ? 0 : sorted.length - 1; + } else { + nextIdx = e.key === 'ArrowDown' ? selIdx + 1 : selIdx - 1; + nextIdx = Math.max(0, Math.min(sorted.length - 1, nextIdx)); + } + const targetId = sorted[nextIdx].id; + setBoxes(prev => prev.map(b => ({ ...b, selected: b.id === targetId }))); + return; + } + if (e.key === 'ArrowLeft') goToPage(currentIdx - 1); + if (e.key === 'ArrowRight') goToPage(currentIdx + 1); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [selectedId, handleDelete, handleDuplicate, handleUndo, handleRedo, currentIdx, goToPage]); + + // ── Fit view ── + const handleFit = useCallback(() => { + const { w, h } = imageSizeRef.current; + if (!containerRef.current || w === 0) return; + const cw = containerRef.current.clientWidth, ch = containerRef.current.clientHeight; + const fitZ = Math.min(cw / w, ch / h, 1) * 0.9; + const fitPan = { x: (cw - w * fitZ) / 2, y: (ch - h * fitZ) / 2 }; + setZoom(fitZ); setPan(fitPan); zoomRef.current = fitZ; panRef.current = fitPan; + }, []); + + // ── Cursor style ── + const [cursor, setCursor] = useState('default'); + const handleMouseMoveForCursor = useCallback((e) => { + if (mode === 'draw') { setCursor('crosshair'); return; } + if (isPanRef.current) { setCursor('grabbing'); return; } + if (dragRef.current) return; // already handling a drag + + const { x: imgX, y: imgY } = screenToImage(e.clientX, e.clientY); + const z = zoomRef.current; + const selBox = boxes.find(b => b.selected); + const cr = Math.max(4, HANDLE_RADIUS / z); + + if (selBox) { + const rr = Math.max(5, (HANDLE_RADIUS + 2) / z); + const rotOffset = Math.max(8, 30 / z); + if (hitRotationHandle(selBox.points, imgX, imgY, rr * 1.8, rotOffset)) { setCursor('grab'); return; } + if (hitCorner(selBox.points, imgX, imgY, cr * 1.5) !== -1) { setCursor('nwse-resize'); return; } + } + const hit = [...boxes].reverse().find(b => pointInPolygon(imgX, imgY, b.points)); + setCursor(hit ? 'move' : 'default'); + }, [mode, boxes, screenToImage]); + + // ── Save all pages ── + const handleSave = useCallback(() => { + saveCurrentPage(); + const results = {}; + pages.forEach(page => { + const bxs = pageBoxesRef.current[page.pageNumber]; + results[page.pageNumber] = bxs ? bxs.map(boxToPoly) : page.polygons || []; + }); + onSave(results); + }, [pages, onSave, saveCurrentPage]); + + const selBox = boxes.find(b => b.selected); + + // ── Render ── + return ( +
e.preventDefault()} + > + + {/* ── TOOLBAR ────────────────────────────────────────────── */} +
+ + {/* Left — title + mode toggle */} +
+
+
+ +
+
+

Box Editor

+

+ {boxes.length} box{boxes.length !== 1 ? 'es' : ''} + {modifiedPages.size > 0 && · {modifiedPages.size} page{modifiedPages.size > 1 ? 's' : ''} modified} +

+
+
+ +
+ + +
+
+ + {/* Center — info + undo/redo + zoom */} +
+ {selBox ? ( +
+ Drag corners to resize · Drag body to move · Drag ○ to rotate +
+ ) : ( +
+ Click a box to select · drag to move or resize +
+ )} + +
+ + +
+ +
+ + {Math.round(zoom * 100)}% + + +
+ + {selectedId && ( + + )} + {selectedId && ( + + )} +
+ + {/* Right — cancel / save */} +
+ + +
+
+ + {/* ── CANVAS AREA ──────────────────────────────────────────── */} +
{ handlePointerMove(e); handleMouseMoveForCursor(e); }} + > + {/* Background image (drawn once) */} + 2 ? 'pixelated' : 'auto', display: imageReady ? 'block' : 'none' }} /> + + {/* Interactive overlay */} + 2 ? 'pixelated' : 'auto', display: imageReady ? 'block' : 'none', pointerEvents: 'none' }} /> + + {!imageReady && ( +
+
+
+ )} + + {mode === 'draw' && imageReady && ( +
+
+ Drag to draw a rectangle · Press N or Esc to exit +
+
+ )} +
+ + {/* ── BOTTOM: page nav + shortcuts ─────────────────────────── */} +
+
+ N Add box + Del Delete + D Duplicate + Rotate + Ctrl+Z Undo + Ctrl+drag Pan + ← → Pages + ↑ ↓ Boxes +
+ + {pages.length > 1 && ( +
+ {currentIdx + 1}/{pages.length} + +
+ {pages.map((page, idx) => { + // Unvisited pages have no edited boxes yet; showing 0 + // looked like detection had been reverted. + const pageBoxCount = idx === currentIdx + ? boxes.length + : (pageBoxesRef.current[page.pageNumber]?.length ?? page.polygons?.length ?? 0); + return ( + + ); + })} +
+ +
+ )} + + + {imageSize.w > 0 && `${imageSize.w} × ${imageSize.h} px`} + +
+
+ ); +} diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/components/DatasetStepper.jsx b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/components/DatasetStepper.jsx new file mode 100644 index 00000000..740e4c79 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/components/DatasetStepper.jsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { + Check, + Upload, + GitMerge, + Wand2, + ScanText, + Database, +} from 'lucide-react'; + +const recognitionSteps = [ + { id: 1, name: 'Upload', description: 'Book & transcript', icon: Upload }, + { id: 2, name: 'Match & Review', description: 'Verify page matches', icon: GitMerge }, + { id: 3, name: 'Preprocess', description: 'Apply image transforms', icon: Wand2 }, + { id: 4, name: 'Detect & Align', description: 'Detect lines & align text', icon: ScanText }, + { id: 5, name: 'Export Dataset', description: 'Download training data', icon: Database }, +]; + +const detectionSteps = [ + { id: 1, name: 'Upload', description: 'Book images', icon: Upload }, + { id: 2, name: 'Select Pages', description: 'Pick pages to detect', icon: GitMerge }, + { id: 3, name: 'Preprocess', description: 'Apply image transforms', icon: Wand2 }, + { id: 4, name: 'Detect Lines', description: 'Detect text bounding boxes', icon: ScanText }, + { id: 5, name: 'Export Dataset', description: 'Download training data', icon: Database }, +]; + +export default function DatasetStepper({ currentStep, onStepClick, compact = false, datasetMode = 'recognition' }) { + const datasetSteps = datasetMode === 'detection' ? detectionSteps : recognitionSteps; + const progressPercent = ((currentStep - 1) / (datasetSteps.length - 1)) * 100; + + const inner = ( + + ); + + if (compact) { + return
{inner}
; + } + + return ( +
+ {inner} +
+ ); +} diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/components/ImageCropper.jsx b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/components/ImageCropper.jsx new file mode 100644 index 00000000..25c73ec2 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/components/ImageCropper.jsx @@ -0,0 +1,607 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { Crop, Check, X, RotateCcw, Maximize2, Lock, Unlock, Copy, ChevronDown, ChevronUp } from 'lucide-react'; + +// Draggable crop box with optional aspect lock. The same crop can be applied +// across several pages at once. +export default function ImageCropper({ + imageSrc, + onCropComplete, + onCancel, + initialCrop = null, + availablePages = [], // Array of { pageNumber, thumbnail } objects + currentPageNumber = null, + onBatchCropComplete = null, // Callback for applying crop to multiple pages +}) { + const containerRef = useRef(null); + const imageRef = useRef(null); + const [imageLoaded, setImageLoaded] = useState(false); + const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 }); + const [displayDimensions, setDisplayDimensions] = useState({ width: 0, height: 0 }); + + // Percentages of the displayed image, not pixels. + const [crop, setCrop] = useState({ + x: 10, // percentage from left + y: 10, // percentage from top + width: 80, // percentage width + height: 80, // percentage height + }); + + const [isDragging, setIsDragging] = useState(false); + const [dragType, setDragType] = useState(null); // 'move', 'nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w' + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [cropStart, setCropStart] = useState({ x: 0, y: 0, width: 0, height: 0 }); + const [aspectLocked, setAspectLocked] = useState(false); + const [aspectRatio, setAspectRatio] = useState(null); + + const [showPageSelector, setShowPageSelector] = useState(false); + const [selectedPages, setSelectedPages] = useState(new Set()); // Pages to apply crop to + const [isBatchProcessing, setIsBatchProcessing] = useState(false); + + const otherPages = availablePages.filter(p => p.pageNumber !== currentPageNumber); + const hasMultiplePages = otherPages.length > 0 && onBatchCropComplete; + + useEffect(() => { + if (!imageSrc) return; + + const img = new Image(); + img.onload = () => { + setImageDimensions({ width: img.width, height: img.height }); + setImageLoaded(true); + }; + img.src = imageSrc; + }, [imageSrc]); + + useEffect(() => { + if (!containerRef.current || !imageLoaded) return; + + const updateDisplayDimensions = () => { + const container = containerRef.current; + const containerRect = container.getBoundingClientRect(); + const containerWidth = containerRect.width - 48; // padding + const containerHeight = containerRect.height - 48; + + const imageAspect = imageDimensions.width / imageDimensions.height; + const containerAspect = containerWidth / containerHeight; + + let displayWidth, displayHeight; + if (imageAspect > containerAspect) { + displayWidth = containerWidth; + displayHeight = containerWidth / imageAspect; + } else { + displayHeight = containerHeight; + displayWidth = containerHeight * imageAspect; + } + + setDisplayDimensions({ width: displayWidth, height: displayHeight }); + }; + + updateDisplayDimensions(); + window.addEventListener('resize', updateDisplayDimensions); + return () => window.removeEventListener('resize', updateDisplayDimensions); + }, [imageLoaded, imageDimensions]); + + useEffect(() => { + if (initialCrop) { + setCrop(initialCrop); + } + }, [initialCrop]); + + const handleLockAspect = () => { + if (!aspectLocked) { + setAspectRatio(crop.width / crop.height); + } + setAspectLocked(!aspectLocked); + }; + + const handleResetCrop = () => { + setCrop({ x: 5, y: 5, width: 90, height: 90 }); + setAspectLocked(false); + setAspectRatio(null); + }; + + const getCursor = (type) => { + const cursors = { + move: 'move', + nw: 'nw-resize', + ne: 'ne-resize', + sw: 'sw-resize', + se: 'se-resize', + n: 'n-resize', + s: 's-resize', + e: 'e-resize', + w: 'w-resize', + }; + return cursors[type] || 'default'; + }; + + const handleDragStart = (e, type) => { + e.preventDefault(); + e.stopPropagation(); + + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const clientY = e.touches ? e.touches[0].clientY : e.clientY; + + setIsDragging(true); + setDragType(type); + setDragStart({ x: clientX, y: clientY }); + setCropStart({ ...crop }); + }; + + const handleDragMove = useCallback((e) => { + if (!isDragging || !dragType) return; + + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const clientY = e.touches ? e.touches[0].clientY : e.clientY; + + const deltaX = ((clientX - dragStart.x) / displayDimensions.width) * 100; + const deltaY = ((clientY - dragStart.y) / displayDimensions.height) * 100; + + let newCrop = { ...cropStart }; + + if (dragType === 'move') { + newCrop.x = Math.max(0, Math.min(100 - cropStart.width, cropStart.x + deltaX)); + newCrop.y = Math.max(0, Math.min(100 - cropStart.height, cropStart.y + deltaY)); + } else { + const minSize = 10; // minimum 10% size + + if (dragType.includes('w')) { + const newX = Math.max(0, Math.min(cropStart.x + cropStart.width - minSize, cropStart.x + deltaX)); + const widthDiff = cropStart.x - newX; + newCrop.x = newX; + newCrop.width = cropStart.width + widthDiff; + } + if (dragType.includes('e')) { + newCrop.width = Math.max(minSize, Math.min(100 - cropStart.x, cropStart.width + deltaX)); + } + if (dragType.includes('n')) { + const newY = Math.max(0, Math.min(cropStart.y + cropStart.height - minSize, cropStart.y + deltaY)); + const heightDiff = cropStart.y - newY; + newCrop.y = newY; + newCrop.height = cropStart.height + heightDiff; + } + if (dragType.includes('s')) { + newCrop.height = Math.max(minSize, Math.min(100 - cropStart.y, cropStart.height + deltaY)); + } + + if (aspectLocked && aspectRatio) { + if (dragType.includes('e') || dragType.includes('w')) { + newCrop.height = newCrop.width / aspectRatio; + if (newCrop.y + newCrop.height > 100) { + newCrop.height = 100 - newCrop.y; + newCrop.width = newCrop.height * aspectRatio; + } + } else { + newCrop.width = newCrop.height * aspectRatio; + if (newCrop.x + newCrop.width > 100) { + newCrop.width = 100 - newCrop.x; + newCrop.height = newCrop.width / aspectRatio; + } + } + } + } + + setCrop(newCrop); + }, [isDragging, dragType, dragStart, cropStart, displayDimensions, aspectLocked, aspectRatio]); + + const handleDragEnd = useCallback(() => { + setIsDragging(false); + setDragType(null); + }, []); + + useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleDragMove); + window.addEventListener('mouseup', handleDragEnd); + window.addEventListener('touchmove', handleDragMove, { passive: false }); + window.addEventListener('touchend', handleDragEnd); + return () => { + window.removeEventListener('mousemove', handleDragMove); + window.removeEventListener('mouseup', handleDragEnd); + window.removeEventListener('touchmove', handleDragMove); + window.removeEventListener('touchend', handleDragEnd); + }; + } + }, [isDragging, handleDragMove, handleDragEnd]); + + const applyCropToImage = async (imgSrc, cropParams) => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + const img = new Image(); + img.crossOrigin = 'anonymous'; + + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + img.src = imgSrc; + }); + + const actualX = (cropParams.x / 100) * img.width; + const actualY = (cropParams.y / 100) * img.height; + const actualWidth = (cropParams.width / 100) * img.width; + const actualHeight = (cropParams.height / 100) * img.height; + + canvas.width = actualWidth; + canvas.height = actualHeight; + + ctx.drawImage( + img, + actualX, actualY, actualWidth, actualHeight, + 0, 0, actualWidth, actualHeight + ); + + return canvas.toDataURL('image/png'); + }; + + const handleApplyCrop = async () => { + if (!imageRef.current) return; + + const cropData = { + x: crop.x, + y: crop.y, + width: crop.width, + height: crop.height, + }; + + const croppedDataUrl = await applyCropToImage(imageSrc, cropData); + onCropComplete(croppedDataUrl, cropData); + }; + + const handleApplyBatchCrop = async () => { + if (!imageRef.current || selectedPages.size === 0) return; + + setIsBatchProcessing(true); + + const cropData = { + x: crop.x, + y: crop.y, + width: crop.width, + height: crop.height, + }; + + try { + const currentCroppedUrl = await applyCropToImage(imageSrc, cropData); + + const batchResults = []; + for (const pageNum of selectedPages) { + const page = availablePages.find(p => p.pageNumber === pageNum); + if (page) { + const croppedUrl = await applyCropToImage(page.thumbnail, cropData); + batchResults.push({ + pageNumber: pageNum, + croppedDataUrl: croppedUrl, + cropData, + }); + } + } + + onBatchCropComplete(currentCroppedUrl, cropData, batchResults); + } catch (error) { + console.error('Batch crop failed:', error); + } finally { + setIsBatchProcessing(false); + } + }; + + const togglePageSelection = (pageNum) => { + setSelectedPages(prev => { + const newSet = new Set(prev); + if (newSet.has(pageNum)) { + newSet.delete(pageNum); + } else { + newSet.add(pageNum); + } + return newSet; + }); + }; + + const toggleSelectAll = () => { + if (selectedPages.size === otherPages.length) { + setSelectedPages(new Set()); + } else { + setSelectedPages(new Set(otherPages.map(p => p.pageNumber))); + } + }; + + const renderHandle = (position, cursor) => { + const baseClasses = "absolute w-4 h-4 bg-white border-2 border-blue-500 rounded-full shadow-lg z-20 transition-transform hover:scale-125"; + const positions = { + nw: 'top-0 left-0 -translate-x-1/2 -translate-y-1/2', + ne: 'top-0 right-0 translate-x-1/2 -translate-y-1/2', + sw: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2', + se: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2', + n: 'top-0 left-1/2 -translate-x-1/2 -translate-y-1/2', + s: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2', + e: 'right-0 top-1/2 translate-x-1/2 -translate-y-1/2', + w: 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2', + }; + + return ( +
handleDragStart(e, position)} + onTouchStart={(e) => handleDragStart(e, position)} + /> + ); + }; + + if (!imageSrc) { + return ( +
+

No image to crop

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

Crop Image

+
+ +
+ {/* Aspect lock button */} + + + {/* Reset button */} + + + {/* Full image button */} + +
+
+ + {/* Crop area */} +
+ {imageLoaded && displayDimensions.width > 0 && ( +
+ {/* Original image */} + To crop + + {/* Dark overlay outside crop area */} +
+
+
+ + {/* Crop box */} +
handleDragStart(e, 'move')} + onTouchStart={(e) => handleDragStart(e, 'move')} + > + {/* Grid lines (rule of thirds) */} +
+
+
+
+
+
+ + {/* Corner handles */} + {renderHandle('nw')} + {renderHandle('ne')} + {renderHandle('sw')} + {renderHandle('se')} + + {/* Edge handles */} + {renderHandle('n')} + {renderHandle('s')} + {renderHandle('e')} + {renderHandle('w')} +
+ + {/* Dimensions display */} +
+ {Math.round((crop.width / 100) * imageDimensions.width)} × {Math.round((crop.height / 100) * imageDimensions.height)} px +
+
+ )} +
+ + {/* Page selector panel (collapsible) */} + {hasMultiplePages && ( +
+ {/* Toggle header */} + + + {/* Page selection grid */} + {showPageSelector && ( +
+ {/* Select all button */} +
+ + Select pages to apply the same crop + + +
+ + {/* Page thumbnails */} +
+ {otherPages.map((page) => ( + + ))} +
+
+ )} +
+ )} + + {/* Footer with actions */} +
+

+ Drag corners or edges to resize • Drag center to move +

+ +
+ + + {/* Show batch apply button if pages are selected */} + {selectedPages.size > 0 ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/components/LayoutAwareDetectionPanel.jsx b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/components/LayoutAwareDetectionPanel.jsx new file mode 100644 index 00000000..4ef4be47 --- /dev/null +++ b/RenAIssance_CRNN_OCR_with_LLM_Integration_Saarthak_Gupta/frontend/src/components/LayoutAwareDetectionPanel.jsx @@ -0,0 +1,2419 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { + Play, + Loader2, + AlertTriangle, + CheckCircle2, + ChevronLeft, + ChevronRight, + Layers, + Image as ImageIcon, + FileText, + Info, + ChevronDown, + PenLine, + ArrowUp, + ArrowDown, + Split, + Link2, + Sparkles, + Cpu, + Download, + FileText as FileTextIcon, + FileJson, + FileType, + BookOpen, + RefreshCw, + Home, + Plus, + Wand2, +} from 'lucide-react'; +import ResizablePanels from './ocr/ResizablePanels'; +import BBoxEditor from './BBoxEditor'; +import { + getLocalRecognitionModels, + runLocalRecognition, + exportCRNNResultsAsText, + exportCRNNResultsAsJSON, + downloadBlob, +} from '../features/ocr/services/ocrApi'; +import { saveTranscriptSession, fetchStorageOverview } from '../services/storageApi'; +import { + getLLMProviders, + getLLMTemplates, + postProcessWithLLMProvider, +} from '../services/llmApi'; +import { API_ORIGIN } from '../config'; + +const API_BASE = API_ORIGIN; + +// ── Layout model options ── +const LAYOUT_MODELS = [ + { id: 'PP-DocLayout_plus-L', name: 'PP-DocLayout+ Large', desc: 'Best accuracy' }, + { id: 'PP-DocLayout-L', name: 'PP-DocLayout Large', desc: 'High accuracy' }, + { id: 'PP-DocLayout-M', name: 'PP-DocLayout Medium', desc: 'Balanced' }, + { id: 'PP-DocLayout-S', name: 'PP-DocLayout Small', desc: 'Fastest' }, + { id: 'PicoDet-L_layout_17cls', name: 'PicoDet-L 17cls', desc: '17-class layout' }, + { id: 'RT-DETR-H_layout_17cls', name: 'RT-DETR-H 17cls', desc: '17-class, high acc' }, + { id: 'PicoDet-L_layout_3cls', name: 'PicoDet-L 3cls', desc: '3-class layout' }, + { id: 'PicoDet-S_layout_3cls', name: 'PicoDet-S 3cls', desc: '3-class, fastest' }, +]; + +// ── Detection model options ── +const DETECTION_MODELS = [ + { id: 'PP-OCRv5_server_det', name: 'PP-OCRv5 Server Det', desc: 'High accuracy, slower' }, + { id: 'PP-OCRv5_mobile_det', name: 'PP-OCRv5 Mobile Det', desc: 'Fast, lighter' }, + { id: 'DB', name: 'DBNet', desc: 'Classic, reliable' }, + { id: 'DB++', name: 'DB++', desc: 'Enhanced DBNet' }, + { id: 'EAST', name: 'EAST', desc: 'Efficient text detection' }, + { id: 'SAST', name: 'SAST', desc: 'Segmentation-based' }, +]; + +// ── Default tuning parameters ── +const DEFAULT_PARAMS = { + region_padding: 50, + layout_expand: 2, + score_thresh: 0.5, + upscale_min_h: 60, + nms_iou_thresh: 0.3, + gap_multiplier: 2.0, +}; + +// ── Slider definitions for the tuning UI ── +const PARAM_DEFS = [ + { key: 'region_padding', label: 'Region Padding', unit: 'px', min: 0, max: 200, step: 5, type: 'int', tooltip: 'Padding around detected regions before OCR' }, + { key: 'layout_expand', label: 'Layout Expand', unit: 'px', min: 0, max: 50, step: 1, type: 'int', tooltip: 'Expand layout bounding boxes before cropping' }, + { key: 'score_thresh', label: 'Score Threshold', unit: '', min: 0.1, max: 1.0, step: 0.05, type: 'float', tooltip: 'Minimum confidence score for text detection' }, + { key: 'upscale_min_h', label: 'Upscale Min H', unit: 'px', min: 20, max: 200, step: 10, type: 'int', tooltip: 'Upscale crops shorter than this height' }, + { key: 'nms_iou_thresh', label: 'NMS IoU Thresh', unit: '', min: 0.1, max: 0.9, step: 0.05, type: 'float', tooltip: 'IoU threshold for non-max suppression' }, + { key: 'gap_multiplier', label: 'Gap Multiplier', unit: '×', min: 0.5, max: 5.0, step: 0.5, type: 'float', tooltip: 'Gap threshold multiplier for line merging' }, +]; + +// Monotonic, not Date.now(): rows created in the same millisecond would collide +// as React keys and silently remount. +let _rowIdCounter = 0; +const nextRowId = () => ++_rowIdCounter; + +// Word-level LCS diff for the before/after view. Whitespace splitting is fine — +// OCR corrections are small (diacritics, glyph fixes) and we only need to point +// at the words that changed. +function diffWords(before, after) { + const a = before.split(/\s+/).filter(Boolean); + const b = after.split(/\s+/).filter(Boolean); + const n = a.length; + const m = b.length; + const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i -= 1) { + for (let j = m - 1; j >= 0; j -= 1) { + lcs[i][j] = a[i] === b[j] + ? lcs[i + 1][j + 1] + 1 + : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + const beforeTokens = []; + const afterTokens = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + beforeTokens.push({ text: a[i], removed: false }); + afterTokens.push({ text: b[j], added: false }); + i += 1; j += 1; + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + beforeTokens.push({ text: a[i], removed: true }); + i += 1; + } else { + afterTokens.push({ text: b[j], added: true }); + j += 1; + } + } + while (i < n) { beforeTokens.push({ text: a[i], removed: true }); i += 1; } + while (j < m) { afterTokens.push({ text: b[j], added: true }); j += 1; } + return { beforeTokens, afterTokens }; +} + +function shortPageLabel(pageKey) { + return String(pageKey).replace('_left', 'L').replace('_right', 'R'); +} + +function getPageBaseNumber(pageKey) { + const value = String(pageKey).toLowerCase(); + const match = value.match(/(\d+)/); + return match ? Number(match[1]) : null; +} + +function getPageSide(pageKey) { + const value = String(pageKey).toLowerCase(); + if (value.includes('left') || value.endsWith('l')) return 'left'; + if (value.includes('right') || value.endsWith('r')) return 'right'; + return null; +} + +function resolveTranscriptKey(transcript, pageKey) { + if (!transcript) return null; + const keys = Object.keys(transcript); + const asString = String(pageKey); + if (asString in transcript) return asString; + + const targetNum = getPageBaseNumber(asString); + const targetSide = getPageSide(asString); + + if (targetNum == null) return null; + + const exactSideKey = keys.find((k) => { + const keyNum = getPageBaseNumber(k); + const keySide = getPageSide(k); + return keyNum === targetNum && keySide === targetSide; + }); + if (exactSideKey) return exactSideKey; + + return keys.find((k) => getPageBaseNumber(k) === targetNum) || null; +} + + +// ── Thumbnail item ── +function ThumbnailItem({ image, index, isActive, isDetected, onClick, pageLabel, processedSrc }) { + const imageSrc = processedSrc || image?.processed || image?.original || image?.thumbnail; + + return ( + + ); +} + + +// ── Main component ── +export default function LayoutAwareDetectionPage({ + pages, + selectedPages, + processedImages, + transcript = {}, + bookName = 'transcript', + preprocessing = [], + onBack, + onHome, + datasetMode = false, + onDatasetNext, + // Restored when navigating back from export. + initialDetectedPages = {}, + initialAlignmentByPage = {}, + onStateChange, +}) { + // ── Model selection ── + const [selectedDetModel, setSelectedDetModel] = useState('PP-OCRv5_server_det'); + const [selectedLayoutModel, setSelectedLayoutModel] = useState('PP-DocLayout_plus-L'); + + // ── Tuning parameters ── + const [tuningParams, setTuningParams] = useState({ ...DEFAULT_PARAMS }); + const [showAdvanced, setShowAdvanced] = useState(false); + + // ── UI state ── + const [viewingPageIndex, setViewingPageIndex] = useState(0); + const [loading, setLoading] = useState(false); + // null = not checked yet, true = cached, false = needs the big download. + const [modelsReady, setModelsReady] = useState(null); + const [downloadingModels, setDownloadingModels] = useState(false); + const [detectedPages, setDetectedPages] = useState(() => initialDetectedPages); + const [error, setError] = useState(null); + const [warning, setWarning] = useState(null); + const [processingTime, setProcessingTime] = useState(null); + const [imageLoaded, setImageLoaded] = useState(false); + + // ── BBox editor state ── + const [showBBoxEditor, setShowBBoxEditor] = useState(false); + // Drives the "edited" dot on thumbnails. + const [editedPages, setEditedPages] = useState(new Set()); + + // ── Process-all state ── + const [processingAll, setProcessingAll] = useState(false); + const [processAllProgress, setProcessAllProgress] = useState(null); + const cancelProcessingRef = useRef(false); + + // ── Alignment state (dataset mode) ── + const [alignmentByPage, setAlignmentByPage] = useState(() => initialAlignmentByPage); + + // ── Local OCR state (OCR mode) ── + const [localModels, setLocalModels] = useState([]); + const [localModelsLoading, setLocalModelsLoading] = useState(false); + const [localModelsError, setLocalModelsError] = useState(null); + const [selectedOcrModel, setSelectedOcrModel] = useState(''); + const [recognizedByPage, setRecognizedByPage] = useState({}); + const [ocrProcessing, setOcrProcessing] = useState(false); + const [ocrProcessingAll, setOcrProcessingAll] = useState(false); + const [ocrProgress, setOcrProgress] = useState(null); + const cancelOcrRef = useRef(false); + const lastSavedSignatureRef = useRef(''); + const [ocrDevice, setOcrDevice] = useState(null); + const [ocrTimeMs, setOcrTimeMs] = useState(null); + const [ocrError, setOcrError] = useState(null); + + // ── LLM post-processing (optional cleanup pass) ── + const [llmEnabled, setLlmEnabled] = useState(false); + const [llmProviders, setLlmProviders] = useState([]); + const [llmProvider, setLlmProvider] = useState('gemini'); + const [llmModel, setLlmModel] = useState('gemini-2.5-flash'); + const [llmTemplates, setLlmTemplates] = useState([]); + const [llmTemplate, setLlmTemplate] = useState('full_cleanup'); + // Keyed by provider so switching doesn't lose an entered key. + const [llmApiKeys, setLlmApiKeys] = useState({}); + const [llmProcessing, setLlmProcessing] = useState(false); + const [llmProgress, setLlmProgress] = useState(null); + const [llmError, setLlmError] = useState(null); + // Local corrector only: null = unknown/not applicable, false = base weights + // still to download (~8 GB, one time), true = cached. + const [llmModelReady, setLlmModelReady] = useState(null); + const [downloadingLlmModel, setDownloadingLlmModel] = useState(false); + // Stamped into the saved transcript's My Files metadata. + const [lastLlmRun, setLastLlmRun] = useState(null); + + // { [pageNum]: { [boxIndex]: textBeforePolish } } for the changed-lines view. + const [llmDiffByPage, setLlmDiffByPage] = useState({}); + const [showLlmDiff, setShowLlmDiff] = useState(true); + + // One source of truth for image<->transcript hover. Rows derive their + // highlight from this, so hovering costs one state update, not two. + const [hoveredBoxIndex, setHoveredBoxIndex] = useState(null); + + const imgRef = useRef(null); + const imageWrapRef = useRef(null); + // Row refs by box index — used to scroll a row into view on image hover. + const lineRefsRef = useRef(new Map()); + const setLineRef = useCallback((boxIndex, el) => { + if (boxIndex == null) return; + const map = lineRefsRef.current; + if (el) map.set(boxIndex, el); + else map.delete(boxIndex); + }, []); + // Needed for the SVG viewBox, so polygon coords land in the right place. + const [imageNaturalSize, setImageNaturalSize] = useState({ w: 0, h: 0 }); + // Stops the seed effect from clobbering alignment that already exists. + const seededPages = useRef(new Set()); + + // Push state up to the parent cache. Skipping the first pass matters — + // otherwise we overwrite the cache with empty state on mount. The ref keeps + // a new onStateChange identity from causing extra syncs. + const onStateChangeRef = useRef(onStateChange); + useEffect(() => { onStateChangeRef.current = onStateChange; }, [onStateChange]); + const didMountStateSyncRef = useRef(false); + useEffect(() => { + if (!didMountStateSyncRef.current) { + didMountStateSyncRef.current = true; + return; + } + onStateChangeRef.current?.({ pages: detectedPages, alignment: alignmentByPage }); + }, [detectedPages, alignmentByPage]); + + // ── Derived data ── + const availablePages = useMemo(() => selectedPages || [], [selectedPages]); + const currentPageNum = availablePages[viewingPageIndex] || 1; + const totalPages = availablePages.length; + + const currentLines = detectedPages[currentPageNum] || []; + const isCurrentDetected = currentPageNum in detectedPages; + const detectedCount = Object.keys(detectedPages).length; + const transcriptKeyForCurrentPage = resolveTranscriptKey(transcript, currentPageNum); + + const currentAlignmentRows = alignmentByPage[currentPageNum] || []; + const currentRecognition = recognizedByPage[currentPageNum] || []; + const recognizedCount = Object.keys(recognizedByPage).length; + + const currentRecognitionByIndex = useMemo(() => { + const map = {}; + currentRecognition.forEach((item) => { + map[item.box_index] = item.text || ''; + }); + return map; + }, [currentRecognition]); + + // { boxIndex: beforeText } for the current page. + const currentDiff = llmDiffByPage[currentPageNum] || {}; + const changedLineCount = Object.keys(currentDiff).length; + + // Sort top-to-bottom by the midpoint-Y of the top edge (average of the two + // highest vertices). More stable than min-Y once boxes are rotated. + const sortedBoxIndices = useMemo(() => { + return currentLines + .map((poly, idx) => { + const byY = [...poly].sort((a, b) => a[1] - b[1]); + const topMidY = (byY[0][1] + byY[1][1]) / 2; + return { idx, topMidY }; + }) + .sort((a, b) => a.topMidY - b.topMidY) + .map(item => item.idx); + }, [currentLines]); + + const getImageUrl = useCallback((pageNum) => { + if (processedImages?.[pageNum]) return processedImages[pageNum]; + // find-by-pageNumber, so split pages ("1_left") resolve correctly. + const page = pages?.find(p => p.pageNumber === pageNum); + return page?.thumbnail || null; + }, [pages, processedImages]); + + const currentImageUrl = getImageUrl(currentPageNum); + + const toDataUrl = useCallback(async (url) => { + if (!url) return null; + if (url.startsWith('data:')) return url; + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = () => reject(new Error('Failed converting image to base64')); + reader.readAsDataURL(blob); + }); + }, []); + + useEffect(() => { + if (datasetMode) return; + let cancelled = false; + async function loadLocalModels() { + setLocalModelsLoading(true); + setLocalModelsError(null); + try { + const data = await getLocalRecognitionModels(); + const models = data.models || []; + if (cancelled) return; + setLocalModels(models); + if (models.length > 0) { + const preferred = models.find((m) => m.model_type === 'crnn') || models[0]; + setSelectedOcrModel((prev) => prev || preferred.id); + } + } catch (err) { + if (!cancelled) { + setLocalModelsError(err.message || 'Failed loading local models'); + } + } finally { + if (!cancelled) { + setLocalModelsLoading(false); + } + } + } + loadLocalModels(); + return () => { + cancelled = true; + }; + }, [datasetMode]); + + // ── Arrow key navigation (BBox editor closed only) ── + useEffect(() => { + const handleKeyDown = (e) => { + if (showBBoxEditor) return; + const tag = document.activeElement?.tagName?.toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return; + if (e.key === 'ArrowLeft') { + e.preventDefault(); + setViewingPageIndex(prev => Math.max(0, prev - 1)); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + setViewingPageIndex(prev => Math.min(totalPages - 1, prev + 1)); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [showBBoxEditor, totalPages]); + + const runOcrForPage = useCallback(async (pageNum) => { + const imageUrl = getImageUrl(pageNum); + const boxes = detectedPages[pageNum] || []; + if (!imageUrl || boxes.length === 0 || !selectedOcrModel) return null; + const imageData = await toDataUrl(imageUrl); + return runLocalRecognition(imageData, boxes, selectedOcrModel); + }, [detectedPages, getImageUrl, selectedOcrModel, toDataUrl]); + + const handleRecognizeCurrentPage = useCallback(async () => { + if (datasetMode || ocrProcessing || ocrProcessingAll) return; + if (!selectedOcrModel || !isCurrentDetected) return; + + setOcrProcessing(true); + setOcrError(null); + try { + const result = await runOcrForPage(currentPageNum); + if (!result) { + setOcrError('No detected boxes available for this page.'); + return; + } + if (!result.success) { + setOcrError(result.error || 'Recognition failed'); + return; + } + + setRecognizedByPage((prev) => ({ ...prev, [currentPageNum]: result.results || [] })); + setLlmDiffByPage((prev) => ({ ...prev, [currentPageNum]: {} })); // fresh OCR → drop stale diff + setOcrDevice(result.device || null); + setOcrTimeMs(result.processing_time_ms ?? null); + } catch (err) { + setOcrError(err.message || 'Recognition failed'); + } finally { + setOcrProcessing(false); + } + }, [currentPageNum, datasetMode, isCurrentDetected, ocrProcessing, ocrProcessingAll, runOcrForPage, selectedOcrModel]); + + const handleRecognizeAllPages = useCallback(async () => { + if (datasetMode || ocrProcessingAll || ocrProcessing) return; + if (!selectedOcrModel) return; + + setOcrProcessingAll(true); + setOcrError(null); + cancelOcrRef.current = false; + + try { + for (let i = 0; i < availablePages.length; i++) { + if (cancelOcrRef.current) break; + const pageNum = availablePages[i]; + if (!(pageNum in detectedPages)) continue; + + setOcrProgress({ current: i + 1, total: availablePages.length }); + const result = await runOcrForPage(pageNum); + if (!result) continue; + if (!result.success) { + setOcrError(`Page ${pageNum}: ${result.error || 'Recognition failed'}`); + break; + } + + setRecognizedByPage((prev) => ({ ...prev, [pageNum]: result.results || [] })); + setLlmDiffByPage((prev) => ({ ...prev, [pageNum]: {} })); // fresh OCR → drop stale diff + setOcrDevice(result.device || null); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } catch (err) { + setOcrError(err.message || 'Batch recognition failed'); + } finally { + setOcrProcessingAll(false); + setOcrProgress(null); + } + }, [availablePages, datasetMode, detectedPages, ocrProcessing, ocrProcessingAll, runOcrForPage, selectedOcrModel]); + + const handleSaveTranscript = useCallback(async () => { + if (recognizedCount === 0) return; + + const transcriptByPage = {}; + const transcriptImages = {}; + + for (let i = 0; i < availablePages.length; i += 1) { + const p = availablePages[i]; + const byIndex = {}; + (recognizedByPage[p] || []).forEach((r) => { + byIndex[r.box_index] = r.text || ''; + }); + const sorted = (detectedPages[p] || []) + .map((poly, idx) => { + const byY = [...poly].sort((a, b) => a[1] - b[1]); + return { idx, y: (byY[0][1] + byY[1][1]) / 2 }; + }) + .sort((a, b) => a.y - b.y) + .map((item) => byIndex[item.idx] || ''); + + const pageText = sorted.join('\n').trim(); + if (!pageText) continue; + + const pageKey = String(p); + transcriptByPage[pageKey] = pageText; + + const imageUrl = getImageUrl(p); + if (imageUrl) { + try { + const dataUrl = await toDataUrl(imageUrl); + if (typeof dataUrl === 'string' && dataUrl.startsWith('data:')) { + transcriptImages[pageKey] = dataUrl; + } + } catch { + } + } + } + + if (Object.keys(transcriptByPage).length === 0) { + setOcrError('No transcript pages to save.'); + return; + } + + const signature = JSON.stringify(transcriptByPage); + if (lastSavedSignatureRef.current === signature) { + window.alert('Already saved — this exact transcript is already in My Files.'); + return; + } + + const defaultName = bookName && bookName !== 'transcript' ? bookName : 'My Transcript'; + const chosenName = window.prompt('Name this transcript:', defaultName); + if (chosenName === null) return; // user cancelled + const finalName = chosenName.trim() || defaultName; + + try { + const overview = await fetchStorageOverview(); + const existingNames = (overview.transcripts || []).map((t) => (t.name || '').toLowerCase()); + if (existingNames.includes(finalName.toLowerCase())) { + const proceed = window.confirm(`A transcript named "${finalName}" already exists in My Files. Save anyway?`); + if (!proceed) return; + } + } catch { + } + + try { + await saveTranscriptSession( + transcriptByPage, + 'layout-aware ocr', + 'recognition', + transcriptImages, + finalName, + { + preprocessing, + ocr_provider: 'local', + ocr_model: selectedOcrModel, + layout_model: selectedLayoutModel, + detection_model: selectedDetModel, + llm_postprocess: lastLlmRun || { used: false }, + }, + ); + lastSavedSignatureRef.current = signature; + window.alert(`Saved "${finalName}" to My Files.`); + } catch (err) { + setOcrError(err.message || 'Failed to save transcript session'); + } + }, [availablePages, bookName, detectedPages, getImageUrl, recognizedByPage, recognizedCount, selectedDetModel, selectedLayoutModel, selectedOcrModel, toDataUrl, preprocessing, lastLlmRun]); + + const updateRecognizedText = useCallback((lineIndex, value) => { + const boxIndex = sortedBoxIndices[lineIndex]; + if (boxIndex === undefined) return; + + setRecognizedByPage((prev) => { + const existing = prev[currentPageNum] || []; + const next = [...existing]; + const foundIndex = next.findIndex((r) => r.box_index === boxIndex); + if (foundIndex >= 0) { + next[foundIndex] = { ...next[foundIndex], text: value }; + } else { + next.push({ box_index: boxIndex, text: value }); + } + return { ...prev, [currentPageNum]: next }; + }); + }, [currentPageNum, sortedBoxIndices]); + + // Autogrow each line textarea so the transcript reads as one page. + const autoGrowLine = useCallback((el) => { + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + }, []); + + // Fetch providers/templates only when the feature is first switched on. + useEffect(() => { + if (!llmEnabled || llmProviders.length > 0) return; + getLLMProviders() + .then((data) => { + const provs = data.providers || []; + setLlmProviders(provs); + const firstEnabled = provs.find((p) => p.enabled); + if (firstEnabled) { + setLlmProvider(firstEnabled.id); + if (firstEnabled.default_model) setLlmModel(firstEnabled.default_model); + } + }) + .catch(() => setLlmError('Could not load LLM providers.')); + getLLMTemplates() + .then((data) => setLlmTemplates(data.templates || [])) + .catch(() => { /* fallback