diff --git a/README.md b/README.md index c4e65ee..3c2df5c 100644 --- a/README.md +++ b/README.md @@ -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 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 diff --git a/sks/image/image.k b/sks/image/image.k index bee9db7..d40e90e 100644 --- a/sks/image/image.k +++ b/sks/image/image.k @@ -19,6 +19,19 @@ colours per cell under ansi), braille (eight dots per cell). :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 :desc Under txt and ansi, the density ramp assumes a dark terminal background (true): bright pixels are the dense characters. diff --git a/sks/image/image.py b/sks/image/image.py index 7a609c0..c865775 100644 --- a/sks/image/image.py +++ b/sks/image/image.py @@ -122,6 +122,15 @@ class Image(klammer_base.Klammer_base): Image_quality, whose shifted colours and edge ringing are invisible on a page and plain to see in eighty characters. A PDF source has 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 source = self.cache.find_file(self.cache_target, self.basename) or self.source if os.path.splitext(source)[1][1:].lower() in ("pdf", "eps"): @@ -141,6 +150,48 @@ class Image(klammer_base.Klammer_base): rows.append(row) 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 def txt_dither(samples, dark_background): """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): 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": + # 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) for y in range(rows): cells.append([("▀", samples[2 * y][x][1], samples[2 * y + 1][x][1]) for x in range(cols)])