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 = 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_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 def prepare_gallery_svg(svg_content: str) -> str: """ Модифицирует SVG для долговременного хранения в галерее: добавляет CSS-правила, чтобы в изолированном контексте () анимация находилась на паузе по умолчанию и запускалась только при наведении (: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 "" in svg_content: return svg_content.replace("", f"{hover_css}", 1) return svg_content