系列 B 第二篇:量化「兩個年份之間城市變了多少」有兩條主流路徑——SSIM 著重結構相似度,ResNet 著重語義變化。本篇拆解兩者各自的擅長領域,並以實際數據示範何時應同時採用兩個指標。


一、為何需要「跨年變化偵測」

前一篇介紹的三個指標(邊緣密度、建築覆蓋率、紋理熵)屬於單幀指標——每張影像獨立計算一個數值。

然而研究城市發展時,真正想回答的問題是「這個地點從 2018 到 2025 發生了多大的變化」。最直覺的做法是差分(metric_2025 - metric_2018),但此方式有一個根本限制:

兩張影像的 edge_density 同樣是 0.18,差值為零——但實際上整片區域可能已完全重建,只是新舊建築的邊緣密度恰好相同。

差分丟失了「像素級的對應關係」。需要的是直接比對兩張影像本身的方法。

主流有兩條路徑:

方法 偵測對象 計算成本 可解釋性
SSIM 結構相似度(像素對應) 低(CPU 即可)
ResNet 餘弦距離 語義相似度(特徵對應) 中(一次 forward pass)

兩者並非替代關係,而是互補


二、SSIM:結構相似度

直覺

SSIM(Structural Similarity Index)衡量兩張影像在亮度、對比度、結構三個維度的相似程度,輸出值域為 −1 至 1,值為 1 代表完全相同。

其設計初衷是取代 MSE / PSNR 評估影像壓縮品質——因為 MSE 過於機械,輕微平移即導致數值暴漲,但人眼幾乎察覺不出差異。SSIM 改採滑窗加結構統計,更貼近人類視覺感知。

公式

對兩張影像 xy,以逐像素滑窗(通常為 11×11 高斯窗)計算:

SSIM(x, y) = [l(x,y)]^α · [c(x,y)]^β · [s(x,y)]^γ

l(x,y) = (2μ_x μ_y + C_1) / (μ_x² + μ_y² + C_1)         # 亮度
c(x,y) = (2σ_x σ_y + C_2) / (σ_x² + σ_y² + C_2)         # 對比度
s(x,y) = (σ_xy + C_3) / (σ_x σ_y + C_3)                  # 結構

C_1, C_2, C_3 為避免除零的穩定化常數,最終取整張影像的均值。

程式碼

# cv/cv_metrics.py:45-53
def calc_ssim(img1_gray: np.ndarray, img2_gray: np.ndarray) -> float:
    """結構相似度 SSIM,值越低代表兩年份變化越大。"""
    h = min(img1_gray.shape[0], img2_gray.shape[0])
    w = min(img1_gray.shape[1], img2_gray.shape[1])
    a = cv2.resize(img1_gray, (w, h))
    b = cv2.resize(img2_gray, (w, h))
    score, _ = ssim(a, b, full=True)
    return float(score)

兩個設計細節值得注意:

  • 採用 skimage.metrics.structural_similarity,無需自行實作
  • 比對前須統一影像尺寸。不同年份的 Mapbox 切片尺寸可能存在差異,省略此步驟將導致直接報錯
  • 以灰階圖輸入——不關注顏色變化,僅比對結構

78357-pq2uzx6l9vr.png

SSIM 的偵測範圍與局限

適合偵測

  • 建築物位移(同一建築在影像中的位置略有偏移)
  • 大範圍空地轉變為建築(亮度、對比度、紋理全面改變)
  • 道路重新鋪設(亮度顯著變化)

難以偵測

  • 季節差異(草地枯黃或翠綠 → SSIM 下降,但城市並未改變)
  • 雲影、陰影等短期光照變化
  • 語義變化但結構相似:舊社區拆除、新社區重建,若整體紋理未顯著改變,SSIM 可能仍維持在 0.7 以上

台中 8 年 SSIM 統計

統計項目 數值
平均值 0.6300
標準差 0.1202
最小值 0.3417
最大值 0.8516

平均 0.63 表示相鄰年份影像存在中等程度的結構差異。最小值 0.34 暗示特定地點在單一年度內發生顯著變化,對應重建或大規模工程事件。


三、ResNet 餘弦距離:語義相似度

直覺

將兩張影像分別輸入 ImageNet 預訓練的 ResNet,取倒數第二層(global average pooling 之後)的 2048 維特徵向量。兩個向量的餘弦相似度,反映兩張影像在 ResNet 視角下「是否屬於同一類場景」

ResNet 在 ImageNet 上習得的特徵屬於語義層級——能夠區辨「都市」「郊區」「農田」「水體」等高層次概念,而非僅僅統計邊緣數量這類底層特徵。

公式

cos_sim  = (f_1 · f_2) / (|f_1| · |f_2|)
cos_dist = 1 − cos_sim

距離為 0 代表語義相同;距離愈大代表語義變化愈顯著。

程式碼

# cv/cv_metrics.py:56-59
def calc_resnet_cosine_distance(feat1: np.ndarray, feat2: np.ndarray) -> float:
    """ResNet 特徵餘弦距離,值越大代表語義變化越大。"""
    cos_sim = np.dot(feat1, feat2) / (np.linalg.norm(feat1) * np.linalg.norm(feat2) + 1e-8)
    return float(1.0 - cos_sim)

特徵抽取流程(不進行訓練):

# src/aiutils.py:102-147(節錄)
class CityResNet:
    def __init__(self):
        self.model = models.resnet50(pretrained=True)
        # 移除最後一層分類頭,保留 GAP 後的 2048 維特徵
        self.feature_extractor = nn.Sequential(*list(self.model.children())[:-1])
        self.model.eval()

        self.preprocess = transforms.Compose([
            transforms.Resize((256, 256)),
            transforms.CenterCrop(224),
            transforms.ToTensor(),
            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
        ])

    def get_vector(self, img_path):
        img = Image.open(img_path).convert('RGB')
        tensor = self.preprocess(img).unsqueeze(0)
        with torch.no_grad():
            vector = self.feature_extractor(tensor).flatten().numpy()
        return vector

三個工程細節

1. 採用 pretrained=True,不進行微調

ImageNet 預訓練特徵已具備相當的通用性。微調需要標註資料(例如:哪些座標屬於「繁榮」?此類標籤難以取得)。直接使用 frozen 特徵加餘弦距離,可跳過全部訓練流程。

2. 採用 ResNet50 而非 ResNet18

50 層的特徵維度(2048 維)是 18 層(512 維)的四倍,描述能力更強。城市衛星影像細節豐富,多花一點推理時間是值得的取捨。8 年 × 44 採樣點 = 352 次 forward pass,CPU 執行僅需數分鐘。

3. [:-1] 保留 GAP,去除 FC 層

ResNet 最後是「2048 維 → 1000 類分類」的全連接層。此處需要的是 2048 維的中間表示,而非分類結果,因此移除最後一層。

75999-09i6wjgnsdkp.png

ResNet 的偵測範圍與局限

適合偵測

  • 語義巨變:空地轉變為高層建築群、農田轉變為商業區
  • 場景類別更替:低密度住宅區轉變為高密度住宅區
  • 跨季節穩定:草地枯黃或翠綠在 ResNet 視角下同屬「植被」,不易誤判

難以偵測

  • 同類細微變化:5 棟低層建築增至 6 棟,整體場景類別未變,距離值偏小
  • 個別建築替換:拆除一棟、重建一棟,整體場景類別不變,難以捕捉

台中 8 年 ResNet 餘弦距離統計

統計項目 數值
平均值 0.0860
標準差 0.0619
最小值 0.0126
最大值 0.3524

平均 0.086 表示整體語義變化溫和——大多數採樣點的場景類別未發生改變。最大值 0.3524 對應某個從空地轉變為密集建築的地點。


四、互補性:兩個指標合併解讀

將 SSIM 與 ResNet 餘弦距離繪製為散佈圖,每個資料點代表某一座標的某對比較年份:

                  ↑ ResNet 距離大(語義變化顯著)
                  │
        類別 1    │    類別 2
       「重大改建」│  「整片重建」
                  │
       ───────────┼─────────── → SSIM 小(結構變化顯著)
                  │
        類別 3    │    類別 4
       「光照差異」│  「微小調整」
                  │
                  ↓ ResNet 距離小(語義穩定)

四個象限的解讀邏輯:

象限 SSIM ResNet 距離 解讀
類別 1 高(結構穩定) 大(語義變化) 重大改建——個別建築功能更替,整體佈局未動(如商辦轉住宅)
類別 2 低(結構變化) 大(語義變化) 整片重建——拆遷重建、大規模開發
類別 3 低(結構變化) 小(語義穩定) 光照或季節差異——城市無實質變化,拍攝條件不同
類別 4 高(結構穩定) 小(語義穩定) 微小調整——無明顯變化

類別 2 是最值得關注的「真實重大變化」。單看 SSIM 易受類別 3 干擾(誤報);單看 ResNet 距離則可能漏掉類別 1(漏報)。兩者同時異常,才能以高精確率、高召回率識別真實大事件

40157-xcwoee6gbfh.png


五、實作:兩個指標整合於同一批次流程

# cv/cv_metrics.py:114-167(節錄)
def build_change_metrics(raw_dir, year_pairs):
    """計算跨年份變化指標(SSIM + ResNet 餘弦距離)。"""
    from src.aiutils import CityResNet
    resnet = CityResNet()

    rows = []
    for y1, y2 in year_pairs:
        dir1 = raw_dir / str(y1)
        dir2 = raw_dir / str(y2)

        files1 = {f.name: f for f in dir1.glob("*.png")}
        files2 = {f.name: f for f in dir2.glob("*.png")}
        common = sorted(set(files1) & set(files2))

        for fname in common:
            p1, p2 = files1[fname], files2[fname]
            img1 = cv2.imread(str(p1), cv2.IMREAD_GRAYSCALE)
            img2 = cv2.imread(str(p2), cv2.IMREAD_GRAYSCALE)
            if img1 is None or img2 is None:
                continue

            ssim_val = calc_ssim(img1, img2)

            feat1 = resnet.get_vector(str(p1))
            feat2 = resnet.get_vector(str(p2))
            cos_dist = calc_resnet_cosine_distance(feat1, feat2)

            # 從檔名解析座標(格式:<lon>_<lat>_*.png)
            parts = fname.replace(".png", "").split("_")
            lon, lat = float(parts[0]), float(parts[1])

            rows.append({
                "year_from": y1, "year_to": y2,
                "lon": lon, "lat": lat,
                "ssim": ssim_val,
                "resnet_cosine_dist": cos_dist,
            })
    return pd.DataFrame(rows)

兩個指標共用同一輪檔案遍歷,僅額外增加 ResNet 一次 forward pass 的計算成本。


六、選用指引

使用場景 建議做法
資源有限、僅需單一指標 SSIM——無需 GPU、無需安裝 PyTorch、可解釋性高
偵測場景類型變化(如農地都市化) ResNet 餘弦距離——SSIM 對類別變化不敏感
進行大事件異常偵測 兩者皆計算,取兩者同時異常的採樣點
影像存在嚴重季節或光照差異 以 ResNet 為主,SSIM 雜訊過大
學術發表 兩者皆呈現,可增強審稿說服力

七、未來方向:城市場景專用 backbone

ResNet50 以 ImageNet 預訓練,主要習得「物體」特徵(狗、汽車、椅子)。對於城市俯瞰場景,理論上以 Places365 預訓練的 backbone 更為適配

Places365 是 MIT 發布的 365 類場景分類資料集,類別涵蓋「downtown」「residential」「highway」「farm」等,非常契合城市衛星分析的需求。

替換方式相當簡單:

# 僅需修改一行
self.model = models.resnet50(weights=None)
self.model.load_state_dict(torch.hub.load_state_dict_from_url(
    "http://places2.csail.mit.edu/models_places365/resnet50_places365.pth.tar"
))

此替換留待後續實驗驗證。就目前資料而言,ImageNet ResNet 已足以識別「空地轉變為建築群」這類顯著的語義變化。


八、本篇小結

指標 一句話描述 適合偵測的場景
SSIM 像素級結構相似度 結構變化、低運算成本
ResNet 餘弦距離 語義級場景相似度 場景類型變化、對光照差異穩健

兩個指標合併使用方為最佳實踐。台中數據顯示:SSIM 平均 0.63、ResNet 距離平均 0.086,兩者 Pearson 相關係數約 −0.4——存在相關但不重疊,正是互補性的量化體現。

下一篇預告:跳至系列 C 學術深度系列,Moran's I 入門——城市發展是隨機分佈還是空間聚集?


程式碼cv/cv_metrics.py:45-59src/aiutils.py:102-147
依賴套件scikit-imagetorchtorchvision
運算成本:352 對影像約 5 分鐘(CPU),不足 1 分鐘(GPU)

無標籤

關注作者:

新增評論