Skip to content

Commit 6ea9247

Browse files
committed
progress
1 parent f9afdd7 commit 6ea9247

27 files changed

Lines changed: 418 additions & 84 deletions

File tree

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ class-leak:
166166
docker-build:
167167
docker build -t lychee-frankenphp .
168168

169+
docker-build-legacy:
170+
docker build -t lychee-frankenphp -f Dockerfile-legacy .
171+
169172
docker-build-no-cache:
170173
docker build -t lychee-frankenphp . --no-cache
171174

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!.gitignore

ai-vision-service/face-recognition/README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,22 +80,49 @@ All variables are prefixed `VISION_FACE_`.
8080

8181
## Development
8282

83+
### Setup
84+
8385
```bash
8486
# Install uv (https://docs.astral.sh/uv/getting-started/installation/)
8587
curl -LsSf https://astral.sh/uv/install.sh | sh
8688

8789
# Install all dependencies (including dev)
8890
uv sync
8991

92+
# Configure .env file (create or edit .env in this directory)
93+
# Minimum required variables:
94+
# VISION_FACE_LYCHEE_API_URL=https://lychee.test
95+
# VISION_FACE_API_KEY=changeme
96+
# VISION_FACE_VERIFY_SSL=false
97+
# VISION_FACE_PHOTOS_PATH=../../public/uploads
98+
```
99+
100+
### Running locally
101+
102+
```bash
103+
# Using uv run (recommended)
104+
uv run python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
105+
```
106+
107+
The service will be available at http://localhost:8000
108+
- API docs: http://localhost:8000/docs
109+
- Health check: http://localhost:8000/health
110+
111+
### Linting and testing
112+
113+
```bash
90114
# Lint and format
91-
uv run ruff format --check
92-
uv run ruff check
115+
uv run ruff format
116+
uv run ruff check --fix
93117

94118
# Type check
95119
uv run ty check
96120

97121
# Run tests
98122
uv run pytest
123+
124+
# Run tests with coverage
125+
uv run pytest --cov=app --cov-report=html
99126
```
100127

101128
## Docker

ai-vision-service/face-recognition/app/api/routes.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,25 @@ async def match(
118118
store: EmbeddingStore = get_store(request)
119119
executor: Executor = request.app.state.executor
120120

121+
logger.info("Processing selfie match request (%d bytes)", len(image_bytes))
122+
121123
loop = asyncio.get_running_loop()
122124
raw_faces: list[DetectedFace] = await loop.run_in_executor(executor, detector.detect_bytes, image_bytes)
123125

124126
if not raw_faces:
127+
logger.warning("No face detected in uploaded selfie image")
125128
raise HTTPException(status_code=422, detail="No face detected in the uploaded image")
126129

127130
best = raw_faces[0] # highest confidence (sorted descending)
128131
matches = store.similarity_search(best.embedding, settings.match_threshold, limit=10)
129132

133+
logger.info(
134+
"Selfie match found %d match(es) above threshold %.2f (detected face confidence: %.3f)",
135+
len(matches),
136+
settings.match_threshold,
137+
best.confidence,
138+
)
139+
130140
return MatchResponse(matches=[MatchResult(lychee_face_id=face_id, confidence=conf) for face_id, conf in matches])
131141

132142

@@ -221,13 +231,24 @@ async def _run_detection_job(
221231
returned 202. All CPU-bound work is offloaded to ``executor`` via
222232
``run_in_executor`` so the event loop remains responsive.
223233
"""
234+
logger.info("Starting detection job for photo_id=%s, path=%s", photo_id, image_path)
224235
try:
225236
loop = asyncio.get_running_loop()
226237

227238
# --- 1. Detect faces (CPU-bound, runs in thread pool) ---
228239
raw_faces: list[DetectedFace] = await loop.run_in_executor(executor, detector.detect, image_path)
240+
241+
if len(raw_faces) > settings.max_faces_per_photo:
242+
logger.info(
243+
"Limiting faces from %d to %d (max_faces_per_photo setting)",
244+
len(raw_faces),
245+
settings.max_faces_per_photo,
246+
)
229247
raw_faces = raw_faces[: settings.max_faces_per_photo]
230248

249+
if not raw_faces:
250+
logger.info("No faces detected in photo_id=%s, sending empty results", photo_id)
251+
231252
# --- 2. For each face: generate crop + search suggestions ---
232253
face_data: list[tuple[str, list[float], FaceResult]] = []
233254

@@ -246,6 +267,13 @@ async def _run_detection_job(
246267

247268
suggestions = store.similarity_search(raw_face.embedding, settings.match_threshold, limit=10)
248269

270+
if suggestions:
271+
logger.debug(
272+
"Found %d suggestion(s) for face with confidence=%.3f",
273+
len(suggestions),
274+
raw_face.confidence,
275+
)
276+
249277
result = FaceResult(
250278
x=raw_face.x,
251279
y=raw_face.y,
@@ -279,6 +307,12 @@ async def _run_detection_job(
279307
response.raise_for_status()
280308
callback_resp = DetectCallbackResponse.model_validate(response.json())
281309

310+
logger.info(
311+
"Successfully sent detection results to Lychee for photo_id=%s (%d face(s))",
312+
photo_id,
313+
len(face_data),
314+
)
315+
282316
# --- 4. Persist embeddings now that we have stable lychee_face_ids ---
283317
id_to_vector: dict[str, list[float]] = {eid: vec for eid, vec, _ in face_data}
284318
for mapping in callback_resp.faces:

ai-vision-service/face-recognition/app/config.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
from functools import lru_cache
8+
from pathlib import Path
89

910
from pydantic_settings import BaseSettings, SettingsConfigDict
1011

@@ -94,16 +95,25 @@ class AppSettings(BaseSettings):
9495
Lower values produce tighter, more homogeneous clusters."""
9596

9697
# --- Quality filtering ---
97-
blur_threshold: float = 100.0
98+
blur_threshold: float = 0.5
9899
"""Laplacian variance threshold for blur detection.
99100
Face crops with a variance below this value are discarded before embedding."""
100101

102+
model_root: str = "/root/.insightface"
103+
"""Root directory for InsightFace model packs. Defaults to the library's default (``~/.insightface``)
104+
but can be overridden to point to a shared Docker volume if desired."""
105+
101106
model_config = SettingsConfigDict(
102107
env_prefix="VISION_FACE_",
103108
# Support .env files in development but never require them in production.
104-
env_file=".env",
109+
# Load project root .env first (fallback), then working directory .env (override)
110+
env_file=(
111+
Path(__file__).parent.parent / ".env", # Project root (fallback)
112+
".env", # Current working directory (takes precedence)
113+
),
105114
env_file_encoding="utf-8",
106115
case_sensitive=False,
116+
extra="ignore", # Ignore extra fields (e.g., from Lychee's .env when running from main project)
107117
)
108118

109119

ai-vision-service/face-recognition/app/detection/cropper.py

Lines changed: 111 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -21,38 +21,129 @@
2121
"""Fractional padding added around each bounding box side before cropping."""
2222

2323

24-
def generate_crop(image_path: Path, x: float, y: float, width: float, height: float) -> str:
25-
"""Generate a base64-encoded 150 x 150 JPEG face crop.
24+
def _calculate_square_crop_coords(
25+
x: float, y: float, width: float, height: float, img_w: int, img_h: int, padding_factor: float
26+
) -> tuple[int, int, int, int]:
27+
"""Calculate square crop coordinates centered on the face bounding box.
28+
29+
Attempts to create a square crop centered on the face. If the square would
30+
extend beyond image boundaries, it shifts the crop to fit. If the square is
31+
larger than the image dimensions, the crop will be the maximum square that
32+
fits within the image.
2633
2734
Args:
28-
image_path: Absolute path to the source image.
2935
x: Normalised left edge of the bounding box (0.0-1.0).
3036
y: Normalised top edge of the bounding box (0.0-1.0).
3137
width: Normalised bounding-box width (0.0-1.0).
3238
height: Normalised bounding-box height (0.0-1.0).
39+
img_w: Image width in pixels.
40+
img_h: Image height in pixels.
41+
padding_factor: Fractional padding to add around the bounding box.
3342
3443
Returns:
35-
Base64-encoded JPEG bytes (ASCII string).
44+
Tuple of (x1, y1, x2, y2) defining a square crop region in absolute pixels.
3645
"""
37-
img = Image.open(image_path).convert("RGB")
38-
img_w, img_h = img.size
39-
40-
# Absolute pixel coordinates
46+
# Convert to absolute pixels
4147
abs_x = x * img_w
4248
abs_y = y * img_h
4349
abs_w = width * img_w
4450
abs_h = height * img_h
4551

4652
# Add padding
47-
pad_x = abs_w * _PADDING_FACTOR
48-
pad_y = abs_h * _PADDING_FACTOR
53+
pad_x = abs_w * padding_factor
54+
pad_y = abs_h * padding_factor
55+
56+
padded_x = abs_x - pad_x
57+
padded_y = abs_y - pad_y
58+
padded_w = abs_w + 2 * pad_x
59+
padded_h = abs_h + 2 * pad_y
60+
61+
# Determine square size (use the larger dimension)
62+
square_size = max(padded_w, padded_h)
63+
64+
# Cap square size to image dimensions (can't crop larger than the image)
65+
max_possible_size = min(img_w, img_h)
66+
square_size = min(square_size, max_possible_size)
67+
68+
# Calculate center point of the padded bounding box
69+
center_x = padded_x + padded_w / 2
70+
center_y = padded_y + padded_h / 2
71+
72+
# Calculate square crop coordinates centered on the face
73+
x1 = center_x - square_size / 2
74+
y1 = center_y - square_size / 2
75+
x2 = center_x + square_size / 2
76+
y2 = center_y + square_size / 2
77+
78+
# Adjust to keep square within image boundaries
79+
# If the square extends beyond the left edge, shift it right
80+
if x1 < 0:
81+
shift = -x1
82+
x1 = 0
83+
x2 = min(float(img_w), x2 + shift)
84+
# If the square extends beyond the right edge, shift it left
85+
if x2 > img_w:
86+
shift = x2 - img_w
87+
x2 = img_w
88+
x1 = max(0.0, x1 - shift)
89+
90+
# If the square extends beyond the top edge, shift it down
91+
if y1 < 0:
92+
shift = -y1
93+
y1 = 0
94+
y2 = min(float(img_h), y2 + shift)
95+
# If the square extends beyond the bottom edge, shift it up
96+
if y2 > img_h:
97+
shift = y2 - img_h
98+
y2 = img_h
99+
y1 = max(0.0, y1 - shift)
100+
101+
return int(x1), int(y1), int(x2), int(y2)
102+
103+
104+
def _pad_to_square(img: Image.Image) -> Image.Image:
105+
"""Pad a non-square image to square with black borders.
49106
50-
x1 = max(0.0, abs_x - pad_x)
51-
y1 = max(0.0, abs_y - pad_y)
52-
x2 = min(float(img_w), abs_x + abs_w + pad_x)
53-
y2 = min(float(img_h), abs_y + abs_h + pad_y)
107+
Args:
108+
img: Input PIL Image.
54109
55-
crop = img.crop((int(x1), int(y1), int(x2), int(y2)))
110+
Returns:
111+
Square PIL Image with black padding if needed.
112+
"""
113+
width, height = img.size
114+
if width == height:
115+
return img
116+
117+
size = max(width, height)
118+
square_img = Image.new("RGB", (size, size), (0, 0, 0))
119+
paste_x = (size - width) // 2
120+
paste_y = (size - height) // 2
121+
square_img.paste(img, (paste_x, paste_y))
122+
return square_img
123+
124+
125+
def generate_crop(image_path: Path, x: float, y: float, width: float, height: float) -> str:
126+
"""Generate a base64-encoded 150 x 150 JPEG face crop.
127+
128+
Args:
129+
image_path: Absolute path to the source image.
130+
x: Normalised left edge of the bounding box (0.0-1.0).
131+
y: Normalised top edge of the bounding box (0.0-1.0).
132+
width: Normalised bounding-box width (0.0-1.0).
133+
height: Normalised bounding-box height (0.0-1.0).
134+
135+
Returns:
136+
Base64-encoded JPEG bytes (ASCII string).
137+
"""
138+
img = Image.open(image_path).convert("RGB")
139+
img_w, img_h = img.size
140+
141+
# Calculate square crop coordinates
142+
x1, y1, x2, y2 = _calculate_square_crop_coords(x, y, width, height, img_w, img_h, _PADDING_FACTOR)
143+
144+
# Crop and ensure it's square (pad if needed due to edge constraints)
145+
crop = img.crop((x1, y1, x2, y2))
146+
crop = _pad_to_square(crop)
56147
crop = crop.resize((CROP_SIZE, CROP_SIZE), Image.Resampling.LANCZOS)
57148

58149
buf = io.BytesIO()
@@ -76,20 +167,12 @@ def generate_crop_from_bytes(image_bytes: bytes, x: float, y: float, width: floa
76167
img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
77168
img_w, img_h = img.size
78169

79-
abs_x = x * img_w
80-
abs_y = y * img_h
81-
abs_w = width * img_w
82-
abs_h = height * img_h
83-
84-
pad_x = abs_w * _PADDING_FACTOR
85-
pad_y = abs_h * _PADDING_FACTOR
86-
87-
x1 = max(0.0, abs_x - pad_x)
88-
y1 = max(0.0, abs_y - pad_y)
89-
x2 = min(float(img_w), abs_x + abs_w + pad_x)
90-
y2 = min(float(img_h), abs_y + abs_h + pad_y)
170+
# Calculate square crop coordinates
171+
x1, y1, x2, y2 = _calculate_square_crop_coords(x, y, width, height, img_w, img_h, _PADDING_FACTOR)
91172

92-
crop = img.crop((int(x1), int(y1), int(x2), int(y2)))
173+
# Crop and ensure it's square (pad if needed due to edge constraints)
174+
crop = img.crop((x1, y1, x2, y2))
175+
crop = _pad_to_square(crop)
93176
crop = crop.resize((CROP_SIZE, CROP_SIZE), Image.Resampling.LANCZOS)
94177

95178
buf = io.BytesIO()

0 commit comments

Comments
 (0)