fusebead 0.1.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.
- fusebead/__init__.py +19 -0
- fusebead/__main__.py +4 -0
- fusebead/cli.py +72 -0
- fusebead/color.py +93 -0
- fusebead/palettes.py +135 -0
- fusebead/pattern.py +187 -0
- fusebead/render.py +138 -0
- fusebead-0.1.0.dist-info/METADATA +80 -0
- fusebead-0.1.0.dist-info/RECORD +13 -0
- fusebead-0.1.0.dist-info/WHEEL +5 -0
- fusebead-0.1.0.dist-info/entry_points.txt +2 -0
- fusebead-0.1.0.dist-info/licenses/LICENSE +21 -0
- fusebead-0.1.0.dist-info/top_level.txt +1 -0
fusebead/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Fuse-bead patterns from images.
|
|
2
|
+
|
|
3
|
+
The matching rules follow the ROCKIMG pattern maker:
|
|
4
|
+
https://www.rockimg.com/perler-bead-pattern-maker.html
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from fusebead.palettes import PALETTES, BeadColor, Palette, get_palette
|
|
8
|
+
from fusebead.pattern import ColorCount, Pattern, fit_grid
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BeadColor",
|
|
13
|
+
"ColorCount",
|
|
14
|
+
"PALETTES",
|
|
15
|
+
"Palette",
|
|
16
|
+
"Pattern",
|
|
17
|
+
"fit_grid",
|
|
18
|
+
"get_palette",
|
|
19
|
+
]
|
fusebead/__main__.py
ADDED
fusebead/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Command line for fuse-bead patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from fusebead.palettes import PALETTES
|
|
9
|
+
from fusebead.pattern import Pattern
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="fusebead",
|
|
15
|
+
description="Turn a photo into a fuse-bead pattern and a color-count chart.",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument("image", help="Input image path")
|
|
18
|
+
parser.add_argument("-o", "--output", required=True, help="Output PNG path")
|
|
19
|
+
parser.add_argument("--csv", help="Also write a color-count CSV")
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--palette",
|
|
22
|
+
default="perler",
|
|
23
|
+
choices=sorted(PALETTES),
|
|
24
|
+
help="Bead palette (default: perler)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("--width", type=int, default=50, help="Max grid width (default: 50)")
|
|
27
|
+
parser.add_argument("--height", type=int, default=50, help="Max grid height (default: 50)")
|
|
28
|
+
parser.add_argument("--brightness", type=int, default=0, help="Brightness offset, site range -80 to 80")
|
|
29
|
+
parser.add_argument("--contrast", type=int, default=0, help="Contrast offset, site range -80 to 80")
|
|
30
|
+
parser.add_argument("--remove-bg", action="store_true", help="Drop near-white and transparent cells")
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--bg-threshold",
|
|
33
|
+
type=int,
|
|
34
|
+
default=40,
|
|
35
|
+
help="How neutral a near-white pixel must be to count as background (default: 40)",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument("--no-grid", action="store_true", help="Draw beads without the 1px gap")
|
|
38
|
+
parser.add_argument("--bead-size", type=int, help="Bead diameter in pixels (default: fit source width)")
|
|
39
|
+
return parser
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(argv: list[str] | None = None) -> int:
|
|
43
|
+
parser = build_parser()
|
|
44
|
+
args = parser.parse_args(argv)
|
|
45
|
+
try:
|
|
46
|
+
pattern = Pattern.from_image(
|
|
47
|
+
args.image,
|
|
48
|
+
palette=args.palette,
|
|
49
|
+
grid_width=args.width,
|
|
50
|
+
grid_height=args.height,
|
|
51
|
+
brightness=args.brightness,
|
|
52
|
+
contrast=args.contrast,
|
|
53
|
+
remove_background=args.remove_bg,
|
|
54
|
+
background_threshold=args.bg_threshold,
|
|
55
|
+
)
|
|
56
|
+
pattern.save_png(args.output, show_grid=not args.no_grid, bead_size=args.bead_size)
|
|
57
|
+
if args.csv:
|
|
58
|
+
pattern.save_csv(args.csv)
|
|
59
|
+
except (OSError, ValueError) as exc:
|
|
60
|
+
print(f"fusebead: {exc}", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
print(f"grid: {pattern.width} x {pattern.height}")
|
|
64
|
+
print(f"beads: {pattern.total}")
|
|
65
|
+
print(f"colors: {len(pattern.entries)}")
|
|
66
|
+
for entry in pattern.entries[:8]:
|
|
67
|
+
pct = entry.percent(pattern.total)
|
|
68
|
+
print(f" {entry.color.code} {entry.color.name}: {entry.count} ({pct:.1f}%)")
|
|
69
|
+
print(f"png: {args.output}")
|
|
70
|
+
if args.csv:
|
|
71
|
+
print(f"csv: {args.csv}")
|
|
72
|
+
return 0
|
fusebead/color.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Color conversion and nearest-bead matching.
|
|
2
|
+
|
|
3
|
+
The formulas match the ROCKIMG fuse-bead tool: sRGB to CIE Lab, then CIE76
|
|
4
|
+
distance. Ties keep the earlier palette color.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from typing import Sequence
|
|
12
|
+
|
|
13
|
+
from fusebead.palettes import BeadColor, Palette
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def js_round(value: float) -> int:
|
|
17
|
+
"""Round half toward +infinity, matching JavaScript Math.round."""
|
|
18
|
+
return math.floor(value + 0.5)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def clamp(value: int, low: int, high: int) -> int:
|
|
22
|
+
return low if value < low else high if value > high else value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def srgb_to_linear(channel: float) -> float:
|
|
26
|
+
channel /= 255.0
|
|
27
|
+
if channel <= 0.04045:
|
|
28
|
+
return channel / 12.92
|
|
29
|
+
return ((channel + 0.055) / 1.055) ** 2.4
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def rgb_to_lab(r: float, g: float, b: float) -> tuple[float, float, float]:
|
|
33
|
+
rl = srgb_to_linear(r)
|
|
34
|
+
gl = srgb_to_linear(g)
|
|
35
|
+
bl = srgb_to_linear(b)
|
|
36
|
+
x = (rl * 0.4124564 + gl * 0.3575761 + bl * 0.1804375) / 0.95047
|
|
37
|
+
y = rl * 0.2126729 + gl * 0.7151522 + bl * 0.0721750
|
|
38
|
+
z = (rl * 0.0193339 + gl * 0.1191920 + bl * 0.9503041) / 1.08883
|
|
39
|
+
|
|
40
|
+
def f(t: float) -> float:
|
|
41
|
+
return t ** (1 / 3) if t > 0.008856 else (7.787 * t) + 16 / 116
|
|
42
|
+
|
|
43
|
+
fx, fy, fz = f(x), f(y), f(z)
|
|
44
|
+
return (116 * fy) - 16, 500 * (fx - fy), 200 * (fy - fz)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def delta_e(lab1: Sequence[float], lab2: Sequence[float]) -> float:
|
|
48
|
+
d_l = lab1[0] - lab2[0]
|
|
49
|
+
d_a = lab1[1] - lab2[1]
|
|
50
|
+
d_b = lab1[2] - lab2[2]
|
|
51
|
+
return math.sqrt(d_l * d_l + d_a * d_a + d_b * d_b)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def adjust_rgb(
|
|
55
|
+
r: int, g: int, b: int, brightness: int, contrast: int
|
|
56
|
+
) -> tuple[int, int, int]:
|
|
57
|
+
"""Match the site slider: contrast around 128, then brightness offset."""
|
|
58
|
+
if contrast >= 259:
|
|
59
|
+
raise ValueError("contrast must be less than 259")
|
|
60
|
+
factor = (259 * (contrast + 255)) / (255 * (259 - contrast))
|
|
61
|
+
|
|
62
|
+
def channel(value: int) -> int:
|
|
63
|
+
adjusted = js_round(factor * (value - 128) + 128 + brightness)
|
|
64
|
+
return clamp(adjusted, 0, 255)
|
|
65
|
+
|
|
66
|
+
return channel(r), channel(g), channel(b)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_near_white(r: int, g: int, b: int, threshold: int) -> bool:
|
|
70
|
+
peak = r if r >= g and r >= b else g if g >= b else b
|
|
71
|
+
floor = r if r <= g and r <= b else g if g <= b else b
|
|
72
|
+
return peak > 240 and (peak - floor) < threshold
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@lru_cache(maxsize=8)
|
|
76
|
+
def palette_entries(
|
|
77
|
+
palette: Palette,
|
|
78
|
+
) -> tuple[tuple[BeadColor, tuple[float, float, float]], ...]:
|
|
79
|
+
return tuple((color, rgb_to_lab(color.r, color.g, color.b)) for color in palette.colors)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def nearest_color(r: int, g: int, b: int, palette: Palette) -> BeadColor:
|
|
83
|
+
target = rgb_to_lab(r, g, b)
|
|
84
|
+
best: BeadColor | None = None
|
|
85
|
+
best_dist = math.inf
|
|
86
|
+
for color, lab in palette_entries(palette):
|
|
87
|
+
dist = delta_e(target, lab)
|
|
88
|
+
if dist < best_dist:
|
|
89
|
+
best_dist = dist
|
|
90
|
+
best = color
|
|
91
|
+
if best is None:
|
|
92
|
+
raise ValueError("palette is empty")
|
|
93
|
+
return best
|
fusebead/palettes.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Approximate fuse-bead palettes used by the ROCKIMG pattern tool.
|
|
2
|
+
|
|
3
|
+
Perler, Hama, and Artkal are trademarks of their owners. This package is not
|
|
4
|
+
affiliated with those brands. The RGB values are the approximations shipped
|
|
5
|
+
with the website tool, not official manufacturer color cards.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class BeadColor:
|
|
15
|
+
code: str
|
|
16
|
+
name: str
|
|
17
|
+
r: int
|
|
18
|
+
g: int
|
|
19
|
+
b: int
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def rgb(self) -> tuple[int, int, int]:
|
|
23
|
+
return self.r, self.g, self.b
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Palette:
|
|
28
|
+
key: str
|
|
29
|
+
colors: tuple[BeadColor, ...]
|
|
30
|
+
|
|
31
|
+
def by_code(self) -> dict[str, BeadColor]:
|
|
32
|
+
return {color.code: color for color in self.colors}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _palette(key: str, rows: list[tuple[str, str, int, int, int]]) -> Palette:
|
|
36
|
+
colors = tuple(BeadColor(code, name, r, g, b) for code, name, r, g, b in rows)
|
|
37
|
+
return Palette(key, colors)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
PALETTES: dict[str, Palette] = {
|
|
41
|
+
"perler": _palette(
|
|
42
|
+
"perler",
|
|
43
|
+
[
|
|
44
|
+
("80-19002", "White", 255, 255, 255),
|
|
45
|
+
("80-19001", "Black", 28, 28, 28),
|
|
46
|
+
("80-19005", "Red", 227, 0, 15),
|
|
47
|
+
("80-19003", "Yellow", 255, 230, 0),
|
|
48
|
+
("80-19004", "Orange", 255, 109, 0),
|
|
49
|
+
("80-19017", "Blue", 0, 102, 179),
|
|
50
|
+
("80-19010", "Green", 0, 166, 81),
|
|
51
|
+
("80-19007", "Purple", 155, 89, 182),
|
|
52
|
+
("80-19019", "Pink", 255, 105, 180),
|
|
53
|
+
("80-19012", "Brown", 139, 69, 19),
|
|
54
|
+
("80-19018", "Gray", 128, 128, 128),
|
|
55
|
+
("80-19022", "Light Gray", 192, 192, 192),
|
|
56
|
+
("80-19023", "Dark Blue", 0, 51, 102),
|
|
57
|
+
("80-19024", "Light Blue", 135, 206, 235),
|
|
58
|
+
("80-19025", "Tan", 210, 180, 140),
|
|
59
|
+
("80-19026", "Cream", 255, 253, 208),
|
|
60
|
+
("80-19027", "Peach", 255, 218, 185),
|
|
61
|
+
("80-19028", "Coral", 255, 127, 80),
|
|
62
|
+
("80-19029", "Magenta", 255, 0, 255),
|
|
63
|
+
("80-19030", "Lime", 50, 205, 50),
|
|
64
|
+
("80-19031", "Teal", 0, 128, 128),
|
|
65
|
+
("80-19032", "Navy", 0, 0, 128),
|
|
66
|
+
("80-19033", "Gold", 255, 215, 0),
|
|
67
|
+
("80-19034", "Sand", 194, 178, 128),
|
|
68
|
+
("80-19035", "Rust", 183, 65, 14),
|
|
69
|
+
("80-19036", "Wine", 114, 47, 55),
|
|
70
|
+
("80-19037", "Sky", 135, 206, 250),
|
|
71
|
+
("80-19038", "Mint", 152, 255, 152),
|
|
72
|
+
("80-19039", "Lavender", 230, 230, 250),
|
|
73
|
+
("80-19040", "Beige", 245, 245, 220),
|
|
74
|
+
],
|
|
75
|
+
),
|
|
76
|
+
"hama": _palette(
|
|
77
|
+
"hama",
|
|
78
|
+
[
|
|
79
|
+
("H01", "White", 255, 255, 255),
|
|
80
|
+
("H02", "Black", 0, 0, 0),
|
|
81
|
+
("H03", "Red", 237, 28, 36),
|
|
82
|
+
("H04", "Blue", 0, 102, 179),
|
|
83
|
+
("H05", "Green", 0, 166, 81),
|
|
84
|
+
("H06", "Yellow", 255, 242, 0),
|
|
85
|
+
("H07", "Orange", 255, 127, 39),
|
|
86
|
+
("H08", "Brown", 139, 69, 19),
|
|
87
|
+
("H09", "Pink", 255, 174, 201),
|
|
88
|
+
("H10", "Purple", 128, 0, 128),
|
|
89
|
+
("H11", "Light Blue", 135, 206, 235),
|
|
90
|
+
("H12", "Light Green", 152, 255, 152),
|
|
91
|
+
("H13", "Gray", 128, 128, 128),
|
|
92
|
+
("H14", "Light Gray", 192, 192, 192),
|
|
93
|
+
("H15", "Tan", 210, 180, 140),
|
|
94
|
+
("H16", "Cream", 255, 253, 208),
|
|
95
|
+
("H17", "Peach", 255, 218, 185),
|
|
96
|
+
("H18", "Coral", 255, 127, 80),
|
|
97
|
+
("H19", "Teal", 0, 128, 128),
|
|
98
|
+
("H20", "Navy", 0, 0, 128),
|
|
99
|
+
],
|
|
100
|
+
),
|
|
101
|
+
"artkal": _palette(
|
|
102
|
+
"artkal",
|
|
103
|
+
[
|
|
104
|
+
("A01", "White", 255, 255, 255),
|
|
105
|
+
("A02", "Black", 30, 30, 30),
|
|
106
|
+
("A03", "Red", 230, 0, 18),
|
|
107
|
+
("A04", "Blue", 0, 100, 175),
|
|
108
|
+
("A05", "Green", 0, 160, 80),
|
|
109
|
+
("A06", "Yellow", 255, 228, 0),
|
|
110
|
+
("A07", "Orange", 255, 110, 0),
|
|
111
|
+
("A08", "Purple", 150, 85, 180),
|
|
112
|
+
("A09", "Pink", 255, 100, 175),
|
|
113
|
+
("A10", "Brown", 135, 65, 20),
|
|
114
|
+
("A11", "Gray", 130, 130, 130),
|
|
115
|
+
("A12", "Light Gray", 195, 195, 195),
|
|
116
|
+
("A13", "Sky Blue", 130, 200, 240),
|
|
117
|
+
("A14", "Mint", 150, 250, 150),
|
|
118
|
+
("A15", "Peach", 255, 215, 180),
|
|
119
|
+
("A16", "Tan", 205, 175, 135),
|
|
120
|
+
("A17", "Cream", 255, 250, 205),
|
|
121
|
+
("A18", "Teal", 0, 125, 125),
|
|
122
|
+
("A19", "Navy", 0, 0, 120),
|
|
123
|
+
("A20", "Gold", 255, 210, 0),
|
|
124
|
+
],
|
|
125
|
+
),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_palette(key: str) -> Palette:
|
|
130
|
+
name = key.strip().lower()
|
|
131
|
+
try:
|
|
132
|
+
return PALETTES[name]
|
|
133
|
+
except KeyError as exc:
|
|
134
|
+
choices = ", ".join(PALETTES)
|
|
135
|
+
raise ValueError(f"Unknown palette {key!r}. Choose from: {choices}.") from exc
|
fusebead/pattern.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Sample an image into a fuse-bead grid."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import BinaryIO, Union
|
|
8
|
+
|
|
9
|
+
from PIL import Image, ImageOps
|
|
10
|
+
|
|
11
|
+
from fusebead.color import adjust_rgb, is_near_white, js_round, nearest_color
|
|
12
|
+
from fusebead.palettes import BeadColor, Palette, get_palette
|
|
13
|
+
|
|
14
|
+
ImageSource = Union[str, Path, BinaryIO, Image.Image]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fit_grid(
|
|
18
|
+
image_width: int, image_height: int, grid_width: int, grid_height: int
|
|
19
|
+
) -> tuple[int, int]:
|
|
20
|
+
"""Fit a grid inside the requested max width and height, keeping aspect.
|
|
21
|
+
|
|
22
|
+
This follows the website: the longer side uses the requested size, and the
|
|
23
|
+
other side shrinks. Rounding matches JavaScript Math.round.
|
|
24
|
+
"""
|
|
25
|
+
if image_width < 1 or image_height < 1:
|
|
26
|
+
raise ValueError("image width and height must be at least 1")
|
|
27
|
+
if grid_width < 1 or grid_height < 1:
|
|
28
|
+
raise ValueError("grid width and height must be at least 1")
|
|
29
|
+
|
|
30
|
+
aspect = image_width / image_height
|
|
31
|
+
if aspect >= 1:
|
|
32
|
+
cells_w = grid_width
|
|
33
|
+
cells_h = max(1, js_round(grid_width / aspect))
|
|
34
|
+
if cells_h > grid_height:
|
|
35
|
+
cells_h = grid_height
|
|
36
|
+
cells_w = max(1, js_round(grid_height * aspect))
|
|
37
|
+
else:
|
|
38
|
+
cells_h = grid_height
|
|
39
|
+
cells_w = max(1, js_round(grid_height * aspect))
|
|
40
|
+
if cells_w > grid_width:
|
|
41
|
+
cells_w = grid_width
|
|
42
|
+
cells_h = max(1, js_round(grid_width / aspect))
|
|
43
|
+
return cells_w, cells_h
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ColorCount:
|
|
48
|
+
color: BeadColor
|
|
49
|
+
count: int
|
|
50
|
+
|
|
51
|
+
def percent(self, total: int) -> float:
|
|
52
|
+
if total <= 0:
|
|
53
|
+
return 0.0
|
|
54
|
+
return (self.count / total) * 100
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Pattern:
|
|
59
|
+
"""A bead grid. Empty cells are None when the background was removed."""
|
|
60
|
+
|
|
61
|
+
grid: list[list[BeadColor | None]]
|
|
62
|
+
palette: Palette
|
|
63
|
+
source_width: int
|
|
64
|
+
_order: tuple[str, ...]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def width(self) -> int:
|
|
68
|
+
return len(self.grid[0]) if self.grid else 0
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def height(self) -> int:
|
|
72
|
+
return len(self.grid)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def counts(self) -> dict[str, int]:
|
|
76
|
+
found: dict[str, int] = {}
|
|
77
|
+
for row in self.grid:
|
|
78
|
+
for cell in row:
|
|
79
|
+
if cell is None:
|
|
80
|
+
continue
|
|
81
|
+
found[cell.code] = found.get(cell.code, 0) + 1
|
|
82
|
+
return found
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def total(self) -> int:
|
|
86
|
+
return sum(self.counts.values())
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def entries(self) -> list[ColorCount]:
|
|
90
|
+
"""Counts sorted by quantity, then by the order colors first appeared."""
|
|
91
|
+
counts = self.counts
|
|
92
|
+
rank = {code: index for index, code in enumerate(self._order)}
|
|
93
|
+
by_code = self.palette.by_code()
|
|
94
|
+
items = [
|
|
95
|
+
ColorCount(by_code[code], count)
|
|
96
|
+
for code, count in counts.items()
|
|
97
|
+
if code in by_code
|
|
98
|
+
]
|
|
99
|
+
items.sort(key=lambda item: (-item.count, rank.get(item.color.code, 0)))
|
|
100
|
+
return items
|
|
101
|
+
|
|
102
|
+
def cell(self, x: int, y: int) -> BeadColor | None:
|
|
103
|
+
return self.grid[y][x]
|
|
104
|
+
|
|
105
|
+
def render(self, show_grid: bool = True, bead_size: int | None = None) -> Image.Image:
|
|
106
|
+
from fusebead.render import render_pattern
|
|
107
|
+
|
|
108
|
+
return render_pattern(self, show_grid=show_grid, bead_size=bead_size)
|
|
109
|
+
|
|
110
|
+
def save_png(
|
|
111
|
+
self,
|
|
112
|
+
path: str | Path,
|
|
113
|
+
show_grid: bool = True,
|
|
114
|
+
bead_size: int | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
self.render(show_grid=show_grid, bead_size=bead_size).save(path, format="PNG")
|
|
117
|
+
|
|
118
|
+
def to_csv(self) -> str:
|
|
119
|
+
rows = [["Code", "Color", "Count", "%"]]
|
|
120
|
+
total = self.total
|
|
121
|
+
for entry in self.entries:
|
|
122
|
+
pct = f"{entry.percent(total):.1f}%"
|
|
123
|
+
rows.append([entry.color.code, entry.color.name, str(entry.count), pct])
|
|
124
|
+
rows.append([])
|
|
125
|
+
rows.append(["Total beads", str(total), "", ""])
|
|
126
|
+
lines = [",".join(_csv_cell(cell) for cell in row) for row in rows]
|
|
127
|
+
return "\ufeff" + "\r\n".join(lines)
|
|
128
|
+
|
|
129
|
+
def save_csv(self, path: str | Path) -> None:
|
|
130
|
+
Path(path).write_text(self.to_csv(), encoding="utf-8")
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_image(
|
|
134
|
+
cls,
|
|
135
|
+
image: ImageSource,
|
|
136
|
+
palette: str | Palette = "perler",
|
|
137
|
+
grid_width: int = 50,
|
|
138
|
+
grid_height: int = 50,
|
|
139
|
+
brightness: int = 0,
|
|
140
|
+
contrast: int = 0,
|
|
141
|
+
remove_background: bool = False,
|
|
142
|
+
background_threshold: int = 40,
|
|
143
|
+
) -> Pattern:
|
|
144
|
+
if background_threshold < 0:
|
|
145
|
+
raise ValueError("background_threshold must be 0 or greater")
|
|
146
|
+
chosen = palette if isinstance(palette, Palette) else get_palette(palette)
|
|
147
|
+
opened, close_after = _open_image(image)
|
|
148
|
+
try:
|
|
149
|
+
rgba = ImageOps.exif_transpose(opened).convert("RGBA")
|
|
150
|
+
cells_w, cells_h = fit_grid(rgba.width, rgba.height, grid_width, grid_height)
|
|
151
|
+
sampled = rgba.resize((cells_w, cells_h), Image.Resampling.BILINEAR)
|
|
152
|
+
pixels = sampled.load()
|
|
153
|
+
grid: list[list[BeadColor | None]] = []
|
|
154
|
+
order: list[str] = []
|
|
155
|
+
seen: set[str] = set()
|
|
156
|
+
for y in range(cells_h):
|
|
157
|
+
row: list[BeadColor | None] = []
|
|
158
|
+
for x in range(cells_w):
|
|
159
|
+
r, g, b, a = pixels[x, y]
|
|
160
|
+
r, g, b = adjust_rgb(r, g, b, brightness, contrast)
|
|
161
|
+
if remove_background and (
|
|
162
|
+
a < 128 or is_near_white(r, g, b, background_threshold)
|
|
163
|
+
):
|
|
164
|
+
row.append(None)
|
|
165
|
+
continue
|
|
166
|
+
color = nearest_color(r, g, b, chosen)
|
|
167
|
+
if color.code not in seen:
|
|
168
|
+
seen.add(color.code)
|
|
169
|
+
order.append(color.code)
|
|
170
|
+
row.append(color)
|
|
171
|
+
grid.append(row)
|
|
172
|
+
return cls(grid, chosen, rgba.width, tuple(order))
|
|
173
|
+
finally:
|
|
174
|
+
if close_after:
|
|
175
|
+
opened.close()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _csv_cell(value: str) -> str:
|
|
179
|
+
if any(ch in value for ch in '",\n\r'):
|
|
180
|
+
return '"' + value.replace('"', '""') + '"'
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _open_image(image: ImageSource) -> tuple[Image.Image, bool]:
|
|
185
|
+
if isinstance(image, Image.Image):
|
|
186
|
+
return image, False
|
|
187
|
+
return Image.open(image), True
|
fusebead/render.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Draw a bead pattern the same way the website canvas does."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
from fusebead.color import clamp, js_round
|
|
11
|
+
from fusebead.pattern import Pattern
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def bead_pixel_size(source_width: int, cells_w: int, show_grid: bool) -> tuple[int, int]:
|
|
15
|
+
"""Return (bead size, gap) so the PNG stays near the source width, max 1000px."""
|
|
16
|
+
gap = 1 if show_grid else 0
|
|
17
|
+
target_width = min(1000, max(1, source_width))
|
|
18
|
+
bead_size = js_round((target_width - gap * (cells_w + 1)) / cells_w)
|
|
19
|
+
if bead_size < 4:
|
|
20
|
+
bead_size = 4
|
|
21
|
+
return bead_size, gap
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def render_pattern(
|
|
25
|
+
pattern: Pattern, show_grid: bool = True, bead_size: int | None = None
|
|
26
|
+
) -> Image.Image:
|
|
27
|
+
if pattern.width < 1 or pattern.height < 1:
|
|
28
|
+
raise ValueError("pattern grid is empty")
|
|
29
|
+
if bead_size is None:
|
|
30
|
+
bead_size, gap = bead_pixel_size(pattern.source_width, pattern.width, show_grid)
|
|
31
|
+
else:
|
|
32
|
+
if bead_size < 1:
|
|
33
|
+
raise ValueError("bead_size must be at least 1")
|
|
34
|
+
gap = 1 if show_grid else 0
|
|
35
|
+
cell = bead_size + gap
|
|
36
|
+
canvas = Image.new(
|
|
37
|
+
"RGB",
|
|
38
|
+
(pattern.width * cell + gap, pattern.height * cell + gap),
|
|
39
|
+
(255, 255, 255),
|
|
40
|
+
)
|
|
41
|
+
sprites: dict[str, Image.Image] = {}
|
|
42
|
+
for y, row in enumerate(pattern.grid):
|
|
43
|
+
for x, color in enumerate(row):
|
|
44
|
+
if color is None:
|
|
45
|
+
continue
|
|
46
|
+
sprite = sprites.get(color.code)
|
|
47
|
+
if sprite is None:
|
|
48
|
+
sprite = _bead_sprite(color.r, color.g, color.b, bead_size)
|
|
49
|
+
sprites[color.code] = sprite
|
|
50
|
+
canvas.paste(
|
|
51
|
+
sprite,
|
|
52
|
+
(gap + x * cell, gap + y * cell),
|
|
53
|
+
sprite,
|
|
54
|
+
)
|
|
55
|
+
return canvas
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@lru_cache(maxsize=128)
|
|
59
|
+
def _bead_sprite(r: int, g: int, b: int, bead_size: int) -> Image.Image:
|
|
60
|
+
"""Radial highlight matching canvas createRadialGradient, plus a soft edge."""
|
|
61
|
+
image = Image.new("RGBA", (bead_size, bead_size), (0, 0, 0, 0))
|
|
62
|
+
pixels = image.load()
|
|
63
|
+
radius = bead_size / 2 - 0.5
|
|
64
|
+
cx = bead_size / 2
|
|
65
|
+
cy = bead_size / 2
|
|
66
|
+
x0 = cx - radius * 0.25
|
|
67
|
+
y0 = cy - radius * 0.25
|
|
68
|
+
light = (clamp(r + 18, 0, 255), clamp(g + 18, 0, 255), clamp(b + 18, 0, 255))
|
|
69
|
+
base = (r, g, b)
|
|
70
|
+
dark = (clamp(r - 22, 0, 255), clamp(g - 22, 0, 255), clamp(b - 22, 0, 255))
|
|
71
|
+
for py in range(bead_size):
|
|
72
|
+
for px in range(bead_size):
|
|
73
|
+
sx = px + 0.5
|
|
74
|
+
sy = py + 0.5
|
|
75
|
+
dist = math.hypot(sx - cx, sy - cy)
|
|
76
|
+
coverage = radius + 0.5 - dist
|
|
77
|
+
if coverage <= 0:
|
|
78
|
+
continue
|
|
79
|
+
alpha = 255 if coverage >= 1 else int(coverage * 255)
|
|
80
|
+
t = _radial_t(sx, sy, x0, y0, cx, cy, radius)
|
|
81
|
+
if t < 0:
|
|
82
|
+
t = 0.0
|
|
83
|
+
elif t > 1:
|
|
84
|
+
t = 1.0
|
|
85
|
+
rgb = _shade(t, light, base, dark)
|
|
86
|
+
edge = abs(dist - radius)
|
|
87
|
+
if edge < 0.5:
|
|
88
|
+
ink = (1 - edge / 0.5) * 0.08
|
|
89
|
+
rgb = tuple(int(js_round(channel * (1 - ink))) for channel in rgb)
|
|
90
|
+
pixels[px, py] = (rgb[0], rgb[1], rgb[2], alpha)
|
|
91
|
+
return image
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _shade(
|
|
95
|
+
t: float,
|
|
96
|
+
light: tuple[int, int, int],
|
|
97
|
+
base: tuple[int, int, int],
|
|
98
|
+
dark: tuple[int, int, int],
|
|
99
|
+
) -> tuple[int, int, int]:
|
|
100
|
+
if t <= 0.55:
|
|
101
|
+
return _lerp(light, base, 0 if t <= 0 else t / 0.55)
|
|
102
|
+
return _lerp(base, dark, (t - 0.55) / 0.45)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _lerp(
|
|
106
|
+
a: tuple[int, int, int], b: tuple[int, int, int], t: float
|
|
107
|
+
) -> tuple[int, int, int]:
|
|
108
|
+
return tuple(int(js_round(a[i] + (b[i] - a[i]) * t)) for i in range(3))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _radial_t(
|
|
112
|
+
px: float, py: float, x0: float, y0: float, x1: float, y1: float, r1: float
|
|
113
|
+
) -> float:
|
|
114
|
+
"""Offset of a canvas radial gradient whose start radius is 0."""
|
|
115
|
+
dx = x1 - x0
|
|
116
|
+
dy = y1 - y0
|
|
117
|
+
vx = px - x0
|
|
118
|
+
vy = py - y0
|
|
119
|
+
a = dx * dx + dy * dy - r1 * r1
|
|
120
|
+
b = -2 * (vx * dx + vy * dy)
|
|
121
|
+
c = vx * vx + vy * vy
|
|
122
|
+
if abs(a) < 1e-8:
|
|
123
|
+
if abs(b) < 1e-8:
|
|
124
|
+
return 0.0
|
|
125
|
+
return -c / b
|
|
126
|
+
disc = b * b - 4 * a * c
|
|
127
|
+
if disc < 0:
|
|
128
|
+
disc = 0.0
|
|
129
|
+
root = math.sqrt(disc)
|
|
130
|
+
t1 = (-b + root) / (2 * a)
|
|
131
|
+
t2 = (-b - root) / (2 * a)
|
|
132
|
+
inside = [t for t in (t1, t2) if 0 <= t <= 1]
|
|
133
|
+
if inside:
|
|
134
|
+
return min(inside)
|
|
135
|
+
positive = [t for t in (t1, t2) if t >= 0]
|
|
136
|
+
if positive:
|
|
137
|
+
return min(positive)
|
|
138
|
+
return min(t1, t2)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fusebead
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn a photo into a fuse-bead pattern, with palette matching, bead counts, and a printable PNG.
|
|
5
|
+
Author-email: ROCKIMG <admin@rockimg.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.rockimg.com/perler-bead-pattern-maker.html
|
|
8
|
+
Project-URL: Documentation, https://www.rockimg.com/perler-bead-pattern-maker.html
|
|
9
|
+
Keywords: fuse-bead,bead-pattern,pixel-art,image
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: pillow>=10
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# fusebead
|
|
27
|
+
|
|
28
|
+
Turn a photo into a fuse-bead pattern: a pegboard grid, the nearest bead color, a printable PNG, and a color-count chart.
|
|
29
|
+
|
|
30
|
+
Color matching follows the [ROCKIMG fuse-bead pattern maker](https://www.rockimg.com/perler-bead-pattern-maker.html): sRGB to CIE Lab, CIE76 distance, the same brightness and contrast sliders, and the same three palettes.
|
|
31
|
+
|
|
32
|
+
Perler, Hama, and Artkal are trademarks of their owners. This package is not affiliated with those brands. Palette RGB values are the approximations used by the website, not official manufacturer color cards.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install fusebead
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
From a checkout of this repository:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install -e packages/fusebead
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python 3.9+ and Pillow.
|
|
47
|
+
|
|
48
|
+
## Library
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from fusebead import Pattern
|
|
52
|
+
|
|
53
|
+
pattern = Pattern.from_image(
|
|
54
|
+
"cat.png",
|
|
55
|
+
palette="perler", # perler, hama, or artkal
|
|
56
|
+
grid_width=50, # max pegs on the long side; aspect ratio is kept
|
|
57
|
+
grid_height=50,
|
|
58
|
+
brightness=0, # website slider range is -80..80
|
|
59
|
+
contrast=0,
|
|
60
|
+
remove_background=True,
|
|
61
|
+
)
|
|
62
|
+
pattern.save_png("pattern.png")
|
|
63
|
+
pattern.save_csv("beads.csv")
|
|
64
|
+
|
|
65
|
+
pattern.width, pattern.height
|
|
66
|
+
pattern.total
|
|
67
|
+
pattern.cell(0, 0) # BeadColor or None when the background was removed
|
|
68
|
+
pattern.entries[0].color.code
|
|
69
|
+
pattern.entries[0].count
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Phone photos are rotated with EXIF orientation before sampling. The website canvas does not do that step.
|
|
73
|
+
|
|
74
|
+
## Command line
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
fusebead cat.png -o pattern.png --csv beads.csv --palette hama --width 40 --height 40 --remove-bg
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`--no-grid` draws beads flush. `--bead-size` sets the peg diameter in pixels. By default the PNG width follows the source image, capped at 1000 pixels, and each peg is at least 4 pixels.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
fusebead/__init__.py,sha256=vOGVYXvT5kBpek7Ng3GoeQV5YFHEiSs-FgkpdKrPR9w,433
|
|
2
|
+
fusebead/__main__.py,sha256=4q4jPMxcBvbMW2GULlKlvrWA9YsZ8pfSsCIkM_wZzCs,87
|
|
3
|
+
fusebead/cli.py,sha256=-aBeLoDpuybgtc7C-6r-CtPa6ehqw_X-Ba3Aldq1stU,2814
|
|
4
|
+
fusebead/color.py,sha256=YPTGQ3DrHd6-KeGGzuM7pEnhRUkGzqkCeZJDEX98WAo,2934
|
|
5
|
+
fusebead/palettes.py,sha256=sK3lKf2usKrZGtgnqwZ7uL4AhQVs3QT66-YFTwbCtRw,4681
|
|
6
|
+
fusebead/pattern.py,sha256=_ZyJ23mfrQpxuAhWPv0dRX47JNytkUwtDWiMs6aGNuQ,6327
|
|
7
|
+
fusebead/render.py,sha256=zaxLVC4ZWd2bxp8hzzDe1MkhvY2TdYpEaUcih9OwZE8,4463
|
|
8
|
+
fusebead-0.1.0.dist-info/licenses/LICENSE,sha256=DIdV3CdTk4LBFcgZn-9-7SH742tEaFDjtoaUYpJZvHg,1064
|
|
9
|
+
fusebead-0.1.0.dist-info/METADATA,sha256=_oNfqvKyPtEFnskjbgr1aQkEU9feXAGo2Wv3lo266bA,2865
|
|
10
|
+
fusebead-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
fusebead-0.1.0.dist-info/entry_points.txt,sha256=fsH1fzIb4oYCKA2Ixau1Az4nsd9mxmxocdoN0DgVEFI,47
|
|
12
|
+
fusebead-0.1.0.dist-info/top_level.txt,sha256=No9fvddz6IXV_mD_CKtuTJdqTil_W3bKfMo6bdpdE94,9
|
|
13
|
+
fusebead-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ROCKIMG
|
|
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 @@
|
|
|
1
|
+
fusebead
|