353 lines
11 KiB
Go
353 lines
11 KiB
Go
|
|
package app
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"flag"
|
||
|
|
"fmt"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"os/signal"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"materialanalyzer/internal/embedding"
|
||
|
|
"materialanalyzer/internal/intelligence"
|
||
|
|
"materialanalyzer/internal/model"
|
||
|
|
"materialanalyzer/internal/output"
|
||
|
|
"materialanalyzer/internal/repository"
|
||
|
|
"materialanalyzer/internal/service"
|
||
|
|
)
|
||
|
|
|
||
|
|
type workerResult struct {
|
||
|
|
result service.ProcessResult
|
||
|
|
err error
|
||
|
|
attempts int
|
||
|
|
duration time.Duration
|
||
|
|
}
|
||
|
|
|
||
|
|
func Run(args []string) int {
|
||
|
|
if len(args) > 0 {
|
||
|
|
switch args[0] {
|
||
|
|
case "intelligence", "build-intelligence":
|
||
|
|
return intelligence.Run(args[1:])
|
||
|
|
case "analyze":
|
||
|
|
args = args[1:]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fs := flag.NewFlagSet("material-analyzer", flag.ContinueOnError)
|
||
|
|
dataDir := fs.String("data-dir", defaultDataDir(), "product JSON directory")
|
||
|
|
outputDir := fs.String("output-dir", "MaterialAssets", "material asset output directory")
|
||
|
|
cacheDir := fs.String("cache-dir", filepath.Join("cache", "images"), "downloaded image cache directory")
|
||
|
|
workers := fs.Int("workers", 4, "number of concurrent image workers")
|
||
|
|
limit := fs.Int("limit", 0, "maximum products to process; 0 means all")
|
||
|
|
sku := fs.String("sku", "", "comma-separated SKU filter")
|
||
|
|
force := fs.Bool("force", false, "reprocess products even if material.json already exists")
|
||
|
|
timeout := fs.Duration("timeout", 40*time.Second, "per-image/model request timeout")
|
||
|
|
strict := fs.Bool("strict", false, "return non-zero exit code when any product fails")
|
||
|
|
verbose := fs.Bool("verbose", false, "print every processed SKU")
|
||
|
|
retry := fs.Int("retry", 1, "retry count per failed SKU")
|
||
|
|
allowFallback := fs.Bool("allow-fallback", true, "fallback to local semantic/embedding providers when configured providers fail")
|
||
|
|
embeddingProviderName := fs.String("embedding-provider", envOr("EMBEDDING_PROVIDER", "local"), "embedding provider: local or http")
|
||
|
|
embeddingURL := fs.String("embedding-url", envOr("EMBEDDING_SERVER_URL", ""), "embedding HTTP server base URL")
|
||
|
|
semanticProviderName := fs.String("semantic-provider", envOr("SEMANTIC_PROVIDER", "local"), "semantic provider: local or http")
|
||
|
|
semanticURL := fs.String("semantic-url", envOr("SEMANTIC_SERVER_URL", ""), "semantic HTTP server base URL")
|
||
|
|
semanticModel := fs.String("semantic-model", envOr("SEMANTIC_MODEL", "gemini-3-pro-image"), "semantic vision model metadata")
|
||
|
|
if err := fs.Parse(args); err != nil {
|
||
|
|
return 2
|
||
|
|
}
|
||
|
|
if *workers <= 0 {
|
||
|
|
*workers = 1
|
||
|
|
}
|
||
|
|
if *retry < 0 {
|
||
|
|
*retry = 0
|
||
|
|
}
|
||
|
|
|
||
|
|
products, err := repository.LoadProducts(*dataDir)
|
||
|
|
if err != nil {
|
||
|
|
fmt.Fprintf(os.Stderr, "load products: %v\n", err)
|
||
|
|
return 1
|
||
|
|
}
|
||
|
|
products = repository.FilterProducts(products, *sku, *limit)
|
||
|
|
if len(products) == 0 {
|
||
|
|
fmt.Fprintln(os.Stderr, "no products matched")
|
||
|
|
return 1
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||
|
|
defer stop()
|
||
|
|
|
||
|
|
client := &http.Client{Timeout: *timeout}
|
||
|
|
embeddingProvider := newEmbeddingProvider(*embeddingProviderName, *embeddingURL, client)
|
||
|
|
semanticProvider := newSemanticProvider(*semanticProviderName, *semanticURL, *semanticModel, client)
|
||
|
|
processor := service.NewProcessor(service.ProcessorConfig{
|
||
|
|
OutputDir: *outputDir,
|
||
|
|
CacheDir: *cacheDir,
|
||
|
|
Force: *force,
|
||
|
|
AllowFallback: *allowFallback,
|
||
|
|
EmbeddingProvider: embeddingProvider,
|
||
|
|
SemanticProvider: semanticProvider,
|
||
|
|
}, client)
|
||
|
|
|
||
|
|
started := time.Now()
|
||
|
|
fmt.Printf("Material Analyzer %s\n", service.AnalyzerVersion)
|
||
|
|
fmt.Printf("data=%s output=%s cache=%s products=%d workers=%d retry=%d\n", *dataDir, *outputDir, *cacheDir, len(products), *workers, *retry)
|
||
|
|
fmt.Printf("providers embedding=%s semantic=%s fallback=%v\n", embeddingProvider.Name(), semanticProvider.Name(), *allowFallback)
|
||
|
|
|
||
|
|
jobs := make(chan model.Product)
|
||
|
|
results := make(chan workerResult)
|
||
|
|
var wg sync.WaitGroup
|
||
|
|
for i := 0; i < *workers; i++ {
|
||
|
|
wg.Add(1)
|
||
|
|
go func() {
|
||
|
|
defer wg.Done()
|
||
|
|
for product := range jobs {
|
||
|
|
result, err, attempts, duration := processWithRetry(ctx, processor, product, *retry)
|
||
|
|
results <- workerResult{result: result, err: err, attempts: attempts, duration: duration}
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
}
|
||
|
|
|
||
|
|
go func() {
|
||
|
|
defer close(jobs)
|
||
|
|
for _, product := range products {
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
return
|
||
|
|
case jobs <- product:
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
|
||
|
|
go func() {
|
||
|
|
wg.Wait()
|
||
|
|
close(results)
|
||
|
|
}()
|
||
|
|
|
||
|
|
done, processed, skipped, failed := 0, 0, 0, 0
|
||
|
|
var processedDuration time.Duration
|
||
|
|
resolutionDist := map[string]int{}
|
||
|
|
failures := make([]model.FailureItem, 0)
|
||
|
|
|
||
|
|
for r := range results {
|
||
|
|
done++
|
||
|
|
if r.result.Width > 0 && r.result.Height > 0 {
|
||
|
|
resolutionDist[resolutionBucket(r.result.Width, r.result.Height)]++
|
||
|
|
}
|
||
|
|
if r.err != nil {
|
||
|
|
failed++
|
||
|
|
service.RemoveIncompleteAsset(r.result.AssetDir)
|
||
|
|
failures = append(failures, model.FailureItem{
|
||
|
|
SKU: r.result.SKU, Error: r.err.Error(), Attempts: r.attempts, DurationMS: r.duration.Milliseconds(),
|
||
|
|
})
|
||
|
|
fmt.Fprintf(os.Stderr, "[FAIL] %s attempts=%d: %v\n", r.result.SKU, r.attempts, r.err)
|
||
|
|
} else if r.result.Skipped {
|
||
|
|
skipped++
|
||
|
|
if *verbose {
|
||
|
|
fmt.Printf("[SKIP] %s\n", r.result.SKU)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
processed++
|
||
|
|
processedDuration += r.duration
|
||
|
|
if *verbose {
|
||
|
|
fmt.Printf("[OK] %s -> %s (%s)\n", r.result.SKU, r.result.AssetDir, r.duration.Round(time.Millisecond))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(r.result.Warnings) > 0 && *verbose {
|
||
|
|
fmt.Printf(" warnings: %v\n", r.result.Warnings)
|
||
|
|
}
|
||
|
|
if !*verbose && (done%25 == 0 || done == len(products)) {
|
||
|
|
fmt.Printf("progress %d/%d %.1f%% (processed=%d skipped=%d failed=%d)\n", done, len(products), float64(done)*100/float64(len(products)), processed, skipped, failed)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
avgMS := 0.0
|
||
|
|
if processed > 0 {
|
||
|
|
avgMS = float64(processedDuration.Milliseconds()) / float64(processed)
|
||
|
|
}
|
||
|
|
benchmark := model.BatchBenchmark{
|
||
|
|
AnalyzerVersion: service.AnalyzerVersion,
|
||
|
|
FeatureSchema: service.FeatureSchema,
|
||
|
|
Generator: service.Generator,
|
||
|
|
StartedAt: started.UTC().Format(time.RFC3339),
|
||
|
|
CompletedAt: time.Now().UTC().Format(time.RFC3339),
|
||
|
|
DataDir: *dataDir,
|
||
|
|
OutputDir: *outputDir,
|
||
|
|
EmbeddingProvider: embeddingProvider.Name(),
|
||
|
|
SemanticProvider: semanticProvider.Name(),
|
||
|
|
TotalSKU: len(products),
|
||
|
|
Processed: processed,
|
||
|
|
Skipped: skipped,
|
||
|
|
Failed: failed,
|
||
|
|
AverageTimeMS: round2(avgMS),
|
||
|
|
Workers: *workers,
|
||
|
|
ImageResolutionDistribution: resolutionDist,
|
||
|
|
}
|
||
|
|
if err := output.WriteBenchmark(*outputDir, benchmark); err != nil {
|
||
|
|
fmt.Fprintf(os.Stderr, "write benchmark: %v\n", err)
|
||
|
|
}
|
||
|
|
if len(failures) > 0 {
|
||
|
|
if err := output.WriteFailures(*outputDir, model.FailureReport{
|
||
|
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||
|
|
Failures: failures,
|
||
|
|
}); err != nil {
|
||
|
|
fmt.Fprintf(os.Stderr, "write failures: %v\n", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Printf("Done: total=%d processed=%d skipped=%d failed=%d avg_ms=%.2f\n", len(products), processed, skipped, failed, avgMS)
|
||
|
|
if failed > 0 && *strict {
|
||
|
|
return 1
|
||
|
|
}
|
||
|
|
if ctx.Err() != nil {
|
||
|
|
return 130
|
||
|
|
}
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
func processWithRetry(ctx context.Context, processor *service.Processor, product model.Product, retry int) (service.ProcessResult, error, int, time.Duration) {
|
||
|
|
start := time.Now()
|
||
|
|
var result service.ProcessResult
|
||
|
|
var err error
|
||
|
|
attempts := 0
|
||
|
|
for attempts < retry+1 {
|
||
|
|
attempts++
|
||
|
|
result, err = processor.Process(ctx, product)
|
||
|
|
if err == nil || result.Skipped || ctx.Err() != nil {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
if attempts <= retry {
|
||
|
|
time.Sleep(time.Duration(attempts) * 500 * time.Millisecond)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result, err, attempts, time.Since(start)
|
||
|
|
}
|
||
|
|
|
||
|
|
func newEmbeddingProvider(name, url string, client *http.Client) embedding.Provider {
|
||
|
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
||
|
|
case "http", "server", "model-server", "real":
|
||
|
|
return embedding.HTTPProvider{BaseURL: url, Client: client}
|
||
|
|
default:
|
||
|
|
return embedding.LocalProvider{}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func newSemanticProvider(name, url, modelName string, client *http.Client) service.SemanticProvider {
|
||
|
|
names := strings.Split(name, ",")
|
||
|
|
providers := make([]service.SemanticProvider, 0, len(names))
|
||
|
|
for _, item := range names {
|
||
|
|
item = strings.ToLower(strings.TrimSpace(item))
|
||
|
|
if item == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
switch item {
|
||
|
|
case "local", "rule", "rules":
|
||
|
|
providers = append(providers, service.RuleBasedSemanticProvider{})
|
||
|
|
case "http", "server", "vision", "llm":
|
||
|
|
providers = append(providers, service.HTTPSemanticProvider{ProviderName: "http", BaseURL: url, Model: modelName, Client: client})
|
||
|
|
case "internvl3", "florence2", "qwen2_5vl", "qwen2.5vl", "gemini", "gpt4o":
|
||
|
|
providerName := normalizeProviderName(item)
|
||
|
|
providers = append(providers, service.HTTPSemanticProvider{
|
||
|
|
ProviderName: providerName,
|
||
|
|
BaseURL: providerURL(providerName, url),
|
||
|
|
Model: providerModel(providerName, modelName),
|
||
|
|
Client: client,
|
||
|
|
})
|
||
|
|
default:
|
||
|
|
providers = append(providers, service.RuleBasedSemanticProvider{})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(providers) == 0 {
|
||
|
|
return service.RuleBasedSemanticProvider{}
|
||
|
|
}
|
||
|
|
if len(providers) == 1 {
|
||
|
|
return providers[0]
|
||
|
|
}
|
||
|
|
return service.FusionSemanticProvider{Providers: providers}
|
||
|
|
}
|
||
|
|
|
||
|
|
func normalizeProviderName(name string) string {
|
||
|
|
if name == "qwen2.5vl" {
|
||
|
|
return "qwen2_5vl"
|
||
|
|
}
|
||
|
|
return name
|
||
|
|
}
|
||
|
|
|
||
|
|
func providerURL(providerName, fallback string) string {
|
||
|
|
envName := strings.ToUpper(strings.ReplaceAll(providerName, ".", "_")) + "_VISION_URL"
|
||
|
|
if v := os.Getenv(envName); v != "" {
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(fallback) != "" {
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
if providerName == "internvl3" {
|
||
|
|
return "http://127.0.0.1:5300"
|
||
|
|
}
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
|
||
|
|
func providerModel(providerName, fallback string) string {
|
||
|
|
if fallback != "" && fallback != "gemini-3-pro-image" {
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
switch providerName {
|
||
|
|
case "internvl3":
|
||
|
|
return "OpenGVLab/InternVL3-8B"
|
||
|
|
case "florence2":
|
||
|
|
return "Florence-2"
|
||
|
|
case "qwen2_5vl":
|
||
|
|
return "Qwen2.5-VL"
|
||
|
|
case "gpt4o":
|
||
|
|
return "gpt-4o"
|
||
|
|
case "gemini":
|
||
|
|
return "gemini-3-pro-image"
|
||
|
|
default:
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func resolutionBucket(width, height int) string {
|
||
|
|
maxDim := width
|
||
|
|
if height > maxDim {
|
||
|
|
maxDim = height
|
||
|
|
}
|
||
|
|
switch {
|
||
|
|
case maxDim <= 512:
|
||
|
|
return "<=512"
|
||
|
|
case maxDim <= 1024:
|
||
|
|
return "513-1024"
|
||
|
|
case maxDim <= 2048:
|
||
|
|
return "1025-2048"
|
||
|
|
default:
|
||
|
|
return ">2048"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func round2(v float64) float64 {
|
||
|
|
return float64(int(v*100+0.5)) / 100
|
||
|
|
}
|
||
|
|
|
||
|
|
func envOr(name, fallback string) string {
|
||
|
|
if v := os.Getenv(name); v != "" {
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
|
||
|
|
func defaultDataDir() string {
|
||
|
|
if v := os.Getenv("PRODUCT_DATA_DIR"); v != "" {
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
cwd, err := os.Getwd()
|
||
|
|
if err == nil {
|
||
|
|
sibling := filepath.Clean(filepath.Join(cwd, "..", "FloorVisualizer", "data", "products"))
|
||
|
|
if stat, err := os.Stat(sibling); err == nil && stat.IsDir() {
|
||
|
|
return sibling
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return filepath.Join("data", "products")
|
||
|
|
}
|