hatch-surf/internal/face/service.go
Chris Anderson 4bf37faf4f Add face service package for AWS Rekognition integration
- Client interface and MockClient for testing
- Service layer with IndexPhoto and SearchFaces methods
- CacheService interface and MemoryCache implementation
- Configuration with environment variable support
- Error types and user-facing messages
- Unit tests for service layer

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-25 21:11:45 +02:00

221 lines
6.2 KiB
Go

package face
import (
"context"
"crypto/sha256"
"fmt"
"log"
"time"
)
// PhotoRepository defines the photo operations needed by the face service
type PhotoRepository interface {
GetPhoto(ctx context.Context, id string) (*PhotoInfo, error)
CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbeddingInfo) (*FaceEmbeddingInfo, error)
}
// PhotoInfo represents photo metadata needed by the face service
type PhotoInfo struct {
ID string `json:"id"`
SpaceID string `json:"spaceId"`
URL string `json:"url"`
ThumbnailURL string `json:"thumbnailUrl"`
FaceCount int `json:"faceCount"`
}
// FaceEmbeddingInfo represents a face embedding record
type FaceEmbeddingInfo struct {
ID string `json:"id"`
PhotoID string `json:"photoId"`
SpaceID string `json:"spaceId"`
Embedding []float32 `json:"embedding"`
BoundingBox string `json:"boundingBox"`
CreatedAt time.Time `json:"createdAt"`
}
// Service provides face detection, indexing, and search operations
type Service struct {
client Client
repo PhotoRepository
cache CacheService
config *Config
keys CacheKeys
}
// NewService creates a new face service
func NewService(client Client, repo PhotoRepository, cache CacheService, config *Config) *Service {
return &Service{
client: client,
repo: repo,
cache: cache,
config: config,
}
}
// ValidateImage validates that image bytes are a valid image
func (s *Service) ValidateImage(imageBytes []byte) error {
if len(imageBytes) > s.config.MaxImageSize {
return ErrImageTooLarge
}
if len(imageBytes) < 100 { // Minimum viable image size
return ErrImageTooSmall
}
// Check for valid image headers
if len(imageBytes) < 4 {
return ErrInvalidImageFormat
}
// JPEG: starts with FF D8 FF
// PNG: starts with 89 50 4E 47
// WebP: starts with 52 49 46 46 (RIFF)
if imageBytes[0] == 0xFF && imageBytes[1] == 0xD8 && imageBytes[2] == 0xFF {
return nil // JPEG
}
if imageBytes[0] == 0x89 && imageBytes[1] == 0x50 && imageBytes[2] == 0x4E && imageBytes[3] == 0x47 {
return nil // PNG
}
if imageBytes[0] == 0x52 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46 && imageBytes[3] == 0x46 {
return nil // WebP (RIFF)
}
return ErrInvalidImageFormat
}
// IndexPhoto indexes all faces in a photo
func (s *Service) IndexPhoto(ctx context.Context, photoID string, imageBytes []byte) error {
// Check if already indexed
indexKey := s.keys.Indexed(photoID)
if indexed, _ := s.cache.Get(ctx, indexKey); indexed == "true" {
return nil
}
// Detect faces
detections, err := s.client.DetectFaces(ctx, imageBytes)
if err != nil {
return fmt.Errorf("failed to detect faces: %w", err)
}
if len(detections) == 0 {
log.Printf("No faces detected in photo %s", photoID)
return nil
}
// Get photo metadata
photo, err := s.repo.GetPhoto(ctx, photoID)
if err != nil {
return fmt.Errorf("failed to get photo: %w", err)
}
// Index each face with confidence > 99%
indexedCount := 0
for _, detection := range detections {
if detection.Confidence < 99.0 {
continue
}
// Index face in Rekognition
_, err := s.client.IndexFace(ctx, imageBytes, photoID)
if err != nil {
log.Printf("Failed to index face in photo %s: %v", photoID, err)
continue
}
// Store in database
embedding := &FaceEmbeddingInfo{
PhotoID: photoID,
SpaceID: photo.SpaceID,
Embedding: []float32{}, // Rekognition handles embeddings internally
BoundingBox: fmt.Sprintf("%.2f,%.2f,%.2f,%.2f",
detection.BoundingBox.Left,
detection.BoundingBox.Top,
detection.BoundingBox.Width,
detection.BoundingBox.Height),
}
if _, err := s.repo.CreateFaceEmbedding(ctx, embedding); err != nil {
log.Printf("Failed to store face embedding for photo %s: %v", photoID, err)
continue
}
indexedCount++
}
// Mark as indexed in cache
s.cache.Set(ctx, indexKey, "true", s.config.PhotoCacheTTL)
log.Printf("Indexed %d faces in photo %s", indexedCount, photoID)
return nil
}
// SearchFaces searches for faces in a space
func (s *Service) SearchFaces(ctx context.Context, spaceID string, imageBytes []byte, limit int) (*FaceSearchResult, error) {
startTime := time.Now()
// Check cache for similar searches
imageHash := computeHash(imageBytes)
cacheKey := s.keys.Search(spaceID, imageHash)
var cachedResult FaceSearchResult
if err := s.cache.GetJSON(ctx, cacheKey, &cachedResult); err == nil && cachedResult.Matches != nil {
cachedResult.CacheHit = true
cachedResult.SearchTime = time.Since(startTime)
return &cachedResult, nil
}
// Search in Rekognition
matches, err := s.client.SearchFacesByImage(ctx, imageBytes, limit, s.config.Threshold)
if err != nil {
return nil, fmt.Errorf("failed to search faces: %w", err)
}
// Get photo metadata for matches
var resultMatches []FaceMatch
for _, match := range matches {
// Get photo ID from external image ID
photoID := match.ExternalImageID
photo, err := s.repo.GetPhoto(ctx, photoID)
if err != nil {
log.Printf("Failed to get photo %s: %v", photoID, err)
continue
}
// Enrich match with photo data
match.PhotoID = photoID
match.PhotoURL = photo.URL
match.ThumbnailURL = photo.ThumbnailURL
resultMatches = append(resultMatches, match)
}
// Build result
result := &FaceSearchResult{
Matches: resultMatches,
PhotoCount: len(resultMatches),
SearchTime: time.Since(startTime),
CacheHit: false,
}
// Cache result
s.cache.SetJSON(ctx, cacheKey, result, s.config.SearchCacheTTL)
return result, nil
}
// CreateCollection creates a face collection for a space
func (s *Service) CreateCollection(ctx context.Context, collectionName string) error {
return s.client.CreateCollection(ctx, collectionName)
}
// DeleteCollection deletes a face collection for a space
func (s *Service) DeleteCollection(ctx context.Context, collectionName string) error {
return s.client.DeleteCollection(ctx, collectionName)
}
// computeHash computes a SHA256 hash of the image bytes for caching
func computeHash(data []byte) string {
// Use first 1KB for quick hashing (full hash is expensive for large images)
sampleSize := 1024
if len(data) < sampleSize {
sampleSize = len(data)
}
hash := sha256.Sum256(data[:sampleSize])
return fmt.Sprintf("%x", hash[:8]) // Use first 8 bytes for shorter key
}