Image_ansi_colors: dithering to the 256-colour terminal palette, for a terminal without true colour (from dev 1ce461b4)

This commit is contained in:
ack committed 2026-10-01 18:42:56 +02:00
1 parent 62f08401aa
commit 6e9889f7ae
3 files changed
+71 -1

No files matched your search

+1 -1
View File
@@ -54,7 +54,7 @@ are regenerated on each release — patches cannot be merged directly.
Report problems (or send patches) to the author; accepted changes are Report problems (or send patches) to the author; accepted changes are
applied to the development tree and appear in a following snapshot. applied to the development tree and appear in a following snapshot.
This snapshot was assembled from development commit `6f49f498c3c6`. This snapshot was assembled from development commit `1ce461b48a7d`.
## License ## License
+13
View File
@@ -19,6 +19,19 @@
colours per cell under ansi), braille (eight dots per cell). colours per cell under ansi), braille (eight dots per cell).
:value halfblock :value halfblock
@@@ @@@
@@@state Image_ansi_colors
:desc Under ansi, the colours an image is drawn with. true (the
default) emits each cell's exact 24-bit colour, and how
faithfully the picture appears then depends on the terminal
supporting true colour: most do (GNOME Terminal, iTerm2,
Ghostty, kitty, WezTerm); one that does not (Apple's
Terminal) rounds every colour to its nearest of 256 fixed
ones, and runs of similar cells become one blotch. 256 is
for such a terminal: the picture is first dithered to that
256-colour palette, so the rounding error is spread over
neighbouring cells instead. A true-colour terminal shows the
exact picture and needs no setting.
:value true @@@
@@@state Image_txt_dark_background @@@state Image_txt_dark_background
:desc Under txt and ansi, the density ramp assumes a dark terminal :desc Under txt and ansi, the density ramp assumes a dark terminal
background (true): bright pixels are the dense characters. background (true): bright pixels are the dense characters.
+57
View File
@@ -122,6 +122,15 @@ class Image(klammer_base.Klammer_base):
Image_quality, whose shifted colours and edge ringing are invisible Image_quality, whose shifted colours and edge ringing are invisible
on a page and plain to see in eighty characters. A PDF source has on a page and plain to see in eighty characters. A PDF source has
to go through the cache's rasterising.""" to go through the cache's rasterising."""
samples = self.txt_samples_raw(width, height)
colors = str(getattr(self, "Image_ansi_colors", "true")).strip().lower()
if colors not in ("true", "256"):
raise Exception(f'Image_ansi_colors "{colors}" is not one of true, 256')
if colors == "256":
samples = Image.txt_palette_dither(samples)
return samples
def txt_samples_raw(self, width, height):
import OpenImageIO as oiio import OpenImageIO as oiio
source = self.cache.find_file(self.cache_target, self.basename) or self.source source = self.cache.find_file(self.cache_target, self.basename) or self.source
if os.path.splitext(source)[1][1:].lower() in ("pdf", "eps"): if os.path.splitext(source)[1][1:].lower() in ("pdf", "eps"):
@@ -141,6 +150,48 @@ class Image(klammer_base.Klammer_base):
rows.append(row) rows.append(row)
return rows return rows
# The xterm 256-colour palette (the 6x6x6 cube and the 24 greys; the 16
# system colours are left out, a terminal redefines them), for a terminal
# that shows no 24-bit colour and maps each colour to its nearest entry
# (Apple's Terminal, 2026-10-01): dithering the picture to the palette
# FIRST spreads each cell's error over its neighbours instead of letting
# the terminal round whole runs of a sky to one entry.
TXT_LEVELS = (0, 95, 135, 175, 215, 255) # the cube's axis; the greys are 8, 18, ... 238
@staticmethod
def txt_nearest_palette(rgb):
r, g, b = rgb
cube = tuple(min(Image.TXT_LEVELS, key=lambda l: abs(l - v)) for v in (r, g, b))
grey = max(8, min(238, 8 + 10 * round(((r + g + b) / 3 - 8) / 10)))
# Weighted as the eye weighs the channels, so a grey-blue sky does
# not fall to a green or a yellow entry when its error moves it.
d = lambda c: 2 * (c[0] - r) ** 2 + 4 * (c[1] - g) ** 2 + 3 * (c[2] - b) ** 2
return cube if d(cube) <= d((grey, grey, grey)) else (grey, grey, grey)
@staticmethod
def txt_palette_dither(samples):
"""samples (rows of (luminance, rgb)) with every rgb replaced by a
palette colour, the error diffused Floyd-Steinberg fashion in RGB."""
h, w = len(samples), len(samples[0]) if samples else 0
acc = [[list(map(float, rgb)) for lum, rgb in row] for row in samples]
out = []
for y in range(h):
row = []
for x in range(w):
want = tuple(min(255.0, max(0.0, v)) for v in acc[y][x])
got = Image.txt_nearest_palette(tuple(int(round(v)) for v in want))
# Three quarters of the error travel on: full diffusion
# over a 256-entry palette speckles a smooth sky with
# far-off entries, none of it looks posterised.
err = [0.75 * (a - b) for a, b in zip(want, got)]
for dx, dy, wgt in ((1, 0, 7 / 16), (-1, 1, 3 / 16), (0, 1, 5 / 16), (1, 1, 1 / 16)):
xx, yy = x + dx, y + dy
if 0 <= xx < w and yy < h:
acc[yy][xx] = [a + e * wgt for a, e in zip(acc[yy][xx], err)]
row.append((samples[y][x][0], got))
out.append(row)
return out
@staticmethod @staticmethod
def txt_dither(samples, dark_background): def txt_dither(samples, dark_background):
"""Floyd-Steinberg error diffusion of the luminance to one bit: True """Floyd-Steinberg error diffusion of the luminance to one bit: True
@@ -193,6 +244,12 @@ class Image(klammer_base.Klammer_base):
for row in self.txt_samples(cols, rows): for row in self.txt_samples(cols, rows):
cells.append([(ramp[min(len(ramp) - 1, int(lum * len(ramp)))], rgb, Image.txt_toned(rgb, dark)) for lum, rgb in row]) cells.append([(ramp[min(len(ramp) - 1, int(lum * len(ramp)))], rgb, Image.txt_toned(rgb, dark)) for lum, rgb in row])
elif style == "halfblock": elif style == "halfblock":
# Every cell is "▀" with the pair's two EXACT colours as foreground
# and background. Choosing the character by the pair's dithered
# brightness (tried 2026-10-01, to give txt a monochrome picture)
# merges every pair of one brightness class into one colour, and
# Andy saw the merged pairs at once on Linux; the txt twin stays a
# solid field, as Image_txt_style's description says.
samples = self.txt_samples(cols, rows * 2) samples = self.txt_samples(cols, rows * 2)
for y in range(rows): for y in range(rows):
cells.append([("▀", samples[2 * y][x][1], samples[2 * y + 1][x][1]) for x in range(cols)]) cells.append([("▀", samples[2 * y][x][1], samples[2 * y + 1][x][1]) for x in range(cols)])