diff --git a/backend/go.mod b/backend/go.mod index b35dbad..1a2b5df 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,6 +24,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/backend/go.sum b/backend/go.sum index 01186f6..ff02780 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -45,6 +45,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8= +github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/backend/internal/ocr/client.go b/backend/internal/ocr/client.go index 1fdb66b..cecc344 100644 --- a/backend/internal/ocr/client.go +++ b/backend/internal/ocr/client.go @@ -8,6 +8,8 @@ import ( "io" "net/http" "time" + + "github.com/ledongthuc/pdf" ) type Client struct { @@ -25,6 +27,18 @@ func NewClient(endpoint string) *Client { } func (c *Client) Recognize(imageData []byte) ([]TextBlock, error) { + + docType := DetectDocumentType(imageData) + + switch docType { + case PDFScanned: + fmt.Println("PDFScanned -> PaddleOCR") + case Image: + fmt.Println("Image -> PaddleOCR") + default: + return []TextBlock{}, fmt.Errorf("DOCTYPE not supported => %s", docType) + } + b64 := base64.StdEncoding.EncodeToString(imageData) reqBody, err := json.Marshal(OCRRequest{Image: b64}) @@ -63,6 +77,59 @@ func (c *Client) Recognize(imageData []byte) ([]TextBlock, error) { return flattenResults(ocrResp.Result), nil } +type DocType string + +const ( + Image DocType = "image" + PDFText DocType = "pdf_text" + PDFScanned DocType = "pdf_scanned" + Unknown DocType = "unknown" +) + +func isPDFText(data []byte) bool { + reader := bytes.NewReader(data) + + r, err := pdf.NewReader(reader, int64(len(data))) + if err != nil { + return false + } + + for i := 1; i <= r.NumPage(); i++ { + page := r.Page(i) + if page.V.IsNull() { + continue + } + + text, _ := page.GetPlainText(nil) + if len(text) > 20 { + return true + } + } + + return false +} + +func DetectDocumentType(data []byte) DocType { + mime := http.DetectContentType(data) + + switch { + case mime == "application/pdf": + if isPDFText(data) { + return PDFText + } + return PDFScanned + + case bytes.HasPrefix(data, []byte{0xFF, 0xD8}): // JPEG + return Image + + case bytes.HasPrefix(data, []byte{0x89, 0x50, 0x4E, 0x47}): // PNG + return Image + + default: + return Unknown + } +} + func (c *Client) HealthCheck() error { resp, err := c.httpClient.Get(c.endpoint + "/health") if err != nil { diff --git a/backend/internal/service/presignedurl.go b/backend/internal/service/presignedurl.go index d20d5e8..b14fd6c 100644 --- a/backend/internal/service/presignedurl.go +++ b/backend/internal/service/presignedurl.go @@ -22,7 +22,7 @@ func GenerateFileDownloadUrl(fileID string) string { sig := sign(fileID, expires, secret) url := fmt.Sprintf( - "http://192.168.1.142:8080/api/v1/files/%s?expires=%d&sig=%s", + "http://192.168.1.17:8080/api/v1/files/%s?expires=%d&sig=%s", fileID, expires, sig, diff --git a/backend/ocr-server/Dockerfile b/backend/ocr-server/Dockerfile new file mode 100644 index 0000000..a3c2fe8 --- /dev/null +++ b/backend/ocr-server/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.10-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libglib2.0-0 libgl1 libgomp1 curl \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir \ + paddlepaddle==3.0.0 \ + paddleocr==3.3.3 \ + paddlex==3.3.13 \ + fastapi \ + uvicorn \ + python-multipart \ + Pillow \ + numpy + +COPY server.py /workspace/server.py + +WORKDIR /workspace + +EXPOSE 8080 + +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/backend/ocr-server/server.py b/backend/ocr-server/server.py new file mode 100644 index 0000000..ffa09b4 --- /dev/null +++ b/backend/ocr-server/server.py @@ -0,0 +1,96 @@ +import os +import base64 +import logging +from io import BytesIO + +import numpy as np +from fastapi import FastAPI, UploadFile, File +from fastapi.responses import JSONResponse +from paddleocr import PaddleOCR +from pydantic import BaseModel +from PIL import Image + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("paddleocr-server") + +OCR_LANG = os.getenv("OCR_LANG", "fr") + +print(f"[INIT] Initializing PaddleOCR (lang={OCR_LANG})...", flush=True) +ocr_engine = PaddleOCR( + use_doc_orientation_classify=False, + use_doc_unwarping=False, + use_textline_orientation=False, + lang=OCR_LANG, +) +print("[INIT] PaddleOCR ready.", flush=True) + +app = FastAPI() + + +class OCRRequest(BaseModel): + image: str + + +@app.get("/health") +def health(): + return {"status": "healthy", "service": "PaddleOCR Server"} + + +@app.post("/ocr") +def ocr_json(req: OCRRequest): + print(f"[OCR] Request received, image field length: {len(req.image)}", flush=True) + try: + img_bytes = base64.b64decode(req.image) + except Exception as e: + print(f"[OCR] Base64 decode failed: {e}", flush=True) + return JSONResponse( + status_code=400, + content={"errorCode": 1, "message": "invalid base64 image"}, + ) + print(f"[OCR] Decoded {len(img_bytes)} bytes, header: {img_bytes[:32].hex()}", flush=True) + return run_ocr(img_bytes) + + +@app.post("/ocr/upload") +def ocr_upload(file: UploadFile = File(...)): + img_bytes = file.file.read() + print(f"[OCR] Upload received, {len(img_bytes)} bytes, header: {img_bytes[:32].hex()}", flush=True) + return run_ocr(img_bytes) + + +def run_ocr(img_bytes: bytes): + try: + image = Image.open(BytesIO(img_bytes)) + if image.mode != "RGB": + image = image.convert("RGB") + img_array = np.array(image) + print(f"[OCR] Image decoded: {img_array.shape}", flush=True) + except Exception as e: + print(f"[OCR] Image decode FAILED: {e}", flush=True) + return JSONResponse( + status_code=400, + content={"errorCode": 1, "message": f"failed to decode image: {e}"}, + ) + + try: + result = list(ocr_engine.predict(img_array)) + + pages = [] + for r in result: + raw = r._to_json() + data = raw.get("res", raw) + pages.append({ + "rec_texts": data.get("rec_texts", []), + "rec_scores": [float(s) for s in data.get("rec_scores", [])], + "rec_boxes": data.get("rec_boxes", []), + "rec_polys": data.get("rec_polys", []), + }) + + return {"errorCode": 0, "result": {"ocrResults": pages}} + + except Exception as e: + logger.exception("OCR failed") + return JSONResponse( + status_code=500, + content={"errorCode": 2, "message": str(e)}, + ) diff --git a/docker-compose.yml b/docker-compose.yml index efeaff9..77c66ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,19 @@ services: paddleocr: - image: paddlecloud/paddleocr:2.6-cpu-latest + build: + context: ./backend/ocr-server + dockerfile: Dockerfile ports: - "9090:8080" + environment: + - OCR_LANG=fr restart: unless-stopped healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8080/health"] interval: 30s timeout: 10s retries: 5 - start_period: 60s + start_period: 120s # backend: # build: diff --git a/mobile/constants/api.ts b/mobile/constants/api.ts index 5248594..31312aa 100644 --- a/mobile/constants/api.ts +++ b/mobile/constants/api.ts @@ -1,4 +1,4 @@ -export const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.1.142:8080/api/v1'; +export const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.1.17:8080/api/v1'; export const ENDPOINTS = { FILES: '/files',