From e148e7af464339396419aa2dba595a313c7a602c Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Sat, 4 May 2019 11:27:33 +0200 Subject: [PATCH] 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. --- internal/photoprism/colors.go | 72 ++++++++------ internal/photoprism/colors_test.go | 126 +++++++++++++++++++------ internal/photoprism/exif_test.go | 4 +- internal/photoprism/importer_test.go | 2 +- internal/photoprism/indexer.go | 15 ++- internal/photoprism/mediafile_test.go | 8 +- internal/photoprism/thumbnails_test.go | 4 +- 7 files changed, 156 insertions(+), 75 deletions(-) diff --git a/internal/photoprism/colors.go b/internal/photoprism/colors.go index 137a4f188..cccc2ec0c 100644 --- a/internal/photoprism/colors.go +++ b/internal/photoprism/colors.go @@ -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 } diff --git a/internal/photoprism/colors_test.go b/internal/photoprism/colors_test.go index c5cf4b037..d74eb6d6d 100644 --- a/internal/photoprism/colors_test.go +++ b/internal/photoprism/colors_test.go @@ -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) } diff --git a/internal/photoprism/exif_test.go b/internal/photoprism/exif_test.go index fbcc99c17..baa4b75e0 100644 --- a/internal/photoprism/exif_test.go +++ b/internal/photoprism/exif_test.go @@ -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.") } diff --git a/internal/photoprism/importer_test.go b/internal/photoprism/importer_test.go index 993230f84..8be9dd37f 100644 --- a/internal/photoprism/importer_test.go +++ b/internal/photoprism/importer_test.go @@ -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) diff --git a/internal/photoprism/indexer.go b/internal/photoprism/indexer.go index 99518e4bb..d15f9e1ae 100644 --- a/internal/photoprism/indexer.go +++ b/internal/photoprism/indexer.go @@ -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 { diff --git a/internal/photoprism/mediafile_test.go b/internal/photoprism/mediafile_test.go index 6fd4d0c17..600ee767b 100644 --- a/internal/photoprism/mediafile_test.go +++ b/internal/photoprism/mediafile_test.go @@ -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) diff --git a/internal/photoprism/thumbnails_test.go b/internal/photoprism/thumbnails_test.go index 376d7fe55..6dbddec70 100644 --- a/internal/photoprism/thumbnails_test.go +++ b/internal/photoprism/thumbnails_test.go @@ -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()