feat: enhance face-lock service with improved upload handling and response structure
python / test (push) Failing after 8s
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:
@@ -4,3 +4,5 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.git/
|
.git/
|
||||||
|
.gitea/
|
||||||
|
tests/
|
||||||
|
|||||||
+9
-2
@@ -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
|
||||||
|
|||||||
@@ -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 .
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
|||||||
+62
-3
@@ -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()
|
||||||
|
|||||||
+140
-158
@@ -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 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>
|
async def _process_upload(file: UploadFile, *, buffer_ratio: float, detector: Detector) -> dict[str, object]:
|
||||||
<body class="bg-slate-950 text-slate-100 min-h-screen">
|
from app.vision import process_image
|
||||||
<main class="mx-auto max-w-6xl p-6">
|
|
||||||
<div class="mb-6 flex items-center justify-between gap-4">
|
payload = await file.read()
|
||||||
<div>
|
_validate_upload(file, payload)
|
||||||
<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>
|
try:
|
||||||
</div>
|
return process_image(
|
||||||
<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>
|
payload,
|
||||||
</div>
|
_safe_filename(file.filename),
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
buffer_ratio=buffer_ratio,
|
||||||
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-4">
|
detector=detector.value,
|
||||||
<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" />
|
except ValueError as exc:
|
||||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
<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">
|
@app.middleware("http")
|
||||||
<option value="face">Face</option>
|
async def add_response_headers(request: Request, call_next) -> Response:
|
||||||
<option value="animal">Animal</option>
|
response = await call_next(request)
|
||||||
<option value="person">Person</option>
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
<option value="subject" selected>Subject</option>
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
</select>
|
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||||
</div>
|
response.headers.setdefault("Permissions-Policy", "camera=(), geolocation=(), microphone=()")
|
||||||
<div>
|
return response
|
||||||
<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>
|
@app.get("/health", response_model=HealthResponse, tags=["meta"])
|
||||||
</div>
|
def health() -> HealthResponse:
|
||||||
<div class="mt-4">
|
return HealthResponse(
|
||||||
<label class="block text-sm text-slate-400">Auth token (only if enabled)</label>
|
env=settings.env,
|
||||||
<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" />
|
version=__version__,
|
||||||
</div>
|
docs_enabled=settings.docs_enabled,
|
||||||
<button id="go" class="mt-4 rounded-lg bg-cyan-500 px-4 py-2 font-semibold text-slate-950">Process</button>
|
test_ui_enabled=settings.test_ui_enabled,
|
||||||
<pre id="meta" class="mt-4 whitespace-pre-wrap rounded-lg bg-slate-950 p-3 text-xs text-slate-300"></pre>
|
auth_enabled=settings.auth_enabled,
|
||||||
</section>
|
auth_header=settings.auth_header_name if settings.auth_enabled else None,
|
||||||
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-4">
|
max_upload_bytes=settings.max_upload_bytes,
|
||||||
<div class="mb-3 text-sm font-semibold text-slate-400">Result</div>
|
allowed_mime_types=list(settings.allowed_mime_types),
|
||||||
<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")
|
@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(
|
async def focus(
|
||||||
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),
|
||||||
):
|
) -> dict[str, object]:
|
||||||
from app.vision import process_image
|
result = await _process_upload(file, buffer_ratio=buffer_ratio, detector=detector)
|
||||||
|
return {
|
||||||
try:
|
"filename": result["filename"],
|
||||||
payload = await file.read()
|
"detector": result["detector"],
|
||||||
result = process_image(payload, file.filename or "upload", buffer_ratio=buffer_ratio, detector=detector)
|
"method": result["method"],
|
||||||
return {
|
"buffer_ratio": result["buffer_ratio"],
|
||||||
"filename": result["filename"],
|
"detected_bbox": result["detected_bbox"],
|
||||||
"detector": result["detector"],
|
"square_bbox": result["square_bbox"],
|
||||||
"method": result["method"],
|
"source_size": result["source_size"],
|
||||||
"buffer_ratio": result["buffer_ratio"],
|
"mime_type": result["mime_type"],
|
||||||
"detected_bbox": result["detected_bbox"],
|
"crop_data_url": result["crop_data_url"],
|
||||||
"square_bbox": result["square_bbox"],
|
"annotated_data_url": result["annotated_data_url"],
|
||||||
"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
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user