├── 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 [](https://travis-ci.org/JuanPotato/Legofy) [](https://pypi.python.org/pypi/legofy) [](https://pypi.python.org/pypi/legofy) [](https://pypi.python.org/pypi/legofy) [](https://coveralls.io/github/JuanPotato/Legofy?branch=master) [](https://landscape.io/github/JuanPotato/Legofy/master) [](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 |
9 |
10 |
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 |
--------------------------------------------------------------------------------