# PyonFX: An easy way to create KFX (Karaoke Effects) and complex typesetting using the ASS format (Advanced Substation Alpha).
# Copyright (C) 2019-2025 Antonio Strippoli (CoffeeStraw/YellowFlash)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PyonFX is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see http://www.gnu.org/licenses/.
import colorsys
import math
import os
import re
import sys
from enum import Enum
from typing import TYPE_CHECKING, cast, overload
import numpy as np
from PIL import Image
from shapely.affinity import scale as _shapely_scale
from shapely.affinity import translate as _shapely_translate
from shapely.vectorized import contains as _shapely_contains
from .font import Font
from .pixel import Pixel, PixelCollection
if TYPE_CHECKING:
from .ass_core import Char, Line, Syllable, Word
from .shape import Shape
[docs]
class ColorModel(Enum):
ASS = "&HBBGGRR&"
ASS_STYLE = "&HAABBGGRR"
RGB = "(r, g, b)"
RGB_STR = "#RRGGBB"
RGBA = "(r, g, b, a)"
RGBA_STR = "#RRGGBBAA"
HSV = "(h, s, v)"
[docs]
class Convert:
"""
This class is a collection of static methods that will help
the user to convert everything needed to the ASS format.
"""
@overload
@staticmethod
def time(ass_ms: int) -> str: ...
@overload
@staticmethod
def time(ass_ms: str) -> int: ...
[docs]
@staticmethod
def time(ass_ms: int | str) -> int | str:
"""Converts between milliseconds and ASS timestamp.
You can probably ignore that function, you will not make use of it for KFX or typesetting generation.
Parameters:
ass_ms (int or str): If int, than milliseconds are expected, else ASS timestamp as str is expected.
Returns:
If milliseconds -> ASS timestamp, else if ASS timestamp -> milliseconds, else ValueError will be raised.
"""
# Milliseconds?
if isinstance(ass_ms, int) and ass_ms >= 0:
# It round ms to cs. From https://github.com/Aegisub/Aegisub/blob/6f546951b4f004da16ce19ba638bf3eedefb9f31/libaegisub/include/libaegisub/ass/time.h#L32
# Ex: 49 ms to 50 ms
ass_ms = (ass_ms + 5) - (ass_ms + 5) % 10
return "{:d}:{:02d}:{:02d}.{:02d}".format(
math.floor(ass_ms / 3600000) % 10,
math.floor(ass_ms % 3600000 / 60000),
math.floor(ass_ms % 60000 / 1000),
math.floor(ass_ms % 1000 / 10),
)
# ASS timestamp?
elif isinstance(ass_ms, str) and re.fullmatch(r"\d:\d+:\d+\.\d+", ass_ms):
return (
int(ass_ms[0]) * 3600000
+ int(ass_ms[2:4]) * 60000
+ int(ass_ms[5:7]) * 1000
+ int(ass_ms[8:10]) * 10
)
else:
raise ValueError("Milliseconds or ASS timestamp expected")
[docs]
@staticmethod
def alpha_ass_to_dec(alpha_ass: str) -> int:
"""Converts from ASS alpha string to corresponding decimal value.
Parameters:
alpha_ass (str): A string in the format '&HXX&'.
Returns:
A decimal in [0, 255] representing ``alpha_ass`` converted.
Examples:
.. code-block:: python3
print(Convert.alpha_ass_to_dec("&HFF&"))
>>> 255
"""
match = re.fullmatch(r"&H([0-9A-F]{2})&", alpha_ass)
if match is None:
raise ValueError(
f"Provided ASS alpha string '{alpha_ass}' is not in the expected format '&HXX&'."
)
return int(match.group(1), 16)
[docs]
@staticmethod
def alpha_dec_to_ass(alpha_dec: int | float) -> str:
"""Converts from decimal value to corresponding ASS alpha string.
Parameters:
alpha_dec (int or float): Decimal in [0, 255] representing an alpha value.
Returns:
A string in the format '&HXX&' representing ``alpha_dec`` converted.
Examples:
.. code-block:: python3
print(Convert.alpha_dec_to_ass(255))
print(Convert.alpha_dec_to_ass(255.0))
>>> "&HFF&"
>>> "&HFF&"
"""
try:
if not 0 <= alpha_dec <= 255:
raise ValueError(
f"Provided alpha decimal '{alpha_dec}' is out of the range [0, 255]."
)
except TypeError as e:
raise TypeError(
f"Provided alpha decimal was expected of type 'int' or 'float', but you provided a '{type(alpha_dec)}'."
) from e
return f"&H{round(alpha_dec):02X}&"
[docs]
@staticmethod
def color(
c: (
str
| tuple[int | float, int | float, int | float]
| tuple[int | float, int | float, int | float, int | float]
),
input_format: ColorModel,
output_format: ColorModel,
round_output: bool = True,
) -> (
str
| tuple[int, int, int]
| tuple[int, int, int, int]
| tuple[float, float, float]
| tuple[float, float, float, float]
):
"""Converts a provided color from a color model to another.
Parameters:
c (str or tuple of int or tuple of float): A color in the format ``input_format``.
input_format (ColorModel): The color format of ``c``.
output_format (ColorModel): The color format for the output.
round_output (bool): A boolean to determine whether the output should be rounded or not.
Returns:
A color in the format ``output_format``.
Examples:
.. code-block:: python3
print(Convert.color("&H0000FF&", ColorModel.ASS, ColorModel.RGB))
>>> (255, 0, 0)
"""
try:
# Text for exception if input is out of ranges
input_range_e = f"Provided input '{c}' has value(s) out of the range "
# Parse input, obtaining its corresponding (r,g,b,a) values
if input_format == ColorModel.ASS:
if not isinstance(c, str):
raise TypeError("ASS color format requires string input")
match = re.fullmatch(r"&H([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})&", c)
if match is None:
raise ValueError(f"Invalid ASS color format: {c}")
(b, g, r), a = map(lambda x: int(x, 16), match.groups()), 255
elif input_format == ColorModel.ASS_STYLE:
if not isinstance(c, str):
raise TypeError("ASS_STYLE color format requires string input")
match = re.fullmatch("&H" + r"([0-9A-F]{2})" * 4, c)
if match is None:
raise ValueError(f"Invalid ASS_STYLE color format: {c}")
a, b, g, r = map(lambda x: int(x, 16), match.groups())
elif input_format == ColorModel.RGB:
if not isinstance(c, tuple) or len(c) != 3:
raise TypeError("RGB color format requires tuple of 3 values")
if not all(0 <= n <= 255 for n in c):
raise ValueError(input_range_e + "[0, 255].")
(r, g, b), a = c, 255
elif input_format == ColorModel.RGB_STR:
if not isinstance(c, str):
raise TypeError("RGB_STR color format requires string input")
match = re.fullmatch("#" + r"([0-9A-F]{2})" * 3, c)
if match is None:
raise ValueError(f"Invalid RGB_STR color format: {c}")
(r, g, b), a = map(lambda x: int(x, 16), match.groups()), 255
elif input_format == ColorModel.RGBA:
if not isinstance(c, tuple) or len(c) != 4:
raise TypeError("RGBA color format requires tuple of 4 values")
if not all(0 <= n <= 255 for n in c):
raise ValueError(input_range_e + "[0, 255].")
r, g, b, a = c
elif input_format == ColorModel.RGBA_STR:
if not isinstance(c, str):
raise TypeError("RGBA_STR color format requires string input")
match = re.fullmatch("#" + r"([0-9A-F]{2})" * 4, c)
if match is None:
raise ValueError(f"Invalid RGBA_STR color format: {c}")
r, g, b, a = map(lambda x: int(x, 16), match.groups())
elif input_format == ColorModel.HSV:
if not isinstance(c, tuple) or len(c) != 3:
raise TypeError("HSV color format requires tuple of 3 values")
h, s, v = c
if not (0 <= h < 360 and 0 <= s <= 100 and 0 <= v <= 100):
raise ValueError(
input_range_e + "( [0, 360), [0, 100], [0, 100] )."
)
h, s, v = h / 360, s / 100, v / 100
(r, g, b), a = map(lambda x: 255 * x, colorsys.hsv_to_rgb(h, s, v)), 255
except (AttributeError, ValueError, TypeError) as e:
# AttributeError -> re.fullmatch failed
# ValueError -> too many values to unpack
# TypeError -> in case the provided tuple is not a list of numbers
raise ValueError(
f"Provided input '{c}' is not in the format '{input_format}'."
) from e
# Convert (r,g,b,a) to the desired output_format
try:
if output_format == ColorModel.ASS:
return f"&H{round(b):02X}{round(g):02X}{round(r):02X}&"
elif output_format == ColorModel.ASS_STYLE:
return f"&H{round(a):02X}{round(b):02X}{round(g):02X}{round(r):02X}"
elif output_format == ColorModel.RGB:
method = round if round_output else float
return cast(tuple[int, int, int], tuple(map(method, (r, g, b))))
elif output_format == ColorModel.RGB_STR:
return f"#{round(r):02X}{round(g):02X}{round(b):02X}"
elif output_format == ColorModel.RGBA:
method = round if round_output else float
return cast(tuple[int, int, int, int], tuple(map(method, (r, g, b, a))))
elif output_format == ColorModel.RGBA_STR:
return f"#{round(r):02X}{round(g):02X}{round(b):02X}{round(a):02X}"
elif output_format == ColorModel.HSV:
method = round if round_output else float
h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
return cast(
tuple[float, float, float],
(method(h * 360) % 360, method(s * 100), method(v * 100)),
)
except NameError as e:
raise ValueError(f"Unsupported input_format ('{input_format}').") from e
[docs]
@staticmethod
def color_ass_to_rgb(
color_ass: str, as_str: bool = False
) -> str | tuple[int, int, int]:
"""Converts from ASS color string to corresponding RGB color.
Parameters:
color_ass (str): A string in the format '&HBBGGRR&'.
as_str (bool): A boolean to determine the output type format.
Returns:
The output represents ``color_ass`` converted. If ``as_str`` = False, the output is a tuple of integers in range *[0, 255]*.
Else, the output is a string in the format '#RRGGBB'.
Examples:
.. code-block:: python3
print(Convert.color_ass_to_rgb("&HABCDEF&"))
print(Convert.color_ass_to_rgb("&HABCDEF&", as_str=True))
>>> (239, 205, 171)
>>> "#EFCDAB"
"""
result = Convert.color(
color_ass, ColorModel.ASS, ColorModel.RGB_STR if as_str else ColorModel.RGB
)
if as_str:
return cast(str, result)
return cast(tuple[int, int, int], result)
[docs]
@staticmethod
def color_ass_to_hsv(
color_ass: str, round_output: bool = True
) -> tuple[int, int, int] | tuple[float, float, float]:
"""Converts from ASS color string to corresponding HSV color.
Parameters:
color_ass (str): A string in the format '&HBBGGRR&'.
round_output (bool): A boolean to determine whether the output should be rounded or not.
Returns:
The output represents ``color_ass`` converted. If ``round_output`` = True, the output is a tuple of integers in range *( [0, 360), [0, 100], [0, 100] )*.
Else, the output is a tuple of floats in range *( [0, 360), [0, 100], [0, 100] )*.
Examples:
.. code-block:: python3
print(Convert.color_ass_to_hsv("&HABCDEF&"))
print(Convert.color_ass_to_hsv("&HABCDEF&", round_output=False))
>>> (30, 28, 94)
>>> (30.000000000000014, 28.451882845188294, 93.72549019607843)
"""
result = Convert.color(color_ass, ColorModel.ASS, ColorModel.HSV, round_output)
return cast(tuple[int, int, int] | tuple[float, float, float], result)
[docs]
@staticmethod
def color_rgb_to_ass(
color_rgb: str | tuple[int | float, int | float, int | float],
) -> str:
"""Converts from RGB color to corresponding ASS color.
Parameters:
color_rgb (str or tuple of int or tuple of float): Either a string in the format '#RRGGBB' or a tuple of three integers (or floats) in the range *[0, 255]*.
Returns:
A string in the format '&HBBGGRR&' representing ``color_rgb`` converted.
Examples:
.. code-block:: python3
print(Convert.color_rgb_to_ass("#ABCDEF"))
>>> "&HEFCDAB&"
"""
result = Convert.color(
color_rgb,
ColorModel.RGB_STR if isinstance(color_rgb, str) else ColorModel.RGB,
ColorModel.ASS,
)
return cast(str, result)
[docs]
@staticmethod
def color_rgb_to_hsv(
color_rgb: str | tuple[int | float, int | float, int | float],
round_output: bool = True,
) -> tuple[int, int, int] | tuple[float, float, float]:
"""Converts from RGB color to corresponding HSV color.
Parameters:
color_rgb (str or tuple of int or tuple of float): Either a string in the format '#RRGGBB' or a tuple of three integers (or floats) in the range *[0, 255]*.
round_output (bool): A boolean to determine whether the output should be rounded or not.
Returns:
The output represents ``color_rgb`` converted. If ``round_output`` = True, the output is a tuple of integers in range *( [0, 360), [0, 100], [0, 100] )*.
Else, the output is a tuple of floats in range *( [0, 360), [0, 100], [0, 100] )*.
Examples:
.. code-block:: python3
print(Convert.color_rgb_to_hsv("#ABCDEF"))
print(Convert.color_rgb_to_hsv("#ABCDEF"), round_output=False)
>>> (210, 28, 94)
>>> (210.0, 28.451882845188294, 93.72549019607843)
"""
result = Convert.color(
color_rgb,
ColorModel.RGB_STR if isinstance(color_rgb, str) else ColorModel.RGB,
ColorModel.HSV,
round_output,
)
return cast(tuple[int, int, int] | tuple[float, float, float], result)
[docs]
@staticmethod
def color_hsv_to_ass(
color_hsv: tuple[int | float, int | float, int | float],
) -> str:
"""Converts from HSV color string to corresponding ASS color.
Parameters:
color_hsv (tuple of int/float): A tuple of three integers (or floats) in the range *( [0, 360), [0, 100], [0, 100] )*.
Returns:
A string in the format '&HBBGGRR&' representing ``color_hsv`` converted.
Examples:
.. code-block:: python3
print(Convert.color_hsv_to_ass((100, 100, 100)))
>>> "&H00FF55&"
"""
result = Convert.color(color_hsv, ColorModel.HSV, ColorModel.ASS)
return cast(str, result)
[docs]
@staticmethod
def color_hsv_to_rgb(
color_hsv: tuple[int | float, int | float, int | float],
as_str: bool = False,
round_output: bool = True,
) -> str | tuple[int, int, int] | tuple[float, float, float]:
"""Converts from HSV color string to corresponding RGB color.
Parameters:
color_hsv (tuple of int/float): A tuple of three integers (or floats) in the range *( [0, 360), [0, 100], [0, 100] )*.
as_str (bool): A boolean to determine the output type format.
round_output (bool): A boolean to determine whether the output should be rounded or not.
Returns:
The output represents ``color_hsv`` converted. If ``as_str`` = False, the output is a tuple
( also, if ``round_output`` = True, the output is a tuple of integers in range *( [0, 360), [0, 100], [0, 100] )*, else a tuple of float in range *( [0, 360), [0, 100], [0, 100] ) )*.
Else, the output is a string in the format '#RRGGBB'.
Examples:
.. code-block:: python3
print(Convert.color_hsv_to_rgb((100, 100, 100)))
print(Convert.color_hsv_to_rgb((100, 100, 100), as_str=True))
print(Convert.color_hsv_to_rgb((100, 100, 100), round_output=False))
>>> (85, 255, 0)
>>> "#55FF00"
>>> (84.99999999999999, 255.0, 0.0)
"""
result = Convert.color(
color_hsv,
ColorModel.HSV,
ColorModel.RGB_STR if as_str else ColorModel.RGB,
round_output,
)
if as_str:
return cast(str, result)
return cast(tuple[int, int, int] | tuple[float, float, float], result)
[docs]
@staticmethod
def text_to_shape(
obj: "Line | Word | Syllable | Char",
fscx: float | None = None,
fscy: float | None = None,
) -> "Shape":
"""Converts text with given style information to an ASS shape.
**Tips:** *You can easily create impressive deforming effects.*
Parameters:
obj (Line, Word, Syllable or Char): An object of class Line, Word, Syllable or Char.
fscx (float, optional): The scale_x value for the shape.
fscy (float, optional): The scale_y value for the shape.
Returns:
A Shape object, representing the text with the style format values of the object.
Examples:
.. code-block:: python3
line = Line.copy(lines[1])
line.text = "{\\\\an7\\\\pos(%.3f,%.3f)\\\\p1}%s" % (line.left, line.top, Convert.text_to_shape(line))
io.write_line(line)
"""
if obj.styleref is None:
raise ValueError("Object must have a style reference and text content")
# Obtaining information and editing values of style if requested
original_scale_x = obj.styleref.scale_x
original_scale_y = obj.styleref.scale_y
# Editing temporary the style to properly get the shape
if fscx is not None:
obj.styleref.scale_x = fscx
if fscy is not None:
obj.styleref.scale_y = fscy
# Obtaining font information from style and obtaining shape
font = Font(obj.styleref)
shape = font.text_to_shape(obj.text)
# Clearing resources to not let overflow errors take over
del font
# Restoring values of style and returning the shape converted
if fscx is not None:
obj.styleref.scale_x = original_scale_x
if fscy is not None:
obj.styleref.scale_y = original_scale_y
return shape
[docs]
@staticmethod
def text_to_clip(
obj: "Line | Word | Syllable | Char",
an: int = 5,
fscx: float | None = None,
fscy: float | None = None,
) -> "Shape":
"""Converts text with given style information to an ASS shape, applying some translation/scaling to it since
it is not possible to position a shape with \\pos() once it is in a clip.
This is an high level function since it does some additional operations, check text_to_shape for further infromations.
**Tips:** *You can easily create text masks even for growing/shrinking text without too much effort.*
Parameters:
obj (Line, Word, Syllable or Char): An object of class Line, Word, Syllable or Char.
an (integer, optional): The alignment wanted for the shape.
fscx (float, optional): The scale_x value for the shape.
fscy (float, optional): The scale_y value for the shape.
Returns:
A Shape object, representing the text with the style format values of the object.
Examples:
.. code-block:: python3
line = Line.copy(lines[1])
line.text = "{\\\\an5\\\\pos(%.3f,%.3f)\\\\clip(%s)}%s" % (line.center, line.middle, Convert.text_to_clip(line), line.text)
io.write_line(line)
"""
if obj.styleref is None:
raise ValueError("Object must have a style reference")
# Checking for errors
if an < 1 or an > 9:
raise ValueError("Alignment value must be an integer between 1 and 9")
# Setting default values
if fscx is None:
fscx = obj.styleref.scale_x
if fscy is None:
fscy = obj.styleref.scale_y
# Obtaining text converted to shape
shape = Convert.text_to_shape(obj, fscx, fscy)
# Setting mult_x based on alignment
if an % 3 == 1: # an=1 or an=4 or an=7
mult_x = 0
elif an % 3 == 2: # an=2 or an=5 or an=8
mult_x = 1 / 2
else:
mult_x = 1
# Setting mult_y based on alignment
if an < 4:
mult_y = 1
elif an < 7:
mult_y = 1 / 2
else:
mult_y = 0
# Calculating offsets
cx = (
obj.left
- obj.width * mult_x * (fscx - obj.styleref.scale_x) / obj.styleref.scale_x
)
cy = (
obj.top
- obj.height * mult_y * (fscy - obj.styleref.scale_y) / obj.styleref.scale_y
)
return shape.move(cx, cy)
[docs]
@staticmethod
def text_to_pixels(
obj: "Line | Word | Syllable | Char",
supersampling: int = 8,
) -> PixelCollection:
"""| Converts text with given style information to a PixelCollection.
| A pixel data is a dictionary containing 'x' (horizontal position), 'y' (vertical position) and 'alpha' (alpha/transparency).
It is highly suggested to create a dedicated style for pixels,
because you will write less tags for line in your pixels, which means less size for your .ass file.
| The style suggested (named "p" in the example) is:
| - **an=7 (very important!);**
| - bord=0;
| - shad=0;
| - For Font informations leave whatever the default is;
**Tips:** *It allows easy creation of text decaying or light effects.*
Parameters:
obj (Line, Word, Syllable or Char): An object of class Line, Word, Syllable or Char.
supersampling (int): Value used for supersampling. Higher value means smoother and more precise anti-aliasing (and more computational time for generation).
Returns:
A list of dictionaries representing each individual pixel of the input text styled.
Examples:
.. code-block:: python3
l.style = "p"
p_sh = Shape.polygon(4, 1)
for pixel in Convert.text_to_pixels(l):
x, y = math.floor(l.left) + pixel.x, math.floor(l.top) + pixel.y
alpha = "\\alpha" + Convert.alpha_dec_to_ass(pixel.alpha) if pixel.alpha != 0 else ""
l.text = "{\\p1\\pos(%d,%d)%s}%s" % (x, y, alpha, p_sh)
io.write_line(l)
"""
shape = Convert.text_to_shape(obj).move(obj.left % 1, obj.top % 1)
return Convert.shape_to_pixels(shape, supersampling)
[docs]
@staticmethod
def shape_to_pixels(
shape: "Shape", supersampling: int = 8, output_rgba: bool = False
) -> PixelCollection:
"""Converts a Shape object to a PixelCollection.
It is highly suggested to use a dedicated style for pixels,
because you will write less tags for line in your pixels, which means less size for your .ass file.
PyonFX provides ``io.insert_pixel_style()`` to take care of this for you,
so be sure to call it before using this function.
**Tips:** *As for text, even shapes can decay!*
Parameters:
shape (Shape): An object of class Shape.
supersampling (int): Supersampling factor (≥ 1). Higher values mean smoother anti-aliasing but slower generation.
output_rgba (bool): If True, output RGBA values instead of ASS color and alpha.
Returns:
A ``PixelCollection`` containing ``Pixel`` objects, representing each individual pixel of the input shape.
Each pixel contains 'x' (horizontal position), 'y' (vertical position) and 'alpha' (alpha/transparency).
"""
# Validate input
if supersampling < 1 or not isinstance(supersampling, int):
raise ValueError(
"supersampling must be a positive integer (got %r)" % supersampling
)
# Convert to Shapely geometry
multipolygon = shape.to_multipolygon()
if multipolygon.is_empty:
return PixelCollection([])
# Upscale and shift so the bbox is in +ve quadrant
multipolygon = _shapely_scale(
multipolygon, xfact=supersampling, yfact=supersampling, origin=(0.0, 0.0)
)
min_x, min_y, max_x, max_y = multipolygon.bounds
shift_x = -1 * (min_x - (min_x % supersampling))
shift_y = -1 * (min_y - (min_y % supersampling))
multipolygon = _shapely_translate(multipolygon, xoff=shift_x, yoff=shift_y)
# Compute high-res grid size (multiple of supersampling)
_, _, max_x, max_y = multipolygon.bounds
high_w = int(math.ceil(max_x))
high_h = int(math.ceil(max_y))
if high_w % supersampling:
high_w += supersampling - (high_w % supersampling)
if high_h % supersampling:
high_h += supersampling - (high_h % supersampling)
# Mark which high-res pixels fall inside the geometry (centre sampling)
xs = np.arange(0.5, high_w + 0.5, 1.0, dtype=np.float64)
ys = np.arange(0.5, high_h + 0.5, 1.0, dtype=np.float64)
X, Y = np.meshgrid(xs, ys)
mask = _shapely_contains(multipolygon, X, Y)
# Downsample mask to screen resolution
low_h = high_h // supersampling
low_w = high_w // supersampling
mask_rs = mask.reshape(low_h, supersampling, low_w, supersampling)
coverage_cnt = mask_rs.sum(axis=(1, 3))
# Convert coverage to alpha
denom = supersampling * supersampling
alpha_arr = np.rint((denom - coverage_cnt) * 255 / denom).astype(np.int16)
# Build output PixelCollection, skipping fully transparent pixels using vectorized selection
downscale = 1 / supersampling
shift_x_low = shift_x * downscale
shift_y_low = shift_y * downscale
non_transparent = np.argwhere(alpha_arr < 255)
pixels = [
Pixel(
x=int(xi - shift_x_low),
y=int(yi - shift_y_low),
alpha=(
int(alpha_arr[yi, xi])
if output_rgba
else Convert.alpha_dec_to_ass(int(alpha_arr[yi, xi]))
),
)
for yi, xi in non_transparent
]
return PixelCollection(pixels)
[docs]
@staticmethod
def image_to_pixels(
image_path: str,
width: int | None = None,
height: int | None = None,
skip_transparent: bool = True,
output_rgba: bool = False,
) -> PixelCollection:
"""Converts an image to a PixelCollection.
Parameters:
image_path (str): A file path to an image (either absolute or relative to the script).
width (int, optional): Target width for rescaling. If None, original width is used.
height (int, optional): Target height for rescaling. If None, original height is used.
If only one dimension is specified, aspect ratio is maintained.
skip_transparent (bool): If True, skip fully transparent pixels (i.e. alpha == 255).
output_rgba (bool): If True, output RGBA values instead of ASS color and alpha.
Returns:
A ``PixelCollection`` containing ``Pixel`` objects, each containing x, y, color, alpha values.
"""
dirname = os.path.dirname(os.path.abspath(sys.argv[0]))
if not os.path.isabs(image_path):
image_path = os.path.join(dirname, image_path)
try:
img = Image.open(image_path)
except Exception as e:
raise ValueError(f"Could not open image at '{image_path}': {e}")
if img.mode != "RGBA":
img = img.convert("RGBA")
# Rescale image if width or height is specified
if width is not None or height is not None:
try:
# If only one dimension is specified, maintain aspect ratio
original_width, original_height = img.size
if width is not None and height is None:
ratio = width / original_width
height = int(original_height * ratio)
elif height is not None and width is None:
ratio = height / original_height
width = int(original_width * ratio)
if width is not None and height is not None:
img = img.resize((width, height), Image.Resampling.LANCZOS)
except Exception as e:
raise ValueError(f"Error resizing image: {e}")
width, height = img.size
pixels_data = list(img.getdata()) # type: ignore[arg-type]
pixels = []
for i, (r, g, b, a) in enumerate(pixels_data):
if skip_transparent and a == 0:
continue
x = i % width
y = i // width
if output_rgba:
pixel_color = (r, g, b)
pixel_alpha = 255 - a
else:
pixel_color = Convert.color_rgb_to_ass((r, g, b))
pixel_alpha = Convert.alpha_dec_to_ass(255 - a)
pixels.append(Pixel(x=x, y=y, color=pixel_color, alpha=pixel_alpha))
return PixelCollection(pixels)