fractex 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fractex/interactive.py ADDED
@@ -0,0 +1,158 @@
1
+ """Helpers for interactive rendering with adaptive quality."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable, Optional, Tuple
7
+ import itertools
8
+ import time
9
+
10
+ import numpy as np
11
+
12
+
13
+ def add_interactive_args(parser) -> None:
14
+ parser.add_argument("--interactive", action="store_true", help="Run interactive view")
15
+ parser.add_argument("--scale", type=float, default=1.0, help="Display scale multiplier")
16
+ parser.add_argument("--fps", type=float, default=30.0, help="Target FPS")
17
+ parser.add_argument("--width", type=int, default=None, help="Override width")
18
+ parser.add_argument("--height", type=int, default=None, help="Override height")
19
+
20
+
21
+ def add_preset_arg(parser, presets, default: Optional[str] = None, dest: str = "preset") -> None:
22
+ parser.add_argument(
23
+ "--preset",
24
+ choices=presets,
25
+ default=default,
26
+ dest=dest,
27
+ help="Preset name",
28
+ )
29
+
30
+
31
+ def resolve_preset(value: Optional[str], presets, fallback: Optional[str] = None) -> Optional[str]:
32
+ if value is None:
33
+ return fallback
34
+ if value not in presets:
35
+ raise ValueError(f"Unknown preset '{value}'. Available: {', '.join(presets)}")
36
+ return value
37
+
38
+
39
+ def _ensure_rgb(image: np.ndarray) -> np.ndarray:
40
+ if image.ndim == 2:
41
+ image = np.repeat(image[:, :, None], 3, axis=2)
42
+ if image.shape[2] == 1:
43
+ image = np.repeat(image, 3, axis=2)
44
+ if image.shape[2] >= 3:
45
+ return image[:, :, :3]
46
+ raise ValueError("Unsupported image shape for RGB conversion.")
47
+
48
+
49
+ def resize_nearest(image: np.ndarray, out_w: int, out_h: int) -> np.ndarray:
50
+ if image.ndim == 2:
51
+ image = image[:, :, None]
52
+ in_h, in_w, channels = image.shape
53
+ if in_w == out_w and in_h == out_h:
54
+ return image
55
+ scale_x = max(1, out_w // in_w)
56
+ scale_y = max(1, out_h // in_h)
57
+ up = np.repeat(np.repeat(image, scale_y, axis=0), scale_x, axis=1)
58
+ up = up[:out_h, :out_w, :]
59
+ return up if channels > 1 else up[:, :, 0]
60
+
61
+
62
+ def get_screen_size(default: Tuple[int, int] = (1920, 1080)) -> Tuple[int, int]:
63
+ try:
64
+ import tkinter as tk
65
+ root = tk.Tk()
66
+ root.withdraw()
67
+ screen_width = root.winfo_screenwidth()
68
+ screen_height = root.winfo_screenheight()
69
+ root.destroy()
70
+ return int(screen_width), int(screen_height)
71
+ except Exception:
72
+ return default
73
+
74
+
75
+ @dataclass
76
+ class InteractiveConfig:
77
+ title: str = "Fractex"
78
+ target_fps: float = 30.0
79
+ scale: float = 1.0
80
+ width: Optional[int] = None
81
+ height: Optional[int] = None
82
+ min_scale: float = 0.4
83
+ max_scale: float = 1.0
84
+ min_render: int = 64
85
+
86
+ @classmethod
87
+ def from_args(cls, args, title: Optional[str] = None) -> "InteractiveConfig":
88
+ return cls(
89
+ title=title or "Fractex",
90
+ target_fps=max(1.0, getattr(args, "fps", 30.0)),
91
+ scale=max(0.1, getattr(args, "scale", 1.0)),
92
+ width=getattr(args, "width", None),
93
+ height=getattr(args, "height", None),
94
+ )
95
+
96
+
97
+ def run_interactive(
98
+ render_frame: Callable[[float, int, int], np.ndarray],
99
+ config: InteractiveConfig,
100
+ ) -> None:
101
+ try:
102
+ import matplotlib.pyplot as plt
103
+ from matplotlib.animation import FuncAnimation
104
+ except Exception:
105
+ print("matplotlib is not available; cannot display interactive output.")
106
+ return
107
+
108
+ screen_w, screen_h = get_screen_size()
109
+ width = config.width or int(screen_w * config.scale)
110
+ height = config.height or int(screen_h * config.scale)
111
+ width = max(config.min_render, width)
112
+ height = max(config.min_render, height)
113
+
114
+ fig, ax = plt.subplots()
115
+ dpi = fig.get_dpi()
116
+ fig.set_size_inches(width / dpi, height / dpi)
117
+ ax.axis("off")
118
+ ax.set_title(config.title)
119
+ fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
120
+
121
+ target_ms = 1000.0 / max(1.0, config.target_fps)
122
+ render_scale = 1.0
123
+ last_time = time.perf_counter()
124
+ ema_ms = target_ms
125
+
126
+ frame0 = render_frame(0.0, width, height)
127
+ im = ax.imshow(_ensure_rgb(frame0), animated=True, aspect="auto")
128
+
129
+ def update(frame):
130
+ nonlocal render_scale, last_time, ema_ms
131
+ now = time.perf_counter()
132
+ dt_ms = (now - last_time) * 1000.0
133
+ last_time = now
134
+ ema_ms = ema_ms * 0.9 + dt_ms * 0.1
135
+
136
+ if ema_ms > target_ms * 1.1:
137
+ render_scale = max(config.min_scale, render_scale * 0.9)
138
+ elif ema_ms < target_ms * 0.9:
139
+ render_scale = min(config.max_scale, render_scale * 1.05)
140
+
141
+ render_w = max(config.min_render, int(width * render_scale))
142
+ render_h = max(config.min_render, int(height * render_scale))
143
+ t = frame / 10.0
144
+ frame_img = render_frame(t, render_w, render_h)
145
+ frame_img = resize_nearest(frame_img, width, height)
146
+ im.set_array(_ensure_rgb(frame_img))
147
+ return (im,)
148
+
149
+ anim = FuncAnimation(
150
+ fig,
151
+ update,
152
+ frames=itertools.count(),
153
+ interval=0,
154
+ blit=True,
155
+ repeat=True,
156
+ cache_frame_data=False,
157
+ )
158
+ plt.show(block=True)