Pythonでカメラ映像を録画・静止画キャプチャ!OpenCVとTkinterで簡単実装

Pythonを使って、カメラ映像をリアルタイムに表示し、動画録画と静止画キャプチャを行う方法を解説します。OpenCVとTkinterを活用し、GUI操作で簡単にカメラを制御できるアプリケーションを作成します。

Pythonでカメラ操作を簡単にする方法

カメラ映像の操作は、画像処理やコンピュータビジョンなど様々な分野で重要です。PythonではOpenCVライブラリを使ってカメラにアクセスし、TkinterでGUIを構築することで、ユーザーフレンドリーなアプリケーションを作成できます。

以前の記事Pythonでカメラ映像を表示する方法を基に、動画録画と静止画キャプチャ機能を追加します。

録画・キャプチャ機能の実装方法

このプログラムの特徴は、カメラ描画中にのみ録画・キャプチャ操作が可能である点です。これにより、無駄なインスタンス生成を避け、効率的な動作を実現しています。

静止画キャプチャ

capture_image()関数で、filedialogモジュールを利用した保存ダイアログを表示し、ユーザーがファイル名と保存場所を指定します。is_caputureフラグでキャプチャのタイミングを制御し、映像の読み出しおよび描画関数の中で、cv2.imwrite()で画像を保存します。
これは余計なcaputureインスタンスを作成しないようにこのようにしています。

def capture_image():
    # 現在のフレームを静止画として保存
    global capture,is_caputure,image_filename
    image_filename = select_file(0)
    is_caputure = True

動画録画

start_recording()関数で録画開始、stop_recording()関数で録画停止を制御します。cv2.VideoWriterオブジェクトで動画ファイルを作成し、is_recordingフラグで録画状態を管理します。

def start_recording():
    # 録画を開始
    global capture, is_recording, video_writer
    video_filename = select_file(1)
    
    if capture is not None and capture.isOpened():
        is_recording = True
        # 動画書き込みインスタンスの作成
        fourcc = cv2.VideoWriter_fourcc(*'X264')
        video_writer = cv2.VideoWriter(video_filename, fourcc, 30, (640, 480))

def stop_recording():
    # 録画を停止
    global is_recording, video_writer
    if is_recording:
        is_recording = False
        if video_writer is not None:
            video_writer.release()

GUI操作との連携

TkinterPAGEを使ってGUIを作成し、上記の関数バインドさせています。ボタンクリックでそれぞれのコールバック関数が呼び出されます。

# camerahmi.py (抜粋) self.TButton3.configure(command=camerahmi_support.capture_on_click) # キャプチャボタン self.TButton4.configure(command=camerahmi_support.recording_on_click) # 録画ボタン

プログラム全貌

camerahmi.py

#! /usr/bin/env python3
#  -*- coding: utf-8 -*-
#
# GUI module generated by PAGE version 8.0
#  in conjunction with Tcl version 8.6
#    Oct 06, 2024 09:14:46 PM JST  platform: Windows NT

import sys
import tkinter as tk
import tkinter.ttk as ttk
from tkinter.constants import *
import os.path

_location = os.path.dirname(__file__)

import camerahmi_support

_bgcolor = '#d9d9d9'
_fgcolor = '#000000'
_tabfg1 = 'black' 
_tabfg2 = 'white' 
_bgmode = 'light' 
_tabbg1 = '#d9d9d9' 
_tabbg2 = 'gray40' 

_style_code_ran = 0
def _style_code():
    global _style_code_ran
    if _style_code_ran: return        
    try: camerahmi_support.root.tk.call('source',
                os.path.join(_location, 'themes', 'default.tcl'))
    except: pass
    style = ttk.Style()
    style.theme_use('default')
    style.configure('.', font = "TkDefaultFont")
    if sys.platform == "win32":
       style.theme_use('winnative')    
    _style_code_ran = 1

class Toplevel1:
    def __init__(self, top=None):
        '''This class configures and populates the toplevel window.
           top is the toplevel containing window.'''

        top.geometry("918x487+448+236")
        top.minsize(120, 1)
        top.maxsize(3844, 1061)
        top.resizable(1,  1)
        top.title("Toplevel 0")
        top.configure(background="#d9d9d9")
        top.configure(highlightbackground="#d9d9d9")
        top.configure(highlightcolor="#000000")

        self.top = top
        self.combobox = tk.StringVar()

        _style_code()
        self.TButton1 = ttk.Button(self.top)
        self.TButton1.place(relx=0.033, rely=0.062, height=26, width=75)
        self.TButton1.configure(command=camerahmi_support.ConnectBt_on_Click)
        self.TButton1.configure(text='''Connect''')
        self.TButton1.configure(compound='left')

        self.TButton2 = ttk.Button(self.top)
        self.TButton2.place(relx=0.142, rely=0.062, height=26, width=85)
        self.TButton2.configure(command=camerahmi_support.DiconnectBt_on_Click)
        self.TButton2.configure(text='''Disconnect''')
        self.TButton2.configure(compound='left')

        self.Canvas1 = tk.Canvas(self.top)
        self.Canvas1.place(relx=0.283, rely=0.041, relheight=0.93
                , relwidth=0.699)
        self.Canvas1.configure(background="#d9d9d9")
        self.Canvas1.configure(borderwidth="2")
        self.Canvas1.configure(highlightbackground="#d9d9d9")
        self.Canvas1.configure(highlightcolor="#000000")
        self.Canvas1.configure(insertbackground="#000000")
        self.Canvas1.configure(relief="ridge")
        self.Canvas1.configure(selectbackground="#d9d9d9")
        self.Canvas1.configure(selectforeground="black")

        self.TCombobox1 = ttk.Combobox(self.top)
        self.TCombobox1.place(relx=0.033, rely=0.144, relheight=0.06
                , relwidth=0.225)
        self.value_list = []
        self.TCombobox1.configure(values=self.value_list)
        self.TCombobox1.configure(font="-family {Yu Gothic UI} -size 9")
        self.TCombobox1.configure(textvariable=self.combobox)

        self.Scrolledtext1 = ScrolledText(self.top)
        self.Scrolledtext1.place(relx=0.032, rely=0.22, relheight=0.643
                , relwidth=0.231)
        self.Scrolledtext1.configure(background="white")
        self.Scrolledtext1.configure(font="TkTextFont")
        self.Scrolledtext1.configure(foreground="black")
        self.Scrolledtext1.configure(highlightbackground="#d9d9d9")
        self.Scrolledtext1.configure(highlightcolor="#000000")
        self.Scrolledtext1.configure(insertbackground="#000000")
        self.Scrolledtext1.configure(insertborderwidth="3")
        self.Scrolledtext1.configure(selectbackground="#d9d9d9")
        self.Scrolledtext1.configure(selectforeground="black")
        self.Scrolledtext1.configure(wrap="none")

        self.TButton3 = ttk.Button(self.top)
        self.TButton3.place(relx=0.033, rely=0.903, height=26, width=85)
        self.TButton3.configure(command=camerahmi_support.capture_on_click)
        self.TButton3.configure(text='''キャプチャ保存''')
        self.TButton3.configure(compound='left')

        self.TButton4 = ttk.Button(self.top)
        self.TButton4.place(relx=0.153, rely=0.903, height=26, width=75)
        self.TButton4.configure(command=camerahmi_support.recording_on_click)
        self.TButton4.configure(text='''録画Start''')
        self.TButton4.configure(compound='left')

# The following code is added to facilitate the Scrolled widgets you specified.
class AutoScroll(object):
    '''Configure the scrollbars for a widget.'''
    def __init__(self, master):
        #  Rozen. Added the try-except clauses so that this class
        #  could be used for scrolled entry widget for which vertical
        #  scrolling is not supported. 5/7/14.
        try:
            vsb = ttk.Scrollbar(master, orient='vertical', command=self.yview)
        except:
            pass
        hsb = ttk.Scrollbar(master, orient='horizontal', command=self.xview)
        try:
            self.configure(yscrollcommand=self._autoscroll(vsb))
        except:
            pass
        self.configure(xscrollcommand=self._autoscroll(hsb))
        self.grid(column=0, row=0, sticky='nsew')
        try:
            vsb.grid(column=1, row=0, sticky='ns')
        except:
            pass
        hsb.grid(column=0, row=1, sticky='ew')
        master.grid_columnconfigure(0, weight=1)
        master.grid_rowconfigure(0, weight=1)
        # Copy geometry methods of master  (taken from ScrolledText.py)
        methods = tk.Pack.__dict__.keys() | tk.Grid.__dict__.keys() \
                  | tk.Place.__dict__.keys()
        for meth in methods:
            if meth[0] != '_' and meth not in ('config', 'configure'):
                setattr(self, meth, getattr(master, meth))

    @staticmethod
    def _autoscroll(sbar):
        '''Hide and show scrollbar as needed.'''
        def wrapped(first, last):
            first, last = float(first), float(last)
            if first <= 0 and last >= 1:
                sbar.grid_remove()
            else:
                sbar.grid()
            sbar.set(first, last)
        return wrapped

    def __str__(self):
        return str(self.master)

def _create_container(func):
    '''Creates a ttk Frame with a given master, and use this new frame to
    place the scrollbars and the widget.'''
    def wrapped(cls, master, **kw):
        container = ttk.Frame(master)
        container.bind('<Enter>', lambda e: _bound_to_mousewheel(e, container))
        container.bind('<Leave>', lambda e: _unbound_to_mousewheel(e, container))
        return func(cls, container, **kw)
    return wrapped

class ScrolledText(AutoScroll, tk.Text):
    '''A standard Tkinter Text widget with scrollbars that will
    automatically show/hide as needed.'''
    @_create_container
    def __init__(self, master, **kw):
        tk.Text.__init__(self, master, **kw)
        AutoScroll.__init__(self, master)

import platform
def _bound_to_mousewheel(event, widget):
    child = widget.winfo_children()[0]
    if platform.system() == 'Windows' or platform.system() == 'Darwin':
        child.bind_all('<MouseWheel>', lambda e: _on_mousewheel(e, child))
        child.bind_all('<Shift-MouseWheel>', lambda e: _on_shiftmouse(e, child))
    else:
        child.bind_all('<Button-4>', lambda e: _on_mousewheel(e, child))
        child.bind_all('<Button-5>', lambda e: _on_mousewheel(e, child))
        child.bind_all('<Shift-Button-4>', lambda e: _on_shiftmouse(e, child))
        child.bind_all('<Shift-Button-5>', lambda e: _on_shiftmouse(e, child))

def _unbound_to_mousewheel(event, widget):
    if platform.system() == 'Windows' or platform.system() == 'Darwin':
        widget.unbind_all('<MouseWheel>')
        widget.unbind_all('<Shift-MouseWheel>')
    else:
        widget.unbind_all('<Button-4>')
        widget.unbind_all('<Button-5>')
        widget.unbind_all('<Shift-Button-4>')
        widget.unbind_all('<Shift-Button-5>')

def _on_mousewheel(event, widget):
    if platform.system() == 'Windows':
        widget.yview_scroll(-1*int(event.delta/120),'units')
    elif platform.system() == 'Darwin':
        widget.yview_scroll(-1*int(event.delta),'units')
    else:
        if event.num == 4:
            widget.yview_scroll(-1, 'units')
        elif event.num == 5:
            widget.yview_scroll(1, 'units')

def _on_shiftmouse(event, widget):
    if platform.system() == 'Windows':
        widget.xview_scroll(-1*int(event.delta/120), 'units')
    elif platform.system() == 'Darwin':
        widget.xview_scroll(-1*int(event.delta), 'units')
    else:
        if event.num == 4:
            widget.xview_scroll(-1, 'units')
        elif event.num == 5:
            widget.xview_scroll(1, 'units')
def start_up():
    camerahmi_support.main()

if __name__ == '__main__':
    camerahmi_support.main()

camerahmi_support.py

#! /usr/bin/env python3
#  -*- coding: utf-8 -*-
#
# Support module generated by PAGE version 8.0
#  in conjunction with Tcl version 8.6
#    Oct 05, 2024 09:33:11 PM JST  platform: Windows NT
#    Oct 06, 2024 09:14:54 PM JST  platform: Windows NT

import sys
import tkinter as tk
import tkinter.ttk as ttk
from tkinter.constants import *

import camerahmi
import cv2
import threading
from PIL import Image, ImageTk
from tkinter import filedialog

devices = []
capture = None
is_running = True
is_recording = False
is_caputure  = False
image_filename = ""
video_writer = None

def main(*args):
    '''Main entry point for the application.'''
    global root
    root = tk.Tk()
    root.protocol( 'WM_DELETE_WINDOW' , root.destroy)
    # Creates a toplevel widget.
    global _top1, _w1
    _top1 = root
    _w1 = camerahmi.Toplevel1(_top1)
    device_serch(_w1.TCombobox1)
    
    # Comboboxに選択イベントをバインド
    _w1.TCombobox1.bind("<<ComboboxSelected>>", device_on_selected)
    root.mainloop()

def ConnectBt_on_Click(*args):
    global _w1
    global capture
    # イベント発生元のウィジェットを取得
    widget = _w1.combobox
    selected_value = widget.get()  # イベント発生元のComboboxの選択された値を取得
    device_number = int(selected_value.split("Device ")[1])
    
    if capture is not None:
        capture.release()  # 既存のキャプチャがあれば解放
    capture = cv2.VideoCapture(device_number)
    # スレッドを起動してカメラのリードを開始
    thread = threading.Thread(target=read_camera)
    thread.start()
    
def DiconnectBt_on_Click(*args):
    global capture, is_running
    is_running = False

def read_camera():
    global capture, is_running, is_caputure,video_writer,image_filename
    global _w1
    
    is_running = True
    while is_running:
        if capture is not None and capture.isOpened():
            ret, frame = capture.read()
            if ret:
                # BGRからRGBに変換
                frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
                
                # 画像をPIL形式に変換し、Tkinterで表示できる形式に変換
                img = Image.fromarray(frame)
                img = img.resize((640, 480))  # サイズ変更(必要に応じて)
                img_tk = ImageTk.PhotoImage(image=img)
                
                # Canvasに画像を描画
                _w1.Canvas1.create_image(0, 0, anchor=tk.NW, image=img_tk)
                _w1.Canvas1.image = img_tk  # 参照を保持する必要がある
                
                if is_caputure and image_filename != "":
                    cv2.imwrite(image_filename, frame)
                    is_caputure = False
                    image_filename = ""
                if is_recording:
                    video_writer.write(frame)
                
    # カメラをリリースしてウィンドウを閉じる
    if capture is not None:
        capture.release()
    cv2.destroyAllWindows()
    
def device_serch( combo ):
    global devices
    current_values = list(combo["values"])

    # 利用可能なデバイスを捜索
    for i in range(10):  
        cap = cv2.VideoCapture(i)

        # デバイスが存在し、開かれた場合
        if cap.isOpened():
           
            width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
            height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
            fps = cap.get(cv2.CAP_PROP_FPS)
            autofocus = cap.get(cv2.CAP_PROP_AUTOFOCUS)
            focus = cap.get(cv2.CAP_PROP_FOCUS)
            autoexposure = cap.get(cv2.CAP_PROP_AUTO_EXPOSURE)
            exposure = cap.get(cv2.CAP_PROP_EXPOSURE)
            iso_speed = cap.get(cv2.CAP_PROP_ISO_SPEED)
            cv2.waitKey(0)
            cap.release()
            
             # デバイスをリストに追加
            devices.append({
                "index": i,
                "width": width,
                "height": height,
                "fps": fps,
                "autofocus":autofocus,
                "focus":focus,
                "autoexposure":autoexposure,
                "exposure":exposure,
                "iso_speed":iso_speed,
            })
            new_value = f"Device {i}"
            updated_values = current_values + [new_value]
        else:
            pass

    combo["values"] = updated_values
    cv2.destroyAllWindows()
    
def device_on_selected(event):
    global _w1
    # イベント発生元のウィジェットを取得
    widget = event.widget
    selected_value = widget.get()  # イベント発生元のComboboxの選択された値を取得
    #print(f"Selected value from {widget}: {selected_value}")
    
    device_number = int(selected_value.split("Device ")[1])
    
    device = devices[device_number]
    device_info = (
            f"Index: {device['index']}, \n"
            f"Resolution: {device['width']}x{device['height']}, \n"
            f"FPS: {device['fps']}, \n"
            f"Autofocus: {device['autofocus']}, \n"
            f"Focus: {device['focus']}, \n"
            f"Autoexposure: {device['autoexposure']}, \n"
            f"Exposure: {device['exposure']}, \n"
            f"ISO Speed: {device['iso_speed']}\n"
        )
    _w1.Scrolledtext1.delete(1.0, 'end')  # 全削除(0から末尾まで)
    _w1.Scrolledtext1.insert('end', device_info)  # 新しいテキストを挿入
    
    

def capture_on_click(*args):
    capture_image()

def recording_on_click(*args):
    global _w1
    if _w1.TButton4['text']=="録画Start":
        _w1.TButton4['text']="録画Stop"
        start_recording()
    else:
        _w1.TButton4['text']="録画Start"
        stop_recording()
        

def select_file(type):
    
    if type == 0:
        # ファイルダイアログを開いて保存ファイル名を取得
        filename = filedialog.asksaveasfilename(
            defaultextension=".jpg",
            filetypes=[("JPEG", "*.jpg"), ("PNG", "*.png"), ("All Files", "*.*")]
        )
        if filename:
            # ファイル名を設定
            return filename
    elif type == 1:
        # ファイルダイアログを開いて保存ファイル名を取得
        filename = filedialog.asksaveasfilename(
            defaultextension=".mp4",
            filetypes=[("MPEG", "*.mp4")]
        )
        if filename:
            # ファイル名を設定
            return filename
    else:
        pass
    return None
            
def capture_image():
    # 現在のフレームを静止画として保存
    global capture,is_caputure,image_filename
    image_filename = select_file(0)
    is_caputure = True

def start_recording():
    # 録画を開始
    global capture, is_recording, video_writer
    video_filename = select_file(1)
    
    if capture is not None and capture.isOpened():
        is_recording = True
        # 動画書き込みインスタンスの作成
        fourcc = cv2.VideoWriter_fourcc(*'X264') #H264で保存する
        video_writer = cv2.VideoWriter(video_filename, fourcc, 30, (640, 480)) # 30fps 640x480で保存

def stop_recording():
    # 録画を停止
    global is_recording, video_writer
    if is_recording:
        is_recording = False
        if video_writer is not None:
            video_writer.release()
                
if __name__ == '__main__':
    camerahmi.start_up()

今後の改善ポイント

本プログラムを参考に使う場合は以下についてポイントについて意識して改善していってもらえばよいかと思います。

  • エラー処理の強化: このプログラムではエラー処理を入れていません。カメラアクセスやファイル保存のエラー処理をいれる方がよいでしょう。
  • 解像度設定: ユーザーがカメラの解像度を選択できれば使い勝手が上がります。
  • ファイル形式の選択: 動画のコーデックやコンテナ形式をユーザーが選択できるようにするとことで使い勝手が上がります。

まとめ

この記事では、Python、OpenCV、Tkinterを使ってカメラ映像の録画・静止画キャプチャ機能を実装する方法を解説しました。今回のコードを参考に、自分自身のカメラアプリケーションを作成してみてください。

この記事が、あなたの役に立てば幸いです!

Pythonの学習には、こちらを参考にしてみてください

にいやん

出身 : 関西 居住区 : 関西 職業 : 組み込み機器エンジニア (エンジニア歴13年) 年齢 : 38歳(2022年11月現在) 最近 業務の効率化で噂もありPython言語に興味を持ち勉強しています。 そこで学んだことを記事にして皆さんとシェアさせていただければと思いブログをはじめました!! 興味ある記事があれば皆さん見ていってください!! にほんブログ村