Identify uniform colors in pictures in Python

Question:

I have a Python code that identifies dark images:

import os
import glob
import cv2
import numpy as np

def isbright(image, dim=10, thresh=0.16):
    # Resize image to 10x10
    image = cv2.resize(image, (dim, dim))
    # Convert color space to LAB format and extract L channel
    L, A, B = cv2.split(cv2.cvtColor(image, cv2.COLOR_BGR2LAB))
    # Normalize L channel by dividing all pixel values with maximum pixel value
    L = L/np.max(L)
    # Return True if mean is greater than thresh else False
    return np.mean(L) > thresh

# create output directories if not exists
os.makedirs("output/bright", exist_ok=True)
os.makedirs("output/dark", exist_ok=True)

# iterate through images directory
for i, path in enumerate(os.listdir(os.path.abspath(''))):
    # load image from path
    image = cv2.imread(path)

    # find if image is bright or dark
    path = os.path.basename(path)
    text = "bright" if isbright(image) else "dark"

    # save image to disk
    cv2.imwrite("output/{}/{}".format(text, path), image)
    print(path, "=>", text)

I’d like to also identify, for example, mostly red images, mostly yellow images and so on. Basically mostly uniform colors in pictures keeping the same code structure, more or less. How would you guys do it?

Samples:

JPG Sample1

JPG Sample

JPG Sample3

Asked By: ANRIOS2020

||

Answers:

I had a try at this by converting to HSV colourspace and looking for the 6 vertices of the HSV Hue wheel, namely red, yellow, green, cyan, blue and magenta as identified in the lower part of this diagram from the previously linked page.

I convert the input image to HSV colourspace and use cv2.inRange() to find each of the 6 colours. There are two things to note:

  • OpenCV uses a range of 0..180 for Hue so that it fits into a np.uint8 so you need to halve all the values in the conventional 0..360 Hue wheel. I mean green shows as 120 in the conventional wheel, but I use 60 in the code.

  • the red hues straddle the boundary around zero degrees, i.e. 170..179..0..10 and this makes for code where you have to coalesce two ranges 170..180 and 0..10. Rather than doing that, when looking for red, I invert the image and look for cyan.

I then count the pixels selected by that hue and show as an absolute number and as a percentage.

#!/usr/bin/env python3

import numpy as np
import glob
import cv2

colours = {     
      'reds':     (90,  True),  # Hue angle and whether to invert
      'yellows':  (30,  False),
      'greens':   (60,  False),
      'cyans':    (90,  False),
      'blues':    (120, False),
      'magentas': (150, False)
   }

def process(filename):
   print(f'Image: {filename}')
   im = cv2.imread(filename)
   total = im.shape[0] * im.shape[1]   # total pixels in image

   hueTolerance = 20
   for name, params in colours.items():
      hueAngle, invert = params
      if invert:
         hsv = cv2.cvtColor(255-im, cv2.COLOR_BGR2HSV)
      else:
         hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)

      # Set low and high limit for this colour
      lo = np.uint8([hueAngle-hueTolerance,10,0])
      hi = np.uint8([hueAngle+hueTolerance,255,255])

      # Get in range pixels and count them
      inRange = cv2.inRange(hsv,lo,hi)
      N = cv2.countNonZero(inRange)
      percent = (N * 100)/ total
      print(f'   {name}: {N}/{total} ({percent:.1f})')

def main():
   for image in glob.glob('mostly-*'):
      process(image)

if __name__ == "__main__":
   main()

For your three images, which I saved as mostly-red.jpg, mostly-orange.jpg and mostly-yellow.jpg, I get these results:

Image: mostly-yellow.jpg
   reds: 152901/172800 (88.5)
   yellows: 147459/172800 (85.3)
   greens: 0/172800 (0.0)
   cyans: 0/172800 (0.0)
   blues: 16476/172800 (9.5)
   magentas: 9335/172800 (5.4)
Image: mostly-red.jpg
   reds: 172800/172800 (100.0)
   yellows: 0/172800 (0.0)
   greens: 0/172800 (0.0)
   cyans: 0/172800 (0.0)
   blues: 0/172800 (0.0)
   magentas: 0/172800 (0.0)
Image: mostly-orange.jpg
   reds: 172800/172800 (100.0)
   yellows: 18749/172800 (10.9)
   greens: 0/172800 (0.0)
   cyans: 0/172800 (0.0)
   blues: 0/172800 (0.0)
   magentas: 0/172800 (0.0)

Note that you didn’t answer when I asked what colours you are looking for so I didn’t add "orange" to the list.

Note that there is a tolerance on each colour, so the percentages will not sum to 100, because some shades say of orange could get counted as looking like both red and yellow. You can define the tolerance more closely and increase the number of different colours you are looking for in the list of colours at the start.

Answered By: Mark Setchell

Getting the average color of an image is almost trivial – if you resize it to a single pixel with INTER_AREA, the color of that pixel will be the overall average. Then all you need is a way to tell how closely the image colors map to that average. A good way to do that is to calculate the variance with the mean squared error (MSE). Setting an appropriate threshold will be a matter of trial and error.

def average_color(image):
    ''' Return the average color (R,G,B) of the image and the variance from that average.
    '''
    bgr = cv2.resize(image, (1,1), interpolation=cv2.INTER_AREA)[0,0]
    mse = ((image.astype(np.float64) - bgr) ** 2).mean()
    return (bgr[2], bgr[1], bgr[0]), mse

This function will work with either a full size image or a resized one as you used in your example, although the variance will be different. I’d advise using interpolation=cv2.INTER_AREA in your resize too.

For your 3 test images, it returned the following:

((231, 64, 72), 387.9535165895062)
((238, 100, 55), 834.753994984568)
((196, 153, 86), 2236.9471739969135)

You didn’t specify how you wanted to classify the color, so I came up with a way to do that too. This code maps an RGB value to the nearest of the CSS color names. You can substitute your own table of color names, as long or as short as you want.

colors = {'AliceBlue': (240, 248, 255),
 'AntiqueWhite': (250, 235, 215),
 'Aqua': (0, 255, 255),
 'Aquamarine': (127, 255, 212),
 'Azure': (240, 255, 255),
 'Beige': (245, 245, 220),
 'Bisque': (255, 228, 196),
 'Black': (0, 0, 0),
 'BlanchedAlmond': (255, 235, 205),
 'Blue': (0, 0, 255),
 'BlueViolet': (138, 43, 226),
 'Brown': (165, 42, 42),
 'BurlyWood': (222, 184, 135),
 'CadetBlue': (95, 158, 160),
 'Chartreuse': (127, 255, 0),
 'Chocolate': (210, 105, 30),
 'Coral': (255, 127, 80),
 'CornflowerBlue': (100, 149, 237),
 'Cornsilk': (255, 248, 220),
 'Crimson': (220, 20, 60),
 'Cyan': (0, 255, 255),
 'DarkBlue': (0, 0, 139),
 'DarkCyan': (0, 139, 139),
 'DarkGoldenRod': (184, 134, 11),
 'DarkGray': (169, 169, 169),
 'DarkGreen': (0, 100, 0),
 'DarkGrey': (169, 169, 169),
 'DarkKhaki': (189, 183, 107),
 'DarkMagenta': (139, 0, 139),
 'DarkOliveGreen': (85, 107, 47),
 'DarkOrange': (255, 140, 0),
 'DarkOrchid': (153, 50, 204),
 'DarkRed': (139, 0, 0),
 'DarkSalmon': (233, 150, 122),
 'DarkSeaGreen': (143, 188, 143),
 'DarkSlateBlue': (72, 61, 139),
 'DarkSlateGray': (47, 79, 79),
 'DarkSlateGrey': (47, 79, 79),
 'DarkTurquoise': (0, 206, 209),
 'DarkViolet': (148, 0, 211),
 'DeepPink': (255, 20, 147),
 'DeepSkyBlue': (0, 191, 255),
 'DimGray': (105, 105, 105),
 'DimGrey': (105, 105, 105),
 'DodgerBlue': (30, 144, 255),
 'FireBrick': (178, 34, 34),
 'FloralWhite': (255, 250, 240),
 'ForestGreen': (34, 139, 34),
 'Fuchsia': (255, 0, 255),
 'Gainsboro': (220, 220, 220),
 'GhostWhite': (248, 248, 255),
 'Gold': (255, 215, 0),
 'GoldenRod': (218, 165, 32),
 'Gray': (128, 128, 128),
 'Green': (0, 128, 0),
 'GreenYellow': (173, 255, 47),
 'Grey': (128, 128, 128),
 'HoneyDew': (240, 255, 240),
 'HotPink': (255, 105, 180),
 'IndianRed ': (205, 92, 92),
 'Indigo  ': (75, 0, 130),
 'Ivory': (255, 255, 240),
 'Khaki': (240, 230, 140),
 'Lavender': (230, 230, 250),
 'LavenderBlush': (255, 240, 245),
 'LawnGreen': (124, 252, 0),
 'LemonChiffon': (255, 250, 205),
 'LightBlue': (173, 216, 230),
 'LightCoral': (240, 128, 128),
 'LightCyan': (224, 255, 255),
 'LightGoldenRodYellow': (250, 250, 210),
 'LightGray': (211, 211, 211),
 'LightGreen': (144, 238, 144),
 'LightGrey': (211, 211, 211),
 'LightPink': (255, 182, 193),
 'LightSalmon': (255, 160, 122),
 'LightSeaGreen': (32, 178, 170),
 'LightSkyBlue': (135, 206, 250),
 'LightSlateGray': (119, 136, 153),
 'LightSlateGrey': (119, 136, 153),
 'LightSteelBlue': (176, 196, 222),
 'LightYellow': (255, 255, 224),
 'Lime': (0, 255, 0),
 'LimeGreen': (50, 205, 50),
 'Linen': (250, 240, 230),
 'Magenta': (255, 0, 255),
 'Maroon': (128, 0, 0),
 'MediumAquaMarine': (102, 205, 170),
 'MediumBlue': (0, 0, 205),
 'MediumOrchid': (186, 85, 211),
 'MediumPurple': (147, 112, 219),
 'MediumSeaGreen': (60, 179, 113),
 'MediumSlateBlue': (123, 104, 238),
 'MediumSpringGreen': (0, 250, 154),
 'MediumTurquoise': (72, 209, 204),
 'MediumVioletRed': (199, 21, 133),
 'MidnightBlue': (25, 25, 112),
 'MintCream': (245, 255, 250),
 'MistyRose': (255, 228, 225),
 'Moccasin': (255, 228, 181),
 'NavajoWhite': (255, 222, 173),
 'Navy': (0, 0, 128),
 'OldLace': (253, 245, 230),
 'Olive': (128, 128, 0),
 'OliveDrab': (107, 142, 35),
 'Orange': (255, 165, 0),
 'OrangeRed': (255, 69, 0),
 'Orchid': (218, 112, 214),
 'PaleGoldenRod': (238, 232, 170),
 'PaleGreen': (152, 251, 152),
 'PaleTurquoise': (175, 238, 238),
 'PaleVioletRed': (219, 112, 147),
 'PapayaWhip': (255, 239, 213),
 'PeachPuff': (255, 218, 185),
 'Peru': (205, 133, 63),
 'Pink': (255, 192, 203),
 'Plum': (221, 160, 221),
 'PowderBlue': (176, 224, 230),
 'Purple': (128, 0, 128),
 'RebeccaPurple': (102, 51, 153),
 'Red': (255, 0, 0),
 'RosyBrown': (188, 143, 143),
 'RoyalBlue': (65, 105, 225),
 'SaddleBrown': (139, 69, 19),
 'Salmon': (250, 128, 114),
 'SandyBrown': (244, 164, 96),
 'SeaGreen': (46, 139, 87),
 'SeaShell': (255, 245, 238),
 'Sienna': (160, 82, 45),
 'Silver': (192, 192, 192),
 'SkyBlue': (135, 206, 235),
 'SlateBlue': (106, 90, 205),
 'SlateGray': (112, 128, 144),
 'SlateGrey': (112, 128, 144),
 'Snow': (255, 250, 250),
 'SpringGreen': (0, 255, 127),
 'SteelBlue': (70, 130, 180),
 'Tan': (210, 180, 140),
 'Teal': (0, 128, 128),
 'Thistle': (216, 191, 216),
 'Tomato': (255, 99, 71),
 'Turquoise': (64, 224, 208),
 'Violet': (238, 130, 238),
 'Wheat': (245, 222, 179),
 'White': (255, 255, 255),
 'WhiteSmoke': (245, 245, 245),
 'Yellow': (255, 255, 0),
 'YellowGreen': (154, 205, 50)}
def nearest_lab(rgb):
    def to_lab(color):
        return cv2.cvtColor(np.array([[[color[i]/255 for i in range(3)]]], dtype=np.float32), cv2.COLOR_RGB2LAB)[0,0]
    lab = to_lab(rgb)
    def dist_squared(item):
        lab2 = to_lab(item[1])
        return sum((lab[i] - lab2[i]) ** 2 for i in range(3))
    return min(colors.items(), key=dist_squared)[0]

Here are the results of that lookup for your 3 test images:

enter image description here

enter image description here

enter image description here


I had second thoughts about using the CSS colors. If you know anything about their history, you know that most of the colors were adapted from X11 which was a product of MIT. The colors and their names in X11 were chosen by a small number of people, and I don’t know how much emphasis was placed on their practicality. Who, when seeing that light brown, thinks "Peru"?

I decided to try an alternate color table. Randall Munroe of xkcd crowd-sourced a list of color names and released it to the public at https://xkcd.com/color/rgb/. It’s a much larger list than CSS, and since it required lots of people agreeing on the name of a color there’s a chance it would be more useful. The code for the new color table is too long to put in this answer, but if you’re interested leave a comment and I’ll find a way to get it to you.

Here are the updated results again for the 3 test images. I don’t think the first 2 really improved at all, but I was impressed by the third – it’s almost dead on.

enter image description here

enter image description here

enter image description here

Answered By: Mark Ransom
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.