Skip to content

Commit bd9af60

Browse files
committed
new visutil features
- color interpolator (replaces 2022/14's make_resting() function) - Bitmap can auto-detect bounding boxes from sets or dicts of coordinates - Bitmap can add extra borders around specified bounding rectangle - default zoom factor and window title are no longer hardcoded for 2022/14 - automatic timing / frameskip functionality (adds -s/--speed option in ticks per second, not just frames per second; assumed that write() is called for every tick, so it can skip frames if needed) - write() got initial= and final= arguments to add the same pre-roll and post-roll dummy frames that 2022/14 does
1 parent 9309bb6 commit bd9af60

File tree

2 files changed

+106
-25
lines changed

2 files changed

+106
-25
lines changed

2022/14/aoc2022_14_vis.py

+4-11
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,7 @@ class Colors:
1717
Falling = (240, 224, 64)
1818
Resting0 = (160, 128, 48)
1919
Resting1 = ( 64, 48, 8)
20-
21-
@classmethod
22-
def make_resting(self, grains):
23-
t = 1.0 - min(1, max(0, grains / ExpectedGrainCount))
24-
t *= t
25-
t *= t
26-
return tuple(int(a * t + b * (1.0 - t)) for a,b in zip(self.Resting0, self.Resting1))
20+
make_resting = visutil.ColorInterpolator(Resting0, Resting1, ExpectedGrainCount, power=-4)
2721

2822
class LandOfSand:
2923
def __init__(self, x0,y0, x1,y1, rate=3):
@@ -83,7 +77,7 @@ def extra_args(parser):
8377

8478

8579
if __name__ == "__main__":
86-
vis = visutil.Visualizer(input_arg=True, extra_args=extra_args)
80+
vis = visutil.Visualizer(default_zoom=4, input_arg=True, extra_args=extra_args, title="AoC Sand Simulation")
8781
args = vis.args
8882

8983
lines = []
@@ -120,8 +114,7 @@ def get_real_dps(cpf_override=None):
120114

121115
vis.start(sim.bmp)
122116
try:
123-
# generate 1/2 second of initial state (or 1 frame if just viewing)
124-
vis.write(n=((vis.fps // 2) if args.output else 1))
117+
vis.write(initial=True)
125118
while sim.step(cpf):
126119
vis.write()
127120
expect_dps = args.dps + args.accel * sim.resting
@@ -132,7 +125,7 @@ def get_real_dps(cpf_override=None):
132125
if speed_changed:
133126
real_dps = get_real_dps()
134127
print(f"timing changed: {cpf} cycle(s)/frame -> {real_dps:.1f} drops/second")
135-
vis.write(n=vis.fps*2) # generate 2 second of final state
128+
vis.write(final=True)
136129
except (EnvironmentError, KeyboardInterrupt):
137130
pass
138131
vis.stop()

lib/visutil.py

+102-14
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,61 @@
66
"""
77
import subprocess
88
import argparse
9+
import math
910
import os
1011

1112
################################################################################
1213

14+
class ColorInterpolator:
15+
"Linear interpolation between two sRGB colors."
16+
17+
def __init__(self, rgb0, rgb1, t0=1.0, t1=None, power=1.0):
18+
"""
19+
Initialize the generator.
20+
- rgb0: 3-tuple of 8-bit sRGB colors that represent the start time
21+
- rgb1: 3-tuple of 8-bit sRGB colors that represent the end time
22+
- t0: start time (if t1 is omitted, the end time, and start is 0)
23+
- t1: end time
24+
- power: power to apply to the interpolation coefficient;
25+
if negative, the power will be applied to *reversed* time
26+
"""
27+
self.rgb0 = rgb0
28+
self.rgb1 = rgb1
29+
if t1 is None:
30+
t0, t1 = 0, t0
31+
self.t0, self.t1 = t0, t1
32+
self.dt = float(t1 - t0)
33+
self.power = power
34+
35+
def __call__(self, t):
36+
"Generate a color for a specified time."
37+
if t <= self.t0: return self.rgb0
38+
if t >= self.t1: return self.rgb1
39+
t = (t - self.t0) / self.dt
40+
if self.power < 0: t = 1.0 - t
41+
t = math.pow(t, abs(self.power))
42+
if self.power < 0: t = 1.0 - t
43+
return tuple(max(0, min(255, int((1.0 - t) * c0 + t * c1 + 0.5))) for c0, c1 in zip(self.rgb0, self.rgb1))
44+
45+
################################################################################
46+
1347
class Bitmap:
1448
"A single still frame."
1549

16-
def __init__(self, c1, c2=None, init=None, background=None, exterior=None):
50+
def __init__(self, c1, c2=None, border=0, init=None, background=None, exterior=None):
1751
"""
1852
Initialize an RGB bitmap using one of the following syntaxes:
1953
- Bitmap(other) => copy everything from other bitmap
54+
- Bitmap(seq) => auto-detect bounding box from sequence (or dict) of
55+
2-tuples or complex numbers
2056
- Bitmap(width, height) => valid coordinates 0,0 ... width-1, height-1
2157
- Bitmap((width,height)) => as above
22-
- Bitmap(width+height*1j) => as above
2358
- Bitmap((x0,y0), (x1,y1)) => valid coordinates x0,y0 ... x1,y1;
2459
width = x1 - x0 + 1; height = y1 - y0 + 1
2560
- Bitmap(x0+y0*1j, x1+y1*1j) => as above
2661
Other parameters:
62+
- border: number of extra background pixels to add around the edges
63+
of the image
2764
- init: bytes, bytearray, list or anything that has a tobytes()
2865
method, to be used to initialize bitmap with
2966
- background: 3-tuple of 8-bit sRGB values for background;
@@ -37,21 +74,38 @@ def __init__(self, c1, c2=None, init=None, background=None, exterior=None):
3774
self.exterior = c1.exterior
3875
if c2 and isinstance(c1, int) and isinstance(c2, int):
3976
c1, c2 = (c1, c2), None
77+
if (c2 is None) and not(isinstance(c1, int) or (isinstance(c1, tuple) and (len(c1) == 2))):
78+
keys = set(c1)
79+
any_item = keys.pop()
80+
cplx = isinstance(any, complex)
81+
keys.add(any_item)
82+
if cplx:
83+
xx = {int(p.real) for p in keys}
84+
yy = {int(p.imag) for p in keys}
85+
else:
86+
xx = {x for x,y in keys}
87+
yy = {y for x,y in keys}
88+
c1 = (min(xx), min(yy))
89+
c2 = (max(xx), max(yy))
4090
if isinstance(c1, complex):
4191
c1 = (int(c1.real), int(c1.imag))
4292
if isinstance(c2, complex):
4393
c2 = (int(c2.real), int(c2.imag))
4494
if c2 is None:
4595
self.width, self.height = c1
46-
self.x0, self.y0 = 0, 0
47-
self.x1, self.y1 = self.width - 1, self.height - 1
96+
self.x0, self.y0 = border, border
97+
self.x1, self.y1 = self.width - 1 + border, self.height - 1 + border
4898
else:
4999
self.x0, self.y0 = c1
50100
self.x1, self.y1 = c2
51101
self.x0, self.x1 = min(self.x0, self.x1), max(self.x0, self.x1)
52102
self.y0, self.y1 = min(self.y0, self.y1), max(self.y0, self.y1)
53-
self.width = self.x1 - self.x0 + 1
54-
self.height = self.y1 - self.y0 + 1
103+
self.x0 -= border
104+
self.y0 -= border
105+
self.x1 += border
106+
self.y1 += border
107+
self.width = self.x1 - self.x0 + 1
108+
self.height = self.y1 - self.y0 + 1
55109
if not background:
56110
background = (0, 0, 0)
57111
self.exterior = tuple(exterior or background)
@@ -139,15 +193,19 @@ class Visualizer:
139193
a live preview will be shown instead.
140194
"""
141195

142-
def __init__(self, source=None, extra_args=None, input_arg=False):
196+
def __init__(self, default_zoom=1, default_speed=None, source=None, extra_args=None, input_arg=False, title="Visualization"):
143197
"""
144198
Read command-line options.
199+
- default_zoom: default zoom factor
200+
- default_speed: if present, offer a -s/--speed option and set
201+
frameskip appropriately
145202
- source: source bitmap (if already known)
146203
- extra_args: function that is called with the argparse.ArgumentParser
147204
instance, to add additional arguments
148205
- input_arg: set to nonzero to include the -i/--input argument;
149206
if set to a string, this will be the default argument
150207
(result will be available as Visualizer.input)
208+
- title: window title
151209
"""
152210
parser = argparse.ArgumentParser()
153211
parser = argparse.ArgumentParser()
@@ -156,29 +214,39 @@ def __init__(self, source=None, extra_args=None, input_arg=False):
156214
help="input file name")
157215
parser.add_argument("-o", "--output", metavar="VIDEO.mp4",
158216
help="save result into video file [default: show on screen]")
159-
parser.add_argument("-z", "--zoom", metavar="N", type=int, default=4,
217+
parser.add_argument("-z", "--zoom", metavar="N", type=int, default=default_zoom,
160218
help="scaling factor [integer; default: %(default)s]")
161219
parser.add_argument("-r", "--fps", metavar="FPS", type=int, default=30,
162220
help="video speed in frames per second [default: %(default)s]")
221+
if default_speed:
222+
parser.add_argument("-s", "--speed", metavar="TPS", type=float, default=default_speed,
223+
help="simulation speed in ticks per second [default: %(default)s]")
163224
if extra_args:
164225
extra_args(parser)
165226
parser.add_argument("-q", "--crf", metavar="CRF", type=int, default=26,
166227
help="x64 CRF quality factor [default: %(default)s]")
167228
self.args = parser.parse_args()
168229
self.gif = self.args.output and (os.path.splitext(self.args.output)[-1].strip(".").lower() == "gif")
169-
self.source = source
230+
self.source = self.bmp = source
170231
self.fps = self.args.fps
232+
if default_speed:
233+
self.speed = self.args.speed
234+
self.frameskip = max(1, int(self.speed / self.fps + 0.5))
235+
else:
236+
self.frameskip = 1
171237
self.input = self.args.input if input_arg else None
172238
self.proc = None
239+
self.title = title
173240

174-
def start(self, source=None, verbose=False):
241+
def start(self, source=None, frameskip=0, verbose=False):
175242
"""
176243
Start the visualization (or encoding).
177244
- source: set source bitmap (if not already done so in the constructor)
245+
- frameskip: override frameskip value that's been set in the constructor
178246
- verbose: set to True to show the FFmpeg/FFplay command line
179247
"""
180248
if source:
181-
self.source = source
249+
self.source = self.bmp = source
182250
w, h = getattr(self.source, 'width', 0), getattr(self.source, 'height', 0)
183251
if not(w) or not(h):
184252
w, h = getattr(self.source, 'size', (0,0))
@@ -190,7 +258,7 @@ def start(self, source=None, verbose=False):
190258
if self.args.output: cmdline += ["-i"]
191259
cmdline += ["-", "-vf", f"scale={w*self.args.zoom}x{h*self.args.zoom}:flags=neighbor"]
192260
if not self.args.output:
193-
cmdline += ["-window_title", "AoC Sand Simulation"]
261+
cmdline += ["-window_title", self.title]
194262
elif self.gif:
195263
cmdline[-1] += ",split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse=dither=bayer"
196264
cmdline += ["-f", "gif", self.args.output]
@@ -201,17 +269,36 @@ def start(self, source=None, verbose=False):
201269
print(os.getenv("PS4", "+ ") + ' '.join((f'"{a}"' if (' ' in a) else a) for a in cmdline))
202270
self.proc = subprocess.Popen(cmdline, stdin=subprocess.PIPE)
203271

204-
def write(self, source=None, n=1):
272+
if frameskip:
273+
self.frameskip = frameskip
274+
self.skipcount = self.frameskip
275+
self.frames = 0
276+
self.ticks = 0
277+
278+
def write(self, source=None, n=0, initial=False, final=False):
205279
"""
206280
Send a frame to the display or encoder. start() must have been called
207281
before that.
208282
- source: source bitmap (if not already set earlier)
209-
- n: number of times to repeat the frame
283+
- n: number of times to repeat the frame;
284+
0 = automatic (depends on frameskip), larger = explicit count
285+
- initial: if set to True, override n to 1 (on preview) or fps/2
286+
(on export), to visualize initial state
287+
- final: if set to True, override n to fps*2, to visualize final state
210288
EnvironmentErrors or KeyboardInterrupts may occur; those should be
211289
intercepted by the host application and be treated as if the output
212290
finished normally, i.e. stop()/close() should *still* be called.
213291
"""
214292
assert self.proc, "no output running"
293+
if initial: n = max(1, (self.fps // 2)) if self.args.output else 1
294+
if final: n = self.fps * 2
295+
if not n:
296+
self.ticks += 1
297+
self.skipcount -= 1
298+
if self.skipcount > 0:
299+
return
300+
self.skipcount = self.frameskip
301+
n = 1
215302
if not source:
216303
source = self.source
217304
if hasattr(source, 'tofile'):
@@ -221,6 +308,7 @@ def write(self, source=None, n=1):
221308
frame = source.tobytes()
222309
for i in range(n):
223310
self.proc.stdin.write(frame)
311+
self.frames += n
224312

225313
def stop(self):
226314
"Stop the display or encoder."

0 commit comments

Comments
 (0)