From 2113ed2d74853aed086cadb4284c8a7f9fa3cb74 Mon Sep 17 00:00:00 2001 From: KutayX7 <103666634+KutayX7@users.noreply.github.com> Date: Tue, 13 May 2025 23:21:05 +0300 Subject: [PATCH 01/10] Added some multiprocessing Converting large images to SIXEL with the default options is now a lot faster on multi-core systems. On single core systems (or on systems where ProcessPoolExecutor isn't available), the performance will likely to go down a bit. WARNING: Register counts over 256 are now not supported at all and will likely raise an error (they weren't supposed to be allowed in the first place). TODO: * Make it faster * Cleanup the code * Error handling --- pySixelify.py | 192 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 156 insertions(+), 36 deletions(-) diff --git a/pySixelify.py b/pySixelify.py index b5a5e44..b7d7e87 100644 --- a/pySixelify.py +++ b/pySixelify.py @@ -23,9 +23,11 @@ # https://github.com/KutayX7/pySixelify import argparse +import concurrent.futures from queue import Queue from typing import List, Tuple, Dict, Literal, Any from functools import cache +from itertools import repeat type PaletteGenerationAlgorithm = Literal['QPUNM', 'OTFCD'] type _RGBAImage = List[List[Tuple[int, int, int, int]]] @@ -33,16 +35,29 @@ type _ColorMap = Dict[_Color, _Color] type _ColorCounter = Dict[_Color, int] type _Color2strMap = Dict[_Color, str] +type _Color2bytesMap = Dict[_Color, bytes] type _OutputStream = List[str] type _1DImage = List[_Color] +type _1DImageBytes = bytearray type _2DImage = List[List[_Color]] type _Mask = int type _kwargs = Dict[str, Any] DEFAULT_PALETTE_GENERATION_ALGORITHM: PaletteGenerationAlgorithm = 'QPUNM' +_QPUNM_CHUNK_SIZE = 4096 +_RENDERING_CHUNK_SIZE = 16 +_mask2byte = [bytes([i + 63]) for i in range(64)] _mask2str = [chr(i + 63) for i in range(64)] _runlength2str = ['!' + str(i) for i in range(256)] +_runlength2bytes = [b'!' + str(i).encode(encoding='ascii') for i in range(256)] + +def _get_executor(initargs: tuple[()] = ()) -> concurrent.futures.Executor: + try: + return concurrent.futures.ProcessPoolExecutor(initargs=initargs) + except: + return concurrent.futures.ThreadPoolExecutor(initargs=initargs) +_executor = _get_executor() def _from_file_to_RGBImage(file_path: str) -> _RGBAImage: try: @@ -91,7 +106,7 @@ def _avg_RGBs(rgb_list: list[tuple[int, int, int]]) -> tuple[float, float, float def _round_RGB(r: float, g: float, b: float) -> tuple[int, int, int]: return (int(r + 0.5), int(g + 0.5), int(b + 0.5)) -def _repeatMask(mask: _Mask, run_length: int, output: _OutputStream): +def _repeat_mask(mask: _Mask, run_length: int, output: _OutputStream): s = _mask2str[mask] if run_length < 4: output.append(s * run_length) @@ -115,15 +130,25 @@ def _generate_color_map(colorCounts: _ColorCounter, register_count: int, algorit return _FPIR(colorCounts, register_count) raise Exception(f'Unknown algorithm "{algorithm}"') -def _stringify_color_map(colorMap: _ColorMap, output: _OutputStream) -> _Color2strMap: - result: _Color2strMap = dict() +def _bytify_color_map(colorMap: _ColorMap, output: _OutputStream) -> _Color2bytesMap: + result: _Color2bytesMap = dict() register_index = 0 - for color in list(colorMap.values()): - if color not in result: - r, g, b = _to_RGB(color) - output.append(f'#{register_index};2;{int(r*100/255)};{int(g*100/255)};{int(b*100/255)}') - result[color] = f'#{register_index}' - register_index += 1 + remapping: dict[_Color, int] = dict() + for color in list(set(colorMap.values())): + r, g, b = _to_RGB(color) + output.append(f'#{register_index};2;{int(r*100/255)};{int(g*100/255)};{int(b*100/255)}') + result[register_index] = f'#{register_index}'.encode(encoding='ascii') + remapping[color] = register_index + register_index += 1 + for color in list(colorMap.keys()): + to_color = colorMap[color] + if to_color in remapping: + colorMap[color] = remapping[to_color] + for color in list(remapping.keys()): + colorMap[color] = remapping[color] + for i in range(256): + if i not in colorMap: + colorMap[i] = 0 return result def _stringify_0_color_map(colorMap: _ColorMap) -> _Color2strMap: @@ -145,6 +170,22 @@ def _remap_2d_image(image: _2DImage, colorMap: _ColorMap): def _FPIR(colorCounts: _ColorCounter, register_count: int) -> _ColorMap: return {c: c for c in colorCounts} +def _find_closest(i: int, Rs: list[int], Gs: list[int], Bs: list[int], register_count: int) -> tuple[int, int]: + r = Rs[i] + g = Gs[i] + b = Bs[i] + closest = 0 + min_diff = 400000 + for j in range(register_count): + r2 = Rs[j] + g2 = Gs[j] + b2 = Bs[j] + diff = (r2-r)**2+(g2-g)**2+(b2-b)**2 + if diff <= min_diff: + closest = j + min_diff = diff + return i, closest + # Quadratic Push Up, Nearest Match def _QPUNM(colorCounts: _ColorCounter, register_count: int) -> _ColorMap: colorMap: _ColorMap = {} @@ -169,22 +210,8 @@ def _QPUNM(colorCounts: _ColorCounter, register_count: int) -> _ColorMap: color = colors[i] colorMap[color] = color if len(colors) > register_count: - for i in range(register_count, len(colors)): - color = colors[i] - r = Rs[i] - g = Gs[i] - b = Bs[i] - closest = 0 - min_diff = 200000 - for j in range(register_count): - r2 = Rs[j] - g2 = Gs[j] - b2 = Bs[j] - diff = (r2-r)**2+(g2-g)**2+(b2-b)**2 - if diff <= min_diff: - closest = j - min_diff = diff - colorMap[color] = colors[closest] + for i, closest in _executor.map(_find_closest, list(range(register_count, len(colors))), repeat(Rs), repeat(Gs), repeat(Bs), repeat(register_count), chunksize=_QPUNM_CHUNK_SIZE): + colorMap[colors[i]] = colors[closest] return colorMap # type hell xD @@ -261,7 +288,99 @@ def divide(node, depth=1): # type: ignore backup_color = colorMap[avgc] return colorMap -def _render_sixels(image: _1DImage, width: int, color2str: _Color2strMap, output: _OutputStream): +def _repeat_mask_bytes(mask: int, run_length: int) -> bytearray: + s: bytes = _mask2byte[mask] + result: bytearray = bytearray() + if run_length < 4: + result += s * run_length + return result + while run_length > 255: # for compatibility (max allowed repetitions is unknown) + result += b'!255' + s + run_length -= 255 + if run_length < 4: + result += s * run_length + return result + result += _runlength2bytes[run_length] + s + return result + +def _render_row(y: int, image: _1DImageBytes, width: int, height: int, color2bytes: _Color2bytesMap) -> bytearray: + row_out: bytearray = bytearray() + yw6 = y * width * 6 + colors_to_fill: list[int] = list() + colors_to_fill_set: set[int] = set() + start_indicies: dict[int, int] = dict() + end_indicies: dict[int, int] = dict() + + # detect colors on the row + for x in range(width * 6): + c = image[yw6 + x] + if c in colors_to_fill_set: + end_indicies[c] = x + else: + end_indicies[c] = x + start_indicies[c] = x + colors_to_fill_set.add(c) + + for c in colors_to_fill_set: + colors_to_fill.append(c) + + worst_rl = 0 + worst_color = colors_to_fill[0] + for c in start_indicies: + rl = 1 + end_indicies[c] - start_indicies[c] + if rl > worst_rl: + worst_rl = rl + worst_color = c + + # early row fill + c = worst_color + assert(c in color2bytes) + row_out += color2bytes[c] + row_out += _repeat_mask_bytes(63, width) + row_out += b'$' + + # draw row + for c in colors_to_fill: + if c == worst_color: + continue + start_index = int(start_indicies[c] / 6) + end_index = int(end_indicies[c] / 6) + 1 + index = yw6 + start_index * 6 + row_out += color2bytes[c] + last_mask = 0 + run_length = start_index * (end_index > start_index) + for x in range(start_index, end_index): + mask = (image[index ] == c) * 32 + mask += (image[index + 1] == c) * 16 + mask += (image[index + 2] == c) * 8 + mask += (image[index + 3] == c) * 4 + mask += (image[index + 4] == c) * 2 + mask += (image[index + 5] == c) + + index += 6 + if last_mask == mask: + run_length += 1 + else: + row_out += _repeat_mask_bytes(last_mask, run_length) + last_mask = mask + run_length = 1 + row_out += _repeat_mask_bytes(last_mask, run_length) + if end_index < width: + row_out += _repeat_mask_bytes(0, width - end_index) + if start_index < width: + row_out += b'$' + row_out += b'-' + return row_out + +def _render_sixels(image: _1DImageBytes, width: int, color2bytes: _Color2bytesMap) -> bytearray: + height = len(image) // width + y_array: list[int] = list(range(height//6)) + sixel_out = bytearray() + for row in _executor.map(_render_row, y_array, repeat(image), repeat(width), repeat(height), repeat(color2bytes), chunksize=_RENDERING_CHUNK_SIZE): + sixel_out += row + return sixel_out + +def _render_full(image: _1DImage, width: int, color2str: _Color2strMap, output: _OutputStream): height = len(image) // width for y in range(height//6): yw6 = y * width * 6 @@ -294,7 +413,7 @@ def _render_sixels(image: _1DImage, width: int, color2str: _Color2strMap, output # early row fill c = worst_color output.append(color2str[c]) - _repeatMask(63, width, output) + _repeat_mask(63, width, output) output.append('$') # draw row @@ -319,12 +438,12 @@ def _render_sixels(image: _1DImage, width: int, color2str: _Color2strMap, output if last_mask == mask: run_length += 1 else: - _repeatMask(last_mask, run_length, output) + _repeat_mask(last_mask, run_length, output) last_mask = mask run_length = 1 - _repeatMask(last_mask, run_length, output) + _repeat_mask(last_mask, run_length, output) if end_index < width: - _repeatMask(0, width - end_index, output) + _repeat_mask(0, width - end_index, output) if start_index < width: output.append('$') output.append('-') @@ -339,7 +458,6 @@ def img2sixels(image: _RGBAImage, *, register_count: int = 256, palette_generati return _img2sixels_full_color(image) width, height = len(image[0]), len(image) output = [f"\033P0;0;0q\"1;1;{width};{height}"] - colors2str: dict[int, str] = {} colors: list[int] = [] colorCounts: dict[int, int] = {} colorMap: dict[int, int] = {} @@ -370,20 +488,22 @@ def img2sixels(image: _RGBAImage, *, register_count: int = 256, palette_generati # color palette colorMap = _generate_color_map(colorCounts, register_count, palette_generation_algorithm) - colors2str = _stringify_color_map(colorMap, output) + colors2bytes = _bytify_color_map(colorMap, output) + assert(len(colors2bytes) > 1) # convert colors according to the color palette _remap_2d_image(packed_image, colorMap) # flatten the packed image - flattened_image: list[int] = [] + flattened_image: _1DImageBytes = bytearray() for y in range(0, height, 6): for x in range(width): for i in range(y + 5, y - 1, -1): flattened_image.append(packed_image[i][x]) # render - _render_sixels(flattened_image, width, colors2str, output) + result = _render_sixels(flattened_image, width, colors2bytes) + output.append(result.decode(encoding='ascii')) output.append("\033\\") return "".join(output) @@ -423,14 +543,14 @@ def packRGB(r: int, g: int, b: int) -> int: packed_image.append([0] * width) # flatten the packed image - flattened_image: list[int] = [] + flattened_image: _1DImage = [] for y in range(0, height, 6): for x in range(width): for i in range(y + 5, y - 1, -1): flattened_image.append(packed_image[i][x]) # render - _render_sixels(flattened_image, width, _stringify_0_color_map(_FPIR({c: 1 for c in flattened_image}, 1)), output) + _render_full(flattened_image, width, _stringify_0_color_map(_FPIR({c: 1 for c in flattened_image}, 1)), output) output.append("\033\\") return "".join(output) From cc995ca5254adadbdef538af834e132414151dfa Mon Sep 17 00:00:00 2001 From: KutayX7 <103666634+KutayX7@users.noreply.github.com> Date: Tue, 13 May 2025 23:21:59 +0300 Subject: [PATCH 02/10] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 992ed8e..99e32e9 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ A relatively fast SIXEL converter utility, written purely in Python. * `python pySixelify "test.png" -r 16` ## TO-DO -* Multiprocessing (only if available; it won't be a strong dependency) +* ~~Multiprocessing~~ * More efficient palette generators (if possible) * Realtime SIXEL conversion * Video player From 9f5d40fd5e8ac4831fd1c3950f59a680c9038a34 Mon Sep 17 00:00:00 2001 From: KutayX7 <103666634+KutayX7@users.noreply.github.com> Date: Wed, 14 May 2025 19:19:31 +0300 Subject: [PATCH 03/10] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 99e32e9..6ebdf1f 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ A relatively fast SIXEL converter utility, written purely in Python. -s, --silent : Ignores warning mesasges (if any). -p, --palette : Sets the palette generator algorithm. Choices: QPUNM, OTFCD. - QPUNM is the default algorithm, usually high quality - OTFCD is the new, blazingly fast algorithm; but provides lower quality + QPUNM is the default algorithm. + OTFCD is faster at the cost of quality. ``` ## Example usage (as a command line tool) From 0eefd40de684864816bbbf35caa2565efca0b594 Mon Sep 17 00:00:00 2001 From: KutayX7 <103666634+KutayX7@users.noreply.github.com> Date: Wed, 14 May 2025 19:21:30 +0300 Subject: [PATCH 04/10] Refactor and optimizations --- pySixelify.py | 133 ++++++++++++++++++++++++++++---------------------- 1 file changed, 74 insertions(+), 59 deletions(-) diff --git a/pySixelify.py b/pySixelify.py index b7d7e87..f3ccc4e 100644 --- a/pySixelify.py +++ b/pySixelify.py @@ -25,13 +25,14 @@ import argparse import concurrent.futures from queue import Queue -from typing import List, Tuple, Dict, Literal, Any -from functools import cache +from typing import List, Set, Tuple, Dict, Literal from itertools import repeat type PaletteGenerationAlgorithm = Literal['QPUNM', 'OTFCD'] type _RGBAImage = List[List[Tuple[int, int, int, int]]] type _Color = int +type _ColorList = List[_Color] +type _ColorSet = Set[_Color] type _ColorMap = Dict[_Color, _Color] type _ColorCounter = Dict[_Color, int] type _Color2strMap = Dict[_Color, str] @@ -41,22 +42,21 @@ type _1DImageBytes = bytearray type _2DImage = List[List[_Color]] type _Mask = int -type _kwargs = Dict[str, Any] DEFAULT_PALETTE_GENERATION_ALGORITHM: PaletteGenerationAlgorithm = 'QPUNM' -_QPUNM_CHUNK_SIZE = 4096 -_RENDERING_CHUNK_SIZE = 16 +_QPUNM_CHUNK_SIZE: int = 4096 +_RENDERING_CHUNK_SIZE: int = 1 _mask2byte = [bytes([i + 63]) for i in range(64)] _mask2str = [chr(i + 63) for i in range(64)] _runlength2str = ['!' + str(i) for i in range(256)] _runlength2bytes = [b'!' + str(i).encode(encoding='ascii') for i in range(256)] -def _get_executor(initargs: tuple[()] = ()) -> concurrent.futures.Executor: +def _get_executor() -> concurrent.futures.Executor: try: - return concurrent.futures.ProcessPoolExecutor(initargs=initargs) + return concurrent.futures.ProcessPoolExecutor() except: - return concurrent.futures.ThreadPoolExecutor(initargs=initargs) + return concurrent.futures.ThreadPoolExecutor() _executor = _get_executor() def _from_file_to_RGBImage(file_path: str) -> _RGBAImage: @@ -81,17 +81,22 @@ def from_file_to_file(input_path: str, output_path: str, *, register_count: int sixels = img2sixels(image, register_count=register_count, palette_generation_algorithm=palette_generation_algorithm) file.write(sixels.encode('utf-8')) -@cache def _to_color(r: int, g: int, b: int) -> _Color: return (r << 16) + (g << 8) + b -@cache def _to_RGB(color: _Color) -> tuple[int, int, int]: B = color % 256 R = color >> 16 G = (color >> 8) % 256 return (R, G, B) +def _get_R(color: _Color) -> int: + return color >> 16 +def _get_G(color: _Color) -> int: + return (color >> 8) % 256 +def _get_B(color: _Color) -> int: + return color % 256 + def _avg_RGBs(rgb_list: list[tuple[int, int, int]]) -> tuple[float, float, float]: tr: int = 0 tg: int = 0 @@ -170,48 +175,60 @@ def _remap_2d_image(image: _2DImage, colorMap: _ColorMap): def _FPIR(colorCounts: _ColorCounter, register_count: int) -> _ColorMap: return {c: c for c in colorCounts} -def _find_closest(i: int, Rs: list[int], Gs: list[int], Bs: list[int], register_count: int) -> tuple[int, int]: - r = Rs[i] - g = Gs[i] - b = Bs[i] +def _find_closest(color: int, Rs: list[int], Gs: list[int], Bs: list[int]) -> tuple[int, int]: + r, g, b = _to_RGB(color) closest = 0 min_diff = 400000 - for j in range(register_count): + for j in range(len(Bs)): r2 = Rs[j] g2 = Gs[j] b2 = Bs[j] - diff = (r2-r)**2+(g2-g)**2+(b2-b)**2 + rd = r2-r + gd = g2-g + bd = b2-b + diff = rd*rd + gd*gd + bd*bd if diff <= min_diff: closest = j min_diff = diff - return i, closest + return color, closest # Quadratic Push Up, Nearest Match -def _QPUNM(colorCounts: _ColorCounter, register_count: int) -> _ColorMap: +def _QPUNM(color_counts: _ColorCounter, register_count: int) -> _ColorMap: colorMap: _ColorMap = {} - colors = [c for c in colorCounts] - colors.sort(reverse=True, key=lambda e: colorCounts[e]) - RGBs = [_to_RGB(c) for c in colors] + colors: _ColorList = [c for c in color_counts] + colors.sort(reverse=True, key=lambda e: color_counts[e]) + Rs: list[int] = [_get_R(c) for c in colors] + Gs: list[int] = [_get_G(c) for c in colors] + Bs: list[int] = [_get_B(c) for c in colors] for i in range(1, int(len(colors) ** 0.5)): j = i*i - r, g, b = RGBs[i-1] - rx, gx, bx = RGBs[i] - ry, gy, by = RGBs[j] + r = Rs[i-1] + g = Gs[i-1] + b = Bs[i-1] + rx = Rs[i] + gx = Gs[i] + bx = Bs[i] + ry = Rs[j] + gy = Gs[j] + by = Bs[j] dx = abs(rx-r) + abs(gx-g) + abs(bx-b) dy = abs(ry-r) + abs(gy-g) + abs(by-b) if dy > dx: - RGBs[i] = (ry, gy, by) - RGBs[j] = (rx, gx, bx) - colors = [_to_color(r, g, b) for r, g, b in RGBs] - Rs = [c[0] for c in RGBs] - Gs = [c[1] for c in RGBs] - Bs = [c[2] for c in RGBs] + Rs[i] = ry + Gs[i] = gy + Bs[i] = by + Rs[j] = rx + Gs[j] = gx + Bs[j] = bx + c = colors[i] + colors[i] = colors[j] + colors[j] = c for i in range(min(len(colors), register_count)): color = colors[i] colorMap[color] = color if len(colors) > register_count: - for i, closest in _executor.map(_find_closest, list(range(register_count, len(colors))), repeat(Rs), repeat(Gs), repeat(Bs), repeat(register_count), chunksize=_QPUNM_CHUNK_SIZE): - colorMap[colors[i]] = colors[closest] + for color, closest in _executor.map(_find_closest, colors[register_count:], repeat(Rs[:register_count]), repeat(Gs[:register_count]), repeat(Bs[:register_count]), chunksize=_QPUNM_CHUNK_SIZE): + colorMap[color] = colors[closest] return colorMap # type hell xD @@ -305,15 +322,15 @@ def _repeat_mask_bytes(mask: int, run_length: int) -> bytearray: def _render_row(y: int, image: _1DImageBytes, width: int, height: int, color2bytes: _Color2bytesMap) -> bytearray: row_out: bytearray = bytearray() - yw6 = y * width * 6 - colors_to_fill: list[int] = list() - colors_to_fill_set: set[int] = set() - start_indicies: dict[int, int] = dict() - end_indicies: dict[int, int] = dict() + yw6: int = y * width * 6 + colors_to_fill: _ColorList = list() + colors_to_fill_set: _ColorSet = set() + start_indicies: dict[_Color, int] = dict() + end_indicies: dict[_Color, int] = dict() # detect colors on the row for x in range(width * 6): - c = image[yw6 + x] + c: _Color = image[yw6 + x] if c in colors_to_fill_set: end_indicies[c] = x else: @@ -327,13 +344,13 @@ def _render_row(y: int, image: _1DImageBytes, width: int, height: int, color2byt worst_rl = 0 worst_color = colors_to_fill[0] for c in start_indicies: - rl = 1 + end_indicies[c] - start_indicies[c] + rl: int = 1 + end_indicies[c] - start_indicies[c] if rl > worst_rl: worst_rl = rl worst_color = c # early row fill - c = worst_color + c: _Color = worst_color assert(c in color2bytes) row_out += color2bytes[c] row_out += _repeat_mask_bytes(63, width) @@ -343,12 +360,12 @@ def _render_row(y: int, image: _1DImageBytes, width: int, height: int, color2byt for c in colors_to_fill: if c == worst_color: continue - start_index = int(start_indicies[c] / 6) - end_index = int(end_indicies[c] / 6) + 1 - index = yw6 + start_index * 6 + start_index: int = int(start_indicies[c] / 6) + end_index: int = int(end_indicies[c] / 6) + 1 + index: int = yw6 + start_index * 6 row_out += color2bytes[c] - last_mask = 0 - run_length = start_index * (end_index > start_index) + last_mask: _Mask = 0 + run_length: int = start_index * (end_index > start_index) for x in range(start_index, end_index): mask = (image[index ] == c) * 32 mask += (image[index + 1] == c) * 16 @@ -373,7 +390,7 @@ def _render_row(y: int, image: _1DImageBytes, width: int, height: int, color2byt return row_out def _render_sixels(image: _1DImageBytes, width: int, color2bytes: _Color2bytesMap) -> bytearray: - height = len(image) // width + height: int = len(image) // width y_array: list[int] = list(range(height//6)) sixel_out = bytearray() for row in _executor.map(_render_row, y_array, repeat(image), repeat(width), repeat(height), repeat(color2bytes), chunksize=_RENDERING_CHUNK_SIZE): @@ -449,18 +466,17 @@ def _render_full(image: _1DImage, width: int, color2str: _Color2strMap, output: output.append('-') # Converts an image (2D lists of tuples, RGBA int[0, 255]) into an sixel image that can be printed. -# for black and white images, this should be near instant -# for grayscale images, this should take less than a few seconds -# for colored images, this can take up to a minute or two (depending on the image size, the amount of different colors in the source image and the amount of required color registers) -# EXPERIMENTAL: setting `register_count` argument to anything less than 2, outputs a full color image def img2sixels(image: _RGBAImage, *, register_count: int = 256, palette_generation_algorithm: PaletteGenerationAlgorithm = DEFAULT_PALETTE_GENERATION_ALGORITHM) -> str: - if register_count < 2: + assert(register_count == int(register_count)) + assert(register_count > 0) + assert(register_count <= 256) + if register_count == 1: return _img2sixels_full_color(image) - width, height = len(image[0]), len(image) - output = [f"\033P0;0;0q\"1;1;{width};{height}"] - colors: list[int] = [] - colorCounts: dict[int, int] = {} - colorMap: dict[int, int] = {} + width: int = len(image[0]) + height: int = len(image) + output: _OutputStream = [f"\033P0;0;0q\"1;1;{width};{height}"] + colors: _ColorList = [] + colorCounts: _ColorCounter = {} # pack image packed_image: list[list[int]] = [] @@ -487,9 +503,8 @@ def img2sixels(image: _RGBAImage, *, register_count: int = 256, palette_generati colorCounts[0] = 2**31 # color palette - colorMap = _generate_color_map(colorCounts, register_count, palette_generation_algorithm) - colors2bytes = _bytify_color_map(colorMap, output) - assert(len(colors2bytes) > 1) + colorMap: _ColorMap = _generate_color_map(colorCounts, register_count, palette_generation_algorithm) + colors2bytes: _Color2bytesMap = _bytify_color_map(colorMap, output) # convert colors according to the color palette _remap_2d_image(packed_image, colorMap) From 5f2c1bdafcb4c34cb317d56a8b4d15b3a235b488 Mon Sep 17 00:00:00 2001 From: KutayX7 <103666634+KutayX7@users.noreply.github.com> Date: Thu, 15 May 2025 13:33:21 +0300 Subject: [PATCH 05/10] Update README.md --- README.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6ebdf1f..720ece0 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,16 @@ A relatively fast SIXEL converter utility, written purely in Python. Choices: 1, 2, 4, 8, 16, 32, 64, 128, or 256 (default). WARNING: Setting it to 1 is a special case. When set to 1, it will (re)use a single register to render EVERY color, - which may not work with every sixel terminal. + which may not work with most terminals. It's experimental so please give feedback if you do use it <3 -s, --silent : Ignores warning mesasges (if any). -p, --palette : Sets the palette generator algorithm. Choices: QPUNM, OTFCD. + These only apply if there are more colors in the image + than there are color registers. QPUNM is the default algorithm. - OTFCD is faster at the cost of quality. + OTFCD is newer and faster. + Can't choose? Try both! You will be surprised. ``` ## Example usage (as a command line tool) @@ -46,9 +49,17 @@ A relatively fast SIXEL converter utility, written purely in Python. * `python pySixelify "test.png" -r 16` ## TO-DO -* ~~Multiprocessing~~ +* ~~Multiprocessing~~ (done) * More efficient palette generators (if possible) +* Global interpreter lock (GIL) detection * Realtime SIXEL conversion * Video player * Play Bad Apple on it in real-time at minimum 60 FPS -* Remove the `pillow` dependency +* ~~Remove the `pillow` dependency~~ (impractical for now) + +## Known issues +* Doesn't work on WASI (`concurrent.futures` library is not available) +* No multiprocessing on mobile platforms (`multiprocessing` library is not available) +* QPUNM loses information of low frequency (but important) colors +* OTFCD doesn't utilize every register and sometimes assigns wrong colors +* Pure Python is too slow for real-time conversion From 30a95abb3ce5d93c774fba142070ab3d9ddcaefc Mon Sep 17 00:00:00 2001 From: KutayX7 <103666634+KutayX7@users.noreply.github.com> Date: Fri, 16 May 2025 17:54:09 +0300 Subject: [PATCH 06/10] OTFCD improvements and refactor Added more type annotations. OTFCD palette generator now gives much better results. Combined with the previous changes, the difference between QPUNM and OTFCD is now more of a preference. --- pySixelify.py | 138 +++++++++++++++++++++++++++++--------------------- 1 file changed, 80 insertions(+), 58 deletions(-) diff --git a/pySixelify.py b/pySixelify.py index f3ccc4e..f258050 100644 --- a/pySixelify.py +++ b/pySixelify.py @@ -29,7 +29,11 @@ from itertools import repeat type PaletteGenerationAlgorithm = Literal['QPUNM', 'OTFCD'] -type _RGBAImage = List[List[Tuple[int, int, int, int]]] +type _RGBTuple = Tuple[int, int, int] +type _RGBFloatTuple = Tuple[float, float, float] +type _RGBATuple = Tuple[int, int, int, int] +type _RGBImage = List[List[_RGBTuple]] +type _RGBAImage = List[List[_RGBATuple]] type _Color = int type _ColorList = List[_Color] type _ColorSet = Set[_Color] @@ -43,13 +47,14 @@ type _2DImage = List[List[_Color]] type _Mask = int +type _OctreeColorNode = list[_OctreeColorNode|_RGBTuple] + DEFAULT_PALETTE_GENERATION_ALGORITHM: PaletteGenerationAlgorithm = 'QPUNM' _QPUNM_CHUNK_SIZE: int = 4096 _RENDERING_CHUNK_SIZE: int = 1 _mask2byte = [bytes([i + 63]) for i in range(64)] _mask2str = [chr(i + 63) for i in range(64)] -_runlength2str = ['!' + str(i) for i in range(256)] _runlength2bytes = [b'!' + str(i).encode(encoding='ascii') for i in range(256)] def _get_executor() -> concurrent.futures.Executor: @@ -84,7 +89,7 @@ def from_file_to_file(input_path: str, output_path: str, *, register_count: int def _to_color(r: int, g: int, b: int) -> _Color: return (r << 16) + (g << 8) + b -def _to_RGB(color: _Color) -> tuple[int, int, int]: +def _to_RGB(color: _Color) -> _RGBTuple: B = color % 256 R = color >> 16 G = (color >> 8) % 256 @@ -97,18 +102,7 @@ def _get_G(color: _Color) -> int: def _get_B(color: _Color) -> int: return color % 256 -def _avg_RGBs(rgb_list: list[tuple[int, int, int]]) -> tuple[float, float, float]: - tr: int = 0 - tg: int = 0 - tb: int = 0 - n = len(rgb_list) - for r, g, b in rgb_list: - tr += r - tg += g - tb += b - return (tr/n, tg/n, tb/n) - -def _round_RGB(r: float, g: float, b: float) -> tuple[int, int, int]: +def _round_RGB(r: float, g: float, b: float) -> _RGBTuple: return (int(r + 0.5), int(g + 0.5), int(b + 0.5)) def _repeat_mask(mask: _Mask, run_length: int, output: _OutputStream): @@ -172,10 +166,10 @@ def _remap_2d_image(image: _2DImage, colorMap: _ColorMap): image[y][x] = colorMap[image[y][x]] # full palette, infinite registers -def _FPIR(colorCounts: _ColorCounter, register_count: int) -> _ColorMap: +def _FPIR(colorCounts: _ColorCounter, register_count: int = 256) -> _ColorMap: return {c: c for c in colorCounts} -def _find_closest(color: int, Rs: list[int], Gs: list[int], Bs: list[int]) -> tuple[int, int]: +def _find_closest(color: int, Rs: list[int], Gs: list[int], Bs: list[int]) -> Tuple[_Color, _Color]: r, g, b = _to_RGB(color) closest = 0 min_diff = 400000 @@ -231,78 +225,106 @@ def _QPUNM(color_counts: _ColorCounter, register_count: int) -> _ColorMap: colorMap[color] = colors[closest] return colorMap -# type hell xD -# TODO: Fix type annotations -# TODO: Make this actually give decent results +def _avg_RGBs(rgb_list: _OctreeColorNode) -> _RGBFloatTuple: + tr: int = 0 + tg: int = 0 + tb: int = 0 + n = len(rgb_list) + for r, g, b in rgb_list: + tr += r # type: ignore + tg += g # type: ignore + tb += b # type: ignore + return (tr/n, tg/n, tb/n) # type: ignore + # OctTree Fair Color Division def _OTFCD(colorCounts: _ColorCounter, register_count: int) -> _ColorMap: colorMap: _ColorMap = {} - RGBs = [_to_RGB(color) for color in colorCounts] - root = [] - - def is_leaf_node(node: list[object]): + RGBs: List[_RGBTuple] = [_to_RGB(color) for color in colorCounts] + root: _OctreeColorNode = [] + node_queue: Queue[_OctreeColorNode] = Queue() + remaining_color_queue: Queue[_Color] = Queue() + remaining_register_count: int = register_count + backup_color: _Color = max(colorCounts, key=lambda color: colorCounts[color]) + color_count: int = len(colorCounts) + division_threshold: int = int(color_count * 256 * 256 * 2 / register_count ** 3) + + def unpack_node(node: _OctreeColorNode|_RGBFloatTuple) -> _RGBTuple: + r, g, b = node + return r, g, b # type: ignore + + def is_leaf_node(node: _OctreeColorNode) -> bool: if len(node): return isinstance(node[0], tuple) return True - def is_divisible(node: list[object]): - return is_leaf_node(node) and len(node) > register_count * 8 + def is_divisible(node: _OctreeColorNode) -> bool: + return is_leaf_node(node) and len(node) > division_threshold - def divide(node, depth=1): # type: ignore + def divide(node: _OctreeColorNode, depth: int = 1) -> int: if depth > 3: return depth - ar, ag, ab = _avg_RGBs(node) # type: ignore - buckets = [[] for _ in range(8)] # type: ignore - for r, g, b in node: # type: ignore + ar, ag, ab = _avg_RGBs(node) + buckets: List[_OctreeColorNode] = [[] for _ in range(8)] + for item in node: + r, g, b = unpack_node(item) index = 4 if r >= ar else 0 if g >= ag: index += 2 if b >= ab: index += 1 - buckets[index].append((r, g, b)) # type: ignore - node.clear() # type: ignore - node.extend(buckets) # type: ignore - node.append(_round_RGB(ar, ag, ab)) # type: ignore + buckets[index].append((r, g, b)) + node.clear() + node.extend(buckets) + node.append(_round_RGB(ar, ag, ab)) max_depth = depth - for child in node[:8]: # type: ignore - if is_divisible(child): # type: ignore - max_depth = max(max_depth, divide(child, depth+1)) # type: ignore - if is_leaf_node(child): # type: ignore - if len(child): # type: ignore - child.append(_round_RGB(*_avg_RGBs(child))) # type: ignore + for child in buckets: + if is_divisible(child): + max_depth = max(max_depth, divide(child, depth+1)) + if is_leaf_node(child): + if len(child): + child.append(_round_RGB(*_avg_RGBs(child))) else: - child.append(node[8]) # type: ignore + child.append(node[8]) return max_depth - node_queue = Queue() # type: ignore - root.extend(RGBs) # type: ignore - divide(root) # type: ignore - node_queue.put_nowait(root) # type: ignore - remaining_register_count = register_count - backup_color = 0 # type: ignore + root.extend(RGBs) + divide(root) + node_queue.put_nowait(root) while node_queue.qsize(): - node = node_queue.get_nowait() # type: ignore - ar, ag, ab = node[len(node)-1] # type: ignore - avgc = _to_color(ar, ag, ab) # type: ignore - if is_leaf_node(node): # type: ignore + node: _OctreeColorNode = node_queue.get_nowait() + ar, ag, ab = unpack_node(node[len(node)-1]) + avgc = _to_color(ar, ag, ab) + if is_leaf_node(node): if avgc not in colorMap: - if remaining_register_count and (len(node) > 1): # type: ignore + if remaining_register_count > 0 and (len(node) > 1): colorMap[avgc] = avgc remaining_register_count -= 1 - backup_color = avgc # type: ignore + backup_color = avgc else: avgc = backup_color - for i in range(len(node)-1): # type: ignore - r, g, b = node[i] # type: ignore - color = _to_color(r, g, b) # type: ignore + for child in node[:-1]: + color = _to_color(*unpack_node(child)) if color not in colorMap: colorMap[color] = colorMap[avgc] + remaining_color_queue.put_nowait(color) else: - for child in node[:8]: # type: ignore - node_queue.put_nowait(child) # type: ignore + if remaining_register_count == 0: + for child in node[:8]: + if isinstance(child, list): + child[len(child)-1] = _to_RGB(avgc) + for child in node[:8]: + if isinstance(child, list): + node_queue.put_nowait(child) if avgc in colorMap: backup_color = colorMap[avgc] + + if remaining_register_count > register_count: + while remaining_register_count > 0 and remaining_color_queue.qsize() > 0: + color: _Color = remaining_color_queue.get_nowait() + if colorMap[color] != color: + colorMap[color] = color + remaining_register_count -= 1 return colorMap def _repeat_mask_bytes(mask: int, run_length: int) -> bytearray: From daf4d861d705ec18e99250731a57a5d6e7358b69 Mon Sep 17 00:00:00 2001 From: KutayX7 <103666634+KutayX7@users.noreply.github.com> Date: Wed, 13 Aug 2025 01:43:05 +0300 Subject: [PATCH 07/10] Update README.md --- README.md | 56 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 720ece0..817ac1c 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,26 @@ # pySixelify -A relatively fast SIXEL converter utility, written purely in Python. +A relatively fast SIXEL converter utility, written purely in Python. [^1] [^2] +[^1]: It is quite fast if you consider that this is written entirely in Python, without hardware acceleration. +[^2]: Sixel, short for "six pixels", is a bitmap graphics format supported by terminals and printers from DEC. See https://en.wikipedia.org/wiki/Sixel for more details. ## Dependencies -* `pillow`: Python Imaging Library fork. `pip install pillow` - * **It's optional.** You only need it if you need to use this as a command line tool - * or if you use the functions that need to load an image from a file. -* A terminal that support SIXEL images. - * (**It's also optional.** Not needed if you don't need to see the results.) - * VSCode's terminal (***tested***, fully functional, may need some configuration) - * Windows 11 Terminal (***tested***, mostly functional, no register reuse, may need some configuration) +* `pillow`: Python Imaging Library fork. [^3] +* A terminal that support SIXEL images (if you want to see the results). + * VSCode's terminal (***tested***) [^4] + * Windows Terminal (***tested***) [^5] + * Konsole (***tested***) [^6] * XTerm * mlterm * WezTerm - * Konsole * Terminology * Exoterm * Gnuplot +[^3]: `pillow` is an optional dependency. You don't need it if you want to use this as a module and won't use any of the file input methods. + +[^4]: VSCode `1.79+` supports Sixel images and all the features of pySixelify. If you can't see images, set `terminal.integrated.enableImage` to `true` (1.80+) or set `terminal.integrated.experimentalImageSupport` to `true` (1.79). +[^5]: The Terminal app for Windows supports Sixel images. Some configuration may be needed to use it. No register reuse. +[^6]: Konsole `22.04+` supports Sixel images. No configuration needed. No register reuse. ## Comamnd line arguments ``` @@ -36,30 +40,32 @@ A relatively fast SIXEL converter utility, written purely in Python. These only apply if there are more colors in the image than there are color registers. QPUNM is the default algorithm. - OTFCD is newer and faster. - Can't choose? Try both! You will be surprised. + OTFCD is usually faster and sometimes can produce better results than QPUNM. + Try both! ``` ## Example usage (as a command line tool) * Read "test.png" and print it to the terminal - * `python pySixelify "test.png"` -* Read "test.png" and save it to "test.sixel" - * `python pySixelify "test.png" -o "test.sixel"` -* Read "test.png" and print it to the terminal, with only 16 colors - * `python pySixelify "test.png" -r 16` + * `python3 path/to/pySixelify.py "path/to/test.png"` +* Read "test.png" and save it to "output.sixel" + * `python3 path/to/pySixelify.py "path/to/test.png" -o "path/to/output.sixel"` +* Read "test.png" and print it to the terminal, with only 16 color registers + * `python3 path/to/pySixelify.py "path/to/test.png" -r 16` ## TO-DO -* ~~Multiprocessing~~ (done) -* More efficient palette generators (if possible) -* Global interpreter lock (GIL) detection -* Realtime SIXEL conversion -* Video player -* Play Bad Apple on it in real-time at minimum 60 FPS -* ~~Remove the `pillow` dependency~~ (impractical for now) +- [x] ~~Multiprocessing~~ +- [ ] Global interpreter lock detection to switch between multi-threading and multi-processing +- [ ] Apply dithering when needed +- [ ] Automatic fallback to [libsixel](https://github.com/saitoha/libsixel) when it is possible and makes sense to (maybe?) +- [ ] Realtime SIXEL conversion for large, colorful images +- [ ] Ability to load and play videos +- [ ] Play Bad Apple on it in real-time, at minimum 30 FPS (must be done, one way or another) +- [ ] Lossless color output on ALL Sixel terminals +- [ ] ~~Remove the `pillow` dependency~~ (impractical, for now) ## Known issues * Doesn't work on WASI (`concurrent.futures` library is not available) * No multiprocessing on mobile platforms (`multiprocessing` library is not available) * QPUNM loses information of low frequency (but important) colors -* OTFCD doesn't utilize every register and sometimes assigns wrong colors -* Pure Python is too slow for real-time conversion +* OTFCD sometimes assigns wrong colors +* Pure Python is too slow for real-time conversion of large, colorful images From c10ded33c89efe2a7d4be9de9e167b86e3c2c2cb Mon Sep 17 00:00:00 2001 From: KutayX7 <103666634+KutayX7@users.noreply.github.com> Date: Wed, 13 Aug 2025 21:55:31 +0300 Subject: [PATCH 08/10] Update README.md --- README.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 817ac1c..64e3a53 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # pySixelify -A relatively fast SIXEL converter utility, written purely in Python. [^1] [^2] -[^1]: It is quite fast if you consider that this is written entirely in Python, without hardware acceleration. -[^2]: Sixel, short for "six pixels", is a bitmap graphics format supported by terminals and printers from DEC. See https://en.wikipedia.org/wiki/Sixel for more details. +A relatively fast SIXEL converter utility, written purely in Python. [^a] [^1] +[^a]: It is quite fast if you consider that this is written entirely in Python, without hardware acceleration. +[^1]: Sixel, short for "six pixels", is a bitmap graphics format supported by terminals and printers from DEC. See https://en.wikipedia.org/wiki/Sixel for more details. ## Dependencies -* `pillow`: Python Imaging Library fork. [^3] +* `pillow`: Python Imaging Library fork. [^b] * A terminal that support SIXEL images (if you want to see the results). - * VSCode's terminal (***tested***) [^4] - * Windows Terminal (***tested***) [^5] - * Konsole (***tested***) [^6] + * VSCode's terminal (***tested***) [^c] + * Windows Terminal (***tested***) [^d] + * Konsole (***tested***) [^e] * XTerm * mlterm * WezTerm @@ -16,11 +16,11 @@ A relatively fast SIXEL converter utility, written purely in Python. [^1] [^2] * Exoterm * Gnuplot -[^3]: `pillow` is an optional dependency. You don't need it if you want to use this as a module and won't use any of the file input methods. +[^b]: `pillow` is an optional dependency. You don't need it if you want to use this as a module and won't use any of the file input methods. -[^4]: VSCode `1.79+` supports Sixel images and all the features of pySixelify. If you can't see images, set `terminal.integrated.enableImage` to `true` (1.80+) or set `terminal.integrated.experimentalImageSupport` to `true` (1.79). -[^5]: The Terminal app for Windows supports Sixel images. Some configuration may be needed to use it. No register reuse. -[^6]: Konsole `22.04+` supports Sixel images. No configuration needed. No register reuse. +[^c]: VSCode `1.79+` supports Sixel images and all the features of pySixelify. If you can't see images, set `terminal.integrated.enableImage` to `true` (1.80+) or set `terminal.integrated.experimentalImageSupport` to `true` (1.79). +[^d]: The Terminal app for Windows supports Sixel images. Some configuration may be needed to use it. No register reuse. +[^e]: Konsole `22.04+` supports Sixel images. No configuration needed. No register reuse. ## Comamnd line arguments ``` @@ -55,17 +55,17 @@ A relatively fast SIXEL converter utility, written purely in Python. [^1] [^2] ## TO-DO - [x] ~~Multiprocessing~~ - [ ] Global interpreter lock detection to switch between multi-threading and multi-processing -- [ ] Apply dithering when needed +- [ ] Add dithering when needed - [ ] Automatic fallback to [libsixel](https://github.com/saitoha/libsixel) when it is possible and makes sense to (maybe?) -- [ ] Realtime SIXEL conversion for large, colorful images +- [ ] Realtime SIXEL conversion for large images - [ ] Ability to load and play videos - [ ] Play Bad Apple on it in real-time, at minimum 30 FPS (must be done, one way or another) -- [ ] Lossless color output on ALL Sixel terminals -- [ ] ~~Remove the `pillow` dependency~~ (impractical, for now) +- [ ] Lossless true color output on all supported terminals +- [ ] Remove all third-party dependencies ## Known issues * Doesn't work on WASI (`concurrent.futures` library is not available) * No multiprocessing on mobile platforms (`multiprocessing` library is not available) -* QPUNM loses information of low frequency (but important) colors -* OTFCD sometimes assigns wrong colors -* Pure Python is too slow for real-time conversion of large, colorful images +* QPUNM is usually good enough but better options are definitely needed +* Quality of OTFCD seems to be entirely dependent on a number that is too sensitive and the current way we calculate that number is just a very rough approximation +* Pure Python is too slow for real-time conversion of large detailed images From 489a1f8acfb69c4bcd19a513ce974289130dcad6 Mon Sep 17 00:00:00 2001 From: KutayX7 <103666634+KutayX7@users.noreply.github.com> Date: Sat, 16 Aug 2025 16:19:41 +0300 Subject: [PATCH 09/10] Update README.md --- README.md | 92 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 59 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 64e3a53..369a290 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,54 @@ # pySixelify A relatively fast SIXEL converter utility, written purely in Python. [^a] [^1] -[^a]: It is quite fast if you consider that this is written entirely in Python, without hardware acceleration. -[^1]: Sixel, short for "six pixels", is a bitmap graphics format supported by terminals and printers from DEC. See https://en.wikipedia.org/wiki/Sixel for more details. - -## Dependencies -* `pillow`: Python Imaging Library fork. [^b] -* A terminal that support SIXEL images (if you want to see the results). - * VSCode's terminal (***tested***) [^c] - * Windows Terminal (***tested***) [^d] - * Konsole (***tested***) [^e] +[^a]: It is quite fast if you consider that this is written entirely in Python, without hardware acceleration. [^b] +[^1]: "Sixel, short for 'six pixels', is a bitmap graphics format supported by terminals and printers from DEC." See https://en.wikipedia.org/wiki/Sixel for more details. +[^b]: This projects focuses on converting images (that are already decoded in the memory) into the sixel format, not on loading images from files. The way `pillow` reads those files is not accounted for. + +## Requirements +* `pillow`: Python Imaging Library (fork). [^c] +* A terminal that support SIXEL images (if you want to see the results, which you probably do). + * VSCode's terminal (***tested***) [^d] + * Windows Terminal (***tested***) [^e] + * Konsole (***tested***) [^f] * XTerm * mlterm * WezTerm * Terminology * Exoterm * Gnuplot - -[^b]: `pillow` is an optional dependency. You don't need it if you want to use this as a module and won't use any of the file input methods. -[^c]: VSCode `1.79+` supports Sixel images and all the features of pySixelify. If you can't see images, set `terminal.integrated.enableImage` to `true` (1.80+) or set `terminal.integrated.experimentalImageSupport` to `true` (1.79). -[^d]: The Terminal app for Windows supports Sixel images. Some configuration may be needed to use it. No register reuse. -[^e]: Konsole `22.04+` supports Sixel images. No configuration needed. No register reuse. +[^c]: `pillow` is optional. You don't need it if you want to use this as a module and won't use any of the file input methods. + +[^d]: VSCode `1.79+` supports Sixel images and all the features of pySixelify. If you can't see images, set `terminal.integrated.enableImage` to `true` (1.80+) or set `terminal.integrated.experimentalImageSupport` to `true` (1.79). +[^e]: The Terminal app for Windows supports Sixel images. Some configuration may be needed to use it. No register reuse. +[^f]: Konsole `22.04+` supports Sixel images. No configuration needed. No register reuse. ## Comamnd line arguments ``` - [filename] : Name or path of the input file. - -o, --output-file : Name or path of the output file. - If the `filename` is provided but not the `--output-file`, - the result will be written to the standard output. - -r, --register-count : The amount of color registers to use. - Choices: 1, 2, 4, 8, 16, 32, 64, 128, or 256 (default). - WARNING: Setting it to 1 is a special case. - When set to 1, it will (re)use a single register to render EVERY color, - which may not work with most terminals. + filename Name or path of the input image file. + All the other arguments are optional. + + -s, --silent Ignores some warning mesasges (if any). + + -o, --output-file Name or path of the output file. + If the `filename` is provided but not the `--output-file`, + the result will be written to the standard output. + DO NOT USE PIPES to save the output! + + -r, --register-count The amount of color registers to use. + Choices: 1, 2, 4, 8, 16, 32, 64, 128, or 256 (default). + WARNING: Setting it to 1 is a special case. + When set to 1, it will (re)use a single color register to + render EVERY color, which won't work on most terminals. It's experimental so please give feedback if you do use it <3 - -s, --silent : Ignores warning mesasges (if any). - -p, --palette : Sets the palette generator algorithm. - Choices: QPUNM, OTFCD. - These only apply if there are more colors in the image - than there are color registers. - QPUNM is the default algorithm. - OTFCD is usually faster and sometimes can produce better results than QPUNM. - Try both! + + -p, --palette Sets the palette generator algorithm. + Choices: QPUNM, OTFCD. + These only apply if there are more colors in the image + than there are color registers. + QPUNM is the default algorithm, for now. + OTFCD is usually faster and sometimes can produce better results than QPUNM. + Try both! ``` ## Example usage (as a command line tool) @@ -52,13 +59,32 @@ A relatively fast SIXEL converter utility, written purely in Python. [^a] [^1] * Read "test.png" and print it to the terminal, with only 16 color registers * `python3 path/to/pySixelify.py "path/to/test.png" -r 16` +> [!WARNING] +> When saving to a file, please use the `-o ` argument.
+> Pipes may work as well, for now, but that might change in the future. + +## API (not stable) +> [!WARNING] +> Do not rely on any method, or variable, that starts with an underscore as they are supposed to be private and can break at any moment without a warning. + +> [!TIP] +> Never set the `palette_generation_algorithm` unless you really have to or just messing around. The default values should be better. This is likely to be more important later. + +```Python +print_image_from_path(path: str, *, register_count: int = 256, palette_generation_algorithm: PaletteGenerationAlgorithm = DEFAULT_PALETTE_GENERATION_ALGORITHM) + +from_file_to_file(input_path: str, output_path: str, *, register_count: int = 256, palette_generation_algorithm: PaletteGenerationAlgorithm = DEFAULT_PALETTE_GENERATION_ALGORITHM) + +img2sixels(image: List[List[Tuple[int, int, int, int]]], *, register_count: int = 256, palette_generation_algorithm: PaletteGenerationAlgorithm = DEFAULT_PALETTE_GENERATION_ALGORITHM) -> str +``` + ## TO-DO - [x] ~~Multiprocessing~~ - [ ] Global interpreter lock detection to switch between multi-threading and multi-processing -- [ ] Add dithering when needed -- [ ] Automatic fallback to [libsixel](https://github.com/saitoha/libsixel) when it is possible and makes sense to (maybe?) +- [ ] Optional dithering - [ ] Realtime SIXEL conversion for large images - [ ] Ability to load and play videos +- [ ] Auto select the optimal palette generator based on the image or video, if not explicitly specified - [ ] Play Bad Apple on it in real-time, at minimum 30 FPS (must be done, one way or another) - [ ] Lossless true color output on all supported terminals - [ ] Remove all third-party dependencies From 09b2f13270a53fdfa9038dd079dcfe0dde4fc719 Mon Sep 17 00:00:00 2001 From: KutayX7 <103666634+KutayX7@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:00:48 +0300 Subject: [PATCH 10/10] Update README.md --- README.md | 60 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 369a290..91c286d 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ A relatively fast SIXEL converter utility, written purely in Python. [^a] [^1] [^a]: It is quite fast if you consider that this is written entirely in Python, without hardware acceleration. [^b] [^1]: "Sixel, short for 'six pixels', is a bitmap graphics format supported by terminals and printers from DEC." See https://en.wikipedia.org/wiki/Sixel for more details. -[^b]: This projects focuses on converting images (that are already decoded in the memory) into the sixel format, not on loading images from files. The way `pillow` reads those files is not accounted for. +[^b]: This projects focuses on converting images (that are already decoded in the memory) into the sixel format, not on loading images from files. (The way `pillow` reads and decodes those files is not accounted for.) ## Requirements -* `pillow`: Python Imaging Library (fork). [^c] -* A terminal that support SIXEL images (if you want to see the results, which you probably do). +* Python 3.7+ +* `pillow`: Python Imaging Library (fork) [^c] +* A terminal that support SIXEL images (if you want to see the results, which you probably do) * VSCode's terminal (***tested***) [^d] * Windows Terminal (***tested***) [^e] * Konsole (***tested***) [^f] @@ -19,11 +20,11 @@ A relatively fast SIXEL converter utility, written purely in Python. [^a] [^1] [^c]: `pillow` is optional. You don't need it if you want to use this as a module and won't use any of the file input methods. -[^d]: VSCode `1.79+` supports Sixel images and all the features of pySixelify. If you can't see images, set `terminal.integrated.enableImage` to `true` (1.80+) or set `terminal.integrated.experimentalImageSupport` to `true` (1.79). +[^d]: VSCode (1.79+) supports Sixel images and all the features of pySixelify. If you can't see images, set `terminal.integrated.enableImage` to `true` (1.80+) or set `terminal.integrated.experimentalImageSupport` to `true` (1.79). [^e]: The Terminal app for Windows supports Sixel images. Some configuration may be needed to use it. No register reuse. [^f]: Konsole `22.04+` supports Sixel images. No configuration needed. No register reuse. -## Comamnd line arguments +## Command line arguments ``` filename Name or path of the input image file. All the other arguments are optional. @@ -36,17 +37,16 @@ A relatively fast SIXEL converter utility, written purely in Python. [^a] [^1] DO NOT USE PIPES to save the output! -r, --register-count The amount of color registers to use. - Choices: 1, 2, 4, 8, 16, 32, 64, 128, or 256 (default). + Choices: 1, 2, 4, 8, 16, 32, 64, 128, or 256 (default) WARNING: Setting it to 1 is a special case. - When set to 1, it will (re)use a single color register to - render EVERY color, which won't work on most terminals. - It's experimental so please give feedback if you do use it <3 + When set to 1, it will (re)use a single color register to + render EVERY color, which won't work on most terminals. + It's experimental so please give feedback if you do use it! <3 -p, --palette Sets the palette generator algorithm. - Choices: QPUNM, OTFCD. + Choices: OTFCD, QPUNM (default) These only apply if there are more colors in the image than there are color registers. - QPUNM is the default algorithm, for now. OTFCD is usually faster and sometimes can produce better results than QPUNM. Try both! ``` @@ -59,16 +59,12 @@ A relatively fast SIXEL converter utility, written purely in Python. [^a] [^1] * Read "test.png" and print it to the terminal, with only 16 color registers * `python3 path/to/pySixelify.py "path/to/test.png" -r 16` -> [!WARNING] -> When saving to a file, please use the `-o ` argument.
-> Pipes may work as well, for now, but that might change in the future. - ## API (not stable) > [!WARNING] -> Do not rely on any method, or variable, that starts with an underscore as they are supposed to be private and can break at any moment without a warning. +> Do not rely on any method/variable that starts with an underscore as they are supposed to be private and can break at any moment without a warning. (Treat them like how you treat undefined behaviour in C++. 😉) > [!TIP] -> Never set the `palette_generation_algorithm` unless you really have to or just messing around. The default values should be better. This is likely to be more important later. +> Never set the `palette_generation_algorithm` unless you really have to, or just messing around. The default values should be better. This is likely to be more important in future versions. ```Python print_image_from_path(path: str, *, register_count: int = 256, palette_generation_algorithm: PaletteGenerationAlgorithm = DEFAULT_PALETTE_GENERATION_ALGORITHM) @@ -81,17 +77,23 @@ img2sixels(image: List[List[Tuple[int, int, int, int]]], *, register_count: int ## TO-DO - [x] ~~Multiprocessing~~ - [ ] Global interpreter lock detection to switch between multi-threading and multi-processing -- [ ] Optional dithering -- [ ] Realtime SIXEL conversion for large images -- [ ] Ability to load and play videos -- [ ] Auto select the optimal palette generator based on the image or video, if not explicitly specified -- [ ] Play Bad Apple on it in real-time, at minimum 30 FPS (must be done, one way or another) -- [ ] Lossless true color output on all supported terminals -- [ ] Remove all third-party dependencies +- [ ] Dithering option +- [ ] Real-time SIXEL conversion for large images +- [ ] Ability to load and play videos (no, it won't make your "cat" play videos... probably) +- [ ] Auto select the optimal parameters based on the image or video, if not explicitly specified +- [ ] Play Bad Apple in real-time at full quality (must be done, one way or another) +- [ ] Lossless color output on all supported terminals (partially implemented and currently works on some terminals that allow register reuse) +- [ ] Get rid of as many hard dependencies as possibe ## Known issues -* Doesn't work on WASI (`concurrent.futures` library is not available) -* No multiprocessing on mobile platforms (`multiprocessing` library is not available) -* QPUNM is usually good enough but better options are definitely needed -* Quality of OTFCD seems to be entirely dependent on a number that is too sensitive and the current way we calculate that number is just a very rough approximation -* Pure Python is too slow for real-time conversion of large detailed images +* Doesn't/shouldn't work on WebAssembly. (`concurrent.futures` library is not available, according to the Python documentation) +* No multiprocessing on mobile platforms. (`concurrent` library is not available) +* QPUNM is usually good enough but better options are definitely needed. +* Quality of OTFCD seems to be entirely dependent on a number that is too sensitive and the current way we calculate that number is just a very rough approximation. +* Pure Python seems to be too slow for real-time conversion of large detailed images. + +## Q&A + +**Q: Will you add this to PyPI?** + +A: No, I can't bother with that for now. But you're free to do so, if you can maintain it. :)