├── requirements.txt
├── images
├── dancing.jpg
├── picasso.jpg
├── hawaii_ham.jpg
├── dancing_PBN.jpg
├── picasso_PBN.jpg
├── hawaii_ham_PBN.jpg
├── dancing_PBN_outline.jpg
├── picasso_PBN_outline.jpg
└── hawaii_ham_PBN_outline.jpg
├── image_utils.py
├── .gitignore
├── README.md
├── dominant_cluster.py
├── pbnify.py
└── process.py
/requirements.txt:
--------------------------------------------------------------------------------
1 | numpy
2 | faiss-gpu
3 | sklearn
4 | opencv-python
--------------------------------------------------------------------------------
/images/dancing.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CoderHam/PaintingByNumbersIFY/HEAD/images/dancing.jpg
--------------------------------------------------------------------------------
/images/picasso.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CoderHam/PaintingByNumbersIFY/HEAD/images/picasso.jpg
--------------------------------------------------------------------------------
/images/hawaii_ham.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CoderHam/PaintingByNumbersIFY/HEAD/images/hawaii_ham.jpg
--------------------------------------------------------------------------------
/images/dancing_PBN.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CoderHam/PaintingByNumbersIFY/HEAD/images/dancing_PBN.jpg
--------------------------------------------------------------------------------
/images/picasso_PBN.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CoderHam/PaintingByNumbersIFY/HEAD/images/picasso_PBN.jpg
--------------------------------------------------------------------------------
/images/hawaii_ham_PBN.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CoderHam/PaintingByNumbersIFY/HEAD/images/hawaii_ham_PBN.jpg
--------------------------------------------------------------------------------
/images/dancing_PBN_outline.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CoderHam/PaintingByNumbersIFY/HEAD/images/dancing_PBN_outline.jpg
--------------------------------------------------------------------------------
/images/picasso_PBN_outline.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CoderHam/PaintingByNumbersIFY/HEAD/images/picasso_PBN_outline.jpg
--------------------------------------------------------------------------------
/images/hawaii_ham_PBN_outline.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CoderHam/PaintingByNumbersIFY/HEAD/images/hawaii_ham_PBN_outline.jpg
--------------------------------------------------------------------------------
/image_utils.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | import numpy as np
3 | from PIL import Image
4 | import cv2
5 |
6 |
7 | def load_image(image_path, resize=False):
8 | image = np.asarray(Image.open(image_path))
9 | if resize:
10 | return cv2.resize(image, (200, 200), cv2.INTER_AREA)
11 | return np.asarray(image)
12 |
13 |
14 | def bar_colors(centroid_size_tuples):
15 | bar = np.zeros((50, 300, 3), dtype="uint8")
16 | x_start = 0
17 | for (color, percent) in centroid_size_tuples:
18 | x_end = x_start + (percent * 300)
19 | cv2.rectangle(bar, (int(x_start), 0), (int(x_end), 50),
20 | color.astype("uint8").tolist(), -1)
21 | x_start = x_end
22 | return bar
23 |
24 |
25 | def save_image(image, image_path):
26 | if image.shape[-1] == 3:
27 | PIL_image = Image.fromarray(image.astype('uint8'), 'RGB')
28 | else:
29 | PIL_image = Image.fromarray(image.astype('uint8'), 'L')
30 | PIL_image.save(image_path)
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *.cover
47 | .hypothesis/
48 |
49 | # Translations
50 | *.mo
51 | *.pot
52 |
53 | # Django stuff:
54 | *.log
55 | local_settings.py
56 |
57 | # Flask stuff:
58 | instance/
59 | .webassets-cache
60 |
61 | # Scrapy stuff:
62 | .scrapy
63 |
64 | # Sphinx documentation
65 | docs/_build/
66 |
67 | # PyBuilder
68 | target/
69 |
70 | # Jupyter Notebook
71 | .ipynb_checkpoints
72 |
73 | # pyenv
74 | .python-version
75 |
76 | # celery beat schedule file
77 | celerybeat-schedule
78 |
79 | # SageMath parsed files
80 | *.sage.py
81 |
82 | # dotenv
83 | .env
84 |
85 | # virtualenv
86 | .venv
87 | venv/
88 | ENV/
89 |
90 | # Spyder project settings
91 | .spyderproject
92 | .spyproject
93 |
94 | # Rope project settings
95 | .ropeproject
96 |
97 | # mkdocs documentation
98 | /site
99 |
100 | # mypy
101 | .mypy_cache/
102 |
103 | # vscode
104 | .vscode/
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PaintingByNumbersIFY
2 | Python code for converting any image to a Painting By Numbers version of itself.
3 |
4 | ## Usage
5 | ```
6 | python pbnify.py --help
7 | usage: pbnify.py [-h] -i INPUT_IMAGE -o OUTPUT_IMAGE [-k NUM_OF_CLUSTERS]
8 | [--outline]
9 |
10 | optional arguments:
11 | -h, --help show this help message and exit
12 | -i INPUT_IMAGE, --input-image INPUT_IMAGE
13 | Path of input image.
14 | -o OUTPUT_IMAGE, --output-image OUTPUT_IMAGE
15 | Path of output image.
16 | -k NUM_OF_CLUSTERS, --num-of-clusters NUM_OF_CLUSTERS
17 | Number of kmeans clusters for dominant color
18 | calculation. Defaults to 15.
19 | --outline Save outline image containing edges.
20 | ```
21 |
22 | ```
23 | python pbnify.py -i images/picasso.jpg -o images/picasso_PBN.jpg --outline -k 15
24 | ```
25 | ### Original Image/s:
26 |
27 |
28 | ### Image/s converted to their Painting By Number form:
29 |
30 |
31 | ### Outline of Image/s converted to their Painting By Number form:
32 |
33 |
--------------------------------------------------------------------------------
/dominant_cluster.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | import faiss
3 | from sklearn.cluster import KMeans
4 | import numpy as np
5 | from collections import Counter
6 |
7 | import image_utils
8 |
9 |
10 | def kmeans_faiss(dataset, k):
11 | """
12 | Runs KMeans on GPU"
13 | """
14 | dims = dataset.shape[1]
15 | cluster = faiss.Clustering(dims, k)
16 | cluster.verbose = False
17 | cluster.niter = 20
18 | cluster.max_points_per_centroid = 10**7
19 |
20 | resources = faiss.StandardGpuResources()
21 | config = faiss.GpuIndexFlatConfig()
22 | config.useFloat16 = False
23 | config.device = 0
24 | index = faiss.GpuIndexFlatL2(resources, dims, config)
25 |
26 | # perform kmeans
27 | cluster.train(dataset, index)
28 | centroids = faiss.vector_float_to_array(cluster.centroids)
29 |
30 | return centroids.reshape(k, dims)
31 |
32 |
33 | def compute_cluster_assignment(centroids, data):
34 | dims = centroids.shape[1]
35 |
36 | resources = faiss.StandardGpuResources()
37 | config = faiss.GpuIndexFlatConfig()
38 | config.useFloat16 = False
39 | config.device = 0
40 |
41 | index = faiss.GpuIndexFlatL2(resources, dims, config)
42 | index.add(centroids)
43 | _, labels = index.search(data, 1)
44 |
45 | return labels.ravel()
46 |
47 |
48 | def get_dominant_colors(image, n_clusters=10, use_gpu=True, plot=True):
49 | # Must pass FP32 data to kmeans_faiss since faiss does not support uint8
50 | flat_image = image.reshape(
51 | (image.shape[0] * image.shape[1], 3)).astype(np.float32)
52 |
53 | if use_gpu:
54 | centroids = kmeans_faiss(flat_image, n_clusters)
55 | labels = compute_cluster_assignment(centroids,
56 | flat_image).astype(np.uint8)
57 | centroids = centroids.astype(np.uint8)
58 | else:
59 | clt = KMeans(n_clusters=n_clusters).fit(flat_image)
60 | centroids = clt.cluster_centers_.astype(np.uint8)
61 | labels = clt.labels_.astype(np.uint8)
62 |
63 | if plot:
64 | counts = Counter(labels).most_common()
65 | centroid_size_tuples = [
66 | (centroids[k], val / len(labels)) for k, val in counts
67 | ]
68 | bar_image = image_utils.bar_colors(centroid_size_tuples)
69 | return centroids, labels, bar_image
70 |
71 | return centroids, labels
72 |
--------------------------------------------------------------------------------
/pbnify.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | import numpy as np
3 | import argparse
4 | import os
5 |
6 | import dominant_cluster
7 | import image_utils
8 | import process
9 |
10 |
11 | def simple_matrix_to_image(mat, palette):
12 | simple_mat_flat = np.array(
13 | [[col for col in palette[index]] for index in mat.flatten()])
14 | return simple_mat_flat.reshape(mat.shape + (3,))
15 |
16 |
17 | def PBNify(image_path, clusters=20, pre_blur=True):
18 | image = image_utils.load_image(image_path, resize=False)
19 | if pre_blur:
20 | image = process.blur_image(image)
21 |
22 | dominant_colors, quantized_labels, bar_image = dominant_cluster.get_dominant_colors(
23 | image, n_clusters=clusters, use_gpu=True, plot=True)
24 |
25 | # Create final PBN image
26 | smooth_labels = process.smoothen(quantized_labels.reshape(image.shape[:-1]))
27 | pbn_image = dominant_colors[smooth_labels].reshape(image.shape)
28 |
29 | # Create outline image
30 | outline_image = process.outline(pbn_image)
31 |
32 | return pbn_image, outline_image
33 |
34 |
35 | if __name__ == '__main__':
36 | parser = argparse.ArgumentParser()
37 | parser.add_argument('-i',
38 | '--input-image',
39 | type=str,
40 | required=True,
41 | help='Path of input image.')
42 | parser.add_argument('-o',
43 | '--output-image',
44 | type=str,
45 | required=True,
46 | help='Path of output image.')
47 | parser.add_argument(
48 | '-k',
49 | '--num-of-clusters',
50 | type=int,
51 | required=False,
52 | default=15,
53 | help=
54 | 'Number of kmeans clusters for dominant color calculation. Defaults to 15.'
55 | )
56 | parser.add_argument('--outline',
57 | action="store_true",
58 | required=False,
59 | default=False,
60 | help='Save outline image containing edges.')
61 | FLAGS = parser.parse_args()
62 |
63 | pbn_image, outline_image = PBNify(FLAGS.input_image,
64 | clusters=FLAGS.num_of_clusters)
65 | image_utils.save_image(pbn_image, FLAGS.output_image)
66 |
67 | if FLAGS.outline:
68 | outline_image_path = os.path.splitext(
69 | FLAGS.output_image)[0] + "_outline.jpg"
70 | image_utils.save_image(outline_image, outline_image_path)
71 |
--------------------------------------------------------------------------------
/process.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | from copy import deepcopy
3 | import numpy as np
4 | import cv2
5 |
6 |
7 | def get_most_frequent_vicinity_value(mat, x, y, xyrange):
8 | ymax, xmax = mat.shape
9 | vicinity_values = mat[max(y - xyrange, 0):min(y + xyrange, ymax),
10 | max(x - xyrange, 0):min(x + xyrange, xmax)].flatten()
11 | counts = np.bincount(vicinity_values)
12 |
13 | return np.argmax(counts)
14 |
15 |
16 | def smoothen(mat, filter_size=4):
17 | ymax, xmax = mat.shape
18 | flat_mat = np.array([
19 | get_most_frequent_vicinity_value(mat, x, y, filter_size)
20 | for y in range(0, ymax)
21 | for x in range(0, xmax)
22 | ])
23 |
24 | return flat_mat.reshape(mat.shape)
25 |
26 |
27 | def are_neighbors_same(mat, x, y):
28 | width = len(mat[0])
29 | height = len(mat)
30 | val = mat[y][x]
31 | xRel = [1, 0]
32 | yRel = [0, 1]
33 | for i in range(0, len(xRel)):
34 | xx = x + xRel[i]
35 | yy = y + yRel[i]
36 | if xx >= 0 and xx < width and yy >= 0 and yy < height:
37 | if (mat[yy][xx] != val).all():
38 | return False
39 | return True
40 |
41 |
42 | def outline(mat):
43 | ymax, xmax, _ = mat.shape
44 | line_mat = np.array([
45 | 255 if are_neighbors_same(mat, x, y) else 0
46 | for y in range(0, ymax)
47 | for x in range(0, xmax)
48 | ],
49 | dtype=np.uint8)
50 |
51 | return line_mat.reshape((ymax, xmax))
52 |
53 |
54 | def getRegion(mat, cov, x, y):
55 | covered = deepcopy(cov)
56 | region = {'value': mat[y][x], 'x': [], 'y': []}
57 | value = mat[y][x]
58 |
59 | queue = [[x, y]]
60 | while (len(queue) > 0):
61 | coord = queue.pop()
62 | if covered[coord[1]][coord[0]] == False and mat[coord[1]][
63 | coord[0]] == value:
64 | region['x'].append(coord[0])
65 | region['y'].append(coord[1])
66 | covered[coord[1]][coord[0]] = True
67 | if coord[0] > 0:
68 | queue.append([coord[0] - 1, coord[1]])
69 | if coord[0] < len(mat[0]) - 1:
70 | queue.append([coord[0] + 1, coord[1]])
71 | if coord[1] > 0:
72 | queue.append([coord[0], coord[1] - 1])
73 | if coord[1] < len(mat) - 1:
74 | queue.append([coord[0], coord[1] + 1])
75 |
76 | return region
77 |
78 |
79 | def coverRegion(covered, region):
80 | for i in range(0, len(region['x'])):
81 | covered[region['y'][i]][region['x'][i]] = True
82 |
83 |
84 | def sameCount(mat, x, y, incX, incY):
85 | value = mat[y][x]
86 | count = -1
87 | while x >= 0 and x < len(
88 | mat[0]) and y >= 0 and y < len(mat) and mat[y][x] == value:
89 | count += 1
90 | x += incX
91 | y += incY
92 |
93 | return count
94 |
95 |
96 | def getLabelLoc(mat, region):
97 | bestI = 0
98 | best = 0
99 | for i in range(0, len(region['x'])):
100 | goodness = sameCount(
101 | mat, region['x'][i], region['y'][i], -1, 0) * sameCount(
102 | mat, region['x'][i], region['y'][i], 1, 0) * sameCount(
103 | mat, region['x'][i], region['y'][i], 0, -1) * sameCount(
104 | mat, region['x'][i], region['y'][i], 0, 1)
105 | if goodness > best:
106 | best = goodness
107 | bestI = i
108 |
109 | return {
110 | 'value': region['value'],
111 | 'x': region['x'][bestI],
112 | 'y': region['y'][bestI]
113 | }
114 |
115 |
116 | def getBelowValue(mat, region):
117 | x = region['x'][0]
118 | y = region['y'][0]
119 | print(region)
120 | while mat[y][x] == region['value']:
121 | print(mat[y][x])
122 | y += 1
123 |
124 | return mat[y][x]
125 |
126 |
127 | def removeRegion(mat, region):
128 | if region['y'][0] > 0:
129 | newValue = mat[region['y'][0] - 1][region['x'][0]]
130 | else:
131 | newValue = getBelowValue(mat, region)
132 | for i in range(0, len(region['x'])):
133 | mat[region['y'][i]][region['x'][i]] = newValue
134 |
135 |
136 | def getLabelLocs(mat):
137 | width = len(mat[0])
138 | height = len(mat)
139 | covered = [[False] * width] * height
140 |
141 | labelLocs = []
142 | for y in range(0, height):
143 | for x in range(0, width):
144 | if covered[y][x] == False:
145 | region = getRegion(mat, covered, x, y)
146 | coverRegion(covered, region)
147 | if len(region['x']) > 100:
148 | labelLocs.append(getLabelLoc(mat, region))
149 | else:
150 | removeRegion(mat, region)
151 |
152 | return labelLocs
153 |
154 |
155 | def edge_mask(image, line_size=3, blur_value=9):
156 | gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
157 | gray_blur = cv2.medianBlur(gray, blur_value)
158 | edges = cv2.adaptiveThreshold(gray_blur, 255, cv2.ADAPTIVE_THRESH_MEAN_C,
159 | cv2.THRESH_BINARY, line_size, blur_value)
160 |
161 | return edges
162 |
163 |
164 | def merge_mask(image, mask):
165 | return cv2.bitwise_and(image, image, mask=mask)
166 |
167 |
168 | def blur_image(image, blur_d=5):
169 | return cv2.bilateralFilter(image, d=blur_d, sigmaColor=200, sigmaSpace=200)
170 |
--------------------------------------------------------------------------------