- 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>
221 lines
6.2 KiB
Go
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
|
|
}
|