fusebead 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.
fusebead-0.1.0/LICENSE ADDED
@@ -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,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,55 @@
1
+ # fusebead
2
+
3
+ Turn a photo into a fuse-bead pattern: a pegboard grid, the nearest bead color, a printable PNG, and a color-count chart.
4
+
5
+ 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.
6
+
7
+ 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.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install fusebead
13
+ ```
14
+
15
+ From a checkout of this repository:
16
+
17
+ ```bash
18
+ pip install -e packages/fusebead
19
+ ```
20
+
21
+ Requires Python 3.9+ and Pillow.
22
+
23
+ ## Library
24
+
25
+ ```python
26
+ from fusebead import Pattern
27
+
28
+ pattern = Pattern.from_image(
29
+ "cat.png",
30
+ palette="perler", # perler, hama, or artkal
31
+ grid_width=50, # max pegs on the long side; aspect ratio is kept
32
+ grid_height=50,
33
+ brightness=0, # website slider range is -80..80
34
+ contrast=0,
35
+ remove_background=True,
36
+ )
37
+ pattern.save_png("pattern.png")
38
+ pattern.save_csv("beads.csv")
39
+
40
+ pattern.width, pattern.height
41
+ pattern.total
42
+ pattern.cell(0, 0) # BeadColor or None when the background was removed
43
+ pattern.entries[0].color.code
44
+ pattern.entries[0].count
45
+ ```
46
+
47
+ Phone photos are rotated with EXIF orientation before sampling. The website canvas does not do that step.
48
+
49
+ ## Command line
50
+
51
+ ```bash
52
+ fusebead cat.png -o pattern.png --csv beads.csv --palette hama --width 40 --height 40 --remove-bg
53
+ ```
54
+
55
+ `--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,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "fusebead"
7
+ version = "0.1.0"
8
+ description = "Turn a photo into a fuse-bead pattern, with palette matching, bead counts, and a printable PNG."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "ROCKIMG", email = "admin@rockimg.com" }]
14
+ keywords = ["fuse-bead", "bead-pattern", "pixel-art", "image"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Multimedia :: Graphics",
26
+ ]
27
+ dependencies = ["pillow>=10"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://www.rockimg.com/perler-bead-pattern-maker.html"
31
+ Documentation = "https://www.rockimg.com/perler-bead-pattern-maker.html"
32
+
33
+ [project.scripts]
34
+ fusebead = "fusebead.cli:main"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -0,0 +1,4 @@
1
+ from fusebead.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/fusebead/__init__.py
5
+ src/fusebead/__main__.py
6
+ src/fusebead/cli.py
7
+ src/fusebead/color.py
8
+ src/fusebead/palettes.py
9
+ src/fusebead/pattern.py
10
+ src/fusebead/render.py
11
+ src/fusebead.egg-info/PKG-INFO
12
+ src/fusebead.egg-info/SOURCES.txt
13
+ src/fusebead.egg-info/dependency_links.txt
14
+ src/fusebead.egg-info/entry_points.txt
15
+ src/fusebead.egg-info/requires.txt
16
+ src/fusebead.egg-info/top_level.txt
17
+ tests/test_pattern.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fusebead = fusebead.cli:main
@@ -0,0 +1 @@
1
+ pillow>=10
@@ -0,0 +1 @@
1
+ fusebead
@@ -0,0 +1,113 @@
1
+ """Tests for grid fitting, palette matching, counts, and PNG output."""
2
+
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ from PIL import Image
8
+
9
+ from fusebead import Pattern, fit_grid, get_palette
10
+ from fusebead.cli import main
11
+ from fusebead.color import adjust_rgb, js_round, nearest_color
12
+
13
+
14
+ class RoundingTests(unittest.TestCase):
15
+ def test_js_round_matches_math_round(self):
16
+ self.assertEqual(js_round(12.5), 13)
17
+ self.assertEqual(js_round(-1.5), -1)
18
+ self.assertEqual(js_round(1.2), 1)
19
+
20
+
21
+ class GridTests(unittest.TestCase):
22
+ def test_square_uses_both_limits(self):
23
+ self.assertEqual(fit_grid(100, 100, 50, 50), (50, 50))
24
+
25
+ def test_landscape_shrinks_height(self):
26
+ self.assertEqual(fit_grid(40, 10, 50, 50), (50, 13))
27
+
28
+ def test_portrait_shrinks_width(self):
29
+ self.assertEqual(fit_grid(10, 40, 50, 50), (13, 50))
30
+
31
+ def test_second_pass_when_height_exceeds_limit(self):
32
+ self.assertEqual(fit_grid(100, 80, 10, 5), (6, 5))
33
+
34
+
35
+ class MatchTests(unittest.TestCase):
36
+ def test_exact_red_stays_red(self):
37
+ palette = get_palette("perler")
38
+ color = nearest_color(227, 0, 15, palette)
39
+ self.assertEqual(color.code, "80-19005")
40
+
41
+ def test_white_and_black(self):
42
+ palette = get_palette("perler")
43
+ self.assertEqual(nearest_color(255, 255, 255, palette).name, "White")
44
+ self.assertEqual(nearest_color(0, 0, 0, palette).name, "Black")
45
+
46
+ def test_unknown_palette(self):
47
+ with self.assertRaises(ValueError):
48
+ get_palette("nope")
49
+
50
+ def test_contrast_zero_is_identity(self):
51
+ self.assertEqual(adjust_rgb(40, 80, 200, 0, 0), (40, 80, 200))
52
+
53
+ def test_brightness_shifts(self):
54
+ self.assertEqual(adjust_rgb(40, 80, 200, 10, 0), (50, 90, 210))
55
+
56
+
57
+ class PatternTests(unittest.TestCase):
58
+ def test_solid_red_fills_grid(self):
59
+ image = Image.new("RGB", (32, 32), (227, 0, 15))
60
+ pattern = Pattern.from_image(image, grid_width=8, grid_height=8)
61
+ self.assertEqual((pattern.width, pattern.height), (8, 8))
62
+ self.assertEqual(pattern.total, 64)
63
+ self.assertEqual(pattern.entries[0].color.code, "80-19005")
64
+ self.assertEqual(pattern.cell(0, 0).name, "Red")
65
+
66
+ def test_remove_white_background(self):
67
+ image = Image.new("RGB", (16, 16), (255, 255, 255))
68
+ pattern = Pattern.from_image(image, grid_width=4, grid_height=4, remove_background=True)
69
+ self.assertEqual(pattern.total, 0)
70
+ self.assertIsNone(pattern.cell(0, 0))
71
+
72
+ def test_transparent_pixel_is_removed(self):
73
+ image = Image.new("RGBA", (8, 8), (227, 0, 15, 0))
74
+ pattern = Pattern.from_image(image, grid_width=4, grid_height=4, remove_background=True)
75
+ self.assertEqual(pattern.total, 0)
76
+
77
+ def test_csv_and_png(self):
78
+ image = Image.new("RGB", (20, 10), (0, 0, 128))
79
+ pattern = Pattern.from_image(image, palette="hama", grid_width=10, grid_height=10)
80
+ csv = pattern.to_csv()
81
+ self.assertTrue(csv.startswith("\ufeffCode,Color,Count,%"))
82
+ self.assertIn("H20,Navy,", csv)
83
+ self.assertIn("Total beads", csv)
84
+ with tempfile.TemporaryDirectory() as folder:
85
+ png_path = Path(folder) / "pattern.png"
86
+ pattern.save_png(png_path, bead_size=6)
87
+ with Image.open(png_path) as saved:
88
+ self.assertEqual(saved.format, "PNG")
89
+ self.assertGreater(saved.size[0], pattern.width)
90
+
91
+ def test_highlight_is_lighter_than_opposite_edge(self):
92
+ image = Image.new("RGB", (40, 40), (227, 0, 15))
93
+ pattern = Pattern.from_image(image, grid_width=1, grid_height=1)
94
+ rendered = pattern.render(show_grid=False, bead_size=21)
95
+ highlight = rendered.getpixel((6, 6))
96
+ shade = rendered.getpixel((16, 16))
97
+ self.assertGreater(sum(highlight), sum(shade))
98
+
99
+ def test_cli_writes_files(self):
100
+ with tempfile.TemporaryDirectory() as folder:
101
+ root = Path(folder)
102
+ src = root / "in.png"
103
+ Image.new("RGB", (12, 12), (255, 230, 0)).save(src)
104
+ png = root / "out.png"
105
+ csv = root / "out.csv"
106
+ code = main([str(src), "-o", str(png), "--csv", str(csv), "--width", "6", "--height", "6"])
107
+ self.assertEqual(code, 0)
108
+ self.assertTrue(png.is_file())
109
+ self.assertIn("Yellow", csv.read_text(encoding="utf-8"))
110
+
111
+
112
+ if __name__ == "__main__":
113
+ unittest.main()