"I want to make paintings
that look as if they were made
by a child."

β€” Jean-Michel Basquiat

Autogenerated Watercolor Paintings & Illustrations

β–‘β–‘ β–“β–’ β–‘β–‘β–“β–‘ β–“ β–‘β–’β–’β–“ β–‘β–“β–’β–’β–‘β–‘β–“

A Heuristic Process

First off, let me say that my process for creating this series suits a certain set of requirements tied to the goals of the work. These requirements are simple and straight-forward:

Basically, I want a painterly effect.

With these goals in mind, I have concocted a set of steps to run that achieves the goal most of the time. What I have noticed is that some images perform better than others, while some images contain subsections of stunningly realistic painterly effect. The following subsection of a processed image is exemplar:

The source image is:

This was shot on a very rainy morning in Seattle from the kitchen area of Essence Global's former "waterfront" office. Notice people are driving with their lights on and this is reflecting on the rainy surface. Also note the low clouds mixing into the orange cranes in the background.

Let's walk through the script that created the watercolor painting. I've annotated it below and you can download it directly, and the source image. Before we get too far along, you'll want to make sure you have the necessary libs. Obviously Pillow and CV2, but you'll also need the "contrib" library from opencv:

pip3 install opencv-python

pip3 install opencv-contrib-python

Annotated script for creating watercolor images

new_image = Image.open( './watercolor_test_image.png' )
new_image = new_image.resize((2000,2000))

# ======================
# oil paintings - this is for edge variations
# ======================
oil_1 = xp.oilPainting( np.asarray( new_image ), 20, 2, cv2.COLOR_BGR2Lab)
oil_1 = Image.fromarray(oil_1)
edges_oil_1 = self.convert_to_cv2_edges(oil_1)

# save an oil with edges
ImageOps.invert(edges_oil_1).save( save_path_prefix + "_oil_edge_" + timestamp + ".png")

An edge pass run on an oil pass produces squiggly outlines. Just what we want.

# here's another oil with different params, used in later experiment
oil_2 = xp.oilPainting( np.asarray( new_image ), 100, 2, cv2.COLOR_BGR2Lab)

# ======================
# cv2 edges and some contrast.
# ======================

I am using Adrian Rosebrock's cv2 method for edges. Please see his most excellent web site at pyimagesearch.

eq_and_contast = ImageOps.equalize(new_image)
super_contrast = ImageOps.autocontrast(
cutoff=21, ignore=None)
edges_2 = self.convert_to_cv2_edges(

# ======================
# enhancements
# ======================
edges_3 = self.image_offsetter(edges_2, (1,1))

With image_offsetter, I'm doubling up the edge pass and offsetting it by a pixel. This makes it bolder. It's simplistic but it works. My short term goal is to modify the edge-making code to thicken up the border, but this works reasonably the same.

composite_edges = ImageChops.screen(edges_2, edges_3)
composite_edges = ImageChops.screen(edges_oil_1,
composite_edges = ImageOps.invert(composite_edges)
find_edges = new_image.filter(
find_edges = ImageOps.invert(find_edges)
find_edges = ImageOps.grayscale(find_edges)
find_edges = ImageOps.autocontrast(find_edges, cutoff=5, ignore=None)

# composite of all edge passes
all_edges = ImageChops.multiply(find_edges, composite_edges)

# ======================
# save equalized, oil, edges (cccolouring_book)
# ======================
rgb_edges = Image.fromarray( np.dstack([all_edges]*3) ) # Make it 3 channel, convert to pil
oil_with_edges = ImageChops.multiply( self.filter_1(oil_1), rgb_edges)
oil_with_edges.save( save_path_prefix + "_oil1_" + timestamp + ".jpg")

This is the oil pass combined with the all_edges pass. Note the conversion to RGB.

oil_with_edges = ImageChops.multiply(
Image.fromarray(oil_2), rgb_edges)
oil_with_edges.save( save_path_prefix + "_oil2_" + timestamp + ".jpg")

Another oil pass (with contrast variations) composited with an edge pass.

washed_out_oil = ImageChops.multiply(
self.filter_2(oil_2), rgb_edges)
washed_out_oil.save( save_path_prefix + "_oil3_" + timestamp + ".jpg")

And yet another slight variation of an oil+edge pass.

As I've stated elsewhere, I like to equalize many of my photos to achieve a kind of film look. Equalizing simply means adjusting all grayscale and color values so that no single value is the strongest. It is a normalization. To equalize an image I use ImageOps.equalize(image) from the Pillow fork of PIL.

equalized = ImageOps.equalize(new_image)
equalized.save( save_path_prefix + "_eq_" + timestamp + ".png" )

The equalized image, which in this case was the same as what I started with.

all_edges.save( save_path_prefix + timestamp + ".png" )

All of our edges saved down to a file.

As you can see, there's plenty of room to try different ideas out here. I'd like to try to get some (not all) edges thicker. I'd also like to more watercolor layering.

If you're not interested in the code but would like me to process some photos for you, please contact me at mofo@thedamagereport.com. I am also available for image processing work, commissions, and general consultation. Editors and art directors can contact me directly to arrange usage rights of my work in their publications and advertising.

See my series of digital watercolor paintings for many more examples.

πŸ‡―πŸ‡΅ Current Japan time: Monday-09-2023 21:19:30 πŸ‡ΊπŸ‡² Current Seattle time: Monday-09-2023 05:19:30