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>
This commit is contained in:
parent
6e472f3eea
commit
4bf37faf4f
153
internal/face/cache.go
Normal file
153
internal/face/cache.go
Normal file
@ -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
|
||||||
|
}
|
||||||
191
internal/face/client.go
Normal file
191
internal/face/client.go
Normal file
@ -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
|
||||||
|
}
|
||||||
102
internal/face/config.go
Normal file
102
internal/face/config.go
Normal file
@ -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
|
||||||
|
}
|
||||||
94
internal/face/errors.go
Normal file
94
internal/face/errors.go
Normal file
@ -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."
|
||||||
|
}
|
||||||
35
internal/face/models.go
Normal file
35
internal/face/models.go
Normal file
@ -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"`
|
||||||
|
}
|
||||||
218
internal/face/rekognition.go
Normal file
218
internal/face/rekognition.go
Normal file
@ -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
|
||||||
|
}
|
||||||
220
internal/face/service.go
Normal file
220
internal/face/service.go
Normal file
@ -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
|
||||||
|
}
|
||||||
315
internal/face/service_test.go
Normal file
315
internal/face/service_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user