shaderflow 0.4.0.dev1__py2.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.

Potentially problematic release.


This version of shaderflow might be problematic. Click here for more details.

Files changed (51) hide show
  1. ShaderFlow/Common/Imgui.py +0 -0
  2. ShaderFlow/Common/Notes.py +134 -0
  3. ShaderFlow/Common/__init__.py +0 -0
  4. ShaderFlow/Message.py +147 -0
  5. ShaderFlow/Module.py +162 -0
  6. ShaderFlow/Modules/Audio.py +431 -0
  7. ShaderFlow/Modules/Bouncing.py +94 -0
  8. ShaderFlow/Modules/Camera.py +451 -0
  9. ShaderFlow/Modules/Depth.py +0 -0
  10. ShaderFlow/Modules/Dynamics.py +288 -0
  11. ShaderFlow/Modules/Frametimer.py +79 -0
  12. ShaderFlow/Modules/Keyboard.py +43 -0
  13. ShaderFlow/Modules/Noise.py +80 -0
  14. ShaderFlow/Modules/OpticalFlow.py +1 -0
  15. ShaderFlow/Modules/Piano.py +362 -0
  16. ShaderFlow/Modules/Spectrogram.py +315 -0
  17. ShaderFlow/Modules/Video.py +234 -0
  18. ShaderFlow/Modules/Waveform.py +100 -0
  19. ShaderFlow/Modules/__init__.py +0 -0
  20. ShaderFlow/Resources/Images/ShaderFlow.png +0 -0
  21. ShaderFlow/Resources/Images/ShaderFlow.svg +1 -0
  22. ShaderFlow/Resources/Scenes/Examples/Demo.py +284 -0
  23. ShaderFlow/Resources/Scenes/Examples/GLSL/Bars.frag +20 -0
  24. ShaderFlow/Resources/Scenes/Examples/GLSL/Bouncing.frag +50 -0
  25. ShaderFlow/Resources/Scenes/Examples/GLSL/Life/Simulation.glsl +49 -0
  26. ShaderFlow/Resources/Scenes/Examples/GLSL/Life/Visuals.glsl +31 -0
  27. ShaderFlow/Resources/Scenes/Examples/GLSL/MotionBlur.frag +20 -0
  28. ShaderFlow/Resources/Scenes/Examples/GLSL/Multipass.frag +43 -0
  29. ShaderFlow/Resources/Scenes/Examples/GLSL/RayMarch.frag +59 -0
  30. ShaderFlow/Resources/Scenes/Examples/GLSL/ShaderToy.frag +65 -0
  31. ShaderFlow/Resources/Scenes/Examples/GLSL/Video.frag +5 -0
  32. ShaderFlow/Resources/Scenes/Examples/GLSL/Visualizer.frag +75 -0
  33. ShaderFlow/Resources/Scenes/Examples/GLSL/Waveform.frag +18 -0
  34. ShaderFlow/Resources/Shaders/Fragment/Default.glsl +65 -0
  35. ShaderFlow/Resources/Shaders/Fragment/Final.glsl +7 -0
  36. ShaderFlow/Resources/Shaders/Fragment/Loading.frag +40 -0
  37. ShaderFlow/Resources/Shaders/Fragment/Missing.glsl +23 -0
  38. ShaderFlow/Resources/Shaders/Include/Camera.glsl +166 -0
  39. ShaderFlow/Resources/Shaders/Include/Complex.glsl +0 -0
  40. ShaderFlow/Resources/Shaders/Include/ShaderFlow.glsl +444 -0
  41. ShaderFlow/Resources/Shaders/Vertex/Default.glsl +21 -0
  42. ShaderFlow/Scene.py +1124 -0
  43. ShaderFlow/Shader.py +391 -0
  44. ShaderFlow/Texture.py +528 -0
  45. ShaderFlow/Variable.py +128 -0
  46. ShaderFlow/__init__.py +11 -0
  47. ShaderFlow/__main__.py +22 -0
  48. shaderflow-0.4.0.dev1.dist-info/METADATA +79 -0
  49. shaderflow-0.4.0.dev1.dist-info/RECORD +51 -0
  50. shaderflow-0.4.0.dev1.dist-info/WHEEL +5 -0
  51. shaderflow-0.4.0.dev1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,431 @@
1
+ import math
2
+ import time
3
+ import warnings
4
+ from collections import deque
5
+ from pathlib import Path
6
+ from typing import Any, Deque, Generator, Iterable, List, Optional, Self, Tuple
7
+
8
+ import numpy
9
+ from attr import Factory, define, field
10
+
11
+ from Broken import (
12
+ BrokenEnum,
13
+ BrokenPath,
14
+ BrokenPlatform,
15
+ BrokenThread,
16
+ Ignore,
17
+ log,
18
+ )
19
+ from Broken.Externals.FFmpeg import BrokenAudioReader, BrokenFFmpeg
20
+ from Broken.Types import Channels, Hertz, Samples, Seconds
21
+ from ShaderFlow.Module import ShaderModule
22
+ from ShaderFlow.Modules.Dynamics import ShaderDynamics
23
+
24
+ try:
25
+ import soundcard
26
+ except OSError as exception:
27
+ raise ImportError(log.error('\n'.join((
28
+ f"Original ImportError: {exception}\n\n",
29
+ "Couldn't import 'soundcard' library, probably due missing audio shared libraries (libpulse)",
30
+ "• If you're on Linux, consider installing 'pulseaudio' or 'pipewire-pulse' packages",
31
+ "• Shouldn't happen elsewhere, get support at (https://github.com/bastibe/SoundCard)"
32
+ ))))
33
+
34
+ # Disable runtime warnings on SoundCard, it's ok to read nothing on Windows
35
+ if BrokenPlatform.OnWindows:
36
+ warnings.filterwarnings("ignore", category=soundcard.SoundcardRuntimeWarning)
37
+
38
+ # -------------------------------------------------------------------------------------------------|
39
+
40
+ def fuzzy_string_search(string: str, choices: List[str], many: int=1, minimum_score: int=0) -> list[tuple[str, int]]:
41
+ """Fuzzy search a string in a list of strings, returns a list of matches"""
42
+ with warnings.catch_warnings():
43
+ warnings.filterwarnings("ignore")
44
+ import thefuzz.process
45
+ result = thefuzz.process.extract(string, choices, limit=many)
46
+ if many == 1:
47
+ return result[0]
48
+ return result
49
+
50
+ def root_mean_square(data) -> float:
51
+ return numpy.sqrt(numpy.mean(numpy.square(data)))
52
+
53
+ # -------------------------------------------------------------------------------------------------|
54
+
55
+ class BrokenAudioMode(BrokenEnum):
56
+ Realtime = "realtime"
57
+ File = "file"
58
+
59
+ @define(slots=False)
60
+ class BrokenAudio:
61
+ mode: BrokenAudioMode = BrokenAudioMode.Realtime.field()
62
+ dtype: numpy.dtype = numpy.float32
63
+
64
+ data: numpy.ndarray = None
65
+ """Progressive audio data, shape: (channels, samples)"""
66
+
67
+ tell: int = 0
68
+ """The number of samples to read from the audio so far"""
69
+
70
+ def __post__(self):
71
+ BrokenThread.new(self._play_thread, daemon=True)
72
+ BrokenThread.new(self._record_thread, daemon=True)
73
+ self.create_buffer()
74
+
75
+ @property
76
+ def buffer_size(self) -> Samples:
77
+ return int(self.samplerate*self.buffer_seconds)
78
+
79
+ @property
80
+ def shape(self) -> Tuple[Channels, Samples]:
81
+ return (self.channels, self.buffer_size)
82
+
83
+ def create_buffer(self):
84
+ self.data = numpy.zeros(self.shape, dtype=self.dtype)
85
+
86
+ def add_data(self, data: numpy.ndarray) -> Optional[numpy.ndarray]:
87
+ """
88
+ Roll the data to the left by the length of the new data; copy new data to the end
89
+ Note: Channel count must match the buffer's one
90
+
91
+ Args:
92
+ data: The new data of shape: (channels, length)
93
+
94
+ Returns:
95
+ The data that was written, if any
96
+ """
97
+ if (data := numpy.array(data, dtype=self.dtype)).any():
98
+ length = data.shape[1]
99
+ self.data = numpy.roll(self.data, -length, axis=1)
100
+ self.data[:, -length:] = data
101
+ self.tell += length
102
+ return data
103
+
104
+ def get_data_between_samples(self, start: Samples, end: Samples) -> numpy.ndarray:
105
+ return self.data[:, int(start):int(end)]
106
+
107
+ def get_data_between_seconds(self, start: Seconds, end: Seconds) -> numpy.ndarray:
108
+ return self.get_data_between_samples(start*self.samplerate, end*self.samplerate)
109
+
110
+ def get_last_n_samples(self, n: Samples) -> numpy.ndarray:
111
+ return self.data[:, -int(n):]
112
+
113
+ def get_last_n_seconds(self, n: Seconds) -> numpy.ndarray:
114
+ return self.get_last_n_samples(n*self.samplerate)
115
+
116
+ # ------------------------------------------|
117
+ # Sample Rate
118
+
119
+ _samplerate: Hertz = 44100
120
+
121
+ @property
122
+ def samplerate(self) -> Hertz:
123
+ """How many data points per second the audio is sampled at. Defaults to 44100"""
124
+ return self._samplerate or 44100
125
+
126
+ @samplerate.setter
127
+ def samplerate(self, value: Hertz):
128
+ self._samplerate = value
129
+ self.create_buffer()
130
+
131
+ # ------------------------------------------|
132
+ # Channels
133
+
134
+ _channels: int = 2
135
+
136
+ @property
137
+ def channels(self) -> int:
138
+ """Number of audio streams (channels). Two is stereo, one is mono. Defaults to 2"""
139
+ return self._channels or 2
140
+
141
+ @channels.setter
142
+ def channels(self, value: int):
143
+ self._channels = value
144
+ self.create_buffer()
145
+
146
+ # ------------------------------------------|
147
+ # History
148
+
149
+ _buffer_seconds: Seconds = 30.0
150
+
151
+ @property
152
+ def buffer_seconds(self) -> Seconds:
153
+ """Buffer length in seconds. Cheap on ram and fast, ideally have a decent side"""
154
+ # Note: To convince yourself, (48000 Hz) * (2 Channels) * (30 sec) * (f32=4 bytes) = 11 MB
155
+ return self._buffer_seconds
156
+
157
+ @buffer_seconds.setter
158
+ def buffer_seconds(self, value: Seconds):
159
+ self._buffer_seconds = value
160
+ self.create_buffer()
161
+
162
+ # ------------------------------------------|
163
+ # File
164
+
165
+ _file: Path = None
166
+ _file_reader: BrokenAudioReader = None
167
+ _file_stream: Generator[Tuple[Seconds, numpy.ndarray], None, Seconds] = None
168
+
169
+ @property
170
+ def file(self) -> Path:
171
+ return self._file
172
+
173
+ @file.setter
174
+ def file(self, value: Path):
175
+ self._file = BrokenPath(value)
176
+ if self._file and not (self._file.exists()):
177
+ return log.minor(f"Audio File doesn't exist ({value})")
178
+ self.samplerate = BrokenFFmpeg.get_audio_samplerate(self.file, echo=False)
179
+ self.channels = BrokenFFmpeg.get_audio_channels(self.file, echo=False)
180
+ self._file_reader = BrokenAudioReader(path=self.file)
181
+ self._file_stream = self._file_reader.stream
182
+ self.mode = BrokenAudioMode.File
183
+ self.close_recorder()
184
+
185
+ # ------------------------------------------|
186
+ # Soundcard
187
+
188
+ recorder_device: Any = None
189
+ recorder: Any = None
190
+
191
+ speaker_device: Any = None
192
+ speaker: Any = None
193
+
194
+ @staticmethod
195
+ def recorders() -> Iterable[Any]:
196
+ yield from soundcard.all_microphones(include_loopback=True)
197
+
198
+ @staticmethod
199
+ def speakers() -> Iterable[Any]:
200
+ yield from soundcard.all_speakers()
201
+
202
+ @staticmethod
203
+ def recorders_names() -> Iterable[str]:
204
+ yield from map(lambda device: device.name, BrokenAudio.recorders())
205
+
206
+ @staticmethod
207
+ def speakers_names() -> Iterable[str]:
208
+ yield from map(lambda device: device.name, BrokenAudio.speakers())
209
+
210
+ def list_recorders(self) -> None:
211
+ """List and print all available Audio recording devices"""
212
+ log.info("Recording Devices:")
213
+ for i, device in enumerate(BrokenAudio.recorders()):
214
+ log.info(f"• ({i:2d}) Recorder: '{device.name}'")
215
+
216
+ def list_speakers(self) -> None:
217
+ """List and print all available Audio playback devices"""
218
+ log.info("Playback Devices:")
219
+ for i, device in enumerate(BrokenAudio.speakers()):
220
+ log.info(f"• ({i:2d}) Speaker: '{device.name}'")
221
+
222
+ def __fuzzy__(self, name: str, devices: Iterable[str]) -> Optional[str]:
223
+ device_name = fuzzy_string_search(name, devices)[0]
224
+ return next(filter(lambda x: x.name == device_name, devices), None)
225
+
226
+ def open_speaker(self,
227
+ name: str=None,
228
+ *,
229
+ samplerate: Hertz=None,
230
+ ) -> Self:
231
+ """
232
+ Open a SoundCard device for playing real-time audio.
233
+
234
+ Args:
235
+ name: The name of the device to open. If None, the default speaker is used. The search
236
+ is fuzzy, so the match does not need to be exact
237
+
238
+ samplerate: If None, gets self.samplerate
239
+
240
+ Returns:
241
+ Self, Fluent interface
242
+ """
243
+ (self.speaker or Ignore()).__exit__(None, None, None)
244
+
245
+ # Search for the Speaker
246
+ if name is None:
247
+ self.speaker_device = soundcard.default_speaker()
248
+ else:
249
+ self.speaker_device = self.__fuzzy__(name, self.speakers_names)
250
+
251
+ # Open the speaker
252
+ log.info(f"Opening Speaker with Device ({self.speaker_device})")
253
+ self.speaker = self.speaker_device.player(
254
+ samplerate=samplerate or self.samplerate,
255
+ ).__enter__()
256
+ return self
257
+
258
+ def close_speaker(self) -> Self:
259
+ (self.speaker or Ignore()).__exit__(None, None, None)
260
+ self.speaker = None
261
+ return self
262
+
263
+ def open_recorder(self,
264
+ name: str=None,
265
+ *,
266
+ samplerate: Hertz=44100,
267
+ channels: List[int]=None,
268
+ blocksize: int=512,
269
+ ) -> Self:
270
+ """
271
+ Open a SoundCard device for recording real-time audio. Specifics implementation adapted
272
+ from the `soundcard` library Source Code (docstring only)
273
+
274
+ Args:
275
+ name: The name of the device to open. If None, the first loopback device or default
276
+ microphone is used. The search is fuzzy, so the match does not need to be exact
277
+
278
+ samplerate: The desired sample rate of the audio
279
+
280
+ channels: Channels to read from the device.
281
+ • None: Record all available channels
282
+ • List[int]: Record only the specified channels
283
+ • -1: (Linux: Mono mix of all channels) (MacOS: Silence)
284
+
285
+ blocksize: Desired minimum latency in samples, and also the number of recorded
286
+ samples at a time. Lower values reduces latency and increases CPU usage, which
287
+ funnily enough might cause latency issues
288
+
289
+ Returns:
290
+ Self, Fluent interface
291
+ """
292
+ self.close_recorder()
293
+
294
+ # Search for default loopback device
295
+ if name is None:
296
+ for device in self.recorders():
297
+ if device.isloopback:
298
+ self.recorder_device = device
299
+ break
300
+ self.recorder_device = (self.recorder_device or soundcard.default_microphone())
301
+ else:
302
+ self.recorder_device = self.__fuzzy__(name, self.recorders_names())
303
+
304
+ # Open the recorder
305
+ log.info(f"Opening Recorder with Device ({self.recorder_device})")
306
+ self.recorder = self.recorder_device.recorder(
307
+ samplerate=samplerate,
308
+ channels=channels,
309
+ blocksize=blocksize,
310
+ ).__enter__()
311
+
312
+ # Update properties
313
+ self.samplerate = getattr(self.recorder, "_samplerate", samplerate)
314
+ self.channels = self.recorder_device.channels
315
+ self.mode = BrokenAudioMode.Realtime
316
+ return self
317
+
318
+ def close_recorder(self) -> Self:
319
+ (self.recorder or Ignore()).__exit__(None, None, None)
320
+ self.recorder = None
321
+ return self
322
+
323
+ def record(self, numframes: int=None) -> Optional[numpy.ndarray]:
324
+ """Record a number of samples from the recorder. 'None' records all"""
325
+ if not self.recorder:
326
+ return None
327
+ return self.add_data(self.recorder.record(numframes=numframes).T)
328
+
329
+ def _record_thread(self) -> None:
330
+ while True:
331
+ if (self.record() is None):
332
+ time.sleep(0.01)
333
+
334
+ # # Playing
335
+
336
+ _play_queue: Deque[numpy.ndarray] = Factory(deque)
337
+
338
+ def play(self, data: numpy.ndarray) -> None:
339
+ """Add a numpy array to the play queue. for non-blocking playback"""
340
+ if not self.speaker_device:
341
+ return None
342
+ self._play_queue.append(data)
343
+
344
+ def _play_thread(self) -> None:
345
+ while True:
346
+ if (self._play_queue and self.speaker):
347
+ self.speaker.play(self._play_queue.popleft().T)
348
+ continue
349
+ time.sleep(0.01)
350
+
351
+ # ------------------------------------------|
352
+ # Properties utils
353
+
354
+ @property
355
+ def stereo(self) -> bool:
356
+ """Is this Audio object stereo?"""
357
+ return (self.channels == 2)
358
+
359
+ @property
360
+ def mono(self) -> bool:
361
+ """Is this Audio object mono?"""
362
+ return (self.channels == 1)
363
+
364
+ @property
365
+ def duration(self) -> Seconds:
366
+ if self.mode == BrokenAudioMode.Realtime:
367
+ return math.inf
368
+ if self.mode == BrokenAudioMode.File:
369
+ return BrokenFFmpeg.get_audio_duration(self.file)
370
+
371
+ # -------------------------------------------------------------------------------------------------|
372
+
373
+ @define
374
+ class ShaderAudio(BrokenAudio, ShaderModule):
375
+
376
+ # Todo: Move to a ShaderAudioProcessing class
377
+ volume: ShaderDynamics = None
378
+ std: ShaderDynamics = None
379
+ final: bool = True
380
+
381
+ def __post__(self):
382
+ self.volume = ShaderDynamics(
383
+ scene=self.scene, name=f"{self.name}Volume",
384
+ frequency=2, zeta=1, response=0, value=0
385
+ )
386
+ self.std = ShaderDynamics(
387
+ scene=self.scene, name=f"{self.name}STD",
388
+ frequency=10, zeta=1, response=0, value=0
389
+ )
390
+
391
+ def commands(self):
392
+ return
393
+
394
+ # Common commands
395
+ self.scene.typer.command(self.list_recorders, panel=self.__class__.__name__)
396
+ self.scene.typer.command(self.list_speakers, panel=self.__class__.__name__)
397
+
398
+ # Proper commands
399
+ self.scene.typer.command(self.open_recorder, name=f"{self.name}-recorder", panel=f"{self.__class__.__name__}: {self.name}")
400
+ self.scene.typer.command(self.open_speaker, name=f"{self.name}-speaker", panel=f"{self.__class__.__name__}: {self.name}")
401
+
402
+ @property
403
+ def duration(self) -> Seconds:
404
+ return BrokenFFmpeg.get_audio_duration(self.file) or self.scene.duration
405
+
406
+ def setup(self):
407
+ self.file = self.file
408
+ if (self.final and self.scene.realtime):
409
+ if (self.mode == BrokenAudioMode.File):
410
+ self.open_speaker()
411
+ else:
412
+ self.open_recorder()
413
+
414
+ def ffmpeg(self, ffmpeg: BrokenFFmpeg) -> None:
415
+ if BrokenPath(self.file, valid=True):
416
+ ffmpeg.input(path=self.file)
417
+
418
+ def update(self):
419
+ try:
420
+ if self._file_stream:
421
+ self._file_reader.chunk = self.scene.rdt
422
+ data = next(self._file_stream).T
423
+ self.add_data(data)
424
+ self.play(data)
425
+ except StopIteration:
426
+ pass
427
+
428
+ self.volume.target = 2 * root_mean_square(self.get_last_n_seconds(0.1)) * (2**0.5)
429
+ self.std.target = numpy.std(self.get_last_n_seconds(0.1))
430
+
431
+ # -------------------------------------------------------------------------------------------------|
@@ -0,0 +1,94 @@
1
+ import random
2
+ from math import cos, sin
3
+ from typing import Iterable
4
+
5
+ import numpy
6
+ from attr import define
7
+
8
+ from Broken import clamp
9
+ from Broken.Loaders import LoadableImage, LoaderImage
10
+ from Broken.Types import TAU, Degrees
11
+ from ShaderFlow.Module import ShaderModule
12
+ from ShaderFlow.Texture import ShaderTexture
13
+ from ShaderFlow.Variable import ShaderVariable
14
+
15
+
16
+ @define
17
+ class ShaderBouncing(ShaderModule):
18
+ name: str = "iBounce"
19
+ position: numpy.ndarray = None
20
+ velocity: numpy.ndarray = None
21
+ aspect_ratio: float = 1
22
+
23
+ def setup(self):
24
+ self.set_velocity_polar(1, random.uniform(0, TAU))
25
+ self.position = numpy.array((0.0, 0.0))
26
+
27
+ def update(self):
28
+ self.position += (self.velocity * self.scene.dt)
29
+
30
+ for i, limit in enumerate((self.aspect_ratio, 1)):
31
+ if abs(self.position[i]) > limit:
32
+ self.velocity[i] = -self.velocity[i]
33
+ self.position[i] = clamp(self.position[i], -limit, limit)
34
+
35
+ def pipeline(self) -> Iterable[ShaderVariable]:
36
+ yield ShaderVariable("uniform", "vec2", f"{self.name}Position", self.position)
37
+ yield ShaderVariable("uniform", "vec2", f"{self.name}Velocity", self.velocity)
38
+
39
+ # # Quality of Life
40
+
41
+ def set_velocity_polar(self, magnitude: float, angle: Degrees):
42
+ self.velocity = magnitude*numpy.array((cos(angle), sin(angle)))
43
+
44
+ @property
45
+ def x(self) -> float:
46
+ return self.position[0]
47
+
48
+ @property
49
+ def y(self) -> float:
50
+ return self.position[1]
51
+
52
+ @x.setter
53
+ def x(self, value: float):
54
+ self.position[0] = value
55
+
56
+ @y.setter
57
+ def y(self, value: float):
58
+ self.position[1] = value
59
+
60
+ # # Advanced
61
+
62
+ def advanced_ratios(self, image: LoadableImage, steps: int=1000) -> ShaderTexture:
63
+ """Get a texture of `aspect_ratio(angle)` from linspace(0, tau, steps)"""
64
+ ratios = numpy.zeros((steps, 1), dtype=numpy.float32)
65
+ image = numpy.array(LoaderImage(image))
66
+ width, height, _ = image.shape
67
+ bigger = max(width, height)
68
+
69
+ import cv2
70
+
71
+ # image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
72
+ image = image[:, :, 3]
73
+
74
+ # Make anew image with a centered raw copy
75
+ square = numpy.zeros((bigger, bigger), dtype=numpy.uint8)
76
+ x, y = (bigger-width)//2, (bigger-height)//2
77
+ square[x:x+width, y:y+height] = image
78
+
79
+ # Rotate the image and find its alpha content bounding box
80
+ for i, angle in enumerate(numpy.linspace(0, 360, steps)):
81
+ rotation = cv2.getRotationMatrix2D((bigger/2, bigger/2), angle, 1)
82
+ rotated = cv2.warpAffine(square, rotation, (bigger, bigger))
83
+ thresh = cv2.threshold(rotated, 4, 255, cv2.THRESH_BINARY)[1]
84
+ contours = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
85
+ _, _, w, h = cv2.boundingRect(max(contours, key=cv2.contourArea))
86
+ ratios[i][0] = (w/h)
87
+
88
+ return ShaderTexture(
89
+ name=f"{self.name}AspectRatio",
90
+ scene=self.scene,
91
+ components=1,
92
+ width=steps,
93
+ height=1,
94
+ ).from_numpy(ratios)