imgrit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- imgrit-0.1.0/.gitignore +194 -0
- imgrit-0.1.0/LICENSE +28 -0
- imgrit-0.1.0/PKG-INFO +9 -0
- imgrit-0.1.0/README.md +2 -0
- imgrit-0.1.0/pyproject.toml +13 -0
- imgrit-0.1.0/src/imgrit/__init__.py +2 -0
- imgrit-0.1.0/src/imgrit/imgrit.py +343 -0
- imgrit-0.1.0/tests/Untitled.ipynb +154 -0
imgrit-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
|
|
110
|
+
# pdm
|
|
111
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
112
|
+
#pdm.lock
|
|
113
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
114
|
+
# in version control.
|
|
115
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
116
|
+
.pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
121
|
+
__pypackages__/
|
|
122
|
+
|
|
123
|
+
# Celery stuff
|
|
124
|
+
celerybeat-schedule
|
|
125
|
+
celerybeat.pid
|
|
126
|
+
|
|
127
|
+
# SageMath parsed files
|
|
128
|
+
*.sage.py
|
|
129
|
+
|
|
130
|
+
# Environments
|
|
131
|
+
.env
|
|
132
|
+
.venv
|
|
133
|
+
env/
|
|
134
|
+
venv/
|
|
135
|
+
ENV/
|
|
136
|
+
env.bak/
|
|
137
|
+
venv.bak/
|
|
138
|
+
|
|
139
|
+
# Spyder project settings
|
|
140
|
+
.spyderproject
|
|
141
|
+
.spyproject
|
|
142
|
+
|
|
143
|
+
# Rope project settings
|
|
144
|
+
.ropeproject
|
|
145
|
+
|
|
146
|
+
# mkdocs documentation
|
|
147
|
+
/site
|
|
148
|
+
|
|
149
|
+
# mypy
|
|
150
|
+
.mypy_cache/
|
|
151
|
+
.dmypy.json
|
|
152
|
+
dmypy.json
|
|
153
|
+
|
|
154
|
+
# Pyre type checker
|
|
155
|
+
.pyre/
|
|
156
|
+
|
|
157
|
+
# pytype static type analyzer
|
|
158
|
+
.pytype/
|
|
159
|
+
|
|
160
|
+
# Cython debug symbols
|
|
161
|
+
cython_debug/
|
|
162
|
+
|
|
163
|
+
# PyCharm
|
|
164
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
165
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
166
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
167
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
168
|
+
#.idea/
|
|
169
|
+
|
|
170
|
+
# Abstra
|
|
171
|
+
# Abstra is an AI-powered process automation framework.
|
|
172
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
173
|
+
# Learn more at https://abstra.io/docs
|
|
174
|
+
.abstra/
|
|
175
|
+
|
|
176
|
+
# Visual Studio Code
|
|
177
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
178
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
179
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
180
|
+
# you could uncomment the following to ignore the enitre vscode folder
|
|
181
|
+
# .vscode/
|
|
182
|
+
|
|
183
|
+
# Ruff stuff:
|
|
184
|
+
.ruff_cache/
|
|
185
|
+
|
|
186
|
+
# PyPI configuration file
|
|
187
|
+
.pypirc
|
|
188
|
+
|
|
189
|
+
# Cursor
|
|
190
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
191
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
192
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
193
|
+
.cursorignore
|
|
194
|
+
.cursorindexingignore
|
imgrit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, Shingo Tsuji
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
imgrit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: imgrit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tiny image processing library with k-means and Voronoi diagram.
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: numpy>=2.3.1
|
|
8
|
+
Requires-Dist: pillow>=11.2.1
|
|
9
|
+
Requires-Dist: scipy>=1.15.3
|
imgrit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "imgrit"
|
|
7
|
+
description = "A tiny image processing library with k-means and Voronoi diagram."
|
|
8
|
+
version="0.1.0"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"Pillow>=11.2.1",
|
|
11
|
+
"numpy>=2.3.1",
|
|
12
|
+
"scipy>=1.15.3"]
|
|
13
|
+
requires-python = ">= 3.9"
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from random import sample
|
|
3
|
+
from math import isclose
|
|
4
|
+
|
|
5
|
+
from PIL import Image, ImageDraw
|
|
6
|
+
import numpy as np
|
|
7
|
+
from scipy.spatial import KDTree, Voronoi
|
|
8
|
+
|
|
9
|
+
# kmeans is clearly a bottle neck of this library
|
|
10
|
+
# todo: replace faster implementation such as by Rust
|
|
11
|
+
from scipy.cluster.vq import kmeans2
|
|
12
|
+
|
|
13
|
+
ZERO_TOL = 1.0e-12
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Pixel:
|
|
18
|
+
"""A pixel with coordinate and the distance to the Voronoi site"""
|
|
19
|
+
|
|
20
|
+
x: int
|
|
21
|
+
y: int
|
|
22
|
+
distance: float
|
|
23
|
+
|
|
24
|
+
def to_array(self):
|
|
25
|
+
return np.array([self.x, self.y], np.int64)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def solve(vec, p, v, on="x"):
|
|
29
|
+
"""法線ベクトルvecと通る点pを受け取って、onで指定された値をもとめる
|
|
30
|
+
onがxならvはyの値で、算出されたxを返すということ。
|
|
31
|
+
|
|
32
|
+
Check vec[0] and vec[1] is not zero before using this.
|
|
33
|
+
"""
|
|
34
|
+
if on == "x":
|
|
35
|
+
return p[0] - vec[1] * (v - p[1]) / vec[0]
|
|
36
|
+
return p[1] - vec[0] * (v - p[0]) / vec[1]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def find_edge_point(ridge_point_pair, regions, vor, x_max=600, y_max=400):
|
|
40
|
+
"""ボロノイ領域の境界線を描くための関数(描画用)
|
|
41
|
+
|
|
42
|
+
2つのボロノイ領域を分ける境界線(ボロノイ境界?)を表現する線分のうち、
|
|
43
|
+
片方のインデックスが-1のものを受け取り、画像の境界線とどこで交わるかを探してその点の座標を返す関数
|
|
44
|
+
"""
|
|
45
|
+
# −1ではない方のボロノイ点の座標 (たぶん、計算しなくてもよさそう -> 先頭が常に−1?)
|
|
46
|
+
i = ridge_point_pair[1] if ridge_point_pair[0] == -1 else ridge_point_pair[0]
|
|
47
|
+
# voronoi vertex
|
|
48
|
+
# 座標を入れ換えるために配列を逆順にする
|
|
49
|
+
vv = vor.vertices[i][::-1]
|
|
50
|
+
# この点が画像の内部かどうかのチェック
|
|
51
|
+
# 画像の外のボロノイ点も含まれているため
|
|
52
|
+
if not (0 <= vv[0] <= x_max and 0 <= vv[1] <= y_max):
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
# 領域のインデックスから母点の座標を取得
|
|
56
|
+
# 座標系を入れ換える
|
|
57
|
+
a = vor.points[regions[0]][::-1]
|
|
58
|
+
b = vor.points[regions[1]][::-1]
|
|
59
|
+
# 垂直2等分線の方向を表すベクトル
|
|
60
|
+
v1 = b - a
|
|
61
|
+
# explore the point across Voronoi edge and the image boundary
|
|
62
|
+
temp = []
|
|
63
|
+
if isclose(v1[0], 0.0, abs_tol=ZERO_TOL): # parallel along with x-axis
|
|
64
|
+
temp = [(0, vv[1]), (x_max, vv[1])]
|
|
65
|
+
elif isclose(v1[1], 0.0, abs_tol=ZERO_TOL): # parallel along with y-axis
|
|
66
|
+
temp = [(vv[0], 0), (vv[1], y_max)]
|
|
67
|
+
else: # pass the zero div check
|
|
68
|
+
for v, on in [(0, "x"), (0, "y"), (x_max, "y"), (y_max, "x")]:
|
|
69
|
+
ret_val = solve(v1, vv, v, on)
|
|
70
|
+
if on == "x" and 0 <= ret_val <= x_max:
|
|
71
|
+
# 画像の範囲に入っているx座標
|
|
72
|
+
temp.append((ret_val, v))
|
|
73
|
+
elif 0 <= ret_val <= y_max:
|
|
74
|
+
# 画像の範囲に入っているy座標
|
|
75
|
+
temp.append((v, ret_val))
|
|
76
|
+
# 制御点に近い方を返す(これでいいのか?)
|
|
77
|
+
if np.linalg.norm(temp[0] - a) < np.linalg.norm(temp[1] - a):
|
|
78
|
+
return tuple([temp[0], tuple(vv)])
|
|
79
|
+
return tuple([temp[1], tuple(vv)])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class VoronoiRegion:
|
|
83
|
+
"""A Voronoi region"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, site, color):
|
|
86
|
+
"""
|
|
87
|
+
site: 2D array-like
|
|
88
|
+
coordinate of the site
|
|
89
|
+
color:
|
|
90
|
+
"""
|
|
91
|
+
self.site = site
|
|
92
|
+
# the color of this region
|
|
93
|
+
self.color = color
|
|
94
|
+
# Pixels that belong to this region
|
|
95
|
+
self.followers = []
|
|
96
|
+
|
|
97
|
+
def append(self, pixel):
|
|
98
|
+
if not isinstance(pixel, Pixel):
|
|
99
|
+
raise ValueError("pixel must be Pixel object.")
|
|
100
|
+
self.followers.append(pixel)
|
|
101
|
+
|
|
102
|
+
def size(self):
|
|
103
|
+
# number of pixels in this region
|
|
104
|
+
return len(self.followers) + 1
|
|
105
|
+
|
|
106
|
+
def __sub__(self, obj):
|
|
107
|
+
"""color difference"""
|
|
108
|
+
return np.abs(
|
|
109
|
+
np.array(self.color, dtype=np.int32) - np.array(obj.color, dtype=np.int32)
|
|
110
|
+
).sum()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class VoronoiImage:
|
|
114
|
+
"""元画像とボロノイ制御点を保持してボロノイ画像としての最低限の機能を有する
|
|
115
|
+
制御点の最適化などはこのクラスのインスタンスを受け取って、制御点の位置を最適化する設計が良いかと
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, img, sites):
|
|
119
|
+
"""
|
|
120
|
+
img: PIL image file
|
|
121
|
+
sites: int or array-like. specify the number of Voronoi sites or concrete one
|
|
122
|
+
"""
|
|
123
|
+
self.img = img
|
|
124
|
+
# to NumPy array
|
|
125
|
+
self.img_array = np.array(self.img)
|
|
126
|
+
# todo: take care of alpha value
|
|
127
|
+
# now ignore them
|
|
128
|
+
if self.img_array.shape[2] == 4:
|
|
129
|
+
self.img_array = self.img_array[:, :, :3]
|
|
130
|
+
# axis0: height of the image
|
|
131
|
+
# axis1: width of the image
|
|
132
|
+
self.axis0, self.axis1, self.color_channels = self.img_array.shape
|
|
133
|
+
# coordinate of all the pixel
|
|
134
|
+
self.grid_array = [
|
|
135
|
+
(x, y) for x in np.arange(self.axis0) for y in np.arange(self.axis1)
|
|
136
|
+
]
|
|
137
|
+
if isinstance(sites, int):
|
|
138
|
+
# sites are given by number
|
|
139
|
+
self.sites_num = sites
|
|
140
|
+
self.sites = self.init_sites()
|
|
141
|
+
elif isinstance(sites, (list, tuple, np.array)):
|
|
142
|
+
# sites are given by specific values
|
|
143
|
+
self.sites_num = len(sites)
|
|
144
|
+
# to tuple
|
|
145
|
+
temp = [tuple(v) for v in sites]
|
|
146
|
+
if not all([self._is_in_frame(v) for v in temp]):
|
|
147
|
+
raise ValueError("all sites must be within the image.")
|
|
148
|
+
self.sites = tuple(temp)
|
|
149
|
+
else:
|
|
150
|
+
raise TypeError("sites must be int or 2D array-list object")
|
|
151
|
+
# the followings are set after calling create_Voronoi_image method
|
|
152
|
+
# Voronoi mosaic art
|
|
153
|
+
self.voronoi_img = None
|
|
154
|
+
# difference between the input pixel values and their Voronoi site pixel values
|
|
155
|
+
self.error = None
|
|
156
|
+
# each voronoi region
|
|
157
|
+
self.regions = None
|
|
158
|
+
|
|
159
|
+
def get_error(self):
|
|
160
|
+
if self.error is None:
|
|
161
|
+
return np.inf
|
|
162
|
+
return self.error.sum()
|
|
163
|
+
|
|
164
|
+
def init_sites(self):
|
|
165
|
+
"""determine Voronoi sites randomly"""
|
|
166
|
+
return sample(self.grid_array, self.sites_num)
|
|
167
|
+
|
|
168
|
+
def _is_in_frame(self, p):
|
|
169
|
+
"""within the frame?"""
|
|
170
|
+
return 0 <= p[0] < self.axis0 and 0 <= p[1] < self.axis1
|
|
171
|
+
|
|
172
|
+
def _convert_boundaries(self, boundaries, width_dict):
|
|
173
|
+
"""todo: understand and test this 色の差を線の太さに変換する関数"""
|
|
174
|
+
data = np.array([v[1] for v in boundaries]).reshape(-1, 1)
|
|
175
|
+
centroid, pred = kmeans2(data, len(width_dict), minit="++")
|
|
176
|
+
# クラスターの中心の並びを調べる
|
|
177
|
+
class_to_width = {v: i for i, v in enumerate(np.argsort(centroid.flatten()))}
|
|
178
|
+
for i in range(len(boundaries)):
|
|
179
|
+
# boundaries[i][1] = class_to_width[pred[i]] + 1
|
|
180
|
+
boundaries[i][1] = width_dict[class_to_width[pred[i]]]
|
|
181
|
+
return boundaries
|
|
182
|
+
|
|
183
|
+
def create_Voronoi_image(self, boundary=True, with_sites=False, line_width=1):
|
|
184
|
+
"""Voronoi image
|
|
185
|
+
|
|
186
|
+
boundary: boolean
|
|
187
|
+
with boundary lines or not
|
|
188
|
+
with_sites: boolean
|
|
189
|
+
if true put 'x' on control point
|
|
190
|
+
line_width: int or dict
|
|
191
|
+
if dict is given like {0:1, 1:5, 2:10} line width are changed based on the color differences between neighbor Voronoit regions
|
|
192
|
+
|
|
193
|
+
Returns: PIL image
|
|
194
|
+
"""
|
|
195
|
+
kd_tree = KDTree(self.sites)
|
|
196
|
+
# make Resions from self.sites
|
|
197
|
+
self.regions = [
|
|
198
|
+
VoronoiRegion(s, self.img_array[s[0], s[1]]) for s in self.sites
|
|
199
|
+
]
|
|
200
|
+
# if line_width is dict the line width will be changed
|
|
201
|
+
change_width = isinstance(line_width, dict)
|
|
202
|
+
# make Voronoi diagram to create the mosaic art
|
|
203
|
+
res = kd_tree.query(self.grid_array)
|
|
204
|
+
for i, p in enumerate(self.grid_array):
|
|
205
|
+
if res[0][i] == 0:
|
|
206
|
+
# skip site point
|
|
207
|
+
continue
|
|
208
|
+
pixel = Pixel(p[0], p[1], res[0][i])
|
|
209
|
+
# res[1][i] is the index of sites
|
|
210
|
+
self.regions[res[1][i]].append(pixel)
|
|
211
|
+
voronoi_art = self.img_array.copy()
|
|
212
|
+
# initialize the error
|
|
213
|
+
# error = np.zeros(3, np.float64)
|
|
214
|
+
# don't change the color of the site point
|
|
215
|
+
for region in self.regions:
|
|
216
|
+
for p in region.followers:
|
|
217
|
+
voronoi_art[p.x, p.y] = self.img_array[region.site[0], region.site[1]]
|
|
218
|
+
# error += np.abs(
|
|
219
|
+
# np.array(voronoi_art[p.x, p.y], dtype=np.int32)
|
|
220
|
+
# - np.array(self.img_array[p.x, p.y], dtype=np.int32)
|
|
221
|
+
# )
|
|
222
|
+
# self.error = np.sum(error) / (self.axis0 * self.axis1)
|
|
223
|
+
# if don't need the boundaries this is the goal.
|
|
224
|
+
img = Image.fromarray(voronoi_art)
|
|
225
|
+
if not boundary:
|
|
226
|
+
self.voronoi_img = img
|
|
227
|
+
return img
|
|
228
|
+
# make Voronoi diagram with SciPy function
|
|
229
|
+
vor = Voronoi(self.sites)
|
|
230
|
+
draw = ImageDraw.Draw(img)
|
|
231
|
+
# to change the width of boundaries depend on the color difference
|
|
232
|
+
boundaries = [] # [[[start, end], color diff], ...]
|
|
233
|
+
for k, v in vor.ridge_dict.items():
|
|
234
|
+
if -1 in v:
|
|
235
|
+
idx = find_edge_point(v, k, vor, self.axis1, self.axis0)
|
|
236
|
+
if idx is None:
|
|
237
|
+
continue
|
|
238
|
+
else:
|
|
239
|
+
idx = tuple([(a[1], a[0]) for a in vor.vertices[v]])
|
|
240
|
+
# 画像の外にある点が指定されても、画角に収まる線だけが描画されることが判明
|
|
241
|
+
# 画角の外側のボロノイ点と結ばれて線が描かれることもある。
|
|
242
|
+
if change_width:
|
|
243
|
+
# 中点を計算(座標を逆にするのが正しいみたい)
|
|
244
|
+
m = ((idx[0][1] + idx[1][1]) * 0.5, (idx[0][0] + idx[1][0]) * 0.5)
|
|
245
|
+
# これら2つを分ける境界線
|
|
246
|
+
res = kd_tree.query(m, k=2)
|
|
247
|
+
boundaries.append(
|
|
248
|
+
[idx, self.regions[res[1][0]] - self.regions[res[1][1]]]
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
boundaries.append([idx, line_width])
|
|
252
|
+
if change_width:
|
|
253
|
+
boundaries = self._convert_boundaries(boundaries, line_width)
|
|
254
|
+
for idx, width in boundaries:
|
|
255
|
+
try:
|
|
256
|
+
draw.line(idx, fill=(0, 0, 0), width=width)
|
|
257
|
+
except:
|
|
258
|
+
# What's happened?
|
|
259
|
+
print(idx)
|
|
260
|
+
if with_sites:
|
|
261
|
+
for vv in vor.points:
|
|
262
|
+
draw.text((vv[1], vv[0]), "x")
|
|
263
|
+
self.voronoi_img = img
|
|
264
|
+
return img
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class KMeansImage:
|
|
268
|
+
def __init__(self, img_file):
|
|
269
|
+
if isinstance(img_file, str):
|
|
270
|
+
self.img = Image.open(img_file)
|
|
271
|
+
else:
|
|
272
|
+
self.img = img_file
|
|
273
|
+
self.img_array = np.array(self.img)
|
|
274
|
+
self.h, self.w, self.channel = self.img_array.shape
|
|
275
|
+
# X, Y
|
|
276
|
+
data = np.array([[x, y] for x in np.arange(self.h) for y in np.arange(self.w)])
|
|
277
|
+
# R G B L
|
|
278
|
+
data = np.concatenate(
|
|
279
|
+
[
|
|
280
|
+
data,
|
|
281
|
+
np.array([self.img_array[:, :, 0].flatten()]).T, # R
|
|
282
|
+
np.array([self.img_array[:, :, 1].flatten()]).T, # G
|
|
283
|
+
np.array([self.img_array[:, :, 2].flatten()]).T, # B
|
|
284
|
+
np.array([np.array(self.img.convert("L")).flatten()]).T,
|
|
285
|
+
],
|
|
286
|
+
axis=1,
|
|
287
|
+
)
|
|
288
|
+
self.img_minmax = data / data.max(
|
|
289
|
+
axis=0
|
|
290
|
+
) # all values are positive so this make the input minmax-scaled data
|
|
291
|
+
self.voronoi_img_instance = None
|
|
292
|
+
|
|
293
|
+
def voronoi_img(
|
|
294
|
+
self,
|
|
295
|
+
num_sites,
|
|
296
|
+
with_sites=False,
|
|
297
|
+
line_width=1,
|
|
298
|
+
boundary=True,
|
|
299
|
+
mode="color",
|
|
300
|
+
):
|
|
301
|
+
if mode == "color":
|
|
302
|
+
# X, Y, R, G, B
|
|
303
|
+
temp = self.img_minmax[:, [0, 1, 2, 3, 4]]
|
|
304
|
+
else: # black and white
|
|
305
|
+
temp = self.img_minmax[:, [0, 1, 5]]
|
|
306
|
+
centroid, cluster = kmeans2(temp, num_sites, minit="++")
|
|
307
|
+
# depends on the order of self.img_df column names
|
|
308
|
+
sites = [
|
|
309
|
+
tuple(v) for v in (centroid[:, [0, 1]] * [self.h, self.w]).astype(np.int32)
|
|
310
|
+
]
|
|
311
|
+
self.voronoi_img_instance = VoronoiImage(self.img, sites)
|
|
312
|
+
voronoi_img = self.voronoi_img_instance.create_Voronoi_image(
|
|
313
|
+
with_sites=with_sites, line_width=line_width, boundary=boundary
|
|
314
|
+
)
|
|
315
|
+
return voronoi_img
|
|
316
|
+
|
|
317
|
+
def clustered_img(self, num_sites):
|
|
318
|
+
# X, Y, R, G, B
|
|
319
|
+
input_data = self.img_minmax[:, [0, 1, 2, 3, 4]]
|
|
320
|
+
centroid, labels = kmeans2(input_data, num_sites, minit="++")
|
|
321
|
+
cluster_centers = [
|
|
322
|
+
v for v in (centroid[:, 2:] * [255, 255, 255]).astype(np.int32)
|
|
323
|
+
]
|
|
324
|
+
temp = []
|
|
325
|
+
for c in range(3):
|
|
326
|
+
temp.append(
|
|
327
|
+
np.array([cluster_centers[i][c] for i in labels])
|
|
328
|
+
.astype(np.uint8)
|
|
329
|
+
.reshape(self.h, self.w)
|
|
330
|
+
)
|
|
331
|
+
res_img_array = np.dstack(temp)
|
|
332
|
+
return Image.fromarray(res_img_array)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def voronoi_mosaic(img, num_regions=20, line_width=2, mode="L"):
|
|
336
|
+
img = KMeansImage(img)
|
|
337
|
+
voronoi_img = img.voronoi_img(num_regions, line_width=line_width, mode=mode)
|
|
338
|
+
return voronoi_img
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def warhol_effect(img, n_clusters=5):
|
|
342
|
+
img = KMeansImage(img)
|
|
343
|
+
return img.clustered_img(n_clusters)
|