Image_ansi_colors: dithering to the 256-colour terminal palette, for a terminal without true colour (from dev 1ce461b4)
This commit is contained in:
1 parent
62f08401aa
commit
6e9889f7ae
3 files changed
+71
-1
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)])
|
||||||
|
|||||||
Reference in new issue
Block a user