290 lines
8.6 KiB
Go
290 lines
8.6 KiB
Go
|
|
package service
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"materialanalyzer/internal/model"
|
||
|
|
)
|
||
|
|
|
||
|
|
type SemanticProvider interface {
|
||
|
|
Name() string
|
||
|
|
Analyze(ctx context.Context, imagePath string, product model.Product, visual model.VisualFeatures, texture model.TextureFeatures) (model.SemanticFeatures, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
type FusionSemanticProvider struct {
|
||
|
|
Providers []SemanticProvider
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p FusionSemanticProvider) Name() string {
|
||
|
|
names := make([]string, 0, len(p.Providers))
|
||
|
|
for _, provider := range p.Providers {
|
||
|
|
names = append(names, provider.Name())
|
||
|
|
}
|
||
|
|
return "fusion(" + strings.Join(names, "+") + ")"
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p FusionSemanticProvider) Analyze(ctx context.Context, imagePath string, product model.Product, visual model.VisualFeatures, texture model.TextureFeatures) (model.SemanticFeatures, error) {
|
||
|
|
var results []model.SemanticFeatures
|
||
|
|
var errors []string
|
||
|
|
for _, provider := range p.Providers {
|
||
|
|
semantic, err := provider.Analyze(ctx, imagePath, product, visual, texture)
|
||
|
|
if err != nil {
|
||
|
|
errors = append(errors, provider.Name()+": "+err.Error())
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
results = append(results, semantic)
|
||
|
|
}
|
||
|
|
if len(results) == 0 {
|
||
|
|
return model.SemanticFeatures{}, fmt.Errorf("all semantic providers failed: %s", strings.Join(errors, "; "))
|
||
|
|
}
|
||
|
|
if len(results) == 1 {
|
||
|
|
results[0].Provider = "fusion"
|
||
|
|
results[0].Model = "single:" + results[0].Model
|
||
|
|
return results[0], nil
|
||
|
|
}
|
||
|
|
return fuseSemantic(results), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type RuleBasedSemanticProvider struct{}
|
||
|
|
|
||
|
|
func (RuleBasedSemanticProvider) Name() string { return "local" }
|
||
|
|
|
||
|
|
func (RuleBasedSemanticProvider) Analyze(ctx context.Context, imagePath string, product model.Product, visual model.VisualFeatures, texture model.TextureFeatures) (model.SemanticFeatures, error) {
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
return model.SemanticFeatures{}, ctx.Err()
|
||
|
|
default:
|
||
|
|
}
|
||
|
|
return BuildSemantic(product, visual, texture), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type HTTPSemanticProvider struct {
|
||
|
|
ProviderName string
|
||
|
|
BaseURL string
|
||
|
|
Model string
|
||
|
|
Client *http.Client
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p HTTPSemanticProvider) Name() string {
|
||
|
|
if p.ProviderName != "" {
|
||
|
|
return p.ProviderName
|
||
|
|
}
|
||
|
|
return "http"
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p HTTPSemanticProvider) Analyze(ctx context.Context, imagePath string, product model.Product, visual model.VisualFeatures, texture model.TextureFeatures) (model.SemanticFeatures, error) {
|
||
|
|
if strings.TrimSpace(p.BaseURL) == "" {
|
||
|
|
return model.SemanticFeatures{}, fmt.Errorf("semantic HTTP provider URL is empty")
|
||
|
|
}
|
||
|
|
client := p.Client
|
||
|
|
if client == nil {
|
||
|
|
client = http.DefaultClient
|
||
|
|
}
|
||
|
|
imgData, err := os.ReadFile(imagePath)
|
||
|
|
if err != nil {
|
||
|
|
return model.SemanticFeatures{}, fmt.Errorf("read semantic image: %w", err)
|
||
|
|
}
|
||
|
|
body := map[string]interface{}{
|
||
|
|
"model": p.Model,
|
||
|
|
"image_base64": base64.StdEncoding.EncodeToString(imgData),
|
||
|
|
"image_path": imagePath,
|
||
|
|
"product": product,
|
||
|
|
"schema": map[string]interface{}{
|
||
|
|
"required": []string{"description", "material_type", "surface_finish", "grain_type", "color_family", "visual_style", "variation", "gloss_level", "tags"},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
payload, err := json.Marshal(body)
|
||
|
|
if err != nil {
|
||
|
|
return model.SemanticFeatures{}, err
|
||
|
|
}
|
||
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, semanticEndpoint(p.BaseURL), bytes.NewReader(payload))
|
||
|
|
if err != nil {
|
||
|
|
return model.SemanticFeatures{}, err
|
||
|
|
}
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
req.Header.Set("Accept", "application/json")
|
||
|
|
|
||
|
|
resp, err := client.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return model.SemanticFeatures{}, fmt.Errorf("semantic HTTP request: %w", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
|
|
return model.SemanticFeatures{}, fmt.Errorf("semantic HTTP status %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
var semantic model.SemanticFeatures
|
||
|
|
if err := json.NewDecoder(resp.Body).Decode(&semantic); err != nil {
|
||
|
|
return model.SemanticFeatures{}, fmt.Errorf("decode semantic response: %w", err)
|
||
|
|
}
|
||
|
|
if semantic.Provider == "" {
|
||
|
|
semantic.Provider = p.Name()
|
||
|
|
}
|
||
|
|
if semantic.Model == "" {
|
||
|
|
semantic.Model = p.Model
|
||
|
|
}
|
||
|
|
if semantic.Confidence == 0 {
|
||
|
|
semantic.Confidence = 0.80
|
||
|
|
}
|
||
|
|
if err := validateSemanticRequired(semantic); err != nil {
|
||
|
|
return model.SemanticFeatures{}, err
|
||
|
|
}
|
||
|
|
return semantic, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func fuseSemantic(results []model.SemanticFeatures) model.SemanticFeatures {
|
||
|
|
best := results[0]
|
||
|
|
for _, candidate := range results[1:] {
|
||
|
|
if candidate.Confidence > best.Confidence {
|
||
|
|
best = candidate
|
||
|
|
}
|
||
|
|
}
|
||
|
|
out := model.SemanticFeatures{
|
||
|
|
Provider: "fusion",
|
||
|
|
Model: fusionModels(results),
|
||
|
|
Description: best.Description,
|
||
|
|
MaterialType: pickField(results, "material_type", func(s model.SemanticFeatures) string { return s.MaterialType }),
|
||
|
|
SurfaceFinish: pickField(results, "surface_finish", func(s model.SemanticFeatures) string { return s.SurfaceFinish }),
|
||
|
|
GrainType: pickField(results, "grain_type", func(s model.SemanticFeatures) string { return s.GrainType }),
|
||
|
|
ColorFamily: pickField(results, "color_family", func(s model.SemanticFeatures) string { return s.ColorFamily }),
|
||
|
|
VisualStyle: pickField(results, "visual_style", func(s model.SemanticFeatures) string { return s.VisualStyle }),
|
||
|
|
Variation: pickField(results, "variation", func(s model.SemanticFeatures) string { return s.Variation }),
|
||
|
|
GlossLevel: pickField(results, "gloss_level", func(s model.SemanticFeatures) string { return s.GlossLevel }),
|
||
|
|
Confidence: averageConfidence(results),
|
||
|
|
FieldConfidence: map[string]float64{},
|
||
|
|
}
|
||
|
|
out.Grain = out.GrainType
|
||
|
|
out.Tags = fusedTags(results)
|
||
|
|
for _, field := range []string{"material_type", "surface_finish", "grain_type", "color_family", "visual_style", "variation", "gloss_level"} {
|
||
|
|
out.FieldConfidence[field] = fieldConfidence(results, field)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func pickField(results []model.SemanticFeatures, field string, getter func(model.SemanticFeatures) string) string {
|
||
|
|
type score struct {
|
||
|
|
value string
|
||
|
|
score float64
|
||
|
|
votes int
|
||
|
|
}
|
||
|
|
scores := map[string]*score{}
|
||
|
|
for _, result := range results {
|
||
|
|
value := strings.TrimSpace(getter(result))
|
||
|
|
if value == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
key := strings.ToLower(value)
|
||
|
|
weight := result.Confidence
|
||
|
|
if result.FieldConfidence != nil && result.FieldConfidence[field] > 0 {
|
||
|
|
weight *= result.FieldConfidence[field]
|
||
|
|
}
|
||
|
|
if scores[key] == nil {
|
||
|
|
scores[key] = &score{value: value}
|
||
|
|
}
|
||
|
|
scores[key].score += weight
|
||
|
|
scores[key].votes++
|
||
|
|
}
|
||
|
|
list := make([]score, 0, len(scores))
|
||
|
|
for _, item := range scores {
|
||
|
|
list = append(list, *item)
|
||
|
|
}
|
||
|
|
sort.Slice(list, func(i, j int) bool {
|
||
|
|
if list[i].votes != list[j].votes {
|
||
|
|
return list[i].votes > list[j].votes
|
||
|
|
}
|
||
|
|
return list[i].score > list[j].score
|
||
|
|
})
|
||
|
|
if len(list) == 0 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return list[0].value
|
||
|
|
}
|
||
|
|
|
||
|
|
func fieldConfidence(results []model.SemanticFeatures, field string) float64 {
|
||
|
|
if len(results) == 0 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
var sum float64
|
||
|
|
for _, result := range results {
|
||
|
|
conf := result.Confidence
|
||
|
|
if result.FieldConfidence != nil && result.FieldConfidence[field] > 0 {
|
||
|
|
conf = result.FieldConfidence[field]
|
||
|
|
}
|
||
|
|
sum += conf
|
||
|
|
}
|
||
|
|
return sum / float64(len(results))
|
||
|
|
}
|
||
|
|
|
||
|
|
func averageConfidence(results []model.SemanticFeatures) float64 {
|
||
|
|
var sum float64
|
||
|
|
for _, result := range results {
|
||
|
|
sum += result.Confidence
|
||
|
|
}
|
||
|
|
return sum / float64(len(results))
|
||
|
|
}
|
||
|
|
|
||
|
|
func fusionModels(results []model.SemanticFeatures) string {
|
||
|
|
models := make([]string, 0, len(results))
|
||
|
|
for _, result := range results {
|
||
|
|
models = append(models, result.Provider+":"+result.Model)
|
||
|
|
}
|
||
|
|
return strings.Join(models, "+")
|
||
|
|
}
|
||
|
|
|
||
|
|
func fusedTags(results []model.SemanticFeatures) []string {
|
||
|
|
seen := map[string]bool{}
|
||
|
|
var tags []string
|
||
|
|
for _, result := range results {
|
||
|
|
for _, tag := range result.Tags {
|
||
|
|
key := strings.ToLower(strings.TrimSpace(tag))
|
||
|
|
if key == "" || seen[key] {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
seen[key] = true
|
||
|
|
tags = append(tags, tag)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return tags
|
||
|
|
}
|
||
|
|
|
||
|
|
func semanticEndpoint(base string) string {
|
||
|
|
base = strings.TrimRight(base, "/")
|
||
|
|
if strings.HasSuffix(base, "/semantic") {
|
||
|
|
return base
|
||
|
|
}
|
||
|
|
return base + "/semantic"
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateSemanticRequired(s model.SemanticFeatures) error {
|
||
|
|
missing := make([]string, 0)
|
||
|
|
required := map[string]string{
|
||
|
|
"description": s.Description,
|
||
|
|
"material_type": s.MaterialType,
|
||
|
|
"surface_finish": s.SurfaceFinish,
|
||
|
|
"grain_type": s.GrainType,
|
||
|
|
"color_family": s.ColorFamily,
|
||
|
|
"visual_style": s.VisualStyle,
|
||
|
|
"variation": s.Variation,
|
||
|
|
"gloss_level": s.GlossLevel,
|
||
|
|
}
|
||
|
|
for name, value := range required {
|
||
|
|
if strings.TrimSpace(value) == "" {
|
||
|
|
missing = append(missing, name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(s.Tags) == 0 {
|
||
|
|
missing = append(missing, "tags")
|
||
|
|
}
|
||
|
|
if len(missing) > 0 {
|
||
|
|
return fmt.Errorf("semantic response missing fields: %s", strings.Join(missing, ", "))
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|