From 4917e04e5a304ca34b50c72684d40bee46c2acbb Mon Sep 17 00:00:00 2001 From: erjemin Date: Sun, 23 Aug 2026 22:53:57 +0300 Subject: [PATCH] =?UTF-8?q?add:=20=D1=8F=D0=B4=D1=80=D0=BE=20(02)=20=D0=B4?= =?UTF-8?q?=D1=80=D0=B0=D1=84=D1=82...?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hypn0/hypn0_site/services/__init__.py | 1 + hypn0/hypn0_site/services/halftone.py | 262 ++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 hypn0/hypn0_site/services/__init__.py create mode 100644 hypn0/hypn0_site/services/halftone.py diff --git a/hypn0/hypn0_site/services/__init__.py b/hypn0/hypn0_site/services/__init__.py new file mode 100644 index 0000000..277aae5 --- /dev/null +++ b/hypn0/hypn0_site/services/__init__.py @@ -0,0 +1 @@ +# Services package for hypn0_site diff --git a/hypn0/hypn0_site/services/halftone.py b/hypn0/hypn0_site/services/halftone.py new file mode 100644 index 0000000..b091c25 --- /dev/null +++ b/hypn0/hypn0_site/services/halftone.py @@ -0,0 +1,262 @@ +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-элемент для тега в зависимости от типа фигуры.""" + 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'' + + case "square": + size = r * 2 + rx = max(0.5, r * 0.1) + return f'' + + case "diamond": + return f'' + + case "triangle": + return f'' + + case "hexagon": + w = round(r * 0.866) + h_half = round(r * 0.5) + return f'' + + 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'' + + case "cross": + arm = max(1, round(r * 0.35)) + return f'' + + case "line": + th = max(1, round(r * 0.25)) + return f'' + + case "wave": + th = max(1.0, r * 0.3) + return f'' + + case "heart": + scale_f = r / 12.0 + return ( + f'' + ) + + case "circle" | _: + return f'' + + +def generate_halftone_svg( + image: Union[Image.Image, BinaryIO, bytes, str], + *, + cols: int = 80, + 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_list = [""] + 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_html = "".join(defs_list) + + # 5. Формирование групп + groups = [] + for anim_class in sorted(elements_by_class.keys()): + group_elements = elements_by_class[anim_class] + uses = "".join( + f'' + for radius, cx, cy in group_elements + ) + if uses: + groups.append(f'{uses}') + + 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"" + ) + + svg_template = ( + f'\n' + f'' + f'{svg_css}' + f'{defs_html}' + f'{groups_html}' + f'' + ) + + return svg_template