0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

画像一括処理デスクトップアプリを作って学んだ、Pillow×customtkinterの実践テクニック

Posted at

はじめに

EC出品用の画像処理や、ブログ用の画像リサイズを毎回手作業でやるのが面倒になり、「一括処理できるツールを自作しよう」と思い立ちました。

本記事では、ImageBatchProcessorというデスクトップアプリを開発する中で得た知見を共有します。特に以下の点にフォーカスします:

  • 実装時のこだわりと設計判断
  • 苦労したポイントとその解決策
  • 他のプロジェクトでも応用できそうな発見

完成したツールはBoothで公開しています。

使用技術

カテゴリ 技術
言語 Python 3.x
GUI customtkinter
画像処理 Pillow
テスト pytest
配布 PyInstaller

機能紹介

  • 画像リサイズ(ピクセル/パーセント指定、アスペクト比維持)
  • 形式変換(JPG, PNG, WebP, BMP, GIF)
  • テキスト/画像透かし(位置・回転・不透明度指定)
  • 一括リネーム(連番、日付パターン対応)
  • EXIF情報削除
  • ドラッグ&ドロップ対応

実装のこだわり

1. 責務分離を徹底したモジュール構成

ImageBatchProcessor/
├── ui/ # GUI層
│ ├── main_window.py
│ └── theme.py
├── core/ # ビジネスロジック層
│ ├── image_processor.py
│ └── file_handler.py
└── utils/ # 共通ユーティリティ
└── helpers.py

なぜこの構成にしたか:

  • core/はUIに依存しない純粋なロジックなので、単体テストが書きやすい
  • 将来CLI版を作りたくなってもcore/をそのまま使える
  • ui/theme.pyに色定義を集約することで、テーマ変更が1ファイルで完結

2. テーマシステムの設計

ネオンカラーのUIを実現するため、色定義を一元管理しました。

class Theme:
    THEME_MODE = 'neon_green'  # 'neon_blue', 'neon_pink' に変更可能

    THEMES = {
        'neon_green': {
            'accent': '#39ff14',
            'accent_hover': '#32e012',
            'text': '#39ff14',
            'bg': '#1a1a1a',
        },
        'neon_blue': {
            'accent': '#00f5ff',
            # ...
        },
    }

    @classmethod
    def get_accent(cls):
        return cls.THEMES[cls.THEME_MODE]['accent']

他プロジェクトへの応用:

このパターンは、ダークモード/ライトモード切り替えにも使えます。THEME_MODEをユーザー設定から読み込むようにすれば、動的なテーマ切り替えも簡単です。

ハマったポイント

1. テキスト透かしの「はみ出し問題」

透かしテキストを回転させると、画像の外にはみ出すケースが発生しました。

問題: 負の座標や画像サイズを超える座標に描画しようとすると、意図しない表示になる

解決策: クリッピング処理を実装

  def add_text_watermark(self, image, text, position, ...):
      # テキスト画像を作成(回転込み)
      txt_img = self._create_rotated_text(text, font, color, opacity, angle)
      txt_width, txt_height = txt_img.size

      # 貼り付け位置を計算
      x, y = self._calculate_position(image.size, txt_img.size, position)

      # === ここからクリッピング処理 ===
      img_width, img_height = image.size
      src_x1, src_y1 = 0, 0
      src_x2, src_y2 = txt_width, txt_height
      dst_x, dst_y = x, y

      # 負の座標対応(左・上にはみ出した場合)
      if x < 0:
          src_x1 = -x
          dst_x = 0
      if y < 0:
          src_y1 = -y
          dst_y = 0

      # 画像サイズ超過対応(右・下にはみ出した場合)
      if dst_x + (src_x2 - src_x1) > img_width:
          src_x2 = src_x1 + (img_width - dst_x)
      if dst_y + (src_y2 - src_y1) > img_height:
          src_y2 = src_y1 + (img_height - dst_y)

      # 完全にはみ出している場合はスキップ
      if src_x2 <= src_x1 or src_y2 <= src_y1:
          return image

      # クリップした領域のみ貼り付け
      cropped = txt_img.crop((src_x1, src_y1, src_x2, src_y2))
      image.paste(cropped, (dst_x, dst_y), cropped)
      return image

学び: 座標計算が絡む処理では、境界条件を必ずテストすべき。特に「負の座標」「サイズ超過」「完全に範囲外」の3パターンは忘れがち。

2. プレビュー更新のパフォーマンス問題

スライダーを動かすたびにプレビューを更新すると、大きな画像でUIがカクつく問題が発生。

解決策: キャッシング + 事前縮小

  def _update_preview(self):
      filepath = self._get_selected_file()

      # キャッシュチェック:同じファイルなら元画像の再読み込みをスキップ
      if self._cached_preview_path != filepath:
          original = Image.open(filepath)

          # プレビュー用に事前縮小(元画像が大きすぎる場合)
          preview_max = max(self.canvas_width, self.canvas_height) + 100
          if max(original.size) > preview_max:
              original.thumbnail((preview_max, preview_max), Image.Resampling.BILINEAR)

          self._cached_preview_image = original
          self._cached_preview_path = filepath

      # キャッシュから処理
      preview = self._cached_preview_image.copy()
      # 透かし等の処理を適用...

他プロジェクトへの応用:

リアルタイムプレビューが必要な場面(画像エディタ、動画サムネイル生成など)では、この「元データをキャッシュ + 軽量版で処理」パターンが有効です。

3. マルチスレッドとUI更新の競合

画像処理をメインスレッドで行うとUIがフリーズ。threadingで分離したものの、スレッドから直接UIを更新するとクラッシュ。

解決策: after()メソッドでメインスレッドに処理を委譲

  def _process_images(self):
      # 別スレッドで処理を実行
      thread = threading.Thread(target=self._run_processing, args=(output_dir, options))
      thread.start()

  def _run_processing(self, output_dir, options):
      results = self.processor.process_batch(self.file_list, output_dir, options)

      # UIの更新はメインスレッドで行う(after()を使用)
      self.after(0, lambda: self._on_processing_complete(results))

  def _on_processing_complete(self, results):
      # ここはメインスレッドなので安全にUI更新できる
      self.progress_bar.set(1.0)
      self._show_results_dialog(results)

他プロジェクトへの応用:

Tkinter/customtkinterに限らず、多くのGUIフレームワークでは「UIの更新はメインスレッドのみ」というルールがあります。after()やinvoke()のような仕組みを使って、ワーカースレッドからメインスレッドに処理を委譲するパ
ターンを覚えておくと便利です。

  1. クロスプラットフォームでの日本語フォント問題

日本語テキストの透かしを入れようとしたら、Windows以外で文字化けが発生。

解決策: OS別にフォントパスを探索

  def _get_default_font_path(self):
      """OS別に日本語フォントを自動検出"""
      font_paths = [
          # Windows
          "C:/Windows/Fonts/meiryo.ttc",
          "C:/Windows/Fonts/msgothic.ttc",
          # WSL(WindowsのフォントをLinuxから参照)
          "/mnt/c/Windows/Fonts/meiryo.ttc",
          # macOS
          "/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc",
          "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
          # Linux
          "/usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf",
          "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
      ]

      for path in font_paths:
          if os.path.exists(path):
              return path

      return None  # 見つからない場合はデフォルトフォント

学び: 日本語を扱うアプリでは、フォントの存在確認をサボると痛い目を見ます。特にWSL環境は見落としがちなので注意。

他のケースでも使えそうな発見

  1. オプションビルダーパターン

GUIの設定値をまとめて処理関数に渡すとき、有効なオプションだけを辞書に格納する方式が便利でした。

  def _build_options(self):
      """UIの状態からオプション辞書を構築"""
      options = {}

      if self.resize_enabled.get():
          options['resize'] = {
              'width': self.resize_width.get(),
              'height': self.resize_height.get(),
              'keep_aspect': self.keep_aspect.get(),
          }

      if self.watermark_enabled.get():
          options['watermark'] = {
              'text': self.watermark_text.get(),
              'position': self.watermark_position.get(),
              # ...
          }

      return options

処理側はif 'resize' in options:でチェックするだけ。オプションが増えても処理関数のシグネチャを変更しなくて済むのが利点です。

  1. 統一された結果フォーマット

バッチ処理の結果を統一フォーマットで返すと、エラーハンドリングが楽になります。

  def process_batch(self, file_list, output_dir, options):
      results = []
      for filepath in file_list:
          result = {
              'input': filepath,
              'output': None,
              'success': False,
              'error': None,
          }
          try:
              output_path = self._process_single(filepath, output_dir, options)
              result['output'] = output_path
              result['success'] = True
          except Exception as e:
              result['error'] = str(e)

          results.append(result)

      return results

UIでの表示例:

success_count = sum(1 for r in results if r['success'])
failed = [r for r in results if not r['success']]
print(f"成功: {success_count}, 失敗: {len(failed)}")
for r in failed:
    print(f"  {r['input']}: {r['error']}")
  1. プロパティによる値の自動クリップ

設定値の境界チェックを@propertyのsetterに任せると、呼び出し側がシンプルになります。

  @property
  def quality(self):
      return self._quality

  @quality.setter
  def quality(self, value):
      # 1-100の範囲に自動クリップ
      self._quality = max(1, min(100, value))

これでprocessor.quality = 150としても内部的には100になります。不正な値でクラッシュしない安心感があります。

まとめ

カテゴリ 学び
設計 UI/ロジック/ユーティリティの責務分離でテストしやすく
パフォーマンス リアルタイムプレビューはキャッシュ+事前縮小で最適化
スレッド after()でメインスレッドにUI更新を委譲
座標計算 負の座標・範囲外のクリッピングは必須
クロスプラットフォーム 日本語フォントのパスはOS別に探索

これらのパターンは画像処理に限らず、他のデスクトップアプリ開発でも活用できると思います。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?