Analyzing the darkest pixels of an image in Python

This script takes an image copied from the clipboard and analyzes the n darkest pixels of the image. It will loop through each found value, prints out the value information and the quantity, then displays the visual location of the pixels in a tkinter window.

There are two modes for reading the pixels. One is by simply looping through the darkest pixels given the default 0-255 value range. The other is by compressing the range to 0-100 to fit the convention of mainstream image-editing programs.

The script seems to work through casual testing, but many parts of the script feels very brittle, awkward and hacked together. A few of my concerns:

• My script finds the darkest pixels, removes them, then finds the next darkest pixels. But my method of 'removal' is by assigning the value of the found pixels to '999'. Is there a better method?

• My method of visually marking the pixels is by looping through the found pixels manually. Is there a faster way?

• Is min(gray.flatten()) a good way to find the darkest pixels?

• Any other inaccuracies regarding image handling or other bad practices

• General performance

import platform
if (platform.system() != "Windows"):
print("Only Windows is supported for now.")
raise SystemExit()

import cv2
import math
import argparse
import numpy as np
import tkinter as tk
import win32clipboard
from io import BytesIO
from PIL import ImageTk, Image, ImageGrab

parser = argparse.ArgumentParser(description='Finds the darkest pixels of a grayscaled image pasted from the clipboard. It will output the value information, quantity and a visual pixel map. The pixel map will be copied to your clipboard.')
parser.add_argument('-n', '--num', dest='num', metavar='NUM', type=int, default=5,
help='The number of darkest values to find. Default=5')
help='Detects values using an accurate 0-255 range, instead of a compressed 0-100 range.')
help='Colorizes detected pixels, instead of drawing a circle around it.')
parser.add_argument('-t', '--threshold', dest='threshold', metavar='VALUE', type=int, default=False,
help='Detects only values lighter or as light as the specified threshold (0-100 range).')
help='Outputs pixel map in the original color, instead of the grayscaled version.')
args = parser.parse_args()

def ordinal(n):
n = int(n)
suffix = ['th', 'st', 'nd', 'rd', 'th'][min(n % 10, 4)]
if 11 <= (n % 100) <= 13:
suffix = 'th'
return str(n) + suffix

def bmp_process(im):
output = BytesIO()
im.save(output, "BMP")
data = output.getvalue()[14:]
output.close()
return data

def clip_send(clip_type, data):
win32clipboard.OpenClipboard()
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardData(clip_type, data)
win32clipboard.CloseClipboard()

def show_img(im, size):
thumb = im.copy()
thumb.thumbnail(size, Image.ANTIALIAS)
window = tk.Tk()
w = size[0]
h = size[1]
ws = window.winfo_screenwidth()
hs = window.winfo_screenheight()
x = (ws/2) - (w/2)
y = (hs/2) - (h/2)
window.geometry('%dx%d+%d+%d' % (w, h, x, y))
img = ImageTk.PhotoImage(thumb)
panel = tk.Label(window, image=img)
panel.pack(side="bottom", fill="both", expand="yes")
window.mainloop()

try:
clip = ImageGrab.grabclipboard().convert('RGB')
clip.copy().verify()
except:
print("Invalid image data!")
raise SystemExit()

gray = cv2.cvtColor(np.array(clip.copy()), cv2.COLOR_RGB2GRAY)

if args.color:
img = cv2.cvtColor(np.array(clip.copy()), cv2.COLOR_RGB2BGR)
else:
img = cv2.cvtColor(gray.copy(), cv2.COLOR_GRAY2BGR)

if args.threshold:
threshold = math.ceil(( args.threshold / 100 ) * 255)
while True:
rounded = int(round((threshold / 255) * 100))
if rounded < args.threshold:
break
threshold -= 1

for i in range(args.num):
raw = min(gray.flatten())
value_f = (raw / 255) * 100
value = int(round(value_f))

cnt = 0
n = raw
marked = img.copy()
while True:
rounded = int(round((n / 255) * 100))
if rounded == value:
points = np.argwhere(gray == n)
for point in points:
if args.pix:
marked[point[0], point[1]] = [0, 0, 255]
else:
cv2.circle(marked, (point[1], point[0]), 5, (0, 0, 255), 2)
cnt += 1
else:
break
n += 1
if args.acc or n > 255:
break

if args.acc:
print("The {0} darkest grayscale value is {1}% ({2}/255 or {3:.2f}%), quantity is {4}".format(str(ordinal(i + 1)), str(value), str(raw), value_f, str(cnt)))
else:
print("The {0} darkest grayscale value is {1}%, quantity is {2}".format(str(ordinal(i + 1)), str(value), str(cnt)))

display = Image.fromarray(cv2.cvtColor(marked, cv2.COLOR_BGR2RGB))
clip_out = bmp_process(display)
clip_send(win32clipboard.CF_DIB, clip_out)
show_img(display, (500, 500))
if n > 255:
raise SystemExit()


List Unpacking

Instead of assigning each variable to an index of the list, you can assign both variables to the list and it will unpack each item of the list to its respective variable.

w, h = size


String Formatting

I personally would rather use f"" since it allows you to visually see where your variables are in the string, instead of remembering which number ({0}, {1}, etc) is associated with which variable.

print(f"The {ordinal(i + 1)} darkest grayscale value is {value}%, quantity is {cnt}.")


Unnecessary Type Conversions

In ordinal, you convert n to an integer. However, you already pass an integer as an argument, so no need to convert. Also, if you want to use f"", you don't need to convert variables to strings before you format them with your strings. You can put the raw values in and python will work all of it out.

Constants

There are a lot of magic numbers in your program. Specifically, 255 and 100 show up a lot. I would define constants that contain these values and use those instead.

I’ll focus solely on the image processing algorithm. You find the lowest value (N comparisons), then find which pixels have this value (another N comparisons), and repeat this until you have K values. So you do about 2NK comparisons.

This is OK for small K, but as it gets larger, this becomes a very inefficient algorithm.

Instead, you could sort all pixels (np.argsort returns the indices to the sorted pixels). Since the pixel values are integers, you can use counting sort, which takes about 2N operations, but even the general quicksort would be OK to use.

Next, finding the next lowest pixel is a single operation. So your algorithm goes from about 2NK operations to 2N+K, a huge saving for larger K.