posterize 2.10.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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)
posterize/defaults.py ADDED
@@ -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()
posterize/layers.py ADDED
@@ -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)