mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
393 lines
8.8 KiB
Go
393 lines
8.8 KiB
Go
package vision
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
|
|
"github.com/photoprism/photoprism/internal/ai/classify"
|
|
"github.com/photoprism/photoprism/internal/entity"
|
|
"github.com/photoprism/photoprism/pkg/txt"
|
|
)
|
|
|
|
type canonicalLabel struct {
|
|
Name string
|
|
Priority int
|
|
Categories []string
|
|
Threshold float32
|
|
hasRule bool
|
|
}
|
|
|
|
var (
|
|
canonicalLabelOnce sync.Once
|
|
canonicalLabels map[string]canonicalLabel
|
|
)
|
|
|
|
var labelWordSplitter = strings.NewReplacer(
|
|
"-", " ",
|
|
"_", " ",
|
|
"/", " ",
|
|
"\\", " ",
|
|
"|", " ",
|
|
",", " ",
|
|
";", " ",
|
|
":", " ",
|
|
)
|
|
|
|
// normalizeLabelResult canonicalizes the label name, merges categories, and assigns a priority so every engine reuses the same vocabulary logic.
|
|
func normalizeLabelResult(result *LabelResult) {
|
|
if result == nil {
|
|
return
|
|
}
|
|
|
|
// Get canonical label name and metadata,
|
|
name, meta := resolveLabelName(result.Name)
|
|
|
|
// Use canonical name from rules.
|
|
if name != "" {
|
|
result.Name = name
|
|
}
|
|
|
|
// Apply Confidence threshold if configured and the label has a Confidence score.
|
|
if result.Confidence > 0 || meta.Threshold == 1 {
|
|
// Cap Confidence at 100%.
|
|
if result.Confidence > 1 {
|
|
result.Confidence = 1
|
|
}
|
|
|
|
// Get Confidence threshold from label rules.
|
|
threshold := meta.Threshold
|
|
|
|
// Get global Confidence threshold, if label has no rule,
|
|
if threshold <= 0 {
|
|
threshold = Config.Thresholds.GetConfidenceFloat32()
|
|
}
|
|
|
|
// Compare Confidence threshold.
|
|
if threshold > 0 && result.Confidence < threshold {
|
|
result.Name = ""
|
|
result.Categories = nil
|
|
result.Priority = 0
|
|
return
|
|
}
|
|
} else if result.Confidence < 0 {
|
|
// Confidence cannot be negative.
|
|
result.Confidence = 0
|
|
}
|
|
|
|
// Apply Topicality threshold if it is configured and the label has a Topicality score.
|
|
if result.Topicality > 0 || Config.Thresholds.Topicality == 100 {
|
|
// Cap Topicality at 100%.
|
|
if result.Topicality > 1 {
|
|
result.Topicality = 1
|
|
}
|
|
|
|
// Compare Topicality threshold.
|
|
if t := Config.Thresholds.GetTopicalityFloat32(); t > 0 && result.Topicality < t {
|
|
result.Name = ""
|
|
result.Categories = nil
|
|
result.Priority = 0
|
|
return
|
|
}
|
|
} else if result.Topicality < 0 {
|
|
// Topicality cannot be negative.
|
|
result.Topicality = 0
|
|
}
|
|
|
|
if len(meta.Categories) > 0 {
|
|
result.Categories = mergeCategories(result.Categories, meta.Categories)
|
|
}
|
|
|
|
if meta.Priority != 0 {
|
|
result.Priority = meta.Priority
|
|
}
|
|
|
|
if result.Priority == 0 {
|
|
result.Priority = PriorityFromTopicality(result.Topicality)
|
|
}
|
|
|
|
// NSFWConfidence cannot be less than 0%, or more than 100%.
|
|
if result.NSFWConfidence < 0 {
|
|
result.NSFWConfidence = 0
|
|
} else if result.NSFWConfidence > 1 {
|
|
result.NSFWConfidence = 1
|
|
result.NSFW = true
|
|
}
|
|
|
|
// Set NSFWConfidence to 100% if result.NSFW
|
|
// is set without a numeric score.
|
|
if result.NSFW && result.NSFWConfidence <= 0 {
|
|
result.NSFWConfidence = 1
|
|
}
|
|
}
|
|
|
|
// resolveLabelName returns the canonical label name and metadata, preferring (1) TensorFlow rules, (2) existing PhotoPrism labels, (3) sanitized tokens, then (4) a Title-case fallback.
|
|
func resolveLabelName(raw string) (string, canonicalLabel) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", canonicalLabel{}
|
|
}
|
|
|
|
if meta, ok := canonicalLabelFor(raw); ok {
|
|
return meta.Name, meta
|
|
}
|
|
|
|
if meta, ok := lookupExistingLabel(raw); ok {
|
|
return meta.Name, meta
|
|
}
|
|
|
|
tokens := candidateTokens(raw)
|
|
var fallback string
|
|
|
|
for _, token := range tokens {
|
|
if token == "" {
|
|
continue
|
|
}
|
|
|
|
if fallback == "" {
|
|
fallback = token
|
|
}
|
|
|
|
if meta, ok := canonicalLabelFor(token); ok {
|
|
return meta.Name, meta
|
|
}
|
|
|
|
if meta, ok := lookupExistingLabel(token); ok {
|
|
return meta.Name, meta
|
|
}
|
|
}
|
|
|
|
if fallback != "" {
|
|
titled := txt.Title(fallback)
|
|
if meta, ok := canonicalLabelFor(titled); ok {
|
|
return meta.Name, meta
|
|
}
|
|
return titled, canonicalLabel{}
|
|
}
|
|
|
|
return txt.Title(raw), canonicalLabel{}
|
|
}
|
|
|
|
// candidateTokens breaks a raw label into sanitized tokens and adds potential singular forms.
|
|
func candidateTokens(raw string) []string {
|
|
sanitized := labelWordSplitter.Replace(raw)
|
|
fields := strings.Fields(sanitized)
|
|
tokens := make([]string, 0, len(fields))
|
|
|
|
for _, f := range fields {
|
|
cleaned := sanitizeToken(f)
|
|
if cleaned == "" {
|
|
continue
|
|
}
|
|
|
|
tokens = append(tokens, cleaned)
|
|
|
|
trimmed := trimPlural(cleaned)
|
|
if trimmed != "" && trimmed != cleaned {
|
|
tokens = append(tokens, trimmed)
|
|
}
|
|
}
|
|
|
|
return tokens
|
|
}
|
|
|
|
// sanitizeToken strips punctuation, digits, and separators so tokens can be matched consistently.
|
|
func sanitizeToken(token string) string {
|
|
trimmed := strings.Trim(token, "\"'()[]{}<>.,!?`~")
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
|
|
noDigits := strings.Map(func(r rune) rune {
|
|
if unicode.IsDigit(r) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, trimmed)
|
|
|
|
noDigits = strings.Trim(noDigits, "-_")
|
|
return strings.TrimSpace(noDigits)
|
|
}
|
|
|
|
// trimPlural removes a trailing "s" from longer tokens to produce a singular candidate.
|
|
func trimPlural(token string) string {
|
|
runes := []rune(token)
|
|
if len(runes) < 4 {
|
|
return token
|
|
}
|
|
|
|
last := unicode.ToLower(runes[len(runes)-1])
|
|
if last != 's' {
|
|
return token
|
|
}
|
|
|
|
trimmed := strings.TrimSpace(string(runes[:len(runes)-1]))
|
|
if len([]rune(trimmed)) < 3 {
|
|
return token
|
|
}
|
|
|
|
return trimmed
|
|
}
|
|
|
|
// lookupExistingLabel reuses labels already stored in the database (if the connection is available).
|
|
func lookupExistingLabel(name string) (canonicalLabel, bool) {
|
|
if db := entity.Db(); db == nil {
|
|
return canonicalLabel{}, false
|
|
}
|
|
|
|
candidates := []string{name}
|
|
plural := trimPlural(name)
|
|
if plural != name {
|
|
candidates = append(candidates, plural)
|
|
}
|
|
|
|
for _, candidate := range candidates {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
|
|
if existing, err := entity.FindLabel(candidate, true); err == nil && existing.HasID() {
|
|
if meta, ok := canonicalLabelFor(existing.LabelName); ok {
|
|
return meta, true
|
|
}
|
|
|
|
return canonicalLabel{Name: existing.LabelName}, true
|
|
}
|
|
}
|
|
|
|
return canonicalLabel{}, false
|
|
}
|
|
|
|
// canonicalLabelFor reads canonical names from classify.Rules (TensorFlow vocabulary).
|
|
func canonicalLabelFor(name string) (canonicalLabel, bool) {
|
|
ensureCanonicalLabels()
|
|
|
|
slug := txt.Slug(name)
|
|
if slug == "" {
|
|
return canonicalLabel{}, false
|
|
}
|
|
|
|
canonical, ok := canonicalLabels[slug]
|
|
return canonical, ok
|
|
}
|
|
|
|
// ensureCanonicalLabels lazily populates the canonical label map once per process.
|
|
func ensureCanonicalLabels() {
|
|
canonicalLabelOnce.Do(func() {
|
|
canonicalLabels = make(map[string]canonicalLabel, len(classify.Rules)*2)
|
|
|
|
for key, rule := range classify.Rules {
|
|
canonicalName := rule.Label
|
|
if canonicalName == "" {
|
|
canonicalName = key
|
|
}
|
|
|
|
meta := canonicalLabel{
|
|
Name: txt.Title(canonicalName),
|
|
Priority: rule.Priority,
|
|
Categories: append([]string(nil), rule.Categories...),
|
|
Threshold: rule.Threshold,
|
|
hasRule: true,
|
|
}
|
|
|
|
addCanonicalMapping(key, meta)
|
|
addCanonicalMapping(canonicalName, meta)
|
|
}
|
|
})
|
|
}
|
|
|
|
// addCanonicalMapping stores or merges canonical metadata for a given slug.
|
|
func addCanonicalMapping(name string, meta canonicalLabel) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return
|
|
}
|
|
|
|
slug := txt.Slug(name)
|
|
if slug == "" {
|
|
return
|
|
}
|
|
|
|
// Update existing canonical label.
|
|
if existing, ok := canonicalLabels[slug]; ok {
|
|
if existing.Name == "" || meta.Name != "" && len(meta.Name) < len(existing.Name) {
|
|
existing.Name = meta.Name
|
|
}
|
|
|
|
if meta.Priority != 0 && (existing.Priority == 0 || meta.Priority > existing.Priority) {
|
|
existing.Priority = meta.Priority
|
|
}
|
|
|
|
existing.Categories = mergeCategories(existing.Categories, meta.Categories)
|
|
|
|
if meta.Threshold > 0 && (existing.Threshold <= 0 || meta.Threshold < existing.Threshold) {
|
|
existing.Threshold = meta.Threshold
|
|
}
|
|
|
|
existing.hasRule = existing.hasRule || meta.hasRule
|
|
canonicalLabels[slug] = existing
|
|
return
|
|
}
|
|
|
|
// Create new canonical label.
|
|
canonicalLabels[slug] = canonicalLabel{
|
|
Name: meta.Name,
|
|
Priority: meta.Priority,
|
|
Categories: mergeCategories(nil, meta.Categories),
|
|
Threshold: meta.Threshold,
|
|
hasRule: meta.hasRule,
|
|
}
|
|
}
|
|
|
|
// mergeCategories keeps categories unique by comparing slugs case-insensitively.
|
|
func mergeCategories(existing, additional []string) []string {
|
|
if len(existing) == 0 && len(additional) == 0 {
|
|
return nil
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(existing)+len(additional))
|
|
merged := make([]string, 0, len(existing)+len(additional))
|
|
|
|
for _, c := range existing {
|
|
slug := txt.Slug(c)
|
|
if slug == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[slug]; ok {
|
|
continue
|
|
}
|
|
seen[slug] = struct{}{}
|
|
|
|
normalized := txt.Title(c)
|
|
if normalized == "" {
|
|
continue
|
|
}
|
|
|
|
merged = append(merged, normalized)
|
|
}
|
|
|
|
for _, c := range additional {
|
|
slug := txt.Slug(c)
|
|
if slug == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[slug]; ok {
|
|
continue
|
|
}
|
|
seen[slug] = struct{}{}
|
|
|
|
normalized := txt.Title(c)
|
|
if normalized == "" {
|
|
continue
|
|
}
|
|
|
|
merged = append(merged, normalized)
|
|
}
|
|
|
|
if len(merged) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return merged
|
|
}
|