├── 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 | --------------------------------------------------------------------------------