Initial Material Analyzer project
This commit is contained in:
commit
b0dfe370e8
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@ -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
|
||||
242
README.md
Normal file
242
README.md
Normal file
@ -0,0 +1,242 @@
|
||||
# Material Analyzer
|
||||
|
||||
用于将 FloorVisualizer 的地板 SKU 离线预处理为可复现 `MaterialAssets` 的流水线。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
go run . -limit 10 -workers 4 -verbose
|
||||
```
|
||||
|
||||
CLI 默认读取:
|
||||
|
||||
```text
|
||||
..\FloorVisualizer\data\products
|
||||
```
|
||||
|
||||
并输出到:
|
||||
|
||||
```text
|
||||
MaterialAssets\<safe-sku>\
|
||||
```
|
||||
|
||||
常用参数示例:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
并使用图像下方的地面区域裁剪做特征提取。这样可以让流水线继续运行,同时也便于后续审计和清理这些资产。
|
||||
11
cmd/analyze/main.go
Normal file
11
cmd/analyze/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"materialanalyzer/internal/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(app.Run(os.Args[1:]))
|
||||
}
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
@ -0,0 +1,5 @@
|
||||
module materialanalyzer
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require golang.org/x/image v0.43.0
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
@ -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=
|
||||
33
internal/analyzer/interfaces.go
Normal file
33
internal/analyzer/interfaces.go
Normal file
@ -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)
|
||||
}
|
||||
352
internal/app/app.go
Normal file
352
internal/app/app.go
Normal file
@ -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")
|
||||
}
|
||||
280
internal/embedding/embedding.go
Normal file
280
internal/embedding/embedding.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
250
internal/imageproc/color.go
Normal file
250
internal/imageproc/color.go
Normal file
@ -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 }
|
||||
27
internal/imageproc/color_test.go
Normal file
27
internal/imageproc/color_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
128
internal/imageproc/io.go
Normal file
128
internal/imageproc/io.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
15
internal/imageproc/math.go
Normal file
15
internal/imageproc/math.go
Normal file
@ -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
|
||||
}
|
||||
124
internal/imageproc/prepare.go
Normal file
124
internal/imageproc/prepare.go
Normal file
@ -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)
|
||||
}
|
||||
99
internal/imageproc/statistics.go
Normal file
99
internal/imageproc/statistics.go
Normal file
@ -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
|
||||
}
|
||||
218
internal/imageproc/texture.go
Normal file
218
internal/imageproc/texture.go
Normal file
@ -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),
|
||||
}
|
||||
}
|
||||
79
internal/imageproc/transform.go
Normal file
79
internal/imageproc/transform.go
Normal file
@ -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)
|
||||
}
|
||||
124
internal/intelligence/builders.go
Normal file
124
internal/intelligence/builders.go
Normal file
@ -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
|
||||
}
|
||||
29
internal/intelligence/builders_test.go
Normal file
29
internal/intelligence/builders_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
140
internal/intelligence/load.go
Normal file
140
internal/intelligence/load.go
Normal file
@ -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
|
||||
}
|
||||
108
internal/intelligence/regression.go
Normal file
108
internal/intelligence/regression.go
Normal file
@ -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)
|
||||
}
|
||||
196
internal/intelligence/run.go
Normal file
196
internal/intelligence/run.go
Normal file
@ -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)
|
||||
}
|
||||
79
internal/intelligence/similarity.go
Normal file
79
internal/intelligence/similarity.go
Normal file
@ -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))
|
||||
}
|
||||
82
internal/intelligence/statistics.go
Normal file
82
internal/intelligence/statistics.go
Normal file
@ -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
|
||||
}
|
||||
161
internal/intelligence/types.go
Normal file
161
internal/intelligence/types.go
Normal file
@ -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"`
|
||||
}
|
||||
30
internal/intelligence/vision_benchmark.go
Normal file
30
internal/intelligence/vision_benchmark.go
Normal file
@ -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
|
||||
}
|
||||
210
internal/model/asset.go
Normal file
210
internal/model/asset.go
Normal file
@ -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"`
|
||||
}
|
||||
78
internal/model/product.go
Normal file
78
internal/model/product.go
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
47
internal/model/semantic_json.go
Normal file
47
internal/model/semantic_json.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
16
internal/model/semantic_json_test.go
Normal file
16
internal/model/semantic_json_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
160
internal/output/writer.go
Normal file
160
internal/output/writer.go
Normal file
@ -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
|
||||
}
|
||||
11
internal/output/writer_test.go
Normal file
11
internal/output/writer_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
71
internal/repository/product_loader.go
Normal file
71
internal/repository/product_loader.go
Normal file
@ -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
|
||||
}
|
||||
29
internal/repository/product_loader_test.go
Normal file
29
internal/repository/product_loader_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
98
internal/service/adapter.go
Normal file
98
internal/service/adapter.go
Normal file
@ -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"
|
||||
}
|
||||
248
internal/service/processor.go
Normal file
248
internal/service/processor.go
Normal file
@ -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"))
|
||||
}
|
||||
274
internal/service/semantic.go
Normal file
274
internal/service/semantic.go
Normal file
@ -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
|
||||
}
|
||||
289
internal/service/semantic_provider.go
Normal file
289
internal/service/semantic_provider.go
Normal file
@ -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
|
||||
}
|
||||
70
internal/validation/validation.go
Normal file
70
internal/validation/validation.go
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
11
main.go
Normal file
11
main.go
Normal file
@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"materialanalyzer/internal/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(app.Run(os.Args[1:]))
|
||||
}
|
||||
5
tools/model_server/requirements.txt
Normal file
5
tools/model_server/requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
flask
|
||||
open_clip_torch
|
||||
pillow
|
||||
torch
|
||||
transformers
|
||||
136
tools/model_server/server.py
Normal file
136
tools/model_server/server.py
Normal file
@ -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)
|
||||
7
tools/vision_server/requirements.txt
Normal file
7
tools/vision_server/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
flask
|
||||
torch
|
||||
torchvision
|
||||
transformers>=4.37.2
|
||||
pillow
|
||||
accelerate
|
||||
einops
|
||||
335
tools/vision_server/server.py
Normal file
335
tools/vision_server/server.py
Normal file
@ -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"""<image>
|
||||
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)
|
||||
276
开发文档.txt
Normal file
276
开发文档.txt
Normal file
@ -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
|
||||
332
开发文档2
Normal file
332
开发文档2
Normal file
@ -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
|
||||
501
开发文档3
Normal file
501
开发文档3
Normal file
@ -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/
|
||||
Loading…
Reference in New Issue
Block a user