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:
@@ -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)
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
Reference in New Issue
Block a user