feat: enhance face-lock service with improved upload handling and response structure
python / test (push) Failing after 8s

- Updated README.md to reflect new features and API changes.
- Introduced versioning in app initialization.
- Enhanced configuration management in app/config.py with new validation functions.
- Refactored main.py to improve request handling and response generation.
- Added new models in app/models.py for structured API responses.
- Implemented a dedicated UI rendering function in app/ui.py.
- Improved Docker configuration for better security and health checks.
- Updated tests to cover new validation rules and response formats.
- Added CLAUDE.md for project guidelines and working rules.
This commit is contained in:
Space-Banane
2026-06-20 11:23:12 +02:00
parent f1072cb7b0
commit 3cf4a9a40a
17 changed files with 759 additions and 222 deletions
+2
View File
@@ -4,3 +4,5 @@ __pycache__/
.env .env
.pytest_cache/ .pytest_cache/
.git/ .git/
.gitea/
tests/
+9 -2
View File
@@ -1,5 +1,12 @@
ENV=prod ENV=production
FACE_LOCK_TEST_UI=true 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 # Optional auth
# FACE_LOCK_AUTH_TOKEN=change-me # FACE_LOCK_AUTH_TOKEN=change-me
# FACE_LOCK_AUTH_HEADER=X-API-Key # FACE_LOCK_AUTH_HEADER=X-API-Key
+3
View File
@@ -26,3 +26,6 @@ jobs:
- name: Run tests - name: Run tests
run: pytest -q run: pytest -q
- name: Build Docker image
run: docker build -t face-lock:test .
+99
View File
@@ -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 <token>`.
---
## 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 .
```
+36 -9
View File
@@ -1,19 +1,46 @@
FROM python:3.13-slim FROM python:3.12-slim-bookworm AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \ 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 WORKDIR /app
RUN apt-get update \ COPY --from=builder /opt/venv /opt/venv
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \ COPY app ./app
&& rm -rf /var/lib/apt/lists/* COPY docs ./docs
COPY README.md ./
COPY docker/entrypoint.sh /entrypoint.sh
COPY requirements.txt ./ RUN chmod 0555 /entrypoint.sh \
RUN pip install --no-cache-dir -r requirements.txt && chown -R app:app /app /opt/venv /home/app /entrypoint.sh
COPY . . USER app
EXPOSE 8000 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"]
+50 -26
View File
@@ -1,51 +1,75 @@
# face-lock # 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 ## Endpoints
Optional header auth is enabled when `FACE_LOCK_AUTH_TOKEN` is set.
- Default header: `X-API-Key`
- Alternate: `Authorization: Bearer <token>`
- Override the header name with `FACE_LOCK_AUTH_HEADER`
## API
- `GET /health`
- `GET /`
- `POST /api/focus` - `POST /api/focus`
- `POST /api/focus/image` - `POST /api/focus/image`
- `GET /health` - `GET /docs`
- `GET /openapi.json`
## Detectors ## Runtime defaults
- `face` - Docs are enabled by default: `FACE_LOCK_DOCS=true`
- `animal` - Test UI is disabled by default: `FACE_LOCK_TEST_UI=false`
- `person` - Maximum upload size defaults to `8388608` bytes
- `subject` - 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` - `X-API-Key: <token>`
- Project docs: `docs/README.md` - `Authorization: Bearer <token>`
## Run You can override the custom header name with `FACE_LOCK_AUTH_HEADER`.
## Local development
```bash ```bash
cp .env.example .env 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 uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
``` ```
Set `FACE_LOCK_TEST_UI=false` to disable the UI.
## Docker ## Docker
```bash ```bash
docker compose up --build 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`
+3
View File
@@ -0,0 +1,3 @@
__all__ = ["__version__"]
__version__ = "1.0.0"
+62 -3
View File
@@ -5,6 +5,14 @@ from dotenv import load_dotenv
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: def _env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name) 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"} 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) @dataclass(frozen=True)
class Settings: class Settings:
env: str = os.getenv("ENV", "prod").strip().lower() env: str = _normalized_env()
test_ui_enabled: bool = _env_bool("FACE_LOCK_TEST_UI", True) 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_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 @property
def auth_enabled(self) -> bool: def auth_enabled(self) -> bool:
return bool(self.auth_token) return bool(self.auth_token)
@property
def is_production(self) -> bool:
return self.env in {"prod", "production"}
settings = Settings() settings = Settings()
+129 -147
View File
@@ -1,157 +1,142 @@
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 fastapi.responses import HTMLResponse, StreamingResponse
from app import __version__
from app.config import settings 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: def require_auth(request: Request) -> None:
if not settings.auth_enabled: if not settings.auth_enabled:
return return
header_name = settings.auth_header_name.lower()
provided = request.headers.get(header_name) provided = (request.headers.get(settings.auth_header_name) or "").strip()
if not provided and request.headers.get("authorization", "").lower().startswith("bearer "): if settings.auth_header_name.lower() == "authorization" and provided.lower().startswith("bearer "):
provided = request.headers.get("authorization", "")[7:].strip() provided = provided[7:].strip()
if provided != settings.auth_token:
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") raise HTTPException(status_code=401, detail="unauthorized")
@app.get("/health") def _safe_filename(filename: str | None) -> str:
def health(): name = Path(filename or "upload").name.strip()
return { return name or "upload"
"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,
}
@app.get("/", response_class=HTMLResponse) def _validate_upload(file: UploadFile, payload: bytes) -> None:
def index(): if not payload:
if not settings.test_ui_enabled: raise HTTPException(status_code=400, detail="empty upload")
return HTMLResponse(
"<!doctype html><html><body style='font-family:sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem'><h1>face-lock</h1><p>Test UI is disabled.</p><p><a href='/docs' style='color:#67e8f9'>Open API docs</a></p></body></html>" 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(
""" content_type = (file.content_type or "").strip().lower()
<!doctype html> if content_type not in settings.allowed_mime_types:
<html lang="en"> raise HTTPException(
<head> status_code=415,
<meta charset="utf-8" /> detail=f"unsupported content type: {content_type or 'unknown'}",
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com"></script>
<title>face-lock</title>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<main class="mx-auto max-w-6xl p-6">
<div class="mb-6 flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold">face-lock</h1>
<p class="text-slate-400">Square the subject, crop it, and keep the raw blobs out of sight.</p>
</div>
<a class="rounded-lg border border-slate-700 px-3 py-2 text-sm text-cyan-300 hover:bg-slate-900" href="/docs" target="_blank">Docs</a>
</div>
<div class="grid gap-6 md:grid-cols-2">
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-4">
<label class="block text-sm text-slate-400">Image</label>
<input id="file" type="file" accept="image/*" class="mt-2 block w-full rounded-lg border border-slate-700 bg-slate-950 p-3" />
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm text-slate-400">Detector</label>
<select id="detector" class="mt-2 block w-full rounded-lg border border-slate-700 bg-slate-950 p-3">
<option value="face">Face</option>
<option value="animal">Animal</option>
<option value="person">Person</option>
<option value="subject" selected>Subject</option>
</select>
</div>
<div>
<label class="block text-sm text-slate-400">Buffer ratio</label>
<input id="buffer_ratio" type="number" step="0.05" min="0" max="0.6" value="0.20" class="mt-2 block w-full rounded-lg border border-slate-700 bg-slate-950 p-3" />
</div>
</div>
<div class="mt-4">
<label class="block text-sm text-slate-400">Auth token (only if enabled)</label>
<input id="auth_token" type="password" placeholder="paste token here" class="mt-2 block w-full rounded-lg border border-slate-700 bg-slate-950 p-3" />
</div>
<button id="go" class="mt-4 rounded-lg bg-cyan-500 px-4 py-2 font-semibold text-slate-950">Process</button>
<pre id="meta" class="mt-4 whitespace-pre-wrap rounded-lg bg-slate-950 p-3 text-xs text-slate-300"></pre>
</section>
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-4">
<div class="mb-3 text-sm font-semibold text-slate-400">Result</div>
<div class="grid gap-4">
<div>
<div class="mb-2 text-xs uppercase tracking-wide text-slate-500">Crop</div>
<img id="crop" class="hidden w-full rounded-xl border border-slate-800" />
</div>
<div>
<div class="mb-2 text-xs uppercase tracking-wide text-slate-500">Annotated source</div>
<img id="annotated" class="hidden w-full rounded-xl border border-slate-800" />
</div>
</div>
</section>
</div>
</main>
<script>
const file = document.getElementById('file');
const go = document.getElementById('go');
const crop = document.getElementById('crop');
const annotated = document.getElementById('annotated');
const meta = document.getElementById('meta');
go.onclick = async () => {
if (!file.files.length) return;
const form = new FormData();
form.append('file', file.files[0]);
form.append('detector', document.getElementById('detector').value);
form.append('buffer_ratio', document.getElementById('buffer_ratio').value);
meta.textContent = 'Working...';
const headers = {};
const token = document.getElementById('auth_token').value.trim();
if (token) headers['__AUTH_HEADER_NAME__'] = token;
const resp = await fetch('/api/focus', { method: 'POST', body: form, headers });
const data = await resp.json();
if (!resp.ok) {
meta.textContent = JSON.stringify(data, null, 2);
return;
}
meta.textContent = JSON.stringify({
filename: data.filename,
detector: data.detector,
method: data.method,
buffer_ratio: data.buffer_ratio,
detected_bbox: data.detected_bbox,
square_bbox: data.square_bbox,
source_size: data.source_size,
}, null, 2);
crop.src = data.crop_data_url;
annotated.src = data.annotated_data_url;
crop.classList.remove('hidden');
annotated.classList.remove('hidden');
};
</script>
</body>
</html>
""".replace("__AUTH_HEADER_NAME__", settings.auth_header_name)
) )
@app.post("/api/focus") async def _process_upload(file: UploadFile, *, buffer_ratio: float, detector: Detector) -> dict[str, object]:
async def focus(
request: Request,
file: UploadFile = File(...),
buffer_ratio: float = Form(0.15),
detector: str = Form("subject"),
_auth: None = Depends(require_auth),
):
from app.vision import process_image from app.vision import process_image
try:
payload = await file.read() payload = await file.read()
result = process_image(payload, file.filename or "upload", buffer_ratio=buffer_ratio, detector=detector) _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.get("/", response_class=HTMLResponse, tags=["meta"])
def index() -> HTMLResponse:
if not settings.test_ui_enabled:
docs_link = "<p><a href='/docs' style='color:#67e8f9'>Open API docs</a></p>" if settings.docs_enabled else ""
return HTMLResponse(
(
"<!doctype html><html><body style='font-family:sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem'>"
"<h1>face-lock</h1><p>Test UI is disabled.</p>"
f"{docs_link}</body></html>"
),
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(
file: UploadFile = File(...),
buffer_ratio: Annotated[float, Form(ge=0.0, le=0.6)] = 0.15,
detector: Annotated[Detector, Form()] = Detector.SUBJECT,
_auth: None = Depends(require_auth),
) -> dict[str, object]:
result = await _process_upload(file, buffer_ratio=buffer_ratio, detector=detector)
return { return {
"filename": result["filename"], "filename": result["filename"],
"detector": result["detector"], "detector": result["detector"],
@@ -160,26 +145,23 @@ async def focus(
"detected_bbox": result["detected_bbox"], "detected_bbox": result["detected_bbox"],
"square_bbox": result["square_bbox"], "square_bbox": result["square_bbox"],
"source_size": result["source_size"], "source_size": result["source_size"],
"mime_type": result["mime_type"],
"crop_data_url": result["crop_data_url"], "crop_data_url": result["crop_data_url"],
"annotated_data_url": result["annotated_data_url"], "annotated_data_url": result["annotated_data_url"],
} }
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/focus/image") @app.post("/api/focus/image", tags=["focus"])
async def focus_image( async def focus_image(
request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
buffer_ratio: float = Form(0.15), buffer_ratio: Annotated[float, Form(ge=0.0, le=0.6)] = 0.15,
detector: str = Form("subject"), detector: Annotated[Detector, Form()] = Detector.SUBJECT,
_auth: None = Depends(require_auth), _auth: None = Depends(require_auth),
): ) -> StreamingResponse:
from app.vision import process_image result = await _process_upload(file, buffer_ratio=buffer_ratio, detector=detector)
filename_stem = Path(str(result["filename"])).stem or "focus"
try: return StreamingResponse(
payload = await file.read() BytesIO(result["crop_bytes"]),
result = process_image(payload, file.filename or "upload", buffer_ratio=buffer_ratio, detector=detector) media_type=str(result["mime_type"]),
return StreamingResponse(BytesIO(result["crop_bytes"]), media_type="image/jpeg") headers={"Content-Disposition": f'inline; filename="{filename_stem}-crop.jpg"'},
except ValueError as exc: )
raise HTTPException(status_code=400, detail=str(exc)) from exc
+49
View File
@@ -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]
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
def render_index_html(*, auth_header_name: str) -> str:
return """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com"></script>
<title>face-lock</title>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<main class="mx-auto max-w-6xl p-6">
<div class="mb-6 flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold">face-lock</h1>
<p class="text-slate-400">Square the subject, crop it, and keep the raw blobs out of sight.</p>
</div>
<a class="rounded-lg border border-slate-700 px-3 py-2 text-sm text-cyan-300 hover:bg-slate-900" href="/docs" target="_blank" rel="noreferrer">Docs</a>
</div>
<div class="grid gap-6 md:grid-cols-2">
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-4">
<label class="block text-sm text-slate-400">Image</label>
<input id="file" type="file" accept="image/*" class="mt-2 block w-full rounded-lg border border-slate-700 bg-slate-950 p-3" />
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm text-slate-400">Detector</label>
<select id="detector" class="mt-2 block w-full rounded-lg border border-slate-700 bg-slate-950 p-3">
<option value="face">Face</option>
<option value="animal">Animal</option>
<option value="person">Person</option>
<option value="subject" selected>Subject</option>
</select>
</div>
<div>
<label class="block text-sm text-slate-400">Buffer ratio</label>
<input id="buffer_ratio" type="number" step="0.05" min="0" max="0.6" value="0.20" class="mt-2 block w-full rounded-lg border border-slate-700 bg-slate-950 p-3" />
</div>
</div>
<div class="mt-4">
<label class="block text-sm text-slate-400">Auth token (only if enabled)</label>
<input id="auth_token" type="password" placeholder="paste token here" class="mt-2 block w-full rounded-lg border border-slate-700 bg-slate-950 p-3" />
</div>
<button id="go" class="mt-4 rounded-lg bg-cyan-500 px-4 py-2 font-semibold text-slate-950">Process</button>
<pre id="meta" class="mt-4 whitespace-pre-wrap rounded-lg bg-slate-950 p-3 text-xs text-slate-300"></pre>
</section>
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-4">
<div class="mb-3 text-sm font-semibold text-slate-400">Result</div>
<div class="grid gap-4">
<div>
<div class="mb-2 text-xs uppercase tracking-wide text-slate-500">Crop</div>
<img id="crop" class="hidden w-full rounded-xl border border-slate-800" />
</div>
<div>
<div class="mb-2 text-xs uppercase tracking-wide text-slate-500">Annotated source</div>
<img id="annotated" class="hidden w-full rounded-xl border border-slate-800" />
</div>
</div>
</section>
</div>
</main>
<script>
const file = document.getElementById('file');
const go = document.getElementById('go');
const crop = document.getElementById('crop');
const annotated = document.getElementById('annotated');
const meta = document.getElementById('meta');
go.onclick = async () => {
if (!file.files.length) return;
const form = new FormData();
form.append('file', file.files[0]);
form.append('detector', document.getElementById('detector').value);
form.append('buffer_ratio', document.getElementById('buffer_ratio').value);
meta.textContent = 'Working...';
const headers = {};
const token = document.getElementById('auth_token').value.trim();
if (token) headers['__AUTH_HEADER_NAME__'] = token;
const resp = await fetch('/api/focus', { method: 'POST', body: form, headers });
const data = await resp.json();
if (!resp.ok) {
meta.textContent = JSON.stringify(data, null, 2);
return;
}
meta.textContent = JSON.stringify({
filename: data.filename,
detector: data.detector,
method: data.method,
buffer_ratio: data.buffer_ratio,
detected_bbox: data.detected_bbox,
square_bbox: data.square_bbox,
source_size: data.source_size,
}, null, 2);
crop.src = data.crop_data_url;
annotated.src = data.annotated_data_url;
crop.classList.remove('hidden');
annotated.classList.remove('hidden');
};
</script>
</body>
</html>
""".replace("__AUTH_HEADER_NAME__", auth_header_name)
+18 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import TypedDict
import cv2 import cv2
import numpy as np import numpy as np
@@ -31,6 +31,20 @@ HOG = cv2.HOGDescriptor()
HOG.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector()) 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: def decode_image(image_bytes: bytes) -> np.ndarray:
data = np.frombuffer(image_bytes, dtype=np.uint8) data = np.frombuffer(image_bytes, dtype=np.uint8)
image = cv2.imdecode(data, cv2.IMREAD_COLOR) 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: def detect_face(image: np.ndarray) -> BBox | None:
if FACE_CASCADE.empty():
return None
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
gray = cv2.equalizeHist(gray) gray = cv2.equalizeHist(gray)
faces = FACE_CASCADE.detectMultiScale(gray, scaleFactor=1.08, minNeighbors=5, minSize=(24, 24)) 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')}" 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) image = decode_image(image_bytes)
bbox, method = select_primary_bbox(image, detector=detector) bbox, method = select_primary_bbox(image, detector=detector)
square = square_bbox(bbox, image.shape, buffer_ratio=buffer_ratio) square = square_bbox(bbox, image.shape, buffer_ratio=buffer_ratio)
+23 -3
View File
@@ -1,8 +1,28 @@
services: services:
face-lock: face-lock:
build: . build:
context: .
image: face-lock:local
ports: ports:
- "8100:8000" - "${FACE_LOCK_PUBLISHED_PORT:-8100}:8000"
env_file: 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 restart: unless-stopped
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
+31
View File
@@ -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"
+40 -14
View File
@@ -2,7 +2,9 @@
## Overview ## 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 ## 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`
- `POST /api/focus/image` - `POST /api/focus/image`
- `GET /docs` - `GET /docs`
- `GET /openapi.json`
## Detectors ## Detector modes
- `face` for human faces - `face` for frontal human faces
- `animal` for pets / animals, with a contour fallback - `animal` for pets and animals, with contour fallback
- `person` for full-body person detection - `person` for full-body people detection
- `subject` for general foreground subjects - `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 ## 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: <token>` - `X-API-Key: <token>`
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
Optional override: Optional override:
- `FACE_LOCK_AUTH_HEADER` changes the expected header name. - `FACE_LOCK_AUTH_HEADER`
## Example ## Example
```bash ```bash
curl -H 'X-API-Key: your-token' \ curl \
-H 'X-API-Key: your-token' \
-F 'file=@image.jpg' \ -F 'file=@image.jpg' \
-F 'detector=animal' \ -F 'detector=animal' \
-F 'buffer_ratio=0.2' \
http://localhost:8000/api/focus 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
```
+5 -5
View File
@@ -1,5 +1,5 @@
fastapi fastapi==0.135.3
uvicorn[standard] opencv-python-headless==4.13.0.88
opencv-python-headless python-dotenv==1.2.2
python-dotenv python-multipart==0.0.22
python-multipart uvicorn[standard]==0.41.0
+86
View File
@@ -4,6 +4,7 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import app.main as main import app.main as main
from app import __version__
class DummySettings(SimpleNamespace): class DummySettings(SimpleNamespace):
@@ -19,9 +20,12 @@ def reset_settings(monkeypatch):
"settings", "settings",
DummySettings( DummySettings(
env="test", env="test",
docs_enabled=True,
test_ui_enabled=True, test_ui_enabled=True,
auth_token="", auth_token="",
auth_header_name="X-API-Key", 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() == { assert resp.json() == {
"ok": True, "ok": True,
"env": "test", "env": "test",
"version": __version__,
"docs_enabled": True,
"test_ui_enabled": True, "test_ui_enabled": True,
"auth_enabled": False, "auth_enabled": False,
"auth_header": None, "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", "settings",
DummySettings( DummySettings(
env="test", env="test",
docs_enabled=True,
test_ui_enabled=False, test_ui_enabled=False,
auth_token="", auth_token="",
auth_header_name="X-API-Key", 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("/") resp = TestClient(main.app).get("/")
assert resp.status_code == 200 assert resp.status_code == 200
assert "Test UI is disabled." in resp.text 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): 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", "settings",
DummySettings( DummySettings(
env="test", env="test",
docs_enabled=True,
test_ui_enabled=True, test_ui_enabled=True,
auth_token="secret", auth_token="secret",
auth_header_name="X-API-Key", 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.status_code == 200
assert resp.json()["method"] == "face_cascade" assert resp.json()["method"] == "face_cascade"
assert resp.json()["detected_bbox"] == {"x": 1, "y": 2, "w": 3, "h": 4} 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): 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.status_code == 200
assert resp.headers["content-type"] == "image/jpeg" assert resp.headers["content-type"] == "image/jpeg"
assert resp.headers["content-disposition"] == 'inline; filename="sample-crop.jpg"'
assert resp.content == b"crop-bytes" assert resp.content == b"crop-bytes"