Search code examples
pythonperformancepython-imaging-libraryled

How do I improve the speed of getting the average pixel color on sections of my screen


I'm working on a project where I get the average rgb value of all the pixels in a section of the screen ex: top_left and bottom_middle. These values are then mapped to pixels on an led strip which acts as a backlight on my monitor.

My problem is that to get the average rgb value of each section it takes about .8 seconds. This makes the backlights look very laggy. Is there any way to quicken the process of getting the rgb values, the code is below.

import serial, time, tkinter as tk, PIL.ImageGrab as ImageGrab


root = tk.Tk()
screen_width = root.winfo_screenwidth()
screen_height = root.winfo_screenheight()
root.destroy()
lastx = 23
class Ordinator:
    """get the average color of 8 boxes on the screen so that I can do less math on arduino"""
    def __init__(self,sw,sh):
        self.grab()
        self.screenW = sw
        self.screenH = sh
        self.top_left = tuple(map(int, (0, 0, 0.33 * self.screenW, 0.33 * self.screenH)))
        self.top_middle = tuple(map(int, (0.33 * self.screenW, 0, 0.66 * self.screenW, 0.33 * self.screenH)))
        self.top_right = tuple(map(int, (0.66 * self.screenW, 0, self.screenW, 0.33 * self.screenH)))
        self.middle_left = tuple(map(int, (0, 0.33 * self.screenH, 0.33 * self.screenW, 0.66 * self.screenH)))
        self.middle_right = tuple(map(int, (0.66 * self.screenW, 0.33 * self.screenH, self.screenW, 0.66 * self.screenH)))
        self.bottom_left = tuple(map(int, (0, 0.66 * self.screenH, 0.33 * self.screenW, self.screenH)))
        self.bottom_middle = tuple(map(int, (0.33 * self.screenW, 0.66 * self.screenH, 0.66 * self.screenW, self.screenH)))
        self.bottom_right = tuple(map(int, (0.66 * self.screenW, 0.66 * self.screenH, self.screenW, self.screenH)))
    #turn get_average_color into a class method
    def grab(self):
        self.pixels = (ImageGrab.grab())
    def get_average_color(self,box):
        """Uses PIL to get the average color of a box on the screen"""
        #get screenshot with all windows that are pulled up not just background
        pixels = (self.pixels.crop(box)).getdata()
        num_pixels = len(pixels)
        r = g = b = 0
        for pixel in pixels:
            r += pixel[0]
            g += pixel[1]
            b += pixel[2]
        
        avg_r, avg_g, avg_b = [x // num_pixels for x in (r, g, b)]
        #if var doesnt have 3 digits add 0s in front
        if len(str(avg_r)) < 3:
            avg_r = str(avg_r).zfill(3)
        if len(str(avg_g)) < 3:
            avg_g = str(avg_g).zfill(3)
        if len(str(avg_b)) < 3:
            avg_b = str(avg_b).zfill(3)
        #return as rgb ex: 255010255
        return str(avg_r) + str(avg_g) + str(avg_b)
    #turn get_all_colors into a class method
    def get_all_colors(self):
        """Returns a string of all the colors in the order of the boxes"""
        self.grab()
        return (self.get_average_color(self.top_right)+self.get_average_color(self.top_middle) +  self.get_average_color(self.top_left) + self.get_average_color(self.middle_left) +self.get_average_color(self.bottom_left) +  self.get_average_color(self.bottom_middle) + self.get_average_color(self.bottom_right) +  self.get_average_color(self.middle_right))
    #example output 255  ffffff|ffffff|ffffff|ffffff|ffffff|ffffff|ffffff|ffffff
    #or 255255255255255255255255255255255255255255255255255255255255255255255255
Ord = Ordinator(screen_width,screen_height)
#ser = serial.Serial('/dev/tty.usbmodem14101', 9600)
while True:
    #time how fast the loop is
    start = time.time()
    x =Ord.get_all_colors()
    if x != lastx:
        #ser.write(x.encode())
        print(x)
    lastx = x
    #print end time in seconds
    print("End:" + str(time.time()- start))
    time.sleep(0.1)

What I've tried so far: Instead of taking 8 screenshots I have 1 screenshot of the screen and I crop from there.


Solution

  • Use high-performance, vectorised Numpy instead of slow, error-prone for loops.

    Code looks like this:

    from PIL import Image
    import numpy as np
    
    # Make an orange 640x480 pixel PIL Image
    im = Image.new('RGB', (640,480), 'orange')
    
    # Convert image to Numpy array
    na = np.array(im)
    
    # Check its shape - Numpy indexes height first
    print(na.shape)           # prints (480, 640, 3)
    
    # Calculate means of each of 3 channels
    means = np.mean(na, axis=(0,1))
    print(means)              # prints array([255., 165.,   0.])
    
    RedMean   = means[0]
    GreenMean = means[1]
    BlueMean  = means[2]
    

    That should be ok, but maybe you need to slice just the left half of the image:

    means = np.mean(na[:,:320], axis=(0,1))
    

    That takes 2.6ms, timed in Python like this:

    %timeit means = np.mean(na, axis=(0,1))
    2.68 ms ± 975 ns per loop (mean ± std. dev. of 7 runs, 100 loops each)