diff --git a/docs/plan/tools/README.md b/docs/plan/tools/README.md new file mode 100644 index 0000000..cc1f3dc --- /dev/null +++ b/docs/plan/tools/README.md @@ -0,0 +1,60 @@ +# Tools — Document Scanner & Media Processing Hub + +Self-hosted, no-install document scanner dan media processing tools yang jalan di browser. Alternatif dari CamScanner, ilovepdf, compressjpeg — tanpa upload ke pihak ketiga. + +## Visi + +Satu platform dengan tools manipulasi file yang **beneran dipake orang setiap hari**. Semua proses di backend Rust — cepat, hemat memory, ga perlu install software. + +## Fitur Utama + +### Phase 1 — Document Scanner (Prioritas) +- Foto dokumen pake HP → auto-detect tepi → lurusin (perspective correction) +- Enhance: iluminasi merata, contrast, sharpen, B&W +- OCR → searchable PDF (teks bisa di-copy, dicari) +- Batch: multi-page → satu PDF +- Fallback crop manual (kalau auto-detect gagal) + +### Phase 2 — Image Tools +- Compress JPEG/PNG/WebP (lossy + lossless, atur kualitas %) +- Resize batch (atur dimensi, semua foto disamain) +- Convert format (HEIC→JPEG, PNG→WebP, SVG→PNG) +- Remove background (ONNX model, Rust runtime) + +### Phase 3 — PDF Tools +- Merge PDF (gabung file) +- Split PDF (ekstrak halaman tertentu) +- Images→PDF (kumpulan foto jadi 1 file) +- PDF→Images (tiap halaman jadi gambar) +- PDF compress (turunkin kualitas embedded images) + +### Phase 4 — Video/Audio Tools +- Compress video (bitrate + resolusi) +- Extract audio (MP4→MP3) +- Trim/crop +- GIF maker +- Audio convert + trim + +## Target User + +Orang yang: +- Punya HP/PC, paham teknologi dasar (buka browser, upload file) +- Butuh scan dokumen tanpa install aplikasi +- Butuh kompres file buat kirim WA/email +- Butuh manipulasi PDF sesekali +- Peduli privasi — ga mau upload file ke server pihak ketiga + +## Prinsip Desain + +1. **Satu task selesai dalam <5 detik** — ga ada loading lama +2. **Drag & drop + preview** — liat hasil sebelum download +3. **Progress realtime** via WebSocket — tau lagi di tahap mana +4. **Batch processing** — banyak file, satu klik +5. **Privasi first** — file otomatis dihapus setelah 1 jam +6. **WASM fallback** — tools ringan jalan di client (tanpa upload) + +## Domain & Branding + +- **Domain**: `tools.asepharyana.my.id` | `tools.asepharyana.web.id` +- **Design**: Twilight Terminal theme (sama kaya portfolio), konsisten visual +- **Dashboard**: Link dari hub dashboard → tools stats (total files processed, storage used) diff --git a/docs/plan/tools/architecture.md b/docs/plan/tools/architecture.md new file mode 100644 index 0000000..013f903 --- /dev/null +++ b/docs/plan/tools/architecture.md @@ -0,0 +1,451 @@ +# Architecture + +## System Overview + +``` +┌────────────────────────────────────────────────────────────────┐ +│ BROWSER │ +│ ┌────────────┐ ┌────────────┐ ┌────────────────────────┐ │ +│ │ Upload │ │ Camera │ │ Preview + Download │ │ +│ │ (drag/drop)│ │ (PWA) │ │ (streaming) │ │ +│ └─────┬──────┘ └─────┬──────┘ └───────────┬────────────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ WebSocket (progress: processing/step/percentage) │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────────────┬───────────────────────────────────┘ + │ HTTPS / WSS + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ TRAEFIK (tools.asepharyana.my.id) │ +│ Middleware chain: secure-headers → compress → rate-limit │ +└──────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ tools-app (Next.js 16 / TypeScript) │ +│ │ +│ ┌──────────────────┐ ┌─────────────────┐ │ +│ │ Pages/Routes │ │ API Routes │ │ +│ │ / → home │ │ POST /api/upload ──▶ file │ +│ │ /scan → scanner │ │ GET /api/job/:id ─▶ status │ +│ │ /image → image │ │ WS /api/job/:id/ws ─▶ progress │ +│ │ /pdf → pdf tools │ │ GET /api/download/:id ─▶ file │ +│ └──────────────────┘ └─────────────────┘ │ +│ │ +│ Upload validation: MIME type, size limit (50MB), virus scan │ +│ Temp storage bridge ke worker via HTTP/NATS │ +└──────────────────┬────────────────────────────────────────────┘ + │ HTTP (internal) + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ API GATEWAY (Rust / Axum) │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────┐ │ +│ │ Upload │ │ Job Manager │ │ Download │ │ +│ │ (streaming │ │ (CRUD job │ │ (stream file, │ │ +│ │ chunked) │ │ status) │ │ auto-delete) │ │ +│ └──────┬───────┘ └──────┬───────┘ └────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ NATS JetStream │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ +│ │ │ scan. │ │ image. │ │ pdf. │ │ │ +│ │ │ jobs │ │ jobs │ │ jobs │ │ │ +│ │ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │ │ +│ │ │ │ │ │ │ +│ │ ▼ ▼ ▼ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ +│ │ │ scan. │ │ image. │ │ pdf. │ │ │ +│ │ │ progress │ │ progress │ │ progress │ │ │ +│ │ └──────────┘ └──────────┘ └──────────────┘ │ │ +│ └────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ Cache (Redis) │ │ +│ │ - Job metadata (status, progress, timestamps) │ │ +│ │ - Rate limiting (sliding window per IP/tool) │ │ +│ │ - Result metadata (file path, size, type) │ │ +│ └────────────────────────────────────────────────────┘ │ +└──────────────────┬────────────────────────────────────────────┘ + │ consume NATS queue + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ WORKER POOL (Rust / Tokio + Rayon) │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │ +│ │ Scan Worker │ │ Image Worker │ │ PDF Worker │ │ +│ │ ×4 instances │ │ ×2 instances │ │ ×2 instances│ │ +│ │ │ │ │ │ │ │ +│ │ 1. Load image │ │ 1. Load image │ │ 1. Load PDF │ │ +│ │ 2. Edge detect │ │ 2. Compress │ │ 2. Merge/ │ │ +│ │ 3. Warp │ │ /resize/ │ │ split │ │ +│ │ 4. Enhance │ │ convert │ │ 3. Save │ │ +│ │ 5. OCR │ │ 3. Save │ │ 4. Update │ │ +│ │ 6. Gen PDF │ │ 4. Update │ │ job │ │ +│ │ 7. Update job │ │ job status │ │ status │ │ +│ │ └───────────────┘ └─────────────────┘ └──────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ Temp Storage (filesystem volume / S3-compatible) │ │ +│ │ Auto-cleanup: job TTL 1 jam, NATS cron tiap 10m │ │ +│ └────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────┘ +``` + +## Component Diagram + +``` +┌────────────────────────────────────────────┐ +│ apps/tools │ +│ │ +│ ├── frontend/ │ +│ │ ├── pages/ ← Next.js pages │ +│ │ ├── components/ ← React components │ +│ │ ├── lib/ ← utilities │ +│ │ └── public/ ← static assets │ +│ │ │ +│ ├── backend/ ← Rust workspace │ +│ │ ├── gateway/ ← Axum API server │ +│ │ ├── workers/ ← Processing workers │ +│ │ │ ├── scanner/ ← Document scanner │ +│ │ │ ├── image/ ← Image tools │ +│ │ │ └── pdf/ ← PDF tools │ +│ │ └── common/ ← Shared libs │ +│ │ │ +│ └── Dockerfile │ +└────────────────────────────────────────────┘ +``` + +## Data Flow (Document Scanner — Flow Lengkap) + +``` +1. User buka tools.asepharyana.my.id/scan +2. Upload foto via drag-drop atau kamera HP (PWA) +3. Next.js route handler menerima file + ├─ Validasi: MIME type (image/*), max 50MB, virus header scan + └─ Upload chunked ke Gateway internal (HTTP POST) + +4. Gateway menerima stream: + ├─ Simpan ke temp storage + ├─ Buat job record di Redis: {id, tool: "scan", status: "queued", progress: 0} + └─ Publish ke NATS: tools.scan.jobs {job_id, file_path, options} + +5. Scan Worker consume dari NATS: + ├─ Update Redis: status = "processing", progress = 10 + ├─ Load image (image-rs) + ├─ Pipeline (detail di pipeline.md): + │ 1. Edge detection ──▶ progress 25 + │ 2. Perspective warp ──▶ progress 40 + │ 3. Shadow removal ──▶ progress 55 + │ 4. Binarization ──▶ progress 70 + │ 5. Contrast/sharpen ──▶ progress 80 + │ 6. OCR ──▶ progress 90 + │ 7. Generate PDF ──▶ progress 95 + ├─ Simpan file hasil ke temp storage + ├─ Update Redis: status = "completed", progress = 100, result_path, ocr_text + └─ Publish ke NATS: tools.scan.progress {job_id, status, progress} + +6. WebSocket handler di Gateway: + ├─ Subscribe NATS topics tools.scan.progress + ├─ Forward ke browser user (per-job-id filter) + └─ Browser update progress bar + preview + +7. User download PDF: + ├─ GET /api/download/:job_id + ├─ Gateway stream file dari temp storage + └─ Browser save file +``` + +## Tech Stack + +### Frontend (Next.js + TypeScript) + +| Library | Fungsi | +|---------|--------| +| Next.js 16 | App router, API routes | +| shadcn/ui + Tailwind v4 | UI components | +| Framer Motion | Animasi progress, transisi | +| Canvas API | Preview crop manual, image manipulation client-side | +| WebSocket API | Real-time progress | + +### Backend (Rust) + +| Crate | Fungsi | +|-------|--------| +| `axum` | HTTP server (Gateway) | +| `tokio` | Async runtime | +| `image` | Image I/O, resize, convert, compress | +| `imageproc` | Edge detection, contour, thresholding | +| `lopdf` | PDF generation, merge, split, compress | +| `leptess` | Tesseract OCR binding | +| `ort` | ONNX Runtime (background removal) | +| `async-nats` | NATS JetStream client | +| `deadpool-redis` | Redis connection pool | +| `redis` | Redis async client | +| `rayon` | Parallel processing (batch, pixel ops) | +| `serde` | Serialization | +| `tracing` + `opentelemetry` | Observability | +| `uuid` | Job ID generation | + +### Infrastructure + +| Komponen | Fungsi | +|----------|--------| +| NATS JetStream | Job queue, progress pub/sub, scheduler | +| Redis | Job metadata, rate limiting, cache | +| PostgreSQL | Opsional — audit log, usage statistics | +| Tesseract | OCR engine (data files di Docker image) | +| Prometheus | Metrics (jobs/min, queue depth, latency per stage) | + +## Job Queue (NATS Streams & Consumers) + +### Streams + +``` +tools-scan-jobs → 1 stream, mirror to all scan workers +tools-image-jobs → 1 stream, mirror to all image workers +tools-pdf-jobs → 1 stream, mirror to all pdf workers +tools-progress → 1 stream, all progress events (key-value by job_id) +tools-scheduler → 1 stream, cron events +``` + +### Subjects + +``` +tools.scan.jobs.{job_id} → job submission +tools.scan.progress.{job_id} → progress update (fan-out ke Gateway) +tools.image.jobs.{job_id} → job submission +tools.image.progress.{job_id} → progress update +tools.pdf.jobs.{job_id} → job submission +tools.pdf.progress.{job_id} → progress update +tools.scheduler.cleanup → cleanup expired files (every 10 min) +``` + +## Redis Schema + +``` +job:{id} → Hash {status, tool, progress, file_path, result_path, ocr_text, created_at, ttl} +rate_limit:{ip}:{tool} → Sorted Set (sliding window) +file_meta:{hash} → String {original_name, size, mime} +``` + +## Metrics (Prometheus) + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `tools_jobs_total` | Counter | `tool`, `status` | Total jobs processed | +| `tools_jobs_in_flight` | Gauge | `tool` | Currently processing jobs | +| `tools_queue_depth` | Gauge | `tool` | NATS queue depth | +| `tools_processing_duration` | Histogram | `tool`, `stage` | Duration per stage | +| `tools_file_size_bytes` | Histogram | `tool` | Upload file size distribution | +| `tools_rate_limit_hits` | Counter | `tool` | Rate limit violations | + +## Directory Structure + +``` +apps/tools/ +├── frontend/ +│ ├── src/ +│ │ ├── app/ +│ │ │ ├── page.tsx # Landing page +│ │ │ ├── scan/ +│ │ │ │ ├── page.tsx # Scanner page +│ │ │ │ └── result/[id]/ +│ │ │ │ └── page.tsx # Result page +│ │ │ ├── image/ +│ │ │ │ ├── compress/page.tsx +│ │ │ │ ├── resize/page.tsx +│ │ │ │ ├── convert/page.tsx +│ │ │ │ └── remove-bg/page.tsx +│ │ │ ├── pdf/ +│ │ │ │ ├── merge/page.tsx +│ │ │ │ ├── split/page.tsx +│ │ │ │ ├── images-to-pdf/page.tsx +│ │ │ │ └── compress/page.tsx +│ │ │ ├── api/ +│ │ │ │ ├── upload/route.ts +│ │ │ │ ├── job/[id]/route.ts +│ │ │ │ │ └── ws/route.ts +│ │ │ │ └── download/[id]/route.ts +│ │ │ ├── layout.tsx +│ │ │ └── globals.css +│ │ ├── components/ +│ │ │ ├── upload-zone.tsx # Drag & drop area +│ │ │ ├── progress-bar.tsx # WebSocket-connected progress +│ │ │ ├── preview.tsx # Before/after preview +│ │ │ ├── crop-editor.tsx # Manual corner adjustment +│ │ │ ├── tool-layout.tsx # Consistent tool page layout +│ │ │ └── camera-capture.tsx # PWA camera interface +│ │ ├── hooks/ +│ │ │ ├── use-job-status.ts # WebSocket connection +│ │ │ ├── use-upload.ts # Upload with progress +│ │ │ └── use-camera.ts # Camera access +│ │ └── lib/ +│ │ ├── utils.ts +│ │ └── types.ts +│ ├── next.config.ts +│ ├── package.json +│ └── tsconfig.json +│ +├── backend/ +│ ├── Cargo.toml +│ ├── gateway/ +│ │ ├── Cargo.toml +│ │ └── src/ +│ │ ├── main.rs +│ │ ├── routes/ +│ │ │ ├── mod.rs +│ │ │ ├── upload.rs +│ │ │ ├── job.rs +│ │ │ ├── download.rs +│ │ │ └── ws.rs +│ │ ├── nats/ +│ │ │ ├── mod.rs +│ │ │ └── publisher.rs +│ │ ├── redis/ +│ │ │ ├── mod.rs +│ │ │ ├── job.rs +│ │ │ └── ratelimit.rs +│ │ ├── metrics.rs +│ │ └── config.rs +│ │ +│ ├── workers/ +│ │ ├── Cargo.toml +│ │ └── src/ +│ │ ├── main.rs +│ │ ├── scanner/ +│ │ │ ├── mod.rs +│ │ │ ├── pipeline.rs +│ │ │ ├── edge.rs # Edge detection +│ │ │ ├── warp.rs # Perspective correction +│ │ │ ├── enhance.rs # Shadow removal, B&W, contrast +│ │ │ ├── ocr.rs # Tesseract wrapper +│ │ │ └── pdf.rs # Generate searchable PDF +│ │ ├── image/ +│ │ │ ├── mod.rs +│ │ │ ├── compress.rs +│ │ │ ├── resize.rs +│ │ │ ├── convert.rs +│ │ │ └── remove_bg.rs +│ │ ├── pdf/ +│ │ │ ├── mod.rs +│ │ │ ├── merge.rs +│ │ │ ├── split.rs +│ │ │ ├── extract.rs +│ │ │ └── compress.rs +│ │ ├── nats/ +│ │ │ ├── mod.rs +│ │ │ └── consumer.rs +│ │ └── config.rs +│ │ +│ └── common/ +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs +│ ├── types.rs # Shared types (JobStatus, Job, etc.) +│ ├── error.rs # Error types +│ └── nats.rs # NATS subject constants +│ +├── Dockerfile +├── compose.yml # Local dev compose +└── README.md +``` + +## API Design + +### Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/upload` | Upload file, create job | +| `GET` | `/api/job/:id` | Get job status + result metadata | +| `WS` | `/api/job/:id/ws` | WebSocket — realtime progress | +| `GET` | `/api/download/:id` | Download result file | +| `DELETE` | `/api/job/:id` | Cancel job, delete files | +| `GET` | `/health` | Health check | + +### Upload Request + +``` +POST /api/upload +Content-Type: multipart/form-data + +{ + file: , + tool: "scan" | "image-compress" | "image-resize" | "image-convert" | "remove-bg" | + "pdf-merge" | "pdf-split" | "images-to-pdf" | "pdf-compress", + options?: { // tool-specific options + quality?: 80, // compress quality + width?: 1920, // resize width + format?: "webp", // convert format + pages?: "1,3-5", // PDF split pages + dpi?: 300, // scan DPI + enhance?: true, // scan auto-enhance + ocr?: true // scan OCR + } +} +``` + +### Response (202 Accepted) + +```json +{ + "job_id": "uuid", + "status": "queued", + "tool": "scan", + "ws_url": "/api/job/uuid/ws", + "created_at": "2026-07-24T10:00:00Z", + "estimated_seconds": 5 +} +``` + +### WebSocket Messages + +```json +// Server → Client +{ + "type": "progress", + "job_id": "uuid", + "status": "processing", + "progress": 45, + "stage": "warp", + "message": "Meluruskan perspektif dokumen..." +} + +{ + "type": "complete", + "job_id": "uuid", + "status": "completed", + "progress": 100, + "result": { + "download_url": "/api/download/uuid", + "file_name": "scan_20260724.pdf", + "file_size": 1245678, + "pages": 1, + "ocr_text": "Nama: Asep...", + "preview_url": "/api/job/uuid/preview" + } +} + +{ + "type": "error", + "job_id": "uuid", + "status": "failed", + "error": "Edge detection failed: cannot find document boundary" +} +``` + +## Integration with Existing Portfolio + +| Area | Detail | +|------|--------| +| **Domain** | `tools.asepharyana.my.id` — tambah entry di `infra/traefik/dynamic/apps.yaml` | +| **Dashboard** | Link ke tools stats di dashboard hub yang sudah ada | +| **Docker Compose** | `infra/compose/tools.yml` — pola sama kaya `hub.yml` | +| **CI/CD** | Tambah service `tools` di `docker-build-push.yml` | +| **Style** | Ulang Twilight Terminal theme dari hub, konsisten visual branding | +| **Monitoring** | Reuse existing Prometheus + Grafana, tambah metrics tools | diff --git a/docs/plan/tools/implementation.md b/docs/plan/tools/implementation.md new file mode 100644 index 0000000..91447ea --- /dev/null +++ b/docs/plan/tools/implementation.md @@ -0,0 +1,322 @@ +# Implementation Plan + +## Phase Breakdown + +``` +Phase 1 — Foundation + Document Scanner MVP +├── Milestone 1.1: Rust backend skeleton (Gateway + Worker + NATS + Redis) +├── Milestone 1.2: Scanner pipeline core (edge → warp → enhance → B&W) +├── Milestone 1.3: Next.js frontend + upload + download +└── Milestone 1.4: OCR + searchable PDF + WebSocket progress + +Phase 2 — Scanner Complete + Image Tools +├── Milestone 2.1: Scanner fallback manual crop + PWA camera +├── Milestone 2.2: Image compress, resize, convert (WASM client-side) +├── Milestone 2.3: Background removal (ONNX) +└── Milestone 2.4: Batch processing + +Phase 3 — PDF Tools + Video/Audio +├── Milestone 3.1: PDF merge, split, images-to-pdf +├── Milestone 3.2: Video compress, extract audio +└── Milestone 3.3: Final polish + load testing +``` + +--- + +## Phase 1: Foundation + Document Scanner MVP + +### Milestone 1.1 — Rust Backend Skeleton + +**Goal**: Gateway + Worker bisa connected ke NATS + Redis, upload flow work. + +**Tasks**: + +| # | Task | Files | Detail | +|---|------|-------|--------| +| 1.1.1 | Init Rust workspace | `apps/tools/backend/Cargo.toml` | Workspace dengan 3 crate: `gateway`, `workers`, `common` | +| 1.1.2 | Common types | `common/src/types.rs` | `JobStatus`, `Job`, `Tool`, `ScanOptions`, `UploadResponse` | +| 1.1.3 | Common error | `common/src/error.rs` | `PipelineError`, `UploadError`, `NatsError` | +| 1.1.4 | NATS subjects | `common/src/nats.rs` | Constants untuk semua subject/stream | +| 1.1.5 | Gateway: config | `gateway/src/config.rs` | Env-based config (Redis URL, NATS URL, storage path) | +| 1.1.6 | Gateway: upload route | `gateway/src/routes/upload.rs` | Multipart upload, validasi, save ke temp, publish NATS | +| 1.1.7 | Gateway: job status | `gateway/src/routes/job.rs` | GET job status dari Redis | +| 1.1.8 | Gateway: download | `gateway/src/routes/download.rs` | Stream file dari storage | +| 1.1.9 | Gateway: NATS publish | `gateway/src/nats/publisher.rs` | Publish job + progress | +| 1.1.10 | Gateway: Redis job | `gateway/src/redis/job.rs` | CRUD job status di Redis | +| 1.1.11 | Gateway: main | `gateway/src/main.rs` | Axum app bootstrap + routes | +| 1.1.12 | Workers: main loop | `workers/src/main.rs` | NATS consumer, dispatch ke tool handler | +| 1.1.13 | Workers: NATS consumer | `workers/src/nats/consumer.rs` | Subscribe jobs queue, ack/nack | +| 1.1.14 | Worker: scanner stub | `workers/src/scanner/mod.rs` | Cuma menerima job, update progress, complete | + +**Acceptance**: `curl -X POST -F "file=@test.jpg" -F "tool=scan" http://localhost:3001/api/upload` → return job_id, setelah beberapa detik `GET /api/job/:id` return completed. + +**Effort**: ~3-4 hari + +--- + +### Milestone 1.2 — Scanner Pipeline Core + +**Goal**: Image processing pipeline bisa ngubah foto miring jadi lurus + bersih + hitam-putih. Belum termasuk OCR dan PDF. + +**Tasks**: + +| # | Task | Detail | Referensi | +|---|------|--------|-----------| +| 1.2.1 | Edge detection | Canny + morphological close + adaptive fallback | `pipeline.md` Stage 2-3 | +| 1.2.2 | Corner detection | Contour detection, largest rectangle filter, polygon approximation | `pipeline.md` Stage 3 | +| 1.2.3 | Perspective warp | DLT homography + backward mapping + bilinear interpolation | `pipeline.md` Stage 4 | +| 1.2.4 | Shadow removal | Adaptive illumination correction + Retinex | `pipeline.md` Stage 5 | +| 1.2.5 | Binarization | Sauvola local threshold + integral image optimization | `pipeline.md` Stage 6 | +| 1.2.6 | Deskew | Hough transform line detection + rotation | `pipeline.md` Stage 7 | +| 1.2.7 | Image enhancement | CLAHE + sharpen + contrast | `pipeline.md` Stage 5 | +| 1.2.8 | Pipeline assembly | All stages connected, progress callback per stage | `pipeline.md` Complete assembly | + +**Critical Algorithm**: Perspective warp via SVD untuk homography matrix. Butuh implementasi DLT algorithm atau pin `nalgebra` crate. + +```rust +// Pseudo untuk testing before optimization +// imageproc contour → corner detection → warp +let edges = robust_edge_detection(&gray); +let corners = find_document_corners(&edges)?; +let warped = perspective_warp(&original, corners); +let cleaned = remove_shadow(&warped); +let binary = binarize(&cleaned); +let final_img = deskew(&binary); +``` + +**Acceptance**: Image foto miring test → keluar hasil lurus bersih hitam-putih. + +**Effort**: ~5-7 hari (ini bagian paling susah) + +--- + +### Milestone 1.3 — Next.js Frontend + +**Goal**: User bisa upload foto, liat progress, download hasil. + +**Tasks**: + +| # | Task | Detail | +|---|------|--------| +| 1.3.1 | Init Next.js app | `bun create next-app` dengan Tailwind v4 + shadcn/ui | +| 1.3.2 | Landing page | Cards: Scan, Image, PDF — link ke masing-masing tool | +| 1.3.3 | Upload zone component | Drag & drop + file picker, validasi tipe/ukuran | +| 1.3.4 | Scanner page | `/scan` — upload area, tool options, progress bar | +| 1.3.5 | API upload route | Next.js API route → proxy ke Rust Gateway | +| 1.3.6 | Progress bar component | Animated bar + stage label dari WebSocket | +| 1.3.7 | Preview component | Before/after comparison slider | +| 1.3.8 | Result page | Preview + download button + file info | +| 1.3.9 | Error handling | Upload error, processing error, timeout | + +**Acceptance**: User upload foto → liat progress bar → download PDF. + +**Effort**: ~3-4 hari + +--- + +### Milestone 1.4 — OCR + Searchable PDF + +**Goal**: Output berupa PDF dengan hidden text layer — teks bisa di-copy, file bisa di-search. + +**Tasks**: + +| # | Task | Detail | +|---|------|--------| +| 1.4.1 | Install Tesseract data | Tambah `tessdata` di Docker image | +| 1.4.2 | OCR integration | `leptess` binding, set language, get text + word boxes | +| 1.4.3 | PDF generation | `lopdf` — page with image + invisible text layer | +| 1.4.4 | WebSocket progress | NATS consumer di Gateway → broadcast ke WS client | +| 1.4.5 | Auto-cleanup scheduler | NATS cron tiap 10 menit, hapus file expired >1 jam | +| 1.4.6 | Rate limiting | Redis sliding window per IP, per tool | + +**Acceptance**: Download PDF → buka di browser → teks bisa di-select + di-search. + +**Effort**: ~3-4 hari + +--- + +### Phase 1 Total: ~14-19 hari kerja + +--- + +## Phase 2: Scanner Complete + Image Tools + +### Milestone 2.1 — Scanner Fallback + PWA + +**Goal**: Scanner robust — kalau auto gagal, user bisa atur manual. Kamera langsung dari browser. + +**Tasks**: + +| # | Task | Detail | +|---|------|--------| +| 2.1.1 | Manual crop UI | Canvas: 4 draggable corners, background image | +| 2.1.2 | Fallback pipeline | Auto → gagal → manual → kirim corners ke worker | +| 2.1.3 | Camera capture | PWA: akses kamera via `getUserMedia`, capture frame | +| 2.1.4 | Auto-exposure helper | Tap to focus + exposure lock | +| 2.1.5 | Batch multi-page | Upload multiple photos → 1 PDF result | + +**Effort**: ~4-5 hari + +--- + +### Milestone 2.2 — Image Tools + +**Goal**: Compress, resize, convert image langsung di browser (WASM). + +**Tasks**: + +| # | Task | Detail | +|---|------|--------| +| 2.2.1 | WASM image crate | Compile `image` crate to WASM via `wasm-pack` | +| 2.2.2 | Compress UI | Slider kualitas %, preview perbandingan ukuran | +| 2.2.3 | Resize UI | Input dimensi, lock aspect ratio, preview | +| 2.2.4 | Convert UI | Pilih format output, preview | +| 2.2.5 | Client-side processing | Semua image tool jalan di browser — no upload needed | +| 2.2.6 | Fallback server-side | Kalau WASM gagal/browser tua → upload ke server worker | + +**WASM Strategy**: +```rust +// apps/tools/backend/wasm/src/lib.rs +use wasm_bindgen::prelude::*; +use image::{DynamicImage, ImageFormat}; +use std::io::Cursor; + +#[wasm_bindgen] +pub fn compress_jpeg(bytes: &[u8], quality: u8) -> Vec { + let img = image::load_from_memory(bytes).unwrap(); + let mut output = Cursor::new(Vec::new()); + img.write_to(&mut output, ImageFormat::Jpeg).unwrap(); + // quality compression via mozjpeg or custom + output.into_inner() +} +``` + +**Effort**: ~4-5 hari + +--- + +### Milestone 2.3 — Background Removal + +**Goal**: Hapus background foto pake AI model ONNX — jalan di Rust native. + +**Tasks**: + +| # | Task | Detail | +|---|------|--------| +| 2.3.1 | Download RMBG model | `rmbg-1.4.onnx` (atau model lebih kecil seperti `u2net`) | +| 2.3.2 | ONNX Runtime binding | `ort` crate — load model, run inference | +| 2.3.3 | Pre/post processing | Resize ke 1024x1024 → normalize → softmax → threshold | +| 2.3.4 | Mask application | Alpha channel: background transparent / warna solid | +| 2.3.5 | Image preview | Before/after dengan background removal | + +**Model Options**: +| Model | Size | Quality | Notes | +|-------|------|---------|-------| +| RMBG-1.4 | ~50MB | Excellent | BRIA, butuh license untuk commercial | +| U-2-Net | ~170MB | Good | Open source, lebih besar | +| MODNet | ~25MB | Good | Ringan, cepat | +| Dis_seg | ~8MB | Decent | Paling kecil, cocok untuk VPS | + +**Effort**: ~3-4 hari + +--- + +### Milestone 2.4 — Batch Processing + +**Goal**: Upload 10-20 foto sekaligus, diproses parallel, jadi 1 PDF. + +**Tasks**: + +| # | Task | Detail | +|---|------|--------| +| 2.4.1 | Batch upload UI | Drop zone accept multiple files, thumbnail list | +| 2.4.2 | Group job | 1 group job = N individual jobs, track per-item progress | +| 2.4.3 | Rayon parallel | Worker process multiple pages in parallel | +| 2.4.4 | PDF merger | `lopdf` merge multiple pages → 1 document | + +**Effort**: ~3-4 hari + +--- + +## Phase 3: PDF + Video/Audio Tools + +### Milestone 3.1 — PDF Tools + +| # | Task | Detail | +|---|------|--------| +| 3.1.1 | Merge PDF | Upload 2+ PDF, `lopdf` merge pages | +| 3.1.2 | Split PDF | Input pages "1-3,5,7-9", ekstrak + save jadi 1 file | +| 3.1.3 | Images to PDF | Upload images, sort order, jadi 1 PDF | +| 3.1.4 | PDF compress | Re-encode embedded images dengan kualitas lebih rendah | +| 3.1.5 | PDF to images | Tiap halaman → JPEG/PNG | + +**Effort**: ~4-5 hari + +--- + +### Milestone 3.2 — Video/Audio Tools + +| # | Task | Detail | +|---|------|--------| +| 3.2.1 | Video compress | FFmpeg binding (`ffmpeg-next`), turunin bitrate + resolusi | +| 3.2.2 | Extract audio | MP4 → MP3 via FFmpeg | +| 3.2.3 | Trim video | Start/end time → cut segment | +| 3.2.4 | GIF maker | Video segment → GIF, atur FPS + dimensi | +| 3.2.5 | Audio convert | Format conversion via FFmpeg | + +**Catatan**: Video processing heavy — butuh dedicated worker dengan resource lebih besar. Queue priority: video jobs ke stream terpisah dengan max 1 concurrent. + +**Effort**: ~5-7 hari + +--- + +### Milestone 3.3 — Final Polish + +| # | Task | Detail | +|---|------|--------| +| 3.3.1 | Theme integration | Twilight Terminal theme dari hub | +| 3.3.2 | Responsive design | Mobile-first, touch-friendly crop | +| 3.3.3 | Error monitoring | Error tracking, alert kalau pipeline gagal | +| 3.3.4 | Load testing | k6: simulasi concurrent users, measure P50/P95/P99 latency | +| 3.3.5 | Dashboard integration | Link dari hub dashboard → tools stats | + +**Effort**: ~3-4 hari + +--- + +## Timeline Summary + +``` +Minggu 1: Gateway + upload/download + edge detection + warp +Minggu 2: Enhance + binarization + deskew + Next.js frontend +Minggu 3: OCR + PDF + WebSocket + rate limit + cleanup + ─── MVP LAUNCH (Document Scanner ready) ─── +Minggu 4: Manual crop fallback + PWA camera + batch +Minggu 5: WASM image tools + compress/resize/convert +Minggu 6: Background removal (ONNX) + PDF tools + ─── V1 LAUNCH (Scanner + Image + PDF) ─── +Minggu 7: Video/audio tools + final polish +Minggu 8: Load testing + bug fixes + deployment +``` + +## Critical Path + +``` +Edge Detection ──▶ Corner Detection ──▶ Perspective Warp + │ │ + │ ┌────────┘ + │ ▼ + │ Shadow Removal ──▶ Binarization ──▶ Deskew + │ │ + │ ┌─────────────┘ + │ ▼ + │ OCR ──▶ PDF Gen ──▶ Output + │ + └───(Kalau gagal)─── Manual Crop ◀── Frontend Canvas +``` + +**Risks**: +1. Edge detection paling rentan gagal — pipeline harus graceful fallback ke manual crop +2. Homography SVD implementasi perlu numerik stabil — test dengan extreme perspective angles +3. OCR kualitas sangat tergantung pada binarization — Sauvola parameter perlu tuning +4. WASM image processing size besar (~2MB gzipped) — perlu code splitting + lazy load diff --git a/docs/plan/tools/infrastructure.md b/docs/plan/tools/infrastructure.md new file mode 100644 index 0000000..c4104cd --- /dev/null +++ b/docs/plan/tools/infrastructure.md @@ -0,0 +1,427 @@ +# Infrastructure & Deployment + +## Docker Image Architecture + +Project ini punya **satu Docker image** dengan multi-stage build. Backend Rust + Tesseract + ONNX model plus frontend Next.js. + +### Dockerfile Structure + +```dockerfile +# ============================================================ +# Stage 1: Build Rust Backend +# ============================================================ +FROM rust:1.85-slim-bookworm AS chef +RUN cargo install cargo-chef +WORKDIR /app + +FROM chef AS planner +COPY backend/ . +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json + +COPY backend/ . +RUN cargo build --release --bin gateway --bin workers + +# ============================================================ +# Stage 2: Build Next.js Frontend +# ============================================================ +FROM oven/bun:1.3 AS frontend-builder +WORKDIR /app +COPY frontend/package.json frontend/bun.lock ./ +RUN bun install --frozen-lockfile +COPY frontend/ . +RUN bun run build + +# ============================================================ +# Stage 3: Production Runtime +# ============================================================ +FROM debian:bookworm-slim AS runtime + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr \ + tesseract-ocr-eng \ + tesseract-ocr-ind \ + ca-certificates \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy Rust binaries +COPY --from=builder /app/target/release/gateway /app/gateway +COPY --from=builder /app/target/release/workers /app/workers + +# Copy Next.js build +COPY --from=frontend-builder /app/.next /app/.next +COPY --from=frontend-builder /app/public /app/public +COPY --from=frontend-builder /app/package.json /app/package.json +COPY --from=frontend-builder /app/node_modules /app/node_modules + +# Copy ONNX model (for background removal) +COPY models/ /app/models/ + +# Create temp storage directory +RUN mkdir -p /data/tools && chmod 1777 /data/tools + +# Environment +ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata +ENV TOOLS_STORAGE_PATH=/data/tools +ENV TOOLS_GATEWAY_PORT=3001 +ENV TOOLS_WORKER_CONCURRENCY=4 +ENV RUST_LOG=info + +# Expose port +EXPOSE 3001 + +# Run both gateway and workers via supervisor script +COPY scripts/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +CMD ["/app/entrypoint.sh"] +``` + +### Entrypoint Script + +```bash +#!/bin/bash +# Start Gateway (Axum HTTP server) +/app/gateway & +GATEWAY_PID=$! + +# Start Worker(s) +/app/workers & +WORKER_PID=$! + +# Handle graceful shutdown +trap "kill $GATEWAY_PID $WORKER_PID; exit 0" SIGINT SIGTERM + +# Wait for either process to exit +wait -n $GATEWAY_PID $WORKER_PID + +# If one exits, kill the other +kill $GATEWAY_PID $WORKER_PID 2>/dev/null +exit 1 +``` + +### Image Size Estimates + +| Component | Size | +|-----------|------| +| Rust binary (gateway) | ~8 MB | +| Rust binary (workers) | ~15 MB | +| Next.js build | ~10 MB | +| Tesseract + data | ~25 MB | +| ONNX model | ~50 MB | +| Base (Debian slim) | ~80 MB | +| **Total** | **~188 MB** | + +> ONNX model opsional — bisa di-download runtime daripada di-include di image. + +--- + +## Docker Compose + +```yaml +# infra/compose/tools.yml +services: + tools: + container_name: tools + image: ghcr.io/asepharyana/asepharyana-hub/tools:sha-xxxxxxx + restart: always + networks: + app-shared-net: + aliases: + - tools + env_file: + - ../../.env + environment: + - REDIS_URL=redis://redis:6379 + - NATS_URL=nats://nats:4222 + - TOOLS_STORAGE_PATH=/data/tools + - TOOLS_GATEWAY_PORT=3001 + - TOOLS_WORKER_CONCURRENCY=4 + - RUST_LOG=info + volumes: + - tools_data:/data/tools + ports: + - "3001:3001" + depends_on: + redis: + condition: service_started + nats: + condition: service_started + +volumes: + tools_data: + +networks: + app-shared-net: + name: app-shared-net + external: true +``` + +### Environment Variables (`../../.env`) + +```bash +# Tools +TOOLS_GATEWAY_PORT=3001 +TOOLS_WORKER_CONCURRENCY=4 +TOOLS_STORAGE_PATH=/data/tools +TOOLS_JOB_TTL_SECONDS=3600 +TOOLS_RATE_LIMIT_PER_MINUTE=30 +TOOLS_MAX_FILE_SIZE_MB=50 +TOOLS_OCR_LANG=eng+ind + +# Infra (reuse existing) +REDIS_URL=redis://redis:6379 +NATS_URL=nats://nats:4222 +``` + +--- + +## CI/CD Integration + +### Docker Build Workflow + +Tambah service `tools` di `.github/workflows/docker-build-push.yml`: + +```yaml +# Di job "changes" step "Detect changed services" +changed() { + printf '%s\n' "$CHANGED_FILES" | grep -Eq "$1" && echo true || echo false +} +echo "tools=$(changed '^(apps/tools(/|$)|\.github/workflows/docker-build-push\.yml$|infra/docker/tools\.Dockerfile$)')" >> "$GITHUB_OUTPUT" + +# Di job "build" step "Set matrix" +if [ "${{ steps.filter.outputs['tools'] == 'true' || steps.dispatch.outputs['tools'] == 'true' || github.event_name == 'workflow_dispatch' }}" == "true" ]; then + add_service "tools" "docker-tools" "apps/tools" +fi + +# Di job "build" step "Docker metadata" +case "$SVC_NAME" in + "tools") echo "dockerfile=infra/docker/tools.Dockerfile" >> $GITHUB_OUTPUT ;; +esac + +# Di job "update-manifest" +SERVICES["tools"]="tools.yml" +PATHS["tools"]="apps/tools" +``` + +### Deploy Workflow + +Tambah di `.github/workflows/deploy-docker.yml`: +```yaml +# Tidak perlu perubahan — deploy-docker.yml auto-detect compose file changes. +# Kalau compose/tools.yml berubah, service tools akan di-restart. +``` + +### Service Registration (update infra/traefik/dynamic/apps.yaml) + +```yaml +tools: + rule: 'Host(`tools.asepharyana.my.id`) || Host(`tools.asepharyana.web.id`)' + entryPoints: + - websecure + tls: {} + middlewares: + - common-chain@file + service: tools-service + +# ...di bagian services: +tools-service: + loadBalancer: + servers: + - url: 'http://tools:3001' +``` + +--- + +## Monitoring + +### Prometheus Metrics + +Tambahkan label Prometheus ke container tools: + +```yaml +# Di compose tools.yml +labels: + - 'prometheus.io/scrape=true' + - 'prometheus.io/port=3001' + - 'prometheus.io/path=/metrics' +``` + +### Dashboard Integration + +Tambah card di dashboard hub yang sudah ada: + +```tsx +// Di dashboard hub — tambah section "Tools Usage" +// Data dari /api/dashboard → Prometheus query: +// rate(tools_jobs_total[24h]) — jobs per tool per hari +// sum(increase(tools_jobs_total[7d])) — total jobs minggu ini +// tools_jobs_in_flight — current processing +``` + +--- + +## Storage Architecture + +### Temp Storage + +``` +/data/tools/ +├── upload/ # Uploaded files +│ └── {job_id}.{ext} +├── processing/ # Intermediate files (stage-by-stage) +│ └── {job_id}/ +│ ├── 00_original.png +│ ├── 01_grayscale.png +│ ├── 02_edges.png +│ ├── 03_warped.png +│ └── ... +└── output/ # Final output + └── {job_id}.pdf +``` + +### Cleanup Strategy + +| Mekanisme | Timing | +|-----------|--------| +| NATS cron job | Setiap 10 menit | +| Scan files >1 jam | `find /data/tools -mmin +60 -delete` | +| Redis job keys >1 jam | `SCAN 0 MATCH job:*` → TTL check → DEL | +| Storage low warning | Alert via Notification Hub (future) | + +--- + +## Resource Estimation (VPS orangevps) + +### Current Usage + +| Service | CPU | RAM | Disk | +|---------|-----|-----|------| +| Traefik | 0.1 | 50 MB | 10 MB | +| NATS | 0.05 | 30 MB | 10 MB | +| Redis | 0.05 | 10 MB | 5 MB | +| Dapr Placement | 0.02 | 20 MB | 5 MB | +| Scraper API | 0.1 | 30 MB | 50 MB | +| Hub | 0.05 | 120 MB | 200 MB | +| Jaeger | 0.1 | 200 MB | 500 MB | +| Prometheus | 0.1 | 150 MB | 1 GB | +| Node Exporter | 0.02 | 10 MB | 5 MB | +| OTel Collector | 0.05 | 50 MB | 10 MB | +| **Total Current** | **~0.64** | **~670 MB** | **~1.8 GB** | + +### Tools Addition + +| Resources | Estimate | Notes | +|-----------|----------|-------| +| CPU | +1.0 core (burst) | Pipeline processing berat di CPU. Scoring, warp, OCR semua CPU-bound. | +| RAM | +300 MB | Rust binary + image processing buffers + Tesseract + ONNX | +| Disk | +5 GB | Temp files, bisa lebih untuk batch processing. Butuh auto-cleanup ketat. | +| **Total After** | **~1.64 cores** | **~970 MB RAM** | **~6.8 GB disk** | + +> **Catatan**: Kalau VPS cuma punya 1-2 cores, processing akan antri. NATS queue handle ini. Untuk production, pastikan CPU ada >2 cores. + +### Scalability + +``` +VPS 1 core: + - Scanner: ~5-8 detik per page + - Concurrent: 1 job at a time + - Antrian: NATS queue buffer unlimited + +VPS 4+ core: + - Scanner: ~2-3 detik per page + - Concurrent: 4 jobs parallel (1 per worker) + - Rayon: parallel per-page dalam batch +``` + +--- + +## Security Considerations + +| Area | Mitigation | +|------|-----------| +| **Upload validation** | MIME type check (whitelist), magic bytes verification, max size 50MB | +| **Path traversal** | Job ID = UUID v4, no user-controlled filenames in storage | +| **Command injection** | No shell commands — semua processing via Rust crates, FFmpeg via crate binding | +| **Temporary files** | Auto-cleanup, random filenames, restricted permissions (0600) | +| **Rate limiting** | Redis sliding window: 30 requests/min/IP per tool, 429 response | +| **CORS** | Origin terbatas ke domain portfolio | +| **Resource exhaustion** | Max image dimension 8000px, max file count per batch 50, worker concurrency limit | +| **OCR data** | Tesseract data dari package manager, no user-trained models | +| **ONNX model** | Model dari source terpercaya, verify checksum | + +--- + +## Rollback Strategy + +1. **Image tag**: `tools:sha-` immutable — tinggal update compose file ke tag sebelumnya +2. **Data**: Files auto-expire dalam 1 jam — no persistent data migration needed +3. **Traefik**: Cukup restart, TLS certs ga berubah +4. **Monitor**: Prometheus metrics akan langsung show error rate spike + +--- + +## Development Setup (Local) + +Untuk development tanpa Docker: + +```bash +# Terminal 1: Redis + NATS +docker compose -f infra/compose/shared.yml -f infra/compose/nats.yml up -d + +# Terminal 2: Rust workers +cd apps/tools/backend +REDIS_URL=redis://localhost:6379 NATS_URL=nats://localhost:4222 \ +cargo run --bin workers + +# Terminal 3: Rust gateway +REDIS_URL=redis://localhost:6379 NATS_URL=nats://localhost:4222 \ +TOOLS_STORAGE_PATH=/tmp/tools \ +cargo run --bin gateway + +# Terminal 4: Next.js +cd apps/tools/frontend +bun dev --port 3002 +``` + +### Test Pipeline Locally (tanpa NATS/Redis) + +Untuk development pipeline image processing doang: + +```rust +// Di workers/src/scanner/pipeline.rs — test function +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_full_pipeline() { + let pipeline = ScanPipeline::default(); + let result = pipeline.process_sync( + "test_images/scan_miring.jpg", + ScanOptions { ocr: false, enhance: true } + ); + assert!(result.is_ok()); + assert!(result.unwrap().output_path.exists()); + } + + #[test] + fn test_edge_detection_variations() { + // Test dengan berbagai kondisi: kertas putih, background ramai, sudut ekstrim + for case in &["normal.jpg", "dark.jpg", "angle45.jpg", "shadow.jpg"] { + let img = image::open(format!("test_images/{}", case)).unwrap(); + let corners = detect_corners_with_fallback(&img.grayscale().into_luma8()); + assert!(corners.is_ok(), "Failed on: {}", case); + } + } +} +``` + +Test images kumpulin dari foto dokumen real di berbagai kondisi — ini penting buat tuning parameter. diff --git a/docs/plan/tools/pipeline.md b/docs/plan/tools/pipeline.md new file mode 100644 index 0000000..962f10b --- /dev/null +++ b/docs/plan/tools/pipeline.md @@ -0,0 +1,798 @@ +# Document Scanner — Processing Pipeline + +Ini adalah inti dari project. Pipeline mengubah foto dokumen HP jadi dokumen scan yang proper. Setiap tahap dibahas detail teknisnya. + +## Pipeline Overview + +``` +Input: Foto HP (JPEG/PNG/HEIC, 2-12MP) + │ + ▼ +┌──────────────────────────────────┐ +│ 1. Preprocess ──▶ resize + │ +│ konversi grayscale │ +└────────────────┬─────────────────┘ + │ + ▼ +┌──────────────────────────────────┐ +│ 2. Edge Detection ──▶ cari │ +│ kontur dokumen │ +└────────────────┬─────────────────┘ + │ + ▼ +┌──────────────────────────────────┐ +│ 3. Corner Detection ──▶ 4 titik │ +│ sudut dokumen │ +└────────────────┬─────────────────┘ + │ + ▼ +┌──────────────────────────────────┐ +│ 4. Perspective Warp ──▶ lurusin│ +│ (homography) │ +└────────────────┬─────────────────┘ + │ + ▼ +┌──────────────────────────────────┐ +│ 5. Shadow Removal ──▶ iluminasi │ +│ merata │ +└────────────────┬─────────────────┘ + │ + ▼ +┌──────────────────────────────────┐ +│ 6. Binarization ──▶ hitam-putih │ +│ bersih │ +└────────────────┬─────────────────┘ + │ + ▼ +┌──────────────────────────────────┐ +│ 7. Deskew ──▶ lurusin teks │ +│ (kalau masih miring) │ +└────────────────┬─────────────────┘ + │ + ▼ +┌──────────────────────────────────┐ +│ 8. OCR ──▶ extract teks │ +└────────────────┬─────────────────┘ + │ + ▼ +┌──────────────────────────────────┐ +│ 9. Generate PDF ──▶ output │ +│ PDF + hidden text layer │ +└────────────────┬─────────────────┘ + │ + ▼ +Output: searchable PDF + teks OCR +``` + +--- + +## Stage 1: Preprocess + +### Input +- Raw image dari HP (bisa 4000×3000 = 12MP, ~3-5MB JPEG) +- Format: JPEG, PNG, HEIC (via `image` crate, HEIC butuh feature) + +### Proses +```rust +use image::{DynamicImage, imageops}; + +fn preprocess(img: &DynamicImage) -> DynamicImage { + // 1. Resize kalau terlalu besar → max 2000px di sisi terpanjang + // Ini penting: edge detection di resolusi tinggi lambat + // dan ga nambah akurasi secara signifikan + let max_dim = 2000.0; + let (w, h) = (img.width() as f64, img.height() as f64); + let img = if w.max(h) > max_dim { + let scale = max_dim / w.max(h); + let new_w = (w * scale) as u32; + let new_h = (h * scale) as u32; + img.resize_exact(new_w, new_h, imageops::FilterType::Lanczos3) + } else { + img.clone() + }; + + // 2. Grayscale → untuk edge detection + img.grayscale() +} +``` + +### Edge Cases +| Kasus | Penanganan | +|-------|-----------| +| Foto resolusi rendah (<800px) | Skip resize, langsung proses | +| HEIC format | Butuh feature `heic` di `image` crate | +| Grayscale input | `img.grayscale()` no-op | +| Foto malam/noise tinggi | Gaussian blur sebelum edge detection | + +--- + +## Stage 2: Edge Detection + +### Tujuan +Cari tepi dokumen dalam foto. Ini hardest part karena background bisa kacau. + +### Algoritma: Canny Edge Detection + Adaptive Threshold + +```rust +use image::GrayImage; +use imageproc::edges::canny; + +fn detect_edges(img: &GrayImage) -> GrayImage { + // Canny dengan dual threshold + // low: 50, high: 150 — parameter ini harus di-tune + // buat kondisi pencahayaan yang berbeda + canny(img, 50.0, 150.0) +} +``` + +### Masalah & Solusi + +| Masalah | Penyebab | Solusi | +|---------|----------|--------| +| **Tepi dokumen putus** | Kontras rendah, bayangan | Morphological close (dilate → erode) untuk sambungin tepi | +| **Tepi palsu** | Background ramai (meja motif, lantai) | Cari contour terbesar + area terluas = dokumen | +| **Tidak ada tepi** | Background putih, dokumen putih (kertas di meja putih) | Adaptive threshold dulu sebelum Canny, atau fallback ke manual crop | +| **Noise garis** | Texture background | Gaussian blur (kernel 5x5) sebelum Canny | + +### Implementation Detail + +```rust +/// Edge detection yang robust terhadap berbagai kondisi +fn robust_edge_detection(img: &GrayImage) -> GrayImage { + // 1. Gaussian blur untuk noise reduction + let blurred = imageproc::filter::gaussian_blur_f32(img, 3.0); + + // 2. Coba Canny standard + let edges = canny(&blurred, 50.0, 150.0); + + // 3. Morphological close untuk sambung tepi yang putus + let kernel = imageproc::morphology::dilate_square(5); + let closed = imageproc::morphology::close(&edges, &kernel); + + // 4. Kalau jumlah tepi terlalu sedikit (<1% pixels), + // ulang dengan threshold lebih rendah + let edge_count = count_non_zero(&closed); + let total_pixels = (closed.width() * closed.height()) as u32; + if edge_count < total_pixels / 100 { + let edges2 = canny(&blurred, 20.0, 80.0); + return imageproc::morphology::close(&edges2, &kernel); + } + + closed +} +``` + +--- + +## Stage 3: Corner Detection + +### Tujuan +Dari edge image, cari 4 sudut dokumen. + +### Algoritma: Contour Detection → Largest Rectangle + +```rust +use imageproc::contours::{find_contours, Contour}; + +fn find_document_corners(edges: &GrayImage) -> Option<[(f64, f64); 4]> { + // 1. Cari semua contours + let contours = find_contours(edges); + + // 2. Filter: cuma contour dengan area > 20% dari total image + // (dokumen biasanya mengisi sebagian besar frame) + let total_area = edges.width() as f64 * edges.height() as f64; + let docs: Vec<&Contour> = contours + .iter() + .filter(|c| area_perimeter_ratio(c) > 0.3) + .collect(); + + // 3. Approximate polygon → cari yang 4 sisi + for contour in docs { + // Approximate contour ke polygon + let polygon = approximate_polygon(&contour.points, 4); + if let Some(vertices) = polygon { + // Urutkan: top-left, top-right, bottom-right, bottom-left + let corners = order_corners(vertices); + return Some(corners); + } + } + + // 4. Fallback: contour terbesar → bounding rect + contours.iter() + .max_by_key(|c| c.points.len()) + .map(|c| { + let rect = bounding_rect(&c.points); + order_corners(vec![ + (rect.left as f64, rect.top as f64), + (rect.right as f64, rect.top as f64), + (rect.right as f64, rect.bottom as f64), + (rect.left as f64, rect.bottom as f64), + ]) + }) +} +``` + +### Corner Ordering Convention + +``` +(0,0) top-left ────────── top-right (w,0) + │ │ + │ DOKUMEN │ + │ │ +(0,h) bottom-left ────── bottom-right (w,h) +``` + +### Fallback Strategy + +Kalau auto-detect gagal total (contour tidak ketemu, confidence rendah): +1. **Fallback 1**: Coba di resolusi lebih rendah (noise berkurang) +2. **Fallback 2**: Coba adaptive threshold + Canny ulang +3. **Fallback 3**: Minta user crop manual — 4 draggable corners di canvas + +```rust +fn detect_corners_with_fallback(img: &GrayImage) -> Result<[(f64, f64); 4], CropMode> { + // Attempt 1: Resolusi penuh + if let Some(corners) = find_document_corners(img) { + return Ok(corners); + } + + // Attempt 2: Half resolution (noise reduction) + let half = image::imageops::resize(img, img.width() / 2, img.height() / 2, + imageops::FilterType::Lanczos3); + if let Some(corners) = find_document_corners(&half) { + return Ok(corners.map(|(x, y)| (x * 2.0, y * 2.0))); + } + + // Fallback: user manual + Err(CropMode::Manual) +} +``` + +--- + +## Stage 4: Perspective Warp + +### Tujuan +Transform 4 titik sudut ke persegi panjang (rectangular). Koreksi perspektif dari foto miring. + +### Algoritma: Homography + +```rust +use image::{DynamicImage, GrayImage}; +use std::f64::consts::PI; + +fn perspective_warp(img: &DynamicImage, corners: [(f64, f64); 4]) -> DynamicImage { + // Target: persegi panjang dengan aspect ratio dokumen + // Hitung lebar dan tinggi target dari 4 corner + let [tl, tr, br, bl] = corners; + + let width_top = distance(tl, tr); + let width_bot = distance(bl, br); + let width = width_top.max(width_bot).ceil() as u32; + + let height_left = distance(tl, bl); + let height_right = distance(tr, br); + let height = height_left.max(height_right).ceil() as u32; + + // Source points (4 corners dari detection) + let src = [ + tl, // top-left + tr, // top-right + br, // bottom-right + bl, // bottom-left + ]; + + // Destination points (rectangle) + let dst = [ + (0.0, 0.0), // top-left + (width as f64, 0.0), // top-right + (width as f64, height as f64), // bottom-right + (0.0, height as f64), // bottom-left + ]; + + // Hitung homography matrix + let h = compute_homography(&src, &dst); + + // Apply warp (backward mapping + bilinear interpolation) + warp_image(img, &h, width, height) +} +``` + +### Homography Matrix + +``` +H = [h11 h12 h13] x' = (h11*x + h12*y + h13) / (h31*x + h32*y + 1) + [h21 h22 h23] y' = (h21*x + h22*y + h23) / (h31*x + h32*y + 1) + [h31 h32 1 ] +``` + +Komputasi manual (tanpa OpenCV): +```rust +/// Compute homography from 4 point correspondences using DLT algorithm +fn compute_homography(src: &[(f64, f64); 4], dst: &[(f64, f64); 4]) -> [[f64; 3]; 3] { + // Direct Linear Transform + // Bangun matrix A (8x9) dari 4 titik + // Solve Ah = 0 via SVD → h = last column of V + // Reshape ke 3x3 + // + // Detail implementasi: + // Setiap titik correspondence (x,y) → (x',y') menghasilkan 2 baris: + // [-x, -y, -1, 0, 0, 0, x*x', y*x', x'] = 0 + // [ 0, 0, 0, -x, -y, -1, x*y', y*y', y'] = 0 + // + // 4 titik → 8 baris → SVD → H matrix + + // Implementasi SVD atau pakai crate `nalgebra` atau `splines` + todo!("Implement DLT + SVD") +} +``` + +### Image Warp (Backward Mapping) + +```rust +fn warp_image(img: &DynamicImage, h: &[[f64; 3]; 3], width: u32, height: u32) -> DynamicImage { + let gray = img.grayscale().into_luma8(); + let mut output = GrayImage::new(width, height); + + // Inverse homography (backward mapping) + // tiap pixel output = sample dari input + let h_inv = invert_homography(h); + + for y in 0..height { + for x in 0..width { + // Map (x,y) → source image coordinates + let (sx, sy) = apply_homography(&h_inv, x as f64, y as f64); + + // Bilinear interpolation + let pixel = bilinear_interpolate(&gray, sx, sy); + output.put_pixel(x, y, pixel); + } + } + + DynamicImage::ImageLuma8(output) +} +``` + +### Edge Cases + +| Masalah | Solusi | +|---------|--------| +| Dokuen sangat miring (>60°) | Warping mungkin hasilnya gepeng. Deteksi dan skip kalau sudut terlalu ekstrim | +| Output sangat besar | Clamp width/height ke max 3000px | +| Pixel jaggy (aliasing) | Bilinear interpolation (bukan nearest neighbor) | +| Koordinat negative | Clamp ke 0 | +| Warp membuat rasio aneh | Lock aspect ratio ke common (A4=1.414, Letter=1.294) | + +--- + +## Stage 5: Shadow Removal + +### Tujuan +Hilangkan bayangan (dari lampu, jari, atau sudut ruangan). + +### Algoritma: Adaptive Illumination Correction + +Shadow adalah low-frequency variation. Teks adalah high-frequency. Pisahkan pake low-pass filter. + +```rust +fn remove_shadow(img: &GrayImage) -> GrayImage { + let (w, h) = (img.width(), img.height()); + + // 1. Large Gaussian blur untuk estimasi iluminasi background + // Kernel besar (≥sx/50) → cuma dapet variasi iluminasi, bukan teks + let blur_radius = (w.min(h) as f64 / 50.0).max(15.0); + let background = imageproc::filter::gaussian_blur_f32(img, blur_radius); + + // 2. Subtract background dari original + // pixel = max(0, original - background + mean(background)) + let bg_mean = mean_pixel(&background); + let mut corrected = GrayImage::new(w, h); + + for y in 0..h { + for x in 0..w { + let orig = img.get_pixel(x, y)[0] as f32; + let bg = background.get_pixel(x, y)[0] as f32; + let corrected_val = (orig - bg + bg_mean) as u8; + corrected.put_pixel(x, y, Luma([corrected_val])); + } + } + + // 3. CLAHE (Contrast Limited Adaptive Histogram Equalization) + // untuk normalisasi kontras lokal + apply_clahe(&corrected, 8, 4) // 8x8 tiles, clip limit 4 +} +``` + +### Alternatif: Retinex Theory + +```rust +/// Retinex-based illumination correction +/// I(x,y) = R(x,y) × L(x,y) +/// I = observed image, R = reflectance (teks), L = illumination (shadow) +fn retinex_shadow_removal(img: &GrayImage) -> GrayImage { + // Single-scale Retinex + // log(R) = log(I) - log(G * I) + // dimana G = Gaussian kernel + + let float_img = convert_to_float(img); + let blurred = gaussian_blur_float(&float_img, 30.0); + let retinex = element_wise(|p| (p.0.ln() - p.1.ln()), &float_img, &blurred); + + // Normalize ke [0, 255] + normalize_to_u8(&retinex) +} +``` + +--- + +## Stage 6: Binarization + +### Tujuan +Ubah ke hitam-putih bersih — teks hitam, background putih. + +### Algoritma: Sauvola Local Threshold + +Global threshold (Otsu) gagal kalau iluminasi ga merata. Sauvola adaptif per region. + +```rust +fn sauvola_threshold(img: &GrayImage, window_size: u32, k: f32) -> GrayImage { + // Sauvola: T(x,y) = m(x,y) * [1 + k * (s(x,y)/R - 1)] + // m = local mean, s = local std dev, R = max std dev (128), k = parameter (~0.2) + + let (w, h) = (img.width(), img.height()); + let half_win = (window_size / 2) as i32; + let mut output = GrayImage::new(w, h); + + // Integral image for O(1) mean and variance computation + let integral = compute_integral_image(img); + let integral_sq = compute_integral_image_sq(img); + + for y in 0..h { + for x in 0..w { + let (mean, variance) = local_stats(&integral, &integral_sq, + x as i32, y as i32, + half_win, w as i32, h as i32); + let std_dev = variance.sqrt(); + let threshold = mean * (1.0 + k * (std_dev / 128.0 - 1.0)); + + let pixel = img.get_pixel(x, y)[0] as f32; + output.put_pixel(x, y, Luma([if pixel > threshold { 255 } else { 0 }])); + } + } + + output +} +``` + +### Parameter Default + +| Parameter | Value | Notes | +|-----------|-------|-------| +| Window size | max(w,h)/30 | Minimum 15, maksimum 100 | +| k | 0.2 | Lower → lebih sensitif, higher → lebih toleran | + +### Edge Cases + +| Masalah | Solusi | +|---------|--------| +| Dokumen berwarna (bukan putih) | Deteksi warna dominan background, invert logic | +| Background gradasi | Sauvola handle ini lebih baik dari Otsu | +| Foto terlalu gelap | CLAHE dulu sebelum binarization | +| Text tipis/kabur | Morphological erode tipis sesudah binarization | + +--- + +## Stage 7: Deskew + +### Tujuan +Koreksi rotasi sisa (kalau dokumen masih miring sedikit — biasanya <5°). + +### Algoritma: Hough Transform + +```rust +fn deskew(img: &GrayImage) -> GrayImage { + // 1. Cari garis teks via Hough transform + // Probabilistic Hough lebih cepat + let lines = probabilistic_hough_lines(img, 10, PI / 180.0, 50, 50.0, 10.0); + + if lines.is_empty() { + return img.clone(); + } + + // 2. Hitung sudut rata-rata semua garis + let angles: Vec = lines.iter() + .map(|line| line.angle().to_degrees()) + .filter(|a| a.abs() < 45.0) // skip garis vertikal + .collect(); + + if angles.is_empty() { + return img.clone(); + } + + let median_angle = median(&angles); + + // Skip kalau sudutnya <0.5 derajat (ga perlu koreksi) + if median_angle.abs() < 0.5 { + return img.clone(); + } + + // 3. Rotate image + rotate(img, median_angle, imageops::FilterType::Lanczos3) +} +``` + +--- + +## Stage 8: OCR + +### Tujuan +Extract teks dari gambar biar PDF-nya searchable dan teks bisa di-copy. + +### Implementation + +```rust +use leptess::LepTess; + +fn ocr(img: &GrayImage, lang: &str) -> Result { + // 1. Init Tesseract + let mut tess = LepTess::new(Some("/usr/share/tesseract/tessdata"), lang)?; + + // 2. Set image + tess.set_image_from_mem(&img.to_bytes())?; + // 3. Set PSM (Page Segmentation Mode) + // PSM 3 = Fully automatic, default + // PSM 6 = Assume single uniform block of text + // PSM 4 = Assume single column of text + tess.set_source_resolution(300); + + // 4. Recognize + let text = tess.get_utf8_text()?; + + Ok(text) +} + +/// Dapatkan word-level bounding boxes untuk positioning di PDF +fn ocr_words(img: &GrayImage, lang: &str) -> Result, OcrError> { + let mut tess = LepTess::new(Some("/usr/share/tesseract/tessdata"), lang)?; + tess.set_image_from_mem(&img.to_bytes())?; + + let words = tess.get_words() + .iter() + .map(|w| Word { + text: w.text.clone(), + bbox: Bbox { + x: w.x, + y: w.y, + width: w.w, + height: w.h, + }, + confidence: w.confidence, + }) + .collect(); + + Ok(words) +} +``` + +### Output Format + +```rust +struct Word { + text: String, + bbox: Bbox, + confidence: i32, // 0-100 +} +``` + +--- + +## Stage 9: PDF Generation + +### Tujuan +Generate PDF yang: +1. Berisi gambar hasil scan (JPEG compressed) +2. Hidden text layer dari OCR (biar searchable, selectable) + +### Implementation + +```rust +use lopdf::{Document, Object, Stream}; +use std::io::Write; + +fn generate_searchable_pdf( + image_data: &[u8], // JPEG-compressed scan image + ocr_text: &str, // Full OCR text + words: &[Word], // Word positions + page_width: f64, // PDF page width in points + page_height: f64, // PDF page height in points +) -> Result, PdfError> { + let mut doc = Document::new(); + + // 1. Create image XObject + let image_stream = Stream::new( + dictionary! { + "Type" => "XObject", + "Subtype" => "Image", + "Width" => page_width as u32, + "Height" => page_height as u32, + "ColorSpace" => "DeviceGray", + "BitsPerComponent" => 8, + "Filter" => "DCTDecode", // JPEG compression + }, + image_data, + ); + let image_id = doc.add_object(image_stream); + + // 2. Create content stream: place image, then invisible text + // Text layer is invisible (rendering mode 3 = neither fill nor stroke) + let mut content = Vec::new(); + writeln!(content, "q")?; // save state + writeln!(content, "{} 0 0 {} 0 0 cm", page_width, page_height)?; // scale to page + writeln!(content, "/Im0 Do")?; // place image + writeln!(content, "Q")?; // restore state + + // 3. Add invisible text layer (searchable) + for word in words { + let x = word.bbox.x as f64 / DPI * 72.0; // convert pixels → points + let y = (page_height - word.bbox.y as f64 / DPI * 72.0); + writeln!(content, "BT")?; + writeln!(content, "3 Tr")?; // rendering mode: invisible + writeln!(content, "1 Tw")?; // word spacing + writeln!(content, "{} {} Td", x, y)?; // position + writeln!(content, "({}) Tj", escape_pdf_string(&word.text))?; + writeln!(content, "ET")?; + } + + let content_stream = Stream::new( + dictionary! {}, + content, + ); + let content_id = doc.add_object(content_stream); + + // 4. Create page + let page_id = doc.new_object_id(); + let pages_id = doc.new_object_id(); + + doc.objects.insert(page_id, Object::Dictionary(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => vec![0.0, 0.0, page_width, page_height], + "Contents" => content_id, + "Resources" => dictionary! { + "XObject" => dictionary! { + "Im0" => image_id, + }, + }, + })); + + // 5. Close and return bytes + let bytes = doc.save_to_bytes()?; + Ok(bytes) +} +``` + +### PDF Coordinate System + +``` +PDF origin = bottom-left +Image origin = top-left + +Perlu flip Y coordinate untuk text layer: +y_pdf = page_height - (y_image / dpi * 72) +``` + +--- + +## Complete Pipeline Assembly + +```rust +pub struct ScanPipeline { + config: PipelineConfig, + metrics: MetricsRecorder, +} + +impl ScanPipeline { + pub async fn process(&self, input_path: &Path, options: ScanOptions) + -> Result + { + let timer = self.metrics.start_timer("scan.full"); + + // 1. Load + let img = image::open(input_path) + .map_err(PipelineError::ImageLoad)?; + self.metrics.stage_duration("load", timer.split()); + + // 2. Preprocess + let gray = preprocess(&img); + self.metrics.stage_duration("preprocess", timer.split()); + + // 3. Edge detection + corners (fallback chain) + let corners = detect_corners_with_fallback(&gray) + .map_err(PipelineError::CornerDetection)?; + self.metrics.stage_duration("corner_detection", timer.split()); + + // 4. Perspective warp + let warped = perspective_warp(&img, corners); // warp from COLOR original, not gray + self.metrics.stage_duration("warp", timer.split()); + + let warped_gray = warped.grayscale().into_luma8(); + + // 5. Shadow removal + let clean = remove_shadow(&warped_gray); + self.metrics.stage_duration("shadow_removal", timer.split()); + + // 6. Binarization + let binary = sauvola_threshold(&clean, 50, 0.2); + self.metrics.stage_duration("binarization", timer.split()); + + // 7. Deskew + let final_image = deskew(&binary); + self.metrics.stage_duration("deskew", timer.split()); + + // 8. Enhance final (sharpening) + let final_image = sharpen(&final_image, 1.0); + self.metrics.stage_duration("sharpen", timer.split()); + + // 9. OCR + let ocr_text = if options.ocr { + Some(ocr(&final_image, "eng")?) + } else { + None + }; + self.metrics.stage_duration("ocr", timer.split()); + + // 10. Generate PDF + let pdf_bytes = generate_searchable_pdf( + &compress_jpeg(&final_image, 90)?, + &ocr_text.unwrap_or_default(), + &[], // word positions (simplified) + A4_WIDTH_PT, + A4_HEIGHT_PT, + )?; + self.metrics.stage_duration("pdf_generation", timer.split()); + + // 11. Save + let output_path = PathBuf::from("/tmp/tools").join(format!("{}.pdf", uuid::Uuid::new_v4())); + std::fs::write(&output_path, &pdf_bytes)?; + + timer.finish(); + + Ok(ScanResult { + output_path, + page_count: 1, + file_size: pdf_bytes.len() as u64, + ocr_text, + }) + } +} +``` + +## Performance Budget + +| Stage | Target | Notes | +|-------|--------|-------| +| Load + Preprocess | <200ms | File I/O + resize | +| Edge + Corner Detection | <500ms | Canny + contour | +| Perspective Warp | <800ms | Per-pixel backward mapping | +| Shadow Removal | <300ms | FFT convolution atau integral image | +| Binarization | <200ms | Integral image | +| Deskew | <300ms | Hough transform | +| OCR | <1.5s | Tesseract, 300dpi | +| PDF Generation | <200ms | lopdf | +| **Total** | **<4s** | Per page | + +> **Catatan**: Target di atas untuk image 12MP (4000×3000). Parallel via Rayon untuk batch processing. + +## Edge Cases Matrix + +| Skenario | Pipeline Behavior | +|----------|------------------| +| Kertas putih di meja putih | Edge detection gagal → fallback ke manual crop | +| Foto dari sudut 45° | Warp koreksi perspektif, output presisi | +| Dokumen terlipat | Edge detection dapet bentuk aneh → fallback manual | +| Bayangan jari | Shadow removal hilangkan | +| Teks pudar/pensil | Sauvola threshold adaptif, contrast enhance dulu | +| Tanda tangan & stempel | OCR bisa gagal di handwriting, tetap di-image | +| Multi-page (buku/kontrak) | Batch upload, masing-masing diproses, digabung 1 PDF | +| Foto malam | CLAHE + strong denoise sebelum edge detection | +| Latar belakang gradasi | Sauvola handle lebih baik dari Otsu |