hatch-surf/internal/face/rekognition.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

218 lines
6.1 KiB
Go

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
}