├── toascii ├── converters │ ├── extensions │ │ ├── nim.cfg │ │ ├── extension_utils.py │ │ ├── grayscale_converter_nim.py │ │ ├── unsupported_extension.py │ │ ├── html_color_converter_nim.py │ │ ├── color_converter_nim.py │ │ ├── grayscale_converter.nim │ │ ├── nimpy_numpy.nim │ │ ├── html_color_converter.nim │ │ ├── color_converter.nim │ │ └── converter_utils.nim │ ├── options.py │ ├── grayscale_converter.py │ ├── __init__.py │ ├── html_color_converter.py │ ├── base_converter.py │ └── color_converter.py ├── gradients.py ├── __init__.py ├── image.py ├── cli.py ├── video.py └── media_source.py ├── .gitignore ├── examples ├── image.py ├── video.py ├── live_video.py └── live_video_nim.py ├── pyproject.toml ├── LICENSE ├── .github └── workflows │ └── checks.yml ├── README.md └── poetry.lock /toascii/converters/extensions/nim.cfg: -------------------------------------------------------------------------------- 1 | -d:danger 2 | --opt:speed 3 | --passC="-O3" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | hentai_test.mp4 2 | __pycache__/ 3 | nim-extensions/ 4 | MANIFEST.in 5 | dist/ 6 | .venv/ 7 | .idea/ 8 | .ruff_cache/ 9 | -------------------------------------------------------------------------------- /toascii/gradients.py: -------------------------------------------------------------------------------- 1 | """Some example ASCII gradients which tend to work pretty well""" 2 | 3 | BLOCK = " ░▒▓█" 4 | HIGH = " `-~+#@" 5 | LOW = " ¨'³•µðEÆ" 6 | OXXO = " .-=+*#%@" 7 | -------------------------------------------------------------------------------- /toascii/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | 3 | import importlib.metadata 4 | 5 | from . import gradients 6 | from .converters import * # noqa: F403 7 | from .image import Image 8 | from .video import FrameClearStrategy, Video 9 | 10 | __version__ = importlib.metadata.version("to-ascii") 11 | -------------------------------------------------------------------------------- /examples/image.py: -------------------------------------------------------------------------------- 1 | from toascii import ConverterOptions, GrayscaleConverter, Image, gradients 2 | 3 | options = ConverterOptions(gradient=gradients.BLOCK, width=32, y_stretch=0.5, saturation=0.25) 4 | converter = GrayscaleConverter(options) 5 | image_path = "some_image.png" 6 | image = Image(image_path, converter) 7 | image.view() 8 | -------------------------------------------------------------------------------- /examples/video.py: -------------------------------------------------------------------------------- 1 | from toascii import ColorConverter, ConverterOptions, Video, gradients 2 | 3 | options = ConverterOptions(gradient=gradients.HIGH, width=32, y_stretch=0.5, saturation=0.25) 4 | converter = ColorConverter(options) 5 | image_path = "some_video.mp4" 6 | image = Video(image_path, converter, loop=True) 7 | image.view() 8 | -------------------------------------------------------------------------------- /examples/live_video.py: -------------------------------------------------------------------------------- 1 | from toascii import ColorConverter, ConverterOptions, Video, gradients 2 | 3 | options = ConverterOptions( 4 | gradient=gradients.LOW, height=56, x_stretch=4, saturation=0.5, contrast=0.01 5 | ) 6 | converter = ColorConverter(options) 7 | camera_id = 0 8 | video = Video(camera_id, converter) 9 | video.view() 10 | -------------------------------------------------------------------------------- /examples/live_video_nim.py: -------------------------------------------------------------------------------- 1 | from toascii import ColorConverterNim, ConverterOptions, Video, gradients 2 | 3 | options = ConverterOptions( 4 | gradient=gradients.LOW, height=56, x_stretch=4, saturation=0.5, contrast=0.01 5 | ) 6 | converter = ColorConverterNim(options) 7 | camera_id = 0 8 | video = Video(camera_id, converter) 9 | video.view() 10 | -------------------------------------------------------------------------------- /toascii/converters/extensions/extension_utils.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import nimporter 4 | 5 | _did_build_extensions = False 6 | 7 | 8 | def build_extensions() -> None: 9 | global _did_build_extensions 10 | if not _did_build_extensions: 11 | nimporter.build_nim_extensions(pathlib.Path(__file__).parent.resolve()) 12 | _did_build_extensions = True 13 | -------------------------------------------------------------------------------- /toascii/converters/extensions/grayscale_converter_nim.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | import numpy as np 4 | 5 | from ..grayscale_converter import GrayscaleConverter 6 | from .extension_utils import build_extensions 7 | 8 | build_extensions() 9 | 10 | from . import grayscale_converter # noqa 11 | 12 | 13 | class GrayscaleConverterNim(GrayscaleConverter): 14 | def _asciify_image(self, image: np.ndarray) -> Generator[str, None, None]: 15 | yield grayscale_converter.asciifyImage(image, list(self.options.gradient)) 16 | -------------------------------------------------------------------------------- /toascii/converters/options.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | class ConverterOptions(BaseModel): 7 | gradient: str = Field(min_length=1) 8 | width: Optional[int] = Field(default=None, gt=0) 9 | height: Optional[int] = Field(default=None, gt=0) 10 | x_stretch: float = Field(default=1.0, gt=0) 11 | y_stretch: float = Field(default=1.0, gt=0) 12 | saturation: float = Field(default=0.5, ge=-1, le=1) 13 | contrast: Optional[float] = Field(default=None, ge=0, le=1) 14 | blur: Optional[int] = Field(default=None, ge=1) 15 | -------------------------------------------------------------------------------- /toascii/converters/extensions/unsupported_extension.py: -------------------------------------------------------------------------------- 1 | class UnsupportedExtensionException(RuntimeError): 2 | def __init__(self, ext_name: str, from_exc: Exception): 3 | super().__init__(f"{ext_name} can not be initialized.") 4 | 5 | self.ext_name = ext_name 6 | self.from_exc = from_exc 7 | 8 | 9 | def unsupported_extension(ext_name: str, exception: Exception): 10 | class _UnsupportedExtension: 11 | def __init__(self, *args, **kwargs): 12 | raise UnsupportedExtensionException(ext_name, exception) from exception 13 | 14 | return _UnsupportedExtension 15 | -------------------------------------------------------------------------------- /toascii/converters/extensions/html_color_converter_nim.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | import numpy as np 4 | 5 | from ..html_color_converter import HtmlColorConverter 6 | from .extension_utils import build_extensions 7 | 8 | build_extensions() 9 | 10 | from . import html_color_converter # noqa 11 | 12 | 13 | class HtmlColorConverterNim(HtmlColorConverter): 14 | def _asciify_image(self, image: np.ndarray) -> Generator[str, None, None]: 15 | image = self._contrast(image) 16 | yield html_color_converter.asciifyImage( 17 | image, list(self.options.gradient), self.options.saturation 18 | ) 19 | -------------------------------------------------------------------------------- /toascii/image.py: -------------------------------------------------------------------------------- 1 | from .converters import BaseConverter 2 | from .media_source import IMAGE_SOURCE, load_image 3 | 4 | 5 | class Image: 6 | def __init__(self, source: IMAGE_SOURCE, converter: BaseConverter): 7 | self.source = source 8 | self.converter = converter 9 | self.options = converter.options 10 | 11 | def to_ascii(self) -> str: 12 | image = load_image(self.source) 13 | 14 | if image is None: 15 | raise ValueError("Invalid image source provided") 16 | 17 | return self.converter.asciify_image(self.converter.apply_opencv_fx(image)) 18 | 19 | def view(self) -> None: 20 | print(self.to_ascii()) 21 | -------------------------------------------------------------------------------- /toascii/converters/extensions/color_converter_nim.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | import numpy as np 4 | 5 | from ..color_converter import RGB_TO_ASCII_CODE, ColorConverter 6 | from .extension_utils import build_extensions 7 | 8 | build_extensions() 9 | 10 | from . import color_converter # noqa 11 | 12 | color_converter.setRgbValuesMap(list(RGB_TO_ASCII_CODE.items())) 13 | 14 | 15 | class ColorConverterNim(ColorConverter): 16 | def _asciify_image(self, image: np.ndarray) -> Generator[str, None, None]: 17 | image = self._contrast(image) 18 | yield color_converter.asciifyImage( 19 | image, list(self.options.gradient), self.options.saturation 20 | ) 21 | -------------------------------------------------------------------------------- /toascii/converters/extensions/grayscale_converter.nim: -------------------------------------------------------------------------------- 1 | import nimpy 2 | import nimpy/[raw_buffers] 3 | 4 | import nimpy_numpy 5 | import converter_utils 6 | 7 | 8 | proc asciifyImage(imgPyO: PyObject, gradient: openArray[string]): string {.exportpy.} = 9 | let gradientLen = gradient.len.float 10 | result = "" 11 | 12 | var imgBuf: RawPyBuffer 13 | imgPyo.getBuffer(imgBuf, PyBUF_WRITABLE or PyBuf_ND) 14 | defer: imgBuf.release() 15 | 16 | for rowIdx in 0 .. imgBuf.dimShape(0) - 1: 17 | for colIdx in 0 .. imgBuf.dimShape(1) - 1: 18 | let color = imgBuf[rowIdx, colIdx] 19 | 20 | result &= gradient[int((luminosity(color) / 255) * gradientLen)] 21 | 22 | result &= "\n" 23 | -------------------------------------------------------------------------------- /toascii/converters/grayscale_converter.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | import numpy as np 4 | 5 | from .base_converter import BaseConverter 6 | 7 | 8 | class GrayscaleConverter(BaseConverter): 9 | @staticmethod 10 | def _luminosity(r: int, g: int, b: int) -> float: 11 | return 0.2126 * r + 0.7152 * g + 0.0722 * b 12 | 13 | def _asciify_image(self, image: np.ndarray) -> Generator[str, None, None]: 14 | g_l_m = len(self.options.gradient) - 1 15 | 16 | row: np.ndarray 17 | for row in image: 18 | for b, g, r in row: 19 | lumination = self._luminosity(r, g, b) 20 | yield self.options.gradient[int((lumination / 255) * g_l_m)] 21 | 22 | yield "\n" 23 | 24 | def asciify_image(self, image: np.ndarray) -> str: 25 | return "".join(self._asciify_image(image)) 26 | -------------------------------------------------------------------------------- /toascii/converters/extensions/nimpy_numpy.nim: -------------------------------------------------------------------------------- 1 | import nimpy/[raw_buffers] 2 | 3 | proc `+`[T](p: ptr T, val: int) : ptr T {.inline.} = 4 | cast[ptr T](cast[uint](p) + cast[uint](val * sizeof(T))) 5 | 6 | proc dimShape*(imgBuf: RawPyBuffer, dim: int): uint32 {.inline.} = 7 | return (imgBuf.shape + dim)[].uint32 8 | 9 | # [] operator for a 3d numpy array 10 | proc `[]`*(imgBuf: RawPyBuffer, y: uint32, x: uint32, z: uint32): uint8 {.inline.} = 11 | let 12 | arr = cast[ptr UncheckedArray[uint8]](imgBuf.buf) 13 | xMax = imgBuf.dimShape(2) 14 | zMax = imgBuf.dimShape(1) 15 | 16 | return arr[y * xMax * zMax + x * xMax + z] 17 | 18 | # []= operator for a 3d numpy array 19 | proc `[]=`*(imgBuf: RawPyBuffer, y: uint32, x: uint32, z: uint32, v: uint8) {.inline.} = 20 | let 21 | arr = cast[ptr UncheckedArray[uint8]](imgBuf.buf) 22 | xMax = imgBuf.dimShape(2) 23 | zMax = imgBuf.dimShape(1) 24 | 25 | arr[y * xMax * zMax + x * xMax + z] = v 26 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "to-ascii" 3 | version = "6.1.0" 4 | description = "Convert videos, images, gifs, and even live video to ASCII art!" 5 | authors = ["Milo Weinberg "] 6 | license = "MIT" 7 | packages = [ 8 | { include = "toascii" } 9 | ] 10 | readme = "README.md" 11 | repository = "https://github.com/Iapetus-11/To-ASCII" 12 | keywords = ["ascii", "color", "colors", "ascii-art", "video", "image", "nim"] 13 | 14 | [tool.poetry.dependencies] 15 | python = ">=3.9,<4.0" 16 | numpy = "2.0" 17 | opencv-python = "^4.10.0.84" 18 | colorama = "^0.4.6" 19 | nimporter = { version = "^1.1.0", optional = true} 20 | pydantic = "^1.9.1" 21 | click = "^8.1.8" 22 | 23 | [tool.poetry.extras] 24 | speedups = ["nimporter"] 25 | cli = ["click"] 26 | 27 | [tool.poetry.scripts] 28 | toascii = "toascii.cli:toascii_command" 29 | 30 | [tool.poetry.group.dev.dependencies] 31 | ruff = "^0.9.1" 32 | 33 | [build-system] 34 | requires = ["poetry-core>=1.0.0"] 35 | build-backend = "poetry.core.masonry.api" 36 | 37 | [tool.ruff] 38 | line-length = 100 39 | target-version = "py39" 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Milo Weinberg 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. 22 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Run Checks 2 | 3 | on: [ push, pull_request, workflow_dispatch ] 4 | 5 | jobs: 6 | checks: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - name: Install Poetry 22 | run: curl -sSL https://install.python-poetry.org | python3 - 23 | 24 | - name: Configure Poetry 25 | run: poetry config virtualenvs.in-project true 26 | 27 | - name: Load cached venv 28 | id: cached-poetry-dependencies 29 | uses: actions/cache@v4 30 | with: 31 | path: .venv 32 | key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} 33 | 34 | - name: Install dependencies 35 | run: poetry install 36 | 37 | - name: Run Ruff linter 38 | run: poetry run ruff check . --no-fix 39 | 40 | - name: Run Ruff formatter 41 | run: poetry run ruff format . --check 42 | -------------------------------------------------------------------------------- /toascii/converters/__init__.py: -------------------------------------------------------------------------------- 1 | from .base_converter import BaseConverter 2 | from .color_converter import ColorConverter 3 | from .extensions.unsupported_extension import unsupported_extension 4 | from .grayscale_converter import GrayscaleConverter 5 | from .html_color_converter import HtmlColorConverter 6 | from .options import ConverterOptions 7 | 8 | try: 9 | from .extensions.color_converter_nim import ColorConverterNim 10 | except Exception as e: 11 | ColorConverterNim = unsupported_extension("color_converter_nim.ColorConverterNim", e) 12 | 13 | try: 14 | from .extensions.grayscale_converter_nim import GrayscaleConverterNim 15 | except Exception as e: 16 | GrayscaleConverterNim = unsupported_extension( 17 | "grayscale_converter_nim.GrayscaleConverterNim", e 18 | ) 19 | 20 | try: 21 | from .extensions.html_color_converter_nim import HtmlColorConverterNim 22 | except Exception as e: 23 | HtmlColorConverterNim = unsupported_extension( 24 | "html_color_converter_nim.HtmlColorConverterNim", e 25 | ) 26 | 27 | __all__ = ( 28 | "BaseConverter", 29 | "ColorConverter", 30 | "GrayscaleConverter", 31 | "HtmlColorConverter", 32 | "ConverterOptions", 33 | "ColorConverterNim", 34 | "GrayscaleConverterNim", 35 | "HtmlColorConverterNim", 36 | ) 37 | -------------------------------------------------------------------------------- /toascii/converters/html_color_converter.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | import numpy as np 4 | 5 | from .color_converter import ColorConverter 6 | 7 | 8 | class HtmlColorConverter(ColorConverter): 9 | def _asciify_image(self, image: np.ndarray) -> Generator[str, None, None]: 10 | image = self._contrast(image) 11 | g_l_m = len(self.options.gradient) - 1 12 | 13 | last_color = None 14 | 15 | row: np.ndarray 16 | for row in image: 17 | for b, g, r in row: 18 | color = self._saturate((r, g, b), self.options.saturation) 19 | lum = self._luminosity(r, g, b) 20 | char = self.options.gradient[int((lum / 255) * g_l_m)] 21 | 22 | if color != last_color: 23 | if last_color is not None: 24 | yield "" 25 | 26 | last_color = color 27 | yield f"""""" 28 | 29 | yield char 30 | 31 | yield "
" 32 | 33 | yield "
" 34 | 35 | def asciify_image(self, image: np.ndarray) -> str: 36 | return f'
{"".join(self._asciify_image(image))}
' 37 | -------------------------------------------------------------------------------- /toascii/converters/extensions/html_color_converter.nim: -------------------------------------------------------------------------------- 1 | import nimpy 2 | import nimpy/[raw_buffers] 3 | 4 | import nimpy_numpy 5 | import converter_utils 6 | 7 | proc colorToCss(c: Color): string {.inline.} = 8 | return "rgb(" & $c.r & "," & $c.g & "," & $c.b & ")" 9 | 10 | proc asciifyImage(imgPyo: PyObject, gradient: openArray[string], saturation: float): string {.exportpy.} = 11 | var saturation = saturation 12 | if saturation > 1: saturation = 1 13 | elif saturation < -1: saturation = -1 14 | 15 | result = "" 16 | let gradientLen = gradient.len.float 17 | var lastColor = "" 18 | 19 | var imgBuf: RawPyBuffer 20 | imgPyo.getBuffer(imgBuf, PyBUF_WRITABLE or PyBuf_ND) 21 | defer: imgBuf.release() 22 | 23 | for rowIdx in 0 .. imgBuf.dimShape(0) - 1: 24 | for colIdx in 0 .. imgBuf.dimShape(1) - 1: 25 | let color = saturate(imgBuf[rowIdx, colIdx], saturation) 26 | let gChar = gradient[int((luminosity(color) / 255) * gradientLen)] 27 | let cssColor = colorToCss(color) 28 | 29 | if cssColor != lastColor: 30 | if lastColor != "": 31 | result &= "" 32 | 33 | lastColor = cssColor 34 | result &= "" 35 | 36 | result &= gChar 37 | 38 | result &= "
" 39 | -------------------------------------------------------------------------------- /toascii/converters/extensions/color_converter.nim: -------------------------------------------------------------------------------- 1 | import std/[tables, math] 2 | 3 | import nimpy 4 | import nimpy/[raw_buffers] 5 | 6 | import nimpy_numpy 7 | import converter_utils 8 | 9 | const COLOR_TRUNC = 128 10 | 11 | proc truncColor(c: Color): Color {.inline.} = 12 | return (c.r div COLOR_TRUNC, c.g div COLOR_TRUNC, c.b div COLOR_TRUNC) 13 | 14 | var RGB_TO_ASCII_CODE = initTable[Color, string]() 15 | proc setRgbValuesMap(vals: seq[tuple[k: Color, v: string]]) {.exportpy.} = 16 | for p in vals: 17 | RGB_TO_ASCII_CODE[p.k] = p.v 18 | 19 | proc colorAprox(c: Color): string {.inline.} = 20 | return RGB_TO_ASCII_CODE[truncColor(c)] 21 | 22 | proc asciifyImage(imgPyo: PyObject, gradient: openArray[string], saturation: float): string {.exportpy.} = 23 | var saturation = saturation 24 | if saturation > 1: saturation = 1 25 | elif saturation < -1: saturation = -1 26 | 27 | result = "" 28 | let gradientLen = gradient.len.float 29 | var lastColoramaCode = "-1" 30 | 31 | var imgBuf: RawPyBuffer 32 | imgPyo.getBuffer(imgBuf, PyBUF_WRITABLE or PyBuf_ND) 33 | defer: imgBuf.release() 34 | 35 | for rowIdx in 0 .. imgBuf.dimShape(0) - 1: 36 | for colIdx in 0 .. imgBuf.dimShape(1) - 1: 37 | let color = saturate(imgBuf[rowIdx, colIdx], saturation) 38 | 39 | let coloramaCode = colorAprox(color) 40 | 41 | if coloramaCode != lastColoramaCode: 42 | lastColoramaCode = coloramaCode 43 | result &= coloramaCode 44 | 45 | result &= gradient[int((luminosity(color) / 255) * gradientLen)] 46 | 47 | result &= "\n" 48 | -------------------------------------------------------------------------------- /toascii/converters/base_converter.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional, Tuple 3 | 4 | import cv2 5 | import numpy as np 6 | 7 | from .options import ConverterOptions 8 | 9 | 10 | class BaseConverter(ABC): 11 | def __init__(self, options: ConverterOptions): 12 | self.options = options 13 | 14 | @abstractmethod 15 | def asciify_image(self, image: np.ndarray) -> str: 16 | """Takes a 3D numpy array containing the pixels of an image and converts it to a str""" 17 | 18 | raise NotImplementedError 19 | 20 | def calculate_dimensions(self, initial_width: int, initial_height: int) -> Tuple[int, int]: 21 | width = self.options.width 22 | height = self.options.height 23 | 24 | # keep ratio based off w 25 | if width and not height: 26 | height = initial_height / (initial_width / width) 27 | elif height and not width: 28 | width = initial_width / (initial_height / height) 29 | elif not (height or width): 30 | width = initial_width 31 | height = initial_height 32 | 33 | width *= self.options.x_stretch 34 | height *= self.options.y_stretch 35 | 36 | return (int(width), int(height)) 37 | 38 | def apply_opencv_fx( 39 | self, image: np.ndarray, *, resize_dims: Optional[Tuple[int, int]] = None 40 | ) -> np.ndarray: 41 | if resize_dims is None: 42 | resize_dims = self.calculate_dimensions(*image.shape[:2]) 43 | 44 | if self.options.blur is not None: 45 | image = cv2.blur(image, (self.options.blur, self.options.blur)) 46 | 47 | return cv2.resize(image, resize_dims) 48 | -------------------------------------------------------------------------------- /toascii/converters/extensions/converter_utils.nim: -------------------------------------------------------------------------------- 1 | import nimpy/[raw_buffers] 2 | 3 | import nimpy_numpy 4 | 5 | type 6 | Color* = tuple[r, g, b: uint8] 7 | HslColor* = tuple[h, s, l: float] 8 | 9 | proc `[]`*(imgBuf: RawPyBuffer, y: uint32, x: uint32): Color {.inline.} = 10 | return (imgBuf[y, x, 2], imgBuf[y, x, 1], imgBuf[y, x, 0]) 11 | 12 | proc `[]=`*(imgBuf: RawPyBuffer, y: uint32, x: uint32, v: Color) {.inline.} = 13 | imgBuf[y, x, 0] = v.b 14 | imgBuf[y, x, 1] = v.g 15 | imgBuf[y, x, 2] = v.r 16 | 17 | proc luminosity*(c: Color): float {.inline.} = 18 | return 0.2126 * c.r.float + 0.7152 * c.g.float + 0.0722 * c.b.float 19 | 20 | proc rgb2hsl*(c: Color): HslColor {.inline.} = 21 | let 22 | r = c.r.float / 255.0 23 | g = c.g.float / 255.0 24 | b = c.b.float / 255.0 25 | cMin = min(r, min(g, b)) 26 | cMax = max(r, max(g, b)) 27 | delta = cMax - cMin 28 | 29 | var h, s, l: float = 0.0 30 | 31 | if delta == 0.0: h = 0.0 32 | elif cMax == r: h = ((g - b) / delta) mod 6.0 33 | elif cMax == g: h = ((b - r) / delta) + 2.0 34 | else: h = ((r - g) / delta) + 4.0 35 | 36 | h = round(h * 60.0) 37 | 38 | if (h < 0.0): h += 360.0 39 | 40 | l = (cMax + cMin) / 2.0 41 | 42 | if delta == 0.0: s = 0.0 43 | else: s = delta / (1 - abs(2.0 * l - 1.0)) 44 | 45 | s *= 100.0 46 | l *= 100.0 47 | 48 | return (h, s, l) 49 | 50 | proc hsl2rgb*(c: HslColor): Color {.inline.} = 51 | let 52 | h = c.h 53 | s = c.s / 100.0 54 | l = c.l / 100.0 55 | 56 | var 57 | r, g, b: float = 0.0 58 | c = (1.0 - abs(2 * l - 1.0)) * s 59 | x = c * (1.0 - abs((h / 60.0) mod 2.0 - 1.0)) 60 | m = l - c / 2.0 61 | 62 | if (0 <= h and h < 60): 63 | r = c 64 | g = x 65 | b = 0 66 | elif (60 <= h and h < 120): 67 | r = x 68 | g = c 69 | b = 0 70 | elif (120 <= h and h < 180): 71 | r = 0 72 | g = c 73 | b = x 74 | elif (180 <= h and h < 240): 75 | r = 0 76 | g = x 77 | b = c 78 | elif (240 <= h and h < 300): 79 | r = x 80 | g = 0 81 | b = c 82 | elif (300 <= h and h < 360): 83 | r = c 84 | g = 0 85 | b = x 86 | 87 | r = round((r + m) * 255) 88 | g = round((g + m) * 255) 89 | b = round((b + m) * 255) 90 | 91 | return (r.uint8, g.uint8, b.uint8) 92 | 93 | proc saturate*(c: Color, saturation: float): Color {.inline.} = 94 | var hsl = rgb2hsl(c) 95 | 96 | if saturation >= 0: 97 | let gray_factor = hsl.s / 100.0 98 | let var_interval = 100.0 - hsl.s 99 | hsl.s = hsl.s + saturation * var_interval * gray_factor 100 | else: 101 | hsl.s = hsl.s + saturation * hsl.s 102 | 103 | return hsl2rgb(hsl) 104 | -------------------------------------------------------------------------------- /toascii/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import typing as t 3 | 4 | import click 5 | 6 | from toascii import ConverterOptions, FrameClearStrategy 7 | from toascii import Image as AsciiImage 8 | from toascii import Video as AsciiVideo 9 | from toascii import converters, gradients 10 | 11 | CONVERTER_TYPE_OPTIONS = { 12 | c.__name__.lower(): c 13 | for c in map(lambda a: getattr(converters, a), dir(converters)) 14 | if ( 15 | isinstance(c, type) 16 | and issubclass(c, converters.BaseConverter) 17 | and c is not converters.BaseConverter 18 | ) 19 | } 20 | 21 | GRADIENTS = { 22 | "block": gradients.BLOCK, 23 | "low": gradients.LOW, 24 | "high": gradients.HIGH, 25 | "oxxo": gradients.OXXO, 26 | } 27 | 28 | 29 | def cb_source(ctx: click.Context, param: click.Parameter, value: str) -> t.Union[int, str]: 30 | try: 31 | return int(value) 32 | except ValueError: 33 | return value 34 | 35 | 36 | def cb_converter( 37 | ctx: click.Context, param: click.Parameter, value: str 38 | ) -> converters.BaseConverter: 39 | return CONVERTER_TYPE_OPTIONS[value] 40 | 41 | 42 | def cb_gradient(ctx: click.Context, param: click.Parameter, value: str) -> str: 43 | return GRADIENTS.get(value, value) 44 | 45 | 46 | @click.command(name="toascii", no_args_is_help=True) 47 | @click.argument("media_type", type=click.Choice(["image", "video"], case_sensitive=False)) 48 | @click.argument("source", type=click.STRING, callback=cb_source) 49 | @click.argument( 50 | "converter", 51 | type=click.Choice(list(CONVERTER_TYPE_OPTIONS), case_sensitive=False), 52 | callback=cb_converter, 53 | ) 54 | @click.option("--gradient", "-g", type=click.STRING, callback=cb_gradient, default=gradients.LOW) 55 | @click.option("--width", "-w", type=click.IntRange(min=1)) 56 | @click.option("--height", "-h", type=click.IntRange(min=1)) 57 | @click.option( 58 | "--x-stretch", "--xstretch", type=click.FloatRange(min=0.0, min_open=True), default=1.0 59 | ) 60 | @click.option( 61 | "--y-stretch", "--ystretch", type=click.FloatRange(min=0.0, min_open=True), default=1.0 62 | ) 63 | @click.option("--saturation", type=click.FloatRange(min=-1.0, max=1.0), default=0.5) 64 | @click.option("--contrast", type=click.FloatRange(min=0.0, max=1.0)) 65 | @click.option("--blur", type=click.IntRange(min=2)) 66 | @click.option("--loop", is_flag=True) 67 | def toascii_command(**kwargs): 68 | if not kwargs.get("height"): 69 | kwargs["height"] = max(min(os.get_terminal_size().lines - 1, 32), 4) 70 | 71 | media_type: t.Literal["image", "video"] = kwargs.pop("media_type") 72 | 73 | if media_type == "video": 74 | kwargs["frame_clear_strategy"] = FrameClearStrategy.ANSI_ERASE_IN_LINE 75 | else: 76 | del kwargs["loop"] 77 | 78 | converter_options = ConverterOptions( 79 | **{ 80 | k: kwargs.pop(k) 81 | for k in list(kwargs) 82 | if k in ConverterOptions.schema()["properties"].keys() 83 | } 84 | ) 85 | 86 | kwargs["converter"] = kwargs["converter"](converter_options) 87 | 88 | cls = {"image": AsciiImage, "video": AsciiVideo}[media_type] 89 | cls_instance: t.Union[AsciiImage, AsciiVideo] = cls(**kwargs) 90 | 91 | cls_instance.view() 92 | -------------------------------------------------------------------------------- /toascii/video.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import os 3 | import time 4 | from typing import Generator, Optional, Tuple, Union 5 | 6 | from .converters import BaseConverter 7 | from .media_source import VIDEO_SOURCE, AbstractVideoSource, OpenCVVideoSource 8 | 9 | 10 | class FrameClearStrategy(enum.Enum): 11 | NONE = enum.auto() 12 | DOUBLE_LINE_BREAK = enum.auto() 13 | TERMINAL_HEIGHT_LINE_BREAKS = enum.auto() 14 | ANSI_ERASE_IN_LINE = enum.auto() 15 | ANSI_ERASE_IN_DISPLAY = enum.auto() 16 | ANSI_CURSOR_POS = enum.auto() 17 | 18 | 19 | class Video: 20 | def __init__( 21 | self, 22 | source: Union[VIDEO_SOURCE, AbstractVideoSource], 23 | converter: BaseConverter, 24 | *, 25 | fps: Optional[float] = None, 26 | loop: bool = False, 27 | frame_clear_strategy: FrameClearStrategy = FrameClearStrategy.ANSI_ERASE_IN_DISPLAY, 28 | ): 29 | self.source = ( 30 | source if isinstance(source, AbstractVideoSource) else OpenCVVideoSource(source) 31 | ) 32 | self.converter = converter 33 | self.options = converter.options 34 | self.fps = fps 35 | self.loop = loop 36 | self.frame_clear_strategy = frame_clear_strategy 37 | 38 | def _get_ascii_frames(self, video: AbstractVideoSource) -> Generator[str, None, None]: 39 | resize_dims = self.converter.calculate_dimensions(video.width, video.height) 40 | 41 | for frame in video: 42 | yield self.converter.asciify_image( 43 | self.converter.apply_opencv_fx(frame, resize_dims=resize_dims) 44 | ) 45 | 46 | def get_ascii_frames(self) -> Generator[str, None, None]: 47 | with self.source as video: 48 | video.ensure_valid() 49 | yield from self._get_ascii_frames(video) 50 | 51 | def _get_print_affixes(self, video: AbstractVideoSource) -> Tuple[str, str]: 52 | print_prefix = "" 53 | print_suffix = "" 54 | 55 | if self.frame_clear_strategy is FrameClearStrategy.DOUBLE_LINE_BREAK: 56 | print_prefix = "\n\n" 57 | print_suffix = "\n" 58 | elif self.frame_clear_strategy is FrameClearStrategy.TERMINAL_HEIGHT_LINE_BREAKS: 59 | print_prefix = ( 60 | "\n" * (os.get_terminal_size().lines - (self.options.height or video.height)) 61 | ) + "\r" 62 | print_suffix = "\r" 63 | elif self.frame_clear_strategy is FrameClearStrategy.ANSI_ERASE_IN_LINE: 64 | print_prefix = f"\033[{video.height}A\033[2K" 65 | elif self.frame_clear_strategy is FrameClearStrategy.ANSI_ERASE_IN_DISPLAY: 66 | print_prefix = "\033[2J" 67 | elif self.frame_clear_strategy is FrameClearStrategy.ANSI_CURSOR_POS: 68 | print_prefix = "\033[H" 69 | 70 | return print_prefix, print_suffix 71 | 72 | def view(self) -> None: 73 | with self.source as video: 74 | video.ensure_valid() 75 | 76 | frames = self._get_ascii_frames(video) 77 | 78 | # if video isn't live we should pre-gen frames for optimal viewing 79 | if not video.is_live: 80 | genned_frames = [] 81 | 82 | for i, frame in enumerate(frames, start=1): 83 | genned_frames.append(frame) 84 | print(f"Generating frames... ({i}/{video.frame_count})", end="\r") 85 | 86 | frames = genned_frames 87 | 88 | seconds_per_frame = 1 / (self.fps if self.fps else video.fps) 89 | 90 | print_prefix, print_suffix = self._get_print_affixes(video) 91 | 92 | def _view(): 93 | start = time.time() 94 | 95 | for frame in frames: 96 | print(print_prefix + frame, end=print_suffix) 97 | time.sleep(seconds_per_frame - (start - time.time())) 98 | start = time.time() 99 | 100 | _view() 101 | while self.loop: 102 | _view() 103 | -------------------------------------------------------------------------------- /toascii/media_source.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import io 3 | import pathlib 4 | import tempfile 5 | from typing import Any, Optional, Type, Union 6 | 7 | import cv2 8 | import numpy 9 | import numpy as np 10 | import typing_extensions 11 | 12 | # type aliases 13 | IMAGE_SOURCE = Union[str, bytes, io.IOBase] 14 | VIDEO_SOURCE = Union[IMAGE_SOURCE, int] 15 | 16 | # used for isinstance/subclass checks because np.ndarray: 22 | if not isinstance(source, T_IMAGE_SOURCE): 23 | raise TypeError(f"{source!r} is not an instance of bytes or IOBase.") 24 | 25 | # attempt to load an image from a file, where src is the path 26 | if isinstance(source, str): 27 | if not pathlib.Path(source).exists(): 28 | raise FileNotFoundError(source) 29 | 30 | return cv2.imread(source, cv2.IMREAD_COLOR) 31 | 32 | # attempt to load image from provided bytes / io 33 | data: bytes 34 | if isinstance(source, io.IOBase): 35 | data = source.read() 36 | else: 37 | data = source 38 | 39 | np_data = np.frombuffer(data, dtype=np.uint8) 40 | return cv2.imdecode(np_data, cv2.IMREAD_COLOR) 41 | 42 | 43 | class AbstractVideoSource(abc.ABC): 44 | def ensure_valid(self) -> None: 45 | if self.fps < 1 or self.height < 1 or self.width < 1: 46 | raise Exception("Invalid video source provided") 47 | 48 | @property 49 | @abc.abstractmethod 50 | def is_live(self) -> bool: ... 51 | 52 | @property 53 | @abc.abstractmethod 54 | def fps(self) -> float: ... 55 | 56 | @property 57 | @abc.abstractmethod 58 | def width(self) -> int: ... 59 | 60 | @property 61 | @abc.abstractmethod 62 | def height(self) -> int: ... 63 | 64 | @property 65 | @abc.abstractmethod 66 | def frame_count(self) -> int: ... 67 | 68 | def __iter__(self) -> typing_extensions.Self: 69 | return self 70 | 71 | @abc.abstractmethod 72 | def __next__(self) -> numpy.ndarray: ... 73 | 74 | @abc.abstractmethod 75 | def __enter__(self) -> typing_extensions.Self: ... 76 | 77 | @abc.abstractmethod 78 | def __exit__( 79 | self, exc_type: Type[BaseException], exc_val: BaseException, exc_tb: Any 80 | ) -> None: ... 81 | 82 | 83 | class OpenCVVideoSource(AbstractVideoSource): 84 | def __init__(self, source: VIDEO_SOURCE): 85 | if not isinstance(source, T_VIDEO_SOURCE): 86 | raise TypeError(f"{source!r} is not an instance of int, bytes, or IOBase.") 87 | 88 | self.source: Optional[VIDEO_SOURCE] = source 89 | 90 | self._video_cap: Optional[cv2.VideoCapture] = None 91 | self._temp_file: Optional[tempfile.NamedTemporaryFile] = None 92 | 93 | @property 94 | def video_cap(self) -> cv2.VideoCapture: 95 | if self._video_cap is None: 96 | raise Exception("VideoCapture hasn't been initialized yet") 97 | 98 | return self._video_cap 99 | 100 | @property 101 | def is_live(self) -> bool: 102 | return self.video_cap.get(cv2.CAP_PROP_FRAME_COUNT) <= 0 103 | 104 | @property 105 | def fps(self) -> float: 106 | return self.video_cap.get(cv2.CAP_PROP_FPS) 107 | 108 | @property 109 | def width(self) -> int: 110 | return int(self.video_cap.get(cv2.CAP_PROP_FRAME_WIDTH)) 111 | 112 | @property 113 | def height(self) -> int: 114 | return int(self.video_cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) 115 | 116 | @property 117 | def frame_count(self) -> int: 118 | return int(self.video_cap.get(cv2.CAP_PROP_FRAME_COUNT)) 119 | 120 | def __next__(self) -> numpy.ndarray: 121 | success, frame = self._video_cap.read() 122 | 123 | if not success: 124 | raise StopIteration 125 | 126 | return frame 127 | 128 | def __enter__(self) -> typing_extensions.Self: 129 | if self.source is None: 130 | raise RuntimeError("The video source has already been closed.") 131 | 132 | if isinstance(self.source, (str, int)): 133 | self._video_cap = cv2.VideoCapture(self.source) 134 | else: 135 | data: bytes 136 | if isinstance(self.source, io.IOBase): 137 | data = self.source.read() 138 | else: 139 | data = self.source 140 | 141 | self._temp_file = tempfile.NamedTemporaryFile("wb") 142 | self._temp_file.write(data) 143 | 144 | self.source = None # so gc can cleanup 145 | 146 | self._video_cap = cv2.VideoCapture(self._temp_file.name) 147 | 148 | return self 149 | 150 | def __exit__(self, exc_type: type, exc_value: Exception, exc_tb: Any) -> None: 151 | if self._video_cap: 152 | self._video_cap.release() 153 | 154 | if self._temp_file: 155 | self._temp_file.close() 156 | -------------------------------------------------------------------------------- /toascii/converters/color_converter.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Generator, List, Tuple, Union 2 | 3 | import colorama 4 | import numpy as np 5 | 6 | from .grayscale_converter import GrayscaleConverter 7 | 8 | T_COLOR = Union[List[int], Tuple[int, int, int]] 9 | T_HSL_COLOR = Union[List[float], Tuple[float, float, float]] 10 | 11 | COLOR_TRUNC = 128 12 | COLOR_TRUNC_TO = 256 // COLOR_TRUNC 13 | 14 | 15 | # generates all colors possible within the color space COLOR_TRUNC_TO 16 | def _gen_colors() -> Generator[T_COLOR, None, None]: 17 | for r in range(0, COLOR_TRUNC_TO): 18 | for g in range(0, COLOR_TRUNC_TO): 19 | for b in range(0, COLOR_TRUNC_TO): 20 | yield (r, g, b) 21 | 22 | 23 | def _dist_3d(a: T_COLOR, b: T_COLOR) -> float: 24 | return abs((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2 + (b[2] - a[2]) ** 2) 25 | 26 | 27 | def _trunc_color(r: int, g: int, b: int) -> Tuple[int, int, int]: 28 | return (r // COLOR_TRUNC, g // COLOR_TRUNC, b // COLOR_TRUNC) 29 | 30 | 31 | RGB_TO_COLORAMA_NAME = { 32 | _trunc_color(*k): v 33 | for k, v in { 34 | (196, 29, 17): "RED", 35 | (0, 193, 32): "GREEN", 36 | (199, 195, 38): "YELLOW", 37 | (10, 47, 196): "BLUE", 38 | (200, 57, 197): "MAGENTA", 39 | (1, 197, 198): "CYAN", 40 | (199, 199, 199): "WHITE", 41 | (104, 104, 104): "LIGHTBLACK_EX", 42 | (255, 110, 103): "LIGHTRED_EX", 43 | (96, 249, 102): "LIGHTGREEN_EX", 44 | (255, 252, 96): "LIGHTYELLOW_EX", 45 | (100, 111, 253): "LIGHTBLUE_EX", 46 | (255, 119, 255): "LIGHTMAGENTA_EX", 47 | (96, 253, 255): "LIGHTCYAN_EX", 48 | (255, 254, 245): "LIGHTWHITE_EX", 49 | }.items() 50 | } 51 | 52 | # all possible rgb values to color ascii codes 53 | RGB_TO_ASCII_CODE: Dict[T_COLOR, str] = { 54 | a: getattr( 55 | colorama.Fore, 56 | RGB_TO_COLORAMA_NAME[min(RGB_TO_COLORAMA_NAME.keys(), key=(lambda b: _dist_3d(a, b)))], 57 | ) 58 | for a in _gen_colors() 59 | } 60 | 61 | 62 | # ruff: noqa: E741 63 | def _rgb2hsl(c: T_COLOR) -> T_HSL_COLOR: 64 | r = c[0] / 255.0 65 | g = c[1] / 255.0 66 | b = c[2] / 255.0 67 | c_min = min(r, min(g, b)) 68 | c_max = max(r, max(g, b)) 69 | delta = c_max - c_min 70 | h, s, l = [0.0] * 3 71 | 72 | if delta == 0.0: 73 | h = 0.0 74 | elif c_max == r: 75 | h = ((g - b) / delta) % 6.0 76 | elif c_max == g: 77 | h = ((b - r) / delta) + 2.0 78 | else: 79 | h = ((r - g) / delta) + 4.0 80 | 81 | h = round(h * 60.0) 82 | 83 | if h < 0.0: 84 | h += 360.0 85 | 86 | l = (c_max + c_min) / 2.0 87 | 88 | if delta == 0.0: 89 | s = 0.0 90 | else: 91 | s = delta / (1 - abs(2.0 * l - 1.0)) 92 | 93 | s *= 100.0 94 | l *= 100.0 95 | 96 | return [h, s, l] 97 | 98 | 99 | def _hsl2rgb(c: T_HSL_COLOR) -> T_COLOR: 100 | h = c[0] 101 | s = c[1] / 100.0 102 | l = c[2] / 100.0 103 | 104 | r, g, b = [0.0] * 3 105 | c = (1.0 - abs(2 * l - 1.0)) * s 106 | x = c * (1.0 - abs((h / 60.0) % 2.0 - 1.0)) 107 | m = l - c / 2.0 108 | 109 | if 0 <= h and h < 60: 110 | r = c 111 | g = x 112 | b = 0 113 | elif 60 <= h and h < 120: 114 | r = x 115 | g = c 116 | b = 0 117 | elif 120 <= h and h < 180: 118 | r = 0 119 | g = c 120 | b = x 121 | elif 180 <= h and h < 240: 122 | r = 0 123 | g = x 124 | b = c 125 | elif 240 <= h and h < 300: 126 | r = x 127 | g = 0 128 | b = c 129 | elif 300 <= h and h < 360: 130 | r = c 131 | g = 0 132 | b = x 133 | 134 | r = round((r + m) * 255) 135 | g = round((g + m) * 255) 136 | b = round((b + m) * 255) 137 | 138 | return [r, g, b] 139 | 140 | 141 | class ColorConverter(GrayscaleConverter): 142 | @staticmethod 143 | def _saturate(pixel: T_COLOR, saturation: float) -> T_COLOR: 144 | hsl = _rgb2hsl(pixel) 145 | 146 | if saturation >= 0: 147 | gray_factor = hsl[1] / 100.0 148 | var_interval = 100.0 - hsl[1] 149 | hsl[1] = hsl[1] + saturation * var_interval * gray_factor 150 | else: 151 | hsl[1] = hsl[1] + saturation * hsl[1] 152 | 153 | return _hsl2rgb(hsl) 154 | 155 | def _contrast(self, image: np.ndarray) -> np.ndarray: 156 | if self.options.contrast is not None: 157 | image = ((image - image.min()) / (image.max() - image.min())) * 255 158 | min_val = np.percentile(image, self.options.contrast * 50) 159 | max_val = np.percentile(image, 100 - self.options.contrast * 50) 160 | image = np.clip(image, min_val, max_val) 161 | image = ((image - min_val) / (max_val - min_val)) * 255 162 | image = image.astype(np.uint8) 163 | 164 | return image 165 | 166 | def _asciify_image(self, image: np.ndarray) -> Generator[str, None, None]: 167 | image = self._contrast(image) 168 | g_l_m = len(self.options.gradient) - 1 169 | 170 | row: np.ndarray 171 | for row in image: 172 | for b, g, r in row: 173 | yield RGB_TO_ASCII_CODE[ 174 | _trunc_color(*self._saturate((r, g, b), self.options.saturation)) 175 | ] 176 | 177 | lum = self._luminosity(r, g, b) 178 | yield self.options.gradient[int((lum / 255) * g_l_m)] 179 | 180 | yield "\n" 181 | 182 | def asciify_image(self, image: np.ndarray) -> str: 183 | return "".join(self._asciify_image(image)) + colorama.Fore.RESET 184 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # To-ASCII ![Code Quality](https://www.codefactor.io/repository/github/iapetus-11/to-ascii/badge/master) ![PYPI Version](https://img.shields.io/pypi/v/to-ascii.svg) ![PYPI Downloads](https://img.shields.io/pypi/dw/to-ascii?color=0FAE6E) 2 | *Converts videos, images, gifs, and even live video into ascii art!* 3 | 4 | - Works on most image and video types including GIFs 5 | - Works on LIVE VIDEO 6 | 7 | 8 | 9 | [[DEMO SITE]](https://ascii.iapetus11.me/) [\[Video Example\]](https://www.youtube.com/watch?v=S5-_BzdrOkQ) [\[Video Example 2\]](https://www.youtube.com/watch?v=eX4pYQjCyYg) 10 | 11 | ## Installation 12 | Via pip: 13 | ``` 14 | pip install to-ascii[speedups,cli] 15 | ``` 16 | - The `speedups` extra is recommended, [see below](#extensions) 17 | - The `cli` extra is required for CLI use (it adds [click](https://pypi.org/project/click/) as a dependency) 18 | 19 | ## CLI Usage 20 | ``` 21 | Usage: toascii [OPTIONS] {image|video} SOURCE {colorconverter|colorconverternim|grayscaleconverter|grayscaleconverternim|htmlcolorconverter|htmlcolorconverternim} 22 | 23 | Options: 24 | -g, --gradient TEXT 25 | -w, --width INTEGER RANGE [x>=1] 26 | -h, --height INTEGER RANGE [x>=1] 27 | --x-stretch, --xstretch FLOAT RANGE 28 | [x>0.0] 29 | --y-stretch, --ystretch FLOAT RANGE 30 | [x>0.0] 31 | --saturation FLOAT RANGE [-1.0<=x<=1.0] 32 | --contrast FLOAT RANGE [0.0<=x<=1.0] 33 | --blur INTEGER RANGE [x>=2] 34 | --loop 35 | --help Show this message and exit. 36 | ``` 37 | 38 | ### CLI Examples 39 | ```bash 40 | # live video 41 | toascii video 0 colorconverternim -h 103 --x-stretch 3.5 --blur 10 --contrast 0.01 --saturation 0.0 42 | ``` 43 | ```bash 44 | toascii image sammie.jpg grayscaleconverter -h 32 --x-stretch 2.1 --blur 20 --contrast 0.0 --saturation 0.0 45 | ``` 46 | ```bash 47 | toascii video IMG_7845.MOV colorconverternim -h 81 --x-stretch 2.5 --blur 15 --contrast 0.01 --saturation 0.0 --loop 48 | ``` 49 | 50 | ## API Reference 51 | ### [Usage Examples Folder](examples) 52 | #### *class* [`ConverterOptions`](toascii/converters/options.py)(\*, `gradient`: *`str`*, `width`: *`Optional[int]`*, `height`: *`Optional[int]`*, `x_stretch`: *`float`*, `y_stretch`: *`float`*, `saturation`: *`float`*, `contrast`: *`Optional[float]`*) 53 | - *pydantic model for converter options* 54 | - Parameters / Attributes: 55 | - `gradient`: *`str`* - *string containing the characters the converter will use when converting the image to ascii* 56 | - must be at least one character 57 | - `width`: *`Optional[int]`* - *width in characters of the final converted image* 58 | - default value is `None` 59 | - must be greater than `0` 60 | - `height`: *`Optional[int]`* - *height in characters of the final converted image* 61 | - default value is `None` 62 | - must be greater than `0` 63 | - `x_stretch`: *`float`* - *how much to stretch the width by* 64 | - default value is `1.0` (which doesn't change the width by anything) 65 | - must be greater than `0.0` 66 | - `y_stretch`: *`float`* - *how much to stretch the height by* 67 | - default value is `1.0` (which doesn't change the height by anything) 68 | - must be greater than `0.0` 69 | - `saturation`: *`float`* - *how much to adjust the saturation* 70 | - default value is `0.5` (which increases the saturation) 71 | - must be between `-1.0` and `1.0`, `0.0` is no change to saturation 72 | - `contrast`: *`Optional[float]`* - *how much to increase the contrast by* 73 | - default value is `None` (which doesn't apply any contrast filter) 74 | - must be between `0.0` and `1.0` 75 | - `blur`: *`Optional[int]`* - *how much to blur the image by* 76 | - default value is `None` (which doesn't apply any blur) 77 | - must be greater than `0` 78 | #### *class* [`ConverterBase`](toascii/converters/base_converter.py)(`options`: *`ConverterOptions`*) 79 | - *base class for implementing converters* 80 | - Parameters: 81 | - `options`: *`ConverterOptions`* - *Options used when converting media* 82 | - Methods: 83 | - *abstract* `asciify_image`(`image`: *`numpy.ndarray`*) -> *`str`* 84 | - `calculate_dimensions`(`initial_height`: *`int`*, `initial_width`: *`int`*) -> *`Tuple[int, int]`* 85 | - `apply_opencv_fx`(`image`: *`numpy.ndarray`*, \*, `resize_dims`: *`Optional[Tuple[int, int]]`*) -> *`numpy.ndarray`* 86 | - Implementations: 87 | - [`GrayscaleConverter`](toascii/converters/grayscale_converter.py) - *converts media to grayscale ascii* 88 | - [`GrayscaleConverterNim`](toascii/converters/extensions/grayscale_converter_nim.py) - *converters media to grayscale ascii, see the [Extensions](#extensions) section* 89 | - [`ColorConverter`](toascii/converters/color_converter.py) - *converts media to colored ascii using [Colorama](https://pypi.org/project/colorama/)* 90 | - [`ColorConverterNim`](toascii/converters/extensions/color_converter_nim.py) - *converts media to colored ascii using [Colorama](https://pypi.org/project/colorama/), see the [Extensions](#extensions) section* 91 | - [`HtmlColorConverter`](toascii/converters/html_color_converter.py) - *converts media to ascii in colored html spans* 92 | - [`HtmlColorConverterNim`](toascii/converters/extensions/html_color_converter_nim.py) - *converts media to ascii in colored html spans, see the [Extensions](#extensions) section* 93 | #### *class* [`Image`](toascii/image.py)(`source`: *`Union[str, bytes, IOBase]`*, `converter`: *`BaseConverter`*) 94 | - *class for converting an image to ascii* 95 | - Parameters: 96 | - `source`: *`Union[str, bytes, IOBase]`* - *the source of the image that is to be loaded and converted* 97 | - if `source` is a `str`, it's assumed that it's a path to an image file 98 | - if `source` is `bytes` or `IOBase` it's assumed to be the data of an image and is decoded in-memory 99 | - `converter`: *`ConverterBase`* - *the converter used to convert the image* 100 | - takes anything that implements `ConverterBase` 101 | - Methods: 102 | - `to_ascii`() -> *`str`* 103 | - *returns the image converted by the converter* 104 | - `view`() -> *`None`* 105 | - *prints out the converted image to the console* 106 | #### *class* [`Video`](toascii/video.py)(`source`: *`Union[str, int, bytes, IOBase]`*, `converter`: *`BaseConverter`*, \*, `fps`: *`Optional[float]`*, `loop`: *`bool`*) 107 | - *class for converting a video to ascii* 108 | - Parameters: 109 | - `source`: *`Union[str, int bytes, IOBase]`* - *the source of the video that is to be loaded and converted* 110 | - if `source` is a `str`, it's assumed that it's a path to an image file 111 | - if `source` is `bytes` or `IOBase` it's assumed to be the data of an image and is written to a temporary file before being loaded and decoded by OpenCV 112 | - if `source` is an `int`, it's assumed to be the index of a camera device 113 | - see [OpenCV's `VideoCapture`](https://docs.opencv.org/3.4/d8/dfe/classcv_1_1VideoCapture.html) for more information 114 | - `converter`: *`ConverterBase`* - *the converter used to convert the image* 115 | - takes anything that implements `ConverterBase` 116 | - `fps`: *`Optional[float]`* - *the fps to play the video at* 117 | - default value is `None` 118 | - if `None` then the fps used is fetched from OpenCV's `VideoCapture` API 119 | - `loop`: *`bool`* - *whether or not to loop the video when it's done playing* 120 | - default value is `False` 121 | - if the video source is live, this parameter is ignored 122 | - Methods: 123 | - `get_ascii_frames`() -> *`Generator[str, None, None]`* - *returns a generator which yields each ascii frame as it is converted* 124 | - `view`() -> *`None`* - *prints out each frame of the converted video* 125 | - if the video source is not live, this method will first generate all frames and cache them in memory for a smoother playback 126 | - if the `loop` parameter was set to `True` earlier, then this will play the video and restart it when it finishes unless the source is live 127 | 128 | ## Extensions 129 | - For each converter available, there is a separate implementation written in [Nim](https://nim-lang.org/) 130 | - These implementations are generally orders of magnitude faster than their Python counterparts 131 | - To use these extensions you must [install Nim](https://nim-lang.org/install.html) and install the `to-ascii[speedups]` package via pip or your package manager of choice 132 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "click" 5 | version = "8.1.8" 6 | description = "Composable command line interface toolkit" 7 | optional = false 8 | python-versions = ">=3.7" 9 | files = [ 10 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 11 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 12 | ] 13 | 14 | [package.dependencies] 15 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 16 | 17 | [[package]] 18 | name = "colorama" 19 | version = "0.4.6" 20 | description = "Cross-platform colored terminal text." 21 | optional = false 22 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 23 | files = [ 24 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 25 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 26 | ] 27 | 28 | [[package]] 29 | name = "nimporter" 30 | version = "1.1.0" 31 | description = "Compile Nim extensions for Python when imported!" 32 | optional = true 33 | python-versions = "^3.6" 34 | files = [ 35 | {file = "nimporter-1.1.0.tar.gz", hash = "sha256:d119d6eb35eb713cd483324ec919f3bf8269eef48c770e6d6549f6e34b4689c8"}, 36 | ] 37 | 38 | [[package]] 39 | name = "numpy" 40 | version = "2.0.0" 41 | description = "Fundamental package for array computing in Python" 42 | optional = false 43 | python-versions = ">=3.9" 44 | files = [ 45 | {file = "numpy-2.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:04494f6ec467ccb5369d1808570ae55f6ed9b5809d7f035059000a37b8d7e86f"}, 46 | {file = "numpy-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2635dbd200c2d6faf2ef9a0d04f0ecc6b13b3cad54f7c67c61155138835515d2"}, 47 | {file = "numpy-2.0.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:0a43f0974d501842866cc83471bdb0116ba0dffdbaac33ec05e6afed5b615238"}, 48 | {file = "numpy-2.0.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:8d83bb187fb647643bd56e1ae43f273c7f4dbcdf94550d7938cfc32566756514"}, 49 | {file = "numpy-2.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79e843d186c8fb1b102bef3e2bc35ef81160ffef3194646a7fdd6a73c6b97196"}, 50 | {file = "numpy-2.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d7696c615765091cc5093f76fd1fa069870304beaccfd58b5dcc69e55ef49c1"}, 51 | {file = "numpy-2.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b4c76e3d4c56f145d41b7b6751255feefae92edbc9a61e1758a98204200f30fc"}, 52 | {file = "numpy-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acd3a644e4807e73b4e1867b769fbf1ce8c5d80e7caaef0d90dcdc640dfc9787"}, 53 | {file = "numpy-2.0.0-cp310-cp310-win32.whl", hash = "sha256:cee6cc0584f71adefe2c908856ccc98702baf95ff80092e4ca46061538a2ba98"}, 54 | {file = "numpy-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:ed08d2703b5972ec736451b818c2eb9da80d66c3e84aed1deeb0c345fefe461b"}, 55 | {file = "numpy-2.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad0c86f3455fbd0de6c31a3056eb822fc939f81b1618f10ff3406971893b62a5"}, 56 | {file = "numpy-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7f387600d424f91576af20518334df3d97bc76a300a755f9a8d6e4f5cadd289"}, 57 | {file = "numpy-2.0.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:34f003cb88b1ba38cb9a9a4a3161c1604973d7f9d5552c38bc2f04f829536609"}, 58 | {file = "numpy-2.0.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:b6f6a8f45d0313db07d6d1d37bd0b112f887e1369758a5419c0370ba915b3871"}, 59 | {file = "numpy-2.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f64641b42b2429f56ee08b4f427a4d2daf916ec59686061de751a55aafa22e4"}, 60 | {file = "numpy-2.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7039a136017eaa92c1848152827e1424701532ca8e8967fe480fe1569dae581"}, 61 | {file = "numpy-2.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:46e161722e0f619749d1cd892167039015b2c2817296104487cd03ed4a955995"}, 62 | {file = "numpy-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0e50842b2295ba8414c8c1d9d957083d5dfe9e16828b37de883f51fc53c4016f"}, 63 | {file = "numpy-2.0.0-cp311-cp311-win32.whl", hash = "sha256:2ce46fd0b8a0c947ae047d222f7136fc4d55538741373107574271bc00e20e8f"}, 64 | {file = "numpy-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:fbd6acc766814ea6443628f4e6751d0da6593dae29c08c0b2606164db026970c"}, 65 | {file = "numpy-2.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:354f373279768fa5a584bac997de6a6c9bc535c482592d7a813bb0c09be6c76f"}, 66 | {file = "numpy-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d2f62e55a4cd9c58c1d9a1c9edaedcd857a73cb6fda875bf79093f9d9086f85"}, 67 | {file = "numpy-2.0.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:1e72728e7501a450288fc8e1f9ebc73d90cfd4671ebbd631f3e7857c39bd16f2"}, 68 | {file = "numpy-2.0.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:84554fc53daa8f6abf8e8a66e076aff6ece62de68523d9f665f32d2fc50fd66e"}, 69 | {file = "numpy-2.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c73aafd1afca80afecb22718f8700b40ac7cab927b8abab3c3e337d70e10e5a2"}, 70 | {file = "numpy-2.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49d9f7d256fbc804391a7f72d4a617302b1afac1112fac19b6c6cec63fe7fe8a"}, 71 | {file = "numpy-2.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0ec84b9ba0654f3b962802edc91424331f423dcf5d5f926676e0150789cb3d95"}, 72 | {file = "numpy-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:feff59f27338135776f6d4e2ec7aeeac5d5f7a08a83e80869121ef8164b74af9"}, 73 | {file = "numpy-2.0.0-cp312-cp312-win32.whl", hash = "sha256:c5a59996dc61835133b56a32ebe4ef3740ea5bc19b3983ac60cc32be5a665d54"}, 74 | {file = "numpy-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a356364941fb0593bb899a1076b92dfa2029f6f5b8ba88a14fd0984aaf76d0df"}, 75 | {file = "numpy-2.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e61155fae27570692ad1d327e81c6cf27d535a5d7ef97648a17d922224b216de"}, 76 | {file = "numpy-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4554eb96f0fd263041baf16cf0881b3f5dafae7a59b1049acb9540c4d57bc8cb"}, 77 | {file = "numpy-2.0.0-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:903703372d46bce88b6920a0cd86c3ad82dae2dbef157b5fc01b70ea1cfc430f"}, 78 | {file = "numpy-2.0.0-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:3e8e01233d57639b2e30966c63d36fcea099d17c53bf424d77f088b0f4babd86"}, 79 | {file = "numpy-2.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cde1753efe513705a0c6d28f5884e22bdc30438bf0085c5c486cdaff40cd67a"}, 80 | {file = "numpy-2.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821eedb7165ead9eebdb569986968b541f9908979c2da8a4967ecac4439bae3d"}, 81 | {file = "numpy-2.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9a1712c015831da583b21c5bfe15e8684137097969c6d22e8316ba66b5baabe4"}, 82 | {file = "numpy-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9c27f0946a3536403efb0e1c28def1ae6730a72cd0d5878db38824855e3afc44"}, 83 | {file = "numpy-2.0.0-cp39-cp39-win32.whl", hash = "sha256:63b92c512d9dbcc37f9d81b123dec99fdb318ba38c8059afc78086fe73820275"}, 84 | {file = "numpy-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:3f6bed7f840d44c08ebdb73b1825282b801799e325bcbdfa6bc5c370e5aecc65"}, 85 | {file = "numpy-2.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9416a5c2e92ace094e9f0082c5fd473502c91651fb896bc17690d6fc475128d6"}, 86 | {file = "numpy-2.0.0-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:17067d097ed036636fa79f6a869ac26df7db1ba22039d962422506640314933a"}, 87 | {file = "numpy-2.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38ecb5b0582cd125f67a629072fed6f83562d9dd04d7e03256c9829bdec027ad"}, 88 | {file = "numpy-2.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cef04d068f5fb0518a77857953193b6bb94809a806bd0a14983a8f12ada060c9"}, 89 | {file = "numpy-2.0.0.tar.gz", hash = "sha256:cf5d1c9e6837f8af9f92b6bd3e86d513cdc11f60fd62185cc49ec7d1aba34864"}, 90 | ] 91 | 92 | [[package]] 93 | name = "opencv-python" 94 | version = "4.10.0.84" 95 | description = "Wrapper package for OpenCV python bindings." 96 | optional = false 97 | python-versions = ">=3.6" 98 | files = [ 99 | {file = "opencv-python-4.10.0.84.tar.gz", hash = "sha256:72d234e4582e9658ffea8e9cae5b63d488ad06994ef12d81dc303b17472f3526"}, 100 | {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fc182f8f4cda51b45f01c64e4cbedfc2f00aff799debebc305d8d0210c43f251"}, 101 | {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:71e575744f1d23f79741450254660442785f45a0797212852ee5199ef12eed98"}, 102 | {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a332b50488e2dda866a6c5573ee192fe3583239fb26ff2f7f9ceb0bc119ea6"}, 103 | {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ace140fc6d647fbe1c692bcb2abce768973491222c067c131d80957c595b71f"}, 104 | {file = "opencv_python-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:2db02bb7e50b703f0a2d50c50ced72e95c574e1e5a0bb35a8a86d0b35c98c236"}, 105 | {file = "opencv_python-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:32dbbd94c26f611dc5cc6979e6b7aa1f55a64d6b463cc1dcd3c95505a63e48fe"}, 106 | ] 107 | 108 | [package.dependencies] 109 | numpy = [ 110 | {version = ">=1.21.0", markers = "python_version == \"3.9\" and platform_system == \"Darwin\" and platform_machine == \"arm64\""}, 111 | {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, 112 | {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, 113 | {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, 114 | {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, 115 | {version = ">=1.19.3", markers = "platform_system == \"Linux\" and platform_machine == \"aarch64\" and python_version >= \"3.8\" and python_version < \"3.10\" or python_version > \"3.9\" and python_version < \"3.10\" or python_version >= \"3.9\" and platform_system != \"Darwin\" and python_version < \"3.10\" or python_version >= \"3.9\" and platform_machine != \"arm64\" and python_version < \"3.10\""}, 116 | ] 117 | 118 | [[package]] 119 | name = "pydantic" 120 | version = "1.10.2" 121 | description = "Data validation and settings management using python type hints" 122 | optional = false 123 | python-versions = ">=3.7" 124 | files = [ 125 | {file = "pydantic-1.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb6ad4489af1bac6955d38ebcb95079a836af31e4c4f74aba1ca05bb9f6027bd"}, 126 | {file = "pydantic-1.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1f5a63a6dfe19d719b1b6e6106561869d2efaca6167f84f5ab9347887d78b98"}, 127 | {file = "pydantic-1.10.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:352aedb1d71b8b0736c6d56ad2bd34c6982720644b0624462059ab29bd6e5912"}, 128 | {file = "pydantic-1.10.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19b3b9ccf97af2b7519c42032441a891a5e05c68368f40865a90eb88833c2559"}, 129 | {file = "pydantic-1.10.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e9069e1b01525a96e6ff49e25876d90d5a563bc31c658289a8772ae186552236"}, 130 | {file = "pydantic-1.10.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:355639d9afc76bcb9b0c3000ddcd08472ae75318a6eb67a15866b87e2efa168c"}, 131 | {file = "pydantic-1.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:ae544c47bec47a86bc7d350f965d8b15540e27e5aa4f55170ac6a75e5f73b644"}, 132 | {file = "pydantic-1.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4c805731c33a8db4b6ace45ce440c4ef5336e712508b4d9e1aafa617dc9907f"}, 133 | {file = "pydantic-1.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d49f3db871575e0426b12e2f32fdb25e579dea16486a26e5a0474af87cb1ab0a"}, 134 | {file = "pydantic-1.10.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37c90345ec7dd2f1bcef82ce49b6235b40f282b94d3eec47e801baf864d15525"}, 135 | {file = "pydantic-1.10.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b5ba54d026c2bd2cb769d3468885f23f43710f651688e91f5fb1edcf0ee9283"}, 136 | {file = "pydantic-1.10.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:05e00dbebbe810b33c7a7362f231893183bcc4251f3f2ff991c31d5c08240c42"}, 137 | {file = "pydantic-1.10.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2d0567e60eb01bccda3a4df01df677adf6b437958d35c12a3ac3e0f078b0ee52"}, 138 | {file = "pydantic-1.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:c6f981882aea41e021f72779ce2a4e87267458cc4d39ea990729e21ef18f0f8c"}, 139 | {file = "pydantic-1.10.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c4aac8e7103bf598373208f6299fa9a5cfd1fc571f2d40bf1dd1955a63d6eeb5"}, 140 | {file = "pydantic-1.10.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a7b66c3f499108b448f3f004801fcd7d7165fb4200acb03f1c2402da73ce4c"}, 141 | {file = "pydantic-1.10.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bedf309630209e78582ffacda64a21f96f3ed2e51fbf3962d4d488e503420254"}, 142 | {file = "pydantic-1.10.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9300fcbebf85f6339a02c6994b2eb3ff1b9c8c14f502058b5bf349d42447dcf5"}, 143 | {file = "pydantic-1.10.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:216f3bcbf19c726b1cc22b099dd409aa371f55c08800bcea4c44c8f74b73478d"}, 144 | {file = "pydantic-1.10.2-cp37-cp37m-win_amd64.whl", hash = "sha256:dd3f9a40c16daf323cf913593083698caee97df2804aa36c4b3175d5ac1b92a2"}, 145 | {file = "pydantic-1.10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b97890e56a694486f772d36efd2ba31612739bc6f3caeee50e9e7e3ebd2fdd13"}, 146 | {file = "pydantic-1.10.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9cabf4a7f05a776e7793e72793cd92cc865ea0e83a819f9ae4ecccb1b8aa6116"}, 147 | {file = "pydantic-1.10.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06094d18dd5e6f2bbf93efa54991c3240964bb663b87729ac340eb5014310624"}, 148 | {file = "pydantic-1.10.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc78cc83110d2f275ec1970e7a831f4e371ee92405332ebfe9860a715f8336e1"}, 149 | {file = "pydantic-1.10.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ee433e274268a4b0c8fde7ad9d58ecba12b069a033ecc4645bb6303c062d2e9"}, 150 | {file = "pydantic-1.10.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7c2abc4393dea97a4ccbb4ec7d8658d4e22c4765b7b9b9445588f16c71ad9965"}, 151 | {file = "pydantic-1.10.2-cp38-cp38-win_amd64.whl", hash = "sha256:0b959f4d8211fc964772b595ebb25f7652da3f22322c007b6fed26846a40685e"}, 152 | {file = "pydantic-1.10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c33602f93bfb67779f9c507e4d69451664524389546bacfe1bee13cae6dc7488"}, 153 | {file = "pydantic-1.10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5760e164b807a48a8f25f8aa1a6d857e6ce62e7ec83ea5d5c5a802eac81bad41"}, 154 | {file = "pydantic-1.10.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6eb843dcc411b6a2237a694f5e1d649fc66c6064d02b204a7e9d194dff81eb4b"}, 155 | {file = "pydantic-1.10.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b8795290deaae348c4eba0cebb196e1c6b98bdbe7f50b2d0d9a4a99716342fe"}, 156 | {file = "pydantic-1.10.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e0bedafe4bc165ad0a56ac0bd7695df25c50f76961da29c050712596cf092d6d"}, 157 | {file = "pydantic-1.10.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2e05aed07fa02231dbf03d0adb1be1d79cabb09025dd45aa094aa8b4e7b9dcda"}, 158 | {file = "pydantic-1.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:c1ba1afb396148bbc70e9eaa8c06c1716fdddabaf86e7027c5988bae2a829ab6"}, 159 | {file = "pydantic-1.10.2-py3-none-any.whl", hash = "sha256:1b6ee725bd6e83ec78b1aa32c5b1fa67a3a65badddde3976bca5fe4568f27709"}, 160 | {file = "pydantic-1.10.2.tar.gz", hash = "sha256:91b8e218852ef6007c2b98cd861601c6a09f1aa32bbbb74fab5b1c33d4a1e410"}, 161 | ] 162 | 163 | [package.dependencies] 164 | typing-extensions = ">=4.1.0" 165 | 166 | [package.extras] 167 | dotenv = ["python-dotenv (>=0.10.4)"] 168 | email = ["email-validator (>=1.0.3)"] 169 | 170 | [[package]] 171 | name = "ruff" 172 | version = "0.9.1" 173 | description = "An extremely fast Python linter and code formatter, written in Rust." 174 | optional = false 175 | python-versions = ">=3.7" 176 | files = [ 177 | {file = "ruff-0.9.1-py3-none-linux_armv6l.whl", hash = "sha256:84330dda7abcc270e6055551aca93fdde1b0685fc4fd358f26410f9349cf1743"}, 178 | {file = "ruff-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3cae39ba5d137054b0e5b472aee3b78a7c884e61591b100aeb544bcd1fc38d4f"}, 179 | {file = "ruff-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50c647ff96f4ba288db0ad87048257753733763b409b2faf2ea78b45c8bb7fcb"}, 180 | {file = "ruff-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0c8b149e9c7353cace7d698e1656ffcf1e36e50f8ea3b5d5f7f87ff9986a7ca"}, 181 | {file = "ruff-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:beb3298604540c884d8b282fe7625651378e1986c25df51dec5b2f60cafc31ce"}, 182 | {file = "ruff-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39d0174ccc45c439093971cc06ed3ac4dc545f5e8bdacf9f067adf879544d969"}, 183 | {file = "ruff-0.9.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69572926c0f0c9912288915214ca9b2809525ea263603370b9e00bed2ba56dbd"}, 184 | {file = "ruff-0.9.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:937267afce0c9170d6d29f01fcd1f4378172dec6760a9f4dface48cdabf9610a"}, 185 | {file = "ruff-0.9.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:186c2313de946f2c22bdf5954b8dd083e124bcfb685732cfb0beae0c47233d9b"}, 186 | {file = "ruff-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f94942a3bb767675d9a051867c036655fe9f6c8a491539156a6f7e6b5f31831"}, 187 | {file = "ruff-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:728d791b769cc28c05f12c280f99e8896932e9833fef1dd8756a6af2261fd1ab"}, 188 | {file = "ruff-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f312c86fb40c5c02b44a29a750ee3b21002bd813b5233facdaf63a51d9a85e1"}, 189 | {file = "ruff-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae017c3a29bee341ba584f3823f805abbe5fe9cd97f87ed07ecbf533c4c88366"}, 190 | {file = "ruff-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5dc40a378a0e21b4cfe2b8a0f1812a6572fc7b230ef12cd9fac9161aa91d807f"}, 191 | {file = "ruff-0.9.1-py3-none-win32.whl", hash = "sha256:46ebf5cc106cf7e7378ca3c28ce4293b61b449cd121b98699be727d40b79ba72"}, 192 | {file = "ruff-0.9.1-py3-none-win_amd64.whl", hash = "sha256:342a824b46ddbcdddd3abfbb332fa7fcaac5488bf18073e841236aadf4ad5c19"}, 193 | {file = "ruff-0.9.1-py3-none-win_arm64.whl", hash = "sha256:1cd76c7f9c679e6e8f2af8f778367dca82b95009bc7b1a85a47f1521ae524fa7"}, 194 | {file = "ruff-0.9.1.tar.gz", hash = "sha256:fd2b25ecaf907d6458fa842675382c8597b3c746a2dde6717fe3415425df0c17"}, 195 | ] 196 | 197 | [[package]] 198 | name = "typing-extensions" 199 | version = "4.3.0" 200 | description = "Backported and Experimental Type Hints for Python 3.7+" 201 | optional = false 202 | python-versions = ">=3.7" 203 | files = [ 204 | {file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"}, 205 | {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"}, 206 | ] 207 | 208 | [extras] 209 | cli = ["click"] 210 | speedups = ["nimporter"] 211 | 212 | [metadata] 213 | lock-version = "2.0" 214 | python-versions = ">=3.9,<4.0" 215 | content-hash = "92558cb4daafcfe1fd96ed4940e1dda6df60f49477c0662045021b5703a3ce28" 216 | --------------------------------------------------------------------------------