[NAS] 讓回憶更美:Synology Photos 自動化九宮格封面生成器的開發

身為一個攝影愛好者,隨著照片資料庫累積到了 16TB 的驚人規模,如何管理這些橫跨二十多年的回憶成為了一大挑戰。


在我的「安迪連碎碎念」部落格中,我曾分享過我行之有年的照片資料夾命名與分類邏輯。這是一套階層分明、利於檢索的結構,具體格式如下:

年份 / 年-月 / 年-月-日 事件名稱

舉例來說,一個跨年派對的檔案路徑會長這樣:

/PhotosLibrary/2026/2026-01/2026-01-01 跨年派對/照片檔案.jpg

這套分類法在檔案總管中非常清晰,但當我將資料庫遷移到 Synology Photos 時,卻遇到了一個極為惱人的視覺缺陷——「消失的封面」

痛點:結構層的視覺黑洞

Synology Photos 的縮圖邏輯其實很單純:「如果該資料夾內有圖片檔案,就抓第一張當封面;如果沒有圖片(只有子資料夾),它就會顯示一個灰色的預設圖示。」

原始的資料夾架構,因為該資料夾沒有直接的圖片,縮圖無法呈現

這正是問題所在,所以我的小目標就是要自動長出一個 folder.jpg 檔案,讓他有縮圖!

在我的分類邏輯中:

  1. 最底層 (事件層):例如 2026-01-01 跨年派對,裡面有照片,所以 Synology Photos 能正常顯示封面。

  2. 中層 (月份層):例如 2026-01,這個資料夾裡面沒有照片,只有好幾個「事件資料夾」。因此,Synology 判定它為「空(無圖片)」,顯示灰色圖示。

  3. 頂層 (年份層):例如 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) 喚醒機制」,這是一套用來「大力敲門」的組合拳:

  1. 隱身潛入:程式先將做好的圖片存為一個暫存檔(例如 folder-tmp.jpg),這時還不會驚動系統。

  2. 身份偽裝:程式會讀取使用者的 UID/GID,將這張暫存檔的擁有者強制改為使用者的身份,並賦予 777 (rwxrwxrwx) 的最高權限。這是為了確保 Synology 認為這張圖是「使用者自己放進去的」,而不是某個不知名的系統程式。

  3. 原子替換 (The Knock):這是最關鍵的一步。程式使用 Linux 系統級的 os.replace 指令,瞬間將 folder-tmp.jpg 重新命名並覆蓋成 folder.jpg

  4. 時間戳記更新:最後,程式會執行 Touch 指令,更新檔案的修改時間。

這一連串動作在 Linux 檔案系統層級中,會被判定為一個明確的 「使用者重新命名/寫入事件」。這就像是按下了門鈴,強制喚醒 Synology 的索引服務,讓它立刻重新掃描該資料夾,幾秒鐘後,原本灰色的資料夾就會瞬間亮起漂亮的九宮格封面。


結語

這套自動化邏輯的誕生,解決了我多年來的分類強迫症與視覺潔癖之間的衝突。

它尊重了我的檔案管理邏輯(年/月/日),同時補足了 Synology Photos 在這種邏輯下的顯示缺陷。現在,無論是點開 2004 年還是 2026 年,迎接我的不再是冷冰冰的資料夾圖示,而是一面由該年度、該月份最精彩瞬間組成的回憶牆。

這就是自動化的魅力——把繁瑣的整理工作交給程式,把美好的回憶留給自己。




實際安裝

如果你跟我一樣是 Synology Photos 的重度使用者,且照片庫高達 10TB 以上,你一定遇過一個痛點:相簿封面很醜,或是根本跑不出來。

Synology Photos 預設只會抓該資料夾的「第一張圖」當封面,不僅單調,而且對於只有子資料夾的「年份」或「月份」目錄,它常常抓不到代表圖。

為了解決這個問題,我用 Gemini 寫了一個 Python 腳本,透過 Docker 在 NAS 上跑。這個版本是特別調教過的 「囉唆安心版」,它在運作時會詳細回報每一個動作,讓你盯著日誌看時非常有安全感,確信它正在努力掃描你那海量照片。

這個腳本能做到:

  • 自動產生九宮格:從子目錄隨機抓 9 張圖拼起來。
  • 智慧選圖:優先抓取我有修圖過(檔名 Andy_ 開頭)的精華照片(這你可以改)。
  • 無縫拼貼:沒有醜醜的白框,視覺張力更強。
  • 詳細回報:不論是跳過、分析還是存檔,都會在日誌顯示,絕不讓你猜測它是不是當機了。
  • 自動喚醒索引:解決 Synology Photos 抓不到 Docker 產生圖片的問題。

前置準備

  1. Synology NAS (支援 Docker/Container Manager 機種)
  2. 已安裝 Container Manager 套件
  3. 確認你的照片路徑 (例如 /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。

  1. 開啟 SSH 連線到 NAS。
  2. 輸入 id
  3. 記下 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/

--

若有任何疑問或建議,歡迎在文章下面留言! 

不想錯過任何新文章/攝影教學/實用軟體推薦/超誠實食記
 →現在就立刻按讚「安迪連碎碎念粉絲團」吧~