FloorMaterialAnalyzer/internal/service/processor.go

249 lines
7.7 KiB
Go
Raw Permalink Normal View History

2026-07-27 03:03:51 +00:00
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"))
}