├── .gitattributes ├── IC_GUI.py ├── ImageCraft.py ├── LICENSE └── mc_images.zip /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /IC_GUI.py: -------------------------------------------------------------------------------- 1 | ''' 2 | -08/09/2021 3 | -ImageCraft, a program made by Sebastian Jimenez 4 | -This script is responsible for running the custom GUI for ImageCraft 5 | ''' 6 | 7 | # Imports 8 | from gooey import Gooey, GooeyParser 9 | import os 10 | 11 | 12 | # GUI configuration of tabs, size , name, ect.. 13 | @Gooey(program_name='IMAGE CRAFT', default_size=(700, 650), 14 | program_description='A program designed to turn digital images into mosaics made of minecraft blocks.', 15 | menu=[{ 16 | 'name': 'File', 17 | 'items': [{ 18 | 'type': 'AboutDialog', 19 | 'menuTitle': 'About', 20 | 'name': 'Image Craft (Beta)', 21 | 'description': '2021', 22 | 'website': 'https://potassium3919.itch.io/', 23 | 'developer': 'Sebastian Jimenez', 24 | 25 | }] 26 | }, { 27 | 'name': 'Help', 28 | 'items': [{ 29 | 'type': 'Link', 30 | 'menuTitle': 'Source Code', 31 | 'url': 'https://github.com/sjimenez100/ImageCraft' 32 | }]}]) 33 | 34 | # contains attributes that are refined versions of the raw GUI inputs 35 | class ImageGUI: 36 | 37 | def __init__(self): 38 | self.host_image_path = None 39 | self.keywords = None 40 | self.threshold = None 41 | self.resolution_divider = None 42 | self.show_image = None 43 | self.fill_trans = None 44 | self.output_directory = None 45 | 46 | def start_get_and_clean(self): 47 | parser = GooeyParser() 48 | 49 | parser.add_argument('host_image_path', 50 | metavar='Host Image', 51 | widget='FileChooser', gooey_options={ 52 | 'validator': { 53 | 'test': 'str(user_input).endswith(".png") or str(user_input).endswith(".jpg")', 54 | 'message': 'Only image formats: (.png) or (.jpg) will be accepted'}}, 55 | help='Enter path of the image to process\n' 56 | r'Example: "C:\Users\user\Desktop\image.png"') 57 | 58 | parser.add_argument('-keywords', 59 | metavar='Keywords (Recommended)', 60 | help='Enter keywords separated by spaces to filter search\n' 61 | 'Example: "concrete wool plank diamond blue"') 62 | 63 | parser.add_argument('-threshold', 64 | metavar='Inverse Threshold', widget='Slider', 65 | help='Enter an integer to adjust inverse threshold', default=15, 66 | gooey_options={ 67 | 'max': 150}) 68 | 69 | parser.add_argument('-resolution_divider', 70 | metavar='Resolution Factor', widget='Slider', 71 | help='Enter an integer to adjust resolution (pixels per block)', default=8, 72 | gooey_options={ 73 | 'min': 1, 74 | 'max': 16}) 75 | 76 | parser.add_argument('-show_image', 77 | metavar='Display Image', widget='CheckBox', 78 | help='Toggle whether to display image when process is complete', 79 | action='store_false', default=True) 80 | 81 | parser.add_argument('-fill_trans', 82 | metavar='Fill Transparency', widget='CheckBox', 83 | help='Toggle whether to fill transparent pixels', action='store_true', default=False) 84 | 85 | parser.add_argument('-output_directory', 86 | metavar='Output Location', widget='DirChooser', 87 | default=rf'{os.path.join(os.getcwd(), "mosaics")}', 88 | help='Enter path to output directory') 89 | 90 | inputs = parser.parse_args() 91 | 92 | self.threshold = int(inputs.threshold) 93 | self.fill_trans = bool(inputs.fill_trans) 94 | 95 | if str(inputs.keywords).isspace() or inputs.keywords is None: 96 | self.keywords = [] 97 | else: 98 | self.keywords = list(str(inputs.keywords).split()) 99 | 100 | self.host_image_path = inputs.host_image_path 101 | self.resolution_divider = int(inputs.resolution_divider) 102 | self.output_directory = inputs.output_directory 103 | self.show_image = not bool(inputs.show_image) 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /ImageCraft.py: -------------------------------------------------------------------------------- 1 | ''' 2 | -08/23/2021 3 | -ImageCraft, a program made by Sebastian Jimenez 4 | -This script is responsible for running the main functionalities of Image Craft. 5 | This does not include the custom GUI. 6 | ''' 7 | # Imports 8 | import sys 9 | import time 10 | import PIL.ImageFile 11 | from PIL import Image 12 | import numpy 13 | import os 14 | import IC_GUI 15 | 16 | # Variables 17 | # GUI determined 18 | image_gui = IC_GUI.ImageGUI() 19 | 20 | # dependents 21 | dependent_images = [] 22 | dependent_jimages = [] 23 | blank_jimage = None 24 | trans_jimage = None 25 | 26 | # misc. 27 | selected_jimages = [] 28 | start_time = time.time() 29 | cwd = os.getcwd() 30 | n = 0 31 | f = 0 32 | 33 | 34 | # Classes 35 | 36 | # Jimage class responsible for creating a synthetic of PIL.Image() 37 | class Jimage: 38 | 39 | def __init__(self, pil_image): 40 | self.pil_image = pil_image 41 | self.data = self.pil_image.getdata() 42 | self.av_color = [0, 0, 0, 0] 43 | 44 | # returns the average color as a vector 45 | def average_color(self): 46 | num_pixels = 0 47 | av_color = numpy.array([0, 0, 0, 0]) 48 | 49 | for p in range(len(self.data)): 50 | 51 | data_copy = list(self.data[p]) 52 | 53 | if self.pil_image.mode == 'RGB': 54 | # assumes max opacity 55 | data_copy.append(255) 56 | 57 | av_color = numpy.add(av_color, data_copy) 58 | num_pixels += 1 59 | 60 | av_color = numpy.divide(av_color, num_pixels) 61 | 62 | return av_color 63 | 64 | 65 | class HostImage: 66 | 67 | def __init__(self, fp): 68 | self.fp = fp 69 | self.pil_image = Image.open(fp) 70 | self.format = self.pil_image.format 71 | self.width, self.height = self.pil_image.size[0], self.pil_image.size[1] 72 | self.pixel_count = self.width * self.height 73 | self.tail = os.path.split(self.fp)[1] 74 | self.mode = self.pil_image.mode 75 | self.resized = False 76 | 77 | def lower_resolution(self): 78 | if image_gui.resolution_divider > 1: 79 | self.pil_image = self.pil_image.resize((int(self.width / image_gui.resolution_divider), 80 | int(self.height / image_gui.resolution_divider)), Image.LANCZOS) 81 | 82 | self.fp = os.path.join(cwd, rf'RESIZED-{self.tail}') 83 | self.pil_image.save(self.fp) 84 | 85 | self.resized = True 86 | 87 | self.width, self.height = self.pil_image.size[0], self.pil_image.size[1] 88 | self.pixel_count = (self.pil_image.size[0] * self.pil_image.size[1]) 89 | 90 | # Functions 91 | # ensures that system does not ignore print() updates 92 | def config_gui(): 93 | class Unbuffered(object): 94 | def __init__(self, stream): 95 | self.stream = stream 96 | 97 | def write(self, data): 98 | self.stream.write(data) 99 | self.stream.flush() 100 | 101 | def writelines(self, datas): 102 | self.stream.writelines(datas) 103 | self.stream.flush() 104 | 105 | def __getattr__(self, attr): 106 | return getattr(self.stream, attr) 107 | 108 | sys.stdout = Unbuffered(sys.stdout) 109 | 110 | 111 | # configures all of the images into a dependent list of jimages 112 | def dependent_image_configuration(): 113 | global blank_jimage 114 | global trans_jimage 115 | 116 | # returns True if keyword is found inside string 117 | def keyword_inside(name): 118 | if len(image_gui.keywords) > 0: 119 | for keyword in image_gui.keywords: 120 | if keyword in name: 121 | return True 122 | else: 123 | return True 124 | 125 | return False 126 | 127 | dependent_images_directory = os.path.join(cwd, 'mc_images') 128 | 129 | # sets common images 130 | blank_jimage = Jimage(Image.new('RGBA', (0, 0))) 131 | trans_jimage = Jimage(Image.open(os.path.join(dependent_images_directory, 'glass.png'))) 132 | 133 | # creates path and a unrefined list of .png images from the directory 134 | for dependent_image_name in os.listdir(dependent_images_directory): 135 | if keyword_inside(dependent_image_name): 136 | final_dependent_image_path = os.path.join(dependent_images_directory, dependent_image_name) 137 | dependent_images.append(Image.open(final_dependent_image_path)) 138 | 139 | # turns each PIL.Image() into a Jimage() with a pre-calculated av_color 140 | for image in dependent_images: 141 | new_jimage_instance = Jimage(image) 142 | dependent_jimages.append(new_jimage_instance) 143 | new_jimage_instance.av_color = new_jimage_instance.average_color() 144 | 145 | 146 | # returns first dependent Jimage that in beneath the threshold number 147 | def best_jimage(pixel): 148 | global n 149 | global f 150 | 151 | # returns the Jimage for transparent space 152 | if pixel[3] <= image_gui.threshold: 153 | if image_gui.fill_trans: 154 | return trans_jimage 155 | else: 156 | return blank_jimage 157 | 158 | return check_dependents(pixel) 159 | 160 | 161 | # returns the best possible dependent Jimage at or beneath the threshold 162 | def check_dependents(pixel): 163 | best_jimage_distance = 510 164 | best_dependent = Jimage(Image.new('RGBA', (0, 0))) 165 | index = -1 166 | 167 | for dependent_jimage in dependent_jimages: 168 | index += 1 169 | 170 | distance = numpy.linalg.norm(numpy.subtract(dependent_jimage.av_color, pixel)) 171 | 172 | if distance <= image_gui.threshold: 173 | return dependent_jimage 174 | 175 | if distance < best_jimage_distance: 176 | best_jimage_distance = distance 177 | best_dependent = dependent_jimage 178 | 179 | # insert best_dependent into the front of the list 180 | dependent_jimages.insert(0, dependent_jimages.pop(index)) 181 | 182 | return best_dependent 183 | 184 | 185 | # appends the best jimage to the selected jimages for each pixel and updates status 186 | def main(): 187 | i = 0 188 | last_pixel_time = 0 189 | last_pixel_number = 0 190 | 191 | # loops through each pixel of the host_image and appends a dependent_jimage to selected_jimages 192 | for pixel in host_image.pil_image.getdata(): 193 | 194 | pixel_copy = list(pixel) 195 | 196 | if host_image.format == 'JPEG' or host_image.mode == 'RGB': 197 | pixel_copy.append(225) 198 | 199 | jimage = best_jimage(pixel_copy) 200 | selected_jimages.append(jimage) 201 | 202 | # data status estimates/records/prints 203 | i += 1 204 | 205 | if i % numpy.floor(host_image.pixel_count/20) == 0: 206 | 207 | try: 208 | percent = i / host_image.pixel_count * 100 209 | speed = (i - last_pixel_number) / (time.time() - last_pixel_time) 210 | etc_raw = time.gmtime((host_image.pixel_count - i) / speed) 211 | etc = str(etc_raw.tm_hour) + 'hrs : ' + str(etc_raw.tm_min) + 'mins : ' + str(etc_raw.tm_sec) + 'secs' 212 | 213 | print(f'{format(percent, "0.2f")}% complete | pixel ({i} / {host_image.pixel_count}) | ' 214 | f'{format(speed, "0.2f")} pixels/sec | ETC: ({etc})') 215 | 216 | except: 217 | print(f'{format(i / host_image.pixel_count * 100, "0.2f")})% complete | pixel ({i} / ' 218 | f'{host_image.pixel_count} | n/a | n/a') 219 | 220 | last_pixel_time = time.time() 221 | last_pixel_number = i 222 | 223 | elif i == 1: 224 | print(f'{format(0, "0.2f")}% complete | pixel (0 / {host_image.pixel_count}) | n/a | n/a') 225 | 226 | last_pixel_time = time.time() 227 | last_pixel_number = i 228 | 229 | elif i == host_image.pixel_count: 230 | print(f'100% complete | pixel ({i} / {host_image.pixel_count}) | n/a | n/a') 231 | 232 | 233 | # creates a mosaic from the list of selected_jimages 234 | def create_mosaic(): 235 | 236 | mosaic = Image.new('RGBA', (16*host_image.width, 16*host_image.height)) 237 | 238 | rows = host_image.width 239 | cols = host_image.height 240 | 241 | i = 0 242 | 243 | for col in range(cols): 244 | for row in range(rows): 245 | x = row * 16 246 | y = col * 16 247 | mosaic.paste(selected_jimages[i].pil_image, (x, y)) 248 | i += 1 249 | 250 | save_image(mosaic) 251 | 252 | 253 | # saves the mosaic 254 | def save_image(mosaic_image): 255 | print('saving image...') 256 | 257 | # ensures that the new tail is .png 258 | copy_tail = host_image.tail 259 | tail_head = copy_tail.rsplit('.', 1)[0] 260 | new_tail = tail_head + '.png' 261 | 262 | # mkdir 'mosaics' if it does not already exist 263 | if 'mosaics' not in os.listdir(cwd): 264 | os.mkdir('mosaics') 265 | 266 | # if output directory is undefined or not a directory (somehow) 267 | if image_gui.output_directory is None or os.path.isdir(image_gui.output_directory) is False: 268 | image_gui.output_directory = os.path.join(cwd, r"mosaics") 269 | 270 | output_file = os.path.join(image_gui.output_directory, rf'MOSAIC-{new_tail}') 271 | 272 | mosaic_image.save(output_file) 273 | print(f'image saved as "{output_file}"') 274 | 275 | if image_gui.show_image: 276 | print('showing image...') 277 | mosaic_image.show() 278 | 279 | 280 | # little bit of clean up and data print 281 | def final_touches(): 282 | 283 | # removes resized host_image 284 | if host_image.resized: 285 | os.remove(host_image.fp) 286 | 287 | # does not show file explorer if it's on desktop 288 | if os.path.split(image_gui.output_directory)[1] != 'Desktop': 289 | os.startfile(image_gui.output_directory) 290 | 291 | # data print 292 | time_raw = time.gmtime(time.time() - start_time) 293 | time_complete = str(time_raw.tm_hour) + 'hrs : ' + str(time_raw.tm_min) + 'mins : ' + str(time_raw.tm_sec) + 'secs' 294 | 295 | print(f'\nProcess Complete in ({time_complete}) | Parameters used - IT: {image_gui.threshold} RF: ' 296 | f'{image_gui.resolution_divider}') 297 | 298 | print() 299 | 300 | 301 | if __name__ == "__main__": 302 | config_gui() 303 | image_gui.start_get_and_clean() 304 | host_image = HostImage(image_gui.host_image_path) 305 | print('rescaling host image...') 306 | host_image.lower_resolution() 307 | print('retrieving minecraft images...') 308 | dependent_image_configuration() 309 | print('main process initiated...') 310 | print() 311 | main() 312 | print('\nmain process complete') 313 | print('mapping images...') 314 | create_mosaic() 315 | print('deleting resized host image...') 316 | final_touches() 317 | 318 | 319 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sebastian Jimenez 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. 22 | -------------------------------------------------------------------------------- /mc_images.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjimenez100/ImageCraft/013b9a5b2d804c722469dfead7615b7ed8dee8fa/mc_images.zip --------------------------------------------------------------------------------