feat: add infrastructure documentation and processing pipeline for document scanner

- Introduced a comprehensive Docker image architecture for the project, detailing multi-stage builds for Rust backend and Next.js frontend.
- Added Docker Compose configuration for the tools service, including environment variables and volume management.
- Documented CI/CD integration steps for Docker build and deployment workflows.
- Implemented a detailed processing pipeline for document scanning, covering stages from image preprocessing to PDF generation.
- Included edge case handling and performance budget for each stage of the pipeline.
- Enhanced security considerations and rollback strategies for the tools service.
This commit is contained in:
asepharyana
2026-07-24 10:19:03 +07:00
parent 447b22d9da
commit 68dea35d73
5 changed files with 2058 additions and 0 deletions
+60
View File
@@ -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)
+451
View File
@@ -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: <binary>,
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 |
+322
View File
@@ -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<u8> {
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
+427
View File
@@ -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-<short>` 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.
+798
View File
@@ -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<f64> = 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<String, OcrError> {
// 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<Vec<Word>, 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<Vec<u8>, 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<ScanResult, PipelineError>
{
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 |