Skip to content

Commit d0e4a71

Browse files
committed
Initial
0 parents  commit d0e4a71

File tree

4 files changed

+218
-0
lines changed

4 files changed

+218
-0
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# img
2+
3+
Print images in the terminal using ANSI codes
4+
5+
![Screenshot](screenshot.png)
6+
7+
- Sets foreground and background color of half block unicode "▄" to represent two pixels for each character
8+
- Matches colors with closest ANSI extended color (16-231)
9+
- Color lookup table for quick rendering of large images
10+
- Depends on Pillow for image processing
11+
12+
## Features
13+
14+
- List all images in current dir with thumbnails and filenames `img`
15+
- Print single image to fit terminal `img image.jpg`
16+
- Custom size options `--cols` and/or `--rows`
17+
- Accepts raw image data over stdin `cat image.jpg | img -`
18+
- `img.process_image(path)` returns the image rows as strings
19+
20+
## Installation
21+
22+
`pip3 install --user "git+https://github.com/oysols/img"`

img.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
#!/usr/bin/env python3
2+
import os
3+
import argparse
4+
from pathlib import Path
5+
from typing import List, Tuple, Dict, Optional, Iterator, Union, BinaryIO
6+
import contextlib
7+
import sys
8+
9+
from PIL import Image # type: ignore
10+
11+
12+
def get_palette() -> List[Tuple[str, str]]:
13+
# Basic colors 0 - 7
14+
# Bright basic colors 8 - 15
15+
palette = [
16+
("00", "000000"),
17+
("01", "800000"),
18+
("02", "008000"),
19+
("03", "808000"),
20+
("04", "000080"),
21+
("05", "800080"),
22+
("06", "008080"),
23+
("07", "c0c0c0"),
24+
("08", "808080"),
25+
("09", "ff0000"),
26+
("10", "00ff00"),
27+
("11", "ffff00"),
28+
("12", "0000ff"),
29+
("13", "ff00ff"),
30+
("14", "00ffff"),
31+
("15", "ffffff"),
32+
]
33+
34+
# Extended colors 16 - 231 (000000, ffffff)
35+
# Almost evenly spread
36+
# values = [0] + [95 + 40 * i for i in range(5)]
37+
# All RGB values with any combination of "values" are valid
38+
color_code = 16
39+
values = ["00", "5f", "87", "af", "d7", "ff"]
40+
for r in values:
41+
for g in values:
42+
for b in values:
43+
palette.append((str(color_code), "".join([r, g, b])))
44+
color_code += 1
45+
46+
# Gray scale 232 - 255 (080808 - eeeeee)
47+
# Almost evenly spread
48+
gray_values = [8 + 10 * i for i in range(24)]
49+
for i in gray_values:
50+
palette.append((str(color_code), "{:02x}{:02x}{:02x}".format(i, i, i)))
51+
color_code += 1
52+
return palette
53+
54+
def create_closest_valid_color_dict() -> Dict[int, str]:
55+
# All RGB values with any combination of `values` are valid
56+
values = ["00", "5f", "87", "af", "d7", "ff"]
57+
closest_hex_lookup = {}
58+
for i in range(256):
59+
distances = [abs(i - int(value, 16)) for value in values]
60+
index_of_minimum = distances.index(min(distances))
61+
closest_hex_lookup[i] = values[index_of_minimum]
62+
return closest_hex_lookup
63+
64+
65+
# Pregenerate lookup table for finding the closest valid color value
66+
CLOSEST_VALID_COLORS = create_closest_valid_color_dict()
67+
68+
# Pregenerate lookup table for converting a valid hexcolor to colorcode
69+
HEX_2_COLORCODE = {hexcolor: colorcode for colorcode, hexcolor in get_palette()}
70+
71+
72+
def get_colorcode_from_rgb(rgb_tuple: Tuple[int, int, int]) -> str:
73+
hexcolor = "".join([CLOSEST_VALID_COLORS[color] for color in rgb_tuple])
74+
return HEX_2_COLORCODE[hexcolor]
75+
76+
77+
def is_valid_image(path: Path) -> bool:
78+
try:
79+
im = Image.open(path)
80+
except:
81+
return False
82+
return True
83+
84+
85+
def process_image(image: Union[Path, BinaryIO], cols: Optional[int] = None, rows: Optional[int] = None) -> List[str]:
86+
term_cols, term_rows = os.get_terminal_size(1)
87+
if not cols:
88+
cols = term_cols
89+
if not rows:
90+
rows = term_rows
91+
size = (cols, rows*2)
92+
im = Image.open(image)
93+
im.thumbnail(size, Image.ANTIALIAS)
94+
output = []
95+
for y in range(1, im.size[1], 2): # Use y and y -1 every loop
96+
line = ""
97+
for x in range(0, im.size[0], 1):
98+
# Build image using utf-8 half block symbol
99+
char = "▄"
100+
101+
# Background (top)
102+
p = im.getpixel((x, y - 1))
103+
colorcode = get_colorcode_from_rgb(p[:3])
104+
background_color = "\033[48;5;{}m".format(colorcode)
105+
106+
# Foreground (bottom)
107+
p = im.getpixel((x, y))
108+
colorcode = get_colorcode_from_rgb(p[:3])
109+
foreground_color = "\033[38;5;{}m".format(colorcode)
110+
line += background_color + foreground_color + char
111+
line += "\033[0m" # Clear formatting
112+
output.append(line)
113+
return output
114+
115+
116+
@contextlib.contextmanager
117+
def safe_print() -> Iterator[None]:
118+
try:
119+
yield
120+
finally:
121+
print("\033[0m", end="") # Make sure to not break terminal
122+
123+
124+
def main() -> None:
125+
parser = argparse.ArgumentParser(description="Print image to terminal")
126+
parser.add_argument("image_paths", help="Path to image(s)", nargs="*")
127+
parser.add_argument("-c", "--cols", type=int, help="Columns of image")
128+
parser.add_argument("-r", "--rows", type=int, help="Rows of image")
129+
args = parser.parse_args()
130+
131+
if len(args.image_paths) == 1:
132+
# Fill terminal by default
133+
if args.image_paths == ["-"]:
134+
# Read directly from stdin
135+
image = process_image(sys.stdin.buffer, args.cols, args.rows)
136+
with safe_print():
137+
for text_row in image:
138+
print(text_row)
139+
path = Path(args.image_paths[0])
140+
if is_valid_image(path):
141+
image = process_image(path, args.cols, args.rows)
142+
with safe_print():
143+
for text_row in image:
144+
print(text_row)
145+
else:
146+
# Show thumbs and filenames
147+
if not args.image_paths:
148+
image_paths = Path().iterdir()
149+
else:
150+
image_paths = (Path(path) for path in args.image_paths)
151+
sorted_image_paths = sorted([path for path in image_paths if is_valid_image(path)])
152+
153+
cols = args.cols
154+
if not cols:
155+
cols = 40
156+
157+
blank_line = " " * cols
158+
column_separator = " "
159+
row_separator = "\n"
160+
161+
term_cols, term_rows = os.get_terminal_size(1)
162+
images_per_row = term_cols // (cols + len(column_separator))
163+
164+
current_row_images = []
165+
current_row_paths = []
166+
for path in sorted_image_paths:
167+
current_row_images.append(process_image(path, cols, args.rows))
168+
current_row_paths.append(path)
169+
if len(current_row_images) == images_per_row or path == sorted_image_paths[-1]:
170+
max_rows = max([len(im) for im in current_row_images])
171+
print(column_separator.join(["{:{}}".format(str(path), cols) for path in current_row_paths]))
172+
for line in range(max_rows):
173+
image_lines = [im[line] if len(im) > line else blank_line for im in current_row_images]
174+
output = column_separator.join(image_lines)
175+
with safe_print():
176+
print(output)
177+
print(row_separator, end="")
178+
current_row_images = []
179+
current_row_paths = []
180+
181+
182+
if __name__ == "__main__":
183+
main()

screenshot.png

5.76 KB
Loading

setup.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from setuptools import setup
2+
3+
setup(
4+
name="img",
5+
version="0.1",
6+
py_modules=["img"],
7+
entry_points = {
8+
'console_scripts': [
9+
'img=img:main',
10+
],
11+
},
12+
install_requires=["pillow>=6.1.0"],
13+
)

0 commit comments

Comments
 (0)