1+ """
2+ Generate an mp4 loop video illustrating how the Ebbinghaus illusion parameters
3+ (illusion_strength and difference) affect the image.
4+
5+ The video follows a continuous smooth sequence:
6+ 1. Difference: 0 -> negative max -> positive max
7+ 2. Illusion strength: 0 -> positive max -> negative max
8+ 3. Difference -> 0
9+ 4. Illusion strength -> 0
10+ """
11+
12+ import os
13+ import sys
14+
15+ # Ensure the project root is on sys.path so pyllusion can be imported
16+ # regardless of the working directory from which the script is invoked.
17+ _HERE = os .path .dirname (os .path .abspath (__file__ ))
18+ _PROJECT_ROOT = os .path .dirname (os .path .dirname (_HERE ))
19+ if _PROJECT_ROOT not in sys .path :
20+ sys .path .insert (0 , _PROJECT_ROOT )
21+
22+ import imageio
23+ import numpy as np
24+ import PIL .Image
25+ import PIL .ImageDraw
26+ import PIL .ImageFont
27+
28+ import pyllusion
29+
30+
31+ # ── Video settings ─────────────────────────────────────────────────────────────
32+ FPS = 24
33+ WIDTH = 800
34+ HEIGHT = 544 # divisible by 16 for codec compatibility
35+ OUTPUT = "ebbinghaus_parameters.mp4"
36+
37+ # ── Layout constants ────────────────────────────────────────────────────────
38+ LEFT_BAR = 100 # width of vertical illusion_strength slider strip
39+ TOP_BAR = 50 # height of title strip
40+ BOT_BAR = 90 # height of bottom info strip
41+
42+
43+ # ── Helpers ────────────────────────────────────────────────────────────────────
44+
45+ def _get_font (size , bold = False ):
46+ """Load a TrueType font, falling back to the PIL default."""
47+ names = ["arialbd.ttf" , "C:/Windows/Fonts/arialbd.ttf" ] if bold \
48+ else ["arial.ttf" , "C:/Windows/Fonts/arial.ttf" ]
49+ for name in names :
50+ try :
51+ return PIL .ImageFont .truetype (name , size )
52+ except OSError :
53+ pass
54+ return PIL .ImageFont .load_default ()
55+
56+
57+ def _add_labels (img : PIL .Image .Image , strength : float , difference : float ) -> PIL .Image .Image :
58+ """Overlay all UI chrome (title, sliders, labels) onto the canvas."""
59+
60+ draw = PIL .ImageDraw .Draw (img )
61+
62+ # ── "Ebbinghaus" centred bold title ─────────────────────────────────────
63+ font_title = _get_font (28 , bold = True )
64+ bbox = draw .textbbox ((0 , 0 ), "Ebbinghaus" , font = font_title )
65+ tw , th = bbox [2 ] - bbox [0 ], bbox [3 ] - bbox [1 ]
66+ draw .text (((img .width - tw ) // 2 , (TOP_BAR - th ) // 2 ),
67+ "Ebbinghaus" , fill = "black" , font = font_title )
68+
69+ # ── Shared Slider Properties ─────────────────────────────────────────────
70+ TRACK_W = 6 # half-thickness (12px total thickness)
71+ TRACK_BG = (220 , 220 , 220 ) # grey empty track
72+ ILL_TRACK_FG = (100 , 150 , 200 ) # blue filled track (strength)
73+ ILL_DOT_FG = (80 , 130 , 220 ) # blue dot color (strength)
74+ DIFF_TRACK_FG = (220 , 80 , 80 ) # red filled track (difference)
75+ DIFF_DOT_FG = (220 , 50 , 50 ) # red dot color (difference)
76+ DOT_R = 8 # dot radius
77+ font_slider_title = _get_font (18 , bold = True )
78+
79+ # ── 1. Left Vertical Slider (Illusion Strength) ──────────────────────────
80+ SX = 70
81+ S_TOP = TOP_BAR + 20
82+ S_BOT = img .height - BOT_BAR - 20
83+ track_h = S_BOT - S_TOP
84+ zero_y = (S_TOP + S_BOT ) // 2
85+
86+ # track and zero-tick
87+ draw .rectangle ([(SX - TRACK_W , S_TOP ), (SX + TRACK_W , S_BOT )], fill = TRACK_BG )
88+ draw .line ([(SX - TRACK_W - 4 , zero_y ), (SX + TRACK_W + 4 , zero_y )], fill = (160 , 160 , 160 ), width = 2 )
89+
90+ # calculate position and draw fill/dot
91+ norm_str = float (np .clip (strength / 2.0 , - 1.0 , 1.0 ))
92+ cursor_y = int (S_BOT - (norm_str + 1.0 ) / 2.0 * track_h )
93+
94+ y0 , y1 = min (zero_y , cursor_y ), max (zero_y , cursor_y )
95+ draw .rectangle ([(SX - TRACK_W , y0 ), (SX + TRACK_W , y1 )], fill = ILL_TRACK_FG )
96+ draw .ellipse ([(SX - DOT_R , cursor_y - DOT_R ), (SX + DOT_R , cursor_y + DOT_R )], fill = ILL_DOT_FG , outline = "white" , width = 2 )
97+
98+ # Vertical Title (Illusion Strength)
99+ lb = draw .textbbox ((0 , 0 ), "Illusion Strength" , font = font_slider_title )
100+ lw , lh = lb [2 ] - lb [0 ], lb [3 ] - lb [1 ]
101+ lbl_surf = PIL .Image .new ("RGBA" , (lw + 4 , lh + 4 ), (255 , 255 , 255 , 0 ))
102+ PIL .ImageDraw .Draw (lbl_surf ).text ((2 , 2 ), "Illusion Strength" , fill = "black" , font = font_slider_title )
103+ lbl_rot = lbl_surf .rotate (90 , expand = True )
104+
105+ # Paste centered vertically on the left
106+ img .paste (lbl_rot , (20 , (S_TOP + S_BOT ) // 2 - lbl_rot .height // 2 ), lbl_rot )
107+ draw = PIL .ImageDraw .Draw (img ) # refresh draw context after paste
108+
109+
110+ # ── 2. Bottom Horizontal Slider (Difference Size) ────────────────────────
111+ SY = img .height - 65
112+ S_LEFT = LEFT_BAR + 20
113+ S_RIGHT = img .width - 20
114+ track_w = S_RIGHT - S_LEFT
115+ zero_x = (S_LEFT + S_RIGHT ) // 2
116+
117+ # track and zero-tick
118+ draw .rectangle ([(S_LEFT , SY - TRACK_W ), (S_RIGHT , SY + TRACK_W )], fill = TRACK_BG )
119+ draw .line ([(zero_x , SY - TRACK_W - 4 ), (zero_x , SY + TRACK_W + 4 )], fill = (160 , 160 , 160 ), width = 2 )
120+
121+ # calculate position and draw fill/dot (Mapping is reversed here)
122+ norm_diff = float (np .clip (difference , - 1.0 , 1.0 ))
123+ cursor_x = int (zero_x - norm_diff * (track_w / 2 ))
124+
125+ x0 , x1 = min (zero_x , cursor_x ), max (zero_x , cursor_x )
126+ draw .rectangle ([(x0 , SY - TRACK_W ), (x1 , SY + TRACK_W )], fill = DIFF_TRACK_FG )
127+ draw .ellipse ([(cursor_x - DOT_R , SY - DOT_R ), (cursor_x + DOT_R , SY + DOT_R )], fill = DIFF_DOT_FG , outline = "white" , width = 2 )
128+
129+ # Horizontal Title (Difference Size)
130+ db = draw .textbbox ((0 , 0 ), "Difference Size" , font = font_slider_title )
131+ dw , dh = db [2 ] - db [0 ], db [3 ] - db [1 ]
132+ # Place it directly below the slider, centered between the left bar and the right edge
133+ title_x = S_LEFT + (track_w // 2 ) - (dw // 2 )
134+ draw .text ((title_x , img .height - 35 ), "Difference Size" , fill = "black" , font = font_slider_title )
135+
136+ return img
137+
138+
139+ def _render_frame (strength : float , difference : float ) -> np .ndarray :
140+ """Render one video frame as an RGB numpy array."""
141+ illusion_w = WIDTH - LEFT_BAR
142+ illusion_h = HEIGHT - TOP_BAR - BOT_BAR
143+ illusion = pyllusion .Ebbinghaus (illusion_strength = strength , difference = difference )
144+ img = illusion .to_image (width = illusion_w , height = illusion_h , background = "white" )
145+
146+ # Build full canvas
147+ canvas = PIL .Image .new ("RGB" , (WIDTH , HEIGHT ), "white" )
148+ canvas .paste (img , (LEFT_BAR , TOP_BAR ))
149+
150+ canvas = _add_labels (canvas , strength , difference )
151+ return np .array (canvas )
152+
153+
154+ # ── Sequence Generation ────────────────────────────────────────────────────────
155+
156+ def generate_sequence ():
157+ """Generates the seamless parametric path across the 4 stages."""
158+ diff_vals = []
159+ ill_vals = []
160+
161+ def add_move (d_start , d_end , i_start , i_end , time_units ):
162+ frames = int (FPS * time_units )
163+ # Use a smoothstep cosine interpolation for smooth easing
164+ t = np .linspace (0 , 1 , frames , endpoint = False )
165+ weight = (1 - np .cos (t * np .pi )) / 2
166+
167+ diff_vals .extend (d_start + (d_end - d_start ) * weight )
168+ ill_vals .extend (i_start + (i_end - i_start ) * weight )
169+
170+ # 1) Difference from 0 to negative max (-1), then to positive max (+1)
171+ add_move (0.0 , - 1.0 , 0.0 , 0.0 , 1.0 )
172+ add_move (- 1.0 , 1.0 , 0.0 , 0.0 , 2.0 )
173+
174+ # 2) Illusion strength from 0 to positive max (+2), then to negative max (-2)
175+ add_move (1.0 , 1.0 , 0.0 , 2.0 , 2.0 )
176+ add_move (1.0 , 1.0 , 2.0 , - 2.0 , 4.0 )
177+
178+ # 3) Difference goes back to 0
179+ add_move (1.0 , 0.0 , - 2.0 , - 2.0 , 1.0 )
180+
181+ # 4) Illusion strength goes back to 0
182+ add_move (0.0 , 0.0 , - 2.0 , 0.0 , 2.0 )
183+
184+ return diff_vals , ill_vals
185+
186+
187+ # ── Main ───────────────────────────────────────────────────────────────────────
188+
189+ if __name__ == "__main__" :
190+
191+ diffs , strengths = generate_sequence ()
192+ frames = []
193+ total_frames = len (diffs )
194+
195+ print (f"Starting render of { total_frames } frames..." )
196+
197+ for i , (d , s ) in enumerate (zip (diffs , strengths )):
198+ print (f" Rendering frame { i + 1 } /{ total_frames } " , end = "\r " )
199+ frames .append (_render_frame (float (s ), float (d )))
200+
201+ print (f"\n Writing frames → { OUTPUT } " )
202+ imageio .mimwrite (OUTPUT , frames , fps = FPS , codec = "libx264" ,
203+ output_params = ["-pix_fmt" , "yuv420p" , "-crf" , "18" ])
204+ print ("Done." )
0 commit comments