ASCII-Filter/ctif.py
2025-05-28 09:17:27 -05:00

361 lines
9.2 KiB
Python

import colorsys
import logging
import math
import os
from rich.console import Console
from PIL import Image, ImageFilter
import pygame as pg
import filters
logging.getLogger(__name__)
FONT_PATH = 'fonts/scientifica.ttf'
TEXT_WIDTH = 6 # on font size 12 monocraft medium
TEXT_HEIGHT = 11
SCALE_FACTOR = 1
RESIZE_FACTOR = 0.75
SCALED_WIDTH = TEXT_WIDTH // SCALE_FACTOR
SCALED_HEIGHT = TEXT_HEIGHT // SCALE_FACTOR
DEBUG = True
ANTI_ALIASING = False
if not os.path.exists('./debug/') and DEBUG:
os.makedirs('./debug')
# '█@?OPoci. '
CHARS = list(reversed([
u'',
'@',
'?',
'O',
'P',
'o',
'c',
'i',
'.',
' '
]))
NEW_CHARS = ' .:\',`;_<^~%>"?!ir=clsxzL}ovCS(+eknuFJfj{amwPZtIT)*hbdpqyEGKOVXYgU&ABDHQR[]MNW@$#'
EDGE_CHARS = '-\\|/'
ALL_CHARS = [
u'',
'@',
'?',
'O',
'P',
'o',
'c',
'i',
'.',
' ',
'|',
'/',
'-',
'\\'
]
# Character Text Image Format
class CTIF:
colors: list
text: str
width: int
height: int
def __init__(self, image: Image.Image | None = None):
self.colors = []
self.text = ""
self.width = 0
self.height = 0
if image:
self.convert(image)
def convert(self, image: Image.Image) -> None:
if image.mode != 'RGB':
image = image.convert('RGB')
image = image.resize((int(image.width * RESIZE_FACTOR), int(image.height * RESIZE_FACTOR)))
logging.debug(f'Image Size: {image.size}')
L_image = image.convert('L')
# logging.debug(f'L_image Size: {L_image.size}')
dog = filters.difference_of_gaussians(image, blur_strength_2=1)
# logging.debug(f'Dog Size: {dog.size}')
dog.save('debug/dog.png')
sb, gradient = filters.sobel(dog)
# image = image.convert('HSV')
w, h = sb.size
sb.save('debug/sobel.png')
text_grid_width = w // SCALED_WIDTH
self.width = text_grid_width
text_grind_height = h // SCALED_HEIGHT
self.height = text_grind_height
y_offset = 0
for y in range(math.floor(h / SCALED_HEIGHT)):
x_offset = 0
for x in range(math.floor(w / SCALED_WIDTH)):
histogram = {}
# Collect the most common char for a group of pixels
color = []
for y2 in range(SCALED_HEIGHT):
for x2 in range(SCALED_WIDTH):
real_x = x2 + x_offset
real_y = y2 + y_offset
r, g, b = image.getpixel((real_x, real_y))
color.append((r, g, b))
h, s, v = colorsys.rgb_to_hsv(r, g, b)
# print(h, s, v)
gradient_v = gradient[real_y * w + real_x]
char = ' '
if gradient_v:
char = self._match_gradient(gradient_v)
else:
char = NEW_CHARS[round(v/(255/len(NEW_CHARS))) - 1]
if char in histogram:
histogram[char] += 1
else:
histogram[char] = 1
r, g, b = filters.average_colors(color)
color_avg_hsv = colorsys.rgb_to_hsv(r/255, g/255, b/255)
self.colors.append(color_avg_hsv)
# get most common
most_common = None
score = float('-inf')
for char in histogram:
if histogram[char] > score:
score = histogram[char]
most_common = char
self.text += str(most_common)
x_offset += SCALED_WIDTH
y_offset += SCALED_HEIGHT
# TODO @0x01FE : refactor plz increment by 30 & 60
def _match_gradient(self, n: float) -> str:
n = abs(n)
if n < math.radians(30):
return '|'
elif n < math.radians(60):
return '/'
elif n < math.radians(120):
return '-'
elif n < math.radians(150):
return '\\'
elif n < math.radians(210):
return '|'
elif n < math.radians(240):
return '/'
elif n < math.radians(300):
return '-'
elif n < math.radians(330):
return '\\'
else:
return '|'
def render(self) -> None:
console = Console()
for i, (color, char) in enumerate(zip(self.colors, self.text)):
hex_color = '#%x%x%x' % color
console.print(f'[{hex_color}]{char}', end='')
if i + 1 % self.width == 0:
print()
def save_image(self, path: str) -> None:
logging.debug('Rendering and Saving image...')
pg.init()
font = pg.font.Font(FONT_PATH, 12)
window = pg.display.set_mode((self.width * TEXT_WIDTH, self.height * TEXT_HEIGHT))
x, y = 0, 0
logging.debug(f'Rendering CITF Image with Dim: ({self.width}, {self.height})')
for (color, char) in zip(self.colors, self.text):
h, s, v = color
if char not in EDGE_CHARS:
v = (NEW_CHARS.find(char) + 1) * (255/len(NEW_CHARS))/255
# logging.debug(f'HSV Color: {h, s, v}')
# Make the bright colors brighter and the darks darker
if v >= 0.5:
v *= 1.5
else:
v *= 1.25
r, g, b = colorsys.hsv_to_rgb(h, s, v)
# logging.debug(f'RGB Color Before: {r, g, b}')
r *= 255
g *= 255
b *= 255
r = min(r, 255)
g = min(g, 255)
b = min(b, 255)
# logging.debug(f'RGB Color: {r, g, b}')
rendered_char = font.render(char, ANTI_ALIASING, (int(r), int(g), int(b)))
window.blit(rendered_char, (x * TEXT_WIDTH, y * TEXT_HEIGHT))
x += 1
if x >= self.width:
x = 0
y += 1
pg.display.update()
pg.image.save(window, path)
pg.quit()
def save_edges(self, path: str) -> None:
pg.init()
font = pg.font.Font(FONT_PATH, 12)
window = pg.display.set_mode((self.width * TEXT_WIDTH, self.height * TEXT_HEIGHT))
x, y = 0, 0
logging.debug(f'Rendering CITF Image with Dim: ({self.width}, {self.height})')
for (color, char) in zip(self.colors, self.text):
if char in EDGE_CHARS:
h, s, v = color
new_color = colorsys.hsv_to_rgb(h, s, 255)
# print(color)
rendered_char = font.render(char, ANTI_ALIASING, new_color)
window.blit(rendered_char, (x * TEXT_WIDTH, y * TEXT_HEIGHT))
x += 1
if x >= self.width:
x = 0
y += 1
pg.display.update()
pg.image.save(window, path)
pg.quit()
def save_citf(self, filename: str) -> None:
# 1 byte for h, 1 byte for s, 1 byte for character which will be interperated as v when rendering
bin = bytes()
# Save Width and Height
bin += self.width.to_bytes(2, 'big')
bin += self.height.to_bytes(2, 'big')
for (color, char) in zip(self.colors, self.text):
h, s, v = color
h = min(int(h * 255), 255)
bin += h.to_bytes(1, 'big')
s = min(int(s * 255), 255)
bin += s.to_bytes(1, 'big')
# for n in (h, s):
# i = int(n * 255)
# bin += i.to_bytes(1, 'big')
char_index = (NEW_CHARS + EDGE_CHARS).index(char)
bin += char_index.to_bytes(1, 'big')
if char in EDGE_CHARS:
v = min(int(v * 255), 255)
bin += v.to_bytes(1, 'big')
with open(f'{filename}.citf', 'wb+') as file:
file.write(bin)
def read_citf(self, filepath: str) -> None:
self.text = ''
self.colors = []
with open(filepath, 'rb') as file:
data = file.read()
# Get Width and Height
self.width = int.from_bytes(data[:2], 'big')
self.height = int.from_bytes(data[2:4], 'big')
data = data[4:]
if len(data) % 3 != 0:
logging.error(f"File is bad size. {len(data)}")
# Read colors and characters
i = 0
for n in range(1, (int(len(data)/3)) + 1):
d = data[i:i + 3]
# print(f'index: {i}')
# print(f'index end: {i + 3}')
# print(f'data: {d}')
h = d[0:1]
h = int.from_bytes(h, 'big')
s = d[1:2]
s = int.from_bytes(s, 'big')
char_n = d[2:3]
char_n = int.from_bytes(char_n, 'big')
# print(char_n)
char = (NEW_CHARS + EDGE_CHARS)[char_n]
logging.debug(f'H: {h}, S: {s}, Char: {char}')
self.text += char
if char in EDGE_CHARS:
v = d[i+3:i+4]
v = int.from_bytes(v, 'big')
self.colors.append((h/255, s/255, v/255))
i += 4
else:
self.colors.append((h/255, s/255, 255))
i += 3