275 lines
7.3 KiB
Go
275 lines
7.3 KiB
Go
|
|
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
|
||
|
|
}
|