dotz 0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dotz-0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arun Prakash Jana
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
dotz-0.1/PKG-INFO ADDED
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: dotz
3
+ Version: 0.1
4
+ Summary: Render images and video previews as Braille art in the terminal with color and animation support.
5
+ Home-page: https://github.com/jarun/dotz
6
+ Author: Arun Prakash Jana
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/jarun/dotz
9
+ Project-URL: Repository, https://github.com/jarun/dotz
10
+ Project-URL: Issues, https://github.com/jarun/dotz/issues
11
+ Keywords: terminal image-viewer braille curses gif video ffmpeg
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console :: Curses
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.7
19
+ Classifier: Programming Language :: Python :: 3.8
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Topic :: Multimedia :: Graphics
25
+ Classifier: Topic :: Terminals
26
+ Requires-Python: >=3.7
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: numpy>=1.20
30
+ Requires-Dist: Pillow>=8.0
31
+ Dynamic: author
32
+ Dynamic: classifier
33
+ Dynamic: description
34
+ Dynamic: description-content-type
35
+ Dynamic: home-page
36
+ Dynamic: keywords
37
+ Dynamic: license
38
+ Dynamic: license-file
39
+ Dynamic: project-url
40
+ Dynamic: requires-dist
41
+ Dynamic: requires-python
42
+ Dynamic: summary
43
+
44
+ # dotz
45
+
46
+ Render images and video previews as Braille art in the terminal with xterm-256 color and ncurses dim/normal/bold attributes.
47
+
48
+ It was written to be used as a terminal image viewer with [`nnn`](https://github.com/jarun/nnn). Works independently too.
49
+
50
+ ## Features
51
+
52
+ - Braille art rendering for images
53
+ - Animated GIF support
54
+ - xterm-256 color and grayscale
55
+ - Dithering options (ordered, error diffusion)
56
+ - Video preview (frame extraction with ffmpeg)
57
+ - Keyboard navigation and slideshow mode
58
+
59
+ <br>
60
+ <img width="1323" height="826" alt="image_01" src="https://github.com/user-attachments/assets/f2becbbc-cfeb-42b3-bd92-3882ff3fb570" />
61
+ <br><br>
62
+ <img width="1333" height="827" alt="image_02" src="https://github.com/user-attachments/assets/62bc16a8-246b-4b5a-a11e-fd0faa5c8066" />
63
+ <br><br>
64
+ <img width="1301" height="954" alt="image_03" src="https://github.com/user-attachments/assets/609805be-c0c5-4815-bb33-3bc70d69c152" />
65
+
66
+
67
+ ## Installation
68
+
69
+ Install the required dependencies and the dotz command:
70
+
71
+ ```sh
72
+ # Install system dependencies (e.g., ffmpeg)
73
+ sudo apt-get install ffmpeg # or use your OS package manager
74
+
75
+ # Install Python dependencies and the CLI tool
76
+ sudo pip3 install .
77
+ ```
78
+
79
+ After installation, you can run the tool using:
80
+
81
+ ```sh
82
+ dotz [options] <file-or-directory>
83
+ ```
84
+
85
+ ## Usage
86
+
87
+ ```
88
+ usage: dotz [-h] [-S] [-C] [-d {ordered,error,none}] [-s [DELAY]] [-k SEEK] [-f {jpeg,png}] [path]
89
+
90
+ Render an image or all images/videos in a directory as Braille cells using ncurses with optional xterm-256 color.
91
+
92
+ positional arguments:
93
+ path Path to the image/video file or directory (optional)
94
+
95
+ options:
96
+ -h, --help show this help message and exit
97
+ -S, --no-sharpen Disable edge sharpening
98
+ -C, --no-color Disable color (greyscale only with dim/normal/bold)
99
+ -d {ordered,error,none}, --dither {ordered,error,none}
100
+ Dithering mode: ordered (default, clean), error (Floyd-Steinberg, smooth gradients), none
101
+ -s [DELAY], --slideshow [DELAY]
102
+ Enable slideshow mode with optional integer delay in seconds (default: 5).
103
+ -k SEEK, --seek SEEK Seek position to extract frame from videos in seconds (default: 10)
104
+ -f {jpeg,png}, --format {jpeg,png}
105
+ Format for extracted video frames: jpeg (default) or png
106
+ ```
107
+
108
+ #### Examples
109
+
110
+ - Syntax:
111
+ ```sh
112
+ python -m dotz <file-or-directory>
113
+ ```
114
+ - To render a single image:
115
+ ```sh
116
+ python -m dotz path/to/image.jpg
117
+ ```
118
+ - To render all images and videos in a directory:
119
+ ```sh
120
+ python -m dotz path/to/directory/
121
+ ```
122
+ - To run a slideshow with a custom delay (e.g. 3 seconds):
123
+ ```sh
124
+ python -m dotz -s 3 path/to/directory/
125
+ ```
126
+
127
+ ## Navigation
128
+
129
+ | Key | Action |
130
+ |-----------------|----------|
131
+ | Right, l, Space | Next |
132
+ | Left, h | Previous |
133
+ | Up | First |
134
+ | Down | Last |
135
+ | s | Toggle slideshow |
136
+ | S | Toggle reverse slideshow |
137
+ | q, Esc | Quit |
138
+
139
+ ## Dependencies
140
+
141
+ | Package | Version | Usage |
142
+ |-----------|------------|--------------------------------------------|
143
+ | python | >=3.7 | Required Python version |
144
+ | numpy | >=1.20 | Fast array operations for image processing |
145
+ | Pillow | >=8.0 | Image loading and manipulation |
146
+ | ffmpeg | >=4.2 | Video frame extraction |
147
+
148
+ ## Formats
149
+
150
+ **Image:** PNG, JPG, JPEG, BMP, GIF, TIFF, WEBP
151
+
152
+ **Video:** MP4, MKV, AVI, MOV, WEBM, FLV, WMV, MPEG, MPG
153
+
154
+ ## License
155
+
156
+ MIT
dotz-0.1/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # dotz
2
+
3
+ Render images and video previews as Braille art in the terminal with xterm-256 color and ncurses dim/normal/bold attributes.
4
+
5
+ It was written to be used as a terminal image viewer with [`nnn`](https://github.com/jarun/nnn). Works independently too.
6
+
7
+ ## Features
8
+
9
+ - Braille art rendering for images
10
+ - Animated GIF support
11
+ - xterm-256 color and grayscale
12
+ - Dithering options (ordered, error diffusion)
13
+ - Video preview (frame extraction with ffmpeg)
14
+ - Keyboard navigation and slideshow mode
15
+
16
+ <br>
17
+ <img width="1323" height="826" alt="image_01" src="https://github.com/user-attachments/assets/f2becbbc-cfeb-42b3-bd92-3882ff3fb570" />
18
+ <br><br>
19
+ <img width="1333" height="827" alt="image_02" src="https://github.com/user-attachments/assets/62bc16a8-246b-4b5a-a11e-fd0faa5c8066" />
20
+ <br><br>
21
+ <img width="1301" height="954" alt="image_03" src="https://github.com/user-attachments/assets/609805be-c0c5-4815-bb33-3bc70d69c152" />
22
+
23
+
24
+ ## Installation
25
+
26
+ Install the required dependencies and the dotz command:
27
+
28
+ ```sh
29
+ # Install system dependencies (e.g., ffmpeg)
30
+ sudo apt-get install ffmpeg # or use your OS package manager
31
+
32
+ # Install Python dependencies and the CLI tool
33
+ sudo pip3 install .
34
+ ```
35
+
36
+ After installation, you can run the tool using:
37
+
38
+ ```sh
39
+ dotz [options] <file-or-directory>
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```
45
+ usage: dotz [-h] [-S] [-C] [-d {ordered,error,none}] [-s [DELAY]] [-k SEEK] [-f {jpeg,png}] [path]
46
+
47
+ Render an image or all images/videos in a directory as Braille cells using ncurses with optional xterm-256 color.
48
+
49
+ positional arguments:
50
+ path Path to the image/video file or directory (optional)
51
+
52
+ options:
53
+ -h, --help show this help message and exit
54
+ -S, --no-sharpen Disable edge sharpening
55
+ -C, --no-color Disable color (greyscale only with dim/normal/bold)
56
+ -d {ordered,error,none}, --dither {ordered,error,none}
57
+ Dithering mode: ordered (default, clean), error (Floyd-Steinberg, smooth gradients), none
58
+ -s [DELAY], --slideshow [DELAY]
59
+ Enable slideshow mode with optional integer delay in seconds (default: 5).
60
+ -k SEEK, --seek SEEK Seek position to extract frame from videos in seconds (default: 10)
61
+ -f {jpeg,png}, --format {jpeg,png}
62
+ Format for extracted video frames: jpeg (default) or png
63
+ ```
64
+
65
+ #### Examples
66
+
67
+ - Syntax:
68
+ ```sh
69
+ python -m dotz <file-or-directory>
70
+ ```
71
+ - To render a single image:
72
+ ```sh
73
+ python -m dotz path/to/image.jpg
74
+ ```
75
+ - To render all images and videos in a directory:
76
+ ```sh
77
+ python -m dotz path/to/directory/
78
+ ```
79
+ - To run a slideshow with a custom delay (e.g. 3 seconds):
80
+ ```sh
81
+ python -m dotz -s 3 path/to/directory/
82
+ ```
83
+
84
+ ## Navigation
85
+
86
+ | Key | Action |
87
+ |-----------------|----------|
88
+ | Right, l, Space | Next |
89
+ | Left, h | Previous |
90
+ | Up | First |
91
+ | Down | Last |
92
+ | s | Toggle slideshow |
93
+ | S | Toggle reverse slideshow |
94
+ | q, Esc | Quit |
95
+
96
+ ## Dependencies
97
+
98
+ | Package | Version | Usage |
99
+ |-----------|------------|--------------------------------------------|
100
+ | python | >=3.7 | Required Python version |
101
+ | numpy | >=1.20 | Fast array operations for image processing |
102
+ | Pillow | >=8.0 | Image loading and manipulation |
103
+ | ffmpeg | >=4.2 | Video frame extraction |
104
+
105
+ ## Formats
106
+
107
+ **Image:** PNG, JPG, JPEG, BMP, GIF, TIFF, WEBP
108
+
109
+ **Video:** MP4, MKV, AVI, MOV, WEBM, FLV, WMV, MPEG, MPG
110
+
111
+ ## License
112
+
113
+ MIT
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: dotz
3
+ Version: 0.1
4
+ Summary: Render images and video previews as Braille art in the terminal with color and animation support.
5
+ Home-page: https://github.com/jarun/dotz
6
+ Author: Arun Prakash Jana
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/jarun/dotz
9
+ Project-URL: Repository, https://github.com/jarun/dotz
10
+ Project-URL: Issues, https://github.com/jarun/dotz/issues
11
+ Keywords: terminal image-viewer braille curses gif video ffmpeg
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console :: Curses
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.7
19
+ Classifier: Programming Language :: Python :: 3.8
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Topic :: Multimedia :: Graphics
25
+ Classifier: Topic :: Terminals
26
+ Requires-Python: >=3.7
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: numpy>=1.20
30
+ Requires-Dist: Pillow>=8.0
31
+ Dynamic: author
32
+ Dynamic: classifier
33
+ Dynamic: description
34
+ Dynamic: description-content-type
35
+ Dynamic: home-page
36
+ Dynamic: keywords
37
+ Dynamic: license
38
+ Dynamic: license-file
39
+ Dynamic: project-url
40
+ Dynamic: requires-dist
41
+ Dynamic: requires-python
42
+ Dynamic: summary
43
+
44
+ # dotz
45
+
46
+ Render images and video previews as Braille art in the terminal with xterm-256 color and ncurses dim/normal/bold attributes.
47
+
48
+ It was written to be used as a terminal image viewer with [`nnn`](https://github.com/jarun/nnn). Works independently too.
49
+
50
+ ## Features
51
+
52
+ - Braille art rendering for images
53
+ - Animated GIF support
54
+ - xterm-256 color and grayscale
55
+ - Dithering options (ordered, error diffusion)
56
+ - Video preview (frame extraction with ffmpeg)
57
+ - Keyboard navigation and slideshow mode
58
+
59
+ <br>
60
+ <img width="1323" height="826" alt="image_01" src="https://github.com/user-attachments/assets/f2becbbc-cfeb-42b3-bd92-3882ff3fb570" />
61
+ <br><br>
62
+ <img width="1333" height="827" alt="image_02" src="https://github.com/user-attachments/assets/62bc16a8-246b-4b5a-a11e-fd0faa5c8066" />
63
+ <br><br>
64
+ <img width="1301" height="954" alt="image_03" src="https://github.com/user-attachments/assets/609805be-c0c5-4815-bb33-3bc70d69c152" />
65
+
66
+
67
+ ## Installation
68
+
69
+ Install the required dependencies and the dotz command:
70
+
71
+ ```sh
72
+ # Install system dependencies (e.g., ffmpeg)
73
+ sudo apt-get install ffmpeg # or use your OS package manager
74
+
75
+ # Install Python dependencies and the CLI tool
76
+ sudo pip3 install .
77
+ ```
78
+
79
+ After installation, you can run the tool using:
80
+
81
+ ```sh
82
+ dotz [options] <file-or-directory>
83
+ ```
84
+
85
+ ## Usage
86
+
87
+ ```
88
+ usage: dotz [-h] [-S] [-C] [-d {ordered,error,none}] [-s [DELAY]] [-k SEEK] [-f {jpeg,png}] [path]
89
+
90
+ Render an image or all images/videos in a directory as Braille cells using ncurses with optional xterm-256 color.
91
+
92
+ positional arguments:
93
+ path Path to the image/video file or directory (optional)
94
+
95
+ options:
96
+ -h, --help show this help message and exit
97
+ -S, --no-sharpen Disable edge sharpening
98
+ -C, --no-color Disable color (greyscale only with dim/normal/bold)
99
+ -d {ordered,error,none}, --dither {ordered,error,none}
100
+ Dithering mode: ordered (default, clean), error (Floyd-Steinberg, smooth gradients), none
101
+ -s [DELAY], --slideshow [DELAY]
102
+ Enable slideshow mode with optional integer delay in seconds (default: 5).
103
+ -k SEEK, --seek SEEK Seek position to extract frame from videos in seconds (default: 10)
104
+ -f {jpeg,png}, --format {jpeg,png}
105
+ Format for extracted video frames: jpeg (default) or png
106
+ ```
107
+
108
+ #### Examples
109
+
110
+ - Syntax:
111
+ ```sh
112
+ python -m dotz <file-or-directory>
113
+ ```
114
+ - To render a single image:
115
+ ```sh
116
+ python -m dotz path/to/image.jpg
117
+ ```
118
+ - To render all images and videos in a directory:
119
+ ```sh
120
+ python -m dotz path/to/directory/
121
+ ```
122
+ - To run a slideshow with a custom delay (e.g. 3 seconds):
123
+ ```sh
124
+ python -m dotz -s 3 path/to/directory/
125
+ ```
126
+
127
+ ## Navigation
128
+
129
+ | Key | Action |
130
+ |-----------------|----------|
131
+ | Right, l, Space | Next |
132
+ | Left, h | Previous |
133
+ | Up | First |
134
+ | Down | Last |
135
+ | s | Toggle slideshow |
136
+ | S | Toggle reverse slideshow |
137
+ | q, Esc | Quit |
138
+
139
+ ## Dependencies
140
+
141
+ | Package | Version | Usage |
142
+ |-----------|------------|--------------------------------------------|
143
+ | python | >=3.7 | Required Python version |
144
+ | numpy | >=1.20 | Fast array operations for image processing |
145
+ | Pillow | >=8.0 | Image loading and manipulation |
146
+ | ffmpeg | >=4.2 | Video frame extraction |
147
+
148
+ ## Formats
149
+
150
+ **Image:** PNG, JPG, JPEG, BMP, GIF, TIFF, WEBP
151
+
152
+ **Video:** MP4, MKV, AVI, MOV, WEBM, FLV, WMV, MPEG, MPG
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ dotz.py
4
+ pyproject.toml
5
+ setup.py
6
+ dotz.egg-info/PKG-INFO
7
+ dotz.egg-info/SOURCES.txt
8
+ dotz.egg-info/dependency_links.txt
9
+ dotz.egg-info/entry_points.txt
10
+ dotz.egg-info/requires.txt
11
+ dotz.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dotz = dotz:main
@@ -0,0 +1,2 @@
1
+ numpy>=1.20
2
+ Pillow>=8.0
@@ -0,0 +1 @@
1
+ dotz
dotz-0.1/dotz.py ADDED
@@ -0,0 +1,513 @@
1
+ #!/usr/bin/env python3
2
+ """Render images as Braille art with xterm-256 color and ncurses dim/normal/bold."""
3
+
4
+
5
+ import argparse
6
+ import curses
7
+ import sys
8
+ import os
9
+ import glob
10
+
11
+ import numpy as np
12
+ from PIL import Image, ImageFilter
13
+
14
+ _VERSION_ = "0.1"
15
+ _AUTHOR_ = "Arun Prakash Jana"
16
+ _AUTHOR_EMAIL_ = "engineerarun@gmail.com"
17
+ _LICENSE_ = "MIT"
18
+ _WEBPAGE_ = "https://github.com/jarun/dotz"
19
+
20
+ BRAILLE_BASE = 0x2800
21
+ BRAILLE_MAP = (
22
+ (0x01, 0x08), # row 0
23
+ (0x02, 0x10), # row 1
24
+ (0x04, 0x20), # row 2
25
+ (0x40, 0x80), # row 3
26
+ )
27
+
28
+ # Ordered dither matrix for the 4×2 braille grid.
29
+ BAYER_4x2 = np.array([
30
+ [0, 4],
31
+ [2, 6],
32
+ [5, 1],
33
+ [7, 3],
34
+ ], dtype=np.float64)
35
+
36
+ # ── xterm-256 color cube helpers ──────────────────────────────────────────────
37
+ # Colors 16-231 form a 6×6×6 RGB cube. Values per axis: 0,95,135,175,215,255.
38
+ # Colors 232-255 are a 24-step greyscale ramp.
39
+ _CUBE_VALS = np.array([0, 0x5f, 0x87, 0xaf, 0xd7, 0xff], dtype=np.float64)
40
+ _GREY_VALS = np.array([8 + 10 * i for i in range(24)], dtype=np.float64)
41
+
42
+
43
+ def _build_xterm256_table():
44
+ """Build an (N, 3) array of all xterm-256 RGB values (indices 16-255)."""
45
+ table = np.zeros((240, 3), dtype=np.float64)
46
+ # 6×6×6 cube: indices 0-215 → xterm 16-231
47
+ idx = 0
48
+ for r in _CUBE_VALS:
49
+ for g in _CUBE_VALS:
50
+ for b in _CUBE_VALS:
51
+ table[idx] = (r, g, b)
52
+ idx += 1
53
+ # greyscale ramp: indices 216-239 → xterm 232-255
54
+ for i, v in enumerate(_GREY_VALS):
55
+ table[216 + i] = (v, v, v)
56
+ return table
57
+
58
+
59
+ _XTERM_TABLE = _build_xterm256_table() # (240, 3)
60
+
61
+
62
+ def _nearest_xterm256(r, g, b):
63
+ """Return the xterm-256 color index (16-255) closest to an sRGB triplet (0-255)."""
64
+ diff = _XTERM_TABLE - np.array([r, g, b], dtype=np.float64)
65
+ dists = np.sum(diff * diff, axis=1)
66
+ return int(np.argmin(dists)) + 16
67
+
68
+
69
+ def _init_color_pairs():
70
+ """Initialise ncurses color pairs 1-240 mapping to xterm colors 16-255."""
71
+ for i in range(240):
72
+ xterm_idx = i + 16
73
+ curses.init_pair(i + 1, xterm_idx, -1) # fg=xterm color, bg=default
74
+
75
+
76
+ def _cell_to_global(local_brightness, attr, bounds):
77
+ """Map a cell's local brightness back to global perceptual brightness."""
78
+ if attr == curses.A_DIM:
79
+ return local_brightness * bounds[0]
80
+ elif attr == curses.A_NORMAL:
81
+ return bounds[0] + local_brightness * (bounds[1] - bounds[0])
82
+ else:
83
+ return bounds[1] + local_brightness * (1.0 - bounds[1])
84
+
85
+
86
+ def srgb_to_linear(c):
87
+ """Convert sRGB [0,1] to linear light."""
88
+ return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
89
+
90
+
91
+ def linear_to_srgb(c):
92
+ """Convert linear light to sRGB [0,1]."""
93
+ return np.where(c <= 0.0031308, c * 12.92, 1.055 * np.power(np.clip(c, 0, None), 1.0 / 2.4) - 0.055)
94
+
95
+
96
+ def _load_image(image_path, img_w, img_h, sharpen, color):
97
+ """Load and prepare image data. Accepts a file path or PIL Image. Returns (frames, color_maps, oy, ox, fit_h, fit_w, durations)."""
98
+ if isinstance(image_path, Image.Image):
99
+ img = image_path
100
+ else:
101
+ img = Image.open(image_path)
102
+ is_animated = getattr(img, "is_animated", False)
103
+ n_frames = getattr(img, "n_frames", 1)
104
+ frames = []
105
+ color_maps = []
106
+ durations = []
107
+ for frame_idx in range(n_frames):
108
+ if is_animated:
109
+ img.seek(frame_idx)
110
+ durations.append(img.info.get("duration", 100))
111
+ if color:
112
+ img_rgb = img.convert("RGB"); img_grey = img_rgb.convert("L")
113
+ else:
114
+ img_rgb = None; img_grey = img.convert("L")
115
+ cell_cols, cell_rows = img_w // 2, img_h // 4
116
+ img_aspect = img_grey.width / img_grey.height
117
+ max_w, max_h = cell_cols * 2, cell_rows * 4
118
+ fit_w, fit_h = (max_w, int(round(max_w / img_aspect))) if (max_w / img_aspect) <= max_h else (int(round(max_h * img_aspect)), max_h)
119
+ fit_w, fit_h = min(fit_w, max_w), min(fit_h, max_h)
120
+ img_grey_r = img_grey.resize((fit_w, fit_h), Image.LANCZOS)
121
+ if sharpen: img_grey_r = img_grey_r.filter(ImageFilter.UnsharpMask(radius=1.2, percent=100, threshold=2))
122
+ oy, ox = (img_h - fit_h) // 2, (img_w - fit_w) // 2
123
+ raw = np.asarray(img_grey_r, dtype=np.float64)
124
+ np.multiply(raw, 1.0/255.0, out=raw) # in-place normalization
125
+ linear = srgb_to_linear(raw)
126
+ canvas = np.zeros((img_h, img_w), dtype=np.float64)
127
+ canvas[oy:oy + fit_h, ox:ox + fit_w] = linear
128
+ perceptual = linear_to_srgb(canvas)
129
+ frames.append(perceptual)
130
+ color_map = None
131
+ if color and img_rgb is not None:
132
+ img_rgb_r = img_rgb.resize((fit_w, fit_h), Image.LANCZOS)
133
+ rgb_arr = np.asarray(img_rgb_r, dtype=np.float64)
134
+ canvas_rgb = np.zeros((img_h, img_w, 3), dtype=np.float64)
135
+ canvas_rgb[oy:oy + fit_h, ox:ox + fit_w] = rgb_arr
136
+ blocks = canvas_rgb[:cell_rows*4, :cell_cols*2, :].reshape(cell_rows, 4, cell_cols, 2, 3)
137
+ block_means = blocks.mean(axis=(1,3), dtype=np.float64)
138
+ block_means_flat = block_means.reshape(-1, 3)
139
+ diffs = block_means_flat[:, None, :] - _XTERM_TABLE[None, :, :]
140
+ np.square(diffs, out=diffs)
141
+ dists = np.sum(diffs, axis=2)
142
+ color_indices = np.argmin(dists, axis=1) + 16
143
+ color_map = color_indices.reshape(cell_rows, cell_cols).astype(np.int32)
144
+ color_maps.append(color_map)
145
+ if not is_animated: break
146
+ return frames, color_maps, oy, ox, fit_h, fit_w, durations
147
+
148
+
149
+ # Helper to extract a video frame using ffmpeg and return a PIL Image
150
+ def extract_video_frame(path, frametime, extractformat):
151
+ import subprocess, io
152
+ vcodec = 'mjpeg' if extractformat == 'jpeg' else 'png'
153
+ ffmpeg_cmd = [
154
+ 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-loglevel', 'error',
155
+ '-skip_frame', 'nokey', '-ss', str(int(frametime)), '-i', path,
156
+ '-an', '-threads', '1', '-vsync', '0',
157
+ '-vframes', '1',
158
+ '-f', 'image2pipe',
159
+ '-vcodec', vcodec,
160
+ '-']
161
+ result = subprocess.run(ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
162
+ if result.returncode != 0 or not result.stdout:
163
+ raise RuntimeError(f"ffmpeg error: {result.stderr.decode()[:100]}")
164
+ from PIL import Image
165
+ img = Image.open(io.BytesIO(result.stdout))
166
+ img.load()
167
+ return img
168
+
169
+
170
+ def get_image_files(directory, include_videos=False):
171
+ from pathlib import Path
172
+ exts = ["png", "jpg", "jpeg", "bmp", "gif", "tiff", "webp"]
173
+ if include_videos:
174
+ exts += ["mp4", "mkv", "avi", "mov", "webm", "flv", "wmv", "mpeg", "mpg"]
175
+ p = Path(directory)
176
+ files = []
177
+ for f in p.iterdir():
178
+ if f.is_file():
179
+ suffix = f.suffix.lower().lstrip('.')
180
+ if suffix in exts:
181
+ files.append(str(f.resolve()))
182
+ files.sort()
183
+ return files
184
+
185
+
186
+ def render(stdscr, image_files, idx, sharpen, dither_mode, color, single_image_mode=False, wait_time=5, slideshow=False):
187
+ import time
188
+ curses.curs_set(0)
189
+ curses.use_default_colors()
190
+ if color:
191
+ curses.start_color()
192
+ _init_color_pairs()
193
+
194
+ n = len(image_files)
195
+ def floyd_steinberg_dither(img):
196
+ arr = img.copy()
197
+ h, w = arr.shape
198
+ for y in range(h):
199
+ for x in range(w):
200
+ old = arr[y, x]
201
+ new = 1.0 if old > 0.5 else 0.0
202
+ err = old - new
203
+ arr[y, x] = new
204
+ if x + 1 < w:
205
+ arr[y, x+1] += err * 7/16
206
+ if y + 1 < h:
207
+ if x > 0:
208
+ arr[y+1, x-1] += err * 3/16
209
+ arr[y+1, x] += err * 5/16
210
+ if x + 1 < w:
211
+ arr[y+1, x+1] += err * 1/16
212
+ return arr
213
+
214
+ stdscr.clear()
215
+ max_y, max_x = stdscr.getmaxyx()
216
+ rows = max_y - 1
217
+ cols = max_x - 1
218
+ img_w = cols * 2
219
+ img_h = rows * 4
220
+ try:
221
+ image_path = image_files[idx]
222
+ display_name = None
223
+ video_exts = ['.mp4', '.mkv', '.avi', '.mov', '.webm', '.flv', '.wmv', '.mpeg', '.mpg']
224
+ ext = os.path.splitext(image_path)[1].lower() if isinstance(image_path, str) else ''
225
+ if ext in video_exts:
226
+ try:
227
+ seek = getattr(render, '_seek', 10)
228
+ fmt = getattr(render, '_format', 'jpeg')
229
+ img = extract_video_frame(image_path, seek, fmt)
230
+ display_name = image_path
231
+ frames, color_maps, oy, ox, fit_h, fit_w, durations = _load_image(img, img_w, img_h, sharpen, color)
232
+ except Exception as e:
233
+ stdscr.clear()
234
+ stdscr.addstr(0, 0, f"Video frame error: {e}")
235
+ stdscr.refresh()
236
+ stdscr.getch()
237
+ return -1
238
+ else:
239
+ if isinstance(image_path, tuple) and len(image_path) == 2:
240
+ image_path, display_name = image_path
241
+ elif isinstance(image_path, Image.Image):
242
+ display_name = '[video frame]'
243
+ frames, color_maps, oy, ox, fit_h, fit_w, durations = _load_image(image_path, img_w, img_h, sharpen, color)
244
+ is_animated = len(frames) > 1
245
+ frame_idx = 0
246
+ key = -1
247
+ last_time = time.time()
248
+ stdscr.nodelay(True)
249
+ except Exception as e:
250
+ stdscr.clear()
251
+ stdscr.addstr(0, 0, f"Render error: {e}")
252
+ stdscr.refresh()
253
+ stdscr.getch()
254
+ return -1
255
+ while True:
256
+ stdscr.clear()
257
+ perceptual = frames[frame_idx]
258
+ color_map = color_maps[frame_idx] if color_maps else None
259
+ thresholds = (BAYER_4x2 + 0.5) / 8.0
260
+ ATTR_BOUNDS = (0.30, 0.62)
261
+ cell_rows = rows
262
+ cell_cols = cols
263
+ if dither_mode == "error":
264
+ dithered = floyd_steinberg_dither(perceptual[:cell_rows*4, :cell_cols*2].copy())
265
+ blocks = dithered.reshape(cell_rows, 4, cell_cols, 2)
266
+ else:
267
+ blocks = perceptual[:cell_rows*4, :cell_cols*2].reshape(cell_rows, 4, cell_cols, 2)
268
+ block_means = blocks.mean(axis=(1,3))
269
+ use_dither = dither_mode == "ordered"
270
+ for cy in range(rows):
271
+ for cx in range(cols):
272
+ avg = block_means[cy, cx]
273
+ if avg < ATTR_BOUNDS[0]:
274
+ attr = curses.A_DIM
275
+ elif avg < ATTR_BOUNDS[1]:
276
+ attr = curses.A_NORMAL
277
+ else:
278
+ attr = curses.A_BOLD
279
+ code = BRAILLE_BASE
280
+ block = blocks[cy, :, cx, :]
281
+ for dr in range(4):
282
+ for dc in range(2):
283
+ t = thresholds[dr, dc] if use_dither else 0.5
284
+ if dither_mode == "error":
285
+ if block[dr, dc] > 0.5:
286
+ code |= BRAILLE_MAP[dr][dc]
287
+ else:
288
+ if block[dr, dc] > t:
289
+ code |= BRAILLE_MAP[dr][dc]
290
+ if color and color_map is not None:
291
+ pair = color_map[cy, cx] - 16 + 1
292
+ attr |= curses.color_pair(pair)
293
+ try:
294
+ stdscr.addstr(cy, cx, chr(code), attr)
295
+ except curses.error:
296
+ pass
297
+ try:
298
+ if display_name:
299
+ shown_name = os.path.basename(display_name)
300
+ elif isinstance(image_path, (str, bytes, os.PathLike)):
301
+ shown_name = os.path.basename(image_path)
302
+ else:
303
+ shown_name = '[video frame]'
304
+ stdscr.addstr(rows, 0, f"[{idx+1}/{n}] {shown_name}", curses.A_REVERSE)
305
+ except curses.error:
306
+ pass
307
+ stdscr.refresh()
308
+ if is_animated:
309
+ duration = durations[frame_idx] / 1000.0 if frame_idx < len(durations) else 0.1
310
+ start_time = time.time()
311
+ while True:
312
+ key = stdscr.getch()
313
+ if key != -1:
314
+ stdscr.nodelay(False)
315
+ return key
316
+ if (time.time() - start_time) >= duration:
317
+ break
318
+ time.sleep(0.01)
319
+ frame_idx = (frame_idx + 1) % len(frames)
320
+ else:
321
+ if slideshow:
322
+ start_time = time.time()
323
+ while True:
324
+ key = stdscr.getch()
325
+ if key == ord('s'):
326
+ return 'toggle_slideshow'
327
+ if key == ord('S'):
328
+ return 'toggle_slideshow_reverse'
329
+ if key != -1:
330
+ stdscr.nodelay(False)
331
+ return key
332
+ if (time.time() - start_time) >= wait_time:
333
+ return 'slideshow_next'
334
+ time.sleep(0.01)
335
+ else:
336
+ while True:
337
+ key = stdscr.getch()
338
+ if key == ord('s'):
339
+ return 'toggle_slideshow'
340
+ if key == ord('S'):
341
+ return 'toggle_slideshow_reverse'
342
+ if key != -1:
343
+ stdscr.nodelay(False)
344
+ return key
345
+ time.sleep(0.01)
346
+ break
347
+ return key
348
+
349
+
350
+ def main():
351
+ class ExtendedArgumentParser(argparse.ArgumentParser):
352
+ @staticmethod
353
+ def print_extended_help(file=None):
354
+ if file is None:
355
+ file = sys.stderr
356
+ file.write(
357
+ "\n"
358
+ f"Version: {_VERSION_}\n"
359
+ f"Author: {_AUTHOR_} <{_AUTHOR_EMAIL_}>\n"
360
+ f"License: {_LICENSE_}\n"
361
+ f"Webpage: {_WEBPAGE_}\n"
362
+ )
363
+
364
+ def print_help(self, file=None):
365
+ super().print_help(file)
366
+ self.print_extended_help(file)
367
+
368
+ parser = ExtendedArgumentParser(description="Render an image or all images/videos in a directory as Braille cells using ncurses with optional xterm-256 color.")
369
+ parser.add_argument("path", nargs="?", help="Path to the image/video file or directory (optional)")
370
+ parser.add_argument("-S", "--no-sharpen", action="store_true", help="Disable edge sharpening")
371
+ parser.add_argument("-C", "--no-color", action="store_true", help="Disable color (greyscale only with dim/normal/bold)")
372
+ parser.add_argument("-d", "--dither", choices=["ordered", "error", "none"], default="ordered",
373
+ help="Dithering mode: ordered (default, clean), error (Floyd-Steinberg, smooth gradients), none")
374
+
375
+ parser.add_argument("-s", "--slideshow", dest="delay", nargs="?", const=5, type=int, help="Enable slideshow mode with optional integer delay in seconds (default: 5).")
376
+ parser.add_argument("-k", "--seek", type=int, default=10, help="Seek position to extract frame from videos in seconds (default: 10)")
377
+ parser.add_argument("-f", "--format", type=str, choices=["jpeg", "png"], default="jpeg", help="Format for extracted video frames: jpeg (default) or png")
378
+ parser.add_argument("-v", "--version", action="version", version=_VERSION_)
379
+ args = parser.parse_args()
380
+
381
+ if args.delay is not None:
382
+ slideshow = True
383
+ slideshow_delay = args.delay
384
+ else:
385
+ slideshow = False
386
+ slideshow_delay = 5
387
+
388
+ if args.path == '-':
389
+ # Read image from stdin
390
+ from PIL import Image
391
+ import tempfile
392
+ import shutil
393
+ # Read stdin to a temporary file (since PIL.Image.open(sys.stdin.buffer) may not work for all formats)
394
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as tmp:
395
+ shutil.copyfileobj(sys.stdin.buffer, tmp)
396
+ tmp_path = tmp.name
397
+ image_files = [tmp_path]
398
+ idx = 0
399
+ # Redirect stdin file descriptor to /dev/tty so curses reads input from the terminal
400
+ # (removed local import os; using global import)
401
+ try:
402
+ tty_fd = os.open('/dev/tty', os.O_RDWR)
403
+ orig_stdin_fd = os.dup(0)
404
+ os.dup2(tty_fd, 0)
405
+ os.close(tty_fd)
406
+ try:
407
+ curses.wrapper(lambda *a, **kw: render(*a, **kw, single_image_mode=True, wait_time=slideshow_delay, slideshow=slideshow), image_files, idx, not args.no_sharpen, args.dither, not args.no_color)
408
+ finally:
409
+ os.dup2(orig_stdin_fd, 0)
410
+ os.close(orig_stdin_fd)
411
+ finally:
412
+ os.unlink(tmp_path)
413
+ return
414
+ elif args.path:
415
+ # If a directory is passed, render all images/videos in it
416
+ if os.path.isdir(args.path):
417
+ directory = os.path.abspath(args.path)
418
+ image_files = get_image_files(directory, include_videos=args.seek is not None)
419
+ if not image_files:
420
+ print(f"No images or videos found in directory: {directory}", file=sys.stderr)
421
+ sys.exit(1)
422
+ idx = 0
423
+ else:
424
+ video_exts = ['.mp4', '.mkv', '.avi', '.mov', '.webm', '.flv', '.wmv', '.mpeg', '.mpg']
425
+ abs_path = os.path.abspath(args.path)
426
+ directory = os.path.dirname(abs_path) or os.getcwd()
427
+ # Always include videos if the selected file is a video
428
+ include_videos = os.path.splitext(abs_path)[1].lower() in video_exts or args.seek is not None
429
+ image_files = get_image_files(directory, include_videos=include_videos)
430
+ if not image_files:
431
+ print(f"No images or videos found in directory: {directory}", file=sys.stderr)
432
+ sys.exit(1)
433
+ try:
434
+ idx = image_files.index(abs_path)
435
+ except ValueError:
436
+ base = os.path.basename(abs_path)
437
+ idx = next((i for i, f in enumerate(image_files) if os.path.basename(f) == base), 0)
438
+ else:
439
+ # No argument: use current directory
440
+ directory = os.getcwd()
441
+ include_videos = args.seek is not None
442
+ image_files = get_image_files(directory, include_videos=include_videos)
443
+ if not image_files:
444
+ print(f"No images or videos found in current directory.", file=sys.stderr)
445
+ sys.exit(1)
446
+ idx = 0
447
+
448
+
449
+ # Always pass the video_exts and frametime to render for dynamic extraction
450
+ video_exts = ['.mp4', '.mkv', '.avi', '.mov', '.webm', '.flv', '.wmv', '.mpeg', '.mpg']
451
+
452
+ # Pass seek and format to render via function attributes for video frame extraction
453
+ def render_with_video_support(stdscr, image_files, start_idx, sharpen, dither_mode, color, single_image_mode=False, wait_time=5, slideshow=False):
454
+ idx = start_idx
455
+ n = len(image_files)
456
+ # Pass seek/format to render via function attributes
457
+ render._seek = args.seek
458
+ render._format = args.format
459
+ slideshow_active = slideshow
460
+ slideshow_reverse = False
461
+ while True:
462
+ try:
463
+ key = render(stdscr, image_files, idx, sharpen, dither_mode, color, single_image_mode=False, wait_time=wait_time, slideshow=slideshow_active)
464
+ except Exception as e:
465
+ stdscr.clear()
466
+ stdscr.addstr(0, 0, f"Error: {e}")
467
+ stdscr.refresh()
468
+ stdscr.getch()
469
+ return
470
+ # Navigation
471
+ if key == 'toggle_slideshow':
472
+ if slideshow_active and slideshow_reverse:
473
+ # While running in reverse mode, `s` switches back to forward.
474
+ slideshow_reverse = False
475
+ else:
476
+ slideshow_active = not slideshow_active
477
+ if slideshow_active:
478
+ slideshow_reverse = False
479
+ continue
480
+ if key == 'toggle_slideshow_reverse':
481
+ if slideshow_active and not slideshow_reverse:
482
+ # While running in forward mode, `S` switches to reverse.
483
+ slideshow_reverse = True
484
+ else:
485
+ slideshow_active = not slideshow_active
486
+ if slideshow_active:
487
+ slideshow_reverse = True
488
+ continue
489
+ if slideshow_active and key not in ('slideshow_next', -1, 'toggle_slideshow', 'toggle_slideshow_reverse'):
490
+ # Any other key disables slideshow
491
+ slideshow_active = False
492
+ slideshow_reverse = False
493
+ if key == 'slideshow_next':
494
+ if slideshow_reverse:
495
+ idx = (idx - 1) % n
496
+ else:
497
+ idx = (idx + 1) % n
498
+ elif key in (curses.KEY_RIGHT, ord('l'), ord(' ')):
499
+ idx = (idx + 1) % n
500
+ elif key in (curses.KEY_LEFT, ord('h')):
501
+ idx = (idx - 1) % n
502
+ elif key == curses.KEY_UP:
503
+ idx = 0
504
+ elif key == curses.KEY_DOWN:
505
+ idx = n - 1
506
+ elif key in (ord('q'), 27):
507
+ break
508
+
509
+ curses.wrapper(render_with_video_support, image_files, idx, not args.no_sharpen, args.dither, not args.no_color, wait_time=slideshow_delay, slideshow=slideshow)
510
+
511
+
512
+ if __name__ == "__main__":
513
+ main()
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
dotz-0.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
dotz-0.1/setup.py ADDED
@@ -0,0 +1,57 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ from setuptools import setup
5
+
6
+ README = Path(__file__).parent / "README.md"
7
+ DOTZ = Path(__file__).parent / "dotz.py"
8
+
9
+ version = re.search(
10
+ r'^_VERSION_\s*=\s*[\"\']([^\"\']+)[\"\']',
11
+ DOTZ.read_text(encoding="utf-8"),
12
+ re.MULTILINE,
13
+ ).group(1)
14
+
15
+ setup(
16
+ name="dotz",
17
+ version=version,
18
+ description="Render images and video previews as Braille art in the terminal with color and animation support.",
19
+ long_description=README.read_text(encoding="utf-8"),
20
+ long_description_content_type="text/markdown",
21
+ author="Arun Prakash Jana",
22
+ license="MIT",
23
+ url="https://github.com/jarun/dotz",
24
+ project_urls={
25
+ "Homepage": "https://github.com/jarun/dotz",
26
+ "Repository": "https://github.com/jarun/dotz",
27
+ "Issues": "https://github.com/jarun/dotz/issues",
28
+ },
29
+ py_modules=["dotz"],
30
+ install_requires=[
31
+ "numpy>=1.20",
32
+ "Pillow>=8.0",
33
+ ],
34
+ entry_points={
35
+ "console_scripts": [
36
+ "dotz=dotz:main",
37
+ ],
38
+ },
39
+ python_requires=">=3.7",
40
+ classifiers=[
41
+ "Development Status :: 4 - Beta",
42
+ "Environment :: Console :: Curses",
43
+ "Intended Audience :: End Users/Desktop",
44
+ "Operating System :: POSIX :: Linux",
45
+ "Programming Language :: Python :: 3",
46
+ "Programming Language :: Python :: 3 :: Only",
47
+ "Programming Language :: Python :: 3.7",
48
+ "Programming Language :: Python :: 3.8",
49
+ "Programming Language :: Python :: 3.9",
50
+ "Programming Language :: Python :: 3.10",
51
+ "Programming Language :: Python :: 3.11",
52
+ "Programming Language :: Python :: 3.12",
53
+ "Topic :: Multimedia :: Graphics",
54
+ "Topic :: Terminals",
55
+ ],
56
+ keywords="terminal image-viewer braille curses gif video ffmpeg",
57
+ )