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?

Streamlitで「写真ビューア+EXIF検索ツール」を作る

Posted at

はじめに

趣味で写真をたくさん撮るようになると、撮影した写真を一覧で見たいと思うことがあった。
win11のエクスプローラで見ることもできるが、写真と合わせてEXIF情報も見たいと思った。
そこで、streamlitでEXIF(撮影日時 / F値 / 焦点距離)が見れるようなウェブアプリを作った。

image.png

実装について

方針としては、「検索対象となる情報をすべて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に全ての情報を入れて表示のフィルタに用いた。

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?