Gui Control for Autofocus

Using Copilot Prompts to Build a PyQt5 Based Autofocus Control for Raspberry Pi Camera Module 3

The Raspberry Pi Camera Module 3 has an Autofocus control, this can also be manually controlled from code.

How far can I get building a GUI control for this function using just prompts from autopilot.

Visual Studio Code (VS Code) is a free, open-source code editor and IDE developed by Microsoft. It is a recommened IDE for both Raspberry Pi and Jetson Orin Nano.

Copilot self describes as an AI-powered code completion tool developed by GitHub in partnership with OpenAI. Copilot is fully integrated into the VS Code environment.

Copilot claims to generate code from descriptive comments.

I have a Raspberry Pi Module 3 camera with autofocus. Can Copilot build me a GUI based Autofocus control using only prompts?

Lets find out!

Prompt 1

using pyqt5 and picamera2 create a widget that allows the af settings of the camera 3 camera to be adjusted from the widget

Copilot tells me what it’s going to do and then generates some code. Running this code generates a window pretty much like the following:

Prompt 1 Output

Prompt 1 Output

Here’s the Copilot Generated Code Listing
import sys
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel

class AutoFocusControl(QWidget):
    def __init__(self):
        super().__init__()
        self.init_ui()

    def init_ui(self):
        self.setWindowTitle('Raspberry Pi Camera Autofocus Control')
        layout = QVBoxLayout()

        self.status = QLabel('Autofocus status: Unknown')
        layout.addWidget(self.status)

        btn_focus = QPushButton('Trigger Autofocus')
        btn_focus.clicked.connect(self.trigger_autofocus)
        layout.addWidget(btn_focus)

        self.setLayout(layout)

    def trigger_autofocus(self):
        # Here you would add code to trigger autofocus on the camera
        self.status.setText('Autofocus triggered!')

if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = AutoFocusControl()
    window.show()
    sys.exit(app.exec_())

Well… I can’t argue! It is a GUI widget, and yes, a brief look at the code confirms that it could indeed update at least some of the Camera AF control parameters. But I had hoped for some kind of preview facility to give me an indation of what effect changing the control would have. Let’s refine the prompt.

Prompt 2

integrate this with video

Copilot tells me: “To integrate video into your existing AFSettingsWidget, you can use the QLabel widget to display the video feed from the camera. The Picamera2 library provides a way to capture frames and display them in a PyQt5 application. Below is the updated code:”

To be honest, I wasn’t too convinced by this approach, and the output from the revised listing confirmed my worst suspicions.

Here’s the Copilot Generated Code Listing
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QLabel, QSlider, QPushButton, QHBoxLayout
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QImage, QPixmap
from picamera2 import Picamera2, Preview

class AFSettingsWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Camera AF Settings")
        self.picam = Picamera2()
        self.picam.configure(self.picam.create_preview_configuration(main={"size": (640, 480)}))
        self.picam.start()

        # Layout
        self.layout = QVBoxLayout()

        # Video Display
        self.video_label = QLabel("Video Feed")
        self.video_label.setFixedSize(640, 480)
        self.layout.addWidget(self.video_label)

        # Timer for updating video feed
        self.timer = QTimer(self)
        self.timer.timeout.connect(self.update_video_feed)
        self.timer.start(30)  # Update every 30ms (~33 FPS)

        # AF Speed Slider
        self.af_speed_label = QLabel("AF Speed:")
        self.af_speed_slider = QSlider(Qt.Horizontal)
        self.af_speed_slider.setMinimum(0)
        self.af_speed_slider.setMaximum(100)
        self.af_speed_slider.setValue(50)
        self.af_speed_slider.valueChanged.connect(self.update_af_speed)

        # AF Range Slider
        self.af_range_label = QLabel("AF Range:")
        self.af_range_slider = QSlider(Qt.Horizontal)
        self.af_range_slider.setMinimum(0)
        self.af_range_slider.setMaximum(100)
        self.af_range_slider.setValue(50)
        self.af_range_slider.valueChanged.connect(self.update_af_range)

        # Buttons
        self.apply_button = QPushButton("Apply Settings")
        self.apply_button.clicked.connect(self.apply_settings)

        self.reset_button = QPushButton("Reset Settings")
        self.reset_button.clicked.connect(self.reset_settings)

        # Add widgets to layout
        self.layout.addWidget(self.af_speed_label)
        self.layout.addWidget(self.af_speed_slider)
        self.layout.addWidget(self.af_range_label)
        self.layout.addWidget(self.af_range_slider)

        button_layout = QHBoxLayout()
        button_layout.addWidget(self.apply_button)
        button_layout.addWidget(self.reset_button)
        self.layout.addLayout(button_layout)

        self.setLayout(self.layout)

    def update_video_feed(self):
        # Capture a frame from the camera
        frame = self.picam.capture_array()
        # Convert the frame to QImage
        height, width, channel = frame.shape
        bytes_per_line = channel * width
        q_image = QImage(frame.data, width, height, bytes_per_line, QImage.Format_RGB888)
        # Display the frame in the QLabel
        self.video_label.setPixmap(QPixmap.fromImage(q_image))

    def update_af_speed(self, value):
        self.af_speed_label.setText(f"AF Speed: {value}")

    def update_af_range(self, value):
        self.af_range_label.setText(f"AF Range: {value}")

    def apply_settings(self):
        af_speed = self.af_speed_slider.value()
        af_range = self.af_range_slider.value()
        # Apply AF settings to the camera
        self.picam.set_controls({"AfSpeed": af_speed, "AfRange": af_range})
        print(f"Applied AF Speed: {af_speed}, AF Range: {af_range}")

    def reset_settings(self):
        self.af_speed_slider.setValue(50)
        self.af_range_slider.setValue(50)
        self.picam.set_controls({"AfSpeed": 50, "AfRange": 50})
        print("Reset AF settings to default.")

if __name__ == "__main__":
    import sys
    app = QApplication(sys.argv)
    widget = AFSettingsWidget()
    widget.show()
    sys.exit(app.exec_())

Prompt 2 Output

Prompt 2 Output

Anybody who worked with picamera1 will remember the issues around integrating any kind of preview window witin the context of a PyQt app. Picamera2’s QGLPicamera2 widget which is a true PyQt widget gets round all of these problems. So…

Prompt 3

do this again but using the picamera2 QGlPicamera2 widget

Copilot now tells me: To use the QGlPicamera2 widget from the picamera2 library, you can replace the manual video feed handling with this widget. The QGlPicamera2 widget is specifically designed for displaying video feeds in PyQt5 applications, making the integration simpler and more efficient.

Didn’t I just tell you that?

Here’s the Copilot Generated Code Listing
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QLabel, QSlider, QPushButton, QHBoxLayout
from PyQt5.QtCore import Qt
from picamera2 import Picamera2

# from picamera2.previews import QGlPicamera2
# manually changed by ln
from picamera2.previews.qt import QGlPicamera2

class AFSettingsWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Camera AF Settings")
        self.picam = Picamera2()
        self.picam.configure(self.picam.create_preview_configuration(main={"size": (640, 480)}))

        # Layout
        self.layout = QVBoxLayout()

        # Video Display using QGlPicamera2
        self.video_widget = QGlPicamera2(self.picam, width=640, height=480)
        self.layout.addWidget(self.video_widget)

        # Start the camera
        self.picam.start()

        # AF Speed Slider
        self.af_speed_label = QLabel("AF Speed:")
        self.af_speed_slider = QSlider(Qt.Horizontal)
        self.af_speed_slider.setMinimum(0)
        self.af_speed_slider.setMaximum(100)
        self.af_speed_slider.setValue(50)
        self.af_speed_slider.valueChanged.connect(self.update_af_speed)

        # AF Range Slider
        self.af_range_label = QLabel("AF Range:")
        self.af_range_slider = QSlider(Qt.Horizontal)
        self.af_range_slider.setMinimum(0)
        self.af_range_slider.setMaximum(100)
        self.af_range_slider.setValue(50)
        self.af_range_slider.valueChanged.connect(self.update_af_range)

        # Buttons
        self.apply_button = QPushButton("Apply Settings")
        self.apply_button.clicked.connect(self.apply_settings)

        self.reset_button = QPushButton("Reset Settings")
        self.reset_button.clicked.connect(self.reset_settings)

        # Add widgets to layout
        self.layout.addWidget(self.af_speed_label)
        self.layout.addWidget(self.af_speed_slider)
        self.layout.addWidget(self.af_range_label)
        self.layout.addWidget(self.af_range_slider)

        button_layout = QHBoxLayout()
        button_layout.addWidget(self.apply_button)
        button_layout.addWidget(self.reset_button)
        self.layout.addLayout(button_layout)

        self.setLayout(self.layout)

    def update_af_speed(self, value):
        self.af_speed_label.setText(f"AF Speed: {value}")

    def update_af_range(self, value):
        self.af_range_label.setText(f"AF Range: {value}")

    def apply_settings(self):
        af_speed = self.af_speed_slider.value()
        af_range = self.af_range_slider.value()
        # Apply AF settings to the camera
        self.picam.set_controls({"AfSpeed": af_speed, "AfRange": af_range})
        print(f"Applied AF Speed: {af_speed}, AF Range: {af_range}")

    def reset_settings(self):
        self.af_speed_slider.setValue(50)
        self.af_range_slider.setValue(50)
        self.picam.set_controls({"AfSpeed": 50, "AfRange": 50})
        print("Reset AF settings to default.")

if __name__ == "__main__":
    import sys
    app = QApplication(sys.argv)
    widget = AFSettingsWidget()
    widget.show()
    sys.exit(app.exec_())

Superficially, if we run this code, we appear to be right back where we started, but the clip below identifies what the problem is. It also identifies what the problem after that will be too!

Prompt 4

i need to see the whole of the video_widget

Copilot responded: To ensure the video_widget (the QGlPicamera2 widget) is fully visible, you can adjust the size of the main window to match the dimensions of the video feed. Here’s how you can modify your code to ensure the video_widget is fully displayed:

Here’s the output. Which is still far from ideal.

Prompt 4 Output

Prompt 4 Output

Here’s the Copilot Generated Code Listing

from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QLabel, QSlider, QPushButton, QHBoxLayout
from PyQt5.QtCore import Qt
from picamera2 import Picamera2

# from picamera2.previews import QGlPicamera2
# manually changed by ln
from picamera2.previews.qt import QGlPicamera2

class AFSettingsWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Camera AF Settings")
        self.picam = Picamera2()
        self.picam.configure(self.picam.create_preview_configuration(main={"size": (640, 480)}))

        # Layout
        self.layout = QVBoxLayout()

        # Video Display using QGlPicamera2
        self.video_widget = QGlPicamera2(self.picam, width=640, height=480)
        self.layout.addWidget(self.video_widget)

        # Start the camera
        self.picam.start()

        # AF Speed Slider
        self.af_speed_label = QLabel("AF Speed:")
        self.af_speed_slider = QSlider(Qt.Horizontal)
        self.af_speed_slider.setMinimum(0)
        self.af_speed_slider.setMaximum(100)
        self.af_speed_slider.setValue(50)
        self.af_speed_slider.valueChanged.connect(self.update_af_speed)

        # AF Range Slider
        self.af_range_label = QLabel("AF Range:")
        self.af_range_slider = QSlider(Qt.Horizontal)
        self.af_range_slider.setMinimum(0)
        self.af_range_slider.setMaximum(100)
        self.af_range_slider.setValue(50)
        self.af_range_slider.valueChanged.connect(self.update_af_range)

        # Buttons
        self.apply_button = QPushButton("Apply Settings")
        self.apply_button.clicked.connect(self.apply_settings)

        self.reset_button = QPushButton("Reset Settings")
        self.reset_button.clicked.connect(self.reset_settings)

        # Add widgets to layout
        self.layout.addWidget(self.af_speed_label)
        self.layout.addWidget(self.af_speed_slider)
        self.layout.addWidget(self.af_range_label)
        self.layout.addWidget(self.af_range_slider)

        button_layout = QHBoxLayout()
        button_layout.addWidget(self.apply_button)
        button_layout.addWidget(self.reset_button)
        self.layout.addLayout(button_layout)

        self.setLayout(self.layout)

        # Resize the main window to fit the video widget and controls
        self.resize(640, 600)

    def update_af_speed(self, value):
        self.af_speed_label.setText(f"AF Speed: {value}")

    def update_af_range(self, value):
        self.af_range_label.setText(f"AF Range: {value}")

    def apply_settings(self):
        af_speed = self.af_speed_slider.value()
        af_range = self.af_range_slider.value()
        # Apply AF settings to the camera
        self.picam.set_controls({"AfSpeed": af_speed, "AfRange": af_range})
        print(f"Applied AF Speed: {af_speed}, AF Range: {af_range}")

    def reset_settings(self):
        self.af_speed_slider.setValue(50)
        self.af_range_slider.setValue(50)
        self.picam.set_controls({"AfSpeed": 50, "AfRange": 50})
        print("Reset AF settings to default.")

if __name__ == "__main__":
    import sys
    app = QApplication(sys.argv)
    widget = AFSettingsWidget()
    widget.show()
    sys.exit(app.exec_())

A Pause for thought

This is still not looking anything like what I had had had in mind. However, by this time I concluded that a better plan would be to seperate the preview window from the control dialog.

Also the one control I was really interested in was a manual autofocus control , so I decided to pursue that.

It’s worth commenting at this point on the code that Coplilot is generating. It is robust and well structured. There are well implemented naming conventions for both functions and variable names.

While coding GUI widgets can be a very tedious business when often many of the properties of a widget have to be set across a number of lines. This can be a tedious and error prone business.

It was alrady clear to me that while some major manual finessing of the code might be required that by and large Copilot is a game changer both in terms of productivity and stress management when creating complex GUIs.

The image below shows CPs neat diff based approach to applying updates to the existing code.

Code Differences

Code Differences

Prompts 5, 6 and 7

So finally, after

Prompt 5: add a radio button group to set afmode and a slider to set manually the LensPosition. Units to be in dioptres

Prompt 6: put the QGLPicamera2 widget into its own window

and a rather cheeky

Prompt 7: Provide docstrings for all functions and classes

we end up with our final GUI looking something like this:

Final GUI Output

Final GUI Output

Final Copilot Listing
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QLabel, QSlider, QPushButton, QHBoxLayout, QRadioButton, QButtonGroup
from PyQt5.QtCore import Qt
from picamera2 import Picamera2
from libcamera import controls  # Import libcamera controls

# from picamera2.previews import QGlPicamera2
# manually changed by ln
from picamera2.previews.qt import QGlPicamera2

class VideoWindow(QWidget):
    """
    A PyQt5 widget for displaying the camera video feed using QGlPicamera2.
    """

    def __init__(self, video_widget):
        """
        Initialize the VideoWindow.

        Args:
            video_widget (QGlPicamera2): The QGlPicamera2 widget for displaying the video feed.
        """
        super().__init__()
        self.setWindowTitle("Camera Video Feed")

        # Layout for the video window
        layout = QVBoxLayout()

        # Add the provided QGlPicamera2 widget to the layout
        layout.addWidget(video_widget)

        self.setLayout(layout)
        self.resize(640, 480)


class AFSettingsWidget(QWidget):
    """
    A PyQt5 widget for controlling autofocus (AF) settings of a camera using Picamera2.

    This widget allows users to:
    - Switch between autofocus modes (Auto and Manual).
    - Adjust the lens position in dioptres (0.0–10.0) when in Manual mode.
    - Adjust autofocus speed and range.
    - Apply or reset settings.
    """

    def __init__(self, picam, video_widget):
        """
        Initialize the AFSettingsWidget.

        Args:
            picam (Picamera2): The Picamera2 object for controlling the camera.
            video_widget (QGlPicamera2): The QGlPicamera2 widget for displaying the video feed.
        """
        super().__init__()
        self.setWindowTitle("Camera AF Settings")
        self.picam = picam

        # Open the video window
        self.video_window = VideoWindow(video_widget)
        self.video_window.show()

        # Start the camera
        self.picam.start()

        # Layout
        self.layout = QVBoxLayout()

        # AF Mode Radio Buttons
        self.af_mode_label = QLabel("AF Mode:")
        self.auto_mode_button = QRadioButton("Auto")
        self.manual_mode_button = QRadioButton("Manual")
        self.auto_mode_button.setChecked(True)  # Default to Auto mode
        self.af_mode_group = QButtonGroup()
        self.af_mode_group.addButton(self.auto_mode_button)
        self.af_mode_group.addButton(self.manual_mode_button)
        self.af_mode_group.buttonClicked.connect(self.update_af_mode)

        af_mode_layout = QHBoxLayout()
        af_mode_layout.addWidget(self.auto_mode_button)
        af_mode_layout.addWidget(self.manual_mode_button)

        self.layout.addWidget(self.af_mode_label)
        self.layout.addLayout(af_mode_layout)

        # Lens Position Slider (in dioptres)
        self.lens_position_label = QLabel("Lens Position (Dioptres):")
        self.lens_position_slider = QSlider(Qt.Horizontal)
        self.lens_position_slider.setMinimum(0)  # Minimum dioptres
        self.lens_position_slider.setMaximum(100)  # Maximum dioptres (scaled by 10 for 0.1 increments)
        self.lens_position_slider.setValue(0)  # Default to 0 dioptres
        self.lens_position_slider.setEnabled(False)  # Disabled by default (enabled in manual mode)
        self.lens_position_slider.valueChanged.connect(self.update_lens_position)

        self.layout.addWidget(self.lens_position_label)
        self.layout.addWidget(self.lens_position_slider)

        # AF Speed Slider
        self.af_speed_label = QLabel("AF Speed:")
        self.af_speed_slider = QSlider(Qt.Horizontal)
        self.af_speed_slider.setMinimum(0)
        self.af_speed_slider.setMaximum(100)
        self.af_speed_slider.setValue(50)
        self.af_speed_slider.valueChanged.connect(self.update_af_speed)

        # AF Range Slider
        self.af_range_label = QLabel("AF Range:")
        self.af_range_slider = QSlider(Qt.Horizontal)
        self.af_range_slider.setMinimum(0)
        self.af_range_slider.setMaximum(100)
        self.af_range_slider.setValue(50)
        self.af_range_slider.valueChanged.connect(self.update_af_range)

        # Buttons
        self.apply_button = QPushButton("Apply Settings")
        self.apply_button.clicked.connect(self.apply_settings)

        self.reset_button = QPushButton("Reset Settings")
        self.reset_button.clicked.connect(self.reset_settings)

        # Add widgets to layout
        self.layout.addWidget(self.af_speed_label)
        self.layout.addWidget(self.af_speed_slider)
        self.layout.addWidget(self.af_range_label)
        self.layout.addWidget(self.af_range_slider)

        button_layout = QHBoxLayout()
        button_layout.addWidget(self.apply_button)
        button_layout.addWidget(self.reset_button)
        self.layout.addLayout(button_layout)

        self.setLayout(self.layout)

        # Resize the main window to fit the controls
        self.resize(400, 400)

    def update_af_mode(self, button):
        """
        Update the autofocus mode based on the selected radio button.

        Args:
            button (QRadioButton): The selected radio button (Auto or Manual).

        Sets the autofocus mode to Auto or Manual using the appropriate enum
        from libcamera.controls. Enables or disables the Lens Position slider
        based on the selected mode.
        """
        mode = controls.AfModeEnum.Auto if button == self.auto_mode_button else controls.AfModeEnum.Manual
        try:
            self.picam.set_controls({"AfMode": mode})
            print(f"Set AF Mode to: {mode}")
        except Exception as e:
            print(f"Error setting AF Mode: {e}")
        # Enable/Disable Lens Position slider based on mode
        self.lens_position_slider.setEnabled(mode == controls.AfModeEnum.Manual)
        if mode == controls.AfModeEnum.Auto:
            self.lens_position_slider.setValue(0)  # Reset to default

    def update_lens_position(self, value):
        """
        Update the lens position based on the slider value.

        Args:
            value (int): The slider value (0–100, scaled to 0.0–10.0 dioptres).

        Converts the slider value to dioptres and updates the LensPosition
        control on the camera.
        """
        dioptres = float(value) / 10.0  # Ensure the value is a float
        try:
            self.picam.set_controls({"LensPosition": dioptres})
            print(f"Set Lens Position to: {dioptres:.1f} dioptres")
        except Exception as e:
            print(f"Error setting Lens Position: {e}")

    def update_af_speed(self, value):
        """
        Update the autofocus speed label based on the slider value.

        Args:
            value (int): The slider value (0–100).
        """
        self.af_speed_label.setText(f"AF Speed: {value}")

    def update_af_range(self, value):
        """
        Update the autofocus range label based on the slider value.

        Args:
            value (int): The slider value (0–100).
        """
        self.af_range_label.setText(f"AF Range: {value}")

    def apply_settings(self):
        """
        Apply the current autofocus speed and range settings to the camera.

        Retrieves the values from the sliders and updates the camera controls.
        """
        af_speed = int(self.af_speed_slider.value())  # Ensure integer type
        af_range = int(self.af_range_slider.value())  # Ensure integer type
        try:
            self.picam.set_controls({"AfSpeed": af_speed, "AfRange": af_range})
            print(f"Applied AF Speed: {af_speed}, AF Range: {af_range}")
        except Exception as e:
            print(f"Error applying settings: {e}")

    def reset_settings(self):
        """
        Reset all autofocus settings to their default values.

        Resets the sliders, autofocus mode, and lens position to their defaults.
        """
        self.af_speed_slider.setValue(50)
        self.af_range_slider.setValue(50)
        self.lens_position_slider.setValue(0)
        self.auto_mode_button.setChecked(True)
        self.picam.set_controls({
            "AfSpeed": 50,
            "AfRange": 50,
            "AfMode": controls.AfModeEnum.Auto,
            "LensPosition": 0.0
        })
        print("Reset AF settings to default.")

    def closeEvent(self, event):
        """
        Ensure that closing this window also closes the video window and exits the app.
        """
        if hasattr(self, 'video_window'):
            self.video_window.close()
        QApplication.quit()
        super().closeEvent(event)

class VideoWindow(QWidget):
    """
    A PyQt5 widget for displaying the camera video feed using QGlPicamera2.
    """

    def __init__(self, video_widget):
        """
        Initialize the VideoWindow.

        Args:
            video_widget (QGlPicamera2): The QGlPicamera2 widget for displaying the video feed.
        """
        super().__init__()
        self.setWindowTitle("Camera Video Feed")

        # Layout for the video window
        layout = QVBoxLayout()

        # Add the provided QGlPicamera2 widget to the layout
        layout.addWidget(video_widget)

        self.setLayout(layout)
        self.resize(640, 480)

    def closeEvent(self, event):
        """
        Ensure that closing this window also closes the AF settings window and exits the app.
        """
        # Find the AFSettingsWidget parent and close it if it exists
        for widget in QApplication.topLevelWidgets():
            if isinstance(widget, AFSettingsWidget):
                widget.close()
        QApplication.quit()
        super().closeEvent(event)

# if __name__ == "__main__":
#     import sys
#     app = QApplication(sys.argv)
#     widget = AFSettingsWidget()
#     widget.show()
#     sys.exit(app.exec_())


if __name__ == "__main__":
    import sys
    app = QApplication(sys.argv)

    # Instantiate the Picamera2 object
    picam = Picamera2()

    # Create the QGlPicamera2 widget
    video_widget = QGlPicamera2(picam, width=640, height=480)

    # Create the AFSettingsWidget and pass the camera and video widget
    widget = AFSettingsWidget(picam, video_widget)

    # Show the widget
    widget.show()

    # Execute the application
    sys.exit(app.exec_())

Further Thoughts

To be added

Last modified July 17, 2025: tidying up (f424db7)