mod: ядро (14) циферки в виде фигур

This commit is contained in:
2026-09-06 00:55:22 +03:00
parent 217056b37b
commit 5904fc675d
4 changed files with 53 additions and 19 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ class HalftoneGenerateForm(forms.Form):
("cross", "Крестик"), ("cross", "Крестик"),
("line", "Линия"), ("line", "Линия"),
("snowflake", "Снежинка"), ("snowflake", "Снежинка"),
("wave", "Волна"), ("binary", "Матрица (0/1)"),
("heart", "Сердечко"), ("heart", "Сердечко"),
] ]
+42 -15
View File
@@ -3,7 +3,7 @@ import math
import random import random
import re import re
from collections import defaultdict from collections import defaultdict
from typing import BinaryIO, Union from typing import BinaryIO, Optional, Union
from PIL import Image from PIL import Image
@@ -24,7 +24,9 @@ def encode_to_base36(num: int) -> str:
return "".join(reversed(result)) return "".join(reversed(result))
def generate_shape_def(shape: str, radius: int, shape_id: str) -> str: def generate_shape_def(
shape: str, radius: int, shape_id: str, max_radius: int = 8, char: Optional[str] = None
) -> str:
"""Генерирует SVG-элемент для тега <defs> в зависимости от типа фигуры.""" """Генерирует SVG-элемент для тега <defs> в зависимости от типа фигуры."""
r = radius r = radius
match shape: match shape:
@@ -88,6 +90,18 @@ def generate_shape_def(shape: str, radius: int, shape_id: str) -> str:
pts_str = " ".join(points) pts_str = " ".join(points)
return f'<polygon id="{shape_id}" points="{pts_str}"/>' return f'<polygon id="{shape_id}" points="{pts_str}"/>'
case "binary":
if char is None:
# Вероятностная функция: вероятность "0" пропорциональна оптической плотности
# В тенях преобладают '0' (~85%), но с шансом ~15% выскакивают '1'
# В светах преобладают '1' (~85%), но с шансом ~15% выскакивают '0'
prob_zero = min(0.85, max(0.15, (r / max_radius) if max_radius else 0.5))
char = "0" if random.random() < prob_zero else "1"
font_size = max(4, round(r * 2.0))
return (
f'<text id="{shape_id}" font-family="ui-monospace,monospace" font-weight="900" font-size="{font_size}">{char}</text>'
)
case "heart": case "heart":
r_top = round(r * 0.3) r_top = round(r * 0.3)
r_mid = round(r * 0.4) r_mid = round(r * 0.4)
@@ -194,7 +208,7 @@ def generate_halftone_svg(
# 2. Обход пикселей и группировка # 2. Обход пикселей и группировка
elements_by_class = defaultdict(list) elements_by_class = defaultdict(list)
unique_radii = set() unique_shape_keys = set()
is_animated = blink > 0 is_animated = blink > 0
for y in range(grid_height): for y in range(grid_height):
@@ -216,14 +230,24 @@ def generate_halftone_svg(
anim_index = rng.randint(0, animation_variants - 1) if is_animated else 0 anim_index = rng.randint(0, animation_variants - 1) if is_animated else 0
encoded_class = encode_to_base36(anim_index) encoded_class = encode_to_base36(anim_index)
elements_by_class[encoded_class].append((radius, cx, cy, anim_index)) if shape == "binary":
unique_radii.add(radius) # Вероятность выпадения '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}"
unique_shape_keys.add((radius, char, shape_id))
else:
shape_id = f"s{encode_to_base36(radius)}"
unique_shape_keys.add((radius, None, shape_id))
elements_by_class[encoded_class].append((shape_id, cx, cy, anim_index))
# 3. Формирование <defs> # 3. Формирование <defs>
defs_list = ["<defs>"] defs_list = ["<defs>"]
for radius in sorted(unique_radii): for radius, char, shape_id in sorted(unique_shape_keys, key=lambda k: (k[0], k[1] or "")):
shape_id = f"s{encode_to_base36(radius)}" defs_list.append(generate_shape_def(shape, radius, shape_id, max_radius=max_radius, char=char))
defs_list.append(generate_shape_def(shape, radius, shape_id))
defs_list.append("</defs>") defs_list.append("</defs>")
defs_html = "".join(defs_list) defs_html = "".join(defs_list)
@@ -254,8 +278,8 @@ def generate_halftone_svg(
animation_classes.append(f".a{encoded_class}{{--d:{delay_str}s}}") animation_classes.append(f".a{encoded_class}{{--d:{delay_str}s}}")
uses = "".join( uses = "".join(
f'<use href="#s{encode_to_base36(radius)}" x="{cx}" y="{cy}"/>' f'<use href="#{shape_id}" x="{cx}" y="{cy}"/>'
for radius, cx, cy, _ in group_elements for shape_id, cx, cy, _ in group_elements
) )
if uses: if uses:
groups.append(f'<g class="a{encoded_class}">{uses}</g>') groups.append(f'<g class="a{encoded_class}">{uses}</g>')
@@ -299,9 +323,9 @@ def generate_halftone_svg(
svg_css = ( svg_css = (
f"<style>" f"<style>"
f"svg{{background:transparent}}" f"svg{{background:transparent}}"
f"circle,rect,polygon,path{{{fill_style}transform-origin:center;{anim_rule}transition:all .5s ease-out}}" f"circle,rect,polygon,path,text{{{fill_style}transform-origin:center;{anim_rule}transition:all .5s ease-out}}"
f"{keyframes_rule}" f"{keyframes_rule}"
f".frozen circle,.frozen rect,.frozen polygon,.frozen path{{animation:none!important;opacity:{opacity:.1f}!important;transform:none!important}}" f".frozen circle,.frozen rect,.frozen polygon,.frozen path,.frozen text{{animation:none!important;opacity:{opacity:.1f}!important;transform:none!important}}"
f"{animation_css}" f"{animation_css}"
f"</style>" f"</style>"
) )
@@ -340,7 +364,7 @@ def prepare_gallery_svg(svg_content: str) -> str:
hover_css = ( hover_css = (
"svg{--hypn0-play:paused}" "svg{--hypn0-play:paused}"
":host(:hover) svg,svg:hover{--hypn0-play:running!important}" ":host(:hover) svg,svg:hover{--hypn0-play:running!important}"
"circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}" "circle,rect,polygon,path,text{animation-play-state:var(--hypn0-play,paused)!important}"
) )
if "</style>" in svg_content: if "</style>" in svg_content:
@@ -364,8 +388,10 @@ def prepare_active_svg(svg_content: str) -> str:
gallery_css_variants = [ gallery_css_variants = [
"svg{--hypn0-play:paused}:host(:hover) svg,svg:hover{--hypn0-play:running!important}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}", "svg{--hypn0-play:paused}:host(:hover) svg,svg:hover{--hypn0-play:running!important}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}",
"svg{--hypn0-play:paused}:host(:hover) svg,svg:hover{--hypn0-play:running!important}circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}", "svg{--hypn0-play:paused}:host(:hover) svg,svg:hover{--hypn0-play:running!important}circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}",
"svg{--hypn0-play:paused}:host(:hover) svg,svg:hover{--hypn0-play:running!important}circle,rect,polygon,path,text{animation-play-state:var(--hypn0-play,paused)!important}",
"svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}", "svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}",
"svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}", "svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}",
"svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}circle,rect,polygon,path,text{animation-play-state:var(--hypn0-play,paused)!important}",
] ]
cleaned = svg_content cleaned = svg_content
for gcss in gallery_css_variants: for gcss in gallery_css_variants:
@@ -374,7 +400,7 @@ def prepare_active_svg(svg_content: str) -> str:
cleaned = re.sub(r"svg\s*\{[^}]*--hypn0-play:\s*paused[^}]*\}", "", cleaned) cleaned = re.sub(r"svg\s*\{[^}]*--hypn0-play:\s*paused[^}]*\}", "", cleaned)
cleaned = re.sub(r"(:host\(:hover\)\s*svg\s*,\s*)?svg:hover\s*\{[^}]*--hypn0-play:[^}]*\}", "", cleaned) cleaned = re.sub(r"(:host\(:hover\)\s*svg\s*,\s*)?svg:hover\s*\{[^}]*--hypn0-play:[^}]*\}", "", cleaned)
cleaned = re.sub( cleaned = re.sub(
r"(\.shape,)?circle,rect,polygon,path\s*\{animation-play-state:\s*var\(--hypn0-play,\s*paused\)!important\}", r"(\.shape,)?circle,rect,polygon,path(,text)?\s*\{animation-play-state:\s*var\(--hypn0-play,\s*paused\)!important\}",
"", "",
cleaned, cleaned,
) )
@@ -423,7 +449,8 @@ def analyze_svg_structure(svg_content: str) -> dict:
rect_count = len(re.findall(r"<rect\b", svg_content)) rect_count = len(re.findall(r"<rect\b", svg_content))
polygon_count = len(re.findall(r"<polygon\b", svg_content)) polygon_count = len(re.findall(r"<polygon\b", svg_content))
path_count = len(re.findall(r"<path\b", svg_content)) path_count = len(re.findall(r"<path\b", svg_content))
defs_count = circle_count + rect_count + polygon_count + path_count text_count = len(re.findall(r"<text\b", svg_content))
defs_count = circle_count + rect_count + polygon_count + path_count + text_count
# Группы и классы # Группы и классы
groups_count = len(re.findall(r"<g\b", svg_content)) groups_count = len(re.findall(r"<g\b", svg_content))
+8 -1
View File
@@ -78,7 +78,7 @@ class HalftoneServiceTests(BaseMediaTestCase):
self.assertIn("<desc>Generated by Hypn0", svg) self.assertIn("<desc>Generated by Hypn0", svg)
def test_generate_shapes(self): def test_generate_shapes(self):
shapes = ["circle", "ring", "square", "diamond", "triangle", "hexagon", "star", "cross", "line", "snowflake", "heart"] shapes = ["circle", "ring", "square", "diamond", "triangle", "hexagon", "star", "cross", "line", "snowflake", "binary", "heart"]
for shape in shapes: for shape in shapes:
svg = generate_halftone_svg(self.img, cols=20, max_radius=5, shape=shape) svg = generate_halftone_svg(self.img, cols=20, max_radius=5, shape=shape)
self.assertIn("<svg", svg) self.assertIn("<svg", svg)
@@ -100,6 +100,13 @@ class HalftoneServiceTests(BaseMediaTestCase):
self.assertIn('<polygon id="s', snowflake_svg) self.assertIn('<polygon id="s', snowflake_svg)
self.assertIn('points="', snowflake_svg) self.assertIn('points="', snowflake_svg)
# Проверяем структуру матрицы 0/1: текстовый моноширинный элемент и наличие обоих символов 0 и 1
binary_svg = generate_halftone_svg(self.img, cols=20, max_radius=8, shape="binary")
self.assertIn('<text id="s', binary_svg)
self.assertIn('font-family="ui-monospace,monospace"', binary_svg)
self.assertIn(">0</text>", binary_svg)
self.assertIn(">1</text>", binary_svg)
def test_generate_static_blink_zero(self): def test_generate_static_blink_zero(self):
svg = generate_halftone_svg(self.img, cols=20, blink=0) svg = generate_halftone_svg(self.img, cols=20, blink=0)
self.assertIn("animation:none", svg) self.assertIn("animation:none", svg)
+2 -2
View File
@@ -260,8 +260,8 @@
</div> </div>
</label> </label>
<!-- 12. Двоичный блок 3x3 --> <!-- 12. Двоичный блок 2x2 -->
<label class="cursor-pointer group" title="Матрица 3x3 (0/1)"> <label class="cursor-pointer group" title="Матрица 2x2 (0/1)">
<input type="radio" name="shape" value="binary" class="hidden peer"> <input type="radio" name="shape" value="binary" class="hidden peer">
<div class="aspect-square flex flex-col items-center justify-center p-1 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700"> <div class="aspect-square flex flex-col items-center justify-center p-1 border-2 border-stone-100 dark:border-zinc-800 rounded-xl text-stone-400 group-hover:text-emerald-500 peer-checked:text-emerald-500 peer-checked:border-emerald-500 peer-checked:bg-emerald-50/10 transition-all hover:border-stone-300 dark:hover:border-zinc-700">
<div class="grid grid-cols-2 gap-0.5 font-mono text-[14px] leading-[12px] font-black transition-colors select-none"> <div class="grid grid-cols-2 gap-0.5 font-mono text-[14px] leading-[12px] font-black transition-colors select-none">