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.
@@ -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,2 @@
1
+ # imgrit
2
+ A tiny image processing library with k-means and Voronoi diagram.
@@ -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,2 @@
1
+ from .imgrit import voronoi_mosaic
2
+ from .imgrit import warhol_effect
@@ -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)