- 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>
192 lines
5.0 KiB
Go
192 lines
5.0 KiB
Go
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
|
|
}
|