Implement grayscale DMG palette specs (#1709)

This commit is contained in:
Rangi
2025-06-30 14:53:05 -04:00
committed by GitHub
parent 5942117ac3
commit 7054d81650
21 changed files with 166 additions and 76 deletions

View File

@@ -29,8 +29,10 @@ struct Options {
NO_SPEC, NO_SPEC,
EXPLICIT, EXPLICIT,
EMBEDDED, EMBEDDED,
DMG,
} palSpecType = NO_SPEC; // -c } palSpecType = NO_SPEC; // -c
std::vector<std::array<std::optional<Rgba>, 4>> palSpec{}; std::vector<std::array<std::optional<Rgba>, 4>> palSpec{};
uint8_t palSpecDmg = 0;
uint8_t bitDepth = 2; // -d uint8_t bitDepth = 2; // -d
std::string inputTileset{}; // -i std::string inputTileset{}; // -i
struct { struct {
@@ -65,6 +67,12 @@ struct Options {
mutable bool hasTransparentPixels = false; mutable bool hasTransparentPixels = false;
uint8_t maxOpaqueColors() const { return nbColorsPerPal - hasTransparentPixels; } uint8_t maxOpaqueColors() const { return nbColorsPerPal - hasTransparentPixels; }
uint8_t dmgColors[4] = {};
uint8_t dmgValue(uint8_t i) const {
assume(i < 4);
return (palSpecDmg >> (2 * i)) & 0b11;
}
}; };
extern Options options; extern Options options;
@@ -119,27 +127,4 @@ static constexpr auto flipTable = ([]() constexpr {
return table; return table;
})(); })();
// Parsing helpers.
static constexpr uint8_t nibble(char c) {
if (c >= 'a') {
assume(c <= 'f');
return c - 'a' + 10;
} else if (c >= 'A') {
assume(c <= 'F');
return c - 'A' + 10;
} else {
assume(c >= '0' && c <= '9');
return c - '0';
}
}
static constexpr uint8_t toHex(char c1, char c2) {
return nibble(c1) * 16 + nibble(c2);
}
static constexpr uint8_t singleToHex(char c) {
return toHex(c, c);
}
#endif // RGBDS_GFX_MAIN_HPP #endif // RGBDS_GFX_MAIN_HPP

View File

@@ -3,7 +3,10 @@
#ifndef RGBDS_GFX_PAL_SPEC_HPP #ifndef RGBDS_GFX_PAL_SPEC_HPP
#define RGBDS_GFX_PAL_SPEC_HPP #define RGBDS_GFX_PAL_SPEC_HPP
void parseInlinePalSpec(char const * const arg); void parseInlinePalSpec(char const * const rawArg);
void parseExternalPalSpec(char const *arg); void parseExternalPalSpec(char const *arg);
void parseDmgPalSpec(char const * const rawArg);
void parseBackgroundPalSpec(char const *arg);
#endif // RGBDS_GFX_PAL_SPEC_HPP #endif // RGBDS_GFX_PAL_SPEC_HPP

View File

@@ -159,6 +159,24 @@ is the case-insensitive word
then the first four colors of the input PNG's embedded palette are used. then the first four colors of the input PNG's embedded palette are used.
It is an error if the PNG is not indexed, or if colors other than these 4 are used. It is an error if the PNG is not indexed, or if colors other than these 4 are used.
.Pq This is different from the default behavior of indexed PNGs, as then unused entries in the embedded palette are ignored, whereas they are not with Fl c Cm embedded . .Pq This is different from the default behavior of indexed PNGs, as then unused entries in the embedded palette are ignored, whereas they are not with Fl c Cm embedded .
.It Sy DMG palette spec
If
.Ar pal_spec
starts with case-insensitive
.Cm dmg= ,
then the following two-digit hexadecimal number specifies four grayscale DMG color indexes.
The number functions like the DMG's $FF47
.Sy BGP
register
(see
.Lk https://gbdev.io/pandocs/Palettes.html Pan Docs
for more information):
the low two bits 0-1 specify which gray shade goes in color index 0,
the next two bits 2-3 specify which gray shade goes in color index 1,
and so on.
Gray shade 0 is the lightest (white), 3 is the darkest (black).
The same gray shade cannot go in two color indexes.
To specify a DMG palette, the input PNG must have all its colors in shades of gray, without any transparent colors.
.It Sy external palette spec .It Sy external palette spec
Otherwise, Otherwise,
.Ar pal_spec .Ar pal_spec
@@ -528,6 +546,8 @@ Otherwise, if the PNG only contains shades of gray, they will be categorized int
.Dq bins .Dq bins
as there are colors per palette, and the palette is set to these bins. as there are colors per palette, and the palette is set to these bins.
The darkest gray will end up in bin #0, and so on; note that this is the opposite of the RGB method below. The darkest gray will end up in bin #0, and so on; note that this is the opposite of the RGB method below.
This is equivalent to having specified a DMG palette of
.Fl c Cm dmg=E4 .
If two distinct grays end up in the same bin, the RGB method is used instead. If two distinct grays end up in the same bin, the RGB method is used instead.
.Pp .Pp
Be careful that Be careful that

View File

@@ -16,7 +16,6 @@
#include "extern/getopt.hpp" #include "extern/getopt.hpp"
#include "file.hpp" #include "file.hpp"
#include "helpers.hpp" // assume
#include "platform.hpp" #include "platform.hpp"
#include "version.hpp" #include "version.hpp"
@@ -354,7 +353,6 @@ static char *parseArgv(int argc, char *argv[]) {
for (int ch; (ch = musl_getopt_long_only(argc, argv, optstring, longopts, nullptr)) != -1;) { for (int ch; (ch = musl_getopt_long_only(argc, argv, optstring, longopts, nullptr)) != -1;) {
char *arg = musl_optarg; // Make a copy for scanning char *arg = musl_optarg; // Make a copy for scanning
uint16_t number; uint16_t number;
size_t size;
switch (ch) { switch (ch) {
case 'A': case 'A':
localOptions.autoAttrmap = true; localOptions.autoAttrmap = true;
@@ -367,41 +365,7 @@ static char *parseArgv(int argc, char *argv[]) {
options.attrmap = musl_optarg; options.attrmap = musl_optarg;
break; break;
case 'B': case 'B':
if (strcasecmp(musl_optarg, "transparent") == 0) { parseBackgroundPalSpec(musl_optarg);
options.bgColor = Rgba(0x00, 0x00, 0x00, 0x00);
break;
}
if (musl_optarg[0] != '#') {
error("Background color specification must be `#rgb`, `#rrggbb`, or `transparent`");
break;
}
size = strspn(&musl_optarg[1], "0123456789ABCDEFabcdef");
switch (size) {
case 3:
options.bgColor = Rgba(
singleToHex(musl_optarg[1]),
singleToHex(musl_optarg[2]),
singleToHex(musl_optarg[3]),
0xFF
);
break;
case 6:
options.bgColor = Rgba(
toHex(musl_optarg[1], musl_optarg[2]),
toHex(musl_optarg[3], musl_optarg[4]),
toHex(musl_optarg[5], musl_optarg[6]),
0xFF
);
break;
default:
error("Unknown background color specification \"%s\"", musl_optarg);
}
if (musl_optarg[size + 1] != '\0') {
error(
"Unexpected text \"%s\" after background color specification",
&musl_optarg[size + 1]
);
}
break; break;
case 'b': case 'b':
number = parseNumber(arg, "Bank 0 base tile ID", 0); number = parseNumber(arg, "Bank 0 base tile ID", 0);
@@ -442,18 +406,18 @@ static char *parseArgv(int argc, char *argv[]) {
options.useColorCurve = true; options.useColorCurve = true;
break; break;
case 'c': case 'c':
localOptions.externalPalSpec = nullptr; // Allow overriding a previous pal spec
if (musl_optarg[0] == '#') { if (musl_optarg[0] == '#') {
options.palSpecType = Options::EXPLICIT; options.palSpecType = Options::EXPLICIT;
parseInlinePalSpec(musl_optarg); parseInlinePalSpec(musl_optarg);
} else if (strcasecmp(musl_optarg, "embedded") == 0) { } else if (strcasecmp(musl_optarg, "embedded") == 0) {
// Use PLTE, error out if missing // Use PLTE, error out if missing
options.palSpecType = Options::EMBEDDED; options.palSpecType = Options::EMBEDDED;
} else if (strncasecmp(musl_optarg, "dmg=", literal_strlen("dmg=")) == 0) {
options.palSpecType = Options::DMG;
parseDmgPalSpec(&musl_optarg[literal_strlen("dmg=")]);
} else { } else {
options.palSpecType = Options::EXPLICIT; options.palSpecType = Options::EXPLICIT;
// Can't parse the file yet, as "flat" color collections need to know the palette
// size to be split; thus, we defer that.
// If a following `-c` overrides a previous one, the `fmt` part of an overridden
// external palette spec will not be validated, but I guess that's okay.
localOptions.externalPalSpec = musl_optarg; localOptions.externalPalSpec = musl_optarg;
} }
break; break;
@@ -877,6 +841,8 @@ int main(int argc, char *argv[]) {
return "Explicit"; return "Explicit";
case Options::EMBEDDED: case Options::EMBEDDED:
return "Embedded"; return "Embedded";
case Options::DMG:
return "DMG";
} }
return "???"; return "???";
}()); }());

View File

@@ -299,7 +299,7 @@ static void decant(
break; break;
} }
auto attrs = from.begin(); auto attrs = from.begin();
std::advance(attrs, (iter - processed.begin())); std::advance(attrs, iter - processed.begin());
// Build up the "component"... // Build up the "component"...
colors.clear(); colors.clear();

View File

@@ -28,6 +28,27 @@ static void skipWhitespace(Str const &str, size_t &pos) {
pos = std::min(str.find_first_not_of(" \t"sv, pos), str.length()); pos = std::min(str.find_first_not_of(" \t"sv, pos), str.length());
} }
static constexpr uint8_t nibble(char c) {
if (c >= 'a') {
assume(c <= 'f');
return c - 'a' + 10;
} else if (c >= 'A') {
assume(c <= 'F');
return c - 'A' + 10;
} else {
assume(c >= '0' && c <= '9');
return c - '0';
}
}
static constexpr uint8_t toHex(char c1, char c2) {
return nibble(c1) * 16 + nibble(c2);
}
static constexpr uint8_t singleToHex(char c) {
return toHex(c, c);
}
void parseInlinePalSpec(char const * const rawArg) { void parseInlinePalSpec(char const * const rawArg) {
// List of #rrggbb/#rgb colors (or #none); comma-separated. // List of #rrggbb/#rgb colors (or #none); comma-separated.
// Palettes are separated by colons. // Palettes are separated by colons.
@@ -595,3 +616,61 @@ void parseExternalPalSpec(char const *arg) {
std::get<1> (*iter)(file); std::get<1> (*iter)(file);
} }
void parseDmgPalSpec(char const * const rawArg) {
// Two hex digit DMG palette spec
std::string_view arg(rawArg);
if (arg.length() != 2
|| arg.find_first_not_of("0123456789ABCDEFabcdef"sv) != std::string_view::npos) {
error("Unknown DMG palette specification \"%s\"", rawArg);
return;
}
options.palSpecDmg = toHex(arg[0], arg[1]);
// Map gray shades to their DMG color indexes for fast lookup by `Rgba::grayIndex`
for (uint8_t i = 0; i < 4; ++i) {
options.dmgColors[options.dmgValue(i)] = i;
}
// Validate that DMG palette spec does not have conflicting colors
for (uint8_t i = 0; i < 3; ++i) {
for (uint8_t j = i + 1; j < 4; ++j) {
if (options.dmgValue(i) == options.dmgValue(j)) {
error("DMG palette specification maps two gray shades to the same color index");
return;
}
}
}
}
void parseBackgroundPalSpec(char const *arg) {
if (strcasecmp(arg, "transparent") == 0) {
options.bgColor = Rgba(0x00, 0x00, 0x00, 0x00);
return;
}
if (arg[0] != '#') {
error("Background color specification must be `#rgb`, `#rrggbb`, or `transparent`");
return;
}
size_t size = strspn(&arg[1], "0123456789ABCDEFabcdef");
switch (size) {
case 3:
options.bgColor = Rgba(singleToHex(arg[1]), singleToHex(arg[2]), singleToHex(arg[3]), 0xFF);
break;
case 6:
options.bgColor =
Rgba(toHex(arg[1], arg[2]), toHex(arg[3], arg[4]), toHex(arg[5], arg[6]), 0xFF);
break;
default:
error("Unknown background color specification \"%s\"", arg);
}
if (arg[size + 1] != '\0') {
error("Unexpected text \"%s\" after background color specification", &arg[size + 1]);
}
}

View File

@@ -586,8 +586,10 @@ static std::tuple<DefaultInitVec<size_t>, std::vector<Palette>>
} }
// "Sort" colors in the generated palettes, see the man page for the flowchart // "Sort" colors in the generated palettes, see the man page for the flowchart
auto [embPalSize, embPalRGB, embPalAlphaSize, embPalAlpha] = png.getEmbeddedPal(); if (options.palSpecType == Options::DMG) {
if (embPalRGB != nullptr) { sortGrayscale(palettes, png.getColors().raw());
} else if (auto [embPalSize, embPalRGB, embPalAlphaSize, embPalAlpha] = png.getEmbeddedPal();
embPalRGB != nullptr) {
sortIndexed(palettes, embPalSize, embPalRGB, embPalAlphaSize, embPalAlpha); sortIndexed(palettes, embPalSize, embPalRGB, embPalAlphaSize, embPalAlpha);
} else if (png.isSuitableForGrayscale()) { } else if (png.isSuitableForGrayscale()) {
sortGrayscale(palettes, png.getColors().raw()); sortGrayscale(palettes, png.getColors().raw());
@@ -1139,6 +1141,18 @@ void process() {
} }
// LCOV_EXCL_STOP // LCOV_EXCL_STOP
if (options.palSpecType == Options::DMG) {
if (options.hasTransparentPixels) {
fatal(
"Image contains transparent pixels, not compatible with a DMG palette specification"
);
}
if (!png.isSuitableForGrayscale()) {
fatal("Image contains too many or non-gray colors, not compatible with a DMG palette "
"specification");
}
}
// Now, iterate through the tiles, generating proto-palettes as we go // Now, iterate through the tiles, generating proto-palettes as we go
// We do this unconditionally because this performs the image validation (which we want to // We do this unconditionally because this performs the image validation (which we want to
// perform even if no output is requested), and because it's necessary to generate any // perform even if no output is requested), and because it's necessary to generate any
@@ -1248,9 +1262,10 @@ continue_visiting_tiles:;
if (options.palSpecType == Options::EMBEDDED) { if (options.palSpecType == Options::EMBEDDED) {
generatePalSpec(png); generatePalSpec(png);
} }
auto [mappings, palettes] = options.palSpecType == Options::NO_SPEC auto [mappings, palettes] =
? generatePalettes(protoPalettes, png) options.palSpecType == Options::NO_SPEC || options.palSpecType == Options::DMG
: makePalsAsSpecified(protoPalettes); ? generatePalettes(protoPalettes, png)
: makePalsAsSpecified(protoPalettes);
outputPalettes(palettes); outputPalettes(palettes);
// If deduplication is not happening, we just need to output the tile data and/or maps as-is // If deduplication is not happening, we just need to output the tile data and/or maps as-is

View File

@@ -195,10 +195,13 @@ void reverse() {
Options::VERB_INTERM, "Reversed image dimensions: %zux%zu tiles\n", width, height Options::VERB_INTERM, "Reversed image dimensions: %zux%zu tiles\n", width, height
); );
std::vector<std::array<std::optional<Rgba>, 4>> palettes{ Rgba const grayColors[4] = {
{Rgba(0xFFFFFFFF), Rgba(0xAAAAAAFF), Rgba(0x555555FF), Rgba(0x000000FF)} Rgba(0xFFFFFFFF), Rgba(0xAAAAAAFF), Rgba(0x555555FF), Rgba(0x000000FF)
}; };
// If a palette file is used as input, it overrides the default colors. std::vector<std::array<std::optional<Rgba>, 4>> palettes{
{grayColors[0], grayColors[1], grayColors[2], grayColors[3]}
};
// If a palette file or palette spec is used as input, it overrides the default colors.
if (!options.palettes.empty()) { if (!options.palettes.empty()) {
File file; File file;
if (!file.open(options.palettes, std::ios::in | std::ios::binary)) { if (!file.open(options.palettes, std::ios::in | std::ios::binary)) {
@@ -255,6 +258,10 @@ void reverse() {
putc('\n', stderr); putc('\n', stderr);
} }
} }
} else if (options.palSpecType == Options::DMG) {
for (size_t i = 0; i < palettes[0].size(); ++i) {
palettes[0][i] = grayColors[options.dmgValue(i)];
}
} else if (options.palSpecType == Options::EMBEDDED) { } else if (options.palSpecType == Options::EMBEDDED) {
warning("An embedded palette was requested, but no palette file was specified; ignoring " warning("An embedded palette was requested, but no palette file was specified; ignoring "
"request."); "request.");

View File

@@ -58,7 +58,15 @@ uint16_t Rgba::cgbColor() const {
uint8_t Rgba::grayIndex() const { uint8_t Rgba::grayIndex() const {
assume(isGray()); assume(isGray());
// Convert from 0..<256 to hasTransparentPixels..<nbColorsPerPal // 2bpp shades are inverted from RGB PNG; %00 = white, %11 = black
uint8_t gray = 255 - red;
if (options.palSpecType == Options::DMG) {
assume(!options.hasTransparentPixels);
// Reduce gray shade from 0..<256 to 0..<4, then map to color index,
// then reduce to 0..<nbColorsPerPal
return options.dmgColors[gray * 4 / 256] * options.nbColorsPerPal / 4;
}
// Reduce gray shade from 0..<256 to hasTransparentPixels..<nbColorsPerPal
// Note that `maxOpaqueColors()` already takes `hasTransparentPixels` into account // Note that `maxOpaqueColors()` already takes `hasTransparentPixels` into account
return (255 - red) * options.maxOpaqueColors() / 256 + options.hasTransparentPixels; return gray * options.maxOpaqueColors() / 256 + options.hasTransparentPixels;
} }

Binary file not shown.

View File

@@ -0,0 +1,2 @@
-d 1
-c dmg=1b

2
test/gfx/dmg_2bit.flags Normal file
View File

@@ -0,0 +1,2 @@
-c nonexistent.gpl
-c DMG=E4

BIN
test/gfx/dmg_2bit.out.2bpp Normal file

Binary file not shown.

BIN
test/gfx/dmg_2bit.out.pal Normal file

Binary file not shown.

BIN
test/gfx/dmg_2bit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

2
test/gfx/dmg_plte.flags Normal file
View File

@@ -0,0 +1,2 @@
-c embedded
-c DMG=E4

BIN
test/gfx/dmg_plte.out.2bpp Normal file

Binary file not shown.

BIN
test/gfx/dmg_plte.out.pal Normal file

Binary file not shown.

BIN
test/gfx/dmg_plte.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

View File

@@ -0,0 +1 @@
-c dmg=72