├── LICENSE ├── README.md ├── connery_old__3cc96.jpg └── mosaic.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Rob Dawson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mosaic 2 | 3 | This utility can be used to generate [photo-mosaic](http://en.wikipedia.org/wiki/Photographic_mosaic) images. There are several mosaic libraries. 4 | 5 | There is **no cheating** with semi-transparent overlays, tile tinting, or repeating images (by default). 6 | 7 | This one is focused on **maximum accuracy** (using scikit's Mean Squared Error). This is a pixel by pixel comparison to produce a best match. Other libraries simplify this by only comparing against a limited (averaged, antialiased) number of pixel from the original sector, or just compare against the histograms. Producing large, highly detailed mosaics takes much time! 8 | 9 | As well as an image to use for the photo-mosaic ([most common image formats are supported](http://pillow.readthedocs.org/en/latest/handbook/image-file-formats.html)), you will need a large collection of different images to be used as tiles. The tile images can be any shape or size (the utility will automatically crop and resize them) but for good results you will need a lot of them - a few hundred at least. One convenient way of generating large numbers of tile images is to [extract screenshots from video files](https://trac.ffmpeg.org/wiki/Create%20a%20thumbnail%20image%20every%20X%20seconds%20of%20the%20video) using [ffmpeg](https://www.ffmpeg.org/). 10 | 11 | **Prerequisites** 12 |
pip install scikit-image numpy pillow
13 | 14 | **Usage** 15 | ```python 16 | create_mosaic( 17 | subject="/path/to/source/image", 18 | target="/path/to/output/image", 19 | tile_paths=["/path/to/tile_1" , ... "/path/to/tile_n"], 20 | tile_ratio=1920/800, # Crop tiles to be height/width ratio 21 | tile_width=300, # Tile will be scaled 22 | enlargement=20, # Mosiac will be this times larger than original 23 | reuse=False, # Should tiles be used multiple times? 24 | color_mode='L', # RGB (color) L (greyscale) 25 | ) 26 | ``` 27 | 28 | ### Sample: Sean Connery made of screenshots from his Bond films 29 | Sean starred in 7 Bond films. A still every 3 seconds yields about 10,000 images. 2,500 unique stills are matched and used below: 30 | ![Sample](https://github.com/dvdtho/mosaic/blob/master/connery_old__3cc96.jpg) 31 | 32 | 33 | # Image Aspect Crop with Focus (Bonus Feature) 34 | Ability crop an image to the desired perspective at the maximum size available. Centerpoint can be provided to focus the crop to one side or another. 35 | 36 | For example, if we are cropping the left and right sides and the left side is more interesting than the right side: 37 | ```python 38 | from PIL import Image 39 | img = aspect_crop_to_extent( 40 | img=Image.open("/path/to/image"), 41 | target_aspect=1, # width / float(height) -- 1 is square 42 | centerpoint=(0,0), # (width, height) -- we will focus on the left, and crop from the right 43 | ) 44 | ``` 45 | 46 | ------------ 47 | Changed from original project (https://github.com/codebox/mosaic): 48 | * ability to not reuse tiles 49 | * increased quality of match by comparing against whole tile images 50 | * increased quality of mosaic by starting with the center instead of top left 51 | * ability to input color mode 52 | 53 | Tested with: 54 | * Python 3.6.5 55 | * numpy==1.14.4 56 | * Pillow==5.1.0 57 | * scikit-image==0.14.1 58 | 59 | 60 | -------------------------------------------------------------------------------- /connery_old__3cc96.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvdtho/python-photo-mosaic/3a96789faeb1b07df7f01631b49e4a94ef2ed017/connery_old__3cc96.jpg -------------------------------------------------------------------------------- /mosaic.py: -------------------------------------------------------------------------------- 1 | import time 2 | import itertools 3 | import random 4 | import sys 5 | 6 | import numpy as np 7 | from PIL import Image 8 | from skimage import img_as_float 9 | from skimage.measure import compare_mse 10 | 11 | def shuffle_first_items(lst, i): 12 | if not i: 13 | return lst 14 | first_few = lst[:i] 15 | remaining = lst[i:] 16 | random.shuffle(first_few) 17 | return first_few + remaining 18 | 19 | def bound(low, high, value): 20 | return max(low, min(high, value)) 21 | 22 | class ProgressCounter: 23 | def __init__(self, total): 24 | self.total = total 25 | self.counter = 0 26 | 27 | def update(self): 28 | self.counter += 1 29 | sys.stdout.write("Progress: %s%% %s" % (100 * self.counter / self.total, "\r")) 30 | sys.stdout.flush() 31 | 32 | def img_mse(im1, im2): 33 | """Calculates the root mean square error (RSME) between two images""" 34 | try: 35 | return compare_mse(img_as_float(im1), img_as_float(im2)) 36 | except ValueError: 37 | print(f'RMS issue, Img1: {im1.size[0]} {im1.size[1]}, Img2: {im2.size[0]} {im2.size[1]}') 38 | raise KeyboardInterrupt 39 | 40 | def resize_box_aspect_crop_to_extent(img, target_aspect, centerpoint=None): 41 | width = img.size[0] 42 | height = img.size[1] 43 | if not centerpoint: 44 | centerpoint = (int(width / 2), int(height / 2)) 45 | 46 | requested_target_x = centerpoint[0] 47 | requested_target_y = centerpoint[1] 48 | aspect = width / float(height) 49 | if aspect > target_aspect: 50 | # Then crop the left and right edges: 51 | new_width = int(target_aspect * height) 52 | new_width_half = int(new_width/2) 53 | target_x = bound(new_width_half, width-new_width_half, requested_target_x) 54 | left = target_x - new_width_half 55 | right = target_x + new_width_half 56 | resize = (left, 0, right, height) 57 | else: 58 | # ... crop the top and bottom: 59 | new_height = int(width / target_aspect) 60 | new_height_half = int(new_height/2) 61 | target_y = bound(new_height_half, height-new_height_half, requested_target_y) 62 | top = target_y - new_height_half 63 | bottom = target_y + new_height_half 64 | resize = (0, top, width, bottom) 65 | return resize 66 | 67 | def aspect_crop_to_extent(img, target_aspect, centerpoint=None): 68 | ''' 69 | Crop an image to the desired perspective at the maximum size available. 70 | Centerpoint can be provided to focus the crop to one side or another - 71 | eg just cut the left side off if interested in the right side. 72 | 73 | target_aspect = width / float(height) 74 | centerpoint = (width, height) 75 | ''' 76 | resize = resize_box_aspect_crop_to_extent(img, target_aspect, centerpoint) 77 | return img.crop(resize) 78 | 79 | class Config: 80 | def __init__(self, tile_ratio=1920/800, tile_width=50, enlargement=8, color_mode='RGB'): 81 | self.tile_ratio = tile_ratio # 2.4 82 | self.tile_width = tile_width # height/width of mosaic tiles in pixels 83 | self.enlargement = enlargement # mosaic image will be this many times wider and taller than original 84 | self.color_mode = color_mode # mosaic image will be this many times wider and taller than original 85 | 86 | @property 87 | def tile_height(self): 88 | return int(self.tile_width / self.tile_ratio) 89 | 90 | @property 91 | def tile_size(self): 92 | return self.tile_width, self.tile_height # PIL expects (width, height) 93 | 94 | class TileBox: 95 | """ 96 | Container to import, process, hold, and compare all of the tiles 97 | we have to make the mosaic with. 98 | """ 99 | def __init__(self, tile_paths, config): 100 | self.config = config 101 | self.tiles = list() 102 | self.prepare_tiles_from_paths(tile_paths) 103 | 104 | def __process_tile(self, tile_path): 105 | with Image.open(tile_path) as i: 106 | img = i.copy() 107 | img = aspect_crop_to_extent(img, self.config.tile_ratio) 108 | large_tile_img = img.resize(self.config.tile_size, Image.ANTIALIAS).convert(self.config.color_mode) 109 | self.tiles.append(large_tile_img) 110 | return True 111 | 112 | def prepare_tiles_from_paths(self, tile_paths): 113 | print('Reading tiles from provided list...') 114 | progress = ProgressCounter(len(tile_paths)) 115 | for tile_path in tile_paths: 116 | progress.update() 117 | self.__process_tile(tile_path) 118 | print('Processed tiles.') 119 | return True 120 | 121 | def best_tile_block_match(self, tile_block_original): 122 | match_results = [img_mse(t, tile_block_original) for t in self.tiles] 123 | best_fit_tile_index = np.argmin(match_results) 124 | return best_fit_tile_index 125 | 126 | def best_tile_from_block(self, tile_block_original, reuse=False): 127 | if not self.tiles: 128 | print('Ran out of images.') 129 | raise KeyboardInterrupt 130 | 131 | #start_time = time.time() 132 | i = self.best_tile_block_match(tile_block_original) 133 | #print("BLOCK MATCH took --- %s seconds ---" % (time.time() - start_time)) 134 | match = self.tiles[i].copy() 135 | if not reuse: 136 | del self.tiles[i] 137 | return match 138 | 139 | class SourceImage: 140 | """Processing original image - scaling and cropping as needed.""" 141 | def __init__(self, image_path, config): 142 | print('Processing main image...') 143 | self.image_path = image_path 144 | self.config = config 145 | 146 | with Image.open(self.image_path) as i: 147 | img = i.copy() 148 | w = img.size[0] * self.config.enlargement 149 | h = img.size[1] * self.config.enlargement 150 | large_img = img.resize((w, h), Image.ANTIALIAS) 151 | w_diff = (w % self.config.tile_width)/2 152 | h_diff = (h % self.config.tile_height)/2 153 | 154 | # if necesary, crop the image slightly so we use a 155 | # whole number of tiles horizontally and vertically 156 | if w_diff or h_diff: 157 | large_img = large_img.crop((w_diff, h_diff, w - w_diff, h - h_diff)) 158 | 159 | self.image = large_img.convert(self.config.color_mode) 160 | print('Main image processed.') 161 | 162 | class MosaicImage: 163 | """Holder for the mosaic""" 164 | def __init__(self, original_img, target, config): 165 | self.config = config 166 | self.target = target 167 | # Lets just start with original image, scaled up, instead of a blank one 168 | self.image = original_img 169 | # self.image = Image.new(original_img.mode, original_img.size) 170 | self.x_tile_count = int(original_img.size[0] / self.config.tile_width) 171 | self.y_tile_count = int(original_img.size[1] / self.config.tile_height) 172 | self.total_tiles = self.x_tile_count * self.y_tile_count 173 | print(f'Mosaic will be {self.x_tile_count:,} tiles wide and {self.y_tile_count:,} tiles high ({self.total_tiles:,} total).') 174 | 175 | def add_tile(self, tile, coords): 176 | """Adds the provided image onto the mosiac at the provided coords.""" 177 | try: 178 | self.image.paste(tile, coords) 179 | except TypeError as e: 180 | print('Maybe the tiles are not the right size. ' + str(e)) 181 | 182 | def save(self): 183 | self.image.save(self.target) 184 | 185 | def coords_from_middle(x_count, y_count, y_bias=1, shuffle_first=0, ): 186 | ''' 187 | Lets start in the middle where we have more images. 188 | And we dont get "lines" where the same-best images 189 | get used at the start. 190 | 191 | y_bias - if we are using non-square coords, we can 192 | influence the order to be closer to the real middle. 193 | If width is 2x height, y_bias should be 2. 194 | 195 | shuffle_first - We can suffle the first X coords 196 | so that we dont use all the same-best images 197 | in the same spot - in the middle 198 | 199 | from movies.mosaic_mem import coords_from_middle 200 | x = 10 201 | y = 10 202 | coords_from_middle(x, y, y_bias=2, shuffle_first=0) 203 | ''' 204 | x_mid = int(x_count/2) 205 | y_mid = int(y_count/2) 206 | coords = list(itertools.product(range(x_count), range(y_count))) 207 | coords.sort(key=lambda c: abs(c[0]-x_mid)*y_bias + abs(c[1]-y_mid)) 208 | coords = shuffle_first_items(coords, shuffle_first) 209 | return coords 210 | 211 | 212 | def create_mosaic(source_path, target, tile_ratio=1920/800, tile_width=75, enlargement=8, reuse=True, color_mode='RGB', tile_paths=None, shuffle_first=30): 213 | """Forms an mosiac from an original image using the best 214 | tiles provided. This reads, processes, and keeps in memory 215 | a copy of the source image, and all the tiles while processing. 216 | 217 | Arguments: 218 | source_path -- filepath to the source image for the mosiac 219 | target -- filepath to save the mosiac 220 | tile_ratio -- height/width of mosaic tiles in pixels 221 | tile_width -- width of mosaic tiles in pixels 222 | enlargement -- mosaic image will be this many times wider and taller than the original 223 | reuse -- Should we reuse tiles in the mosaic, or just use each tile once? 224 | color_mode -- L for greyscale or RGB for color 225 | tile_paths -- List of filepaths to your tiles 226 | shuffle_first -- Mosiac will be filled out starting in the center for best effect. Also, 227 | we will shuffle the order of assessment so that all of our best images aren't 228 | necessarily in one spot. 229 | """ 230 | config = Config( 231 | tile_ratio = tile_ratio, # height/width of mosaic tiles in pixels 232 | tile_width = tile_width, # height/width of mosaic tiles in pixels 233 | enlargement = enlargement, # the mosaic image will be this many times wider and taller than the original 234 | color_mode = color_mode, # L for greyscale or RGB for color 235 | ) 236 | # Pull in and Process Original Image 237 | print('Setting Up Target image') 238 | source_image = SourceImage(source_path, config) 239 | 240 | # Setup Mosaic 241 | mosaic = MosaicImage(source_image.image, target, config) 242 | 243 | # Assest Tiles, and save if needed, returns directories where the small and large pictures are stored 244 | print('Assessing Tiles') 245 | tile_box = TileBox(tile_paths, config) 246 | 247 | try: 248 | progress = ProgressCounter(mosaic.total_tiles) 249 | for x, y in coords_from_middle(mosaic.x_tile_count, mosaic.y_tile_count, y_bias=config.tile_ratio, shuffle_first=shuffle_first): 250 | progress.update() 251 | 252 | # Make a box for this sector 253 | box_crop = (x * config.tile_width, y * config.tile_height, (x + 1) * config.tile_width, (y + 1) * config.tile_height) 254 | 255 | # Get Original Image Data for this Sector 256 | comparison_block = source_image.image.crop(box_crop) 257 | 258 | # Get Best Image name that matches the Orig Sector image 259 | tile_match = tile_box.best_tile_from_block(comparison_block, reuse=reuse) 260 | 261 | # Add Best Match to Mosaic 262 | mosaic.add_tile(tile_match, box_crop) 263 | 264 | # Saving Every Sector 265 | mosaic.save() 266 | 267 | except KeyboardInterrupt: 268 | print('\nStopping, saving partial image...') 269 | 270 | finally: 271 | mosaic.save() 272 | --------------------------------------------------------------------------------