Files
2026-hypn0-site/hypn0/hypn0_site/services/halftone.py
T

284 lines
12 KiB
Python

import io
import math
import random
from collections import defaultdict
from typing import BinaryIO, Union
from PIL import Image
def encode_to_base36(num: int) -> str:
"""Кодирует неотрицательное число в компактный строковый идентификатор."""
if num < 0:
raise ValueError("Число должно быть неотрицательным")
charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
if num < len(charset):
return charset[num]
result = []
while num > 0:
result.append(charset[num % len(charset)])
num //= len(charset)
return "".join(reversed(result))
def generate_shape_def(shape: str, radius: int, shape_id: str) -> str:
"""Генерирует SVG-элемент для тега <defs> в зависимости от типа фигуры."""
r = radius
match shape:
case "ring":
stroke_width = max(1.0, r * 0.35)
inner_r = max(0.5, r - stroke_width / 2.0)
return f'<circle id="{shape_id}" r="{inner_r:.1f}" fill="none" stroke="currentColor" stroke-width="{stroke_width:.1f}"/>'
case "square":
size = r * 2
rx = max(0.5, r * 0.1)
return f'<rect id="{shape_id}" class="shape" x="{-r}" y="{-r}" width="{size}" height="{size}" rx="{rx:.1f}"/>'
case "diamond":
return f'<polygon id="{shape_id}" class="shape" points="0,{-r} {-r},0 0,{r} {r},0"/>'
case "triangle":
return f'<polygon id="{shape_id}" class="shape" points="0,{-r} {-r},{r} {r},{r}"/>'
case "hexagon":
w = round(r * 0.866)
h_half = round(r * 0.5)
return f'<polygon id="{shape_id}" class="shape" points="0,{-r} {w},{-h_half} {w},{h_half} 0,{r} {-w},{h_half} {-w},{-h_half}"/>'
case "star":
points = []
inner_r = r * 0.45
for i in range(10):
angle = i * math.pi / 5 - math.pi / 2
curr_r = r if i % 2 == 0 else inner_r
px = round(curr_r * math.cos(angle), 1)
py = round(curr_r * math.sin(angle), 1)
points.append(f"{px},{py}")
pts_str = " ".join(points)
return f'<polygon id="{shape_id}" class="shape" points="{pts_str}"/>'
case "cross":
arm = max(1, round(r * 0.35))
return f'<path id="{shape_id}" class="shape" d="M{-arm},{-r} H{arm} V{-arm} H{r} V{arm} H{arm} V{r} H{-arm} V{arm} H{-r} V{-arm} H{-arm} Z"/>'
case "line":
th = max(1, round(r * 0.25))
return f'<rect id="{shape_id}" class="shape" x="{-r}" y="{-th}" width="{2*r}" height="{2*th}" rx="1"/>'
case "wave":
th = max(1.0, r * 0.3)
return f'<path id="{shape_id}" class="shape" d="M{-r},0 Q{-r/2:.1f},{-r} 0,0 T{r},0" fill="none" stroke="currentColor" stroke-width="{th:.1f}"/>'
case "heart":
scale_f = r / 12.0
return (
f'<path id="{shape_id}" class="shape" transform="scale({scale_f:.3f}) translate(-12, -12)" '
f'd="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>'
)
case "circle" | _:
return f'<circle id="{shape_id}" class="shape" r="{r}"/>'
def generate_halftone_svg(
image: Union[Image.Image, BinaryIO, bytes, str],
*,
cols: int = 35,
max_radius: int = 8,
shape: str = "circle",
color: str = "#a855ff",
opacity: float = 0.9,
blink: int = 6,
rotation: int = 0,
scale: int = 980,
angle: int = 0,
animation_variants: int = 12,
seed: int = 42,
) -> str:
"""
Генерирует чистый оптимизированный SVG-халфтон из растрового изображения.
Параметры:
- image: PIL Image, файловый объект (BytesIO), байты или путь к файлу
- cols: число точек по наибольшей стороне сетки (10-200)
- max_radius: максимальный радиус/размер точки (3-20)
- shape: тип фигуры ('circle', 'ring', 'square', 'diamond', 'triangle', 'hexagon', 'star', 'cross', 'line', 'wave', 'heart')
- color: основной HEX цвет (например '#a855ff')
- opacity: коэффициент непрозрачности (0.0 - 1.0)
- blink: интенсивность мерцания (0 - выключено/статичный, 1-10 - скорость)
- rotation: угол покачивания в градусах (-15 .. +15)
- scale: масштаб пульсации (800 .. 1040, где 980 = 0.98)
- angle: наклон растровой сетки (-44 .. +45)
- animation_variants: число вариантов CSS-задержек
- seed: сид для детерминированного распределения анимаций
"""
rng = random.Random(seed)
# 1. Загрузка и подготовка изображения в градациях серого
if isinstance(image, (bytes, bytearray)):
pil_img = Image.open(io.BytesIO(image))
elif hasattr(image, "read"):
if hasattr(image, "seek"):
image.seek(0)
pil_img = Image.open(image)
elif isinstance(image, str):
pil_img = Image.open(image)
elif isinstance(image, Image.Image):
pil_img = image
else:
raise ValueError("Неподдерживаемый тип входного изображения")
# Конвертируем в Grayscale
img = pil_img.convert("L")
# Обработка наклона сетки (angle): поворачиваем изображение при необходимости
if angle != 0:
# Поворачиваем с сохранением пропорций и белым фоном (255 = прозрачно/пусто)
img = img.rotate(-angle, resample=Image.Resampling.BICUBIC, expand=True, fillcolor=255)
# Вычисляем размеры сетки: значение `cols` задает число точек по наибольшей стороне (ширине или высоте)
if img.width >= img.height:
grid_width = max(1, cols)
grid_height = max(1, round(cols * (img.height / img.width)))
else:
grid_height = max(1, cols)
grid_width = max(1, round(cols * (img.width / img.height)))
img_resized = img.resize((grid_width, grid_height), Image.Resampling.LANCZOS)
step = max_radius * 2 + 2
width = grid_width * step
height = grid_height * step
# 2. Формирование классов анимации
animation_classes = []
is_animated = blink > 0
# Длительность цикла анимации: при blink=10 -> 0.6s, при blink=1 -> 2.4s
duration = max(0.4, 2.5 - (blink * 0.19)) if is_animated else 1.0
for i in range(animation_variants):
delay = (i / animation_variants) * duration * 1.5
encoded_i = encode_to_base36(i)
delay_str = f"{int(delay)}" if delay == int(delay) else f"{delay:.2f}".rstrip("0").rstrip(".")
animation_classes.append(f".a{encoded_i}{{--d:{delay_str}s}}")
# 3. Обход пикселей и группировка
elements_by_class = defaultdict(list)
unique_radii = set()
for y in range(grid_height):
for x in range(grid_width):
brightness = img_resized.getpixel((x, y))
factor = (255 - brightness) / 255.0
# Отсекаем слишком светлые участки (шум фона)
if factor < 0.12:
continue
radius = int(factor * max_radius)
if radius == 0:
continue
cx = x * step + step // 2
cy = y * step + step // 2
anim_class = rng.randint(0, animation_variants - 1)
encoded_class = encode_to_base36(anim_class)
elements_by_class[encoded_class].append((radius, cx, cy))
unique_radii.add(radius)
# 4. Формирование <defs>
defs_list = ["<defs>"]
for radius in sorted(unique_radii):
shape_id = f"s{encode_to_base36(radius)}"
defs_list.append(generate_shape_def(shape, radius, shape_id))
defs_list.append("</defs>")
defs_html = "".join(defs_list)
# 5. Формирование групп <g class="a...">
groups = []
for anim_class in sorted(elements_by_class.keys()):
group_elements = elements_by_class[anim_class]
uses = "".join(
f'<use href="#s{encode_to_base36(radius)}" x="{cx}" y="{cy}"/>'
for radius, cx, cy in group_elements
)
if uses:
groups.append(f'<g class="a{anim_class}">{uses}</g>')
animation_css = "".join(animation_classes)
groups_html = "".join(groups)
# 6. Стилизация и Keyframes
scale_val = max(0.5, min(1.5, scale / 1000.0))
scale_str = f"{scale_val:.3f}".rstrip("0").rstrip(".")
rot_str = f"{rotation}deg"
# Корректный цвет с прозрачностью
clean_color = color.strip() if color else "#a855ff"
if not clean_color.startswith("#"):
clean_color = f"#{clean_color}"
# Добавляем альфа-канал в hex при необходимости или используем CSS opacity
fill_style = f"fill:{clean_color};stroke:{clean_color};opacity:{opacity:.2f};"
if is_animated:
anim_rule = f"animation:noise {duration:.2f}s ease-in-out infinite alternate;animation-delay:var(--d);"
keyframes_rule = (
f"@keyframes noise{{"
f"0%{{opacity:{max(0.2, opacity * 0.7):.2f};transform:scale(1) rotate(0deg)}}"
f"50%{{opacity:{opacity:.2f}}}"
f"100%{{opacity:{max(0.1, opacity * 0.5):.2f};transform:scale({scale_str}) rotate({rot_str})}}"
f"}}"
)
else:
anim_rule = "animation:none;"
keyframes_rule = ""
svg_css = (
f"<style>"
f"svg{{background:transparent}}"
f".shape,circle,rect,polygon,path{{{fill_style}transform-origin:center;{anim_rule}transition:all .5s ease-out}}"
f"{keyframes_rule}"
f".frozen .shape,.frozen circle,.frozen rect,.frozen polygon,.frozen path{{animation:none!important;opacity:{opacity:.2f}!important;transform:none!important}}"
f"{animation_css}"
f"</style>"
)
svg_template = (
f'<!-- Generated by Hypn0 Generator (https://hypn0.ru) -->\n'
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" width="100%" height="100%">'
f'{svg_css}'
f'{defs_html}'
f'<g id="hypn0-canvas">{groups_html}</g>'
f'</svg>'
)
return svg_template
def prepare_gallery_svg(svg_content: str) -> str:
"""
Модифицирует SVG для долговременного хранения в галерее:
добавляет CSS-правила, чтобы в изолированном контексте (<img>) анимация
находилась на паузе по умолчанию и запускалась только при наведении (:hover),
предотвращая перегрузку CPU/GPU браузера при выводе сетки карточек.
"""
if not svg_content:
return svg_content
hover_css = (
"svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,"
"svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}"
)
if "</style>" in svg_content:
return svg_content.replace("</style>", f"{hover_css}</style>", 1)
return svg_content