framesource 0.3.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.
Files changed (37) hide show
  1. frame_processors/__init__.py +37 -0
  2. frame_source/__init__.py +50 -0
  3. framesource/__init__.py +93 -0
  4. framesource/_msmf_config.py +26 -0
  5. framesource/_version.py +24 -0
  6. framesource/discovery.py +109 -0
  7. framesource/errors.py +82 -0
  8. framesource/factory.py +290 -0
  9. framesource/processors/__init__.py +34 -0
  10. framesource/processors/equirectangular360_processor.py +577 -0
  11. framesource/processors/fisheye2equirectangular_processor.py +328 -0
  12. framesource/processors/frame_processor.py +30 -0
  13. framesource/processors/hyperspectral_processor.py +23 -0
  14. framesource/processors/realsense_depth_processor.py +90 -0
  15. framesource/py.typed +0 -0
  16. framesource/sources/__init__.py +40 -0
  17. framesource/sources/audiospectrogram_capture.py +1068 -0
  18. framesource/sources/basler_capture.py +477 -0
  19. framesource/sources/folder_capture.py +920 -0
  20. framesource/sources/genicam_capture.py +681 -0
  21. framesource/sources/huateng_capture.py +245 -0
  22. framesource/sources/ipcamera_capture.py +254 -0
  23. framesource/sources/mvsdk.py +2454 -0
  24. framesource/sources/realsense_capture.py +565 -0
  25. framesource/sources/screen_capture.py +800 -0
  26. framesource/sources/video_capture_base.py +560 -0
  27. framesource/sources/video_file_capture.py +259 -0
  28. framesource/sources/webcam_capture.py +511 -0
  29. framesource/sources/ximea_capture.py +299 -0
  30. framesource/threading_utils.py +790 -0
  31. framesource-0.3.0.dist-info/METADATA +787 -0
  32. framesource-0.3.0.dist-info/RECORD +37 -0
  33. framesource-0.3.0.dist-info/WHEEL +5 -0
  34. framesource-0.3.0.dist-info/licenses/LICENSE +21 -0
  35. framesource-0.3.0.dist-info/scm_file_list.json +276 -0
  36. framesource-0.3.0.dist-info/scm_version.json +8 -0
  37. framesource-0.3.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,577 @@
1
+ import math
2
+
3
+ import cv2
4
+ import numpy as np
5
+ from numba import njit, prange
6
+
7
+ from .frame_processor import FrameProcessor, FrameType
8
+
9
+
10
+ # Optimized functions using Numba for better performance
11
+ @njit(cache=True, fastmath=True)
12
+ def generate_mapping_jit(
13
+ output_width,
14
+ output_height,
15
+ focal_length,
16
+ cx,
17
+ cy,
18
+ R00,
19
+ R01,
20
+ R02,
21
+ R10,
22
+ R11,
23
+ R12,
24
+ R20,
25
+ R21,
26
+ R22,
27
+ frame_height,
28
+ frame_width,
29
+ ):
30
+ """JIT-compiled coordinate mapping generation for maximum speed"""
31
+
32
+ # Pre-allocate output arrays
33
+ pixel_x = np.empty((output_height, output_width), dtype=np.float32)
34
+ pixel_y = np.empty((output_height, output_width), dtype=np.float32)
35
+
36
+ # Pre-calculate constants
37
+ inv_focal = 1.0 / focal_length
38
+ inv_2pi = 1.0 / (2.0 * np.pi)
39
+ inv_pi = 1.0 / np.pi
40
+ half_pi = np.pi * 0.5
41
+ frame_width_minus_1 = frame_width - 1
42
+ frame_height_minus_1 = frame_height - 1
43
+
44
+ # Process each pixel
45
+ for j in range(output_height):
46
+ y_norm = (j - cy) * inv_focal
47
+ for i in range(output_width):
48
+ x_norm = (i - cx) * inv_focal
49
+
50
+ # Normalize direction vector
51
+ norm_factor = 1.0 / math.sqrt(x_norm * x_norm + y_norm * y_norm + 1.0)
52
+ x_unit = x_norm * norm_factor
53
+ y_unit = y_norm * norm_factor
54
+ z_unit = norm_factor
55
+
56
+ # Apply rotation matrix
57
+ x_rot = R00 * x_unit + R01 * y_unit + R02 * z_unit
58
+ y_rot = R10 * x_unit + R11 * y_unit + R12 * z_unit
59
+ z_rot = R20 * x_unit + R21 * y_unit + R22 * z_unit
60
+
61
+ # Convert to spherical coordinates
62
+ theta = math.atan2(x_rot, z_rot) # Azimuth
63
+ phi = math.asin(max(-1.0, min(1.0, y_rot))) # Elevation (clamped)
64
+
65
+ # Convert to pixel coordinates
66
+ u = (theta + np.pi) * inv_2pi
67
+ v = (phi + half_pi) * inv_pi
68
+
69
+ pixel_x[j, i] = max(0.0, min(frame_width_minus_1, u * frame_width_minus_1))
70
+ pixel_y[j, i] = max(0.0, min(frame_height_minus_1, v * frame_height_minus_1))
71
+
72
+ return pixel_x, pixel_y
73
+
74
+
75
+ @njit(cache=True, fastmath=True, parallel=True)
76
+ def generate_mapping_jit_parallel(
77
+ output_width,
78
+ output_height,
79
+ focal_length,
80
+ cx,
81
+ cy,
82
+ R00,
83
+ R01,
84
+ R02,
85
+ R10,
86
+ R11,
87
+ R12,
88
+ R20,
89
+ R21,
90
+ R22,
91
+ frame_height,
92
+ frame_width,
93
+ ):
94
+ """Parallel JIT-compiled coordinate mapping for multi-core systems"""
95
+
96
+ # Pre-allocate output arrays
97
+ pixel_x = np.empty((output_height, output_width), dtype=np.float32)
98
+ pixel_y = np.empty((output_height, output_width), dtype=np.float32)
99
+
100
+ # Pre-calculate constants
101
+ inv_focal = 1.0 / focal_length
102
+ inv_2pi = 1.0 / (2.0 * np.pi)
103
+ inv_pi = 1.0 / np.pi
104
+ half_pi = np.pi * 0.5
105
+ frame_width_minus_1 = frame_width - 1
106
+ frame_height_minus_1 = frame_height - 1
107
+
108
+ # Process rows in parallel using prange
109
+ for j in prange(output_height):
110
+ y_norm = (j - cy) * inv_focal
111
+ for i in range(output_width):
112
+ x_norm = (i - cx) * inv_focal
113
+
114
+ # Normalize direction vector
115
+ norm_factor = 1.0 / math.sqrt(x_norm * x_norm + y_norm * y_norm + 1.0)
116
+ x_unit = x_norm * norm_factor
117
+ y_unit = y_norm * norm_factor
118
+ z_unit = norm_factor
119
+
120
+ # Apply rotation matrix
121
+ x_rot = R00 * x_unit + R01 * y_unit + R02 * z_unit
122
+ y_rot = R10 * x_unit + R11 * y_unit + R12 * z_unit
123
+ z_rot = R20 * x_unit + R21 * y_unit + R22 * z_unit
124
+
125
+ # Convert to spherical coordinates
126
+ theta = math.atan2(x_rot, z_rot)
127
+ phi = math.asin(max(-1.0, min(1.0, y_rot)))
128
+
129
+ # Convert to pixel coordinates
130
+ u = (theta + np.pi) * inv_2pi
131
+ v = (phi + half_pi) * inv_pi
132
+
133
+ pixel_x[j, i] = max(0.0, min(frame_width_minus_1, u * frame_width_minus_1))
134
+ pixel_y[j, i] = max(0.0, min(frame_height_minus_1, v * frame_height_minus_1))
135
+
136
+ return pixel_x, pixel_y
137
+
138
+
139
+ # Advanced optimized functions for maximum performance
140
+ @njit(cache=True, fastmath=True, inline="always")
141
+ def generate_mapping_jit_ultra(
142
+ output_width,
143
+ output_height,
144
+ focal_length,
145
+ cx,
146
+ cy,
147
+ R00,
148
+ R01,
149
+ R02,
150
+ R10,
151
+ R11,
152
+ R12,
153
+ R20,
154
+ R21,
155
+ R22,
156
+ frame_height,
157
+ frame_width,
158
+ ):
159
+ """Ultra-optimized coordinate mapping with memory layout optimization"""
160
+
161
+ # Pre-allocate output arrays with optimal memory layout
162
+ pixel_x = np.empty((output_height, output_width), dtype=np.float32)
163
+ pixel_y = np.empty((output_height, output_width), dtype=np.float32)
164
+
165
+ # Pre-calculate all constants (more than before)
166
+ inv_focal = 1.0 / focal_length
167
+ inv_2pi = 0.15915494309189535 # 1/(2*pi) precomputed
168
+ inv_pi = 0.3183098861837907 # 1/pi precomputed
169
+ half_pi = 1.5707963267948966 # pi/2 precomputed
170
+ frame_width_f = float(frame_width - 1)
171
+ frame_height_f = float(frame_height - 1)
172
+
173
+ # Process each pixel with optimized inner loop
174
+ for j in range(output_height):
175
+ y_norm = (j - cy) * inv_focal
176
+ y_norm_sq = y_norm * y_norm
177
+ for i in range(output_width):
178
+ x_norm = (i - cx) * inv_focal
179
+
180
+ # Optimized normalization using precomputed y_norm_sq
181
+ norm_factor = 1.0 / math.sqrt(x_norm * x_norm + y_norm_sq + 1.0)
182
+ x_unit = x_norm * norm_factor
183
+ y_unit = y_norm * norm_factor
184
+ z_unit = norm_factor
185
+
186
+ # Apply rotation matrix (unrolled for speed)
187
+ x_rot = R00 * x_unit + R01 * y_unit + R02 * z_unit
188
+ y_rot = R10 * x_unit + R11 * y_unit + R12 * z_unit
189
+ z_rot = R20 * x_unit + R21 * y_unit + R22 * z_unit
190
+
191
+ # Optimized spherical coordinate calculation
192
+ theta = math.atan2(x_rot, z_rot)
193
+ phi = math.asin(max(-1.0, min(1.0, y_rot)))
194
+
195
+ # Direct pixel coordinate calculation with precomputed constants
196
+ u = (theta + math.pi) * inv_2pi
197
+ v = (phi + half_pi) * inv_pi
198
+
199
+ # Final pixel coordinates with bounds checking
200
+ pixel_x[j, i] = max(0.0, min(frame_width_f, u * frame_width_f))
201
+ pixel_y[j, i] = max(0.0, min(frame_height_f, v * frame_height_f))
202
+
203
+ return pixel_x, pixel_y
204
+
205
+
206
+ @njit(cache=True, fastmath=True, parallel=True)
207
+ def generate_mapping_jit_ultra_parallel(
208
+ output_width,
209
+ output_height,
210
+ focal_length,
211
+ cx,
212
+ cy,
213
+ R00,
214
+ R01,
215
+ R02,
216
+ R10,
217
+ R11,
218
+ R12,
219
+ R20,
220
+ R21,
221
+ R22,
222
+ frame_height,
223
+ frame_width,
224
+ ):
225
+ """Ultra-optimized parallel coordinate mapping using a flattened approach for
226
+ better Numba parallelization."""
227
+
228
+ # Pre-allocate output arrays
229
+ pixel_x = np.empty((output_height, output_width), dtype=np.float32)
230
+ pixel_y = np.empty((output_height, output_width), dtype=np.float32)
231
+
232
+ # Pre-calculate constants
233
+ inv_focal = 1.0 / focal_length
234
+ inv_2pi = 0.15915494309189535
235
+ inv_pi = 0.3183098861837907
236
+ half_pi = 1.5707963267948966
237
+ frame_width_f = float(frame_width - 1)
238
+ frame_height_f = float(frame_height - 1)
239
+ pi = 3.141592653589793
240
+
241
+ # Flatten to 1D for better parallelization
242
+ total_pixels = output_height * output_width
243
+
244
+ # Use prange for parallel processing of individual pixels
245
+ for idx in prange(total_pixels):
246
+ # Convert 1D index back to 2D coordinates
247
+ j = idx // output_width
248
+ i = idx % output_width
249
+
250
+ # Same calculation as before
251
+ x_norm = (i - cx) * inv_focal
252
+ y_norm = (j - cy) * inv_focal
253
+
254
+ # Fast normalization
255
+ norm_factor = 1.0 / math.sqrt(x_norm * x_norm + y_norm * y_norm + 1.0)
256
+ x_unit = x_norm * norm_factor
257
+ y_unit = y_norm * norm_factor
258
+ z_unit = norm_factor
259
+
260
+ # Matrix multiply
261
+ x_rot = R00 * x_unit + R01 * y_unit + R02 * z_unit
262
+ y_rot = R10 * x_unit + R11 * y_unit + R12 * z_unit
263
+ z_rot = R20 * x_unit + R21 * y_unit + R22 * z_unit
264
+
265
+ # Spherical conversion
266
+ theta = math.atan2(x_rot, z_rot)
267
+ phi = math.asin(max(-1.0, min(1.0, y_rot)))
268
+
269
+ # Pixel mapping
270
+ u = (theta + pi) * inv_2pi
271
+ v = (phi + half_pi) * inv_pi
272
+
273
+ pixel_x[j, i] = max(0.0, min(frame_width_f, u * frame_width_f))
274
+ pixel_y[j, i] = max(0.0, min(frame_height_f, v * frame_height_f))
275
+
276
+ return pixel_x, pixel_y
277
+
278
+
279
+ class Equirectangular2PinholeProcessor(FrameProcessor):
280
+ """Convert equirectangular 360 frames to pinhole projections."""
281
+
282
+ def __init__(self, fov: float = 90.0, output_width: int = 1920, output_height: int = 1080):
283
+ super().__init__()
284
+ self._parameters = {
285
+ "roll": 0.0,
286
+ "pitch": 0.0,
287
+ "yaw": 0.0,
288
+ "fov": fov,
289
+ "output_width": output_width,
290
+ "output_height": output_height,
291
+ }
292
+
293
+ # Coordinate mapping cache
294
+ self._map_cache = {}
295
+ self._cache_size_limit = 50
296
+
297
+ def process(self, frame: FrameType) -> FrameType:
298
+ """Convert equirectangular frame to pinhole projection."""
299
+ roll = self._parameters["roll"]
300
+ pitch = self._parameters["pitch"]
301
+ yaw = self._parameters["yaw"]
302
+ fov = self._parameters["fov"]
303
+ output_width = self._parameters["output_width"]
304
+ output_height = self._parameters["output_height"]
305
+
306
+ # Normalize angles for consistent caching
307
+ norm_yaw, norm_pitch, norm_roll = self._normalize_angles(yaw, pitch, roll)
308
+
309
+ # Create cache key for coordinate mapping
310
+ cache_key = (
311
+ norm_yaw,
312
+ norm_pitch,
313
+ norm_roll,
314
+ fov,
315
+ output_width,
316
+ output_height,
317
+ frame.shape[0],
318
+ frame.shape[1],
319
+ )
320
+
321
+ # Check cache for coordinate mapping
322
+ if cache_key in self._map_cache:
323
+ pixel_x, pixel_y = self._map_cache[cache_key]
324
+ else:
325
+ # Generate new mapping
326
+ pixel_x, pixel_y = self._generate_coordinate_mapping(
327
+ norm_yaw, norm_pitch, norm_roll, fov, output_width, output_height, frame.shape
328
+ )
329
+
330
+ # Cache management
331
+ if len(self._map_cache) >= self._cache_size_limit:
332
+ oldest_key = next(iter(self._map_cache))
333
+ del self._map_cache[oldest_key]
334
+
335
+ self._map_cache[cache_key] = (pixel_x, pixel_y)
336
+
337
+ # Apply remapping
338
+ return cv2.remap(frame, pixel_x, pixel_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_WRAP)
339
+
340
+ def _generate_coordinate_mapping(
341
+ self,
342
+ yaw: float,
343
+ pitch: float,
344
+ roll: float,
345
+ fov: float,
346
+ output_width: int,
347
+ output_height: int,
348
+ frame_shape: tuple[int, ...],
349
+ ) -> tuple[np.ndarray, np.ndarray]:
350
+ """Generate coordinate mapping for equirectangular to pinhole projection."""
351
+ # Convert to radians
352
+ yaw_rad = math.radians(yaw)
353
+ pitch_rad = math.radians(pitch)
354
+ roll_rad = math.radians(roll)
355
+ fov_rad = math.radians(fov)
356
+
357
+ # Pre-calculate constants
358
+ focal_length = output_width / (2 * math.tan(fov_rad / 2))
359
+ cx = output_width * 0.5
360
+ cy = output_height * 0.5
361
+
362
+ # Create rotation matrix elements
363
+ cos_r, sin_r = math.cos(roll_rad), math.sin(roll_rad)
364
+ cos_p, sin_p = math.cos(pitch_rad), math.sin(pitch_rad)
365
+ cos_y, sin_y = math.cos(yaw_rad), math.sin(yaw_rad)
366
+
367
+ # Combined rotation matrix elements
368
+ R00 = cos_y * cos_r + sin_y * sin_r * sin_p
369
+ R01 = cos_y * (-sin_r) + sin_y * cos_r * sin_p
370
+ R02 = sin_y * cos_p
371
+ R10 = sin_r * cos_p
372
+ R11 = cos_r * cos_p
373
+ R12 = -sin_p
374
+ R20 = -sin_y * cos_r + cos_y * sin_r * sin_p
375
+ R21 = -sin_y * (-sin_r) + cos_y * cos_r * sin_p
376
+ R22 = cos_y * cos_p
377
+
378
+ # Choose appropriate JIT implementation based on output size
379
+ total_pixels = output_width * output_height
380
+
381
+ if total_pixels > 1000000:
382
+ return generate_mapping_jit_ultra_parallel(
383
+ output_width,
384
+ output_height,
385
+ focal_length,
386
+ cx,
387
+ cy,
388
+ R00,
389
+ R01,
390
+ R02,
391
+ R10,
392
+ R11,
393
+ R12,
394
+ R20,
395
+ R21,
396
+ R22,
397
+ frame_shape[0],
398
+ frame_shape[1],
399
+ )
400
+ elif total_pixels > 500000:
401
+ return generate_mapping_jit_parallel(
402
+ output_width,
403
+ output_height,
404
+ focal_length,
405
+ cx,
406
+ cy,
407
+ R00,
408
+ R01,
409
+ R02,
410
+ R10,
411
+ R11,
412
+ R12,
413
+ R20,
414
+ R21,
415
+ R22,
416
+ frame_shape[0],
417
+ frame_shape[1],
418
+ )
419
+ elif total_pixels > 200000:
420
+ return generate_mapping_jit_ultra(
421
+ output_width,
422
+ output_height,
423
+ focal_length,
424
+ cx,
425
+ cy,
426
+ R00,
427
+ R01,
428
+ R02,
429
+ R10,
430
+ R11,
431
+ R12,
432
+ R20,
433
+ R21,
434
+ R22,
435
+ frame_shape[0],
436
+ frame_shape[1],
437
+ )
438
+ else:
439
+ return generate_mapping_jit(
440
+ output_width,
441
+ output_height,
442
+ focal_length,
443
+ cx,
444
+ cy,
445
+ R00,
446
+ R01,
447
+ R02,
448
+ R10,
449
+ R11,
450
+ R12,
451
+ R20,
452
+ R21,
453
+ R22,
454
+ frame_shape[0],
455
+ frame_shape[1],
456
+ )
457
+
458
+ def _normalize_angles(
459
+ self, yaw: float, pitch: float, roll: float
460
+ ) -> tuple[float, float, float]:
461
+ """Normalize angles to canonical ranges for cache consistency."""
462
+ # Normalize yaw to [0, 360)
463
+ yaw = yaw % 360
464
+
465
+ # Normalize pitch properly
466
+ pitch = ((pitch + 180) % 360) - 180
467
+
468
+ # Handle pitch overflow beyond valid range [-90, 90]
469
+ if pitch > 90:
470
+ pitch = 180 - pitch
471
+ yaw = (yaw + 180) % 360
472
+ roll = (roll + 180) % 360
473
+ elif pitch < -90:
474
+ pitch = -180 - pitch
475
+ yaw = (yaw + 180) % 360
476
+ roll = (roll + 180) % 360
477
+
478
+ # Normalize roll to [0, 360)
479
+ roll = roll % 360
480
+
481
+ return yaw, pitch, roll
482
+
483
+ def clear_cache(self) -> None:
484
+ """Clear the coordinate mapping cache."""
485
+ self._map_cache.clear()
486
+
487
+ def pixel_to_spherical(
488
+ self, x: int, y: int, frame_width: int, frame_height: int
489
+ ) -> tuple[float, float]:
490
+ """
491
+ Convert pixel coordinates in equirectangular image to spherical coordinates.
492
+
493
+ Args:
494
+ x: Pixel x coordinate (0 to frame_width-1)
495
+ y: Pixel y coordinate (0 to frame_height-1)
496
+ frame_width: Width of the equirectangular frame
497
+ frame_height: Height of the equirectangular frame
498
+
499
+ Returns:
500
+ Tuple of (longitude_deg, latitude_deg) where:
501
+ - longitude_deg: -180° to +180° (yaw)
502
+ - latitude_deg: -90° to +90° (pitch, inverted for correct orientation)
503
+ """
504
+ # Convert pixel coordinates to spherical coordinates
505
+ # Equirectangular mapping: x -> longitude (-180° to +180°), y -> latitude (-90° to +90°)
506
+ longitude_deg = (x / frame_width) * 360.0 - 180.0 # -180 to +180
507
+ latitude_deg = (y / frame_height) * 180.0 - 90.0 # -90 to +90
508
+
509
+ return longitude_deg, latitude_deg
510
+
511
+ def spherical_to_processor_angles(
512
+ self, longitude_deg: float, latitude_deg: float, current_roll: float = 0.0
513
+ ) -> tuple[float, float, float]:
514
+ """
515
+ Convert spherical coordinates to processor angles.
516
+
517
+ Args:
518
+ longitude_deg: Longitude in degrees (-180° to +180°)
519
+ latitude_deg: Latitude in degrees (-90° to +90°)
520
+ current_roll: Current roll value to preserve (default: 0.0)
521
+
522
+ Returns:
523
+ Tuple of (yaw, pitch, roll) where:
524
+ - yaw: longitude (left/right rotation)
525
+ - pitch: -latitude (up/down rotation, inverted)
526
+ - roll: preserved current_roll value
527
+ """
528
+ yaw = longitude_deg
529
+ pitch = -latitude_deg # Invert for correct orientation
530
+ roll = current_roll # Preserve current roll
531
+
532
+ return yaw, pitch, roll
533
+
534
+ def processor_angles_to_equirectangular_coords(
535
+ self, yaw: float, pitch: float, roll: float, frame_width: int, frame_height: int
536
+ ) -> tuple[int, int]:
537
+ """
538
+ Convert processor angles to equirectangular pixel coordinates.
539
+
540
+ Args:
541
+ yaw: Yaw angle in degrees
542
+ pitch: Pitch angle in degrees
543
+ roll: Roll angle in degrees (used for 3D calculations)
544
+ frame_width: Width of the equirectangular frame
545
+ frame_height: Height of the equirectangular frame
546
+
547
+ Returns:
548
+ Tuple of (x, y) pixel coordinates in the equirectangular image
549
+ """
550
+ # Convert angles to radians
551
+ pitch_rad = math.radians(-pitch) # Invert pitch for calculations
552
+ yaw_rad = math.radians(yaw)
553
+
554
+ # For center of projected image, calculate 3D ray direction
555
+ cos_pitch = math.cos(pitch_rad)
556
+ sin_pitch = math.sin(pitch_rad)
557
+ cos_yaw = math.cos(yaw_rad)
558
+ sin_yaw = math.sin(yaw_rad)
559
+
560
+ # Apply pitch and yaw rotation to forward vector
561
+ ray_x = sin_yaw * cos_pitch
562
+ ray_y = -sin_pitch
563
+ ray_z = cos_yaw * cos_pitch
564
+
565
+ # Convert 3D ray to equirectangular coordinates
566
+ longitude = math.atan2(ray_x, ray_z)
567
+ latitude = math.asin(ray_y)
568
+
569
+ # Convert to pixel coordinates in equirectangular image
570
+ eq_x = int((longitude + math.pi) / (2 * math.pi) * frame_width)
571
+ eq_y = int((math.pi / 2 - latitude) / math.pi * frame_height)
572
+
573
+ # Clamp to image bounds
574
+ eq_x = max(0, min(eq_x, frame_width - 1))
575
+ eq_y = max(0, min(eq_y, frame_height - 1))
576
+
577
+ return eq_x, eq_y