AlphaViewer は、透過 PNG(RGBA 画像)の アルファ(透明度) を確認しながら、半透明の整理・輪郭のクリーニング・透明部分の色にじみ対策などを行い、結果を PNG として保存するアプリです。

イメージ

はじめに

AlphaViewer_New.py は、透過 PNG(RGBA 画像)の アルファ(透明度) を確認しながら、半透明の整理・輪郭のクリーニング・透明部分の色にじみ対策などを行い、結果を PNG として保存するデスクトップアプリです。

処理はすべて「プレビュー → 確認 → 保存」の流れで行い、元画像を直接書き換えるのは「不透明化」だけです(確認ダイアログあり)。処理の組み合わせと実行順を変えられるので、画像に合わせて調整できます。

概要

主な機能

  • 画像を読み込み、元画像と 8 種類の処理結果を 3×3 の 9 パネルで同時に確認
  • 処理ごとに ON / OFF と 実行順 を設定(別ウィンドウ)
  • 半透明(α が 0 より大きく閾値以下)を 透明にする / 不透明にする を選択
  • α 分布(α ごとのピクセル数)を別ウィンドウの表で確認
  • 1 枚ずつ PNG 保存、またはフォルダ内の画像を 一括処理
  • 背景色・背景画像・市松模様との合成表示(保存内容にも反映可)
  • 透明部分の RGB だけを変更(α は変えない)して色にじみを軽減
  • パラメータ・実行順・ON/OFF を JSON ファイルに保存 / 読み込み
  • 全 UI に ツールチップ(マウスを乗せると説明を表示)
  • 画像ファイルの ドラッグ&ドロップ読み込み

動作環境

  • Python 3(tkinter を含むもの)
  • 必須ライブラリ: numpy, Pillow
  • 任意ライブラリ:
  • scipy … 端部小物体除去・ロングフェザーが高速かつ高精度になります。無い場合は簡易処理で動作します。
  • tkinterdnd2 … ドラッグ&ドロップ読み込みが有効になります。無い場合はメニューから読み込みます。

起動方法

python AlphaViewer_New.py

同じフォルダに画像を置く必要はありません。起動後に「フォルダ読み込み」で選ぶか、画像ファイルをウィンドウへドロップしてください。

対応画像形式

.png / .jpg / .jpeg / .bmp / .tif / .tiff / .webp

読み込み時に RGBA へ変換されます(α が無い形式は α=255 として扱われます)。

基本の流れ

  1. 「フォルダ読み込み」でフォルダを選ぶ(または画像をドロップ)
  2. 左のサムネイルをクリックして画像を読み込む
  3. 右側で処理の ON/OFF・実行順・パラメータを調整する
  4. 9 パネルと「α分布」で結果を確認する
  5. 各パネルの「保存」または「フォルダ一括」で PNG を書き出す

初期設定

項目初期値
半透明の扱い半透明 → 透明
処理の ON/OFF6 処理すべて ON
実行順半透明 → 膨張/縮小 → 行・列クリーン → 色クリア → 端部小物体除去 → ロングフェザー
背景無(保存は α を保持)
透明部 RGB 変更OFF(α≤10、色は黒)

画面の全体構成

┌──────────────────────────────────────────────────────────────────────┐
│ タイトル行: AlphaViewer New | 幅W × 高さH px | ファイルパス           │
├────────────┬──────────────────────────────────────┬──────────────────┤
│ 左側        │ 中央 3×3 パネル                       │ 右側             │
│ ・フォルダ   │  1 元画像   2 透明検出   3 半透明     │ ・実行順ボタン    │
│ ・背景合成   │  4 膨張縮小 5 行列入り  6 色クリア    │ ・一括処理        │
│ ・サムネイル │  7 端部除去 8 フェザー  9 最終        │ ・各パラメータ    │
│            │                                      │ ・下部固定バー    │
└────────────┴──────────────────────────────────────┴──────────────────┘

メインウィンドウ

タイトル行

ウィンドウ最上部に、次の内容を常に表示します。

AlphaViewer New | 幅240 × 高さ200 px | C:\...\sample.png

  • 画像を読み込むと 幅・高さ・フルパス が更新されます。
  • パスが長い場合は行内では末尾を残して省略されます。カーソルを乗せると全文がツールチップで表示されます。
  • ウィンドウのタイトルバーにも同じ内容(フルパス)が入ります。

左側

フォルダ読み込み

ボタン説明
フォルダ読み込み画像フォルダを選択し、その中の画像をサムネイル一覧に表示します。選択したフォルダは「フォルダ一括」の処理対象にもなります。

背景合成・色

プレビューおよび保存時の背景の扱いを設定します。

操作説明
無背景を合成しません。保存は α 付きのまま(透過 PNG)です。画面では透明部分が市松模様で表示されます。
色選んだ単色と合成します。保存にも背景色が焼き込まれます。
画読み込んだ背景画像と合成します。保存にも焼き込まれます。
市市松模様と合成します。保存にも市松模様が焼き込まれます(透過を残したいときは「無」を使います)。
白 / 黒背景色を白 / 黒に設定し、自動的に「色」に切り替えます。
選択カラーピッカーで任意の背景色を選びます。
背景画像読込背景として使う画像ファイルを読み込み、「画」に切り替えます。
解除背景画像を破棄し、「市」に切り替えます。

サムネイル一覧

  • フォルダ内の画像をファイル名順に並べます。
  • 透明部分は 市松模様 で合成して表示されるため、透過の状態が分かりやすくなっています。
  • クリックするとその画像を読み込みます。
  • カーソルを乗せると フルパスと画像サイズ が表示されます。
  • 現在読み込んでいる画像の強調表示はありません。

中央 3×3 パネル

9 枚のパネルは「どこまで処理を適用したか」が異なります。処理の ON/OFF によって内容が変わります。

No.パネル名内容
1元画像読み込んだままの画像。透明部 RGB 変更が ON ならその変更も反映されます。
2透明検出(α)α だけを白黒(グレースケール)で表示します。白いほど不透明、黒いほど透明です。α チャンネルを持たない 3 チャンネル画像です。
3半透明→透明 / 不透明「3. 半透明」だけを適用した結果です。
4膨張 / 縮小「3. 半透明」→「4. 膨張/縮小」までを適用した結果です。
5行・列クリーン上記のベースに「5. 行・列クリーン」だけを適用した結果です。他の 6〜8 は含みません。
6色クリアベースに「6. 色クリア」だけを適用した結果です。
7端部小物体除去ベースに「7. 端部小物体除去」だけを適用した結果です。
8ロングフェザーベースに「8. ロングフェザー」だけを適用した結果です。
9最終(全適用)ON になっているすべての処理を 実行順どおり に適用した結果です。

補足:

  • 「ベース」は「3. 半透明」と「4. 膨張/縮小」までを適用した状態です。
  • パネル 4〜8 は それぞれ単独の効果を確認するため のもので、他のクリーン処理は含みません。
  • パネル 9 が実際に保存したい最終結果です(一括処理の「最終(全適用)」と同じ)。

パネルのクリック操作

  • パネル 1(元画像) または 6(色クリア) をクリックすると、その位置の画素の RGB 色を取得します。
  • 色を取得すると、色クリアの「左上 / 右上 / 左下 / 右下」チェックはすべて外れます。
  • 角のチェックが 1 つも無い状態では、取得した色に近い画素(色差の範囲内)を画像全体から透明にします。

右側(実行順とパラメータ)

右側は縦スクロールできます。下部の「不透明化/設定/RGB 変更」バーだけは固定です。

上部のボタン

ボタン説明
実行順を設定…別ウィンドウを開き、6 処理の ON/OFF と実行順を変更します。
⑨ 最終を保存パネル 9(最終)の画像を PNG で保存します。
初期化6 処理の ON/OFF と実行順を初期状態に戻します(パラメータは変わりません)。
フォルダ一括選択中のフォルダの画像をまとめて処理し、batch_output に保存します。
対象メニュー一括処理で保存するパネルを選びます。最終(全適用) / ロングフェザー / 端部小物体除去 / 色クリア / 行列クリーン / 膨張/縮小 / 半透明 / 透明検出 / 元画像 / 全9種

ボタンの下には「ON になっている処理の数」と「実行順の要約」が表示されます。

下部固定バー(不透明化・設定・RGB 変更)

操作説明
不透明化読み込んだ元画像の α をすべて 255(完全な不透明)にします。元に戻せません(確認ダイアログが出ます)。
設定保存現在のパラメータ・実行順・ON/OFF を JSON ファイルに保存します。成功するとボタンが一瞬「保存✓」になります。
設定読込保存した JSON を読み込み、設定を復元します。成功すると「読込✓」になります。壊れたファイルはエラーダイアログを出し、アプリは継続します。
α≤ スライダー透明部 RGB 変更の対象となる α の上限です(0〜255)。
透明部 RGB 変更ON にすると、α が閾値以下の画素の RGB だけを変更します。α は変えません(透明のまま)。
表示ON にすると、変更した RGB を目視できるよう、画面上だけ対象画素を塗りつぶして表示します。保存内容は変わりません。
色選択透明部に設定する RGB 色をカラーピッカーで選びます。
黒 / 白 / 紫透明部の RGB を黒・白・紫に設定します。
状態表示対象ピクセル数を 1234px のように表示します。OFF のときは OFF。

1. 元画像 / 2. 透明検出

操作説明
保存そのパネルの画像を PNG で保存します(初期ファイル名は 元ファイル名_original.png / _alpha_detect.png)。
α分布「α分布」ウィンドウを開きます(下記参照)。

3. 半透明→透明 / 不透明

操作説明
透明α が「0 より大きく α≤ 閾値」の画素を α=0 にします(初期設定)。
不透明同じ画素を α=255 にします。
α≤ スライダー半透明とみなす α の上限です(0〜255、初期値 128)。
保存パネル 3 を保存します(_semi_transparent.png)。

選んだモードに応じて、パネル名・見出し・実行順ウィンドウの表示が「半透明→透明」または「半透明→不透明」に切り替わります。

4. 膨張 / 縮小

操作説明
膨張不透明部分の輪郭を外側へ太らせます。
縮小不透明部分の輪郭を内側へ痩せさせます。
カーネル処理する範囲の大きさです(1〜9)。偶数のときは内部で +1 されます。
回数同じ処理を繰り返す回数です(1〜10)。
保存パネル 4 を保存します(_morph.png)。

5. 行・列クリーン

操作説明
α閾値これ以下の α を透明とみなして行・列を判定します(0〜255)。
行 割合%行内の透明画素の割合がこの値以上なら、その行を透明にします(0〜100)。
行 連続数行内の「不透明部分の塊(ラン)の個数」がこの値以下なら対象にします(1〜50)。
列 割合% / 列 連続数列について同じ判定を行います。
保存パネル 5 を保存します(_rowcol_clean.png)。

処理結果として「クリア: 行n / 列m」が表示されます。膨張で生成した画素は判定時に透明として扱われます。

6. 色クリア

操作説明
左上 / 右上 / 左下 / 右下チェックした角から連続する同色を透明にします(複数指定可)。
色差同じ色とみなす許容差です(0〜100。大きいほど広い色が対象)。
保存パネル 6 を保存します(_color_clear.png)。

角のチェックがすべて OFF のときは、パネル 1 / 6 のクリックで取得した色に近い画素を画像全体から透明にします。

7. 端部小物体除去

画面の端に接している小さなゴミ(ノイズ)を消します。

操作説明
上 / 下 / 左 / 右チェックした端に接する塊を除去対象にします。
面積≤%画像全体の面積に対する上限割合です(0〜20%、0.1 刻み、初期値 0.9%)。例: 0.5% なら全画素の 0.5% 以下の大きさの塊を消します。画像サイズが変わっても同じ意味になります。
α閾値これ以下の α を透明とみなして塊の大きさを判定します(0〜255)。
保存パネル 7 を保存します(_edge_clean.png)。

結果は「除去: n個 (m px)」と表示されます。

8. ロングフェザー

輪郭の α をなめらかなグラデーションにします。

操作説明
S字滑らかα の変化を S 字カーブにして自然なグラデーションにします。
境界色保護輪郭付近の色を内側からにじませ、色の欠けを防ぎます。
内側侵入不透明部分の内側へグラデーションを入れる幅です(0〜50)。
外側広がり輪郭の外側へ透明グラデーションを広げる幅です(0〜50)。
広がり方カーブの強さです(10〜300、100 が標準)。
保存パネル 8 を保存します(_long_feather.png)。

実行順の設定ウィンドウ

「実行順を設定…」で開きます。チェックした処理を 上から順に 適用します。

操作説明
左の番号現在の適用順です。
チェックボックスその処理を適用する / しない。
↑ / ↓その処理を 1 つ上 / 下へ移動します。
全ONすべての処理を ON にします。
全OFFすべての処理を OFF にします(⑨ 最終は元画像と同じになります)。
初期化ON/OFF と実行順を初期状態に戻します。
閉じるウィンドウを閉じます(設定は保持されます)。

この順序は パネル 9(最終) と、一括処理の「最終(全適用)」に反映されます。

α分布ウィンドウ

「2. 透明検出」の行にある α分布 ボタンで開きます。α(0〜255)ごとのピクセル数を一覧表示します。

列内容
α0〜255 のアルファ値(全 256 行)。
元画像 px / %元画像でその α を持つ画素の数と割合。
最終 px / %パネル 9(最終)でその α を持つ画素の数と割合。
  • 半透明(1〜254)の行は色付きで表示されます。
  • 上部の集計は 合計 / α=0 / 半透明(1-254) / α=255 をそれぞれ表示します。
  • ウィンドウを開いたままパラメータを変えると、約 0.4 秒後に自動で再計算されます。
ボタン説明
再計算現在の設定で表と集計を作り直します。
コピー表の内容をタブ区切りテキストとしてクリップボードにコピーします(Excel などに貼り付け可能)。
閉じるウィンドウを閉じます。

保存の仕組み

パネル単体の保存

  • 各セクションの 保存 ボタン、または上部の ⑨ 最終を保存 で PNG を書き出します。
  • 初期ファイル名は 元ファイル名_パネル名.png です。
  • 背景が「無」なら RGBA(透過を保持)、「色 / 画 / 市」ならその背景を合成した画像になります。

パネル名と保存ファイル名の対応:

パネル保存名の接尾辞
1 元画像_original.png
2 透明検出_alpha_detect.png
3 半透明_semi_transparent.png
4 膨張/縮小_morph.png
5 行・列クリーン_rowcol_clean.png
6 色クリア_color_clear.png
7 端部小物体除去_edge_clean.png
8 ロングフェザー_long_feather.png
9 最終_final.png

フォルダ一括処理

  1. 処理対象のフォルダを「フォルダ読み込み」で選ぶ
  2. 対象メニューから保存したいパネルを選ぶ(「全9種」ですべて)
  3. 「フォルダ一括」を押す
  4. 確認ダイアログ(対象件数・内容・保存モード・出力先)で実行
  5. 完了後、成功件数と失敗件数、出力先が表示される
  • 出力先は 選択フォルダ内の batch_output に固定です(自動作成)。
  • ファイル名は 元ファイル名_パネル名.png です。
  • 通常保存と一括処理は同じ書き出し処理を使うため、同じ設定なら 同じ内容の PNG になります。
  • 処理中はボタンが無効になり、3/10 のような進捗が表示されます。
  • 失敗したファイルは最大 10 件までエラー内容が表示されます。

背景合成と保存の関係

背景画面表示保存
無透明部は市松模様で表示α 付きのまま(透過保持)
色指定色と合成指定色を焼き込み
画背景画像と合成背景画像を焼き込み
市市松模様と合成市松模様を焼き込み

設定ファイル(設定保存 / 設定読込)

保存される内容

  • 半透明モード(透明 / 不透明)と α 閾値
  • 膨張/縮小(種類・カーネル・回数)
  • 行・列クリーン(α 閾値・割合・連続数)
  • 色クリア(色差・角のチェック)
  • 端部小物体除去(面積 %・α 閾値・上下左右)
  • ロングフェザー(内側・外側・広がり方・S 字・境界色保護)
  • 透明部 RGB 変更(ON/OFF・α 閾値・変更色)
  • 6 処理の 実行順 と ON/OFF

動作

  • 形式は JSON(version: 1)。既定のファイル名は alphaviewer_settings.json です。
  • 読み込み時、未知の項目や古い項目は無視されます。
  • 値は現在の型(真偽値・整数・小数・文字列)に合わせて変換されます。
  • 実行順ウィンドウと画面表示は読み込み直後に更新されます。
  • 壊れた JSON を読ませてもアプリは終了せず、エラーダイアログを表示します。

ツールチップとドラッグ&ドロップ

  • 主要なボタン・スライダー・チェックボックス・パネル・サムネイルにツールチップがあります。約 0.45 秒カーソルを乗せると表示されます。
  • タイトル行とサムネイルのツールチップではフルパスを確認できます。
  • 画面外にはみ出さないよう、ツールチップの位置は自動調整されます。
  • tkinterdnd2 が入っている場合、画像ファイルをウィンドウへドロップすると読み込めます(フォルダのドロップには対応していません)。

処理の考え方(パイプライン)

  • パネル 1 は「元画像」、パネル 2 は「α の可視化」です。
  • パネル 3 は「半透明」、パネル 4 は「半透明 + 膨張/縮小」までのベースです。
  • パネル 5〜8 は、そのベースに各処理を 1 つだけ 適用した確認用の画像です。
  • パネル 9 は、ON の処理を実行順にすべて適用した最終結果です。
  • 透明部 RGB 変更が ON のときは、α チャンネルを持たないパネル 2 を除く各 RGBA パネルに反映されます。
  • 表示用の合成(背景・市松)と、保存用の画像は別々に管理されています。「表示」チェック(RGB 変更の目視用)は保存内容に影響しません。

注意事項・既知の制限

  • 「不透明化」は元画像の α を書き換えるため 元に戻せません。必要な場合は画像を再読み込みしてください。
  • 背景を「市」にすると、保存した PNG に市松模様が焼き込まれます。透過を残したい場合は「無」を選んでください。
  • パネル 2(透明検出)は 3 チャンネルのグレースケール画像として保存されるため、α を持ちません。
  • 透明部 RGB 変更は α=0 の画素の RGB も書き換えます。一部のビューアでは α=0 の RGB が見えないため、「表示」で確認してください。
  • 行・列クリーンの「連続数」は、不透明部分の 塊(ラン)の個数 です(長さではありません)。
  • 端部小物体除去の「面積≤%」は、画像全体の面積に対する割合です。画像サイズが大きいほど、消える塊の画素数も大きくなります。
  • scipy が無い場合、端部小物体除去は簡易的な探索処理になり、ロングフェザーも簡易的なぼかしになります。処理速度と精度のため scipy の導入を推奨します。
  • tkinterdnd2 が無い場合、ドラッグ&ドロップは使えません。「フォルダ読み込み」から読み込んでください。
  • サムネイルはフォルダ読み込み時に生成します。ファイルを追加・削除した場合は、もう一度フォルダを読み込んでください。
  • 一括処理の出力先は選択フォルダ内の batch_output 固定です。既存ファイルは同じ名前で上書きされます。
  • 設定の「初期化」は ON/OFF と実行順のみを戻します。パラメータは初期化されません。
  • 画像処理はメモリ上で行われます。非常に大きな画像を多数一括処理する場合は時間とメモリに注意してください。

付録: 用語

用語意味
α(アルファ)画素の不透明度。0=完全な透明、255=完全な不透明。
半透明α が 0 より大きく 255 未満の画素。本アプリでは「0 より大きく閾値以下」を処理対象にします。
ベース「半透明」と「膨張/縮小」までを適用した状態。パネル 4〜8 の出発点です。
パネル 9 / 最終ON の処理を実行順にすべて適用した結果。
背景合成透過画像を色・画像・市松模様の上に重ねて表示・保存すること。
色にじみ透過 PNG の透明部分に残った RGB が、縮小表示などで縁に色として現れる現象。透明部 RGB 変更で軽減できます。

プログラム

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AlphaViewer New
- 左: サムネイル一覧(透明部分は市松模様)
- 中央: 3x3 グリッド
    1 元画像 / 2 透明検出 / 3 半透明→透明(または不透明)
    4 膨張・縮小 / 5 行列クリーン / 6 色クリア
    7 端部小物体除去 / 8 ロングフェザー / 9 最終(全適用)
- 右: 実行順序(有効/無効 + 並べ替え)と各パラメータ
- 上部: 画像サイズとファイルパス
"""

import os
import json
import tkinter as tk
from tkinter import filedialog, messagebox, colorchooser, ttk
from PIL import Image, ImageTk, ImageFilter
import numpy as np
from collections import deque

try:
    from tkinterdnd2 import DND_FILES, TkinterDnD
    DND_AVAILABLE = True
except Exception:
    DND_AVAILABLE = False

try:
    from scipy import ndimage
    HAS_SCIPY = True
except Exception:
    HAS_SCIPY = False

F6 = ("Meiryo", 6)
F7 = ("Meiryo", 7)
F7B = ("Meiryo", 7, "bold")
F8 = ("Meiryo", 8)
F8B = ("Meiryo", 8, "bold")
F9 = ("Meiryo", 9)


class ToolTip:
    """マウスを乗せると説明を表示する簡易ツールチップ。"""

    DELAY = 450

    def __init__(self, widget, text):
        self.widget = widget
        self.text = str(text or "")
        self._after = None
        self._win = None
        try:
            widget.bind("<Enter>", self._schedule, add="+")
            widget.bind("<Leave>", self._hide, add="+")
            widget.bind("<ButtonPress>", self._hide, add="+")
            widget.bind("<Destroy>", self._hide, add="+")
        except Exception:
            pass

    def set_text(self, text):
        self.text = str(text or "")

    def _schedule(self, event=None):
        self._cancel()
        if not self.text:
            return
        try:
            self._after = self.widget.after(self.DELAY, self._show)
        except Exception:
            self._after = None

    def _cancel(self):
        if self._after is not None:
            try:
                self.widget.after_cancel(self._after)
            except Exception:
                pass
            self._after = None

    def _show(self):
        self._after = None
        if self._win is not None or not self.text:
            return
        try:
            if not self.widget.winfo_exists():
                return
            x = self.widget.winfo_rootx() + 12
            y = self.widget.winfo_rooty() + self.widget.winfo_height() + 6
        except Exception:
            return
        try:
            win = tk.Toplevel(self.widget)
            win.wm_overrideredirect(True)
            try:
                win.attributes("-topmost", True)
            except Exception:
                pass
            tk.Label(win, text=self.text, justify=tk.LEFT,
                     bg="#ffffe0", fg="#111111", relief=tk.SOLID, bd=1,
                     font=("Meiryo", 8), padx=5, pady=3,
                     wraplength=430).pack()
            win.update_idletasks()
            # 画面内に収まるように位置を調整
            tw, th = win.winfo_reqwidth(), win.winfo_reqheight()
            scr_w = win.winfo_screenwidth()
            scr_h = win.winfo_screenheight()
            x = max(0, min(x, scr_w - tw - 4))
            y = max(0, min(y, scr_h - th - 4))
            win.wm_geometry("+{}+{}".format(x, y))
            self._win = win
        except Exception:
            self._win = None

    def _hide(self, event=None):
        self._cancel()
        win = self._win
        self._win = None
        if win is not None:
            try:
                win.destroy()
            except Exception:
                pass


def attach_tip(widget, text):
    """ウィジェットにツールチップを付ける(重複時は文言を更新)。"""
    if widget is None or not text:
        return widget
    try:
        old = getattr(widget, "_av_tooltip", None)
        if old is not None:
            old.set_text(text)
            return widget
        widget._av_tooltip = ToolTip(widget, text)
    except Exception:
        pass
    return widget


def tip(widget, text):
    """attach_tip の短縮形(戻り値は widget)。"""
    attach_tip(widget, text)
    return widget


class AlphaViewerNew:
    IMAGE_EXTS = ('.png', '.jpg', '.jpeg', '.bmp', '.tif', '.tiff', '.webp')

    # パネル番号 0-8
    PANEL_TITLES = [
        "1. 元画像",
        "2. 透明検出(α)",
        "3. 半透明→透明",
        "4. 膨張 / 縮小",
        "5. 行列クリーン",
        "6. 色クリア",
        "7. 端部小物体除去",
        "8. ロングフェザー",
        "9. 最終(全適用)",
    ]
    PANEL_NAMES = [
        "original",
        "alpha_detect",
        "semi_transparent",
        "morph",
        "rowcol_clean",
        "color_clear",
        "edge_clean",
        "long_feather",
        "final",
    ]

    PANEL_TIPS = [
        "1. 元画像:読み込んだままの画像です。\nクリックするとその画素の色を取得します。",
        "2. 透明検出:α(透明度)だけをグレースケールで表示します。\n白いほど不透明、黒いほど透明です。\n上の「α分布」でαごとの画素数を確認できます。",
        "3. 半透明:αが0より大きく閾値以下の画素を\n「透明」または「不透明」にまとめます。",
        "4. 膨張/縮小:不透明部分の輪郭を太らせる(膨張)か\n痩せさせる(縮小)処理です。",
        "5. 行列クリーン:ほぼ透明な行・列をまとめて透明にします。",
        "6. 色クリア:指定した角から連続する同色を透明にします。\nパネルをクリックすると色を取得できます。",
        "7. 端部小物体除去:画面の端に接する小さなゴミを消します。\nサイズは画像全体の面積に対する割合(%)で指定します。",
        "8. ロングフェザー:輪郭のαをなめらかなグラデーションにします。",
        "9. 最終:ON の処理を実行順にすべて適用した結果です。",
    ]

    # 適用順を入れ替えられる処理
    STAGE_DEFS = [
        ("semi",         "3 半透明→透明",     2),
        ("morph",        "4 膨張/縮小",       3),
        ("rowcol",       "5 行列クリーン",    4),
        ("color",        "6 色クリア",        5),
        ("edge_clean",   "7 端部小物体除去",  6),
        ("long_feather", "8 ロングフェザー",  7),
    ]

    # 既定でONにする処理と、その初期順序
    DEFAULT_ON = ("semi", "morph", "rowcol", "color", "edge_clean", "long_feather")
    DEFAULT_ORDER = ("semi", "morph", "rowcol", "color", "edge_clean", "long_feather")

    # 設定ファイルに保存するパラメータ
    PARAM_KEYS = (
        "semi_mode", "semi_thresh", "morph_op", "morph_k", "morph_iter",
        "rowcol_alpha", "row_pct", "row_cnt", "col_pct", "col_cnt",
        "color_thresh", "corner_tl", "corner_tr", "corner_bl", "corner_br",
        "edge_pct", "edge_alpha", "edge_top", "edge_bottom",
        "edge_left", "edge_right",
        "lf_inner", "lf_outer", "lf_curve", "lf_smooth", "lf_bleed",
        "rgb_enable", "rgb_alpha_th",
    )
    SETTINGS_VERSION = 1

    BATCH_TARGETS = {
        "最終(全適用)": 8,
        "ロングフェザー": 7,
        "端部小物体除去": 6,
        "色クリア": 5,
        "行列クリーン": 4,
        "膨張/縮小": 3,
        "半透明": 2,
        "透明検出": 1,
        "元画像": 0,
    }

    def __init__(self, root):
        self.root = root
        self.root.title("AlphaViewer New")
        self.root.geometry("1040x750")
        self.root.minsize(920, 680)

        self.folder = ""
        self.image_path = ""
        self.original_image = None   # PIL RGBA
        self.original_array = None   # numpy RGBA

        # 結果画像(9枚)
        self.img_orig = None
        self.img_trans_detect = None
        self.img_semi = None
        self.img_morph = None
        self.img_rowcol = None
        self.img_color = None
        self.img_edge = None
        self.img_alpha = None
        self.img_final = None
        self.panels_raw = None   # 保存用の生パネル配列(9枚)

        self._update_job = None
        self._resize_job = None
        self.batch_running = False
        self._generated = None   # 膨張で生成した画素(行列クリーンで透明扱い)
        self._rc_info = (0, 0)   # 行列クリーンで消した行数・列数
        self._edge_info = (0, 0) # 端部小物体除去で消した個数・画素数

        # 実行順の別ウィンドウ
        self.order_win = None
        self.order_frames = {}

        # α分布の別ウィンドウ
        self.alpha_win = None
        self.alpha_tree = None
        self.alpha_summary = None
        self.alpha_summary2 = None
        self.alpha_rows = None
        self._alpha_job = None

        # --- パラメータ ---
        # 半透明("transparent" = 半透明→透明 / "opaque" = 半透明→不透明)
        self.semi_mode = tk.StringVar(value="transparent")
        self.semi_thresh = tk.IntVar(value=128)
        # 膨張/縮小
        self.morph_op = tk.StringVar(value="dilate")
        self.morph_k = tk.IntVar(value=3)
        self.morph_iter = tk.IntVar(value=1)
        # 行列クリーン
        self.rowcol_alpha = tk.IntVar(value=10)
        self.row_pct = tk.IntVar(value=95)
        self.row_cnt = tk.IntVar(value=3)
        self.col_pct = tk.IntVar(value=95)
        self.col_cnt = tk.IntVar(value=3)
        # 色クリア
        self.color_thresh = tk.IntVar(value=30)
        self.corner_tl = tk.BooleanVar(value=True)
        self.corner_tr = tk.BooleanVar(value=False)
        self.corner_bl = tk.BooleanVar(value=False)
        self.corner_br = tk.BooleanVar(value=False)
        self.picked_color = None
        # 端部小物体除去(面積割合: 画像全体に対する %)
        self.edge_pct = tk.DoubleVar(value=0.9)
        self.edge_alpha = tk.IntVar(value=10)
        self.edge_top = tk.BooleanVar(value=True)
        self.edge_bottom = tk.BooleanVar(value=True)
        self.edge_left = tk.BooleanVar(value=True)
        self.edge_right = tk.BooleanVar(value=True)
        # ロングフェザー(透明グラデーション)
        self.lf_inner = tk.IntVar(value=5)
        self.lf_outer = tk.IntVar(value=5)
        self.lf_curve = tk.IntVar(value=100)
        self.lf_smooth = tk.BooleanVar(value=True)
        self.lf_bleed = tk.BooleanVar(value=True)
        # バッチ
        self.batch_target = tk.StringVar(value="最終(全適用)")
        # 背景合成・色設定
        self.bg_mode = tk.StringVar(value="raw")  # "checker", "color", "image"
        self.bg_color = (255, 255, 255)               # デフォルト白
        self.bg_image_path = ""
        self.bg_image_pil = None
        # 透明部のRGB変更設定(背景透明のまま)
        self.rgb_enable = tk.BooleanVar(value=False)
        self.rgb_target_color = (0, 0, 0)          # デフォルト黒 (0, 0, 0)
        self.rgb_alpha_th = tk.IntVar(value=10)     # α <= 閾値の透明画素を対象
        # 表示確認用: ONにすると透明部を塗りつぶしてRGB変更を目視できる(保存内容は変わらない)
        self.rgb_preview = tk.BooleanVar(value=True)

        # --- 処理ステージ ---
        self.stages = []
        for i, (key, label, panel) in enumerate(self.STAGE_DEFS):
            self.stages.append({
                "key": key,
                "label": label,
                "panel": panel,
                "var": tk.BooleanVar(value=key in self.DEFAULT_ON),
            })

        self.setup_ui()

        def _on_root_config(e):
            if e.widget is not self.root:
                return
            if self._resize_job is not None:
                try:
                    self.root.after_cancel(self._resize_job)
                except Exception:
                    pass
            self._resize_job = self.root.after(120, self._redraw_views_now)

        self.root.bind("<Configure>", _on_root_config)
        self.root.protocol("WM_DELETE_WINDOW", self.on_close)

    def on_close(self):
        """終了時に保留中の after ジョブを止める。"""
        for job in (self._update_job, self._resize_job):
            if job is not None:
                try:
                    self.root.after_cancel(job)
                except Exception:
                    pass
        self._update_job = None
        self._resize_job = None
        try:
            self.close_order_window()
        except Exception:
            pass
        try:
            self.close_alpha_window()
        except Exception:
            pass
        try:
            self.root.destroy()
        except Exception:
            pass

    # ------------------------------------------------------------
    # UI
    # ------------------------------------------------------------
    def _redraw_views_now(self):
        self._resize_job = None
        self.update_all()

    def setup_ui(self):
        # 最上部: タイトル行(サイズとファイルパス)
        header = tk.Frame(self.root, bg="#e8e8e8", bd=1, relief=tk.SOLID)
        header.pack(side=tk.TOP, fill=tk.X)
        self.title_label = tk.Label(header, text="AlphaViewer New", font=F7,
                                    bg="#e8e8e8", anchor="w", padx=4, pady=1)
        self.title_label.pack(side=tk.LEFT, fill=tk.X, expand=True)

        # 左 → 右 → 中央 の順に配置
        left = tk.Frame(self.root, width=140, bg="#e8e8e8")
        left.pack(side=tk.LEFT, fill=tk.Y)
        left.pack_propagate(False)
        b = tk.Button(left, text="フォルダ読み込み", command=self.load_folder,
                      font=F7, padx=2, pady=1)
        b.pack(fill=tk.X, padx=2, pady=2)
        tip(b, "画像フォルダを選択し、左側にサムネイル一覧を表示します。\n"
               "画像ファイルをウィンドウへドラッグ&ドロップしても読み込めます。")

        # 背景合成・色設定枠
        bg_box = tk.LabelFrame(left, text="背景合成・色", font=F7B, bg="#e8e8e8", padx=2, pady=2)
        bg_box.pack(fill=tk.X, padx=2, pady=(0, 2))

        bm_frame = tk.Frame(bg_box, bg="#e8e8e8")
        bm_frame.pack(fill=tk.X, pady=1)
        for txt, val, tip_text in (
                ("無", "raw", "背景を合成せず、透明部分をそのまま表示します。"),
                ("色", "color", "単色の背景と合成して表示します。"),
                ("画", "image", "読み込んだ背景画像と合成して表示します。"),
                ("市", "checker",
                 "市松模様(チェッカーボード)の背景で表示します。\n透明部分が分かりやすくなります。")):
            rb = tk.Radiobutton(bm_frame, text=txt, variable=self.bg_mode,
                                value=val, command=self.update_all, font=F6,
                                bg="#e8e8e8", padx=0, pady=0)
            rb.pack(side=tk.LEFT, padx=0)
            tip(rb, tip_text)

        bc_frame = tk.Frame(bg_box, bg="#e8e8e8")
        bc_frame.pack(fill=tk.X, pady=1)
        b = tk.Button(bc_frame, text="白", font=F6, padx=2, pady=0,
                      command=lambda: self.set_bg_color(255, 255, 255))
        b.pack(side=tk.LEFT, padx=1)
        tip(b, "背景色を白にします。")
        b = tk.Button(bc_frame, text="黒", font=F6, padx=2, pady=0,
                      command=lambda: self.set_bg_color(0, 0, 0))
        b.pack(side=tk.LEFT, padx=1)
        tip(b, "背景色を黒にします。")
        b = tk.Button(bc_frame, text="選択", font=F6, padx=2, pady=0,
                      command=self.choose_bg_color)
        b.pack(side=tk.LEFT, padx=1)
        tip(b, "任意の背景色を選択します。")
        self.bg_swatch = tk.Label(bc_frame, text="  ", bg="#ffffff", relief=tk.SUNKEN, width=2)
        self.bg_swatch.pack(side=tk.LEFT, padx=1)
        tip(self.bg_swatch, "現在の背景色の見本です。")

        bi_frame = tk.Frame(bg_box, bg="#e8e8e8")
        bi_frame.pack(fill=tk.X, pady=1)
        b = tk.Button(bi_frame, text="背景画像読込", font=F6, padx=2, pady=0,
                      command=self.load_bg_image)
        b.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=1)
        tip(b, "背景として合成する画像を読み込みます。")
        b = tk.Button(bi_frame, text="解除", font=F6, padx=2, pady=0,
                      command=self.clear_bg_image)
        b.pack(side=tk.LEFT, padx=1)
        tip(b, "背景画像を解除して市松模様に戻します。")
        thumb_c = tk.Frame(left, bg="#e8e8e8")
        thumb_c.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
        self.tcanvas = tk.Canvas(thumb_c, bg="#d0d0d0", width=110,
                                 highlightthickness=0)
        ts = tk.Scrollbar(thumb_c, orient=tk.VERTICAL, command=self.tcanvas.yview)
        self.tcanvas.configure(yscrollcommand=ts.set)
        ts.pack(side=tk.RIGHT, fill=tk.Y)
        self.tcanvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        self.tinner = tk.Frame(self.tcanvas, bg="#d0d0d0")
        self.tcanvas.create_window((0, 0), window=self.tinner, anchor=tk.NW)
        self.tinner.bind("<Configure>",
                         lambda e: self.tcanvas.configure(
                             scrollregion=self.tcanvas.bbox("all")))
        tip(self.tcanvas, "サムネイル一覧(透明部分は市松模様)\n"
                          "クリックでその画像を読み込みます。")

        # 右(縦スクロール)
        right = tk.Frame(self.root, width=310, bg="#f0f0f0")
        right.pack(side=tk.RIGHT, fill=tk.Y)
        right.pack_propagate(False)

        r_canvas = tk.Canvas(right, width=290, highlightthickness=0, bg="#f0f0f0")
        r_scroll = tk.Scrollbar(right, orient=tk.VERTICAL, command=r_canvas.yview)
        r_canvas.configure(yscrollcommand=r_scroll.set)
        self.rc_canvas = r_canvas

        # 下部固定バー(不透明化 & 色クリア & 透明部RGB変更)
        bottom = tk.Frame(right, bg="#dcdcdc", bd=1, relief=tk.SOLID)
        bottom.pack(side=tk.BOTTOM, fill=tk.X)
        # 1行目: 不透明化 / 設定の保存・読込 / α閾値
        bf2 = tk.Frame(bottom, bg="#dcdcdc")
        bf2.pack(fill=tk.X, padx=2, pady=(2, 1))
        b = tk.Button(bf2, text="不透明化", font=F7, padx=3, pady=1,
                      command=self.clear_alpha_all)
        b.pack(side=tk.LEFT, padx=(1, 3))
        tip(b, "元画像のαをすべて255(完全な不透明)にします。\n"
               "元に戻せないため、確認ダイアログが出ます。")
        self.cfg_save_btn = tk.Button(bf2, text="設定保存", font=F6,
                                      padx=2, pady=0,
                                      command=self.save_settings)
        self.cfg_save_btn.pack(side=tk.LEFT, padx=(0, 1))
        tip(self.cfg_save_btn, "現在のパラメータ・実行順・ON/OFF を\n"
                               "JSONファイルに保存します。")
        self.cfg_load_btn = tk.Button(bf2, text="設定読込", font=F6,
                                      padx=2, pady=0,
                                      command=self.load_settings)
        self.cfg_load_btn.pack(side=tk.LEFT, padx=(0, 2))
        tip(self.cfg_load_btn, "保存したJSONファイルを読み込み、\n"
                               "パラメータ・実行順・ON/OFF を復元します。")
        self.rgb_alpha_th_label = tk.Label(
            bf2, text="α≤{}".format(self.rgb_alpha_th.get()), font=F6,
            bg="#dcdcdc", width=5)
        self.rgb_alpha_th_label.pack(side=tk.RIGHT, padx=(1, 2))
        sc = tk.Scale(bf2, from_=0, to=255, orient=tk.HORIZONTAL,
                      variable=self.rgb_alpha_th, font=F6, length=70, width=7,
                      sliderlength=10, showvalue=False, bd=0, highlightthickness=0,
                      bg="#dcdcdc",
                      command=lambda e: (
                          self.rgb_alpha_th_label.config(
                              text="α≤{}".format(self.rgb_alpha_th.get())),
                          self.update_all()
                      ))
        sc.pack(side=tk.RIGHT)
        self.rgb_alpha_th_scale = sc
        tip(sc, "透明部RGB変更の対象となるαの上限です。\n"
                "この値以下の画素のRGBを変更します(αは変わりません)。")
        # 2行目: 透明部RGB変更(背景透明のまま)
        bf3 = tk.Frame(bottom, bg="#dcdcdc")
        bf3.pack(fill=tk.X, padx=2, pady=(0, 2))
        c = tk.Checkbutton(bf3, text="透明部RGB変更", variable=self.rgb_enable,
                           command=self.update_all, font=F7, bg="#dcdcdc",
                           padx=0, pady=0)
        c.pack(side=tk.LEFT)
        tip(c, "透明な画素のRGB値だけを変更します(αは透明のまま)。\n"
               "透過PNGの色にじみ対策などに使います。")
        c = tk.Checkbutton(bf3, text="表示", variable=self.rgb_preview,
                           command=self.update_all, font=F6, bg="#dcdcdc",
                           padx=0, pady=0)
        c.pack(side=tk.LEFT, padx=(1, 2))
        tip(c, "変更したRGBを確認しやすいよう、\n"
               "画面上だけ透明部を塗りつぶして表示します。\n"
               "保存される画像は変わりません。")
        b = tk.Button(bf3, text="色選択", command=self.choose_rgb_target,
                      font=F6, padx=2, pady=0)
        b.pack(side=tk.LEFT)
        tip(b, "透明部に設定するRGB色を選択します。")
        self.rgb_swatch = tk.Label(bf3, text="  ", bg="#000000", relief=tk.SUNKEN, width=2)
        self.rgb_swatch.pack(side=tk.LEFT, padx=(2, 0))
        tip(self.rgb_swatch, "現在の変更色の見本です。")
        b = tk.Button(bf3, text="黒", font=F6, padx=2, pady=0,
                      command=lambda: self.set_rgb_target(0, 0, 0))
        b.pack(side=tk.LEFT, padx=(2, 1))
        tip(b, "透明部のRGBを黒 (0,0,0) にします。")
        b = tk.Button(bf3, text="白", font=F6, padx=2, pady=0,
                      command=lambda: self.set_rgb_target(255, 255, 255))
        b.pack(side=tk.LEFT, padx=1)
        tip(b, "透明部のRGBを白 (255,255,255) にします。")
        b = tk.Button(bf3, text="紫", font=F6, padx=2, pady=0,
                      command=lambda: self.set_rgb_target(255, 0, 255))
        b.pack(side=tk.LEFT, padx=1)
        tip(b, "透明部のRGBを紫 (255,0,255) にします。\n"
               "にじみの確認用です。")
        self.rgb_status = tk.Label(bf3, text="", font=F6, fg="#666666",
                                   bg="#dcdcdc", anchor="e")
        self.rgb_status.pack(side=tk.RIGHT, padx=(2, 2))


        r_scroll.pack(side=tk.RIGHT, fill=tk.Y)
        r_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

        rc = tk.Frame(r_canvas, bg="#f0f0f0")
        r_win = r_canvas.create_window((0, 0), window=rc, anchor=tk.NW)
        rc.bind("<Configure>",
                lambda e: r_canvas.configure(scrollregion=r_canvas.bbox("all")))
        r_canvas.bind("<Configure>",
                      lambda e: r_canvas.itemconfig(r_win, width=e.width))

        def _on_rwheel(e):
            try:
                r_canvas.yview_scroll(int(-1 * (e.delta / 120)), "units")
            except Exception:
                pass

        r_canvas.bind("<Enter>",
                      lambda e: r_canvas.bind_all("<MouseWheel>", _on_rwheel))
        r_canvas.bind("<Leave>", lambda e: r_canvas.unbind_all("<MouseWheel>"))

        # 中央
        center = tk.Frame(self.root)
        center.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        for rr in range(3):
            center.grid_rowconfigure(rr, weight=1, uniform="row")
        for cc in range(3):
            center.grid_columnconfigure(cc, weight=1, uniform="col")

        self.cvs = []
        self.panel_title_labels = []
        for i, title in enumerate(self.PANEL_TITLES):
            rr = i // 3
            cc = i % 3
            f = tk.Frame(center, bd=1, relief=tk.SOLID)
            f.grid(row=rr, column=cc, sticky="nsew", padx=1, pady=1)
            f.grid_rowconfigure(1, weight=1)
            f.grid_columnconfigure(0, weight=1)
            tl = tk.Label(f, text=title, font=F7)
            tl.grid(row=0, column=0, sticky="w")
            self.panel_title_labels.append(tl)
            cv = tk.Canvas(f, bg="#808080", highlightthickness=0,
                           width=110, height=85)
            cv.grid(row=1, column=0, sticky="nsew")
            if i in (0, 5):
                cv.bind("<Button-1>", self.click_pick_color)
            tip(cv, self.PANEL_TIPS[i])
            self.cvs.append(cv)


        # ---- 右パネル内容 ----
        rc_ref = tk.Frame(rc, padx=2, pady=2)
        rc_ref.pack(fill=tk.BOTH, expand=True)
        rc_ref.grid_columnconfigure(1, weight=1)

        def section(row, title, save_idx=None, enable_var=None, buttons=(),
                    label_sink=None):
            bar = tk.Frame(rc_ref)
            bar.grid(row=row, column=0, columnspan=3, sticky="ew", pady=(3, 0))
            if enable_var is not None:
                w = tk.Checkbutton(bar, text=title, variable=enable_var,
                                   command=self.update_all, font=F8B,
                                   padx=0, pady=0)
            else:
                w = tk.Label(bar, text=title, font=F8B)
            w.pack(side=tk.LEFT)
            if label_sink is not None:
                label_sink.append(w)
            if save_idx is not None:
                b = tk.Button(bar, text="保存", width=4, font=F7, padx=2, pady=0,
                              command=lambda i=save_idx: self.save_panel(i))
                b.pack(side=tk.RIGHT)
                tip(b, "このパネルの画像をPNGで保存します。")
            for text, cmd, tip_text in buttons:
                b = tk.Button(bar, text=text, font=F7, padx=2, pady=0,
                              command=cmd)
                b.pack(side=tk.RIGHT, padx=(0, 2))
                tip(b, tip_text)
            return row + 1

        def prow(row, label, var, from_, to, width=8, stage_key=None,
                 resolution=None, tip_text=None):
            lb = tk.Label(rc_ref, text=label, font=F6, width=width, anchor="w")
            lb.grid(row=row, column=0, sticky="w")
            kw = {}
            if resolution:
                kw["resolution"] = resolution
            sc = tk.Scale(rc_ref, from_=from_, to=to, orient=tk.HORIZONTAL,
                          variable=var, font=F6, length=84, width=7,
                          sliderlength=12, showvalue=False, bd=0,
                          highlightthickness=0, **kw,
                          command=lambda e: self._param_changed(stage_key))
            sc.grid(row=row, column=1, sticky="ew")
            vl = tk.Label(rc_ref, textvariable=var, font=F6, width=3,
                          anchor="e")
            vl.grid(row=row, column=2, sticky="e", padx=(0, 4))
            if tip_text is None:
                tip_text = "{}({}〜{})".format(label, from_, to)
            for w in (lb, sc, vl):
                tip(w, tip_text)
            return row + 1

        r = 0

        # 実行順序(上部横並び配置)
        bar1 = tk.Frame(rc_ref)
        bar1.grid(row=r, column=0, columnspan=3, sticky="ew", pady=(2, 2))
        b = tk.Button(bar1, text="実行順を設定…", font=F8B, padx=3, pady=1,
                      command=self.open_order_window)
        b.pack(side=tk.LEFT)
        tip(b, "適用する処理のON/OFFと実行順を別ウィンドウで設定します。\n"
               "上から順に適用され、⑨ 最終(全適用)に反映されます。")
        b = tk.Button(bar1, text="⑨ 最終を保存", font=F8B, padx=3, pady=1,
                      command=lambda: self.save_panel(8))
        b.pack(side=tk.LEFT, padx=2)
        tip(b, "⑨ 最終(全適用)の画像をPNGで保存します。")
        b = tk.Button(bar1, text="初期化", font=F7, padx=3, pady=1,
                      command=self.reset_stages)
        b.pack(side=tk.RIGHT)
        tip(b, "処理のON/OFFと実行順を初期状態に戻します。")
        r += 1

        # フォルダ一括処理(実行順ボタンの場所に移動、横並び)
        bar2 = tk.Frame(rc_ref)
        bar2.grid(row=r, column=0, columnspan=3, sticky="ew", pady=(1, 3))
        self.batch_btn = tk.Button(bar2, text="フォルダ一括",
                                   command=self.batch_process, font=F8B,
                                   padx=3, pady=1)
        self.batch_btn.pack(side=tk.LEFT)
        tip(self.batch_btn, "選択したフォルダ内の画像をまとめて処理し、\n"
                            "別フォルダに保存します。")
        self.batch_menu = tk.OptionMenu(
            bar2, self.batch_target,
            "最終(全適用)", "ロングフェザー", "端部小物体除去", "色クリア", "行列クリーン",
            "膨張/縮小", "半透明", "透明検出", "元画像", "全9種")
        self.batch_menu.config(font=F7, padx=1, pady=0, highlightthickness=0,
                               width=10, anchor="w")
        self.batch_menu.pack(side=tk.LEFT, padx=2)
        tip(self.batch_menu, "一括処理で保存するパネルを選びます。\n"
                             "「全9種」は9枚すべてを保存します。")
        self.batch_status = tk.Label(bar2, text="", font=F6, fg="#555555",
                                     anchor="w")
        self.batch_status.pack(side=tk.LEFT, padx=1)
        r += 1
        self.order_summary = tk.Label(rc_ref, text="", font=F6, fg="#444444",
                                      anchor="w", justify=tk.LEFT,
                                      wraplength=285)
        self.order_summary.grid(row=r, column=0, columnspan=3, sticky="ew",
                                pady=(0, 2))
        r += 1

        # 1, 2
        r = section(r, "1. 元画像", 0)
        r = section(r, "2. 透明検出", 1,
                    buttons=(("α分布", self.show_alpha_histogram,
                              "α(0〜255)ごとのピクセル数を\n"
                              "別ウィンドウの一覧で表示します。"),))

        # 3
        semi_labels = []
        r = section(r, self._semi_title(), 2, label_sink=semi_labels)
        self.semi_section_label = semi_labels[0]
        tip(self.semi_section_label,
            "αが0より大きく閾値以下の画素をどう扱うかを選びます。")
        sm = tk.Frame(rc_ref)
        sm.grid(row=r, column=0, columnspan=3, sticky="w")
        rb = tk.Radiobutton(sm, text="透明", variable=self.semi_mode,
                            value="transparent", command=self._set_semi_mode,
                            font=F7)
        rb.pack(side=tk.LEFT)
        tip(rb, "半透明の画素を完全な透明(α=0)にします。\n(初期設定)")
        rb = tk.Radiobutton(sm, text="不透明", variable=self.semi_mode,
                            value="opaque", command=self._set_semi_mode,
                            font=F7)
        rb.pack(side=tk.LEFT, padx=(6, 0))
        tip(rb, "半透明の画素を完全な不透明(α=255)にします。")
        r += 1
        r = prow(r, "α≤", self.semi_thresh, 0, 255, stage_key="semi",
                 tip_text="この値以下のαを半透明として扱います。\n"
                          "0 より大きく、この値以下の画素が対象です。")

        # 4
        r = section(r, "4. 膨張/縮小", 3)
        mf = tk.Frame(rc_ref)
        mf.grid(row=r, column=0, columnspan=3, sticky="w")
        rb = tk.Radiobutton(mf, text="膨張", variable=self.morph_op, value="dilate",
                            command=self.update_all, font=F8)
        rb.pack(side=tk.LEFT)
        tip(rb, "不透明部分を太らせます(輪郭が広がります)。")
        rb = tk.Radiobutton(mf, text="縮小", variable=self.morph_op, value="erode",
                            command=self.update_all, font=F8)
        rb.pack(side=tk.LEFT)
        tip(rb, "不透明部分を痩せさせます(輪郭が内側に縮みます)。")
        r += 1
        r = prow(r, "カーネル", self.morph_k, 1, 9, stage_key="morph",
                 tip_text="処理する範囲(カーネル)の大きさです。\n大きいほど強く効きます。")
        r = prow(r, "回数", self.morph_iter, 1, 10, stage_key="morph",
                 tip_text="同じ膨張/縮小を繰り返す回数です。")

        # 5
        r = section(r, "5. 行・列クリーン", 4)
        r = prow(r, "α閾値", self.rowcol_alpha, 0, 255, stage_key="rowcol",
                 tip_text="これ以下のαを透明とみなして行・列を判定します。")
        r = prow(r, "行 割合%", self.row_pct, 0, 100, stage_key="rowcol",
                 tip_text="行のうち透明な画素がこの割合以上なら、その行を消します。")
        r = prow(r, "行 連続数", self.row_cnt, 1, 50, stage_key="rowcol",
                 tip_text="行内で許容する不透明の連続数です。\n少ないほど消えやすくなります。")
        r = prow(r, "列 割合%", self.col_pct, 0, 100, stage_key="rowcol",
                 tip_text="列のうち透明な画素がこの割合以上なら、その列を消します。")
        r = prow(r, "列 連続数", self.col_cnt, 1, 50, stage_key="rowcol",
                 tip_text="列内で許容する不透明の連続数です。\n少ないほど消えやすくなります。")
        self.rc_status = tk.Label(rc_ref, text="クリア: 行0 / 列0", font=F6,
                                  fg="#777777", anchor="w")
        self.rc_status.grid(row=r, column=0, columnspan=3, sticky="w")
        r += 1

        # 6
        r = section(r, "6. 色クリア", 5)
        cf0 = tk.Frame(rc_ref)
        cf0.grid(row=r, column=0, columnspan=3, sticky="w")
        tk.Label(cf0, text="角から連続する同色を透明化", font=F6).pack(anchor="w")
        cf1 = tk.Frame(cf0)
        cf1.pack(anchor="w")
        for txt, var, tip_text in (
                ("左上", self.corner_tl, "左上から連続する同色を透明にします。"),
                ("右上", self.corner_tr, "右上から連続する同色を透明にします。"),
                ("左下", self.corner_bl, "左下から連続する同色を透明にします。"),
                ("右下", self.corner_br, "右下から連続する同色を透明にします。")):
            cb = tk.Checkbutton(cf1, text=txt, variable=var,
                                command=self.update_all, font=F7, padx=0, pady=0,
                                takefocus=0)
            cb.pack(side=tk.LEFT, padx=(0, 2))
            tip(cb, tip_text)
        r += 1
        r = prow(r, "色差", self.color_thresh, 0, 100, stage_key="color",
                 tip_text="同じ色とみなす許容差です。\n大きいほど広い色を透明にします。")

        # 7 端部小物体除去
        r = section(r, "7. 端部小物体除去", 6)
        ef0 = tk.Frame(rc_ref)
        ef0.grid(row=r, column=0, columnspan=3, sticky="w")
        for txt, var, tip_text in (
                ("上", self.edge_top, "上端に接する小物体を除去します。"),
                ("下", self.edge_bottom, "下端に接する小物体を除去します。"),
                ("左", self.edge_left, "左端に接する小物体を除去します。"),
                ("右", self.edge_right, "右端に接する小物体を除去します。")):
            cb = tk.Checkbutton(ef0, text=txt, variable=var,
                                command=self.update_all, font=F7, padx=0, pady=0,
                                takefocus=0)
            cb.pack(side=tk.LEFT, padx=(0, 2))
            tip(cb, tip_text)
        r += 1
        r = prow(r, "面積≤%", self.edge_pct, 0, 20, stage_key="edge_clean",
                 resolution=0.1,
                 tip_text="画像全体の面積に対する上限割合です。\n"
                          "この割合より小さい塊を除去します(例: 0.5%)。")
        r = prow(r, "α閾値", self.edge_alpha, 0, 255, stage_key="edge_clean",
                 tip_text="これ以下のαを透明とみなして塊の大きさを判定します。")
        self.edge_status = tk.Label(rc_ref, text="除去: 0個 (0 px)", font=F6,
                                    fg="#777777", anchor="w")
        self.edge_status.grid(row=r, column=0, columnspan=3, sticky="w")
        r += 1

        # 8 ロングフェザー(透明グラデーション)
        r = section(r, "8. ロングフェザー", 7)
        lff = tk.Frame(rc_ref)
        lff.grid(row=r, column=0, columnspan=3, sticky="w")
        cb = tk.Checkbutton(lff, text="S字滑らか", variable=self.lf_smooth,
                            command=self.update_all, font=F7, padx=0, pady=0,
                            takefocus=0)
        cb.pack(side=tk.LEFT, padx=(0, 4))
        tip(cb, "αの変化をS字カーブにして、より自然なグラデーションにします。")
        cb = tk.Checkbutton(lff, text="境界色保護", variable=self.lf_bleed,
                            command=self.update_all, font=F7, padx=0, pady=0,
                            takefocus=0)
        cb.pack(side=tk.LEFT)
        tip(cb, "輪郭付近の色を内側からにじませて、色の欠けを防ぎます。")
        r += 1
        r = prow(r, "内側侵入", self.lf_inner, 0, 50, stage_key="long_feather",
                 tip_text="不透明部分の内側へグラデーションを入れる幅です。")
        r = prow(r, "外側広がり", self.lf_outer, 0, 50, stage_key="long_feather",
                 tip_text="輪郭の外側へ透明グラデーションを広げる幅です。")
        r = prow(r, "広がり方", self.lf_curve, 10, 300, stage_key="long_feather",
                 tip_text="グラデーションのカーブの強さです。\n100 が標準です。")

        self._update_title()

        if DND_AVAILABLE:
            try:
                DragDropHandler(self.root, self.load_file)
            except Exception:
                pass

    # ------------------------------------------------------------
    # タイトル行(サイズ・ファイルパス)
    # ------------------------------------------------------------
    def _update_title(self):
        """タイトル行とウィンドウタイトルに サイズ・パス を表示する。"""
        if self.original_image is None:
            label = "AlphaViewer New"
            full = "AlphaViewer New"
        else:
            try:
                w, h = self.original_image.size
            except Exception:
                w, h = 0, 0
            path = self.image_path or "(未読み込み)"
            size_txt = "幅{} × 高さ{} px".format(w, h)
            full = "AlphaViewer New | {} | {}".format(size_txt, path)
            label = full
            # 幅に収まらない場合はパスを末尾優先で省略(全文はツールチップ)
            max_len = 110
            if len(label) > max_len:
                tail = path[-(max_len - 30):]
                label = "AlphaViewer New | {} | …{}".format(size_txt, tail)
        try:
            self.root.title(full)
        except Exception:
            pass
        try:
            self.title_label.config(text=label)
            tip(self.title_label, full)
        except Exception:
            pass

    # ------------------------------------------------------------
    # 半透明モード(透明 / 不透明)
    # ------------------------------------------------------------
    def _semi_target(self):
        try:
            return "不透明" if self.semi_mode.get() == "opaque" else "透明"
        except Exception:
            return "透明"

    def _semi_title(self, short=False):
        prefix = "3 " if short else "3. "
        return prefix + "半透明→" + self._semi_target()

    def _update_semi_labels(self):
        """半透明モードに合わせてパネル名・見出し・実行順の表示を更新する。"""
        for st in self.stages:
            if st.get("key") == "semi":
                st["label"] = self._semi_title(short=True)
        try:
            self.semi_section_label.config(text=self._semi_title())
        except Exception:
            pass
        try:
            self.panel_title_labels[2].config(text=self._semi_title())
        except Exception:
            pass
        tip(self.cvs[2], self.PANEL_TIPS[2].replace(
            "「透明」または「不透明」", "「" + self._semi_target() + "」"))

    def _set_semi_mode(self):
        self._update_semi_labels()
        self._refresh_order_window()
        self.update_all()

    # ------------------------------------------------------------
    # α分布(別画面)
    # ------------------------------------------------------------
    def show_alpha_histogram(self):
        if self.original_array is None or self.panels_raw is None:
            messagebox.showinfo("α分布", "画像が読み込まれていません。")
            return
        if self.alpha_win is not None:
            try:
                if self.alpha_win.winfo_exists():
                    self._fill_alpha_window()
                    self.alpha_win.deiconify()
                    self.alpha_win.lift()
                    self.alpha_win.focus_set()
                    return
            except Exception:
                pass
            self.alpha_win = None

        w = tk.Toplevel(self.root)
        self.alpha_win = w
        w.title("α分布(アルファごとのピクセル数)")
        w.geometry("460x600")
        w.minsize(420, 400)
        try:
            w.transient(self.root)
        except Exception:
            pass
        w.protocol("WM_DELETE_WINDOW", self.close_alpha_window)

        head = tk.Frame(w)
        head.pack(fill=tk.X, padx=8, pady=(6, 2))
        tk.Label(head, text="αごとのピクセル数", font=F8B).pack(anchor="w")
        tk.Label(head, text="元画像と ⑨ 最終(全適用)のα分布を並べて表示します。",
                 font=F6, fg="#666666").pack(anchor="w")

        self.alpha_summary = tk.Label(w, text="", font=F7, anchor="w",
                                      justify=tk.LEFT, wraplength=430)
        self.alpha_summary.pack(fill=tk.X, padx=8)
        self.alpha_summary2 = tk.Label(w, text="", font=F7, anchor="w",
                                       justify=tk.LEFT, fg="#555555",
                                       wraplength=430)
        self.alpha_summary2.pack(fill=tk.X, padx=8, pady=(0, 4))

        wrap = tk.Frame(w)
        wrap.pack(fill=tk.BOTH, expand=True, padx=8, pady=2)
        cols = ("a", "o", "op", "f", "fp")
        tree = ttk.Treeview(wrap, columns=cols, show="headings", height=16,
                            selectmode="browse")
        for c, text, width, anchor in (
                ("a", "α", 52, "center"),
                ("o", "元画像 px", 86, "e"),
                ("op", "元画像 %", 76, "e"),
                ("f", "最終 px", 86, "e"),
                ("fp", "最終 %", 76, "e")):
            tree.heading(c, text=text)
            tree.column(c, width=width, anchor=anchor, stretch=False)
        vs = tk.Scrollbar(wrap, orient=tk.VERTICAL, command=tree.yview)
        tree.configure(yscrollcommand=vs.set)
        vs.pack(side=tk.RIGHT, fill=tk.Y)
        tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        try:
            st = ttk.Style(w)
            st.configure("Alpha.Treeview", font=("Meiryo", 7), rowheight=15)
            st.configure("Alpha.Treeview.Heading", font=("Meiryo", 7))
            tree.configure(style="Alpha.Treeview")
        except Exception:
            pass
        tree.tag_configure("semi", background="#fff3c4")
        self.alpha_tree = tree

        foot = tk.Frame(w)
        foot.pack(fill=tk.X, padx=8, pady=(2, 8))
        b = tk.Button(foot, text="再計算", font=F7, padx=4, pady=1,
                      command=self._fill_alpha_window)
        b.pack(side=tk.LEFT)
        tip(b, "現在の設定で α分布 を計算し直します。")
        b = tk.Button(foot, text="コピー", font=F7, padx=4, pady=1,
                      command=self.copy_alpha_table)
        b.pack(side=tk.LEFT, padx=3)
        tip(b, "表の内容をタブ区切りテキストとして\nクリップボードにコピーします。")
        b = tk.Button(foot, text="閉じる", font=F8B, padx=8, pady=1,
                      command=self.close_alpha_window)
        b.pack(side=tk.RIGHT)
        tip(b, "このウィンドウを閉じます。")

        self._fill_alpha_window()

    def _fill_alpha_window(self):
        """α分布の表と集計を最新の内容で埋め直す。"""
        tree = getattr(self, "alpha_tree", None)
        if tree is None or self.original_array is None or self.panels_raw is None:
            return
        try:
            if not tree.winfo_exists():
                return
        except Exception:
            return
        orig = np.asarray(self.original_array[..., 3]).ravel()
        fin = np.asarray(self.panels_raw[8][..., 3]).ravel()
        co = np.bincount(orig, minlength=256).astype(np.int64)
        cf = np.bincount(fin, minlength=256).astype(np.int64)
        tot_o = int(co.sum()) or 1
        tot_f = int(cf.sum()) or 1

        try:
            tree.delete(*tree.get_children())
        except Exception:
            pass
        rows = []
        for a in range(256):
            o = int(co[a])
            f = int(cf[a])
            po = 100.0 * o / tot_o
            pf = 100.0 * f / tot_f
            tags = ("semi",) if (0 < a < 255 and (o or f)) else ()
            try:
                tree.insert("", "end", values=(
                    a, o, "{:.2f}".format(po), f, "{:.2f}".format(pf)),
                    tags=tags)
            except Exception:
                break
            rows.append((a, o, po, f, pf))
        self.alpha_rows = rows

        def summary(c):
            z = int(c[0])
            semi = int(c[1:255].sum())
            full = int(c[255])
            return dict(z=z, semi=semi, full=full)

        so = summary(co)
        sf = summary(cf)
        try:
            self.alpha_summary.config(
                text="元画像: 合計 {:,} px / α=0: {:,} ({:.2f}%) / "
                     "半透明(1-254): {:,} ({:.2f}%) / α=255: {:,} ({:.2f}%)".format(
                         tot_o, so["z"], 100.0 * so["z"] / tot_o,
                         so["semi"], 100.0 * so["semi"] / tot_o,
                         so["full"], 100.0 * so["full"] / tot_o))
            self.alpha_summary2.config(
                text="最終  : 合計 {:,} px / α=0: {:,} ({:.2f}%) / "
                     "半透明(1-254): {:,} ({:.2f}%) / α=255: {:,} ({:.2f}%)".format(
                         tot_f, sf["z"], 100.0 * sf["z"] / tot_f,
                         sf["semi"], 100.0 * sf["semi"] / tot_f,
                         sf["full"], 100.0 * sf["full"] / tot_f))
        except Exception:
            pass

    def copy_alpha_table(self):
        rows = getattr(self, "alpha_rows", None)
        if not rows:
            return
        lines = ["α\t元画像 px\t元画像 %\t最終 px\t最終 %"]
        for a, o, po, f, pf in rows:
            lines.append("{}\t{}\t{:.2f}\t{}\t{:.2f}".format(a, o, po, f, pf))
        try:
            self.root.clipboard_clear()
            self.root.clipboard_append("\n".join(lines))
        except Exception:
            pass

    def _schedule_alpha_refresh(self):
        """α分布ウィンドウが開いているときだけ、少し遅らせて再計算する。"""
        if self.alpha_win is None or self._alpha_job is not None:
            return
        try:
            self._alpha_job = self.root.after(400, self._run_alpha_refresh)
        except Exception:
            self._alpha_job = None

    def _run_alpha_refresh(self):
        self._alpha_job = None
        win = self.alpha_win
        if win is None:
            return
        try:
            if not win.winfo_exists():
                self.alpha_win = None
                return
        except Exception:
            self.alpha_win = None
            return
        self._fill_alpha_window()

    def _cancel_alpha_job(self):
        job = self._alpha_job
        self._alpha_job = None
        if job is not None:
            try:
                self.root.after_cancel(job)
            except Exception:
                pass

    def close_alpha_window(self):
        self._cancel_alpha_job()
        win = self.alpha_win
        self.alpha_win = None
        self.alpha_tree = None
        self.alpha_rows = None
        if win is not None:
            try:
                win.destroy()
            except Exception:
                pass

    # ------------------------------------------------------------
    # 実行順ウィンドウ(別画面)
    # ------------------------------------------------------------
    def open_order_window(self):
        if self.order_win is not None:
            try:
                if self.order_win.winfo_exists():
                    self.order_win.deiconify()
                    self.order_win.lift()
                    self.order_win.focus_set()
                    return
            except Exception:
                pass
            self.order_win = None

        w = tk.Toplevel(self.root)
        self.order_win = w
        w.title("実行順の設定(上から順に適用)")
        w.geometry("500x680")
        w.minsize(460, 480)
        try:
            w.transient(self.root)
        except Exception:
            pass
        w.protocol("WM_DELETE_WINDOW", self.close_order_window)

        head = tk.Frame(w)
        head.pack(fill=tk.X, padx=10, pady=(8, 2))
        tk.Label(head, text="チェックした処理を上から順に適用します",
                 font=F8B).pack(anchor="w")
        tk.Label(head,
                 text="↑↓ で順番を入れ替え / 各処理と ⑨ 最終(全適用)に反映",
                 font=F6, fg="#666666").pack(anchor="w")

        listwrap = tk.Frame(w)
        listwrap.pack(fill=tk.BOTH, expand=True, padx=8, pady=2)
        canvas = tk.Canvas(listwrap, highlightthickness=0)
        sb = tk.Scrollbar(listwrap, orient=tk.VERTICAL, command=canvas.yview)
        canvas.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        inner = tk.Frame(canvas)
        win_id = canvas.create_window((0, 0), window=inner, anchor=tk.NW)
        inner.bind("<Configure>",
                   lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
        canvas.bind("<Configure>",
                    lambda e: canvas.itemconfig(win_id, width=e.width))

        def _on_wheel(e):
            try:
                canvas.yview_scroll(int(-1 * (e.delta / 120)), "units")
            except Exception:
                pass

        canvas.bind("<Enter>",
                    lambda e: canvas.bind_all("<MouseWheel>", _on_wheel))
        canvas.bind("<Leave>", lambda e: canvas.unbind_all("<MouseWheel>"))

        self.order_canvas = canvas
        self.order_list = inner

        foot = tk.Frame(w)
        foot.pack(fill=tk.X, padx=8, pady=(2, 8))
        b = tk.Button(foot, text="全ON", font=F7, padx=4, pady=1,
                      command=lambda: self.set_all_stages(True))
        b.pack(side=tk.LEFT)
        tip(b, "すべての処理をONにします。")
        b = tk.Button(foot, text="全OFF", font=F7, padx=4, pady=1,
                      command=lambda: self.set_all_stages(False))
        b.pack(side=tk.LEFT, padx=3)
        tip(b, "すべての処理をOFFにします(⑨ 最終は元画像と同じになります)。")
        b = tk.Button(foot, text="初期化", font=F7, padx=4, pady=1,
                      command=self.reset_stages)
        b.pack(side=tk.LEFT)
        tip(b, "初期のON/OFFと実行順に戻します。")
        b = tk.Button(foot, text="閉じる", font=F8B, padx=8, pady=1,
                      command=self.close_order_window)
        b.pack(side=tk.RIGHT)
        tip(b, "このウィンドウを閉じます(設定は保持されます)。")

        self._refresh_order_window()

    def close_order_window(self):
        win = self.order_win
        self.order_win = None
        self.order_frames = {}
        try:
            self.root.unbind_all("<MouseWheel>")
        except Exception:
            pass
        if win is not None:
            try:
                win.destroy()
            except Exception:
                pass

    def _refresh_order_window(self):
        """順序リストを作り直す(番号と並びを常に最新にする)"""
        win = self.order_win
        if win is None:
            return
        try:
            if not win.winfo_exists():
                self.order_win = None
                return
            if not hasattr(self, "order_list") or not self.order_list.winfo_exists():
                return
        except Exception:
            self.order_win = None
            return

        for ch in self.order_list.winfo_children():
            ch.destroy()
        self.order_frames = {}
        for i, st in enumerate(self.stages):
            f = tk.Frame(self.order_list, bd=1, relief=tk.GROOVE)
            f.pack(fill=tk.X, pady=1)
            tk.Label(f, text="{:>2}".format(i + 1), font=F8, width=3,
                     anchor="e").pack(side=tk.LEFT, padx=(4, 2))
            cb = tk.Checkbutton(f, text=st["label"], variable=st["var"],
                                command=self.update_all, font=F8, anchor="w",
                                padx=0, pady=1)
            cb.pack(side=tk.LEFT)
            tip(cb, "チェックした処理が上から順に適用されます。")
            b = tk.Button(f, text="↓", font=F7, width=2, pady=0,
                          command=lambda k=st["key"]: self.move_stage(k, 1))
            b.pack(side=tk.RIGHT, padx=(1, 4))
            tip(b, "この処理を1つ下へ移動します。")
            b = tk.Button(f, text="↑", font=F7, width=2, pady=0,
                          command=lambda k=st["key"]: self.move_stage(k, -1))
            b.pack(side=tk.RIGHT, padx=1)
            tip(b, "この処理を1つ上へ移動します。")
            self.order_frames[st["key"]] = f

    def move_stage(self, key, delta):
        keys = [s["key"] for s in self.stages]
        try:
            i = keys.index(key)
        except ValueError:
            return
        j = i + delta
        if 0 <= j < len(self.stages):
            self.stages[i], self.stages[j] = self.stages[j], self.stages[i]
            self._refresh_order_window()
            self.update_all()

    def reset_stages(self):
        order = list(self.DEFAULT_ORDER)
        self.stages.sort(key=lambda s: order.index(s["key"]))
        for st in self.stages:
            st["var"].set(st["key"] in self.DEFAULT_ON)
        self._refresh_order_window()
        self.update_all()

    def set_all_stages(self, value):
        for st in self.stages:
            st["var"].set(value)
        self._refresh_order_window()
        self.update_all()

    # ------------------------------------------------------------
    # 実行順の要約表示(右パネル)
    # ------------------------------------------------------------
    def _enabled_stage_names(self):
        names = []
        for st in self.stages:
            if st["var"].get():
                parts = st["label"].split(" ", 1)
                names.append(parts[1] if len(parts) > 1 else st["label"])
        return names

    def _update_order_summary(self):
        if not getattr(self, "order_summary", None):
            return
        names = self._enabled_stage_names()
        total = len(self.stages)
        if names:
            txt = "ON {} / {} 件\n{}".format(
                len(names), total, " → ".join(names))
        else:
            txt = "ON 0 / {} 件(処理なし)".format(total)
        try:
            self.order_summary.config(text=txt)
        except Exception:
            pass

    # ------------------------------------------------------------
    # 読み込み
    # ------------------------------------------------------------
    def thumbnail_image(self, im):
        """サムネイル用画像(透明部分を市松模様に合成)"""
        im = im.copy()
        im.thumbnail((96, 96))
        bg = self.checker_bg(im.width, im.height, cell=6)
        return Image.alpha_composite(bg, im)

    def load_folder(self):
        folder = filedialog.askdirectory()
        if not folder:
            return
        self.folder = folder
        for w in self.tinner.winfo_children():
            w.destroy()
        for fn in sorted(os.listdir(folder)):
            if fn.lower().endswith(self.IMAGE_EXTS):
                path = os.path.join(folder, fn)
                try:
                    im = Image.open(path).convert('RGBA')
                    iw, ih = im.size
                    # 透明部分が分かるよう市松模様に合成する
                    tkim = ImageTk.PhotoImage(self.thumbnail_image(im))
                    b = tk.Button(self.tinner, image=tkim,
                                  command=lambda p=path: self.load_file(p))
                    b.image = tkim
                    b.pack(pady=2)
                    tip(b, "{}\n{} × {} px".format(path, iw, ih))
                except Exception:
                    pass

    def load_file(self, path):
        try:
            self.image_path = path
            self.original_image = Image.open(path).convert('RGBA')
            self.original_array = np.array(self.original_image).copy()
            self.picked_color = None
            self._update_title()
            self.update_all()
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    # ------------------------------------------------------------
    # 表示・背景合成
    # ------------------------------------------------------------
    def checker_bg(self, w, h, cell=8):
        yy, xx = np.indices((h, w))
        mask = ((xx // cell + yy // cell) % 2 == 0)
        arr = np.where(mask[..., None],
                       np.array([240, 240, 240], dtype=np.uint8),
                       np.array([200, 200, 200], dtype=np.uint8))
        return Image.fromarray(arr).convert('RGBA')

    def get_background(self, nw, nh):
        mode = self.bg_mode.get()
        if mode == "color":
            r, g, b = self.bg_color
            arr = np.zeros((nh, nw, 4), dtype=np.uint8)
            arr[..., 0] = r
            arr[..., 1] = g
            arr[..., 2] = b
            arr[..., 3] = 255
            return Image.fromarray(arr, 'RGBA')
        elif mode == "image" and self.bg_image_pil is not None:
            bg = self.bg_image_pil
            bg_w, bg_h = bg.size
            if bg_w > 0 and bg_h > 0:
                sc = max(nw / bg_w, nh / bg_h)
                rw, rh = max(1, int(bg_w * sc)), max(1, int(bg_h * sc))
                bg_resized = bg.resize((rw, rh), Image.LANCZOS)
                left_x = (rw - nw) // 2
                top_y = (rh - nh) // 2
                bg_cropped = bg_resized.crop((left_x, top_y, left_x + nw, top_y + nh))
                return bg_cropped.convert('RGBA')
            return self.checker_bg(nw, nh)
        else:
            return self.checker_bg(nw, nh)

    def set_bg_color(self, r, g, b):
        self.bg_color = (r, g, b)
        self.bg_mode.set("color")
        try:
            self.bg_swatch.config(bg='#{:02x}{:02x}{:02x}'.format(r, g, b))
        except Exception:
            pass
        self.update_all()

    def choose_bg_color(self):
        c = colorchooser.askcolor(title="背景色の選択")
        if c[0]:
            r, g, b = map(int, c[0])
            self.set_bg_color(r, g, b)

    def load_bg_image(self):
        fn = filedialog.askopenfilename(
            title="背景画像を選択",
            filetypes=[("画像ファイル", "*.png;*.jpg;*.jpeg;*.bmp;*.webp;*.tif;*.tiff"),
                       ("すべてのファイル", "*.*")]
        )
        if fn:
            try:
                self.bg_image_path = fn
                self.bg_image_pil = Image.open(fn).convert('RGBA')
                self.bg_mode.set("image")
                self.update_all()
            except Exception as e:
                messagebox.showerror("背景画像エラー", str(e))

    def clear_bg_image(self):
        self.bg_image_path = ""
        self.bg_image_pil = None
        self.bg_mode.set("checker")
        self.update_all()

    def set_rgb_target(self, r, g, b):
        self.rgb_target_color = (r, g, b)
        try:
            self.rgb_swatch.config(bg='#{:02x}{:02x}{:02x}'.format(r, g, b))
        except Exception:
            pass
        self.update_all()

    def choose_rgb_target(self):
        c = colorchooser.askcolor(title="透明部分のRGB色を選択")
        if c[0]:
            r, g, b = map(int, c[0])
            self.set_rgb_target(r, g, b)

    # ------------------------------------------------------------
    # 設定ファイル(パラメータの保存・読込)
    # ------------------------------------------------------------
    def collect_settings(self):
        """現在のパラメータ・実行順・ON/OFF を dict にまとめる"""
        params = {}
        for key in self.PARAM_KEYS:
            var = getattr(self, key, None)
            if var is None:
                continue
            try:
                params[key] = var.get()
            except Exception:
                pass
        params["rgb_target_color"] = [int(c) for c in self.rgb_target_color]
        return {
            "version": self.SETTINGS_VERSION,
            "stages": [{"key": s["key"], "on": bool(s["var"].get())}
                       for s in self.stages],
            "params": params,
        }

    def apply_settings(self, data):
        """collect_settings() の内容を適用する(未知の項目は無視)"""
        if not isinstance(data, dict):
            raise ValueError("設定ファイルの形式が不正です")

        # 実行順と ON/OFF
        known = {s["key"]: s for s in self.stages}
        ordered = []
        for item in data.get("stages") or []:
            if not isinstance(item, dict):
                continue
            st = known.pop(item.get("key"), None)
            if st is None:
                continue
            try:
                st["var"].set(bool(item.get("on", True)))
            except Exception:
                pass
            ordered.append(st)
        for key in self.DEFAULT_ORDER:
            st = known.pop(key, None)
            if st is not None:
                ordered.append(st)
        ordered.extend(known.values())
        if ordered:
            self.stages = ordered

        # パラメータ
        params = data.get("params") or {}
        for key, value in params.items():
            var = getattr(self, key, None)
            if not isinstance(var, tk.Variable):
                continue
            try:
                cur = var.get()
                if isinstance(cur, bool):
                    var.set(bool(value))
                elif isinstance(cur, int):
                    var.set(int(round(float(value))))
                elif isinstance(cur, float):
                    var.set(float(value))
                else:
                    var.set(str(value))
            except Exception:
                pass
        col = params.get("rgb_target_color")
        if isinstance(col, (list, tuple)) and len(col) == 3:
            try:
                self.set_rgb_target(*[int(c) for c in col])
            except Exception:
                pass

        if self.semi_mode.get() not in ("transparent", "opaque"):
            self.semi_mode.set("transparent")
        self._update_semi_labels()
        self._refresh_order_window()
        self.update_all()

    def _flash_button(self, btn, text):
        try:
            orig = btn.cget("text")
            btn.config(text=text)

            def _restore():
                try:
                    if btn.winfo_exists():
                        btn.config(text=orig)
                except Exception:
                    pass

            self.root.after(1200, _restore)
        except Exception:
            pass

    def save_settings(self):
        fn = filedialog.asksaveasfilename(
            title="設定の保存",
            defaultextension=".json",
            filetypes=[("設定ファイル", "*.json"), ("すべてのファイル", "*.*")],
            initialfile="alphaviewer_settings.json")
        if not fn:
            return
        try:
            with open(fn, "w", encoding="utf-8") as fh:
                json.dump(self.collect_settings(), fh,
                          ensure_ascii=False, indent=2)
            self._flash_button(self.cfg_save_btn, "保存✓")
        except Exception as e:
            messagebox.showerror("設定の保存", str(e))

    def load_settings(self):
        fn = filedialog.askopenfilename(
            title="設定の読込",
            filetypes=[("設定ファイル", "*.json"), ("すべてのファイル", "*.*")])
        if not fn:
            return
        try:
            with open(fn, "r", encoding="utf-8") as fh:
                data = json.load(fh)
            self.apply_settings(data)
            self._flash_button(self.cfg_load_btn, "読込✓")
        except Exception as e:
            messagebox.showerror("設定の読込", "読み込めません:\n" + str(e))

    def _apply_rgb_change(self, arr):
        """本来は透明な部分(α<=閾値)のRGB値を、α(透明)はそのまま維持して変更する"""
        if not self.rgb_enable.get():
            return arr
        out = arr.copy()
        th = int(self.rgb_alpha_th.get())
        mask = (out[:, :, 3] <= th)
        if not mask.any():
            return out
        r, g, b = self.rgb_target_color
        out[mask, 0] = r
        out[mask, 1] = g
        out[mask, 2] = b
        # out[:, :, 3] は一切変更しない!透明のまま!
        return out

    def show(self, cv, pil_img):
        """キャンバスへ描画(表示専用。保存用画像には一切影響しない)"""
        if pil_img is None:
            cv.delete("all")
            return
        cv.delete("all")
        cw = cv.winfo_width()
        ch = cv.winfo_height()
        if cw < 5 or ch < 5:
            return
        iw, ih = pil_img.size
        sc = min(cw / iw, ch / ih) if iw > 0 and ih > 0 else 1
        nw, nh = max(1, int(iw * sc)), max(1, int(ih * sc))
        src = pil_img
        if self._rgb_preview_active():
            # 縮小前に塗る(PILのresizeはα=0のRGBを0にしてしまうため)
            src = self._rgb_preview_image(src)
        r = src.resize((nw, nh), Image.LANCZOS).convert('RGBA')
        bg = self.get_background(nw, nh)
        comp = Image.alpha_composite(bg, r)
        tkimg = ImageTk.PhotoImage(comp)
        cv.create_image(cw // 2, ch // 2, image=tkimg)
        cv.image = tkimg

    def _rgb_preview_active(self):
        try:
            return bool(self.rgb_preview.get() and self.rgb_enable.get())
        except Exception:
            return False

    def _rgb_preview_image(self, img):
        """透明画素(α<=閾値)のαを表示用に255へ。RGBは既に変更済みのものを使う。"""
        try:
            if img.mode != 'RGBA':
                return img
            th = int(self.rgb_alpha_th.get())
            a = np.array(img.getchannel('A'))
            m = a <= th
            if not m.any():
                return img
            out = img.copy()
            out.putalpha(Image.fromarray(np.where(m, 255, a).astype(np.uint8)))
            return out
        except Exception:
            return img


    # ------------------------------------------------------------
    # 更新(スライダー連続操作はスロットル)
    # ------------------------------------------------------------
    def _param_changed(self, stage_key=None):
        if stage_key:
            for st in self.stages:
                if st["key"] == stage_key:
                    if not st["var"].get():
                        st["var"].set(True)
                    break
        self.schedule_update()

    def schedule_update(self, *args):
        if self._update_job is not None:
            return
        self._update_job = self.root.after(16, self._run_scheduled_update)

    def _run_scheduled_update(self):
        self._update_job = None
        self.update_all()

    # ------------------------------------------------------------
    # 各処理(入力配列は変更せず、常に新しい配列を返す)
    # ------------------------------------------------------------
    def _stage_semi(self, arr):
        out = arr.copy()
        st = int(self.semi_thresh.get())
        m = (out[:, :, 3] > 0) & (out[:, :, 3] <= st)
        if self.semi_mode.get() == "opaque":
            out[m, 3] = 255
        else:
            out[m, 3] = 0
        return out

    def _stage_morph(self, arr):
        out = arr.copy()
        h, w = out.shape[:2]
        mask = out[:, :, 3] > 0
        binm = mask.astype(np.uint8)
        k = int(self.morph_k.get())
        if k % 2 == 0:
            k += 1
        it = int(self.morph_iter.get())
        dilate = self.morph_op.get() == "dilate"
        if HAS_SCIPY:
            stc = np.ones((k, k))
            if dilate:
                bm = ndimage.binary_dilation(binm, structure=stc, iterations=it)
            else:
                bm = ndimage.binary_erosion(binm, structure=stc, iterations=it)
            bm = bm.astype(bool)
        else:
            bm = mask.copy()
            rad = k // 2
            for _ in range(it):
                pad = np.pad(bm, rad, mode="constant", constant_values=False)
                if dilate:
                    res = np.zeros_like(bm)
                    for dy in range(k):
                        for dx in range(k):
                            res |= pad[dy:dy + h, dx:dx + w]
                else:
                    res = np.ones_like(bm)
                    for dy in range(k):
                        for dx in range(k):
                            res &= pad[dy:dy + h, dx:dx + w]
                bm = res

        if dilate:
            # 膨張で新たに覆われる画素は最寄りの不透明画素の色をコピー
            added = bm & ~mask
            if added.any() and mask.any():
                if HAS_SCIPY:
                    try:
                        _, (iy, ix) = ndimage.distance_transform_edt(
                            ~mask, return_indices=True)
                        out[added, :3] = out[iy[added], ix[added], :3]
                        out[added, 3] = out[iy[added], ix[added], 3]
                    except Exception:
                        out[added, 3] = 255
                else:
                    out[added, 3] = 255
                if self._generated is None or self._generated.shape != added.shape:
                    self._generated = added.copy()
                else:
                    self._generated |= added
        out[~bm, 3] = 0
        return out

    def _stage_rowcol(self, arr):
        out = arr.copy()
        h, w = out.shape[:2]
        if h == 0 or w == 0:
            return out
        th = int(self.rowcol_alpha.get())
        rp = int(self.row_pct.get())
        rcnt = int(self.row_cnt.get())
        cp = int(self.col_pct.get())
        ccnt = int(self.col_cnt.get())

        def eff_alpha():
            """判定用のα。膨張で生成した画素は「透明」として扱う。"""
            a = out[:, :, 3]
            gen = self._generated
            if gen is not None and gen.shape == a.shape:
                a = a.copy()
                a[gen] = 0
            return a

        # 行判定
        a = eff_alpha()
        z = (a <= th)      # 透明(背景)判定
        op = ~z            # 不透明(物体・ノイズ)判定
        pct = z.sum(axis=1) / w * 100.0
        # 不透明画素の連続している数(塊/ランの個数)
        runs_r = op[:, 0].astype(np.int32)
        if w > 1:
            runs_r += (np.diff(op.astype(np.int8), axis=1) == 1).sum(axis=1)
        clear_r = (pct >= rp) & (runs_r <= rcnt)
        nrow = int(np.count_nonzero(clear_r))
        out[clear_r, :, 3] = 0

        # 行を消した後のαで列を判定
        a = eff_alpha()
        z = (a <= th)
        op = ~z
        pct = z.sum(axis=0) / h * 100.0
        # 列の不透明画素の連続している数(塊/ランの個数)
        runs_c = op[0, :].astype(np.int32)
        if h > 1:
            runs_c += (np.diff(op.astype(np.int8), axis=0) == 1).sum(axis=0)
        clear_c = (pct >= cp) & (runs_c <= ccnt)
        ncol = int(np.count_nonzero(clear_c))
        out[:, clear_c, 3] = 0

        self._rc_info = (nrow, ncol)
        return out

    def _stage_color(self, arr):
        out = arr.copy()
        return self._apply_color_clear(out)

    def _stage_edge_clean(self, arr):
        out = arr.copy()
        h, w = out.shape[:2]
        if h == 0 or w == 0:
            return out
        th = int(self.edge_alpha.get())
        # 画像全体の面積に対する割合(%)で上限を決める
        pct = max(0.0, float(self.edge_pct.get()))
        max_size = pct / 100.0 * (h * w)
        a = out[:, :, 3]
        mask = (a > th)
        if not mask.any():
            self._edge_info = (0, 0)
            return out

        if HAS_SCIPY:
            stc = np.ones((3, 3), bool)
            lbl, num = ndimage.label(mask, structure=stc)
            sizes = ndimage.sum(mask, lbl, range(1, num + 1))
        else:
            lbl = np.zeros((h, w), dtype=np.int32)
            cur_lbl = 0
            sizes_list = []
            for y in range(h):
                for x in range(w):
                    if mask[y, x] and lbl[y, x] == 0:
                        cur_lbl += 1
                        q = deque([(y, x)])
                        lbl[y, x] = cur_lbl
                        sz = 0
                        while q:
                            cy, cx = q.popleft()
                            sz += 1
                            for dy in (-1, 0, 1):
                                for dx in (-1, 0, 1):
                                    ny, nx = cy + dy, cx + dx
                                    if 0 <= ny < h and 0 <= nx < w:
                                        if mask[ny, nx] and lbl[ny, nx] == 0:
                                            lbl[ny, nx] = cur_lbl
                                            q.append((ny, nx))
                        sizes_list.append(sz)
            num = cur_lbl
            sizes = np.array(sizes_list)

        edge_labels = set()
        if self.edge_top.get():
            edge_labels.update(lbl[0, :])
        if self.edge_bottom.get():
            edge_labels.update(lbl[-1, :])
        if self.edge_left.get():
            edge_labels.update(lbl[:, 0])
        if self.edge_right.get():
            edge_labels.update(lbl[:, -1])
        edge_labels.discard(0)

        rem_cnt = 0
        rem_px = 0
        for comp_id in edge_labels:
            sz = sizes[comp_id - 1]
            if sz <= max_size:
                rem_cnt += 1
                rem_px += int(sz)
                out[lbl == comp_id, 3] = 0

        self._edge_info = (rem_cnt, rem_px)
        return out

    def _stage_long_feather(self, arr):
        out = arr.copy()
        h, w = out.shape[:2]
        if h == 0 or w == 0:
            return out

        inner = int(self.lf_inner.get())
        outer = int(self.lf_outer.get())
        curve = max(10, int(self.lf_curve.get())) / 100.0
        smooth = self.lf_smooth.get()
        bleed = self.lf_bleed.get()

        if inner <= 0 and outer <= 0:
            return out

        a = out[:, :, 3]
        mask = a > 0
        if not mask.any() or mask.all():
            return out

        if HAS_SCIPY:
            d_in = ndimage.distance_transform_edt(mask)
            d_out = ndimage.distance_transform_edt(~mask)
            signed_dist = d_in - d_out

            if outer > 0 and bleed:
                bleed_region = (~mask) & (d_out <= outer + 2)
                if bleed_region.any():
                    try:
                        _, (iy, ix) = ndimage.distance_transform_edt(~mask, return_indices=True)
                        out[bleed_region, :3] = out[iy[bleed_region], ix[bleed_region], :3]
                    except Exception:
                        pass

            total_range = inner + outer
            if total_range <= 0:
                return out

            t = np.clip((signed_dist + outer) / total_range, 0.0, 1.0)
            if smooth:
                t = t * t * (3.0 - 2.0 * t)
            if abs(curve - 1.0) > 1e-3:
                t = np.power(t, curve)

            new_a = np.clip(t * 255.0, 0, 255).astype(np.uint8)
            feather_zone = (signed_dist >= -outer) & (signed_dist <= inner)
            out[signed_dist < -outer, 3] = 0
            out[signed_dist > inner, 3] = np.maximum(out[signed_dist > inner, 3], new_a[signed_dist > inner])
            out[feather_zone, 3] = new_a[feather_zone]
        else:
            rad = max(1, (inner + outer) // 2)
            pil_a = Image.fromarray(out[:, :, 3]).filter(ImageFilter.GaussianBlur(rad))
            out[:, :, 3] = np.array(pil_a)

        return out

    def _apply_stage(self, key, arr):
        fn = getattr(self, "_stage_" + key, None)
        if fn is None:
            return arr
        try:
            return fn(arr)
        except Exception:
            return arr

    # ------------------------------------------------------------
    # 色クリア(角フラッド / 取得色)
    # ------------------------------------------------------------
    def _corner_flood_clear(self, arr, sy, sx, th):
        h, w = arr.shape[:2]
        if not (0 <= sy < h and 0 <= sx < w):
            return
        tr = int(arr[sy, sx, 0])
        tg = int(arr[sy, sx, 1])
        tb = int(arr[sy, sx, 2])
        rgb = arr[..., :3].astype(np.int16)
        mask = ((np.abs(rgb[..., 0] - tr) <= th) &
                (np.abs(rgb[..., 1] - tg) <= th) &
                (np.abs(rgb[..., 2] - tb) <= th))
        if not mask[sy, sx]:
            return
        if HAS_SCIPY:
            lbl, _ = ndimage.label(mask)
            tgt = lbl[sy, sx]
            if tgt:
                arr[lbl == tgt, 3] = 0
            return
        vis = np.zeros((h, w), dtype=bool)
        dq = deque([(sy, sx)])
        vis[sy, sx] = True
        while dq:
            cy, cx = dq.popleft()
            if not mask[cy, cx]:
                continue
            arr[cy, cx, 3] = 0
            for ny, nx in ((cy + 1, cx), (cy - 1, cx), (cy, cx + 1), (cy, cx - 1)):
                if 0 <= ny < h and 0 <= nx < w and not vis[ny, nx]:
                    vis[ny, nx] = True
                    dq.append((ny, nx))

    def _apply_color_clear(self, arr):
        h, w = arr.shape[:2]
        if h == 0 or w == 0:
            return arr
        th = int(self.color_thresh.get())
        seeds = []
        if self.corner_tl.get():
            seeds.append((0, 0))
        if self.corner_tr.get():
            seeds.append((0, w - 1))
        if self.corner_bl.get():
            seeds.append((h - 1, 0))
        if self.corner_br.get():
            seeds.append((h - 1, w - 1))
        if seeds:
            done = set()
            for sy, sx in seeds:
                if (sy, sx) in done:
                    continue
                done.add((sy, sx))
                self._corner_flood_clear(arr, sy, sx, th)
        elif self.picked_color is not None:
            pr, pg, pb = self.picked_color
            rgb = arr[..., :3].astype(np.int16)
            mask = ((np.abs(rgb[..., 0] - pr) <= th) &
                    (np.abs(rgb[..., 1] - pg) <= th) &
                    (np.abs(rgb[..., 2] - pb) <= th))
            arr[mask, 3] = 0
        return arr

    # ------------------------------------------------------------
    # パイプライン(9枚の ndarray を返す)
    # ------------------------------------------------------------
    def _run_stages(self, arr, skip=()):
        """arr から出発して、ON の処理を順に適用する。
        戻り値: (最終状態, {キー: その処理を終えた時点の状態})
        """
        self._generated = None
        state = arr
        snaps = {}
        for st in self.stages:
            key = st["key"]
            if st["var"].get() and key not in skip:
                state = self._apply_stage(key, state)
            snaps[key] = state
        return state, snaps

    def _is_stage_on(self, key):
        for st in self.stages:
            if st["key"] == key:
                return st["var"].get()
        return False

    def compute_pipeline(self, arr_in):
        arr = np.array(arr_in, copy=True)
        panels = [None] * 9

        # パネル 0: 元画像
        panels[0] = arr.copy()
        panels[0] = self._apply_rgb_change(panels[0])
        # パネル 1: 透明検出(α)
        a0 = arr[:, :, 3]
        panels[1] = np.dstack([a0, a0, a0]).astype(np.uint8)

        # 基礎ベース処理 (半透明 -> 膨張/縮小)
        base = arr.copy()
        if self._is_stage_on("semi"):
            base = self._apply_stage("semi", base)
        panels[2] = base.copy()  # パネル 2: 3. 半透明→透明
        panels[2] = self._apply_rgb_change(panels[2])

        if self._is_stage_on("morph"):
            base = self._apply_stage("morph", base)
        panels[3] = base.copy()  # パネル 3: 4. 膨張/縮小
        panels[3] = self._apply_rgb_change(panels[3])

        # --- 各処理の分離・非反映化プレビュー ---

        # パネル 4: 5. 行列クリーン (baseに対して適用。端部小物体除去・ロングフェザー・色クリアは反映しない)
        self._rc_info = (0, 0)
        if self._is_stage_on("rowcol"):
            panels[4] = self._apply_stage("rowcol", base.copy())
        else:
            panels[4] = base.copy()
        rc_info = self._rc_info
        panels[4] = self._apply_rgb_change(panels[4])

        # パネル 5: 6. 色クリア (baseに対して適用。行列クリーン・端部小物体除去・ロングフェザーは反映しない)
        if self._is_stage_on("color"):
            panels[5] = self._apply_stage("color", base.copy())
        else:
            panels[5] = base.copy()
        panels[5] = self._apply_rgb_change(panels[5])

        # パネル 6: 7. 端部小物体除去 (行列クリーンは反映しない!baseに対して適用)
        self._edge_info = (0, 0)
        if self._is_stage_on("edge_clean"):
            panels[6] = self._apply_stage("edge_clean", base.copy())
        else:
            panels[6] = base.copy()
        edge_info = self._edge_info
        panels[6] = self._apply_rgb_change(panels[6])

        # パネル 7: 8. ロングフェザー
        if self._is_stage_on("long_feather"):
            panels[7] = self._apply_stage("long_feather", base.copy())
        else:
            panels[7] = base.copy()
        panels[7] = self._apply_rgb_change(panels[7])

        # パネル 8: 9. 最終 (全適用: 実行順にすべてのON処理を適用)
        self._rc_info = (0, 0)
        self._edge_info = (0, 0)
        final_state, _ = self._run_stages(arr)
        # 背景透明のままRGB変更(する/しない)
        final_state = self._apply_rgb_change(final_state)
        panels[8] = final_state

        # ステータス情報を行列クリーン・端部小物体除去から正しく保持
        self._rc_info = rc_info
        self._edge_info = edge_info
        self._generated = None

        return panels

    def update_all(self, *args):
        self._update_order_summary()
        if self.original_array is None:
            return
        panels = self.compute_pipeline(self.original_array)
        # 保存・一括処理の元データ(表示合成とは無関係に保持する)
        self.panels_raw = panels

        self.img_orig = Image.fromarray(panels[0])
        self.img_trans_detect = Image.fromarray(panels[1])
        self.img_semi = Image.fromarray(panels[2])
        self.img_morph = Image.fromarray(panels[3])
        self.img_rowcol = Image.fromarray(panels[4])
        self.img_color = Image.fromarray(panels[5])
        self.img_edge = Image.fromarray(panels[6])
        self.img_alpha = Image.fromarray(panels[7])
        self.img_final = Image.fromarray(panels[8])

        for i, img in enumerate(self._panel_images()):
            self.show(self.cvs[i], img)

        try:
            self.rc_status.config(
                text="クリア: 行{} / 列{}".format(self._rc_info[0],
                                                 self._rc_info[1]))
        except Exception:
            pass
        try:
            self.edge_status.config(
                text="除去: {}個 ({} px)".format(self._edge_info[0],
                                                self._edge_info[1]))
        except Exception:
            pass
        try:
            if self.rgb_enable.get():
                th = int(self.rgb_alpha_th.get())
                n = int((self.panels_raw[8][..., 3] <= th).sum())
                self.rgb_status.config(text="{}px".format(n))
            else:
                self.rgb_status.config(text="OFF")
        except Exception:
            pass

        self._schedule_alpha_refresh()

    # ------------------------------------------------------------
    # 色の取得
    # ------------------------------------------------------------
    def click_pick_color(self, event):
        if self.original_array is None:
            return
        cv = event.widget
        cw = cv.winfo_width()
        ch = cv.winfo_height()
        if cw < 5 or ch < 5:
            return
        img = self.original_image
        iw, ih = img.size
        sc = min(cw / iw, ch / ih) if iw > 0 and ih > 0 else 1
        nw, nh = max(1, int(iw * sc)), max(1, int(ih * sc))
        cx = event.x - (cw - nw) // 2
        cy = event.y - (ch - nh) // 2
        ix = int(cx / sc) if sc else 0
        iy = int(cy / sc) if sc else 0
        if 0 <= ix < iw and 0 <= iy < ih:
            r, g, b, a = self.original_array[iy, ix]
            self.picked_color = (int(r), int(g), int(b))
            self._clear_corners()
            self.update_all()

    def _clear_corners(self):
        for v in (self.corner_tl, self.corner_tr, self.corner_bl, self.corner_br):
            v.set(False)

    def clear_alpha_all(self):
        if self.original_array is None:
            return
        if not messagebox.askyesno(
                "不透明化",
                "読み込んだ元画像のαをすべて255にします。\n"
                "この操作は元に戻せません(再読み込みしてください)。\n\n"
                "実行しますか?"):
            return
        arr = self.original_array.copy()
        arr[:, :, 3] = 255
        self.original_array = arr
        self.original_image = Image.fromarray(arr)
        self.picked_color = None
        self.update_all()

    # ------------------------------------------------------------
    # 保存
    # ------------------------------------------------------------
    def _panel_images(self):
        return [
            self.img_orig,
            self.img_trans_detect,
            self.img_semi,
            self.img_morph,
            self.img_rowcol,
            self.img_color,
            self.img_edge,
            self.img_alpha,
            self.img_final,
        ]

    def _export_image(self, panels, idx):
        """保存用画像。通常保存・一括処理で共通(内容が完全に一致する)。"""
        if panels is None or idx < 0 or idx >= len(panels):
            return None
        arr = np.ascontiguousarray(panels[idx])
        img = Image.fromarray(arr)
        if self.bg_mode.get() != "raw":
            # 表示と同じ背景合成を「原寸」で行う(縮小しない)
            bg = self.get_background(img.size[0], img.size[1])
            img = Image.alpha_composite(bg, img.convert('RGBA'))
        return img

    def save_panel(self, idx):
        img = None
        try:
            img = self._export_image(self.panels_raw, idx)
        except Exception:
            img = None
        if img is None:
            messagebox.showinfo("保存", "画像が読み込まれていません。")
            return

        if self.image_path:
            base = os.path.splitext(os.path.basename(self.image_path))[0]
        else:
            base = "image"
        name = self.PANEL_NAMES[idx]

        fn = filedialog.asksaveasfilename(
            defaultextension=".png",
            filetypes=[("PNG", "*.png")],
            initialfile="{}_{}.png".format(base, name),
        )
        if not fn:
            return
        try:
            img.save(fn)
    #        messagebox.showinfo("保存", "保存しました:\n" + fn)
        except Exception as e:
            messagebox.showerror("保存", str(e))

    # ------------------------------------------------------------
    # フォルダ一括処理
    # ------------------------------------------------------------
    def batch_process(self):
        if self.batch_running:
            return
        if not self.folder or not os.path.isdir(self.folder):
            messagebox.showinfo(
                "バッチ処理",
                "先に「フォルダ読み込み」でフォルダを指定してください。")
            return

        files = [fn for fn in sorted(os.listdir(self.folder))
                 if fn.lower().endswith(self.IMAGE_EXTS)]
        if not files:
            messagebox.showinfo("バッチ処理", "対象画像がありません。")
            return

        target = self.batch_target.get()
        if target == "全9種":
            targets = list(range(9))
        else:
            targets = [self.BATCH_TARGETS.get(target, 8)]

        outdir = os.path.join(self.folder, "batch_output")
        try:
            os.makedirs(outdir, exist_ok=True)
        except Exception as e:
            messagebox.showerror("バッチ処理",
                                 "出力フォルダを作成できません:\n" + str(e))
            return

        mode_name = {
            "raw": "αを保持(背景透明のまま)",
            "color": "背景色と合成",
            "image": "背景画像と合成",
            "checker": "市松模様と合成",
        }.get(self.bg_mode.get(), "αを保持")
        if not messagebox.askyesno(
                "バッチ処理",
                "対象: {} ファイル\n内容: {}\n保存: {}\n出力: {}\n\n"
                "実行しますか?".format(len(files), target, mode_name, outdir)):
            return

        self.batch_running = True
        self.batch_btn.config(state=tk.DISABLED)
        ok = 0
        errors = []
        try:
            for i, fn in enumerate(files, 1):
                self.batch_status.config(text="{}/{}".format(i, len(files)))
                self.root.update_idletasks()
                try:
                    im = Image.open(os.path.join(self.folder, fn)).convert("RGBA")
                    panels = self.compute_pipeline(np.array(im))

                    base = os.path.splitext(fn)[0]
                    for idx in targets:
                        out = os.path.join(
                            outdir, "{}_{}.png".format(base, self.PANEL_NAMES[idx]))
                        exp = self._export_image(panels, idx)
                        if exp is None:
                            continue
                        exp.save(out)
                    ok += 1
                except Exception as e:
                    errors.append("{}: {}".format(fn, e))
        finally:
            self.batch_running = False
            self.batch_btn.config(state=tk.NORMAL)
            self.batch_status.config(
                text="成功{} / 失敗{}".format(ok, len(errors)))
            try:
                self.update_all()
            except Exception:
                pass

        msg = "完了しました。\n成功: {} / 失敗: {}\n出力先:\n{}".format(
            ok, len(errors), outdir)
        if errors:
            msg += "\n\n--- エラー ---\n" + "\n".join(errors[:10])
            if len(errors) > 10:
                msg += "\n... 他 {} 件".format(len(errors) - 10)
            messagebox.showwarning("バッチ処理", msg)
        else:
            messagebox.showinfo("バッチ処理", msg)


class DragDropHandler:
    def __init__(self, widget, callback):
        self.widget = widget
        self.callback = callback
        if not DND_AVAILABLE:
            return
        try:
            if hasattr(widget, 'drop_target_register'):
                widget.drop_target_register(DND_FILES)
                widget.dnd_bind('<<Drop>>', self.drop)
        except Exception:
            pass

    def drop(self, event):
        try:
            items = self.widget.tk.splitlist(event.data)
        except Exception:
            items = [str(getattr(event, "data", ""))]
        for fn in items:
            fn = str(fn).strip()
            if os.path.isfile(fn):
                try:
                    self.callback(fn)
                except Exception:
                    pass
                break


def main():
    if DND_AVAILABLE:
        try:
            root = TkinterDnD.Tk()
        except Exception:
            root = tk.Tk()
    else:
        root = tk.Tk()
    AlphaViewerNew(root)
    root.mainloop()


if __name__ == '__main__':
    main()

Python

Posted by eightban