写真の日付修復ツール — Google フォトから書き出した写真の撮影日時を戻す
Google データ エクスポート(Takeout)で書き出した写真・動画は、解凍した時点でファイルの日付がダウンロード日になります。同梱の JSON から本来の撮影日時を読み、ファイル日時と JPEG の EXIF に書き戻す tkinter 製ツールです。既定はドライランなので、結果を全部見てから実行できます。
1. アプリ概要
Google フォトの写真を手元のパソコンに引き取ると、多くの人が同じ問題に当たります。フォルダを開くと数千枚の日付が全部同じ日になり、並び順がバラバラで撮った順に並べられない。写真管理ソフトに取り込んでも、2015年の旅行写真が今年の写真の上に来てしまいます。
このツールは、その並び順を撮影当時へ戻すものです。Google データ エクスポート(Takeout)には写真ごとに JSON が付くことが多く、本来の撮影日時がそこに残っています。ツールはその JSON を読み、ファイルの更新日時(Windows は下記ライブラリ導入時に作成日時も)と、JPEG なら EXIF(写真の中に埋め込まれた撮影情報。多くの写真管理ソフトは日付をここで見ます)の撮影日時に書き戻します。
| 入手のしかた | 更新日時 | EXIF 撮影日時 | JSON サイドカー | このツールで直せるか |
|---|---|---|---|---|
| Google フォトの画面から「ダウンロード」 | ダウンロード日時になりうる | 元のまま(無い写真も) | 付かない | 直せません(手がかりが無い) |
| Google データ エクスポート(Takeout) | エクスポート・解凍日時になる | 元のまま(無い写真も) | 付く | これが対象です |
Google のヘルプにも「写真や動画がダウンロードされた時刻に基づいて、オペレーティング システムがファイル自体に新しいタイムスタンプを割り当てることがあります」と書かれています。不具合ではなく、ダウンロードの副作用です。
- 直す:対象ファイル全部の更新日時(Windows で
win32-setctimeを入れていれば作成日時も)。 - 直す:JPEG(
.jpg/.jpeg)の EXIF 撮影日時(piexifを入れている場合)。 - 直さない:HEIC・PNG・WebP・GIF の EXIF と、動画の内部タグ(理由は仕組みの章)。
- 直さない:位置情報・説明文・アルバム構成(日時だけに絞っています)。
このツールは写真ファイルそのものを書き換えます。実行前に Takeout の zip を消さずに残しておいてください。zip さえあれば、何かおかしくなっても解凍し直してやり直せます。本ツールは Google 非公式の学習用コードで、データの破損について運営者は責任を負いません。
なぜ Google フォトから書き出した写真は日付がおかしくなるのか
Takeout の zip を解凍すると、写真と一緒に IMG_1234.jpg.json のようなファイルが大量に出てきます。邪魔に見えますが、これが撮影日時を取り戻す手がかりです。
IMG_2005.HEIC.supplemental-metadata.json(公開された実例・一部抜粋。出典: immich-app/immich Discussion #5083 のユーザー投稿。ファイル名は新形式に合わせて表記)
{
"title": "IMG_2005.HEIC",
"description": "A good description",
"imageViews": "77",
"photoTakenTime": {
"timestamp": "1664140144",
"formatted": "Sep 25, 2022, 9:09:04 PM UTC"
},
"geoData": { "latitude": 1.2345, "longitude": 5.4321, "altitude": 200.1234 },
"favorited": true
}
ツールが使うのは次の3つです。
| フィールド | 中身 | このツールでの使い道 |
|---|---|---|
photoTakenTime.timestamp | 撮影日時。UTC の秒数が文字列で入る | これが「正」。最優先で使う |
creationTime.timestamp | Google フォトにアップロードされた日時 | photoTakenTime が欠けたときの代役 |
title | Google フォト上の元ファイル名 | 「本当にこの写真の JSON か」の検算 |
creationTime を撮影日時だと思わないこと別物です。2019年7月17日に撮った写真を翌日アップロードすると、photoTakenTime は7月17日、creationTime は7月18日になります。昔のフィルム写真をスキャンして2020年に上げたなら creationTime は2020年です。ネット上には creationTime を使っているスクリプトもあるので、自分で書くときは取り違えに注意してください。
なお Google はこの JSON の仕様を公式に公開していないため、ここに書いた規則は実際のエクスポート結果とオープンソース実装から分かっている2026年9月時点の内容で、予告なく変わることがあります。
2. 機能一覧
- 「フォルダを選ぶ」ボタン(
_choose())・「まず試す(変更しない)」ボタン(_scan())・「日付を直す」ボタン(_run())・「ログをCSV保存」ボタン(_save_log())で操作 - マウス操作
<Double-1>から_show_detail()を実行 ttk.Treeviewによる表形式の一覧表示- JSON ファイルの読み込み
- CSV ファイルへの書き出し
filedialog.askdirectory()によるフォルダ選択ダイアログfiledialog.asksaveasfilename()によるファイル保存ダイアログmessagebox.askyesno()・messagebox.showerror()・messagebox.showinfo()・messagebox.showwarning()によるダイアログ通知- ウィンドウはタイトル「写真の日付修復ツール」・サイズ 1000x680 で起動
3. 事前準備・環境
最初に Takeout 側の準備をします。分割 zip は全部を同じフォルダへ解凍してください(理由はつまずきの章)。Windows の「すべて展開」で zip ごとのサブフォルダに分かれても、その親フォルダを選べば中まで見にいきます。解凍後も zip は消さないでください。
ツール本体は標準ライブラリだけで動きます。追加インストールなしでも、全ファイルの更新日時を撮影日時に戻せます。次の2つは入れるとできることが増える任意のライブラリで、無ければその機能だけ自動で落ちます(起動時のログに現在のモードが出ます)。
| ライブラリ | 入れないとどうなるか | ライセンス |
|---|---|---|
piexif | EXIF は書き換えず、ファイル日時だけ直します。EXIF を見て並べる写真管理ソフトを使うなら入れたほうが確実です | MIT |
win32-setctime | 更新日時だけ直します。エクスプローラーの既定の並び順は更新日時なので、実害は小さいです | MIT |
- Python 3.11 以上(3.12.10 で確認/Ubuntu で 3.14・3.15 の起動も確認)。まだ入れていない人はPythonのインストール手順から
- OS: Windows 11(実機・ヘッドレステスト24件と、起動・基本操作(一覧表示→本適用)を確認)/Linux Mint 22.3(仮想マシン・Python 3.12.3。起動と一覧表示まで確認)
- Takeout の zip をすべて同じフォルダへ解凍済み
- OneDrive などの同期フォルダ以外に置くこと(理由はつまずきの章)
起動のしかた:4. 完全なソースコードの app101.py は好きな場所(デスクトップなど)に保存して構いません。写真フォルダと同じ場所に置く必要はありません(対象フォルダは起動後に画面から選びます)。Windows なら、保存したフォルダをエクスプローラーで開き、アドレスバーに cmd と打って Enter を押し、出てきた黒い画面に python app101.py と入力すると起動します。この章の pip install も同じ黒い画面に貼り付けます。
Python 3.12.10 / Windows 11 Pro(実機・piexif 有効・win32-setctime 未導入)で、合成した Takeout フォルダに対する突合・ドライラン・書き込みのヘッドレステスト24件が全件成功。画面からの一覧表示→本適用も確認
Python 3.12.3 / Linux Mint 22.3(仮想マシン・Pillow 12.3.0・piexif 1.1.3)で起動と一覧表示を確認(書き込みは実行していません)。macOS は未検証
Linux Mint 22.3(仮想マシン)でも起動と一覧表示を確認しました。合成した Takeout サンプル(JPEG 7枚+JSON 5本)で、日付を直す5行・変更なし1行・スキップ(JSONなし)1行と、Windows と同じ一覧になります。
使い方 — まず試してから直す
このツールには「実際にファイルを書き換える」というチェックボックスがあり、既定はオフです。オフのままなら何度実行してもファイルは1バイトも変わりません。結果を先に全部見て、納得してからチェックを入れる2段構えです。チェックを外したまま「日付を直す」を押した場合も、ログに「(ドライラン)日付を直す」と出るだけで書き込みは起きません。
- 1フォルダを選ぶ
Takeout を解凍したいちばん上のフォルダを指定します。中のサブフォルダは再帰的に見にいくので、
Takeoutフォルダごと選んで構いません。 - 2「まず試す(変更しない)」を押す
写真と JSON の突合だけを行い、一覧を作ります。読むだけで書き込みはしません(進捗バーが動きます)。
- 3一覧と件数サマリを確認する
画面下に「対象 1,240 件/直す 1,180 件/変更なし 12 件/JSONなし 44 件…」のように内訳が出ます。「JSONなし」が多いときは実行せず、つまずきの章を確認してください。
- 4必要ならログを CSV に保存する
「ログをCSV保存」で一覧を CSV に落とせます。Excel で開ける形式(UTF-8 BOM 付き)なので、スキップされたファイルだけ抜き出して確認できます。
- 5チェックを入れて「日付を直す」
初回は手順1「フォルダを選ぶ」に戻り、サブフォルダを1つだけ選び直して試すのがおすすめです。「Photos from 2015」のような小さいフォルダで実行し、エクスプローラーで更新日時が撮影日に変わったことを自分の目で確かめてから、Takeout フォルダ全体をやり直してください。(更新日時の列が見えないときは、エクスプローラーの「表示 → 詳細」に切り替えてください)
「実際にファイルを書き換える」にチェックを入れて「日付を直す」を押すと、確認ダイアログのあとで書き込みが始まります。終わると自動で再スキャンします。件数サマリの「直す」が 0 件になっていれば成功です(「JSONなし」などのスキップ行は赤いまま残りますが、それが正常です)。
本適用後にエクスプローラー(詳細表示)で更新日時を見たところ(Windows 11・2026-09-30 撮影)
「予定」列には5種類の値が出ます(一覧の行もこの色で塗り分けられます)。何がスキップされたのかを隠さず残すのが方針です。
| 予定 | 意味 | どうすればいいか |
|---|---|---|
| 🟩 日付を直す | JSON が見つかり、日時も妥当 | そのまま実行してください |
| ⬜ 変更なし(既に一致) | 更新日時も EXIF も既に正しい | 何もしません。2回目以降の実行はほぼこれになります |
| 🟥 スキップ(JSONなし) | 対応する JSON が見つからない | 未解凍の zip がないか確認(つまずきの章) |
| 🟥 スキップ(日時が不正) | timestamp が無い/0以下で扱えない | 0以下なら1970年以前の写真、無いなら JSON に日時が入っていません。どちらも手で直してください |
| 🟥 スキップ(名前が一致しない) | JSON の title がこの写真のファイル名と違う | 誤った日付を書き込む事故を防ぐ停止です。行をダブルクリックすると JSON 側の名前が見られます |
4. 完全なソースコード
右上の「コピー」ボタンでクリップボードにコピーできます。app101.py をダウンロード(右クリック→「名前を付けて保存」)することもできます。起動のしかたは3. 事前準備・環境をご覧ください。
piexif と win32-setctime は入っていれば自動で使います)
"""写真の日付修復ツール(app101.py / PythonLand)
Google データ エクスポート(Takeout)で書き出した写真・動画は、解凍した時点で
ファイルの更新日時がダウンロード日時になってしまう。Takeout に同梱される
JSON サイドカー(photoTakenTime)を読み、本来の撮影日時へ戻す tkinter 製ツール。
安全側の設計:
- 既定はドライラン。「実際にファイルを書き換える」を ON にしないと 1 バイトも書かない
- JSON が見つからない写真は推測せず「スキップ」として一覧に残す
- JPEG の EXIF 書き込みは piexif.insert のみ(再エンコードしない=画質を落とさない)
- 1 ファイルの失敗で全体を止めない(ログに残して次へ進む)
任意ライブラリ(無くても動く):
pip install piexif … JPEG の EXIF 撮影日時も書き戻す
pip install win32-setctime … Windows の「作成日時」も書き戻す
MIT License / PythonLand (https://pythonland.tech/)
"""
import csv
import importlib
import json
import os
import re
import tkinter as tk
from datetime import datetime, timezone
from tkinter import filedialog, messagebox, ttk
# ---------------------------------------------------------------------------
# 任意ライブラリの読み込み(入っていなければ None にして機能だけ落とす)
# ---------------------------------------------------------------------------
def load_optional(module_name):
"""任意ライブラリを読み込む。入っていなければ None を返す(落とさない)。"""
try:
return importlib.import_module(module_name)
except Exception:
return None
piexif = load_optional("piexif")
setctime_mod = load_optional("win32_setctime") if os.name == "nt" else None
# ---------------------------------------------------------------------------
# 定数
# ---------------------------------------------------------------------------
MEDIA_EXTS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".mp4", ".mov")
EXIF_EXTS = (".jpg", ".jpeg")
SUPPLEMENTAL = "supplemental-metadata"
# Google フォトの「編集済みコピー」の接尾辞は表示言語でローカライズされる。
# 編集済みコピーは自前の JSON を持たず、元ファイルの JSON を共有する。
EDITED_SUFFIXES = (
"-edited", "-編集済み", "-编辑", "-편집됨", "-editado", "-modifié",
"-bearbeitet", "-modificato", "-bewerkt", "-muokattu", "-edytowane",
"-redigeret", "-отредактировано", "-відредаговано",
)
SEQ_RE = re.compile(r"\((\d+)\)$")
ACT_FIX = "日付を直す"
ACT_SAME = "変更なし(既に一致)"
ACT_NO_JSON = "スキップ(JSONなし)"
ACT_BAD_TIME = "スキップ(日時が不正)"
ACT_MISMATCH = "スキップ(名前が一致しない)"
# ---------------------------------------------------------------------------
# コア(1)ファイル名の正規化
# ---------------------------------------------------------------------------
def split_sequence(name):
"""末尾の "(3)" を切り離して (本体, 3) を返す。無ければ (name, None)。"""
m = SEQ_RE.search(name)
if not m:
return name, None
return name[:m.start()], int(m.group(1))
def strip_edited(stem):
"""編集済みコピーの接尾辞を外して (元の名前, 外したか) を返す。"""
for suffix in EDITED_SUFFIXES:
if stem.endswith(suffix):
return stem[:-len(suffix)], True
return stem, False
def normalize_json_name(filename):
"""JSON サイドカーのファイル名から (照合キー, 連番) を作る。
Google は ".supplemental-metadata" を長さの都合で途中まで切り詰めるので、
「supplemental-metadata の前方一致になっているセグメント」を丸ごと落とす。
切り詰めの文字数ルールには依存しない。
"""
if not filename.lower().endswith(".json"):
return None, None
body = filename[:-len(".json")]
body, num = split_sequence(body)
dot = body.rfind(".")
if dot != -1:
segment = body[dot + 1:]
# ".." で終わる(接尾辞がドットだけ残った)ケースも segment == "" で拾える
if SUPPLEMENTAL.startswith(segment) and "." in body[:dot]:
body = body[:dot]
return body, num
def media_lookup_keys(filename):
"""メディアファイル名から、JSON 索引を引く順番どおりの候補キーを返す。"""
stem, ext = os.path.splitext(filename)
stem, num = split_sequence(stem)
original, edited = strip_edited(stem)
keys = [(stem + ext, num), (stem + ext, None)]
if edited:
keys += [(original + ext, num), (original + ext, None)]
return keys
# ---------------------------------------------------------------------------
# コア(2)JSON の索引づくりと突合
# ---------------------------------------------------------------------------
def build_json_index(root):
"""フォルダ以下の JSON を先に全部読み、正規化キーの索引にする。"""
index = {}
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
if not name.lower().endswith(".json"):
continue
key, num = normalize_json_name(name)
if not key:
continue
index.setdefault((key.casefold(), num), os.path.join(dirpath, name))
return index
def find_json(index, filename):
"""索引からこのメディアに対応する JSON のパスを探す。見つからなければ None。"""
for key, num in media_lookup_keys(filename):
hit = index.get((key.casefold(), num))
if hit:
return hit
# 元ファイル名まで切り詰められた場合の保険。最も長く一致したキーを採用する
target = media_lookup_keys(filename)[0][0].casefold()
_, num = split_sequence(os.path.splitext(filename)[0])
best_key, best_path = "", None
for (key, key_num), path in index.items():
if key_num not in (num, None):
continue
if len(key) >= 8 and target.startswith(key) and len(key) > len(best_key):
best_key, best_path = key, path
return best_path
def read_sidecar(json_path):
"""JSON から (撮影日時のUTC秒, 使ったフィールド名, JSON内の元ファイル名) を読む。"""
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return None, None, None
title = data.get("title") if isinstance(data.get("title"), str) else None
for field in ("photoTakenTime", "creationTime"):
node = data.get(field)
if not isinstance(node, dict):
continue
raw = node.get("timestamp")
if raw is None:
continue
try:
return int(str(raw).strip()), field, title
except ValueError:
continue
return None, None, title
def verify_title(filename, title):
"""JSON の title とメディアのファイル名を突き合わせて検算する。
戻り値: "ok"(一致)/ "near"(編集済み・切り詰めの範囲で一致)/
"none"(title が無い)/ "mismatch"(別の写真の JSON の疑い)
"""
if not title:
return "none"
name = filename.casefold()
title_cf = title.casefold()
if name == title_cf:
return "ok"
stem, ext = os.path.splitext(filename)
stem, _num = split_sequence(stem)
original, _edited = strip_edited(stem)
if (original + ext).casefold() == title_cf:
return "ok"
t_stem = os.path.splitext(title)[0].casefold()
if t_stem and (original.casefold().startswith(t_stem)
or t_stem.startswith(original.casefold())):
return "near"
return "mismatch"
# ---------------------------------------------------------------------------
# コア(3)日時の変換
# ---------------------------------------------------------------------------
def to_local(timestamp):
"""UTC 秒を、このパソコンのタイムゾーンの日時に直す。扱えなければ None。"""
if timestamp is None or timestamp <= 0:
return None
try:
return datetime.fromtimestamp(timestamp, tz=timezone.utc).astimezone()
except (OverflowError, OSError, ValueError):
# Windows では 1970 年より前(負の値)で OSError [Errno 22] になる
return None
def exif_datetime(local_dt):
"""EXIF の日時書式 "YYYY:MM:DD HH:MM:SS" に整える。"""
return local_dt.strftime("%Y:%m:%d %H:%M:%S")
def exif_offset(local_dt):
"""EXIF の OffsetTimeOriginal 用に "+09:00" 形式の時差を作る。"""
text = local_dt.strftime("%z")
return text[:3] + ":" + text[3:] if len(text) == 5 else None
# ---------------------------------------------------------------------------
# コア(4)計画を立てる
# ---------------------------------------------------------------------------
class PlanItem:
"""1 ファイル分の「今どうなっていて、どう直すか」。"""
def __init__(self, path, root):
self.path = path
self.name = os.path.basename(path)
self.rel = os.path.relpath(path, root)
self.json_path = None
self.timestamp = None
self.source = ""
self.verify = ""
self.local = None
self.action = ACT_NO_JSON
self.note = ""
@property
def json_name(self):
return os.path.basename(self.json_path) if self.json_path else "—"
@property
def current_text(self):
try:
return datetime.fromtimestamp(os.path.getmtime(self.path)).strftime(
"%Y-%m-%d %H:%M:%S")
except (OSError, OverflowError, ValueError):
return "読めません"
@property
def target_text(self):
return self.local.strftime("%Y-%m-%d %H:%M:%S") if self.local else "—"
@property
def writes_exif(self):
return (piexif is not None
and os.path.splitext(self.name)[1].lower() in EXIF_EXTS)
def collect_media(root):
"""対象フォルダ以下の対応拡張子のファイルを集める。"""
found = []
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
if os.path.splitext(name)[1].lower() in MEDIA_EXTS:
found.append(os.path.join(dirpath, name))
found.sort()
return found
def build_plan(root, progress=None):
"""フォルダをスキャンして PlanItem の一覧を返す(ファイルは一切書き換えない)。"""
index = build_json_index(root)
media = collect_media(root)
items = []
for i, path in enumerate(media):
item = PlanItem(path, root)
item.json_path = find_json(index, item.name)
if item.json_path:
try:
item.timestamp, item.source, title = read_sidecar(item.json_path)
except (OSError, ValueError, UnicodeDecodeError) as exc:
item.action = ACT_NO_JSON
item.note = "JSON を読めません: {}".format(exc)
items.append(item)
continue
item.verify = verify_title(item.name, title)
item.local = to_local(item.timestamp)
if item.verify == "mismatch":
item.action = ACT_MISMATCH
item.note = "JSON の title が '{}' で別の写真の可能性".format(title)
elif item.local is None:
item.action = ACT_BAD_TIME
item.note = "timestamp={} はこの環境で扱えません".format(item.timestamp)
elif is_already_correct(item):
item.action = ACT_SAME
else:
item.action = ACT_FIX
if item.source == "creationTime":
item.note = "photoTakenTime が無いため creationTime を使用"
if progress:
progress(i + 1, len(media))
items.append(item)
return items
def is_already_correct(item):
"""更新日時も(対象なら)EXIF も既に正しいか。"""
try:
if int(os.path.getmtime(item.path)) != int(item.timestamp):
return False
except OSError:
return False
if item.writes_exif:
return read_exif_original(item.path) == exif_datetime(item.local)
return True
# ---------------------------------------------------------------------------
# コア(5)書き込み
# ---------------------------------------------------------------------------
def read_exif_original(path):
"""JPEG の EXIF 撮影日時を読む。無ければ None。"""
if piexif is None:
return None
try:
exif = piexif.load(path)
except Exception:
return None
raw = exif.get("Exif", {}).get(piexif.ExifIFD.DateTimeOriginal)
if isinstance(raw, bytes):
return raw.decode("ascii", "ignore")
return raw
def write_exif(path, local_dt):
"""piexif.insert で JPEG の日時タグだけ差し替える(再エンコードしない)。"""
exif = piexif.load(path)
stamp = exif_datetime(local_dt).encode("ascii")
exif.setdefault("Exif", {})[piexif.ExifIFD.DateTimeOriginal] = stamp
exif["Exif"][piexif.ExifIFD.DateTimeDigitized] = stamp
exif.setdefault("0th", {})[piexif.ImageIFD.DateTime] = stamp
offset = exif_offset(local_dt)
if offset:
code = offset.encode("ascii")
exif["Exif"][piexif.ExifIFD.OffsetTimeOriginal] = code
exif["Exif"][piexif.ExifIFD.OffsetTimeDigitized] = code
exif.pop("thumbnail", None)
exif.pop("1st", None)
piexif.insert(piexif.dump(exif), path)
def apply_item(item, dry_run=True):
"""1 ファイルを直す。dry_run のときは何も書かずに予定だけ返す。"""
done = []
if item.action != ACT_FIX:
return item.action
if dry_run:
return "(ドライラン)" + ACT_FIX
if item.writes_exif:
try:
write_exif(item.path, item.local)
done.append("EXIF")
except Exception as exc:
done.append("EXIF失敗({})".format(type(exc).__name__))
os.utime(item.path, (item.timestamp, item.timestamp))
done.append("更新日時")
if setctime_mod is not None:
try:
setctime_mod.setctime(item.path, float(item.timestamp))
done.append("作成日時")
except Exception as exc:
done.append("作成日時失敗({})".format(type(exc).__name__))
return "書き換え: " + "・".join(done)
def apply_plan(items, dry_run=True, log=None, progress=None):
"""計画をまとめて実行する。1 件の失敗で全体を止めない。"""
summary = {"fixed": 0, "skipped": 0, "failed": 0}
targets = [it for it in items if it.action == ACT_FIX]
for i, item in enumerate(targets):
try:
result = apply_item(item, dry_run)
summary["fixed"] += 1
except Exception as exc:
result = "失敗: {}: {}".format(type(exc).__name__, exc)
summary["failed"] += 1
if log:
log("{} … {}".format(item.rel, result))
if progress:
progress(i + 1, len(targets))
summary["skipped"] = len(items) - len(targets)
return summary
def save_log_csv(items, path):
"""一覧をそのまま CSV に保存する(Excel で開ける UTF-8 BOM 付き)。"""
with open(path, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
writer.writerow(["ファイル", "対応するJSON", "現在の更新日時",
"修正後の日時", "予定", "メモ"])
for item in items:
writer.writerow([item.rel, item.json_name, item.current_text,
item.target_text, item.action, item.note])
# ---------------------------------------------------------------------------
# GUI
# ---------------------------------------------------------------------------
class App101:
"""写真の日付修復ツール"""
COLUMNS = ("ファイル", "対応するJSON", "現在の更新日時", "修正後の日時", "予定")
WIDTHS = (250, 250, 150, 150, 170)
def __init__(self, root):
self.root = root
self.root.title("写真の日付修復ツール")
self.root.geometry("1000x680")
self.folder = tk.StringVar(value="")
self.do_write = tk.BooleanVar(value=False)
self.status = tk.StringVar(value="フォルダを選んで「まず試す」を押してください。")
self.items = []
self._busy = False
self._build_ui()
self._show_environment()
def _build_ui(self):
top = tk.Frame(self.root, padx=10, pady=8)
top.pack(fill="x")
tk.Label(top, text="Takeout を解凍したフォルダ").pack(anchor="w")
row = tk.Frame(top)
row.pack(fill="x", pady=4)
ttk.Entry(row, textvariable=self.folder).pack(side="left", fill="x",
expand=True)
ttk.Button(row, text="フォルダを選ぶ", command=self._choose).pack(
side="left", padx=6)
ttk.Button(row, text="まず試す(変更しない)", command=self._scan).pack(
side="left")
opt = tk.Frame(self.root, padx=10)
opt.pack(fill="x")
ttk.Checkbutton(opt, text="実際にファイルを書き換える(チェックを外している間は何も変更しません)",
variable=self.do_write).pack(side="left")
ttk.Button(opt, text="日付を直す", command=self._run).pack(side="right")
ttk.Button(opt, text="ログをCSV保存", command=self._save_log).pack(
side="right", padx=6)
self.tree = ttk.Treeview(self.root, columns=self.COLUMNS, show="headings",
height=14)
for col, width in zip(self.COLUMNS, self.WIDTHS):
self.tree.heading(col, text=col)
self.tree.column(col, width=width, anchor="w")
self.tree.tag_configure("fix", background="#e8f5e9")
self.tree.tag_configure("skip", background="#ffebee")
self.tree.tag_configure("same", background="#f5f5f5")
bar = ttk.Scrollbar(self.root, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=bar.set)
self.tree.pack(side="top", fill="both", expand=True, padx=10, pady=8)
self.tree.bind("<Double-1>", self._show_detail)
self.progress = ttk.Progressbar(self.root, mode="determinate")
self.progress.pack(fill="x", padx=10)
tk.Label(self.root, textvariable=self.status, anchor="w").pack(
fill="x", padx=10, pady=4)
self.log = tk.Text(self.root, height=7, wrap="none")
self.log.pack(fill="both", padx=10, pady=(0, 10))
def _show_environment(self):
self._log("写真の日付修復ツール — 先に Takeout の zip を残したままにしてください。")
self._log("JPEG の EXIF 書き込み: {}".format(
"有効(piexif)" if piexif else "無効(piexif が未導入のためファイル日時のみ直します)"))
self._log("Windows の作成日時: {}".format(
"有効(win32-setctime)" if setctime_mod else "無効(更新日時のみ直します)"))
def _log(self, text):
self.log.insert("end", text + "\n")
self.log.see("end")
def _choose(self):
path = filedialog.askdirectory(title="Takeout を解凍したフォルダを選んでください")
if path:
self.folder.set(path)
def _folder_ok(self):
path = self.folder.get().strip()
if not path or not os.path.isdir(path):
messagebox.showwarning("フォルダが未選択です",
"Takeout を解凍したフォルダを選んでください。")
return None
return path
def _set_progress(self, done, total):
self.progress["maximum"] = max(total, 1)
self.progress["value"] = done
self.root.update_idletasks()
def _scan(self):
path = self._folder_ok()
if not path or self._busy:
return
self._busy = True
self.status.set("スキャン中…")
try:
self.items = build_plan(path, progress=self._set_progress)
except OSError as exc:
messagebox.showerror("スキャンできません", str(exc))
self._busy = False
return
self._fill_tree()
self._busy = False
def _fill_tree(self):
self.tree.delete(*self.tree.get_children())
counts = {ACT_FIX: 0, ACT_SAME: 0, ACT_NO_JSON: 0,
ACT_BAD_TIME: 0, ACT_MISMATCH: 0}
for item in self.items:
counts[item.action] = counts.get(item.action, 0) + 1
tag = ("fix" if item.action == ACT_FIX
else "same" if item.action == ACT_SAME else "skip")
self.tree.insert("", "end", tags=(tag,),
values=(item.rel, item.json_name, item.current_text,
item.target_text, item.action))
self.status.set(
"対象 {} 件/直す {} 件/変更なし {} 件/JSONなし {} 件/日時が不正 {} 件/名前が一致しない {} 件".format(
len(self.items), counts[ACT_FIX], counts[ACT_SAME],
counts[ACT_NO_JSON], counts[ACT_BAD_TIME], counts[ACT_MISMATCH]))
self._log(self.status.get())
def _show_detail(self, event):
selected = self.tree.focus()
if not selected:
return
row = self.tree.index(selected)
if 0 <= row < len(self.items):
item = self.items[row]
messagebox.showinfo(item.name, item.note or "特記事項はありません。")
def _run(self):
if not self.items:
messagebox.showinfo("先にスキャンしてください",
"「まず試す(変更しない)」で内容を確認してから実行してください。")
return
if self._busy:
return
dry_run = not self.do_write.get()
targets = sum(1 for it in self.items if it.action == ACT_FIX)
if not dry_run and not messagebox.askyesno(
"確認", "{} 件のファイルを書き換えます。Takeout の zip は残っていますか?".format(targets)):
return
self._busy = True
self.status.set("実行中…")
summary = apply_plan(self.items, dry_run=dry_run, log=self._log,
progress=self._set_progress)
self.status.set("{}: 処理 {} 件/スキップ {} 件/失敗 {} 件".format(
"ドライラン" if dry_run else "書き換え完了",
summary["fixed"], summary["skipped"], summary["failed"]))
self._log(self.status.get())
if not dry_run:
self.items = build_plan(self.folder.get(), progress=self._set_progress)
self._fill_tree()
self._busy = False
def _save_log(self):
if not self.items:
messagebox.showinfo("保存するものがありません", "先にスキャンしてください。")
return
path = filedialog.asksaveasfilename(
defaultextension=".csv", initialfile="photo_date_log.csv",
filetypes=[("CSVファイル", "*.csv")])
if not path:
return
try:
save_log_csv(self.items, path)
except OSError as exc:
messagebox.showerror("保存できません", str(exc))
return
self._log("ログを保存しました: " + path)
if __name__ == "__main__":
root = tk.Tk()
app = App101(root)
root.mainloop()
5. コード解説
写真の日付修復ツールのコードを、実際に書かれている実装に沿って解説します。
クラス設計とコンストラクタ
App101 クラスにアプリの全機能をまとめています(メソッド12個・全595行)。__init__ ではタイトル「写真の日付修復ツール」とウィンドウサイズ 1000x680 を設定します。あわせて状態を保持する変数(self._busy)を初期化し、そのあと _build_ui() → _show_environment() の順に呼び出して初期状態を作ります。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
ウィジェット構成
画面は tk.Frame×3・tk.Label×2・ttk.Entry・ttk.Button×4・ttk.Checkbutton・ttk.Treeview・ttk.Scrollbar・ttk.Progressbar・tk.Text で構成しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
イベント処理とボタンの接続
「フォルダを選ぶ」ボタン(_choose())・「まず試す(変更しない)」ボタン(_scan())・「日付を直す」ボタン(_run())・「ログをCSV保存」ボタン(_save_log())を command= で接続しています。また bind("<Double-1>", ...) から _show_detail() を呼べるようにイベントも登録しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
クラス定数によるデータ定義
クラスの先頭でデータを定数として定義しています:COLUMNS = ('ファイル', '対応するJSON', '現在の更新日時', '修正後の日時', '予定')、WIDTHS = (250, 250, 150, 150, 170)。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
例外処理
try-except で Exception・OSError・OverflowError・UnicodeDecodeError・ValueError を捕捉しています。あわせて messagebox.askyesno()・messagebox.showerror()・messagebox.showinfo()・messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
仕組み — JSON の索引づくりと日時の線引き
ここからは実装の話です。写真を直したいだけならTakeout ならではのつまずきへ飛んで構いません。
写真と JSON の名前は、きれいに対応していない
「IMG_1234.jpg なら IMG_1234.jpg.json を読めばいい」と書くと、かなりの数で JSON が見つかりません。Takeout のファイル名に癖があるためです。
| 写真・動画 | 対応する JSON | 癖 |
|---|---|---|
IMG_1234.jpg | IMG_1234.jpg.json | 基本形 |
IMG_1234.jpg | IMG_1234.jpg.supplemental-metadata.json | 同じ Takeout の中で新旧が混ざる |
IMG_1234(1).jpg | IMG_1234.jpg(1).json | 連番 (1) の位置がズレる(写真は拡張子の前、JSON は末尾) |
IMG_1234(1).jpg | IMG_1234.jpg.supplemental-metadata(1).json | 新形式では連番が最後尾 |
PXL_20240817_202602411.mp4 | PXL_20240817_202602411.mp4.supplemental-metada.json | 接尾辞が途中で切れる(-metada) |
PXL_20230815_142536789.jpg | PXL_20230815_142536789.jpg.supplemental-m.json | 切れる長さは元の名前の長さ次第で変わる |
IMG_3333(1).jpg | IMG_3333.jpg.suppl(1).json | 切り詰めと連番の合わせ技 |
filename_that_is_45_chars_long.jpg | filename_that_is_45_chars_long.jpg..json | 接尾辞がドットだけ残る |
IMG_20180830_123540573-edited(1).jpg | IMG_20180830_123540573.jpg(1).json | 編集済みコピーは自前の JSON を持たず元のものを共有 |
photo-編集済み.jpg | photo.jpg.json | 編集済みの接尾辞は表示言語でローカライズされる |
切り詰めの閾値は「46文字」「50文字」と説が分かれ、一次情報では確認できませんでした。確認できていない数字をコードに書くと、Google が仕様を変えた瞬間に動かなくなります。そこで文字数のルールを使わない方式にしました。
- フォルダ以下の
.jsonを集める - 末尾の
.jsonを外し、(3)を連番として切り離す - 残った名前の最後のセグメントが
supplemental-metadataの前方一致なら丸ごと捨てる(-metadaも-mも空文字も、この1本で落ちる) - 残りを
(キー, 連番)の辞書に登録する - 写真側は連番を切り離し、編集済みの接尾辞を外してからこの辞書を引く
上の表の例を含む12ペアのダミーファイルで突合テストを書き、すべてが正しい JSON に当たることを確認しました。文字数を数えていないので、Google が切り詰めの長さを変えても影響を受けません。
間違った JSON を当ててしまう事故を止める
索引が引けても、その写真の JSON だとは限りません。前方一致で似た名前の別の写真に当たる余地が残り、間違った日付は元に戻せません。
そこでツールは、見つけた JSON の title(Google フォト上の元ファイル名)と写真のファイル名を突き合わせて検算し、一致しなければ書き込まず「スキップ(名前が一致しない)」として一覧に赤く残します。編集済みコピーのように接尾辞を外せば一致する場合は正常です。手元で確認した範囲では、他のツールはこの検算をしていません。
EXIF を直す形式と、ファイル日時だけ直す形式
「全形式で EXIF も直す」ほうが親切に見えますが、あえてやっていません。理由は画質です。
| 形式 | EXIF 撮影日時 | ファイル日時 | そうしている理由 |
|---|---|---|---|
| JPEG(.jpg / .jpeg) | 書き換える | 直す | piexif.insert() は EXIF 領域を差し替えるだけで再圧縮しない |
| HEIC | 書き換えない | 直す | 書き込むと再エンコードで画質が落ちる |
| PNG / WebP / GIF | 書き換えない | 直す | PNG の eXIf チャンクは対応ビューアが限られる |
| MP4 / MOV | — | 直す | 動画に EXIF という仕組みが無い(別系統の QuickTime タグ) |
JPEG が無劣化であることは手元で測りました。640×480・約232KB のテスト JPEG に日時を書き込むと、サイズは 232,778 バイト → 232,932 バイト(+154 バイト)。増えたぶんは EXIF そのもので、デコードした画素データの SHA-256 は書き込み前後で一致しました。Pillow の save() でも EXIF を付けられますが、JPEG を再圧縮するため実行のたびに画質が落ちます。だから piexif.insert() しか使いません。
書き込むタグは DateTimeOriginal(撮影日時・本命)・DateTimeDigitized・DateTime の3つと、+09:00 のような時差を入れる OffsetTimeOriginal・OffsetTimeDigitized です。EXIF の日時タグ自体にタイムゾーン情報が無いため、時差も併記しています。
タイムゾーンで気をつけること
JSON の photoTakenTime.timestamp は UTC の秒数です。更新日時(os.utime)は秒数をそのまま渡せますが、EXIF に書くときだけは表示用の時刻に直す必要があります。ツールは datetime.fromtimestamp(ts, tz=timezone.utc).astimezone() で実行中のパソコンのタイムゾーンへ変換する実装です。限界もあり、パソコンが日本時間なら海外で撮った写真も日本時間になります。JSON に撮影地のタイムゾーンが入っていないため、どのツールでも同じ制約です。
スキャンした古い写真では photoTakenTime.timestamp が -725791800(1947年)のような負の値になることがあります。Windows 11 / Python 3.12.10 で試すと、datetime.fromtimestamp(-725791800) は OSError: [Errno 22] Invalid argument で失敗しました。os.utime() は負の値でも通りますが、書いた日時を読み取れず、結果を確認できない書き込みになります。ツールは 0 以下の timestamp を「不正」として書き込まず、一覧に残します。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app101.py と保存します。使うのは
csv・datetime・importlib・json・os・re・tkinterだけなので、追加インストールは不要です。 -
2クラスの骨格を作る
App101クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("写真の日付修復ツール")・geometry("1000x680")をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×3・tk.Label×2・ttk.Entry・ttk.Button×4・ttk.Checkbutton・ttk.Treeview・ttk.Scrollbar・ttk.Progressbar・tk.Textを作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_choose()・_scan()・_run()・_save_log())につなぎます。bind("<Double-1>", ...)の登録も忘れずに。 -
6中心になるメソッドを実装する
アプリの本体である
_run()(24行)・_save_log()(15行)・_scan()(14行)など を実装します。 -
7動作確認する
python app101.pyで起動し、各ボタンが反応すること・マウス操作(クリック・ホイールなど)が効くことを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。
💡 元フォルダを触らず、コピー先に出力する
apply_item() がその場のファイルを書き換える前に shutil.copy2() で別フォルダへ写し、写した側だけを直すようにします。元フォルダを1バイトも触らずに済む代わりに、ディスクを2倍使います。どちらを既定にするかは空き容量で決めてください。
💡 位置情報も JSON から書き戻す
JSON の geoData には緯度・経度が入っています。piexif の GPS IFD に GPSLatitude / GPSLongitude を書けば、地図対応ビューアで撮影場所が出ます。度分秒の分数表現に直す変換関数を1つ足すのが最初の課題です。
💡 撮影日でフォルダ分けする
日付を直したあと、2023/08/ のような年月フォルダへ振り分けます。PlanItem は local(変換後の日時)を持っているので、strftime("%Y/%m") で行き先を作って shutil.move() するだけです。移動は取り消せないので、こちらもドライランを先に用意してください。
💡 一覧をクリックして並べ替えられるようにする
いまの ttk.Treeview はファイル名順で固定です。self.tree.heading(col, command=...) に並べ替え関数をつなぐと、「予定」列でスキップだけをまとめて眺められます。件数が多いときの確認が楽になります。
8. よくある問題と解決法
❌ ウィンドウの大きさが画面に合わない
原因:geometry("1000x680") の固定サイズで起動するためです。
解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。
Takeout ならではのつまずき
❌ 「スキップ(JSONなし)」が大量に出る
原因:分割 zip の解凍もれです。Takeout は指定サイズを超えると複数の zip に分かれ、写真とその JSON が別々の zip に入ることがあります。アーカイブは約7日で期限切れになるため、数日かけて落としていると最後の zip が取得できていないこともあります。
解決法:すべての zip を同じ1つのフォルダに解凍し直してから、もう一度「まず試す」を押してください。それでも残るぶんは Takeout 自体が JSON を出していない写真です(ユーザー報告が複数あります)。日時の手がかりが無いので、ツールは推測せずスキップします。
❌ 「-編集済み」が付いた写真だけ直らない
原因:Google フォトで明るさなどを調整すると photo-編集済み.jpg が別に作られます。編集済みコピーは自分の JSON を持たず元ファイルのものを共有し、接尾辞は表示言語で変わるため、海外製ツールは -edited しか知らないことがあります。
解決法:このツールは EDITED_SUFFIXES に14言語ぶんを持っていて、日本語の -編集済み も最初から対象です。直らない接尾辞があれば、このタプルに1行足してください。
❌ iPhone の HEIC は日付が直るのに、写真アプリでは古い日付のまま
原因:ツールは HEIC のファイル日時は直しますが、内部の EXIF は書き換えません。写真アプリが EXIF を見て並べている場合、表示は変わりません。
解決法:HEIC の EXIF まで直すなら ExifTool を別途使ってください。pillow-heif でも書けますが、再エンコードで画質が落ちるため採用していません。
❌ OneDrive / Google ドライブのフォルダで実行すると失敗する
原因:オンデマンド機能で実体のないプレースホルダになっているファイルがあり、処理中に同期が走ると書き込んだ日時が上書きされることもあります。
解決法:Takeout は同期フォルダの外(C:\Users\あなた\Pictures\takeout_work など)に解凍してから実行してください。どうしても同期フォルダで作業するなら、実行中だけ同期を止めます。
❌ 同じ写真が何度も一覧に出てくる
原因:Takeout には「Photos from 2023」のような年フォルダとアルバム名のフォルダが両方入り、アルバムに入れた写真は両方に重複して出力されます。ツールは両方とも対象にするため、一覧に同じファイル名が複数行並びます。
解決法:どちらも正しく日付が直るので、そのままで問題ありません。重複を整理したいなら、日付を直したあとに重複ファイル検出ツールでハッシュ比較して片方を消すのが安全です。
❌ PermissionError で止まる
原因:zip の中を直接開いている、読み取り専用属性が付いている、写真ビューアがファイルを掴んでいる、のいずれかです。
解決法:ツールは1ファイルの失敗で全体を止めず、ログに残して次へ進みます。ログに出たファイルだけ、開いているアプリを閉じてから再実行してください。
9. 練習問題
アプリの理解を深めるための練習問題です(課題3は定型の発展課題)。どの課題もダミーのファイルで試してください。空のフォルダに適当な jpg と JSON を作れば、本物の写真を使わずに確認できます。
-
課題1:スキップされた理由を一覧の中で見えるようにする
「スキップ(名前が一致しない)」の詳しい理由は
PlanItem.noteに入っていますが、行をダブルクリックしないと読めません。一覧に「メモ」列を足して、その場で読めるようにしてください。期待結果:スキップされた行の右端に「JSON の title が '〜' で別の写真の可能性」が出る。
合格条件
- 「日付を直す」の行はメモ列が空か、フォールバックの注記だけ
- 列を足しても既存5列の幅が崩れない
- CSV 保存の中身は変わらない(もともとメモ列を書き出している)
- ヒント①(どこを触るか):
App101.COLUMNS・WIDTHS・_fill_tree()のvalues=(...)の3か所です。 - ヒント②(使うもの):
_build_ui()はzip(self.COLUMNS, self.WIDTHS)で見出しを作るので、両方のタプルに同じ数だけ足せば列が増えます。 - つまずきやすい点:
values=の要素数がCOLUMNSと合っていないと、ずれた列に値が入ります。
-
課題2:
creationTimeを使うかどうかを選べるようにするread_sidecar()はphotoTakenTimeが無いときcreationTimeにフォールバックしますが、これは「アップロードした日」で撮影日ではありません。使うかどうかをチェックボックスで選べるようにしてください。期待結果:チェックを外すと
photoTakenTimeが無い写真は「スキップ(日時が不正)」になり、対象から外れる。合格条件
- チェックを入れた状態はいまと同じ結果になる
photoTakenTimeのある写真は、チェックの有無によらず同じ日時になる- フォールバックを使った行にはメモが残る
- ヒント①(どこを触るか):
read_sidecar()に引数を1つ足し、build_plan()経由で GUI のtk.BooleanVarの値を渡します。 - ヒント②(使うもの):
read_sidecar()のfor field in ("photoTakenTime", "creationTime"):のタプルを引数で切り替えます。 - つまずきやすい点:
build_plan()は_scan()と_run()の2か所から呼ばれます。片方だけ直すと、書き換え後の再スキャンで結果が変わります。
-
課題3:新しいボタンを追加する
既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と
command=の接続がセットで身に付きます。
他の方法との比較
同じ問題を解く道具は既にいくつかあります。向き不向きが分かれるので、自分の状況で選んでください。
| 方法 | できること | つまずきやすいところ |
|---|---|---|
| このツール 無料・MIT・Python |
日本語 GUI・ドライラン既定。-編集済み と切り詰め名に対応し、JPEG は EXIF も直す |
動画の内部タグ・位置情報・説明文は移さない。数万枚では専用ツールより遅い |
| GooglePhotosTakeoutHelper 無料・Apache-2.0・実行ファイル |
Takeout をまとめて整理し、更新日時を復元する | EXIF は書かない(README が exiftool を別途使うよう案内)。英語 CLI。コピーではなく移動する |
| ExifTool のワンライナー 無料・CLI |
JSON から EXIF へ広範に転記でき、動画のタグにも対応する | 長い英語コマンド。.json と .supplemental-metadata.json で2回流す必要があり、切り詰め名と (1) のズレはこの書き方では拾えない。-overwrite_original は取り消せない |
| 有料のデスクトップアプリ | インストールして押すだけ。サポートあり | 無料で試せる枚数に上限があり、超えると有料(金額は各社サイトで確認)。UI は英語が多い |
正直に書くと、数万枚を一気に処理したいだけなら ExifTool のほうが速く、対応形式も広いです。
このツールが向くのは、日本語の画面で「何がどう変わるのか」を先に見てから実行したい人と、-編集済み や切り詰め名で取りこぼした人。まず GUI で全体像を把握し、残った特殊なファイルだけ ExifTool で仕上げる使い分けが現実的です。
参考リンク
- Google フォト ヘルプ: デバイスに写真や動画をダウンロードする — タイムスタンプが付け替わることの公式説明
- Google アカウント ヘルプ: データをダウンロードする — Takeout の分割・有効期限
- Python ドキュメント: os.utime — 更新日時・アクセス日時の書き換え
- Python ドキュメント: datetime.fromtimestamp — UTC 秒からローカル日時への変換と範囲外の例外
- piexif ドキュメント: functions —
load/dump/insertの対応形式 - PyPI: win32-setctime — Windows の作成日時を書き換えるライブラリ
- ExifTool: EXIF Tags —
DateTimeOriginalほか日時タグの ID と格納先 - GitHub: GooglePhotosTakeoutHelper — 比較に挙げた OSS ツール
- Pythonライブラリ一覧 —
os・json・csvの位置づけ - Pythonエラー一覧&解決法 —
PermissionError・OSErrorの読み方
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。