Add ColorPerception struct and improve Color() tests, see #7
Since we're not using the very same colors as material design anymore, MaterialColor was renamed to IndexedColor.
This commit is contained in:
parent
e565195f23
commit
e148e7af46
7 changed files with 156 additions and 75 deletions
|
@ -1,6 +1,7 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
|
@ -12,8 +13,15 @@ import (
|
|||
"github.com/lucasb-eyer/go-colorful"
|
||||
)
|
||||
|
||||
type MaterialColor uint16
|
||||
type MaterialColors []MaterialColor
|
||||
type ColorPerception struct {
|
||||
Colors IndexedColors
|
||||
MainColor IndexedColor
|
||||
Luminance LightMap
|
||||
Saturation Saturation
|
||||
}
|
||||
|
||||
type IndexedColor uint16
|
||||
type IndexedColors []IndexedColor
|
||||
|
||||
type Saturation uint8
|
||||
type Luminance uint8
|
||||
|
@ -22,7 +30,7 @@ type LightMap []Luminance
|
|||
const ColorSampleSize = 3
|
||||
|
||||
const (
|
||||
Black MaterialColor = iota
|
||||
Black IndexedColor = iota
|
||||
Brown
|
||||
Grey
|
||||
White
|
||||
|
@ -40,7 +48,7 @@ const (
|
|||
Pink
|
||||
)
|
||||
|
||||
var materialColorNames = map[MaterialColor]string{
|
||||
var IndexedColorNames = map[IndexedColor]string{
|
||||
Black: "black", // 0
|
||||
Brown: "brown", // 1
|
||||
Grey: "grey", // 2
|
||||
|
@ -59,7 +67,7 @@ var materialColorNames = map[MaterialColor]string{
|
|||
Pink: "pink", // F
|
||||
}
|
||||
|
||||
var materialColorWeight = map[MaterialColor]uint16{
|
||||
var IndexedColorWeight = map[IndexedColor]uint16{
|
||||
Black: 2,
|
||||
Brown: 1,
|
||||
Grey: 2,
|
||||
|
@ -78,15 +86,15 @@ var materialColorWeight = map[MaterialColor]uint16{
|
|||
Pink: 5,
|
||||
}
|
||||
|
||||
func (c MaterialColor) Name() string {
|
||||
return materialColorNames[c]
|
||||
func (c IndexedColor) Name() string {
|
||||
return IndexedColorNames[c]
|
||||
}
|
||||
|
||||
func (c MaterialColor) Hex() string {
|
||||
func (c IndexedColor) Hex() string {
|
||||
return fmt.Sprintf("%X", c)
|
||||
}
|
||||
|
||||
func (c MaterialColors) Hex() (result string) {
|
||||
func (c IndexedColors) Hex() (result string) {
|
||||
for _, materialColor := range c {
|
||||
result += materialColor.Hex()
|
||||
}
|
||||
|
@ -118,7 +126,7 @@ func (m LightMap) Hex() (result string) {
|
|||
return result
|
||||
}
|
||||
|
||||
var materialColorMap = map[color.RGBA]MaterialColor{
|
||||
var IndexedColorMap = map[color.RGBA]IndexedColor{
|
||||
{0x00, 0x00, 0x00, 0xff}: Black,
|
||||
{0x79, 0x55, 0x48, 0xff}: Brown,
|
||||
{0x9E, 0x9E, 0x9E, 0xff}: Grey,
|
||||
|
@ -137,16 +145,16 @@ var materialColorMap = map[color.RGBA]MaterialColor{
|
|||
{0xe9, 0x1e, 0x63, 0xff}: Pink,
|
||||
}
|
||||
|
||||
func colorfulToMaterialColor(actualColor colorful.Color) (result MaterialColor) {
|
||||
func ColorfulToIndexedColor(actualColor colorful.Color) (result IndexedColor) {
|
||||
var distance = 1.0
|
||||
|
||||
for colorRGBA, materialColor := range materialColorMap {
|
||||
colorColorful, _ := colorful.MakeColor(colorRGBA)
|
||||
for rgba, i := range IndexedColorMap {
|
||||
colorColorful, _ := colorful.MakeColor(rgba)
|
||||
currentDistance := colorColorful.DistanceLab(actualColor)
|
||||
|
||||
if distance >= currentDistance {
|
||||
distance = currentDistance
|
||||
result = materialColor
|
||||
result = i
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -170,13 +178,17 @@ func (m *MediaFile) Resize(width, height int) (result *image.NRGBA, err error) {
|
|||
}
|
||||
|
||||
// Colors returns color information for a media file.
|
||||
func (m *MediaFile) Colors() (colors MaterialColors, mainColor MaterialColor, luminance LightMap, saturation Saturation, err error) {
|
||||
func (m *MediaFile) Colors() (perception ColorPerception, err error) {
|
||||
if !m.IsJpeg() {
|
||||
return perception, errors.New("no color information: not a JPEG file")
|
||||
}
|
||||
|
||||
img, err := m.Resize(ColorSampleSize, ColorSampleSize)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("can't open image: %s", err.Error())
|
||||
|
||||
return colors, mainColor, luminance, saturation, err
|
||||
return perception, err
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
|
@ -184,36 +196,36 @@ func (m *MediaFile) Colors() (colors MaterialColors, mainColor MaterialColor, lu
|
|||
pixels := float64(width * height)
|
||||
saturationSum := 0.0
|
||||
|
||||
colorCount := make(map[MaterialColor]uint16)
|
||||
colorCount := make(map[IndexedColor]uint16)
|
||||
var mainColorCount uint16
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
r, g, b, a := img.At(x, y).RGBA()
|
||||
rgbColor, _ := colorful.MakeColor(color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: uint8(a)})
|
||||
materialColor := colorfulToMaterialColor(rgbColor)
|
||||
colors = append(colors, materialColor)
|
||||
rgb, _ := colorful.MakeColor(color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: uint8(a)})
|
||||
i := ColorfulToIndexedColor(rgb)
|
||||
perception.Colors = append(perception.Colors, i)
|
||||
|
||||
if _, ok := colorCount[materialColor]; ok == true {
|
||||
colorCount[materialColor] += materialColorWeight[materialColor]
|
||||
if _, ok := colorCount[i]; ok == true {
|
||||
colorCount[i] += IndexedColorWeight[i]
|
||||
} else {
|
||||
colorCount[materialColor] = materialColorWeight[materialColor]
|
||||
colorCount[i] = IndexedColorWeight[i]
|
||||
}
|
||||
|
||||
if colorCount[materialColor] > mainColorCount {
|
||||
mainColorCount = colorCount[materialColor]
|
||||
mainColor = materialColor
|
||||
if colorCount[i] > mainColorCount {
|
||||
mainColorCount = colorCount[i]
|
||||
perception.MainColor = i
|
||||
}
|
||||
|
||||
_, s, l := rgbColor.Hsl()
|
||||
_, s, l := rgb.Hsl()
|
||||
|
||||
saturationSum += s
|
||||
|
||||
luminance = append(luminance, Luminance(math.Round(l*16)))
|
||||
perception.Luminance = append(perception.Luminance, Luminance(math.Round(l * 15)))
|
||||
}
|
||||
}
|
||||
|
||||
saturation = Saturation(math.Ceil((saturationSum / pixels) * 16))
|
||||
perception.Saturation = Saturation(math.Round((saturationSum / pixels) * 15))
|
||||
|
||||
return colors, mainColor, luminance, saturation, nil
|
||||
return perception, nil
|
||||
}
|
||||
|
|
|
@ -1,29 +1,101 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/context"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMediaFile_GetColors(t *testing.T) {
|
||||
func TestMediaFile_Colors_Testdata (t *testing.T) {
|
||||
/*
|
||||
TODO: Add and compare other images in "testdata/"
|
||||
*/
|
||||
expected := map[string]ColorPerception{
|
||||
"testdata/sharks_blue.jpg": {
|
||||
Colors: IndexedColors{0x6, 0x6, 0x5, 0x4, 0x4, 0x4, 0x4, 0x4, 0x4},
|
||||
MainColor: 4,
|
||||
Luminance: LightMap{0x9, 0x8, 0x7, 0x6, 0x6, 0x6, 0x5, 0x5, 0x5},
|
||||
Saturation: 14,
|
||||
},
|
||||
"testdata/cat_black.jpg": {
|
||||
Colors: IndexedColors{0x1, 0x2, 0x2, 0x2, 0x1, 0x1, 0x3, 0x2, 0x2},
|
||||
MainColor: 2,
|
||||
Luminance: LightMap{0x8, 0x8, 0x8, 0x8, 0x4, 0x6, 0xd, 0xc, 0x8},
|
||||
Saturation: 2,
|
||||
},
|
||||
"testdata/cat_brown.jpg": {
|
||||
Colors: IndexedColors{0x1, 0x2, 0x2, 0x1, 0x2, 0x1, 0x1, 0x1, 0x1},
|
||||
MainColor: 2,
|
||||
Luminance: LightMap{0x5, 0x9, 0x8, 0x7, 0xb, 0x7, 0x3, 0x6, 0x7},
|
||||
Saturation: 2,
|
||||
},
|
||||
"testdata/cat_yellow_grey.jpg": {
|
||||
Colors: IndexedColors{0x1, 0x1, 0x2, 0x1, 0x2, 0x2, 0x1, 0x1, 0xa},
|
||||
MainColor: 2,
|
||||
Luminance: LightMap{0x5, 0x6, 0x8, 0x6, 0x8, 0x8, 0x5, 0x5, 0x6},
|
||||
Saturation: 4,
|
||||
},
|
||||
}
|
||||
|
||||
err := filepath.Walk("testdata", func(filename string, fileInfo os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() || strings.HasPrefix(filepath.Base(filename), ".") {
|
||||
return nil
|
||||
}
|
||||
|
||||
mediaFile, err := NewMediaFile(filename)
|
||||
|
||||
if err != nil || !mediaFile.IsJpeg() {
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Run(filename, func(t *testing.T) {
|
||||
p, err := mediaFile.Colors()
|
||||
|
||||
t.Log(p, err)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, p.Saturation.Int() >= 0)
|
||||
assert.True(t, p.Saturation.Int() < 16)
|
||||
assert.NotEmpty(t, p.MainColor.Name())
|
||||
|
||||
if e, ok := expected[filename]; ok {
|
||||
assert.Equal(t, e, p)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Log(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaFile_Colors(t *testing.T) {
|
||||
ctx := context.TestContext()
|
||||
|
||||
ctx.InitializeTestData(t)
|
||||
|
||||
t.Run("dog.jpg", func(t *testing.T) {
|
||||
if mediaFile, err := NewMediaFile(ctx.ImportPath() + "/dog.jpg"); err == nil {
|
||||
colors, main, l, s, err := mediaFile.Colors()
|
||||
p, err := mediaFile.Colors()
|
||||
|
||||
t.Log(colors, main, l, s, err)
|
||||
t.Log(p, err)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 3, s.Int())
|
||||
assert.IsType(t, MaterialColors{}, colors)
|
||||
assert.Equal(t, "grey", main.Name())
|
||||
assert.Equal(t, MaterialColors{0x1, 0x2, 0x1, 0x2, 0x2, 0x1, 0x1, 0x1, 0x0}, colors)
|
||||
assert.Equal(t, LightMap{5, 9, 7, 10, 9, 5, 5, 6, 2}, l)
|
||||
assert.Equal(t, 2, p.Saturation.Int())
|
||||
assert.IsType(t, IndexedColors{}, p.Colors)
|
||||
assert.Equal(t, "grey", p.MainColor.Name())
|
||||
assert.Equal(t, IndexedColors{0x1, 0x2, 0x1, 0x2, 0x2, 0x1, 0x1, 0x1, 0x0}, p.Colors)
|
||||
assert.Equal(t, LightMap{0x5, 0x9, 0x7, 0xa, 0x8, 0x5, 0x5, 0x6, 0x2}, p.Luminance)
|
||||
} else {
|
||||
t.Error(err)
|
||||
}
|
||||
|
@ -31,16 +103,16 @@ func TestMediaFile_GetColors(t *testing.T) {
|
|||
|
||||
t.Run("ape.jpeg", func(t *testing.T) {
|
||||
if mediaFile, err := NewMediaFile(ctx.ImportPath() + "/ape.jpeg"); err == nil {
|
||||
colors, main, l, s, err := mediaFile.Colors()
|
||||
p, err := mediaFile.Colors()
|
||||
|
||||
t.Log(colors, main, l, s, err)
|
||||
t.Log(p, err)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 3, s.Int())
|
||||
assert.IsType(t, MaterialColors{}, colors)
|
||||
assert.Equal(t, "teal", main.Name())
|
||||
assert.Equal(t, MaterialColors{0x8, 0x8, 0x2, 0x8, 0x2, 0x1, 0x8, 0x1, 0x2}, colors)
|
||||
assert.Equal(t, LightMap{8, 8, 7, 7, 7, 5, 8, 6, 8}, l)
|
||||
assert.Equal(t, 2, p.Saturation.Int())
|
||||
assert.IsType(t, IndexedColors{}, p.Colors)
|
||||
assert.Equal(t, "teal", p.MainColor.Name())
|
||||
assert.Equal(t, IndexedColors{0x8, 0x8, 0x2, 0x8, 0x2, 0x1, 0x8, 0x1, 0x2}, p.Colors)
|
||||
assert.Equal(t, LightMap{0x7, 0x7, 0x6, 0x7, 0x7, 0x5, 0x7, 0x6, 0x8}, p.Luminance)
|
||||
} else {
|
||||
t.Error(err)
|
||||
}
|
||||
|
@ -48,15 +120,15 @@ func TestMediaFile_GetColors(t *testing.T) {
|
|||
|
||||
t.Run("iphone/IMG_6788.JPG", func(t *testing.T) {
|
||||
if mediaFile, err := NewMediaFile(ctx.ImportPath() + "/iphone/IMG_6788.JPG"); err == nil {
|
||||
colors, main, l, s, err := mediaFile.Colors()
|
||||
p, err := mediaFile.Colors()
|
||||
|
||||
t.Log(colors, main, l, s, err)
|
||||
t.Log(p, err)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 3, s.Int())
|
||||
assert.IsType(t, MaterialColors{}, colors)
|
||||
assert.Equal(t, "grey", main.Name())
|
||||
assert.Equal(t, MaterialColors{0x2, 0x1, 0x2, 0x1, 0x1, 0x1, 0x2, 0x1, 0x2}, colors)
|
||||
assert.Equal(t, 2, p.Saturation.Int())
|
||||
assert.IsType(t, IndexedColors{}, p.Colors)
|
||||
assert.Equal(t, "grey", p.MainColor.Name())
|
||||
assert.Equal(t, IndexedColors{0x2, 0x1, 0x2, 0x1, 0x1, 0x1, 0x2, 0x1, 0x2}, p.Colors)
|
||||
} else {
|
||||
t.Error(err)
|
||||
}
|
||||
|
@ -64,16 +136,16 @@ func TestMediaFile_GetColors(t *testing.T) {
|
|||
|
||||
t.Run("raw/20140717_154212_1EC48F8489.jpg", func(t *testing.T) {
|
||||
if mediaFile, err := NewMediaFile(ctx.ImportPath() + "/raw/20140717_154212_1EC48F8489.jpg"); err == nil {
|
||||
colors, main, l, s, err := mediaFile.Colors()
|
||||
p, err := mediaFile.Colors()
|
||||
|
||||
t.Log(colors, main, l, s, err)
|
||||
t.Log(p, err)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 2, s.Int())
|
||||
assert.IsType(t, MaterialColors{}, colors)
|
||||
assert.Equal(t, "grey", main.Name())
|
||||
assert.Equal(t, 2, p.Saturation.Int())
|
||||
assert.IsType(t, IndexedColors{}, p.Colors)
|
||||
assert.Equal(t, "grey", p.MainColor.Name())
|
||||
|
||||
assert.Equal(t, MaterialColors{0x3, 0x2, 0x2, 0x1, 0x2, 0x2, 0x2, 0x2, 0x1}, colors)
|
||||
assert.Equal(t, IndexedColors{0x3, 0x2, 0x2, 0x1, 0x2, 0x2, 0x2, 0x2, 0x1}, p.Colors)
|
||||
} else {
|
||||
t.Error(err)
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMediaFile_GetExifData(t *testing.T) {
|
||||
func TestMediaFile_ExifData(t *testing.T) {
|
||||
ctx := context.TestContext()
|
||||
|
||||
ctx.InitializeTestData(t)
|
||||
|
@ -25,7 +25,7 @@ func TestMediaFile_GetExifData(t *testing.T) {
|
|||
assert.Equal(t, "iPhone SE", info.CameraModel)
|
||||
}
|
||||
|
||||
func TestMediaFile_GetExifData_Slow(t *testing.T) {
|
||||
func TestMediaFile_ExifData_Slow(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode.")
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ func TestNewImporter(t *testing.T) {
|
|||
assert.IsType(t, &Importer{}, importer)
|
||||
}
|
||||
|
||||
func TestImporter_GetDestinationFilename(t *testing.T) {
|
||||
func TestImporter_DestinationFilename(t *testing.T) {
|
||||
ctx := context.TestContext()
|
||||
|
||||
ctx.InitializeTestData(t)
|
||||
|
|
|
@ -200,15 +200,12 @@ func (i *Indexer) indexMediaFile(mediaFile *MediaFile) string {
|
|||
file.FileMime = mediaFile.MimeType()
|
||||
file.FileOrientation = mediaFile.Orientation()
|
||||
|
||||
// Perceptual Hash
|
||||
if mediaFile.IsJpeg() {
|
||||
// PhotoColors
|
||||
c, mc, l, s, _ := mediaFile.Colors()
|
||||
|
||||
file.FileMainColor = mc.Name()
|
||||
file.FileColors = c.Hex()
|
||||
file.FileLuminance = l.Hex()
|
||||
file.FileSaturation = s.Uint()
|
||||
// Color information
|
||||
if p, err := mediaFile.Colors(); err == nil {
|
||||
file.FileMainColor = p.MainColor.Name()
|
||||
file.FileColors = p.Colors.Hex()
|
||||
file.FileLuminance = p.Luminance.Hex()
|
||||
file.FileSaturation = p.Saturation.Uint()
|
||||
}
|
||||
|
||||
if mediaFile.Width() > 0 && mediaFile.Height() > 0 {
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMediaFile_GetRelatedFiles(t *testing.T) {
|
||||
func TestMediaFile_RelatedFiles(t *testing.T) {
|
||||
ctx := context.TestContext()
|
||||
|
||||
ctx.InitializeTestData(t)
|
||||
|
@ -37,7 +37,7 @@ func TestMediaFile_GetRelatedFiles(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMediaFile_GetRelatedFiles_Ordering(t *testing.T) {
|
||||
func TestMediaFile_RelatedFiles_Ordering(t *testing.T) {
|
||||
ctx := context.TestContext()
|
||||
|
||||
ctx.InitializeTestData(t)
|
||||
|
@ -58,7 +58,7 @@ func TestMediaFile_GetRelatedFiles_Ordering(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMediaFile_GetEditedFilename(t *testing.T) {
|
||||
func TestMediaFile_EditedFilename(t *testing.T) {
|
||||
ctx := context.TestContext()
|
||||
|
||||
ctx.InitializeTestData(t)
|
||||
|
@ -78,7 +78,7 @@ func TestMediaFile_GetEditedFilename(t *testing.T) {
|
|||
assert.Equal(t, "", mediaFile3.EditedFilename())
|
||||
}
|
||||
|
||||
func TestMediaFile_GetMimeType(t *testing.T) {
|
||||
func TestMediaFile_MimeType(t *testing.T) {
|
||||
ctx := context.TestContext()
|
||||
|
||||
ctx.InitializeTestData(t)
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMediaFile_GetThumbnail(t *testing.T) {
|
||||
func TestMediaFile_Thumbnail(t *testing.T) {
|
||||
ctx := context.TestContext()
|
||||
|
||||
ctx.CreateDirectories()
|
||||
|
@ -24,7 +24,7 @@ func TestMediaFile_GetThumbnail(t *testing.T) {
|
|||
assert.IsType(t, &MediaFile{}, thumbnail1)
|
||||
}
|
||||
|
||||
func TestMediaFile_GetSquareThumbnail(t *testing.T) {
|
||||
func TestMediaFile_SquareThumbnail(t *testing.T) {
|
||||
ctx := context.TestContext()
|
||||
|
||||
ctx.CreateDirectories()
|
||||
|
|
Loading…
Reference in a new issue