├── .gitattributes ├── README.md ├── config.json ├── main.py └── requirements.txt /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NFT-Image-Generator 2 | Utility for creating a generative art collection from supplied image layers, especially made for making NFT collectibles. 3 | 4 |
5 | Click here for example images. 6 | 7 | final_images eyes clothes_hats body_horns backgrounds 8 | 9 |
10 | 11 | ## Prerequisites 12 | 1. Clone the repository by opening your Terminal and typing ```git clone https://github.com/sem/NFT-Image-Generator.git```. 13 | 2. Install the dependencies ```pip3 install -r requirements.txt```. 14 | 15 | ## How to use 16 | 1. Get an API key from [Pinata](https://app.pinata.cloud/keys). 17 | 2. Open ``config.json`` and put the JWT (Secret access token) in ``api_key``. 18 | 3. Adapt the config to your liking and make sure there is a sequential number at the beginning of each folder to represent the order of layers. 19 | 4. Run ``main.py``. 20 | 21 | ## File structure 22 | Before you start, make sure your file structure looks something like this: 23 | ``` 24 | NFT-Image-Generator/ 25 | ├─ main.py 26 | ├─ config.json 27 | ├─ 1 background/ 28 | │ ├─ red.png 29 | │ ├─ green.png 30 | │ ├─ blue.png 31 | ├─ 2 body/ 32 | │ ├─ female.png 33 | │ ├─ male.png 34 | │ ├─ zombie.png 35 | ├─ 3 eyes/ 36 | │ ├─ sun_glasses.png 37 | │ ├─ normal_eyes.png 38 | │ ├─ vr_glasses.png 39 | ``` 40 | 41 | ## Features 42 | - [x] Generate metadata for direct use on [OpenSea](https://docs.opensea.io/docs/metadata-standards). 43 | - [x] Being able to update the image URL after the metadata is created. 44 | - [x] Automatically upload images and metadata to [Pinata](https://www.pinata.cloud). 45 | - [x] Ensure that no duplicate images will appear in the collection. 46 | - [x] Create a .GIF profile picture for your collection. 47 | - [x] Give each image a rarity value. 48 | - [x] Influence rarity by giving layers a weight. 49 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_name": "name of your project", 3 | 4 | "amount": 50, 5 | 6 | "profile_images": 5, 7 | 8 | "description": "description of your collection", 9 | 10 | "api_key": "your secret access token", 11 | 12 | "folders": [ 13 | "1 background", 14 | "2 body", 15 | "3 eyes" 16 | ], 17 | 18 | "ignore": [ 19 | ".DS_Store" 20 | ], 21 | 22 | "rarity": { 23 | "3 eyes/normal_eyes.png": 10.0, 24 | "2 body/male.png": 0.5, 25 | "1 background/red.png": 140.0 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | import random 5 | import urllib.parse as up 6 | from datetime import datetime 7 | from io import BytesIO 8 | from itertools import product 9 | from typing import Optional, Dict, List 10 | 11 | from PIL import Image 12 | from termcolor import cprint 13 | from requests.sessions import Session 14 | from requests.exceptions import HTTPError 15 | 16 | 17 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 18 | DATETIME_FMT = '%Y-%m-%dT%H:%M:%S.%fZ' 19 | UPLOAD_URL = 'https://api.pinata.cloud/pinning/pinFileToIPFS' 20 | UPLOAD_JSON_URL = 'https://api.pinata.cloud/pinning/pinJSONToIPFS' 21 | UA_HEADER = 'Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Firefox/91.0' 22 | 23 | 24 | class Config: 25 | def __init__(self, filename): 26 | self.filename = filename 27 | self.data = None 28 | 29 | def __getitem__(self, key): 30 | if self.data is None: 31 | with open(self.filename) as conf: 32 | self.data = json.load(conf) 33 | return self.data[key] 34 | 35 | def get(self, key): 36 | if self.data is None: 37 | with open(self.filename) as conf: 38 | self.data = json.load(conf) 39 | return self.data.get(key) 40 | 41 | 42 | class ImageSource: 43 | """Hold Image source data""" 44 | 45 | def __init__(self, directory: str, filename: str): 46 | self.directory = directory 47 | self.filename = filename 48 | self.rarity_rate = 1.0 49 | # Appearance counter 50 | self.counter = 0 51 | 52 | def __str__(self): 53 | return os.path.join(self.directory, self.filename) 54 | 55 | def __repr__(self): 56 | return self.__str__() 57 | 58 | def as_name(self): 59 | """File nome minus file extension""" 60 | return self.filename.split('.')[0] 61 | 62 | def full_path(self): 63 | """Full path of the image""" 64 | return os.path.join(BASE_DIR, self.directory, self.filename) 65 | 66 | 67 | class ImageResult: 68 | """Hold Image result data""" 69 | # List of image source 70 | img_sources: List[ImageSource] 71 | 72 | def __init__(self, number: int, img_sources: List[ImageSource]): 73 | self.number = number 74 | self.metadata = {} 75 | self.rarity = 0.0 76 | self.img_sources = img_sources 77 | self.buffer = None 78 | 79 | def __str__(self): 80 | return self.filename 81 | 82 | def __repr__(self): 83 | return self.__str__() 84 | 85 | @property 86 | def filename(self): 87 | return f'{self.number}.png' 88 | 89 | @property 90 | def upload_path(self): 91 | return f'images/{self.filename}.png' 92 | 93 | @property 94 | def json_upload_path(self): 95 | return f'json/{self.filename}.json' 96 | 97 | def full_path(self): 98 | """Full path of the image""" 99 | return os.path.join(BASE_DIR, 'output', 'images', self.filename) 100 | 101 | @property 102 | def json_full_path(self): 103 | """Full path of the image""" 104 | return os.path.join( 105 | BASE_DIR, 'output', 'metadata', f'{self.number}.json' 106 | ) 107 | 108 | def save(self): 109 | """ 110 | Save the image result 111 | and return Buffer to be sent to API 112 | """ 113 | new_image = None 114 | attributes = [] 115 | for img_src in self.img_sources: 116 | with Image.open(img_src.full_path()).convert('RGBA') as img: 117 | if new_image is None: 118 | new_image = Image.new(mode='RGBA', size=img.size) 119 | new_image.paste(img, (0, 0), mask=img) 120 | 121 | trait_value = ' '.join(img_src.directory.split(' ')[:-1]).capitalize() 122 | attribute = { 123 | 'trait_value': trait_value, 124 | 'value': os.path.splitext(img_src.filename)[0] 125 | } 126 | attributes.append(attribute) 127 | 128 | # Save to file 129 | new_image.save(self.full_path()) 130 | # Add metadata: attributes 131 | self.metadata.update({'attributes': attributes}) 132 | 133 | # Buffer of current file that to be uploaded to API 134 | self.buffer = BytesIO() 135 | new_image.save(self.buffer, format='PNG') 136 | return attributes 137 | 138 | def save_metadata(self): 139 | with open(self.json_full_path, 'w') as fd: 140 | json.dump(self.metadata, fd, ensure_ascii=False, indent=4) 141 | 142 | 143 | class Main: 144 | def __init__(self): 145 | self.http = Session() 146 | self.config = Config(os.path.join(BASE_DIR, 'config.json')) 147 | 148 | # List of image sources `ImageSource` 149 | self.img_sources = [] 150 | # List of image result `ImageResult` 151 | self.img_results = [] 152 | self.sum_of_rarity_rate = 0.0 153 | 154 | self.products = None 155 | # Dict of product and its rarity rate 156 | self.product_dict = {} 157 | 158 | def setup(self): 159 | # Result folders 160 | for folder in ['images', 'metadata']: 161 | os.makedirs( 162 | os.path.join(BASE_DIR, 'output', folder), exist_ok=True 163 | ) 164 | 165 | # Count source file numbers 166 | possible_combinations = 1 167 | folder_files = [] 168 | custom_rarity_rage = self.config.get('rarity') 169 | for folder in self.config['folders']: 170 | current_path = os.path.join(BASE_DIR, folder) 171 | os.makedirs(current_path, exist_ok=True) 172 | 173 | ignored = self.config['ignore'] or [] 174 | 175 | num_files = 0 176 | file_list = [] 177 | for filename in os.listdir(current_path): 178 | if filename in ignored: 179 | # Skip ignored files 180 | continue 181 | num_files += 1 182 | 183 | img_source = ImageSource(directory=folder, filename=filename) 184 | # Set image source rarity rate (default 1) 185 | cur_file_rarity = custom_rarity_rage.get(str(img_source)) 186 | if cur_file_rarity == 0: 187 | # current image rarity is 0, skipping 188 | cprint(f' Skipping 0% rarity image: {img_source}', 'red') 189 | continue 190 | if cur_file_rarity is not None: 191 | img_source.rarity_rate = float(cur_file_rarity) 192 | self.sum_of_rarity_rate += float(cur_file_rarity) 193 | else: 194 | img_source.rarity_rate = 1.0 195 | self.sum_of_rarity_rate += 1.0 196 | 197 | self.img_sources.append(img_source) 198 | file_list.append(img_source) 199 | 200 | folder_files.append(file_list) 201 | possible_combinations *= num_files 202 | cprint(f'{current_path} contains {num_files} file(s)') 203 | 204 | if possible_combinations <= 0: 205 | cprint('Please make sure that all folders contain images', 'red') 206 | return False 207 | 208 | amount = self.config['amount'] 209 | if amount > possible_combinations: 210 | shutil.rmtree('output') 211 | cprint(f"Can't make {amount} images, " 212 | f"there are only {possible_combinations} possible combinations", 213 | 'red') 214 | return False 215 | 216 | # Image source file combinations 217 | self.products = list(product(*folder_files)) 218 | self.prepare_randomization() 219 | 220 | cprint(f"Successfully created {amount} images", 'green') 221 | return True 222 | 223 | def prepare_randomization(self): 224 | """Preparation actions before `.set_list`""" 225 | counted_list = [] 226 | cprint('\nImage rarity', 'magenta', attrs=['bold']) 227 | 228 | for p in self.products: 229 | rarity_rates = 0 230 | for item in p: 231 | rarity_rates += item.rarity_rate 232 | if item not in counted_list: 233 | counted_list.append(item) 234 | self.sum_of_rarity_rate += item.rarity_rate 235 | self.product_dict[p] = rarity_rates 236 | 237 | # Count mean of rarity rates 238 | sum_perc = 0.0 239 | for counted in counted_list: 240 | perc = round(counted.rarity_rate / self.sum_of_rarity_rate * 100, 2) 241 | cprint(f' {counted.filename:20}: {perc:02} %') 242 | sum_perc += perc 243 | img_rarity_perc = round(sum_perc / len(counted_list), 2) 244 | cprint(f'Average : {img_rarity_perc} %\n', 'magenta', attrs=['bold']) 245 | 246 | def get_random(self): 247 | """Get random product""" 248 | sum_of_rarity = sum(self.product_dict.values()) 249 | rand_score = random.randint(0, sum_of_rarity) 250 | score_count = 0 251 | choice = None 252 | for p, score in self.product_dict.items(): 253 | score_count += score 254 | if rand_score <= score_count: 255 | choice = p 256 | break 257 | product_idx = self.products.index(choice) 258 | 259 | # Remove choice from `products` and `product_dict` 260 | self.products.pop(product_idx) 261 | del self.product_dict[choice] 262 | 263 | # Return product and its rarity rates 264 | rarity_rate = sum([c.rarity_rate for c in choice]) \ 265 | / self.sum_of_rarity_rate * 100 266 | return choice, round(rarity_rate, 2) 267 | 268 | def set_list(self): 269 | for num in range(self.config['amount']): 270 | choice, rarity_rate = self.get_random() 271 | img_result = ImageResult(num, choice) 272 | # save to file 273 | attributes = img_result.save() 274 | img_result.save_metadata() 275 | 276 | # Add metadata: description and name 277 | metadata = { 278 | 'description': self.config['description'], 279 | 'name': f'{self.config["project_name"]}#{img_result.number}', 280 | 'rarity': rarity_rate, 281 | 'attributes': attributes 282 | } 283 | img_result.metadata.update(metadata) 284 | self.img_results.append(img_result) 285 | 286 | def upload_all(self): 287 | cprint('\nUploading...\n', attrs=['bold']) 288 | 289 | image_files = [] 290 | image_data = {} 291 | for img in self.img_results: 292 | image_files.append(('file', ( 293 | f'images/{img.upload_path}', img.buffer.getvalue() 294 | ))) 295 | img_name = f'{self.config["project_name"]}#{img.number}' 296 | image_data[img_name] = img.metadata 297 | 298 | metadata = { 299 | 'name': self.config['project_name'], 300 | 'description': self.config['description'], 301 | 'keyvalues': image_data, 302 | } 303 | 304 | # images upload, retry 3 times 305 | count, response = 0, None 306 | while count <= 3: 307 | response = self.upload_files(image_files, metadata) 308 | count += 1 309 | if response is not None: 310 | break 311 | if count >= 3 or response is None: 312 | cprint('Images upload failed', 'red') 313 | return 314 | cprint(f'Images upload response: {json.dumps(response, indent=2)}', 315 | 'green') 316 | 317 | ipfs_hash = response['IpfsHash'] 318 | # the https version: f'https://gateway.pinata.cloud/ipfs/{ipfs_hash}' 319 | ipfs_url = f'ipfs://{ipfs_hash}' 320 | timestamp = datetime.strptime(response['Timestamp'], DATETIME_FMT) 321 | date = int(timestamp.timestamp() * 1000) 322 | 323 | json_files = [] 324 | for img in self.img_results: 325 | img.metadata.update({ 326 | 'image': f'{ipfs_url}/' + up.quote(img.filename), 327 | 'date': date 328 | }) 329 | img.save_metadata() 330 | json_files.append(('file', ( 331 | img.json_upload_path, open(img.json_full_path, 'rb') 332 | ))) 333 | 334 | json_metadata = { 335 | 'name': f'{self.config["project_name"]} metadata', 336 | 'description': self.config['description'], 337 | } 338 | # images upload, retry 3 times 339 | count, response = 0, None 340 | while count <= 3: 341 | response = self.upload_files(json_files, json_metadata) 342 | if response is not None: 343 | break 344 | count += 1 345 | if count >= 3 or response is None: 346 | cprint('JSON upload failed', 'red') 347 | return 348 | cprint(f'JSON upload response: {json.dumps(response, indent=2)}', 349 | 'green') 350 | 351 | def upload_files(self, files, metadata): 352 | try: 353 | resp = self.http.post( 354 | UPLOAD_URL, 355 | headers={ 356 | 'Authorization': f'Bearer {self.config["api_key"]}', 357 | 'User-Agent': UA_HEADER 358 | }, 359 | files=files, 360 | json={'pinataMetadata': json.dumps(metadata)}, 361 | timeout=500, 362 | ) 363 | resp.raise_for_status() 364 | response = resp.json() 365 | return response 366 | except HTTPError as exc: 367 | try: 368 | cprint(f'HTTP ERROR: {exc.response.json()}', 'red') 369 | except: 370 | cprint(f'HTTP ERROR: {exc}', 'red') 371 | except Exception as exc: 372 | cprint(f'ERROR: {exc}', 'red') 373 | 374 | def manual_upload_all(self): 375 | cprint('\nManual Upload...', 'green', attrs=['bold']) 376 | 377 | cprint('Metadata', 'green') 378 | ipfs_hash = input('IpfsHash: ') 379 | timestamp = input('Timestamp: ') or None 380 | 381 | # the https version: f'https://gateway.pinata.cloud/ipfs/{ipfs_hash}' 382 | ipfs_url = f'ipfs://{ipfs_hash}' 383 | if timestamp is not None: 384 | timestamp = datetime.strptime(timestamp, DATETIME_FMT) 385 | date = int(timestamp.timestamp() * 1000) 386 | else: 387 | date = None 388 | 389 | for img in self.img_results: 390 | img.metadata['image'] = f'{ipfs_url}/' + up.quote(img.filename) 391 | if date is not None: 392 | img.metadata['date'] = date 393 | img.save_metadata() 394 | 395 | cprint(f'JSON metadata is updated...', 'green') 396 | 397 | def save_gif(self): 398 | assert self.img_results, 'Result images is not saved' 399 | 400 | frames = [] 401 | # for img in self.img_results: 402 | for img in self.img_results[:self.config['profile_images']]: 403 | frames.append(Image.open(img.full_path())) 404 | 405 | frames[0].save( 406 | os.path.join(BASE_DIR, 'output', 'profile.gif'), 407 | format='GIF', 408 | append_images=frames[1:], 409 | save_all=True, duration=230, loop=0 410 | ) 411 | 412 | 413 | def main(): 414 | m = Main() 415 | m.setup() 416 | m.set_list() 417 | 418 | ## API upload 419 | m.upload_all() 420 | 421 | ## For manual files upload 422 | #m.manual_upload_all() 423 | 424 | m.save_gif() 425 | 426 | 427 | if __name__ == '__main__': 428 | main() 429 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow==9.0.0 2 | requests==2.26.0 3 | termcolor==1.1.0 4 | --------------------------------------------------------------------------------