From c576084d53d7c65508a530f6025911459d310fae Mon Sep 17 00:00:00 2001 From: erjemin Date: Sun, 6 Sep 2026 17:37:15 +0300 Subject: [PATCH] =?UTF-8?q?mod:=20=D1=8F=D0=B4=D1=80=D0=BE=20(16)=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=BA=D0=BB=D0=BE=D0=BD=20=D1=81=D0=B5=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hypn0/hypn0_site/services/halftone.py | 102 +++++++++++++++++++------- hypn0/hypn0_site/tests.py | 10 +++ 2 files changed, 85 insertions(+), 27 deletions(-) diff --git a/hypn0/hypn0_site/services/halftone.py b/hypn0/hypn0_site/services/halftone.py index e92e7dd..8f4b85b 100644 --- a/hypn0/hypn0_site/services/halftone.py +++ b/hypn0/hypn0_site/services/halftone.py @@ -146,18 +146,9 @@ def generate_halftone_svg( - animation_variants: число вариантов CSS-задержек - seed: сид для детерминированного распределения анимаций - TODO ПОЧИНИТЬ-УЛУЧШИТЬ: - - angle: сейчас это просто поворот картинки. Нужно чтобы: - - нужно чтобы это был поворт картинки (т.е. ряды-столбцы были со смещение-поворотом, как при полиграфии) - - blink: что-то страннное. пока не понял - - -- ПРОЧЕЕ: - -- C "кольцами" что-то не так (они кружочки) - -- Проверить: scale и rotation кажется не вокруг центра. - -- ЕСЛИ ЦВЕТ ЧЕРЕЗ ИНТЕРФЕЙС НЕ МЕНЯЛСЯ (ДЕФОЛТНЫЙ), ТО ЛУЧШЕ (???) НАЗНАЧАТЬ СЛУЧАНЫЙ (или из набора). - А то слишком однообразные цвета герераций в галерее - -- Если задан большой размер точки, rotation или scale -- то нужно делать "паспорту", иначе точки "вылезают" - за пределы ViewPort + ПОЧИНИТЬ-УЛУЧШИТЬ: + -- ЕСЛИ ЦВЕТ ЧЕРЕЗ ИНТЕРФЕЙС НЕ МЕНЯЛСЯ (ДЕФОЛТНЫЙ), ТО ЛУЧШЕ (???) НАЗНАЧАТЬ СЛУЧАЙНЫЙ (или из набора). + А то слишком однообразные цвета генераций в галерее """ rng = random.Random(seed) @@ -178,12 +169,8 @@ def generate_halftone_svg( # Конвертируем в Grayscale img = pil_img.convert("L") - # Обработка наклона сетки (angle): поворачиваем изображение при необходимости - if angle != 0: - # Поворачиваем с сохранением пропорций и белым фоном (255 = прозрачно/пусто) - img = img.rotate(-angle, resample=Image.Resampling.BICUBIC, expand=True, fillcolor=255) - - # Вычисляем размеры сетки: значение `cols` задает число точек по наибольшей стороне (ширине или высоте) + # Вычисляем размеры базовой сетки по исходным пропорциям изображения + # Значение `cols` задает число ячеек по наибольшей стороне (ширине или высоте) if img.width >= img.height: grid_width = max(1, cols) grid_height = max(1, round(cols * (img.height / img.width))) @@ -192,6 +179,7 @@ def generate_halftone_svg( grid_width = max(1, round(cols * (img.width / img.height))) img_resized = img.resize((grid_width, grid_height), Image.Resampling.LANCZOS) + img_w, img_h = img_resized.width, img_resized.height # Базовый шаг ячейки сетки (номинальный радиус касания точек R0 = 10, диаметр D0 = 20) STEP = 20 @@ -206,15 +194,76 @@ def generate_halftone_svg( width = grid_width * STEP + 2 * margin height = grid_height * STEP + 2 * margin - # 2. Обход пикселей и группировка + # Функция билинейной интерполяции для сэмплирования яркости в дробных координатах сетки + def sample_brightness(fx: float, fy: float) -> float: + """Возвращает интерполированную яркость (0-255) в нормализованных координатах img_resized.""" + if fx < 0 or fx > img_w - 1 or fy < 0 or fy > img_h - 1: + # За пределами изображения считаем фон белым (255) + if fx < -0.5 or fx > img_w - 0.5 or fy < -0.5 or fy > img_h - 0.5: + return 255.0 + fx = max(0.0, min(img_w - 1.0, fx)) + fy = max(0.0, min(img_h - 1.0, fy)) + + x0 = int(math.floor(fx)) + y0 = int(math.floor(fy)) + x1 = min(x0 + 1, img_w - 1) + y1 = min(y0 + 1, img_h - 1) + + wx = fx - x0 + wy = fy - y0 + + p00 = img_resized.getpixel((x0, y0)) + p10 = img_resized.getpixel((x1, y0)) + p01 = img_resized.getpixel((x0, y1)) + p11 = img_resized.getpixel((x1, y1)) + + top = p00 * (1.0 - wx) + p10 * wx + bottom = p01 * (1.0 - wx) + p11 * wx + return top * (1.0 - wy) + bottom * wy + + # 2. Обход узлов наклонной сетки и группировка elements_by_class = defaultdict(list) unique_shape_keys = set() is_animated = blink > 0 - for y in range(grid_height): - for x in range(grid_width): - brightness = img_resized.getpixel((x, y)) - factor = (255 - brightness) / 255.0 + # Центр изображения на холсте (в координатах SVG без марджина) + canvas_cx = (grid_width * STEP) / 2.0 + canvas_cy = (grid_height * STEP) / 2.0 + + rad = math.radians(angle) + cos_a = math.cos(rad) + sin_a = math.sin(rad) + + # Векторы шага сетки вдоль осей U (строка) и V (столбец) + dx_u, dy_u = STEP * cos_a, STEP * sin_a + dx_v, dy_v = -STEP * sin_a, STEP * cos_a + + # Диапазон охвата узлов сетки с запасом, чтобы перекрыть весь повернутый холст + max_dim = math.hypot(grid_width, grid_height) + u_range = int(math.ceil(max_dim / 2.0)) + 2 + v_range = int(math.ceil(max_dim / 2.0)) + 2 + + # Перебираем узлы сетки вокруг центра + for v in range(-v_range, v_range + 1): + for u in range(-u_range, u_range + 1): + # Точные вещественные координаты узла относительно центра холста + rel_x = u * dx_u + v * dx_v + rel_y = u * dy_u + v * dy_v + + # Координаты на видимом холсте (от 0 до grid_width * STEP) + pos_x = canvas_cx + rel_x + pos_y = canvas_cy + rel_y + + # Проверяем, попадает ли узел в пределы видимой области холста + if not (0 <= pos_x <= grid_width * STEP and 0 <= pos_y <= grid_height * STEP): + continue + + # Координаты в сетке img_resized + sample_fx = (pos_x / (grid_width * STEP)) * (img_w - 1) + sample_fy = (pos_y / (grid_height * STEP)) * (img_h - 1) + + brightness = sample_brightness(sample_fx, sample_fy) + factor = (255.0 - brightness) / 255.0 # Отсекаем слишком светлые участки (шум фона) if factor < 0.12: @@ -224,16 +273,15 @@ def generate_halftone_svg( if radius == 0: continue - cx = margin + x * STEP + STEP // 2 - cy = margin + y * STEP + STEP // 2 + # Итоговые координаты центра точки в SVG (с учетом паспарту-margin) + cx = round(margin + pos_x) + cy = round(margin + pos_y) anim_index = rng.randint(0, animation_variants - 1) if is_animated else 0 encoded_class = encode_to_base36(anim_index) if shape == "binary": # Вероятность выпадения '0' плавно растет с оптической плотностью - # В тенях преобладают '0' (~85%), но с шансом ~15% выскакивают '1' - # В светах преобладают '1' (~85%), но с шансом ~15% выскакивают '0' prob_zero = 0.15 + 0.70 * (radius / max_radius if max_radius else factor) char = "0" if rng.random() < prob_zero else "1" shape_id = f"s{encode_to_base36(radius)}{char}" diff --git a/hypn0/hypn0_site/tests.py b/hypn0/hypn0_site/tests.py index 2a19ad7..560be47 100644 --- a/hypn0/hypn0_site/tests.py +++ b/hypn0/hypn0_site/tests.py @@ -174,6 +174,16 @@ class HalftoneServiceTests(BaseMediaTestCase): svg_wide = generate_halftone_svg(wide_img, cols=30, max_radius=5) self.assertIn('viewBox="0 0 608 208"', svg_wide) + def test_generate_screen_angles(self): + # Проверяем поворот растровой сетки под разными углами (-45, 0, 15, 45) + # Холст должен сохранять прямые пропорции (viewBox), а внутри генерироваться точки + for angle in [-45, -15, 0, 15, 45]: + svg = generate_halftone_svg(self.img, cols=20, max_radius=6, angle=angle) + self.assertIn("