249 lines
7.7 KiB
Go
249 lines
7.7 KiB
Go
|
|
package service
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"image"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"materialanalyzer/internal/embedding"
|
||
|
|
"materialanalyzer/internal/imageproc"
|
||
|
|
"materialanalyzer/internal/model"
|
||
|
|
"materialanalyzer/internal/output"
|
||
|
|
"materialanalyzer/internal/validation"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
SchemaVersion = "material_asset_v2"
|
||
|
|
AnalyzerVersion = "2.0.0"
|
||
|
|
FeatureSchema = "2026.07"
|
||
|
|
Generator = "Material Analyzer"
|
||
|
|
)
|
||
|
|
|
||
|
|
type ProcessorConfig struct {
|
||
|
|
OutputDir string
|
||
|
|
CacheDir string
|
||
|
|
Force bool
|
||
|
|
AllowFallback bool
|
||
|
|
EmbeddingProvider embedding.Provider
|
||
|
|
SemanticProvider SemanticProvider
|
||
|
|
}
|
||
|
|
|
||
|
|
type Processor struct {
|
||
|
|
cfg ProcessorConfig
|
||
|
|
client *http.Client
|
||
|
|
}
|
||
|
|
|
||
|
|
type ProcessResult struct {
|
||
|
|
SKU string
|
||
|
|
AssetDir string
|
||
|
|
Skipped bool
|
||
|
|
Warnings []string
|
||
|
|
Width int
|
||
|
|
Height int
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewProcessor(cfg ProcessorConfig, client *http.Client) *Processor {
|
||
|
|
if cfg.OutputDir == "" {
|
||
|
|
cfg.OutputDir = "MaterialAssets"
|
||
|
|
}
|
||
|
|
if cfg.CacheDir == "" {
|
||
|
|
cfg.CacheDir = filepath.Join("cache", "images")
|
||
|
|
}
|
||
|
|
if cfg.EmbeddingProvider == nil {
|
||
|
|
cfg.EmbeddingProvider = embedding.LocalProvider{}
|
||
|
|
}
|
||
|
|
if cfg.SemanticProvider == nil {
|
||
|
|
cfg.SemanticProvider = RuleBasedSemanticProvider{}
|
||
|
|
}
|
||
|
|
return &Processor{cfg: cfg, client: client}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Processor) Process(ctx context.Context, product model.Product) (ProcessResult, error) {
|
||
|
|
result := ProcessResult{SKU: product.SKU}
|
||
|
|
assetDir := output.AssetDir(p.cfg.OutputDir, product.SKU)
|
||
|
|
result.AssetDir = assetDir
|
||
|
|
if !p.cfg.Force && output.AssetExists(assetDir) {
|
||
|
|
result.Skipped = true
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
source, loaded, warnings, err := p.loadFirstImage(ctx, product)
|
||
|
|
if err != nil {
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
|
||
|
|
analysis := imageproc.PrepareAnalysisImage(loaded.Image, source)
|
||
|
|
result.Width = analysis.OriginalWidth
|
||
|
|
result.Height = analysis.OriginalHeight
|
||
|
|
if analysis.RoomLike {
|
||
|
|
warnings = append(warnings, "main material image looks room-like; analyzed lower floor crop and flagged for cleanup")
|
||
|
|
}
|
||
|
|
|
||
|
|
visual, hist := imageproc.ExtractVisual(analysis.Image)
|
||
|
|
texture := imageproc.ExtractTexture(analysis.Image)
|
||
|
|
canonical := imageproc.ExtractCanonicalStatistics(analysis.Image, visual, texture)
|
||
|
|
|
||
|
|
if err := output.EnsureAssetDir(assetDir); err != nil {
|
||
|
|
return result, fmt.Errorf("create asset dir: %w", err)
|
||
|
|
}
|
||
|
|
if err := output.WriteImages(assetDir, analysis.Image); err != nil {
|
||
|
|
return result, fmt.Errorf("write preview images: %w", err)
|
||
|
|
}
|
||
|
|
previewPath := filepath.Join(assetDir, "preview.jpg")
|
||
|
|
|
||
|
|
semantic, err := p.cfg.SemanticProvider.Analyze(ctx, previewPath, product, visual, texture)
|
||
|
|
if err != nil {
|
||
|
|
if !p.cfg.AllowFallback {
|
||
|
|
return result, fmt.Errorf("semantic provider %s: %w", p.cfg.SemanticProvider.Name(), err)
|
||
|
|
}
|
||
|
|
warnings = append(warnings, fmt.Sprintf("semantic provider %s failed; used local fallback: %v", p.cfg.SemanticProvider.Name(), err))
|
||
|
|
semantic = BuildSemantic(product, visual, texture)
|
||
|
|
}
|
||
|
|
specific := BuildMaterialSpecific(product, visual, texture, semantic)
|
||
|
|
|
||
|
|
if err := output.WriteHistogram(assetDir, hist); err != nil {
|
||
|
|
return result, fmt.Errorf("write histogram: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
embeddingResult, err := p.cfg.EmbeddingProvider.Embed(ctx, embedding.Request{
|
||
|
|
ImagePath: previewPath, Product: product, Histogram: hist, Visual: visual, Texture: texture, Semantic: semantic,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
if !p.cfg.AllowFallback {
|
||
|
|
return result, fmt.Errorf("embedding provider %s: %w", p.cfg.EmbeddingProvider.Name(), err)
|
||
|
|
}
|
||
|
|
warnings = append(warnings, fmt.Sprintf("embedding provider %s failed; used local fallback: %v", p.cfg.EmbeddingProvider.Name(), err))
|
||
|
|
embeddingResult, err = (embedding.LocalProvider{}).Embed(ctx, embedding.Request{
|
||
|
|
ImagePath: previewPath, Product: product, Histogram: hist, Visual: visual, Texture: texture, Semantic: semantic,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return result, fmt.Errorf("local embedding fallback: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
manifest, err := embedding.WriteBinary(filepath.Join(assetDir, "embedding.bin"), embeddingResult)
|
||
|
|
if err != nil {
|
||
|
|
return result, fmt.Errorf("write embeddings: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
generatedAt := time.Now().UTC().Format(time.RFC3339)
|
||
|
|
asset := model.MaterialAsset{
|
||
|
|
SchemaVersion: SchemaVersion,
|
||
|
|
AnalyzerVersion: AnalyzerVersion,
|
||
|
|
FeatureSchema: FeatureSchema,
|
||
|
|
Generator: Generator,
|
||
|
|
GeneratedAt: generatedAt,
|
||
|
|
SKU: product.SKU,
|
||
|
|
Category: product.Category,
|
||
|
|
Material: product.Material,
|
||
|
|
Product: model.NewProductSnapshot(product),
|
||
|
|
Input: model.InputInfo{
|
||
|
|
ImageURL: source,
|
||
|
|
ImageCachePath: loaded.SourcePath,
|
||
|
|
OriginalWidth: analysis.OriginalWidth,
|
||
|
|
OriginalHeight: analysis.OriginalHeight,
|
||
|
|
AnalysisStrategy: analysis.Strategy,
|
||
|
|
AnalysisRegion: regionString(analysis.Region),
|
||
|
|
RoomLike: analysis.RoomLike,
|
||
|
|
RoomLikeConfidence: analysis.RoomLikeConfidence,
|
||
|
|
RoomLikeReason: analysis.RoomLikeReason,
|
||
|
|
SourceImageFormat: loaded.Format,
|
||
|
|
GeneratedPreview: "preview.jpg",
|
||
|
|
GeneratedThumbnail: "thumbnail.jpg",
|
||
|
|
GeneratedHistogram: "histogram.json",
|
||
|
|
GeneratedEmbeddings: "embedding.bin",
|
||
|
|
},
|
||
|
|
Visual: visual,
|
||
|
|
Texture: texture,
|
||
|
|
Canonical: canonical,
|
||
|
|
Semantic: semantic,
|
||
|
|
MaterialSpecific: specific,
|
||
|
|
Embeddings: manifest,
|
||
|
|
Confidence: map[string]float64{
|
||
|
|
"visual": 0.95,
|
||
|
|
"texture": 0.90,
|
||
|
|
"semantic": semantic.Confidence,
|
||
|
|
"embedding": embeddingConfidence(manifest),
|
||
|
|
},
|
||
|
|
Warnings: warnings,
|
||
|
|
}
|
||
|
|
asset.Validation = validation.ValidateAsset(asset, hist)
|
||
|
|
if asset.Validation.Status != "valid" {
|
||
|
|
return result, fmt.Errorf("asset validation failed: %s", strings.Join(asset.Validation.Errors, "; "))
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := output.WriteSemantic(assetDir, semantic); err != nil {
|
||
|
|
return result, fmt.Errorf("write semantic json: %w", err)
|
||
|
|
}
|
||
|
|
if err := output.WriteMaterial(assetDir, asset); err != nil {
|
||
|
|
return result, fmt.Errorf("write material json: %w", err)
|
||
|
|
}
|
||
|
|
if err := output.WriteManifest(assetDir, model.AssetManifest{
|
||
|
|
SKU: product.SKU, AnalyzerVersion: AnalyzerVersion, FeatureSchema: FeatureSchema, GeneratedAt: generatedAt,
|
||
|
|
Files: []string{"preview.jpg", "thumbnail.jpg", "material.json", "histogram.json", "embedding.bin", "semantic.json", "manifest.json"},
|
||
|
|
Status: "complete", Validation: asset.Validation, Warnings: warnings,
|
||
|
|
}); err != nil {
|
||
|
|
return result, fmt.Errorf("write manifest: %w", err)
|
||
|
|
}
|
||
|
|
result.Warnings = warnings
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func embeddingConfidence(manifest model.EmbeddingManifest) float64 {
|
||
|
|
for _, vector := range manifest.Vectors {
|
||
|
|
if vector.Provider != "local" && vector.Provider != "" {
|
||
|
|
return 0.92
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return 0.70
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *Processor) loadFirstImage(ctx context.Context, product model.Product) (string, *imageproc.LoadedImage, []string, error) {
|
||
|
|
var warnings []string
|
||
|
|
var lastErr error
|
||
|
|
for i, source := range imageSources(product) {
|
||
|
|
loaded, err := imageproc.LoadImage(ctx, source, p.cfg.CacheDir, p.client)
|
||
|
|
if err == nil {
|
||
|
|
if i > 0 {
|
||
|
|
warnings = append(warnings, "primary image failed; used fallback image source")
|
||
|
|
}
|
||
|
|
return source, loaded, warnings, nil
|
||
|
|
}
|
||
|
|
lastErr = err
|
||
|
|
}
|
||
|
|
if lastErr == nil {
|
||
|
|
lastErr = fmt.Errorf("no image source available")
|
||
|
|
}
|
||
|
|
return "", nil, warnings, fmt.Errorf("load image for %s: %w", product.SKU, lastErr)
|
||
|
|
}
|
||
|
|
|
||
|
|
func imageSources(product model.Product) []string {
|
||
|
|
var out []string
|
||
|
|
seen := map[string]bool{}
|
||
|
|
add := func(v string) {
|
||
|
|
v = strings.TrimSpace(v)
|
||
|
|
if v == "" || seen[v] {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
seen[v] = true
|
||
|
|
out = append(out, v)
|
||
|
|
}
|
||
|
|
add(product.MainImageURL)
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func regionString(rect image.Rectangle) string {
|
||
|
|
return fmt.Sprintf("x=%d,y=%d,w=%d,h=%d", rect.Min.X, rect.Min.Y, rect.Dx(), rect.Dy())
|
||
|
|
}
|
||
|
|
|
||
|
|
func RemoveIncompleteAsset(assetDir string) {
|
||
|
|
if strings.TrimSpace(assetDir) == "" {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
_ = os.Remove(filepath.Join(assetDir, "material.json"))
|
||
|
|
}
|