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.
- porypal_fe8-0.1.1/LICENSE +32 -0
- porypal_fe8-0.1.1/PKG-INFO +190 -0
- porypal_fe8-0.1.1/README.md +143 -0
- porypal_fe8-0.1.1/palette_tool.py +356 -0
- porypal_fe8-0.1.1/porypal_fe8.egg-info/PKG-INFO +190 -0
- porypal_fe8-0.1.1/porypal_fe8.egg-info/SOURCES.txt +10 -0
- porypal_fe8-0.1.1/porypal_fe8.egg-info/dependency_links.txt +1 -0
- porypal_fe8-0.1.1/porypal_fe8.egg-info/entry_points.txt +2 -0
- porypal_fe8-0.1.1/porypal_fe8.egg-info/requires.txt +2 -0
- porypal_fe8-0.1.1/porypal_fe8.egg-info/top_level.txt +1 -0
- porypal_fe8-0.1.1/pyproject.toml +26 -0
- porypal_fe8-0.1.1/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|