Recoloring my sketches with Python

| supernote, drawing

The SuperNote lets me draw with black, dark gray (0x9d), gray (0xc9), or white. I wanted to make it easy to recolor them, since a little splash of colour makes sketches more fun and also makes them easier to pick out from thumbnails. Here's the Python script I wrote:

Download recolor.py
#!/usr/bin/python3
# Recolor PNGs
#
# (c) 2022 Sacha Chua (sacha@sachachua.com) - MIT License
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:

# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import numpy as np
import sys
import os
import csv
import argparse
from PIL import Image

DARK_GRAY = 0x9d
GRAY = 0xc9
WHITE = 0xfe

parser = argparse.ArgumentParser(description='Recolor a PNG.',
                                 formatter_class=argparse.RawTextHelpFormatter,
                                 epilog="If neither --colors nor --freq are specified, display the most frequent colours in the image.")
parser.add_argument('--colors', help="""Comma-separated list of RGB hex values in the form of old,new,old,new
Examples:
9d,ffaaaa,c9,ffd2d2 - reddish
c9,ffea96 - yellow highlighter
c9,d2d2ff - light blue
""")
parser.add_argument('--freq', help="Color replacements in order of descending frequency (ex: .,ffea96). .: use original color")
parser.add_argument('--csv', help="CSV of color names to use in the form of colorname,hex")
parser.add_argument('--preview', help="Preview only", action='store_const', const=True)
parser.add_argument('input', help="Input file")
parser.add_argument('output', nargs='?', help="Output file. If not specified, overwrite input file.")

args = parser.parse_args()

def color_to_tuple(color_dict, s):
    if s in color_dict:
        s = color_dict[s]
    s = s.lstrip('#')
    if (s == '.'):
        return (None, None, None)
    elif (len(s) == 2):
        return (int(s, 16), int(s, 16), int(s, 16))
    else:
        return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4))

def load_color_dict(filename):
    dict = {}
    with open(filename, newline='') as csvfile:
        reader = csv.reader(csvfile, delimiter=',', quotechar='"')
        for row in reader:
            dict[row[0]] = row[1]
    return dict

color_dict = load_color_dict(args.csv) if args.csv else {}

input = args.input
output = args.output if args.output else input
if os.path.isdir(output):
    output = os.path.join(output, os.path.basename(input))

im = Image.open(input).convert('RGB')
data = np.array(im)
red, green, blue = data[:, :, 0], data[:, :, 1], data[:, :, 2]

if args.colors:
    colors = iter(args.colors.split(','))
    for from_c in colors:
        to_c = next(colors)
        from_r, from_b, from_g = color_to_tuple(color_dict, from_c)
        to_r, to_b, to_g = color_to_tuple(color_dict, to_c)
        mask = (red == from_r) & (green == from_g) & (blue == from_b)
        data[:, :, :3][mask] = [to_r, to_b, to_g]
else:
    colors = im.getcolors()
    sorted_colors = sorted(colors, key=lambda x: x[0], reverse=True)
    if args.freq:
        freq = iter(args.freq.split(','))
        for i, f in enumerate(freq):
            if f != '.':
                to_r, to_b, to_g = color_to_tuple(color_dict, f)
                by_freq = sorted_colors[i][1]
                if isinstance(by_freq, np.uint8):
                    print(i, '%02x' % by_freq, f)
                    mask = (red == by_freq) & (green == by_freq) & (blue == by_freq)
                else:
                    print(i, ''.join(['%02x' % c for c in by_freq]), f)
                    mask = (red == by_freq[0]) & (green == by_freq[1]) & (blue == by_freq[2])
                data[:, :, :3][mask] = [to_r, to_b, to_g]
    else:
        for x in sorted_colors:
            if isinstance(x[1], np.uint8):
                print('%02x' % x[1])
            else:
                print(''.join(['%02x' % c for c in x[1]]))
        exit(0)
im = Image.fromarray(data)
if args.preview:
    im.thumbnail((700, 700), Image.ANTIALIAS)
    im.show()
else:
    im.save(output)

I don't think in hex colours, so I added a way to refer to colours by names. I converted this list of Copic CSS colours to a CSV by copying the text, pasting it into a file, and doing a little replacement. It's not complete, but I can copy selected colours from this longer list. I can also add my own. The CSV looks a little like this:

darkgray,9d
gray,c9
lightgreen,cfe8d3
lightyellow,f6f396
lightblue,b3e3f1
y02,f6f396
w2,ddddd5
b02,b3e3f1
...

It doesn't do any fuzzing or clustering of similar colours, so it won't work well on antialiased images. For the simple sketches I make with the SuperNote, though, it seems to work well enough.

I can preview my changes with something like ./recolor.py ~/sketches/"2022-08-02-01 Playing with my drawing workflow #supernote #drawing #workflow #sketching #kaizen.png" --csv colors.csv --freq .,lightyellow --preview , and then I can take the --preview flag off to overwrite the PNG.

Here's what the output looks like after recoloring grayscale images:

You can comment with Disqus or you can e-mail me at sacha@sachachua.com.