├── tests ├── __init__.py └── data │ └── LR_jacket_site_v2.jpg ├── instakit ├── __init__.pxd ├── abc │ ├── __init__.pxd │ └── __init__.py ├── meta │ ├── __init__.pxd │ ├── __init__.py │ └── parameters.py ├── utils │ ├── __init__.pxd │ ├── __init__.py │ ├── ext │ │ ├── __init__.pxd │ │ ├── __init__.py │ │ ├── funcs.pxd │ │ ├── api.pyx │ │ ├── hsluv.h │ │ └── hsluv.c │ ├── scripts │ │ └── .insert-scripts-here │ ├── misc.py │ ├── kernels.py │ ├── static.py │ ├── stats.py │ ├── colortype.py │ ├── lutmap.py │ ├── gcr.py │ ├── mode.py │ ├── ndarrays.py │ └── pipeline.py ├── comparators │ ├── __init__.pxd │ ├── __init__.py │ └── ext │ │ ├── __init__.pxd │ │ ├── __init__.py │ │ ├── buttereye.pyx │ │ └── butteraugli.pxd ├── processors │ ├── __init__.pxd │ ├── __init__.py │ ├── ext │ │ ├── __init__.pxd │ │ ├── __init__.py │ │ ├── halftone.h │ │ └── halftone.pyx │ ├── noise.py │ ├── squarecrop.py │ ├── blur.py │ ├── adjust.py │ ├── curves.py │ └── halftone.py ├── __version__.py ├── data │ ├── acv │ │ ├── red.acv │ │ ├── green.acv │ │ ├── Vintage.acv │ │ ├── country.acv │ │ ├── desert.acv │ │ ├── trains.acv │ │ ├── yellow.acv │ │ ├── fogy_blue.acv │ │ ├── fresh_blue.acv │ │ ├── dramaticsee.acv │ │ ├── exoticmountain.acv │ │ ├── new_2_fresh_blue.acv │ │ ├── exoticmountain_light.acv │ │ ├── sparks by julia trotti.acv │ │ ├── carousel by julia trotti.acv │ │ ├── electric by julia trotti.acv │ │ ├── blood orange by julia trotti.acv │ │ ├── humming bees by julia trotti.acv │ │ ├── midnight hour by julia trotti.acv │ │ └── wild at heart by julia trotti.acv │ ├── hither │ │ ├── scene.png │ │ ├── rocket.jpg │ │ ├── astronaut.png │ │ ├── scenebayer0.png │ │ └── scenenodither.png │ ├── lut │ │ ├── amatorka.png │ │ ├── gradient.png │ │ ├── identity.png │ │ ├── lookup.png │ │ ├── miss_etikate.png │ │ ├── soft_elegance_1.png │ │ └── soft_elegance_2.png │ ├── img │ │ ├── 06-DSCN4771.JPG │ │ ├── indy-moving-into-my-move-in.jpg │ │ ├── max-hardcore-feline-strategy-advisor.jpg │ │ ├── 430023_3625646599363_1219964362_3676052_834528487_n.jpg │ │ └── 472512_10100627861801935_22413710_51140672_988173580_o.jpg │ └── icc │ │ └── sRGB-IEC61966-2.1.icc ├── exporting.py └── __init__.py ├── .gitattributes ├── requirements ├── tox.txt ├── mypy.txt ├── dev.txt └── install.txt ├── .landscape.yml ├── .gitignore ├── ABOUT.ETC.md ├── .bumpversion.cfg ├── .travis.yml ├── .editorconfig ├── tox.ini ├── appveyor.yml ├── MANIFEST.in ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── Makefile ├── ABOUT.md ├── LICENSE.txt ├── CODE_OF_CONDUCT.md ├── README.md └── setup.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/__init__.pxd: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/abc/__init__.pxd: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/meta/__init__.pxd: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/meta/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/utils/__init__.pxd: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/comparators/__init__.pxd: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/comparators/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/processors/__init__.pxd: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/processors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/utils/ext/__init__.pxd: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/utils/ext/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/comparators/ext/__init__.pxd: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/processors/ext/__init__.pxd: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/utils/scripts/.insert-scripts-here: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instakit/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.9.0' -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.eps binary 2 | *.ppm binary 3 | *.container binary 4 | -------------------------------------------------------------------------------- /requirements/tox.txt: -------------------------------------------------------------------------------- 1 | -r install.txt 2 | check-manifest>=0.36 3 | pytest>=5.0.0 -------------------------------------------------------------------------------- /.landscape.yml: -------------------------------------------------------------------------------- 1 | strictness: medium 2 | test-warnings: yes 3 | max-line-length: 120 4 | -------------------------------------------------------------------------------- /instakit/data/acv/red.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/red.acv -------------------------------------------------------------------------------- /instakit/data/acv/green.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/green.acv -------------------------------------------------------------------------------- /instakit/data/acv/Vintage.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/Vintage.acv -------------------------------------------------------------------------------- /instakit/data/acv/country.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/country.acv -------------------------------------------------------------------------------- /instakit/data/acv/desert.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/desert.acv -------------------------------------------------------------------------------- /instakit/data/acv/trains.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/trains.acv -------------------------------------------------------------------------------- /instakit/data/acv/yellow.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/yellow.acv -------------------------------------------------------------------------------- /instakit/data/hither/scene.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/hither/scene.png -------------------------------------------------------------------------------- /instakit/data/lut/amatorka.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/lut/amatorka.png -------------------------------------------------------------------------------- /instakit/data/lut/gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/lut/gradient.png -------------------------------------------------------------------------------- /instakit/data/lut/identity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/lut/identity.png -------------------------------------------------------------------------------- /instakit/data/lut/lookup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/lut/lookup.png -------------------------------------------------------------------------------- /instakit/data/acv/fogy_blue.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/fogy_blue.acv -------------------------------------------------------------------------------- /instakit/data/acv/fresh_blue.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/fresh_blue.acv -------------------------------------------------------------------------------- /instakit/data/hither/rocket.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/hither/rocket.jpg -------------------------------------------------------------------------------- /instakit/processors/ext/__init__.py: -------------------------------------------------------------------------------- 1 | from pkgutil import extend_path 2 | __path__ = extend_path(__path__, __name__) 3 | -------------------------------------------------------------------------------- /tests/data/LR_jacket_site_v2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/tests/data/LR_jacket_site_v2.jpg -------------------------------------------------------------------------------- /instakit/comparators/ext/__init__.py: -------------------------------------------------------------------------------- 1 | from pkgutil import extend_path 2 | __path__ = extend_path(__path__, __name__) 3 | -------------------------------------------------------------------------------- /instakit/data/acv/dramaticsee.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/dramaticsee.acv -------------------------------------------------------------------------------- /instakit/data/hither/astronaut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/hither/astronaut.png -------------------------------------------------------------------------------- /instakit/data/img/06-DSCN4771.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/img/06-DSCN4771.JPG -------------------------------------------------------------------------------- /instakit/data/lut/miss_etikate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/lut/miss_etikate.png -------------------------------------------------------------------------------- /instakit/data/acv/exoticmountain.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/exoticmountain.acv -------------------------------------------------------------------------------- /instakit/data/hither/scenebayer0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/hither/scenebayer0.png -------------------------------------------------------------------------------- /instakit/data/lut/soft_elegance_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/lut/soft_elegance_1.png -------------------------------------------------------------------------------- /instakit/data/lut/soft_elegance_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/lut/soft_elegance_2.png -------------------------------------------------------------------------------- /instakit/data/acv/new_2_fresh_blue.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/new_2_fresh_blue.acv -------------------------------------------------------------------------------- /instakit/data/hither/scenenodither.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/hither/scenenodither.png -------------------------------------------------------------------------------- /instakit/data/icc/sRGB-IEC61966-2.1.icc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/icc/sRGB-IEC61966-2.1.icc -------------------------------------------------------------------------------- /instakit/data/acv/exoticmountain_light.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/exoticmountain_light.acv -------------------------------------------------------------------------------- /instakit/data/acv/sparks by julia trotti.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/sparks by julia trotti.acv -------------------------------------------------------------------------------- /instakit/data/acv/carousel by julia trotti.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/carousel by julia trotti.acv -------------------------------------------------------------------------------- /instakit/data/acv/electric by julia trotti.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/electric by julia trotti.acv -------------------------------------------------------------------------------- /requirements/mypy.txt: -------------------------------------------------------------------------------- 1 | -r install.txt 2 | mypy 3 | mypy_extensions 4 | typeshed 5 | typed-ast 6 | typing 7 | typing_extensions 8 | typing_inspect 9 | -------------------------------------------------------------------------------- /instakit/data/acv/blood orange by julia trotti.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/blood orange by julia trotti.acv -------------------------------------------------------------------------------- /instakit/data/acv/humming bees by julia trotti.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/humming bees by julia trotti.acv -------------------------------------------------------------------------------- /instakit/data/img/indy-moving-into-my-move-in.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/img/indy-moving-into-my-move-in.jpg -------------------------------------------------------------------------------- /instakit/data/acv/midnight hour by julia trotti.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/midnight hour by julia trotti.acv -------------------------------------------------------------------------------- /instakit/data/acv/wild at heart by julia trotti.acv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/acv/wild at heart by julia trotti.acv -------------------------------------------------------------------------------- /instakit/data/img/max-hardcore-feline-strategy-advisor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/img/max-hardcore-feline-strategy-advisor.jpg -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r tox.txt 2 | PyYAML 3 | bpython 4 | bumpversion 5 | flake8 6 | flake8-bugbear 7 | ipython 8 | jupyter 9 | pythonpy 10 | twine 11 | xerox 12 | yolk1977 -------------------------------------------------------------------------------- /instakit/data/img/430023_3625646599363_1219964362_3676052_834528487_n.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/img/430023_3625646599363_1219964362_3676052_834528487_n.jpg -------------------------------------------------------------------------------- /instakit/data/img/472512_10100627861801935_22413710_51140672_988173580_o.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fish2000/instakit/HEAD/instakit/data/img/472512_10100627861801935_22413710_51140672_988173580_o.jpg -------------------------------------------------------------------------------- /instakit/processors/ext/halftone.h: -------------------------------------------------------------------------------- 1 | 2 | #define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION 3 | 4 | #ifndef atkinson_add_error 5 | #define atkinson_add_error(b, e) ( ((b + e) <= 0x00) ? 0x00 : ( (( b + e) >= 0xFF) ? 0xFF : (b + e) ) ) 6 | #endif 7 | -------------------------------------------------------------------------------- /requirements/install.txt: -------------------------------------------------------------------------------- 1 | python-clu>=0.6.6 2 | Cython>=0.29.0 3 | docopt>=0.6.0 4 | enum34>=1.1.0; python_version < '3.4' 5 | Pillow>=6.0.0 6 | numpy>=1.7.0 7 | scipy>=1.1.0 8 | scikit-image>=0.12.0 9 | setuptools>=20.0.0 10 | six>=1.10.0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.orig 3 | .DS_Store 4 | .tm_properties 5 | *~ 6 | \#*\# 7 | Icon* 8 | 9 | *.o 10 | *.so 11 | *.dmg 12 | *.jar 13 | a.out 14 | 15 | instakit.egg-info/ 16 | django_instakit.egg-info/ 17 | build/ 18 | dist/ 19 | sdist/ 20 | 21 | .envrc 22 | 23 | tmtags 24 | tmtagsHistory -------------------------------------------------------------------------------- /ABOUT.ETC.md: -------------------------------------------------------------------------------- 1 | Experienced users may also make use of the many utilities shipping with 2 | instakit: LUT maps, color structs, pipeline processing primitives and 3 | ink-based separation simulation tools, Enums and wrapper APIs to simplify 4 | PIL's rougher edges – like (say) image modes and compositing - plus other 5 | related miscellany for the enterprising programmer. -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.9.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = __version__ = '{current_version}' 8 | replace = __version__ = '{new_version}' 9 | 10 | [bumpversion:file:instakit/__version__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # this file is *not* meant to cover or endorse the use of travis, but rather to 2 | # help confirm pull requests to this project. 3 | 4 | language: python 5 | 6 | matrix: 7 | include: 8 | - python: 2.7 9 | env: TOXENV=py27 10 | - python: 3.4 11 | env: TOXENV=py34 12 | - python: 3.5 13 | env: TOXENV=py35 14 | - python: 3.6 15 | env: TOXENV=py36 16 | 17 | install: pip install tox 18 | 19 | script: tox 20 | 21 | notifications: 22 | email: false -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | # Unix-style newlines with a newline ending every file 6 | end_of_line = lf 7 | insert_final_newline = false 8 | charset = utf-8 9 | 10 | # Four-space indentation 11 | indent_size = 4 12 | indent_style = space 13 | trim_trailing_whitespace = false 14 | 15 | [*.yml] 16 | # Four-space indentation 17 | indent_size = 4 18 | indent_style = space 19 | 20 | # Tab indentation (no size specified) 21 | [Makefile] 22 | indent_style = tab 23 | -------------------------------------------------------------------------------- /instakit/utils/ext/funcs.pxd: -------------------------------------------------------------------------------- 1 | 2 | cdef extern from "hsluv.h" nogil: 3 | 4 | void hsluv2rgb(double h, double s, double l, 5 | double* pr, double* pg, double* pb) 6 | 7 | void rgb2hsluv(double r, double g, double b, 8 | double* ph, double* ps, double* pl) 9 | 10 | void hpluv2rgb(double h, double s, double l, 11 | double* pr, double* pg, double* pb) 12 | 13 | void rgb2hpluv(double r, double g, double b, 14 | double* ph, double* ps, double* pl) 15 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | norecursedirs = .git .svn .hg *.egg *.egg-info *. CVS build bdist dist sdist venv develop 4 | markers = 5 | nondeterministic: mark a test as potentially nondeterministic. 6 | TODO: mark a test as suggesting work needing to be done. 7 | 8 | [tox] 9 | envlist = py37,pypy36 10 | 11 | [testenv] 12 | platform = linux2|darwin 13 | deps = -rrequirements/tox.txt 14 | commands = 15 | check-manifest -v 16 | pytest 17 | passenv = 18 | USER 19 | HOME 20 | 21 | [flake8] 22 | exclude = .tox,*.egg,build,data,requirements 23 | select = E,W,F 24 | -------------------------------------------------------------------------------- /instakit/exporting.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import print_function 3 | 4 | import os 5 | 6 | from clu.exporting import ExporterBase 7 | 8 | # The “basepath” is the directory enclosing the package root: 9 | basepath = os.path.dirname( 10 | os.path.dirname(__file__)) 11 | 12 | class Exporter(ExporterBase, basepath=basepath, appname="instakit"): 13 | pass 14 | 15 | exporter = Exporter(path=__file__) 16 | export = exporter.decorator() 17 | 18 | export(Exporter) 19 | 20 | # Assign the modules’ `__all__` and `__dir__` using the exporter: 21 | __all__, __dir__ = exporter.all_and_dir() 22 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - PYTHON: "C:\\Python27" 4 | - PYTHON: "C:\\Python27-x64" 5 | - PYTHON: "C:\\Python34" 6 | - PYTHON: "C:\\Python34-x64" 7 | - PYTHON: "C:\\Python35" 8 | - PYTHON: "C:\\Python35-x64" 9 | - PYTHON: "C:\\Python36" 10 | - PYTHON: "C:\\Python36-x64" 11 | - PYTHON: "C:\\Python37" 12 | - PYTHON: "C:\\Python37-x64" 13 | 14 | install: 15 | - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" 16 | - "python --version" 17 | - "pip install tox" 18 | 19 | build: off 20 | 21 | cache: 22 | - '%LOCALAPPDATA%\pip\Cache' 23 | 24 | test_script: 25 | - "tox -e py" -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | exclude *.pyc *~ *.so .DS_Store 2 | recursive-include requirements *.txt 3 | include .bumpversion.cfg 4 | include .clang-format 5 | include .editorconfig 6 | include .gitattributes 7 | include .gitignore 8 | include .landscape.yml 9 | include .travis.yml 10 | include ABOUT.md 11 | include ABOUT.ETC.md 12 | include appveyor.yml 13 | include CODE_OF_CONDUCT.md 14 | include LICENSE.txt 15 | include Makefile 16 | include README.md 17 | include setup.py 18 | include tox.ini 19 | recursive-include instakit * 20 | recursive-include tests * 21 | recursive-exclude requirements *.pyc *~ *.so .DS_Store 22 | recursive-exclude instakit *.pyc *~ *.so .DS_Store 23 | recursive-exclude tests *.pyc *~ *.so .DS_Store 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | clean: clean-pyc clean-cython 3 | 4 | distclean: clean-pyc clean-cython clean-build-artifacts 5 | 6 | rebuild: distclean cython 7 | 8 | dist: rebuild sdist twine-upload 9 | 10 | upload: bump dist 11 | git push 12 | 13 | bigupload: bigbump dist 14 | git push 15 | 16 | clean-pyc: 17 | find . -name \*.pyc -print -delete 18 | 19 | clean-cython: 20 | find . -name \*.so -print -delete 21 | 22 | clean-build-artifacts: 23 | rm -rf build dist instakit.egg-info 24 | 25 | cython: 26 | python setup.py build_ext --inplace 27 | 28 | sdist: 29 | python setup.py sdist 30 | 31 | twine-upload: 32 | twine upload -s dist/* 33 | 34 | bump: 35 | bumpversion --verbose patch 36 | 37 | bigbump: 38 | bumpversion --verbose minor 39 | 40 | check: 41 | check-manifest -v 42 | python setup.py check -m -s 43 | travis lint .travis.yml 44 | 45 | .PHONY: clean-pyc clean-cython clean-build-artifacts 46 | .PHONY: clean distclean rebuild dist upload bigupload 47 | .PHONY: cython sdist twine-upload bump bigbump check 48 | 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /ABOUT.md: -------------------------------------------------------------------------------- 1 | Instakit: Filters and Tools; BYO Facebook Buyout 2 | ===================================== 3 | 4 | Image processors and filters - inspired by Instagram, built on top of the 5 | PIL/Pillow, SciPy and scikit-image packages, accelerated with Cython, and 6 | ready to use with PILKit and the django-imagekit framework. 7 | 8 | Included are filters for Atkinson and Floyd-Steinberg dithering, dot-pitch 9 | halftoning (with GCR and per-channel pipeline processors), classes exposing 10 | image-processing pipeline data as NumPy ND-arrays, Gaussian kernel functions, 11 | processors for applying channel-based LUT curves to images from Photoshop 12 | .acv files, imagekit-ready processors furnishing streamlined access to a wide 13 | schmorgasbord of Pillow's many image adjustment algorithms (e.g. noise, blur, 14 | and sharpen functions, histogram-based operations like Brightness/Contrast, 15 | among others), an implementation of the entropy-based smart-crop algorithm 16 | many will recognize from the easy-thumbnails Django app - and much more. -------------------------------------------------------------------------------- /instakit/utils/misc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | from __future__ import print_function 4 | 5 | from clu.fs.misc import suffix_searcher, u8encode, u8bytes, u8str 6 | from clu.predicates import wrap_value, none_function, tuplize, uniquify, listify 7 | from clu.repr import stringify 8 | from clu.typespace.namespace import SimpleNamespace, Namespace 9 | from clu.typology import (string_types, bytes_types as byte_types) 10 | from instakit.exporting import Exporter 11 | 12 | exporter = Exporter(path=__file__) 13 | export = exporter.decorator() 14 | 15 | try: 16 | import six 17 | except ImportError: 18 | class FakeSix(object): 19 | @property 20 | def string_types(self): 21 | return tuple() 22 | six = FakeSix() 23 | 24 | export(wrap_value, name='wrap_value') 25 | export(none_function, name='none_function') 26 | export(tuplize, name='tuplize') 27 | export(uniquify, name='uniquify') 28 | export(listify, name='listify') 29 | 30 | export(SimpleNamespace) 31 | export(Namespace) 32 | 33 | export(stringify, name='stringify') 34 | export(string_types) 35 | export(byte_types) 36 | 37 | export(suffix_searcher) 38 | export(u8encode) 39 | export(u8bytes) 40 | export(u8str) 41 | 42 | # Assign the modules’ `__all__` and `__dir__` using the exporter: 43 | __all__, __dir__ = exporter.all_and_dir() 44 | 45 | -------------------------------------------------------------------------------- /instakit/utils/ext/api.pyx: -------------------------------------------------------------------------------- 1 | #cython: boundscheck=False 2 | #cython: nonecheck=False 3 | #cython: wraparound=False 4 | 5 | from cython.operator cimport address 6 | from instakit.utils.ext cimport funcs 7 | 8 | cpdef double[:] hsluv_to_rgb(double[:] hsl_triple) nogil: 9 | cdef double[:] rgb_triple = hsl_triple 10 | funcs.hsluv2rgb( hsl_triple[0], hsl_triple[1], hsl_triple[2], 11 | address(rgb_triple[0]), address(rgb_triple[1]), address(rgb_triple[2])) 12 | return rgb_triple 13 | 14 | cpdef double[:] rgb_to_hsluv(double[:] rgb_triple) nogil: 15 | cdef double[:] hsl_triple = rgb_triple 16 | funcs.rgb2hsluv( rgb_triple[0], rgb_triple[1], rgb_triple[2], 17 | address(hsl_triple[0]), address(hsl_triple[1]), address(hsl_triple[2])) 18 | return hsl_triple 19 | 20 | cpdef double[:] hpluv_to_rgb(double[:] hpl_triple) nogil: 21 | cdef double[:] rgb_triple = hpl_triple 22 | funcs.hpluv2rgb( hpl_triple[0], hpl_triple[1], hpl_triple[2], 23 | address(rgb_triple[0]), address(rgb_triple[1]), address(rgb_triple[2])) 24 | return rgb_triple 25 | 26 | cpdef double[:] rgb_to_hpluv(double[:] rgb_triple) nogil: 27 | cdef double[:] hpl_triple = rgb_triple 28 | funcs.rgb2hpluv( rgb_triple[0], rgb_triple[1], rgb_triple[2], 29 | address(hpl_triple[0]), address(hpl_triple[1]), address(hpl_triple[2])) 30 | return hpl_triple 31 | -------------------------------------------------------------------------------- /instakit/comparators/ext/buttereye.pyx: -------------------------------------------------------------------------------- 1 | #cython: boundscheck=False 2 | #cython: nonecheck=False 3 | #cython: wraparound=False 4 | 5 | from cython.operator cimport dereference as deref 6 | from cpython.float cimport PyFloat_AS_DOUBLE 7 | from libcpp.memory cimport unique_ptr 8 | 9 | from instakit.comparators.ext.butteraugli cimport Image8, ImageF 10 | from instakit.comparators.ext.butteraugli cimport image8vec, imagefvec 11 | from instakit.comparators.ext.butteraugli cimport floatvecvec, CreatePlanes 12 | from instakit.utils.mode import Mode 13 | 14 | ctypedef unique_ptr[ImageF] imagef_ptr 15 | ctypedef unique_ptr[Image8] image8_ptr 16 | 17 | cdef imagefvec image_to_planar_vector(object pilimage): 18 | cdef int width, height, x, y 19 | cdef double point 20 | cdef float* planerow 21 | cdef object pypoint, image, accessor 22 | cdef object bands, band 23 | cdef imagef_ptr plane 24 | cdef imagefvec planes 25 | cdef int bandcount, idx 26 | 27 | width, height = pilimage.size 28 | bands = Mode.RGB.process(pilimage).split() 29 | 30 | with nogil: 31 | bandcount = 3 32 | planes = CreatePlanes[float](width, 33 | height, 34 | bandcount) 35 | 36 | for idx in range(bandcount): 37 | band = bands[idx] 38 | image = Mode.F.process(band) 39 | accessor = image.load() 40 | 41 | for y in range(height): 42 | planerow = planes[idx].Row(y) 43 | for x in range(width): 44 | pypoint = accessor[x, y] 45 | point = PyFloat_AS_DOUBLE(pypoint) 46 | planerow[x] = point 47 | 48 | # AND SO NOW WHAT??! 49 | -------------------------------------------------------------------------------- /instakit/utils/kernels.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | from __future__ import print_function 4 | 5 | from instakit.exporting import Exporter 6 | 7 | exporter = Exporter(path=__file__) 8 | export = exporter.decorator() 9 | 10 | @export 11 | def gaussian(size, sizeY=None): 12 | """ Returns a normalized 2D gauss kernel array for convolutions """ 13 | from scipy import mgrid, exp 14 | sizeX = int(size) 15 | if not sizeY: 16 | sizeY = sizeX 17 | else: 18 | sizeY = int(sizeY) 19 | x, y = mgrid[-sizeX:sizeX+1, 20 | -sizeY:sizeY+1] 21 | g = exp(-(x**2/float(sizeX)+y**2/float(sizeY))) 22 | return (g / g.sum()).flatten() 23 | 24 | @export 25 | def gaussian_blur_kernel(ndim, kernel): 26 | from scipy.ndimage import convolve 27 | return convolve(ndim, kernel, mode='reflect') 28 | 29 | @export 30 | def gaussian_blur(ndim, sigma=3, sizeY=None): 31 | return gaussian_blur_kernel(ndim, 32 | gaussian(sigma, 33 | sizeY=sizeY)) 34 | 35 | @export 36 | def gaussian_blur_filter(input, sigmaX=3, 37 | sigmaY=3, 38 | sigmaZ=0): 39 | from scipy.ndimage.filters import gaussian_filter 40 | if not sigmaY: 41 | sigmaY = sigmaX 42 | if not sigmaZ: 43 | sigmaZ = sigmaX 44 | return gaussian_filter(input, sigma=(sigmaX, 45 | sigmaY, 46 | sigmaZ), order=0, 47 | mode='reflect') 48 | 49 | # Assign the modules’ `__all__` and `__dir__` using the exporter: 50 | __all__, __dir__ = exporter.all_and_dir() 51 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright © 2012-2019 Alexander Böhn 2 | 3 | Permission is hereby granted by Alexander Böhn (the "Guy"), 4 | free of charge, to any person obtaining a copy of this software 5 | and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation 7 | the rights to use, copy, modify, merge, publish, distribute, 8 | sublicense, sell copies of, and/or generally fuck with the 9 | Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | * The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | * Users of the Software (the "User(s)") who enjoy the use of the 16 | Software who recognize the Guy in person without mentioning the 17 | fact of their enjoyment shall, henceforth and hithertoforthwith, 18 | waive any soveriegn right to contest reputational damages related 19 | to the Guy anecdotally describing how the User(s) were totally 20 | weird that one time, potentially. 21 | 22 | § Any such aforementioned potential anecdotes may, at the sole 23 | discretion of the Guy, terminate herewith in a pause before 24 | the word "Huh" and then maybe like an eyebrow-raisey thing. 25 | 26 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 27 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 28 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 29 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 30 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 31 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 32 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 33 | OTHER DEALINGS IN THE SOFTWARE. 34 | -------------------------------------------------------------------------------- /instakit/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # INSTAKIT -- Instagrammy image-processors and tools, based on Pillow and SciPy 5 | # 6 | # Copyright © 2012-2025 Alexander Böhn 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in all 16 | # copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | # SOFTWARE. 25 | # 26 | from __future__ import print_function 27 | from os.path import dirname 28 | 29 | from clu.version import read_version_file, VersionInfo 30 | 31 | # module exports: 32 | __all__ = ('__version__', 'version_info', 33 | '__title__', '__author__', '__maintainer__', 34 | '__license__', '__copyright__') 35 | 36 | __dir__ = lambda: list(__all__) 37 | 38 | # Embedded project metadata: 39 | __version__ = read_version_file(dirname(__file__)) 40 | __title__ = 'instakit' 41 | __author__ = 'Alexander Böhn' 42 | __maintainer__ = __author__ 43 | __license__ = 'MIT' 44 | __copyright__ = '© 2012-2025 %s' % __author__ 45 | 46 | # The CLU project version: 47 | version_info = VersionInfo(__version__) 48 | -------------------------------------------------------------------------------- /instakit/utils/static.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import print_function 4 | import os 5 | 6 | from instakit.exporting import Exporter 7 | 8 | exporter = Exporter(path=__file__) 9 | export = exporter.decorator() 10 | 11 | projectdir = os.path.join(os.path.dirname(__file__), '..', '..') 12 | namespaces = set() 13 | 14 | @export 15 | def static_namespace(name): 16 | """ Configure and return a clu.typespace.namespace.Namespace instance, 17 | festooning it with shortcuts allowing for accesing static files 18 | within subdirectories of the Instakit project package tree. 19 | """ 20 | from clu.typespace.namespace import SimpleNamespace as Namespace 21 | ns = Namespace() 22 | ns.name = str(name) 23 | ns.root = os.path.join(projectdir, ns.name) 24 | ns.data = os.path.join(ns.root, 'data') 25 | ns.relative = lambda p: os.path.relpath(p, start=ns.root) 26 | ns.listfiles = lambda *p: os.listdir(os.path.join(ns.data, *p)) 27 | ns.path = lambda *p: os.path.abspath(os.path.join(ns.data, *p)) 28 | namespaces.add(ns) 29 | return ns 30 | 31 | asset = static_namespace('instakit') 32 | tests = static_namespace('tests') 33 | 34 | export(projectdir, name='projectdir') 35 | export(namespaces, name='namespaces') 36 | export(asset, name='asset', doc="asset → static namespace relative to the Instakit package assets") 37 | export(tests, name='tests', doc="tests → static namespace relative to the Instakit testing assets") 38 | 39 | # Assign the modules’ `__all__` and `__dir__` using the exporter: 40 | __all__, __dir__ = exporter.all_and_dir() 41 | 42 | def test(): 43 | assert os.path.isdir(projectdir) 44 | assert len(namespaces) == 2 45 | 46 | assert os.path.isdir(asset.root) 47 | assert os.path.isdir(asset.data) 48 | assert len(asset.listfiles('acv')) > 0 49 | assert len(asset.listfiles('icc')) > 0 50 | assert len(asset.listfiles('img')) > 0 51 | assert len(asset.listfiles('lut')) > 0 52 | 53 | assert os.path.isdir(tests.root) 54 | assert os.path.isdir(tests.data) 55 | assert len(os.listdir(tests.data)) > 0 56 | 57 | if __name__ == '__main__': 58 | test() 59 | -------------------------------------------------------------------------------- /instakit/comparators/ext/butteraugli.pxd: -------------------------------------------------------------------------------- 1 | 2 | from libc.stdint cimport * 3 | from libcpp.vector cimport vector 4 | 5 | cdef extern from "butteraugli.h" namespace "butteraugli" nogil: 6 | 7 | cppclass Image[ComponentType]: 8 | Image() 9 | Image(size_t const, size_t const) # xsize, ysize 10 | Image(size_t const, size_t const, # 11 | ComponentType val) # xsize, ysize, component-type 12 | Image(size_t const, size_t const, # xsize, ysize, 13 | uint8_t*, size_t const) # byteptr, bytes-per-row 14 | 15 | Image(Image&&) # move constructor 16 | Image& operator=(Image&&) # move assignment operator 17 | 18 | size_t xsize() const 19 | size_t ysize() const 20 | 21 | const ComponentType* Row(size_t const) 22 | const uint8_t* byte_ptr "bytes"() 23 | 24 | size_t bytes_per_row() 25 | intptr_t PixelsPerRow() const 26 | 27 | ctypedef Image[float] ImageF 28 | ctypedef Image[uint8_t] Image8 29 | 30 | cdef extern from "butteraugli.h" namespace "butteraugli" nogil: 31 | 32 | cdef vector[Image[T]] CreatePlanes[T](size_t const, 33 | size_t const, 34 | size_t const) 35 | 36 | ctypedef vector[ImageF] imagefvec 37 | ctypedef vector[Image8] image8vec 38 | ctypedef vector[float] floatvec 39 | ctypedef vector[floatvec] floatvecvec 40 | 41 | 42 | cdef extern from "butteraugli.h" namespace "butteraugli" nogil: 43 | 44 | cdef bint ButteraugliInterface(imagefvec&, 45 | imagefvec&, float, 46 | ImageF&, 47 | double&) 48 | 49 | cdef double ButteraugliFuzzyClass(double) 50 | cdef double ButteraugliFuzzyInverse(double) 51 | 52 | cdef bint ButteraugliAdaptiveQuantization(size_t, size_t, 53 | floatvecvec&, 54 | floatvec&) 55 | 56 | cdef const double kButteraugliQuantLow 57 | cdef const double kButteraugliQuantHigh 58 | -------------------------------------------------------------------------------- /instakit/utils/stats.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | """ 4 | stats.py 5 | 6 | Created by FI$H 2000 on 2018-12-24. 7 | Copyright (c) 2018 Objects In Space And Time, LLC. All rights reserved. 8 | """ 9 | from __future__ import print_function 10 | from instakit.utils.mode import Mode 11 | from instakit.exporting import Exporter 12 | 13 | exporter = Exporter(path=__file__) 14 | export = exporter.decorator() 15 | 16 | @export 17 | def pixel_count(image): 18 | """ Return the number of pixels in the input image. """ 19 | width, height = image.size 20 | return width * height 21 | 22 | @export 23 | def color_count(image): 24 | """ Return the number of color values in the input image -- 25 | this is the number of pixels times the band count 26 | of the image. 27 | """ 28 | width, height = image.size 29 | return width * height * Mode.of(image).band_count 30 | 31 | @export 32 | def histogram_sum(image): 33 | """ Return the sum of the input images’ histogram values -- 34 | Basically this is an optimized way of doing: 35 | 36 | out = 0.0 37 | histogram = Mode.L.process(image).histogram() 38 | for value, count in enumerate(histogram): 39 | out += value * count 40 | return out 41 | 42 | … the one-liner uses the much faster sum(…) in léu 43 | of looping over the histogram’s enumerated values. 44 | """ 45 | histogram = Mode.L.process(image).histogram() 46 | return sum(value * count for value, count in enumerate(histogram)) 47 | 48 | @export 49 | def histogram_mean(image): 50 | """ Return the mean of the input images’ histogram values. """ 51 | return float(histogram_sum(image)) / pixel_count(image) 52 | 53 | @export 54 | def histogram_entropy_py(image): 55 | """ Calculate the entropy of an images' histogram. """ 56 | from math import log2, fsum 57 | histosum = float(color_count(image)) 58 | histonorm = (histocol / histosum for histocol in image.histogram()) 59 | 60 | return -fsum(p * log2(p) for p in histonorm if p != 0.0) 61 | 62 | from PIL import Image 63 | 64 | histogram_entropy = hasattr(Image.Image, 'entropy') \ 65 | and Image.Image.entropy \ 66 | or histogram_entropy_py 67 | 68 | export(histogram_entropy, name='histogram_entropy') 69 | 70 | # Assign the modules’ `__all__` and `__dir__` using the exporter: 71 | __all__, __dir__ = exporter.all_and_dir() -------------------------------------------------------------------------------- /instakit/utils/colortype.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | """ 4 | utils/colortype.py 5 | 6 | Created by FI$H 2000 on 2012-08-23. 7 | Copyright (c) 2012 Objects In Space And Time, LLC. All rights reserved. 8 | """ 9 | from __future__ import print_function 10 | from collections import namedtuple, defaultdict 11 | 12 | from clu.constants.polyfills import numpy 13 | from clu.naming import split_abbreviations 14 | from instakit.exporting import Exporter 15 | 16 | exporter = Exporter(path=__file__) 17 | export = exporter.decorator() 18 | 19 | color_types = defaultdict(dict) 20 | 21 | # hash_RGB = lambda rgb: (rgb[0]*256)**2 + (rgb[1]*256) + rgb[2] 22 | 23 | @export 24 | def ColorType(name, *args, **kwargs): 25 | global color_types 26 | dtype = numpy.dtype(kwargs.pop('dtype', numpy.uint8)) 27 | if name not in color_types[dtype.name]: 28 | channels = split_abbreviations(name) 29 | 30 | class Color(namedtuple(name, channels)): 31 | 32 | def __repr__(self): 33 | return "%s(dtype=%s, %s)" % ( 34 | name, self.__class__.dtype.name, 35 | ', '.join(['%s=%s' % (i[0], i[1]) \ 36 | for i in self._asdict().items()])) 37 | 38 | def __hex__(self): 39 | return '0x' + "%x" * len(self) % self 40 | 41 | def __int__(self): 42 | return int(self.__hex__(), 16) 43 | 44 | def __long__(self): 45 | return numpy.long(self.__hex__(), 16) 46 | 47 | def __hash__(self): 48 | return self.__long__() 49 | 50 | def __eq__(self, other): 51 | if not len(other) == len(self): 52 | return False 53 | return all([self[i] == other[i] for i in range(len(self))]) 54 | 55 | def __str__(self): 56 | return str(repr(self)) 57 | 58 | def composite(self): 59 | return numpy.dtype([ 60 | (k, self.__class__.dtype) for k, v in self._asdict().items()]) 61 | 62 | Color.__name__ = "%s<%s>" % (name, dtype.name) 63 | Color.dtype = dtype 64 | color_types[dtype.name][name] = Color 65 | return color_types[dtype.name][name] 66 | 67 | export(color_types, name='color_types') 68 | 69 | # Assign the modules’ `__all__` and `__dir__` using the exporter: 70 | __all__, __dir__ = exporter.all_and_dir() 71 | 72 | def test(): 73 | assert split_abbreviations('RGB') == ('R', 'G', 'B') 74 | assert split_abbreviations('CMYK') == ('C', 'M', 'Y', 'K') 75 | assert split_abbreviations('YCbCr') == ('Y', 'Cb', 'Cr') 76 | assert split_abbreviations('sRGB') == ('R', 'G', 'B') 77 | assert split_abbreviations('XYZ') == ('X', 'Y', 'Z') 78 | 79 | if __name__ == '__main__': 80 | test() -------------------------------------------------------------------------------- /instakit/utils/ext/hsluv.h: -------------------------------------------------------------------------------- 1 | /* 2 | * HSLuv-C: Human-friendly HSL 3 | * 4 | * 5 | * 6 | * Copyright (c) 2015 Alexei Boronine (original idea, JavaScript implementation) 7 | * Copyright (c) 2015 Roger Tallada (Obj-C implementation) 8 | * Copyright (c) 2017 Martin Mitas (C implementation, based on Obj-C implementation) 9 | * 10 | * Permission is hereby granted, free of charge, to any person obtaining a 11 | * copy of this software and associated documentation files (the "Software"), 12 | * to deal in the Software without restriction, including without limitation 13 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 14 | * and/or sell copies of the Software, and to permit persons to whom the 15 | * Software is furnished to do so, subject to the following conditions: 16 | * 17 | * The above copyright notice and this permission notice shall be included in 18 | * all copies or substantial portions of the Software. 19 | * 20 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 21 | * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 25 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 26 | * IN THE SOFTWARE. 27 | */ 28 | 29 | #ifndef HSLUV_H 30 | #define HSLUV_H 31 | 32 | #ifdef __cplusplus 33 | extern "C" { 34 | #endif 35 | 36 | 37 | /** 38 | * Convert HSLuv to RGB. 39 | * 40 | * @param h Hue. Between 0.0 and 360.0. 41 | * @param s Saturation. Between 0.0 and 100.0. 42 | * @param l Lightness. Between 0.0 and 100.0. 43 | * @param[out] pr Red component. Between 0.0 and 1.0. 44 | * @param[out] pr Green component. Between 0.0 and 1.0. 45 | * @param[out] pr Blue component. Between 0.0 and 1.0. 46 | */ 47 | void hsluv2rgb(double h, double s, double l, double* pr, double* pg, double* pb); 48 | 49 | /** 50 | * Convert RGB to HSLuv. 51 | * 52 | * @param r Red component. Between 0.0 and 1.0. 53 | * @param g Green component. Between 0.0 and 1.0. 54 | * @param b Blue component. Between 0.0 and 1.0. 55 | * @param[out] ph Hue. Between 0.0 and 360.0. 56 | * @param[out] ps Saturation. Between 0.0 and 100.0. 57 | * @param[out] pl Lightness. Between 0.0 and 100.0. 58 | */ 59 | void rgb2hsluv(double r, double g, double b, double* ph, double* ps, double* pl); 60 | 61 | /** 62 | * Convert HPLuv to RGB. 63 | * 64 | * @param h Hue. Between 0.0 and 360.0. 65 | * @param s Saturation. Between 0.0 and 100.0. 66 | * @param l Lightness. Between 0.0 and 100.0. 67 | * @param[out] pr Red component. Between 0.0 and 1.0. 68 | * @param[out] pg Green component. Between 0.0 and 1.0. 69 | * @param[out] pb Blue component. Between 0.0 and 1.0. 70 | */ 71 | void hpluv2rgb(double h, double s, double l, double* pr, double* pg, double* pb); 72 | 73 | /** 74 | * Convert RGB to HPLuv. 75 | * 76 | * @param r Red component. Between 0.0 and 1.0. 77 | * @param g Green component. Between 0.0 and 1.0. 78 | * @param b Blue component. Between 0.0 and 1.0. 79 | * @param[out] ph Hue. Between 0.0 and 360.0. 80 | * @param[out] ps Saturation. Between 0.0 and 100.0. 81 | * @param[out] pl Lightness. Between 0.0 and 100.0. 82 | */ 83 | void rgb2hpluv(double r, double g, double b, double* ph, double* ps, double* pl); 84 | 85 | 86 | #ifdef __cplusplus 87 | } 88 | #endif 89 | 90 | #endif /* HSLUV_H */ 91 | -------------------------------------------------------------------------------- /instakit/processors/noise.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | """ 4 | noise.py 5 | 6 | Created by FI$H 2000 on 2014-05-23. 7 | Copyright (c) 2012-2019 Objects In Space And Time, LLC. All rights reserved. 8 | """ 9 | from __future__ import print_function 10 | from enum import Enum, unique 11 | 12 | from instakit.utils.ndarrays import NDProcessor 13 | 14 | @unique 15 | class NoiseMode(Enum): 16 | 17 | LOCALVAR = 'localvar' 18 | GAUSSIAN = 'gaussian' 19 | POISSON = 'poisson' 20 | SALT = 'salt' 21 | PEPPER = 'pepper' 22 | SALT_N_PEPPER = 's&p' 23 | SPECKLE = 'speckle' 24 | 25 | def to_string(self): 26 | return str(self.value) 27 | 28 | def __str__(self): 29 | return self.to_string() 30 | 31 | def process_nd(self, ndimage, **kwargs): 32 | from skimage.util import random_noise 33 | return random_noise(ndimage, 34 | mode=self.to_string(), 35 | **kwargs) 36 | 37 | 38 | class Noise(NDProcessor): 39 | """ Base noise processor 40 | -- defaults to “localvar” mode; q.v. `GaussianLocalVarianceNoise` sub. 41 | """ 42 | mode = NoiseMode.LOCALVAR 43 | 44 | def process_nd(self, ndimage): 45 | noisemaker = type(self).mode 46 | return self.compand(noisemaker.process_nd(ndimage)) 47 | 48 | 49 | class GaussianNoise(Noise): 50 | """ Add Gaussian noise """ 51 | mode = NoiseMode.GAUSSIAN 52 | 53 | class PoissonNoise(Noise): 54 | """ Add Poisson-distributed noise """ 55 | mode = NoiseMode.POISSON 56 | 57 | class GaussianLocalVarianceNoise(Noise): 58 | """ Add Gaussian noise, with image-dependant local variance """ 59 | pass 60 | 61 | class SaltNoise(Noise): 62 | """ Add “salt noise” 63 | -- replace random pixel values with 1.0f (255) 64 | """ 65 | mode = NoiseMode.SALT 66 | 67 | class PepperNoise(Noise): 68 | """ Add “pepper noise” 69 | -- replace random pixel values with zero 70 | """ 71 | mode = NoiseMode.PEPPER 72 | 73 | class SaltAndPepperNoise(Noise): 74 | """ Add “salt and pepper noise” 75 | -- replace random pixel values with either 1.0f (255) or zero 76 | """ 77 | mode = NoiseMode.SALT_N_PEPPER 78 | 79 | class SpeckleNoise(Noise): 80 | """ Add “speckle noise” 81 | --- multiplicative noise using `out = image + n * image` 82 | (where `n` is uniform noise with specified mean + variance) 83 | """ 84 | mode = NoiseMode.SPECKLE 85 | 86 | def test(): 87 | from instakit.utils.static import asset 88 | from instakit.utils.mode import Mode 89 | 90 | image_paths = list(map( 91 | lambda image_file: asset.path('img', image_file), 92 | asset.listfiles('img'))) 93 | image_inputs = list(map( 94 | lambda image_path: Mode.RGB.open(image_path), 95 | image_paths)) 96 | 97 | noises = [GaussianNoise, 98 | PoissonNoise, 99 | GaussianLocalVarianceNoise, 100 | SaltNoise, 101 | PepperNoise, 102 | SaltAndPepperNoise, 103 | SpeckleNoise] 104 | 105 | for idx, image_input in enumerate(image_inputs + image_inputs[:2]): 106 | for NoiseProcessor in noises: 107 | NoiseProcessor().process(image_input).show() 108 | 109 | print(image_paths) 110 | 111 | if __name__ == '__main__': 112 | test() -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at fish2000 {at sign} gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /instakit/processors/squarecrop.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | """ 4 | squarecrop.py 5 | 6 | Created by FI$H 2000 on 2012-08-23. 7 | Copyright (c) 2012 Objects In Space And Time, LLC. All rights reserved. 8 | """ 9 | from __future__ import print_function 10 | 11 | from instakit.abc import Processor 12 | from instakit.exporting import Exporter 13 | 14 | exporter = Exporter(path=__file__) 15 | export = exporter.decorator() 16 | 17 | @export 18 | class SquareCrop(Processor): 19 | 20 | """ Crop an image to an Instagrammy square, by whittling away 21 | the parts of the image with the least entropy. 22 | 23 | Based on a smart-crop implementation from easy-thumbnails: 24 | https://git.io/fhqxj 25 | """ 26 | __slots__ = tuple() 27 | 28 | @staticmethod 29 | def compare_entropy(start_slice, end_slice, slice, difference): 30 | """ Calculate the entropy of two slices (from the start and end 31 | of an axis), returning a tuple containing the amount that 32 | should be added to the start, and removed from the end 33 | of that axis. 34 | 35 | Based on the eponymous function from easy-thumbnails: 36 | https://git.io/fhqpT 37 | """ 38 | from instakit.utils.stats import histogram_entropy 39 | 40 | start_entropy = histogram_entropy(start_slice) 41 | end_entropy = histogram_entropy(end_slice) 42 | 43 | if end_entropy and abs(start_entropy / end_entropy - 1) < 0.01: 44 | # Less than 1% difference, remove from both sides. 45 | if difference >= slice * 2: 46 | return slice, slice 47 | half_slice = slice // 2 48 | return half_slice, slice - half_slice 49 | 50 | if start_entropy > end_entropy: 51 | return 0, slice 52 | else: 53 | return slice, 0 54 | 55 | def process(self, image): 56 | source_x, source_y = image.size 57 | target_width = target_height = min(image.size) 58 | 59 | diff_x = int(source_x - min(source_x, target_width)) 60 | diff_y = int(source_y - min(source_y, target_height)) 61 | left = top = 0 62 | right, bottom = source_x, source_y 63 | 64 | while diff_x: 65 | slice = min(diff_x, max(diff_x // 5, 10)) 66 | start = image.crop((left, 0, left + slice, source_y)) 67 | end = image.crop((right - slice, 0, right, source_y)) 68 | add, remove = self.compare_entropy(start, end, slice, diff_x) 69 | left += add 70 | right -= remove 71 | diff_x = diff_x - add - remove 72 | 73 | while diff_y: 74 | slice = min(diff_y, max(diff_y // 5, 10)) 75 | start = image.crop((0, top, source_x, top + slice)) 76 | end = image.crop((0, bottom - slice, source_x, bottom)) 77 | add, remove = self.compare_entropy(start, end, slice, diff_y) 78 | top += add 79 | bottom -= remove 80 | diff_y = diff_y - add - remove 81 | 82 | box = (left, top, right, bottom) 83 | return image.crop(box) 84 | 85 | # Assign the modules’ `__all__` and `__dir__` using the exporter: 86 | __all__, __dir__ = exporter.all_and_dir('pout', 'inline') 87 | 88 | def test(): 89 | from instakit.utils.static import asset 90 | from instakit.utils.mode import Mode 91 | 92 | image_paths = list(map( 93 | lambda image_file: asset.path('img', image_file), 94 | asset.listfiles('img'))) 95 | image_inputs = list(map( 96 | lambda image_path: Mode.RGB.open(image_path), 97 | image_paths)) 98 | 99 | for image_input in image_inputs: 100 | image_input.show() 101 | SquareCrop().process(image_input).show() 102 | 103 | print(image_paths) 104 | 105 | if __name__ == '__main__': 106 | test() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | INSTAKIT 2 | ======== 3 | 4 | Instakit is a suite of image processors and filters, for processing PIL images. 5 | 6 | InstaKit processors use the same API as [PILKit](https://github.com/matthewwithanm/pilkit)'s, 7 | so they can be used with anything that supports those, including [ImageKit](https://github.com/matthewwithanm/django-imagekit). 8 | Or you can just use them by themselves to process images using Python. 9 | 10 | ![one](http://i.imgur.com/pQ6Vw.jpg) 11 | 12 | Image Processors and Utilities On Offer: 13 | ----------------------------------------------------- 14 | 15 | * `instakit.processors.adjust` 16 | 17 | * `Color(0.0 – 1.0)` 18 | * `Brightness(0.0 – 1.0)` 19 | * `Contrast(0.0 – 1.0)` 20 | * `Sharpness(0.0 – 1.0)` 21 | * `Invert()` 22 | * `Equalize([mask])` 23 | * `AutoContrast([cutoff{uint8_t}, [ignore{uint8_t}]])` 24 | * `Solarize([threshold{uint8_t}])` 25 | * `Posterize([bits{2**n}])` 26 | 27 | * `instakit.processors.blur` 28 | 29 | * `Contour()` 30 | * `Detail()` 31 | * `Emboss()` 32 | * `FindEdges()` 33 | * `EdgeEnhance()` 34 | * `EdgeEnhanceMore()` 35 | * `Smooth()` 36 | * `SmoothMore()` 37 | * `Sharpen()` 38 | * `UnsharpMask([radius=2, [percent=150, [threshold=3]]])` 39 | * `SimpleGaussianBlur([radius=2])` 40 | * `GaussianBlur([sigmaX=3, [sigmaY=3, [sigmaZ=3]]])` 41 | 42 | * `instakit.processors.curves` 43 | 44 | * `InterpolationMode` 45 | * `LINEAR`, `NEAREST`, `ZERO`, `SLINEAR`, `QUADRATIC`, `CUBIC`, `PREVIOUS`, `NEXT`, `LAGRANGE` 46 | * `CurveSet(, [InterpolationMode.LAGRANGE])` 47 | 48 | * `instakit.processors.halftone` 49 | 50 | * `Atkinson([threshold{uint8_t}])` 51 | * `FloydSteinberg([threshold{uint8_t}])` 52 | * `SlowAtkinson([threshold{uint8_t}])` 53 | * `SlowFloydSteinberg([threshold{uint8_t}])` 54 | * `CMYKAtkinson([gcr=20{%}])` 55 | * `CMYKFloydsterBill([gcr=20{%}])` 56 | * `DotScreen([sample=1, [scale=2, [angle=0{°}]]])` 57 | * `CMYKDotScreen([gcr=20{%}, [sample=10, [scale=10, [thetaC=0{°}, [thetaM=15{°}, [theta=30{°}, [thetaK=45{°}]]]]]]])` 58 | 59 | * `instakit.processors.noise` 60 | 61 | * `GaussianNoise()` 62 | * `PoissonNoise()` 63 | * `GaussianLocalVarianceNoise()` 64 | * `SaltNoise()` 65 | * `PepperNoise()` 66 | * `SaltAndPepperNoise()` 67 | * `SpeckleNoise()` 68 | 69 | * `instakit.processors.squarecrop` 70 | 71 | * `histogram_entropy(image)` 72 | * `SquareCrop()` … smart! 73 | 74 | * `instakit.utils.ext.api` (Cythonized) 75 | 76 | * `hsluv_to_rgb(…)` 77 | * `rgb_to_hsluv(…)` 78 | * `hpluv_to_rgb(…)` 79 | * `rgb_to_hpluv(…)` 80 | 81 | * `instakit.utils.gcr` 82 | 83 | * `gcr(image, [percentage=20{%}, [revert_mode=False]])` 84 | * `BasicGCR([percentage=20{%}, [revert_mode=False]])` 85 | 86 | * `instakit.utils.kernels` 87 | * `instakit.utils.lutmap` 88 | * `instakit.utils.mode` 89 | 90 | * `Mode` 91 | * `MONO`, `L`, `I`, `F`, `P`, `RGB`, `RGBX`, `RGBA`, `CMYK`, `YCbCr`, `LAB`, `HSV`, `RGBa`, `LA`, `La`, `PA`, `I16`, `I16L`, `I16B` 92 | * Many useful `PIL.Image` delegate methods (q.v. source) 93 | 94 | * `instakit.utils.ndarrays` 95 | * `instakit.utils.pipeline` 96 | 97 | * `Pipe`, `Ink`, `NOOp` 98 | * `CMYKInk` and `RGBInk` 99 | * `ChannelFork` and `ChannelOverprinter` 100 | 101 | * `instakit.utils.static` 102 | * `instakit.utils.stats` 103 | 104 | 105 | ![two](http://i.imgur.com/ln1Eq.jpg) 106 | 107 | ![three](http://i.imgur.com/MBuC5.jpg) 108 | 109 | As of this first draft there are [Instagrammy](http://www.instagram.com/) image-curve adjusters and a few other geegaws. 110 | 111 | Instakit is made available to you and the public at large under the [MIT license](http://opensource.org/licenses/MIT) -- see LICENSE.md for the full text. 112 | 113 | † née “django-instakit” – All dependencies and traces of Django have since been excised, with thanks to [matthewwithanm](https://github.com/matthewwithanm). 114 | -------------------------------------------------------------------------------- /instakit/processors/blur.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | """ 4 | blur.py 5 | 6 | Created by FI$H 2000 on 2012-08-23. 7 | Copyright (c) 2012 Objects In Space And Time, LLC. All rights reserved. 8 | """ 9 | from __future__ import print_function 10 | 11 | from PIL import ImageFilter 12 | from instakit.abc import Processor 13 | from instakit.exporting import Exporter 14 | 15 | exporter = Exporter(path=__file__) 16 | export = exporter.decorator() 17 | 18 | @export 19 | class ImagingCoreFilterMixin(Processor): 20 | """ A mixin furnishing a `process(…)` method to PIL.ImageFilter classes """ 21 | 22 | def process(self, image): 23 | return image.filter(self) 24 | 25 | @export 26 | class Contour(ImageFilter.CONTOUR, ImagingCoreFilterMixin): 27 | """ Contour-Enhance Filter """ 28 | pass 29 | 30 | @export 31 | class Detail(ImageFilter.DETAIL, ImagingCoreFilterMixin): 32 | """ Detail-Enhance Filter """ 33 | pass 34 | 35 | @export 36 | class Emboss(ImageFilter.EMBOSS, ImagingCoreFilterMixin): 37 | """ Emboss-Effect Filter """ 38 | pass 39 | 40 | @export 41 | class FindEdges(ImageFilter.FIND_EDGES, ImagingCoreFilterMixin): 42 | """ Edge-Finder Filter """ 43 | pass 44 | 45 | @export 46 | class EdgeEnhance(ImageFilter.EDGE_ENHANCE, ImagingCoreFilterMixin): 47 | """ Edge-Enhance Filter """ 48 | pass 49 | 50 | @export 51 | class EdgeEnhanceMore(ImageFilter.EDGE_ENHANCE_MORE, ImagingCoreFilterMixin): 52 | """ Edge-Enhance (With Extreme Predjudice) Filter """ 53 | pass 54 | 55 | @export 56 | class Smooth(ImageFilter.SMOOTH, ImagingCoreFilterMixin): 57 | """ Image-Smoothing Filter """ 58 | pass 59 | 60 | @export 61 | class SmoothMore(ImageFilter.SMOOTH_MORE, ImagingCoreFilterMixin): 62 | """ Image-Smoothing (With Extreme Prejudice) Filter """ 63 | pass 64 | 65 | @export 66 | class Sharpen(ImageFilter.SHARPEN, ImagingCoreFilterMixin): 67 | """ Image Sharpener """ 68 | pass 69 | 70 | @export 71 | class UnsharpMask(ImageFilter.UnsharpMask, ImagingCoreFilterMixin): 72 | """ Unsharp Mask Filter 73 | Optionally initialize with params: 74 | radius (2), percent (150), threshold (3) """ 75 | pass 76 | 77 | @export 78 | class SimpleGaussianBlur(ImageFilter.GaussianBlur, ImagingCoreFilterMixin): 79 | """ Simple Gaussian Blur Filter 80 | Optionally initialize with radius (2) """ 81 | pass 82 | 83 | @export 84 | class GaussianBlur(Processor): 85 | """ Gaussian Blur Filter 86 | Optionally initialize with params: 87 | sigmaX (3) 88 | sigmaY (3; same as sigmaX) 89 | sigmaZ (0; same as sigmaX) 90 | """ 91 | __slots__ = ('sigmaX', 'sigmaY', 'sigmaZ') 92 | 93 | def __init__(self, sigmaX=3, sigmaY=None, sigmaZ=None): 94 | self.sigmaX = sigmaX 95 | self.sigmaY = sigmaY or sigmaX 96 | self.sigmaZ = sigmaZ or sigmaX 97 | 98 | def process(self, image): 99 | from PIL import Image 100 | from numpy import array 101 | from instakit.utils import kernels 102 | return Image.fromarray(kernels.gaussian_blur_filter( 103 | input=array(image), 104 | sigmaX=self.sigmaX, 105 | sigmaY=self.sigmaY, 106 | sigmaZ=self.sigmaZ)) 107 | 108 | # Assign the modules’ `__all__` and `__dir__` using the exporter: 109 | __all__, __dir__ = exporter.all_and_dir() 110 | 111 | def test(): 112 | from instakit.utils.static import asset 113 | from instakit.utils.mode import Mode 114 | from clu.predicates import isslotted 115 | 116 | image_paths = list(map( 117 | lambda image_file: asset.path('img', image_file), 118 | asset.listfiles('img'))) 119 | image_inputs = list(map( 120 | lambda image_path: Mode.RGB.open(image_path), 121 | image_paths)) 122 | 123 | processors = (Contour(), 124 | Detail(), 125 | Emboss(), 126 | EdgeEnhance(), 127 | EdgeEnhanceMore(), 128 | FindEdges(), 129 | Smooth(), 130 | SmoothMore(), 131 | Sharpen(), 132 | UnsharpMask(), 133 | GaussianBlur(sigmaX=3), 134 | SimpleGaussianBlur(radius=3)) 135 | 136 | for processor in processors: 137 | assert isslotted(processor) 138 | 139 | for image_input in image_inputs: 140 | # image_input.show() 141 | # for processor in processors: 142 | # processor.process(image_input).show() 143 | 144 | # image_input.show() 145 | # Contour().process(image_input).show() 146 | # Detail().process(image_input).show() 147 | # Emboss().process(image_input).show() 148 | # EdgeEnhance().process(image_input).show() 149 | # EdgeEnhanceMore().process(image_input).show() 150 | # FindEdges().process(image_input).show() 151 | # Smooth().process(image_input).show() 152 | # SmoothMore().process(image_input).show() 153 | # Sharpen().process(image_input).show() 154 | # UnsharpMask().process(image_input).show() 155 | GaussianBlur(sigmaX=3).process(image_input).show() 156 | # SimpleGaussianBlur(radius=3).process(image_input).show() 157 | 158 | print(image_paths) 159 | 160 | if __name__ == '__main__': 161 | test() -------------------------------------------------------------------------------- /instakit/processors/adjust.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | from __future__ import print_function 3 | 4 | from PIL import ImageOps, ImageChops, ImageEnhance as enhancers 5 | from clu.abstract import Slotted 6 | from clu.predicates import tuplize 7 | from instakit.abc import abstract, ABC, Processor 8 | from instakit.exporting import Exporter 9 | 10 | exporter = Exporter(path=__file__) 11 | export = exporter.decorator() 12 | 13 | @export 14 | class EnhanceNop(ABC, metaclass=Slotted): 15 | 16 | __slots__ = tuplize('image') 17 | 18 | def __init__(self, image=None): 19 | self.image = image 20 | 21 | def adjust(self, *args, **kwargs): 22 | return self.image 23 | 24 | @export 25 | class Adjustment(Processor): 26 | 27 | """ Base type for image adjustment processors """ 28 | __slots__ = tuplize('value') 29 | 30 | def __init__(self, value=1.0): 31 | """ Initialize the adjustment with a float value """ 32 | self.value = value 33 | 34 | @abstract 35 | def adjust(self, image): 36 | """ Adjust the image, using the float value with which 37 | the adjustment was first initialized 38 | """ 39 | ... 40 | 41 | def process(self, image): 42 | return (self.value == 1.0) and image or self.adjust(image) 43 | 44 | @export 45 | class Color(Adjustment): 46 | 47 | """ Globally tweak the image color """ 48 | 49 | def adjust(self, image): 50 | return enhancers.Color(image).enhance(self.value) 51 | 52 | @export 53 | class Brightness(Adjustment): 54 | 55 | """ Adjust the image brightness """ 56 | 57 | def adjust(self, image): 58 | return enhancers.Brightness(image).enhance(self.value) 59 | 60 | @export 61 | class Contrast(Adjustment): 62 | 63 | """ Adjust the image contrast """ 64 | 65 | def adjust(self, image): 66 | return enhancers.Contrast(image).enhance(self.value) 67 | 68 | @export 69 | class Sharpness(Adjustment): 70 | 71 | """ Adjust the sharpness of the image """ 72 | 73 | def adjust(self, image): 74 | return enhancers.Sharpness(image).enhance(self.value) 75 | 76 | @export 77 | class BrightnessContrast(Adjustment): 78 | 79 | """ Adjust the image brightness and contrast simultaneously """ 80 | 81 | def adjust(self, image): 82 | for Enhancement in (enhancers.Brightness, enhancers.Contrast): 83 | image = Enhancement(image).enhance(self.value) 84 | return image 85 | 86 | @export 87 | class BrightnessSharpness(Adjustment): 88 | 89 | """ Adjust the image brightness and sharpness simultaneously """ 90 | 91 | def adjust(self, image): 92 | for Enhancement in (enhancers.Brightness, enhancers.Sharpness): 93 | image = Enhancement(image).enhance(self.value) 94 | return image 95 | 96 | @export 97 | class ContrastSharpness(Adjustment): 98 | 99 | """ Adjust the image contrast and sharpness simultaneously """ 100 | 101 | def adjust(self, image): 102 | for Enhancement in (enhancers.Contrast, enhancers.Sharpness): 103 | image = Enhancement(image).enhance(self.value) 104 | return image 105 | 106 | @export 107 | class Invert(Processor): 108 | 109 | """ Perform a simple inversion of the image values """ 110 | 111 | def process(self, image): 112 | return ImageChops.invert(image) 113 | 114 | @export 115 | class Equalize(Processor): 116 | 117 | """ Apply a non-linear mapping to the image, via histogram """ 118 | __slots__ = tuplize('mask') 119 | 120 | def __init__(self, mask=None): 121 | self.mask = hasattr(mask, 'copy') and mask.copy() or mask 122 | 123 | def process(self, image): 124 | return ImageOps.equalize(image, mask=self.mask) 125 | 126 | @export 127 | class AutoContrast(Processor): 128 | 129 | """ Normalize contrast throughout the image, via histogram """ 130 | __slots__ = tuplize('cutoff', 'ignore') 131 | 132 | def __init__(self, cutoff=0, ignore=None): 133 | self.cutoff, self.ignore = cutoff, ignore 134 | 135 | def process(self, image): 136 | return ImageOps.autocontrast(image, cutoff=self.cutoff, 137 | ignore=self.ignore) 138 | 139 | @export 140 | class Solarize(Processor): 141 | 142 | """ Invert all pixel values above an 8-bit threshold """ 143 | __slots__ = tuplize('threshold') 144 | 145 | def __init__(self, threshold=128): 146 | self.threshold = min(max(1, threshold), 255) 147 | 148 | def process(self, image): 149 | return ImageOps.solarize(image, threshold=self.threshold) 150 | 151 | @export 152 | class Posterize(Processor): 153 | 154 | """ Reduce the number of bits (1 to 8) per channel """ 155 | __slots__ = tuplize('bits') 156 | 157 | def __init__(self, bits=4): 158 | self.bits = min(max(1, bits), 8) 159 | 160 | def process(self, image): 161 | return ImageOps.posterize(image, bits=self.bits) 162 | 163 | # Assign the modules’ `__all__` and `__dir__` using the exporter: 164 | __all__, __dir__ = exporter.all_and_dir() 165 | 166 | def test(): 167 | # from clu.predicates import haspyattr 168 | from clu.predicates import isslotted 169 | 170 | G = globals() 171 | 172 | for typename in __all__: 173 | if typename != "Adjustment": 174 | assert G[typename] 175 | assert isslotted(G[typename]()) 176 | # assert not isdictish(G[typename]()) 177 | # assert not haspyattr(G[typename](), 'dict') 178 | 179 | if __name__ == '__main__': 180 | test() -------------------------------------------------------------------------------- /instakit/processors/ext/halftone.pyx: -------------------------------------------------------------------------------- 1 | #cython: cdivision=True 2 | #cython: boundscheck=False 3 | #cython: nonecheck=False 4 | #cython: wraparound=False 5 | 6 | from __future__ import division 7 | 8 | ctypedef unsigned char byte_t 9 | 10 | cdef extern from "halftone.h" nogil: 11 | byte_t atkinson_add_error(int b, int e) 12 | 13 | import numpy 14 | cimport numpy 15 | cimport cython 16 | 17 | from instakit.utils import ndarrays 18 | 19 | ctypedef numpy.int_t int_t 20 | ctypedef numpy.uint8_t uint8_t 21 | ctypedef numpy.uint32_t uint32_t 22 | ctypedef numpy.float32_t float32_t 23 | 24 | cdef void atkinson_dither(uint8_t[:, :] input_view, 25 | int_t width, int_t height, 26 | byte_t* threshold_matrix_ptr) nogil: 27 | 28 | cdef int_t y, x, err 29 | cdef uint8_t oldpx, newpx 30 | 31 | for y in range(height): 32 | for x in range(width): 33 | oldpx = input_view[y, x] 34 | newpx = threshold_matrix_ptr[oldpx] 35 | err = (oldpx - newpx) >> 3 36 | 37 | input_view[y, x] = newpx 38 | 39 | if y + 1 < height: 40 | input_view[y+1, x] = atkinson_add_error(input_view[y+1, x], err) 41 | 42 | if y + 2 < height: 43 | input_view[y+2, x] = atkinson_add_error(input_view[y+2, x], err) 44 | 45 | if (y > 0) and (x + 1 < width): 46 | input_view[y-1, x+1] = atkinson_add_error(input_view[y-1, x+1], err) 47 | 48 | if x + 1 < width: 49 | input_view[y, x+1] = atkinson_add_error(input_view[y, x+1], err) 50 | 51 | if (y + 1 < height) and (x + 1 < width): 52 | input_view[y+1, x+1] = atkinson_add_error(input_view[y+1, x+1], err) 53 | 54 | if x + 2 < width: 55 | input_view[y, x+2] = atkinson_add_error(input_view[y, x+2], err) 56 | 57 | cdef inline uint8_t floyd_steinberg_add_error_SEVEN(uint8_t base, 58 | int_t err) nogil: 59 | cdef int_t something = base + err * 7 / 16 60 | return max(min(255, something), 0) 61 | 62 | cdef inline uint8_t floyd_steinberg_add_error_THREE(uint8_t base, 63 | int_t err) nogil: 64 | cdef int_t something = base + err * 3 / 16 65 | return max(min(255, something), 0) 66 | 67 | cdef inline uint8_t floyd_steinberg_add_error_CINCO(uint8_t base, 68 | int_t err) nogil: 69 | cdef int_t something = base + err * 5 / 16 70 | return max(min(255, something), 0) 71 | 72 | cdef inline uint8_t floyd_steinberg_add_error_ALONE(uint8_t base, 73 | int_t err) nogil: 74 | cdef int_t something = base + err * 1 / 16 75 | return max(min(255, something), 0) 76 | 77 | cdef void floyd_steinberg_dither(uint8_t[:, :] input_view, 78 | int_t width, int_t height, 79 | byte_t* threshold_matrix_ptr) nogil: 80 | 81 | cdef int_t y, x, err 82 | cdef uint8_t oldpx, newpx 83 | 84 | for y in range(height): 85 | for x in range(width): 86 | oldpx = input_view[y, x] 87 | newpx = threshold_matrix_ptr[oldpx] 88 | input_view[y, x] = newpx 89 | err = oldpx - newpx 90 | 91 | if (x + 1 < width): 92 | input_view[y, x+1] = floyd_steinberg_add_error_SEVEN(input_view[y, x+1], err) 93 | 94 | if (y + 1 < height) and (x > 0): 95 | input_view[y+1, x-1] = floyd_steinberg_add_error_THREE(input_view[y+1, x-1], err) 96 | 97 | if (y + 1 < height): 98 | input_view[y+1, x] = floyd_steinberg_add_error_CINCO(input_view[y+1, x], err) 99 | 100 | if (y + 1 < height) and (x + 1 < width): 101 | input_view[y+1, x+1] = floyd_steinberg_add_error_ALONE(input_view[y+1, x+1], err) 102 | 103 | @cython.freelist(16) 104 | cdef class ThresholdMatrixDitherer: 105 | 106 | """ Base ditherer image processor class """ 107 | 108 | cdef: 109 | byte_t threshold_matrix[256] 110 | 111 | def __cinit__(self, float32_t threshold = 128.0): 112 | cdef uint8_t idx 113 | with nogil: 114 | for idx in range(255): 115 | self.threshold_matrix[idx] = ((idx / threshold) * 255) 116 | 117 | cdef class Atkinson(ThresholdMatrixDitherer): 118 | 119 | """ Fast cythonized Atkinson-dither halftone image processor """ 120 | 121 | def process(self, image not None): 122 | input_array = ndarrays.fromimage(image.convert('L'), dtype=numpy.uint8) 123 | cdef uint32_t width = image.size[0] 124 | cdef uint32_t height = image.size[1] 125 | cdef uint8_t[:, :] input_view = input_array 126 | with nogil: 127 | atkinson_dither(input_view, width, height, self.threshold_matrix) 128 | output_array = numpy.asarray(input_view.base) 129 | return ndarrays.toimage(output_array) 130 | 131 | cdef class FloydSteinberg(ThresholdMatrixDitherer): 132 | 133 | """ Fast cythonized Floyd-Steinberg-dither halftone image processor """ 134 | 135 | def process(self, image not None): 136 | input_array = ndarrays.fromimage(image.convert('L'), dtype=numpy.uint8) 137 | cdef uint32_t width = image.size[0] 138 | cdef uint32_t height = image.size[1] 139 | cdef uint8_t[:, :] input_view = input_array 140 | with nogil: 141 | floyd_steinberg_dither(input_view, width, height, self.threshold_matrix) 142 | output_array = numpy.asarray(input_view.base) 143 | return ndarrays.toimage(output_array) 144 | -------------------------------------------------------------------------------- /instakit/utils/lutmap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | """ 4 | lutmap.py 5 | 6 | Created by FI$H 2000 on 2012-08-23. 7 | Copyright (c) 2012 Objects In Space And Time, LLC. All rights reserved. 8 | """ 9 | from __future__ import print_function 10 | 11 | import numpy 12 | import imread 13 | from os.path import join 14 | from collections import defaultdict 15 | 16 | from PIL import Image 17 | #from math import floor 18 | 19 | from instakit.utils.colortype import ColorType 20 | from instakit.utils import static 21 | 22 | class RGBTable(defaultdict): 23 | RGB = ColorType('RGB', dtype=numpy.uint8) 24 | identity = numpy.zeros( 25 | shape=(512, 512), 26 | dtype=numpy.uint64) 27 | 28 | for bx in range(0, 8): 29 | for by in range(0, 8): 30 | for r in range(0, 64): 31 | for g in range(0, 64): 32 | identity[ 33 | int(g + float(by) * 64.0 + 0.5), 34 | int(r + float(bx) * 64.0 + 0.5)] = hash(RGB( 35 | int(r * 255.0 / 63.0 + 0.5), 36 | int(g * 255.0 / 63.0 + 0.5), 37 | int((bx + by * 8.0) * 255.0 / 63.0 + 0.5))) 38 | 39 | def __init__(self): 40 | super(RGBTable, self).__init__(default_factory=None) 41 | self.data = self.identity 42 | 43 | def __missing__(self, color): 44 | self[color] = value = self.lookup(color) 45 | return value 46 | 47 | def _idx(self, color): 48 | print("_idx COLOR:") 49 | print(color) 50 | print("_idx WAT:") 51 | print(int('%02x%02x%02x' % color, 16)) 52 | print("hash COLOR:") 53 | print(hash(color)) 54 | return int('%02x%02x%02x' % color, 16) 55 | 56 | def _rgb(self, idx): 57 | RGB = self.RGB 58 | return RGB(*reversed( 59 | [(idx >> (8*i)) & 255 for i in range(3)])) 60 | 61 | def lookup(self, color): 62 | print("lookup COLOR:") 63 | print(color) 64 | return self.color_at(*self._xy(color)) 65 | 66 | def _xy(self, color): 67 | where = numpy.where( 68 | self.identity[:,:] == hash(color)) 69 | print("WHERE:") 70 | print(len(tuple(zip(*where)))) 71 | try: 72 | return tuple(zip(*where))[0] 73 | except IndexError: 74 | return [] 75 | 76 | def color_at(self, x, y, data=None): 77 | print("X, Y: %s, %s" % (x, y)) 78 | print("data: %s" % data) 79 | if data is None: 80 | data = self.data 81 | print("DATA.shape:") 82 | print(self.data.shape) 83 | print(data[x, y]) 84 | return self.RGB(*data[x, y]) 85 | 86 | def float_color_at(self, x, y, data=None): 87 | if data is None: 88 | data = self.identity 89 | return (channel/255.0 for channel in self.color_at(x, y, data=data)) 90 | 91 | class LUT(RGBTable): 92 | 93 | def __init__(self, name='identity'): 94 | RGBTable.__init__(self) 95 | self.name = name 96 | self.data = self._read_png_matrix(self.name) 97 | 98 | @classmethod 99 | def _read_png_matrix(cls, name): 100 | print("Reading LUT image: %s" % static.path(join('lut', '%s.png' % name))) 101 | return imread.imread( 102 | static.path(join('lut', '%s.png' % name))) 103 | 104 | def main(): 105 | 106 | RGB = ColorType('RGB') 107 | RGB24 = ColorType('RGB', dtype=numpy.uint8) 108 | YCrCb = ColorType('YCrCb', dtype=numpy.uint8) 109 | 110 | print(RGB(2, 3, 4)) 111 | print(RGB24) 112 | print(YCrCb(8, 88, 808)) 113 | 114 | identity = LUT() 115 | amatorka = LUT('amatorka') 116 | 117 | print(identity.identity) 118 | print(RGB(22,33,44)) 119 | print(int(RGB(22,33,44))) 120 | print(int(RGB(55,66,77))) 121 | print(numpy.any(identity.identity[:,:] == int(RGB(11,44,99)))) 122 | print(numpy.max(identity.identity)) 123 | print(RGB(111, 222, 11).composite) 124 | 125 | print("") 126 | print(identity[RGB(146,146,36)]) 127 | #print(identity[RGB(22,33,44)]) 128 | print(identity[RGB(132, 166, 188)]) 129 | 130 | print("") 131 | print("YO DOGG") 132 | print(amatorka[RGB(146,146,36)]) 133 | print(identity[RGB(22,33,44)]) 134 | print(identity[RGB(255, 25, 25)]) 135 | 136 | def blurthday(): 137 | 138 | from imread import imread 139 | from pprint import pprint 140 | imfuckingshowalready = lambda mx: Image.fromarray(mx).show() 141 | 142 | identity = LUT() 143 | amatorka = LUT('amatorka') 144 | #miss_etikate = LUT('miss_etikate') 145 | #soft_elegance_1 = LUT('soft_elegance_1') 146 | #soft_elegance_2 = LUT('soft_elegance_2') 147 | 148 | im1 = imread(static.path(join('img', '06-DSCN4771.JPG'))) 149 | im2 = imread(static.path(join( 150 | 'img', '430023_3625646599363_1219964362_3676052_834528487_n.jpg'))) 151 | 152 | pprint(identity) 153 | pprint(amatorka) 154 | 155 | im9 = amatorka.transform(im1) 156 | pprint(im9) 157 | imfuckingshowalready(im9) 158 | print(im1) 159 | print(im2) 160 | 161 | def old_maid(): 162 | pass 163 | #global __multipons__ 164 | #from pprint import pprint 165 | #pprint(__multipons__) 166 | 167 | def old_main(): 168 | 169 | #imfuckingshowalready = lambda mx: Image.fromarray(mx).show() 170 | 171 | old_identity = static.path(join('lut', 'identity.png')) 172 | 173 | im_old_identity = imread.imread(old_identity) 174 | im_identity = numpy.zeros_like(im_old_identity) 175 | 176 | for bx in range(0, 8): 177 | for by in range(0, 8): 178 | for r in range(0, 64): 179 | for g in range(0, 64): 180 | im_identity[ 181 | int(g + by * 64), 182 | int(r + bx * 64)] = numpy.array(( 183 | int(r * 255.0 / 63.0 + 0.5), 184 | int(g * 255.0 / 63.0 + 0.5), 185 | int((bx + by * 8.0) * 255.0 / 63.0 + 0.5)), 186 | dtype=numpy.uint8) 187 | 188 | print("THE OLD: %s, %s, %s" % ( 189 | im_old_identity.size, im_old_identity.shape, 190 | str(im_old_identity.dtype))) 191 | #print(im_old_identity) 192 | print("") 193 | 194 | print("THE NEW: %s, %s, %s" % ( 195 | im_identity.size, im_identity.shape, 196 | str(im_identity.dtype))) 197 | #print(im_identity) 198 | print("") 199 | 200 | print("THE END: %s" % bool(im_old_identity.shape == im_identity.shape)) 201 | #print(im_old_identity == im_identity) 202 | 203 | #imfuckingshowalready(im_identity) 204 | #imfuckingshowalready(im_old_identity) 205 | 206 | pil_im_old_identity = Image.fromarray(im_old_identity) 207 | pil_im_old_identity.save('/tmp/im_old_identity.jpg', 208 | format="JPEG") 209 | 210 | pil_im_identity = Image.fromarray(im_identity) 211 | pil_im_identity.save('/tmp/im_identity.jpg', 212 | format="JPEG") 213 | 214 | if __name__ == '__main__': 215 | main() -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # INSTAKIT -- Instagrammy image-processors and tools, based on Pillow and SciPy 5 | # 6 | # Copyright © 2012-2025 Alexander Böhn 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in all 16 | # copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | # SOFTWARE. 25 | # 26 | ''' Image processors for django-imagekit - based on Pillow, SciPy, and scikit-image ''' 27 | 28 | from __future__ import print_function 29 | import os, sys, sysconfig 30 | 31 | try: 32 | from os import cpu_count 33 | except ImportError: 34 | try: 35 | from multiprocessing import cpu_count 36 | except ImportError: 37 | try: 38 | from psutil import cpu_count 39 | except ImportError: 40 | cpu_count = lambda: 1 41 | 42 | from setuptools import setup, find_packages 43 | from Cython.Build import cythonize 44 | 45 | # HOST PYTHON VERSION 46 | PYTHON_VERSION = float("%s%s%s" % (sys.version_info.major, os.extsep, 47 | sys.version_info.minor)) 48 | 49 | # CONSTANTS 50 | PROJECT_NAME = 'instakit' 51 | AUTHOR_NAME = 'Alexander Böhn' 52 | AUTHOR_USER = 'fish2000' 53 | 54 | GITHUB = 'github.com' 55 | GMAIL = 'gmail.com' 56 | 57 | AUTHOR_EMAIL = '%s@%s' % (AUTHOR_USER, GMAIL) 58 | PROJECT_GH_URL = 'https://%s/%s/%s' % (GITHUB, 59 | AUTHOR_USER, 60 | PROJECT_NAME) 61 | PROJECT_DL_URL = '%s/zipball/master' % PROJECT_GH_URL 62 | 63 | KEYWORDS = ('django', 64 | 'imagekit', PROJECT_NAME, 65 | AUTHOR_USER, 66 | 'image processing', 67 | 'image analysis', 68 | 'image comparison', 69 | 'halftone', 'dithering', 70 | 'Photoshop', 'acv', 'curves', 71 | 'PIL', 'Pillow', 72 | 'Cython', 73 | 'NumPy', 'SciPy', 'scikit-image') 74 | 75 | CPPLANGS = ('c++', 'cxx', 'cpp', 'cc', 'mm') 76 | CPPVERSION = PYTHON_VERSION < 3 and 'c++14' or 'c++17' 77 | 78 | # PROJECT DIRECTORY 79 | CWD = os.path.dirname(__file__) 80 | BASE_PATH = os.path.join( 81 | os.path.abspath(CWD), PROJECT_NAME) 82 | 83 | def project_content(*filenames): 84 | import io 85 | filepath = os.path.join(CWD, *filenames) 86 | if not os.path.isfile(filepath): 87 | raise IOError("""File %s doesn't exist""" % filepath) 88 | out = '' 89 | with io.open(filepath, 'r') as handle: 90 | out += handle.read() 91 | if not out: 92 | raise ValueError("""File %s couldn't be read""" % os.path.sep.join(filenames)) 93 | return out.strip() 94 | 95 | # CYTHON & C-API EXTENSION MODULES 96 | def cython_module(*args, **kwargs): 97 | from Cython.Distutils import Extension 98 | sources = [] 99 | sources.extend(kwargs.pop('sources', [])) 100 | include_dirs = [] 101 | include_dirs.extend(kwargs.pop('include_dirs', [])) 102 | ext_package = os.path.extsep.join(args) 103 | ext_pth = os.path.sep.join(args) + os.extsep + "pyx" 104 | sources.insert(0, ext_pth) 105 | language = kwargs.pop('language', 'c').lower() 106 | extra_compile_args = ['-Wno-unused-function', 107 | '-Wno-unneeded-internal-declaration', 108 | '-O3', 109 | '-fstrict-aliasing', 110 | '-funroll-loops', 111 | '-mtune=native'] 112 | if language in CPPLANGS: 113 | extra_compile_args.extend(['-std=%s' % CPPVERSION, 114 | '-stdlib=libc++', 115 | '-Wno-sign-compare', 116 | '-Wno-unused-private-field']) 117 | return Extension(ext_package, sources, 118 | language=language, 119 | include_dirs=include_dirs, 120 | extra_compile_args=extra_compile_args) 121 | 122 | def cython_comparator(name, **kwargs): 123 | return cython_module(PROJECT_NAME, 'comparators', 'ext', name, **kwargs) 124 | 125 | def cython_processor(name, **kwargs): 126 | return cython_module(PROJECT_NAME, 'processors', 'ext', name, **kwargs) 127 | 128 | def cython_utility(name, **kwargs): 129 | return cython_module(PROJECT_NAME, 'utils', 'ext', name, **kwargs) 130 | 131 | def additional_source(*args): 132 | return os.path.join( 133 | os.path.relpath(BASE_PATH, start=CWD), *args) 134 | 135 | # PROJECT VERSION & METADATA 136 | __version__ = "" 137 | try: 138 | exec(compile( 139 | open(os.path.join(BASE_PATH, 140 | '__version__.py')).read(), 141 | '__version__.py', 'exec')) 142 | except: 143 | print("ERROR COMPILING __version__.py") 144 | __version__ = '0.9.0' 145 | 146 | # PROJECT DESCRIPTION 147 | LONG_DESCRIPTION = project_content('ABOUT.md') 148 | 149 | # SOFTWARE LICENSE 150 | LICENSE = 'MIT' 151 | 152 | # REQUIRED INSTALLATION DEPENDENCIES 153 | INSTALL_REQUIRES = project_content('requirements', 'install.txt').splitlines() 154 | 155 | # PYPI PROJECT CLASSIFIERS 156 | CLASSIFIERS = [ 157 | 'Development Status :: 5 - Production/Stable', 158 | 'License :: OSI Approved :: MIT License', 159 | 'Intended Audience :: Developers', 160 | 'Operating System :: MacOS', 161 | 'Operating System :: Microsoft :: Windows', 162 | 'Operating System :: OS Independent', 163 | 'Operating System :: POSIX', 164 | 'Operating System :: Unix', 165 | 'Programming Language :: Python', 166 | 'Programming Language :: Python :: 3', 167 | 'Programming Language :: Python :: 3.5', 168 | 'Programming Language :: Python :: 3.6', 169 | 'Programming Language :: Python :: 3.7', 170 | 'Programming Language :: Python :: 3.8', 171 | 'Programming Language :: Python :: 3.9', 172 | 'Programming Language :: Python :: 3.10', 173 | 'Programming Language :: Python :: 3.11' 174 | ] 175 | 176 | # NUMPY: C-API INCLUDE DIRECTORY 177 | try: 178 | import numpy 179 | except ImportError: 180 | class FakeNumpy(object): 181 | def get_include(self): 182 | return os.path.curdir 183 | numpy = FakeNumpy() 184 | 185 | # SOURCES & INCLUDE DIRECTORIES 186 | hsluv_source = additional_source('utils', 'ext', 'hsluv.c') 187 | augli_source = additional_source('comparators', 'ext', 'butteraugli.cc') 188 | include_dirs = [numpy.get_include(), 189 | sysconfig.get_path('include')] 190 | 191 | # THE CALL TO `setup(…)` 192 | 193 | if __name__ == '__main__': 194 | setup( 195 | name=PROJECT_NAME, 196 | author=AUTHOR_NAME, 197 | author_email=AUTHOR_EMAIL, 198 | 199 | version=__version__, 200 | description=__doc__.strip(), 201 | long_description=LONG_DESCRIPTION, 202 | long_description_content_type="text/markdown", 203 | 204 | keywords=" ".join(KEYWORDS), 205 | url=PROJECT_GH_URL, download_url=PROJECT_DL_URL, 206 | license=LICENSE, platforms=['any'], 207 | classifiers=CLASSIFIERS, 208 | 209 | packages=find_packages(), 210 | package_data={ '' : ['*.*'] }, 211 | include_package_data=True, 212 | zip_safe=False, 213 | 214 | install_requires=INSTALL_REQUIRES, 215 | include_dirs=include_dirs, 216 | 217 | ext_modules=cythonize([ 218 | cython_comparator("buttereye", sources=[augli_source], 219 | language="c++"), 220 | cython_processor("halftone", include_dirs=include_dirs, 221 | language="c"), 222 | cython_utility("api", sources=[hsluv_source], 223 | language="c") 224 | ], nthreads=cpu_count(), 225 | compiler_directives=dict(language_level=3, 226 | infer_types=True, 227 | embedsignature=True)), 228 | ) 229 | -------------------------------------------------------------------------------- /instakit/processors/curves.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | """ 4 | curves.py 5 | 6 | Adapted from this: 7 | 8 | https://gist.github.com/fish2000/5641c3697fa4407fcfd59099575d6938 9 | 10 | And also this: 11 | 12 | https://github.com/vbalnt/filterizer/blob/master/extractCurvesFromACVFile.py 13 | 14 | Created by FI$H 2000 on 2012-08-23. 15 | Copyright (c) 2012-2019 Objects In Space And Time, LLC. All rights reserved. 16 | 17 | """ 18 | from __future__ import print_function 19 | 20 | import numpy 21 | import os 22 | import struct 23 | 24 | from PIL import Image 25 | from enum import Enum, unique 26 | from scipy import interpolate 27 | 28 | from clu.predicates import pyname 29 | from instakit.utils.static import asset 30 | from instakit.utils.mode import Mode 31 | from instakit.abc import Processor 32 | 33 | interpolate_mode_strings = ('linear', 34 | 'nearest', 35 | 'zero', 36 | 'slinear', 37 | 'quadratic', 'cubic', 38 | 'previous', 'next', 39 | 'lagrange') 40 | 41 | @unique 42 | class InterpolateMode(Enum): 43 | 44 | # These correspond to the “kind” arg 45 | # from “scipy.interpolate.interp1d(…)”: 46 | LINEAR = 0 47 | NEAREST = 1 48 | ZERO = 2 49 | SLINEAR = 3 50 | QUADRATIC = 4 51 | CUBIC = 5 52 | PREVIOUS = 6 53 | NEXT = 7 54 | 55 | # This specifies LaGrange interpolation, 56 | # using “scipy.interpolate.lagrange(…)”: 57 | LAGRANGE = 8 58 | 59 | def to_string(self): 60 | return interpolate_mode_strings[self.value] 61 | 62 | def __str__(self): 63 | return self.to_string() 64 | 65 | class SingleCurve(list): 66 | 67 | """ A SingleCurve instance is a named list of (x, y) coordinates, 68 | that provides programmatic access to interpolated values. 69 | 70 | It is constructed with `(name, [(x, y), (x, y)...])`; since it 71 | directly inherits from `__builtins__.list`, the usual methods 72 | e.g. `append(…)`, `insert(…)` &c. can be used to modify an 73 | instance of SingleCurve. 74 | 75 | Before accessing interpolated values, one first calls the 76 | method `interpolate(…)` with an optional argument specifying 77 | the interpolation mode, `mode=InterpolationMode` (q.v. the 78 | `InterpolationMode` enum supra.) and thereafter, instances 79 | of SingleCurve are callable with an x-coordinate argument, 80 | returning the interpolated y-coordinate. 81 | """ 82 | 83 | def __init__(self, name, *args): 84 | self.name = name 85 | list.__init__(self, *args) 86 | 87 | def asarray(self, dtype=None): 88 | return numpy.array(self, dtype=dtype) 89 | 90 | def interpolate(self, mode=InterpolateMode.LAGRANGE): 91 | xy = self.asarray() 92 | if mode == InterpolateMode.LAGRANGE or mode is None: 93 | delegate = interpolate.lagrange(xy.T[0], 94 | xy.T[1]) 95 | else: 96 | kind = InterpolateMode(mode).to_string() 97 | delegate = interpolate.interp1d(xy.T[0], 98 | xy.T[1], kind=kind) 99 | self.delegate = delegate 100 | return self 101 | 102 | def __call__(self, value): 103 | if not hasattr(self, 'delegate'): 104 | self.interpolate() 105 | delegate = self.delegate 106 | return delegate(value) 107 | 108 | 109 | class CurveSet(Processor): 110 | 111 | """ A CurveSet instance represents an ACV file, as generated by the 112 | Adobe® Photoshop™ application, whose data encodes a set of 113 | image-adjustment curves. 114 | 115 | The simplest use is to read an existing set of curves from an 116 | existant ACV file; one instantiates a CurveSet like so: 117 | 118 | mycurveset = CurveSet('path/to/curveset.acv') 119 | 120 | …one can then use `mycurveset.process(…)` to process PIL images, 121 | or one can access underlying curve data via `mycurveset.curves`; 122 | subsequently the curveset can be rewritten to a new ACV file 123 | with `mycurveset.write_acv(acv_file_path)`. 124 | """ 125 | __slots__ = ('_is_builtin', 'count', 'curves', 126 | 'path', 'name', 127 | 'interpolation_mode') 128 | 129 | acv = 'acv' 130 | dotacv = f'.{acv}' 131 | channels = ('composite', 'red', 'green', 'blue') 132 | valid_modes = ( Mode.RGB, Mode.MONO, Mode.L ) 133 | 134 | @classmethod 135 | def builtin(cls, name): 136 | print(f"Reading curves [builtin] {name}{cls.dotacv}") 137 | acv_path = asset.path(cls.acv, f"{name}{cls.dotacv}") 138 | out = cls(acv_path) 139 | out._is_builtin = True 140 | return out 141 | 142 | @classmethod 143 | def instakit_names(cls): 144 | return [curve_file.rstrip(cls.dotacv) \ 145 | for curve_file in asset.listfiles(cls.acv) \ 146 | if curve_file.lower().endswith(cls.dotacv)] 147 | 148 | @classmethod 149 | def instakit_curve_sets(cls): 150 | return [cls.builtin(name) for name in cls.instakit_names()] 151 | 152 | @classmethod 153 | def channel_name(cls, idx): 154 | try: 155 | return cls.channels[idx] 156 | except IndexError: 157 | return f"channel{idx}" 158 | 159 | def __init__(self, path, interpolation_mode=InterpolateMode.LAGRANGE): 160 | self.count = 0 161 | self.curves = [] 162 | self._is_builtin = False 163 | self.path = os.path.abspath(path) 164 | self.name = os.path.basename(path) 165 | self.interpolation_mode = interpolation_mode 166 | if os.path.isfile(self.path): 167 | self.read_acv(self.path, 168 | self.interpolation_mode) 169 | 170 | @property 171 | def is_builtin(self): 172 | return self._is_builtin 173 | 174 | @property 175 | def file_exists(self): 176 | return os.path.isfile(self.path) 177 | 178 | @staticmethod 179 | def read_one_curve(acv_file, name, interpolation_mode): 180 | curve = SingleCurve(name) 181 | points_in_curve, = struct.unpack("!h", acv_file.read(2)) 182 | for _ in range(points_in_curve): 183 | y, x = struct.unpack("!hh", acv_file.read(4)) 184 | curve.append((x, y)) 185 | return curve.interpolate(interpolation_mode) 186 | 187 | @staticmethod 188 | def write_one_curve(acv_file, curve): 189 | points_in_curve = len(curve) 190 | acv_file.write(struct.pack("!h", points_in_curve)) 191 | for idx in range(points_in_curve): 192 | x, y = curve[idx] 193 | acv_file.write(struct.pack("!hh", y, x)) 194 | return points_in_curve 195 | 196 | def read_acv(self, acv_path, interpolation_mode): 197 | if not self.file_exists: 198 | raise IOError(f"Can't read nonexistant ACV file: {self.path}") 199 | with open(acv_path, "rb") as acv_file: 200 | _, self.count = struct.unpack("!hh", acv_file.read(4)) 201 | for idx in range(self.count): 202 | self.curves.append( 203 | self.read_one_curve(acv_file, 204 | type(self).channel_name(idx), 205 | interpolation_mode)) 206 | 207 | def write_acv(self, acv_path): 208 | if self.count < 1: 209 | raise ValueError("Can't write empty curveset as ACV data") 210 | with open(acv_path, "wb") as acv_file: 211 | acv_file.write(struct.pack("!hh", 0, self.count)) 212 | for curve in self.curves: 213 | self.write_one_curve(acv_file, curve) 214 | 215 | def process(self, image): 216 | mode = Mode.of(image) 217 | if mode not in type(self).valid_modes: 218 | image = Mode.RGB.process(image) 219 | elif mode is not Mode.RGB: 220 | return Image.eval(Mode.L.process(image), 221 | self.curves[0]) 222 | # The image to be RGB-modes at this point: 223 | adjusted_bands = [] 224 | for idx, band in enumerate(image.split()): 225 | adjusted_bands.append( 226 | Image.eval(band, 227 | lambda v: self.curves[idx+1](v))) 228 | return Mode.RGB.merge(*adjusted_bands) 229 | 230 | def add(self, curve): 231 | self.curves.append(curve) 232 | self.count = len(self.curves) 233 | 234 | def __repr__(self): 235 | cls_name = pyname(type(self)) 236 | address = id(self) 237 | label = self.is_builtin and '[builtin]' or self.name 238 | interp = self.interpolation_mode or InterpolateMode.LAGRANGE 239 | parenthetical = f"{label}, {self.count}, {interp}" 240 | return f"{cls_name}({parenthetical}) @ <{address}>" 241 | 242 | def test(): 243 | curve_sets = CurveSet.instakit_curve_sets() 244 | 245 | image_paths = list(map( 246 | lambda image_file: asset.path('img', image_file), 247 | asset.listfiles('img'))) 248 | image_inputs = list(map( 249 | lambda image_path: Mode.RGB.open(image_path), 250 | image_paths)) 251 | 252 | for image_input in image_inputs[:1]: 253 | image_input.show() 254 | for curve_set in curve_sets: 255 | curve_set.process(image_input).show() 256 | 257 | print(curve_sets) 258 | print(image_paths) 259 | 260 | import tempfile 261 | temppath = tempfile.mktemp(suffix='.acv') 262 | assert not CurveSet(path=temppath).file_exists 263 | 264 | if __name__ == '__main__': 265 | test() -------------------------------------------------------------------------------- /instakit/processors/halftone.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | """ 4 | halftone.py 5 | 6 | Created by FI$H 2000 on 2012-08-23. 7 | Copyright (c) 2012 Objects In Space And Time, LLC. All rights reserved. 8 | """ 9 | from __future__ import print_function 10 | 11 | from PIL import ImageDraw 12 | 13 | from instakit.utils import pipeline, gcr 14 | from instakit.utils.mode import Mode 15 | from instakit.utils.stats import histogram_mean 16 | from instakit.abc import Processor, ThresholdProcessor 17 | 18 | class SlowAtkinson(ThresholdProcessor): 19 | 20 | """ It’s not a joke, this processor is slow as fuck; 21 | if at all possible, use the cythonized version instead 22 | (q.v. instakit.processors.ext.Atkinson) and never ever 23 | use this one if at all possible – unless, like, you’re 24 | being paid by the hour or somesuch. Up to you dogg. 25 | """ 26 | __slots__ = tuple() 27 | 28 | def process(self, image): 29 | """ The process call returns a monochrome ('L'-mode) image """ 30 | image = Mode.L.process(image) 31 | for y in range(image.size[1]): 32 | for x in range(image.size[0]): 33 | old = image.getpixel((x, y)) 34 | new = self.threshold_matrix[old] 35 | err = (old - new) >> 3 # divide by 8. 36 | image.putpixel((x, y), new) 37 | for nxy in [(x+1, y), 38 | (x+2, y), 39 | (x-1, y+1), 40 | (x, y+1), 41 | (x+1, y+1), 42 | (x, y+2)]: 43 | try: 44 | image.putpixel(nxy, int( 45 | image.getpixel(nxy) + err)) 46 | except IndexError: 47 | pass # it happens, evidently. 48 | return image 49 | 50 | class SlowFloydSteinberg(ThresholdProcessor): 51 | 52 | """ A similarly super-slow reference implementation of Floyd-Steinberg. 53 | Adapted from an RGB version here: https://github.com/trimailov/qwer 54 | """ 55 | __slots__ = tuple() 56 | 57 | # Precalculate fractional error multipliers: 58 | SEVEN_FRAC = 7/16 59 | THREE_FRAC = 3/16 60 | CINCO_FRAC = 5/16 61 | ALONE_FRAC = 1/16 62 | 63 | def process(self, image): 64 | """ The process call returns a monochrome ('L'-mode) image """ 65 | # N.B. We store local references to the fractional error multipliers 66 | # to avoid the Python internal-dict-stuff member-lookup overhead: 67 | image = Mode.L.process(image) 68 | SEVEN_FRAC = type(self).SEVEN_FRAC 69 | THREE_FRAC = type(self).THREE_FRAC 70 | CINCO_FRAC = type(self).CINCO_FRAC 71 | ALONE_FRAC = type(self).ALONE_FRAC 72 | for y in range(image.size[1]): 73 | for x in range(image.size[0]): 74 | old = image.getpixel((x, y)) 75 | new = self.threshold_matrix[old] 76 | image.putpixel((x, y), new) 77 | err = old - new 78 | for nxy in [((x+1, y), SEVEN_FRAC), 79 | ((x-1, y+1), THREE_FRAC), 80 | ((x, y+1), CINCO_FRAC), 81 | ((x+1, y+1), ALONE_FRAC)]: 82 | try: 83 | image.putpixel(nxy[0], int( 84 | image.getpixel(nxy[0]) + err * nxy[1])) 85 | except IndexError: 86 | pass # it happens, evidently. 87 | return image 88 | 89 | # Register the stub as a instakit.abc.Processor “virtual subclass”: 90 | @Processor.register 91 | class Problematic(object): 92 | def __init__(self): 93 | raise TypeError("Fast-math version couldn't be imported") 94 | 95 | try: 96 | # My man, fast Bill Atkinson 97 | from instakit.processors.ext.halftone import Atkinson as FastAtkinson 98 | except ImportError: 99 | Atkinson = SlowAtkinson 100 | FastAtkinson = Problematic 101 | else: 102 | # Register the Cythonized processor with the ABC: 103 | Atkinson = Processor.register(FastAtkinson) 104 | 105 | try: 106 | # THE FLOYDSTER 107 | from instakit.processors.ext.halftone import FloydSteinberg as FastFloydSteinberg 108 | except ImportError: 109 | FloydSteinberg = SlowFloydSteinberg 110 | FastFloydSteinberg = Problematic 111 | else: 112 | # Register the Cythonized processor with the ABC: 113 | FloydSteinberg = Processor.register(FastFloydSteinberg) 114 | 115 | class CMYKAtkinson(Processor): 116 | 117 | """ Create a full-color CMYK Atkinson-dithered halftone, with gray-component 118 | replacement (GCR) at a specified percentage level 119 | """ 120 | __slots__ = ('gcr', 'overprinter') 121 | 122 | def __init__(self, gcr=20): 123 | self.gcr = max(min(100, gcr), 0) 124 | self.overprinter = pipeline.BandFork(Atkinson, mode='CMYK') 125 | 126 | def process(self, image): 127 | return pipeline.Pipe(gcr.BasicGCR(self.gcr), 128 | self.overprinter).process(image) 129 | 130 | class CMYKFloydsterBill(Processor): 131 | 132 | """ Create a full-color CMYK Atkinson-dithered halftone, with gray-component 133 | replacement (GCR) and OH SHIT SON WHAT IS THAT ON THE CYAN CHANNEL DOGG 134 | """ 135 | __slots__ = ('gcr', 'overprinter') 136 | 137 | def __init__(self, gcr=20): 138 | self.gcr = max(min(100, gcr), 0) 139 | self.overprinter = pipeline.BandFork(Atkinson, mode='CMYK') 140 | self.overprinter.update({ 'C' : SlowFloydSteinberg() }) 141 | 142 | def process(self, image): 143 | return pipeline.Pipe(gcr.BasicGCR(self.gcr), 144 | self.overprinter).process(image) 145 | 146 | class DotScreen(Processor): 147 | 148 | """ This processor creates a monochrome dot-screen halftone pattern 149 | from an image. While this may be useful on its own, it is far 150 | more useful when used across all channels of a CMYK image in 151 | a BandFork or OverprintFork processor operation (q.v. sources 152 | of `instakit.utils.pipeline.BandFork` et al. supra.) serially, 153 | with either a gray-component replacement (GCR) or an under-color 154 | replacement (UCR) function. 155 | 156 | Regarding the latter two operations, instakit only has a basic 157 | GCR implementation currently, at the time of writing – q.v. the 158 | `instakit.utils.gcr` module sub. 159 | 160 | Adapted originally from this sample code: 161 | https://stackoverflow.com/a/10575940/298171 162 | """ 163 | __slots__ = ('sample', 'scale', 'angle') 164 | 165 | def __init__(self, sample=1, scale=2, angle=0): 166 | self.sample = sample 167 | self.scale = scale 168 | self.angle = angle 169 | 170 | def process(self, image): 171 | orig_width, orig_height = image.size 172 | image = Mode.L.process(image).rotate(self.angle, expand=1) 173 | width, height = image.size 174 | halftone = Mode.L.new((width * self.scale, 175 | height * self.scale)) 176 | dotscreen = ImageDraw.Draw(halftone) 177 | 178 | SAMPLE = self.sample 179 | SCALE = self.scale 180 | ANGLE = self.angle 181 | 182 | for y in range(0, height, SAMPLE): 183 | for x in range(0, width, SAMPLE): 184 | cropbox = image.crop((x, y, 185 | x + SAMPLE, y + SAMPLE)) 186 | diameter = (histogram_mean(cropbox) / 255) ** 0.5 187 | edge = 0.5 * (1 - diameter) 188 | xpos, ypos = (x + edge) * SCALE, (y + edge) * SCALE 189 | boxedge = SAMPLE * diameter * SCALE 190 | dotscreen.ellipse((xpos, ypos, 191 | xpos + boxedge, ypos + boxedge), 192 | fill=255) 193 | 194 | halftone = halftone.rotate(-ANGLE, expand=1) 195 | tone_width, tone_height = halftone.size 196 | xx = (tone_width - orig_width * SCALE) / 2 197 | yy = (tone_height - orig_height * SCALE) / 2 198 | return halftone.crop((xx, yy, 199 | xx + orig_width * SCALE, yy + orig_height * SCALE)) 200 | 201 | class CMYKDotScreen(Processor): 202 | 203 | """ Create a full-color CMYK dot-screen halftone, with gray-component 204 | replacement (GCR), individual rotation angles for each channel’s 205 | dot-screen, and resampling value controls. 206 | """ 207 | __slots__ = ('overprinter', 'sample', 'scale') 208 | 209 | def __init__(self, gcr=20, 210 | sample=10, scale=10, 211 | thetaC=0, thetaM=15, thetaY=30, thetaK=45): 212 | """ Initialize an internal instakit.utils.pipeline.OverprintFork() """ 213 | self.sample = sample 214 | self.scale = scale 215 | self.overprinter = pipeline.OverprintFork(None, gcr=gcr) 216 | self.overprinter['C'] = DotScreen(angle=thetaC, sample=sample, scale=scale) 217 | self.overprinter['M'] = DotScreen(angle=thetaM, sample=sample, scale=scale) 218 | self.overprinter['Y'] = DotScreen(angle=thetaY, sample=sample, scale=scale) 219 | self.overprinter['K'] = DotScreen(angle=thetaK, sample=sample, scale=scale) 220 | self.overprinter.apply_CMYK_inks() 221 | 222 | @property 223 | def gcr_percentage(self): 224 | return self.overprinter.basicgcr.percentage 225 | 226 | def angle(self, band_label): 227 | if band_label not in self.overprinter.band_labels: 228 | raise ValueError('invalid band label') 229 | return self.overprinter[band_label].angle 230 | 231 | @property 232 | def thetaC(self): 233 | """ Return the C-band halftone screen’s rotation """ 234 | return self.angle('C') 235 | 236 | @property 237 | def thetaM(self): 238 | """ Return the M-band halftone screen’s rotation """ 239 | return self.angle('M') 240 | 241 | @property 242 | def thetaY(self): 243 | """ Return the Y-band halftone screen’s rotation """ 244 | return self.angle('Y') 245 | 246 | @property 247 | def thetaK(self): 248 | """ Return the K-band halftone screen’s rotation """ 249 | return self.angle('K') 250 | 251 | def process(self, image): 252 | return self.overprinter.process(image) 253 | 254 | def test(): 255 | from instakit.utils.static import asset 256 | 257 | image_paths = list(map( 258 | lambda image_file: asset.path('img', image_file), 259 | asset.listfiles('img'))) 260 | image_inputs = list(map( 261 | lambda image_path: Mode.RGB.open(image_path), 262 | image_paths)) 263 | 264 | for image_input in image_inputs: 265 | image_input.show() 266 | 267 | # Atkinson(threshold=128.0).process(image_input).show() 268 | # FloydSteinberg(threshold=128.0).process(image_input).show() 269 | # SlowFloydSteinberg(threshold=128.0).process(image_input).show() 270 | 271 | # CMYKAtkinson().process(image_input).show() 272 | # CMYKFloydsterBill().process(image_input).show() 273 | CMYKDotScreen(sample=10, scale=4).process(image_input).show() 274 | 275 | print(image_paths) 276 | 277 | if __name__ == '__main__': 278 | test() -------------------------------------------------------------------------------- /instakit/utils/ext/hsluv.c: -------------------------------------------------------------------------------- 1 | /* 2 | * HSLuv-C: Human-friendly HSL 3 | * 4 | * 5 | * 6 | * Copyright (c) 2015 Alexei Boronine (original idea, JavaScript implementation) 7 | * Copyright (c) 2015 Roger Tallada (Obj-C implementation) 8 | * Copyright (c) 2017 Martin Mitas (C implementation, based on Obj-C implementation) 9 | * 10 | * Permission is hereby granted, free of charge, to any person obtaining a 11 | * copy of this software and associated documentation files (the "Software"), 12 | * to deal in the Software without restriction, including without limitation 13 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 14 | * and/or sell copies of the Software, and to permit persons to whom the 15 | * Software is furnished to do so, subject to the following conditions: 16 | * 17 | * The above copyright notice and this permission notice shall be included in 18 | * all copies or substantial portions of the Software. 19 | * 20 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 21 | * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 25 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 26 | * IN THE SOFTWARE. 27 | */ 28 | 29 | #include "hsluv.h" 30 | 31 | #include 32 | #include 33 | 34 | 35 | typedef struct Triplet_tag Triplet; 36 | struct Triplet_tag { 37 | double a; 38 | double b; 39 | double c; 40 | }; 41 | 42 | /* for RGB */ 43 | static const Triplet m[3] = { 44 | { 3.24096994190452134377, -1.53738317757009345794, -0.49861076029300328366 }, 45 | { -0.96924363628087982613, 1.87596750150772066772, 0.04155505740717561247 }, 46 | { 0.05563007969699360846, -0.20397695888897656435, 1.05697151424287856072 } 47 | }; 48 | 49 | /* for XYZ */ 50 | static const Triplet m_inv[3] = { 51 | { 0.41239079926595948129, 0.35758433938387796373, 0.18048078840183428751 }, 52 | { 0.21263900587151035754, 0.71516867876775592746, 0.07219231536073371500 }, 53 | { 0.01933081871559185069, 0.11919477979462598791, 0.95053215224966058086 } 54 | }; 55 | 56 | static const double ref_u = 0.19783000664283680764; 57 | static const double ref_v = 0.46831999493879100370; 58 | 59 | static const double kappa = 903.29629629629629629630; 60 | static const double epsilon = 0.00885645167903563082; 61 | 62 | 63 | typedef struct Bounds_tag Bounds; 64 | struct Bounds_tag { 65 | double a; 66 | double b; 67 | }; 68 | 69 | 70 | static void 71 | get_bounds(double l, Bounds bounds[6]) 72 | { 73 | double tl = l + 16.0; 74 | double sub1 = (tl * tl * tl) / 1560896.0; 75 | double sub2 = (sub1 > epsilon ? sub1 : (l / kappa)); 76 | int channel; 77 | int t; 78 | 79 | for(channel = 0; channel < 3; channel++) { 80 | double m1 = m[channel].a; 81 | double m2 = m[channel].b; 82 | double m3 = m[channel].c; 83 | 84 | for (t = 0; t < 2; t++) { 85 | double top1 = (284517.0 * m1 - 94839.0 * m3) * sub2; 86 | double top2 = (838422.0 * m3 + 769860.0 * m2 + 731718.0 * m1) * l * sub2 - 769860.0 * t * l; 87 | double bottom = (632260.0 * m3 - 126452.0 * m2) * sub2 + 126452.0 * t; 88 | 89 | bounds[channel * 2 + t].a = top1 / bottom; 90 | bounds[channel * 2 + t].b = top2 / bottom; 91 | } 92 | } 93 | } 94 | 95 | static double 96 | intersect_line_line(const Bounds* line1, const Bounds* line2) 97 | { 98 | return (line1->b - line2->b) / (line2->a - line1->a); 99 | } 100 | 101 | static double 102 | dist_from_pole(double x, double y) 103 | { 104 | return sqrt(x * x + y * y); 105 | } 106 | 107 | static double 108 | ray_length_until_intersect(double theta, const Bounds* line) 109 | { 110 | return line->b / (sin(theta) - line->a * cos(theta)); 111 | } 112 | 113 | static double 114 | max_safe_chroma_for_l(double l) 115 | { 116 | double min_len = DBL_MAX; 117 | Bounds bounds[6]; 118 | int i; 119 | 120 | get_bounds(l, bounds); 121 | for(i = 0; i < 6; i++) { 122 | double m1 = bounds[i].a; 123 | double b1 = bounds[i].b; 124 | /* x where line intersects with perpendicular running though (0, 0) */ 125 | Bounds line2 = { -1.0 / m1, 0.0 }; 126 | double x = intersect_line_line(&bounds[i], &line2); 127 | double distance = dist_from_pole(x, b1 + x * m1); 128 | 129 | if(distance >= 0.0 && distance < min_len) 130 | min_len = distance; 131 | } 132 | 133 | return min_len; 134 | } 135 | 136 | static double 137 | max_chroma_for_lh(double l, double h) 138 | { 139 | double min_len = DBL_MAX; 140 | double hrad = h * 0.01745329251994329577; /* (2 * pi / 260) */ 141 | Bounds bounds[6]; 142 | int i; 143 | 144 | get_bounds(l, bounds); 145 | for(i = 0; i < 6; i++) { 146 | double l = ray_length_until_intersect(hrad, &bounds[i]); 147 | 148 | if(l >= 0 && l < min_len) 149 | min_len = l; 150 | } 151 | return min_len; 152 | } 153 | 154 | static double 155 | dot_product(const Triplet* t1, const Triplet* t2) 156 | { 157 | return (t1->a * t2->a + t1->b * t2->b + t1->c * t2->c); 158 | } 159 | 160 | /* Used for rgb conversions */ 161 | static double 162 | from_linear(double c) 163 | { 164 | if(c <= 0.0031308) 165 | return 12.92 * c; 166 | else 167 | return 1.055 * pow(c, 1.0 / 2.4) - 0.055; 168 | } 169 | 170 | static double 171 | to_linear(double c) 172 | { 173 | if (c > 0.04045) 174 | return pow((c + 0.055) / 1.055, 2.4); 175 | else 176 | return c / 12.92; 177 | } 178 | 179 | static void 180 | xyz2rgb(Triplet* in_out) 181 | { 182 | double r = from_linear(dot_product(&m[0], in_out)); 183 | double g = from_linear(dot_product(&m[1], in_out)); 184 | double b = from_linear(dot_product(&m[2], in_out)); 185 | in_out->a = r; 186 | in_out->b = g; 187 | in_out->c = b; 188 | } 189 | 190 | static void 191 | rgb2xyz(Triplet* in_out) 192 | { 193 | Triplet rgbl = { to_linear(in_out->a), to_linear(in_out->b), to_linear(in_out->c) }; 194 | double x = dot_product(&m_inv[0], &rgbl); 195 | double y = dot_product(&m_inv[1], &rgbl); 196 | double z = dot_product(&m_inv[2], &rgbl); 197 | in_out->a = x; 198 | in_out->b = y; 199 | in_out->c = z; 200 | } 201 | 202 | /* http://en.wikipedia.org/wiki/CIELUV 203 | * In these formulas, Yn refers to the reference white point. We are using 204 | * illuminant D65, so Yn (see refY in Maxima file) equals 1. The formula is 205 | * simplified accordingly. 206 | */ 207 | static double 208 | y2l(double y) 209 | { 210 | if(y <= epsilon) 211 | return y * kappa; 212 | else 213 | return 116.0 * cbrt(y) - 16.0; 214 | } 215 | 216 | static double 217 | l2y(double l) 218 | { 219 | if(l <= 8.0) { 220 | return l / kappa; 221 | } else { 222 | double x = (l + 16.0) / 116.0; 223 | return (x * x * x); 224 | } 225 | } 226 | 227 | static void 228 | xyz2luv(Triplet* in_out) 229 | { 230 | double var_u = (4.0 * in_out->a) / (in_out->a + (15.0 * in_out->b) + (3.0 * in_out->c)); 231 | double var_v = (9.0 * in_out->b) / (in_out->a + (15.0 * in_out->b) + (3.0 * in_out->c)); 232 | double l = y2l(in_out->b); 233 | double u = 13.0 * l * (var_u - ref_u); 234 | double v = 13.0 * l * (var_v - ref_v); 235 | 236 | in_out->a = l; 237 | if(l < 0.00000001) { 238 | in_out->b = 0.0; 239 | in_out->c = 0.0; 240 | } else { 241 | in_out->b = u; 242 | in_out->c = v; 243 | } 244 | } 245 | 246 | static void 247 | luv2xyz(Triplet* in_out) 248 | { 249 | if(in_out->a <= 0.00000001) { 250 | /* Black will create a divide-by-zero error. */ 251 | in_out->a = 0.0; 252 | in_out->b = 0.0; 253 | in_out->c = 0.0; 254 | return; 255 | } 256 | 257 | double var_u = in_out->b / (13.0 * in_out->a) + ref_u; 258 | double var_v = in_out->c / (13.0 * in_out->a) + ref_v; 259 | double y = l2y(in_out->a); 260 | double x = -(9.0 * y * var_u) / ((var_u - 4.0) * var_v - var_u * var_v); 261 | double z = (9.0 * y - (15.0 * var_v * y) - (var_v * x)) / (3.0 * var_v); 262 | in_out->a = x; 263 | in_out->b = y; 264 | in_out->c = z; 265 | } 266 | 267 | static void 268 | luv2lch(Triplet* in_out) 269 | { 270 | double l = in_out->a; 271 | double u = in_out->b; 272 | double v = in_out->c; 273 | double h; 274 | double c = sqrt(u * u + v * v); 275 | 276 | /* Grays: disambiguate hue */ 277 | if(c < 0.00000001) { 278 | h = 0; 279 | } else { 280 | h = atan2(v, u) * 57.29577951308232087680; /* (180 / pi) */ 281 | if(h < 0.0) 282 | h += 360.0; 283 | } 284 | 285 | in_out->a = l; 286 | in_out->b = c; 287 | in_out->c = h; 288 | } 289 | 290 | static void 291 | lch2luv(Triplet* in_out) 292 | { 293 | double hrad = in_out->c * 0.01745329251994329577; /* (pi / 180.0) */ 294 | double u = cos(hrad) * in_out->b; 295 | double v = sin(hrad) * in_out->b; 296 | 297 | in_out->b = u; 298 | in_out->c = v; 299 | } 300 | 301 | static void 302 | hsluv2lch(Triplet* in_out) 303 | { 304 | double h = in_out->a; 305 | double s = in_out->b; 306 | double l = in_out->c; 307 | double c; 308 | 309 | /* White and black: disambiguate chroma */ 310 | if(l > 99.9999999 || l < 0.00000001) 311 | c = 0.0; 312 | else 313 | c = max_chroma_for_lh(l, h) / 100.0 * s; 314 | 315 | /* Grays: disambiguate hue */ 316 | if (s < 0.00000001) 317 | h = 0.0; 318 | 319 | in_out->a = l; 320 | in_out->b = c; 321 | in_out->c = h; 322 | } 323 | 324 | static void 325 | lch2hsluv(Triplet* in_out) 326 | { 327 | double l = in_out->a; 328 | double c = in_out->b; 329 | double h = in_out->c; 330 | double s; 331 | 332 | /* White and black: disambiguate saturation */ 333 | if(l > 99.9999999 || l < 0.00000001) 334 | s = 0.0; 335 | else 336 | s = c / max_chroma_for_lh(l, h) * 100.0; 337 | 338 | /* Grays: disambiguate hue */ 339 | if (c < 0.00000001) 340 | h = 0.0; 341 | 342 | in_out->a = h; 343 | in_out->b = s; 344 | in_out->c = l; 345 | } 346 | 347 | static void 348 | hpluv2lch(Triplet* in_out) 349 | { 350 | double h = in_out->a; 351 | double s = in_out->b; 352 | double l = in_out->c; 353 | double c; 354 | 355 | /* White and black: disambiguate chroma */ 356 | if(l > 99.9999999 || l < 0.00000001) 357 | c = 0.0; 358 | else 359 | c = max_safe_chroma_for_l(l) / 100.0 * s; 360 | 361 | /* Grays: disambiguate hue */ 362 | if (s < 0.00000001) 363 | h = 0.0; 364 | 365 | in_out->a = l; 366 | in_out->b = c; 367 | in_out->c = h; 368 | } 369 | 370 | static void 371 | lch2hpluv(Triplet* in_out) 372 | { 373 | double l = in_out->a; 374 | double c = in_out->b; 375 | double h = in_out->c; 376 | double s; 377 | 378 | /* White and black: disambiguate saturation */ 379 | if (l > 99.9999999 || l < 0.00000001) 380 | s = 0.0; 381 | else 382 | s = c / max_safe_chroma_for_l(l) * 100.0; 383 | 384 | /* Grays: disambiguate hue */ 385 | if (c < 0.00000001) 386 | h = 0.0; 387 | 388 | in_out->a = h; 389 | in_out->b = s; 390 | in_out->c = l; 391 | } 392 | 393 | 394 | 395 | void 396 | hsluv2rgb(double h, double s, double l, double* pr, double* pg, double* pb) 397 | { 398 | Triplet tmp = { h, s, l }; 399 | 400 | hsluv2lch(&tmp); 401 | lch2luv(&tmp); 402 | luv2xyz(&tmp); 403 | xyz2rgb(&tmp); 404 | 405 | *pr = tmp.a; 406 | *pg = tmp.b; 407 | *pb = tmp.c; 408 | } 409 | 410 | void 411 | hpluv2rgb(double h, double s, double l, double* pr, double* pg, double* pb) 412 | { 413 | Triplet tmp = { h, s, l }; 414 | 415 | hpluv2lch(&tmp); 416 | lch2luv(&tmp); 417 | luv2xyz(&tmp); 418 | xyz2rgb(&tmp); 419 | 420 | *pr = tmp.a; 421 | *pg = tmp.b; 422 | *pb = tmp.c; 423 | } 424 | 425 | void 426 | rgb2hsluv(double r, double g, double b, double* ph, double* ps, double* pl) 427 | { 428 | Triplet tmp = { r, g, b }; 429 | 430 | rgb2xyz(&tmp); 431 | xyz2luv(&tmp); 432 | luv2lch(&tmp); 433 | lch2hsluv(&tmp); 434 | 435 | *ph = tmp.a; 436 | *ps = tmp.b; 437 | *pl = tmp.c; 438 | } 439 | 440 | void 441 | rgb2hpluv(double r, double g, double b, double* ph, double* ps, double* pl) 442 | { 443 | Triplet tmp = { r, g, b }; 444 | 445 | rgb2xyz(&tmp); 446 | xyz2luv(&tmp); 447 | luv2lch(&tmp); 448 | lch2hpluv(&tmp); 449 | 450 | *ph = tmp.a; 451 | *ps = tmp.b; 452 | *pl = tmp.c; 453 | } 454 | -------------------------------------------------------------------------------- /instakit/meta/parameters.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | from __future__ import print_function 4 | 5 | import argparse 6 | import enum 7 | import importlib 8 | import inspect 9 | import types 10 | 11 | from pprint import pprint 12 | 13 | class Parameter(object): 14 | """ A placeholder object, used for the moment in the inline tests """ 15 | pass 16 | 17 | QUALIFIER = '.' 18 | 19 | def dotpath_join(base, *addenda): 20 | """ Join dotpath elements together as one, á la os.path.join(…) """ 21 | for addendum in addenda: 22 | if not base.endswith(QUALIFIER): 23 | base += QUALIFIER 24 | if addendum.startswith(QUALIFIER): 25 | if len(addendum) == 1: 26 | raise ValueError('operand too short: %s' % addendum) 27 | addendum = addendum[1:] 28 | base += addendum 29 | # N.B. this might be overthinking it -- 30 | # maybe we *want* to allow dotpaths 31 | # that happen to start and/or end with dots? 32 | if base.endswith(QUALIFIER): 33 | return base[:-1] 34 | return base 35 | 36 | def qualified_import(qualified): 37 | """ Import a qualified thing-name. 38 | e.g. 'instakit.processors.halftone.FloydSteinberg' 39 | """ 40 | if QUALIFIER not in qualified: 41 | raise ValueError("qualified_import() needs a qualified name " 42 | "(got %s)" % qualified) 43 | head = qualified.split(QUALIFIER)[-1] 44 | tail = qualified.replace("%s%s" % (QUALIFIER, head), '') 45 | module = importlib.import_module(tail) 46 | cls = getattr(module, head) 47 | print("Qualified Import: %s" % qualified) 48 | return cls 49 | 50 | def qualified_name_tuple(cls): 51 | """ Get the module name and the thing-name for a class. 52 | e.g. ('instakit.processors.halftone', 'FloydSteinberg') 53 | """ 54 | mod_name = getattr(cls, '__module__') 55 | cls_name = getattr(cls, '__qualname__', 56 | getattr(cls, '__name__')) 57 | return mod_name, cls_name 58 | 59 | def qualified_name(cls): 60 | """ Get a qualified thing-name for a class. 61 | e.g. 'instakit.processors.halftone.FloydSteinberg' 62 | """ 63 | mod_name, cls_name = qualified_name_tuple(cls) 64 | out = "%s%s%s" % (mod_name, QUALIFIER, cls_name) 65 | print("Qualified Name: %s" % out) 66 | return out 67 | 68 | class Nothing(object): 69 | """ Placeholder singleton, signifying nothing """ 70 | __slots__ = tuple() 71 | def __new__(cls, *a, **k): 72 | return Nothing 73 | 74 | def check_parameter_default(param_default): 75 | """ Filter result values coming from inspect.signature(…) """ 76 | if param_default == inspect._empty: 77 | return Nothing 78 | return param_default 79 | 80 | def default_arguments(cls): 81 | """ Get a dictionary of the keyword arguments with provided defaults, 82 | as furnished by a given classes’ “__init__” function. 83 | """ 84 | try: 85 | signature = inspect.signature(cls) 86 | except (ValueError, TypeError) as exc: 87 | m, n = qualified_name_tuple(cls) 88 | qn = "%s%sSlow%s" % (m.replace('ext.', ''), QUALIFIER, n) # WTF HAX 89 | NonCythonCls = qualified_import(qn) 90 | if qualified_name(NonCythonCls) != qualified_name(cls): 91 | return default_arguments(NonCythonCls) 92 | else: 93 | raise exc 94 | if len(signature.parameters) < 1: 95 | return {} 96 | return { parameter.name : check_parameter_default(parameter.default) \ 97 | for parameter \ 98 | in signature.parameters.values() } 99 | 100 | def is_enum(cls): 101 | """ Predicate function to ascertain whether a class is an Enum. """ 102 | return enum.Enum in cls.__mro__ 103 | 104 | def enum_choices(cls): 105 | """ Return a list of the names of the given Enum class members. """ 106 | return [choice.name for choice in cls] 107 | 108 | FILE_ARGUMENT_NAMES = ('path', 'pth', 'file') 109 | 110 | def add_argparser(subparsers, cls): 111 | """ Add a subparser -- an instance of “argparse.ArgumentParser” -- 112 | with arguments and defaults matching the keyword arguments and 113 | defaults provided by the given class (q.v. “default_arguments(…)” 114 | definition supra.) 115 | """ 116 | qualname = qualified_name(cls) 117 | cls_help = getattr(cls, '__doc__', None) or "help for %s" % qualname 118 | parser = subparsers.add_parser(qualname, help=cls_help) 119 | if is_enum(cls): # Deal with enums 120 | argument_name = cls.__name__.lower() 121 | add_argument_args = dict(choices=enum_choices(cls), 122 | type=str, 123 | help='help for enum %s' % argument_name) 124 | parser.add_argument(argument_name, 125 | **add_argument_args) 126 | else: # Deal with __init__ signature 127 | for argument_name, argument_value in default_arguments(cls).items(): 128 | argument_type = type(argument_value) 129 | argument_required = False 130 | add_argument_args = dict(help='help for argument %s' % argument_name) 131 | if argument_value is not Nothing: 132 | add_argument_args.update({ 'default' : argument_value }) 133 | else: 134 | add_argument_args.update({ 'type' : argument_name in FILE_ARGUMENT_NAMES \ 135 | and argparse.FileType('rb') \ 136 | or str }) 137 | argument_required = True 138 | if argument_type is bool: 139 | add_argument_args.update({ 'action' : 'store_true' }) 140 | elif argument_type is type(None): 141 | add_argument_args.update({ 'type' : str }) 142 | elif is_enum(argument_type): 143 | add_argument_args.update({ 'choices' : enum_choices(argument_type), 144 | 'type' : str }) 145 | argument_template = argument_required and '%s' or '--%s' 146 | parser.add_argument(argument_template % argument_name, 147 | **add_argument_args) 148 | return parser 149 | 150 | functype = types.FunctionType 151 | 152 | def get_processors_from(module_name): 153 | """ Memoized processor-extraction function """ 154 | from instakit.utils.static import asset 155 | if not hasattr(get_processors_from, 'cache'): 156 | get_processors_from.cache = {} 157 | if module_name not in get_processors_from.cache: 158 | processors = [] 159 | module = importlib.import_module(module_name) 160 | print("Module: %s (%s)" % (module.__name__, 161 | asset.relative(module.__file__))) 162 | for thing in (getattr(module, name) for name in dir(module)): 163 | if hasattr(thing, 'process'): 164 | print("Found thing: %s" % thing) 165 | if module.__name__ in thing.__module__: 166 | if thing not in processors: 167 | if type(getattr(thing, 'process')) is functype: 168 | processors.append(thing) 169 | get_processors_from.cache[module_name] = tuple(processors) 170 | return get_processors_from.cache[module_name] 171 | 172 | 173 | def test(): 174 | 175 | # Test “qualified_import()”: 176 | print("Testing “qualified_import()”…") 177 | 178 | class_name = 'instakit.processors.halftone.SlowFloydSteinberg' 179 | ImportedFloydSteinberg = qualified_import(class_name) 180 | assert ImportedFloydSteinberg.__name__ == 'SlowFloydSteinberg' 181 | assert ImportedFloydSteinberg.__qualname__ == 'SlowFloydSteinberg' 182 | assert ImportedFloydSteinberg.__module__ == 'instakit.processors.halftone' 183 | 184 | class_name = 'instakit.processors.halftone.Atkinson' # TWIST!! 185 | ImportedAtkinson = qualified_import(class_name) 186 | assert ImportedAtkinson.__name__ == 'Atkinson' 187 | assert ImportedAtkinson.__qualname__ == 'Atkinson' 188 | assert ImportedAtkinson.__module__ == 'instakit.processors.ext.halftone' 189 | 190 | print("Success!") 191 | print() 192 | 193 | # Test “qualified_name()”: 194 | print("Testing “qualified_name()”…") 195 | 196 | class_name = qualified_name(Parameter) 197 | assert class_name == '__main__.Parameter' 198 | 199 | class_name = qualified_name(ImportedFloydSteinberg) 200 | assert class_name == 'instakit.processors.halftone.SlowFloydSteinberg' 201 | 202 | class_name = qualified_name(ImportedAtkinson) 203 | assert class_name == 'instakit.processors.ext.halftone.Atkinson' 204 | 205 | print("Success!") 206 | print() 207 | 208 | # Test “Nothing”: 209 | print("Testing “Nothing”…") 210 | 211 | assert type(Nothing) == type 212 | assert Nothing() == Nothing 213 | 214 | print("Success!") 215 | print() 216 | 217 | # Test “default_arguments()”: 218 | print("Testing “default_arguments()”…") 219 | 220 | default_args = default_arguments(ImportedFloydSteinberg) 221 | assert default_args == dict(threshold=128.0) 222 | 223 | slow_atkinson = 'instakit.processors.halftone.SlowAtkinson' 224 | default_args = default_arguments(qualified_import(slow_atkinson)) 225 | assert default_args == dict(threshold=128.0) 226 | 227 | noise = 'instakit.processors.noise.GaussianNoise' 228 | default_args = default_arguments(qualified_import(noise)) 229 | assert default_args == dict() 230 | 231 | contrast = 'instakit.processors.adjust.Contrast' 232 | default_args = default_arguments(qualified_import(contrast)) 233 | assert default_args == dict(value=1.0) 234 | 235 | unsharp_mask = 'instakit.processors.blur.UnsharpMask' 236 | default_args = default_arguments(qualified_import(unsharp_mask)) 237 | assert default_args == dict(radius=2, 238 | percent=150, 239 | threshold=3) 240 | 241 | curves = 'instakit.processors.curves' 242 | curveset = dotpath_join(curves, 'CurveSet') 243 | interpolate_mode = dotpath_join(curves, 'InterpolateMode') 244 | ImportedInterpolateMode = qualified_import(interpolate_mode) 245 | default_args = default_arguments(qualified_import(curveset)) 246 | LAGRANGE = ImportedInterpolateMode.LAGRANGE 247 | assert default_args == dict(path=Nothing, 248 | interpolation_mode=LAGRANGE) 249 | 250 | print("Success!") 251 | print() 252 | 253 | # Test “is_enum()”: 254 | print("Testing “is_enum()”…") 255 | 256 | assert is_enum(ImportedInterpolateMode) 257 | assert not is_enum(Parameter) 258 | assert not is_enum(ImportedFloydSteinberg) 259 | assert not is_enum(ImportedAtkinson) 260 | 261 | mode = 'instakit.utils.mode.Mode' 262 | assert is_enum(qualified_import(mode)) 263 | 264 | interpolate_mode = 'instakit.processors.curves.InterpolateMode' 265 | assert is_enum(qualified_import(interpolate_mode)) 266 | 267 | noise_mode = 'instakit.processors.noise.NoiseMode' 268 | assert is_enum(qualified_import(noise_mode)) 269 | 270 | print("Success!") 271 | print() 272 | 273 | # Test “add_argparser()”: 274 | print("Testing “add_argparser()”…") 275 | 276 | parser = argparse.ArgumentParser(prog='instaprocess', 277 | formatter_class=argparse.RawDescriptionHelpFormatter) 278 | 279 | parser.add_argument('--verbose', '-v', 280 | action='store_true', 281 | help="print verbose messages to STDOUT") 282 | 283 | processor_names = ('adjust', 'blur', 'curves', 'halftone', 'noise', 'squarecrop') 284 | utility_names = ('colortype', 'gcr', 'kernels', 'lutmap', 285 | 'misc', 'mode', 'ndarrays', 'pipeline', 'static', 'stats') 286 | 287 | module_names = [] 288 | module_names.extend(['instakit.processors.%s' % name for name in processor_names]) 289 | module_names.extend(['instakit.utils.%s' % name for name in utility_names]) 290 | 291 | processors = {} 292 | 293 | for module_name in module_names: 294 | processors[module_name] = get_processors_from(module_name) 295 | 296 | subparsers = parser.add_subparsers(help="subcommands for instakit processors") 297 | for processor_tuple in processors.values(): 298 | for processor in processor_tuple: 299 | add_argparser(subparsers, processor) 300 | 301 | pprint(processors, indent=4) 302 | 303 | print() 304 | ns = parser.parse_args(['-h']) 305 | print(ns) 306 | 307 | print() 308 | ns = parser.parse_args(['instakit.utils.mode.Mode', '--help']) 309 | print(ns) 310 | 311 | print("Success!") 312 | print() 313 | 314 | 315 | if __name__ == '__main__': 316 | test() -------------------------------------------------------------------------------- /instakit/utils/gcr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | from __future__ import print_function 4 | from math import fabs, pow as mpow 5 | 6 | from instakit.utils.mode import Mode 7 | from instakit.abc import Processor 8 | from instakit.exporting import Exporter 9 | 10 | exporter = Exporter(path=__file__) 11 | export = exporter.decorator() 12 | 13 | PERCENT_ADMONISHMENT = "Do you not know how percents work??!" 14 | 15 | @export 16 | def gcr(image, percentage=20, revert_mode=False): 17 | ''' basic “Gray Component Replacement” function. Returns a CMYK image* with 18 | percentage gray component removed from the CMY channels and put in the 19 | K channel, e.g. for percentage=100, (41, 100, 255, 0) >> (0, 59, 214, 41). 20 | 21 | {*} This is the default behavior – to return an image of the same mode as that 22 | of which was originally provided, pass the value for the (optional) keyword 23 | argument `revert_mode` as `True`. 24 | ''' 25 | # from http://stackoverflow.com/questions/10572274/halftone-images-in-python 26 | 27 | if percentage is None: 28 | return revert_mode and image or Mode.CMYK.process(image) 29 | 30 | if percentage > 100 or percentage < 1: 31 | raise ValueError(PERCENT_ADMONISHMENT) 32 | 33 | percent = percentage / 100 34 | 35 | original_mode = Mode.of(image) 36 | cmyk_channels = Mode.CMYK.process(image).split() 37 | width, height = image.size 38 | 39 | cmyk_image = [] 40 | for channel in cmyk_channels: 41 | cmyk_image.append(channel.load()) 42 | 43 | for x in range(width): 44 | for y in range(height): 45 | gray = int(min(cmyk_image[0][x, y], 46 | cmyk_image[1][x, y], 47 | cmyk_image[2][x, y]) * percent) 48 | cmyk_image[0][x, y] -= gray 49 | cmyk_image[1][x, y] -= gray 50 | cmyk_image[2][x, y] -= gray 51 | cmyk_image[3][x, y] = gray 52 | 53 | recomposed = Mode.CMYK.merge(*cmyk_channels) 54 | 55 | if revert_mode: 56 | return original_mode.process(recomposed) 57 | return recomposed 58 | 59 | @export 60 | class BasicGCR(Processor): 61 | 62 | __slots__ = ('percentage', 'revert_mode') 63 | __doc__ = gcr.__doc__ 64 | 65 | def __init__(self, percentage=20, revert_mode=False): 66 | if percentage is None: 67 | raise ValueError(PERCENT_ADMONISHMENT) 68 | if percentage > 100 or percentage < 1: 69 | raise ValueError(PERCENT_ADMONISHMENT) 70 | self.percentage = percentage 71 | self.revert_mode = revert_mode 72 | 73 | def process(self, image): 74 | return gcr(image, percentage=self.percentage, 75 | revert_mode=self.revert_mode) 76 | 77 | @export 78 | def hex2rgb(h): 79 | """ Convert a hex string or number to an RGB triple """ 80 | # q.v. https://git.io/fh9E2 81 | if isinstance(h, str): 82 | return hex2rgb(int(h[1:] if h.startswith('#') else h, 16)) 83 | return (h >> 16) & 0xff, (h >> 8) & 0xff, h & 0xff 84 | 85 | @export 86 | def compand(v): 87 | """ Compand a linearized value to an sRGB byte value """ 88 | # q.v. http://www.brucelindbloom.com/index.html?Math.html 89 | V = (v <= 0.0031308) and (v * 12.92) or fabs((1.055 * mpow(v, 1 / 2.4)) - 0.055) 90 | return int(V * 255.0) 91 | 92 | @export 93 | def uncompand(A): 94 | """ Uncompand an sRGB byte value to a linearized value """ 95 | # q.v. http://www.brucelindbloom.com/index.html?Eqn_RGB_to_XYZ.html 96 | V = A / 255.0 97 | return (V <= 0.04045) and (V / 12.92) or mpow(((V + 0.055) / 1.055), 2.4) 98 | 99 | @export 100 | def ucr(image, revert_mode=False): 101 | ''' basic “Under-Color Removal” function. Returns a CMYK image* in which regions 102 | containing overlapping C, M, and Y ink are replaced with K (“Key”, née “BlacK”) 103 | ink. Images are first converted to RGB and linearized in order to perform the UCR 104 | operation in linear space. E.g.: 105 | 106 | 0xFFDE17 > rgb(255, 222, 23) 107 | > RGB(1.0, 0.7304607400903537, 0.008568125618069307) 108 | > CMY(0.0, 0.26953925990964633, 0.9914318743819307) 109 | 110 | rgb() > RGB() > CMY() > CMYK(41, 100, 255, 0) >> cmyk(0, 59, 214, 41). 111 | 112 | {*} This is the default behavior – to return an image of the same mode as that 113 | of which was originally provided, pass the value for the (optional) keyword 114 | argument `revert_mode` as `True`. 115 | ''' 116 | # Adapted from http://www.easyrgb.com/en/math.php#text13 117 | # N.B. this is not, out of the gate, particularly well-optimized 118 | 119 | original_mode = Mode.of(image) 120 | width, height = image.size 121 | cmyk_target = Mode.CMYK.new(image.size, color=0) 122 | rgb_channels = Mode.RGB.process(image).split() 123 | cmyk_channels = cmyk_target.split() 124 | 125 | rgb_image = [] 126 | for channel in rgb_channels: 127 | rgb_image.append(channel.load()) 128 | 129 | cmyk_image = [] 130 | for channel in cmyk_channels: 131 | cmyk_image.append(channel.load()) 132 | 133 | for x in range(width): 134 | for y in range(height): 135 | # Get the rgb byte values: 136 | rgb = (rgb_image[0][x, y], 137 | rgb_image[1][x, y], 138 | rgb_image[2][x, y]) 139 | 140 | # Uncompand rgb bytes to linearized RGB: 141 | RGB = (uncompand(v) for v in rgb) 142 | 143 | # Convert linear RGB to linear CMY: 144 | (C, M, Y) = (1.0 - V for V in RGB) 145 | 146 | # Perform simple UCR with the most combined 147 | # overlapping C/M/Y ink values: 148 | K = min(C, M, Y, 1.0) 149 | 150 | if K == 1: 151 | C = M = Y = 0 152 | else: 153 | denominator = (1 - K) 154 | C = (C - K) / denominator 155 | M = (M - K) / denominator 156 | Y = (Y - K) / denominator 157 | 158 | # Recompand linear CMYK to cmyk byte values for Pillow: 159 | cmyk_image[0][x, y] = compand(C) 160 | cmyk_image[1][x, y] = compand(M) 161 | cmyk_image[2][x, y] = compand(Y) 162 | cmyk_image[3][x, y] = compand(K) 163 | 164 | recomposed = Mode.CMYK.merge(*cmyk_channels) 165 | 166 | if revert_mode: 167 | return original_mode.process(recomposed) 168 | return recomposed 169 | 170 | @export 171 | class BasicUCR(Processor): 172 | 173 | __slots__ = ('revert_mode',) 174 | __doc__ = ucr.__doc__ 175 | 176 | def __init__(self, revert_mode=False): 177 | self.revert_mode = revert_mode 178 | 179 | def process(self, image): 180 | return ucr(image, revert_mode=self.revert_mode) 181 | 182 | @export 183 | class DemoUCR(object): 184 | 185 | """ Demonstrate each phase of the UCR color-conversion process """ 186 | 187 | def __init__(self, hex_triple): 188 | """ Initialize with a hex-encoded RGB triple, either as a string 189 | or as a hexadecimal integer, e.g.: 190 | 191 | >>> onedemo = DemoUCR(0xD8DCAB) 192 | >>> another = DemoUCR('#6A9391') 193 | """ 194 | self.hex_triple = hex_triple 195 | 196 | def calculate(self): 197 | self.rgb = self.get_rgb() 198 | self.RGB = self.get_RGB() 199 | self.CMY = self.get_CMY() 200 | self.CMYK = self.get_CMYK() 201 | self.cmyk = self.get_cmyk() 202 | 203 | def get_rgb(self): 204 | """ Return the rgb byte-value (0-255) 3-tuple corresponding to the 205 | initial hex value 206 | """ 207 | return hex2rgb(self.hex_triple) 208 | 209 | def get_RGB(self): 210 | """ Return the linearized (uncompanded) RGB 3-tuple version of the 211 | rgb 3-byte value tuple 212 | """ 213 | return tuple(uncompand(v) for v in self.rgb) 214 | 215 | def get_CMY(self): 216 | """ Return the linearized (uncompanded) CMY color-model analog of the 217 | linear RGB value 3-tuple 218 | """ 219 | (C, M, Y) = (1.0 - V for V in self.RGB) 220 | return (C, M, Y) 221 | 222 | def get_CMYK(self): 223 | """ Return the UCR’ed -- the under-color removed -- linearized CMYK analog 224 | of the linear CMY color-model value tuple 225 | """ 226 | K = min(*self.CMY, 1.0) 227 | (C, M, Y) = self.CMY 228 | 229 | if K == 1: 230 | C = M = Y = 0 231 | else: 232 | denominator = (1 - K) 233 | C = (C - K) / denominator 234 | M = (M - K) / denominator 235 | Y = (Y - K) / denominator 236 | return (C, M, Y, K) 237 | 238 | def get_cmyk(self): 239 | """ Return the companded cmyk byte-value (0-255) 4-tuple Pillow-friendly 240 | CMYK analog of the initial value 241 | """ 242 | return tuple(compand(V) for V in self.CMYK) 243 | 244 | def stringify_demo_values(self): 245 | """ Return each of the values, step-by-step in the conversion process, 246 | admirably formatted in a handsome manner suitable for printing 247 | """ 248 | self.calculate() 249 | return """ 250 | 251 | %(hex_triple)s > rgb%(rgb)s 252 | > RGB%(RGB)s 253 | > CMY%(CMY)s 254 | > CMYK%(CMYK)s 255 | > cmyk%(cmyk)s 256 | 257 | """ % self.__dict__ 258 | 259 | def __str__(self): 260 | """ Stringify yo self """ 261 | return self.stringify_demo_values() 262 | 263 | # Assign the modules’ `__all__` and `__dir__` using the exporter: 264 | __all__, __dir__ = exporter.all_and_dir() 265 | 266 | def test(): 267 | from instakit.utils.static import asset 268 | from itertools import chain 269 | from os.path import relpath 270 | from pprint import pprint 271 | 272 | start = "/usr/local/Cellar/python/3.7.2_2/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages" 273 | 274 | image_paths = list(map(lambda image_file: asset.path('img', image_file), asset.listfiles('img'))) 275 | image_inputs = list(map(lambda image_path: Mode.RGB.open(image_path), image_paths)) 276 | # print("len:", len(list(image_paths)), len(list(image_inputs)), len(list(callables))) 277 | 278 | print('\t<<<<<<<<<<<<<<<------------------------------------------------------>>>>>>>>>>>>>>>') 279 | print() 280 | 281 | functions = (gcr, ucr) 282 | processors = (BasicGCR(), BasicUCR(), Mode.CMYK) 283 | callables = chain((processor.process for processor in processors), functions) 284 | 285 | image_components = zip(image_paths, image_inputs, callables) 286 | 287 | for path, image, process_functor in image_components: 288 | print("«TESTING: %s»" % relpath(path, start=start)) 289 | print() 290 | tup = image.size + (image.mode,) 291 | print("¬ Input: %sx%s %s" % tup) 292 | print("¬ Calling functor on image…") 293 | result = process_functor(image) 294 | tup = result.size + (result.mode,) 295 | print("¬ Output: %sx%s %s" % tup) 296 | print("¬ Displaying…") 297 | print() 298 | result.show() 299 | 300 | print("«¡SUCCESS!»") 301 | print() 302 | 303 | print("«TESTING: MANUAL CALLABLES»") 304 | # print() 305 | 306 | if len(image_inputs): 307 | image = image_inputs.pop() 308 | 309 | # Test GCR function: 310 | gcred = gcr(image) 311 | assert gcred.mode == Mode.CMYK.value.mode 312 | assert Mode.of(gcred) is Mode.CMYK 313 | # gcred.show() 314 | 315 | # close image: 316 | image.close() 317 | 318 | if len(image_inputs): 319 | image = image_inputs.pop() 320 | 321 | # Test UCR function: 322 | ucred = ucr(image) 323 | assert ucred.mode == Mode.CMYK.value.mode 324 | assert Mode.of(ucred) is Mode.CMYK 325 | # ucred.show() 326 | 327 | # close image: 328 | image.close() 329 | 330 | if len(image_inputs): 331 | image = image_inputs.pop() 332 | 333 | # Test GCR processor: 334 | gcr_processor = BasicGCR() 335 | gcred = gcr_processor.process(image) 336 | assert gcred.mode == Mode.CMYK.value.mode 337 | assert Mode.of(gcred) is Mode.CMYK 338 | # gcred.show() 339 | 340 | # close image: 341 | image.close() 342 | 343 | if len(image_inputs): 344 | image = image_inputs.pop() 345 | 346 | # Test UCR processor: 347 | ucr_processor = BasicUCR() 348 | ucred = ucr_processor.process(image) 349 | assert ucred.mode == Mode.CMYK.value.mode 350 | assert Mode.of(ucred) is Mode.CMYK 351 | # ucred.show() 352 | 353 | # close image: 354 | image.close() 355 | 356 | print("«¡SUCCESS!»") 357 | print() 358 | 359 | print('\t<<<<<<<<<<<<<<<------------------------------------------------------>>>>>>>>>>>>>>>') 360 | print() 361 | 362 | pprint(list(relpath(path, start=start) for path in image_paths)) 363 | print() 364 | 365 | print("«TESTING: DemoUCR ALGORITHM-STAGE TRACE PRINTER»") 366 | print() 367 | 368 | print(DemoUCR("#BB2F53")) 369 | print() 370 | 371 | print(DemoUCR(0x6F2039)) 372 | print() 373 | 374 | 375 | if __name__ == '__main__': 376 | test() -------------------------------------------------------------------------------- /instakit/utils/mode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | from __future__ import print_function 4 | 5 | import contextlib 6 | import numpy 7 | import os 8 | 9 | from PIL import Image, ImageMode 10 | from enum import auto, unique 11 | 12 | from clu.constants.consts import DEBUG, ENCODING 13 | from clu.enums import alias, AliasingEnum 14 | from clu.naming import split_abbreviations 15 | from clu.predicates import attr, getpyattr, isclasstype, or_none 16 | from clu.typespace.namespace import Namespace 17 | from clu.typology import string_types 18 | 19 | junkdrawer = Namespace() 20 | junkdrawer.imode = lambda image: ImageMode.getmode(image.mode) 21 | 22 | ImageMode.getmode('RGB') # one call must be made to getmode() 23 | # to properly initialize ImageMode._modes: 24 | 25 | junkdrawer.modes = ImageMode._modes 26 | junkdrawer.types = Image._MODE_CONV 27 | junkdrawer.ismap = Image._MAPMODES 28 | 29 | mode_strings = tuple(junkdrawer.modes.keys()) 30 | dtypes_for_modes = { k : v[0] for k, v in junkdrawer.types.items() } 31 | 32 | junkdrawer.idxmode = lambda idx: ImageMode.getmode(mode_strings[idx]) 33 | junkdrawer.is_mapped = lambda mode: mode in junkdrawer.ismap 34 | 35 | class ModeAncestor(AliasingEnum): 36 | """ 37 | Valid ImageMode mode strings: 38 | ('1', 'L', 'I', 'F', 'P', 39 | 'RGB', 'RGBX', 'RGBA', 'CMYK', 'YCbCr', 40 | 'LAB', 'HSV', 'RGBa', 'LA', 'La', 41 | 'PA', 'I;16', 'I;16L', 'I;16B') 42 | """ 43 | 44 | def _generate_next_value_(name, 45 | start, 46 | count, 47 | last_values): 48 | return junkdrawer.idxmode(count) 49 | 50 | @classmethod 51 | def _missing_(cls, value): 52 | try: 53 | return cls(junkdrawer.idxmode(value)) 54 | except (IndexError, TypeError): 55 | pass 56 | return super(ModeAncestor, cls)._missing_(value) 57 | 58 | @classmethod 59 | def is_mode(cls, instance): 60 | return type(instance) in cls.__mro__ 61 | 62 | class ModeContext(contextlib.AbstractContextManager): 63 | 64 | """ An ad-hoc mutable named-tuple-ish context-manager class, 65 | for keeping track of an image while temporarily converting 66 | it to a specified mode within the managed context block. 67 | 68 | Loosely based on the following Code Review posting: 69 | • https://codereview.stackexchange.com/q/173045/6293 70 | """ 71 | 72 | __slots__ = ('initial_image', 73 | 'image', 74 | 'final_image', 75 | 'original_mode', 76 | 'mode', 77 | 'verbose') 78 | 79 | def __init__(self, image, mode, **kwargs): 80 | assert Image.isImageType(image) 81 | assert Mode.is_mode(mode) 82 | if DEBUG: 83 | label = or_none(image, 'filename') \ 84 | and os.path.basename(getattr(image, 'filename')) \ 85 | or str(image) 86 | print(f"ModeContext.__init__: configured with image: {label}") 87 | self.initial_image = image 88 | self.image = None 89 | self.final_image = None 90 | self.original_mode = Mode.of(image) 91 | self.mode = mode 92 | 93 | def __repr__(self): 94 | return type(self).__name__ + repr(tuple(self)) 95 | 96 | def __iter__(self): 97 | for name in self.__slots__: 98 | yield getattr(self, name) 99 | 100 | def __getitem__(self, idx): 101 | return getattr(self, self.__slots__[idx]) 102 | 103 | def __len__(self): 104 | return len(self.__slots__) 105 | 106 | def attr_or_none(self, name): 107 | return or_none(self, name) 108 | 109 | def attr_set(self, name, value): 110 | setattr(self, name, value) 111 | 112 | def __enter__(self): 113 | initial_image = self.attr_or_none('initial_image') 114 | mode = self.attr_or_none('mode') 115 | if initial_image is not None and mode is not None: 116 | if DEBUG: 117 | initial_mode = Mode.of(initial_image) 118 | print(f"ModeContext.__enter__: converting {initial_mode} to {mode}") 119 | image = mode.process(initial_image) 120 | self.attr_set('image', image) 121 | return self 122 | 123 | def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): 124 | image = self.attr_or_none('image') 125 | original_mode = self.attr_or_none('original_mode') 126 | if image is not None and original_mode is not None: 127 | if DEBUG: 128 | mode = Mode.of(image) 129 | print(f"ModeContext.__exit__: converting {mode} to {original_mode}") 130 | final_image = original_mode.process(image) 131 | self.attr_set('final_image', final_image) 132 | return exc_type is None 133 | 134 | @unique 135 | class Field(AliasingEnum): 136 | 137 | RO = auto() 138 | WO = auto() 139 | RW = auto() 140 | ReadOnly = alias(RO) 141 | WriteOnly = alias(WO) 142 | ReadWrite = alias(RW) 143 | 144 | class FieldIOError(IOError): 145 | pass 146 | 147 | anno_for = lambda cls, name, default=None: getpyattr(cls, 'annotations', default={}).get(name, default) 148 | 149 | class ModeField(object): 150 | 151 | """ Not *that* ModeDescriptor. THIS ModeDescriptor! """ 152 | __slots__ = ('default', 'value', 'name', 'io') 153 | 154 | def __init__(self, default): 155 | self.default = default 156 | 157 | def __set_name__(self, cls, name): 158 | if name is not None: 159 | self.name = name 160 | self.value = None 161 | self.io = anno_for(cls, name, Field.RW) 162 | 163 | def __get__(self, instance=None, cls=None): 164 | if instance is not None: 165 | if self.io is Field.WO: 166 | raise FieldIOError(f"can’t access write-only field {self.name}") 167 | if isclasstype(cls): 168 | return self.get() 169 | 170 | def __set__(self, instance, value): 171 | if self.io is Field.RO: 172 | if value != self.value: 173 | FieldIOError(f"can’t set read-only field {self.name}") 174 | self.set(value) 175 | 176 | def value_from_instance(self, instance): 177 | pass 178 | 179 | def get(self): 180 | return attr(self, 'value', 'default') 181 | 182 | def set(self, value): 183 | if value is None: 184 | self.value = value 185 | return 186 | if type(value) in string_types: 187 | value = Mode.for_string(value) 188 | if Mode.is_mode(value): 189 | if value is not self.default: 190 | self.value = value 191 | return 192 | else: 193 | raise TypeError("can’t set invalid mode: %s (%s)" % (type(value), value)) 194 | 195 | 196 | @unique 197 | class Mode(ModeAncestor): 198 | 199 | """ An enumeration class wrapping ImageMode.ModeDescriptor. """ 200 | 201 | # N.B. this'll have to be manually updated, 202 | # whenever PIL.ImageMode gets a change pushed. 203 | 204 | MONO = auto() # formerly ‘1’ 205 | L = auto() 206 | I = auto() 207 | F = auto() 208 | P = auto() 209 | 210 | RGB = auto() 211 | RGBX = auto() 212 | RGBA = auto() 213 | CMYK = auto() 214 | YCbCr = auto() 215 | 216 | LAB = auto() 217 | HSV = auto() 218 | RGBa = auto() 219 | LA = auto() 220 | La = auto() 221 | 222 | PA = auto() 223 | I16 = auto() # formerly ‘I;16’ 224 | I16L = auto() # formerly ‘I;16L’ 225 | I16B = auto() # formerly ‘I;16B’ 226 | 227 | @classmethod 228 | def of(cls, image): 229 | for mode in cls: 230 | if mode.check(image): 231 | return mode 232 | raise ValueError(f"Image has unknown mode {image.mode}") 233 | 234 | @classmethod 235 | def for_string(cls, string): 236 | for mode in cls: 237 | if mode.to_string() == string: 238 | return mode 239 | raise ValueError(f"for_string(): unknown mode {string}") 240 | 241 | def to_string(self): 242 | return str(self.value) 243 | 244 | def __str__(self): 245 | return self.to_string() 246 | 247 | def __repr__(self): 248 | repr_string = "%s(%s: [%s/%s] ∞ {%s » %s}) @ %s" 249 | return repr_string % (type(self).__qualname__, 250 | self.label, 251 | self.basemode, self.basetype, 252 | self.dtype_code(), self.dtype, 253 | id(self)) 254 | 255 | def __bytes__(self): 256 | return bytes(self.to_string(), encoding=ENCODING) 257 | 258 | def ctx(self, image, **kwargs): 259 | return ModeContext(image, self, **kwargs) 260 | 261 | def dtype_code(self): 262 | return dtypes_for_modes.get(self.to_string(), None) or \ 263 | self.basetype.dtype_code() 264 | 265 | @property 266 | def band_count(self): 267 | return len(self.value.bands) 268 | 269 | @property 270 | def bands(self): 271 | return self.value.bands 272 | 273 | @property 274 | def basemode(self): 275 | return type(self).for_string(self.value.basemode) 276 | 277 | @property 278 | def basetype(self): 279 | return type(self).for_string(self.value.basetype) 280 | 281 | @property 282 | def dtype(self): 283 | return numpy.dtype(self.dtype_code()) 284 | 285 | @property 286 | def is_memory_mapped(self): 287 | return junkdrawer.is_mapped(self.to_string()) 288 | 289 | @property 290 | def label(self): 291 | return str(self) == self.name \ 292 | and self.name \ 293 | or f"{self!s} ({self.name})" 294 | 295 | def check(self, image): 296 | return junkdrawer.imode(image) is self.value 297 | 298 | def merge(self, *channels): 299 | return Image.merge(self.to_string(), channels) 300 | 301 | def render(self, image, *args, **kwargs): 302 | if self.check(image): 303 | return image 304 | return image.convert(self.to_string(), 305 | *args, 306 | **kwargs) 307 | 308 | def process(self, image): 309 | return self.render(image) 310 | 311 | def new(self, size, color=0): 312 | return Image.new(self.to_string(), size, color=color) 313 | 314 | def open(self, fileish): 315 | return self.render(Image.open(fileish)) 316 | 317 | def frombytes(self, size, data, decoder_name='raw', *args): 318 | return Image.frombytes(self.to_string(), 319 | size, data, decoder_name, 320 | *args) 321 | 322 | def test(): 323 | from pprint import pprint 324 | 325 | print("«KNOWN IMAGE MODES»") 326 | print() 327 | 328 | for m in Mode: 329 | print("• %10s\t ∞%5s/%s : %s » %s" % (m.label, 330 | m.basemode, 331 | m.basetype, 332 | m.dtype_code(), 333 | m.dtype)) 334 | 335 | print() 336 | 337 | """ 338 | • 1 (MONO) ∞ L/L : |b1 » bool 339 | • L ∞ L/L : |u1 » uint8 340 | • I ∞ L/I : u2 » >u2 357 | 358 | """ 359 | 360 | print("«TESTING: split_abbreviations()»") 361 | 362 | assert split_abbreviations('RGB') == ('R', 'G', 'B') 363 | assert split_abbreviations('CMYK') == ('C', 'M', 'Y', 'K') 364 | assert split_abbreviations('YCbCr') == ('Y', 'Cb', 'Cr') 365 | assert split_abbreviations('sRGB') == ('R', 'G', 'B') 366 | assert split_abbreviations('XYZZ') == ('X', 'Y', 'Z') 367 | assert split_abbreviations('I;16L') == ('I',) 368 | 369 | assert split_abbreviations('RGB') == Mode.RGB.bands 370 | assert split_abbreviations('CMYK') == Mode.CMYK.bands 371 | assert split_abbreviations('YCbCr') == Mode.YCbCr.bands 372 | assert split_abbreviations('I;16L') == Mode.I16L.bands 373 | assert split_abbreviations('sRGB') == Mode.RGB.bands 374 | # assert split_abbreviations('XYZ') == ('X', 'Y', 'Z') 375 | 376 | print("«SUCCESS»") 377 | print() 378 | 379 | # print(Mode.I16L.bands) 380 | # print(Mode.RGB.bands) 381 | 382 | pprint(list(Mode)) 383 | print() 384 | 385 | assert Mode(10) == Mode.LAB 386 | # assert hasattr(Mode.RGB, '__slots__') 387 | 388 | print() 389 | 390 | print("«TESTING: CONTEXT-MANAGED IMAGE MODES»") 391 | print() 392 | from instakit.utils.static import asset 393 | 394 | image_paths = list(map( 395 | lambda image_file: asset.path('img', image_file), 396 | asset.listfiles('img'))) 397 | image_inputs = list(map( 398 | lambda image_path: Mode.RGB.open(image_path), 399 | image_paths)) 400 | 401 | for image in image_inputs: 402 | with Mode.L.ctx(image) as grayscale: 403 | assert Mode.of(grayscale.image) is Mode.L 404 | print(grayscale.image) 405 | grayscale.image = Mode.MONO.process(grayscale.image) 406 | print() 407 | 408 | print("«SUCCESS»") 409 | print() 410 | 411 | if __name__ == '__main__': 412 | test() 413 | -------------------------------------------------------------------------------- /instakit/utils/ndarrays.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | """ 4 | ndarrays.py 5 | 6 | Created by FI$H 2000 on 2013-08-23. 7 | Copyright © 2012-2019 Objects In Space And Time, LLC. All rights reserved. 8 | 9 | The `bytescale`[0], `fromimage`[1], and `toimage`[2] functions have been 10 | adapted from the versions published in the now-historic `scipy.misc.pilutils` 11 | module; the last official release of which looks to have been in SciPy 1.1.0: 12 | 13 | * [0] https://git.io/fhIoX 14 | * [1] https://git.io/fhIo1 15 | * [2] https://git.io/fhIoD 16 | 17 | """ 18 | from __future__ import division, print_function 19 | 20 | import numpy 21 | from instakit.utils.mode import Mode 22 | from instakit.abc import NDProcessorBase 23 | from instakit.exporting import Exporter 24 | 25 | exporter = Exporter(path=__file__) 26 | export = exporter.decorator() 27 | 28 | uint8_t = numpy.uint8 29 | uint32_t = numpy.uint32 30 | float32_t = numpy.float32 31 | 32 | @export 33 | def bytescale(data, cmin=None, cmax=None, 34 | high=255, low=0): 35 | """ 36 | Byte-scales a `numpy.ndarray` of nd-image data. 37 | 38 | “Byte scaling” means 1) casting the input image to the ``uint8_t`` dtype, and 39 | 2) scaling the range to ``(low, high)`` (default 0-255). 40 | 41 | If the input image data is already of dtype ``uint8_t``, no scaling is done. 42 | 43 | Parameters 44 | ---------- 45 | data : `numpy.ndarray` 46 | PIL image data array. 47 | cmin : scalar, optional 48 | Bias scaling of small values. Default is ``data.min()``. 49 | cmax : scalar, optional 50 | Bias scaling of large values. Default is ``data.max()``. 51 | high : scalar, optional 52 | Scale max value to `high`. Default is 255. 53 | low : scalar, optional 54 | Scale min value to `low`. Default is 0. 55 | 56 | Returns 57 | ------- 58 | array : `numpy.ndarray` of dtype ``uint8_t`` 59 | The byte-scaled array. 60 | """ 61 | if data.dtype == uint8_t: 62 | return data 63 | 64 | if high > 255: 65 | raise ValueError("`high` should be less than or equal to 255.") 66 | if low < 0: 67 | raise ValueError("`low` should be greater than or equal to 0.") 68 | if high < low: 69 | raise ValueError("`high` should be greater than or equal to `low`.") 70 | 71 | if cmin is None: 72 | cmin = data.min() 73 | 74 | if cmax is None: 75 | cmax = data.max() 76 | 77 | cscale = cmax - cmin 78 | 79 | if cscale < 0: 80 | raise ValueError("`cmax` should be larger than `cmin`.") 81 | elif cscale == 0: 82 | cscale = 1 83 | 84 | scale = float(high - low) / cscale 85 | bytedata = (data - cmin) * scale + low 86 | return (bytedata.clip(low, high) + 0.5).astype(uint8_t) 87 | 88 | @export 89 | def fromimage(image, flatten=False, 90 | mode=None, 91 | dtype=None): 92 | """ 93 | Return the data from an input PIL image as a `numpy.ndarray`. 94 | 95 | Parameters 96 | ---------- 97 | im : PIL image 98 | Input image. 99 | flatten : bool, optional 100 | If true, convert the output to greyscale. Default is False. 101 | mode : str / Mode, optional 102 | Mode to convert image to, e.g. ``'RGB'``. See the Notes of the 103 | `imread` docstring for more details. 104 | dtype : str / ``numpy.dtype``, optional 105 | Numpy dtype to which to cast the output image array data, 106 | e.g. ``'float64'`` or ``'uint16'``. 107 | 108 | Returns 109 | ------- 110 | fromimage : ndarray (rank 2..3) 111 | The individual color channels of the input image are stored in the 112 | third dimension, such that greyscale (`L`) images are MxN (rank-2), 113 | `RGB` images are MxNx3 (rank-3), and `RGBA` images are MxNx4 (rank-3). 114 | """ 115 | from PIL import Image 116 | 117 | if not Image.isImageType(image): 118 | raise TypeError(f"Input is not a PIL image (got {image!r})") 119 | 120 | if mode is not None: 121 | if not Mode.is_mode(mode): 122 | mode = Mode.for_string(mode) 123 | image = mode.process(image) 124 | elif Mode.of(image) is Mode.P: 125 | # Mode 'P' means there is an indexed "palette". If we leave the mode 126 | # as 'P', then when we do `a = numpy.array(im)` below, `a` will be a 2D 127 | # containing the indices into the palette, and not a 3D array 128 | # containing the RGB or RGBA values. 129 | if 'transparency' in image.info: 130 | image = Mode.RGBA.process(image) 131 | else: 132 | image = Mode.RGB.process(image) 133 | 134 | if flatten: 135 | image = Mode.F.process(image) 136 | elif Mode.of(image) is Mode.MONO: 137 | # Workaround for crash in PIL. When im is 1-bit, the call numpy.array(im) 138 | # can cause a seg. fault, or generate garbage. See 139 | # https://github.com/scipy/scipy/issues/2138 and 140 | # https://github.com/python-pillow/Pillow/issues/350. 141 | # This converts im from a 1-bit image to an 8-bit image. 142 | image = Mode.L.process(image) 143 | 144 | out = numpy.array(image) 145 | 146 | if dtype is not None: 147 | return out.astype( 148 | numpy.dtype(dtype)) 149 | 150 | return out 151 | 152 | _errstr = "Mode unknown or incompatible with input array shape" 153 | 154 | @export 155 | def toimage(array, high=255, low=0, 156 | cmin=None, cmax=None, 157 | pal=None, 158 | mode=None, 159 | channel_axis=None): 160 | """ 161 | Takes an input `numpy.ndarray` and returns a PIL image. 162 | 163 | The mode of the image returned depends on 1) the array shape, and 164 | 2) the `pal` and `mode` keywords. 165 | 166 | For 2D arrays, if `pal` is a valid (N, 3) rank-2, ``uint8_t`` bytearray -- 167 | populated with an `RGB` LUT of values from 0 to 255, ``mode='P'`` (256-color 168 | single-channel palette mode) will be used; otherwise ``mode='L'`` (256-level 169 | single-channel grayscale mode) will be employed -- unless a “mode” argument 170 | is given, as either 'F' or 'I'; in which case conversion to either a float 171 | or an integer rank-3 array will be made. 172 | 173 | .. warning:: 174 | 175 | This function calls `bytescale` under the hood, to rescale the image 176 | pixel values across the full (0, 255) ``uint8_t`` range if ``mode`` 177 | is one of either: ``None, 'L', 'P', 'l'``. 178 | 179 | It will also cast rank-2 image data to ``uint32_t`` when ``mode=None`` 180 | (which is the default). 181 | 182 | Notes 183 | ----- 184 | For 3D arrays, the `channel_axis` argument tells which dimension of the 185 | array holds the channel data. If one of the dimensions is 3, the mode 186 | is 'RGB' by default, or 'YCbCr' if selected. 187 | 188 | The input `numpy.ndarray` must be either rank-2 or rank-3. 189 | """ 190 | from pprint import pformat 191 | 192 | data = numpy.asarray(array) 193 | if numpy.iscomplexobj(data): 194 | raise ValueError("Cannot convert arrays of complex values") 195 | 196 | shape = list(data.shape) 197 | valid = len(shape) == 2 or ((len(shape) == 3) and 198 | ((3 in shape) or (4 in shape))) 199 | if not valid: 200 | raise ValueError("input array lacks a suitable shape for any mode") 201 | 202 | if mode is not None: 203 | if not Mode.is_mode(mode): 204 | mode = Mode.for_string(mode) 205 | 206 | if len(shape) == 2: 207 | shape = (shape[1], shape[0]) # columns show up first 208 | 209 | if mode is Mode.F: 210 | return mode.frombytes(shape, data.astype(float32_t).tostring()) 211 | 212 | if mode in [ None, Mode.L, Mode.P ]: 213 | bytedata = bytescale(data, high=high, 214 | low=low, 215 | cmin=cmin, 216 | cmax=cmax) 217 | image = Mode.L.frombytes(shape, bytedata.tostring()) 218 | 219 | if pal is not None: 220 | image.putpalette(numpy.asarray(pal, 221 | dtype=uint8_t).tostring()) # Becomes mode='P' automatically 222 | elif mode is Mode.P: # default grayscale 223 | pal = (numpy.arange(0, 256, 1, dtype=uint8_t)[:, numpy.newaxis] * 224 | numpy.ones((3,), dtype=uint8_t)[numpy.newaxis, :]) 225 | image.putpalette(numpy.asarray(pal, 226 | dtype=uint8_t).tostring()) 227 | 228 | return image 229 | 230 | if mode is Mode.MONO: # high input gives threshold for 1 231 | bytedata = (data > high) 232 | return mode.frombytes(shape, bytedata.tostring()) 233 | 234 | if cmin is None: 235 | cmin = numpy.amin(numpy.ravel(data)) 236 | 237 | if cmax is None: 238 | cmax = numpy.amax(numpy.ravel(data)) 239 | 240 | data = (data * 1.0 - cmin) * (high - low) / (cmax - cmin) + low 241 | 242 | if mode is Mode.I: 243 | image = mode.frombytes(shape, data.astype(uint32_t).tostring()) 244 | else: 245 | raise ValueError(_errstr) 246 | 247 | return image 248 | 249 | # if here then 3D array with a 3 or a 4 in the shape length. 250 | # Check for 3 in datacube shape --- 'RGB' or 'YCbCr' 251 | if channel_axis is None: 252 | if (3 in shape): 253 | ca = numpy.flatnonzero(numpy.asarray(shape) == 3)[0] 254 | else: 255 | ca = numpy.flatnonzero(numpy.asarray(shape) == 4) 256 | if len(ca): 257 | ca = ca[0] 258 | else: 259 | raise ValueError( 260 | f"Could not find a channel dimension (shape = {pformat(shape)})") 261 | else: 262 | ca = channel_axis 263 | 264 | numch = shape[ca] 265 | if numch not in [3, 4]: 266 | raise ValueError(f"Channel dimension invalid (#channels = {numch})") 267 | 268 | bytedata = bytescale(data, high=high, 269 | low=low, 270 | cmin=cmin, 271 | cmax=cmax) 272 | 273 | if ca == 2: 274 | strdata = bytedata.tostring() 275 | shape = (shape[1], shape[0]) 276 | elif ca == 1: 277 | strdata = numpy.transpose(bytedata, (0, 2, 1)).tostring() 278 | shape = (shape[2], shape[0]) 279 | elif ca == 0: 280 | strdata = numpy.transpose(bytedata, (1, 2, 0)).tostring() 281 | shape = (shape[2], shape[1]) 282 | 283 | if mode is None: 284 | if numch == 3: 285 | mode = Mode.RGB 286 | else: 287 | mode = Mode.RGBA 288 | 289 | if mode not in [ Mode.RGB, Mode.RGBA, Mode.YCbCr, Mode.CMYK ]: 290 | raise ValueError(_errstr) 291 | 292 | if mode in [ Mode.RGB, Mode.YCbCr ]: 293 | if numch != 3: 294 | raise ValueError(f"Invalid shape for mode “{mode}”: {pformat(shape)}") 295 | if mode in [ Mode.RGBA, Mode.CMYK ]: 296 | if numch != 4: 297 | raise ValueError(f"Invalid shape for mode “{mode}”: {pformat(shape)}") 298 | 299 | # Here we know both `strdata` and `mode` are correct: 300 | image = mode.frombytes(shape, strdata) 301 | return image 302 | 303 | @export 304 | class NDProcessor(NDProcessorBase): 305 | 306 | """ An image processor ancestor class that represents PIL image 307 | data in a `numpy.ndarray`. Subclasses can override the 308 | `process_nd(…)` method to receive, transform, and return 309 | the image data using NumPy, SciPy, and the like. 310 | """ 311 | __slots__ = tuple() 312 | 313 | def process(self, image): 314 | """ NDProcessor.process(…) converts its PIL image operand 315 | to a `numpy.ndarray`, hands it off to the delegate 316 | method NDProcessor.process_nd(…), and converts whatever 317 | that method call returns back to a PIL image before 318 | finally returning it. 319 | """ 320 | return toimage(self.process_nd(fromimage(image))) 321 | 322 | @staticmethod 323 | def compand(ndimage): 324 | """ The NDProcessor.compand(…) static method scales a 325 | `numpy.ndarray` with floating-point values from 0.0»1.0 326 | to unsigned 8-bit integer values from 0»255. 327 | """ 328 | return uint8_t(float32_t(ndimage) * 255.0) 329 | 330 | @staticmethod 331 | def uncompand(ndimage): 332 | """ The NDProcessor.uncompand(…) static method scales a 333 | `numpy.ndarray` with unsigned 8-bit integer values 334 | from 0»255 to floating-point values from 0.0»1.0. 335 | """ 336 | return float32_t(ndimage) / 255.0 337 | 338 | # Assign the modules’ `__all__` and `__dir__` using the exporter: 339 | __all__, __dir__ = exporter.all_and_dir() 340 | 341 | def test(): 342 | """ Tests for bytescale(¬) adapted from `scipy.misc.pilutil` doctests, 343 | q.v. https://git.io/fhkHI supra. 344 | """ 345 | from instakit.utils.static import asset 346 | 347 | image_paths = list(map( 348 | lambda image_file: asset.path('img', image_file), 349 | asset.listfiles('img'))) 350 | image_inputs = list(map( 351 | lambda image_path: Mode.RGB.open(image_path), 352 | image_paths)) 353 | 354 | # print() 355 | print("«TESTING: BYTESCALE UTILITY FUNCTION»") 356 | 357 | image = numpy.array((91.06794177, 3.39058326, 84.4221549, 358 | 73.88003259, 80.91433048, 4.88878881, 359 | 51.53875334, 34.45808177, 27.5873488)).reshape((3, 3)) 360 | assert numpy.all( 361 | bytescale(image) == numpy.array((255, 0, 236, 362 | 205, 225, 4, 363 | 140, 90, 70), 364 | dtype=uint8_t).reshape((3, 3))) 365 | assert numpy.all( 366 | bytescale(image, 367 | high=200, 368 | low=100) == numpy.array((200, 100, 192, 369 | 180, 188, 102, 370 | 155, 135, 128), 371 | dtype=uint8_t).reshape((3, 3))) 372 | assert numpy.all( 373 | bytescale(image, 374 | cmin=0, 375 | cmax=255) == numpy.array((91, 3, 84, 376 | 74, 81, 5, 377 | 52, 34, 28), 378 | dtype=uint8_t).reshape((3, 3))) 379 | print("«SUCCESS»") 380 | print() 381 | 382 | # print() 383 | print("«TESTING: FROMIMAGE»") 384 | 385 | image_arrays = list(map( 386 | lambda image_input: fromimage(image_input), 387 | image_inputs)) 388 | 389 | for idx, image_array in enumerate(image_arrays): 390 | assert image_array.dtype == uint8_t 391 | assert image_array.shape[0] == image_inputs[idx].size[1] 392 | assert image_array.shape[1] == image_inputs[idx].size[0] 393 | 394 | print("«SUCCESS»") 395 | print() 396 | 397 | if __name__ == '__main__': 398 | test() -------------------------------------------------------------------------------- /instakit/abc/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | """ 4 | 5 | d8888 888888b. .d8888b. 6 | o d88888 888 "88b d88P Y88b o 7 | d8b d88P888 888 .88P 888 888 d8b 8 | d888b d88P 888 8888888K. 888 .d8888b d888b 9 | "Y888888888P" d88P 888 888 "Y88b 888 88K "Y888888888P" 10 | "Y88888P" d88P 888 888 888 888 888 "Y8888b. "Y88888P" 11 | d88P"Y88b d8888888888 888 d88P Y88b d88P X88 d88P"Y88b 12 | dP" "Yb d88P 888 8888888P" "Y8888P" 88888P' dP" "Yb 13 | 14 | 15 | Instakit’s Abstract Base Classes – née ABCs – for processors and data structures 16 | 17 | """ 18 | from __future__ import print_function 19 | 20 | from abc import ABC, abstractmethod 21 | from collections import defaultdict as DefaultDict 22 | from enum import Enum as EnumBase, EnumMeta 23 | 24 | from clu.abstract import Slotted 25 | from clu.predicates import (getpyattr, isslotted, 26 | isdictish, 27 | isslotdicty, 28 | slots_for, 29 | predicate_and, 30 | tuplize) 31 | 32 | from instakit.exporting import Exporter 33 | 34 | abstract = abstractmethod 35 | exporter = Exporter(path=__file__) 36 | export = exporter.decorator() 37 | 38 | @export 39 | def is_in_class(atx, cls): 40 | """ Test whether or not a class has a named attribute, 41 | regardless of whether the class uses `__slots__` or 42 | an internal `__dict__`. 43 | """ 44 | if hasattr(cls, '__slots__'): 45 | return atx in cls.__slots__ 46 | elif hasattr(cls, '__dict__'): 47 | return atx in cls.__dict__ 48 | return False 49 | 50 | @export 51 | def subclasshook(cls, subclass): 52 | """ A subclass hook function for both Processor and Enum """ 53 | if any(is_in_class('process', ancestor) for ancestor in subclass.__mro__): 54 | return True 55 | return NotImplemented 56 | 57 | def compare_via_slots(self, other): 58 | """ Compare two slotted objects by checking each available slot 59 | on each instance 60 | """ 61 | if not isslotted(self): 62 | return False 63 | if not isslotted(other): 64 | return False 65 | for slot in slots_for(type(self)): 66 | if getattr(self, slot) != getattr(other, slot): 67 | return False 68 | return True 69 | 70 | def compare_via_dicts(self, other): 71 | """ Compare two objects by comparing the contents of their 72 | internal __dict__ attributes 73 | """ 74 | if not isdictish(self): 75 | return False 76 | if not isdictish(other): 77 | return False 78 | return getpyattr(self, 'dict') == getpyattr(other, 'dict') 79 | 80 | def compare_via_attrs(self, other): 81 | """ Compare two processors: 82 | 1) Return NotImplemented if the types do not exactly match. 83 | 2) For processors using __dict__ mappings for attributes, 84 | compare them directly. 85 | 3) For processors using __slots__ for attributes, 86 | iterate through all ancestor slot names using “slots_for(…)” 87 | and return False if any compare inequal between self and other -- 88 | ultimately returning True. 89 | 4) If the slots/dict situation differs between the two instances, 90 | raise a TypeError. 91 | """ 92 | if type(self) is not type(other): 93 | return NotImplemented 94 | 95 | # If they both have *both* __slots__ and __dicts__, 96 | # delegate to the results of *both* “compare_via_slots(…)” 97 | # and “compare_via_dicts(…)”: 98 | if predicate_and(isslotdicty, self, other): 99 | return compare_via_slots(self, other) and \ 100 | compare_via_dicts(self, other) 101 | 102 | # If they both have __slots__, delegate 103 | # to “compare_via_slots(…)”: 104 | if predicate_and(isslotted, self, other): 105 | return compare_via_slots(self, other) 106 | 107 | # If they both have __dicts__, delegate 108 | # to “compare_via_dicts(…)”: 109 | if predicate_and(isdictish, self, other): 110 | return compare_via_dicts(self, other) 111 | 112 | # Couldn’t match __dict__ and __slots__ attributes, 113 | # raise a TypeError: 114 | raise TypeError("dict/slots mismatch") 115 | 116 | @export 117 | class Processor(ABC, metaclass=Slotted): 118 | 119 | """ Base abstract processor class. """ 120 | 121 | @abstract 122 | def process(self, image): 123 | """ Process an image instance, per the processor instance, 124 | returning the processed image data. 125 | """ 126 | ... 127 | 128 | def __call__(self, image): 129 | return self.process(image) 130 | 131 | @classmethod 132 | def __subclasshook__(cls, subclass): 133 | return subclasshook(cls, subclass) 134 | 135 | def __eq__(self, other): 136 | """ Delegate to “compare_via_attrs(…)” """ 137 | return compare_via_attrs(self, other) 138 | 139 | class SlottedEnumMeta(EnumMeta, metaclass=Slotted): 140 | pass 141 | 142 | @export 143 | class Enum(EnumBase, metaclass=SlottedEnumMeta): 144 | 145 | """ Base abstract processor enum. """ 146 | 147 | @abstract 148 | def process(self, image): 149 | """ Process an image instance, per the processor enum instance, 150 | returning the processed image data. 151 | """ 152 | ... 153 | 154 | @classmethod 155 | def __subclasshook__(cls, subclass): 156 | return subclasshook(cls, subclass) 157 | 158 | @export 159 | class NOOp(Processor): 160 | 161 | """ A no-op processor. """ 162 | 163 | def process(self, image): 164 | """ Return the image instance, unchanged """ 165 | return image 166 | 167 | def __eq__(self, other): 168 | """ Simple type-comparison """ 169 | return type(self) is type(other) 170 | 171 | @export 172 | class Container(Processor): 173 | 174 | """ Base abstract processor container. """ 175 | 176 | @classmethod 177 | @abstract 178 | def base_type(cls): 179 | """ Return the internal type upon which this instakit.abc.Container 180 | subclass is based. 181 | """ 182 | ... 183 | 184 | @abstract 185 | def iterate(self): 186 | """ Return an ordered iterable of sub-processors. """ 187 | ... 188 | 189 | @abstract 190 | def __len__(self): ... 191 | 192 | @abstract 193 | def __contains__(self, value): ... 194 | 195 | @abstract 196 | def __getitem__(self, idx): ... 197 | 198 | def __bool__(self): 199 | """ A processor container is considered Truthy if it contains values, 200 | and Falsey if it is empty. 201 | """ 202 | return len(self) > 0 203 | 204 | def __eq__(self, other): 205 | """ Compare “base_type()” results and item-by-item through “iterate()” """ 206 | if type(self).base_type() is not type(other).base_type(): 207 | return NotImplemented 208 | for self_item, other_item in zip(self.iterate(), 209 | other.iterate()): 210 | if self_item != other_item: 211 | return False 212 | return True 213 | 214 | @export 215 | class Mapping(Container): 216 | 217 | @abstract 218 | def get(self, idx, default_value): ... 219 | 220 | @export 221 | class Sequence(Container): 222 | 223 | @abstract 224 | def index(self, value): ... 225 | 226 | @abstract 227 | def last(self): ... 228 | 229 | @export 230 | class MutableContainer(Container): 231 | 232 | """ Base abstract processor mutable container. """ 233 | 234 | @abstract 235 | def __setitem__(self, idx, value): ... 236 | 237 | @abstract 238 | def __delitem__(self, idx, value): ... 239 | 240 | @export 241 | class MutableMapping(MutableContainer): 242 | 243 | @abstract 244 | def get(self, idx, default_value): ... 245 | 246 | @abstract 247 | def pop(self, idx, default_value): ... 248 | 249 | @abstract 250 | def update(self, iterable=None, **kwargs): ... 251 | 252 | @export 253 | class MutableSequence(MutableContainer): 254 | 255 | @abstract 256 | def index(self, value): ... 257 | 258 | @abstract 259 | def last(self): ... 260 | 261 | @abstract 262 | def append(self, value): ... 263 | 264 | @abstract 265 | def extend(self, iterable): ... 266 | 267 | @abstract 268 | def pop(self, idx=-1): ... 269 | 270 | @export 271 | class Fork(MutableMapping): 272 | 273 | """ Base abstract forking processor. """ 274 | __slots__ = ('dict', '__weakref__') 275 | 276 | @classmethod 277 | def base_type(cls): 278 | return DefaultDict 279 | 280 | def __init__(self, default_factory, *args, **kwargs): 281 | """ The `Fork` ABC implements the same `__init__(¬)` call signature as 282 | its delegate type, `collections.defaultdict`. A “default_factory” 283 | callable argument is required to fill in missing values (although 284 | one can pass None, which will cause a `NOOp` processor to be used). 285 | 286 | From the `collections.defaultdict` docstring: 287 | 288 | “defaultdict(default_factory[, ...]) --> dict with default factory” 289 | 290 | “The default factory is called without arguments to produce 291 | a new value when a key is not present, in __getitem__ only. 292 | A defaultdict compares equal to a dict with the same items. 293 | All remaining arguments are treated the same as if they were 294 | passed to the dict constructor, including keyword arguments.” 295 | 296 | """ 297 | if default_factory in (None, NOOp): 298 | default_factory = NOOp 299 | if not callable(default_factory): 300 | raise AttributeError("Fork() requires a callable default_factory") 301 | 302 | self.dict = type(self).base_type()(default_factory, *args, **kwargs) 303 | super(Fork, self).__init__() 304 | 305 | @property 306 | def default_factory(self): 307 | """ The default factory for the dictionary. """ 308 | return self.dict.default_factory 309 | 310 | @default_factory.setter 311 | def default_factory(self, value): 312 | if not callable(value): 313 | raise AttributeError("Fork.default_factory requires a callable value") 314 | self.dict.default_factory = value 315 | 316 | def __len__(self): 317 | """ The number of entries in the dictionary. 318 | See defaultdict.__len__(…) for details. 319 | """ 320 | return len(self.dict) 321 | 322 | def __contains__(self, idx): 323 | """ True if the dictionary has the specified `idx`, else False. 324 | See defaultdict.__contains__(…) for details. 325 | """ 326 | return idx in self.dict 327 | 328 | def __getitem__(self, idx): 329 | """ Get a value from the dictionary, or if no value is present, 330 | the return value of `default_factory()`. 331 | See defaultdict.__getitem__(…) for details. 332 | """ 333 | return self.dict[idx] 334 | 335 | def __setitem__(self, idx, value): 336 | """ Set the value in the dictionary corresponding to the specified 337 | `idx` to the value passed, or if a value of “None” was passed, 338 | set the value to `instakit.abc.NOOp()` -- the no-op processor. 339 | """ 340 | if value in (None, NOOp): 341 | value = NOOp() 342 | self.dict[idx] = value 343 | 344 | def __delitem__(self, idx): 345 | """ Delete a value from the dictionary corresponding to the specified 346 | `idx`, if one is present. 347 | See defaultdict.__delitem__(…) for details. 348 | """ 349 | del self.dict[idx] 350 | 351 | def get(self, idx, default_value=None): 352 | """ Get a value from the dictionary, with an optional default 353 | value to use should a value not be present for this `idx`. 354 | See defaultdict.get(…) for details. 355 | """ 356 | return self.dict.get(idx, default_value) 357 | 358 | def pop(self, idx, default_value=None): 359 | """ D.pop(idx[,d]) -> v, remove specified `idx` and return the corresponding value. 360 | If `idx` is not found, d is returned if given, otherwise `KeyError` is raised. 361 | See defaultdict.pop(…) for details. 362 | """ 363 | return self.dict.pop(idx, default_value) 364 | 365 | def update(self, iterable=None, **kwargs): 366 | """ Update the dictionary with new key-value pairs. 367 | See defaultdict.update(…) for details. 368 | """ 369 | self.dict.update(iterable or tuple(), **kwargs) 370 | 371 | @abstract 372 | def split(self, image): ... 373 | 374 | @abstract 375 | def compose(self, *bands): ... 376 | 377 | class ThresholdProcessor(Processor): 378 | 379 | """ Abstract base class for a processor using a uint8_t threshold matrix """ 380 | # This is used in instakit.processors.halftone 381 | __slots__ = tuplize('threshold_matrix') 382 | 383 | LO_TUP = tuplize(0) 384 | HI_TUP = tuplize(255) 385 | 386 | def __init__(self, threshold = 128.0): 387 | """ Initialize with a threshold value between 0 and 255 """ 388 | self.threshold_matrix = int(threshold) * self.LO_TUP + \ 389 | (256-int(threshold)) * self.HI_TUP 390 | 391 | @export 392 | class NDProcessorBase(Processor): 393 | 394 | """ An image processor ancestor class that represents PIL image 395 | data in a `numpy.ndarray`. This is the base abstract class, 396 | specifying necessary methods for subclasses to override. 397 | 398 | Note that “process(…)” has NOT been implemented yet in the 399 | inheritance chain – a subclass will need to furnish it. 400 | """ 401 | 402 | @abstract 403 | def process_nd(self, ndimage): 404 | """ Override NDProcessor.process_nd(…) in subclasses 405 | to provide functionality that acts on image data stored 406 | in a `numpy.ndarray`. 407 | """ 408 | ... 409 | 410 | @staticmethod 411 | @abstract 412 | def compand(ndimage): ... 413 | 414 | @staticmethod 415 | @abstract 416 | def uncompand(ndimage): ... 417 | 418 | export(abstract) 419 | 420 | # Assign the modules’ `__all__` and `__dir__` using the exporter: 421 | __all__, __dir__ = exporter.all_and_dir() 422 | 423 | def test(): 424 | """ Inline tests for instakit.abc module """ 425 | import os 426 | if os.environ.get('TM_PYTHON'): 427 | import sys 428 | def print_red(text): 429 | print(text, file=sys.stderr) 430 | else: 431 | import colorama, termcolor 432 | colorama.init() 433 | def print_red(text): 434 | print(termcolor.colored(text, color='red')) 435 | 436 | import __main__ 437 | print_red(__main__.__doc__) 438 | 439 | class SlowAtkinson(ThresholdProcessor): 440 | def process(self, image): 441 | from instakit.utils.mode import Mode 442 | image = Mode.L.process(image) 443 | for y in range(image.size[1]): 444 | for x in range(image.size[0]): 445 | old = image.getpixel((x, y)) 446 | new = self.threshold_matrix[old] 447 | err = (old - new) >> 3 # divide by 8. 448 | image.putpixel((x, y), new) 449 | for nxy in [(x+1, y), 450 | (x+2, y), 451 | (x-1, y+1), 452 | (x, y+1), 453 | (x+1, y+1), 454 | (x, y+2)]: 455 | try: 456 | image.putpixel(nxy, int( 457 | image.getpixel(nxy) + err)) 458 | except IndexError: 459 | pass 460 | return image 461 | 462 | from pprint import pprint 463 | slow_atkinson = SlowAtkinson() 464 | pprint(slow_atkinson) 465 | print("DICT?", hasattr(slow_atkinson, '__dict__')) 466 | print("SLOTS?", hasattr(slow_atkinson, '__slots__')) 467 | pprint(slow_atkinson.__slots__) 468 | pprint(slow_atkinson.__class__.__base__.__slots__) 469 | pprint(slots_for(SlowAtkinson)) 470 | print("THRESHOLD_MATRIX:", slow_atkinson.threshold_matrix) 471 | assert slow_atkinson == SlowAtkinson() 472 | assert NOOp() == NOOp() 473 | 474 | if __name__ == '__main__': 475 | test() 476 | -------------------------------------------------------------------------------- /instakit/utils/pipeline.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | from __future__ import print_function 3 | 4 | from PIL import ImageOps, ImageChops 5 | from collections import defaultdict, OrderedDict 6 | from copy import copy 7 | from enum import unique 8 | from functools import wraps 9 | 10 | from clu.enums import AliasingEnum, alias 11 | from clu.mathematics import Σ 12 | from clu.predicates import tuplize 13 | from clu.typology import string_types 14 | 15 | from instakit.abc import Fork, NOOp, Sequence, MutableSequence 16 | from instakit.utils.gcr import BasicGCR 17 | from instakit.utils.mode import Mode 18 | from instakit.processors.adjust import AutoContrast 19 | from instakit.exporting import Exporter 20 | 21 | exporter = Exporter(path=__file__) 22 | export = exporter.decorator() 23 | 24 | if not hasattr(__builtins__, 'cmp'): 25 | def cmp(a, b): 26 | return (a > b) - (a < b) 27 | 28 | @export 29 | class Pipe(Sequence): 30 | 31 | """ A static linear pipeline of processors to be applied en masse. 32 | Derived from a `pilkit` class: 33 | `pilkit.processors.base.ProcessorPipeline` 34 | """ 35 | __slots__ = tuplize('tuple') 36 | 37 | @classmethod 38 | def base_type(cls): 39 | return tuple 40 | 41 | @wraps(tuple.__init__) 42 | def __init__(self, *args): 43 | self.tuple = tuplize(*args) 44 | 45 | def iterate(self): 46 | yield from self.tuple 47 | 48 | @wraps(tuple.__len__) 49 | def __len__(self): 50 | return len(self.tuple) 51 | 52 | @wraps(tuple.__contains__) 53 | def __contains__(self, value): 54 | return value in self.tuple 55 | 56 | @wraps(tuple.__getitem__) 57 | def __getitem__(self, idx): 58 | return self.tuple[idx] 59 | 60 | @wraps(tuple.index) 61 | def index(self, value): 62 | return self.tuple.index(value) 63 | 64 | def last(self): 65 | if not bool(self): 66 | raise IndexError("pipe is empty") 67 | return self.tuple[-1] 68 | 69 | def process(self, image): 70 | for processor in self.iterate(): 71 | image = processor.process(image) 72 | return image 73 | 74 | def __eq__(self, other): 75 | if not isinstance(other, (type(self), type(self).base_type())): 76 | return NotImplemented 77 | return super(Pipe, self).__eq__(other) 78 | 79 | @export 80 | class Pipeline(MutableSequence): 81 | 82 | """ A mutable linear pipeline of processors to be applied en masse. 83 | Derived from a `pilkit` class: 84 | `pilkit.processors.base.ProcessorPipeline` 85 | """ 86 | __slots__ = tuplize('list') 87 | 88 | @classmethod 89 | def base_type(cls): 90 | return list 91 | 92 | @wraps(list.__init__) 93 | def __init__(self, *args): 94 | base_type = type(self).base_type() 95 | if len(args) == 0: 96 | self.list = base_type() 97 | if len(args) == 1: 98 | target = args[0] 99 | if type(target) is type(self): 100 | self.list = copy(target.list) 101 | elif type(target) is base_type: 102 | self.list = copy(target) 103 | elif type(target) in (tuple, set, frozenset): 104 | self.list = base_type([*target]) 105 | elif type(target) in (dict, defaultdict, OrderedDict): 106 | self.list = base_type([*sorted(target).values()]) 107 | elif hasattr(target, 'iterate'): 108 | self.list = base_type([*target.iterate()]) 109 | elif hasattr(target, '__iter__'): 110 | self.list = base_type([*target]) 111 | else: 112 | self.list = base_type([target]) 113 | else: 114 | self.list = base_type([*args]) 115 | 116 | def iterate(self): 117 | yield from self.list 118 | 119 | @wraps(list.__len__) 120 | def __len__(self): 121 | return len(self.list) 122 | 123 | @wraps(list.__contains__) 124 | def __contains__(self, value): 125 | return value in self.list 126 | 127 | @wraps(list.__getitem__) 128 | def __getitem__(self, idx): 129 | return self.list[idx] 130 | 131 | @wraps(list.__setitem__) 132 | def __setitem__(self, idx, value): 133 | if value in (None, NOOp): 134 | value = NOOp() 135 | self.list[idx] = value 136 | 137 | @wraps(list.__delitem__) 138 | def __delitem__(self, idx): 139 | del self.list[idx] 140 | 141 | @wraps(list.index) 142 | def index(self, value): 143 | return self.list.index(value) 144 | 145 | @wraps(list.append) 146 | def append(self, value): 147 | self.list.append(value) 148 | 149 | @wraps(list.extend) 150 | def extend(self, iterable): 151 | self.list.extend(iterable) 152 | 153 | def pop(self, idx=-1): 154 | """ Remove and return item at `idx` (default last). 155 | Raises IndexError if list is empty or `idx` is out of range. 156 | See list.pop(…) for details. 157 | """ 158 | self.list.pop(idx) 159 | 160 | def last(self): 161 | if not bool(self): 162 | raise IndexError("pipe is empty") 163 | return self.list[-1] 164 | 165 | def process(self, image): 166 | for processor in self.iterate(): 167 | image = processor.process(image) 168 | return image 169 | 170 | def __eq__(self, other): 171 | if not isinstance(other, (type(self), type(self).base_type())): 172 | return NotImplemented 173 | return super(Pipe, self).__eq__(other) 174 | 175 | @export 176 | class BandFork(Fork): 177 | 178 | """ BandFork is a processor container -- a processor that applies other 179 | processors. BandFork acts selectively on the individual bands of 180 | input image data, either: 181 | - applying a band-specific processor instance, or 182 | - applying a default processor factory successively across all bands. 183 | 184 | BandFork’s interface is closely aligned with Python’s mutable-mapping 185 | API‡ -- with which most programmers are no doubt quite familiar: 186 | 187 | • Ex. 1: apply Atkinson dithering to each of an RGB images’ bands: 188 | >>> from instakit.utils.pipeline import BandFork 189 | >>> from instakit.processors.halftone import Atkinson 190 | >>> BandFork(Atkinson).process(my_image) 191 | 192 | • Ex. 2: apply Atkinson dithering to only the green band: 193 | >>> from instakit.utils.pipeline import BandFork 194 | >>> from instakit.processors.halftone import Atkinson 195 | >>> bfork = BandFork(None) 196 | >>> bfork['G'] = Atkinson() 197 | >>> bfork.process(my_image) 198 | 199 | BandFork inherits from `instakit.abc.Fork`, which itself is not just 200 | an Instakit Processor. The Fork ABC implements the required methods 201 | of an Instakit Processor Container†, through which it furnishes an 202 | interface to individual bands -- also generally known as channels, 203 | per the language of the relevant Photoshop UI elements -- of image 204 | data. 205 | 206 | † q.v. the `instakit.abc` module source code supra. 207 | ‡ q.v. the `collections.abc` module, and the `MutableMapping` 208 | abstract base class within, supra. 209 | """ 210 | __slots__ = tuplize('mode_t') 211 | 212 | def __init__(self, processor_factory, *args, **kwargs): 213 | """ Initialize a BandFork instance, using the given callable value 214 | for `processor_factory` and any band-appropriate keyword-arguments, 215 | e.g. `(R=MyProcessor, G=MyOtherProcessor, B=None)` 216 | """ 217 | # Call `super(…)`, passing `processor_factory`: 218 | super(BandFork, self).__init__(processor_factory, *args, **kwargs) 219 | 220 | # Reset `self.mode_t` if a new mode was specified -- 221 | # N.B. we can’t use the “self.mode” property during “__init__(…)”: 222 | self.mode_t = kwargs.pop('mode', Mode.RGB) 223 | 224 | @property 225 | def mode(self): 226 | return self.mode_t 227 | 228 | @mode.setter 229 | def mode(self, value): 230 | if value is None: 231 | return 232 | if type(value) in string_types: 233 | value = Mode.for_string(value) 234 | if Mode.is_mode(value): 235 | # if value is not self.mode_t: 236 | self.set_mode_t(value) 237 | else: 238 | raise TypeError("invalid mode type: %s (%s)" % (type(value), value)) 239 | 240 | def set_mode_t(self, value): 241 | self.mode_t = value # DOUBLE SHADOW!! 242 | 243 | @property 244 | def band_labels(self): 245 | return self.mode.bands 246 | 247 | def iterate(self): 248 | yield from (self[band_label] for band_label in self.band_labels) 249 | 250 | def split(self, image): 251 | return self.mode.process(image).split() 252 | 253 | def compose(self, *bands): 254 | return self.mode.merge(*bands) 255 | 256 | def process(self, image): 257 | processed = [] 258 | for processor, band in zip(self.iterate(), 259 | self.split(image)): 260 | processed.append(processor.process(band)) 261 | return self.compose(*processed) 262 | 263 | ChannelFork = BandFork 264 | 265 | ink_values = ( 266 | (255, 255, 255), # White 267 | (0, 250, 250), # Cyan 268 | (250, 0, 250), # Magenta 269 | (250, 250, 0), # Yellow 270 | (0, 0, 0), # Key (blacK) 271 | (255, 0, 0), # Red 272 | (0, 255, 0), # Green 273 | (0, 0, 255), # Blue 274 | ) 275 | 276 | class Ink(AliasingEnum): 277 | 278 | def rgb(self): 279 | return ink_values[self.value] 280 | 281 | def process(self, image): 282 | InkType = type(self) 283 | return ImageOps.colorize(Mode.L.process(image), 284 | InkType(0).rgb(), 285 | InkType(self.value).rgb()) 286 | 287 | @unique 288 | class CMYKInk(Ink): 289 | 290 | WHITE = 0 291 | CYAN = 1 292 | MAGENTA = 2 293 | YELLOW = 3 294 | KEY = 4 295 | BLACK = alias(KEY) 296 | 297 | @classmethod 298 | def CMYK(cls): 299 | return (cls.CYAN, cls.MAGENTA, cls.YELLOW, cls.BLACK) 300 | 301 | @classmethod 302 | def CMY(cls): 303 | return (cls.CYAN, cls.MAGENTA, cls.YELLOW) 304 | 305 | @unique 306 | class RGBInk(Ink): 307 | 308 | WHITE = 0 309 | RED = 5 310 | GREEN = 6 311 | BLUE = 7 312 | KEY = 4 313 | BLACK = alias(KEY) 314 | 315 | @classmethod 316 | def RGB(cls): 317 | return (cls.RED, cls.GREEN, cls.BLUE) 318 | 319 | @classmethod 320 | def BGR(cls): 321 | return (cls.BLUE, cls.GREEN, cls.RED) 322 | 323 | @export 324 | class OverprintFork(BandFork): 325 | 326 | """ A BandFork subclass that rebuilds its output image using multiply-mode 327 | to simulate CMYK overprinting effects. 328 | 329 | N.B. While this Fork-based processor operates strictly in CMYK mode, 330 | the composite image it eventually returns will be in RGB mode. This is 331 | because the CMYK channels are each individually converted to colorized 332 | representations in order to simulate monotone ink preparations; the 333 | final compositing operation, in which these colorized channel separation 334 | images are combined with multiply-mode, is also computed using the RGB 335 | color model -- q.v. the CMYKInk enum processor supra. and the related 336 | PIL/Pillow module function `ImageOps.colorize(…)` supra. 337 | """ 338 | __slots__ = ('contrast', 'basicgcr') 339 | 340 | inks = CMYKInk.CMYK() 341 | 342 | def __init__(self, processor_factory, gcr=20, *args, **kwargs): 343 | """ Initialize an OverprintFork instance with the given callable value 344 | for `processor_factory` and any band-appropriate keyword-arguments, 345 | e.g. `(C=MyProcessor, M=MyOtherProcessor, Y=MyProcessor, K=None)` 346 | """ 347 | # Store BasicGCR and AutoContrast processors: 348 | self.contrast = AutoContrast() 349 | self.basicgcr = BasicGCR(percentage=gcr) 350 | 351 | # Call `super(…)`, passing `processor_factory`: 352 | super(OverprintFork, self).__init__(processor_factory, *args, mode=Mode.CMYK, 353 | **kwargs) 354 | 355 | # Make each band-processor a Pipeline() ending in 356 | # the channel-appropriate CMYKInk enum processor: 357 | self.apply_CMYK_inks() 358 | 359 | def apply_CMYK_inks(self): 360 | """ This method ensures that each bands’ processor is set up 361 | as a Pipe() or Pipeline() ending in a CMYKInk corresponding 362 | to the band in question. Calling it multiple times *should* 363 | be idempotent (but don’t quote me on that) 364 | """ 365 | for band_label, ink in zip(self.band_labels, 366 | type(self).inks): 367 | processor = self[band_label] 368 | if processor is None: 369 | self[band_label] = Pipe(ink) 370 | elif hasattr(processor, 'append'): 371 | if processor[-1] is not ink: 372 | processor.append(ink) 373 | self[band_label] = processor 374 | elif hasattr(processor, 'last'): 375 | if processor.last() is not ink: 376 | self[band_label] = Pipe(*processor.iterate(), ink) 377 | else: 378 | self[band_label] = Pipe(processor, ink) 379 | 380 | def set_mode_t(self, value): 381 | """ Raise an exception if an attempt is made to set the mode to anything 382 | other than CMYK 383 | """ 384 | if value is not Mode.CMYK: 385 | raise AttributeError( 386 | "OverprintFork only works in %s mode" % Mode.CMYK.to_string()) 387 | 388 | def update(self, iterable=None, **kwargs): 389 | """ OverprintFork.update(…) re-applies CMYK ink processors to the 390 | updated processing dataflow 391 | """ 392 | super(OverprintFork, self).update(iterable, **kwargs) 393 | self.apply_CMYK_inks() 394 | 395 | def split(self, image): 396 | """ OverprintFork.split(image) uses imagekit.utils.gcr.BasicGCR(…) to perform 397 | gray-component replacement in CMYK-mode images; for more information, 398 | see the imagekit.utils.gcr module 399 | """ 400 | return self.basicgcr.process(image).split() 401 | 402 | def compose(self, *bands): 403 | """ OverprintFork.compose(…) uses PIL.ImageChops.multiply() to create 404 | the final composite image output 405 | """ 406 | return Σ(ImageChops.multiply, bands) 407 | 408 | class Grid(Fork): 409 | pass 410 | 411 | class Sequence(Fork): 412 | pass 413 | 414 | ChannelOverprinter = OverprintFork 415 | 416 | export(ChannelFork, name='ChannelFork') 417 | export(ChannelOverprinter, name='ChannelOverprinter') 418 | export(CMYKInk, name='CMYKInk', doc="CMYKInk → Enumeration class furnishing CMYK primitive triple values") 419 | export(RGBInk, name='RGBInk', doc="RGBInk → Enumeration class furnishing RGB primitive triple values") 420 | 421 | # Assign the modules’ `__all__` and `__dir__` using the exporter: 422 | __all__, __dir__ = exporter.all_and_dir() 423 | 424 | def test(): 425 | from pprint import pprint 426 | from instakit.utils.static import asset 427 | from instakit.processors.halftone import Atkinson 428 | 429 | image_paths = list(map( 430 | lambda image_file: asset.path('img', image_file), 431 | asset.listfiles('img'))) 432 | image_inputs = list(map( 433 | lambda image_path: Mode.RGB.open(image_path), 434 | image_paths)) 435 | 436 | for image_input in image_inputs[:2]: 437 | OverprintFork(Atkinson).process(image_input).show() 438 | 439 | print('Creating OverprintFork and BandFork with Atkinson ditherer...') 440 | overatkins = OverprintFork(Atkinson) 441 | forkatkins = BandFork(Atkinson) 442 | 443 | print('Processing image with BandForked Atkinson in default (RGB) mode...') 444 | forkatkins.process(image_input).show() 445 | forkatkins.mode = 'CMYK' 446 | print('Processing image with BandForked Atkinson in CMYK mode...') 447 | forkatkins.process(image_input).show() 448 | forkatkins.mode = 'RGB' 449 | print('Processing image with BandForked Atkinson in RGB mode...') 450 | forkatkins.process(image_input).show() 451 | 452 | overatkins.mode = 'CMYK' 453 | print('Processing image with OverprintFork-ized Atkinson in CMYK mode...') 454 | overatkins.process(image_input).show() 455 | 456 | print('Attempting to reset OverprintFork to RGB mode...') 457 | import traceback, sys 458 | try: 459 | overatkins.mode = 'RGB' 460 | overatkins.process(image_input).show() 461 | except: 462 | print(">>>>>>>>>>>>>>>>>>>>> TRACEBACK <<<<<<<<<<<<<<<<<<<<<") 463 | traceback.print_exc(file=sys.stdout) 464 | print("<<<<<<<<<<<<<<<<<<<<< KCABECART >>>>>>>>>>>>>>>>>>>>>") 465 | print('') 466 | 467 | bandfork = BandFork(None) 468 | pprint(bandfork) 469 | 470 | print(image_paths) 471 | 472 | if __name__ == '__main__': 473 | test() 474 | --------------------------------------------------------------------------------