はじめに
趣味で写真をたくさん撮るようになると、撮影した写真を一覧で見たいと思うことがあった。
win11のエクスプローラで見ることもできるが、写真と合わせてEXIF情報も見たいと思った。
そこで、streamlitでEXIF(撮影日時 / F値 / 焦点距離)が見れるようなウェブアプリを作った。
実装について
方針としては、「検索対象となる情報をすべてDataFrameに入れ込んで、表示もDataFrameから行う」ようにした。
EXIF情報の抽出
最初にすべての写真から EXIF 情報だけを抜き出して DataFrame で管理する仕様にした。
毎回ロードすると、時間が長くなりすぎるのでDataFrameはキャッシュに入れるようにする。
EXIF情報は、Pillow(PIL)ライブラリを利用する。
_get_exif_value() はタグ名から中身を取り出す関数で、逆引きできるようにしておく。
また、分数形式入っているような値は浮動小数で返してくれるように条件分岐を入れておく。
EXIF_TAGS_REVERSE = {v: k for k, v in ExifTags.TAGS.items()}
def _get_exif_value(exif, tag_name, default=None):
"""タグ逆引きで値を取り出す"""
tag_id = EXIF_TAGS_REVERSE.get(tag_name)
if exif is None or tag_id not in exif:
return default
value = exif[tag_id]
try:
if hasattr(value, "numerator") and hasattr(value, "denominator"):
return float(value.numerator) / float(value.denominator)
except Exception:
pass
return value
日時検索をするためにdatetimeにフォーマット統一する。
ありそうなフォーマットを総当たりで試す(もっと効率的な方法無いかな)
def _parse_datetime(dt_str):
"""日時のフォーマット統一"""
if not dt_str:
return None
for fmt in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S"):
try:
return datetime.strptime(dt_str, fmt)
except ValueError:
continue
return None
画像ファイル1枚ずつ読み込みを行いEXIFデータをdict1行としてrecords内に入れていく。
@st.cache_dataにより、EXIF 読み込みを一度だけにすることで検索したタイミングでも読み込みが走らないのでロードに時間がかからない。
@st.cache_data
def load_photo_metadata(base_dir: Path) -> pd.DataFrame:
"""フォルダ内のJPG/JPEGからEXIFデータを読み込んでDataFrameに変換"""
records = []
if not base_dir.exists():
return pd.DataFrame()
paths = [
p for p in base_dir.iterdir()
if p.is_file() and p.suffix.lower() in (".jpg", ".jpeg")
]
for path in sorted(paths, key=lambda p: p.name):
try:
img = Image.open(path)
exif = img._getexif() or {}
except Exception:
exif = {}
dt = _get_exif_value(exif, "DateTimeOriginal")
dt_parsed = _parse_datetime(dt)
f_number = _get_exif_value(exif, "FNumber")
focal_length = _get_exif_value(exif, "FocalLength")
records.append(
{
"filename": path.name,
"path": str(path),
"datetime": dt_parsed,
"f_number": float(f_number) if f_number is not None else None,
"focal_length": float(focal_length) if focal_length is not None else None,
}
)
df = pd.DataFrame(records)
if "datetime" in df.columns:
df = df.sort_values("datetime", ascending=True)
return df.reset_index(drop=True)
ファイル検索の仕組み
filteredにデータをコピーして絞っていく。
ファイル名検索は部分一致に、その他の値には最小・最大の2つがあるのでand検索になるように指定する。
filtered = df.copy()
# ファイル名部分一致
if filename_query:
filtered = filtered[
filtered["filename"].str.contains(filename_query, case=False, na=False)
]
# 撮影時間
if date_range and len(date_range) == 2:
start_date, end_date = date_range
filtered = filtered[
filtered["datetime"].notna()
& (filtered["datetime"].dt.date >= start_date)
& (filtered["datetime"].dt.date <= end_date)
]
# F値
if f_range is not None:
f_min_sel, f_max_sel = f_range
filtered = filtered[
filtered["f_number"].notna()
& (filtered["f_number"] >= f_min_sel)
& (filtered["f_number"] <= f_max_sel)
]
# 焦点距離
if fl_range is not None:
fl_min_sel, fl_max_sel = fl_range
filtered = filtered[
filtered["focal_length"].notna()
& (filtered["focal_length"] >= fl_min_sel)
& (filtered["focal_length"] <= fl_max_sel)
]
st.columns([2, 1])で2段組みに変換。写真の領域を多くとって視認性をあげる。
for _, row in filtered.head(max_photos).iterrows():
col1, col2 = st.columns([2, 1])
with col1:
st.image(row["path"], caption=row["filename"])
with col2:
dt_str = (
row["datetime"].strftime("%Y-%m-%d %H:%M:%S")
if pd.notna(row["datetime"])
else "N/A"
)
detail_df = pd.DataFrame(
{
"項目": ["ファイル名", "撮影日時", "F値", "焦点距離"],
"値": [
row["filename"],
dt_str,
row["f_number"] if pd.notna(row["f_number"]) else "N/A",
f"{row['focal_length']} mm" if pd.notna(row["focal_length"]) else "N/A",
],
}
)
st.table(detail_df)
st.markdown("---")
とにかく、DataFrameに全ての情報を入れて表示のフィルタに用いた。
