FloorMaterialAnalyzer/internal/service/semantic_provider.go
2026-07-27 11:03:51 +08:00

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
}