身為一個攝影愛好者,隨著照片資料庫累積到了 16TB 的驚人規模,如何管理這些橫跨二十多年的回憶成為了一大挑戰。
在我的「安迪連碎碎念」部落格中,我曾分享過我行之有年的照片資料夾命名與分類邏輯。這是一套階層分明、利於檢索的結構,具體格式如下:
年份 / 年-月 / 年-月-日 事件名稱
舉例來說,一個跨年派對的檔案路徑會長這樣:
/PhotosLibrary/2026/2026-01/2026-01-01 跨年派對/照片檔案.jpg
這套分類法在檔案總管中非常清晰,但當我將資料庫遷移到 Synology Photos 時,卻遇到了一個極為惱人的視覺缺陷——「消失的封面」。
痛點:結構層的視覺黑洞
Synology Photos 的縮圖邏輯其實很單純:「如果該資料夾內有圖片檔案,就抓第一張當封面;如果沒有圖片(只有子資料夾),它就會顯示一個灰色的預設圖示。」
| 原始的資料夾架構,因為該資料夾沒有直接的圖片,縮圖無法呈現 |
這正是問題所在,所以我的小目標就是要自動長出一個 folder.jpg 檔案,讓他有縮圖!
在我的分類邏輯中:
最底層 (事件層):例如
2026-01-01 跨年派對,裡面有照片,所以 Synology Photos 能正常顯示封面。中層 (月份層):例如
2026-01,這個資料夾裡面沒有照片,只有好幾個「事件資料夾」。因此,Synology 判定它為「空(無圖片)」,顯示灰色圖示。頂層 (年份層):例如
2026,裡面也沒有照片,只有「月份資料夾」,同樣顯示灰色圖示。
這導致當我打開 Synology Photos 的「資料夾模式」瀏覽時,看到的是一整排死氣沉沉的灰色年份資料夾,必須點進去好幾層才能看到照片。這對於一個主打「回憶」的相簿軟體來說,體驗是不及格的。
| 透過這個 Python 他會自動產出 folder.jpg |
為此,我開發了一套基於 Python 與 Docker 的自動化解決方案。這篇文章將不涉及繁瑣的程式碼,而是深入拆解這套軟體的運作邏輯與演算法設計,讓未來的 AI 或開發者能根據這些邏輯,重現出一樣強大的工具。
核心演算法:Top-Down 的智慧策展人
面對 16TB 的海量資料,程式不能傻傻地亂跑。這套軟體的核心採用了 Top-Down(由上而下) 的遍歷邏輯,它的運作就像是一位極速的策展人,拿著放大鏡掃描每一個資料夾。
1. 智慧分流:是「結構」還是「內容」?
程式從根目錄開始掃描,對於每一個遇到的資料夾,它會進行關鍵的「身分確認」,以決定對策:
情況 A:這是「事件相簿」(底層)
特徵:資料夾第一層內直接包含照片檔案(.jpg, .png 等)。
決策:這是最底層的活動紀錄,Synology Photos 本身已經能處理得很好。為了保持原始資料的純粹性,且避免浪費運算資源,程式會直接跳過,不做任何更動。
情況 B:這是「結構目錄」(年/月層)
特徵:資料夾內沒有照片,只有子資料夾。這就是我們的主戰場(也就是那些顯示灰色圖示的地方)。
決策:啟動封面製作程序。
2. 遞迴搜圖:穿越層級的搜尋
當程式決定要為 2026-01(月份層)製作封面時,它面臨一個問題:這裡面沒照片啊?
這時,程式會啟動「深度搜尋模式」。它會無視中間的目錄結構,直接潛入該目錄底下的所有子資料夾(事件層),建立一個臨時的「候選圖片池」。也就是說,它會把 2026-01 底下所有活動的照片全部列入候選名單,準備從中挑選精華。
3. 選圖演算法:拒絕平庸
有了候選名單,接下來要把這幾百、幾千張照片濃縮成 9 張代表作。為了避免選到模糊的測試照或無意義的地板照(通常都在活動的最前面),我設計了一套**「三級篩選漏斗」**:
Tier 1:VIP 優先權 程式會優先搜尋檔名以特定字串(如
Andy_或Andy12MP_)開頭的檔案。這代表使用者(我)已經修過圖或重新命名過,是精選中的精選。只要有 VIP,絕對優先錄用。Tier 2:中位數法則 如果沒有 VIP,為了避開活動開頭的試拍廢片,程式會刻意去抓該資料夾的第 50 張照片。這通常是活動進行到高潮、畫質最穩定的時刻。
Tier 3:隨機補位 如果照片數量不足 50 張,則啟動隨機挑選,確保九宮格的每一格都是不同的照片。
透過這個漏斗,程式能湊齊 9 張高品質的原始圖檔 (Raw Images),並且產出 folder.jpg 。
視覺呈現:無縫九宮格 (Seamless Grid)
選好圖片後,程式會利用 Python 的影像處理庫(Pillow)進行繪圖。
為了追求最強烈的視覺張力,我捨棄了所有裝飾性的元素(如白框、陰影、文字)。程式會將這 9 張照片進行 Center Crop(置中裁切) 為正方形,然後以 3x3 的矩陣緊密排列。
圖片與圖片之間沒有任何縫隙。
這樣做的好處是,當這張圖縮小成 Synology Photos 的預覽圖示時,它看起來就像是一個色彩斑斕的萬花筒,每一格都塞滿了回憶,完全消除了原本灰色資料夾的單調感。
技術挑戰:如何喚醒沉睡的索引?
這套軟體開發過程中最棘手的挑戰,不在於製圖,而在於「權限」與「索引」。
Synology Photos 有一個背景索引服務(Indexing Service),它負責監控檔案變更。但由於我們的腳本是跑在 Docker 容器內,這相當於在一個與世隔絕的房間裡做事,當程式偷偷把做好的 folder.jpg 放進資料夾時,外面的 Synology 系統往往會「裝作沒看到」,導致封面依然不更新。
為此,我實作了一套「原子替換 (Atomic Replace) 喚醒機制」,這是一套用來「大力敲門」的組合拳:
隱身潛入:程式先將做好的圖片存為一個暫存檔(例如
folder-tmp.jpg),這時還不會驚動系統。身份偽裝:程式會讀取使用者的 UID/GID,將這張暫存檔的擁有者強制改為使用者的身份,並賦予
777(rwxrwxrwx) 的最高權限。這是為了確保 Synology 認為這張圖是「使用者自己放進去的」,而不是某個不知名的系統程式。原子替換 (The Knock):這是最關鍵的一步。程式使用 Linux 系統級的
os.replace指令,瞬間將folder-tmp.jpg重新命名並覆蓋成folder.jpg。時間戳記更新:最後,程式會執行
Touch指令,更新檔案的修改時間。
這一連串動作在 Linux 檔案系統層級中,會被判定為一個明確的 「使用者重新命名/寫入事件」。這就像是按下了門鈴,強制喚醒 Synology 的索引服務,讓它立刻重新掃描該資料夾,幾秒鐘後,原本灰色的資料夾就會瞬間亮起漂亮的九宮格封面。
結語
這套自動化邏輯的誕生,解決了我多年來的分類強迫症與視覺潔癖之間的衝突。
它尊重了我的檔案管理邏輯(年/月/日),同時補足了 Synology Photos 在這種邏輯下的顯示缺陷。現在,無論是點開 2004 年還是 2026 年,迎接我的不再是冷冰冰的資料夾圖示,而是一面由該年度、該月份最精彩瞬間組成的回憶牆。
這就是自動化的魅力——把繁瑣的整理工作交給程式,把美好的回憶留給自己。
實際安裝
如果你跟我一樣是 Synology Photos 的重度使用者,且照片庫高達 10TB 以上,你一定遇過一個痛點:相簿封面很醜,或是根本跑不出來。
Synology Photos 預設只會抓該資料夾的「第一張圖」當封面,不僅單調,而且對於只有子資料夾的「年份」或「月份」目錄,它常常抓不到代表圖。
為了解決這個問題,我用 Gemini 寫了一個 Python 腳本,透過 Docker 在 NAS 上跑。這個版本是特別調教過的 「囉唆安心版」,它在運作時會詳細回報每一個動作,讓你盯著日誌看時非常有安全感,確信它正在努力掃描你那海量照片。
這個腳本能做到:
- 自動產生九宮格:從子目錄隨機抓 9 張圖拼起來。
- 智慧選圖:優先抓取我有修圖過(檔名
Andy_開頭)的精華照片(這你可以改)。 - 無縫拼貼:沒有醜醜的白框,視覺張力更強。
- 詳細回報:不論是跳過、分析還是存檔,都會在日誌顯示,絕不讓你猜測它是不是當機了。
- 自動喚醒索引:解決 Synology Photos 抓不到 Docker 產生圖片的問題。
前置準備
- Synology NAS (支援 Docker/Container Manager 機種)
- 已安裝 Container Manager 套件
- 確認你的照片路徑 (例如
/volume1/homes/使用者帳號/Photos/PhotoLibrary)
第一步:準備 Python 腳本
在 NAS 的 docker 資料夾下,建立一個新資料夾 auto_cover。
然後建立一個檔案 auto_cover_docker.py,貼上以下程式碼:
import os
import random
import sys
import logging
from datetime import datetime, timedelta, timezone
# ================= 設定區 =================
TARGET_DIR = '/photos'
COVER_NAME = 'folder.jpg'
VALID_EXTS = ('.jpg', '.jpeg', '.png')
# 優先選取這些開頭的檔案 (VIP),沒有則抓第 50 張,再沒有則隨機
PRIORITY_PREFIXES = ('Andy_', 'Andy12MP_')
PICK_INDEX = 50
GRID_COLS = 3
GRID_ROWS = 3
TARGET_COUNT = GRID_COLS * GRID_ROWS
# 設定時區 (台北)
tz_taipei = timezone(timedelta(hours=8))
current_time = datetime.now(tz_taipei).strftime('%Y-%m-%d_%H-%M-%S')
LOG_FILE = f'/app/auto_cover_{current_time}.log'
# 讀取環境變數權限
try:
PUID = int(os.getenv('PUID', 0))
PGID = int(os.getenv('PGID', 0))
except ValueError:
PUID, PGID = 0, 0
# =========================================
# 設定日誌 (囉唆模式:顯示所有 INFO)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
handlers=[
logging.FileHandler(LOG_FILE, mode='w', encoding='utf-8'),
logging.StreamHandler(sys.stdout)
]
)
def set_owner(filepath):
"""設定檔案權限,確保 Synology 能讀寫"""
if PUID != 0 and PGID != 0:
try:
os.chown(filepath, PUID, PGID)
os.chmod(filepath, 0o777)
except Exception:
pass
if os.path.exists(LOG_FILE):
set_owner(LOG_FILE)
try:
from PIL import Image, ImageOps
except ImportError:
logging.error("Pillow module not found!")
def is_valid_image(filename):
name_lower = filename.lower()
if not name_lower.endswith(VALID_EXTS): return False
if filename == COVER_NAME: return False
if filename.startswith('._'): return False
if 'folder-tmp' in filename: return False
return True
def has_direct_images(folder_path):
"""檢查資料夾第一層是否有照片"""
try:
with os.scandir(folder_path) as entries:
for entry in entries:
if entry.is_file() and is_valid_image(entry.name):
return True
except Exception:
pass
return False
def find_unique_image(folder_path, exclude_list):
"""搜尋圖片:優先 VIP -> 其次第 50 張 -> 最後隨機"""
candidates_vip = []
candidates_normal = []
subdirs = []
try:
with os.scandir(folder_path) as entries:
for entry in entries:
if entry.is_file() and is_valid_image(entry.name):
if entry.path in exclude_list: continue
if entry.name.startswith(PRIORITY_PREFIXES):
candidates_vip.append(entry.path)
else:
candidates_normal.append(entry.path)
elif entry.is_dir() and '@eaDir' not in entry.name:
subdirs.append(entry.path)
if candidates_vip: return random.choice(candidates_vip)
if candidates_normal:
if len(candidates_normal) >= PICK_INDEX:
return candidates_normal[PICK_INDEX - 1]
else:
return random.choice(candidates_normal)
if subdirs:
random.shuffle(subdirs)
for subdir in subdirs[:3]:
result = find_unique_image(subdir, exclude_list)
if result: return result
except Exception:
pass
return None
def get_scattered_images_fast(base_folder, target_count=9):
"""快速搜集 9 張原始圖片"""
subfolders = []
try:
with os.scandir(base_folder) as entries:
for entry in entries:
if entry.is_dir() and '@eaDir' not in entry.name:
subfolders.append(entry.path)
except Exception:
return []
if not subfolders: subfolders = [base_folder]
selected_images = []
random.shuffle(subfolders)
folder_queue = subfolders * (target_count // len(subfolders) + 1)
folder_queue = folder_queue[:target_count]
for folder in folder_queue:
img = find_unique_image(folder, exclude_list=selected_images)
if img:
selected_images.append(img)
else:
img_retry = find_unique_image(folder, exclude_list=[])
if img_retry and img_retry not in selected_images:
selected_images.append(img_retry)
return selected_images
def create_collage(image_paths, output_path):
if not image_paths: return False
selected_images = image_paths[:TARGET_COUNT]
if len(selected_images) < TARGET_COUNT:
selected_images = selected_images * TARGET_COUNT
selected_images = selected_images[:TARGET_COUNT]
# 無縫 3x3 設定 (1500px)
canvas_size = 1500
cell_size = canvas_size // GRID_COLS
new_im = Image.new('RGB', (canvas_size, canvas_size), color=(255,255,255))
positions = []
for row in range(GRID_ROWS):
for col in range(GRID_COLS):
positions.append((col * cell_size, row * cell_size))
success_count = 0
for i, img_path in enumerate(selected_images):
try:
with Image.open(img_path) as img:
img = ImageOps.exif_transpose(img)
img_ratio = img.width / img.height
if img_ratio > 1:
offset = (img.width - img.height) / 2
box = (offset, 0, offset + img.height, img.height)
else:
offset = (img.height - img.width) / 2
box = (0, offset, img.width, offset + img.width)
img = img.crop(box)
img = img.resize((cell_size, cell_size), Image.LANCZOS)
new_im.paste(img, positions[i])
success_count += 1
except Exception:
pass
if success_count > 0:
try:
folder_dir = os.path.dirname(output_path)
temp_path = os.path.join(folder_dir, "folder-tmp.jpg")
new_im.save(temp_path, quality=95)
set_owner(temp_path)
# 原子替換 + Touch
os.replace(temp_path, output_path)
os.utime(output_path, None)
return True
except Exception as e:
logging.error(f"❌ Save Error: {e}")
return False
return False
def main():
logging.info("="*30)
logging.info(f"🚀 Docker Worker Started (v19: Verbose Mode)")
logging.info(f"👤 Target PUID: {PUID} / PGID: {PGID}")
logging.info(f"📂 Target: {TARGET_DIR}")
logging.info("="*30)
if not os.path.exists(TARGET_DIR):
logging.error("❌ Error: /photos path not found.")
return
processed = 0
# Top-Down 遍歷
for root, dirs, files in os.walk(TARGET_DIR):
if '@eaDir' in root: continue
folder_name = os.path.basename(root)
cover_path = os.path.join(root, COVER_NAME)
# 1. 檢查是否為活動相簿 (詳細回報)
if has_direct_images(root):
logging.info(f"⏩ Skipping (Leaf node/Has Photos): {folder_name}")
continue
# 2. 檢查封面是否已存在 (詳細回報)
if os.path.exists(cover_path):
set_owner(cover_path)
logging.info(f"ℹ️ Skipping (Exists): {folder_name}")
continue
# 3. 開始製作 (詳細回報)
logging.info(f"🔍 Analyzing: {folder_name} ...")
scattered_imgs = get_scattered_images_fast(root, target_count=9)
if len(scattered_imgs) >= 1:
logging.info(f" 📸 Found {len(scattered_imgs)} source images.")
if create_collage(scattered_imgs, cover_path):
logging.info(f"✨ Created Cover: {folder_name}")
processed += 1
else:
logging.error(f"❌ Create Failed: {folder_name}")
else:
logging.warning(f"⚠️ No images found under: {folder_name}")
logging.info("-" * 30)
logging.info(f"🎉 Job Done. Created {processed} new covers.")
if __name__ == "__main__":
main()
第二步:確認使用者 ID (PUID/PGID)
為了讓 Docker 產生的檔案歸你所有,你需要知道你的 UID。
- 開啟 SSH 連線到 NAS。
- 輸入
id。 - 記下 uid (例如 1026) 和 gid (例如 100)。
第三步:設定 Container Manager (YAML)
在 Container Manager 中建立一個新專案,貼上以下設定。
請務必修改 PUID、PGID 和 /photos 的掛載路徑!
version: '3'
services:
worker:
image: python:3.11-slim
container_name: Folder_Cover_Worker
privileged: true
environment:
- TZ=Asia/Taipei # 設定台北時區
- PUID=1026 # 改成你的 UID 預設是1026但可能不同
- PGID=100 # 改成你的 GID 預設是100但可能不同
volumes:
# 腳本存放位置
- /volume1/docker/auto_cover:/app
# ▼▼▼ 關鍵:指向你照片庫的最上層 (例如 PhotoLibrary) 或是你想要的資料夾▼▼▼
- /volume1/homes/使用者帳號/Photos/PhotoLibrary:/photos
# 啟動指令:安裝 Pillow -> 執行腳本 -> 休息 10 分鐘
command: /bin/sh -c "pip install --no-cache-dir Pillow && python -u /app/auto_cover_docker.py; echo '任務結束,休息 10 分鐘...'; sleep 600"
network_mode: bridge
第四步:執行與成果
按下「建立 (Build)」後,請立即點開容器的 Log (日誌) 視窗。
享受你的「刷屏」快感:
你會看到日誌開始瘋狂跳動,每一行都代表它正在掃描一個資料夾,看著這些訊息跳動,你就會知道你的 16TB 正在被妥善照顧中:
⏩ Skipping...(代表這是活動相簿,跳過)ℹ️ Skipping...(代表這個封面做過了,跳過)🔍 Analyzing: 2004 ...(開始分析 2004 年)✨ Created Cover: 2004(成功產生!)
◆邀約體驗,真實心得
★安迪連碎碎念
專注於3C科技生活、美食旅遊與攝影的部落格,誠實心得,歡迎常來!
部落格: https://blog.andylain.com/
臉書粉絲團: https://www.facebook.com/Andyblogtw/
--
▲若有任何疑問或建議,歡迎在文章下面留言!
☆不想錯過任何新文章/攝影教學/實用軟體推薦/超誠實食記?
→現在就立刻按讚「安迪連碎碎念粉絲團」吧~