hatch-surf/internal/face/client.go
Chris Anderson 4bf37faf4f 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>
2026-06-25 21:11:45 +02:00

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
}