initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
139
api/internal/analysis/remote.go
Normal file
139
api/internal/analysis/remote.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue