posterize 2.10.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.
- posterize-2.10.0/PKG-INFO +65 -0
- posterize-2.10.0/README.md +50 -0
- posterize-2.10.0/pyproject.toml +86 -0
- posterize-2.10.0/src/posterize/__init__.py +23 -0
- posterize-2.10.0/src/posterize/bin/potrace.exe +0 -0
- posterize-2.10.0/src/posterize/color_attributes.py +85 -0
- posterize-2.10.0/src/posterize/defaults.py +28 -0
- posterize-2.10.0/src/posterize/image_processing.py +97 -0
- posterize-2.10.0/src/posterize/layers.py +64 -0
- posterize-2.10.0/src/posterize/main.py +480 -0
- posterize-2.10.0/src/posterize/paths.py +11 -0
- posterize-2.10.0/src/posterize/posterization.py +241 -0
- posterize-2.10.0/src/posterize/py.typed +5 -0
- posterize-2.10.0/src/posterize/quantization.py +277 -0
- posterize-2.10.0/src/posterize/stem_creator.py +43 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: posterize
|
|
3
|
+
Version: 2.10.0
|
|
4
|
+
Summary: Posterize an image with stacked SVG geometry (generated by Potrace).
|
|
5
|
+
Author: Shay Hill
|
|
6
|
+
Author-email: Shay Hill <shay_public@hotmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Dist: basic-colormath>=1.1.1
|
|
9
|
+
Requires-Dist: cluster-colors>=0.13
|
|
10
|
+
Requires-Dist: diskcache>=5.6.3
|
|
11
|
+
Requires-Dist: numpy
|
|
12
|
+
Requires-Dist: svg-ultralight
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Posterize
|
|
17
|
+
|
|
18
|
+
Posterize an image with stacked SVG geometry (generated by Potrace).
|
|
19
|
+
|
|
20
|
+
## API
|
|
21
|
+
|
|
22
|
+
The package exposes four main functions and two result types:
|
|
23
|
+
|
|
24
|
+
- **`posterize(image_path, num_cols, *, savings_weight=None, vibrant_weight=None, max_dim=None)`**
|
|
25
|
+
Load an image from disk and posterize it. Returns a `Posterization`.
|
|
26
|
+
`image_path`: path to the image.
|
|
27
|
+
`num_cols`: number of colors (layers) in the result; fewer may be returned if colors are exhausted.
|
|
28
|
+
`savings_weight`: weight for sum savings vs average savings when choosing layer colors (default 0.25).
|
|
29
|
+
`vibrant_weight`: bias toward more vibrant colors, 0–1 (default 0).
|
|
30
|
+
`max_dim`: if set, resize the image so its longer side is at most this many pixels before processing.
|
|
31
|
+
|
|
32
|
+
- **`posterize_mono(pixels, num_cols, *, savings_weight=None, vibrant_weight=None)`**
|
|
33
|
+
Posterize a grayscale pixel array. Returns a `Posterization`.
|
|
34
|
+
`pixels`: `(r, c)` uint8 array (e.g. one channel of an image).
|
|
35
|
+
`num_cols`, `savings_weight`, `vibrant_weight`: same as `posterize`.
|
|
36
|
+
|
|
37
|
+
- **`new_target_image(path, max_dim=None)`**
|
|
38
|
+
Quantize an image file to at most 512 colors and return a `TargetImage` (palette, indices, cost matrix). Used internally by `posterize`; useful if you need the quantized image without building layers.
|
|
39
|
+
`path`: path to an image file.
|
|
40
|
+
`max_dim`: maximum width or height; image is thumbnailed if larger (default 500).
|
|
41
|
+
|
|
42
|
+
- **`new_target_image_mono(pixels)`**
|
|
43
|
+
Quantize a grayscale `(r, c)` uint8 array to at most 256 colors. Returns a `TargetImage`. Used internally by `posterize_mono`.
|
|
44
|
+
|
|
45
|
+
**Result types:** `Posterization` (from `posterize` / `posterize_mono`) has `.write_svg(path, num_cols=None)` and methods for layers, colors, and SVG elements. `TargetImage` holds the quantized palette, indices, and cost matrix for layer-building.
|
|
46
|
+
|
|
47
|
+
### Example
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from posterize import posterize
|
|
51
|
+
|
|
52
|
+
posterized = posterize("image.png", 4)
|
|
53
|
+
posterized.write_svg("output.svg")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Caching
|
|
57
|
+
|
|
58
|
+
- **`.cache_posterize`** (in the current working directory)
|
|
59
|
+
Memoizes `posterize()` and `posterize_mono()` by their arguments. Repeated calls with the same inputs return the cached `Posterization` without recomputing layers.
|
|
60
|
+
|
|
61
|
+
- **`.cache_quantize`** (in the current working directory)
|
|
62
|
+
Memoizes quantized image data: `quantize_image()` / `quantize_rgba()` and `quantize_mono()`. Used by `new_target_image()` and `new_target_image_mono()`, and indirectly by `posterize()` and `posterize_mono()`.
|
|
63
|
+
|
|
64
|
+
- **Temp directory** (`paths.CACHE_DIR`, under the system temp dir as `cluster_colors_cache`)
|
|
65
|
+
Used while generating SVG paths: a temporary BMP and Potrace-generated SVG are written here during `layer_to_svgd()` and then removed. No long-lived cache.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Posterize
|
|
2
|
+
|
|
3
|
+
Posterize an image with stacked SVG geometry (generated by Potrace).
|
|
4
|
+
|
|
5
|
+
## API
|
|
6
|
+
|
|
7
|
+
The package exposes four main functions and two result types:
|
|
8
|
+
|
|
9
|
+
- **`posterize(image_path, num_cols, *, savings_weight=None, vibrant_weight=None, max_dim=None)`**
|
|
10
|
+
Load an image from disk and posterize it. Returns a `Posterization`.
|
|
11
|
+
`image_path`: path to the image.
|
|
12
|
+
`num_cols`: number of colors (layers) in the result; fewer may be returned if colors are exhausted.
|
|
13
|
+
`savings_weight`: weight for sum savings vs average savings when choosing layer colors (default 0.25).
|
|
14
|
+
`vibrant_weight`: bias toward more vibrant colors, 0–1 (default 0).
|
|
15
|
+
`max_dim`: if set, resize the image so its longer side is at most this many pixels before processing.
|
|
16
|
+
|
|
17
|
+
- **`posterize_mono(pixels, num_cols, *, savings_weight=None, vibrant_weight=None)`**
|
|
18
|
+
Posterize a grayscale pixel array. Returns a `Posterization`.
|
|
19
|
+
`pixels`: `(r, c)` uint8 array (e.g. one channel of an image).
|
|
20
|
+
`num_cols`, `savings_weight`, `vibrant_weight`: same as `posterize`.
|
|
21
|
+
|
|
22
|
+
- **`new_target_image(path, max_dim=None)`**
|
|
23
|
+
Quantize an image file to at most 512 colors and return a `TargetImage` (palette, indices, cost matrix). Used internally by `posterize`; useful if you need the quantized image without building layers.
|
|
24
|
+
`path`: path to an image file.
|
|
25
|
+
`max_dim`: maximum width or height; image is thumbnailed if larger (default 500).
|
|
26
|
+
|
|
27
|
+
- **`new_target_image_mono(pixels)`**
|
|
28
|
+
Quantize a grayscale `(r, c)` uint8 array to at most 256 colors. Returns a `TargetImage`. Used internally by `posterize_mono`.
|
|
29
|
+
|
|
30
|
+
**Result types:** `Posterization` (from `posterize` / `posterize_mono`) has `.write_svg(path, num_cols=None)` and methods for layers, colors, and SVG elements. `TargetImage` holds the quantized palette, indices, and cost matrix for layer-building.
|
|
31
|
+
|
|
32
|
+
### Example
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from posterize import posterize
|
|
36
|
+
|
|
37
|
+
posterized = posterize("image.png", 4)
|
|
38
|
+
posterized.write_svg("output.svg")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Caching
|
|
42
|
+
|
|
43
|
+
- **`.cache_posterize`** (in the current working directory)
|
|
44
|
+
Memoizes `posterize()` and `posterize_mono()` by their arguments. Repeated calls with the same inputs return the cached `Posterization` without recomputing layers.
|
|
45
|
+
|
|
46
|
+
- **`.cache_quantize`** (in the current working directory)
|
|
47
|
+
Memoizes quantized image data: `quantize_image()` / `quantize_rgba()` and `quantize_mono()`. Used by `new_target_image()` and `new_target_image_mono()`, and indirectly by `posterize()` and `posterize_mono()`.
|
|
48
|
+
|
|
49
|
+
- **Temp directory** (`paths.CACHE_DIR`, under the system temp dir as `cluster_colors_cache`)
|
|
50
|
+
Used while generating SVG paths: a temporary BMP and Potrace-generated SVG are written here during `layer_to_svgd()` and then removed. No long-lived cache.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "posterize"
|
|
3
|
+
version = "2.10.0"
|
|
4
|
+
description = "Posterize an image with stacked SVG geometry (generated by Potrace)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Shay Hill", email = "shay_public@hotmail.com" }]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"basic-colormath>=1.1.1",
|
|
11
|
+
"cluster-colors>=0.13",
|
|
12
|
+
"diskcache>=5.6.3",
|
|
13
|
+
"numpy",
|
|
14
|
+
"svg-ultralight",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.9.4,<0.10.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = ["commitizen>=4.10.0", "pre-commit>=4.4.0", "pytest>=9.0.1"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
[tool.commitizen]
|
|
26
|
+
name = "cz_conventional_commits"
|
|
27
|
+
version = "2.10.0"
|
|
28
|
+
tag_format = "$version"
|
|
29
|
+
major-version-zero = true
|
|
30
|
+
version_files = ["pyproject.toml:^version"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
[tool.isort]
|
|
34
|
+
profile = "black"
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
addopts = "--doctest-modules"
|
|
38
|
+
pythonpath = "tests"
|
|
39
|
+
log_cli = true
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint.pydocstyle]
|
|
43
|
+
convention = "pep257"
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint.per-file-ignores]
|
|
46
|
+
"tests/*.py" = [
|
|
47
|
+
"S101",
|
|
48
|
+
"D",
|
|
49
|
+
"F401",
|
|
50
|
+
] # Ignore assertions, docstrings, unused imports in test files
|
|
51
|
+
|
|
52
|
+
[tool.ruff.format]
|
|
53
|
+
docstring-code-line-length = 88
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
|
|
57
|
+
select = ["ALL"]
|
|
58
|
+
|
|
59
|
+
ignore = [
|
|
60
|
+
"PLR", # magic values
|
|
61
|
+
"COM", # trailing commas
|
|
62
|
+
"ANN", # forbid Any
|
|
63
|
+
"S", # warnings about pickle, random, etc.
|
|
64
|
+
"N", # names
|
|
65
|
+
"B019", # memory leak warning for lru_cache
|
|
66
|
+
"ISC003", # implicit string concatenation
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
[tool.pyright]
|
|
71
|
+
include = ["src"]
|
|
72
|
+
exclude = ["**/__pycache__.py"]
|
|
73
|
+
|
|
74
|
+
pythonVersion = "3.10"
|
|
75
|
+
pythonPlatform = "All"
|
|
76
|
+
|
|
77
|
+
typeCheckingMode = "strict"
|
|
78
|
+
reportCallInDefaultInitializer = true
|
|
79
|
+
reportImplicitStringConcatenation = true
|
|
80
|
+
reportPropertyTypeMismatch = true
|
|
81
|
+
reportUninitializedInstanceVariable = true
|
|
82
|
+
reportUnnecessaryTypeIgnoreComment = true
|
|
83
|
+
reportUnusedCallResult = true
|
|
84
|
+
|
|
85
|
+
venvPath = "."
|
|
86
|
+
venv = "./.venv"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Import functions into the package namespace.
|
|
2
|
+
|
|
3
|
+
:author: Shay Hill
|
|
4
|
+
:created: 2024-05-09
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from posterize.main import extend_posterization, posterize, posterize_mono
|
|
8
|
+
from posterize.posterization import Posterization
|
|
9
|
+
from posterize.quantization import (
|
|
10
|
+
TargetImage,
|
|
11
|
+
new_target_image,
|
|
12
|
+
new_target_image_mono,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Posterization",
|
|
17
|
+
"TargetImage",
|
|
18
|
+
"extend_posterization",
|
|
19
|
+
"new_target_image",
|
|
20
|
+
"new_target_image_mono",
|
|
21
|
+
"posterize",
|
|
22
|
+
"posterize_mono",
|
|
23
|
+
]
|
|
Binary file
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Return quantified color attributes between 0 and 1 for a given color.
|
|
2
|
+
|
|
3
|
+
For the purposes of this module, gray is (127, 127, 127). Values of (128, 128, 128)
|
|
4
|
+
will be treated as *almost* gray.
|
|
5
|
+
|
|
6
|
+
:author: Shay Hill
|
|
7
|
+
:created: 2025-04-29
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Annotated, TypeAlias
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from numpy import typing as npt
|
|
14
|
+
|
|
15
|
+
_RGB: TypeAlias = Annotated[npt.NDArray[np.uint8], (3,)]
|
|
16
|
+
_RGBWCMYK: TypeAlias = Annotated[npt.NDArray[np.uint8], (8,)]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_chromacity(color: _RGB) -> float:
|
|
20
|
+
"""Return the chromacity of the color.
|
|
21
|
+
|
|
22
|
+
:param color: RGB color as a tuple of three integers in the range [0, 255]
|
|
23
|
+
:return: Chromacity score between 0 and 1, where 0 is gray and 1 is a pure
|
|
24
|
+
chromatic color (red, green, blue, etc.).
|
|
25
|
+
|
|
26
|
+
What is the inverse, relative distance to the color circle?
|
|
27
|
+
|
|
28
|
+
A color with a vibrance of 0 is a shade of gray, while a color with a vibrance of
|
|
29
|
+
1 is a pure color with maximum saturation and lightness.
|
|
30
|
+
"""
|
|
31
|
+
r, g, b = map(float, color)
|
|
32
|
+
return (max((r, g, b)) - min((r, g, b))) / 255
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_rgb_dist(rgb: _RGB) -> _RGBWCMYK:
|
|
36
|
+
"""Convert red, green, blue to an 8-channel color distribution.
|
|
37
|
+
|
|
38
|
+
:param rgb: 3-channel RGB color
|
|
39
|
+
:return: 8-channel color distribution
|
|
40
|
+
"""
|
|
41
|
+
r, g, b = map(float, rgb)
|
|
42
|
+
w: float = min(r, g, b)
|
|
43
|
+
k: float = 255 - max(r, g, b)
|
|
44
|
+
y: float = min(r, g) - w
|
|
45
|
+
c: float = min(g, b) - w
|
|
46
|
+
m: float = min(b, r) - w
|
|
47
|
+
r -= max(m, y) + w
|
|
48
|
+
g -= max(y, c) + w
|
|
49
|
+
b -= max(c, m) + w
|
|
50
|
+
return np.array([r, g, b, w, c, m, y, k])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_purity(color: _RGB) -> float:
|
|
54
|
+
"""Return the purity of the color.
|
|
55
|
+
|
|
56
|
+
:param color: RGB color as a tuple of three integers in the range [0, 255]
|
|
57
|
+
:return: Purity score between 0 and 1, where 0 is gray and 1 is a color on the
|
|
58
|
+
exterior of the color cube.
|
|
59
|
+
|
|
60
|
+
What is the inverse, relative distance to the surface of the color cube? A color
|
|
61
|
+
with a purity of 0 is pure gray (127, 127, 127). A color with a purity of one is
|
|
62
|
+
a pure color, a shaded (mixed with black) or a tinted (mixed with white) color.
|
|
63
|
+
"""
|
|
64
|
+
_, _, _, white, _, _, _, black = map(float, _get_rgb_dist(color))
|
|
65
|
+
return 1 - (min(white, black) / 127)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_vibrance(rgb: _RGB, chroma_weight: float = 0.75) -> float:
|
|
69
|
+
"""Get the vibrance of a color.
|
|
70
|
+
|
|
71
|
+
:param rgb: RGB color as a tuple of three integers in the range [0, 255]
|
|
72
|
+
:param chroma_weight: Weighting factor for chromacity in the vibrance calculation.
|
|
73
|
+
Defaults to 0.75, meaning chromacity contributes 75% to the vibrance score.
|
|
74
|
+
:return: Vibrance score between 0 and 1, where 0 is gray, 1 is a pure color on
|
|
75
|
+
the color wheel, and black or white will be above 0 but below chromatic
|
|
76
|
+
colors.
|
|
77
|
+
|
|
78
|
+
Vibrance is a measure of how colorful a color is. Specifically, it is a weighted
|
|
79
|
+
average of the chromacity (closeness to color wheel) and purity (absence of gray)
|
|
80
|
+
of a color. Black would have a chromacity of 0 and a purity of 1. Red would have
|
|
81
|
+
a chromacity of 1 and a purity of 1. Pure gray would have a chromacity of 0 and a
|
|
82
|
+
purity of 0.
|
|
83
|
+
"""
|
|
84
|
+
chroma_weight = 0.75
|
|
85
|
+
return get_chromacity(rgb) * chroma_weight + get_purity(rgb) * (1 - chroma_weight)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Default values for posterization.
|
|
2
|
+
|
|
3
|
+
:author: Shay Hill
|
|
4
|
+
:created: 2026-02-08
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Default weight for sum savings vs. average savings. Average savings is, by default,
|
|
8
|
+
# weighted highly. These values are used when selecting the best candidate for the
|
|
9
|
+
# next layer color. A higher average savings weight means colors that improve the
|
|
10
|
+
# approximation a lot in a small area are chosen over colors that improve the
|
|
11
|
+
# approximation a tiny amount over a large area. _DEFAULT_SAVINGS_WEIGHT is the
|
|
12
|
+
# weight given to sum savings. (1 - _DEFAULT_SAVINGS_WEIGHT) is the weight given to
|
|
13
|
+
# average savings.
|
|
14
|
+
SAVINGS_WEIGHT = 0.25
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# A higher number (1.0 is maximum) means colors that are more vibrant are more likely
|
|
18
|
+
# to be selected as layer colors. The default is 0.0, which will be good for most
|
|
19
|
+
# images, but the parameter is available if you have an overall drab image with a few
|
|
20
|
+
# bright highlights and want to pay less attention to the background.
|
|
21
|
+
VIBRANT_WEIGHT = 0.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Resize images larger than this to this maximum dimension. This value is necessary,
|
|
25
|
+
# because you can only create arrays of a certain size. A smaller value might speed
|
|
26
|
+
# up testing, but the quantization cache will need to be cleared if this value
|
|
27
|
+
# changes.
|
|
28
|
+
MAX_DIM = 500
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Write images to disk, convert arrays to images, call potrace.
|
|
2
|
+
|
|
3
|
+
:author: Shay Hill
|
|
4
|
+
:created: 2024-10-13
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import importlib.resources
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated, Any, TypeAlias
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from lxml import etree
|
|
15
|
+
from numpy import typing as npt
|
|
16
|
+
from PIL import Image
|
|
17
|
+
from svg_path_data import format_svgd_shortest
|
|
18
|
+
|
|
19
|
+
from posterize.paths import CACHE_DIR
|
|
20
|
+
|
|
21
|
+
POTRACE_EXE = importlib.resources.files("posterize.bin") / "potrace.exe"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_IndexMatrix: TypeAlias = Annotated[npt.NDArray[np.integer[Any]], "(r,c)"]
|
|
25
|
+
|
|
26
|
+
_TMP_BMP = CACHE_DIR / "temp.bmp"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _write_svg_from_mono_bmp(path_to_mono_bmp: str | os.PathLike[str]) -> Path:
|
|
30
|
+
"""Write an svg from a monochrome image.
|
|
31
|
+
|
|
32
|
+
:param path_to_mono_bmp: path to the monochrome image
|
|
33
|
+
:return: path to the output svg
|
|
34
|
+
|
|
35
|
+
Images passed in this library will be not only grayscale, but monochrome. So, a
|
|
36
|
+
blacklevel of 0.5 can be hardcoded.
|
|
37
|
+
"""
|
|
38
|
+
svg_path = (CACHE_DIR / Path(path_to_mono_bmp).name).with_suffix(".svg")
|
|
39
|
+
# fmt: off
|
|
40
|
+
command = [
|
|
41
|
+
str(POTRACE_EXE),
|
|
42
|
+
str(path_to_mono_bmp),
|
|
43
|
+
"-o", str(svg_path), # output file
|
|
44
|
+
"-k", str(0.5), # black level
|
|
45
|
+
"-u", "1", # do not scale svg (points correspond to pixels array)
|
|
46
|
+
"--flat", # all paths combined in one element
|
|
47
|
+
"-b", "svg", # output format
|
|
48
|
+
"--opttolerance", "2.8", # higher values make paths smoother
|
|
49
|
+
]
|
|
50
|
+
# fmt: on
|
|
51
|
+
_ = subprocess.run(command, check=True)
|
|
52
|
+
return svg_path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _write_mono_bmp(layer: _IndexMatrix) -> Path:
|
|
56
|
+
"""Write a monochrome bitmap from an index matrix.
|
|
57
|
+
|
|
58
|
+
:param layer: (r, c) array where -1 is transparent and opaque pixels are all
|
|
59
|
+
filled with the same index colormap.
|
|
60
|
+
:return: path to the monochrome bitmap
|
|
61
|
+
|
|
62
|
+
This bmp will be the input argument for potrace. Black pixels will be inside the
|
|
63
|
+
output path element.
|
|
64
|
+
"""
|
|
65
|
+
mono_pixels = np.ones([*layer.shape, 3], dtype=np.uint8) * 255
|
|
66
|
+
mono_pixels[np.where(layer != -1)] = (0, 0, 0)
|
|
67
|
+
mono_bmp = Image.fromarray(mono_pixels)
|
|
68
|
+
output_path = _TMP_BMP
|
|
69
|
+
mono_bmp.save(output_path)
|
|
70
|
+
return output_path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def layer_to_svgd(layer: _IndexMatrix) -> str:
|
|
74
|
+
"""Convert a layer to an SVG data string.
|
|
75
|
+
|
|
76
|
+
:param layer: (r, c) array where -1 is transparent and opaque pixels are all
|
|
77
|
+
filled with the same index colormap.
|
|
78
|
+
:return: SVG data string
|
|
79
|
+
|
|
80
|
+
If the layer has no -1 values (solid layer), returns a rectangle path covering
|
|
81
|
+
the entire layer. Otherwise, writes a monochrome bitmap from the layer, converts
|
|
82
|
+
it to SVG using potrace, and returns the SVG content as a string.
|
|
83
|
+
"""
|
|
84
|
+
if np.all(layer != -1):
|
|
85
|
+
height, width = layer.shape
|
|
86
|
+
return format_svgd_shortest(f"M0 0 {width} 0 {width} {height} 0 {height}z")
|
|
87
|
+
mono_bmp = _write_mono_bmp(layer)
|
|
88
|
+
svg_path: Path | None = None
|
|
89
|
+
try:
|
|
90
|
+
svg_path = _write_svg_from_mono_bmp(mono_bmp)
|
|
91
|
+
return format_svgd_shortest(
|
|
92
|
+
etree.parse(str(svg_path)).getroot()[1][0].attrib["d"]
|
|
93
|
+
)
|
|
94
|
+
finally:
|
|
95
|
+
mono_bmp.unlink()
|
|
96
|
+
if svg_path is not None:
|
|
97
|
+
svg_path.unlink()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Functions to deal with image-approximation layers.
|
|
2
|
+
|
|
3
|
+
Each layer is 512 values, each either a single color index or -1.
|
|
4
|
+
|
|
5
|
+
The original colormap is 512 unique values. For each layer, 1 color index is selected
|
|
6
|
+
and each color in the original map is compared to it. Where the selected color index
|
|
7
|
+
would improve the approximation (in comparison to lower layers), the value in that
|
|
8
|
+
colormap position [0..511] is set to the selected color index. Where the selected
|
|
9
|
+
color index would *not* improve the approximation, the value is set to -1.
|
|
10
|
+
|
|
11
|
+
:author: Shay Hill
|
|
12
|
+
:created: 2025-04-25
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import TypeAlias
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from numpy import typing as npt
|
|
19
|
+
|
|
20
|
+
_IntA: TypeAlias = npt.NDArray[np.intp]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def merge_layers(*layers: _IntA, size: int | None = None) -> _IntA:
|
|
24
|
+
"""Merge layers into a single layer.
|
|
25
|
+
|
|
26
|
+
:param layers: n shape (palette_size,) layer arrays, each containing at most two
|
|
27
|
+
values:
|
|
28
|
+
* a color index that will replace one or more indices in the quantized image
|
|
29
|
+
* -1 for transparent. The first layer will be a solid color and contain no -1
|
|
30
|
+
:param size: palette size; required only when no layers are provided, used to
|
|
31
|
+
shape the all-transparent result. For a color image, this should be 512. For
|
|
32
|
+
a mono image, this should be 256.
|
|
33
|
+
:return: one (palette_size,) array with the last non-transparent color in each
|
|
34
|
+
position
|
|
35
|
+
:raises ValueError: if no layers are provided and size is None
|
|
36
|
+
|
|
37
|
+
Where an image is a (rows, cols) array of indices---each layer of an
|
|
38
|
+
approximation will color some of those indices with one palette index per layer,
|
|
39
|
+
and others with -1 for transparency.
|
|
40
|
+
"""
|
|
41
|
+
if not layers:
|
|
42
|
+
if size is None:
|
|
43
|
+
msg = "size is required when merging zero layers"
|
|
44
|
+
raise ValueError(msg)
|
|
45
|
+
return np.full(size, -1, dtype=np.intp)
|
|
46
|
+
merged: _IntA = layers[0] * 1
|
|
47
|
+
for layer in layers[1:]:
|
|
48
|
+
non_transparent = layer != -1
|
|
49
|
+
merged[non_transparent] = layer[non_transparent]
|
|
50
|
+
return merged
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def apply_mask(layer: _IntA, mask: _IntA | None) -> _IntA:
|
|
54
|
+
"""Apply a mask to a layer if the mask is not None.
|
|
55
|
+
|
|
56
|
+
:param layer: the layer to apply the mask to (shape (512,) consisting of one
|
|
57
|
+
palette index and -1 where transparent)
|
|
58
|
+
:param mask: the mask to apply to the layer (shape (512,)) consisting of 1s and 0s
|
|
59
|
+
:return: the layer with the mask applied (shape (512,)) with, most likely,
|
|
60
|
+
additional transparent (-1) values
|
|
61
|
+
"""
|
|
62
|
+
if mask is None:
|
|
63
|
+
return layer
|
|
64
|
+
return np.where(mask == 1, layer, -1)
|