見出し画像

【開発してみた】フリーソフトより危険視された自作アプリ〜医療現場でJPEG→MP4をやろうとした話

とある日、医師からこんな依頼が。

「JPEG画像をMP4にして、パワポに貼りたいんだけど?」

あぁ、それなら「Windowsフォトで動画にできますよ」と提案しようとしたが…
調べてみると、うちの病院のフォトは2019年版。あれ?動画作成機能、ないの?。

どうやら、Windowsの標準機能である「フォト」アプリ、2019年版だとビデオにできないっぽいのです。

「よし、ないなら作ってしまおう」と思い立って、Pythonでツールを作ってみました。
これがまあ、フォルダを読み込んで動画に変換するだけのシンプルなやつなんですが…
うん、もちろんペット(ChatGPT)に作って頂きました。

すると上司からまさかの一言:

「それは使わず、フリーソフトを使おう」

え?フリーソフト?電子カルテネットワークで!? 私のツールの方が危険なの?(笑)


結論から言うと:

冗談はさておき、私しか作れないソフトを使う事に抵抗があったようですね。でもフリーソフトはないな~。
最終的に Windows フォト(2021バージョン)なら普通に MP4 にできました
なので、そちらを使うことになりましたとさ。はい。


でもせっかくなので…

作ったツールを載せておきます。
コンパイルすれば、ローカル実行で画像→動画変換したい人には便利かもしれません。


🔧 JPEG to MP4 コンバーター(Python + tkinter)

PythonでGUI付き。画像フォルダを選んで、出力MP4名を指定、変換ボタンを押すだけです。
画像はファイル名順 / 作成日順, 昇順 / 降順の並び替えも可能。


💻 ソースコードはこちら(ちょっと長い)

import tkinter as tk
from tkinter import filedialog, ttk, messagebox
import cv2
import os
import glob
from natsort import natsorted
import threading
import numpy as np

class JpegToMp4Converter:
    def __init__(self, root):
        self.root = root
        self.root.title("JPEG to MP4 コンバーター")
        self.root.geometry("650x420")
        self.root.resizable(True, True)

        # スタイルの設定(カスタムボタンスタイル)
        self.style = ttk.Style()
        self.style.configure("Accent.TButton",
                             foreground="#000000",
                             background="#007bff",
                             relief="flat")
        self.style.map("Accent.TButton",
                       foreground=[('active', '#000000')],
                       background=[('active', '#0056b3')])

        
        # 変数の初期化
        self.folder_path = tk.StringVar()
        # デスクトップをデフォルトにする修正コード
        desktop_path = os.path.join(os.path.expanduser("~"), "Desktop")  # デスクトップのパス
        default_output = os.path.join(desktop_path, "output.mp4")  # デスクトップに出力ファイルのパスを設定
        self.output_path = tk.StringVar(value=default_output)
        
        self.fps = tk.IntVar(value=24)
        self.fps_info = tk.StringVar(value="現在の設定: 24fps (1枚あたり約0.04秒)")
        self.status = tk.StringVar()
        self.progress = tk.IntVar()
        self.converting = False
        
        # ソート設定
        self.sort_method = tk.StringVar(value="name")  # デフォルトは自然順ソート
        self.sort_direction = tk.BooleanVar(value=True)  # Trueは昇順、Falseは降順
        
        # UIの構築
        self.create_widgets()
    
    def create_widgets(self):
        # メインフレーム
        main_frame = ttk.Frame(self.root, padding="10")
        main_frame.pack(fill=tk.BOTH, expand=True)
        
        # タイトル
        title_label = ttk.Label(main_frame, text="JPEG画像からMP4動画を作成", font=("", 16))
        title_label.grid(row=0, column=0, columnspan=3, pady=(0, 10), sticky="w")
        
        # 画像フォルダ選択
        folder_label = ttk.Label(main_frame, text="画像フォルダ:")
        folder_label.grid(row=1, column=0, sticky="w", pady=5)
        
        folder_entry = ttk.Entry(main_frame, textvariable=self.folder_path, width=40)
        folder_entry.grid(row=1, column=1, sticky="ew", padx=5, pady=5)
        
        folder_button = ttk.Button(main_frame, text="参照", command=self.browse_folder)
        folder_button.grid(row=1, column=2, sticky="e", pady=5)
        
        # 出力ファイル選択
        output_label = ttk.Label(main_frame, text="出力ファイル:")
        output_label.grid(row=2, column=0, sticky="w", pady=5)
        
        output_entry = ttk.Entry(main_frame, textvariable=self.output_path, width=40)
        output_entry.grid(row=2, column=1, sticky="ew", padx=5, pady=5)
        
        output_button = ttk.Button(main_frame, text="参照", command=self.browse_output)
        output_button.grid(row=2, column=2, sticky="e", pady=5)
        
        # フレームレート設定
        fps_frame = ttk.Frame(main_frame)
        fps_frame.grid(row=3, column=0, columnspan=3, sticky="ew", pady=5)
        
        fps_label = ttk.Label(fps_frame, text="表示速度:")
        fps_label.pack(side=tk.LEFT, padx=(0, 5))
        
        fps_scale = ttk.Scale(fps_frame, from_=1, to=60, variable=self.fps, orient=tk.HORIZONTAL)
        fps_scale.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
        
        # フレームレートと画像の表示時間の説明
        fps_info_label = ttk.Label(fps_frame, textvariable=self.fps_info, font=("", 9))
        fps_info_label.pack(side=tk.RIGHT, padx=5)
        
        # ソート設定フレーム
        sort_frame = ttk.LabelFrame(main_frame, text="並び順の設定")
        sort_frame.grid(row=4, column=0, columnspan=3, sticky="ew", pady=10, padx=5)
        
        # ソート方法ボタンフレーム
        sort_method_frame = ttk.Frame(sort_frame)
        sort_method_frame.pack(fill=tk.X, expand=True, pady=5)
        
        sort_method_label = ttk.Label(sort_method_frame, text="並び順:")
        sort_method_label.pack(side=tk.LEFT, padx=5)
        
        # ファイル名順ボタン
        self.name_button = ttk.Button(sort_method_frame, text="ファイル名順", 
                                      command=lambda: self.toggle_sort_method("name"))
        self.name_button.pack(side=tk.LEFT, padx=5)
        
        # 作成日順ボタン
        self.date_button = ttk.Button(sort_method_frame, text="作成日順", 
                                      command=lambda: self.toggle_sort_method("date"))
        self.date_button.pack(side=tk.LEFT, padx=5)
        
        # 初期状態でファイル名順ボタンをアクティブに
        self.update_sort_buttons()
        
        # 昇順/降順ボタンフレーム
        sort_dir_frame = ttk.Frame(sort_frame)
        sort_dir_frame.pack(fill=tk.X, expand=True, pady=5)
        
        sort_dir_label = ttk.Label(sort_dir_frame, text="方向:")
        sort_dir_label.pack(side=tk.LEFT, padx=5)
        
        # 昇順ボタン
        self.asc_button = ttk.Button(sort_dir_frame, text="昇順 (A→Z, 古→新)", 
                                     command=lambda: self.toggle_sort_direction(True))
        self.asc_button.pack(side=tk.LEFT, padx=5)
        
        # 降順ボタン
        self.desc_button = ttk.Button(sort_dir_frame, text="降順 (Z→A, 新→古)", 
                                     command=lambda: self.toggle_sort_direction(False))
        self.desc_button.pack(side=tk.LEFT, padx=5)
        
        # 初期状態で昇順ボタンをアクティブに
        self.update_direction_buttons()
        
        
        # 進捗バー
        progress_frame = ttk.Frame(main_frame)
        progress_frame.grid(row=5, column=0, columnspan=3, sticky="ew", pady=10)
        
        self.progress_bar = ttk.Progressbar(progress_frame, variable=self.progress, maximum=100, length=580)
        self.progress_bar.pack(fill=tk.X, expand=True)
        
        # ステータスラベル
        status_label = ttk.Label(main_frame, textvariable=self.status, wraplength=580)
        status_label.grid(row=6, column=0, columnspan=3, sticky="w", pady=5)
        
        # ボタンフレーム
        button_frame = ttk.Frame(main_frame)
        button_frame.grid(row=7, column=0, columnspan=3, sticky="e", pady=10)
        
        convert_button = ttk.Button(button_frame, text="変換開始", command=self.start_conversion)
        convert_button.pack(side=tk.RIGHT, padx=5)

        defaults_button = ttk.Button(button_frame, text="デフォルト設定に戻す", command=self.reset_to_defaults)        
        defaults_button.pack(side=tk.LEFT, padx=15)
        
        # グリッドの設定
        main_frame.columnconfigure(1, weight=1)
        
        # フレームレートの値変更時のコールバック
        fps_scale.bind("<Motion>", self.update_fps_label)
        # 初期値でも更新
        self.update_fps_label()
    
    def reset_to_defaults(self):
        """すべての設定をデフォルト値に戻す"""
        self.fps.set(24)
        self.sort_method.set("name")  # ファイル名順
        self.sort_direction.set(True)  # 昇順
        self.update_fps_label()
        self.update_sort_buttons()
        self.update_direction_buttons()
        
    def toggle_sort_method(self, method):
        """ソート方法を切り替える"""
        self.sort_method.set(method)
        self.update_sort_buttons()
    
    def toggle_sort_direction(self, ascending):
        """ソート方向を切り替える"""
        self.sort_direction.set(ascending)
        self.update_direction_buttons()
    
    def update_sort_buttons(self):
        """ソート方法ボタンの見た目を更新"""
        current_method = self.sort_method.get()
        # ボタンの見た目を変更(選択されているボタンを視覚的に区別)
        if current_method == "name":
            self.name_button.config(style="Accent.TButton")
            self.date_button.config(style="TButton")
        else:
            self.name_button.config(style="TButton")
            self.date_button.config(style="Accent.TButton")
    
    def update_direction_buttons(self):
        """ソート方向ボタンの見た目を更新"""
        ascending = self.sort_direction.get()
        # ボタンの見た目を変更(選択されているボタンを視覚的に区別)
        if ascending:
            self.asc_button.config(style="Accent.TButton")
            self.desc_button.config(style="TButton")
        else:
            self.asc_button.config(style="TButton")
            self.desc_button.config(style="Accent.TButton")
    
    def update_fps_label(self, event=None):
        # 小数点以下を切り捨て
        fps = int(self.fps.get())
        self.fps.set(fps)
        
        # 1枚あたりの表示時間を計算(秒)
        if fps > 0:
            display_time = 1.0 / fps
            
            # 表示時間を適切な単位で表示
            if display_time >= 1:
                time_str = f"{display_time:.2f}秒"
            else:
                time_str = f"{display_time*1000:.0f}ミリ秒"
                
            self.fps_info.set(f"現在の設定: {fps}fps (1枚あたり約{time_str})")
        else:
            self.fps_info.set("フレームレートを選択してください")
    
    def browse_folder(self):
        folder = filedialog.askdirectory()
        if folder:
            self.folder_path.set(folder)
    
    def browse_output(self):
        output_file = filedialog.asksaveasfilename(
            defaultextension=".mp4",
            filetypes=[("MP4 ファイル", "*.mp4"), ("すべてのファイル", "*.*")]
        )
        if output_file:
            self.output_path.set(output_file)
    
    def sort_images(self, image_files):
        """画像ファイルを指定された方法で並べ替える"""
        if self.sort_method.get() == "name":
            # 自然順ソート
            sorted_files = natsorted(image_files)
        elif self.sort_method.get() == "date":
            # 作成日でソート
            sorted_files = sorted(image_files, key=lambda x: os.path.getctime(x))
        else:
            # デフォルトは自然順ソート
            sorted_files = natsorted(image_files)
        
        # 降順の場合は反転
        if not self.sort_direction.get():
            sorted_files.reverse()
            
        return sorted_files
    
    def start_conversion(self):
        # 入力チェック
        if not self.folder_path.get():
            messagebox.showerror("エラー", "画像フォルダを選択してください")
            return
        
        if not self.output_path.get():
            messagebox.showerror("エラー", "出力ファイル名を入力してください")
            return
        
        # 変換中なら何もしない
        if self.converting:
            return
        
        # 出力ファイルの拡張子チェック
        output_path = self.output_path.get()
        if not output_path.lower().endswith('.mp4'):
            output_path += '.mp4'
            self.output_path.set(output_path)
        
        # 変換開始
        self.converting = True
        self.progress.set(0)
        self.status.set("変換を開始しています...")
        
        # スレッドで変換を実行
        thread = threading.Thread(target=self.convert_images_to_video)
        thread.daemon = True
        thread.start()
    
    def update_progress(self, percent, message):
        self.progress.set(percent)
        self.status.set(message)
    
    def convert_images_to_video(self):
        image_folder = self.folder_path.get()
        output_video = self.output_path.get()
        fps = self.fps.get()
        
        try:
            # 画像ファイルの取得(JPEGファイルのみ、大文字小文字両方対応)
            image_files = glob.glob(os.path.join(image_folder, '*.jpg')) + \
                         glob.glob(os.path.join(image_folder, '*.jpeg')) + \
                         glob.glob(os.path.join(image_folder, '*.JPG')) + \
                         glob.glob(os.path.join(image_folder, '*.JPEG'))
            
            if not image_files:
                self.root.after(0, lambda: messagebox.showerror("エラー", "指定されたフォルダにJPEG画像が見つかりませんでした。"))
                self.root.after(0, lambda: self.update_progress(0, "エラー: 画像が見つかりません"))
                self.converting = False
                return
                
            # パスをUTF-8で正規化
            image_files = [os.path.normpath(path) for path in image_files]
            
            # ソートを適用
            image_files = self.sort_images(image_files)
            
            # 最初の画像からサイズを取得
            first_img_path = image_files[0]
            # NumPyを使って画像を読み込む代替方法
            try:
                with open(first_img_path, 'rb') as f:
                    img_array = np.asarray(bytearray(f.read()), dtype=np.uint8)
                first_img = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
                
                if first_img is None:
                    self.root.after(0, lambda p=first_img_path: messagebox.showerror("エラー", f"画像ファイルを読み込めませんでした: {p}"))
                    self.root.after(0, lambda: self.update_progress(0, f"エラー: 画像ファイルを読み込めません"))
                    self.converting = False
                    return
            except Exception as e:
                error_msg = str(e)
                self.root.after(0, lambda msg=error_msg, p=first_img_path: messagebox.showerror("エラー", f"画像ファイル {p} の読み込み中にエラーが発生しました: {msg}"))
                self.root.after(0, lambda msg=error_msg: self.update_progress(0, f"エラー: {msg}"))
                self.converting = False
                return
                
            size = (first_img.shape[1], first_img.shape[0])  # (幅, 高さ)
                
            # VideoWriterオブジェクトの作成
            fourcc = cv2.VideoWriter_fourcc(*'mp4v')  # MP4形式のコーデック
            out = cv2.VideoWriter(output_video, fourcc, fps, size)
            
            # 各画像を読み込み、ビデオに追加
            total_images = len(image_files)
            for i, image_file in enumerate(image_files):
                # NumPyを使って画像を読み込む代替方法
                try:
                    with open(image_file, 'rb') as f:
                        img_array = np.asarray(bytearray(f.read()), dtype=np.uint8)
                    img = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
                    
                    if img is None:
                        self.root.after(0, lambda f=image_file: messagebox.showerror("エラー", f"画像ファイルを読み込めませんでした: {f}"))
                        self.root.after(0, lambda: self.update_progress(0, f"エラー: 画像ファイルを読み込めません"))
                        continue  # エラーの場合はこの画像をスキップして次へ
                except Exception as e:
                    error_msg = str(e)
                    self.root.after(0, lambda msg=error_msg, f=image_file: messagebox.showerror("エラー", f"画像ファイル {f} の読み込み中にエラーが発生しました: {msg}"))
                    continue  # エラーの場合はこの画像をスキップして次へ
                
                # 画像のサイズが一致しない場合はリサイズ
                if img.shape[1] != size[0] or img.shape[0] != size[1]:
                    img = cv2.resize(img, size)
                    
                out.write(img)
                
                # 進捗更新
                percent = int((i + 1) / total_images * 100)
                self.root.after(0, lambda p=percent, m=f"処理中: {i+1}/{total_images}": self.update_progress(p, m))
            
            # リソースの解放
            out.release()
            
            # 完了メッセージ
            self.root.after(0, lambda: self.update_progress(100, f"完了: 動画が {output_video} に保存されました。"))
            self.root.after(0, lambda: messagebox.showinfo("完了", f"動画が正常に作成されました。\n保存先: {output_video}"))
            
        except Exception as e:
            error_msg = str(e)
            self.root.after(0, lambda msg=error_msg: messagebox.showerror("エラー", f"変換中にエラーが発生しました: {msg}"))
            self.root.after(0, lambda msg=error_msg: self.update_progress(0, f"エラー: {msg}"))
        
        finally:
            self.converting = False

if __name__ == "__main__":
    root = tk.Tk()
    app = JpegToMp4Converter(root)
    root.mainloop()

🎯 まとめ

  • Pythonがあれば、JPEG画像をMP4化するGUIツールもすぐ作れます。

  • ただし、環境的に「自作ツールNG / フリーソフトもNG / フォトでギリOK」な場所は注意(笑)


🐾 猫@医療情報技師としてひとこと

シンプルな処理なら、信頼されたローカルツールの方が安心だと思います。
不特定のフリーソフト導入より、ソースコードが見えるPythonアプリ(ちゃんと読めないとだめですが)の方が安全性を検証しやすいですが、そもそも放射線科に依頼すれば出来ていたこともかも・・。
簡単に引き受けない事ですね。(;´д`)トホホ

いいなと思ったら応援しよう!