├── tests ├── __init__.py └── test_legofy.py ├── requirements.txt ├── legofy ├── assets │ ├── bacon.gif │ ├── brick.ico │ ├── flower.jpg │ ├── bricks │ │ └── 1x1.png │ └── flower_lego.png ├── cli.py ├── legofy_gui.py ├── palettes.py ├── __init__.py ├── images2gif_py2.py └── images2gif_py3.py ├── 2010-LEGO-color-palette.pdf ├── .coveragerc ├── MANIFEST.in ├── .travis.yml ├── .gitignore ├── setup.py ├── LICENSE └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pillow>=3.0.0 2 | click>=5.1 -------------------------------------------------------------------------------- /legofy/assets/bacon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuanPotato/Legofy/HEAD/legofy/assets/bacon.gif -------------------------------------------------------------------------------- /legofy/assets/brick.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuanPotato/Legofy/HEAD/legofy/assets/brick.ico -------------------------------------------------------------------------------- /legofy/assets/flower.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuanPotato/Legofy/HEAD/legofy/assets/flower.jpg -------------------------------------------------------------------------------- /2010-LEGO-color-palette.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuanPotato/Legofy/HEAD/2010-LEGO-color-palette.pdf -------------------------------------------------------------------------------- /legofy/assets/bricks/1x1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuanPotato/Legofy/HEAD/legofy/assets/bricks/1x1.png -------------------------------------------------------------------------------- /legofy/assets/flower_lego.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuanPotato/Legofy/HEAD/legofy/assets/flower_lego.png -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | legofy/images2gif_py* 4 | legofy/cli.py 5 | legofy/legofy_gui.py 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include legofy/assets/bricks *.png 2 | include legofy/images2gif_py2.py 3 | include legofy/images2gif_py3.py 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Use container based infrastructure 2 | sudo: false 3 | 4 | language: python 5 | 6 | addons: 7 | apt: 8 | packages: 9 | - imagemagick 10 | 11 | python: 12 | - "3.6" 13 | - "3.7" 14 | - "3.8" 15 | - "3.9" 16 | 17 | install: 18 | - pip install --quiet -r requirements.txt 19 | # Separate the coveralls package because it is only a test requirement 20 | - pip install --quiet coveralls 21 | 22 | script: 23 | - nosetests --with-coverage --cover-package=legofy 24 | - python setup.py install 25 | - legofy legofy/assets/flower.jpg flower_lego.png 26 | - legofy --palette all legofy/assets/flower.jpg flower_lego_all.png 27 | 28 | after_success: 29 | - coveralls 30 | -------------------------------------------------------------------------------- /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Pycharm stuff: 56 | .idea/ 57 | 58 | # Sphinx documentation 59 | docs/_build/ 60 | 61 | # PyBuilder 62 | target/ -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup( 6 | name="legofy", 7 | version="1.0.0", 8 | author="Juan Potato", 9 | author_email="juanpotatodev@gmail.com", 10 | url="https://github.com/JuanPotato/Legofy", 11 | description="Make images look as if they are made out of 1x1 LEGO blocks", 12 | long_description=("Legofy is a python program that takes a static image or" 13 | " gif and makes it so that it looks as if it was built " 14 | "out of LEGO."), 15 | classifiers=[ 16 | 'Development Status :: 4 - Beta', 17 | 'Programming Language :: Python', 18 | ], 19 | license="MIT", 20 | packages=['legofy'], 21 | install_requires=['pillow', 'click'], 22 | include_package_data=True, 23 | entry_points={ 24 | 'console_scripts': [ 25 | 'legofy = legofy.cli:main', 26 | ], 27 | }, 28 | package_data={ 29 | 'bricks': ['*.png'], 30 | }, 31 | test_suite="nose.collector", 32 | tests_require=['nose'], 33 | ) 34 | -------------------------------------------------------------------------------- /legofy/cli.py: -------------------------------------------------------------------------------- 1 | '''Command line interface to Legofy''' 2 | import click 3 | import legofy 4 | from legofy import palettes 5 | 6 | 7 | @click.command() 8 | @click.argument('image', required=True, type=click.Path(dir_okay=False, 9 | exists=True, 10 | resolve_path=True)) 11 | @click.argument('output', default=None, required=False, 12 | type=click.Path(resolve_path=True)) 13 | @click.option('--size', default=None, type=int, 14 | help='Number of bricks the longest side of the legofied image should have.') 15 | @click.option('--dither/--no-dither', default=False, 16 | help='Use dither algorithm to spread the color approximation error.') 17 | @click.option('--palette', default=None, 18 | type=click.Choice(palettes.legos().keys()), 19 | help='Palette to use based on real Lego colors.') 20 | def main(image, output, size, palette, dither): 21 | '''Legofy an image!''' 22 | legofy.main(image, output_path=output, size=size, 23 | palette_mode=palette, dither=dither) 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Juan Potato 4 | All rights reserved 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /legofy/legofy_gui.py: -------------------------------------------------------------------------------- 1 | import os 2 | import legofy 3 | import tkinter as tk 4 | import tkinter.ttk as ttk 5 | from tkinter import filedialog 6 | import tkinter.messagebox as tkmsg 7 | 8 | LEGO_PALETTE = ('none', 'solid', 'transparent', 'effects', 'mono', 'all', ) 9 | 10 | class LegofyGui(tk.Tk): 11 | def __init__(self, *args, **kwargs): 12 | super().__init__(*args, **kwargs) 13 | self.wm_title("Legofy!") 14 | self.iconbitmap(os.path.dirname(os.path.realpath(__file__)) + '/assets/brick.ico') 15 | self.resizable(False, False) 16 | self.body = LegofyGuiMainFrame(self) 17 | self.body.grid(row=0, column=0, padx=10, pady=10) 18 | 19 | 20 | class LegofyGuiMainFrame(tk.Frame): 21 | 22 | def __init__(self, *args, **kwargs): 23 | super().__init__(*args, **kwargs) 24 | 25 | self.chosenFile = None 26 | self.chosenFilePath = tk.StringVar() 27 | 28 | self.pathField = tk.Entry(self, width=40, textvariable=self.chosenFilePath, state=tk.DISABLED) 29 | self.pathField.grid(row=0, column=0, padx=10) 30 | 31 | self.selectFile = tk.Button(self, text="Choose file...", command=self.choose_a_file) 32 | self.selectFile.grid(row=0, column=1) 33 | 34 | self.groupFrame = tk.LabelFrame(self, text="Params", padx=5, pady=5) 35 | self.groupFrame.grid(row=1, column=0, columnspan=2, ) 36 | 37 | self.colorPaletteLabel = tk.Label(self.groupFrame, text = 'Color Palette') 38 | self.colorPaletteLabel.grid(row=0, column=0 ) 39 | 40 | self.colorPalette = ttk.Combobox(self.groupFrame) 41 | self.colorPalette['values'] = LEGO_PALETTE 42 | self.colorPalette.current(0) 43 | self.colorPalette.grid(row=0, column=1) 44 | 45 | self.brickNumberScale = tk.Scale(self.groupFrame, from_=1, to=200, orient=tk.HORIZONTAL, label="Number of bricks (longer edge)", length=250) 46 | self.brickNumberScale.set(30) 47 | self.brickNumberScale.grid(row=1, column=0, columnspan=2, ) 48 | 49 | self.convertFile = tk.Button(text="Legofy this image!", command=self.convert_file) 50 | self.convertFile.grid(row=2, column=0, columnspan=2) 51 | 52 | 53 | def choose_a_file(self): 54 | 55 | options = {} 56 | options['defaultextension'] = '.jpg' 57 | options['filetypes'] = [('JPEG', '.jpg'), 58 | ('GIF', '.gif'), 59 | ('PNG', '.png'),] 60 | options['initialdir'] = os.path.realpath("\\") 61 | options['initialfile'] = '' 62 | options['parent'] = self 63 | options['title'] = 'Choose a file' 64 | 65 | self.chosenFile = filedialog.askopenfile(mode='r', **options) 66 | if self.chosenFile: 67 | self.chosenFilePath.set(self.chosenFile.name) 68 | 69 | 70 | def convert_file(self): 71 | try: 72 | if self.chosenFile is not None: 73 | 74 | palette = self.colorPalette.get() 75 | 76 | if palette in LEGO_PALETTE and palette != 'none': 77 | legofy.main(self.chosenFile.name, size=self.brickNumberScale.get(), palette_mode=palette) 78 | else: 79 | legofy.main(self.chosenFile.name, size=self.brickNumberScale.get()) 80 | 81 | tkmsg.showinfo("Success!", "Your image has been legofied!") 82 | else: 83 | tkmsg.showerror("File not found", "Please select a file before legofying") 84 | except Exception as e: 85 | tkmsg.showerror("Error", str(e)) 86 | 87 | 88 | 89 | if __name__ == '__main__': 90 | app = LegofyGui() 91 | app.mainloop() 92 | -------------------------------------------------------------------------------- /legofy/palettes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | legofy.palettes 5 | --------------- 6 | 7 | This module contains the `lego` palette mappings. 8 | 9 | Color mapping source; 10 | - http://www.brickjournal.com/files/PDFs/2010LEGOcolorpalette.pdf 11 | 12 | 13 | USAGE: 14 | $ legofy.palettes.legos 15 | 16 | See README for project details. 17 | """ 18 | from __future__ import division 19 | 20 | 21 | LEGOS = { 22 | 'solid': { 23 | '024': [0xfe, 0xc4, 0x01], 24 | '106': [0xe7, 0x64, 0x19], 25 | '021': [0xde, 0x01, 0x0e], 26 | '221': [0xde, 0x38, 0x8b], 27 | '023': [0x01, 0x58, 0xa8], 28 | '028': [0x01, 0x7c, 0x29], 29 | '119': [0x95, 0xb9, 0x0c], 30 | '192': [0x5c, 0x1d, 0x0d], 31 | '018': [0xd6, 0x73, 0x41], 32 | '001': [0xf4, 0xf4, 0xf4], 33 | '026': [0x02, 0x02, 0x02], 34 | '226': [0xff, 0xff, 0x99], 35 | '222': [0xee, 0x9d, 0xc3], 36 | '212': [0x87, 0xc0, 0xea], 37 | '037': [0x01, 0x96, 0x25], 38 | '005': [0xd9, 0xbb, 0x7c], 39 | '283': [0xf5, 0xc1, 0x89], 40 | '208': [0xe4, 0xe4, 0xda], 41 | '191': [0xf4, 0x9b, 0x01], 42 | '124': [0x9c, 0x01, 0xc6], 43 | '102': [0x48, 0x8c, 0xc6], 44 | '135': [0x5f, 0x75, 0x8c], 45 | '151': [0x60, 0x82, 0x66], 46 | '138': [0x8d, 0x75, 0x53], 47 | '038': [0xa8, 0x3e, 0x16], 48 | '194': [0x9c, 0x92, 0x91], 49 | '154': [0x80, 0x09, 0x1c], 50 | '268': [0x2d, 0x16, 0x78], 51 | '140': [0x01, 0x26, 0x42], 52 | '141': [0x01, 0x35, 0x17], 53 | '312': [0xaa, 0x7e, 0x56], 54 | '199': [0x4d, 0x5e, 0x57], 55 | '308': [0x31, 0x10, 0x07] 56 | }, 57 | 58 | 'transparent': { 59 | '044': [0xf9, 0xef, 0x69], 60 | '182': [0xec, 0x76, 0x0e], 61 | '047': [0xe7, 0x66, 0x48], 62 | '041': [0xe0, 0x2a, 0x29], 63 | '113': [0xee, 0x9d, 0xc3], 64 | '126': [0x9c, 0x95, 0xc7], 65 | '042': [0xb6, 0xe0, 0xea], 66 | '043': [0x50, 0xb1, 0xe8], 67 | '143': [0xce, 0xe3, 0xf6], 68 | '048': [0x63, 0xb2, 0x6e], 69 | '311': [0x99, 0xff, 0x66], 70 | '049': [0xf1, 0xed, 0x5b], 71 | '111': [0xa6, 0x91, 0x82], 72 | '040': [0xee, 0xee, 0xee] 73 | }, 74 | 75 | 'effects': { 76 | '131': [0x8d, 0x94, 0x96], 77 | '297': [0xaa, 0x7f, 0x2e], 78 | '148': [0x49, 0x3f, 0x3b], 79 | '294': [0xfe, 0xfc, 0xd5] 80 | }, 81 | 82 | 'mono': { 83 | '001': [0xf4, 0xf4, 0xf4], 84 | '026': [0x02, 0x02, 0x02] 85 | }, 86 | } 87 | 88 | 89 | def extend_palette(palette, colors=256, rgb=3): 90 | """Extend palette colors to 256 rgb sets.""" 91 | missing_colors = colors - len(palette)//rgb 92 | if missing_colors > 0: 93 | first_color = palette[:rgb] 94 | palette += first_color * missing_colors 95 | return palette[:colors*rgb] 96 | 97 | 98 | def legos(): 99 | """Build flattened lego palettes.""" 100 | return _flatten_palettes(LEGOS.copy()) 101 | 102 | 103 | def _flatten_palettes(palettes): 104 | """Convert palette mappings into color list.""" 105 | flattened = {} 106 | palettes = _merge_palettes(palettes) 107 | for palette in palettes: 108 | flat = [i for sub in palettes[palette].values() for i in sub] 109 | flattened.update({palette: flat}) 110 | return flattened 111 | 112 | 113 | def _merge_palettes(palettes): 114 | """Build unified palette using all colors.""" 115 | unified = {} 116 | for palette in palettes: 117 | for item in palettes[palette]: 118 | unified.update({item: palettes[palette][item]}) 119 | palettes.update({'all': unified}) 120 | return palettes 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Legofy [![Build Status](https://travis-ci.org/JuanPotato/Legofy.svg?branch=master)](https://travis-ci.org/JuanPotato/Legofy) [![PyPI Downloads](https://img.shields.io/pypi/dm/legofy.svg)](https://pypi.python.org/pypi/legofy) [![PyPI version](https://img.shields.io/pypi/v/legofy.svg)](https://pypi.python.org/pypi/legofy) [![License](https://img.shields.io/pypi/l/legofy.svg)](https://pypi.python.org/pypi/legofy) [![Coverage Status](https://coveralls.io/repos/JuanPotato/Legofy/badge.svg?branch=master&service=github)](https://coveralls.io/github/JuanPotato/Legofy?branch=master) [![Code Health](https://landscape.io/github/JuanPotato/Legofy/master/landscape.svg?style=flat)](https://landscape.io/github/JuanPotato/Legofy/master) [![Join the chat at https://gitter.im/JuanPotato/Legofy](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/JuanPotato/Legofy?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 2 | 3 | 4 | ### What is it? 5 | Legofy is a python program that takes a static image or gif and makes it so that it looks as if it was built out of LEGO. 6 | 7 | 8 | Before 9 | 10 | After 11 | 12 | 13 | ### Requirements 14 | * Python 15 | * Python modules: Pillow, click # pip will install these automatically if using `pip install legofy` 16 | * imagemagick # not needed but recommended 17 | 18 | ### Bugs 19 | If you find a bug: 20 | 1. Check in the [open issues](https://github.com/JuanPotato/Legofy/issues) if the bug already exists. 21 | 2. If the bug is not there, create a [new issue](https://github.com/JuanPotato/Legofy/issues/new) with clear steps on how to reproduce it. 22 | 23 | ### Quickstart 24 | ```shell 25 | $ pip install legofy 26 | ``` 27 | or install from source 28 | ```shell 29 | $ git clone https://github.com/JuanPotato/Legofy.git 30 | $ cd Legofy 31 | $ python setup.py install 32 | ``` 33 | Wait! I don't know what any of this means? Use pip then, or if you really want to install from source [have some help.](#installation) 34 | 35 | ### Usage 36 | ``` 37 | Usage: legofy [OPTIONS] IMAGE [OUTPUT] 38 | 39 | Legofy an image! 40 | 41 | Options: 42 | --size INTEGER Number of bricks the longest side of the legofied image should have. 43 | --dither / --no-dither Use dither algorithm to spread the color approximation error. 44 | --palette [all|effects|mono|solid|transparent] 45 | Palette to use based on real Lego colors. 46 | --help Show this message and exit. 47 | ``` 48 | 49 | #### Palette 50 | There are 3 palettes: solid (33 colors), transparent (14 colors) and effects (4 colors). 51 | You can use one of them or all the 3. 52 | ```shell 53 | $ legofy --palette solid image.jpg 54 | $ legofy --palette transparent image.jpg 55 | $ legofy --palette effects image.jpg 56 | $ legofy --palette all image.jpg 57 | ``` 58 | There is another one palette, mono, with only 2 colors (black and white...). It's just for test and fun... 59 | 60 | 61 | ### Troubleshooting 62 | * [Mac](http://pillow.readthedocs.org/en/3.0.x/installation.html#os-x-installation) 63 | * [Linux](http://pillow.readthedocs.org/en/3.0.x/installation.html#linux-installation) 64 | * [Windows](http://pillow.readthedocs.org/en/3.0.x/installation.html#windows-installation) 65 | 66 | ### Installation 67 | 1. Download and install all requirements 68 | * python from the [official python website](https://www.python.org/) 69 | * imagemagick from the [official imagemagick website](https://imagemagick.org/) 70 | 2. Download this project by using the download zip button on this page, or running `git clone https://github.com/JuanPotato/Legofy` 71 | * If you downloaded a zip file, please unzip it 72 | 3. Open a command line and navigate to the project folder 73 | 4. Run `python setup.py install` while in the project folder 74 | 5. You can now use Legofy anywhere, see [usage](#usage) for more help 75 | 76 | ### Forks 77 | 78 | * JavaScript: [Legofy](https://github.com/Wildhoney/Legofy) 79 | -------------------------------------------------------------------------------- /tests/test_legofy.py: -------------------------------------------------------------------------------- 1 | '''Unit tests for legofy''' 2 | # They can be run individually, for example: 3 | # nosetests tests.test_legofy:Create.test_legofy_image 4 | import os 5 | import tempfile 6 | import unittest 7 | from PIL import Image 8 | 9 | import legofy 10 | 11 | TEST_DIR = os.path.realpath(os.path.dirname(__file__)) 12 | FLOWER_PATH = os.path.join(TEST_DIR, '..', 'legofy', 'assets', 'flower.jpg') 13 | 14 | 15 | class Create(unittest.TestCase): 16 | '''Unit tests that create files.''' 17 | 18 | def setUp(self): 19 | self.out_path = None 20 | 21 | def tearDown(self): 22 | if self.out_path: 23 | os.remove(self.out_path) 24 | 25 | def create_tmpfile(self, suffix): 26 | '''Creates a temporary file and stores the path in self.out_path''' 27 | handle, self.out_path = tempfile.mkstemp(prefix='lego_', suffix=suffix) 28 | os.close(handle) 29 | self.assertTrue(os.path.exists(self.out_path)) 30 | self.assertTrue(os.path.getsize(self.out_path) == 0) 31 | 32 | def test_legofy_image(self): 33 | '''Can we legofy a static image?''' 34 | self.create_tmpfile('.png') 35 | self.assertTrue(os.path.exists(FLOWER_PATH), 36 | "Could not find image : {0}".format(FLOWER_PATH)) 37 | 38 | legofy.main(FLOWER_PATH, output_path=self.out_path) 39 | self.assertTrue(os.path.getsize(self.out_path) > 0) 40 | 41 | def test_legofy_gif(self): 42 | '''Can we legofy a gif?''' 43 | self.create_tmpfile('.gif') 44 | gif_path = os.path.join(TEST_DIR, '..', 'legofy', 'assets', 'bacon.gif') 45 | self.assertTrue(os.path.exists(gif_path), 46 | "Could not find image : {0}".format(gif_path)) 47 | legofy.main(gif_path, output_path=self.out_path) 48 | self.assertTrue(os.path.getsize(self.out_path) > 0) 49 | 50 | def test_legofy_palette(self): 51 | '''Can we use a palette?''' 52 | self.create_tmpfile('.png') 53 | self.assertTrue(os.path.exists(FLOWER_PATH), 54 | "Could not find image : {0}".format(FLOWER_PATH)) 55 | out = self.out_path 56 | legofy.main(FLOWER_PATH, output_path=out, palette_mode='solid') 57 | legofy.main(FLOWER_PATH, output_path=out, palette_mode='transparent') 58 | legofy.main(FLOWER_PATH, output_path=out, palette_mode='effects') 59 | legofy.main(FLOWER_PATH, output_path=out, palette_mode='mono') 60 | legofy.main(FLOWER_PATH, output_path=out, palette_mode='all') 61 | self.assertTrue(os.path.getsize(out) > 0) 62 | 63 | def test_bricks_parameter(self): 64 | '''Can we specify the --brick parameter and is the file size 65 | proportional?''' 66 | self.create_tmpfile('.png') 67 | legofy.main(FLOWER_PATH, output_path=self.out_path, size=5) 68 | size5 = os.path.getsize(self.out_path) 69 | legofy.main(FLOWER_PATH, output_path=self.out_path, size=10) 70 | size10 = os.path.getsize(self.out_path) 71 | self.assertTrue(size5 > 0) 72 | self.assertTrue(size10 > size5) 73 | 74 | def test_small_brick(self): 75 | '''Test hitting the minimal brick size''' 76 | self.create_tmpfile('.png') 77 | legofy.main(FLOWER_PATH, output_path=self.out_path, size=1) 78 | self.assertTrue(Image.open(self.out_path).size == (30, 30)) 79 | 80 | def test_dither_without_palette(self): 81 | '''Dithering without a palette should still work''' 82 | self.create_tmpfile('.png') 83 | legofy.main(FLOWER_PATH, output_path=self.out_path, dither=True) 84 | self.assertTrue(os.path.getsize(self.out_path) > 0) 85 | 86 | 87 | class Functions(unittest.TestCase): 88 | '''Test the behaviour of individual functions''' 89 | 90 | def test_get_new_filename(self): 91 | '''Test the default output filename generation''' 92 | # Is the generated path in the same directory? 93 | new_path = legofy.get_new_filename(FLOWER_PATH) 94 | self.assertTrue(os.path.dirname(FLOWER_PATH) == 95 | os.path.dirname(new_path)) 96 | # Is the generated path unique? 97 | self.assertFalse(os.path.exists(new_path), 98 | "Should not find image : {0}".format(new_path)) 99 | # Test default file extensions 100 | self.assertTrue(new_path.endswith('_lego.jpg')) 101 | new_path = legofy.get_new_filename(FLOWER_PATH, '.gif') 102 | self.assertTrue(new_path.endswith('_lego.gif')) 103 | 104 | def test_lego_palette_structure(self): 105 | """Validate lego palettes structured in 3's.""" 106 | legos = legofy.palettes.legos() 107 | for palette in legos: 108 | self.assertFalse(len(legos[palette]) % 3) 109 | 110 | def test_lego_palette_length(self): 111 | """PIL palette requires 768 ints (256 colors * RGB).""" 112 | legos = legofy.palettes.legos() 113 | for palette in legos: 114 | self.assertTrue(len(legofy.palettes.extend_palette(palette)) == 768) 115 | 116 | 117 | class Failures(unittest.TestCase): 118 | '''Make sure things fail when they should''' 119 | def test_bad_image_path(self): 120 | '''Test invalid image path''' 121 | fake_path = os.path.join(TEST_DIR, 'fake_image.jpg') 122 | self.assertFalse(os.path.exists(fake_path), 123 | "Should not find image : {0}".format(fake_path)) 124 | self.assertRaises(SystemExit, legofy.main, fake_path) 125 | 126 | if __name__ == '__main__': 127 | unittest.main() 128 | -------------------------------------------------------------------------------- /legofy/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from PIL import Image, ImageSequence 4 | import sys 5 | import os 6 | 7 | # Python 2 and 3 support 8 | # TODO: Proper images2gif version that supports both Py 2 and Py 3 (mostly handling binary data) 9 | if sys.version_info < (3,): 10 | import legofy.images2gif_py2 as images2gif 11 | else: 12 | import legofy.images2gif_py3 as images2gif 13 | from legofy import palettes 14 | 15 | 16 | def apply_color_overlay(image, color): 17 | '''Small function to apply an effect over an entire image''' 18 | overlay_red, overlay_green, overlay_blue = color 19 | channels = image.split() 20 | 21 | r = channels[0].point(lambda color: overlay_effect(color, overlay_red)) 22 | g = channels[1].point(lambda color: overlay_effect(color, overlay_green)) 23 | b = channels[2].point(lambda color: overlay_effect(color, overlay_blue)) 24 | 25 | 26 | channels[0].paste(r) 27 | channels[1].paste(g) 28 | channels[2].paste(b) 29 | 30 | return Image.merge(image.mode, channels) 31 | 32 | def overlay_effect(color, overlay): 33 | '''Actual overlay effect function''' 34 | if color < 33: 35 | return overlay - 100 36 | elif color > 233: 37 | return overlay + 100 38 | else: 39 | return overlay - 133 + color 40 | 41 | def make_lego_image(thumbnail_image, brick_image): 42 | '''Create a lego version of an image from an image''' 43 | base_width, base_height = thumbnail_image.size 44 | brick_width, brick_height = brick_image.size 45 | 46 | rgb_image = thumbnail_image.convert('RGB') 47 | 48 | lego_image = Image.new("RGB", (base_width * brick_width, 49 | base_height * brick_height), "white") 50 | 51 | for brick_x in range(base_width): 52 | for brick_y in range(base_height): 53 | color = rgb_image.getpixel((brick_x, brick_y)) 54 | lego_image.paste(apply_color_overlay(brick_image, color), 55 | (brick_x * brick_width, brick_y * brick_height)) 56 | return lego_image 57 | 58 | 59 | def get_new_filename(file_path, ext_override=None): 60 | '''Returns the save destination file path''' 61 | folder, basename = os.path.split(file_path) 62 | base, extention = os.path.splitext(basename) 63 | if ext_override: 64 | extention = ext_override 65 | new_filename = os.path.join(folder, "{0}_lego{1}".format(base, extention)) 66 | return new_filename 67 | 68 | 69 | def get_new_size(base_image, brick_image, size=None): 70 | '''Returns a new size the first image should be so that the second one fits neatly in the longest axis''' 71 | new_size = base_image.size 72 | if size: 73 | scale_x, scale_y = size, size 74 | else: 75 | scale_x, scale_y = brick_image.size 76 | 77 | if new_size[0] > scale_x or new_size[1] > scale_y: 78 | if new_size[0] < new_size[1]: 79 | scale = new_size[1] / scale_y 80 | else: 81 | scale = new_size[0] / scale_x 82 | 83 | new_size = (int(round(new_size[0] / scale)) or 1, 84 | int(round(new_size[1] / scale)) or 1) 85 | 86 | return new_size 87 | 88 | def get_lego_palette(palette_mode): 89 | '''Gets the palette for the specified lego palette mode''' 90 | legos = palettes.legos() 91 | palette = legos[palette_mode] 92 | return palettes.extend_palette(palette) 93 | 94 | 95 | def apply_thumbnail_effects(image, palette, dither): 96 | '''Apply effects on the reduced image before Legofying''' 97 | palette_image = Image.new("P", (1, 1)) 98 | palette_image.putpalette(palette) 99 | return image.im.convert("P", 100 | Image.FLOYDSTEINBERG if dither else Image.NONE, 101 | palette_image.im) 102 | 103 | def legofy_gif(base_image, brick_image, output_path, size, palette_mode, dither): 104 | '''Alternative function that legofies animated gifs, makes use of images2gif - uses numpy!''' 105 | im = base_image 106 | 107 | # Read original image duration 108 | original_duration = im.info['duration'] 109 | 110 | # Split image into single frames 111 | frames = [frame.copy() for frame in ImageSequence.Iterator(im)] 112 | 113 | # Create container for converted images 114 | frames_converted = [] 115 | 116 | print("Number of frames to convert: " + str(len(frames))) 117 | 118 | # Iterate through single frames 119 | for i, frame in enumerate(frames, 1): 120 | print("Converting frame number " + str(i)) 121 | 122 | new_size = get_new_size(frame, brick_image, size) 123 | frame = frame.resize(new_size, Image.LANCZOS) 124 | if palette_mode: 125 | palette = get_lego_palette(palette_mode) 126 | frame = apply_thumbnail_effects(frame, palette, dither) 127 | new_frame = make_lego_image(frame, brick_image) 128 | frames_converted.append(new_frame) 129 | 130 | # Make use of images to gif function 131 | images2gif.writeGif(output_path, frames_converted, duration=original_duration/1000.0, dither=0, subRectangles=False) 132 | 133 | def legofy_image(base_image, brick_image, output_path, size, palette_mode, dither): 134 | '''Legofy an image''' 135 | new_size = get_new_size(base_image, brick_image, size) 136 | base_image = base_image.resize(new_size, Image.LANCZOS) 137 | 138 | if palette_mode: 139 | palette = get_lego_palette(palette_mode) 140 | base_image = apply_thumbnail_effects(base_image, palette, dither) 141 | make_lego_image(base_image, brick_image).save(output_path) 142 | 143 | 144 | def main(image_path, output_path=None, size=None, 145 | palette_mode=None, dither=False): 146 | '''Legofy image or gif with brick_path mask''' 147 | image_path = os.path.realpath(image_path) 148 | if not os.path.isfile(image_path): 149 | print('Image file "{0}" was not found.'.format(image_path)) 150 | sys.exit(1) 151 | 152 | brick_path = os.path.join(os.path.dirname(__file__), "assets", 153 | "bricks", "1x1.png") 154 | 155 | if not os.path.isfile(brick_path): 156 | print('Brick asset "{0}" was not found.'.format(brick_path)) 157 | sys.exit(1) 158 | 159 | base_image = Image.open(image_path) 160 | brick_image = Image.open(brick_path) 161 | 162 | if palette_mode: 163 | print ("LEGO Palette {0} selected...".format(palette_mode.title())) 164 | elif dither: 165 | palette_mode = 'all' 166 | 167 | if image_path.lower().endswith(".gif") and base_image.is_animated: 168 | if output_path is None: 169 | output_path = get_new_filename(image_path) 170 | print("Animated gif detected, will now legofy to {0}".format(output_path)) 171 | legofy_gif(base_image, brick_image, output_path, size, palette_mode, dither) 172 | else: 173 | if output_path is None: 174 | output_path = get_new_filename(image_path, '.png') 175 | print("Static image detected, will now legofy to {0}".format(output_path)) 176 | legofy_image(base_image, brick_image, output_path, size, palette_mode, dither) 177 | 178 | base_image.close() 179 | brick_image.close() 180 | print("Finished!") 181 | -------------------------------------------------------------------------------- /legofy/images2gif_py2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2012, Almar Klein, Ant1, Marius van Voorden 3 | # 4 | # This code is subject to the (new) BSD license: 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # * Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # * Redistributions in binary form must reproduce the above copyright 11 | # notice, this list of conditions and the following disclaimer in the 12 | # documentation and/or other materials provided with the distribution. 13 | # * Neither the name of the nor the 14 | # names of its contributors may be used to endorse or promote products 15 | # derived from this software without specific prior written permission. 16 | # 17 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 20 | # ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 21 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 24 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | 28 | """ Module images2gif 29 | 30 | Provides functionality for reading and writing animated GIF images. 31 | Use writeGif to write a series of numpy arrays or PIL images as an 32 | animated GIF. Use readGif to read an animated gif as a series of numpy 33 | arrays. 34 | 35 | Note that since July 2004, all patents on the LZW compression patent have 36 | expired. Therefore the GIF format may now be used freely. 37 | 38 | Acknowledgements 39 | ---------------- 40 | 41 | Many thanks to Ant1 for: 42 | * noting the use of "palette=PIL.Image.ADAPTIVE", which significantly 43 | improves the results. 44 | * the modifications to save each image with its own palette, or optionally 45 | the global palette (if its the same). 46 | 47 | Many thanks to Marius van Voorden for porting the NeuQuant quantization 48 | algorithm of Anthony Dekker to Python (See the NeuQuant class for its 49 | license). 50 | 51 | Many thanks to Alex Robinson for implementing the concept of subrectangles, 52 | which (depening on image content) can give a very significant reduction in 53 | file size. 54 | 55 | This code is based on gifmaker (in the scripts folder of the source 56 | distribution of PIL) 57 | 58 | 59 | Usefull links 60 | ------------- 61 | * http://tronche.com/computer-graphics/gif/ 62 | * http://en.wikipedia.org/wiki/Graphics_Interchange_Format 63 | * http://www.w3.org/Graphics/GIF/spec-gif89a.txt 64 | 65 | """ 66 | # todo: This module should be part of imageio (or at least based on) 67 | 68 | import os, time 69 | 70 | try: 71 | import PIL 72 | from PIL import Image 73 | from PIL.GifImagePlugin import getheader, getdata 74 | except ImportError: 75 | PIL = None 76 | 77 | try: 78 | import numpy as np 79 | except ImportError: 80 | np = None 81 | 82 | def get_cKDTree(): 83 | try: 84 | from scipy.spatial import cKDTree 85 | except ImportError: 86 | cKDTree = None 87 | return cKDTree 88 | 89 | 90 | # getheader gives a 87a header and a color palette (two elements in a list). 91 | # getdata()[0] gives the Image Descriptor up to (including) "LZW min code size". 92 | # getdatas()[1:] is the image data itself in chuncks of 256 bytes (well 93 | # technically the first byte says how many bytes follow, after which that 94 | # amount (max 255) follows). 95 | 96 | def checkImages(images): 97 | """ checkImages(images) 98 | Check numpy images and correct intensity range etc. 99 | The same for all movie formats. 100 | """ 101 | # Init results 102 | images2 = [] 103 | 104 | for im in images: 105 | if PIL and isinstance(im, PIL.Image.Image): 106 | # We assume PIL images are allright 107 | images2.append(im) 108 | 109 | elif np and isinstance(im, np.ndarray): 110 | # Check and convert dtype 111 | if im.dtype == np.uint8: 112 | images2.append(im) # Ok 113 | elif im.dtype in [np.float32, np.float64]: 114 | im = im.copy() 115 | im[im<0] = 0 116 | im[im>1] = 1 117 | im *= 255 118 | images2.append( im.astype(np.uint8) ) 119 | else: 120 | im = im.astype(np.uint8) 121 | images2.append(im) 122 | # Check size 123 | if im.ndim == 2: 124 | pass # ok 125 | elif im.ndim == 3: 126 | if im.shape[2] not in [3,4]: 127 | raise ValueError('This array can not represent an image.') 128 | else: 129 | raise ValueError('This array can not represent an image.') 130 | else: 131 | raise ValueError('Invalid image type: ' + str(type(im))) 132 | 133 | # Done 134 | return images2 135 | 136 | 137 | def intToBin(i): 138 | """ Integer to two bytes """ 139 | # devide in two parts (bytes) 140 | i1 = i % 256 141 | i2 = int( i/256) 142 | # make string (little endian) 143 | return chr(i1) + chr(i2) 144 | 145 | 146 | class GifWriter: 147 | """ GifWriter() 148 | 149 | Class that contains methods for helping write the animated GIF file. 150 | 151 | """ 152 | 153 | def getheaderAnim(self, im): 154 | """ getheaderAnim(im) 155 | 156 | Get animation header. To replace PILs getheader()[0] 157 | 158 | """ 159 | bb = "GIF89a" 160 | bb += intToBin(im.size[0]) 161 | bb += intToBin(im.size[1]) 162 | bb += "\x87\x00\x00" 163 | return bb 164 | 165 | 166 | def getImageDescriptor(self, im, xy=None): 167 | """ getImageDescriptor(im, xy=None) 168 | 169 | Used for the local color table properties per image. 170 | Otherwise global color table applies to all frames irrespective of 171 | whether additional colors comes in play that require a redefined 172 | palette. Still a maximum of 256 color per frame, obviously. 173 | 174 | Written by Ant1 on 2010-08-22 175 | Modified by Alex Robinson in Janurari 2011 to implement subrectangles. 176 | 177 | """ 178 | 179 | # Defaule use full image and place at upper left 180 | if xy is None: 181 | xy = (0,0) 182 | 183 | # Image separator, 184 | bb = '\x2C' 185 | 186 | # Image position and size 187 | bb += intToBin( xy[0] ) # Left position 188 | bb += intToBin( xy[1] ) # Top position 189 | bb += intToBin( im.size[0] ) # image width 190 | bb += intToBin( im.size[1] ) # image height 191 | 192 | # packed field: local color table flag1, interlace0, sorted table0, 193 | # reserved00, lct size111=7=2^(7+1)=256. 194 | bb += '\x87' 195 | 196 | # LZW minimum size code now comes later, begining of [image data] blocks 197 | return bb 198 | 199 | 200 | def getAppExt(self, loops=float('inf')): 201 | """ getAppExt(loops=float('inf')) 202 | 203 | Application extention. This part specifies the amount of loops. 204 | If loops is 0 or inf, it goes on infinitely. 205 | 206 | """ 207 | 208 | if loops==0 or loops==float('inf'): 209 | loops = 2**16-1 210 | #bb = "" # application extension should not be used 211 | # (the extension interprets zero loops 212 | # to mean an infinite number of loops) 213 | # Mmm, does not seem to work 214 | if True: 215 | bb = "\x21\xFF\x0B" # application extension 216 | bb += "NETSCAPE2.0" 217 | bb += "\x03\x01" 218 | bb += intToBin(loops) 219 | bb += '\x00' # end 220 | return bb 221 | 222 | 223 | def getGraphicsControlExt(self, duration=0.1, dispose=2,transparent_flag=0,transparency_index=0): 224 | """ getGraphicsControlExt(duration=0.1, dispose=2) 225 | 226 | Graphics Control Extension. A sort of header at the start of 227 | each image. Specifies duration and transparancy. 228 | 229 | Dispose 230 | ------- 231 | * 0 - No disposal specified. 232 | * 1 - Do not dispose. The graphic is to be left in place. 233 | * 2 - Restore to background color. The area used by the graphic 234 | must be restored to the background color. 235 | * 3 - Restore to previous. The decoder is required to restore the 236 | area overwritten by the graphic with what was there prior to 237 | rendering the graphic. 238 | * 4-7 -To be defined. 239 | 240 | """ 241 | 242 | bb = '\x21\xF9\x04' 243 | bb += chr(((dispose & 3) << 2)|(transparent_flag & 1)) # low bit 1 == transparency, 244 | # 2nd bit 1 == user input , next 3 bits, the low two of which are used, 245 | # are dispose. 246 | bb += intToBin( int(duration*100) ) # in 100th of seconds 247 | bb += chr(transparency_index) # transparency index 248 | bb += '\x00' # end 249 | return bb 250 | 251 | 252 | def handleSubRectangles(self, images, subRectangles): 253 | """ handleSubRectangles(images) 254 | 255 | Handle the sub-rectangle stuff. If the rectangles are given by the 256 | user, the values are checked. Otherwise the subrectangles are 257 | calculated automatically. 258 | 259 | """ 260 | image_info = [] 261 | 262 | for im in images: 263 | if hasattr(im, 'flags'): 264 | image_info.append(im.flags) 265 | 266 | if isinstance(subRectangles, (tuple, list)): 267 | # xy given directly 268 | 269 | # Check xy 270 | xy = subRectangles 271 | if xy is None: 272 | xy = (0,0) 273 | if hasattr(xy, '__len__'): 274 | if len(xy) == len(images): 275 | xy = [xxyy for xxyy in xy] 276 | else: 277 | raise ValueError("len(xy) doesn't match amount of images.") 278 | else: 279 | xy = [xy for im in images] 280 | xy[0] = (0,0) 281 | 282 | else: 283 | # Calculate xy using some basic image processing 284 | 285 | # Check Numpy 286 | if np is None: 287 | raise RuntimeError("Need Numpy to use auto-subRectangles.") 288 | 289 | # First make numpy arrays if required 290 | for i in range(len(images)): 291 | im = images[i] 292 | if isinstance(im, Image.Image): 293 | tmp = im.convert() # Make without palette 294 | a = np.asarray(tmp) 295 | if len(a.shape)==0: 296 | raise MemoryError("Too little memory to convert PIL image to array") 297 | images[i] = a 298 | 299 | # Determine the sub rectangles 300 | images, xy = self.getSubRectangles(images) 301 | 302 | # Done 303 | return images, xy, image_info 304 | 305 | 306 | def getSubRectangles(self, ims): 307 | """ getSubRectangles(ims) 308 | 309 | Calculate the minimal rectangles that need updating each frame. 310 | Returns a two-element tuple containing the cropped images and a 311 | list of x-y positions. 312 | 313 | Calculating the subrectangles takes extra time, obviously. However, 314 | if the image sizes were reduced, the actual writing of the GIF 315 | goes faster. In some cases applying this method produces a GIF faster. 316 | 317 | """ 318 | 319 | # Check image count 320 | if len(ims) < 2: 321 | return ims, [(0,0) for i in ims] 322 | 323 | # We need numpy 324 | if np is None: 325 | raise RuntimeError("Need Numpy to calculate sub-rectangles. ") 326 | 327 | # Prepare 328 | ims2 = [ims[0]] 329 | xy = [(0,0)] 330 | t0 = time.time() 331 | 332 | # Iterate over images 333 | prev = ims[0] 334 | for im in ims[1:]: 335 | 336 | # Get difference, sum over colors 337 | diff = np.abs(im-prev) 338 | if diff.ndim==3: 339 | diff = diff.sum(2) 340 | # Get begin and end for both dimensions 341 | X = np.argwhere(diff.sum(0)) 342 | Y = np.argwhere(diff.sum(1)) 343 | # Get rect coordinates 344 | if X.size and Y.size: 345 | x0, x1 = X[0], X[-1]+1 346 | y0, y1 = Y[0], Y[-1]+1 347 | else: # No change ... make it minimal 348 | x0, x1 = 0, 2 349 | y0, y1 = 0, 2 350 | 351 | # Cut out and store 352 | im2 = im[y0:y1,x0:x1] 353 | prev = im 354 | ims2.append(im2) 355 | xy.append((x0,y0)) 356 | 357 | # Done 358 | #print('%1.2f seconds to determine subrectangles of %i images' % 359 | # (time.time()-t0, len(ims2)) ) 360 | return ims2, xy 361 | 362 | 363 | def convertImagesToPIL(self, images, dither, nq=0,images_info=None): 364 | """ convertImagesToPIL(images, nq=0) 365 | 366 | Convert images to Paletted PIL images, which can then be 367 | written to a single animaged GIF. 368 | 369 | """ 370 | 371 | # Convert to PIL images 372 | images2 = [] 373 | for im in images: 374 | if isinstance(im, Image.Image): 375 | images2.append(im) 376 | elif np and isinstance(im, np.ndarray): 377 | if im.ndim==3 and im.shape[2]==3: 378 | im = Image.fromarray(im,'RGB') 379 | elif im.ndim==3 and im.shape[2]==4: 380 | # im = Image.fromarray(im[:,:,:3],'RGB') 381 | self.transparency = True 382 | im = Image.fromarray(im[:,:,:4],'RGBA') 383 | elif im.ndim==2: 384 | im = Image.fromarray(im,'L') 385 | images2.append(im) 386 | 387 | # Convert to paletted PIL images 388 | images, images2 = images2, [] 389 | if nq >= 1: 390 | # NeuQuant algorithm 391 | for im in images: 392 | im = im.convert("RGBA") # NQ assumes RGBA 393 | nqInstance = NeuQuant(im, int(nq)) # Learn colors from image 394 | if dither: 395 | im = im.convert("RGB").quantize(palette=nqInstance.paletteImage(),colors=255) 396 | else: 397 | im = nqInstance.quantize(im,colors=255) # Use to quantize the image itself 398 | 399 | self.transparency = True # since NQ assumes transparency 400 | if self.transparency: 401 | alpha = im.split()[3] 402 | mask = Image.eval(alpha, lambda a: 255 if a <=128 else 0) 403 | im.paste(255,mask=mask) 404 | images2.append(im) 405 | else: 406 | # Adaptive PIL algorithm 407 | AD = Image.ADAPTIVE 408 | # for index,im in enumerate(images): 409 | for i in range(len(images)): 410 | im = images[i].convert('RGB').convert('P', palette=AD, dither=dither,colors=255) 411 | if self.transparency: 412 | alpha = images[i].split()[3] 413 | mask = Image.eval(alpha, lambda a: 255 if a <=128 else 0) 414 | im.paste(255,mask=mask) 415 | images2.append(im) 416 | 417 | # Done 418 | return images2 419 | 420 | 421 | def writeGifToFile(self, fp, images, durations, loops, xys, disposes): 422 | """ writeGifToFile(fp, images, durations, loops, xys, disposes) 423 | 424 | Given a set of images writes the bytes to the specified stream. 425 | 426 | """ 427 | 428 | # Obtain palette for all images and count each occurance 429 | palettes, occur = [], [] 430 | 431 | for im in images: 432 | palettes.append(getheader(im)[0][3]) 433 | for palette in palettes: 434 | occur.append(palettes.count(palette)) 435 | 436 | # Select most-used palette as the global one (or first in case no max) 437 | globalPalette = palettes[ occur.index(max(occur)) ] 438 | 439 | # Init 440 | frames = 0 441 | firstFrame = True 442 | 443 | for im, palette in zip(images, palettes): 444 | 445 | if firstFrame: 446 | # Write header 447 | 448 | # Gather info 449 | header = self.getheaderAnim(im) 450 | appext = self.getAppExt(loops) 451 | 452 | # Write 453 | fp.write(header) 454 | fp.write(globalPalette) 455 | fp.write(appext) 456 | 457 | # Next frame is not the first 458 | firstFrame = False 459 | 460 | if True: 461 | # Write palette and image data 462 | 463 | # Gather info 464 | data = getdata(im) 465 | imdes, data = data[0], data[1:] 466 | 467 | transparent_flag = 0 468 | if self.transparency: transparent_flag = 1 469 | 470 | graphext = self.getGraphicsControlExt(durations[frames], 471 | disposes[frames],transparent_flag=transparent_flag,transparency_index=255) 472 | 473 | # Make image descriptor suitable for using 256 local color palette 474 | lid = self.getImageDescriptor(im, xys[frames]) 475 | 476 | # Write local header 477 | if (palette != globalPalette) or (disposes[frames] != 2): 478 | # Use local color palette 479 | fp.write(graphext) 480 | fp.write(lid) # write suitable image descriptor 481 | fp.write(palette) # write local color table 482 | fp.write('\x08') # LZW minimum size code 483 | else: 484 | # Use global color palette 485 | fp.write(graphext) 486 | fp.write(imdes) # write suitable image descriptor 487 | 488 | # Write image data 489 | for d in data: 490 | fp.write(d) 491 | 492 | # Prepare for next round 493 | frames = frames + 1 494 | 495 | fp.write(";") # end gif 496 | return frames 497 | 498 | 499 | 500 | 501 | ## Exposed functions 502 | 503 | def writeGif(filename, images, duration=0.1, repeat=True, dither=False, 504 | nq=0, subRectangles=True, dispose=None): 505 | """ writeGif(filename, images, duration=0.1, repeat=True, dither=False, 506 | nq=0, subRectangles=True, dispose=None) 507 | 508 | Write an animated gif from the specified images. 509 | 510 | Parameters 511 | ---------- 512 | filename : string 513 | The name of the file to write the image to. 514 | images : list 515 | Should be a list consisting of PIL images or numpy arrays. 516 | The latter should be between 0 and 255 for integer types, and 517 | between 0 and 1 for float types. 518 | duration : scalar or list of scalars 519 | The duration for all frames, or (if a list) for each frame. 520 | repeat : bool or integer 521 | The amount of loops. If True, loops infinitetely. 522 | dither : bool 523 | Whether to apply dithering 524 | nq : integer 525 | If nonzero, applies the NeuQuant quantization algorithm to create 526 | the color palette. This algorithm is superior, but slower than 527 | the standard PIL algorithm. The value of nq is the quality 528 | parameter. 1 represents the best quality. 10 is in general a 529 | good tradeoff between quality and speed. When using this option, 530 | better results are usually obtained when subRectangles is False. 531 | subRectangles : False, True, or a list of 2-element tuples 532 | Whether to use sub-rectangles. If True, the minimal rectangle that 533 | is required to update each frame is automatically detected. This 534 | can give significant reductions in file size, particularly if only 535 | a part of the image changes. One can also give a list of x-y 536 | coordinates if you want to do the cropping yourself. The default 537 | is True. 538 | dispose : int 539 | How to dispose each frame. 1 means that each frame is to be left 540 | in place. 2 means the background color should be restored after 541 | each frame. 3 means the decoder should restore the previous frame. 542 | If subRectangles==False, the default is 2, otherwise it is 1. 543 | 544 | """ 545 | 546 | # Check PIL 547 | if PIL is None: 548 | raise RuntimeError("Need PIL to write animated gif files.") 549 | 550 | # Check images 551 | images = checkImages(images) 552 | 553 | # Instantiate writer object 554 | gifWriter = GifWriter() 555 | gifWriter.transparency = False # init transparency flag used in GifWriter functions 556 | 557 | # Check loops 558 | if repeat is False: 559 | loops = 1 560 | elif repeat is True: 561 | loops = 0 # zero means infinite 562 | else: 563 | loops = int(repeat) 564 | 565 | # Check duration 566 | if hasattr(duration, '__len__'): 567 | if len(duration) == len(images): 568 | duration = [d for d in duration] 569 | else: 570 | raise ValueError("len(duration) doesn't match amount of images.") 571 | else: 572 | duration = [duration for im in images] 573 | 574 | # Check subrectangles 575 | if subRectangles: 576 | images, xy, images_info = gifWriter.handleSubRectangles(images, subRectangles) 577 | defaultDispose = 1 # Leave image in place 578 | else: 579 | # Normal mode 580 | xy = [(0,0) for im in images] 581 | defaultDispose = 2 # Restore to background color. 582 | 583 | # Check dispose 584 | if dispose is None: 585 | dispose = defaultDispose 586 | if hasattr(dispose, '__len__'): 587 | if len(dispose) != len(images): 588 | raise ValueError("len(xy) doesn't match amount of images.") 589 | else: 590 | dispose = [dispose for im in images] 591 | 592 | # Make images in a format that we can write easy 593 | images = gifWriter.convertImagesToPIL(images, dither, nq) 594 | 595 | # Write 596 | fp = open(filename, 'wb') 597 | try: 598 | gifWriter.writeGifToFile(fp, images, duration, loops, xy, dispose) 599 | finally: 600 | fp.close() 601 | 602 | 603 | 604 | def readGif(filename, asNumpy=True): 605 | """ readGif(filename, asNumpy=True) 606 | 607 | Read images from an animated GIF file. Returns a list of numpy 608 | arrays, or, if asNumpy is false, a list if PIL images. 609 | 610 | """ 611 | 612 | # Check PIL 613 | if PIL is None: 614 | raise RuntimeError("Need PIL to read animated gif files.") 615 | 616 | # Check Numpy 617 | if np is None: 618 | raise RuntimeError("Need Numpy to read animated gif files.") 619 | 620 | # Check whether it exists 621 | if not os.path.isfile(filename): 622 | raise IOError('File not found: '+str(filename)) 623 | 624 | # Load file using PIL 625 | pilIm = PIL.Image.open(filename) 626 | pilIm.seek(0) 627 | 628 | # Read all images inside 629 | images = [] 630 | try: 631 | while True: 632 | # Get image as numpy array 633 | tmp = pilIm.convert() # Make without palette 634 | a = np.asarray(tmp) 635 | if len(a.shape)==0: 636 | raise MemoryError("Too little memory to convert PIL image to array") 637 | # Store, and next 638 | images.append(a) 639 | pilIm.seek(pilIm.tell()+1) 640 | except EOFError: 641 | pass 642 | 643 | # Convert to normal PIL images if needed 644 | if not asNumpy: 645 | images2 = images 646 | images = [] 647 | for index,im in enumerate(images2): 648 | tmp = PIL.Image.fromarray(im) 649 | images.append(tmp) 650 | 651 | # Done 652 | return images 653 | 654 | 655 | class NeuQuant: 656 | """ NeuQuant(image, samplefac=10, colors=256) 657 | 658 | samplefac should be an integer number of 1 or higher, 1 659 | being the highest quality, but the slowest performance. 660 | With avalue of 10, one tenth of all pixels are used during 661 | training. This value seems a nice tradeof between speed 662 | and quality. 663 | 664 | colors is the amount of colors to reduce the image to. This 665 | should best be a power of two. 666 | 667 | See also: 668 | http://members.ozemail.com.au/~dekker/NEUQUANT.HTML 669 | 670 | License of the NeuQuant Neural-Net Quantization Algorithm 671 | --------------------------------------------------------- 672 | 673 | Copyright (c) 1994 Anthony Dekker 674 | Ported to python by Marius van Voorden in 2010 675 | 676 | NEUQUANT Neural-Net quantization algorithm by Anthony Dekker, 1994. 677 | See "Kohonen neural networks for optimal colour quantization" 678 | in "network: Computation in Neural Systems" Vol. 5 (1994) pp 351-367. 679 | for a discussion of the algorithm. 680 | See also http://members.ozemail.com.au/~dekker/NEUQUANT.HTML 681 | 682 | Any party obtaining a copy of these files from the author, directly or 683 | indirectly, is granted, free of charge, a full and unrestricted irrevocable, 684 | world-wide, paid up, royalty-free, nonexclusive right and license to deal 685 | in this software and documentation files (the "Software"), including without 686 | limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 687 | and/or sell copies of the Software, and to permit persons who receive 688 | copies from any such party to do so, with the only requirement being 689 | that this copyright notice remain intact. 690 | 691 | """ 692 | 693 | NCYCLES = None # Number of learning cycles 694 | NETSIZE = None # Number of colours used 695 | SPECIALS = None # Number of reserved colours used 696 | BGCOLOR = None # Reserved background colour 697 | CUTNETSIZE = None 698 | MAXNETPOS = None 699 | 700 | INITRAD = None # For 256 colours, radius starts at 32 701 | RADIUSBIASSHIFT = None 702 | RADIUSBIAS = None 703 | INITBIASRADIUS = None 704 | RADIUSDEC = None # Factor of 1/30 each cycle 705 | 706 | ALPHABIASSHIFT = None 707 | INITALPHA = None # biased by 10 bits 708 | 709 | GAMMA = None 710 | BETA = None 711 | BETAGAMMA = None 712 | 713 | network = None # The network itself 714 | colormap = None # The network itself 715 | 716 | netindex = None # For network lookup - really 256 717 | 718 | bias = None # Bias and freq arrays for learning 719 | freq = None 720 | 721 | pimage = None 722 | 723 | # Four primes near 500 - assume no image has a length so large 724 | # that it is divisible by all four primes 725 | PRIME1 = 499 726 | PRIME2 = 491 727 | PRIME3 = 487 728 | PRIME4 = 503 729 | MAXPRIME = PRIME4 730 | 731 | pixels = None 732 | samplefac = None 733 | 734 | a_s = None 735 | 736 | 737 | def setconstants(self, samplefac, colors): 738 | self.NCYCLES = 100 # Number of learning cycles 739 | self.NETSIZE = colors # Number of colours used 740 | self.SPECIALS = 3 # Number of reserved colours used 741 | self.BGCOLOR = self.SPECIALS-1 # Reserved background colour 742 | self.CUTNETSIZE = self.NETSIZE - self.SPECIALS 743 | self.MAXNETPOS = self.NETSIZE - 1 744 | 745 | self.INITRAD = self.NETSIZE/8 # For 256 colours, radius starts at 32 746 | self.RADIUSBIASSHIFT = 6 747 | self.RADIUSBIAS = 1 << self.RADIUSBIASSHIFT 748 | self.INITBIASRADIUS = self.INITRAD * self.RADIUSBIAS 749 | self.RADIUSDEC = 30 # Factor of 1/30 each cycle 750 | 751 | self.ALPHABIASSHIFT = 10 # Alpha starts at 1 752 | self.INITALPHA = 1 << self.ALPHABIASSHIFT # biased by 10 bits 753 | 754 | self.GAMMA = 1024.0 755 | self.BETA = 1.0/1024.0 756 | self.BETAGAMMA = self.BETA * self.GAMMA 757 | 758 | self.network = np.empty((self.NETSIZE, 3), dtype='float64') # The network itself 759 | self.colormap = np.empty((self.NETSIZE, 4), dtype='int32') # The network itself 760 | 761 | self.netindex = np.empty(256, dtype='int32') # For network lookup - really 256 762 | 763 | self.bias = np.empty(self.NETSIZE, dtype='float64') # Bias and freq arrays for learning 764 | self.freq = np.empty(self.NETSIZE, dtype='float64') 765 | 766 | self.pixels = None 767 | self.samplefac = samplefac 768 | 769 | self.a_s = {} 770 | 771 | def __init__(self, image, samplefac=10, colors=256): 772 | 773 | # Check Numpy 774 | if np is None: 775 | raise RuntimeError("Need Numpy for the NeuQuant algorithm.") 776 | 777 | # Check image 778 | if image.size[0] * image.size[1] < NeuQuant.MAXPRIME: 779 | raise IOError("Image is too small") 780 | if image.mode != "RGBA": 781 | raise IOError("Image mode should be RGBA.") 782 | 783 | # Initialize 784 | self.setconstants(samplefac, colors) 785 | self.pixels = np.fromstring(image.tostring(), np.uint32) 786 | self.setUpArrays() 787 | 788 | self.learn() 789 | self.fix() 790 | self.inxbuild() 791 | 792 | def writeColourMap(self, rgb, outstream): 793 | for i in range(self.NETSIZE): 794 | bb = self.colormap[i,0]; 795 | gg = self.colormap[i,1]; 796 | rr = self.colormap[i,2]; 797 | outstream.write(rr if rgb else bb) 798 | outstream.write(gg) 799 | outstream.write(bb if rgb else rr) 800 | return self.NETSIZE 801 | 802 | def setUpArrays(self): 803 | self.network[0,0] = 0.0 # Black 804 | self.network[0,1] = 0.0 805 | self.network[0,2] = 0.0 806 | 807 | self.network[1,0] = 255.0 # White 808 | self.network[1,1] = 255.0 809 | self.network[1,2] = 255.0 810 | 811 | # RESERVED self.BGCOLOR # Background 812 | 813 | for i in range(self.SPECIALS): 814 | self.freq[i] = 1.0 / self.NETSIZE 815 | self.bias[i] = 0.0 816 | 817 | for i in range(self.SPECIALS, self.NETSIZE): 818 | p = self.network[i] 819 | p[:] = (255.0 * (i-self.SPECIALS)) / self.CUTNETSIZE 820 | 821 | self.freq[i] = 1.0 / self.NETSIZE 822 | self.bias[i] = 0.0 823 | 824 | # Omitted: setPixels 825 | 826 | def altersingle(self, alpha, i, b, g, r): 827 | """Move neuron i towards biased (b,g,r) by factor alpha""" 828 | n = self.network[i] # Alter hit neuron 829 | n[0] -= (alpha*(n[0] - b)) 830 | n[1] -= (alpha*(n[1] - g)) 831 | n[2] -= (alpha*(n[2] - r)) 832 | 833 | def geta(self, alpha, rad): 834 | try: 835 | return self.a_s[(alpha, rad)] 836 | except KeyError: 837 | length = rad*2-1 838 | mid = length/2 839 | q = np.array(list(range(mid-1,-1,-1))+list(range(-1,mid))) 840 | a = alpha*(rad*rad - q*q)/(rad*rad) 841 | a[mid] = 0 842 | self.a_s[(alpha, rad)] = a 843 | return a 844 | 845 | def alterneigh(self, alpha, rad, i, b, g, r): 846 | if i-rad >= self.SPECIALS-1: 847 | lo = i-rad 848 | start = 0 849 | else: 850 | lo = self.SPECIALS-1 851 | start = (self.SPECIALS-1 - (i-rad)) 852 | 853 | if i+rad <= self.NETSIZE: 854 | hi = i+rad 855 | end = rad*2-1 856 | else: 857 | hi = self.NETSIZE 858 | end = (self.NETSIZE - (i+rad)) 859 | 860 | a = self.geta(alpha, rad)[start:end] 861 | 862 | p = self.network[lo+1:hi] 863 | p -= np.transpose(np.transpose(p - np.array([b, g, r])) * a) 864 | 865 | #def contest(self, b, g, r): 866 | # """ Search for biased BGR values 867 | # Finds closest neuron (min dist) and updates self.freq 868 | # finds best neuron (min dist-self.bias) and returns position 869 | # for frequently chosen neurons, self.freq[i] is high and self.bias[i] is negative 870 | # self.bias[i] = self.GAMMA*((1/self.NETSIZE)-self.freq[i])""" 871 | # 872 | # i, j = self.SPECIALS, self.NETSIZE 873 | # dists = abs(self.network[i:j] - np.array([b,g,r])).sum(1) 874 | # bestpos = i + np.argmin(dists) 875 | # biasdists = dists - self.bias[i:j] 876 | # bestbiaspos = i + np.argmin(biasdists) 877 | # self.freq[i:j] -= self.BETA * self.freq[i:j] 878 | # self.bias[i:j] += self.BETAGAMMA * self.freq[i:j] 879 | # self.freq[bestpos] += self.BETA 880 | # self.bias[bestpos] -= self.BETAGAMMA 881 | # return bestbiaspos 882 | def contest(self, b, g, r): 883 | """ Search for biased BGR values 884 | Finds closest neuron (min dist) and updates self.freq 885 | finds best neuron (min dist-self.bias) and returns position 886 | for frequently chosen neurons, self.freq[i] is high and self.bias[i] is negative 887 | self.bias[i] = self.GAMMA*((1/self.NETSIZE)-self.freq[i])""" 888 | i, j = self.SPECIALS, self.NETSIZE 889 | dists = abs(self.network[i:j] - np.array([b,g,r])).sum(1) 890 | bestpos = i + np.argmin(dists) 891 | biasdists = dists - self.bias[i:j] 892 | bestbiaspos = i + np.argmin(biasdists) 893 | self.freq[i:j] *= (1-self.BETA) 894 | self.bias[i:j] += self.BETAGAMMA * self.freq[i:j] 895 | self.freq[bestpos] += self.BETA 896 | self.bias[bestpos] -= self.BETAGAMMA 897 | return bestbiaspos 898 | 899 | 900 | 901 | 902 | def specialFind(self, b, g, r): 903 | for i in range(self.SPECIALS): 904 | n = self.network[i] 905 | if n[0] == b and n[1] == g and n[2] == r: 906 | return i 907 | return -1 908 | 909 | def learn(self): 910 | biasRadius = self.INITBIASRADIUS 911 | alphadec = 30 + ((self.samplefac-1)/3) 912 | lengthcount = self.pixels.size 913 | samplepixels = lengthcount / self.samplefac 914 | delta = samplepixels / self.NCYCLES 915 | alpha = self.INITALPHA 916 | 917 | i = 0; 918 | rad = biasRadius >> self.RADIUSBIASSHIFT 919 | if rad <= 1: 920 | rad = 0 921 | 922 | print("Beginning 1D learning: samplepixels = %1.2f rad = %i" % 923 | (samplepixels, rad) ) 924 | step = 0 925 | pos = 0 926 | if lengthcount%NeuQuant.PRIME1 != 0: 927 | step = NeuQuant.PRIME1 928 | elif lengthcount%NeuQuant.PRIME2 != 0: 929 | step = NeuQuant.PRIME2 930 | elif lengthcount%NeuQuant.PRIME3 != 0: 931 | step = NeuQuant.PRIME3 932 | else: 933 | step = NeuQuant.PRIME4 934 | 935 | i = 0 936 | printed_string = '' 937 | while i < samplepixels: 938 | if i%100 == 99: 939 | tmp = '\b'*len(printed_string) 940 | printed_string = str((i+1)*100/samplepixels)+"%\n" 941 | print(tmp + printed_string) 942 | p = self.pixels[pos] 943 | r = (p >> 16) & 0xff 944 | g = (p >> 8) & 0xff 945 | b = (p ) & 0xff 946 | 947 | if i == 0: # Remember background colour 948 | self.network[self.BGCOLOR] = [b, g, r] 949 | 950 | j = self.specialFind(b, g, r) 951 | if j < 0: 952 | j = self.contest(b, g, r) 953 | 954 | if j >= self.SPECIALS: # Don't learn for specials 955 | a = (1.0 * alpha) / self.INITALPHA 956 | self.altersingle(a, j, b, g, r) 957 | if rad > 0: 958 | self.alterneigh(a, rad, j, b, g, r) 959 | 960 | pos = (pos+step)%lengthcount 961 | 962 | i += 1 963 | if i%delta == 0: 964 | alpha -= alpha / alphadec 965 | biasRadius -= biasRadius / self.RADIUSDEC 966 | rad = biasRadius >> self.RADIUSBIASSHIFT 967 | if rad <= 1: 968 | rad = 0 969 | 970 | finalAlpha = (1.0*alpha)/self.INITALPHA 971 | print("Finished 1D learning: final alpha = %1.2f!" % finalAlpha) 972 | 973 | def fix(self): 974 | for i in range(self.NETSIZE): 975 | for j in range(3): 976 | x = int(0.5 + self.network[i,j]) 977 | x = max(0, x) 978 | x = min(255, x) 979 | self.colormap[i,j] = x 980 | self.colormap[i,3] = i 981 | 982 | def inxbuild(self): 983 | previouscol = 0 984 | startpos = 0 985 | for i in range(self.NETSIZE): 986 | p = self.colormap[i] 987 | q = None 988 | smallpos = i 989 | smallval = p[1] # Index on g 990 | # Find smallest in i..self.NETSIZE-1 991 | for j in range(i+1, self.NETSIZE): 992 | q = self.colormap[j] 993 | if q[1] < smallval: # Index on g 994 | smallpos = j 995 | smallval = q[1] # Index on g 996 | 997 | q = self.colormap[smallpos] 998 | # Swap p (i) and q (smallpos) entries 999 | if i != smallpos: 1000 | p[:],q[:] = q, p.copy() 1001 | 1002 | # smallval entry is now in position i 1003 | if smallval != previouscol: 1004 | self.netindex[previouscol] = (startpos+i) >> 1 1005 | for j in range(previouscol+1, smallval): 1006 | self.netindex[j] = i 1007 | previouscol = smallval 1008 | startpos = i 1009 | self.netindex[previouscol] = (startpos+self.MAXNETPOS) >> 1 1010 | for j in range(previouscol+1, 256): # Really 256 1011 | self.netindex[j] = self.MAXNETPOS 1012 | 1013 | 1014 | def paletteImage(self): 1015 | """ PIL weird interface for making a paletted image: create an image which 1016 | already has the palette, and use that in Image.quantize. This function 1017 | returns this palette image. """ 1018 | if self.pimage is None: 1019 | palette = [] 1020 | for i in range(self.NETSIZE): 1021 | palette.extend(self.colormap[i][:3]) 1022 | 1023 | palette.extend([0]*(256-self.NETSIZE)*3) 1024 | 1025 | # a palette image to use for quant 1026 | self.pimage = Image.new("P", (1, 1), 0) 1027 | self.pimage.putpalette(palette) 1028 | return self.pimage 1029 | 1030 | 1031 | def quantize(self, image): 1032 | """ Use a kdtree to quickly find the closest palette colors for the pixels """ 1033 | if get_cKDTree(): 1034 | return self.quantize_with_scipy(image) 1035 | else: 1036 | print('Scipy not available, falling back to slower version.') 1037 | return self.quantize_without_scipy(image) 1038 | 1039 | 1040 | def quantize_with_scipy(self, image): 1041 | w,h = image.size 1042 | px = np.asarray(image).copy() 1043 | px2 = px[:,:,:3].reshape((w*h,3)) 1044 | 1045 | cKDTree = get_cKDTree() 1046 | kdtree = cKDTree(self.colormap[:,:3],leafsize=10) 1047 | result = kdtree.query(px2) 1048 | colorindex = result[1] 1049 | print("Distance: %1.2f" % (result[0].sum()/(w*h)) ) 1050 | px2[:] = self.colormap[colorindex,:3] 1051 | 1052 | return Image.fromarray(px).convert("RGB").quantize(palette=self.paletteImage()) 1053 | 1054 | 1055 | def quantize_without_scipy(self, image): 1056 | """" This function can be used if no scipy is availabe. 1057 | It's 7 times slower though. 1058 | """ 1059 | w,h = image.size 1060 | px = np.asarray(image).copy() 1061 | memo = {} 1062 | for j in range(w): 1063 | for i in range(h): 1064 | key = (px[i,j,0],px[i,j,1],px[i,j,2]) 1065 | try: 1066 | val = memo[key] 1067 | except KeyError: 1068 | val = self.convert(*key) 1069 | memo[key] = val 1070 | px[i,j,0],px[i,j,1],px[i,j,2] = val 1071 | return Image.fromarray(px).convert("RGB").quantize(palette=self.paletteImage()) 1072 | 1073 | def convert(self, *color): 1074 | i = self.inxsearch(*color) 1075 | return self.colormap[i,:3] 1076 | 1077 | def inxsearch(self, r, g, b): 1078 | """Search for BGR values 0..255 and return colour index""" 1079 | dists = (self.colormap[:,:3] - np.array([r,g,b])) 1080 | a= np.argmin((dists*dists).sum(1)) 1081 | return a 1082 | 1083 | 1084 | 1085 | if __name__ == '__main__': 1086 | im = np.zeros((200,200), dtype=np.uint8) 1087 | im[10:30,:] = 100 1088 | im[:,80:120] = 255 1089 | im[-50:-40,:] = 50 1090 | 1091 | images = [im*1.0, im*0.8, im*0.6, im*0.4, im*0] 1092 | writeGif('lala3.gif',images, duration=0.5, dither=0) 1093 | -------------------------------------------------------------------------------- /legofy/images2gif_py3.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2012, Almar Klein, Ant1, Marius van Voorden 3 | # 4 | # This code is subject to the (new) BSD license: 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # * Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # * Redistributions in binary form must reproduce the above copyright 11 | # notice, this list of conditions and the following disclaimer in the 12 | # documentation and/or other materials provided with the distribution. 13 | # * Neither the name of the nor the 14 | # names of its contributors may be used to endorse or promote products 15 | # derived from this software without specific prior written permission. 16 | # 17 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 20 | # ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 21 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 24 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | 28 | """ Module images2gif 29 | 30 | Provides functionality for reading and writing animated GIF images. 31 | Use writeGif to write a series of numpy arrays or PIL images as an 32 | animated GIF. Use readGif to read an animated gif as a series of numpy 33 | arrays. 34 | 35 | Note that since July 2004, all patents on the LZW compression patent have 36 | expired. Therefore the GIF format may now be used freely. 37 | 38 | Acknowledgements 39 | ---------------- 40 | 41 | Many thanks to Ant1 for: 42 | * noting the use of "palette=PIL.Image.ADAPTIVE", which significantly 43 | improves the results. 44 | * the modifications to save each image with its own palette, or optionally 45 | the global palette (if its the same). 46 | 47 | Many thanks to Marius van Voorden for porting the NeuQuant quantization 48 | algorithm of Anthony Dekker to Python (See the NeuQuant class for its 49 | license). 50 | 51 | Many thanks to Alex Robinson for implementing the concept of subrectangles, 52 | which (depening on image content) can give a very significant reduction in 53 | file size. 54 | 55 | This code is based on gifmaker (in the scripts folder of the source 56 | distribution of PIL) 57 | 58 | 59 | Usefull links 60 | ------------- 61 | * http://tronche.com/computer-graphics/gif/ 62 | * http://en.wikipedia.org/wiki/Graphics_Interchange_Format 63 | * http://www.w3.org/Graphics/GIF/spec-gif89a.txt 64 | 65 | """ 66 | # todo: This module should be part of imageio (or at least based on) 67 | 68 | import os, time 69 | 70 | try: 71 | import PIL 72 | from PIL import Image 73 | from PIL.GifImagePlugin import getheader, getdata 74 | except ImportError: 75 | PIL = None 76 | 77 | try: 78 | import numpy as np 79 | except ImportError: 80 | np = None 81 | 82 | def get_cKDTree(): 83 | try: 84 | from scipy.spatial import cKDTree 85 | except ImportError: 86 | cKDTree = None 87 | return cKDTree 88 | 89 | 90 | # getheader gives a 87a header and a color palette (two elements in a list). 91 | # getdata()[0] gives the Image Descriptor up to (including) "LZW min code size". 92 | # getdatas()[1:] is the image data itself in chuncks of 256 bytes (well 93 | # technically the first byte says how many bytes follow, after which that 94 | # amount (max 255) follows). 95 | 96 | def checkImages(images): 97 | """ checkImages(images) 98 | Check numpy images and correct intensity range etc. 99 | The same for all movie formats. 100 | """ 101 | # Init results 102 | images2 = [] 103 | 104 | for im in images: 105 | if PIL and isinstance(im, PIL.Image.Image): 106 | # We assume PIL images are allright 107 | images2.append(im) 108 | 109 | elif np and isinstance(im, np.ndarray): 110 | # Check and convert dtype 111 | if im.dtype == np.uint8: 112 | images2.append(im) # Ok 113 | elif im.dtype in [np.float32, np.float64]: 114 | im = im.copy() 115 | im[im<0] = 0 116 | im[im>1] = 1 117 | im *= 255 118 | images2.append( im.astype(np.uint8) ) 119 | else: 120 | im = im.astype(np.uint8) 121 | images2.append(im) 122 | # Check size 123 | if im.ndim == 2: 124 | pass # ok 125 | elif im.ndim == 3: 126 | if im.shape[2] not in [3,4]: 127 | raise ValueError('This array can not represent an image.') 128 | else: 129 | raise ValueError('This array can not represent an image.') 130 | else: 131 | raise ValueError('Invalid image type: ' + str(type(im))) 132 | 133 | # Done 134 | return images2 135 | 136 | 137 | def intToBin(i): 138 | """ Integer to two bytes """ 139 | # devide in two parts (bytes) 140 | i1 = i % 256 141 | i2 = int( i/256) 142 | # make string (little endian) 143 | return i.to_bytes(2,byteorder='little') 144 | 145 | 146 | class GifWriter: 147 | """ GifWriter() 148 | 149 | Class that contains methods for helping write the animated GIF file. 150 | 151 | """ 152 | 153 | def getheaderAnim(self, im): 154 | """ getheaderAnim(im) 155 | 156 | Get animation header. To replace PILs getheader()[0] 157 | 158 | """ 159 | bb = b'GIF89a' 160 | bb += intToBin(im.size[0]) 161 | bb += intToBin(im.size[1]) 162 | bb += b'\x87\x00\x00' 163 | return bb 164 | 165 | 166 | def getImageDescriptor(self, im, xy=None): 167 | """ getImageDescriptor(im, xy=None) 168 | 169 | Used for the local color table properties per image. 170 | Otherwise global color table applies to all frames irrespective of 171 | whether additional colors comes in play that require a redefined 172 | palette. Still a maximum of 256 color per frame, obviously. 173 | 174 | Written by Ant1 on 2010-08-22 175 | Modified by Alex Robinson in Janurari 2011 to implement subrectangles. 176 | 177 | """ 178 | 179 | # Defaule use full image and place at upper left 180 | if xy is None: 181 | xy = (0,0) 182 | 183 | # Image separator, 184 | bb = b'\x2C' 185 | 186 | # Image position and size 187 | bb += intToBin( xy[0] ) # Left position 188 | bb += intToBin( xy[1] ) # Top position 189 | bb += intToBin( im.size[0] ) # image width 190 | bb += intToBin( im.size[1] ) # image height 191 | 192 | # packed field: local color table flag1, interlace0, sorted table0, 193 | # reserved00, lct size111=7=2^(7+1)=256. 194 | bb += b'\x87' 195 | 196 | # LZW minimum size code now comes later, begining of [image data] blocks 197 | return bb 198 | 199 | 200 | def getAppExt(self, loops=float('inf')): 201 | """ getAppExt(loops=float('inf')) 202 | 203 | Application extention. This part specifies the amount of loops. 204 | If loops is 0 or inf, it goes on infinitely. 205 | 206 | """ 207 | 208 | if loops==0 or loops==float('inf'): 209 | loops = 2**16-1 210 | #bb = "" # application extension should not be used 211 | # (the extension interprets zero loops 212 | # to mean an infinite number of loops) 213 | # Mmm, does not seem to work 214 | if True: 215 | bb = b"\x21\xFF\x0B" # application extension 216 | bb += b"NETSCAPE2.0" 217 | bb += b"\x03\x01" 218 | bb += intToBin(loops) 219 | bb += b'\x00' # end 220 | return bb 221 | 222 | 223 | def getGraphicsControlExt(self, duration=0.1, dispose=2,transparent_flag=0,transparency_index=0): 224 | """ getGraphicsControlExt(duration=0.1, dispose=2) 225 | 226 | Graphics Control Extension. A sort of header at the start of 227 | each image. Specifies duration and transparancy. 228 | 229 | Dispose 230 | ------- 231 | * 0 - No disposal specified. 232 | * 1 - Do not dispose. The graphic is to be left in place. 233 | * 2 - Restore to background color. The area used by the graphic 234 | must be restored to the background color. 235 | * 3 - Restore to previous. The decoder is required to restore the 236 | area overwritten by the graphic with what was there prior to 237 | rendering the graphic. 238 | * 4-7 -To be defined. 239 | 240 | """ 241 | 242 | bb = b'\x21\xF9\x04' 243 | bb += bytes([((dispose & 3) << 2)|(transparent_flag & 1)]) # low bit 1 == transparency, 244 | # 2nd bit 1 == user input , next 3 bits, the low two of which are used, 245 | # are dispose. 246 | bb += intToBin( int(duration*100) ) # in 100th of seconds 247 | bb += bytes([transparency_index]) 248 | bb += b'\x00' # end 249 | return bb 250 | 251 | 252 | def handleSubRectangles(self, images, subRectangles): 253 | """ handleSubRectangles(images) 254 | 255 | Handle the sub-rectangle stuff. If the rectangles are given by the 256 | user, the values are checked. Otherwise the subrectangles are 257 | calculated automatically. 258 | 259 | """ 260 | 261 | image_info = [] 262 | 263 | for im in images: 264 | if hasattr(im, 'flags'): 265 | image_info.append(im.flags) 266 | 267 | if isinstance(subRectangles, (tuple, list)): 268 | # xy given directly 269 | 270 | # Check xy 271 | xy = subRectangles 272 | if xy is None: 273 | xy = (0, 0) 274 | if hasattr(xy, '__len__'): 275 | if len(xy) == len(images): 276 | xy = [xxyy for xxyy in xy] 277 | else: 278 | raise ValueError("len(xy) doesn't match amount of images.") 279 | else: 280 | xy = [xy for im in images] 281 | xy[0] = (0, 0) 282 | 283 | else: 284 | # Calculate xy using some basic image processing 285 | 286 | # Check Numpy 287 | if np is None: 288 | raise RuntimeError("Need Numpy to use auto-subRectangles.") 289 | 290 | # First make numpy arrays if required 291 | for i in range(len(images)): 292 | im = images[i] 293 | if isinstance(im, Image.Image): 294 | tmp = im.convert() # Make without palette 295 | a = np.asarray(tmp) 296 | if len(a.shape)==0: 297 | raise MemoryError("Too little memory to convert PIL image to array") 298 | images[i] = a 299 | 300 | # Determine the sub rectangles 301 | images, xy = self.getSubRectangles(images) 302 | 303 | # Done 304 | return images, xy, image_info 305 | 306 | 307 | def getSubRectangles(self, ims): 308 | """ getSubRectangles(ims) 309 | 310 | Calculate the minimal rectangles that need updating each frame. 311 | Returns a two-element tuple containing the cropped images and a 312 | list of x-y positions. 313 | 314 | Calculating the subrectangles takes extra time, obviously. However, 315 | if the image sizes were reduced, the actual writing of the GIF 316 | goes faster. In some cases applying this method produces a GIF faster. 317 | 318 | """ 319 | 320 | # Check image count 321 | if len(ims) < 2: 322 | return ims, [(0,0) for i in ims] 323 | 324 | # We need numpy 325 | if np is None: 326 | raise RuntimeError("Need Numpy to calculate sub-rectangles. ") 327 | 328 | # Prepare 329 | ims2 = [ims[0]] 330 | xy = [(0,0)] 331 | t0 = time.time() 332 | 333 | # Iterate over images 334 | prev = ims[0] 335 | for im in ims[1:]: 336 | 337 | # Get difference, sum over colors 338 | diff = np.abs(im-prev) 339 | if diff.ndim==3: 340 | diff = diff.sum(2) 341 | # Get begin and end for both dimensions 342 | X = np.argwhere(diff.sum(0)) 343 | Y = np.argwhere(diff.sum(1)) 344 | # Get rect coordinates 345 | if X.size and Y.size: 346 | x0, x1 = int(X[0][0]), int(X[-1][0]+1) 347 | y0, y1 = int(Y[0][0]), int(Y[-1][0]+1) 348 | else: # No change ... make it minimal 349 | x0, x1 = 0, 2 350 | y0, y1 = 0, 2 351 | 352 | # Cut out and store 353 | im2 = im[y0:y1,x0:x1] 354 | prev = im 355 | ims2.append(im2) 356 | xy.append((x0,y0)) 357 | 358 | # Done 359 | #print('%1.2f seconds to determine subrectangles of %i images' % 360 | # (time.time()-t0, len(ims2)) ) 361 | return ims2, xy 362 | 363 | 364 | def convertImagesToPIL(self, images, dither, nq=0,images_info=None): 365 | """ convertImagesToPIL(images, nq=0) 366 | 367 | Convert images to Paletted PIL images, which can then be 368 | written to a single animaged GIF. 369 | 370 | """ 371 | 372 | # Convert to PIL images 373 | images2 = [] 374 | for im in images: 375 | if isinstance(im, Image.Image): 376 | images2.append(im) 377 | elif np and isinstance(im, np.ndarray): 378 | if im.ndim==3 and im.shape[2]==3: 379 | im = Image.fromarray(im,'RGB') 380 | elif im.ndim==3 and im.shape[2]==4: 381 | # im = Image.fromarray(im[:,:,:3],'RGB') 382 | self.transparency = True 383 | im = Image.fromarray(im[:,:,:4],'RGBA') 384 | elif im.ndim==2: 385 | im = Image.fromarray(im,'L') 386 | images2.append(im) 387 | 388 | # Convert to paletted PIL images 389 | images, images2 = images2, [] 390 | if nq >= 1: 391 | # NeuQuant algorithm 392 | for im in images: 393 | im = im.convert("RGBA") # NQ assumes RGBA 394 | nqInstance = NeuQuant(im, int(nq)) # Learn colors from image 395 | if dither: 396 | im = im.convert("RGB").quantize(palette=nqInstance.paletteImage(),colors=255) 397 | else: 398 | im = nqInstance.quantize(im,colors=255) # Use to quantize the image itself 399 | 400 | self.transparency = True # since NQ assumes transparency 401 | if self.transparency: 402 | alpha = im.split()[3] 403 | mask = Image.eval(alpha, lambda a: 255 if a <=128 else 0) 404 | im.paste(255,mask=mask) 405 | images2.append(im) 406 | else: 407 | # Adaptive PIL algorithm 408 | AD = Image.ADAPTIVE 409 | # for index,im in enumerate(images): 410 | for i in range(len(images)): 411 | im = images[i].convert('RGB').convert('P', palette=AD, dither=dither,colors=255) 412 | if self.transparency: 413 | alpha = images[i].split()[3] 414 | mask = Image.eval(alpha, lambda a: 255 if a <=128 else 0) 415 | im.paste(255,mask=mask) 416 | images2.append(im) 417 | 418 | # Done 419 | return images2 420 | 421 | 422 | def writeGifToFile(self, fp, images, durations, loops, xys, disposes): 423 | """ writeGifToFile(fp, images, durations, loops, xys, disposes) 424 | 425 | Given a set of images writes the bytes to the specified stream. 426 | 427 | """ 428 | 429 | # Obtain palette for all images and count each occurance 430 | palettes, occur = [], [] 431 | for im in images: 432 | palettes.append( getheader(im)[0][3] ) 433 | for palette in palettes: 434 | occur.append( palettes.count( palette ) ) 435 | 436 | # Select most-used palette as the global one (or first in case no max) 437 | globalPalette = palettes[ occur.index(max(occur)) ] 438 | 439 | # Init 440 | frames = 0 441 | firstFrame = True 442 | 443 | 444 | for im, palette in zip(images, palettes): 445 | 446 | if firstFrame: 447 | # Write header 448 | 449 | # Gather info 450 | header = self.getheaderAnim(im) 451 | appext = self.getAppExt(loops) 452 | 453 | # Write 454 | fp.write(header) 455 | fp.write(globalPalette) 456 | fp.write(appext) 457 | 458 | # Next frame is not the first 459 | firstFrame = False 460 | 461 | if True: 462 | # Write palette and image data 463 | 464 | # Gather info 465 | data = getdata(im) 466 | imdes, data = data[0], data[1:] 467 | 468 | transparent_flag = 0 469 | if self.transparency: transparent_flag = 1 470 | 471 | graphext = self.getGraphicsControlExt(durations[frames], 472 | disposes[frames],transparent_flag=transparent_flag,transparency_index=255) 473 | 474 | # Make image descriptor suitable for using 256 local color palette 475 | lid = self.getImageDescriptor(im, xys[frames]) 476 | 477 | # Write local header 478 | if (palette != globalPalette) or (disposes[frames] != 2): 479 | # Use local color palette 480 | fp.write(graphext) 481 | fp.write(lid) # write suitable image descriptor 482 | fp.write(palette) # write local color table 483 | fp.write(b'\x08') # LZW minimum size code 484 | else: 485 | # Use global color palette 486 | fp.write(graphext) 487 | fp.write(imdes) # write suitable image descriptor 488 | 489 | # Write image data 490 | for d in data: 491 | fp.write(d) 492 | 493 | # Prepare for next round 494 | frames = frames + 1 495 | 496 | fp.write(b';') # end gif 497 | return frames 498 | 499 | 500 | 501 | 502 | ## Exposed functions 503 | 504 | def writeGif(filename, images, duration=0.1, repeat=True, dither=False, 505 | nq=0, subRectangles=True, dispose=None): 506 | """ writeGif(filename, images, duration=0.1, repeat=True, dither=False, 507 | nq=0, subRectangles=True, dispose=None) 508 | 509 | Write an animated gif from the specified images. 510 | 511 | Parameters 512 | ---------- 513 | filename : string 514 | The name of the file to write the image to. 515 | images : list 516 | Should be a list consisting of PIL images or numpy arrays. 517 | The latter should be between 0 and 255 for integer types, and 518 | between 0 and 1 for float types. 519 | duration : scalar or list of scalars 520 | The duration for all frames, or (if a list) for each frame. 521 | repeat : bool or integer 522 | The amount of loops. If True, loops infinitetely. 523 | dither : bool 524 | Whether to apply dithering 525 | nq : integer 526 | If nonzero, applies the NeuQuant quantization algorithm to create 527 | the color palette. This algorithm is superior, but slower than 528 | the standard PIL algorithm. The value of nq is the quality 529 | parameter. 1 represents the best quality. 10 is in general a 530 | good tradeoff between quality and speed. When using this option, 531 | better results are usually obtained when subRectangles is False. 532 | subRectangles : False, True, or a list of 2-element tuples 533 | Whether to use sub-rectangles. If True, the minimal rectangle that 534 | is required to update each frame is automatically detected. This 535 | can give significant reductions in file size, particularly if only 536 | a part of the image changes. One can also give a list of x-y 537 | coordinates if you want to do the cropping yourself. The default 538 | is True. 539 | dispose : int 540 | How to dispose each frame. 1 means that each frame is to be left 541 | in place. 2 means the background color should be restored after 542 | each frame. 3 means the decoder should restore the previous frame. 543 | If subRectangles==False, the default is 2, otherwise it is 1. 544 | 545 | """ 546 | 547 | # Check PIL 548 | if PIL is None: 549 | raise RuntimeError("Need PIL to write animated gif files.") 550 | 551 | # Check images 552 | images = checkImages(images) 553 | 554 | # Instantiate writer object 555 | gifWriter = GifWriter() 556 | gifWriter.transparency = False # init transparency flag used in GifWriter functions 557 | 558 | # Check loops 559 | if repeat is False: 560 | loops = 1 561 | elif repeat is True: 562 | loops = 0 # zero means infinite 563 | else: 564 | loops = int(repeat) 565 | 566 | # Check duration 567 | if hasattr(duration, '__len__'): 568 | if len(duration) == len(images): 569 | duration = [d for d in duration] 570 | else: 571 | raise ValueError("len(duration) doesn't match amount of images.") 572 | else: 573 | duration = [duration for im in images] 574 | 575 | # Check subrectangles 576 | if subRectangles: 577 | images, xy, images_info = gifWriter.handleSubRectangles(images, subRectangles) 578 | defaultDispose = 1 # Leave image in place 579 | else: 580 | # Normal mode 581 | xy = [(0,0) for im in images] 582 | defaultDispose = 2 # Restore to background color. 583 | 584 | # Check dispose 585 | if dispose is None: 586 | dispose = defaultDispose 587 | if hasattr(dispose, '__len__'): 588 | if len(dispose) != len(images): 589 | raise ValueError("len(xy) doesn't match amount of images.") 590 | else: 591 | dispose = [dispose for im in images] 592 | 593 | # Make images in a format that we can write easy 594 | images = gifWriter.convertImagesToPIL(images, dither, nq) 595 | 596 | # Write 597 | fp = open(filename, 'wb') 598 | try: 599 | gifWriter.writeGifToFile(fp, images, duration, loops, xy, dispose) 600 | finally: 601 | fp.close() 602 | 603 | 604 | 605 | def readGif(filename, asNumpy=True): 606 | """ readGif(filename, asNumpy=True) 607 | 608 | Read images from an animated GIF file. Returns a list of numpy 609 | arrays, or, if asNumpy is false, a list if PIL images. 610 | 611 | """ 612 | 613 | # Check PIL 614 | if PIL is None: 615 | raise RuntimeError("Need PIL to read animated gif files.") 616 | 617 | # Check Numpy 618 | if np is None: 619 | raise RuntimeError("Need Numpy to read animated gif files.") 620 | 621 | # Check whether it exists 622 | if not os.path.isfile(filename): 623 | raise IOError('File not found: '+str(filename)) 624 | 625 | # Load file using PIL 626 | pilIm = PIL.Image.open(filename) 627 | pilIm.seek(0) 628 | 629 | # Read all images inside 630 | images = [] 631 | try: 632 | while True: 633 | # Get image as numpy array 634 | tmp = pilIm.convert() # Make without palette 635 | a = np.asarray(tmp) 636 | if len(a.shape)==0: 637 | raise MemoryError("Too little memory to convert PIL image to array") 638 | # Store, and next 639 | images.append(a) 640 | pilIm.seek(pilIm.tell()+1) 641 | except EOFError: 642 | pass 643 | 644 | # Convert to normal PIL images if needed 645 | if not asNumpy: 646 | images2 = images 647 | images = [] 648 | for index,im in enumerate(images2): 649 | tmp = PIL.Image.fromarray(im) 650 | images.append(tmp) 651 | 652 | # Done 653 | return images 654 | 655 | 656 | class NeuQuant: 657 | """ NeuQuant(image, samplefac=10, colors=256) 658 | 659 | samplefac should be an integer number of 1 or higher, 1 660 | being the highest quality, but the slowest performance. 661 | With avalue of 10, one tenth of all pixels are used during 662 | training. This value seems a nice tradeof between speed 663 | and quality. 664 | 665 | colors is the amount of colors to reduce the image to. This 666 | should best be a power of two. 667 | 668 | See also: 669 | http://members.ozemail.com.au/~dekker/NEUQUANT.HTML 670 | 671 | License of the NeuQuant Neural-Net Quantization Algorithm 672 | --------------------------------------------------------- 673 | 674 | Copyright (c) 1994 Anthony Dekker 675 | Ported to python by Marius van Voorden in 2010 676 | 677 | NEUQUANT Neural-Net quantization algorithm by Anthony Dekker, 1994. 678 | See "Kohonen neural networks for optimal colour quantization" 679 | in "network: Computation in Neural Systems" Vol. 5 (1994) pp 351-367. 680 | for a discussion of the algorithm. 681 | See also http://members.ozemail.com.au/~dekker/NEUQUANT.HTML 682 | 683 | Any party obtaining a copy of these files from the author, directly or 684 | indirectly, is granted, free of charge, a full and unrestricted irrevocable, 685 | world-wide, paid up, royalty-free, nonexclusive right and license to deal 686 | in this software and documentation files (the "Software"), including without 687 | limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 688 | and/or sell copies of the Software, and to permit persons who receive 689 | copies from any such party to do so, with the only requirement being 690 | that this copyright notice remain intact. 691 | 692 | """ 693 | 694 | NCYCLES = None # Number of learning cycles 695 | NETSIZE = None # Number of colours used 696 | SPECIALS = None # Number of reserved colours used 697 | BGCOLOR = None # Reserved background colour 698 | CUTNETSIZE = None 699 | MAXNETPOS = None 700 | 701 | INITRAD = None # For 256 colours, radius starts at 32 702 | RADIUSBIASSHIFT = None 703 | RADIUSBIAS = None 704 | INITBIASRADIUS = None 705 | RADIUSDEC = None # Factor of 1/30 each cycle 706 | 707 | ALPHABIASSHIFT = None 708 | INITALPHA = None # biased by 10 bits 709 | 710 | GAMMA = None 711 | BETA = None 712 | BETAGAMMA = None 713 | 714 | network = None # The network itself 715 | colormap = None # The network itself 716 | 717 | netindex = None # For network lookup - really 256 718 | 719 | bias = None # Bias and freq arrays for learning 720 | freq = None 721 | 722 | pimage = None 723 | 724 | # Four primes near 500 - assume no image has a length so large 725 | # that it is divisible by all four primes 726 | PRIME1 = 499 727 | PRIME2 = 491 728 | PRIME3 = 487 729 | PRIME4 = 503 730 | MAXPRIME = PRIME4 731 | 732 | pixels = None 733 | samplefac = None 734 | 735 | a_s = None 736 | 737 | 738 | def setconstants(self, samplefac, colors): 739 | self.NCYCLES = 100 # Number of learning cycles 740 | self.NETSIZE = colors # Number of colours used 741 | self.SPECIALS = 3 # Number of reserved colours used 742 | self.BGCOLOR = self.SPECIALS-1 # Reserved background colour 743 | self.CUTNETSIZE = self.NETSIZE - self.SPECIALS 744 | self.MAXNETPOS = self.NETSIZE - 1 745 | 746 | self.INITRAD = self.NETSIZE/8 # For 256 colours, radius starts at 32 747 | self.RADIUSBIASSHIFT = 6 748 | self.RADIUSBIAS = 1 << self.RADIUSBIASSHIFT 749 | self.INITBIASRADIUS = self.INITRAD * self.RADIUSBIAS 750 | self.RADIUSDEC = 30 # Factor of 1/30 each cycle 751 | 752 | self.ALPHABIASSHIFT = 10 # Alpha starts at 1 753 | self.INITALPHA = 1 << self.ALPHABIASSHIFT # biased by 10 bits 754 | 755 | self.GAMMA = 1024.0 756 | self.BETA = 1.0/1024.0 757 | self.BETAGAMMA = self.BETA * self.GAMMA 758 | 759 | self.network = np.empty((self.NETSIZE, 3), dtype='float64') # The network itself 760 | self.colormap = np.empty((self.NETSIZE, 4), dtype='int32') # The network itself 761 | 762 | self.netindex = np.empty(256, dtype='int32') # For network lookup - really 256 763 | 764 | self.bias = np.empty(self.NETSIZE, dtype='float64') # Bias and freq arrays for learning 765 | self.freq = np.empty(self.NETSIZE, dtype='float64') 766 | 767 | self.pixels = None 768 | self.samplefac = samplefac 769 | 770 | self.a_s = {} 771 | 772 | def __init__(self, image, samplefac=10, colors=256): 773 | 774 | # Check Numpy 775 | if np is None: 776 | raise RuntimeError("Need Numpy for the NeuQuant algorithm.") 777 | 778 | # Check image 779 | if image.size[0] * image.size[1] < NeuQuant.MAXPRIME: 780 | raise IOError("Image is too small") 781 | if image.mode != "RGBA": 782 | raise IOError("Image mode should be RGBA.") 783 | 784 | # Initialize 785 | self.setconstants(samplefac, colors) 786 | self.pixels = np.fromstring(image.tostring(), np.uint32) 787 | self.setUpArrays() 788 | 789 | self.learn() 790 | self.fix() 791 | self.inxbuild() 792 | 793 | def writeColourMap(self, rgb, outstream): 794 | for i in range(self.NETSIZE): 795 | bb = self.colormap[i,0]; 796 | gg = self.colormap[i,1]; 797 | rr = self.colormap[i,2]; 798 | outstream.write(rr if rgb else bb) 799 | outstream.write(gg) 800 | outstream.write(bb if rgb else rr) 801 | return self.NETSIZE 802 | 803 | def setUpArrays(self): 804 | self.network[0,0] = 0.0 # Black 805 | self.network[0,1] = 0.0 806 | self.network[0,2] = 0.0 807 | 808 | self.network[1,0] = 255.0 # White 809 | self.network[1,1] = 255.0 810 | self.network[1,2] = 255.0 811 | 812 | # RESERVED self.BGCOLOR # Background 813 | 814 | for i in range(self.SPECIALS): 815 | self.freq[i] = 1.0 / self.NETSIZE 816 | self.bias[i] = 0.0 817 | 818 | for i in range(self.SPECIALS, self.NETSIZE): 819 | p = self.network[i] 820 | p[:] = (255.0 * (i-self.SPECIALS)) / self.CUTNETSIZE 821 | 822 | self.freq[i] = 1.0 / self.NETSIZE 823 | self.bias[i] = 0.0 824 | 825 | # Omitted: setPixels 826 | 827 | def altersingle(self, alpha, i, b, g, r): 828 | """Move neuron i towards biased (b,g,r) by factor alpha""" 829 | n = self.network[i] # Alter hit neuron 830 | n[0] -= (alpha*(n[0] - b)) 831 | n[1] -= (alpha*(n[1] - g)) 832 | n[2] -= (alpha*(n[2] - r)) 833 | 834 | def geta(self, alpha, rad): 835 | try: 836 | return self.a_s[(alpha, rad)] 837 | except KeyError: 838 | length = rad*2-1 839 | mid = length/2 840 | q = np.array(list(range(mid-1,-1,-1))+list(range(-1,mid))) 841 | a = alpha*(rad*rad - q*q)/(rad*rad) 842 | a[mid] = 0 843 | self.a_s[(alpha, rad)] = a 844 | return a 845 | 846 | def alterneigh(self, alpha, rad, i, b, g, r): 847 | if i-rad >= self.SPECIALS-1: 848 | lo = i-rad 849 | start = 0 850 | else: 851 | lo = self.SPECIALS-1 852 | start = (self.SPECIALS-1 - (i-rad)) 853 | 854 | if i+rad <= self.NETSIZE: 855 | hi = i+rad 856 | end = rad*2-1 857 | else: 858 | hi = self.NETSIZE 859 | end = (self.NETSIZE - (i+rad)) 860 | 861 | a = self.geta(alpha, rad)[start:end] 862 | 863 | p = self.network[lo+1:hi] 864 | p -= np.transpose(np.transpose(p - np.array([b, g, r])) * a) 865 | 866 | #def contest(self, b, g, r): 867 | # """ Search for biased BGR values 868 | # Finds closest neuron (min dist) and updates self.freq 869 | # finds best neuron (min dist-self.bias) and returns position 870 | # for frequently chosen neurons, self.freq[i] is high and self.bias[i] is negative 871 | # self.bias[i] = self.GAMMA*((1/self.NETSIZE)-self.freq[i])""" 872 | # 873 | # i, j = self.SPECIALS, self.NETSIZE 874 | # dists = abs(self.network[i:j] - np.array([b,g,r])).sum(1) 875 | # bestpos = i + np.argmin(dists) 876 | # biasdists = dists - self.bias[i:j] 877 | # bestbiaspos = i + np.argmin(biasdists) 878 | # self.freq[i:j] -= self.BETA * self.freq[i:j] 879 | # self.bias[i:j] += self.BETAGAMMA * self.freq[i:j] 880 | # self.freq[bestpos] += self.BETA 881 | # self.bias[bestpos] -= self.BETAGAMMA 882 | # return bestbiaspos 883 | def contest(self, b, g, r): 884 | """ Search for biased BGR values 885 | Finds closest neuron (min dist) and updates self.freq 886 | finds best neuron (min dist-self.bias) and returns position 887 | for frequently chosen neurons, self.freq[i] is high and self.bias[i] is negative 888 | self.bias[i] = self.GAMMA*((1/self.NETSIZE)-self.freq[i])""" 889 | i, j = self.SPECIALS, self.NETSIZE 890 | dists = abs(self.network[i:j] - np.array([b,g,r])).sum(1) 891 | bestpos = i + np.argmin(dists) 892 | biasdists = dists - self.bias[i:j] 893 | bestbiaspos = i + np.argmin(biasdists) 894 | self.freq[i:j] *= (1-self.BETA) 895 | self.bias[i:j] += self.BETAGAMMA * self.freq[i:j] 896 | self.freq[bestpos] += self.BETA 897 | self.bias[bestpos] -= self.BETAGAMMA 898 | return bestbiaspos 899 | 900 | 901 | 902 | 903 | def specialFind(self, b, g, r): 904 | for i in range(self.SPECIALS): 905 | n = self.network[i] 906 | if n[0] == b and n[1] == g and n[2] == r: 907 | return i 908 | return -1 909 | 910 | def learn(self): 911 | biasRadius = self.INITBIASRADIUS 912 | alphadec = 30 + ((self.samplefac-1)/3) 913 | lengthcount = self.pixels.size 914 | samplepixels = lengthcount / self.samplefac 915 | delta = samplepixels / self.NCYCLES 916 | alpha = self.INITALPHA 917 | 918 | i = 0; 919 | rad = biasRadius >> self.RADIUSBIASSHIFT 920 | if rad <= 1: 921 | rad = 0 922 | 923 | print("Beginning 1D learning: samplepixels = %1.2f rad = %i" % 924 | (samplepixels, rad) ) 925 | step = 0 926 | pos = 0 927 | if lengthcount%NeuQuant.PRIME1 != 0: 928 | step = NeuQuant.PRIME1 929 | elif lengthcount%NeuQuant.PRIME2 != 0: 930 | step = NeuQuant.PRIME2 931 | elif lengthcount%NeuQuant.PRIME3 != 0: 932 | step = NeuQuant.PRIME3 933 | else: 934 | step = NeuQuant.PRIME4 935 | 936 | i = 0 937 | printed_string = '' 938 | while i < samplepixels: 939 | if i%100 == 99: 940 | tmp = '\b'*len(printed_string) 941 | printed_string = str((i+1)*100/samplepixels)+"%\n" 942 | print(tmp + printed_string) 943 | p = self.pixels[pos] 944 | r = (p >> 16) & 0xff 945 | g = (p >> 8) & 0xff 946 | b = (p ) & 0xff 947 | 948 | if i == 0: # Remember background colour 949 | self.network[self.BGCOLOR] = [b, g, r] 950 | 951 | j = self.specialFind(b, g, r) 952 | if j < 0: 953 | j = self.contest(b, g, r) 954 | 955 | if j >= self.SPECIALS: # Don't learn for specials 956 | a = (1.0 * alpha) / self.INITALPHA 957 | self.altersingle(a, j, b, g, r) 958 | if rad > 0: 959 | self.alterneigh(a, rad, j, b, g, r) 960 | 961 | pos = (pos+step)%lengthcount 962 | 963 | i += 1 964 | if i%delta == 0: 965 | alpha -= alpha / alphadec 966 | biasRadius -= biasRadius / self.RADIUSDEC 967 | rad = biasRadius >> self.RADIUSBIASSHIFT 968 | if rad <= 1: 969 | rad = 0 970 | 971 | finalAlpha = (1.0*alpha)/self.INITALPHA 972 | print("Finished 1D learning: final alpha = %1.2f!" % finalAlpha) 973 | 974 | def fix(self): 975 | for i in range(self.NETSIZE): 976 | for j in range(3): 977 | x = int(0.5 + self.network[i,j]) 978 | x = max(0, x) 979 | x = min(255, x) 980 | self.colormap[i,j] = x 981 | self.colormap[i,3] = i 982 | 983 | def inxbuild(self): 984 | previouscol = 0 985 | startpos = 0 986 | for i in range(self.NETSIZE): 987 | p = self.colormap[i] 988 | q = None 989 | smallpos = i 990 | smallval = p[1] # Index on g 991 | # Find smallest in i..self.NETSIZE-1 992 | for j in range(i+1, self.NETSIZE): 993 | q = self.colormap[j] 994 | if q[1] < smallval: # Index on g 995 | smallpos = j 996 | smallval = q[1] # Index on g 997 | 998 | q = self.colormap[smallpos] 999 | # Swap p (i) and q (smallpos) entries 1000 | if i != smallpos: 1001 | p[:],q[:] = q, p.copy() 1002 | 1003 | # smallval entry is now in position i 1004 | if smallval != previouscol: 1005 | self.netindex[previouscol] = (startpos+i) >> 1 1006 | for j in range(previouscol+1, smallval): 1007 | self.netindex[j] = i 1008 | previouscol = smallval 1009 | startpos = i 1010 | self.netindex[previouscol] = (startpos+self.MAXNETPOS) >> 1 1011 | for j in range(previouscol+1, 256): # Really 256 1012 | self.netindex[j] = self.MAXNETPOS 1013 | 1014 | 1015 | def paletteImage(self): 1016 | """ PIL weird interface for making a paletted image: create an image which 1017 | already has the palette, and use that in Image.quantize. This function 1018 | returns this palette image. """ 1019 | if self.pimage is None: 1020 | palette = [] 1021 | for i in range(self.NETSIZE): 1022 | palette.extend(self.colormap[i][:3]) 1023 | 1024 | palette.extend([0]*(256-self.NETSIZE)*3) 1025 | 1026 | # a palette image to use for quant 1027 | self.pimage = Image.new("P", (1, 1), 0) 1028 | self.pimage.putpalette(palette) 1029 | return self.pimage 1030 | 1031 | 1032 | def quantize(self, image): 1033 | """ Use a kdtree to quickly find the closest palette colors for the pixels """ 1034 | if get_cKDTree(): 1035 | return self.quantize_with_scipy(image) 1036 | else: 1037 | print('Scipy not available, falling back to slower version.') 1038 | return self.quantize_without_scipy(image) 1039 | 1040 | 1041 | def quantize_with_scipy(self, image): 1042 | w,h = image.size 1043 | px = np.asarray(image).copy() 1044 | px2 = px[:,:,:3].reshape((w*h,3)) 1045 | 1046 | cKDTree = get_cKDTree() 1047 | kdtree = cKDTree(self.colormap[:,:3],leafsize=10) 1048 | result = kdtree.query(px2) 1049 | colorindex = result[1] 1050 | print("Distance: %1.2f" % (result[0].sum()/(w*h)) ) 1051 | px2[:] = self.colormap[colorindex,:3] 1052 | 1053 | return Image.fromarray(px).convert("RGB").quantize(palette=self.paletteImage()) 1054 | 1055 | 1056 | def quantize_without_scipy(self, image): 1057 | """" This function can be used if no scipy is availabe. 1058 | It's 7 times slower though. 1059 | """ 1060 | w,h = image.size 1061 | px = np.asarray(image).copy() 1062 | memo = {} 1063 | for j in range(w): 1064 | for i in range(h): 1065 | key = (px[i,j,0],px[i,j,1],px[i,j,2]) 1066 | try: 1067 | val = memo[key] 1068 | except KeyError: 1069 | val = self.convert(*key) 1070 | memo[key] = val 1071 | px[i,j,0],px[i,j,1],px[i,j,2] = val 1072 | return Image.fromarray(px).convert("RGB").quantize(palette=self.paletteImage()) 1073 | 1074 | def convert(self, *color): 1075 | i = self.inxsearch(*color) 1076 | return self.colormap[i,:3] 1077 | 1078 | def inxsearch(self, r, g, b): 1079 | """Search for BGR values 0..255 and return colour index""" 1080 | dists = (self.colormap[:,:3] - np.array([r,g,b])) 1081 | a= np.argmin((dists*dists).sum(1)) 1082 | return a 1083 | 1084 | 1085 | 1086 | if __name__ == '__main__': 1087 | im = np.zeros((200,200), dtype=np.uint8) 1088 | im[10:30,:] = 100 1089 | im[:,80:120] = 255 1090 | im[-50:-40,:] = 50 1091 | 1092 | images = [np.uint8(im*1.0), np.uint8(im*0.8), np.uint8(im*0.6), np.uint8(im*0.4), np.uint8(im*0)] 1093 | writeGif('test.gif',images, duration=0.5, dither=0) 1094 | 1095 | print('done') 1096 | --------------------------------------------------------------------------------