From b0dfe370e8aeb454cf47f2f75fb5667875c07f02 Mon Sep 17 00:00:00 2001 From: dindang Date: Mon, 27 Jul 2026 11:03:51 +0800 Subject: [PATCH] Initial Material Analyzer project --- .gitignore | 27 ++ README.md | 242 ++++++++++ cmd/analyze/main.go | 11 + go.mod | 5 + go.sum | 2 + internal/analyzer/interfaces.go | 33 ++ internal/app/app.go | 352 +++++++++++++++ internal/embedding/embedding.go | 280 ++++++++++++ internal/imageproc/color.go | 250 ++++++++++ internal/imageproc/color_test.go | 27 ++ internal/imageproc/io.go | 128 ++++++ internal/imageproc/math.go | 15 + internal/imageproc/prepare.go | 124 +++++ internal/imageproc/statistics.go | 99 ++++ internal/imageproc/texture.go | 218 +++++++++ internal/imageproc/transform.go | 79 ++++ internal/intelligence/builders.go | 124 +++++ internal/intelligence/builders_test.go | 29 ++ internal/intelligence/load.go | 140 ++++++ internal/intelligence/regression.go | 108 +++++ internal/intelligence/run.go | 196 ++++++++ internal/intelligence/similarity.go | 79 ++++ internal/intelligence/statistics.go | 82 ++++ internal/intelligence/types.go | 161 +++++++ internal/intelligence/vision_benchmark.go | 30 ++ internal/model/asset.go | 210 +++++++++ internal/model/product.go | 78 ++++ internal/model/semantic_json.go | 47 ++ internal/model/semantic_json_test.go | 16 + internal/output/writer.go | 160 +++++++ internal/output/writer_test.go | 11 + internal/repository/product_loader.go | 71 +++ internal/repository/product_loader_test.go | 29 ++ internal/service/adapter.go | 98 ++++ internal/service/processor.go | 248 ++++++++++ internal/service/semantic.go | 274 +++++++++++ internal/service/semantic_provider.go | 289 ++++++++++++ internal/validation/validation.go | 70 +++ main.go | 11 + tools/model_server/requirements.txt | 5 + tools/model_server/server.py | 136 ++++++ tools/vision_server/requirements.txt | 7 + tools/vision_server/server.py | 335 ++++++++++++++ 开发文档.txt | 276 ++++++++++++ 开发文档2 | 332 ++++++++++++++ 开发文档3 | 501 +++++++++++++++++++++ 46 files changed, 6045 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/analyze/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/analyzer/interfaces.go create mode 100644 internal/app/app.go create mode 100644 internal/embedding/embedding.go create mode 100644 internal/imageproc/color.go create mode 100644 internal/imageproc/color_test.go create mode 100644 internal/imageproc/io.go create mode 100644 internal/imageproc/math.go create mode 100644 internal/imageproc/prepare.go create mode 100644 internal/imageproc/statistics.go create mode 100644 internal/imageproc/texture.go create mode 100644 internal/imageproc/transform.go create mode 100644 internal/intelligence/builders.go create mode 100644 internal/intelligence/builders_test.go create mode 100644 internal/intelligence/load.go create mode 100644 internal/intelligence/regression.go create mode 100644 internal/intelligence/run.go create mode 100644 internal/intelligence/similarity.go create mode 100644 internal/intelligence/statistics.go create mode 100644 internal/intelligence/types.go create mode 100644 internal/intelligence/vision_benchmark.go create mode 100644 internal/model/asset.go create mode 100644 internal/model/product.go create mode 100644 internal/model/semantic_json.go create mode 100644 internal/model/semantic_json_test.go create mode 100644 internal/output/writer.go create mode 100644 internal/output/writer_test.go create mode 100644 internal/repository/product_loader.go create mode 100644 internal/repository/product_loader_test.go create mode 100644 internal/service/adapter.go create mode 100644 internal/service/processor.go create mode 100644 internal/service/semantic.go create mode 100644 internal/service/semantic_provider.go create mode 100644 internal/validation/validation.go create mode 100644 main.go create mode 100644 tools/model_server/requirements.txt create mode 100644 tools/model_server/server.py create mode 100644 tools/vision_server/requirements.txt create mode 100644 tools/vision_server/server.py create mode 100644 开发文档.txt create mode 100644 开发文档2 create mode 100644 开发文档3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25888f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +MaterialAssets/ +cache/ +logs/ +.idea/ +KnowledgeBase/ +PromptDataset/ +Statistics/ +Embeddings/ +Fingerprints/ +GroundTruth/ +Regression/ +Benchmarks/ +material_intelligence_summary.json +*.exe +*.test +__pycache__/ +*.pyc + +# External model files should live outside this project repository. +data/ +models/ +*.safetensors +*.bin +*.pt +*.pth +*.onnx +*.gguf diff --git a/README.md b/README.md new file mode 100644 index 0000000..0917f8a --- /dev/null +++ b/README.md @@ -0,0 +1,242 @@ +# Material Analyzer + +用于将 FloorVisualizer 的地板 SKU 离线预处理为可复现 `MaterialAssets` 的流水线。 + +## 运行 + +```bash +go run . -limit 10 -workers 4 -verbose +``` + +CLI 默认读取: + +```text +..\FloorVisualizer\data\products +``` + +并输出到: + +```text +MaterialAssets\\ +``` + +常用参数示例: + +```bash +go run . -data-dir "D:\go-demo\FloorVisualizer\data\products" -output-dir MaterialAssets +go run . -sku "I966106LP" -force -verbose +go run . -limit 100 -workers 8 +go run . -retry 2 -strict +``` + +Provider 参数示例: + +```bash +go run . -embedding-provider local -semantic-provider local +go run . -embedding-provider http -embedding-url http://127.0.0.1:5200 +go run . -semantic-provider http -semantic-url http://127.0.0.1:5300 -semantic-model gemini-3-pro-image +go run . -semantic-provider local,internvl3 -semantic-url http://127.0.0.1:5300 +``` + +当配置多个 semantic provider 时,分析器会按字段做语义融合,并根据置信度加权。 + +## 模型依赖 + +本项目代码仓库不包含大模型权重。`InternVL3-8B` 应作为外部模型单独管理,例如放在独立 Git LFS 仓库、Hugging Face 仓库或团队对象存储中。 + +推荐本地目录结构: + +```text +D:\go-demo\ + Material Analyzer\ # 本项目代码仓库 + data\ + InternVL3-8B\ # 外部模型目录,不提交到本项目仓库 +``` + +导入模型时,将模型仓库或下载后的权重放到: + +```text +D:\go-demo\data\InternVL3-8B +``` + +模型目录至少需要包含: + +```text +config.json +generation_config.json +model.safetensors.index.json +model-00001-of-00004.safetensors +model-00002-of-00004.safetensors +model-00003-of-00004.safetensors +model-00004-of-00004.safetensors +tokenizer.json +tokenizer_config.json +vocab.json +merges.txt +``` + +如果模型放在其他位置,通过环境变量指定: + +```bash +set INTERNVL3_MODEL_PATH=D:\path\to\InternVL3-8B +``` + +模型目录中有本项目专用说明: + +```text +D:\go-demo\data\InternVL3-8B\README_MaterialAnalyzer.md +``` + +## 输出 + +每个处理完成的 SKU 会生成: + +```text +preview.jpg +thumbnail.jpg +material.json +histogram.json +embedding.bin +manifest.json +``` + +`material.json` 包含: + +- 分析器版本信息:`analyzer_version`、`feature_schema`、`generator`、`generated_at` +- 通用颜色特征:主/次 RGB、LAB、HSV、亮度、对比度、饱和度 +- 纹理特征:熵、Sobel 频率、边缘密度、方向方差、LBP 均匀度、GLCM 统计 +- 规范化统计:平均/中位 RGB、颜色方差、纹理方差、亮度分布、梯度直方图 +- 语义特征:描述、材质类型、表面处理、纹理类型、颜色族、风格、变化程度、光泽等级、标签 +- 材质适配器输出,以及木材、瓷砖、仿石瓷砖、乙烯基地板和通用材质的字段置信度 +- 校验状态 +- 二进制向量的 embedding manifest + +`manifest.json` 会列出所有文件和资产校验状态。 + +批量运行会写入: + +```text +MaterialAssets/benchmark.json +MaterialAssets/failures.json +``` + +`embedding.bin` 存储两个拼接的 float32 向量。默认 local provider 会生成确定性的 fallback 向量: + +- `dino_v2_texture_local`:颜色与纹理指纹 +- `clip_semantic_local`:元数据与语义哈希指纹 + +如果使用可选模型服务,则会生成: + +- `dinov2_texture`:`facebook/dinov2-large`,1024 维,已归一化 +- `clip_visual`:`ViT-L/14`,768 维,已归一化 + +## 可选模型服务 + +Go CLI 可以调用本地 DINOv2/CLIP 模型服务: + +```bash +cd tools/model_server +python -m pip install -r requirements.txt +python server.py +``` + +然后运行: + +```bash +go run . -sku I966106LP -force -embedding-provider http -embedding-url http://127.0.0.1:5200 +``` + +第一次请求会通过 Hugging Face 下载模型权重,可能需要一些时间。 + +## InternVL3 语义服务 + +`InternVL3-8B` 用于本项目的视觉语义分析。它不会生成 embedding,而是读取 `preview.jpg` 和产品元数据,输出结构化材质语义字段。 + +启动本地 InternVL3 语义服务: + +```bash +cd tools/vision_server +python -m pip install -r requirements.txt +set INTERNVL3_MODEL_PATH=D:\go-demo\data\InternVL3-8B +python server.py +``` + +服务默认监听: + +```text +http://127.0.0.1:5300/semantic +``` + +然后让分析器连接该服务: + +```bash +go run . -sku I966106LP -semantic-provider internvl3 -semantic-url http://127.0.0.1:5300 +``` + +也可以和本地规则 provider 组合使用: + +```bash +go run . -semantic-provider local,internvl3 -semantic-url http://127.0.0.1:5300 +``` + +常用环境变量: + +```text +INTERNVL3_MODEL_PATH 模型目录,默认 D:\go-demo\data\InternVL3-8B +INTERNVL3_PORT 服务端口,默认 5300 +INTERNVL3_IMAGE_SIZE 输入图块尺寸,默认 448 +INTERNVL3_MAX_TILES 最大图块数,默认 4 +INTERNVL3_MAX_NEW_TOKENS 最大生成 token 数,默认 512 +INTERNVL3_TEMPERATURE 生成温度,默认 0.0 +INTERNVL3_LOAD_IN_8BIT 是否 8bit 量化加载 +INTERNVL3_LOAD_IN_4BIT 是否 4bit 量化加载 +INTERNVL3_USE_FLASH_ATTN 是否启用 flash attention +``` + +## Material Intelligence + +基于已有 `MaterialAssets` 构建 v3 知识层: + +```bash +go run . intelligence -assets-dir MaterialAssets -output-root . -k 5 +``` + +该命令会创建: + +```text +KnowledgeBase/knowledge.db +KnowledgeBase/similarity.json +PromptDataset/prompt_dataset.json +Statistics/statistics.json +Embeddings/embeddings.json +Fingerprints/fingerprints.json +GroundTruth/ground_truth.json +Regression/regression_report.json +Benchmarks/benchmark_vision.json +material_intelligence_summary.json +``` + +每个资产也会收到: + +```text +semantic.json +manifest.json +``` + +`manifest.json` 包含 SHA256、文件大小和文件时间戳,用于资产完整性校验。 + +使用基线资产库做回归对比: + +```bash +go run . intelligence -assets-dir MaterialAssets -baseline-dir OldMaterialAssets +``` + +## 房间图识别 + +如果产品主材质图看起来像房间场景,分析器会标记: + +```json +"room_like": true +``` + +并使用图像下方的地面区域裁剪做特征提取。这样可以让流水线继续运行,同时也便于后续审计和清理这些资产。 diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go new file mode 100644 index 0000000..03e56a4 --- /dev/null +++ b/cmd/analyze/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "materialanalyzer/internal/app" +) + +func main() { + os.Exit(app.Run(os.Args[1:])) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..14a511f --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module materialanalyzer + +go 1.26.4 + +require golang.org/x/image v0.43.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8d05f87 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= diff --git a/internal/analyzer/interfaces.go b/internal/analyzer/interfaces.go new file mode 100644 index 0000000..157ac3f --- /dev/null +++ b/internal/analyzer/interfaces.go @@ -0,0 +1,33 @@ +package analyzer + +import ( + "context" + "image" + + "materialanalyzer/internal/embedding" + "materialanalyzer/internal/model" +) + +type ColorAnalyzer interface { + Analyze(ctx context.Context, img image.Image) (model.VisualFeatures, model.ColorHistogram, error) +} + +type TextureAnalyzer interface { + Analyze(ctx context.Context, img image.Image) (model.TextureFeatures, error) +} + +type StatisticsAnalyzer interface { + Analyze(ctx context.Context, img image.Image, visual model.VisualFeatures, texture model.TextureFeatures) (model.CanonicalStatistics, error) +} + +type SemanticAnalyzer interface { + Analyze(ctx context.Context, imagePath string, product model.Product, visual model.VisualFeatures, texture model.TextureFeatures) (model.SemanticFeatures, error) +} + +type EmbeddingAnalyzer interface { + Embed(ctx context.Context, req embedding.Request) (embedding.Result, error) +} + +type MaterialAdapter interface { + Adapt(product model.Product, visual model.VisualFeatures, texture model.TextureFeatures, semantic model.SemanticFeatures) (map[string]interface{}, error) +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..f37d72d --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,352 @@ +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") +} diff --git a/internal/embedding/embedding.go b/internal/embedding/embedding.go new file mode 100644 index 0000000..cf17d8c --- /dev/null +++ b/internal/embedding/embedding.go @@ -0,0 +1,280 @@ +package embedding + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "hash/fnv" + "math" + "net/http" + "os" + "regexp" + "strings" + + "materialanalyzer/internal/model" +) + +const LocalDimensions = 128 + +type Request struct { + ImagePath string + Product model.Product + Histogram model.ColorHistogram + Visual model.VisualFeatures + Texture model.TextureFeatures + Semantic model.SemanticFeatures +} + +type Result struct { + Vectors [][]float32 + Manifest model.EmbeddingManifest +} + +type Provider interface { + Name() string + Embed(ctx context.Context, req Request) (Result, error) +} + +type LocalProvider struct{} + +func (LocalProvider) Name() string { return "local" } + +func (LocalProvider) Embed(ctx context.Context, req Request) (Result, error) { + select { + case <-ctx.Done(): + return Result{}, ctx.Err() + default: + } + textureVector := BuildTextureVector(req.Histogram, req.Visual, req.Texture) + semanticVector := BuildSemanticVector(req.Product, req.Semantic) + return Result{ + Vectors: [][]float32{textureVector, semanticVector}, + Manifest: model.EmbeddingManifest{ + File: "embedding.bin", + Format: "float32_le_concatenated", + Vectors: []model.EmbeddingVector{ + {Name: "dino_v2_texture_local", Model: "local_texture_fingerprint", Dimensions: LocalDimensions, Generator: "local_color_texture_fingerprint_v2", Provider: "local", Normalize: true}, + {Name: "clip_semantic_local", Model: "local_semantic_hash", Dimensions: LocalDimensions, Generator: "local_metadata_semantic_hash_v2", Provider: "local", Normalize: true}, + }, + }, + }, nil +} + +type HTTPProvider struct { + BaseURL string + Client *http.Client +} + +func (p HTTPProvider) Name() string { return "http" } + +func (p HTTPProvider) Embed(ctx context.Context, req Request) (Result, error) { + if strings.TrimSpace(p.BaseURL) == "" { + return Result{}, fmt.Errorf("embedding HTTP provider URL is empty") + } + client := p.Client + if client == nil { + client = http.DefaultClient + } + imgData, err := os.ReadFile(req.ImagePath) + if err != nil { + return Result{}, fmt.Errorf("read embedding image: %w", err) + } + body := httpEmbeddingRequest{ + ImageBase64: base64.StdEncoding.EncodeToString(imgData), + ImagePath: req.ImagePath, + Product: req.Product, + Semantic: req.Semantic, + } + payload, err := json.Marshal(body) + if err != nil { + return Result{}, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, embeddingEndpoint(p.BaseURL), bytes.NewReader(payload)) + if err != nil { + return Result{}, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + resp, err := client.Do(httpReq) + if err != nil { + return Result{}, fmt.Errorf("embedding HTTP request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return Result{}, fmt.Errorf("embedding HTTP status %d", resp.StatusCode) + } + var decoded httpEmbeddingResponse + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + return Result{}, fmt.Errorf("decode embedding response: %w", err) + } + return decoded.toResult() +} + +type httpEmbeddingRequest struct { + ImageBase64 string `json:"image_base64"` + ImagePath string `json:"image_path"` + Product model.Product `json:"product"` + Semantic model.SemanticFeatures `json:"semantic"` +} + +type httpEmbeddingResponse struct { + Vectors []httpEmbeddingVector `json:"vectors"` +} + +type httpEmbeddingVector struct { + Name string `json:"name"` + Model string `json:"model"` + Dimension int `json:"dimension"` + Normalize bool `json:"normalize"` + Provider string `json:"provider"` + Generator string `json:"generator"` + Values []float32 `json:"values"` +} + +func (r httpEmbeddingResponse) toResult() (Result, error) { + if len(r.Vectors) == 0 { + return Result{}, fmt.Errorf("embedding response has no vectors") + } + result := Result{ + Manifest: model.EmbeddingManifest{File: "embedding.bin", Format: "float32_le_concatenated"}, + } + for _, v := range r.Vectors { + if v.Dimension <= 0 { + v.Dimension = len(v.Values) + } + if len(v.Values) != v.Dimension { + return Result{}, fmt.Errorf("embedding %s length = %d, dimension = %d", v.Name, len(v.Values), v.Dimension) + } + if v.Name == "" { + return Result{}, fmt.Errorf("embedding vector name is required") + } + result.Vectors = append(result.Vectors, v.Values) + result.Manifest.Vectors = append(result.Manifest.Vectors, model.EmbeddingVector{ + Name: v.Name, Model: v.Model, Dimensions: v.Dimension, + Generator: v.Generator, Provider: v.Provider, Normalize: v.Normalize, + }) + } + return result, nil +} + +func embeddingEndpoint(base string) string { + base = strings.TrimRight(base, "/") + if strings.HasSuffix(base, "/embeddings") { + return base + } + return base + "/embeddings" +} + +func BuildTextureVector(hist model.ColorHistogram, visual model.VisualFeatures, texture model.TextureFeatures) []float32 { + vec := make([]float32, LocalDimensions) + if len(hist.Values) > 0 { + target := 96 + for i := 0; i < target; i++ { + start := i * len(hist.Values) / target + end := (i + 1) * len(hist.Values) / target + var sum float64 + for _, v := range hist.Values[start:end] { + sum += v + } + vec[i] = float32(sum) + } + } + stats := []float64{ + visual.Brightness, visual.Contrast, visual.Saturation, + float64(visual.DominantRGB.R) / 255, float64(visual.DominantRGB.G) / 255, float64(visual.DominantRGB.B) / 255, + float64(visual.SecondaryRGB.R) / 255, float64(visual.SecondaryRGB.G) / 255, float64(visual.SecondaryRGB.B) / 255, + texture.Entropy / 8, texture.TextureFrequency, texture.EdgeDensity, texture.OrientationVariance, + texture.PrimaryOrientationDeg / 180, texture.LBPUniformity, + texture.GLCM.Contrast / 49, texture.GLCM.Homogeneity, texture.GLCM.Energy, (texture.GLCM.Correlation + 1) / 2, + } + for i, v := range stats { + idx := 96 + i + if idx >= len(vec) { + break + } + vec[idx] = float32(v) + } + normalize(vec) + return vec +} + +func BuildSemanticVector(p model.Product, semantic model.SemanticFeatures) []float32 { + vec := make([]float32, LocalDimensions) + text := strings.Join([]string{ + p.Brand, p.GroupName, p.SeriesName, p.StyleName, p.Category, p.Material, p.ColorTone, + p.Finish, p.Description, semantic.Description, semantic.MaterialType, semantic.VisualStyle, + semantic.GrainType, semantic.Grain, semantic.Variation, semantic.ColorFamily, semantic.GlossLevel, + strings.Join(semantic.Tags, " "), + }, " ") + for _, token := range tokenize(text) { + h := fnv.New32a() + _, _ = h.Write([]byte(token)) + sum := h.Sum32() + idx := int(sum % LocalDimensions) + sign := float32(1) + if sum&0x80 != 0 { + sign = -1 + } + vec[idx] += sign + } + normalize(vec) + return vec +} + +func WriteBinary(path string, result Result) (model.EmbeddingManifest, error) { + file, err := os.Create(path) + if err != nil { + return model.EmbeddingManifest{}, err + } + defer file.Close() + + manifest := result.Manifest + if manifest.File == "" { + manifest.File = "embedding.bin" + } + if manifest.Format == "" { + manifest.Format = "float32_le_concatenated" + } + var offset int64 + for i, vec := range result.Vectors { + if i >= len(manifest.Vectors) { + manifest.Vectors = append(manifest.Vectors, model.EmbeddingVector{Name: fmt.Sprintf("vector_%d", i), Dimensions: len(vec)}) + } + manifest.Vectors[i].OffsetBytes = offset + manifest.Vectors[i].ByteLength = int64(len(vec) * 4) + if manifest.Vectors[i].Dimensions == 0 { + manifest.Vectors[i].Dimensions = len(vec) + } + for _, v := range vec { + if err := binary.Write(file, binary.LittleEndian, v); err != nil { + return model.EmbeddingManifest{}, err + } + } + offset += int64(len(vec) * 4) + } + return manifest, nil +} + +var tokenRE = regexp.MustCompile(`[a-z0-9]+`) + +func tokenize(text string) []string { + return tokenRE.FindAllString(strings.ToLower(text), -1) +} + +func normalize(vec []float32) { + var sum float64 + for _, v := range vec { + sum += float64(v * v) + } + if sum == 0 { + return + } + scale := float32(1 / math.Sqrt(sum)) + for i := range vec { + vec[i] *= scale + } +} diff --git a/internal/imageproc/color.go b/internal/imageproc/color.go new file mode 100644 index 0000000..5acc261 --- /dev/null +++ b/internal/imageproc/color.go @@ -0,0 +1,250 @@ +package imageproc + +import ( + "image" + "math" + "sort" + + "materialanalyzer/internal/model" +) + +type rgbSample struct { + r, g, b float64 + lum float64 + sat float64 +} + +type colorCluster struct { + center rgbSample + count int +} + +func ExtractVisual(img image.Image) (model.VisualFeatures, model.ColorHistogram) { + samples := collectSamples(img, 65000) + hist := buildHSVHistogram(samples, 16, 4, 4) + if len(samples) == 0 { + return model.VisualFeatures{HistogramBins: 256, HistogramSpace: "hsv"}, hist + } + + var sumR, sumG, sumB, sumLum, sumLumSq, sumSat float64 + for _, s := range samples { + sumR += s.r + sumG += s.g + sumB += s.b + sumLum += s.lum + sumLumSq += s.lum * s.lum + sumSat += s.sat + } + n := float64(len(samples)) + avg := model.RGB{R: int(math.Round(sumR / n)), G: int(math.Round(sumG / n)), B: int(math.Round(sumB / n))} + avgHSV := RGBToHSV(avg) + avgLAB := RGBToLAB(avg) + brightness := sumLum / n / 255.0 + contrast := math.Sqrt(math.Max(0, sumLumSq/n-(sumLum/n)*(sumLum/n))) / 255.0 + saturation := sumSat / n + + clusters := kmeansRGB(samples, 5, 12) + colorClusters := make([]model.ColorCluster, 0, len(clusters)) + for _, c := range clusters { + if c.count == 0 { + continue + } + rgb := model.RGB{ + R: int(math.Round(c.center.r)), + G: int(math.Round(c.center.g)), + B: int(math.Round(c.center.b)), + } + colorClusters = append(colorClusters, model.ColorCluster{ + RGB: rgb, LAB: RGBToLAB(rgb), HSV: RGBToHSV(rgb), + Percentage: round4(float64(c.count) / n), + }) + } + sort.Slice(colorClusters, func(i, j int) bool { return colorClusters[i].Percentage > colorClusters[j].Percentage }) + + dominant := avg + secondary := avg + if len(colorClusters) > 0 { + dominant = colorClusters[0].RGB + } + if len(colorClusters) > 1 { + secondary = colorClusters[1].RGB + } + + return model.VisualFeatures{ + DominantRGB: dominant, SecondaryRGB: secondary, + DominantLAB: RGBToLAB(dominant), DominantHSV: RGBToHSV(dominant), + AverageRGB: avg, AverageLAB: avgLAB, AverageHSV: avgHSV, + Brightness: round4(brightness), Contrast: round4(contrast), Saturation: round4(saturation), + ColorClusters: colorClusters, + HistogramBins: 256, HistogramSpace: "hsv", + }, hist +} + +func collectSamples(img image.Image, maxSamples int) []rgbSample { + b := img.Bounds() + total := b.Dx() * b.Dy() + if total <= 0 { + return nil + } + step := 1 + if total > maxSamples { + step = int(math.Ceil(math.Sqrt(float64(total) / float64(maxSamples)))) + } + samples := make([]rgbSample, 0, min(total/(step*step)+1, maxSamples+1024)) + for y := b.Min.Y; y < b.Max.Y; y += step { + for x := b.Min.X; x < b.Max.X; x += step { + r, g, bb := rgba8(img.At(x, y)) + hsv := rgbToHSVFloat(float64(r), float64(g), float64(bb)) + lum := 0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(bb) + samples = append(samples, rgbSample{r: float64(r), g: float64(g), b: float64(bb), lum: lum, sat: hsv.S}) + } + } + return samples +} + +func buildHSVHistogram(samples []rgbSample, hueBins, saturationBins, valueBins int) model.ColorHistogram { + values := make([]float64, hueBins*saturationBins*valueBins) + if len(samples) == 0 { + return model.ColorHistogram{Space: "hsv", Bins: []int{hueBins, saturationBins, valueBins}, Values: values} + } + for _, s := range samples { + hsv := rgbToHSVFloat(s.r, s.g, s.b) + hi := min(hueBins-1, int(hsv.H/360.0*float64(hueBins))) + si := min(saturationBins-1, int(hsv.S*float64(saturationBins))) + vi := min(valueBins-1, int(hsv.V*float64(valueBins))) + values[hi*saturationBins*valueBins+si*valueBins+vi]++ + } + denom := float64(len(samples)) + for i := range values { + values[i] = round6(values[i] / denom) + } + return model.ColorHistogram{Space: "hsv", Bins: []int{hueBins, saturationBins, valueBins}, Values: values} +} + +func kmeansRGB(samples []rgbSample, k, iterations int) []colorCluster { + if len(samples) == 0 || k <= 0 { + return nil + } + sorted := append([]rgbSample(nil), samples...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].lum < sorted[j].lum }) + if k > len(sorted) { + k = len(sorted) + } + centers := make([]rgbSample, k) + for i := 0; i < k; i++ { + idx := int((float64(i) + 0.5) / float64(k) * float64(len(sorted)-1)) + centers[i] = sorted[idx] + } + + assignments := make([]int, len(samples)) + for iter := 0; iter < iterations; iter++ { + sums := make([]rgbSample, k) + counts := make([]int, k) + for i, s := range samples { + bestIdx := 0 + bestDist := math.MaxFloat64 + for c, center := range centers { + dr, dg, db := s.r-center.r, s.g-center.g, s.b-center.b + dist := dr*dr + dg*dg + db*db + if dist < bestDist { + bestDist = dist + bestIdx = c + } + } + assignments[i] = bestIdx + sums[bestIdx].r += s.r + sums[bestIdx].g += s.g + sums[bestIdx].b += s.b + sums[bestIdx].lum += s.lum + sums[bestIdx].sat += s.sat + counts[bestIdx]++ + } + for i := range centers { + if counts[i] == 0 { + continue + } + denom := float64(counts[i]) + centers[i] = rgbSample{ + r: sums[i].r / denom, g: sums[i].g / denom, b: sums[i].b / denom, + lum: sums[i].lum / denom, sat: sums[i].sat / denom, + } + } + } + + clusters := make([]colorCluster, k) + for i := range clusters { + clusters[i].center = centers[i] + } + for _, idx := range assignments { + clusters[idx].count++ + } + sort.Slice(clusters, func(i, j int) bool { return clusters[i].count > clusters[j].count }) + return clusters +} + +func RGBToHSV(rgb model.RGB) model.HSV { + return rgbToHSVFloat(float64(rgb.R), float64(rgb.G), float64(rgb.B)) +} + +func rgbToHSVFloat(r, g, b float64) model.HSV { + r /= 255 + g /= 255 + b /= 255 + maxV := math.Max(r, math.Max(g, b)) + minV := math.Min(r, math.Min(g, b)) + d := maxV - minV + h := 0.0 + if d != 0 { + switch maxV { + case r: + h = math.Mod((g-b)/d, 6) + case g: + h = (b-r)/d + 2 + default: + h = (r-g)/d + 4 + } + h *= 60 + if h < 0 { + h += 360 + } + } + s := 0.0 + if maxV != 0 { + s = d / maxV + } + return model.HSV{H: round4(h), S: round4(s), V: round4(maxV)} +} + +func RGBToLAB(rgb model.RGB) model.LAB { + r := pivotRGB(float64(rgb.R) / 255.0) + g := pivotRGB(float64(rgb.G) / 255.0) + b := pivotRGB(float64(rgb.B) / 255.0) + + x := (r*0.4124 + g*0.3576 + b*0.1805) / 0.95047 + y := (r*0.2126 + g*0.7152 + b*0.0722) / 1.00000 + z := (r*0.0193 + g*0.1192 + b*0.9505) / 1.08883 + + fx, fy, fz := pivotXYZ(x), pivotXYZ(y), pivotXYZ(z) + return model.LAB{ + L: round4(116*fy - 16), + A: round4(500 * (fx - fy)), + B: round4(200 * (fy - fz)), + } +} + +func pivotRGB(v float64) float64 { + if v > 0.04045 { + return math.Pow((v+0.055)/1.055, 2.4) + } + return v / 12.92 +} + +func pivotXYZ(v float64) float64 { + if v > 0.008856 { + return math.Cbrt(v) + } + return 7.787*v + 16.0/116.0 +} + +func round4(v float64) float64 { return math.Round(v*10000) / 10000 } +func round6(v float64) float64 { return math.Round(v*1000000) / 1000000 } diff --git a/internal/imageproc/color_test.go b/internal/imageproc/color_test.go new file mode 100644 index 0000000..d138d9b --- /dev/null +++ b/internal/imageproc/color_test.go @@ -0,0 +1,27 @@ +package imageproc + +import ( + "image" + "image/color" + "testing" +) + +func TestExtractVisualSolidColor(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 24, 24)) + for y := 0; y < 24; y++ { + for x := 0; x < 24; x++ { + img.Set(x, y, color.RGBA{R: 100, G: 150, B: 200, A: 255}) + } + } + + visual, hist := ExtractVisual(img) + if visual.AverageRGB.R != 100 || visual.AverageRGB.G != 150 || visual.AverageRGB.B != 200 { + t.Fatalf("average rgb = %+v", visual.AverageRGB) + } + if len(hist.Values) != 256 { + t.Fatalf("histogram length = %d", len(hist.Values)) + } + if visual.Contrast != 0 { + t.Fatalf("solid color contrast = %v", visual.Contrast) + } +} diff --git a/internal/imageproc/io.go b/internal/imageproc/io.go new file mode 100644 index 0000000..cc65cb9 --- /dev/null +++ b/internal/imageproc/io.go @@ -0,0 +1,128 @@ +package imageproc + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/hex" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" +) + +type LoadedImage struct { + Image image.Image + Format string + SourcePath string + FromCache bool +} + +func LoadImage(ctx context.Context, source, cacheDir string, client *http.Client) (*LoadedImage, error) { + if strings.TrimSpace(source) == "" { + return nil, fmt.Errorf("empty image source") + } + if isHTTPURL(source) { + return loadRemoteImage(ctx, source, cacheDir, client) + } + return loadLocalImage(source) +} + +func isHTTPURL(raw string) bool { + u, err := url.Parse(raw) + return err == nil && (u.Scheme == "http" || u.Scheme == "https") +} + +func loadLocalImage(path string) (*LoadedImage, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open image: %w", err) + } + defer file.Close() + img, format, err := image.Decode(file) + if err != nil { + return nil, fmt.Errorf("decode image: %w", err) + } + return &LoadedImage{Image: img, Format: format, SourcePath: path}, nil +} + +func loadRemoteImage(ctx context.Context, source, cacheDir string, client *http.Client) (*LoadedImage, error) { + if client == nil { + client = http.DefaultClient + } + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return nil, fmt.Errorf("create image cache: %w", err) + } + + cachePath := filepath.Join(cacheDir, cacheFileName(source)) + if data, err := os.ReadFile(cachePath); err == nil { + img, format, err := image.Decode(bytes.NewReader(data)) + if err == nil { + return &LoadedImage{Image: img, Format: format, SourcePath: cachePath, FromCache: true}, nil + } + _ = os.Remove(cachePath) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil) + if err != nil { + return nil, fmt.Errorf("create image request: %w", err) + } + req.Header.Set("User-Agent", "MaterialAnalyzer/1.0 (+offline preprocessing)") + req.Header.Set("Accept", "image/webp,image/jpeg,image/png,image/gif,*/*;q=0.1") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("download image: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("download image returned HTTP %d", resp.StatusCode) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, 25<<20)) + if err != nil { + return nil, fmt.Errorf("read image response: %w", err) + } + if len(data) == 0 { + return nil, fmt.Errorf("downloaded image is empty") + } + img, format, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("decode downloaded image: %w", err) + } + if err := os.WriteFile(cachePath, data, 0o644); err != nil { + return nil, fmt.Errorf("write image cache: %w", err) + } + return &LoadedImage{Image: img, Format: format, SourcePath: cachePath}, nil +} + +func cacheFileName(source string) string { + sum := sha1.Sum([]byte(source)) + ext := ".img" + if u, err := url.Parse(source); err == nil { + if candidate := strings.ToLower(filepath.Ext(u.Path)); isImageExt(candidate) { + ext = candidate + } + } + return hex.EncodeToString(sum[:]) + ext +} + +func isImageExt(ext string) bool { + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff": + return true + default: + return false + } +} diff --git a/internal/imageproc/math.go b/internal/imageproc/math.go new file mode 100644 index 0000000..247b508 --- /dev/null +++ b/internal/imageproc/math.go @@ -0,0 +1,15 @@ +package imageproc + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/imageproc/prepare.go b/internal/imageproc/prepare.go new file mode 100644 index 0000000..66b99f3 --- /dev/null +++ b/internal/imageproc/prepare.go @@ -0,0 +1,124 @@ +package imageproc + +import ( + "fmt" + "image" + "math" + "net/url" + "strings" +) + +type AnalysisImage struct { + Image image.Image + OriginalWidth int + OriginalHeight int + Region image.Rectangle + Strategy string + RoomLike bool + RoomLikeConfidence string + RoomLikeReason string +} + +func PrepareAnalysisImage(src image.Image, source string) AnalysisImage { + b := src.Bounds() + roomLike, confidence, reason := DetectRoomLike(src, source) + region := b + strategy := "full_texture" + if roomLike { + top := b.Min.Y + int(float64(b.Dy())*0.45) + left := b.Min.X + int(float64(b.Dx())*0.08) + right := b.Max.X - int(float64(b.Dx())*0.08) + region = image.Rect(left, top, right, b.Max.Y).Intersect(b) + strategy = "room_like_lower_floor_crop" + } + return AnalysisImage{ + Image: Crop(src, region), OriginalWidth: b.Dx(), OriginalHeight: b.Dy(), + Region: region, Strategy: strategy, RoomLike: roomLike, + RoomLikeConfidence: confidence, RoomLikeReason: reason, + } +} + +func DetectRoomLike(src image.Image, source string) (bool, string, string) { + b := src.Bounds() + w, h := b.Dx(), b.Dy() + if w == 0 || h == 0 { + return false, "", "" + } + urlHint, hint := roomSceneHint(source) + ratio := float64(w) / float64(h) + roomGeometry := false + if urlHint { + top := image.Rect(b.Min.X, b.Min.Y, b.Max.X, b.Min.Y+h/2) + bottom := image.Rect(b.Min.X, b.Min.Y+h/2, b.Max.X, b.Max.Y) + topEdge, topVar := regionEdgeAndVariance(src, top) + bottomEdge, bottomVar := regionEdgeAndVariance(src, bottom) + roomGeometry = (ratio > 1.28 || ratio < 0.78) && bottomEdge > topEdge*1.25 && bottomVar > topVar*1.10 + } + + switch { + case urlHint && roomGeometry: + return true, "high", fmt.Sprintf("source image path has %q token plus room-like edge split (ratio %.2f)", hint, ratio) + case urlHint: + return true, "medium", fmt.Sprintf("source image path contains %q room-scene token", hint) + default: + return false, "", "" + } +} + +func roomSceneHint(source string) (bool, string) { + candidate := source + if parsed, err := url.Parse(source); err == nil && parsed.Path != "" { + candidate = parsed.Path + } + if decoded, err := url.PathUnescape(candidate); err == nil { + candidate = decoded + } + for _, token := range sceneTokens(strings.ToLower(candidate)) { + switch token { + case "room", "rooms", "lifestyle", "installed", "installation", "gallery", "ambient", "beauty": + return true, token + } + } + return false, "" +} + +func sceneTokens(s string) []string { + return strings.FieldsFunc(s, func(r rune) bool { + return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) + }) +} + +func regionEdgeAndVariance(src image.Image, rect image.Rectangle) (float64, float64) { + rect = rect.Intersect(src.Bounds()) + if rect.Dx() < 3 || rect.Dy() < 3 { + return 0, 0 + } + step := int(math.Sqrt(float64(rect.Dx()*rect.Dy())/12000.0)) + 1 + var n int + var sum, sumSq float64 + var edges int + for y := rect.Min.Y + step; y < rect.Max.Y-step; y += step { + for x := rect.Min.X + step; x < rect.Max.X-step; x += step { + l := luminanceAt(src, x, y) + lx := luminanceAt(src, x+step, y) - luminanceAt(src, x-step, y) + ly := luminanceAt(src, x, y+step) - luminanceAt(src, x, y-step) + if math.Hypot(lx, ly) > 26 { + edges++ + } + sum += l + sumSq += l * l + n++ + } + } + if n == 0 { + return 0, 0 + } + mean := sum / float64(n) + variance := sumSq/float64(n) - mean*mean + return float64(edges) / float64(n), variance +} + +func luminanceAt(src image.Image, x, y int) float64 { + r, g, b := rgba8(src.At(x, y)) + return 0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b) +} diff --git a/internal/imageproc/statistics.go b/internal/imageproc/statistics.go new file mode 100644 index 0000000..53c4fce --- /dev/null +++ b/internal/imageproc/statistics.go @@ -0,0 +1,99 @@ +package imageproc + +import ( + "image" + "math" + "sort" + + "materialanalyzer/internal/model" +) + +func ExtractCanonicalStatistics(img image.Image, visual model.VisualFeatures, texture model.TextureFeatures) model.CanonicalStatistics { + samples := collectSamples(img, 65000) + stats := model.CanonicalStatistics{ + MeanRGB: visual.AverageRGB, + DominantOrientationDeg: texture.PrimaryOrientationDeg, + BrightnessHistogramBins: 16, + GradientHistogramBins: 18, + BrightnessDistribution: make([]float64, 16), + GradientHistogram: make([]float64, 18), + } + if len(samples) == 0 { + return stats + } + + rs := make([]int, len(samples)) + gs := make([]int, len(samples)) + bs := make([]int, len(samples)) + var varianceSum float64 + for i, s := range samples { + rs[i] = int(math.Round(s.r)) + gs[i] = int(math.Round(s.g)) + bs[i] = int(math.Round(s.b)) + dr := s.r - float64(visual.AverageRGB.R) + dg := s.g - float64(visual.AverageRGB.G) + db := s.b - float64(visual.AverageRGB.B) + varianceSum += (dr*dr + dg*dg + db*db) / 3 + bin := min(len(stats.BrightnessDistribution)-1, int((s.lum/255.0)*float64(len(stats.BrightnessDistribution)))) + stats.BrightnessDistribution[bin]++ + } + sort.Ints(rs) + sort.Ints(gs) + sort.Ints(bs) + mid := len(samples) / 2 + stats.MedianRGB = model.RGB{R: rs[mid], G: gs[mid], B: bs[mid]} + stats.ColorVariance = round4(varianceSum / float64(len(samples)) / (255.0 * 255.0)) + for i := range stats.BrightnessDistribution { + stats.BrightnessDistribution[i] = round6(stats.BrightnessDistribution[i] / float64(len(samples))) + } + + stats.TextureVariance, stats.GradientHistogram = gradientStatistics(img, len(stats.GradientHistogram)) + return stats +} + +func gradientStatistics(img image.Image, bins int) (float64, []float64) { + small := ResizeToMax(img, 384) + gray, w, h := grayscale(small) + hist := make([]float64, bins) + if w < 3 || h < 3 || bins <= 0 { + return 0, hist + } + + var mags []float64 + var totalWeight float64 + for y := 1; y < h-1; y++ { + for x := 1; x < w-1; x++ { + gx := -int(gray[(y-1)*w+x-1]) + int(gray[(y-1)*w+x+1]) - + 2*int(gray[y*w+x-1]) + 2*int(gray[y*w+x+1]) - + int(gray[(y+1)*w+x-1]) + int(gray[(y+1)*w+x+1]) + gy := -int(gray[(y-1)*w+x-1]) - 2*int(gray[(y-1)*w+x]) - int(gray[(y-1)*w+x+1]) + + int(gray[(y+1)*w+x-1]) + 2*int(gray[(y+1)*w+x]) + int(gray[(y+1)*w+x+1]) + mag := math.Hypot(float64(gx), float64(gy)) + mags = append(mags, mag) + angle := math.Atan2(float64(gy), float64(gx)) + math.Pi/2 + deg := math.Mod(angle*180/math.Pi+180, 180) + bin := min(bins-1, int(deg/180*float64(bins))) + hist[bin] += mag + totalWeight += mag + } + } + if len(mags) == 0 { + return 0, hist + } + var mean, variance float64 + for _, mag := range mags { + mean += mag + } + mean /= float64(len(mags)) + for _, mag := range mags { + d := mag - mean + variance += d * d + } + variance = variance / float64(len(mags)) / (1020.0 * 1020.0) + if totalWeight > 0 { + for i := range hist { + hist[i] = round6(hist[i] / totalWeight) + } + } + return round4(variance), hist +} diff --git a/internal/imageproc/texture.go b/internal/imageproc/texture.go new file mode 100644 index 0000000..da801a8 --- /dev/null +++ b/internal/imageproc/texture.go @@ -0,0 +1,218 @@ +package imageproc + +import ( + "image" + "math" + + "materialanalyzer/internal/model" +) + +func ExtractTexture(img image.Image) model.TextureFeatures { + small := ResizeToMax(img, 384) + gray, w, h := grayscale(small) + if w < 3 || h < 3 { + return model.TextureFeatures{Algorithm: "entropy+sobel+lbp+glcm(local)"} + } + + ent := entropy(gray) + edgeDensity, frequency, orientationVar, primaryOrientation := sobelStats(gray, w, h) + lbpUniformity := lbpUniformity(gray, w, h) + glcm := glcmFeatures(gray, w, h, 8) + + return model.TextureFeatures{ + Entropy: round4(ent), TextureFrequency: round4(frequency), EdgeDensity: round4(edgeDensity), + OrientationVariance: round4(orientationVar), PrimaryOrientationDeg: round4(primaryOrientation), + LBPUniformity: round4(lbpUniformity), GLCM: glcm, + Algorithm: "entropy+sobel+structure_tensor+lbp+glcm(local)", + } +} + +func grayscale(img image.Image) ([]uint8, int, int) { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + out := make([]uint8, w*h) + i := 0 + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r, g, bb := rgba8(img.At(x, y)) + out[i] = uint8(math.Round(0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(bb))) + i++ + } + } + return out, w, h +} + +func entropy(gray []uint8) float64 { + if len(gray) == 0 { + return 0 + } + var hist [256]int + for _, v := range gray { + hist[v]++ + } + var e float64 + denom := float64(len(gray)) + for _, c := range hist { + if c == 0 { + continue + } + p := float64(c) / denom + e -= p * math.Log2(p) + } + return e +} + +func sobelStats(gray []uint8, w, h int) (edgeDensity, frequency, orientationVariance, primaryOrientation float64) { + var edges, n int + var sumMag, sumWeight, sumCos, sumSin float64 + for y := 1; y < h-1; y++ { + for x := 1; x < w-1; x++ { + gx := -int(gray[(y-1)*w+x-1]) + int(gray[(y-1)*w+x+1]) - + 2*int(gray[y*w+x-1]) + 2*int(gray[y*w+x+1]) - + int(gray[(y+1)*w+x-1]) + int(gray[(y+1)*w+x+1]) + gy := -int(gray[(y-1)*w+x-1]) - 2*int(gray[(y-1)*w+x]) - int(gray[(y-1)*w+x+1]) + + int(gray[(y+1)*w+x-1]) + 2*int(gray[(y+1)*w+x]) + int(gray[(y+1)*w+x+1]) + mag := math.Hypot(float64(gx), float64(gy)) + if mag > 72 { + edges++ + } + if mag > 8 { + angle := math.Atan2(float64(gy), float64(gx)) + math.Pi/2 + sumCos += mag * math.Cos(2*angle) + sumSin += mag * math.Sin(2*angle) + sumWeight += mag + } + sumMag += mag + n++ + } + } + if n == 0 { + return 0, 0, 0, 0 + } + edgeDensity = float64(edges) / float64(n) + frequency = math.Min(1, sumMag/(float64(n)*255.0)) + if sumWeight == 0 { + return edgeDensity, frequency, 1, 0 + } + coherence := math.Hypot(sumCos, sumSin) / sumWeight + orientationVariance = 1 - coherence + primary := 0.5 * math.Atan2(sumSin, sumCos) * 180 / math.Pi + if primary < 0 { + primary += 180 + } + return edgeDensity, frequency, orientationVariance, primary +} + +func lbpUniformity(gray []uint8, w, h int) float64 { + if w < 3 || h < 3 { + return 0 + } + var hist [256]int + var n int + for y := 1; y < h-1; y++ { + for x := 1; x < w-1; x++ { + c := gray[y*w+x] + code := 0 + if gray[(y-1)*w+x-1] >= c { + code |= 1 << 7 + } + if gray[(y-1)*w+x] >= c { + code |= 1 << 6 + } + if gray[(y-1)*w+x+1] >= c { + code |= 1 << 5 + } + if gray[y*w+x+1] >= c { + code |= 1 << 4 + } + if gray[(y+1)*w+x+1] >= c { + code |= 1 << 3 + } + if gray[(y+1)*w+x] >= c { + code |= 1 << 2 + } + if gray[(y+1)*w+x-1] >= c { + code |= 1 << 1 + } + if gray[y*w+x-1] >= c { + code |= 1 + } + hist[code]++ + n++ + } + } + if n == 0 { + return 0 + } + var uniformity float64 + for _, c := range hist { + p := float64(c) / float64(n) + uniformity += p * p + } + return uniformity +} + +func glcmFeatures(gray []uint8, w, h, levels int) model.GLCMFeatures { + if w < 2 || h < 2 || levels <= 1 { + return model.GLCMFeatures{Levels: levels} + } + matrix := make([]float64, levels*levels) + add := func(a, b uint8) { + i := min(levels-1, int(a)*levels/256) + j := min(levels-1, int(b)*levels/256) + matrix[i*levels+j]++ + matrix[j*levels+i]++ + } + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + v := gray[y*w+x] + if x+1 < w { + add(v, gray[y*w+x+1]) + } + if y+1 < h { + add(v, gray[(y+1)*w+x]) + } + } + } + var total float64 + for _, v := range matrix { + total += v + } + if total == 0 { + return model.GLCMFeatures{Levels: levels} + } + for i := range matrix { + matrix[i] /= total + } + + var meanI, meanJ float64 + for i := 0; i < levels; i++ { + for j := 0; j < levels; j++ { + p := matrix[i*levels+j] + meanI += float64(i) * p + meanJ += float64(j) * p + } + } + var contrast, homogeneity, energy, varI, varJ, corr float64 + for i := 0; i < levels; i++ { + for j := 0; j < levels; j++ { + p := matrix[i*levels+j] + d := float64(i - j) + contrast += d * d * p + homogeneity += p / (1 + math.Abs(d)) + energy += p * p + varI += (float64(i) - meanI) * (float64(i) - meanI) * p + varJ += (float64(j) - meanJ) * (float64(j) - meanJ) * p + corr += (float64(i) - meanI) * (float64(j) - meanJ) * p + } + } + if varI > 0 && varJ > 0 { + corr /= math.Sqrt(varI * varJ) + } else { + corr = 0 + } + return model.GLCMFeatures{ + Levels: levels, Contrast: round4(contrast), Homogeneity: round4(homogeneity), + Energy: round4(energy), Correlation: round4(corr), + } +} diff --git a/internal/imageproc/transform.go b/internal/imageproc/transform.go new file mode 100644 index 0000000..1f03464 --- /dev/null +++ b/internal/imageproc/transform.go @@ -0,0 +1,79 @@ +package imageproc + +import ( + "image" + "image/color" + + xdraw "golang.org/x/image/draw" +) + +func Crop(src image.Image, rect image.Rectangle) image.Image { + b := src.Bounds() + rect = rect.Intersect(b) + if rect.Empty() { + rect = b + } + dst := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy())) + for y := rect.Min.Y; y < rect.Max.Y; y++ { + for x := rect.Min.X; x < rect.Max.X; x++ { + dst.Set(x-rect.Min.X, y-rect.Min.Y, src.At(x, y)) + } + } + return dst +} + +func ResizeToMax(src image.Image, maxDim int) image.Image { + if maxDim <= 0 { + return src + } + b := src.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxDim && h <= maxDim { + return normalizeRGBA(src) + } + nw, nh := w, h + if w >= h { + nw = maxDim + nh = max(1, h*maxDim/w) + } else { + nh = maxDim + nw = max(1, w*maxDim/h) + } + dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) + xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, b, xdraw.Over, nil) + return dst +} + +func ResizeToFill(src image.Image, width, height int) image.Image { + if width <= 0 || height <= 0 { + return normalizeRGBA(src) + } + b := src.Bounds() + srcRatio := float64(b.Dx()) / float64(b.Dy()) + dstRatio := float64(width) / float64(height) + crop := b + if srcRatio > dstRatio { + cropW := int(float64(b.Dy()) * dstRatio) + x0 := b.Min.X + (b.Dx()-cropW)/2 + crop = image.Rect(x0, b.Min.Y, x0+cropW, b.Max.Y) + } else if srcRatio < dstRatio { + cropH := int(float64(b.Dx()) / dstRatio) + y0 := b.Min.Y + (b.Dy()-cropH)/2 + crop = image.Rect(b.Min.X, y0, b.Max.X, y0+cropH) + } + dst := image.NewRGBA(image.Rect(0, 0, width, height)) + xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, crop, xdraw.Over, nil) + return dst +} + +func normalizeRGBA(src image.Image) image.Image { + b := src.Bounds() + dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + xdraw.Draw(dst, dst.Bounds(), src, b.Min, xdraw.Src) + return dst +} + +func rgba8(c color.Color) (int, int, int) { + r, g, b, _ := c.RGBA() + return int(r >> 8), int(g >> 8), int(b >> 8) +} diff --git a/internal/intelligence/builders.go b/internal/intelligence/builders.go new file mode 100644 index 0000000..726c216 --- /dev/null +++ b/internal/intelligence/builders.go @@ -0,0 +1,124 @@ +package intelligence + +import ( + "fmt" + "math" + "strings" + + "materialanalyzer/internal/model" +) + +func buildFingerprint(asset model.MaterialAsset) MaterialFingerprint { + variation := variationScore(asset.Semantic.Variation, asset.Visual.Contrast, asset.Texture.EdgeDensity) + fp := MaterialFingerprint{ + SKU: asset.SKU, + Brightness: round4(asset.Visual.Brightness), + Contrast: round4(asset.Visual.Contrast), + Saturation: round4(asset.Visual.Saturation), + Variation: round4(variation), + TextureEntropy: round4(asset.Texture.Entropy / 8.0), + TextureFrequency: round4(asset.Texture.TextureFrequency), + Orientation: round4(asset.Texture.PrimaryOrientationDeg), + DominantLAB: []float64{ + round4(asset.Visual.DominantLAB.L), + round4(asset.Visual.DominantLAB.A), + round4(asset.Visual.DominantLAB.B), + }, + ColorVariance: round4(asset.Canonical.ColorVariance), + TextureVariance: round4(asset.Canonical.TextureVariance), + MaterialType: firstNonEmpty(asset.Semantic.MaterialType, familyFromSpecific(asset.MaterialSpecific), asset.Category), + ColorFamily: asset.Semantic.ColorFamily, + SurfaceFinish: asset.Semantic.SurfaceFinish, + GlossLevel: asset.Semantic.GlossLevel, + } + fp.ReadableSignature = fmt.Sprintf("%s %s, %s finish, %s gloss, brightness %.2f, contrast %.2f, entropy %.2f, orientation %.0f", + fp.ColorFamily, fp.MaterialType, fp.SurfaceFinish, fp.GlossLevel, fp.Brightness, fp.Contrast, fp.TextureEntropy, fp.Orientation) + return fp +} + +func buildGroundTruth(asset model.MaterialAsset, fp MaterialFingerprint) GroundTruth { + return GroundTruth{ + SKU: asset.SKU, + Brand: asset.Product.Brand, + Category: asset.Category, + Material: asset.Material, + MaterialType: fp.MaterialType, + CanonicalColor: firstNonEmpty(asset.Semantic.ColorFamily, asset.Product.ColorTone), + SurfaceFinish: asset.Semantic.SurfaceFinish, + GlossLevel: asset.Semantic.GlossLevel, + GrainType: firstNonEmpty(asset.Semantic.GrainType, asset.Semantic.Grain), + VisualStyle: asset.Semantic.VisualStyle, + Variation: asset.Semantic.Variation, + RenderingConstraints: []string{ + "Keep material color, species, finish, grain, gloss, and variation identical to the reference texture.", + "Use the reference image as material ground truth, not as loose style inspiration.", + "Only adjust plank or tile scale when size instructions require it.", + }, + DoNotAlter: []string{"species", "finish", "grain_type", "gloss_level", "color_family", "variation"}, + Fingerprint: fp, + MaterialSpecific: asset.MaterialSpecific, + } +} + +func buildPromptRecord(asset model.MaterialAsset, gt GroundTruth) PromptRecord { + system := "You are a material-constrained image rendering pipeline. Preserve flooring material identity exactly." + material := strings.Join([]string{ + "Material Constraints", + "", + "SKU: " + asset.SKU, + "Material Type: " + gt.MaterialType, + "Material: " + firstNonEmpty(asset.Material, gt.MaterialType), + "Color Family: " + gt.CanonicalColor, + "Surface: " + gt.SurfaceFinish, + "Gloss: " + gt.GlossLevel, + "Grain: " + gt.GrainType, + "Visual Style: " + gt.VisualStyle, + "Variation: " + gt.Variation, + "", + "Keep all material properties identical to the reference texture.", + "Do not alter species, finish, grain, gloss, color temperature, or variation.", + "The reference texture is ground truth for the floor material.", + }, "\n") + negative := "Do not reinterpret the material. Do not change wood species, stone type, grain direction, surface finish, gloss level, color family, plank texture, tile texture, or material variation." + return PromptRecord{SKU: asset.SKU, SystemPrompt: system, MaterialPrompt: material, NegativePrompt: negative} +} + +func variationScore(label string, contrast, edgeDensity float64) float64 { + switch strings.ToLower(strings.TrimSpace(label)) { + case "high": + return math.Max(0.70, math.Min(1, contrast*2.5+edgeDensity*2.0)) + case "medium": + return math.Max(0.35, math.Min(0.75, contrast*2.0+edgeDensity*1.6)) + case "low": + return math.Min(0.35, contrast*1.8+edgeDensity*1.2) + default: + return math.Min(1, contrast*2.0+edgeDensity*1.5) + } +} + +func familyFromSpecific(values map[string]interface{}) string { + if values == nil { + return "" + } + if v, ok := values["family"].(string); ok { + return v + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func round4(v float64) float64 { + return math.Round(v*10000) / 10000 +} + +func round6(v float64) float64 { + return math.Round(v*1000000) / 1000000 +} diff --git a/internal/intelligence/builders_test.go b/internal/intelligence/builders_test.go new file mode 100644 index 0000000..e558df2 --- /dev/null +++ b/internal/intelligence/builders_test.go @@ -0,0 +1,29 @@ +package intelligence + +import ( + "testing" + + "materialanalyzer/internal/model" +) + +func TestBuildFingerprint(t *testing.T) { + asset := model.MaterialAsset{ + SKU: "sku-1", + Visual: model.VisualFeatures{ + Brightness: 0.5, Contrast: 0.1, Saturation: 0.2, + DominantLAB: model.LAB{L: 50, A: 1, B: 2}, + }, + Texture: model.TextureFeatures{Entropy: 4, TextureFrequency: 0.3, EdgeDensity: 0.1, PrimaryOrientationDeg: 90}, + Canonical: model.CanonicalStatistics{ColorVariance: 0.02, TextureVariance: 0.03}, + Semantic: model.SemanticFeatures{ + MaterialType: "wood", ColorFamily: "Brown", SurfaceFinish: "Matte", GlossLevel: "Low", Variation: "Medium", + }, + } + fp := buildFingerprint(asset) + if fp.SKU != "sku-1" || fp.MaterialType != "wood" || fp.TextureEntropy != 0.5 { + t.Fatalf("fingerprint = %+v", fp) + } + if fp.ReadableSignature == "" { + t.Fatal("missing readable signature") + } +} diff --git a/internal/intelligence/load.go b/internal/intelligence/load.go new file mode 100644 index 0000000..c76e053 --- /dev/null +++ b/internal/intelligence/load.go @@ -0,0 +1,140 @@ +package intelligence + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "io" + "math" + "os" + "path/filepath" + "sort" + "strings" + + "materialanalyzer/internal/model" +) + +type loadedAsset struct { + Asset model.MaterialAsset + AssetDir string + Histogram model.ColorHistogram + Embeddings []EmbeddingRecord +} + +func loadAssets(assetsDir string) ([]loadedAsset, error) { + entries, err := os.ReadDir(assetsDir) + if err != nil { + return nil, fmt.Errorf("read assets dir: %w", err) + } + out := make([]loadedAsset, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + assetDir := filepath.Join(assetsDir, entry.Name()) + asset, err := readMaterialAsset(filepath.Join(assetDir, "material.json")) + if err != nil { + continue + } + hist, _ := readHistogram(filepath.Join(assetDir, "histogram.json")) + embeddings, _ := readEmbeddings(filepath.Join(assetDir, "embedding.bin"), asset.Embeddings) + out = append(out, loadedAsset{Asset: asset, AssetDir: assetDir, Histogram: hist, Embeddings: embeddings}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Asset.SKU < out[j].Asset.SKU }) + return out, nil +} + +func readMaterialAsset(path string) (model.MaterialAsset, error) { + file, err := os.Open(path) + if err != nil { + return model.MaterialAsset{}, err + } + defer file.Close() + var asset model.MaterialAsset + if err := json.NewDecoder(file).Decode(&asset); err != nil { + return model.MaterialAsset{}, err + } + if strings.TrimSpace(asset.SKU) == "" { + return model.MaterialAsset{}, fmt.Errorf("asset %s has no SKU", path) + } + migrateAssetDefaults(&asset) + return asset, nil +} + +func migrateAssetDefaults(asset *model.MaterialAsset) { + if asset.Semantic.MaterialType == "" { + asset.Semantic.MaterialType = firstNonEmpty(familyFromSpecific(asset.MaterialSpecific), asset.Category, asset.Material) + } + if asset.Semantic.GrainType == "" { + asset.Semantic.GrainType = asset.Semantic.Grain + } + if asset.Semantic.GlossLevel == "" { + asset.Semantic.GlossLevel = glossFromFinish(asset.Semantic.SurfaceFinish) + } + if asset.Semantic.Confidence == 0 { + asset.Semantic.Confidence = 0.65 + } + if asset.AnalyzerVersion == "" { + asset.AnalyzerVersion = "1.x" + } + if asset.FeatureSchema == "" { + asset.FeatureSchema = "legacy" + } +} + +func glossFromFinish(finish string) string { + lower := strings.ToLower(finish) + switch { + case strings.Contains(lower, "gloss"): + return "High" + case strings.Contains(lower, "satin"): + return "Medium" + case strings.Contains(lower, "matte"): + return "Low" + default: + return "Unknown" + } +} + +func readHistogram(path string) (model.ColorHistogram, error) { + file, err := os.Open(path) + if err != nil { + return model.ColorHistogram{}, err + } + defer file.Close() + var hist model.ColorHistogram + return hist, json.NewDecoder(file).Decode(&hist) +} + +func readEmbeddings(path string, manifest model.EmbeddingManifest) ([]EmbeddingRecord, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + data, err := io.ReadAll(file) + if err != nil { + return nil, err + } + records := make([]EmbeddingRecord, 0, len(manifest.Vectors)) + for _, vector := range manifest.Vectors { + start := int(vector.OffsetBytes) + byteLen := int(vector.ByteLength) + if byteLen == 0 && vector.Dimensions > 0 { + byteLen = vector.Dimensions * 4 + } + end := start + byteLen + if start < 0 || end > len(data) || byteLen%4 != 0 { + continue + } + values := make([]float32, byteLen/4) + for i := range values { + values[i] = math.Float32frombits(binary.LittleEndian.Uint32(data[start+i*4 : start+i*4+4])) + } + records = append(records, EmbeddingRecord{ + Name: vector.Name, Model: vector.Model, Provider: vector.Provider, + Dimensions: len(values), Values: values, + }) + } + return records, nil +} diff --git a/internal/intelligence/regression.go b/internal/intelligence/regression.go new file mode 100644 index 0000000..293ae2a --- /dev/null +++ b/internal/intelligence/regression.go @@ -0,0 +1,108 @@ +package intelligence + +import ( + "path/filepath" + "time" +) + +func buildRegressionReport(assets []loadedAsset, baselineDir string) RegressionReport { + report := RegressionReport{ + Version: "3.0", + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + Status: "skipped", + BaselineDir: baselineDir, + Summary: map[string]interface{}{}, + } + if baselineDir == "" { + report.Summary["reason"] = "baseline-dir not provided" + return report + } + baseline, err := loadAssets(baselineDir) + if err != nil { + report.Summary["reason"] = err.Error() + return report + } + bySKU := map[string]loadedAsset{} + for _, item := range baseline { + bySKU[item.Asset.SKU] = item + } + var drifted int + for _, current := range assets { + old, ok := bySKU[current.Asset.SKU] + if !ok { + continue + } + item := compareAssets(old, current) + if item.Status != "stable" { + drifted++ + } + report.Items = append(report.Items, item) + report.Compared++ + } + report.Status = "complete" + report.Summary["drifted"] = drifted + report.Summary["stable"] = report.Compared - drifted + return report +} + +func compareAssets(old, current loadedAsset) RegressionItem { + brightnessDelta := abs(current.Asset.Visual.Brightness - old.Asset.Visual.Brightness) + histDistance := histogramDistance(old.Histogram.Values, current.Histogram.Values) + embeddingDrift := 1 - cosineFromEmbeddingRecords(old.Embeddings, current.Embeddings) + semanticChanged := old.Asset.Semantic.Description != current.Asset.Semantic.Description || + old.Asset.Semantic.MaterialType != current.Asset.Semantic.MaterialType || + old.Asset.Semantic.SurfaceFinish != current.Asset.Semantic.SurfaceFinish + status := "stable" + if brightnessDelta > 0.04 || histDistance > 0.10 || embeddingDrift > 0.08 || semanticChanged { + status = "drift" + } + return RegressionItem{ + SKU: current.Asset.SKU, BrightnessDelta: round6(brightnessDelta), + HistogramDistance: round6(histDistance), EmbeddingDrift: round6(embeddingDrift), + SemanticChanged: semanticChanged, Status: status, + } +} + +func histogramDistance(a, b []float64) float64 { + n := len(a) + if len(b) < n { + n = len(b) + } + if n == 0 { + return 0 + } + var sum float64 + for i := 0; i < n; i++ { + sum += abs(a[i] - b[i]) + } + return sum / 2 +} + +func cosineFromEmbeddingRecords(a, b []EmbeddingRecord) float64 { + if len(a) == 0 || len(b) == 0 { + return 0 + } + av := make([]float64, len(a[0].Values)) + bv := make([]float64, len(b[0].Values)) + for i, v := range a[0].Values { + av[i] = float64(v) + } + for i, v := range b[0].Values { + bv[i] = float64(v) + } + return cosine(av, bv) +} + +func abs(v float64) float64 { + if v < 0 { + return -v + } + return v +} + +func regressionBaselineName(path string) string { + if path == "" { + return "" + } + return filepath.Base(path) +} diff --git a/internal/intelligence/run.go b/internal/intelligence/run.go new file mode 100644 index 0000000..a66a0f8 --- /dev/null +++ b/internal/intelligence/run.go @@ -0,0 +1,196 @@ +package intelligence + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "materialanalyzer/internal/model" + "materialanalyzer/internal/output" + "materialanalyzer/internal/service" +) + +func Run(args []string) int { + fs := flag.NewFlagSet("material-intelligence", flag.ContinueOnError) + assetsDir := fs.String("assets-dir", "MaterialAssets", "material asset library directory") + outputRoot := fs.String("output-root", ".", "root directory for v3 deliverables") + k := fs.Int("k", 5, "nearest neighbors per material in similarity graph") + baselineDir := fs.String("baseline-dir", "", "optional old MaterialAssets directory for regression comparison") + includeVectors := fs.Bool("include-vectors", true, "include embedding float values in knowledge.db and Embeddings/embeddings.json") + if err := fs.Parse(args); err != nil { + return 2 + } + + assets, err := loadAssets(*assetsDir) + if err != nil { + fmt.Fprintf(os.Stderr, "load assets: %v\n", err) + return 1 + } + if len(assets) == 0 { + fmt.Fprintln(os.Stderr, "no material assets found") + return 1 + } + + if err := buildAll(assets, *assetsDir, *outputRoot, *baselineDir, *k, *includeVectors); err != nil { + fmt.Fprintf(os.Stderr, "build intelligence: %v\n", err) + return 1 + } + fmt.Printf("Material Intelligence v3 built: assets=%d output=%s\n", len(assets), *outputRoot) + return 0 +} + +func buildAll(assets []loadedAsset, assetsDir, outputRoot, baselineDir string, k int, includeVectors bool) error { + now := time.Now().UTC().Format(time.RFC3339) + dirs := map[string]string{ + "knowledge": filepath.Join(outputRoot, "KnowledgeBase"), + "prompts": filepath.Join(outputRoot, "PromptDataset"), + "statistics": filepath.Join(outputRoot, "Statistics"), + "embeddings": filepath.Join(outputRoot, "Embeddings"), + "fingerprint": filepath.Join(outputRoot, "Fingerprints"), + "groundtruth": filepath.Join(outputRoot, "GroundTruth"), + "regression": filepath.Join(outputRoot, "Regression"), + "benchmarks": filepath.Join(outputRoot, "Benchmarks"), + } + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + + fingerprints := map[string]MaterialFingerprint{} + groundTruths := make([]GroundTruth, 0, len(assets)) + prompts := make([]PromptRecord, 0, len(assets)) + knowledge := KnowledgeDB{Version: "3.0", GeneratedAt: now} + embeddingExports := make([]KnowledgeEntry, 0, len(assets)) + + for _, item := range assets { + fp := buildFingerprint(item.Asset) + gt := buildGroundTruth(item.Asset, fp) + prompt := buildPromptRecord(item.Asset, gt) + embeddings := item.Embeddings + if !includeVectors { + embeddings = stripEmbeddingValues(embeddings) + } + entry := KnowledgeEntry{ + SKU: item.Asset.SKU, Brand: item.Asset.Product.Brand, + Category: item.Asset.Category, Material: item.Asset.Material, + Semantic: item.Asset.Semantic, Fingerprint: fp, Embeddings: embeddings, + PreviewImage: filepath.Join(item.AssetDir, "preview.jpg"), AssetDir: item.AssetDir, + } + fingerprints[item.Asset.SKU] = fp + groundTruths = append(groundTruths, gt) + prompts = append(prompts, prompt) + knowledge.Entries = append(knowledge.Entries, entry) + embeddingExports = append(embeddingExports, entry) + + safeName := output.SafePathName(item.Asset.SKU) + if err := writeJSON(filepath.Join(dirs["fingerprint"], safeName+".json"), fp); err != nil { + return err + } + if err := writeJSON(filepath.Join(dirs["groundtruth"], safeName+".json"), gt); err != nil { + return err + } + if err := writeSemanticJSON(item.AssetDir, item.Asset); err != nil { + return err + } + if err := updateAssetManifest(item.AssetDir, item.Asset); err != nil { + return err + } + } + + if err := writeJSON(filepath.Join(dirs["fingerprint"], "fingerprints.json"), mapValues(fingerprints)); err != nil { + return err + } + if err := writeJSON(filepath.Join(dirs["groundtruth"], "ground_truth.json"), groundTruths); err != nil { + return err + } + if err := writeJSON(filepath.Join(dirs["prompts"], "prompt_dataset.json"), prompts); err != nil { + return err + } + if err := writeJSON(filepath.Join(dirs["knowledge"], "knowledge.db"), knowledge); err != nil { + return err + } + similarity := buildSimilarityGraph(assets, fingerprints, k) + if err := writeJSON(filepath.Join(dirs["knowledge"], "similarity.json"), similarity); err != nil { + return err + } + if err := writeJSON(filepath.Join(dirs["embeddings"], "embeddings.json"), embeddingExports); err != nil { + return err + } + stats := buildStatistics(assets, fingerprints) + if err := writeJSON(filepath.Join(dirs["statistics"], "statistics.json"), stats); err != nil { + return err + } + regression := buildRegressionReport(assets, baselineDir) + if err := writeJSON(filepath.Join(dirs["regression"], "regression_report.json"), regression); err != nil { + return err + } + visionBench := buildVisionBenchmark(assets) + if err := writeJSON(filepath.Join(dirs["benchmarks"], "benchmark_vision.json"), visionBench); err != nil { + return err + } + summary := BuildSummary{ + Assets: len(assets), OutputRoot: outputRoot, + KnowledgeDB: filepath.Join(dirs["knowledge"], "knowledge.db"), + PromptDataset: filepath.Join(dirs["prompts"], "prompt_dataset.json"), + Statistics: filepath.Join(dirs["statistics"], "statistics.json"), + } + return writeJSON(filepath.Join(outputRoot, "material_intelligence_summary.json"), summary) +} + +func writeSemanticJSON(assetDir string, asset model.MaterialAsset) error { + payload := map[string]interface{}{ + "sku": asset.SKU, + "strategy": "single_provider_passthrough", + "fusion": "not_applicable", + "final_semantic": asset.Semantic, + "providers": []map[string]interface{}{ + {"provider": asset.Semantic.Provider, "model": asset.Semantic.Model, "confidence": asset.Semantic.Confidence}, + }, + } + return writeJSON(filepath.Join(assetDir, "semantic.json"), payload) +} + +func updateAssetManifest(assetDir string, asset model.MaterialAsset) error { + files := []string{"preview.jpg", "thumbnail.jpg", "material.json", "histogram.json", "embedding.bin", "semantic.json", "manifest.json"} + return output.WriteManifest(assetDir, model.AssetManifest{ + SKU: asset.SKU, AnalyzerVersion: firstNonEmpty(asset.AnalyzerVersion, service.AnalyzerVersion), + FeatureSchema: firstNonEmpty(asset.FeatureSchema, service.FeatureSchema), + GeneratedAt: time.Now().UTC().Format(time.RFC3339), Files: files, + Status: "complete", Validation: asset.Validation, Warnings: asset.Warnings, + }) +} + +func stripEmbeddingValues(records []EmbeddingRecord) []EmbeddingRecord { + out := make([]EmbeddingRecord, len(records)) + for i, record := range records { + record.Values = nil + out[i] = record + } + return out +} + +func mapValues(values map[string]MaterialFingerprint) []MaterialFingerprint { + out := make([]MaterialFingerprint, 0, len(values)) + for _, value := range values { + out = append(out, value) + } + sort.Slice(out, func(i, j int) bool { return out[i].SKU < out[j].SKU }) + return out +} + +func writeJSON(path string, v interface{}) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + enc := json.NewEncoder(file) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return enc.Encode(v) +} diff --git a/internal/intelligence/similarity.go b/internal/intelligence/similarity.go new file mode 100644 index 0000000..aa4083d --- /dev/null +++ b/internal/intelligence/similarity.go @@ -0,0 +1,79 @@ +package intelligence + +import ( + "math" + "sort" + "time" +) + +func buildSimilarityGraph(assets []loadedAsset, fingerprints map[string]MaterialFingerprint, k int) SimilarityGraph { + if k <= 0 { + k = 5 + } + graph := SimilarityGraph{ + Version: "3.0", + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + K: k, + Nodes: len(assets), + } + for _, source := range assets { + candidates := make([]SimilarityEdge, 0, len(assets)-1) + sourceVec, sourceMethod := comparableVector(source, fingerprints[source.Asset.SKU]) + for _, target := range assets { + if source.Asset.SKU == target.Asset.SKU { + continue + } + targetVec, targetMethod := comparableVector(target, fingerprints[target.Asset.SKU]) + score := cosine(sourceVec, targetVec) + method := sourceMethod + if sourceMethod != targetMethod { + method = "mixed" + } + candidates = append(candidates, SimilarityEdge{ + SourceSKU: source.Asset.SKU, TargetSKU: target.Asset.SKU, + Score: round6(score), Method: method, + }) + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Score > candidates[j].Score }) + if len(candidates) > k { + candidates = candidates[:k] + } + graph.Edges = append(graph.Edges, candidates...) + } + return graph +} + +func comparableVector(asset loadedAsset, fp MaterialFingerprint) ([]float64, string) { + if len(asset.Embeddings) > 0 && len(asset.Embeddings[0].Values) > 0 { + vec := make([]float64, len(asset.Embeddings[0].Values)) + for i, v := range asset.Embeddings[0].Values { + vec[i] = float64(v) + } + return vec, "embedding_cosine" + } + return []float64{ + fp.Brightness, fp.Contrast, fp.Saturation, fp.Variation, + fp.TextureEntropy, fp.TextureFrequency, fp.Orientation / 180, + fp.ColorVariance, fp.TextureVariance, + }, "fingerprint_cosine" +} + +func cosine(a, b []float64) float64 { + n := len(a) + if len(b) < n { + n = len(b) + } + if n == 0 { + return 0 + } + var dot, na, nb float64 + for i := 0; i < n; i++ { + dot += a[i] * b[i] + na += a[i] * a[i] + nb += b[i] * b[i] + } + if na == 0 || nb == 0 { + return 0 + } + return dot / (math.Sqrt(na) * math.Sqrt(nb)) +} diff --git a/internal/intelligence/statistics.go b/internal/intelligence/statistics.go new file mode 100644 index 0000000..bfbd473 --- /dev/null +++ b/internal/intelligence/statistics.go @@ -0,0 +1,82 @@ +package intelligence + +import ( + "sort" + "time" +) + +func buildStatistics(assets []loadedAsset, fingerprints map[string]MaterialFingerprint) StatisticsCenter { + stats := StatisticsCenter{ + Version: "3.0", + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + TotalAssets: len(assets), + MaterialDistribution: map[string]int{}, + MaterialTypeDistribution: map[string]int{}, + SpeciesDistribution: map[string]int{}, + ColorDistribution: map[string]int{}, + GlossDistribution: map[string]int{}, + BrightnessDistribution: make([]float64, 10), + } + if len(assets) == 0 { + return stats + } + var sumBrightness, sumContrast, sumSaturation, sumEntropy float64 + for _, item := range assets { + asset := item.Asset + fp := fingerprints[asset.SKU] + stats.MaterialDistribution[firstNonEmpty(asset.Material, "unknown")]++ + stats.MaterialTypeDistribution[firstNonEmpty(fp.MaterialType, "unknown")]++ + stats.SpeciesDistribution[firstNonEmpty(asset.Semantic.GrainType, asset.Semantic.Grain, "unknown")]++ + stats.ColorDistribution[firstNonEmpty(fp.ColorFamily, "unknown")]++ + stats.GlossDistribution[firstNonEmpty(fp.GlossLevel, "unknown")]++ + bin := int(fp.Brightness * float64(len(stats.BrightnessDistribution))) + if bin >= len(stats.BrightnessDistribution) { + bin = len(stats.BrightnessDistribution) - 1 + } + if bin < 0 { + bin = 0 + } + stats.BrightnessDistribution[bin]++ + sumBrightness += fp.Brightness + sumContrast += fp.Contrast + sumSaturation += fp.Saturation + sumEntropy += fp.TextureEntropy + stats.EmbeddingPCA = append(stats.EmbeddingPCA, embeddingProjection(item, fp)) + } + total := float64(len(assets)) + for i := range stats.BrightnessDistribution { + stats.BrightnessDistribution[i] = round6(stats.BrightnessDistribution[i] / total) + } + stats.AverageBrightness = round4(sumBrightness / total) + stats.AverageContrast = round4(sumContrast / total) + stats.AverageSaturation = round4(sumSaturation / total) + stats.AverageTextureEntropy = round4(sumEntropy / total) + stats.ClusterStatistics = buildClusterStats(stats.MaterialTypeDistribution) + return stats +} + +func embeddingProjection(item loadedAsset, fp MaterialFingerprint) PCAPoint { + vec, _ := comparableVector(item, fp) + x, y := 0.0, 0.0 + if len(vec) > 0 { + x = vec[0] + } + if len(vec) > 1 { + y = vec[1] + } + return PCAPoint{SKU: item.Asset.SKU, X: round6(x), Y: round6(y)} +} + +func buildClusterStats(dist map[string]int) []ClusterStat { + out := make([]ClusterStat, 0, len(dist)) + for name, count := range dist { + out = append(out, ClusterStat{Name: name, Count: count}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count != out[j].Count { + return out[i].Count > out[j].Count + } + return out[i].Name < out[j].Name + }) + return out +} diff --git a/internal/intelligence/types.go b/internal/intelligence/types.go new file mode 100644 index 0000000..834d3da --- /dev/null +++ b/internal/intelligence/types.go @@ -0,0 +1,161 @@ +package intelligence + +import "materialanalyzer/internal/model" + +type MaterialFingerprint struct { + SKU string `json:"sku"` + Brightness float64 `json:"brightness"` + Contrast float64 `json:"contrast"` + Saturation float64 `json:"saturation"` + Variation float64 `json:"variation"` + TextureEntropy float64 `json:"texture_entropy"` + TextureFrequency float64 `json:"texture_frequency"` + Orientation float64 `json:"orientation"` + DominantLAB []float64 `json:"dominant_lab"` + ColorVariance float64 `json:"color_variance"` + TextureVariance float64 `json:"texture_variance"` + MaterialType string `json:"material_type"` + ColorFamily string `json:"color_family"` + SurfaceFinish string `json:"surface_finish"` + GlossLevel string `json:"gloss_level"` + ReadableSignature string `json:"readable_signature"` +} + +type GroundTruth struct { + SKU string `json:"sku"` + Brand string `json:"brand"` + Category string `json:"category"` + Material string `json:"material"` + MaterialType string `json:"material_type"` + CanonicalColor string `json:"canonical_color"` + SurfaceFinish string `json:"surface_finish"` + GlossLevel string `json:"gloss_level"` + GrainType string `json:"grain_type"` + VisualStyle string `json:"visual_style"` + Variation string `json:"variation"` + RenderingConstraints []string `json:"rendering_constraints"` + DoNotAlter []string `json:"do_not_alter"` + Fingerprint MaterialFingerprint `json:"fingerprint"` + MaterialSpecific map[string]interface{} `json:"material_specific"` +} + +type PromptRecord struct { + SKU string `json:"sku"` + SystemPrompt string `json:"system_prompt"` + MaterialPrompt string `json:"material_prompt"` + NegativePrompt string `json:"negative_prompt"` +} + +type KnowledgeDB struct { + Version string `json:"version"` + GeneratedAt string `json:"generated_at"` + Entries []KnowledgeEntry `json:"entries"` +} + +type KnowledgeEntry struct { + SKU string `json:"sku"` + Brand string `json:"brand"` + Category string `json:"category"` + Material string `json:"material"` + Semantic model.SemanticFeatures `json:"semantic"` + Fingerprint MaterialFingerprint `json:"fingerprint"` + Embeddings []EmbeddingRecord `json:"embeddings"` + PreviewImage string `json:"preview_image"` + AssetDir string `json:"asset_dir"` +} + +type EmbeddingRecord struct { + Name string `json:"name"` + Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` + Dimensions int `json:"dimensions"` + Values []float32 `json:"values,omitempty"` +} + +type SimilarityGraph struct { + Version string `json:"version"` + GeneratedAt string `json:"generated_at"` + K int `json:"k"` + Nodes int `json:"nodes"` + Edges []SimilarityEdge `json:"edges"` +} + +type SimilarityEdge struct { + SourceSKU string `json:"source_sku"` + TargetSKU string `json:"target_sku"` + Score float64 `json:"score"` + Method string `json:"method"` +} + +type StatisticsCenter struct { + Version string `json:"version"` + GeneratedAt string `json:"generated_at"` + TotalAssets int `json:"total_assets"` + MaterialDistribution map[string]int `json:"material_distribution"` + MaterialTypeDistribution map[string]int `json:"material_type_distribution"` + SpeciesDistribution map[string]int `json:"species_distribution"` + ColorDistribution map[string]int `json:"color_distribution"` + GlossDistribution map[string]int `json:"gloss_distribution"` + BrightnessDistribution []float64 `json:"brightness_distribution"` + AverageBrightness float64 `json:"average_brightness"` + AverageContrast float64 `json:"average_contrast"` + AverageSaturation float64 `json:"average_saturation"` + AverageTextureEntropy float64 `json:"average_texture_entropy"` + EmbeddingPCA []PCAPoint `json:"embedding_pca,omitempty"` + ClusterStatistics []ClusterStat `json:"cluster_statistics,omitempty"` +} + +type PCAPoint struct { + SKU string `json:"sku"` + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type ClusterStat struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type RegressionReport struct { + Version string `json:"version"` + GeneratedAt string `json:"generated_at"` + Status string `json:"status"` + BaselineDir string `json:"baseline_dir,omitempty"` + Compared int `json:"compared"` + Items []RegressionItem `json:"items,omitempty"` + Summary map[string]interface{} `json:"summary,omitempty"` +} + +type RegressionItem struct { + SKU string `json:"sku"` + BrightnessDelta float64 `json:"brightness_delta"` + HistogramDistance float64 `json:"histogram_distance"` + EmbeddingDrift float64 `json:"embedding_drift"` + SemanticChanged bool `json:"semantic_changed"` + Status string `json:"status"` +} + +type VisionBenchmark struct { + Version string `json:"version"` + GeneratedAt string `json:"generated_at"` + Status string `json:"status"` + Providers []VisionProviderBench `json:"providers"` +} + +type VisionProviderBench struct { + Name string `json:"name"` + Model string `json:"model"` + Accuracy float64 `json:"accuracy,omitempty"` + AverageLatencyMS float64 `json:"average_latency_ms,omitempty"` + MemoryMB float64 `json:"memory_mb,omitempty"` + SemanticStability float64 `json:"semantic_stability,omitempty"` + Notes string `json:"notes,omitempty"` +} + +type BuildSummary struct { + Assets int `json:"assets"` + OutputRoot string `json:"output_root"` + KnowledgeDB string `json:"knowledge_db"` + PromptDataset string `json:"prompt_dataset"` + Statistics string `json:"statistics"` +} diff --git a/internal/intelligence/vision_benchmark.go b/internal/intelligence/vision_benchmark.go new file mode 100644 index 0000000..9dbf5f6 --- /dev/null +++ b/internal/intelligence/vision_benchmark.go @@ -0,0 +1,30 @@ +package intelligence + +import "time" + +func buildVisionBenchmark(assets []loadedAsset) VisionBenchmark { + bench := VisionBenchmark{ + Version: "3.0", + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + Status: "metadata_only", + } + seen := map[string]bool{} + for _, item := range assets { + key := item.Asset.Semantic.Provider + "|" + item.Asset.Semantic.Model + if seen[key] { + continue + } + seen[key] = true + bench.Providers = append(bench.Providers, VisionProviderBench{ + Name: item.Asset.Semantic.Provider, + Model: item.Asset.Semantic.Model, + Notes: "Provider was observed in existing MaterialAssets. Accuracy, latency, memory, and semantic stability require labeled evaluation data.", + }) + } + if len(bench.Providers) == 0 { + bench.Providers = append(bench.Providers, VisionProviderBench{ + Name: "none", Model: "none", Notes: "No semantic provider metadata found in assets.", + }) + } + return bench +} diff --git a/internal/model/asset.go b/internal/model/asset.go new file mode 100644 index 0000000..e8a3212 --- /dev/null +++ b/internal/model/asset.go @@ -0,0 +1,210 @@ +package model + +type MaterialAsset struct { + SchemaVersion string `json:"schema_version"` + AnalyzerVersion string `json:"analyzer_version"` + FeatureSchema string `json:"feature_schema"` + Generator string `json:"generator"` + GeneratedAt string `json:"generated_at"` + SKU string `json:"sku"` + Category string `json:"category"` + Material string `json:"material"` + Product ProductSnapshot `json:"product"` + Input InputInfo `json:"input"` + Visual VisualFeatures `json:"visual"` + Texture TextureFeatures `json:"texture"` + Canonical CanonicalStatistics `json:"canonical_statistics"` + Semantic SemanticFeatures `json:"semantic"` + MaterialSpecific map[string]interface{} `json:"material_specific"` + Embeddings EmbeddingManifest `json:"embeddings"` + Validation ValidationReport `json:"validation"` + Confidence map[string]float64 `json:"confidence,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +type InputInfo struct { + ImageURL string `json:"image_url"` + ImageCachePath string `json:"image_cache_path,omitempty"` + OriginalWidth int `json:"original_width"` + OriginalHeight int `json:"original_height"` + AnalysisStrategy string `json:"analysis_strategy"` + AnalysisRegion string `json:"analysis_region"` + RoomLike bool `json:"room_like"` + RoomLikeConfidence string `json:"room_like_confidence,omitempty"` + RoomLikeReason string `json:"room_like_reason,omitempty"` + SourceImageFormat string `json:"source_image_format,omitempty"` + GeneratedPreview string `json:"generated_preview"` + GeneratedThumbnail string `json:"generated_thumbnail"` + GeneratedHistogram string `json:"generated_histogram"` + GeneratedEmbeddings string `json:"generated_embeddings"` +} + +type RGB struct { + R int `json:"r"` + G int `json:"g"` + B int `json:"b"` +} + +type LAB struct { + L float64 `json:"l"` + A float64 `json:"a"` + B float64 `json:"b"` +} + +type HSV struct { + H float64 `json:"h"` + S float64 `json:"s"` + V float64 `json:"v"` +} + +type ColorCluster struct { + RGB RGB `json:"rgb"` + LAB LAB `json:"lab"` + HSV HSV `json:"hsv"` + Percentage float64 `json:"percentage"` +} + +type VisualFeatures struct { + DominantRGB RGB `json:"dominant_rgb"` + SecondaryRGB RGB `json:"secondary_rgb"` + DominantLAB LAB `json:"dominant_lab"` + DominantHSV HSV `json:"dominant_hsv"` + AverageRGB RGB `json:"average_rgb"` + AverageLAB LAB `json:"average_lab"` + AverageHSV HSV `json:"average_hsv"` + Brightness float64 `json:"brightness"` + Contrast float64 `json:"contrast"` + Saturation float64 `json:"saturation"` + ColorClusters []ColorCluster `json:"color_clusters"` + HistogramBins int `json:"histogram_bins"` + HistogramSpace string `json:"histogram_space"` +} + +type ColorHistogram struct { + Space string `json:"space"` + BinsPerAxis int `json:"bins_per_axis,omitempty"` + Bins []int `json:"bins,omitempty"` + Values []float64 `json:"values"` +} + +type GLCMFeatures struct { + Levels int `json:"levels"` + Contrast float64 `json:"contrast"` + Homogeneity float64 `json:"homogeneity"` + Energy float64 `json:"energy"` + Correlation float64 `json:"correlation"` +} + +type TextureFeatures struct { + Entropy float64 `json:"entropy"` + TextureFrequency float64 `json:"texture_frequency"` + EdgeDensity float64 `json:"edge_density"` + OrientationVariance float64 `json:"orientation_variance"` + PrimaryOrientationDeg float64 `json:"primary_orientation_deg"` + LBPUniformity float64 `json:"lbp_uniformity"` + GLCM GLCMFeatures `json:"glcm"` + Algorithm string `json:"algorithm"` +} + +type CanonicalStatistics struct { + MeanRGB RGB `json:"mean_rgb"` + MedianRGB RGB `json:"median_rgb"` + ColorVariance float64 `json:"color_variance"` + TextureVariance float64 `json:"texture_variance"` + BrightnessDistribution []float64 `json:"brightness_distribution"` + DominantOrientationDeg float64 `json:"dominant_orientation_deg"` + GradientHistogram []float64 `json:"gradient_histogram"` + GradientHistogramBins int `json:"gradient_histogram_bins"` + BrightnessHistogramBins int `json:"brightness_histogram_bins"` +} + +type SemanticFeatures struct { + Provider string `json:"provider"` + Model string `json:"model,omitempty"` + Description string `json:"description"` + MaterialType string `json:"material_type"` + SurfaceFinish string `json:"surface_finish"` + GrainType string `json:"grain_type"` + ColorFamily string `json:"color_family"` + VisualStyle string `json:"visual_style"` + Variation string `json:"variation"` + GlossLevel string `json:"gloss_level"` + Grain string `json:"grain,omitempty"` + Tags []string `json:"tags,omitempty"` + Confidence float64 `json:"confidence"` + FieldConfidence map[string]float64 `json:"field_confidence,omitempty"` +} + +type EmbeddingManifest struct { + File string `json:"file"` + Format string `json:"format"` + Vectors []EmbeddingVector `json:"vectors"` +} + +type EmbeddingVector struct { + Name string `json:"name"` + Model string `json:"model,omitempty"` + Dimensions int `json:"dimensions"` + OffsetBytes int64 `json:"offset_bytes"` + ByteLength int64 `json:"byte_length"` + Generator string `json:"generator"` + Provider string `json:"provider,omitempty"` + Normalize bool `json:"normalize"` +} + +type ValidationReport struct { + Status string `json:"status"` + CheckedAt string `json:"checked_at"` + Errors []string `json:"errors,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +type AssetManifest struct { + SKU string `json:"sku"` + AnalyzerVersion string `json:"analyzer_version"` + FeatureSchema string `json:"feature_schema"` + GeneratedAt string `json:"generated_at"` + Files []string `json:"files"` + FileDetails []ManifestFile `json:"file_details,omitempty"` + Status string `json:"status"` + Validation ValidationReport `json:"validation"` + Warnings []string `json:"warnings,omitempty"` +} + +type ManifestFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + SizeBytes int64 `json:"size_bytes"` + CreatedTime string `json:"created_time"` +} + +type BatchBenchmark struct { + AnalyzerVersion string `json:"analyzer_version"` + FeatureSchema string `json:"feature_schema"` + Generator string `json:"generator"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + DataDir string `json:"data_dir"` + OutputDir string `json:"output_dir"` + EmbeddingProvider string `json:"embedding_provider"` + SemanticProvider string `json:"semantic_provider"` + TotalSKU int `json:"total_sku"` + Processed int `json:"processed"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + AverageTimeMS float64 `json:"average_time_ms"` + Workers int `json:"workers"` + ImageResolutionDistribution map[string]int `json:"image_resolution_distribution,omitempty"` +} + +type FailureReport struct { + GeneratedAt string `json:"generated_at"` + Failures []FailureItem `json:"failures"` +} + +type FailureItem struct { + SKU string `json:"sku"` + Error string `json:"error"` + Attempts int `json:"attempts"` + DurationMS int64 `json:"duration_ms"` +} diff --git a/internal/model/product.go b/internal/model/product.go new file mode 100644 index 0000000..30496ce --- /dev/null +++ b/internal/model/product.go @@ -0,0 +1,78 @@ +package model + +type Product struct { + Brand string `json:"brand"` + GroupName string `json:"group_name"` + SKU string `json:"sku"` + SeriesName string `json:"series_name"` + StyleName string `json:"style_name"` + Category string `json:"category"` + Material string `json:"material"` + ColorTone string `json:"color_tone"` + Finish string `json:"finish"` + PricePerSqft float64 `json:"price_per_sqft"` + PriceTier string `json:"price_tier"` + PriceSource string `json:"price_source"` + IsOfficialPrice bool `json:"is_official_price"` + MainImageURL string `json:"main_image_url"` + RoomImageURL string `json:"room_image_url"` + SourceURL string `json:"source_url"` + Description string `json:"description"` + Status string `json:"status"` + CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"` + WoodSpecies string `json:"wood_species"` + SizeLabel string `json:"size_label"` + WidthIn float64 `json:"width_in"` + LengthIn float64 `json:"length_in"` + AllImages []string `json:"all_images,omitempty"` + Variants []Product `json:"variants,omitempty"` + Specs []ProductSpec `json:"specs,omitempty"` +} + +type ProductSpec struct { + SKU string `json:"sku"` + SizeLabel string `json:"size_label"` + WidthIn float64 `json:"width_in"` + LengthIn float64 `json:"length_in"` + Finish string `json:"finish"` + PricePerSqft float64 `json:"price_per_sqft"` + PriceTier string `json:"price_tier"` + CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"` + MainImageURL string `json:"main_image_url"` +} + +type ProductSnapshot struct { + Brand string `json:"brand"` + GroupName string `json:"group_name,omitempty"` + SKU string `json:"sku"` + SeriesName string `json:"series_name,omitempty"` + StyleName string `json:"style_name,omitempty"` + Category string `json:"category,omitempty"` + Material string `json:"material,omitempty"` + ColorTone string `json:"color_tone,omitempty"` + Finish string `json:"finish,omitempty"` + PricePerSqft float64 `json:"price_per_sqft,omitempty"` + PriceTier string `json:"price_tier,omitempty"` + MainImageURL string `json:"main_image_url,omitempty"` + RoomImageURL string `json:"room_image_url,omitempty"` + SourceURL string `json:"source_url,omitempty"` + Description string `json:"description,omitempty"` + CoverageSqftPerBox float64 `json:"coverage_sqft_per_box,omitempty"` + WoodSpecies string `json:"wood_species,omitempty"` + SizeLabel string `json:"size_label,omitempty"` + WidthIn float64 `json:"width_in,omitempty"` + LengthIn float64 `json:"length_in,omitempty"` +} + +func NewProductSnapshot(p Product) ProductSnapshot { + return ProductSnapshot{ + Brand: p.Brand, GroupName: p.GroupName, SKU: p.SKU, + SeriesName: p.SeriesName, StyleName: p.StyleName, + Category: p.Category, Material: p.Material, ColorTone: p.ColorTone, + Finish: p.Finish, PricePerSqft: p.PricePerSqft, PriceTier: p.PriceTier, + MainImageURL: p.MainImageURL, RoomImageURL: p.RoomImageURL, + SourceURL: p.SourceURL, Description: p.Description, + CoverageSqftPerBox: p.CoverageSqftPerBox, WoodSpecies: p.WoodSpecies, + SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn, + } +} diff --git a/internal/model/semantic_json.go b/internal/model/semantic_json.go new file mode 100644 index 0000000..de52c28 --- /dev/null +++ b/internal/model/semantic_json.go @@ -0,0 +1,47 @@ +package model + +import ( + "encoding/json" + "strconv" + "strings" +) + +func (s *SemanticFeatures) UnmarshalJSON(data []byte) error { + type alias SemanticFeatures + var raw struct { + alias + Confidence interface{} `json:"confidence"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *s = SemanticFeatures(raw.alias) + switch v := raw.Confidence.(type) { + case float64: + s.Confidence = v + case string: + s.Confidence = confidenceLabelToFloat(v) + case nil: + s.Confidence = 0 + default: + s.Confidence = 0 + } + return nil +} + +func confidenceLabelToFloat(label string) float64 { + label = strings.ToLower(strings.TrimSpace(label)) + switch label { + case "high": + return 0.88 + case "medium": + return 0.70 + case "low": + return 0.50 + default: + if v, err := strconv.ParseFloat(label, 64); err == nil { + return v + } + return 0 + } +} diff --git a/internal/model/semantic_json_test.go b/internal/model/semantic_json_test.go new file mode 100644 index 0000000..f3fd58a --- /dev/null +++ b/internal/model/semantic_json_test.go @@ -0,0 +1,16 @@ +package model + +import ( + "encoding/json" + "testing" +) + +func TestSemanticFeaturesUnmarshalLegacyConfidence(t *testing.T) { + var semantic SemanticFeatures + if err := json.Unmarshal([]byte(`{"description":"x","confidence":"medium"}`), &semantic); err != nil { + t.Fatal(err) + } + if semantic.Confidence != 0.70 { + t.Fatalf("confidence = %v", semantic.Confidence) + } +} diff --git a/internal/output/writer.go b/internal/output/writer.go new file mode 100644 index 0000000..b35c532 --- /dev/null +++ b/internal/output/writer.go @@ -0,0 +1,160 @@ +package output + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "image" + "image/jpeg" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "materialanalyzer/internal/imageproc" + "materialanalyzer/internal/model" +) + +func AssetDir(outputDir, sku string) string { + return filepath.Join(outputDir, SafePathName(sku)) +} + +func MaterialJSONPath(assetDir string) string { + return filepath.Join(assetDir, "material.json") +} + +func AssetExists(assetDir string) bool { + _, err := os.Stat(MaterialJSONPath(assetDir)) + return err == nil +} + +func EnsureAssetDir(assetDir string) error { + return os.MkdirAll(assetDir, 0o755) +} + +func WriteImages(assetDir string, img image.Image) error { + if err := EnsureAssetDir(assetDir); err != nil { + return err + } + preview := imageproc.ResizeToMax(img, 900) + if err := writeJPEG(filepath.Join(assetDir, "preview.jpg"), preview, 90); err != nil { + return err + } + thumbnail := imageproc.ResizeToFill(img, 256, 256) + return writeJPEG(filepath.Join(assetDir, "thumbnail.jpg"), thumbnail, 84) +} + +func WriteHistogram(assetDir string, hist model.ColorHistogram) error { + return writeJSON(filepath.Join(assetDir, "histogram.json"), hist) +} + +func WriteMaterial(assetDir string, asset model.MaterialAsset) error { + return writeJSON(MaterialJSONPath(assetDir), asset) +} + +func WriteSemantic(assetDir string, semantic interface{}) error { + return writeJSON(filepath.Join(assetDir, "semantic.json"), semantic) +} + +func WriteManifest(assetDir string, manifest model.AssetManifest) error { + if len(manifest.FileDetails) == 0 && len(manifest.Files) > 0 { + details, err := BuildManifestFileDetails(assetDir, manifest.Files) + if err != nil { + return err + } + manifest.FileDetails = details + } + return writeJSON(filepath.Join(assetDir, "manifest.json"), manifest) +} + +func BuildManifestFileDetails(assetDir string, files []string) ([]model.ManifestFile, error) { + details := make([]model.ManifestFile, 0, len(files)) + for _, name := range files { + if name == "manifest.json" { + continue + } + detail, err := manifestFileDetail(assetDir, name) + if err != nil { + return nil, err + } + details = append(details, detail) + } + return details, nil +} + +func manifestFileDetail(assetDir, name string) (model.ManifestFile, error) { + path := filepath.Join(assetDir, name) + file, err := os.Open(path) + if err != nil { + return model.ManifestFile{}, fmt.Errorf("open manifest file %s: %w", name, err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return model.ManifestFile{}, fmt.Errorf("stat manifest file %s: %w", name, err) + } + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return model.ManifestFile{}, fmt.Errorf("hash manifest file %s: %w", name, err) + } + return model.ManifestFile{ + Path: name, + SHA256: fmt.Sprintf("%x", hash.Sum(nil)), + SizeBytes: info.Size(), + CreatedTime: info.ModTime().UTC().Format("2006-01-02T15:04:05Z07:00"), + }, nil +} + +func WriteBenchmark(outputDir string, benchmark model.BatchBenchmark) error { + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return err + } + return writeJSON(filepath.Join(outputDir, "benchmark.json"), benchmark) +} + +func WriteFailures(outputDir string, report model.FailureReport) error { + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return err + } + return writeJSON(filepath.Join(outputDir, "failures.json"), report) +} + +func writeJPEG(path string, img image.Image, quality int) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + return jpeg.Encode(file, img, &jpeg.Options{Quality: quality}) +} + +func writeJSON(path string, v interface{}) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + enc := json.NewEncoder(file) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return enc.Encode(v) +} + +var unsafePathChars = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +func SafePathName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "unknown" + } + name = unsafePathChars.ReplaceAllString(name, "_") + name = strings.Trim(name, "._- ") + if name == "" { + return "unknown" + } + if len(name) > 120 { + name = name[:120] + } + return name +} diff --git a/internal/output/writer_test.go b/internal/output/writer_test.go new file mode 100644 index 0000000..4c4c4df --- /dev/null +++ b/internal/output/writer_test.go @@ -0,0 +1,11 @@ +package output + +import "testing" + +func TestSafePathName(t *testing.T) { + got := SafePathName(`P1000: PEMBROKE OAK / NATURAL`) + want := "P1000_PEMBROKE_OAK_NATURAL" + if got != want { + t.Fatalf("SafePathName() = %q, want %q", got, want) + } +} diff --git a/internal/repository/product_loader.go b/internal/repository/product_loader.go new file mode 100644 index 0000000..6ce78bc --- /dev/null +++ b/internal/repository/product_loader.go @@ -0,0 +1,71 @@ +package repository + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "materialanalyzer/internal/model" +) + +func LoadProducts(dataDir string) ([]model.Product, error) { + files, err := os.ReadDir(dataDir) + if err != nil { + return nil, fmt.Errorf("read product data dir: %w", err) + } + + var products []model.Product + for _, f := range files { + if f.IsDir() || !strings.EqualFold(filepath.Ext(f.Name()), ".json") { + continue + } + path := filepath.Join(dataDir, f.Name()) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + var batch []model.Product + if err := json.Unmarshal(data, &batch); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + for _, p := range batch { + if strings.TrimSpace(p.SKU) == "" { + continue + } + products = append(products, p) + } + } + + sort.Slice(products, func(i, j int) bool { + if products[i].Brand != products[j].Brand { + return products[i].Brand < products[j].Brand + } + return products[i].SKU < products[j].SKU + }) + return products, nil +} + +func FilterProducts(products []model.Product, skuCSV string, limit int) []model.Product { + wanted := map[string]bool{} + for _, sku := range strings.Split(skuCSV, ",") { + sku = strings.TrimSpace(sku) + if sku != "" { + wanted[strings.ToLower(sku)] = true + } + } + + out := make([]model.Product, 0, len(products)) + for _, p := range products { + if len(wanted) > 0 && !wanted[strings.ToLower(p.SKU)] { + continue + } + out = append(out, p) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} diff --git a/internal/repository/product_loader_test.go b/internal/repository/product_loader_test.go new file mode 100644 index 0000000..537df77 --- /dev/null +++ b/internal/repository/product_loader_test.go @@ -0,0 +1,29 @@ +package repository + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadProducts(t *testing.T) { + dir := t.TempDir() + data := `[{"brand":"A","sku":"2","main_image_url":"b.jpg"},{"brand":"A","sku":"1","main_image_url":"a.jpg"},{"brand":"A","sku":""}]` + if err := os.WriteFile(filepath.Join(dir, "brand.json"), []byte(data), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "data.zip"), []byte("ignored"), 0o644); err != nil { + t.Fatal(err) + } + + products, err := LoadProducts(dir) + if err != nil { + t.Fatal(err) + } + if len(products) != 2 { + t.Fatalf("len(products) = %d", len(products)) + } + if products[0].SKU != "1" || products[1].SKU != "2" { + t.Fatalf("products not sorted by sku: %+v", products) + } +} diff --git a/internal/service/adapter.go b/internal/service/adapter.go new file mode 100644 index 0000000..a29fe20 --- /dev/null +++ b/internal/service/adapter.go @@ -0,0 +1,98 @@ +package service + +import ( + "strings" + + "materialanalyzer/internal/model" +) + +func BuildMaterialSpecific(p model.Product, visual model.VisualFeatures, texture model.TextureFeatures, semantic model.SemanticFeatures) map[string]interface{} { + family := MaterialFamily(p.Category, p.Material) + out := map[string]interface{}{ + "family": family, + "source_category": p.Category, + "source_material": p.Material, + "size_label": p.SizeLabel, + "width_in": p.WidthIn, + "length_in": p.LengthIn, + "dominant_color_name": semantic.ColorFamily, + "confidence": map[string]float64{ + "family": 0.86, + "dominant_color_name": 0.88, + "surface_finish": semantic.FieldConfidence["surface_finish"], + "visual_style": semantic.FieldConfidence["visual_style"], + }, + } + + switch family { + case "wood": + out["species"] = inferSpecies(p) + out["grain_direction"] = orientationLabel(texture.PrimaryOrientationDeg) + out["knot_density"] = densityLabel(texture.EdgeDensity, 0.08, 0.17) + out["cathedral_density"] = densityLabel(texture.OrientationVariance, 0.48, 0.72) + case "tile", "stone_tile": + out["surface_look"] = tileSurfaceLook(p, semantic) + out["stone_type"] = inferStoneType(p) + out["vein_density"] = densityLabel(texture.EdgeDensity, 0.06, 0.14) + out["vein_orientation"] = orientationLabel(texture.PrimaryOrientationDeg) + out["grout_color"] = "unknown" + out["tile_visual_variation"] = semantic.Variation + case "vinyl": + out["printed_pattern"] = semantic.VisualStyle + out["emboss_depth"] = densityLabel(texture.TextureFrequency, 0.12, 0.24) + out["surface_finish"] = semantic.SurfaceFinish + out["wood_or_stone_look"] = vinylLook(p, semantic) + default: + out["texture_density"] = densityLabel(texture.TextureFrequency, 0.12, 0.24) + out["surface_finish"] = semantic.SurfaceFinish + } + + _ = visual + return out +} + +func densityLabel(v, medium, high float64) string { + switch { + case v >= high: + return "High" + case v >= medium: + return "Medium" + default: + return "Low" + } +} + +func inferStoneType(p model.Product) string { + text := strings.ToLower(p.Material + " " + p.StyleName) + for _, s := range []string{"marble", "travertine", "slate", "limestone", "granite", "terrazzo", "concrete", "cement"} { + if strings.Contains(text, s) { + return strings.Title(s) + } + } + if strings.Contains(text, "stone") { + return "Stone" + } + return "Unknown" +} + +func tileSurfaceLook(p model.Product, semantic model.SemanticFeatures) string { + text := strings.ToLower(p.Material + " " + p.StyleName + " " + semantic.VisualStyle) + if containsAnyText(text, "oak", "maple", "hickory", "walnut", "pine", "wood") { + return "wood_look" + } + if containsAnyText(text, "marble", "stone", "slate", "travertine", "limestone", "granite") { + return "stone_look" + } + return "tile" +} + +func vinylLook(p model.Product, semantic model.SemanticFeatures) string { + text := strings.ToLower(p.Material + " " + p.StyleName + " " + semantic.VisualStyle) + if containsAnyText(text, "marble", "stone", "slate", "travertine", "tile") { + return "stone_look" + } + if containsAnyText(text, "oak", "maple", "hickory", "walnut", "pine", "wood") { + return "wood_look" + } + return "unknown" +} diff --git a/internal/service/processor.go b/internal/service/processor.go new file mode 100644 index 0000000..45d156a --- /dev/null +++ b/internal/service/processor.go @@ -0,0 +1,248 @@ +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")) +} diff --git a/internal/service/semantic.go b/internal/service/semantic.go new file mode 100644 index 0000000..e5b5b0f --- /dev/null +++ b/internal/service/semantic.go @@ -0,0 +1,274 @@ +package service + +import ( + "fmt" + "math" + "strings" + + "materialanalyzer/internal/model" +) + +func BuildSemantic(p model.Product, visual model.VisualFeatures, texture model.TextureFeatures) model.SemanticFeatures { + family := MaterialFamily(p.Category, p.Material) + color := colorFamily(visual.DominantRGB) + finish := strings.TrimSpace(p.Finish) + if finish == "" { + finish = inferFinish(visual, texture) + } + variation := variationLevel(visual, texture) + style := visualStyle(family, p, visual) + grain := grainLabel(family, p, texture) + + description := fmt.Sprintf("%s %s %s flooring with %s finish, %s visual variation, and %s texture direction.", + color, strings.ToLower(style), cleanMaterialLabel(p), strings.ToLower(finish), strings.ToLower(variation), strings.ToLower(grain)) + + tags := []string{family, color, style, finish, variation} + if p.ColorTone != "" { + tags = append(tags, p.ColorTone) + } + if p.Material != "" { + tags = append(tags, p.Material) + } + + return model.SemanticFeatures{ + Provider: "local_rules_v2", + Model: "rule_based", + Description: description, + MaterialType: family, + SurfaceFinish: finish, + GrainType: grain, + ColorFamily: color, + VisualStyle: style, + Variation: variation, + GlossLevel: glossLevel(finish, visual), + Grain: grain, + Tags: compactStrings(tags), + Confidence: 0.72, + FieldConfidence: map[string]float64{ + "description": 0.68, + "material_type": 0.85, + "surface_finish": confidenceFromSource(p.Finish), + "grain_type": 0.62, + "color_family": 0.88, + "visual_style": 0.72, + "variation": 0.74, + "gloss_level": 0.65, + }, + } +} + +func MaterialFamily(category, material string) string { + cat := strings.ToLower(category) + mat := strings.ToLower(material) + text := cat + " " + mat + switch { + case containsAnyText(text, "spc", "lvp", "wpc", "luxury vinyl", "vinyl", "rigid core", "pvc"): + return "vinyl" + case containsAnyText(text, "porcelain", "ceramic", "tile"): + if containsAnyText(mat, "stone", "marble", "travertine", "slate", "limestone", "granite", "terrazzo") { + return "stone_tile" + } + return "tile" + case containsAnyText(text, "stone", "marble", "travertine", "slate", "limestone"): + return "stone_tile" + case containsAnyText(text, "hardwood", "engineered", "laminate", "oak", "maple", "hickory", "walnut", "pine", "birch", "cherry", "acacia", "bamboo"): + return "wood" + default: + return "generic" + } +} + +func cleanMaterialLabel(p model.Product) string { + for _, candidate := range []string{p.Material, p.Category, "material"} { + candidate = strings.TrimSpace(candidate) + if candidate != "" { + return candidate + } + } + return "material" +} + +func colorFamily(rgb model.RGB) string { + r, g, b := float64(rgb.R), float64(rgb.G), float64(rgb.B) + maxV := math.Max(r, math.Max(g, b)) + minV := math.Min(r, math.Min(g, b)) + brightness := maxV / 255.0 + chroma := (maxV - minV) / 255.0 + if brightness < 0.18 { + return "Black" + } + if brightness > 0.86 && chroma < 0.10 { + return "White" + } + if chroma < 0.08 { + if brightness < 0.42 { + return "Dark Gray" + } + if brightness > 0.72 { + return "Light Gray" + } + return "Gray" + } + if r > g*1.08 && g > b*1.05 { + if brightness > 0.65 { + return "Honey" + } + return "Brown" + } + if r > 150 && g > 125 && b > 95 && math.Abs(r-g) < 45 { + return "Beige" + } + if b > r && b > g { + return "Cool Gray" + } + return "Natural" +} + +func inferFinish(visual model.VisualFeatures, texture model.TextureFeatures) string { + if visual.Contrast < 0.12 && texture.TextureFrequency < 0.18 { + return "Matte" + } + if visual.Brightness > 0.72 && texture.EdgeDensity < 0.08 { + return "Satin" + } + return "Textured" +} + +func glossLevel(finish string, visual model.VisualFeatures) string { + lower := strings.ToLower(finish) + switch { + case strings.Contains(lower, "gloss"): + return "High" + case strings.Contains(lower, "satin"): + return "Medium" + case strings.Contains(lower, "matte"): + return "Low" + case visual.Brightness > 0.72 && visual.Contrast < 0.12: + return "Medium" + default: + return "Low" + } +} + +func confidenceFromSource(v string) float64 { + if strings.TrimSpace(v) == "" { + return 0.55 + } + return 0.88 +} + +func variationLevel(visual model.VisualFeatures, texture model.TextureFeatures) string { + colorDistance := 0.0 + if visual.DominantRGB != visual.SecondaryRGB { + dr := float64(visual.DominantRGB.R - visual.SecondaryRGB.R) + dg := float64(visual.DominantRGB.G - visual.SecondaryRGB.G) + db := float64(visual.DominantRGB.B - visual.SecondaryRGB.B) + colorDistance = math.Sqrt(dr*dr+dg*dg+db*db) / 441.67 + } + score := visual.Contrast*0.45 + texture.EdgeDensity*0.35 + colorDistance*0.20 + switch { + case score > 0.20 || texture.Entropy > 7.35: + return "High" + case score > 0.11 || texture.Entropy > 6.55: + return "Medium" + default: + return "Low" + } +} + +func visualStyle(family string, p model.Product, visual model.VisualFeatures) string { + text := strings.ToLower(p.StyleName + " " + p.Material + " " + p.Category + " " + p.ColorTone) + switch family { + case "wood", "vinyl": + if containsAnyText(text, "weathered", "distressed", "reclaimed", "rustic") { + return "Rustic" + } + if containsAnyText(text, "oak", "hickory", "maple", "pine", "walnut", "cherry") { + return "Natural Wood" + } + case "tile", "stone_tile": + if containsAnyText(text, "oak", "hickory", "maple", "pine", "walnut", "wood") { + return "Wood Look Tile" + } + if containsAnyText(text, "marble") { + return "Marble" + } + if containsAnyText(text, "slate", "travertine", "limestone", "stone") { + return "Natural Stone" + } + return "Contemporary Tile" + } + if visual.Saturation < 0.12 { + return "Neutral" + } + return "Natural" +} + +func grainLabel(family string, p model.Product, texture model.TextureFeatures) string { + if family == "tile" || family == "stone_tile" { + text := strings.ToLower(p.Material + " " + p.StyleName) + if containsAnyText(text, "oak", "hickory", "maple", "pine", "walnut", "wood") { + return orientationLabel(texture.PrimaryOrientationDeg) + " " + inferSpecies(p) + } + if texture.OrientationVariance < 0.38 { + return orientationLabel(texture.PrimaryOrientationDeg) + " Vein" + } + return "Mixed Vein" + } + species := inferSpecies(p) + direction := orientationLabel(texture.PrimaryOrientationDeg) + if texture.OrientationVariance > 0.70 { + return "Mixed " + species + } + return direction + " " + species +} + +func inferSpecies(p model.Product) string { + text := strings.ToLower(p.WoodSpecies + " " + p.Material + " " + p.StyleName) + for _, s := range []string{"oak", "maple", "hickory", "walnut", "pine", "birch", "cherry", "acacia", "bamboo", "ash", "elm", "teak"} { + if strings.Contains(text, s) { + return strings.Title(s) + } + } + return "Grain" +} + +func orientationLabel(deg float64) string { + switch { + case deg < 22.5 || deg >= 157.5: + return "Horizontal" + case deg < 67.5: + return "Diagonal" + case deg < 112.5: + return "Vertical" + default: + return "Diagonal" + } +} + +func compactStrings(values []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(values)) + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + key := strings.ToLower(v) + if seen[key] { + continue + } + seen[key] = true + out = append(out, v) + } + return out +} + +func containsAnyText(text string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(text, needle) { + return true + } + } + return false +} diff --git a/internal/service/semantic_provider.go b/internal/service/semantic_provider.go new file mode 100644 index 0000000..35c646f --- /dev/null +++ b/internal/service/semantic_provider.go @@ -0,0 +1,289 @@ +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 +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go new file mode 100644 index 0000000..9e0886b --- /dev/null +++ b/internal/validation/validation.go @@ -0,0 +1,70 @@ +package validation + +import ( + "fmt" + "time" + + "materialanalyzer/internal/model" +) + +func ValidateAsset(asset model.MaterialAsset, hist model.ColorHistogram) model.ValidationReport { + report := model.ValidationReport{ + Status: "valid", + CheckedAt: time.Now().UTC().Format(time.RFC3339), + } + checkRange(&report, "visual.brightness", asset.Visual.Brightness, 0, 1) + checkRange(&report, "visual.contrast", asset.Visual.Contrast, 0, 1) + checkRange(&report, "visual.saturation", asset.Visual.Saturation, 0, 1) + checkRange(&report, "texture.orientation", asset.Texture.PrimaryOrientationDeg, 0, 180) + if asset.Texture.Entropy <= 0 { + report.Errors = append(report.Errors, "texture.entropy must be > 0") + } + if len(hist.Values) != 256 { + report.Errors = append(report.Errors, fmt.Sprintf("histogram length = %d, want 256", len(hist.Values))) + } + for _, vector := range asset.Embeddings.Vectors { + if vector.Dimensions <= 0 { + report.Errors = append(report.Errors, "embedding "+vector.Name+" has invalid dimension") + } + if vector.ByteLength != int64(vector.Dimensions*4) { + report.Errors = append(report.Errors, fmt.Sprintf("embedding %s byte length = %d, want %d", vector.Name, vector.ByteLength, vector.Dimensions*4)) + } + if vector.Model == "dinov2-large" && vector.Dimensions != 1024 { + report.Errors = append(report.Errors, "dinov2-large dimension must be 1024") + } + if vector.Model == "ViT-L/14" && vector.Dimensions != 768 { + report.Errors = append(report.Errors, "ViT-L/14 dimension must be 768") + } + } + if asset.Semantic.Description == "" { + report.Errors = append(report.Errors, "semantic.description is required") + } + if asset.Semantic.MaterialType == "" { + report.Errors = append(report.Errors, "semantic.material_type is required") + } + if asset.Semantic.SurfaceFinish == "" { + report.Errors = append(report.Errors, "semantic.surface_finish is required") + } + if asset.Semantic.ColorFamily == "" { + report.Errors = append(report.Errors, "semantic.color_family is required") + } + if asset.Semantic.Confidence <= 0 || asset.Semantic.Confidence > 1 { + report.Errors = append(report.Errors, "semantic.confidence must be in (0,1]") + } + if len(asset.Canonical.BrightnessDistribution) != asset.Canonical.BrightnessHistogramBins { + report.Warnings = append(report.Warnings, "canonical brightness histogram bin count mismatch") + } + if len(asset.Canonical.GradientHistogram) != asset.Canonical.GradientHistogramBins { + report.Warnings = append(report.Warnings, "canonical gradient histogram bin count mismatch") + } + if len(report.Errors) > 0 { + report.Status = "invalid" + } + return report +} + +func checkRange(report *model.ValidationReport, name string, value, min, max float64) { + if value < min || value > max { + report.Errors = append(report.Errors, fmt.Sprintf("%s = %.4f, want [%.2f,%.2f]", name, value, min, max)) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..03e56a4 --- /dev/null +++ b/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "materialanalyzer/internal/app" +) + +func main() { + os.Exit(app.Run(os.Args[1:])) +} diff --git a/tools/model_server/requirements.txt b/tools/model_server/requirements.txt new file mode 100644 index 0000000..054ea0b --- /dev/null +++ b/tools/model_server/requirements.txt @@ -0,0 +1,5 @@ +flask +open_clip_torch +pillow +torch +transformers diff --git a/tools/model_server/server.py b/tools/model_server/server.py new file mode 100644 index 0000000..0eb50b1 --- /dev/null +++ b/tools/model_server/server.py @@ -0,0 +1,136 @@ +import base64 +import io +import os + +from flask import Flask, jsonify, request +from PIL import Image + +app = Flask(__name__) +app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 + +DEVICE = os.getenv("MODEL_DEVICE", "cuda") +DINO_MODEL = os.getenv("DINO_MODEL", "facebook/dinov2-large") +CLIP_MODEL = os.getenv("CLIP_MODEL", "ViT-L-14") +CLIP_PRETRAINED = os.getenv("CLIP_PRETRAINED", "laion2b_s32b_b82k") + +_torch = None +_dino_processor = None +_dino_model = None +_clip_model = None +_clip_preprocess = None + + +def torch(): + global _torch, DEVICE + if _torch is None: + import torch as torch_mod + + _torch = torch_mod + if DEVICE == "cuda" and not _torch.cuda.is_available(): + DEVICE = "cpu" + return _torch + + +def load_dino(): + global _dino_processor, _dino_model + if _dino_model is None: + from transformers import AutoImageProcessor, AutoModel + + _dino_processor = AutoImageProcessor.from_pretrained(DINO_MODEL) + _dino_model = AutoModel.from_pretrained(DINO_MODEL).to(DEVICE).eval() + return _dino_processor, _dino_model + + +def load_clip(): + global _clip_model, _clip_preprocess + if _clip_model is None: + import open_clip + + _clip_model, _, _clip_preprocess = open_clip.create_model_and_transforms( + CLIP_MODEL, pretrained=CLIP_PRETRAINED + ) + _clip_model = _clip_model.to(DEVICE).eval() + return _clip_model, _clip_preprocess + + +def decode_image(payload): + raw = payload.get("image_base64") + if not raw: + raise ValueError("image_base64 is required") + data = base64.b64decode(raw) + return Image.open(io.BytesIO(data)).convert("RGB") + + +def normalize_tensor(vec): + t = torch() + vec = vec.float() + vec = vec / vec.norm(dim=-1, keepdim=True).clamp_min(1e-12) + return vec.detach().cpu().numpy()[0].astype("float32").tolist() + + +def dinov2_embedding(img): + t = torch() + processor, model = load_dino() + inputs = processor(images=img, return_tensors="pt") + inputs = {k: v.to(DEVICE) for k, v in inputs.items()} + with t.no_grad(): + out = model(**inputs) + if getattr(out, "pooler_output", None) is not None: + vec = out.pooler_output + else: + vec = out.last_hidden_state[:, 0] + values = normalize_tensor(vec) + return { + "name": "dinov2_texture", + "model": "dinov2-large", + "dimension": len(values), + "normalize": True, + "provider": "model_server", + "generator": DINO_MODEL, + "values": values, + } + + +def clip_embedding(img): + t = torch() + model, preprocess = load_clip() + image_tensor = preprocess(img).unsqueeze(0).to(DEVICE) + with t.no_grad(): + vec = model.encode_image(image_tensor) + values = normalize_tensor(vec) + return { + "name": "clip_visual", + "model": "ViT-L/14", + "dimension": len(values), + "normalize": True, + "provider": "model_server", + "generator": f"open_clip:{CLIP_MODEL}:{CLIP_PRETRAINED}", + "values": values, + } + + +@app.get("/health") +def health(): + return jsonify( + { + "status": "ok", + "device": DEVICE, + "dino_model": DINO_MODEL, + "clip_model": CLIP_MODEL, + "clip_pretrained": CLIP_PRETRAINED, + } + ) + + +@app.post("/embeddings") +def embeddings(): + try: + img = decode_image(request.get_json(force=True)) + return jsonify({"vectors": [dinov2_embedding(img), clip_embedding(img)]}) + except Exception as exc: + return jsonify({"error": str(exc)}), 500 + + +if __name__ == "__main__": + port = int(os.getenv("MODEL_SERVER_PORT", "5200")) + app.run(host="0.0.0.0", port=port) diff --git a/tools/vision_server/requirements.txt b/tools/vision_server/requirements.txt new file mode 100644 index 0000000..abddbe7 --- /dev/null +++ b/tools/vision_server/requirements.txt @@ -0,0 +1,7 @@ +flask +torch +torchvision +transformers>=4.37.2 +pillow +accelerate +einops diff --git a/tools/vision_server/server.py b/tools/vision_server/server.py new file mode 100644 index 0000000..e8e1a1e --- /dev/null +++ b/tools/vision_server/server.py @@ -0,0 +1,335 @@ +import base64 +import io +import json +import os +import re +import threading +from pathlib import Path + +from flask import Flask, jsonify, request +from PIL import Image +import torch +from torchvision import transforms +from transformers import AutoModel, AutoTokenizer + + +app = Flask(__name__) +app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 + +MODEL_PATH = Path(os.getenv("INTERNVL3_MODEL_PATH", r"D:\go-demo\data\InternVL3-8B")) +INPUT_SIZE = int(os.getenv("INTERNVL3_IMAGE_SIZE", "448")) +MAX_TILES = int(os.getenv("INTERNVL3_MAX_TILES", "4")) +MAX_NEW_TOKENS = int(os.getenv("INTERNVL3_MAX_NEW_TOKENS", "512")) +TEMPERATURE = float(os.getenv("INTERNVL3_TEMPERATURE", "0.0")) +USE_FLASH_ATTN = os.getenv("INTERNVL3_USE_FLASH_ATTN", "").lower() in {"1", "true", "yes"} +LOAD_IN_8BIT = os.getenv("INTERNVL3_LOAD_IN_8BIT", "").lower() in {"1", "true", "yes"} +LOAD_IN_4BIT = os.getenv("INTERNVL3_LOAD_IN_4BIT", "").lower() in {"1", "true", "yes"} +DEVICE = "cuda" if torch.cuda.is_available() else "cpu" + +_lock = threading.Lock() +_model = None +_tokenizer = None +_ready = False +_load_error = None + +IMAGENET_MEAN = (0.485, 0.456, 0.406) +IMAGENET_STD = (0.229, 0.224, 0.225) +JSON_RE = re.compile(r"\{.*\}", re.S) + + +def load_model(): + global _model, _tokenizer, _ready, _load_error + with _lock: + if _ready: + return _model, _tokenizer + if _load_error is not None: + raise RuntimeError(_load_error) + if not MODEL_PATH.exists(): + _load_error = f"model path not found: {MODEL_PATH}" + raise RuntimeError(_load_error) + + kwargs = { + "trust_remote_code": True, + "low_cpu_mem_usage": True, + } + if USE_FLASH_ATTN: + kwargs["use_flash_attn"] = True + if LOAD_IN_8BIT or LOAD_IN_4BIT: + try: + from transformers import BitsAndBytesConfig + + kwargs["quantization_config"] = BitsAndBytesConfig( + load_in_8bit=LOAD_IN_8BIT, + load_in_4bit=LOAD_IN_4BIT, + ) + except Exception as exc: + _load_error = f"bitsandbytes quantization unavailable: {exc}" + raise RuntimeError(_load_error) + + if DEVICE == "cuda" and not (LOAD_IN_8BIT or LOAD_IN_4BIT): + kwargs["torch_dtype"] = torch.bfloat16 + kwargs["device_map"] = "auto" + else: + kwargs["torch_dtype"] = torch.float32 if DEVICE == "cpu" else torch.bfloat16 + + app.logger.info("Loading InternVL3 from %s on %s", MODEL_PATH, DEVICE) + _tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True, use_fast=False) + _model = AutoModel.from_pretrained(MODEL_PATH, **kwargs).eval() + _ready = True + app.logger.info("InternVL3 ready") + return _model, _tokenizer + + +def build_transform(): + return transforms.Compose( + [ + transforms.Resize((INPUT_SIZE, INPUT_SIZE), interpolation=transforms.InterpolationMode.BICUBIC), + transforms.ToTensor(), + transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), + ] + ) + + +def find_closest_aspect_ratio(aspect_ratio, target_ratios, width, height, image_size): + best_ratio_diff = float("inf") + best_ratio = (1, 1) + area = width * height + for ratio in target_ratios: + target_aspect_ratio = ratio[0] / ratio[1] + ratio_diff = abs(aspect_ratio - target_aspect_ratio) + if ratio_diff < best_ratio_diff: + best_ratio_diff = ratio_diff + best_ratio = ratio + elif ratio_diff == best_ratio_diff: + if area > 0.5 * image_size * image_size * ratio[0] * ratio[1]: + best_ratio = ratio + return best_ratio + + +def dynamic_preprocess(image, min_num=1, max_num=12, image_size=448, use_thumbnail=False): + orig_width, orig_height = image.size + aspect_ratio = orig_width / orig_height + + target_ratios = set( + (i, j) + for n in range(min_num, max_num + 1) + for i in range(1, n + 1) + for j in range(1, n + 1) + if i * j <= max_num and i * j >= min_num + ) + target_ratios = sorted(target_ratios, key=lambda x: x[0] * x[1]) + target_aspect_ratio = find_closest_aspect_ratio( + aspect_ratio, target_ratios, orig_width, orig_height, image_size + ) + + target_width = image_size * target_aspect_ratio[0] + target_height = image_size * target_aspect_ratio[1] + blocks = target_aspect_ratio[0] * target_aspect_ratio[1] + resized_img = image.resize((target_width, target_height), Image.Resampling.BICUBIC) + processed_images = [] + for i in range(blocks): + box = ( + (i % (target_width // image_size)) * image_size, + (i // (target_width // image_size)) * image_size, + ((i % (target_width // image_size)) + 1) * image_size, + ((i // (target_width // image_size)) + 1) * image_size, + ) + processed_images.append(resized_img.crop(box)) + if use_thumbnail and len(processed_images) != 1: + processed_images.append(image.resize((image_size, image_size), Image.Resampling.BICUBIC)) + return processed_images + + +def split_tiles(image): + return dynamic_preprocess(image, max_num=MAX_TILES, image_size=INPUT_SIZE, use_thumbnail=True) + + +def image_to_tensor(image): + return build_transform()(image.convert("RGB")) + + +def decode_image(payload): + if payload.get("image_path"): + return Image.open(payload["image_path"]).convert("RGB") + raw = payload.get("image_base64") + if not raw: + raise ValueError("image_base64 or image_path is required") + data = base64.b64decode(raw) + return Image.open(io.BytesIO(data)).convert("RGB") + + +def prompt_for_product(product): + sku = product.get("sku", "") + brand = product.get("brand", "") + category = product.get("category", "") + material = product.get("material", "") + style = product.get("style_name", "") + color = product.get("color_tone", "") + finish = product.get("finish", "") + return f""" +You are a flooring material analysis engine. +Analyze only the floor material in the image. +Return valid JSON only. No markdown. No commentary. + +Schema: +{{ + "description": "", + "material_type": "", + "surface_finish": "", + "grain_type": "", + "color_family": "", + "visual_style": "", + "variation": "", + "gloss_level": "", + "tags": [], + "confidence": 0.0, + "field_confidence": {{}} +}} + +Rules: +- material_type should be a short normalized label like wood, tile, stone, vinyl, laminate, spc, lvp, wpc, ceramic, porcelain, or generic. +- gloss_level must be High, Medium, or Low. +- confidence and field_confidence values must be numbers from 0 to 1. +- tags should contain 5 to 12 short material keywords. +- Focus on flooring surface only, not room decor. + +Product metadata: +SKU: {sku} +Brand: {brand} +Category: {category} +Material: {material} +Style: {style} +Color tone: {color} +Finish: {finish} +""" + + +def extract_json_text(text): + if not text: + raise ValueError("empty model output") + match = JSON_RE.search(text) + if not match: + raise ValueError("no JSON object found in model output") + return match.group(0) + + +def normalize_output(data, product): + if not isinstance(data, dict): + raise ValueError("semantic output must be a JSON object") + + def titleish(value): + if not isinstance(value, str): + return "" + return value.strip() + + semantic = { + "provider": "internvl3", + "model": str(MODEL_PATH.name), + "description": titleish(data.get("description")), + "material_type": titleish(data.get("material_type")).lower(), + "surface_finish": titleish(data.get("surface_finish")), + "grain_type": titleish(data.get("grain_type")), + "color_family": titleish(data.get("color_family")), + "visual_style": titleish(data.get("visual_style")), + "variation": titleish(data.get("variation")), + "gloss_level": titleish(data.get("gloss_level")).title(), + "tags": data.get("tags") or [], + "confidence": float(data.get("confidence") or 0.0), + "field_confidence": data.get("field_confidence") or {}, + } + if not semantic["description"]: + semantic["description"] = f"{product.get('material', '')} flooring" + if not semantic["material_type"]: + semantic["material_type"] = "generic" + if semantic["gloss_level"] not in {"High", "Medium", "Low"}: + semantic["gloss_level"] = "Medium" + if not isinstance(semantic["tags"], list): + semantic["tags"] = [] + semantic["tags"] = [str(tag).strip() for tag in semantic["tags"] if str(tag).strip()] + if len(semantic["tags"]) < 5: + semantic["tags"].extend( + [product.get("category", ""), product.get("material", ""), product.get("brand", "")] + ) + semantic["tags"] = [tag for tag in semantic["tags"] if tag] + if not isinstance(semantic["field_confidence"], dict): + semantic["field_confidence"] = {} + return semantic + + +def analyze_semantic(image, product): + model, tokenizer = load_model() + prompt = prompt_for_product(product) + pixel_values = torch.stack([image_to_tensor(tile) for tile in split_tiles(image)]) + input_device = next(model.parameters()).device + pixel_values = pixel_values.to(input_device) + generation_config = { + "max_new_tokens": MAX_NEW_TOKENS, + "do_sample": False, + "temperature": TEMPERATURE, + } + question = prompt + last_error = None + for attempt in range(3): + try: + with torch.no_grad(): + response = model.chat(tokenizer, pixel_values, question, generation_config) + text = response[0] if isinstance(response, (list, tuple)) else response + semantic = normalize_output(json.loads(extract_json_text(text)), product) + semantic["confidence"] = max(0.0, min(1.0, float(semantic["confidence"]))) + if not semantic["field_confidence"]: + semantic["field_confidence"] = { + "description": semantic["confidence"], + "material_type": semantic["confidence"], + "surface_finish": semantic["confidence"], + "grain_type": semantic["confidence"], + "color_family": semantic["confidence"], + "visual_style": semantic["confidence"], + "variation": semantic["confidence"], + "gloss_level": semantic["confidence"], + } + return semantic + except Exception as exc: + last_error = exc + question = ( + prompt + + "\nYour previous output was invalid. Return only a JSON object matching the schema." + + f"\nError: {exc}" + ) + raise RuntimeError(f"semantic analysis failed after retries: {last_error}") + + +@app.get("/health") +def health(): + try: + model, _ = load_model() + device = str(next(model.parameters()).device) + return jsonify({ + "status": "ok", + "ready": True, + "model_path": str(MODEL_PATH), + "device": device, + "input_size": INPUT_SIZE, + "max_tiles": MAX_TILES, + }) + except Exception as exc: + return jsonify({ + "status": "error", + "ready": False, + "model_path": str(MODEL_PATH), + "error": str(exc), + }), 500 + + +@app.post("/semantic") +def semantic(): + payload = request.get_json(force=True, silent=False) + image = decode_image(payload) + product = payload.get("product") or {} + semantic = analyze_semantic(image, product) + return jsonify(semantic) + + +if __name__ == "__main__": + port = int(os.getenv("INTERNVL3_PORT", "5300")) + app.logger.info("Starting InternVL3 vision server on :%s", port) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/开发文档.txt b/开发文档.txt new file mode 100644 index 0000000..bd25b8b --- /dev/null +++ b/开发文档.txt @@ -0,0 +1,276 @@ +Material Analyzer Design Specification + +Version: 1.0 + +Status: Draft + +1. Objective +Purpose + +The Material Analyzer is an offline preprocessing pipeline responsible for converting every flooring SKU into a standardized Material Asset. + +The generated Material Asset serves as the Ground Truth for all downstream AI image generation tasks. + +Its objectives are: + +Eliminate repeated analysis during generation +Standardize all flooring materials into a unified representation +Improve material consistency across AI-generated images +Provide reusable visual features for similarity search and quality assurance +2. Scope + +Supported categories: + +Hardwood +Engineered Wood +Laminate +SPC +LVP +WPC +Ceramic Tile +Porcelain Tile +Stone +Marble +Vinyl + +Every SKU is processed only once. + +3. Pipeline +SKU + +↓ + +Load Texture Image + +↓ + +Universal Feature Extraction + +↓ + +Texture Feature Extraction + +↓ + +Semantic Analysis + +↓ + +Material Adapter + +↓ + +Visual Embedding + +↓ + +Asset Generation + +↓ + +Material Asset +4. Input + +Example + +{ + "sku":"67907_847", + "category":"SPC-LVP", + "material":"Luxury Vinyl", + "main_image_url":"texture.jpg", + "room_image_url":"room.jpg" +} +5. Output +MaterialAssets/ + + 67907_847/ + + preview.jpg + + material.json + + histogram.json + + embedding.bin + + thumbnail.jpg + + tileable.png (future) +6. Universal Visual Features + +These features are extracted for every flooring material. + +Examples: + +Dominant RGB +Secondary RGB +LAB Color +HSV +Brightness +Contrast +Saturation +Color Histogram + +Recommended implementation: + +OpenCV +KMeans +LAB +HSV +7. Texture Features + +Extract low-level texture statistics. + +Examples: + +Texture Entropy +Texture Frequency +Edge Density +Orientation Variance + +Recommended algorithms: + +GLCM +LBP +FFT +Gabor Filter +Structure Tensor +8. Semantic Features + +Generated by a Vision LLM. + +Examples: + +{ + "description":"...", + "surface_finish":"Matte", + "visual_style":"Natural", + "grain":"Straight Oak", + "variation":"Medium" +} + +Recommended models: + +Gemini +GPT-4o +Claude +9. Material Adapter + +Extract category-specific features. + +Wood: + +Species +Grain Direction +Knot Density +Cathedral Density + +Tile: + +Stone Type +Vein Density +Vein Orientation +Grout Color + +SPC / LVP: + +Printed Pattern +Emboss Depth +Surface Finish +10. Visual Embeddings + +Generate two independent embeddings. + +DINOv2 + +Purpose: + +Texture similarity +Material similarity +Drift detection +CLIP + +Purpose: + +Semantic similarity +Product recommendation +11. Material Asset Schema +{ + "sku":"", + "category":"", + "material":"", + + "visual":{ + + }, + + "texture":{ + + }, + + "semantic":{ + + }, + + "material_specific":{ + + }, + + "embeddings":{ + + } +} +12. Future Modules + +Planned but not implemented. + +Canonical Texture Generator +Tile Detection +Image Quilting +Seamless Texture Generation +Pattern Detector +13. Future Material QA + +The Material Analyzer will later be reused by the Material QA module. + +Workflow: + +Generated Image + +↓ + +Floor Segmentation + +↓ + +Crop Floor + +↓ + +Analyzer + +↓ + +Compare with Ground Truth + +↓ + +Similarity Score +14. Development Priority + +Phase 1 + +Universal Features +Texture Features +Semantic Features +Embeddings +Material JSON + +Phase 2 + +Tileable Texture +Pattern Detection + +Phase 3 + +Material QA \ No newline at end of file diff --git a/开发文档2 b/开发文档2 new file mode 100644 index 0000000..3c7bd14 --- /dev/null +++ b/开发文档2 @@ -0,0 +1,332 @@ +Material Analyzer Development Specification v2 + +Version: 2.0 + +Status: Development + +Depends on: Material Analyzer Phase 1 + +1. Objective + +The goal of Version 2 is not to introduce new downstream features such as Material QA or Texture Generation. + +Instead, Version 2 focuses on upgrading the Material Analyzer into a production-grade offline analysis engine. + +After completion, every flooring SKU should have a complete, stable, reproducible Material Asset that can serve as the Ground Truth across all future AI models. + +2. Replace Placeholder Models + +Current implementation uses deterministic placeholder vectors. + +Replace them with real vision models. + +2.1 DINOv2 + +Purpose + +Extract texture-level visual embeddings. + +Recommended model + +facebook/dinov2-large + +Output + +1024-dim float32 vector + +Store + +embedding.bin + +Metadata + +{ + "model":"dinov2-large", + "dimension":1024, + "normalize":true +} + +Applications + +Texture similarity +Material similarity +Drift detection +SKU retrieval +2.2 CLIP + +Purpose + +Extract semantic visual embeddings. + +Recommended + +ViT-L/14 + +Output + +768-dim float32 vector + +Applications + +Semantic similarity +Recommendation +Search +3. Vision LLM Semantic Analyzer + +Replace rule-based semantic generation. + +Recommended models + +Priority + +Gemini 3 Pro Image + +GPT-4o Vision + +Claude Vision + +Input + +Texture Image + +Output Schema + +{ + "description":"", + "material_type":"", + "surface_finish":"", + "grain_type":"", + "color_family":"", + "visual_style":"", + "variation":"", + "gloss_level":"", + "tags":[] +} + +Important + +LLM output should always be validated against JSON Schema. + +Missing fields should be regenerated. + +4. Feature Versioning + +Every generated Material Asset must contain version information. + +Example + +{ + "analyzer_version":"2.0.0", + + "feature_schema":"2026.07", + + "generated_at":"ISO8601", + + "generator":"Material Analyzer" +} + +Future schema changes must remain backward compatible. + +5. Feature Validation + +After extraction, automatically validate all features. + +Example checks + +Brightness ∈ [0,1] + +Contrast ∈ [0,1] + +Histogram length == 256 + +Embedding dimension == expected + +Orientation ∈ [0,180] + +Entropy > 0 + +Invalid assets should be regenerated. + +6. Material Confidence + +Every inferred feature should include a confidence score. + +Example + +{ + "stone_type":"Travertine", + + "confidence":0.91 +} + +This enables future QA weighting. + +7. Canonical Material Statistics + +Generate global statistics for every SKU. + +Examples + +Mean RGB + +Median RGB + +Color Variance + +Texture Variance + +Brightness Distribution + +Dominant Orientation + +Gradient Histogram + +These statistics are independent of LLM output. + +8. Asset Manifest + +Every asset folder should contain + +manifest.json + +Example + +{ + "sku":"67907_847", + + "files":[ + "preview.jpg", + "thumbnail.jpg", + "material.json", + "embedding.bin", + "histogram.json" + ], + + "status":"complete" +} + +This simplifies integrity checking. + +9. Batch Processing + +Improve offline processing. + +Support + +Incremental Update + +Resume + +Retry + +Multi-thread + +Progress Bar + +Logging + +Failure Report + +Target + +3500+ SKU + +One-click processing + +Resume after interruption +10. Analyzer Benchmark + +Generate benchmark reports after every batch. + +Example + +Total SKU + +Processed + +Skipped + +Failed + +Average Time + +Model Version + +Feature Version + +Image Resolution Distribution + +Output + +benchmark.json +11. Plugin Architecture + +Future feature extractors should be pluggable. + +Directory + +analyzers/ + + color/ + + texture/ + + semantic/ + + embedding/ + + adapters/ + + plugins/ + +Every extractor should implement + +Analyze(image) -> Feature + +No module should depend directly on another module. + +12. Future Compatibility + +Analyzer output must remain stable for + +Material QA +Canonical Texture Generator +Tile Detection +Pattern Detection +Similarity Search +AI Prompt Builder +Product Recommendation + +Do not tightly couple Analyzer with any downstream module. + +13. Development Priority + +Phase 1 (Completed) + +Universal Features +Texture Features +Rule-based Semantic +Placeholder Embedding + +Phase 2 (Current) + +DINOv2 +CLIP +Vision LLM +Versioning +Validation +Confidence +Manifest + +Phase 3 + +Canonical Texture Generator +Tile Detection +Image Quilting +Pattern Detector + +Phase 4 + +Material QA +Drift Detection +Auto Retry \ No newline at end of file diff --git a/开发文档3 b/开发文档3 new file mode 100644 index 0000000..3ff1b21 --- /dev/null +++ b/开发文档3 @@ -0,0 +1,501 @@ +Material Intelligence Platform v3 + +Version: 3.0 + +Status: Design + +1. Objective + +The goal of Version 3 is no longer feature extraction. + +Version 3 focuses on transforming Material Assets into an intelligent knowledge base that can directly support AI image generation, material retrieval, quality assurance, and future recommendation systems. + +Material Analyzer is considered feature-complete. + +Version 3 extends it into a Material Intelligence Platform. + +2. Architecture +Crawler + +↓ + +Material Analyzer (Completed) + +↓ + +Material Asset Library + +↓ + +Material Intelligence + +├── Prompt Builder +├── Material Knowledge Base +├── Similarity Search +├── Material Ground Truth +├── Vision Provider +└── Dataset Builder + +↓ + +Gemini Image Renderer + +↓ + +Material QA (Future) +3. Replace Vision Provider + +Current implementation: + +Rule Provider + +↓ + +HTTP Provider + +Upgrade to Provider Architecture. + +vision/ + + internvl3/ + + florence2/ + + qwen2_5vl/ + + gemini/ + + gpt4o/ + +Every provider implements + +AnalyzeMaterial(image) -> MaterialSemantic + +Analyzer must not depend on any specific model. + +4. Default Vision Model + +Default local model + +InternVL3 + +Reason + +Fully offline +Reproducible +No API cost +Fine-grained material understanding +Future LoRA support + +Gemini becomes optional. + +Gemini is no longer the default analyzer. + +Gemini is recommended only for image generation. + +5. Semantic Fusion + +Instead of trusting one model. + +Support multiple providers. + +Example + +InternVL3 + +↓ + +Semantic A + +Florence2 + +↓ + +Semantic B + +↓ + +Fusion + +↓ + +Final Semantic + +Fusion strategy + +voting +confidence weighting +field-level merge + +Output + +semantic.json +6. Material Fingerprint + +Generate a readable fingerprint. + +Example + +{ + "brightness":0.71, + "contrast":0.18, + "saturation":0.14, + "variation":0.22, + "texture_entropy":0.64, + "texture_frequency":0.39, + "orientation":89, + "dominant_lab":[67,-1,8] +} + +Purpose + +Prompt Builder +QA +Recommendation +Search + +Unlike embedding, + +Fingerprint is human-readable. + +7. Prompt Builder + +New module. + +Input + +Material Asset + +Output + +Prompt Block + +Example + +Material Constraints + +Species: +European Oak + +Surface: +Low Satin + +Variation: +Medium + +Gloss: +Low + +Keep all material properties identical to the reference texture. + +Do not alter species, +finish, +grain, +or gloss. + +The rendering pipeline should consume Prompt Blocks instead of manually assembled prompts. + +8. Ground Truth Builder + +Convert low-level features into canonical material definitions. + +Example + +Analyzer + +↓ + +Ground Truth Builder + +↓ + +ground_truth.json + +Purpose + +Separate computer vision features from rendering constraints. + +9. Material Knowledge Base + +Generate + +knowledge.db + +Store + +SKU + +↓ + +Embedding + +↓ + +Semantic + +↓ + +Fingerprint + +Support + +search +recommendation +clustering +analytics +10. Similarity Graph + +Build + +KNN + +↓ + +Material Graph + +Example + +Bruce Cinnamon + +↓ + +Most Similar + +Mohawk Brown Oak + +0.96 + +Output + +similarity.json +11. Prompt Dataset + +Automatically generate + +prompt_dataset.json + +Example + +{ + "sku":"67907_847", + + "system_prompt":"...", + + "material_prompt":"...", + + "negative_prompt":"..." +} + +This dataset becomes the standard prompt source for Gemini. + +12. Embedding Index + +Generate + +faiss.index + +Purpose + +Nearest-neighbor search + +Duplicate detection + +Recommendation + +Visual search + +13. Statistics Center + +Generate + +statistics.json + +Examples + +Species Distribution + +Brightness Distribution + +Material Distribution + +Color Distribution + +Gloss Distribution + +Embedding PCA + +Cluster Statistics + +14. Asset Integrity + +Upgrade manifest. + +Current + +files + +New + +SHA256 + +File Size + +Created Time + +Analyzer Version + +Feature Version + +Every asset must pass integrity verification. + +15. Regression Test + +Every Analyzer update automatically compares + +Old Asset + +↓ + +New Asset + +Compare + +Brightness + +Histogram + +Embedding + +Semantic + +Drift + +Generate + +regression_report.json +16. Vision Benchmark + +Evaluate every Vision Provider. + +Example + +InternVL3 + +Accuracy + +Latency + +Memory + +Semantic Stability + +Compare + +InternVL3 + +VS + +Qwen2.5VL + +VS + +Florence2 + +Generate + +benchmark_vision.json +17. Material Dataset + +Generate + +dataset/ + + assets/ + + prompts/ + + embeddings/ + + fingerprints/ + + statistics/ + + labels/ + +Future + +Training + +Fine-tuning + +LoRA + +Recommendation + +QA + +18. Development Priority +Phase A + +Replace Rule Semantic + +↓ + +InternVL3 + +Phase B + +Prompt Builder + +Ground Truth Builder + +Fingerprint + +Phase C + +Similarity Graph + +Knowledge Base + +FAISS Index + +Phase D + +Statistics Center + +Regression Test + +Benchmark + +19. Out of Scope + +The following modules belong to Version 4. + +Material QA +Drift Detection +Tile Detection +Canonical Texture Generator +Image Quilting +Pattern Detector +Seamless Texture Generation +20. Deliverables + +Version 3 should produce: + +MaterialAssets/ + +KnowledgeBase/ + +PromptDataset/ + +Statistics/ + +Embeddings/ + +Fingerprints/ + +GroundTruth/ + +Regression/ + +Benchmarks/ \ No newline at end of file