Files
photoprism/internal/ai/classify/labels.go
2025-11-22 11:19:30 +01:00

173 lines
4.2 KiB
Go

package classify
import (
"sort"
"github.com/photoprism/photoprism/pkg/txt"
)
// Labels represents a sortable collection of Label values returned by vision
// models, Exif metadata, or user input.
type Labels []Label
// Len implements sort.Interface for Labels.
func (l Labels) Len() int { return len(l) }
// Swap implements sort.Interface for Labels.
func (l Labels) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
// Less implements sort.Interface for Labels. Higher-priority labels come first;
// for equal priority the lower-uncertainty label wins. Labels with an
// uncertainty >= 100 are considered unusable and are ordered last.
func (l Labels) Less(i, j int) bool {
switch {
case l[i].Uncertainty >= 100:
return false
case l[j].Uncertainty >= 100:
return true
case l[i].Priority == l[j].Priority:
return l[i].Uncertainty < l[j].Uncertainty
default:
return l[i].Priority > l[j].Priority
}
}
// AppendLabel mirrors append but discards labels with an empty name so callers
// do not need to check for that guard condition.
func (l Labels) AppendLabel(label Label) Labels {
if label.Name == "" {
return l
}
return append(l, label)
}
// Keywords maps label names and categories to their keyword tokens (using the
// txt.Keywords helper) while skipping low-confidence labels and those sourced
// from plain text fields (title/caption/keyword).
func (l Labels) Keywords() (result []string) {
for _, label := range l {
if label.Uncertainty >= 100 ||
label.Source == SrcTitle ||
label.Source == SrcCaption ||
label.Source == SrcSubject ||
label.Source == SrcKeyword {
continue
}
result = append(result, txt.Keywords(label.Name)...)
for _, c := range label.Categories {
result = append(result, txt.Keywords(c)...)
}
}
return result
}
// Count returns the number of labels that have a non-empty name and an
// uncertainty below 100 (0% confidence cut-off).
func (l Labels) Count() (count int) {
if l == nil {
return 0
}
for _, label := range l {
if label.Name == "" || label.Uncertainty >= 100 {
continue
}
count++
}
return count
}
// Names returns label names whose uncertainty is less than 100 (0% confidence
// cut-off). The order matches the underlying slice.
func (l Labels) Names() (s []string) {
if l == nil {
return s
}
s = make([]string, 0, l.Count())
for _, label := range l {
if label.Name == "" || label.Uncertainty >= 100 {
continue
}
s = append(s, label.Name)
}
return s
}
// String returns a human-readable list of label names joined with commas and an
// "and" before the final element. When no names are available "none" is
// returned to communicate the absence of labels.
func (l Labels) String() string {
if l == nil {
return "none"
}
return txt.JoinAnd(l.Names())
}
// Title selects a suitable title from the labels slice using priority and
// uncertainty thresholds. When titles are not available or fail the confidence
// checks the provided fallback string is returned instead.
func (l Labels) Title(fallback string) string {
fallbackRunes := len([]rune(fallback))
// check if given fallback is valid
if fallbackRunes < 2 || fallbackRunes > 25 || txt.ContainsNumber(fallback) {
fallback = ""
}
if len(l) == 0 {
return fallback
}
// Sort by priority and uncertainty
sort.Sort(l)
// Get best label (at the top)
label := l[0]
// Get second best label in case the first has high uncertainty
if len(l) > 1 && l[0].Uncertainty > 60 && l[1].Uncertainty <= 60 {
label = l[1]
}
switch {
case fallback != "" && label.Priority < 0:
return fallback
case fallback != "" && label.Priority == 0 && label.Uncertainty > 50:
return fallback
case label.Priority >= -1 && label.Uncertainty <= 60:
return label.Name
default:
return fallback
}
}
// IsNSFW reports whether any label marks the asset as "not safe for work"
// (NSFW). The threshold is clamped to [0,100] and checked against
// NSFWConfidence; explicit NSFW flags always trigger a positive result.
func (l Labels) IsNSFW(threshold int) bool {
if l == nil || threshold < 0 {
return false
} else if threshold > 100 {
threshold = 100
}
for _, label := range l {
if label.NSFW || label.NSFWConfidence >= threshold {
return true
}
}
return false
}