package face import ( "context" "crypto/sha256" "fmt" "log" "time" "github.com/elfoundation/hatch/internal/store" ) // PhotoRepository defines the photo operations needed by the face service type PhotoRepository interface { GetPhoto(ctx context.Context, id string) (*store.Photo, error) CreateFaceEmbedding(ctx context.Context, embedding *store.FaceEmbedding) (*store.FaceEmbedding, error) } // 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 := &store.FaceEmbedding{ 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 }