目次
作った経緯とか
AIに記事を書かせたら思ったよりも記事が長くなってしまったので、雑記を前に持ってきました。
写真にフレームを生成するのはスマホアプリだとLiitが有名だと思う。
ただし私の場合は写真をパソコンで処理することが多いため、パソコンで同じようなことができたらな~と思い、ChatGPTに作らせた。
このプログラムを使うと下記のような写真にフレームを付与した画像を生成できる。
Liitと違うのは、レンズの名前も出すようにしているところ。
Xなどでこのようなフレームを使っている人を多々見かけるが、レンズの情報も載っていたらなぁと思うことがあったので追加。
ただ、レンズ情報ってめちゃくちゃな事もあるので、フレームに出力するExif情報は修正できるようにした。
一応縦画像にも対応。
なんとなく文字サイズや枠のサイズに違和感がある場合は適当にプログラムを弄ってください。
プログラムの内容
このPythonプログラムは、ユーザーが選択した画像に対してEXIF情報の編集とフレームの追加を行い、指定された出力先に保存するデスクトップアプリケーションです。主な機能は以下の通りです:
- ドラッグ&ドロップやファイル選択ボタンを用いて画像を簡単に選択。
- 画像のEXIF情報の表示と編集。
- フォントサイズの調整によるカスタマイズ。
- 画像にスタイリッシュなフレームとテキストを追加。
- 出力先ディレクトリの選択と記憶機能。
機能概要
機能概要
- ドラッグ&ドロップでの画像選択:
- 画像ファイルをドラッグして専用のエリアにドロップするだけで、画像の選択が可能です。
- ファイル選択ボタン:
- ドラッグ&ドロップ以外にも、ファイル選択ダイアログから画像を選択できます。
- EXIF情報の表示と編集:
- 選択した画像のEXIF情報(カメラメーカー、モデル、レンズ情報、焦点距離、F値、露出時間、ISO感度)を表示し、ユーザーが必要に応じて修正できます。
- フォントサイズの調整:
- 「Shot on」テキストとEXIF情報のフォントサイズをスケール設定することで、テキストの見やすさをカスタマイズできます。
- フレームとテキストの追加:
- 画像に対してスタイリッシュなフレームを追加し、中心部に「Shot on」とカメラ情報を表示します。
- 出力先ディレクトリの選択と記憶:
- 画像の保存先をユーザーが選択でき、そのパスは
config.json
ファイルに保存されます。次回起動時には前回選択したディレクトリがデフォルトとして設定されます。
- 画像の保存先をユーザーが選択でき、そのパスは
事前準備
このプログラムを実行する前に、以下のライブラリがPython環境にインストールされている必要があります。これらは画像の処理やドラッグ&ドロップ機能に必要です。
pip install pillow exifread tkinterdnd2 ttkthemes
ソースコード
import os
import threading
import json
from fractions import Fraction
from tkinter import Tk, StringVar, DISABLED, NORMAL, messagebox, filedialog, Canvas, Frame
from tkinter import ttk
from tkinterdnd2 import DND_FILES, TkinterDnD
from PIL import Image, ImageDraw, ImageFont, ExifTags
import exifread
# 設定ファイルのパス
CONFIG_FILE = "config.json"
# パラメーター宣言
SCALE_FACTOR = 0.05 # 画像のサイズに基づくスケール
SHOT_ON_FONT_SCALE = 0.4 # 「Shot on」行とカメラの機種名・メーカー名のフォントサイズのスケール
EXIF_FONT_SCALE = 0.3 # EXIF情報のフォントサイズのスケール
LINE_SPACING_FACTOR = 2.0 # 行間の調整
FRAME_COLOR = "#F0F0F0" # フレームの色(HEX)
FRAME_EXTRA_HEIGHT_RATIO = 0.1 # 余白の高さ比率
PORTRAIT_FONT_MULTIPLIER = 1.2 # 縦長画像時のフォントサイズ増加倍率
# フォントキャッシュ
FONT_CACHE = {}
def get_font(font_path, size):
key = (font_path, size)
if key not in FONT_CACHE:
try:
FONT_CACHE[key] = ImageFont.truetype(font_path, size)
except OSError:
FONT_CACHE[key] = ImageFont.load_default()
return FONT_CACHE[key]
class EXIFFrameApp:
def __init__(self, root):
self.root = root
self.root.title("フレーム生成ツール")
self.root.geometry("700x700")
self.root.minsize(600, 600)
# 設定の読み込み
self.config = self.load_config()
# メインフレーム
self.main_frame = ttk.Frame(root, padding=(10, 10))
self.main_frame.pack(fill="both", expand=True)
# 説明ラベル
self.instruction_label = ttk.Label(
self.main_frame,
text="画像をドラッグ&ドロップするか、下のボタンから選択してください。",
font=("Helvetica", 14)
)
self.instruction_label.grid(row=0, column=0, columnspan=3, pady=(0, 10), sticky='w')
# ドラッグ&ドロップエリアとファイル選択ボタン
self.drop_area = ttk.Label(
self.main_frame,
text="ここに画像をドラッグ&ドロップ",
relief="groove",
padding=20,
background="#E8E8E8",
foreground="#555555"
)
self.drop_area.grid(row=1, column=0, columnspan=2, sticky='nsew', padx=(0,5))
self.drop_area.drop_target_register(DND_FILES)
self.drop_area.dnd_bind('<<Drop>>', self.drop)
self.browse_button = ttk.Button(
self.main_frame,
text="ファイルを選択",
command=self.browse_file
)
self.browse_button.grid(row=1, column=2, sticky='ew', padx=(5,0))
# グリッドの列設定
self.main_frame.columnconfigure(0, weight=3)
self.main_frame.columnconfigure(1, weight=3)
self.main_frame.columnconfigure(2, weight=1)
# 選択したファイルのパス表示
self.selected_file = StringVar()
self.file_label = ttk.Label(
self.main_frame,
textvariable=self.selected_file,
foreground="blue",
wraplength=680
)
self.file_label.grid(row=2, column=0, columnspan=3, sticky='w')
# 出力先ディレクトリ表示と変更ボタン
self.output_dir_frame = ttk.Frame(self.main_frame)
self.output_dir_frame.grid(row=3, column=0, columnspan=3, sticky="ew", padx=5, pady=(20, 10))
self.output_dir_label = ttk.Label(self.output_dir_frame, text="出力先ディレクトリ:")
self.output_dir_label.grid(row=0, column=0, padx=5, pady=5, sticky='e')
self.output_dir_var = StringVar()
self.output_dir_var.set(self.config.get("output_directory", os.getcwd()))
self.output_dir_display = ttk.Label(self.output_dir_frame, textvariable=self.output_dir_var, foreground="blue", wraplength=500)
self.output_dir_display.grid(row=0, column=1, padx=5, pady=5, sticky='w')
self.change_output_dir_button = ttk.Button(
self.output_dir_frame,
text="変更",
command=self.change_output_directory
)
self.change_output_dir_button.grid(row=0, column=2, padx=5, pady=5, sticky='w')
self.output_dir_frame.columnconfigure(1, weight=1)
# EXIF編集フレーム(最初は非表示)
self.exif_frame = None
# フォントスケール設定フレーム(最初は非表示)
self.font_scale_frame = None
# 処理ステータスラベル
self.status_label = ttk.Label(
self.main_frame,
text="",
foreground="green",
font=("Helvetica", 12)
)
self.status_label.grid(row=99, column=0, columnspan=3, pady=(10, 0), sticky='w') # 大きな行番号で一番下に配置
# OKボタンを固定配置
self.ok_button = ttk.Button(
self.main_frame,
text="OK",
command=self.save_exif_info
)
self.ok_button.grid(row=100, column=0, columnspan=3, pady=10, sticky='ew')
# レスポンシブ設定
self.main_frame.rowconfigure(1, weight=1)
self.main_frame.columnconfigure(0, weight=1)
self.main_frame.columnconfigure(1, weight=1)
self.main_frame.columnconfigure(2, weight=0)
def load_config(self):
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError:
return {}
return {}
def save_config(self):
try:
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(self.config, f, ensure_ascii=False, indent=4)
except Exception as e:
messagebox.showerror("設定エラー", f"設定ファイルの保存に失敗しました:\n{e}")
def change_output_directory(self):
directory = filedialog.askdirectory(initialdir=self.output_dir_var.get(), title="出力先ディレクトリを選択")
if directory:
self.output_dir_var.set(directory)
self.config["output_directory"] = directory
self.save_config()
def drop(self, event):
file_path = event.data.strip('{}')
if os.path.isfile(file_path):
self.process_file(file_path)
else:
messagebox.showerror("ファイルエラー", "有効な画像ファイルをドロップしてください。")
def browse_file(self):
file_path = filedialog.askopenfilename(
title="画像ファイルを選択",
filetypes=[("Image Files", "*.png;*.jpg;*.jpeg;*.bmp;*.gif")]
)
if file_path:
self.process_file(file_path)
def process_file(self, file_path):
self.status_label.config(text="") # ステータスラベルをクリア
self.selected_file.set(file_path)
self.instruction_label.config(text="EXIF情報を編集してください。")
exif_info = self.get_exif_data(file_path)
self.create_exif_edit_gui(exif_info)
def get_exif_data(self, image_path):
with open(image_path, 'rb') as f:
tags = exifread.process_file(f, stop_tag="UNDEF", details=False)
exif_info = {}
exif_info['CameraMake'] = str(tags.get('Image Make', 'Unknown Manufacturer'))
exif_info['CameraModel'] = str(tags.get('Image Model', 'Unknown Camera'))
exif_info['LensModel'] = str(tags.get('EXIF LensModel', 'Unknown Lens'))
# 焦点距離を分数から数値に変換
focal_length_tag = tags.get('EXIF FocalLength', 'Unknown Focal Length')
if focal_length_tag != 'Unknown Focal Length':
try:
focal_length = float(Fraction(str(focal_length_tag)))
exif_info['FocalLength'] = f"{focal_length:.1f}"
except (ValueError, ZeroDivisionError):
exif_info['FocalLength'] = 'Unknown Focal Length'
else:
exif_info['FocalLength'] = 'Unknown Focal Length'
# F値を分数から数値に変換
f_number_tag = tags.get('EXIF FNumber', 'Unknown FNumber')
if f_number_tag != 'Unknown FNumber':
try:
f_number = float(Fraction(str(f_number_tag)))
exif_info['FNumber'] = f"{f_number:.1f}"
except (ValueError, ZeroDivisionError):
exif_info['FNumber'] = 'Unknown FNumber'
else:
exif_info['FNumber'] = 'Unknown FNumber'
exif_info['ExposureTime'] = str(tags.get('EXIF ExposureTime', 'Unknown Exposure Time'))
exif_info['ISO'] = str(tags.get('EXIF ISOSpeedRatings', 'Unknown ISO'))
return exif_info
def create_exif_edit_gui(self, exif_info):
# 既存のフレームを削除
if self.exif_frame:
self.exif_frame.destroy()
if self.font_scale_frame:
self.font_scale_frame.destroy()
# EXIF編集フレーム
self.exif_frame = ttk.LabelFrame(
self.main_frame,
text="EXIF情報の修正",
padding=(20, 10)
)
self.exif_frame.grid(row=4, column=0, columnspan=3, sticky="ew", padx=5, pady=(0, 10))
self.exif_frame.columnconfigure(1, weight=1)
labels = ['カメラメーカー', 'カメラモデル', 'レンズモデル', '焦点距離 (mm)', 'F値', '露出時間 (s)', 'ISO感度']
exif_keys = ['CameraMake', 'CameraModel', 'LensModel', 'FocalLength', 'FNumber', 'ExposureTime', 'ISO']
self.entries = {}
for i, (label_text, key) in enumerate(zip(labels, exif_keys)):
label = ttk.Label(self.exif_frame, text=label_text)
label.grid(row=i, column=0, padx=5, pady=5, sticky='e')
value = str(exif_info.get(key, ''))
if key in ['FocalLength', 'FNumber', 'ExposureTime', 'ISO']:
value = ''.join([ch for ch in value if ch.isdigit() or ch == '/' or ch == '.'])
entry = ttk.Entry(self.exif_frame)
entry.grid(row=i, column=1, padx=5, pady=5, sticky='ew')
entry.insert(0, value)
self.entries[key] = entry
# フォントサイズ設定セクション
self.font_scale_frame = ttk.LabelFrame(
self.main_frame,
text="フォントサイズ設定",
padding=(20, 10)
)
self.font_scale_frame.grid(row=5, column=0, columnspan=3, sticky="ew", padx=5, pady=(0, 10))
self.font_scale_frame.columnconfigure(1, weight=1)
# Shot on フォントスケール
shot_on_label = ttk.Label(self.font_scale_frame, text="Shot on フォントスケール:")
shot_on_label.grid(row=0, column=0, padx=5, pady=5, sticky='e')
self.shot_on_entry = ttk.Entry(self.font_scale_frame)
self.shot_on_entry.grid(row=0, column=1, padx=5, pady=5, sticky='ew')
self.shot_on_entry.insert(0, str(SHOT_ON_FONT_SCALE))
# EXIF フォントスケール
exif_label = ttk.Label(self.font_scale_frame, text="EXIF フォントスケール:")
exif_label.grid(row=1, column=0, padx=5, pady=5, sticky='e')
self.exif_entry = ttk.Entry(self.font_scale_frame)
self.exif_entry.grid(row=1, column=1, padx=5, pady=5, sticky='ew')
self.exif_entry.insert(0, str(EXIF_FONT_SCALE))
# 保存ボタン
# OKボタンはメインフレームに固定配置されているため、ここでは不要
# save_button = ttk.Button(
# self.font_scale_frame,
# text="OK",
# command=self.save_exif_info
# )
# save_button.grid(row=2, column=0, columnspan=2, pady=10, sticky='ew')
def save_exif_info(self):
try:
shot_on_scale = float(self.shot_on_entry.get())
exif_scale = float(self.exif_entry.get())
except ValueError:
messagebox.showerror("エラー", "フォントスケールは数値で入力してください。")
return
# 更新されたEXIF情報を取得
modified_exif_info = {}
for key in self.entries:
modified_exif_info[key] = self.entries[key].get()
# 処理を別スレッドで実行
threading.Thread(
target=self.process_image,
args=(modified_exif_info, shot_on_scale, exif_scale),
daemon=True
).start()
def process_image(self, exif_info, shot_on_scale, exif_scale):
self.disable_ui()
self.status_label.config(text="処理中...", foreground="orange")
self.root.update_idletasks()
image_path = self.selected_file.get()
file_name, file_extension = os.path.splitext(os.path.basename(image_path))
output_image_path = f"{file_name}_frame{file_extension}"
output_image_path = os.path.join(self.output_dir_var.get(), output_image_path)
try:
self.add_stylish_frame_and_text(
image_path,
output_image_path,
exif_info,
shot_on_scale,
exif_scale
)
clipboard_text = f"{exif_info.get('CameraMake', 'Unknown Manufacturer')} " \
f"{exif_info.get('CameraModel', 'Unknown Camera')} + " \
f"{exif_info.get('LensModel', 'Unknown Lens')}"
self.root.clipboard_clear()
self.root.clipboard_append(clipboard_text)
self.status_label.config(text=f"成功: {output_image_path}", foreground="green")
messagebox.showinfo("成功", f"画像を保存しました:\n{output_image_path}")
except Exception as e:
self.status_label.config(text="エラーが発生しました。", foreground="red")
messagebox.showerror("処理エラー", f"画像の処理中にエラーが発生しました:\n{e}")
finally:
self.enable_ui()
def disable_ui(self):
self.browse_button.config(state=DISABLED)
if self.exif_frame:
for child in self.exif_frame.winfo_children():
child.config(state=DISABLED)
if self.font_scale_frame:
for child in self.font_scale_frame.winfo_children():
child.config(state=DISABLED)
self.change_output_dir_button.config(state=DISABLED)
self.drop_area.config(state=DISABLED)
def enable_ui(self):
self.browse_button.config(state=NORMAL)
if self.exif_frame:
for child in self.exif_frame.winfo_children():
child.config(state=NORMAL)
if self.font_scale_frame:
for child in self.font_scale_frame.winfo_children():
child.config(state=NORMAL)
self.change_output_dir_button.config(state=NORMAL)
self.drop_area.config(state=NORMAL)
def add_stylish_frame_and_text(self, image_path, output_path, exif_info, shot_on_scale=SHOT_ON_FONT_SCALE, exif_scale=EXIF_FONT_SCALE):
image = Image.open(image_path)
# 自動回転
try:
for orientation in ExifTags.TAGS.keys():
if ExifTags.TAGS[orientation] == 'Orientation':
break
exif = image._getexif()
if exif is not None:
orientation_value = exif.get(orientation)
if orientation_value == 3:
image = image.rotate(180, expand=True)
elif orientation_value == 6:
image = image.rotate(270, expand=True)
elif orientation_value == 8:
image = image.rotate(90, expand=True)
except (AttributeError, KeyError, IndexError):
pass
# 縦横比
aspect_ratio = image.width / image.height
# フレームと余白のスケーリング
frame_thickness = int(image.height * SCALE_FACTOR)
extra_height = int(image.height * FRAME_EXTRA_HEIGHT_RATIO)
# 新しい画像サイズ
new_width = image.width + 2 * frame_thickness
new_height = image.height + 2 * frame_thickness + extra_height
# 新しい画像作成
framed_image = Image.new("RGB", (new_width, new_height), FRAME_COLOR)
framed_image.paste(image, (frame_thickness, frame_thickness))
# テキスト描画準備
draw = ImageDraw.Draw(framed_image)
# フォントサイズ調整
if aspect_ratio > 1:
# 横長
shot_on_font_size = max(int(image.width * (SCALE_FACTOR * shot_on_scale)), 12)
exif_font_size = max(int(image.width * (SCALE_FACTOR * exif_scale)), 10)
else:
# 縦長
shot_on_font_size = max(int(image.height * (SCALE_FACTOR * shot_on_scale) * PORTRAIT_FONT_MULTIPLIER), 12)
exif_font_size = max(int(image.height * (SCALE_FACTOR * exif_scale) * PORTRAIT_FONT_MULTIPLIER), 10)
try:
bold_font = get_font("arialbd.ttf", shot_on_font_size)
regular_font = get_font("arial.ttf", exif_font_size)
except OSError:
bold_font = ImageFont.load_default()
regular_font = ImageFont.load_default()
# テキスト内容
lens_info = f"{exif_info.get('LensModel', '')} {exif_info.get('FocalLength', '')}mm" if exif_info.get('LensModel', '') != 'Unknown Lens' else ""
camera_info = f"{exif_info.get('CameraModel', '')} {exif_info.get('CameraMake', '')}"
exif_text = f"{lens_info} f/{exif_info.get('FNumber', '')} {exif_info.get('ExposureTime', '')}s ISO {exif_info.get('ISO', '')}"
# テキスト位置計算
shoton_text = "Shot on"
shoton_bbox = draw.textbbox((0, 0), shoton_text, font=bold_font)
camera_bbox = draw.textbbox((0, 0), camera_info, font=bold_font)
shoton_height = shoton_bbox[3] - shoton_bbox[1]
camera_height = camera_bbox[3] - camera_bbox[1]
max_height = max(shoton_height, camera_height)
y_position = image.height + frame_thickness + (extra_height - max_height) // 2
shoton_length = draw.textlength(shoton_text, font=bold_font)
space_after_shoton = int(shot_on_font_size * 0.2)
total_text_width = shoton_length + space_after_shoton + (camera_bbox[2] - camera_bbox[0])
center_x_shoton = (new_width - total_text_width) // 2
center_x_camera = center_x_shoton + shoton_length + space_after_shoton
# テキスト描画
draw.text((center_x_shoton, y_position), shoton_text, font=bold_font, fill="#969696")
draw.text((center_x_camera, y_position), camera_info, font=bold_font, fill="#000000")
# 行間調整
line_spacing = int(max(shoton_height, camera_height) * LINE_SPACING_FACTOR)
# EXIF情報描画
exif_bbox = draw.textbbox((0, 0), exif_text, font=regular_font)
center_x_exif = (new_width - (exif_bbox[2] - exif_bbox[0])) // 2
y_position_exif = y_position + line_spacing
draw.text((center_x_exif, y_position_exif), exif_text, font=regular_font, fill="#969696")
# 保存
framed_image.save(output_path)
def main():
root = TkinterDnD.Tk()
app = EXIFFrameApp(root)
root.mainloop()
if __name__ == "__main__":
main()
使い方
ソースコードの保存:
- 提供されたコードを
exif_frame_app.py
などの名前で保存します。
プログラムの実行:
- Python環境で保存したファイルを実行します。例えば、コマンドラインから以下のように実行します:bashコードをコピーする
python exif_frame_app.py
画像の選択:
- ドラッグ&ドロップ: 選択したい画像ファイルをアプリケーション内のドラッグ&ドロップエリアにドラッグしてドロップします。
- ファイル選択ボタン: 「ファイルを選択」ボタンをクリックし、保存したい画像ファイルを選びます。
出力先ディレクトリの確認・変更:
- デフォルトでは、プログラムが保存されているディレクトリが出力先となります。
- 「変更」ボタンをクリックして、別の出力先ディレクトリを選択できます。選択したディレクトリは
config.json
に保存され、次回起動時に自動的に読み込まれます。
EXIF情報の編集:
- 表示されたフォームで、必要に応じてEXIF情報(カメラメーカー、モデル、レンズ情報など)を修正します。
フォントスケールの調整:
- 「Shot on フォントスケール」と「EXIF フォントスケール」の値を調整し、テキストのサイズを変更します。
画像の処理と保存:
OK
ボタンをクリックすると、画像にフレームとテキストが追加され、指定した出力先ディレクトリに保存されます。- 処理が完了すると、クリップボードにカメラ情報がコピーされ、成功メッセージが表示されます。
created by Rinker
¥3,234
(2024/10/06 07:23:33時点 Amazon調べ-詳細)
コメント