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:
Michael Mayer 2019-05-04 11:27:33 +02:00
parent e565195f23
commit e148e7af46
7 changed files with 156 additions and 75 deletions

View file

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

View file

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

View file

@ -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.")
}

View file

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

View file

@ -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 {

View file

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

View file

@ -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()