fuzcolor 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.
fuzcolor-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 coredegon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: fuzcolor
3
+ Version: 0.1.0
4
+ Summary: Simple and easy to use Python library for fuzzy color models.
5
+ Keywords: fuzzy,fuzzy-logic,color,color-naming,color-palette,hsi,colibri
6
+ Author: coredegon
7
+ Author-email: coredegon <valikalik15@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
21
+ Classifier: Typing :: Typed
22
+ Requires-Dist: fuzlog==0.3.1
23
+ Requires-Dist: matplotlib>=3.9
24
+ Requires-Dist: numpy>=2.0
25
+ Requires-Python: >=3.12
26
+ Project-URL: Repository, https://github.com/coredegon/fuzcolor
27
+ Project-URL: Documentation, https://github.com/coredegon/fuzcolor/tree/main/docs
28
+ Project-URL: Issues, https://github.com/coredegon/fuzcolor/issues
29
+ Description-Content-Type: text/markdown
30
+
31
+ # fuzcolor
32
+
33
+ Simple and easy to use Python library for fuzzy color models: a pixel belongs
34
+ to every named color to a degree. Built on
35
+ [fuzlog](https://github.com/coredegon/fuzlog) and NumPy, typed, Python 3.12+.
36
+
37
+ ```sh
38
+ pip install fuzcolor
39
+ ```
40
+
41
+ ```python
42
+ import numpy as np
43
+ import fuzcolor as fc
44
+ from fuzcolor.models import COLIBRI
45
+
46
+ image = np.array(
47
+ [[[200, 30, 30], [250, 220, 40]], [[30, 60, 200], [60, 60, 60]]], dtype=np.uint8
48
+ )
49
+
50
+ print(COLIBRI) # Model('COLIBRI', HSI: 81 chromatic, 5 achromatic)
51
+ print(COLIBRI.label([200, 30, 30])) # red/high/dark
52
+ print(COLIBRI.label(image).tolist())
53
+ # [['red/high/dark', 'yellow/high/light'], ['blue/high/medium', 'dark gray']]
54
+
55
+ weights = fc.palette(COLIBRI, image) # the share of the image in each of 86 colors
56
+ fc.pie(COLIBRI, weights)
57
+ ```
58
+
59
+ ![An image, the color of every pixel and the palette of the image](https://raw.githubusercontent.com/coredegon/fuzcolor/main/docs/img/palette.png)
60
+
61
+ ## Inside
62
+
63
+ | | |
64
+ |---|---|
65
+ | **Models** | `Model` names colors as combinations of the terms of fuzlog variables |
66
+ | **Spaces** | `Space` describes the channels of a color space; `HSI` comes with the library |
67
+ | **Features** | `palette` describes an image, `tabulate` works a model out for every 8-bit color, `plot`, `swatches` and `pie` draw |
68
+ | **`fuzcolor.models`** | the models that come with the library: `COLIBRI` |
69
+ | **`fuzcolor.math`** | the color conversions as plain NumPy functions |
70
+
71
+ ## Documentation
72
+
73
+ - [Guide](https://github.com/coredegon/fuzcolor/blob/main/docs/guide.md): how to use a model and how to build one
74
+ - [COLIBRI](https://github.com/coredegon/fuzcolor/blob/main/docs/colibri.md): the model, its colors and its parameters
75
+ - [Reference](https://github.com/coredegon/fuzcolor/blob/main/docs/reference.md): every public name on one page
76
+
77
+ ## Development
78
+
79
+ ```sh
80
+ uv sync # environment with the dev tools
81
+ uv run pytest # tests, including every example in the documentation
82
+ uv run ruff check # lint; `uv run ruff format` formats
83
+ uv run ty check # types
84
+ ```
85
+
86
+ MIT license.
@@ -0,0 +1,56 @@
1
+ # fuzcolor
2
+
3
+ Simple and easy to use Python library for fuzzy color models: a pixel belongs
4
+ to every named color to a degree. Built on
5
+ [fuzlog](https://github.com/coredegon/fuzlog) and NumPy, typed, Python 3.12+.
6
+
7
+ ```sh
8
+ pip install fuzcolor
9
+ ```
10
+
11
+ ```python
12
+ import numpy as np
13
+ import fuzcolor as fc
14
+ from fuzcolor.models import COLIBRI
15
+
16
+ image = np.array(
17
+ [[[200, 30, 30], [250, 220, 40]], [[30, 60, 200], [60, 60, 60]]], dtype=np.uint8
18
+ )
19
+
20
+ print(COLIBRI) # Model('COLIBRI', HSI: 81 chromatic, 5 achromatic)
21
+ print(COLIBRI.label([200, 30, 30])) # red/high/dark
22
+ print(COLIBRI.label(image).tolist())
23
+ # [['red/high/dark', 'yellow/high/light'], ['blue/high/medium', 'dark gray']]
24
+
25
+ weights = fc.palette(COLIBRI, image) # the share of the image in each of 86 colors
26
+ fc.pie(COLIBRI, weights)
27
+ ```
28
+
29
+ ![An image, the color of every pixel and the palette of the image](https://raw.githubusercontent.com/coredegon/fuzcolor/main/docs/img/palette.png)
30
+
31
+ ## Inside
32
+
33
+ | | |
34
+ |---|---|
35
+ | **Models** | `Model` names colors as combinations of the terms of fuzlog variables |
36
+ | **Spaces** | `Space` describes the channels of a color space; `HSI` comes with the library |
37
+ | **Features** | `palette` describes an image, `tabulate` works a model out for every 8-bit color, `plot`, `swatches` and `pie` draw |
38
+ | **`fuzcolor.models`** | the models that come with the library: `COLIBRI` |
39
+ | **`fuzcolor.math`** | the color conversions as plain NumPy functions |
40
+
41
+ ## Documentation
42
+
43
+ - [Guide](https://github.com/coredegon/fuzcolor/blob/main/docs/guide.md): how to use a model and how to build one
44
+ - [COLIBRI](https://github.com/coredegon/fuzcolor/blob/main/docs/colibri.md): the model, its colors and its parameters
45
+ - [Reference](https://github.com/coredegon/fuzcolor/blob/main/docs/reference.md): every public name on one page
46
+
47
+ ## Development
48
+
49
+ ```sh
50
+ uv sync # environment with the dev tools
51
+ uv run pytest # tests, including every example in the documentation
52
+ uv run ruff check # lint; `uv run ruff format` formats
53
+ uv run ty check # types
54
+ ```
55
+
56
+ MIT license.
@@ -0,0 +1,91 @@
1
+ [project]
2
+ name = "fuzcolor"
3
+ version = "0.1.0"
4
+ description = "Simple and easy to use Python library for fuzzy color models."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ dependencies = [
10
+ "fuzlog==0.3.1",
11
+ "matplotlib>=3.9",
12
+ "numpy>=2.0",
13
+ ]
14
+ keywords = [
15
+ "fuzzy",
16
+ "fuzzy-logic",
17
+ "color",
18
+ "color-naming",
19
+ "color-palette",
20
+ "hsi",
21
+ "colibri",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 3 - Alpha",
25
+ "Intended Audience :: Developers",
26
+ "Intended Audience :: Science/Research",
27
+ "Operating System :: OS Independent",
28
+ "Programming Language :: Python :: 3 :: Only",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Programming Language :: Python :: 3.13",
31
+ "Programming Language :: Python :: 3.14",
32
+ "Programming Language :: Python :: 3.15",
33
+ "Topic :: Scientific/Engineering",
34
+ "Topic :: Scientific/Engineering :: Image Processing",
35
+ "Typing :: Typed",
36
+ ]
37
+
38
+ [[project.authors]]
39
+ name = "coredegon"
40
+ email = "valikalik15@gmail.com"
41
+
42
+ [project.urls]
43
+ Repository = "https://github.com/coredegon/fuzcolor"
44
+ Documentation = "https://github.com/coredegon/fuzcolor/tree/main/docs"
45
+ Issues = "https://github.com/coredegon/fuzcolor/issues"
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.13.7,<0.14.0"]
49
+ build-backend = "uv_build"
50
+
51
+ [dependency-groups]
52
+ dev = [
53
+ "pytest>=9.2.0",
54
+ "ruff>=0.17.5",
55
+ "ty>=0.0.104",
56
+ ]
57
+
58
+ [tool.ruff.lint]
59
+ select = [
60
+ "E",
61
+ "W",
62
+ "F",
63
+ "I",
64
+ "N",
65
+ "D",
66
+ "UP",
67
+ "B",
68
+ "C4",
69
+ "SIM",
70
+ "PT",
71
+ "NPY",
72
+ "PERF",
73
+ "RUF",
74
+ ]
75
+ ignore = [
76
+ "D105",
77
+ "D107",
78
+ ]
79
+
80
+ [tool.ruff.lint.pydocstyle]
81
+ convention = "google"
82
+
83
+ [tool.ruff.lint.per-file-ignores]
84
+ "tests/*" = ["D"]
85
+ "docs/*" = ["D"]
86
+
87
+ [tool.pytest]
88
+ minversion = "9.0"
89
+ strict = true
90
+ testpaths = ["tests"]
91
+ filterwarnings = ["error::RuntimeWarning"]
@@ -0,0 +1,92 @@
1
+ [project]
2
+ name = "fuzcolor"
3
+ version = "0.1.0"
4
+ description = "Simple and easy to use Python library for fuzzy color models."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "coredegon", email = "valikalik15@gmail.com" }
10
+ ]
11
+ requires-python = ">=3.12"
12
+ dependencies = [
13
+ "fuzlog==0.3.1", # its interface is not stable yet
14
+ "matplotlib>=3.9",
15
+ "numpy>=2.0",
16
+ ]
17
+ keywords = [
18
+ "fuzzy",
19
+ "fuzzy-logic",
20
+ "color",
21
+ "color-naming",
22
+ "color-palette",
23
+ "hsi",
24
+ "colibri",
25
+ ]
26
+ classifiers = [
27
+ "Development Status :: 3 - Alpha",
28
+ "Intended Audience :: Developers",
29
+ "Intended Audience :: Science/Research",
30
+ "Operating System :: OS Independent",
31
+ "Programming Language :: Python :: 3 :: Only",
32
+ "Programming Language :: Python :: 3.12",
33
+ "Programming Language :: Python :: 3.13",
34
+ "Programming Language :: Python :: 3.14",
35
+ "Programming Language :: Python :: 3.15",
36
+ "Topic :: Scientific/Engineering",
37
+ "Topic :: Scientific/Engineering :: Image Processing",
38
+ "Typing :: Typed",
39
+ ]
40
+
41
+ [project.urls]
42
+ Repository = "https://github.com/coredegon/fuzcolor"
43
+ Documentation = "https://github.com/coredegon/fuzcolor/tree/main/docs"
44
+ Issues = "https://github.com/coredegon/fuzcolor/issues"
45
+
46
+ [build-system]
47
+ requires = ["uv_build>=0.13.7,<0.14.0"]
48
+ build-backend = "uv_build"
49
+
50
+ [dependency-groups]
51
+ dev = [
52
+ "pytest>=9.2.0",
53
+ "ruff>=0.17.5",
54
+ "ty>=0.0.104",
55
+ ]
56
+
57
+ [tool.ruff.lint]
58
+ select = [
59
+ "E", "W", # pycodestyle
60
+ "F", # pyflakes
61
+ "I", # import order
62
+ "N", # naming
63
+ "D", # docstrings
64
+ "UP", # modern syntax
65
+ "B", # likely bugs
66
+ "C4", # comprehensions
67
+ "SIM", # simplifications
68
+ "PT", # pytest style
69
+ "NPY", # numpy
70
+ "PERF", # performance
71
+ "RUF", # ruff's own
72
+ ]
73
+ ignore = [
74
+ "D105", # operators are documented by their class
75
+ "D107", # and so is __init__
76
+ ]
77
+
78
+ [tool.ruff.lint.pydocstyle]
79
+ convention = "google"
80
+
81
+ [tool.ruff.lint.per-file-ignores]
82
+ "tests/*" = ["D"]
83
+ "docs/*" = ["D"]
84
+
85
+ # ty needs no table: it checks against the oldest Python in `requires-python`
86
+ # and fails on warnings by default.
87
+
88
+ [tool.pytest]
89
+ minversion = "9.0"
90
+ strict = true
91
+ testpaths = ["tests"]
92
+ filterwarnings = ["error::RuntimeWarning"] # a leaked overflow or 0/0 is a bug
@@ -0,0 +1,40 @@
1
+ """Fuzzy color models: the colors people name, as fuzzy sets.
2
+
3
+ ```python
4
+ import fuzcolor as fc
5
+ from fuzcolor.models import COLIBRI
6
+
7
+ COLIBRI.label([200, 30, 30]) # "red/high/dark"
8
+ weights = fc.palette(COLIBRI, image) # the share of the image in every color
9
+ fc.pie(COLIBRI, weights)
10
+ ```
11
+
12
+ - models: `Model`, built from the variables of fuzlog;
13
+ - spaces: `Space` and `HSI`;
14
+ - features: `palette`, `tabulate` and `Table`, the plots `plot`, `swatches`, `pie`;
15
+ - `fuzcolor.models`: the models that come with the library;
16
+ - `fuzcolor.math`: the color conversions as plain NumPy functions.
17
+ """
18
+
19
+ from fuzcolor import features, math, models
20
+ from fuzcolor.features.palettes import palette
21
+ from fuzcolor.features.plots import pie, plot, swatches
22
+ from fuzcolor.features.tables import Table, tabulate
23
+ from fuzcolor.model import Model, only
24
+ from fuzcolor.spaces import HSI, Space
25
+
26
+ __all__ = [
27
+ "HSI",
28
+ "Model",
29
+ "Space",
30
+ "Table",
31
+ "features",
32
+ "math",
33
+ "models",
34
+ "only",
35
+ "palette",
36
+ "pie",
37
+ "plot",
38
+ "swatches",
39
+ "tabulate",
40
+ ]
@@ -0,0 +1,6 @@
1
+ """What is built on top of a model: each feature stands on its own.
2
+
3
+ - `palettes`: how much of an image belongs to each color;
4
+ - `tables`: a model worked out for every 8-bit color;
5
+ - `plots`: matplotlib views of models and palettes.
6
+ """
@@ -0,0 +1,41 @@
1
+ """Palettes: how much of an image belongs to each color of a model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from numpy.typing import ArrayLike
7
+
8
+ from fuzcolor.math import Array
9
+ from fuzcolor.model import Model
10
+
11
+ _BLOCK = 1 << 15 # pixels described at once: bounds the pixels x colors degrees
12
+
13
+
14
+ def palette(model: Model, image: ArrayLike, *, crisp: bool = False) -> Array:
15
+ """Return the share of an image that belongs to each color of a model.
16
+
17
+ ```python
18
+ weights = fc.palette(model, image) # weights[i] is of model.colors[i]
19
+ dict(zip(model.colors, weights))
20
+ ```
21
+
22
+ The image has shape `(..., 3)`: 8-bit integers or floats in [0, 1]. Every
23
+ pixel adds its degrees, so a weight is the mean degree of the pixels in
24
+ that color. With `crisp` a pixel counts for its strongest color only, and
25
+ the weights are fractions of the pixels.
26
+ """
27
+ pixels = np.asarray(image)
28
+ if pixels.ndim == 0 or pixels.shape[-1] != 3:
29
+ raise ValueError(f"RGB needs a last axis of 3 components, got {pixels.shape}")
30
+ pixels = pixels.reshape(-1, 3)
31
+ if not len(pixels):
32
+ raise ValueError("an image without pixels has no palette")
33
+ totals = np.zeros(len(model.colors))
34
+ for start in range(0, len(pixels), _BLOCK):
35
+ block = pixels[start : start + _BLOCK]
36
+ if crisp:
37
+ best = model.classify(block)
38
+ totals += np.bincount(best[best >= 0], minlength=len(totals))
39
+ else:
40
+ totals += model(block).sum(axis=1)
41
+ return totals / len(pixels)
@@ -0,0 +1,153 @@
1
+ """Plots: matplotlib views of models and palettes.
2
+
3
+ Fonts, sizes and the colour of text follow matplotlib's configuration; what is
4
+ painted are the colors of the model themselves. Matplotlib is imported by the
5
+ first plot.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import itertools
11
+ import math
12
+ from typing import TYPE_CHECKING
13
+
14
+ import fuzlog as fz
15
+ import numpy as np
16
+ from numpy.typing import ArrayLike
17
+
18
+ from fuzcolor.model import Model
19
+
20
+ if TYPE_CHECKING:
21
+ from matplotlib.axes import Axes
22
+ from matplotlib.figure import Figure
23
+
24
+ _OTHER = (0.76, 0.76, 0.72) # the slice of the colors too small to show
25
+ _EDGE = "#898781"
26
+
27
+
28
+ def plot(model: Model) -> Figure:
29
+ """Draw the terms of a model: a row per family, a column per channel.
30
+
31
+ ```python
32
+ fc.plot(COLIBRI)
33
+ ```
34
+ """
35
+ import matplotlib.pyplot as plt
36
+
37
+ channels = list(model.space.channels)
38
+ figure, axes = plt.subplots(
39
+ len(model.families),
40
+ len(channels),
41
+ squeeze=False,
42
+ figsize=(4.2 * len(channels), 2.6 * len(model.families)),
43
+ layout="constrained",
44
+ )
45
+ for row, family in zip(axes, model.families, strict=True):
46
+ described = {variable.name: variable for variable in family.variables}
47
+ for ax, channel in zip(row, channels, strict=True):
48
+ if channel in described:
49
+ fz.plot(described[channel], ax)
50
+ else:
51
+ ax.set_axis_off()
52
+ shown = next(ax for ax in row if ax.axison)
53
+ shown.set_ylabel(f"{family.name}\nmembership")
54
+ figure.suptitle(f"{model.name}: {len(model.colors)} colors")
55
+ return figure
56
+
57
+
58
+ def swatches(model: Model) -> Figure:
59
+ """Draw the typical color of every color of a model, a panel per family.
60
+
61
+ ```python
62
+ fc.swatches(COLIBRI)
63
+ ```
64
+
65
+ The terms of a family's first variable run across, the combinations of the
66
+ terms of its other variables run down.
67
+ """
68
+ import matplotlib.pyplot as plt
69
+
70
+ across = [len(family.variables[0].terms) for family in model.families]
71
+ down = [
72
+ len(family.colors) // n
73
+ for family, n in zip(model.families, across, strict=True)
74
+ ]
75
+ figure, axes = plt.subplots(
76
+ len(model.families),
77
+ 1,
78
+ squeeze=False,
79
+ figsize=(1.1 * max(across) + 2.0, 0.45 * sum(down) + 0.9 * len(down)),
80
+ height_ratios=down,
81
+ layout="constrained",
82
+ )
83
+ blocks = np.split(model.prototypes(), np.cumsum(np.multiply(across, down))[:-1])
84
+ for ax, family, block, columns in zip(
85
+ axes[:, 0], model.families, blocks, across, strict=True
86
+ ):
87
+ first, *rest = family.variables
88
+ names = itertools.product(*(variable.terms for variable in rest))
89
+ grid = block.reshape(columns, -1, 3).swapaxes(0, 1)
90
+ ax.imshow(grid, aspect="auto")
91
+ ax.set_xticks(range(columns), labels=list(first.terms))
92
+ ax.set_yticks(range(len(grid)), labels=[", ".join(name) for name in names])
93
+ ax.xaxis.tick_top()
94
+ ax.set_xticks(np.arange(0.5, columns - 1), minor=True)
95
+ ax.set_yticks(np.arange(0.5, len(grid) - 1), minor=True)
96
+ ax.grid(which="minor", color=ax.get_facecolor(), linewidth=2)
97
+ ax.tick_params(which="both", length=0)
98
+ ax.set_title(family.name, loc="left")
99
+ ax.spines[:].set(color=_EDGE, linewidth=0.5)
100
+ figure.suptitle(f"{model.name}: {len(model.colors)} colors")
101
+ return figure
102
+
103
+
104
+ def pie(
105
+ model: Model, weights: ArrayLike, ax: Axes | None = None, *, least: float = 0.02
106
+ ) -> Axes:
107
+ """Draw a palette as a pie whose slices wear the colors they stand for.
108
+
109
+ ```python
110
+ fc.pie(COLIBRI, fc.palette(COLIBRI, image))
111
+ ```
112
+
113
+ Colors with a share below `least` are summed into one gray slice.
114
+ """
115
+ import matplotlib.pyplot as plt
116
+
117
+ shares = np.asarray(weights, dtype=np.float64)
118
+ if shares.shape != (len(model.colors),):
119
+ raise ValueError(
120
+ f"a palette of {model.name} has {len(model.colors)} weights, "
121
+ f"got the shape {shares.shape}"
122
+ )
123
+ ranked = np.argsort(-shares, kind="stable")
124
+ shown = [int(i) for i in ranked if shares[i] > 0.0 and shares[i] >= least]
125
+ sizes = shares[shown].tolist()
126
+ names = [model.colors[i] for i in shown]
127
+ fills = [tuple(rgb) for rgb in model.prototypes()[shown].tolist()]
128
+ rest = float(shares.sum()) - sum(sizes)
129
+ if rest > 1e-9:
130
+ sizes.append(rest)
131
+ names.append("other")
132
+ fills.append(_OTHER)
133
+ if ax is None:
134
+ _, ax = plt.subplots(figsize=(6, 6), layout="constrained")
135
+ _, _, percents = ax.pie(
136
+ sizes,
137
+ labels=names,
138
+ colors=fills,
139
+ autopct="%.0f%%",
140
+ pctdistance=0.8,
141
+ startangle=90,
142
+ counterclock=False,
143
+ wedgeprops={"edgecolor": _EDGE, "linewidth": 0.5},
144
+ )
145
+ for percent, fill in zip(percents, fills, strict=True):
146
+ dark = (
147
+ math.fsum(
148
+ w * c for w, c in zip((0.2126, 0.7152, 0.0722), fill, strict=True)
149
+ )
150
+ < 0.5
151
+ )
152
+ percent.set_color("white" if dark else "black")
153
+ return ax
@@ -0,0 +1,64 @@
1
+ """Tables: a model worked out once for every 8-bit color."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import fuzlog as fz
8
+ import numpy as np
9
+ from numpy.typing import ArrayLike
10
+
11
+ from fuzcolor.math import Array
12
+ from fuzcolor.model import Model
13
+
14
+ NONE = 255 # what a table holds for a pixel that belongs to no color
15
+
16
+
17
+ class Table(fz.Table):
18
+ """The strongest color of every 8-bit pixel, looked up; `tabulate` makes one.
19
+
20
+ ```python
21
+ table = fc.tabulate(model)
22
+ table(image) # as model.classify(image), for 8-bit pixels
23
+ table.save("colibri.npz")
24
+ table = fc.Table.load("colibri.npz")
25
+ ```
26
+
27
+ It is a fuzlog table over `red`, `green` and `blue` that also takes pixels
28
+ of shape `(..., 3)`. It answers with `uint8` positions in `model.colors`,
29
+ and with `NONE`, 255, for a pixel that belongs to no color.
30
+ """
31
+
32
+ __slots__ = ()
33
+
34
+ def __call__(self, rgb: ArrayLike | None = None, /, **inputs: ArrayLike) -> Any:
35
+ """Look up the strongest color of 8-bit pixels."""
36
+ if rgb is None:
37
+ return super().__call__(**inputs)
38
+ pixels = np.asarray(rgb)
39
+ if pixels.dtype != np.uint8 or pixels.ndim == 0 or pixels.shape[-1] != 3:
40
+ raise TypeError(
41
+ f"a table takes 8-bit pixels of shape (..., 3), "
42
+ f"got {pixels.dtype} of shape {pixels.shape}"
43
+ )
44
+ return self.values[pixels[..., 0], pixels[..., 1], pixels[..., 2]]
45
+
46
+
47
+ def tabulate(model: Model) -> Table:
48
+ """Classify every 8-bit color with a model and keep the answers in a `Table`.
49
+
50
+ ```python
51
+ table = fc.tabulate(model) # 256 x 256 x 256 of uint8: 16 MB, in seconds
52
+ ```
53
+ """
54
+ if len(model.colors) > NONE:
55
+ raise ValueError(f"a table counts up to {NONE} colors, got {len(model.colors)}")
56
+
57
+ def strongest(red: Array, green: Array, blue: Array) -> Array:
58
+ best = model.classify(np.stack([red, green, blue], axis=-1) / 255.0)
59
+ return np.where(best < 0, NONE, best)
60
+
61
+ levels = (0, 255, 256)
62
+ axes = {"red": levels, "green": levels, "blue": levels}
63
+ made = fz.tabulate(strongest, axes, method="nearest", dtype=np.uint8)
64
+ return Table(made.axes, made.values, made.method, model.name)
@@ -0,0 +1,67 @@
1
+ """Color conversions as plain NumPy functions.
2
+
3
+ Colors are arrays whose last axis holds the components. RGB with components in
4
+ [0, 1] is the exchange format: every other space converts from and to it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ import numpy as np
12
+ from numpy.typing import ArrayLike, NDArray
13
+
14
+ type Array = NDArray[np.float64]
15
+
16
+ _SQRT_3 = math.sqrt(3.0)
17
+
18
+
19
+ def as_rgb(colors: ArrayLike) -> Array:
20
+ """Return colors as float RGB in [0, 1]: whole numbers are 8-bit levels."""
21
+ given = np.asarray(colors)
22
+ if given.ndim == 0 or given.shape[-1] != 3:
23
+ raise ValueError(f"RGB needs a last axis of 3 components, got {given.shape}")
24
+ if np.issubdtype(given.dtype, np.integer):
25
+ return given / 255.0
26
+ return given.astype(np.float64, copy=False)
27
+
28
+
29
+ def rgb_to_hsi(rgb: Array) -> Array:
30
+ """Convert RGB in [0, 1] to hue in [0, 360), saturation and intensity in [0, 1].
31
+
32
+ Hue is the angle of the color around the gray axis, 0 for red. Grays get
33
+ hue 0 and black gets saturation 0.
34
+ """
35
+ red, green, blue = rgb[..., 0], rgb[..., 1], rgb[..., 2]
36
+ intensity = (red + green + blue) / 3.0
37
+ weakest = np.minimum(np.minimum(red, green), blue)
38
+ gray = np.divide(
39
+ weakest, intensity, out=np.ones_like(intensity), where=intensity > 0
40
+ )
41
+ saturation = np.clip(1.0 - gray, 0.0, 1.0)
42
+ angle = np.arctan2(_SQRT_3 * (green - blue), 2.0 * red - green - blue)
43
+ hue = np.mod(np.degrees(angle), 360.0)
44
+ hue = np.where(hue < 360.0, hue, 0.0) # mod rounds tiny negative angles up to 360
45
+ return np.stack([hue, saturation, intensity], axis=-1)
46
+
47
+
48
+ def hsi_to_rgb(hsi: Array) -> Array:
49
+ """Convert hue in degrees, saturation and intensity in [0, 1] to RGB.
50
+
51
+ Not every combination is a color: a light one cannot be saturated. Such
52
+ combinations give components above 1.
53
+ """
54
+ hue, saturation, intensity = hsi[..., 0], hsi[..., 1], hsi[..., 2]
55
+ # Each third of the hue circle has its own weakest primary: blue, red, green.
56
+ third = np.floor_divide(hue, 120.0) % 3
57
+ angle = np.radians(hue - 120.0 * third)
58
+ weakest = intensity * (1.0 - saturation)
59
+ leading = intensity * (
60
+ 1.0 + saturation * np.cos(angle) / np.cos(math.pi / 3 - angle)
61
+ )
62
+ remaining = 3.0 * intensity - (weakest + leading)
63
+ first, second = third == 0, third == 1
64
+ red = np.select([first, second], [leading, weakest], remaining)
65
+ green = np.select([first, second], [remaining, leading], weakest)
66
+ blue = np.select([first, second], [weakest, remaining], leading)
67
+ return np.stack([red, green, blue], axis=-1)
@@ -0,0 +1,229 @@
1
+ """Fuzzy color models: colors as combinations of the terms of fuzlog variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ import math
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import Any
9
+
10
+ import fuzlog as fz
11
+ import numpy as np
12
+ from fuzlog.types import Norm
13
+ from numpy.typing import ArrayLike, NDArray
14
+
15
+ from fuzcolor.math import Array, as_rgb
16
+ from fuzcolor.spaces import Space
17
+
18
+
19
+ def only(variable: fz.Variable, *terms: str) -> fz.Variable:
20
+ """Return a variable with just the named terms of another, in that order.
21
+
22
+ ```python
23
+ only(saturation, "low", "medium", "high")
24
+ ```
25
+ """
26
+ unknown = [term for term in terms if term not in variable.terms]
27
+ if unknown or not terms:
28
+ raise ValueError(
29
+ f"{variable.name!r} has no terms {unknown}, only {list(variable.terms)}"
30
+ )
31
+ kept = {term: variable.terms[term] for term in terms}
32
+ return fz.Variable(
33
+ variable.name, variable.universe, kept, periodic=variable.periodic
34
+ )
35
+
36
+
37
+ class Family:
38
+ """The colors made of one term from each variable; a `Model` makes its own.
39
+
40
+ A color is named after the terms that tell it apart from the others: a
41
+ variable with one term stays out of the name, and a family of one color
42
+ takes the name of the family.
43
+ """
44
+
45
+ __slots__ = ("colors", "name", "variables")
46
+
47
+ def __init__(self, name: str, *variables: fz.Variable) -> None:
48
+ channels = [variable.name for variable in variables]
49
+ if not variables or len(set(channels)) < len(channels):
50
+ raise ValueError(
51
+ f"the family {name!r} needs variables of different channels, "
52
+ f"got {channels}"
53
+ )
54
+ self.name = name
55
+ self.variables = variables
56
+ telling = [len(variable.terms) > 1 for variable in variables]
57
+ self.colors = tuple(
58
+ "/".join(itertools.compress(terms, telling)) or name
59
+ for terms in itertools.product(*(variable.terms for variable in variables))
60
+ )
61
+
62
+ def degrees(
63
+ self, channels: Mapping[str, Array], tnorm: Norm = np.multiply
64
+ ) -> Array:
65
+ """Return the degree of every color: shape `(len(colors), *shape)`.
66
+
67
+ `channels` holds the values of every channel by name; the degrees of
68
+ the terms of a color are joined by `tnorm`.
69
+ """
70
+ terms = [variable(channels[variable.name]) for variable in self.variables]
71
+ out = terms[0]
72
+ for more in terms[1:]: # every color so far with every term of the next
73
+ out = tnorm(out[:, None], more[None]).reshape(-1, *more.shape[1:])
74
+ return out
75
+
76
+ def strongest(
77
+ self, channels: Mapping[str, Array], tnorm: Norm = np.multiply
78
+ ) -> tuple[NDArray[np.intp], Array]:
79
+ """Return the position of the strongest color and its degree.
80
+
81
+ A t-norm never decreases, so the strongest color is made of the
82
+ strongest term of every variable: no other color has to be worked out.
83
+ """
84
+ terms = [variable(channels[variable.name]) for variable in self.variables]
85
+ position, degree = terms[0].argmax(axis=0), terms[0].max(axis=0)
86
+ for more in terms[1:]:
87
+ position = position * len(more) + more.argmax(axis=0)
88
+ degree = tnorm(degree, more.max(axis=0))
89
+ return position, degree
90
+
91
+ def __repr__(self) -> str:
92
+ return f"Family({self.name!r}: {' x '.join(v.name for v in self.variables)})"
93
+
94
+
95
+ class Model:
96
+ """A fuzzy color model: families of colors named in a color space.
97
+
98
+ ```python
99
+ model = fc.Model(
100
+ "toy",
101
+ fc.HSI,
102
+ tint=[hue, fc.only(saturation, "pale", "vivid")],
103
+ gray=[intensity, fc.only(saturation, "gray")],
104
+ )
105
+ model.colors # ("red/pale", "red/vivid", ..., "dark", "light")
106
+ model([200, 30, 30]) # the degree of the pixel in every color
107
+ model.label([200, 30, 30]) # "red/vivid": its strongest color
108
+ ```
109
+
110
+ A family is a list of fuzlog variables, each named after the channel of the
111
+ space that it describes; its colors take one term from each variable.
112
+ Pixels have shape `(..., 3)`: 8-bit integers or floats in [0, 1].
113
+
114
+ Args:
115
+ name: What the model is called.
116
+ space: The color space whose channels the variables describe.
117
+ tnorm: How the degrees of the terms of a color are joined. With the
118
+ product, families of partitions give degrees that sum to 1.
119
+ **families: The variables of every family, by the name of the family.
120
+ """
121
+
122
+ __slots__ = ("_names", "colors", "families", "name", "space", "tnorm")
123
+
124
+ def __init__(
125
+ self,
126
+ name: str,
127
+ space: Space,
128
+ /,
129
+ *,
130
+ tnorm: Norm = np.multiply,
131
+ **families: Sequence[fz.Variable],
132
+ ) -> None:
133
+ made = tuple(Family(key, *variables) for key, variables in families.items())
134
+ for family in made:
135
+ for variable in family.variables:
136
+ if space.channels.get(variable.name) != variable.universe:
137
+ raise ValueError(
138
+ f"{family.name!r}: {variable.name!r} on {variable.universe} "
139
+ f"is not a channel of {space.name}: {space.channels}"
140
+ )
141
+ colors = tuple(color for family in made for color in family.colors)
142
+ if not colors or len(set(colors)) < len(colors):
143
+ raise ValueError(f"{name!r} needs colors that differ, got {colors}")
144
+ self.name = name
145
+ self.space = space
146
+ self.families = made
147
+ self.tnorm = tnorm
148
+ self.colors = colors
149
+ self._names = np.array([*colors, ""]) # the last is what -1 counts to
150
+
151
+ def __call__(self, rgb: ArrayLike) -> Array:
152
+ """Return the degree of the pixels in every color.
153
+
154
+ The shape is `(len(colors), *rgb.shape[:-1])`, so the result unpacks
155
+ into one array per color.
156
+ """
157
+ channels = self._channels(rgb)
158
+ blocks = [family.degrees(channels, self.tnorm) for family in self.families]
159
+ return np.concatenate(blocks)
160
+
161
+ def classify(self, rgb: ArrayLike) -> NDArray[np.intp]:
162
+ """Return the position in `colors` of the strongest color of every pixel.
163
+
164
+ A pixel that belongs to no color gets -1.
165
+ """
166
+ channels = self._channels(rgb)
167
+ first = 0
168
+ best: NDArray[np.intp] = np.intp(-1) # ty: ignore[invalid-assignment]
169
+ degree: Array = np.float64(0.0) # ty: ignore[invalid-assignment]
170
+ for family in self.families:
171
+ position, strength = family.strongest(channels, self.tnorm)
172
+ best = np.where(strength > degree, first + position, best)
173
+ degree = np.maximum(degree, strength)
174
+ first += len(family.colors)
175
+ return best
176
+
177
+ def label(self, rgb: ArrayLike) -> Any:
178
+ """Return the name of the strongest color of every pixel.
179
+
180
+ One pixel gives a string, more give an array of strings; a pixel that
181
+ belongs to no color gets an empty name.
182
+ """
183
+ names = self._names[self.classify(rgb)]
184
+ return names if names.ndim else str(names)
185
+
186
+ def _channels(self, rgb: ArrayLike) -> dict[str, Array]:
187
+ """The values of every channel of the space, by name."""
188
+ values = self.space.from_rgb(as_rgb(rgb))
189
+ return {name: values[..., i] for i, name in enumerate(self.space.channels)}
190
+
191
+ def prototypes(self) -> Array:
192
+ """Return a typical RGB color for every color: shape `(len(colors), 3)`.
193
+
194
+ A channel takes the middle of the core of the color's term, or the
195
+ middle of its range if the family does not describe it. What is too
196
+ bright to be a color is darkened until it fits into RGB.
197
+ """
198
+ middle = [(low + high) / 2 for low, high in self.space.channels.values()]
199
+ places = {channel: i for i, channel in enumerate(self.space.channels)}
200
+ rows = []
201
+ for family in self.families:
202
+ centers = [_centers(variable).tolist() for variable in family.variables]
203
+ for values in itertools.product(*centers):
204
+ row = list(middle)
205
+ for variable, value in zip(family.variables, values, strict=True):
206
+ row[places[variable.name]] = value
207
+ rows.append(row)
208
+ rgb = self.space.to_rgb(np.array(rows))
209
+ return np.clip(rgb / np.maximum(rgb.max(axis=-1, keepdims=True), 1.0), 0.0, 1.0)
210
+
211
+ def __repr__(self) -> str:
212
+ families = ", ".join(f"{len(f.colors)} {f.name}" for f in self.families)
213
+ return f"Model({self.name!r}, {self.space.name}: {families})"
214
+
215
+
216
+ def _centers(variable: fz.Variable) -> Array:
217
+ """The most typical value of every term: the middle of where it is highest.
218
+
219
+ On a periodic universe the middle is found going around the circle.
220
+ """
221
+ x = variable.grid(4001)
222
+ degrees = variable(x)
223
+ core = (degrees >= degrees.max(axis=1, keepdims=True) - 1e-9).astype(np.float64)
224
+ if not variable.periodic:
225
+ return (core @ x) / core.sum(axis=1)
226
+ low, high = variable.universe
227
+ angle = (x - low) * (math.tau / (high - low))
228
+ middle = np.arctan2(core @ np.sin(angle), core @ np.cos(angle)) % math.tau
229
+ return low + middle * ((high - low) / math.tau)
@@ -0,0 +1,5 @@
1
+ """The models that come with the library, one module each."""
2
+
3
+ from fuzcolor.models.colibri import COLIBRI
4
+
5
+ __all__ = ["COLIBRI"]
@@ -0,0 +1,69 @@
1
+ """COLIBRI: the color categories of people, on the HSI space.
2
+
3
+ P. Shamoi et al., "COLIBRI Fuzzy Model: Color Linguistic-Based Representation
4
+ and Interpretation", IEEE Access 13, 2025 (arXiv:2507.11488). The partitions
5
+ of hue, saturation and intensity come from a large survey of how people name
6
+ colors.
7
+
8
+ A color of very low saturation is achromatic and named after its intensity
9
+ alone: black, dark gray, gray, light gray or white. Every other color is
10
+ chromatic and named after its hue, its saturation and a coarser intensity,
11
+ which gives 9 x 3 x 3 + 5 = 86 colors.
12
+ """
13
+
14
+ import fuzlog as fz
15
+
16
+ from fuzcolor.model import Model, only
17
+ from fuzcolor.spaces import HSI
18
+
19
+ HUE = fz.partition(
20
+ "hue",
21
+ (0, 360),
22
+ {
23
+ "red": (350, 5),
24
+ "orange": (20, 35),
25
+ "yellow": 45,
26
+ "green": (65, 130),
27
+ "cyan": 175,
28
+ "light blue": 190,
29
+ "blue": (215, 240),
30
+ "violet": (270, 280),
31
+ "magenta": (320, 330),
32
+ },
33
+ periodic=True,
34
+ )
35
+
36
+ SATURATION = fz.partition(
37
+ "saturation",
38
+ (0, 1),
39
+ {"very low": 0, "low": 0.15, "medium": (0.375, 0.45), "high": (0.725, 1)},
40
+ )
41
+
42
+ INTENSITY = fz.partition(
43
+ "intensity",
44
+ (0, 1),
45
+ {
46
+ "black": (0, 30 / 255),
47
+ "dark gray": 75 / 255,
48
+ "gray": (110 / 255, 115 / 255),
49
+ "light gray": (190 / 255, 215 / 255),
50
+ "white": 1,
51
+ },
52
+ )
53
+
54
+ # Black with dark gray, gray, light gray with white: neighbours of a partition
55
+ # add up to a term whose core runs from the first core to the last.
56
+ SHADE = fz.partition(
57
+ "intensity",
58
+ (0, 1),
59
+ dark=(0, 75 / 255),
60
+ medium=(110 / 255, 115 / 255),
61
+ light=(190 / 255, 1),
62
+ )
63
+
64
+ COLIBRI = Model(
65
+ "COLIBRI",
66
+ HSI,
67
+ chromatic=[HUE, only(SATURATION, "low", "medium", "high"), SHADE],
68
+ achromatic=[INTENSITY, only(SATURATION, "very low")],
69
+ )
File without changes
@@ -0,0 +1,39 @@
1
+ """Color spaces: named channels and the way from and to RGB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import NamedTuple
7
+
8
+ from fuzcolor.math import Array, hsi_to_rgb, rgb_to_hsi
9
+
10
+
11
+ class Space(NamedTuple):
12
+ """A coordinate system for colors.
13
+
14
+ ```python
15
+ HSI = Space(
16
+ "HSI",
17
+ {"hue": (0, 360), "saturation": (0, 1), "intensity": (0, 1)},
18
+ rgb_to_hsi,
19
+ hsi_to_rgb,
20
+ )
21
+ ```
22
+
23
+ `channels` gives every coordinate its range, in the order of the last axis
24
+ that `from_rgb` returns and `to_rgb` takes. Both work on float arrays of
25
+ shape `(..., 3)`, with RGB in [0, 1].
26
+ """
27
+
28
+ name: str
29
+ channels: dict[str, tuple[float, float]]
30
+ from_rgb: Callable[[Array], Array]
31
+ to_rgb: Callable[[Array], Array]
32
+
33
+
34
+ HSI = Space(
35
+ "HSI",
36
+ {"hue": (0.0, 360.0), "saturation": (0.0, 1.0), "intensity": (0.0, 1.0)},
37
+ rgb_to_hsi,
38
+ hsi_to_rgb,
39
+ )