Project

General

Profile

Code » History » Version 3

Sandeep GANESAN, 11/13/2025 11:23 AM

1 1 Yaroslav MAYDEBURA
!https://media.tenor.com/4h8aXFiZ2rEAAAAM/coding-programming.gif!
2
3
h1. 💻 Code & Documentation
4
5
---
6
7 2 Yaroslav MAYDEBURA
All source code is developed collaboratively using Redmine and documented via **Doxygen**.  
8 1 Yaroslav MAYDEBURA
This section will contain references to important code snippets, flow explanations, and system logic.
9
10
---
11
12
h2. 📁 Repository Links
13 2 Yaroslav MAYDEBURA
* [Redmine Repository (Private)](http://www.dh.is.ritsumei.ac.jp/redmine/projects/g12-2025-final-project/wiki/)  
14 1 Yaroslav MAYDEBURA
* [Doxygen Documentation (to be added)]  
15
16
---
17
18
h2. 🧩 Core Modules
19
|_. Module |_. Description |
20
| Image Processing | Core blending and correction logic |
21
| Calibration | Overlap detection and adjustment |
22
| UI / CLI | Interface for testing and visualization |
23
24
---
25
26 3 Sandeep GANESAN
h2. 📘 The code
27
28
*ConfigReader.py*
29
30
<pre><code class="python">
31
import configparser
32
33
class ConfigReader:
34
    """
35
    @brief Configuration file reader class
36
    
37
    @details This class provides methods to read, parse and retrieve 
38
    configuration parameters from INI-style configuration files.
39
    It uses Python's configparser module internally.
40
    """
41
    def __init__(self, config_path):
42
        """
43
      @brief Constructor for ConfigReader class
44
      
45
      @param config_path Path to the configuration file
46
      @exception FileNotFoundError If configuration file is not found
47
      """
48
        self.config_path = config_path
49
        self.config = configparser.ConfigParser()
50
        self.config.read(self.config_path)
51
52
    def get_value(self, section, key, fallback=None):
53
        """
54
        @brief Get value from configuration file
55
        
56
        @param section Configuration section name
57
        @param key Configuration key name
58
        @param fallback Default value if key not found
59
        @return Configuration value as string or fallback value
60
        """
61
        try:
62
            return self.config.get(section, key)
63
        except (configparser.NoSectionError, configparser.NoOptionError):
64
            return fallback
65
66
    def get_linear_parameter(self):
67
        """
68
        @brief Get linear parameter from configuration
69
        
70
        @return Linear parameter as float value
71
        """
72
        return float(self.get_value('Parameters', 'linear_parameter'))
73
    
74
    def get_image_path(self):
75
        """
76
        @brief Get image path from configuration
77
        
78
        @return Image file path as string
79
        """
80
        return self.get_value('Parameters', 'image_path')
81
82
    def get_video_path(self):
83
        """
84
       @brief Get video path from configuration
85
       
86
       @return Video file path as string
87
       """
88
        return self.get_value('Parameters', 'video_path')
89
    
90
    def get_overlap(self):
91
        """
92
        @brief Get overlap parameter from configuration
93
        
94
        @return Overlap value as string
95
        """
96
        return self.get_value('Parameters', 'overlap')
97
    
98
    def get_blend_mode(self):
99
        """
100
       @brief Get blend mode from configuration
101
       
102
       @return Blend mode as string
103
       """
104
        return self.get_value('Parameters', 'blend_mode')
105
106
    def save_config(self):
107
        """
108
        @brief Save current configuration to file
109
        
110
        @details Writes the current configuration state back to the file.
111
        This can be used to persist changes made to configuration values.
112
        """
113
        with open(self.config_path, 'w') as configfile:
114
            self.config.write(configfile)
115
</code></pre>
116
117
*ui.py*
118
119
<pre><code class="python">
120
import tkinter as tk
121
from tkinter import ttk, messagebox
122
from PIL import Image, ImageTk
123
import os
124
import altmain
125
import video_processing
126
import cv2
127
import config_reader
128
129
class ImageDisplayApp:
130
    """
131
    @brief Main GUI application class for image processing and display
132
    
133
    @details This class provides a graphical interface for splitting images,
134
    processing videos, and displaying results with various blending options.
135
    """
136
    def __init__(self, root):
137
        """
138
        @brief Constructor for ImageDisplayApp class
139
        
140
        @param root Tkinter root window object
141
        """
142
        self.root = root
143
        self.root.title("Image Split & Update GUI")
144
        self.root.geometry("1000x750")
145
        self.root.configure(bg="#f5f5f5")
146
        self.cfg = config_reader.ConfigReader("config.ini")
147
148
        self.image_paths = {
149
            "main": self.cfg.get_image_path(),
150
            "left": "left.png",
151
            "right": "right.png"
152
        }
153
154
        self.image_frame = ttk.Frame(self.root, padding=10)
155
        self.image_frame.pack(pady=20)
156
        self.processor = altmain.ProjectionSplit()
157
        self.video_processor = video_processing.VideoProcessing("input.mp4")
158
        self.fps = 60
159
        self.is_running = True
160
161
        self.labels = {}
162
        for key in self.image_paths:
163
            lbl = ttk.Label(self.image_frame)
164
            lbl.pack(side=tk.LEFT, padx=10)
165
            self.labels[key] = lbl
166
167
        control_frame = ttk.Frame(self.root, padding=10)
168
        control_frame.pack(pady=10)
169
170
        ttk.Label(control_frame, text="Overlap (pixels):").grid(row=0, column=0, padx=5)
171
        self.param_var = tk.IntVar(value=self.cfg.get_overlap())
172
        entry = ttk.Entry(control_frame, textvariable=self.param_var, width=8)
173
        entry.grid(row=0, column=1, padx=5)
174
175
        ttk.Label(control_frame, text="Blend Type:").grid(row=0, column=2, padx=5)
176
        self.blend_var = tk.StringVar(value=self.cfg.get_blend_mode())
177
        blend_box = ttk.Combobox(
178
            control_frame,
179
            textvariable=self.blend_var,
180
            values=["linear", "quadratic", "gaussian"],
181
            state="readonly",
182
            width=12
183
        )
184
        blend_box.grid(row=0, column=3, padx=5)
185
186
        entry.bind("<FocusOut>", lambda e: self.debounced_update())
187
        blend_box.bind("<<ComboboxSelected>>", lambda e: self.debounced_update())
188
189
        timer_frame = ttk.Frame(self.root, padding=10)
190
        timer_frame.pack(pady=5)
191
192
        ttk.Label(timer_frame, text="Start Time (HH:MM:SS):").pack(side=tk.LEFT, padx=5)
193
        self.start_time_var = tk.StringVar(value="14:30:00")  # default example
194
        ttk.Entry(timer_frame, textvariable=self.start_time_var, width=10).pack(side=tk.LEFT, padx=5)
195
196
        ttk.Button(
197
            timer_frame,
198
            text="Start at Time",
199
            command=self.schedule_start
200
        ).pack(side=tk.LEFT, padx=10)
201
202
203
        fullscreen_frame = ttk.Frame(self.root, padding=10)
204
        fullscreen_frame.pack(pady=5)
205
206
        ttk.Button(
207
            fullscreen_frame,
208
            text="Show Left Fullscreen",
209
            command=lambda: self.show_fullscreen("left")
210
        ).pack(side=tk.LEFT, padx=10)
211
212
        ttk.Button(
213
            fullscreen_frame,
214
            text="Show Right Fullscreen",
215
            command=lambda: self.show_fullscreen("right")
216
        ).pack(side=tk.LEFT, padx=10)
217
        ttk.Button(
218
            fullscreen_frame,
219
            text="Run Video",
220
            command=self.run_video
221
        ).pack(side=tk.LEFT, padx=10)
222
223
        self._update_after_id = None
224
225
    def debounced_update(self):
226
        """
227
        @brief Debounced update to prevent rapid consecutive calls
228
        
229
        @details Cancels pending updates and schedules a new one after delay
230
        to prevent multiple rapid updates when parameters change.
231
        """
232
        if self._update_after_id is not None:
233
            self.root.after_cancel(self._update_after_id)
234
        self._update_after_id = self.root.after(500, self.run_and_update)
235
236
    def show_fullscreen(self, key):
237
        """
238
        @brief Display image in fullscreen mode
239
        
240
        @param key Image identifier ("left" or "right")
241
        @exception FileNotFoundError If image file does not exist
242
        """
243
        path = self.image_paths.get(key)
244
        if not os.path.exists(path):
245
            messagebox.showwarning("Missing Image", f"{path} not found.")
246
            return
247
248
        fullscreen = tk.Toplevel(self.root)
249
        fullscreen.attributes("-fullscreen", True)
250
        fullscreen.configure(bg="black")
251
        fullscreen.bind("<Escape>", lambda e: fullscreen.destroy())
252
253
        lbl = ttk.Label(fullscreen, background="black")
254
        lbl.pack(expand=True)
255
256
        def update_fullscreen():
257
            """
258
            @brief Update fullscreen display with current image
259
            
260
            @details Continuously updates the fullscreen display with
261
            the current image, resizing to fit screen while maintaining aspect ratio.
262
            """
263
            if not os.path.exists(path):
264
                return
265
            try:
266
                img = Image.open(path)
267
                screen_w = fullscreen.winfo_screenwidth()
268
                screen_h = fullscreen.winfo_screenheight()
269
270
                img_ratio = img.width / img.height
271
                screen_ratio = screen_w / screen_h
272
273
                if img_ratio > screen_ratio:
274
                    new_w = screen_w
275
                    new_h = int(screen_w / img_ratio)
276
                else:
277
                    new_h = screen_h
278
                    new_w = int(screen_h * img_ratio)
279
280
                img = img.resize((new_w, new_h), Image.LANCZOS)
281
                photo = ImageTk.PhotoImage(img)
282
                lbl.configure(image=photo)
283
                lbl.image = photo
284
            except Exception as e:
285
                print("Fullscreen update error:", e)
286
287
            fullscreen.after(100, update_fullscreen)
288
289
        update_fullscreen()
290
291
    def _update_image_widget(self, key, np_image):
292
        """
293
        @brief Update individual image widget with new image data
294
        
295
        @param key Widget identifier
296
        @param np_image Numpy array containing image data
297
        """
298
        if np_image is None:
299
            self.labels[key].configure(text=f"No image data", image="")
300
            self.labels[key].image = None
301
            return
302
303
        if np_image.shape[2] == 4:
304
            np_image = cv2.cvtColor(np_image, cv2.COLOR_BGRA2RGBA)
305
        else:
306
            np_image = cv2.cvtColor(np_image, cv2.COLOR_BGR2RGB)
307
308
        img = Image.fromarray(np_image)
309
        img = img.resize((300, 300))
310
311
        photo = ImageTk.PhotoImage(img)
312
313
        self.labels[key].configure(image=photo, text="")
314
        self.labels[key].image = photo
315
316
    def run_and_update(self):
317
        """
318
        @brief Process images and update display
319
        
320
        @details Applies current parameters to process images and
321
        updates all image displays with the results.
322
        
323
        @exception Exception Catches and displays any processing errors
324
        """
325
        try:
326
            overlap_value = int(self.param_var.get())
327
            blend_type = self.blend_var.get()
328
            self.processor.process_images(overlap_value, blend_type)
329
            self.update_images_from_arrays({"left": self.processor.image_left, "right": self.processor.image_right,
330
                                            "main": self.processor.image_main})
331
        except Exception as e:
332
            import traceback
333
            traceback.print_exc()
334
            print("TYPE OF E:", type(e))
335
            print("VALUE OF E:", repr(e))
336
            messagebox.showerror("Error", repr(e))
337
    def run_video(self):
338
        """
339
        @brief Process and display next video frame
340
        
341
        @details Retrieves next frame from video, processes it with
342
        current parameters, and updates the display.
343
        """
344
345
        if not self.is_running:
346
            return
347
348
        image = self.video_processor.get_next_frame()
349
        if image is None:
350
            print("End of video.")
351
            self.is_running = False
352
            return
353
354
        try:
355
            overlap_value = int(self.param_var.get())
356
            blend_type = self.blend_var.get()
357
            self.processor.process_frame(image, overlap_value, blend_type)
358
            self.update_images_from_arrays({
359
                "left": self.processor.image_left,
360
                "right": self.processor.image_right,
361
                "main": self.processor.image_main
362
            })
363
        except Exception as e:
364
            import traceback
365
            traceback.print_exc()
366
            messagebox.showerror("Error", repr(e))
367
            self.is_running = False
368
            return
369
370
        delay = int(1000 / self.fps)
371
        self.root.after(delay, self.run_video)
372
373
    def start_video(self):
374
        """
375
        @brief Start video processing and display
376
        """
377
        self.is_running = True
378
        self.run_video()
379
380
    def stop_video(self):
381
        """
382
        @brief Stop video processing and release resources
383
        """
384
        self.is_running = False
385
        if self.video_processor.cap is not None:
386
            self.video_processor.release()
387
388
    def update_images_from_arrays(self, images: dict):
389
        """
390
        @brief Update all image widgets from numpy arrays
391
        
392
        @param images Dictionary of image identifiers and numpy arrays
393
        """
394
        for key, np_img in images.items():
395
            self._update_image_widget(key, np_img)
396
397
    def schedule_start(self):
398
        """
399
        @brief Schedule video to start at specified time
400
        
401
        @details Parses time string and starts checking loop to begin
402
        video playback at the specified time.
403
        """
404
405
        import datetime
406
        target_str = self.start_time_var.get().strip()
407
        try:
408
            target_time = datetime.datetime.strptime(target_str, "%H:%M:%S").time()
409
            print(f"[Timer] Scheduled video start at {target_time}.")
410
            self._check_time_loop(target_time)
411
        except ValueError:
412
            print("[Timer] Invalid time format. Please use HH:MM:SS (e.g., 14:30:00).")
413
414
    def _check_time_loop(self, target_time):
415
        """
416
        @brief Internal method to check if target time has been reached
417
        
418
        @param target_time Target time to start video
419
        """
420
421
        import datetime
422
        now = datetime.datetime.now().time()
423
424
        if now >= target_time:
425
            print("[Timer] Target time reached — starting video.")
426
            self.start_video()
427
            return
428
429
        self.root.after(1000, lambda: self._check_time_loop(target_time))
430
431
432
433
434
if __name__ == "__main__":
435
    """
436
    @brief Main entry point for the application
437
    
438
    @details Creates Tkinter root window and starts the GUI application
439
    """
440
    root = tk.Tk()
441
    app = ImageDisplayApp(root)
442
    root.mainloop()
443
</code></pre>
444
445
*altmain.py*
446
447
<pre><code class="python">
448
"""
449
@file altmain.py
450
@brief Projection Splitter with Overlap Blending using PyTorch.
451
452
@details
453
This module provides a projection image processing system designed for 
454
multi-projector setups or panoramic displays. It takes an input image, 
455
splits it into two halves (left and right), and applies an overlap blending 
456
region between them to ensure seamless projection alignment.
457
458
It supports multiple blending modes (linear, quadratic, and Gaussian) 
459
and performs GPU-accelerated computation via PyTorch for high efficiency.
460
"""
461
462
import cv2
463
import numpy as np
464
import math
465
from scipy.special import erf
466
import torch
467
import config_reader
468
469
470
class ProjectionSplit:
471
    """
472
    @brief Handles image splitting and blending for projection alignment.
473
474
    @details
475
    The ProjectionSplit class processes both static images and individual 
476
    frames from video sources. It divides the input image into two parts 
477
    with a configurable overlap and applies smooth blending transitions 
478
    using selected mathematical models.
479
480
    The blending operations are optimized with PyTorch and support the 
481
    Metal backend for GPU acceleration on macOS devices.
482
    """
483
484
    def __init__(self):
485
        """@brief Initialize the ProjectionSplit object and configuration.
486
487
        @details
488
        This constructor initializes placeholders for the left, right, 
489
        and main images, and loads configuration parameters (e.g., 
490
        blending coefficients) from the `config.ini` file via ConfigReader.
491
        """
492
        self.image_left = None
493
        self.image_right = None
494
        self.image_main = None
495
        self.cfg = config_reader.ConfigReader("config.ini")
496
497
    def process_frame(self, image, overlap: int = 75, blend_type: str = "exponential"):
498
        """@brief Process a single input frame into left and right projections with overlap blending.
499
500
        @details
501
        This method divides an input image frame into two halves and applies 
502
        a blending function to the overlapping region between them. The 
503
        blending type determines the transition smoothness between projectors.
504
505
        Available blend types:
506
        - **linear**: Simple linear transition.
507
        - **quadratic**: Smoother parabolic blending.
508
        - **gaussian**: Natural soft transition curve based on Gaussian distribution.
509
510
        The blending is executed on GPU via PyTorch for efficient computation.
511
512
        @param image The input image (NumPy array) in BGR or BGRA format.
513
        @param overlap Integer specifying the pixel width of the overlapping area. Default: 75.
514
        @param blend_type String specifying the blending function ("linear", "quadratic", "gaussian").
515
        @throws FileNotFoundError If the image is None or not found.
516
        @throws ValueError If an invalid blend_type is specified.
517
        """
518
        if image is None:
519
            raise FileNotFoundError("Error: input.png not found or could not be loaded.")
520
        self.image_main = image
521
522
        # Ensure alpha channel
523
        if image.shape[2] == 3:
524
            image = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA)
525
526
        height, width = image.shape[:2]
527
        split_x = width // 2
528
529
        # Define overlapping regions
530
        left_end = split_x + overlap // 2
531
        right_start = split_x - overlap // 2
532
533
        left_img = image[:, :left_end].copy()
534
        right_img = image[:, right_start:].copy()
535
536
        # Generate normalized overlap vector
537
        x = np.linspace(0, 1, overlap)
538
539
        # Select blending function
540
        if blend_type == "linear":
541
            alpha_left_curve = 1 - self.cfg.get_linear_parameter() * x
542
            alpha_right_curve = 1 - self.cfg.get_linear_parameter() + self.cfg.get_linear_parameter() * x
543
        elif blend_type == "quadratic":
544
            alpha_left_curve = (1 - x) ** 2
545
            alpha_right_curve = x ** 2
546
        elif blend_type == "gaussian":
547
            sigma = 0.25
548
            g = 0.5 * (1 + erf((x - 0.5) / (sigma * np.sqrt(2))))
549
            alpha_left_curve = 1 - g
550
            alpha_right_curve = g
551
        else:
552
            raise ValueError(f"Unknown blend_type '{blend_type}'")
553
554
        # GPU accelerated blending using PyTorch
555
        device = "mps"  # Metal backend (for macOS)
556
        left_img_t = torch.from_numpy(left_img).to(device, dtype=torch.float32)
557
        right_img_t = torch.from_numpy(right_img).to(device, dtype=torch.float32)
558
        alpha_left_t = torch.from_numpy(alpha_left_curve).to(device, dtype=torch.float32)
559
        alpha_right_t = torch.from_numpy(alpha_right_curve).to(device, dtype=torch.float32)
560
561
        # Expand alpha for broadcast along image width
562
        alpha_left_2d = alpha_left_t.unsqueeze(0).unsqueeze(-1)
563
        alpha_right_2d = alpha_right_t.unsqueeze(0).unsqueeze(-1)
564
565
        # Apply blending on alpha channel
566
        left_img_t[:, -overlap:, 3] *= alpha_left_2d.squeeze(-1)
567
        right_img_t[:, :overlap, 3] *= alpha_right_2d.squeeze(-1)
568
569
        # Convert back to CPU for saving
570
        left_img = left_img_t.cpu().numpy().astype(np.uint8)
571
        right_img = right_img_t.cpu().numpy().astype(np.uint8)
572
573
        self.image_left = left_img
574
        self.image_right = right_img
575
576
        cv2.imwrite("left.png", left_img)
577
        cv2.imwrite("right.png", right_img)
578
579
    def process_images(self, overlap: int = 75, blend_type: str = "exponential"):
580
        """@brief Process a static image file and generate blended left/right outputs.
581
582
        @details
583
        Reads 'input.png' from the current working directory, applies 
584
        image splitting and overlap blending, and saves the processed 
585
        halves as 'left.png' and 'right.png'.
586
587
        This function uses the same internal logic as process_frame() 
588
        but is intended for static image files instead of real-time frames.
589
590
        @param overlap Integer pixel width of the overlapping region. Default: 75.
591
        @param blend_type String specifying blending mode ("linear", "quadratic", "gaussian").
592
        @throws FileNotFoundError If 'input.png' is not found.
593
        @throws ValueError If an invalid blending type is selected.
594
        """
595
        image = cv2.imread("input.png", cv2.IMREAD_UNCHANGED)
596
        self.image_main = image
597
        if image is None:
598
            raise FileNotFoundError("Error: input.png not found.")
599
600
        # Ensure image has alpha channel
601
        if image.shape[2] == 3:
602
            image = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA)
603
604
        height, width = image.shape[:2]
605
        split_x = width // 2
606
607
        left_end = split_x + overlap // 2
608
        right_start = split_x - overlap // 2
609
610
        left_img = image[:, :left_end].copy()
611
        right_img = image[:, right_start:].copy()
612
613
        # Create blend curve
614
        x = np.linspace(0, 1, overlap)
615
        if blend_type == "linear":
616
            alpha_left_curve = 1 - self.cfg.get_linear_parameter() * x
617
            alpha_right_curve = 1 - self.cfg.get_linear_parameter() + self.cfg.get_linear_parameter() * x
618
        elif blend_type == "quadratic":
619
            alpha_left_curve = (1 - x) ** 2
620
            alpha_right_curve = x ** 2
621
        elif blend_type == "gaussian":
622
            sigma = 0.25
623
            g = 0.5 * (1 + erf((x - 0.5) / (sigma * np.sqrt(2))))
624
            alpha_left_curve = 1 - g
625
            alpha_right_curve = g
626
        else:
627
            raise ValueError(f"Unknown blend_type '{blend_type}'")
628
629
        # GPU blending with PyTorch
630
        device = "mps"
631
        left_img_t = torch.from_numpy(left_img).to(device, dtype=torch.float32)
632
        right_img_t = torch.from_numpy(right_img).to(device, dtype=torch.float32)
633
        alpha_left_t = torch.from_numpy(alpha_left_curve).to(device, dtype=torch.float32)
634
        alpha_right_t = torch.from_numpy(alpha_right_curve).to(device, dtype=torch.float32)
635
636
        alpha_left_2d = alpha_left_t.unsqueeze(0).unsqueeze(-1)
637
        alpha_right_2d = alpha_right_t.unsqueeze(0).unsqueeze(-1)
638
639
        left_img_t[:, -overlap:, 3] *= alpha_left_2d.squeeze(-1)
640
        right_img_t[:, :overlap, 3] *= alpha_right_2d.squeeze(-1)
641
642
        left_img = left_img_t.cpu().numpy().astype(np.uint8)
643
        right_img = right_img_t.cpu().numpy().astype(np.uint8)
644
645
        self.image_left = left_img
646
        self.image_right = right_img
647
648
        cv2.imwrite("left.png", left_img)
649
        cv2.imwrite("right.png", right_img)
650
651
</code></pre>
652
653
*video_processing.py*
654
655
<pre><code class="python">
656
import cv2
657
658
class VideoProcessing:
659
    """@brief A class for loading, reading, and managing video streams using OpenCV.
660
661
    @details
662
    This class provides basic functionality for working with video files:
663
    - Loading a video from a file path.
664
    - Reading frames sequentially.
665
    - Releasing system resources after use.
666
667
    It is designed for modular integration with image-processing workflows, 
668
    enabling real-time or frame-by-frame analysis from video inputs.
669
    """
670
671
    def __init__(self, video_path=None):
672
        """@brief Initialize the VideoProcessing object.
673
674
        @details
675
        Optionally takes a video file path at initialization. If provided, 
676
        the constructor automatically loads the video for reading.
677
678
        @param video_path (optional) The file path to the video file to load.
679
        """
680
        self.cap = None
681
        if video_path:
682
            self.load_video(video_path)
683
684
    def load_video(self, video_path):
685
        """@brief Load a video file for processing.
686
687
        @details
688
        Opens a video file using OpenCV’s `VideoCapture`. If another video is 
689
        already loaded, it is safely released before opening the new one.
690
691
        @param video_path The file path to the video file to load.
692
693
        @throws ValueError If the video cannot be opened successfully.
694
        """
695
        if self.cap is not None:
696
            self.cap.release()
697
698
        self.cap = cv2.VideoCapture(video_path)
699
700
        if not self.cap.isOpened():
701
            raise ValueError(f"Cannot open video file: {video_path}")
702
703
    def get_next_frame(self):
704
        """@brief Retrieve the next frame from the video stream.
705
706
        @details
707
        Reads the next available frame from the video. If the end of the video
708
        is reached or the video is not properly loaded, returns `None`.
709
710
        @return The next video frame as a NumPy array, or `None` if no frame is available.
711
712
        @throws RuntimeError If no video is loaded before calling this function.
713
        """
714
        if self.cap is None:
715
            raise RuntimeError("No video loaded. Call load_video() first.")
716
717
        ret, frame = self.cap.read()
718
        if not ret:
719
            return None  # End of video or error
720
721
        return frame
722
723
    def release(self):
724
        """@brief Release the video capture resource.
725
726
        @details
727
        Safely releases the OpenCV `VideoCapture` object to free up system resources.
728
        This should always be called when video processing is complete.
729
        """
730
        if self.cap is not None:
731
            self.cap.release()
732
            self.cap = None
733
734
</code></pre>