Initial Material Analyzer project

This commit is contained in:
dindang 2026-07-27 11:03:51 +08:00
commit b0dfe370e8
46 changed files with 6045 additions and 0 deletions

27
.gitignore vendored Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,5 @@
module materialanalyzer
go 1.26.4
require golang.org/x/image v0.43.0

2
go.sum Normal file
View 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=

View 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
View 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")
}

View 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
View 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 }

View 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
View 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
}
}

View 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
}

View 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)
}

View 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
}

View 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),
}
}

View 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)
}

View 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
}

View 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")
}
}

View 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
}

View 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)
}

View 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)
}

View 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))
}

View 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
}

View 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"`
}

View 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
View 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
View 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,
}
}

View 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
}
}

View 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
View 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
}

View 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)
}
}

View 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
}

View 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)
}
}

View 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"
}

View 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"))
}

View 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
}

View 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
}

View 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
View File

@ -0,0 +1,11 @@
package main
import (
"os"
"materialanalyzer/internal/app"
)
func main() {
os.Exit(app.Run(os.Args[1:]))
}

View File

@ -0,0 +1,5 @@
flask
open_clip_torch
pillow
torch
transformers

View 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)

View File

@ -0,0 +1,7 @@
flask
torch
torchvision
transformers>=4.37.2
pillow
accelerate
einops

View 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
View 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
View 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
View 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/