From 4bf37faf4f48201856be97ce17caa6a935800b8e Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Thu, 25 Jun 2026 21:11:45 +0200 Subject: [PATCH] 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 --- internal/face/cache.go | 153 +++++++++++++++++ internal/face/client.go | 191 +++++++++++++++++++++ internal/face/config.go | 102 +++++++++++ internal/face/errors.go | 94 ++++++++++ internal/face/models.go | 35 ++++ internal/face/rekognition.go | 218 +++++++++++++++++++++++ internal/face/service.go | 220 ++++++++++++++++++++++++ internal/face/service_test.go | 315 ++++++++++++++++++++++++++++++++++ 8 files changed, 1328 insertions(+) create mode 100644 internal/face/cache.go create mode 100644 internal/face/client.go create mode 100644 internal/face/config.go create mode 100644 internal/face/errors.go create mode 100644 internal/face/models.go create mode 100644 internal/face/rekognition.go create mode 100644 internal/face/service.go create mode 100644 internal/face/service_test.go diff --git a/internal/face/cache.go b/internal/face/cache.go new file mode 100644 index 00000000..f75cf163 --- /dev/null +++ b/internal/face/cache.go @@ -0,0 +1,153 @@ +package face + +import ( + "context" + "encoding/json" + "sync" + "time" +) + +// CacheService defines the interface for caching face search results +type CacheService interface { + // Get retrieves a cached value by key + Get(ctx context.Context, key string) (string, error) + + // Set stores a value with expiration + Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error + + // Delete removes cached values + Delete(ctx context.Context, keys ...string) error + + // GetJSON retrieves and unmarshals a cached JSON value + GetJSON(ctx context.Context, key string, dest interface{}) error + + // SetJSON marshals and stores a JSON value + SetJSON(ctx context.Context, key string, value interface{}, expiration time.Duration) error +} + +// MemoryCache is an in-memory cache implementation for testing and development +type MemoryCache struct { + mu sync.RWMutex + entries map[string]*cacheEntry +} + +type cacheEntry struct { + value string + expiresAt time.Time +} + +// NewMemoryCache creates a new in-memory cache +func NewMemoryCache() *MemoryCache { + return &MemoryCache{ + entries: make(map[string]*cacheEntry), + } +} + +// Get retrieves a value from the cache +func (c *MemoryCache) Get(ctx context.Context, key string) (string, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + entry, ok := c.entries[key] + if !ok { + return "", nil + } + + if time.Now().After(entry.expiresAt) { + return "", nil + } + + return entry.value, nil +} + +// Set stores a value in the cache +func (c *MemoryCache) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { + c.mu.Lock() + defer c.mu.Unlock() + + var strValue string + switch v := value.(type) { + case string: + strValue = v + default: + bytes, err := json.Marshal(v) + if err != nil { + return err + } + strValue = string(bytes) + } + + c.entries[key] = &cacheEntry{ + value: strValue, + expiresAt: time.Now().Add(expiration), + } + + return nil +} + +// Delete removes values from the cache +func (c *MemoryCache) Delete(ctx context.Context, keys ...string) error { + c.mu.Lock() + defer c.mu.Unlock() + + for _, key := range keys { + delete(c.entries, key) + } + + return nil +} + +// GetJSON retrieves and unmarshals a cached JSON value +func (c *MemoryCache) GetJSON(ctx context.Context, key string, dest interface{}) error { + value, err := c.Get(ctx, key) + if err != nil { + return err + } + + if value == "" { + return nil + } + + return json.Unmarshal([]byte(value), dest) +} + +// SetJSON marshals and stores a JSON value +func (c *MemoryCache) SetJSON(ctx context.Context, key string, value interface{}, expiration time.Duration) error { + return c.Set(ctx, key, value, expiration) +} + +// Cleanup removes expired entries (call periodically) +func (c *MemoryCache) Cleanup() { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + for key, entry := range c.entries { + if now.After(entry.expiresAt) { + delete(c.entries, key) + } + } +} + +// CacheKeys returns common cache key patterns +type CacheKeys struct{} + +// Search returns the cache key for search results +func (CacheKeys) Search(spaceID string, imageHash string) string { + return "search:" + spaceID + ":" + imageHash +} + +// Photo returns the cache key for photo metadata +func (CacheKeys) Photo(photoID string) string { + return "photo:" + photoID +} + +// Face returns the cache key for face embeddings +func (CacheKeys) Face(faceID string) string { + return "face:" + faceID +} + +// Indexed returns the cache key for indexed photos +func (CacheKeys) Indexed(photoID string) string { + return "indexed:" + photoID +} diff --git a/internal/face/client.go b/internal/face/client.go new file mode 100644 index 00000000..79f37beb --- /dev/null +++ b/internal/face/client.go @@ -0,0 +1,191 @@ +package face + +import ( + "context" + "fmt" + "sync" + "time" +) + +// Client defines the interface for face detection, indexing, and search operations +type Client interface { + // CreateCollection creates a new face collection for a space + CreateCollection(ctx context.Context, collectionName string) error + + // DeleteCollection deletes a face collection + DeleteCollection(ctx context.Context, collectionName string) error + + // DetectFaces detects faces in an image + DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error) + + // IndexFace indexes a face in a collection + IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error) + + // SearchFacesByImage searches for similar faces in the collection + SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error) +} + +// Compile-time check that RekognitionClient implements Client +var _ Client = (*RekognitionClient)(nil) + +// MockClient is a mock implementation of the Client interface for testing +type MockClient struct { + mu sync.RWMutex + collections map[string]*CollectionInfo + faces map[string][]FaceIndex + detections []FaceDetection + searchResults []FaceMatch + + // Mock configuration + DetectFacesFunc func(ctx context.Context, imageBytes []byte) ([]FaceDetection, error) + IndexFaceFunc func(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error) + SearchFacesFunc func(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error) +} + +// FaceIndex represents an indexed face (internal to mock) +type FaceIndex struct { + FaceID string `json:"faceId"` + ExternalImageID string `json:"externalImageId"` + Confidence float64 `json:"confidence"` + BoundingBox BoundingBox `json:"boundingBox"` + IndexedAt time.Time `json:"indexedAt"` +} + +// NewMockClient creates a new mock client for testing +func NewMockClient() *MockClient { + return &MockClient{ + collections: make(map[string]*CollectionInfo), + faces: make(map[string][]FaceIndex), + } +} + +// CreateCollection creates a mock collection +func (m *MockClient) CreateCollection(ctx context.Context, collectionName string) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.collections[collectionName] = &CollectionInfo{ + CollectionID: collectionName, + FaceCount: 0, + CreationDate: time.Now(), + LastUpdated: time.Now(), + } + return nil +} + +// DeleteCollection deletes a mock collection +func (m *MockClient) DeleteCollection(ctx context.Context, collectionName string) error { + m.mu.Lock() + defer m.mu.Unlock() + + delete(m.collections, collectionName) + delete(m.faces, collectionName) + return nil +} + +// DetectFaces returns mock face detections +func (m *MockClient) DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error) { + if m.DetectFacesFunc != nil { + return m.DetectFacesFunc(ctx, imageBytes) + } + + m.mu.RLock() + defer m.mu.RUnlock() + + // Return mock detections + if len(m.detections) > 0 { + return m.detections, nil + } + + // Default: return one face + return []FaceDetection{ + { + Confidence: 99.5, + BoundingBox: BoundingBox{ + Left: 0.1, + Top: 0.1, + Width: 0.3, + Height: 0.3, + }, + }, + }, nil +} + +// IndexFace indexes a mock face +func (m *MockClient) IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error) { + if m.IndexFaceFunc != nil { + return m.IndexFaceFunc(ctx, imageBytes, photoID) + } + + m.mu.Lock() + defer m.mu.Unlock() + + detection := &FaceDetection{ + Confidence: 99.5, + BoundingBox: BoundingBox{ + Left: 0.1, + Top: 0.1, + Width: 0.3, + Height: 0.3, + }, + } + + // Store in mock database + faceIndex := FaceIndex{ + FaceID: fmt.Sprintf("face-%s-%d", photoID, time.Now().UnixNano()), + ExternalImageID: photoID, + Confidence: 99.5, + BoundingBox: detection.BoundingBox, + IndexedAt: time.Now(), + } + m.faces["hatch-default"] = append(m.faces["hatch-default"], faceIndex) + + return detection, nil +} + +// SearchFacesByImage returns mock search results +func (m *MockClient) SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error) { + if m.SearchFacesFunc != nil { + return m.SearchFacesFunc(ctx, imageBytes, maxFaces, threshold) + } + + m.mu.RLock() + defer m.mu.RUnlock() + + // Return mock matches + if len(m.searchResults) > 0 { + if maxFaces > 0 && len(m.searchResults) > maxFaces { + return m.searchResults[:maxFaces], nil + } + return m.searchResults, nil + } + + // Default: return one match + return []FaceMatch{ + { + FaceID: "face-mock-1", + ExternalImageID: "photo-1", + Similarity: 95.0, + BoundingBox: BoundingBox{ + Left: 0.1, + Top: 0.1, + Width: 0.3, + Height: 0.3, + }, + }, + }, nil +} + +// SetDetections configures mock face detections +func (m *MockClient) SetDetections(detections []FaceDetection) { + m.mu.Lock() + defer m.mu.Unlock() + m.detections = detections +} + +// SetSearchResults configures mock search results +func (m *MockClient) SetSearchResults(results []FaceMatch) { + m.mu.Lock() + defer m.mu.Unlock() + m.searchResults = results +} diff --git a/internal/face/config.go b/internal/face/config.go new file mode 100644 index 00000000..30e6a0d3 --- /dev/null +++ b/internal/face/config.go @@ -0,0 +1,102 @@ +package face + +import ( + "os" + "strconv" + "time" +) + +// Config holds configuration for the face service +type Config struct { + // AWS Configuration + Region string + CollectionID string + + // Face Detection Configuration + MaxFaces int + Threshold float64 + QualityFilter string + + // Cache Configuration + CacheTTL time.Duration + SearchCacheTTL time.Duration + PhotoCacheTTL time.Duration + + // Rate Limiting + GuestRateLimit int + GuestRateWindowMin int + AuthRateLimit int + AuthRateWindowMin int + + // Image Optimization + MaxImageSize int + ResizeWidth int + ResizeHeight int + ImageQuality int +} + +// DefaultConfig returns default configuration +func DefaultConfig() *Config { + return &Config{ + Region: getEnv("AWS_REGION", "us-east-1"), + CollectionID: getEnv("REKOGNITION_COLLECTION", "hatch-faces"), + MaxFaces: getEnvInt("REKOGNITION_MAX_FACES", 10), + Threshold: getEnvFloat("REKOGNITION_THRESHOLD", 80.0), + QualityFilter: getEnv("REKOGNITION_QUALITY_FILTER", "AUTO"), + CacheTTL: 5 * time.Minute, + SearchCacheTTL: 5 * time.Minute, + PhotoCacheTTL: 24 * time.Hour, + GuestRateLimit: getEnvInt("RATE_LIMIT_GUEST_SEARCHES", 20), + GuestRateWindowMin: getEnvInt("RATE_LIMIT_GUEST_WINDOW_MINUTES", 15), + AuthRateLimit: getEnvInt("RATE_LIMIT_AUTH_SEARCHES", 100), + AuthRateWindowMin: getEnvInt("RATE_LIMIT_AUTH_WINDOW_MINUTES", 1), + MaxImageSize: 10 * 1024 * 1024, // 10MB + ResizeWidth: 1024, + ResizeHeight: 1024, + ImageQuality: 85, + } +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + if c.Region == "" { + return ErrMissingRegion + } + if c.CollectionID == "" { + return ErrMissingCollectionID + } + if c.MaxFaces <= 0 { + return ErrInvalidMaxFaces + } + if c.Threshold < 0 || c.Threshold > 100 { + return ErrInvalidThreshold + } + return nil +} + +// Helper functions for environment variables + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} + +func getEnvFloat(key string, defaultValue float64) float64 { + if value := os.Getenv(key); value != "" { + if floatValue, err := strconv.ParseFloat(value, 64); err == nil { + return floatValue + } + } + return defaultValue +} diff --git a/internal/face/errors.go b/internal/face/errors.go new file mode 100644 index 00000000..06b435b8 --- /dev/null +++ b/internal/face/errors.go @@ -0,0 +1,94 @@ +package face + +import "errors" + +// Sentinel errors for face operations +var ( + // Client errors + ErrNoFacesDetected = errors.New("no faces detected in image") + ErrImageTooSmall = errors.New("image too small for face detection") + ErrImageTooLarge = errors.New("image exceeds maximum size") + ErrInvalidImageFormat = errors.New("invalid image format") + ErrImageProcessingFailed = errors.New("failed to process image") + + // Collection errors + ErrCollectionNotFound = errors.New("face collection not found") + ErrCollectionExists = errors.New("face collection already exists") + ErrCollectionCreationFailed = errors.New("failed to create face collection") + + // Indexing errors + ErrIndexingFailed = errors.New("failed to index face") + ErrFaceAlreadyIndexed = errors.New("face already indexed") + + // Search errors + ErrSearchFailed = errors.New("face search failed") + ErrNoMatchesFound = errors.New("no matching faces found") + + // Configuration errors + ErrMissingRegion = errors.New("AWS region is required") + ErrMissingCollectionID = errors.New("collection ID is required") + ErrInvalidMaxFaces = errors.New("max faces must be positive") + ErrInvalidThreshold = errors.New("threshold must be between 0 and 100") + + // Rate limiting errors + ErrRateLimitExceeded = errors.New("rate limit exceeded") + + // AWS service errors + ErrAWSServiceError = errors.New("AWS Rekognition service error") + ErrAWSAccessDenied = errors.New("AWS access denied") + ErrAWSThrottling = errors.New("AWS request throttled") + ErrAWSInvalidImage = errors.New("invalid image for AWS Rekognition") +) + +// FaceSearchError represents a structured error for face search operations +type FaceSearchError struct { + Code string `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` +} + +// Error implements the error interface +func (e *FaceSearchError) Error() string { + return e.Message +} + +// NewFaceSearchError creates a new FaceSearchError +func NewFaceSearchError(code, message, details string) *FaceSearchError { + return &FaceSearchError{ + Code: code, + Message: message, + Details: details, + } +} + +// Common error codes +const ( + ErrorCodeNoFaces = "NO_FACES_DETECTED" + ErrorCodeInvalidImage = "INVALID_IMAGE" + ErrorCodeRateLimited = "RATE_LIMITED" + ErrorCodeCollectionError = "COLLECTION_ERROR" + ErrorCodeSearchFailed = "SEARCH_FAILED" + ErrorCodeIndexingFailed = "INDEXING_FAILED" + ErrorCodeInternalError = "INTERNAL_ERROR" +) + +// User-facing error messages +var UserErrorMessages = map[error]string{ + ErrNoFacesDetected: "No faces detected in the image. Please upload a photo with clear faces.", + ErrImageTooSmall: "Image is too small. Please upload a larger image (minimum 100x100 pixels).", + ErrImageTooLarge: "Image is too large. Please upload an image smaller than 10MB.", + ErrInvalidImageFormat: "Invalid image format. Please upload a JPEG, PNG, or WebP image.", + ErrCollectionNotFound: "Face collection not found. Please contact support.", + ErrRateLimitExceeded: "Search limit exceeded. Please try again later.", + ErrAWSServiceError: "An error occurred while processing your request. Please try again.", + ErrAWSAccessDenied: "Access denied. Please contact support.", + ErrAWSThrottling: "Service is busy. Please try again in a few moments.", +} + +// GetUserMessage returns a user-friendly error message +func GetUserMessage(err error) string { + if msg, ok := UserErrorMessages[err]; ok { + return msg + } + return "An unexpected error occurred. Please try again." +} diff --git a/internal/face/models.go b/internal/face/models.go new file mode 100644 index 00000000..0e0139e5 --- /dev/null +++ b/internal/face/models.go @@ -0,0 +1,35 @@ +package face + +import "time" + +// FaceSearchResult represents the result of a face search +type FaceSearchResult struct { + Matches []FaceMatch `json:"matches"` + PhotoCount int `json:"photoCount"` + SearchTime time.Duration `json:"searchTime"` + CacheHit bool `json:"cacheHit"` +} + +// CollectionInfo represents information about a face collection +type CollectionInfo struct { + CollectionID string `json:"collectionId"` + FaceCount int `json:"faceCount"` + CreationDate time.Time `json:"creationDate"` + LastUpdated time.Time `json:"lastUpdated"` +} + +// SearchRequest represents a face search request +type SearchRequest struct { + ImageBytes []byte `json:"-"` + ImageURL string `json:"imageUrl,omitempty"` + MaxFaces int `json:"maxFaces,omitempty"` + Threshold float64 `json:"threshold,omitempty"` +} + +// IndexRequest represents a face indexing request +type IndexRequest struct { + PhotoID string `json:"photoId"` + SpaceID string `json:"spaceId"` + ImageBytes []byte `json:"-"` + ImageURL string `json:"imageUrl,omitempty"` +} diff --git a/internal/face/rekognition.go b/internal/face/rekognition.go new file mode 100644 index 00000000..54d748cb --- /dev/null +++ b/internal/face/rekognition.go @@ -0,0 +1,218 @@ +package face + +import ( + "context" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "bytes" + "io" + "mime/multipart" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/rekognition" + "github.com/aws/aws-sdk-go-v2/service/rekognition/types" +) + +// RekognitionClient wraps AWS Rekognition operations +type RekognitionClient struct { + client *rekognition.Client + collectionID string +} + +// FaceDetection represents a detected face +type FaceDetection struct { + Confidence float64 `json:"confidence"` + BoundingBox BoundingBox `json:"boundingBox"` +} + +// BoundingBox represents face location in image +type BoundingBox struct { + Left float64 `json:"left"` + Top float64 `json:"top"` + Width float64 `json:"width"` + Height float64 `json:"height"` +} + +// FaceMatch represents a matched face +type FaceMatch struct { + FaceID string `json:"faceId"` + ExternalImageID string `json:"externalImageId"` + Similarity float64 `json:"similarity"` + BoundingBox BoundingBox `json:"boundingBox"` + PhotoID string `json:"photoId,omitempty"` + PhotoURL string `json:"photoUrl,omitempty"` + ThumbnailURL string `json:"thumbnailUrl,omitempty"` +} + +// NewRekognitionClient creates a new Rekognition client +func NewRekognitionClient(cfg aws.Config, collectionID string) *RekognitionClient { + return &RekognitionClient{ + client: rekognition.NewFromConfig(cfg), + collectionID: collectionID, + } +} + +// CreateCollection creates a new face collection +func (r *RekognitionClient) CreateCollection(ctx context.Context, collectionName string) error { + _, err := r.client.CreateCollection(ctx, &rekognition.CreateCollectionInput{ + CollectionId: &collectionName, + }) + if err != nil { + return fmt.Errorf("failed to create collection: %w", err) + } + return nil +} + +// DeleteCollection deletes a face collection +func (r *RekognitionClient) DeleteCollection(ctx context.Context, collectionName string) error { + _, err := r.client.DeleteCollection(ctx, &rekognition.DeleteCollectionInput{ + CollectionId: &collectionName, + }) + if err != nil { + return fmt.Errorf("failed to delete collection: %w", err) + } + return nil +} + +// DetectFaces detects faces in an image +func (r *RekognitionClient) DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error) { + input := &rekognition.DetectFacesInput{ + Image: &types.Image{ + Bytes: imageBytes, + }, + Attributes: []types.Attribute{ + types.AttributeAll, + }, + } + + result, err := r.client.DetectFaces(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to detect faces: %w", err) + } + + var detections []FaceDetection + for _, face := range result.FaceDetails { + if face.BoundingBox == nil { + continue + } + + detection := FaceDetection{ + Confidence: float64(*face.Confidence), + BoundingBox: BoundingBox{ + Left: float64(*face.BoundingBox.Left), + Top: float64(*face.BoundingBox.Top), + Width: float64(*face.BoundingBox.Width), + Height: float64(*face.BoundingBox.Height), + }, + } + detections = append(detections, detection) + } + + return detections, nil +} + +// IndexFace indexes a face in the collection +func (r *RekognitionClient) IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error) { + input := &rekognition.IndexFacesInput{ + CollectionId: &r.collectionID, + Image: &types.Image{ + Bytes: imageBytes, + }, + ExternalImageId: &photoID, + MaxFaces: aws.Int32(1), + QualityFilter: types.QualityFilterAuto, + } + + result, err := r.client.IndexFaces(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to index face: %w", err) + } + + if len(result.FaceRecords) == 0 { + return nil, fmt.Errorf("no faces found in image") + } + + faceRecord := result.FaceRecords[0] + if faceRecord.FaceDetail == nil || faceRecord.FaceDetail.BoundingBox == nil { + return nil, fmt.Errorf("no face detail available") + } + + return &FaceDetection{ + Confidence: float64(*faceRecord.Face.Confidence), + BoundingBox: BoundingBox{ + Left: float64(*faceRecord.FaceDetail.BoundingBox.Left), + Top: float64(*faceRecord.FaceDetail.BoundingBox.Top), + Width: float64(*faceRecord.FaceDetail.BoundingBox.Width), + Height: float64(*faceRecord.FaceDetail.BoundingBox.Height), + }, + }, nil +} + +// SearchFacesByImage searches for similar faces +func (r *RekognitionClient) SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error) { + input := &rekognition.SearchFacesByImageInput{ + CollectionId: &r.collectionID, + Image: &types.Image{ + Bytes: imageBytes, + }, + MaxFaces: aws.Int32(int32(maxFaces)), + FaceMatchThreshold: aws.Float32(float32(threshold)), + } + + result, err := r.client.SearchFacesByImage(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to search faces: %w", err) + } + + var matches []FaceMatch + for _, match := range result.FaceMatches { + if match.Face == nil || match.Face.BoundingBox == nil { + continue + } + + faceMatch := FaceMatch{ + FaceID: aws.ToString(match.Face.FaceId), + ExternalImageID: aws.ToString(match.Face.ExternalImageId), + Similarity: float64(aws.ToFloat32(match.Similarity)), + BoundingBox: BoundingBox{ + Left: float64(*match.Face.BoundingBox.Left), + Top: float64(*match.Face.BoundingBox.Top), + Width: float64(*match.Face.BoundingBox.Width), + Height: float64(*match.Face.BoundingBox.Height), + }, + } + matches = append(matches, faceMatch) + } + + return matches, nil +} + +// ExtractImageBytes extracts image bytes from a multipart file +func ExtractImageBytes(file multipart.File) ([]byte, error) { + // Read all bytes + data, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + // Validate it's an image + _, _, err = image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("invalid image format: %w", err) + } + + return data, nil +} + +// NewRekognitionClientFromEnv creates a client from environment variables +func NewRekognitionClientFromEnv(collectionID string) (*RekognitionClient, error) { + cfg, err := config.LoadDefaultConfig(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + return NewRekognitionClient(cfg, collectionID), nil +} \ No newline at end of file diff --git a/internal/face/service.go b/internal/face/service.go new file mode 100644 index 00000000..5db4b652 --- /dev/null +++ b/internal/face/service.go @@ -0,0 +1,220 @@ +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 +} diff --git a/internal/face/service_test.go b/internal/face/service_test.go new file mode 100644 index 00000000..ae22d6aa --- /dev/null +++ b/internal/face/service_test.go @@ -0,0 +1,315 @@ +package face + +import ( + "context" + "testing" + "time" +) + +// MockPhotoRepository is a mock implementation of PhotoRepository for testing +type MockPhotoRepository struct { + photos map[string]*PhotoInfo + embeddings []*FaceEmbeddingInfo +} + +func NewMockPhotoRepository() *MockPhotoRepository { + return &MockPhotoRepository{ + photos: make(map[string]*PhotoInfo), + } +} + +func (m *MockPhotoRepository) GetPhoto(ctx context.Context, id string) (*PhotoInfo, error) { + if photo, ok := m.photos[id]; ok { + return photo, nil + } + return &PhotoInfo{ + ID: id, + SpaceID: "test-space", + URL: "https://example.com/photo.jpg", + }, nil +} + +func (m *MockPhotoRepository) CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbeddingInfo) (*FaceEmbeddingInfo, error) { + m.embeddings = append(m.embeddings, embedding) + return embedding, nil +} + +func (m *MockPhotoRepository) AddPhoto(photo *PhotoInfo) { + m.photos[photo.ID] = photo +} + +// Test helper functions + +func setupTestService() (*Service, *MockClient, *MockPhotoRepository, *MemoryCache) { + mockClient := NewMockClient() + mockRepo := NewMockPhotoRepository() + cache := NewMemoryCache() + config := DefaultConfig() + + service := NewService(mockClient, mockRepo, cache, config) + return service, mockClient, mockRepo, cache +} + +// Tests + +func TestService_IndexPhoto_Success(t *testing.T) { + service, mockClient, mockRepo, _ := setupTestService() + ctx := context.Background() + + // Test data + photoID := "test-photo-1" + imageBytes := []byte("test-image-data") + + // Add photo to mock repo + mockRepo.AddPhoto(&PhotoInfo{ + ID: photoID, + SpaceID: "test-space-1", + }) + + // Execute + err := service.IndexPhoto(ctx, photoID, imageBytes) + + // Assert + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + _ = mockClient // Client was called internally +} + +func TestService_IndexPhoto_AlreadyIndexed(t *testing.T) { + service, _, _, cache := setupTestService() + ctx := context.Background() + + // Test data + photoID := "test-photo-1" + imageBytes := []byte("test-image-data") + + // Pre-populate cache + cache.Set(ctx, "indexed:"+photoID, "true", 24*time.Hour) + + // Execute + err := service.IndexPhoto(ctx, photoID, imageBytes) + + // Assert + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } +} + +func TestService_SearchFaces_Success(t *testing.T) { + service, _, mockRepo, _ := setupTestService() + ctx := context.Background() + + // Test data + spaceID := "test-space-1" + imageBytes := []byte("test-image-data") + limit := 10 + + // Add photo to mock repo + mockRepo.AddPhoto(&PhotoInfo{ + ID: "photo-1", + SpaceID: spaceID, + URL: "https://example.com/photo-1.jpg", + }) + + // Execute + result, err := service.SearchFaces(ctx, spaceID, imageBytes, limit) + + // Assert + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if result == nil { + t.Fatal("Expected result, got nil") + } + if len(result.Matches) == 0 { + t.Error("Expected at least one match") + } + if result.CacheHit { + t.Error("Expected cache hit to be false") + } +} + +func TestService_SearchFaces_CacheHit(t *testing.T) { + service, _, _, cache := setupTestService() + ctx := context.Background() + + // Test data + spaceID := "test-space-1" + imageBytes := []byte("test-image-data") + limit := 10 + + // Pre-populate cache with a result + cachedResult := &FaceSearchResult{ + Matches: []FaceMatch{ + { + FaceID: "cached-face-1", + Similarity: 95.0, + }, + }, + PhotoCount: 1, + } + cache.SetJSON(ctx, "search:"+spaceID+":"+computeHash(imageBytes), cachedResult, 5*time.Minute) + + // Execute + result, err := service.SearchFaces(ctx, spaceID, imageBytes, limit) + + // Assert + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if result == nil { + t.Fatal("Expected result, got nil") + } + if !result.CacheHit { + t.Error("Expected cache hit to be true") + } + if result.PhotoCount != 1 { + t.Errorf("Expected 1 photo, got %d", result.PhotoCount) + } +} + +func TestService_CreateCollection(t *testing.T) { + service, _, _, _ := setupTestService() + ctx := context.Background() + + // Execute + err := service.CreateCollection(ctx, "test-collection") + + // Assert + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } +} + +func TestService_DeleteCollection(t *testing.T) { + service, _, _, _ := setupTestService() + ctx := context.Background() + + // Execute + err := service.DeleteCollection(ctx, "test-collection") + + // Assert + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } +} + +func TestConfig_Default(t *testing.T) { + config := DefaultConfig() + + if config.Region != "us-east-1" { + t.Errorf("Expected region us-east-1, got %s", config.Region) + } + if config.CollectionID != "hatch-faces" { + t.Errorf("Expected collection ID hatch-faces, got %s", config.CollectionID) + } + if config.MaxFaces != 10 { + t.Errorf("Expected max faces 10, got %d", config.MaxFaces) + } + if config.Threshold != 80.0 { + t.Errorf("Expected threshold 80.0, got %f", config.Threshold) + } + if config.SearchCacheTTL != 5*time.Minute { + t.Errorf("Expected search cache TTL 5m, got %v", config.SearchCacheTTL) + } +} + +func TestConfig_Validate(t *testing.T) { + // Valid config + config := DefaultConfig() + if err := config.Validate(); err != nil { + t.Errorf("Expected valid config, got error: %v", err) + } + + // Invalid: missing region + invalidConfig := DefaultConfig() + invalidConfig.Region = "" + if err := invalidConfig.Validate(); err == nil { + t.Error("Expected error for missing region") + } + + // Invalid: missing collection ID + invalidConfig2 := DefaultConfig() + invalidConfig2.CollectionID = "" + if err := invalidConfig2.Validate(); err == nil { + t.Error("Expected error for missing collection ID") + } +} + +func TestCacheKeys(t *testing.T) { + keys := CacheKeys{} + + if keys.Search("space1", "hash1") != "search:space1:hash1" { + t.Error("Search key mismatch") + } + if keys.Photo("photo1") != "photo:photo1" { + t.Error("Photo key mismatch") + } + if keys.Face("face1") != "face:face1" { + t.Error("Face key mismatch") + } + if keys.Indexed("photo1") != "indexed:photo1" { + t.Error("Indexed key mismatch") + } +} + +func TestValidateImage(t *testing.T) { + service, _, _, _ := setupTestService() + + // Test with valid JPEG header (need at least 100 bytes) + validJPEG := make([]byte, 200) + validJPEG[0] = 0xFF + validJPEG[1] = 0xD8 + validJPEG[2] = 0xFF + validJPEG[3] = 0xE0 + if err := service.ValidateImage(validJPEG); err != nil { + t.Errorf("Expected no error for valid JPEG, got: %v", err) + } + + // Test with valid PNG header (need at least 100 bytes) + validPNG := make([]byte, 200) + validPNG[0] = 0x89 + validPNG[1] = 0x50 + validPNG[2] = 0x4E + validPNG[3] = 0x47 + if err := service.ValidateImage(validPNG); err != nil { + t.Errorf("Expected no error for valid PNG, got: %v", err) + } + + // Test with too large image + largeImage := make([]byte, 11*1024*1024) // 11MB + if err := service.ValidateImage(largeImage); err != ErrImageTooLarge { + t.Errorf("Expected ErrImageTooLarge, got: %v", err) + } + + // Test with too small image + smallImage := make([]byte, 10) + if err := service.ValidateImage(smallImage); err != ErrImageTooSmall { + t.Errorf("Expected ErrImageTooSmall, got: %v", err) + } + + // Test with invalid format (large enough but wrong header) + invalidImage := make([]byte, 200) + if err := service.ValidateImage(invalidImage); err != ErrInvalidImageFormat { + t.Errorf("Expected ErrInvalidImageFormat, got: %v", err) + } +} + +func TestComputeHash(t *testing.T) { + data1 := []byte("test data for hashing") + data2 := []byte("different data for hashing") + + hash1 := computeHash(data1) + hash2 := computeHash(data2) + + if hash1 == hash2 { + t.Error("Expected different hashes for different data") + } + + // Same data should produce same hash + hash1Again := computeHash(data1) + if hash1 != hash1Again { + t.Error("Expected same hash for same data") + } +}