diff --git a/.dockerignore b/.dockerignore index ed7a256..2cca2e5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,5 @@ __pycache__/ .env .pytest_cache/ .git/ +.gitea/ +tests/ diff --git a/.env.example b/.env.example index bff394f..bb6daf2 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,12 @@ -ENV=prod -FACE_LOCK_TEST_UI=true +ENV=production +PORT=8000 +LOG_LEVEL=info +FACE_LOCK_DOCS=true +FACE_LOCK_TEST_UI=false +FACE_LOCK_MAX_UPLOAD_BYTES=8388608 +FACE_LOCK_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,image/gif +WEB_CONCURRENCY=2 +UVICORN_KEEPALIVE_TIMEOUT=5 # Optional auth # FACE_LOCK_AUTH_TOKEN=change-me # FACE_LOCK_AUTH_HEADER=X-API-Key diff --git a/.gitea/workflows/python.yml b/.gitea/workflows/python.yml index 8eaed8d..2a1dcba 100644 --- a/.gitea/workflows/python.yml +++ b/.gitea/workflows/python.yml @@ -26,3 +26,6 @@ jobs: - name: Run tests run: pytest -q + + - name: Build Docker image + run: docker build -t face-lock:test . diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4aac8c9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md — face-lock + +## Project Summary + +face-lock is a standalone FastAPI microservice for image subject detection and +square cropping. It accepts an uploaded image, finds the primary subject using +OpenCV-based detectors, expands the crop with a configurable buffer, and +returns either JSON with preview data URLs or a binary JPEG crop. + +This repo is intentionally independent from the BetterNews API/frontend/worker +stack. Treat it as a fifth repo in the workspace with its own runtime, Docker +image, and CI. + +--- + +## API Surface + +| Method | Path | Description | +| ------ | ------------------ | ----------- | +| GET | `/health` | Health and runtime config summary. | +| GET | `/` | Small test UI when enabled. | +| POST | `/api/focus` | Returns JSON metadata plus crop and annotated previews as data URLs. | +| POST | `/api/focus/image` | Returns the cropped JPEG directly. | +| GET | `/docs` | OpenAPI UI when docs are enabled. | +| GET | `/openapi.json` | OpenAPI schema when docs are enabled. | + +--- + +## Configuration + +Key environment variables: + +- `ENV` or `FACE_LOCK_ENV` — environment label, defaults to `production`. +- `PORT` — HTTP port, defaults to `8000`. +- `LOG_LEVEL` — standard Python log level, defaults to `info`. +- `FACE_LOCK_DOCS` — enable or disable `/docs` and `/openapi.json`. +- `FACE_LOCK_TEST_UI` — enable or disable the browser test UI at `/`. +- `FACE_LOCK_MAX_UPLOAD_BYTES` — maximum upload size in bytes. +- `FACE_LOCK_ALLOWED_MIME_TYPES` — comma-separated allowlist of image MIME types. +- `FACE_LOCK_AUTH_TOKEN` — optional shared secret for header auth. +- `FACE_LOCK_AUTH_HEADER` — optional header override, defaults to `X-API-Key`. +- `WEB_CONCURRENCY` — worker count for the container entrypoint. +- `UVICORN_KEEPALIVE_TIMEOUT` — keepalive timeout for the container entrypoint. + +Auth supports either the configured header value or `Authorization: Bearer `. + +--- + +## Repo Layout + +- `app/main.py` — FastAPI app, routes, auth, upload validation, response headers. +- `app/config.py` — environment parsing and validation. +- `app/models.py` — API enums and response models. +- `app/ui.py` — embedded test UI HTML. +- `app/vision.py` — OpenCV detection and crop logic. +- `tests/` — API and image-processing tests. +- `docker/entrypoint.sh` — production container startup command. +- `.gitea/workflows/python.yml` — test and image build pipeline. + +--- + +## Working Rules + +1. Keep the service stateless. Do not introduce local persistence or writable app directories without explicit need. +2. Preserve the current request contract unless a change is clearly documented in both `README.md` and `docs/README.md`. +3. Validate upload size, detector values, and content type at the API boundary before handing data to OpenCV. +4. Prefer explicit, deterministic failures over silent fallbacks when configuration is invalid. +5. Keep the Docker image non-root and production-friendly. +6. Avoid adding new dependencies unless the standard library, FastAPI, or OpenCV cannot reasonably solve the problem. +7. Update tests whenever request validation, auth, output shape, or detector behavior changes. +8. Keep the test UI optional and disabled by default for production-style environments. + +--- + +## Local Development + +```sh +python -m pip install -r requirements.txt +python -m pytest +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +Docker: + +```sh +docker compose up --build +``` + +--- + +## Verification + +Before pushing: + +```sh +python -m compileall -q app tests +python -m pytest +docker build -t face-lock:test . +``` diff --git a/Dockerfile b/Dockerfile index 12febf7..6dd1cfc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,46 @@ -FROM python:3.13-slim +FROM python:3.12-slim-bookworm AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PATH="/opt/venv/bin:$PATH" + +WORKDIR /build + +RUN python -m venv /opt/venv + +COPY requirements.txt ./ +RUN pip install --upgrade pip \ + && pip install -r requirements.txt + + +FROM python:3.12-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/opt/venv/bin:$PATH" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 libgomp1 \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system app \ + && useradd --system --gid app --create-home --home-dir /home/app app WORKDIR /app -RUN apt-get update \ - && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \ - && rm -rf /var/lib/apt/lists/* +COPY --from=builder /opt/venv /opt/venv +COPY app ./app +COPY docs ./docs +COPY README.md ./ +COPY docker/entrypoint.sh /entrypoint.sh -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt +RUN chmod 0555 /entrypoint.sh \ + && chown -R app:app /app /opt/venv /home/app /entrypoint.sh -COPY . . +USER app EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD python -c "from urllib.request import urlopen; raise SystemExit(0 if urlopen('http://127.0.0.1:8000/health', timeout=3).getcode() == 200 else 1)" + +CMD ["/entrypoint.sh"] diff --git a/README.md b/README.md index a881830..4c29914 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,75 @@ # face-lock -FastAPI microservice that finds the primary subject in an image, draws a square around it, and returns a buffered crop. +`face-lock` is a standalone FastAPI image-processing service that detects a +primary subject, expands it to a square crop, and returns either structured +JSON previews or a binary JPEG crop. It is designed to be deployable as a +small production service, not just a local experiment. -## UI +## What it does -The Tailwind test UI is available at `/` unless disabled with `FACE_LOCK_TEST_UI=false`. +- Accepts a single uploaded image. +- Detects the main subject with one of four detectors: `face`, `animal`, + `person`, or `subject`. +- Applies a configurable square buffer around the chosen bounding box. +- Returns either: + - JSON metadata plus `crop_data_url` and `annotated_data_url` at `POST /api/focus` + - A cropped JPEG image at `POST /api/focus/image` -## Auth - -Optional header auth is enabled when `FACE_LOCK_AUTH_TOKEN` is set. - -- Default header: `X-API-Key` -- Alternate: `Authorization: Bearer ` -- Override the header name with `FACE_LOCK_AUTH_HEADER` - -## API +## Endpoints +- `GET /health` +- `GET /` - `POST /api/focus` - `POST /api/focus/image` -- `GET /health` +- `GET /docs` +- `GET /openapi.json` -## Detectors +## Runtime defaults -- `face` -- `animal` -- `person` -- `subject` +- Docs are enabled by default: `FACE_LOCK_DOCS=true` +- Test UI is disabled by default: `FACE_LOCK_TEST_UI=false` +- Maximum upload size defaults to `8388608` bytes +- Allowed MIME types default to `image/jpeg,image/png,image/webp,image/gif` +- Optional shared-token auth is enabled by setting `FACE_LOCK_AUTH_TOKEN` -## Docs +Supported auth headers: -- OpenAPI UI: `/docs` -- Project docs: `docs/README.md` +- `X-API-Key: ` +- `Authorization: Bearer ` -## Run +You can override the custom header name with `FACE_LOCK_AUTH_HEADER`. + +## Local development ```bash cp .env.example .env -pip install -r requirements.txt +python -m pip install -r requirements.txt +python -m pytest uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ``` -Set `FACE_LOCK_TEST_UI=false` to disable the UI. - ## Docker ```bash docker compose up --build ``` -If you change env vars in `.env`, recreate the container, `docker compose up -d --force-recreate`, because restart alone will not reload `--env-file`. +The image now runs as a non-root user and includes a built-in healthcheck. If +you change values in `.env`, recreate the container so Compose reloads the env +file: + +```bash +docker compose up -d --force-recreate +``` + +## Production notes + +- Set `FACE_LOCK_AUTH_TOKEN` before exposing the service publicly. +- Keep `FACE_LOCK_TEST_UI=false` in production-style environments. +- Tune `WEB_CONCURRENCY` based on CPU and workload. +- The service is stateless and safe to run behind a reverse proxy or container orchestrator. + +## More docs + +- Project notes: `docs/README.md` +- Repo working rules: `CLAUDE.md` diff --git a/app/__init__.py b/app/__init__.py index e69de29..b79805f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1,3 @@ +__all__ = ["__version__"] + +__version__ = "1.0.0" diff --git a/app/config.py b/app/config.py index f423a66..94c67af 100644 --- a/app/config.py +++ b/app/config.py @@ -5,6 +5,14 @@ from dotenv import load_dotenv load_dotenv() +DEFAULT_ALLOWED_MIME_TYPES = ( + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +) +VALID_LOG_LEVELS = {"critical", "error", "warning", "info", "debug"} + def _env_bool(name: str, default: bool = False) -> bool: value = os.getenv(name) @@ -13,16 +21,67 @@ def _env_bool(name: str, default: bool = False) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} +def _env_int(name: str, default: int, *, minimum: int | None = None, maximum: int | None = None) -> int: + raw_value = os.getenv(name) + value = default if raw_value is None else int(raw_value.strip()) + if minimum is not None and value < minimum: + raise ValueError(f"{name} must be >= {minimum}") + if maximum is not None and value > maximum: + raise ValueError(f"{name} must be <= {maximum}") + return value + + +def _env_csv(name: str, default: tuple[str, ...]) -> tuple[str, ...]: + raw_value = os.getenv(name, "").strip() + if not raw_value: + return default + values = tuple(part.strip().lower() for part in raw_value.split(",") if part.strip()) + if not values: + raise ValueError(f"{name} must contain at least one value when set") + return values + + +def _normalized_env() -> str: + value = os.getenv("FACE_LOCK_ENV") or os.getenv("ENV") or "production" + return value.strip().lower() + + +def _validated_log_level() -> str: + value = os.getenv("LOG_LEVEL", "info").strip().lower() + if value not in VALID_LOG_LEVELS: + raise ValueError(f"LOG_LEVEL must be one of: {', '.join(sorted(VALID_LOG_LEVELS))}") + return value + + +def _validated_header_name() -> str: + value = os.getenv("FACE_LOCK_AUTH_HEADER", "X-API-Key").strip() + if not value: + raise ValueError("FACE_LOCK_AUTH_HEADER cannot be empty") + if any(char in value for char in ("\r", "\n", ":")): + raise ValueError("FACE_LOCK_AUTH_HEADER contains invalid characters") + return value + + @dataclass(frozen=True) class Settings: - env: str = os.getenv("ENV", "prod").strip().lower() - test_ui_enabled: bool = _env_bool("FACE_LOCK_TEST_UI", True) + env: str = _normalized_env() + host: str = os.getenv("FACE_LOCK_HOST", "0.0.0.0").strip() or "0.0.0.0" + port: int = _env_int("PORT", 8000, minimum=1, maximum=65535) + log_level: str = _validated_log_level() + test_ui_enabled: bool = _env_bool("FACE_LOCK_TEST_UI", False) + docs_enabled: bool = _env_bool("FACE_LOCK_DOCS", True) auth_token: str = os.getenv("FACE_LOCK_AUTH_TOKEN", "").strip() - auth_header_name: str = os.getenv("FACE_LOCK_AUTH_HEADER", "X-API-Key").strip() + auth_header_name: str = _validated_header_name() + max_upload_bytes: int = _env_int("FACE_LOCK_MAX_UPLOAD_BYTES", 8 * 1024 * 1024, minimum=1024, maximum=25 * 1024 * 1024) + allowed_mime_types: tuple[str, ...] = _env_csv("FACE_LOCK_ALLOWED_MIME_TYPES", DEFAULT_ALLOWED_MIME_TYPES) @property def auth_enabled(self) -> bool: return bool(self.auth_token) + @property + def is_production(self) -> bool: + return self.env in {"prod", "production"} + settings = Settings() diff --git a/app/main.py b/app/main.py index 3484f13..59398be 100644 --- a/app/main.py +++ b/app/main.py @@ -1,185 +1,167 @@ -from io import BytesIO +from __future__ import annotations -from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile +from io import BytesIO +import logging +from pathlib import Path +import secrets +from typing import Annotated + +from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile from fastapi.responses import HTMLResponse, StreamingResponse +from app import __version__ from app.config import settings +from app.models import Detector, FocusResponse, HealthResponse +from app.ui import render_index_html -app = FastAPI(title="face-lock", version="0.3.0") +logging.basicConfig( + level=getattr(logging, settings.log_level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s %(name)s %(message)s", +) +logger = logging.getLogger("face_lock") + +app = FastAPI( + title="face-lock", + version=__version__, + docs_url="/docs" if settings.docs_enabled else None, + redoc_url=None, + openapi_url="/openapi.json" if settings.docs_enabled else None, +) def require_auth(request: Request) -> None: if not settings.auth_enabled: return - header_name = settings.auth_header_name.lower() - provided = request.headers.get(header_name) - if not provided and request.headers.get("authorization", "").lower().startswith("bearer "): - provided = request.headers.get("authorization", "")[7:].strip() - if provided != settings.auth_token: + + provided = (request.headers.get(settings.auth_header_name) or "").strip() + if settings.auth_header_name.lower() == "authorization" and provided.lower().startswith("bearer "): + provided = provided[7:].strip() + + if not provided: + authorization = request.headers.get("authorization", "").strip() + if authorization.lower().startswith("bearer "): + provided = authorization[7:].strip() + + if not provided or not secrets.compare_digest(provided, settings.auth_token): raise HTTPException(status_code=401, detail="unauthorized") -@app.get("/health") -def health(): - return { - "ok": True, - "env": settings.env, - "test_ui_enabled": settings.test_ui_enabled, - "auth_enabled": settings.auth_enabled, - "auth_header": settings.auth_header_name if settings.auth_enabled else None, - } +def _safe_filename(filename: str | None) -> str: + name = Path(filename or "upload").name.strip() + return name or "upload" -@app.get("/", response_class=HTMLResponse) -def index(): - if not settings.test_ui_enabled: - return HTMLResponse( - "

face-lock

Test UI is disabled.

Open API docs

" +def _validate_upload(file: UploadFile, payload: bytes) -> None: + if not payload: + raise HTTPException(status_code=400, detail="empty upload") + + if len(payload) > settings.max_upload_bytes: + raise HTTPException( + status_code=413, + detail=f"upload exceeds FACE_LOCK_MAX_UPLOAD_BYTES ({settings.max_upload_bytes} bytes)", ) - return HTMLResponse( - """ - - - - - - - face-lock - - -
-
-
-

face-lock

-

Square the subject, crop it, and keep the raw blobs out of sight.

-
- Docs -
-
-
- - -
-
- - -
-
- - -
-
-
- - -
- -

-      
-
-
Result
-
-
-
Crop
- -
-
-
Annotated source
- -
-
-
-
-
- - - - """.replace("__AUTH_HEADER_NAME__", settings.auth_header_name) + + content_type = (file.content_type or "").strip().lower() + if content_type not in settings.allowed_mime_types: + raise HTTPException( + status_code=415, + detail=f"unsupported content type: {content_type or 'unknown'}", + ) + + +async def _process_upload(file: UploadFile, *, buffer_ratio: float, detector: Detector) -> dict[str, object]: + from app.vision import process_image + + payload = await file.read() + _validate_upload(file, payload) + + try: + return process_image( + payload, + _safe_filename(file.filename), + buffer_ratio=buffer_ratio, + detector=detector.value, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.middleware("http") +async def add_response_headers(request: Request, call_next) -> Response: + response = await call_next(request) + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "no-referrer") + response.headers.setdefault("Permissions-Policy", "camera=(), geolocation=(), microphone=()") + return response + + +@app.get("/health", response_model=HealthResponse, tags=["meta"]) +def health() -> HealthResponse: + return HealthResponse( + env=settings.env, + version=__version__, + docs_enabled=settings.docs_enabled, + test_ui_enabled=settings.test_ui_enabled, + auth_enabled=settings.auth_enabled, + auth_header=settings.auth_header_name if settings.auth_enabled else None, + max_upload_bytes=settings.max_upload_bytes, + allowed_mime_types=list(settings.allowed_mime_types), ) -@app.post("/api/focus") +@app.get("/", response_class=HTMLResponse, tags=["meta"]) +def index() -> HTMLResponse: + if not settings.test_ui_enabled: + docs_link = "

Open API docs

" if settings.docs_enabled else "" + return HTMLResponse( + ( + "" + "

face-lock

Test UI is disabled.

" + f"{docs_link}" + ), + headers={"Cache-Control": "no-store"}, + ) + + return HTMLResponse( + render_index_html(auth_header_name=settings.auth_header_name), + headers={"Cache-Control": "no-store"}, + ) + + +@app.post("/api/focus", response_model=FocusResponse, tags=["focus"]) async def focus( - request: Request, file: UploadFile = File(...), - buffer_ratio: float = Form(0.15), - detector: str = Form("subject"), + buffer_ratio: Annotated[float, Form(ge=0.0, le=0.6)] = 0.15, + detector: Annotated[Detector, Form()] = Detector.SUBJECT, _auth: None = Depends(require_auth), -): - from app.vision import process_image - - try: - payload = await file.read() - result = process_image(payload, file.filename or "upload", buffer_ratio=buffer_ratio, detector=detector) - return { - "filename": result["filename"], - "detector": result["detector"], - "method": result["method"], - "buffer_ratio": result["buffer_ratio"], - "detected_bbox": result["detected_bbox"], - "square_bbox": result["square_bbox"], - "source_size": result["source_size"], - "crop_data_url": result["crop_data_url"], - "annotated_data_url": result["annotated_data_url"], - } - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc +) -> dict[str, object]: + result = await _process_upload(file, buffer_ratio=buffer_ratio, detector=detector) + return { + "filename": result["filename"], + "detector": result["detector"], + "method": result["method"], + "buffer_ratio": result["buffer_ratio"], + "detected_bbox": result["detected_bbox"], + "square_bbox": result["square_bbox"], + "source_size": result["source_size"], + "mime_type": result["mime_type"], + "crop_data_url": result["crop_data_url"], + "annotated_data_url": result["annotated_data_url"], + } -@app.post("/api/focus/image") +@app.post("/api/focus/image", tags=["focus"]) async def focus_image( - request: Request, file: UploadFile = File(...), - buffer_ratio: float = Form(0.15), - detector: str = Form("subject"), + buffer_ratio: Annotated[float, Form(ge=0.0, le=0.6)] = 0.15, + detector: Annotated[Detector, Form()] = Detector.SUBJECT, _auth: None = Depends(require_auth), -): - from app.vision import process_image - - try: - payload = await file.read() - result = process_image(payload, file.filename or "upload", buffer_ratio=buffer_ratio, detector=detector) - return StreamingResponse(BytesIO(result["crop_bytes"]), media_type="image/jpeg") - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc +) -> StreamingResponse: + result = await _process_upload(file, buffer_ratio=buffer_ratio, detector=detector) + filename_stem = Path(str(result["filename"])).stem or "focus" + return StreamingResponse( + BytesIO(result["crop_bytes"]), + media_type=str(result["mime_type"]), + headers={"Content-Disposition": f'inline; filename="{filename_stem}-crop.jpg"'}, + ) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..755b2e3 --- /dev/null +++ b/app/models.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, Field + + +class Detector(StrEnum): + FACE = "face" + ANIMAL = "animal" + PERSON = "person" + SUBJECT = "subject" + + +class BoundingBoxResponse(BaseModel): + x: int = Field(ge=0) + y: int = Field(ge=0) + w: int = Field(gt=0) + h: int = Field(gt=0) + + +class SourceSizeResponse(BaseModel): + width: int = Field(gt=0) + height: int = Field(gt=0) + + +class FocusResponse(BaseModel): + filename: str + detector: Detector + method: str + buffer_ratio: float = Field(ge=0.0, le=0.6) + detected_bbox: BoundingBoxResponse + square_bbox: BoundingBoxResponse + source_size: SourceSizeResponse + mime_type: str + crop_data_url: str + annotated_data_url: str + + +class HealthResponse(BaseModel): + ok: bool = True + env: str + version: str + docs_enabled: bool + test_ui_enabled: bool + auth_enabled: bool + auth_header: str | None + max_upload_bytes: int + allowed_mime_types: list[str] diff --git a/app/ui.py b/app/ui.py new file mode 100644 index 0000000..6cb4fc0 --- /dev/null +++ b/app/ui.py @@ -0,0 +1,103 @@ +from __future__ import annotations + + +def render_index_html(*, auth_header_name: str) -> str: + return """ + + + + + + + face-lock + + +
+
+
+

face-lock

+

Square the subject, crop it, and keep the raw blobs out of sight.

+
+ Docs +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +

+      
+
+
Result
+
+
+
Crop
+ +
+
+
Annotated source
+ +
+
+
+
+
+ + + + """.replace("__AUTH_HEADER_NAME__", auth_header_name) diff --git a/app/vision.py b/app/vision.py index 1e7a7a4..fe2bf06 100644 --- a/app/vision.py +++ b/app/vision.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import TypedDict import cv2 import numpy as np @@ -31,6 +31,20 @@ HOG = cv2.HOGDescriptor() HOG.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector()) +class ProcessedImage(TypedDict): + filename: str + detector: str + method: str + buffer_ratio: float + detected_bbox: dict[str, int] + square_bbox: dict[str, int] + source_size: dict[str, int] + crop_data_url: str + annotated_data_url: str + mime_type: str + crop_bytes: bytes + + def decode_image(image_bytes: bytes) -> np.ndarray: data = np.frombuffer(image_bytes, dtype=np.uint8) image = cv2.imdecode(data, cv2.IMREAD_COLOR) @@ -68,6 +82,8 @@ def fallback_bbox(image: np.ndarray) -> BBox: def detect_face(image: np.ndarray) -> BBox | None: + if FACE_CASCADE.empty(): + return None gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) gray = cv2.equalizeHist(gray) faces = FACE_CASCADE.detectMultiScale(gray, scaleFactor=1.08, minNeighbors=5, minSize=(24, 24)) @@ -173,7 +189,7 @@ def _data_url(image_bytes: bytes, mime_type: str) -> str: return f"data:{mime_type};base64,{base64.b64encode(image_bytes).decode('ascii')}" -def process_image(image_bytes: bytes, filename: str, buffer_ratio: float = 0.15, detector: str = "subject") -> dict[str, Any]: +def process_image(image_bytes: bytes, filename: str, buffer_ratio: float = 0.15, detector: str = "subject") -> ProcessedImage: image = decode_image(image_bytes) bbox, method = select_primary_bbox(image, detector=detector) square = square_bbox(bbox, image.shape, buffer_ratio=buffer_ratio) diff --git a/docker-compose.yml b/docker-compose.yml index 2865f4e..68b1f1a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,28 @@ services: face-lock: - build: . + build: + context: . + image: face-lock:local ports: - - "8100:8000" + - "${FACE_LOCK_PUBLISHED_PORT:-8100}:8000" env_file: - - .env + - path: .env + required: false + environment: + PORT: 8000 + FACE_LOCK_HOST: 0.0.0.0 + healthcheck: + test: ["CMD", "python", "-c", "from urllib.request import urlopen; raise SystemExit(0 if urlopen('http://127.0.0.1:8000/health', timeout=3).getcode() == 200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + init: true restart: unless-stopped + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..16df936 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -eu + +HOST="${FACE_LOCK_HOST:-0.0.0.0}" +PORT="${PORT:-8000}" +WORKERS="${WEB_CONCURRENCY:-2}" +LOG_LEVEL="${LOG_LEVEL:-info}" +KEEPALIVE="${UVICORN_KEEPALIVE_TIMEOUT:-5}" + +case "$WORKERS" in + ''|*[!0-9]*) + echo "WEB_CONCURRENCY must be a positive integer" >&2 + exit 1 + ;; +esac + +case "$PORT" in + ''|*[!0-9]*) + echo "PORT must be a positive integer" >&2 + exit 1 + ;; +esac + +exec python -m uvicorn app.main:app \ + --host "$HOST" \ + --port "$PORT" \ + --workers "$WORKERS" \ + --timeout-keep-alive "$KEEPALIVE" \ + --proxy-headers \ + --no-server-header \ + --log-level "$LOG_LEVEL" diff --git a/docs/README.md b/docs/README.md index 01279cb..b1c4364 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,9 @@ ## Overview -face-lock is a FastAPI service that detects a primary subject, makes a square crop, and returns both a crop and an annotated preview. The app ships with a simple Tailwind test UI at `/`. +`face-lock` is a FastAPI service for square subject crops. It uses OpenCV-based +detectors, returns stable JSON metadata for preview workflows, and can also +stream the cropped JPEG directly for simple image pipelines. ## Endpoints @@ -11,40 +13,64 @@ face-lock is a FastAPI service that detects a primary subject, makes a square cr - `POST /api/focus` - `POST /api/focus/image` - `GET /docs` +- `GET /openapi.json` -## Detectors +## Detector modes -- `face` for human faces -- `animal` for pets / animals, with a contour fallback -- `person` for full-body person detection -- `subject` for general foreground subjects +- `face` for frontal human faces +- `animal` for pets and animals, with contour fallback +- `person` for full-body people detection +- `subject` for generic foreground contour detection -## Test UI +## Request validation -Set `FACE_LOCK_TEST_UI=false` to disable the `/` test UI. If you update `.env`, recreate the container so Docker picks up the new values. +- Uploads must be non-empty. +- Uploads must stay below `FACE_LOCK_MAX_UPLOAD_BYTES`. +- Content types must match `FACE_LOCK_ALLOWED_MIME_TYPES`. +- `buffer_ratio` is clamped at the API layer to the `0.0` to `0.6` range. ## Authentication -Set `FACE_LOCK_AUTH_TOKEN` to require a header token. +Set `FACE_LOCK_AUTH_TOKEN` to require a shared secret. -Supported headers: +Supported forms: - `X-API-Key: ` - `Authorization: Bearer ` Optional override: -- `FACE_LOCK_AUTH_HEADER` changes the expected header name. +- `FACE_LOCK_AUTH_HEADER` ## Example ```bash -curl -H 'X-API-Key: your-token' \ +curl \ + -H 'X-API-Key: your-token' \ -F 'file=@image.jpg' \ -F 'detector=animal' \ + -F 'buffer_ratio=0.2' \ http://localhost:8000/api/focus ``` -## Docker note +Binary crop response: -Use `docker compose up -d --force-recreate` after env changes. +```bash +curl \ + -H 'X-API-Key: your-token' \ + -F 'file=@image.jpg' \ + http://localhost:8000/api/focus/image \ + --output crop.jpg +``` + +## Container notes + +- The production image runs as a non-root user. +- The container includes a `/health` healthcheck. +- Compose runs the root filesystem read-only and mounts `/tmp` as tmpfs. + +If you change `.env`, recreate the container: + +```bash +docker compose up -d --force-recreate +``` diff --git a/requirements.txt b/requirements.txt index 8924bf9..ec4333f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -fastapi -uvicorn[standard] -opencv-python-headless -python-dotenv -python-multipart +fastapi==0.135.3 +opencv-python-headless==4.13.0.88 +python-dotenv==1.2.2 +python-multipart==0.0.22 +uvicorn[standard]==0.41.0 diff --git a/tests/test_main.py b/tests/test_main.py index e853d63..b885f5b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -4,6 +4,7 @@ import pytest from fastapi.testclient import TestClient import app.main as main +from app import __version__ class DummySettings(SimpleNamespace): @@ -19,9 +20,12 @@ def reset_settings(monkeypatch): "settings", DummySettings( env="test", + docs_enabled=True, test_ui_enabled=True, auth_token="", auth_header_name="X-API-Key", + max_upload_bytes=1024 * 1024, + allowed_mime_types=("image/jpeg", "image/png", "image/webp", "image/gif"), ), ) @@ -37,9 +41,13 @@ def test_health_reflects_settings(client): assert resp.json() == { "ok": True, "env": "test", + "version": __version__, + "docs_enabled": True, "test_ui_enabled": True, "auth_enabled": False, "auth_header": None, + "max_upload_bytes": 1024 * 1024, + "allowed_mime_types": ["image/jpeg", "image/png", "image/webp", "image/gif"], } @@ -49,14 +57,18 @@ def test_index_shows_disabled_message_when_ui_off(monkeypatch): "settings", DummySettings( env="test", + docs_enabled=True, test_ui_enabled=False, auth_token="", auth_header_name="X-API-Key", + max_upload_bytes=1024 * 1024, + allowed_mime_types=("image/jpeg", "image/png", "image/webp", "image/gif"), ), ) resp = TestClient(main.app).get("/") assert resp.status_code == 200 assert "Test UI is disabled." in resp.text + assert resp.headers["cache-control"] == "no-store" def test_focus_accepts_bearer_auth_and_returns_payload(monkeypatch, client): @@ -65,9 +77,12 @@ def test_focus_accepts_bearer_auth_and_returns_payload(monkeypatch, client): "settings", DummySettings( env="test", + docs_enabled=True, test_ui_enabled=True, auth_token="secret", auth_header_name="X-API-Key", + max_upload_bytes=1024 * 1024, + allowed_mime_types=("image/jpeg", "image/png", "image/webp", "image/gif"), ), ) @@ -101,6 +116,76 @@ def test_focus_accepts_bearer_auth_and_returns_payload(monkeypatch, client): assert resp.status_code == 200 assert resp.json()["method"] == "face_cascade" assert resp.json()["detected_bbox"] == {"x": 1, "y": 2, "w": 3, "h": 4} + assert resp.json()["mime_type"] == "image/jpeg" + + +def test_focus_rejects_unauthorized_request(monkeypatch, client): + monkeypatch.setattr( + main, + "settings", + DummySettings( + env="test", + docs_enabled=True, + test_ui_enabled=True, + auth_token="secret", + auth_header_name="X-API-Key", + max_upload_bytes=1024 * 1024, + allowed_mime_types=("image/jpeg", "image/png", "image/webp", "image/gif"), + ), + ) + + resp = client.post( + "/api/focus", + files={"file": ("sample.jpg", b"image-bytes", "image/jpeg")}, + ) + assert resp.status_code == 401 + assert resp.json() == {"detail": "unauthorized"} + + +def test_focus_rejects_unsupported_content_type(monkeypatch, client): + monkeypatch.setattr( + main, + "settings", + DummySettings( + env="test", + docs_enabled=True, + test_ui_enabled=True, + auth_token="", + auth_header_name="X-API-Key", + max_upload_bytes=1024 * 1024, + allowed_mime_types=("image/jpeg",), + ), + ) + + resp = client.post( + "/api/focus", + files={"file": ("sample.txt", b"image-bytes", "text/plain")}, + ) + assert resp.status_code == 415 + assert resp.json() == {"detail": "unsupported content type: text/plain"} + + +def test_focus_rejects_large_upload(monkeypatch, client): + monkeypatch.setattr( + main, + "settings", + DummySettings( + env="test", + docs_enabled=True, + test_ui_enabled=True, + auth_token="", + auth_header_name="X-API-Key", + max_upload_bytes=4, + allowed_mime_types=("image/jpeg",), + ), + ) + + resp = client.post( + "/api/focus", + files={"file": ("sample.jpg", b"image-bytes", "image/jpeg")}, + ) + assert resp.status_code == 413 + assert resp.json() == {"detail": "upload exceeds FACE_LOCK_MAX_UPLOAD_BYTES (4 bytes)"} def test_focus_image_returns_jpeg_stream(monkeypatch, client): @@ -127,4 +212,5 @@ def test_focus_image_returns_jpeg_stream(monkeypatch, client): ) assert resp.status_code == 200 assert resp.headers["content-type"] == "image/jpeg" + assert resp.headers["content-disposition"] == 'inline; filename="sample-crop.jpg"' assert resp.content == b"crop-bytes"