0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HEICをPNGとかJPEGに変換するアプリ

Last updated at Posted at 2025-09-21

✅背景

  • iPhoneや一部のAndroid端末は標準でHEIC保存
    → でも世の中のWebサービスやアプリはまだ JPEGPNG前提 のものが多い。

  • 結果として、サイトに画像をアップロードできない、アプリで開けないという めんどう が発生。

📊そもそもHEIF(.HEIC)とは?

  • HEIF (High Efficiency Image File Format)
    • ISO(国際標準化機構)が規格化した画像フォーマット。
    • 動画圧縮技術 HEVC (H.265) を応用しており、従来のJPEGよりも 高画質・高圧縮率 を実現。
    • 拡張子は主に .heif や .heic。

  • HEIC (High Efficiency Image Container)
    • Appleが主に採用している HEIFの実装形態のひとつ。
    • iOS 11以降のiPhoneやiPadで標準的に使われている。
    • 実際には「HEIFのファイル=HEIC」という理解でOK。

💡 暫定的な解決アプローチ

めんどうだから、自動変換アプリをつくったらいいじゃね?
どうせなら PNG または JPEG に一括変換するシンプルなデスクトップアプリがいいな・・・
ということで、以下コードです。

Python
# heic_batch_converter_debug.py
# -*- coding: utf-8 -*-
"""
HEIC/HEIF を PNG / JPEG に一括変換する Tkinter GUI(診断ログ強化版)
- 例外のフルスタックをGUIログへ出力
- 起動時に環境情報(Pillow / pillow-heif / OS / HEIF対応状況)を表示
"""

import sys
import platform
import threading
import traceback
from pathlib import Path
from typing import List, Optional

# --- 画像処理 / HEIF対応 ---
from PIL import Image, ImageOps, UnidentifiedImageError, features  # Pillow本体
import pillow_heif  # HEIC/HEIFデコーダ(フォールバックにも使用)
pillow_heif.register_heif_opener()  # Pillow側のオープナー登録(効かない環境もあるが害はない)
Image.MAX_IMAGE_PIXELS = None       # 超高解像度でも警告で止まらないように

# pillow-heif の情報を環境ダンプ用に取得
pillow_heif_loaded = True
heif_register_ok = True
try:
    ver = getattr(pillow_heif, "__version__", "unknown")
    compilers = getattr(pillow_heif, "compiled_with", lambda: None)()
    heif_summary_text = f"pillow-heif version: {ver}; compiled_with: {compilers}"
except Exception:
    heif_summary_text = None

# --- GUI部品 ---
import tkinter as tk
from tkinter import filedialog, ttk, messagebox

# --- DnD(任意) ---
DND_AVAILABLE = False
try:
    from tkinterdnd2 import DND_FILES, TkinterDnD
    DND_AVAILABLE = True
except Exception:
    DND_AVAILABLE = False

SUPPORTED_EXTS = {".heic", ".heif", ".HEIC", ".HEIF"}


# =============================================================================
# ユーティリティ
# =============================================================================

def collect_heic_files(paths: List[Path]) -> List[Path]:
    """入力パス(ファイル/フォルダ混在)から HEIC/HEIF を収集(順序維持・重複排除)"""
    files: List[Path] = []
    for p in paths:
        p = Path(p)
        if p.is_dir():
            for ext in SUPPORTED_EXTS:
                files.extend(p.rglob(f"*{ext}"))
        elif p.is_file() and p.suffix in SUPPORTED_EXTS:
            files.append(p)
    seen = set()
    uniq: List[Path] = []
    for f in files:
        if f not in seen:
            uniq.append(f)
            seen.add(f)
    return uniq


def safe_output_path(src: Path, out_dir: Optional[Path], out_ext: str) -> Path:
    """出力先パスを決定(同名があれば _1, _2… を付与)"""
    base = src.stem
    dir_ = out_dir if out_dir else src.parent
    candidate = dir_ / f"{base}{out_ext}"
    idx = 1
    while candidate.exists():
        candidate = dir_ / f"{base}_{idx}{out_ext}"
        idx += 1
    return candidate


def open_image_any(path: Path):
    """HEIC/HEIF を“必ず” Pillow Image として開く。
    1) まず Image.open を試す(成功時は EXIF/ICC も取得)
    2) 失敗したら pillow_heif.open_heif → Image.frombytes で PIL 画像化
    戻り値: (pil_image, exif_bytes or None, icc_bytes or None)
    """
    # 通常ルート(環境によっては HEIC で失敗する)
    try:
        im = Image.open(path)
        exif_bytes = im.info.get("exif")
        icc = im.info.get("icc_profile")
        return im, exif_bytes, icc
    except UnidentifiedImageError:
        pass  # フォールバックへ

    # フォールバック:pillow-heif で直にHEIFを開く
    hf = pillow_heif.open_heif(path)

    # EXIFの取り出し(あれば)
    exif_bytes = None
    try:
        for md in getattr(hf, "metadata", []) or []:
            # 例: {'type': 'Exif', 'data': b'...'}
            if md.get("type", "").lower() == "exif" and md.get("data"):
                exif_bytes = md["data"]
                break
    except Exception:
        pass

    # ICCプロファイル(あれば)
    icc = None
    try:
        cp = getattr(hf, "color_profile", None)  # 例: {'type':'icc','icc_profile': b'...'}
        if isinstance(cp, dict) and cp.get("icc_profile"):
            icc = cp["icc_profile"]
    except Exception:
        pass

    # Pillow Image を生で構築
    im = Image.frombytes(hf.mode, hf.size, hf.data, "raw")
    return im, exif_bytes, icc


# =============================================================================
# 変換ワーカースレッド
# =============================================================================

class ConverterThread(threading.Thread):
    """変換をバックグラウンドで実行(UI操作はコールバック経由でメインスレッドへ)"""
    def __init__(self, files: List[Path], out_dir: Optional[Path], fmt: str,
                 jpg_quality: int, keep_exif: bool, progress_cb, log_cb, done_cb):
        super().__init__(daemon=True)
        self.files = files
        self.out_dir = out_dir
        self.fmt = fmt
        self.jpg_quality = jpg_quality
        self.keep_exif = keep_exif
        self.progress_cb = progress_cb
        self.log_cb = log_cb
        self.done_cb = done_cb

    def run(self):
        total = len(self.files)
        count = 0
        for src in self.files:
            abs_src = str(Path(src).resolve())
            try:
                self.log_cb(f"… 開始: {abs_src}")
                out_ext = ".png" if self.fmt == "PNG" else ".jpg"
                out_path = safe_output_path(src, self.out_dir, out_ext)

                # --- 画像を開く(Image.open → 失敗時 open_heif フォールバック) ---
                im, exif_bytes, icc = open_image_any(src)

                # アニメーションHEIF対策:先頭フレームを選択
                try:
                    if getattr(im, "n_frames", 1) > 1:
                        im.seek(0)
                except Exception as e:
                    self.log_cb(f"!警告: フレームseekに失敗 ({e})")

                # EXIFの回転情報を反映(縦横を正しく)
                try:
                    im = ImageOps.exif_transpose(im)
                except Exception as e:
                    self.log_cb(f"!警告: 回転補正に失敗 ({e})")

                # 保存パラメータを用意
                save_kwargs = {}
                if self.fmt == "JPEG":
                    save_kwargs["quality"] = self.jpg_quality
                    save_kwargs["optimize"] = True
                    save_kwargs["progressive"] = True
                    # 指定したい場合のみ(2=4:2:0):save_kwargs["subsampling"] = 2
                    if icc:
                        save_kwargs["icc_profile"] = icc
                    if self.keep_exif and exif_bytes:
                        save_kwargs["exif"] = exif_bytes
                    if im.mode != "RGB":  # JPEGはRGB前提
                        im = im.convert("RGB")
                else:
                    # PNGは可逆圧縮
                    save_kwargs["optimize"] = True
                    # 必要なら ICC を入れる
                    # if icc:
                    #     save_kwargs["icc_profile"] = icc

                # 出力ディレクトリを作成して保存
                out_path.parent.mkdir(parents=True, exist_ok=True)
                try:
                    im.save(out_path, self.fmt, **save_kwargs)
                except Exception as e:
                    raise RuntimeError(f"[Save] で失敗: {e}(dest={out_path}")

                self.log_cb(f"✔ 変換完了: {src.name}{out_path.name}")

            except Exception:
                # フルスタックをGUIログへ
                tb = traceback.format_exc()
                self.log_cb(f"✖ エラー: {abs_src}\n{tb}")
            finally:
                count += 1
                self.progress_cb(count, total)

        self.done_cb()


# =============================================================================
# GUI本体
# =============================================================================

class App:
    def __init__(self, root: tk.Tk):
        self.root = root
        self.root.title("HEIC → PNG/JPEG 一括変換(診断ログ強化)")
        self.files: List[Path] = []
        self.out_dir: Optional[Path] = None

        outer = ttk.Frame(root, padding=12)
        outer.pack(fill="both", expand=True)

        # ファイルリスト
        self.listbox = tk.Listbox(outer, height=10, selectmode="extended")
        self.listbox.grid(row=0, column=0, columnspan=4, sticky="nsew", pady=(0, 8))
        self._configure_drop(self.listbox)

        # ボタン群
        ttk.Button(outer, text="ファイルを追加", command=self.add_files)\
            .grid(row=1, column=0, sticky="ew", pady=2)
        ttk.Button(outer, text="フォルダを追加", command=self.add_folder)\
            .grid(row=1, column=1, sticky="ew", pady=2)
        ttk.Button(outer, text="選択を削除", command=self.remove_selected)\
            .grid(row=1, column=2, sticky="ew", pady=2)
        ttk.Button(outer, text="リストをクリア", command=self.clear_list)\
            .grid(row=1, column=3, sticky="ew", pady=2)

        # オプション
        opts = ttk.LabelFrame(outer, text="オプション", padding=10)
        opts.grid(row=2, column=0, columnspan=4, sticky="ew", pady=(8, 8))
        opts.grid_columnconfigure(5, weight=1)

        ttk.Label(opts, text="出力形式:").grid(row=0, column=0, sticky="w")
        self.fmt_var = tk.StringVar(value="PNG")
        ttk.Radiobutton(opts, text="PNG(劣化なし)", value="PNG", variable=self.fmt_var)\
            .grid(row=0, column=1, sticky="w")
        ttk.Radiobutton(opts, text="JPEG", value="JPEG", variable=self.fmt_var)\
            .grid(row=0, column=2, sticky="w")

        ttk.Label(opts, text="JPEG品質:").grid(row=0, column=3, padx=(16, 4), sticky="e")
        self.quality = tk.IntVar(value=90)
        self.quality_scale = ttk.Scale(opts, from_=60, to=100, orient="horizontal", variable=self.quality)
        self.quality_scale.grid(row=0, column=4, sticky="ew")
        self.q_label = ttk.Label(opts, text="90")
        self.q_label.grid(row=0, column=5, padx=(6, 0), sticky="w")
        self.quality.trace_add("write", lambda *args: self.q_label.config(text=str(int(self.quality.get()))))

        self.keep_exif = tk.BooleanVar(value=True)
        ttk.Checkbutton(opts, text="EXIFを保持(JPEGのみ)", variable=self.keep_exif)\
            .grid(row=1, column=1, columnspan=2, sticky="w", pady=(8, 0))

        self.out_dir_label = ttk.Label(opts, text="出力先: (元のフォルダ)")
        self.out_dir_label.grid(row=1, column=3, columnspan=2, sticky="w", pady=(8, 0))
        ttk.Button(opts, text="出力先を選択…", command=self.choose_out_dir)\
            .grid(row=1, column=5, sticky="e", pady=(8, 0))

        # 進捗 & 開始
        self.progress = ttk.Progressbar(outer, maximum=100)
        self.progress.grid(row=3, column=0, columnspan=4, sticky="ew", pady=(0, 8))
        self.start_btn = ttk.Button(outer, text="変換開始", command=self.start)
        self.start_btn.grid(row=4, column=0, columnspan=4, sticky="ew", pady=(0, 8))

        # ログ
        self.log = tk.Text(outer, height=14, state="disabled")
        self.log.grid(row=5, column=0, columnspan=4, sticky="nsew")

        # レイアウト伸縮
        outer.grid_columnconfigure(0, weight=1)
        outer.grid_columnconfigure(1, weight=1)
        outer.grid_columnconfigure(2, weight=1)
        outer.grid_columnconfigure(3, weight=1)
        outer.grid_rowconfigure(0, weight=1)
        outer.grid_rowconfigure(5, weight=1)

        # 起動時の環境情報ダンプ
        self._dump_environment()

        if not DND_AVAILABLE:
            self._append_log("※ D&Dを使うには 'pip install tkinterdnd2' を追加インストールしてください。\n")

    def _dump_environment(self):
        """Pillow / pillow-heif / OS / HEIF対応状況など、診断に有用な情報をログ表示"""
        try:
            import PIL
            pil_ver = getattr(PIL, "__version__", "unknown")
        except Exception:
            pil_ver = "(Pillow 不明)"

        # Pillow features は環境によって 'heif' が Unknown になるので安全に判定
        try:
            heif_supported = bool(features.check("heif") or features.check("heif_decoder"))
        except Exception:
            heif_supported = False

        os_info = f"{platform.system()} {platform.release()} ({platform.version()})"
        arch_info = platform.machine()
        py_info = sys.version.replace("\n", " ")

        lines = [
            "=== 環境情報 ===",
            f"Pillow: {pil_ver}",
            f"pillow-heif 読み込み: {pillow_heif_loaded}, register_ok: {heif_register_ok}",
            f"HEIF対応(Pillow features): {heif_supported}",
            f"OS: {os_info}",
            f"Arch: {arch_info}",
            f"Python: {py_info}",
        ]
        if heif_summary_text:
            lines.append(heif_summary_text)
        lines.append("================\n")
        for l in lines:
            self._append_log(l + ("\n" if not l.endswith("\n") else ""))

    def _configure_drop(self, widget):
        if DND_AVAILABLE:
            widget.drop_target_register(DND_FILES)
            widget.dnd_bind("<<Drop>>", self._on_drop)

    def _on_drop(self, event):
        raw = event.data
        paths: List[str] = []
        buf, in_quotes = "", False
        for ch in raw:
            if ch == '"':
                in_quotes = not in_quotes
                if not in_quotes and buf:
                    paths.append(buf); buf = ""
            elif ch == " " and not in_quotes:
                if buf:
                    paths.append(buf); buf = ""
            else:
                buf += ch
        if buf:
            paths.append(buf)
        self.add_paths(paths)

    def add_paths(self, paths: List[str]):
        files = collect_heic_files([Path(p) for p in paths])
        added = 0
        for f in files:
            if f not in self.files:
                self.files.append(f)
                self.listbox.insert("end", str(f))
                added += 1
        self._append_log(f"+ 追加: {added} ファイル\n")

    def add_files(self):
        paths = filedialog.askopenfilenames(
            title="HEIC/HEIF ファイルを選択",
            filetypes=[("HEIC/HEIF", "*.heic *.HEIC *.heif *.HEIF")]
        )
        if paths:
            self.add_paths(list(paths))

    def add_folder(self):
        d = filedialog.askdirectory(title="フォルダを選択")
        if d:
            self.add_paths([d])

    def clear_list(self):
        self.files.clear()
        self.listbox.delete(0, "end")
        self._append_log("リストをクリアしました。\n")

    def remove_selected(self):
        sel = list(self.listbox.curselection())
        sel.reverse()
        for idx in sel:
            try:
                self.files.pop(idx)
                self.listbox.delete(idx)
            except Exception:
                pass

    def choose_out_dir(self):
        d = filedialog.askdirectory(title="出力先フォルダを選択")
        if d:
            self.out_dir = Path(d)
            self.out_dir_label.config(text=f"出力先: {self.out_dir}")

    def start(self):
        if not self.files:
            messagebox.showwarning("警告", "ファイルがありません。先に追加してください。")
            return

        fmt = self.fmt_var.get()
        jpg_q = int(self.quality.get())
        keep_exif = bool(self.keep_exif.get())

        self.start_btn.config(state="disabled")
        self.progress.config(value=0, maximum=len(self.files))
        self._append_log(f"=== 変換開始({fmt}, JPEG品質={jpg_q}, EXIF保持={keep_exif})===\n")

        # ワーカーからの通知を UI スレッドにディスパッチ
        def on_progress(done, total):
            self.root.after(0, lambda: self.progress.config(value=done))

        def on_log(msg: str):
            self.root.after(0, lambda: self._append_log(msg + ("" if msg.endswith("\n") else "\n")))

        def on_done():
            def _finish():
                self._append_log("=== 完了 ===\n")
                self.start_btn.config(state="normal")
                messagebox.showinfo("完了", "変換が完了しました。")
            self.root.after(0, _finish)

        t = ConverterThread(self.files, self.out_dir, fmt, jpg_q, keep_exif, on_progress, on_log, on_done)
        t.start()

    def _append_log(self, text: str):
        self.log.configure(state="normal")
        self.log.insert("end", text)
        self.log.see("end")
        self.log.configure(state="disabled")


def main():
    # DnD対応Tkが使えれば優先
    root = TkinterDnD.Tk() if DND_AVAILABLE else tk.Tk()

    # Windows HiDPI 簡易対策
    try:
        if sys.platform.startswith("win"):
            from ctypes import windll
            windll.shcore.SetProcessDpiAwareness(1)
    except Exception:
        pass

    style = ttk.Style(root)
    if "vista" in style.theme_names():
        style.theme_use("vista")

    App(root)
    root.minsize(760, 560)
    root.mainloop()


if __name__ == "__main__":
    main()

HEIC Convert App / HEIC一括変換GUI

HEIC/HEIF を PNG または JPEG に一括変換するシンプルなデスクトップ GUI(Tkinter)。
A simple Tkinter desktop GUI to batch-convert HEIC/HEIF into PNG or JPEG.

概要 / Overview

  • フォールバック読込 / Fallback loading: Image.open() が HEIC を認識できない環境でも、pillow-heif.open_heif() を直接使って 必ず開く 設計。
  • 非同期処理 / Non‑blocking UI: 変換はワーカースレッドで実行し、UI はフリーズしません。
  • メタデータ / Metadata: JPEG 保存時に EXIF/ICC を可能な範囲で維持(PNG の EXIF は既定で無効)。
  • 回転補正 / Orientation fix: ImageOps.exif_transpose() により、EXIF の向きを反映。

主な機能 / Features

  • HEIC/HEIF → PNG / JPEG 一括変換
  • JPEG 品質スライダー(60–100)
  • EXIF 保持(JPEGのみ、任意)
  • ICC プロファイルを可能な範囲で引き回し
  • 出力先フォルダ指定(未指定時は元フォルダ)
  • 進捗バー・詳細ログ
  • (任意)ドラッグ&ドロップ対応(tkinterdnd2

動作環境 / Requirements

  • Python: 3.9+(3.12 で動作確認) / 3.9+ (tested on 3.12)
  • OS: Windows11 (macOS / Linux は未検証)
  • Required:
  • Optional:
    • tkinterdnd2(ドラッグ&ドロップ) / drag & drop support

Windows + Anaconda では、conda-forge から pillow-heif を入れるのが安定です。
On Windows + Anaconda, prefer installing pillow-heif from conda-forge.

使い方 / Usage

  1. アプリを起動(上記「実行方法」参照)。 / Launch the app.
  2. ファイルを追加」「フォルダを追加」で HEIC/HEIF を読み込み(DnD 可)。 / Add files or folder (DnD supported).
  3. 出力形式(PNG / JPEG)を選択。 / Choose output format (PNG/JPEG).
    • JPEG 時は 品質スライダーEXIF保持を調整。 / For JPEG, set quality and keep EXIF.
  4. 出力先を必要に応じて指定(未指定なら元フォルダ)。 / Set output folder (optional; defaults to source).
  5. 変換開始」で実行。ログと進捗が表示されます。 / Click Convert to start and watch logs/progress.

仕組み / Under the Hood

フォールバック読込 / Fallback loading

open_image_any(path) が以下を試行:

  1. Image.open(path)(成功時は im.info['exif'] / icc_profile 取得)
  2. 失敗時は pillow_heif.open_heif(path)Image.frombytes(...)必ず開く

Pillow の HEIF プラグイン登録が無効でも安定動作します。
This guarantees opening HEIC even when Pillow's HEIF plugin isn't registered.

メタデータ / Metadata

  • JPEG: オプション ON かつ EXIF がある場合 exif= で再埋め込み。ICC があれば icc_profile= を付与。
  • PNG: 既定では EXIF を埋め込みません(必要ならコードで拡張可能)。

回転補正 / Orientation

ImageOps.exif_transpose() で EXIF の向きを画素に適用。画像の天地が正しくなります。

スレッド / Threading

重い処理は ConverterThreadthreading.Thread)で実行。
UI 更新は root.after(...) でメインスレッドにディスパッチ。


GitHub

👉 https://github.com/SioComb/HEIC-CONVERT-APP

  • License :MIT
0
0
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?