139 lines
4.1 KiB
Go
139 lines
4.1 KiB
Go
package analysis
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"go-places-api/internal/domain"
|
|
)
|
|
|
|
// SourceRemote identifies badges produced by an external inference service.
|
|
const SourceRemote = "remote"
|
|
|
|
// RemoteAnalyzer forwards a picture to an external inference service that runs
|
|
// a trained object or scene classifier, and turns the labels it returns into
|
|
// badges. This is the path that recognises specific objects; the local analyzer
|
|
// only produces scene-level categories.
|
|
type RemoteAnalyzer struct {
|
|
baseURL string
|
|
apiKey string
|
|
client *http.Client
|
|
// maxBytes bounds the payload sent upstream so a large upload cannot be
|
|
// forwarded without limit.
|
|
maxBytes int64
|
|
}
|
|
|
|
var _ Analyzer = (*RemoteAnalyzer)(nil)
|
|
|
|
// NewRemote returns a RemoteAnalyzer calling baseURL. The timeout bounds the
|
|
// whole request including reading the response.
|
|
func NewRemote(baseURL, apiKey string, timeout time.Duration) *RemoteAnalyzer {
|
|
if timeout <= 0 {
|
|
timeout = 10 * time.Second
|
|
}
|
|
return &RemoteAnalyzer{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
apiKey: apiKey,
|
|
client: &http.Client{Timeout: timeout},
|
|
maxBytes: 12 << 20,
|
|
}
|
|
}
|
|
|
|
// remoteRequest is the payload sent to the inference service.
|
|
type remoteRequest struct {
|
|
ContentType string `json:"contentType"`
|
|
ImageBase64 string `json:"imageBase64"`
|
|
// Hint carries locally derived scene badges so the service can disambiguate
|
|
// using the signals we already computed.
|
|
Hint []string `json:"hint,omitempty"`
|
|
}
|
|
|
|
// remoteResponse is the expected reply. Width and height are optional because
|
|
// the service may not report them.
|
|
type remoteResponse struct {
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
Labels []struct {
|
|
Label string `json:"label"`
|
|
Display string `json:"display"`
|
|
Confidence float64 `json:"confidence"`
|
|
} `json:"labels"`
|
|
}
|
|
|
|
// Analyze sends the picture upstream and converts the reply into badges.
|
|
func (r *RemoteAnalyzer) Analyze(ctx context.Context, in Input) (*Result, error) {
|
|
if len(in.Bytes) == 0 {
|
|
return nil, fmt.Errorf("analysis: empty image payload")
|
|
}
|
|
if int64(len(in.Bytes)) > r.maxBytes {
|
|
return nil, fmt.Errorf("analysis: payload of %d bytes exceeds remote limit of %d",
|
|
len(in.Bytes), r.maxBytes)
|
|
}
|
|
|
|
body, err := json.Marshal(remoteRequest{
|
|
ContentType: in.ContentType,
|
|
ImageBase64: base64.StdEncoding.EncodeToString(in.Bytes),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("analysis: encode request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.baseURL+"/v1/analyze", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("analysis: build request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
if r.apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+r.apiKey)
|
|
}
|
|
|
|
resp, err := r.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("analysis: call inference service: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
// Read a bounded amount of the error body to aid debugging without
|
|
// letting an error response stream unbounded data.
|
|
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return nil, fmt.Errorf("analysis: inference service returned %s: %s",
|
|
resp.Status, strings.TrimSpace(string(snippet)))
|
|
}
|
|
|
|
// Bound the response so a misbehaving service cannot exhaust memory.
|
|
var decoded remoteResponse
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&decoded); err != nil {
|
|
return nil, fmt.Errorf("analysis: decode inference response: %w", err)
|
|
}
|
|
|
|
res := &Result{Width: decoded.Width, Height: decoded.Height}
|
|
for _, l := range decoded.Labels {
|
|
label := strings.TrimSpace(strings.ToLower(l.Label))
|
|
if label == "" {
|
|
continue
|
|
}
|
|
display := strings.TrimSpace(l.Display)
|
|
if display == "" {
|
|
display = Humanize(label)
|
|
}
|
|
res.Badges = append(res.Badges, domain.Badge{
|
|
Label: label,
|
|
Display: display,
|
|
Confidence: round2(clamp01(l.Confidence)),
|
|
Source: SourceRemote,
|
|
})
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// Name implements Analyzer.
|
|
func (r *RemoteAnalyzer) Name() string { return SourceRemote }
|