porypal-fe8 0.1.1__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.
@@ -0,0 +1,32 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 laqieer
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.
22
+
23
+
24
+ --------------------------------------------------------------------------------
25
+ Acknowledgements
26
+
27
+ porypal-fe8 is an independent, clean-room reimplementation inspired by the
28
+ reusable palette core of Loxed's Porypal (https://github.com/Loxed/porypal):
29
+ the idea of k-means colour quantization in a perceptual colour space and the
30
+ JASC ".pal" round-trip. Porypal is licensed under the GPL-3.0, which is
31
+ incompatible with this MIT distribution, so no Porypal source code was copied;
32
+ only the high-level approach was reused. Credit and thanks to Loxed.
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: porypal-fe8
3
+ Version: 0.1.1
4
+ Summary: FE8 palette tool: PNG -> 16-colour JASC .pal extraction & apply (fireemblem8u; inspired by Loxed's Porypal)
5
+ Author: laqieer
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 laqieer
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+
29
+ --------------------------------------------------------------------------------
30
+ Acknowledgements
31
+
32
+ porypal-fe8 is an independent, clean-room reimplementation inspired by the
33
+ reusable palette core of Loxed's Porypal (https://github.com/Loxed/porypal):
34
+ the idea of k-means colour quantization in a perceptual colour space and the
35
+ JASC ".pal" round-trip. Porypal is licensed under the GPL-3.0, which is
36
+ incompatible with this MIT distribution, so no Porypal source code was copied;
37
+ only the high-level approach was reused. Credit and thanks to Loxed.
38
+
39
+ Project-URL: Homepage, https://github.com/laqieer/porypal-fe8
40
+ Keywords: gba,fireemblem,palette,jasc,decomp,fe8
41
+ Requires-Python: >=3.9
42
+ Description-Content-Type: text/markdown
43
+ License-File: LICENSE
44
+ Requires-Dist: pillow>=9.0
45
+ Requires-Dist: numpy>=1.21
46
+ Dynamic: license-file
47
+
48
+ # porypal-fe8
49
+
50
+ A small, FE8-oriented palette CLI for the
51
+ [`fireemblem8u`](https://github.com/FireEmblemUniverse/fireemblem8u) decomp
52
+ graphics pipeline. It does two things:
53
+
54
+ - **`extract`** — quantize a PNG down to **≤16 colours** and write a GBA-style
55
+ **JASC `.pal`** palette.
56
+ - **`apply`** — remap every pixel of a PNG to its nearest colour in a given
57
+ `.pal` and save an **indexed PNG**, ready for `gbagfx`.
58
+
59
+ It is a focused, clean reimplementation of the *reusable core* of
60
+ [Loxed's Porypal](https://github.com/Loxed/porypal) — k-means colour
61
+ quantization in a perceptual colour space (Oklab) plus JASC `.pal` I/O — with
62
+ all the Pokémon-specific and UI parts dropped. See
63
+ [Credits & license](#credits--license).
64
+
65
+ ## Why this exists (honest note)
66
+
67
+ For day-to-day FE8 graphics work you usually **do not need this tool**:
68
+
69
+ - [**Usenti**](https://www.coranac.com/projects/usenti/) handles palette
70
+ editing, reduction, and indexed-PNG export interactively.
71
+ - The decomp's own **`gbagfx`** already converts between `.png`, `.pal`,
72
+ `.gbapal`, and `.4bpp`.
73
+
74
+ `porypal-fe8` is for **batch / automated quantization** — e.g. scripting the
75
+ reduction of many full-colour PNGs to 16-colour palettes in a build or asset
76
+ pipeline, where a headless CLI is more convenient than a GUI.
77
+
78
+ ## Install
79
+
80
+ Requires Python 3.9+.
81
+
82
+ ```sh
83
+ python3 -m venv .venv && . .venv/bin/activate
84
+ pip install -r requirements.txt
85
+ # optional: install the `porypal-fe8` console command
86
+ pip install .
87
+ ```
88
+
89
+ Without installing, you can run the module directly:
90
+
91
+ ```sh
92
+ python3 palette_tool.py extract IN.png -o OUT.pal
93
+ ```
94
+
95
+ ## Install / Releases
96
+
97
+ Once published to PyPI:
98
+
99
+ ```sh
100
+ pip install porypal-fe8
101
+ ```
102
+
103
+ Each `v*` tag also produces a **GitHub Release** with the built wheel and sdist
104
+ attached (under [Releases](https://github.com/laqieer/porypal-fe8/releases)),
105
+ so you can `pip install` a downloaded artifact even without PyPI.
106
+
107
+ > **Note:** PyPI publishing uses [OIDC trusted
108
+ > publishing](https://docs.pypi.org/trusted-publishers/) (no API token). It
109
+ > requires a **trusted publisher** to be configured on PyPI for project
110
+ > `porypal-fe8`, owner `laqieer`, workflow `release.yml`, environment `pypi`.
111
+ > The GitHub Release job is independent and succeeds even before that is set up.
112
+
113
+ ## Usage
114
+
115
+ ### Extract a palette
116
+
117
+ ```sh
118
+ porypal-fe8 extract IN.png -o OUT.pal [-n 16]
119
+ ```
120
+
121
+ Quantizes `IN.png` to at most `-n` colours (default 16, the GBA 4bpp limit) and
122
+ writes a JASC `.pal`:
123
+
124
+ ```
125
+ JASC-PAL
126
+ 0100
127
+ 16
128
+ 115 131 164
129
+ 255 255 255
130
+ ...
131
+ ```
132
+
133
+ ### Apply a palette
134
+
135
+ ```sh
136
+ porypal-fe8 apply IN.png PALETTE.pal -o OUT.png
137
+ ```
138
+
139
+ Remaps every pixel of `IN.png` to the nearest colour in `PALETTE.pal` (nearest
140
+ in Oklab) and saves an indexed (`P`-mode) PNG whose colours are exactly the
141
+ palette.
142
+
143
+ ## How it fits the FE8 pipeline
144
+
145
+ The decomp stores graphics as PNGs and JASC `.pal` palettes, and its Makefile
146
+ drives `gbagfx` to turn those into the GBA's native formats:
147
+
148
+ ```
149
+ porypal-fe8 extract gbagfx (pal2gbapal)
150
+ IN.png ───────────────────────▶ OUT.pal ───────────────────▶ .gbapal (32 bytes for 16 colours)
151
+
152
+ porypal-fe8 apply gbagfx (png2gbagfx)
153
+ IN.png ───────────────────────▶ OUT.png ───────────────────▶ .4bpp
154
+ (+ .pal) (indexed)
155
+ ```
156
+
157
+ Relevant Makefile rules in the decomp:
158
+
159
+ ```make
160
+ %.gbapal: %.pal ; $(PAL2GBAPAL) $< $@
161
+ %.gbapal: %.png ; $(GBAGFX) $< $@
162
+ ```
163
+
164
+ A 16-colour JASC `.pal` converts to exactly **32 bytes** of `.gbapal`
165
+ (16 colours × 2 bytes, the GBA's 15-bit BGR555 format). Palettes are written
166
+ with **CRLF** line endings to match the decomp's `.pal` files — `gbagfx`
167
+ rejects LF-only palettes. See the
168
+ [`fireemblem8u`](https://github.com/FireEmblemUniverse/fireemblem8u) repo and
169
+ [`gbagfx`](https://github.com/pret/pokeemerald/tree/master/tools/gbagfx) for the
170
+ full graphics flow.
171
+
172
+ ## How it works
173
+
174
+ - **Quantization** clusters the image's pixels with **k-means++** in **Oklab**,
175
+ a perceptually uniform colour space, so the chosen colours match how the eye
176
+ groups them. Each cluster centre is then snapped to the nearest *actual*
177
+ colour in the source image, so every palette entry really occurs in the PNG.
178
+ If the image already has ≤ N colours, they are kept verbatim.
179
+ - **Apply** assigns each pixel to the nearest palette colour, again measured in
180
+ Oklab.
181
+
182
+ ## Credits & license
183
+
184
+ The colour-quantization approach and JASC `.pal` round-trip are **inspired by**
185
+ [Loxed's Porypal](https://github.com/Loxed/porypal). Porypal is licensed under
186
+ the **GPL-3.0**, which is incompatible with this project's MIT license, so
187
+ **no Porypal source code was copied** — this is an independent reimplementation
188
+ of the high-level idea only. Credit and thanks to Loxed.
189
+
190
+ porypal-fe8 itself is released under the [MIT License](LICENSE).
@@ -0,0 +1,143 @@
1
+ # porypal-fe8
2
+
3
+ A small, FE8-oriented palette CLI for the
4
+ [`fireemblem8u`](https://github.com/FireEmblemUniverse/fireemblem8u) decomp
5
+ graphics pipeline. It does two things:
6
+
7
+ - **`extract`** — quantize a PNG down to **≤16 colours** and write a GBA-style
8
+ **JASC `.pal`** palette.
9
+ - **`apply`** — remap every pixel of a PNG to its nearest colour in a given
10
+ `.pal` and save an **indexed PNG**, ready for `gbagfx`.
11
+
12
+ It is a focused, clean reimplementation of the *reusable core* of
13
+ [Loxed's Porypal](https://github.com/Loxed/porypal) — k-means colour
14
+ quantization in a perceptual colour space (Oklab) plus JASC `.pal` I/O — with
15
+ all the Pokémon-specific and UI parts dropped. See
16
+ [Credits & license](#credits--license).
17
+
18
+ ## Why this exists (honest note)
19
+
20
+ For day-to-day FE8 graphics work you usually **do not need this tool**:
21
+
22
+ - [**Usenti**](https://www.coranac.com/projects/usenti/) handles palette
23
+ editing, reduction, and indexed-PNG export interactively.
24
+ - The decomp's own **`gbagfx`** already converts between `.png`, `.pal`,
25
+ `.gbapal`, and `.4bpp`.
26
+
27
+ `porypal-fe8` is for **batch / automated quantization** — e.g. scripting the
28
+ reduction of many full-colour PNGs to 16-colour palettes in a build or asset
29
+ pipeline, where a headless CLI is more convenient than a GUI.
30
+
31
+ ## Install
32
+
33
+ Requires Python 3.9+.
34
+
35
+ ```sh
36
+ python3 -m venv .venv && . .venv/bin/activate
37
+ pip install -r requirements.txt
38
+ # optional: install the `porypal-fe8` console command
39
+ pip install .
40
+ ```
41
+
42
+ Without installing, you can run the module directly:
43
+
44
+ ```sh
45
+ python3 palette_tool.py extract IN.png -o OUT.pal
46
+ ```
47
+
48
+ ## Install / Releases
49
+
50
+ Once published to PyPI:
51
+
52
+ ```sh
53
+ pip install porypal-fe8
54
+ ```
55
+
56
+ Each `v*` tag also produces a **GitHub Release** with the built wheel and sdist
57
+ attached (under [Releases](https://github.com/laqieer/porypal-fe8/releases)),
58
+ so you can `pip install` a downloaded artifact even without PyPI.
59
+
60
+ > **Note:** PyPI publishing uses [OIDC trusted
61
+ > publishing](https://docs.pypi.org/trusted-publishers/) (no API token). It
62
+ > requires a **trusted publisher** to be configured on PyPI for project
63
+ > `porypal-fe8`, owner `laqieer`, workflow `release.yml`, environment `pypi`.
64
+ > The GitHub Release job is independent and succeeds even before that is set up.
65
+
66
+ ## Usage
67
+
68
+ ### Extract a palette
69
+
70
+ ```sh
71
+ porypal-fe8 extract IN.png -o OUT.pal [-n 16]
72
+ ```
73
+
74
+ Quantizes `IN.png` to at most `-n` colours (default 16, the GBA 4bpp limit) and
75
+ writes a JASC `.pal`:
76
+
77
+ ```
78
+ JASC-PAL
79
+ 0100
80
+ 16
81
+ 115 131 164
82
+ 255 255 255
83
+ ...
84
+ ```
85
+
86
+ ### Apply a palette
87
+
88
+ ```sh
89
+ porypal-fe8 apply IN.png PALETTE.pal -o OUT.png
90
+ ```
91
+
92
+ Remaps every pixel of `IN.png` to the nearest colour in `PALETTE.pal` (nearest
93
+ in Oklab) and saves an indexed (`P`-mode) PNG whose colours are exactly the
94
+ palette.
95
+
96
+ ## How it fits the FE8 pipeline
97
+
98
+ The decomp stores graphics as PNGs and JASC `.pal` palettes, and its Makefile
99
+ drives `gbagfx` to turn those into the GBA's native formats:
100
+
101
+ ```
102
+ porypal-fe8 extract gbagfx (pal2gbapal)
103
+ IN.png ───────────────────────▶ OUT.pal ───────────────────▶ .gbapal (32 bytes for 16 colours)
104
+
105
+ porypal-fe8 apply gbagfx (png2gbagfx)
106
+ IN.png ───────────────────────▶ OUT.png ───────────────────▶ .4bpp
107
+ (+ .pal) (indexed)
108
+ ```
109
+
110
+ Relevant Makefile rules in the decomp:
111
+
112
+ ```make
113
+ %.gbapal: %.pal ; $(PAL2GBAPAL) $< $@
114
+ %.gbapal: %.png ; $(GBAGFX) $< $@
115
+ ```
116
+
117
+ A 16-colour JASC `.pal` converts to exactly **32 bytes** of `.gbapal`
118
+ (16 colours × 2 bytes, the GBA's 15-bit BGR555 format). Palettes are written
119
+ with **CRLF** line endings to match the decomp's `.pal` files — `gbagfx`
120
+ rejects LF-only palettes. See the
121
+ [`fireemblem8u`](https://github.com/FireEmblemUniverse/fireemblem8u) repo and
122
+ [`gbagfx`](https://github.com/pret/pokeemerald/tree/master/tools/gbagfx) for the
123
+ full graphics flow.
124
+
125
+ ## How it works
126
+
127
+ - **Quantization** clusters the image's pixels with **k-means++** in **Oklab**,
128
+ a perceptually uniform colour space, so the chosen colours match how the eye
129
+ groups them. Each cluster centre is then snapped to the nearest *actual*
130
+ colour in the source image, so every palette entry really occurs in the PNG.
131
+ If the image already has ≤ N colours, they are kept verbatim.
132
+ - **Apply** assigns each pixel to the nearest palette colour, again measured in
133
+ Oklab.
134
+
135
+ ## Credits & license
136
+
137
+ The colour-quantization approach and JASC `.pal` round-trip are **inspired by**
138
+ [Loxed's Porypal](https://github.com/Loxed/porypal). Porypal is licensed under
139
+ the **GPL-3.0**, which is incompatible with this project's MIT license, so
140
+ **no Porypal source code was copied** — this is an independent reimplementation
141
+ of the high-level idea only. Credit and thanks to Loxed.
142
+
143
+ porypal-fe8 itself is released under the [MIT License](LICENSE).
@@ -0,0 +1,356 @@
1
+ #!/usr/bin/env python3
2
+ """porypal-fe8: an FE8-oriented palette tool.
3
+
4
+ A focused command-line tool for the ``fireemblem8u`` decomp graphics pipeline.
5
+ It does two things:
6
+
7
+ * ``extract`` -- quantize a PNG down to <=16 representative colours and write a
8
+ GBA-style JASC ``.pal`` palette (the format the decomp's hundreds of ``.pal``
9
+ files use, which ``tools/gbagfx`` converts to ``.gbapal``).
10
+ * ``apply`` -- remap every pixel of a PNG to its nearest colour in a given
11
+ ``.pal`` and save an indexed PNG (ready for ``gbagfx`` to turn into ``.4bpp``).
12
+
13
+ The colour-quantization idea (k-means over pixels in a perceptual colour space)
14
+ and the JASC ``.pal`` round-trip are inspired by Loxed's Porypal
15
+ (https://github.com/Loxed/porypal). Porypal is GPL-3.0 and Pokemon-specific;
16
+ this is a clean, independent reimplementation of just that reusable core,
17
+ distributed under the MIT license. Credit to Loxed for the approach.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import sys
24
+ from typing import List, Sequence, Tuple
25
+
26
+ import numpy as np
27
+ from PIL import Image
28
+
29
+ # Maximum number of colours in a GBA 16-colour (4bpp) palette.
30
+ GBA_MAX_COLORS = 16
31
+
32
+ RGB = Tuple[int, int, int]
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # JASC .pal I/O
37
+ # ---------------------------------------------------------------------------
38
+ def read_pal(path: str) -> List[RGB]:
39
+ """Read a JASC-PAL file and return a list of (R, G, B) tuples.
40
+
41
+ The JASC format is::
42
+
43
+ JASC-PAL
44
+ 0100
45
+ <count>
46
+ R G B
47
+ ...
48
+
49
+ Raises ValueError if the file is not a well-formed JASC palette.
50
+ """
51
+ with open(path, "r", encoding="utf-8") as fh:
52
+ lines = [line.strip() for line in fh.read().splitlines()]
53
+
54
+ # Drop trailing blank lines (decomp pals often end with one).
55
+ while lines and lines[-1] == "":
56
+ lines.pop()
57
+
58
+ if len(lines) < 3:
59
+ raise ValueError(f"{path}: too short to be a JASC palette")
60
+ if lines[0] != "JASC-PAL":
61
+ raise ValueError(f"{path}: missing 'JASC-PAL' magic header")
62
+ if lines[1] != "0100":
63
+ raise ValueError(f"{path}: unexpected version '{lines[1]}' (expected 0100)")
64
+
65
+ try:
66
+ count = int(lines[2])
67
+ except ValueError as exc:
68
+ raise ValueError(f"{path}: invalid colour count '{lines[2]}'") from exc
69
+
70
+ colour_lines = lines[3:]
71
+ if len(colour_lines) < count:
72
+ raise ValueError(
73
+ f"{path}: header declares {count} colours but only "
74
+ f"{len(colour_lines)} colour lines are present"
75
+ )
76
+
77
+ colours: List[RGB] = []
78
+ for i in range(count):
79
+ parts = colour_lines[i].split()
80
+ if len(parts) != 3:
81
+ raise ValueError(
82
+ f"{path}: colour line {i + 1} ('{colour_lines[i]}') is not 'R G B'"
83
+ )
84
+ try:
85
+ r, g, b = (int(p) for p in parts)
86
+ except ValueError as exc:
87
+ raise ValueError(
88
+ f"{path}: colour line {i + 1} has non-integer component"
89
+ ) from exc
90
+ for value, name in ((r, "R"), (g, "G"), (b, "B")):
91
+ if not 0 <= value <= 255:
92
+ raise ValueError(
93
+ f"{path}: {name}={value} on colour line {i + 1} out of range 0..255"
94
+ )
95
+ colours.append((r, g, b))
96
+
97
+ return colours
98
+
99
+
100
+ def write_pal(path: str, colours: Sequence[RGB]) -> None:
101
+ """Write a list of (R, G, B) tuples to a JASC-PAL file.
102
+
103
+ Uses CRLF (``\\r\\n``) line endings: that is what the decomp's ``.pal``
104
+ files use, and ``gbagfx`` rejects LF-only palettes ("LF line endings aren't
105
+ supported"). ``newline=""`` stops Python from re-translating them.
106
+ """
107
+ if not colours:
108
+ raise ValueError("refusing to write an empty palette")
109
+ with open(path, "w", encoding="utf-8", newline="") as fh:
110
+ fh.write("JASC-PAL\r\n")
111
+ fh.write("0100\r\n")
112
+ fh.write(f"{len(colours)}\r\n")
113
+ for r, g, b in colours:
114
+ fh.write(f"{r} {g} {b}\r\n")
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Colour space: sRGB <-> Oklab
119
+ # ---------------------------------------------------------------------------
120
+ # Oklab (https://bottosson.github.io/posts/oklab/) is a perceptually uniform
121
+ # colour space, so Euclidean distance in it matches human perception far better
122
+ # than raw RGB. We cluster and find nearest colours in Oklab.
123
+ def _srgb_to_linear(rgb: np.ndarray) -> np.ndarray:
124
+ """Convert sRGB in [0, 1] to linear-light RGB."""
125
+ return np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
126
+
127
+
128
+ def rgb_to_oklab(rgb_u8: np.ndarray) -> np.ndarray:
129
+ """Convert an (N, 3) array of 0..255 sRGB values to (N, 3) Oklab."""
130
+ rgb = _srgb_to_linear(rgb_u8.astype(np.float64) / 255.0)
131
+ r, g, b = rgb[:, 0], rgb[:, 1], rgb[:, 2]
132
+
133
+ l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
134
+ m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
135
+ s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
136
+
137
+ l_ = np.cbrt(l)
138
+ m_ = np.cbrt(m)
139
+ s_ = np.cbrt(s)
140
+
141
+ return np.stack(
142
+ [
143
+ 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
144
+ 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
145
+ 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
146
+ ],
147
+ axis=1,
148
+ )
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # K-means quantization
153
+ # ---------------------------------------------------------------------------
154
+ def _kmeans(
155
+ points: np.ndarray,
156
+ k: int,
157
+ *,
158
+ weights: np.ndarray | None = None,
159
+ seed: int = 0,
160
+ max_iter: int = 100,
161
+ ) -> np.ndarray:
162
+ """Run weighted k-means on (N, D) points; return (k, D) cluster centres.
163
+
164
+ ``weights`` (length N, default all-ones) lets a single point stand in for
165
+ many identical ones -- e.g. clustering an image's distinct colours weighted
166
+ by how many pixels have each, which is equivalent to clustering every pixel
167
+ but far cheaper. Uses k-means++ seeding for stable, good-quality centres.
168
+ ``k`` is clamped to the number of distinct points so we never produce empty
169
+ clusters.
170
+ """
171
+ rng = np.random.default_rng(seed)
172
+ n = points.shape[0]
173
+ k = min(k, n)
174
+ if weights is None:
175
+ weights = np.ones(n, dtype=np.float64)
176
+
177
+ # k-means++ initialisation (sampling weighted by D^2 * pixel count).
178
+ centres = np.empty((k, points.shape[1]), dtype=points.dtype)
179
+ centres[0] = points[rng.integers(n)]
180
+ closest_sq = np.sum((points - centres[0]) ** 2, axis=1)
181
+ for i in range(1, k):
182
+ scored = closest_sq * weights
183
+ total = scored.sum()
184
+ if total <= 0:
185
+ # All remaining points coincide with chosen centres; pad arbitrarily.
186
+ centres[i] = points[rng.integers(n)]
187
+ else:
188
+ centres[i] = points[rng.choice(n, p=scored / total)]
189
+ new_dist = np.sum((points - centres[i]) ** 2, axis=1)
190
+ closest_sq = np.minimum(closest_sq, new_dist)
191
+
192
+ labels = np.zeros(n, dtype=np.int64)
193
+ for _ in range(max_iter):
194
+ # Assign each point to the nearest centre.
195
+ dists = np.sum((points[:, None, :] - centres[None, :, :]) ** 2, axis=2)
196
+ new_labels = np.argmin(dists, axis=1)
197
+ if np.array_equal(new_labels, labels):
198
+ break
199
+ labels = new_labels
200
+ # Recompute centres as the weighted mean of their members; keep empties put.
201
+ for c in range(k):
202
+ mask = labels == c
203
+ w = weights[mask]
204
+ wsum = w.sum()
205
+ if wsum > 0:
206
+ centres[c] = (points[mask] * w[:, None]).sum(axis=0) / wsum
207
+
208
+ return centres
209
+
210
+
211
+ def quantize_image(img: Image.Image, n_colors: int) -> List[RGB]:
212
+ """Return up to ``n_colors`` representative RGB colours for ``img``.
213
+
214
+ Clusters the image's distinct colours -- weighted by how many pixels carry
215
+ each, so common colours dominate -- in Oklab for perceptual accuracy, then
216
+ maps each centre back to the nearest *actual* image colour so every palette
217
+ entry exists in the source. Weighting the distinct colours is equivalent to
218
+ clustering every pixel but costs only as much as the (small) palette of
219
+ distinct colours.
220
+ """
221
+ if n_colors < 1:
222
+ raise ValueError("n_colors must be >= 1")
223
+
224
+ rgb = np.asarray(img.convert("RGB"), dtype=np.uint8).reshape(-1, 3)
225
+ unique, counts = np.unique(rgb, axis=0, return_counts=True)
226
+
227
+ if len(unique) <= n_colors:
228
+ # Already within budget -- keep every colour, sorted for determinism.
229
+ ordered = unique[np.lexsort((unique[:, 2], unique[:, 1], unique[:, 0]))]
230
+ return [tuple(int(v) for v in row) for row in ordered]
231
+
232
+ # Cluster the distinct colours, weighted by pixel frequency, in Oklab space.
233
+ unique_lab = rgb_to_oklab(unique)
234
+ centres_lab = _kmeans(unique_lab, n_colors, weights=counts.astype(np.float64))
235
+
236
+ # Snap each cluster centre to the nearest real image colour (in Oklab).
237
+ palette: List[RGB] = []
238
+ seen = set()
239
+ for centre in centres_lab:
240
+ idx = int(np.argmin(np.sum((unique_lab - centre) ** 2, axis=1)))
241
+ colour = tuple(int(v) for v in unique[idx])
242
+ if colour not in seen:
243
+ seen.add(colour)
244
+ palette.append(colour)
245
+
246
+ palette.sort()
247
+ return palette
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Apply a palette to an image
252
+ # ---------------------------------------------------------------------------
253
+ def apply_palette(img: Image.Image, palette: Sequence[RGB]) -> Image.Image:
254
+ """Remap every pixel of ``img`` to its nearest palette colour (in Oklab).
255
+
256
+ Returns a paletted ('P' mode) image whose colours are exactly ``palette``,
257
+ so ``gbagfx`` can convert it to indexed GBA graphics.
258
+ """
259
+ if not palette:
260
+ raise ValueError("cannot apply an empty palette")
261
+ if len(palette) > 256:
262
+ raise ValueError("a paletted PNG supports at most 256 colours")
263
+
264
+ rgb = np.asarray(img.convert("RGB"), dtype=np.uint8)
265
+ h, w, _ = rgb.shape
266
+ flat = rgb.reshape(-1, 3)
267
+
268
+ pal_arr = np.asarray(palette, dtype=np.uint8)
269
+ pixels_lab = rgb_to_oklab(flat)
270
+ pal_lab = rgb_to_oklab(pal_arr)
271
+
272
+ dists = np.sum((pixels_lab[:, None, :] - pal_lab[None, :, :]) ** 2, axis=2)
273
+ indices = np.argmin(dists, axis=1).astype(np.uint8)
274
+
275
+ out = Image.fromarray(indices.reshape(h, w), mode="P")
276
+ # PIL palettes hold 256 entries; pad the unused tail with zeros.
277
+ flat_palette: List[int] = []
278
+ for r, g, b in palette:
279
+ flat_palette.extend((r, g, b))
280
+ flat_palette.extend([0] * (256 * 3 - len(flat_palette)))
281
+ out.putpalette(flat_palette)
282
+ return out
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # CLI
287
+ # ---------------------------------------------------------------------------
288
+ def cmd_extract(args: argparse.Namespace) -> int:
289
+ if not 1 <= args.n <= GBA_MAX_COLORS:
290
+ print(
291
+ f"error: -n must be between 1 and {GBA_MAX_COLORS} "
292
+ f"(a GBA 4bpp palette holds at most {GBA_MAX_COLORS} colours)",
293
+ file=sys.stderr,
294
+ )
295
+ return 2
296
+ img = Image.open(args.input)
297
+ palette = quantize_image(img, args.n)
298
+ write_pal(args.output, palette)
299
+ print(f"wrote {args.output}: {len(palette)} colours")
300
+ return 0
301
+
302
+
303
+ def cmd_apply(args: argparse.Namespace) -> int:
304
+ palette = read_pal(args.palette)
305
+ img = Image.open(args.input)
306
+ out = apply_palette(img, palette)
307
+ out.save(args.output)
308
+ print(f"wrote {args.output}: remapped to {len(palette)} palette colours")
309
+ return 0
310
+
311
+
312
+ def build_parser() -> argparse.ArgumentParser:
313
+ parser = argparse.ArgumentParser(
314
+ prog="porypal-fe8",
315
+ description="FE8 palette tool: PNG <-> 16-colour JASC .pal.",
316
+ )
317
+ sub = parser.add_subparsers(dest="command", required=True)
318
+
319
+ p_extract = sub.add_parser(
320
+ "extract",
321
+ help="quantize a PNG to <=16 colours and write a JASC .pal",
322
+ )
323
+ p_extract.add_argument("input", help="input PNG path")
324
+ p_extract.add_argument("-o", "--output", required=True, help="output .pal path")
325
+ p_extract.add_argument(
326
+ "-n",
327
+ type=int,
328
+ default=GBA_MAX_COLORS,
329
+ help=f"max colours (1..{GBA_MAX_COLORS}, default {GBA_MAX_COLORS})",
330
+ )
331
+ p_extract.set_defaults(func=cmd_extract)
332
+
333
+ p_apply = sub.add_parser(
334
+ "apply",
335
+ help="remap a PNG to a .pal palette and save an indexed PNG",
336
+ )
337
+ p_apply.add_argument("input", help="input PNG path")
338
+ p_apply.add_argument("palette", help="JASC .pal path")
339
+ p_apply.add_argument("-o", "--output", required=True, help="output PNG path")
340
+ p_apply.set_defaults(func=cmd_apply)
341
+
342
+ return parser
343
+
344
+
345
+ def main(argv: Sequence[str] | None = None) -> int:
346
+ parser = build_parser()
347
+ args = parser.parse_args(argv)
348
+ try:
349
+ return args.func(args)
350
+ except (OSError, ValueError) as exc:
351
+ print(f"error: {exc}", file=sys.stderr)
352
+ return 1
353
+
354
+
355
+ if __name__ == "__main__":
356
+ sys.exit(main())
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: porypal-fe8
3
+ Version: 0.1.1
4
+ Summary: FE8 palette tool: PNG -> 16-colour JASC .pal extraction & apply (fireemblem8u; inspired by Loxed's Porypal)
5
+ Author: laqieer
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 laqieer
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+
29
+ --------------------------------------------------------------------------------
30
+ Acknowledgements
31
+
32
+ porypal-fe8 is an independent, clean-room reimplementation inspired by the
33
+ reusable palette core of Loxed's Porypal (https://github.com/Loxed/porypal):
34
+ the idea of k-means colour quantization in a perceptual colour space and the
35
+ JASC ".pal" round-trip. Porypal is licensed under the GPL-3.0, which is
36
+ incompatible with this MIT distribution, so no Porypal source code was copied;
37
+ only the high-level approach was reused. Credit and thanks to Loxed.
38
+
39
+ Project-URL: Homepage, https://github.com/laqieer/porypal-fe8
40
+ Keywords: gba,fireemblem,palette,jasc,decomp,fe8
41
+ Requires-Python: >=3.9
42
+ Description-Content-Type: text/markdown
43
+ License-File: LICENSE
44
+ Requires-Dist: pillow>=9.0
45
+ Requires-Dist: numpy>=1.21
46
+ Dynamic: license-file
47
+
48
+ # porypal-fe8
49
+
50
+ A small, FE8-oriented palette CLI for the
51
+ [`fireemblem8u`](https://github.com/FireEmblemUniverse/fireemblem8u) decomp
52
+ graphics pipeline. It does two things:
53
+
54
+ - **`extract`** — quantize a PNG down to **≤16 colours** and write a GBA-style
55
+ **JASC `.pal`** palette.
56
+ - **`apply`** — remap every pixel of a PNG to its nearest colour in a given
57
+ `.pal` and save an **indexed PNG**, ready for `gbagfx`.
58
+
59
+ It is a focused, clean reimplementation of the *reusable core* of
60
+ [Loxed's Porypal](https://github.com/Loxed/porypal) — k-means colour
61
+ quantization in a perceptual colour space (Oklab) plus JASC `.pal` I/O — with
62
+ all the Pokémon-specific and UI parts dropped. See
63
+ [Credits & license](#credits--license).
64
+
65
+ ## Why this exists (honest note)
66
+
67
+ For day-to-day FE8 graphics work you usually **do not need this tool**:
68
+
69
+ - [**Usenti**](https://www.coranac.com/projects/usenti/) handles palette
70
+ editing, reduction, and indexed-PNG export interactively.
71
+ - The decomp's own **`gbagfx`** already converts between `.png`, `.pal`,
72
+ `.gbapal`, and `.4bpp`.
73
+
74
+ `porypal-fe8` is for **batch / automated quantization** — e.g. scripting the
75
+ reduction of many full-colour PNGs to 16-colour palettes in a build or asset
76
+ pipeline, where a headless CLI is more convenient than a GUI.
77
+
78
+ ## Install
79
+
80
+ Requires Python 3.9+.
81
+
82
+ ```sh
83
+ python3 -m venv .venv && . .venv/bin/activate
84
+ pip install -r requirements.txt
85
+ # optional: install the `porypal-fe8` console command
86
+ pip install .
87
+ ```
88
+
89
+ Without installing, you can run the module directly:
90
+
91
+ ```sh
92
+ python3 palette_tool.py extract IN.png -o OUT.pal
93
+ ```
94
+
95
+ ## Install / Releases
96
+
97
+ Once published to PyPI:
98
+
99
+ ```sh
100
+ pip install porypal-fe8
101
+ ```
102
+
103
+ Each `v*` tag also produces a **GitHub Release** with the built wheel and sdist
104
+ attached (under [Releases](https://github.com/laqieer/porypal-fe8/releases)),
105
+ so you can `pip install` a downloaded artifact even without PyPI.
106
+
107
+ > **Note:** PyPI publishing uses [OIDC trusted
108
+ > publishing](https://docs.pypi.org/trusted-publishers/) (no API token). It
109
+ > requires a **trusted publisher** to be configured on PyPI for project
110
+ > `porypal-fe8`, owner `laqieer`, workflow `release.yml`, environment `pypi`.
111
+ > The GitHub Release job is independent and succeeds even before that is set up.
112
+
113
+ ## Usage
114
+
115
+ ### Extract a palette
116
+
117
+ ```sh
118
+ porypal-fe8 extract IN.png -o OUT.pal [-n 16]
119
+ ```
120
+
121
+ Quantizes `IN.png` to at most `-n` colours (default 16, the GBA 4bpp limit) and
122
+ writes a JASC `.pal`:
123
+
124
+ ```
125
+ JASC-PAL
126
+ 0100
127
+ 16
128
+ 115 131 164
129
+ 255 255 255
130
+ ...
131
+ ```
132
+
133
+ ### Apply a palette
134
+
135
+ ```sh
136
+ porypal-fe8 apply IN.png PALETTE.pal -o OUT.png
137
+ ```
138
+
139
+ Remaps every pixel of `IN.png` to the nearest colour in `PALETTE.pal` (nearest
140
+ in Oklab) and saves an indexed (`P`-mode) PNG whose colours are exactly the
141
+ palette.
142
+
143
+ ## How it fits the FE8 pipeline
144
+
145
+ The decomp stores graphics as PNGs and JASC `.pal` palettes, and its Makefile
146
+ drives `gbagfx` to turn those into the GBA's native formats:
147
+
148
+ ```
149
+ porypal-fe8 extract gbagfx (pal2gbapal)
150
+ IN.png ───────────────────────▶ OUT.pal ───────────────────▶ .gbapal (32 bytes for 16 colours)
151
+
152
+ porypal-fe8 apply gbagfx (png2gbagfx)
153
+ IN.png ───────────────────────▶ OUT.png ───────────────────▶ .4bpp
154
+ (+ .pal) (indexed)
155
+ ```
156
+
157
+ Relevant Makefile rules in the decomp:
158
+
159
+ ```make
160
+ %.gbapal: %.pal ; $(PAL2GBAPAL) $< $@
161
+ %.gbapal: %.png ; $(GBAGFX) $< $@
162
+ ```
163
+
164
+ A 16-colour JASC `.pal` converts to exactly **32 bytes** of `.gbapal`
165
+ (16 colours × 2 bytes, the GBA's 15-bit BGR555 format). Palettes are written
166
+ with **CRLF** line endings to match the decomp's `.pal` files — `gbagfx`
167
+ rejects LF-only palettes. See the
168
+ [`fireemblem8u`](https://github.com/FireEmblemUniverse/fireemblem8u) repo and
169
+ [`gbagfx`](https://github.com/pret/pokeemerald/tree/master/tools/gbagfx) for the
170
+ full graphics flow.
171
+
172
+ ## How it works
173
+
174
+ - **Quantization** clusters the image's pixels with **k-means++** in **Oklab**,
175
+ a perceptually uniform colour space, so the chosen colours match how the eye
176
+ groups them. Each cluster centre is then snapped to the nearest *actual*
177
+ colour in the source image, so every palette entry really occurs in the PNG.
178
+ If the image already has ≤ N colours, they are kept verbatim.
179
+ - **Apply** assigns each pixel to the nearest palette colour, again measured in
180
+ Oklab.
181
+
182
+ ## Credits & license
183
+
184
+ The colour-quantization approach and JASC `.pal` round-trip are **inspired by**
185
+ [Loxed's Porypal](https://github.com/Loxed/porypal). Porypal is licensed under
186
+ the **GPL-3.0**, which is incompatible with this project's MIT license, so
187
+ **no Porypal source code was copied** — this is an independent reimplementation
188
+ of the high-level idea only. Credit and thanks to Loxed.
189
+
190
+ porypal-fe8 itself is released under the [MIT License](LICENSE).
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ palette_tool.py
4
+ pyproject.toml
5
+ porypal_fe8.egg-info/PKG-INFO
6
+ porypal_fe8.egg-info/SOURCES.txt
7
+ porypal_fe8.egg-info/dependency_links.txt
8
+ porypal_fe8.egg-info/entry_points.txt
9
+ porypal_fe8.egg-info/requires.txt
10
+ porypal_fe8.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ porypal-fe8 = palette_tool:main
@@ -0,0 +1,2 @@
1
+ pillow>=9.0
2
+ numpy>=1.21
@@ -0,0 +1 @@
1
+ palette_tool
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "porypal-fe8"
7
+ version = "0.1.1"
8
+ description = "FE8 palette tool: PNG -> 16-colour JASC .pal extraction & apply (fireemblem8u; inspired by Loxed's Porypal)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "laqieer" }]
13
+ keywords = ["gba", "fireemblem", "palette", "jasc", "decomp", "fe8"]
14
+ dependencies = [
15
+ "pillow>=9.0",
16
+ "numpy>=1.21",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/laqieer/porypal-fe8"
21
+
22
+ [project.scripts]
23
+ porypal-fe8 = "palette_tool:main"
24
+
25
+ [tool.setuptools]
26
+ py-modules = ["palette_tool"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+