featured.svg

每次要把橫幅風景照發 Instagram,都得打開 Lightroom 手動裁切——調整裁切框、對齊、最後再匯出。於是我就寫了 tilesplit,一個用 Rust 打造的小工具,專門在分割圖片的同時保留 Ultra HDR 資訊。這篇文章來聊聊 Ultra HDR 是什麼,以及這個工具背後的技術細節。

為什麼需要 TileSplit?

我最主要的使用情境是把 3:2 的橫幅風景照發到 Instagram 上

雖然 Instagram 現在允許以原始比例上傳照片,但橫幅照片在動態牆上顯示得比直幅照片小很多,視覺衝擊力大打折扣。所以現在 IG 很流行「無縫滑動」的發法:把一張橫幅照片切成兩張(或更多)直幅圖,使用者左右滑的時候會有連續的視覺體驗,同時每張圖在動態牆上都能佔滿版面。但如果這張照片是 Ultra HDR 格式,用一般工具切完,HDR 就沒了——原本在 HDR 螢幕上那種亮眼的層次感,瞬間變得平淡。

原因很簡單:大多數工具在處理圖片時,根本不知道 Gain Map 的存在。它們只保留了 SDR 的部分,Gain Map 直接被丟掉了。

什麼是 Ultra HDR?

Ultra HDR 是 Google 在 Android 14 引入的圖片格式,本質上還是 JPEG,但多藏了一些東西。它最厲害的地方在於向後兼容

傳統的 HDR 圖片(像 10-bit HEIF 或 AVIF)在舊手機或不支援 HDR 的螢幕上,常常會顏色怪怪的或是過暗。Ultra HDR 用了一個很聰明的方法解決這個問題:

  1. SDR 基礎圖片:本質上就是一張普通的 JPEG,任何軟體都能打開、正常顯示。
  2. Gain Map(增益圖):在 JPEG 裡面偷偷塞了一張「變亮地圖」——告訴系統每個區域可以變多亮。
  3. HDR 重建:在支援 HDR 的螢幕上,系統把 SDR 圖片和 Gain Map 合在一起算,就能還原出更亮的高光和更豐富的細節。

同一張圖片,舊手機上正常顯示,新手機上閃閃發光。

TileSplit 是如何工作的?

核心目標很單純:切圖的同時,把 Ultra HDR 的資料完整保留下來。整個流程大概分四步:

1. 偵測與解析

程式先用 jpegli 去讀輸入檔案,檢查 JPEG 裡有沒有 XMP Metadata 和 Gain Map。如果 jpegli 讀不出來(不同手機產生的 Ultra HDR 格式會有差異),就退回去用 Google 原生的 ultrahdr 解碼器做更深入的解析。

2. 雙層解碼

確認是 Ultra HDR 之後,程式會把圖片拆成兩層:SDR 主圖(標準 RGB 像素)和 Gain Map(亮度增益資料)。同時提取 Metadata(像 hdrgm:GainMapMinhdrgm:Gamma 等),這些參數決定了 SDR 和 Gain Map 怎麼合成 HDR。

來看看程式碼長什麼樣子(省略了 debug logging):

fn try_extract_ultrahdr_with_jpegli(
    source_bytes: &[u8],
    debug: bool,
) -> Option<(ultrahdr::GainMapMetadata, RawImage, GainMap, Option<Vec<u8>>)> {
    // 用 catch_unwind 保護 jpegli 解碼——某些損壞的 JPEG 會造成 panic
    let decoded = match catch_unwind_quiet(AssertUnwindSafe(|| {
        JpegDecoder::new()
            .preserve(PreserveConfig::default())
            .output_format(JpegPixelFormat::Rgb)
            .decode(source_bytes)
    })) {
        Ok(Ok(image)) => image,
        _ => return None,
    };

    // 從 JPEG extras 裡挖出 XMP metadata 和 Gain Map
    let extras = decoded.extras()?;
    let xmp = extras.xmp()?;
    let (metadata, _) = ultrahdr::metadata::xmp::parse_xmp(xmp).ok()?;
    let gainmap = match extras.gainmap() {
        Some(gainmap) => gainmap.to_vec(),
        // extras 裡找不到的話,試試 MPF(Multi-Picture Format)
        None => extract_gainmap_jpeg_from_mpf(source_bytes, debug)?,
    };
    // 有些手機的 XMP metadata 不完整,嘗試從 Gain Map 本身的 XMP 補齊
    let metadata = if metadata_looks_default_or_incomplete(&metadata) {
        extract_metadata_from_gainmap_xmp(&gainmap, debug).unwrap_or(metadata)
    } else {
        metadata
    };
    let icc_profile = extras.icc_profile().map(|icc| icc.to_vec());

    // 把像素資料包成 RawImage 結構
    let sdr = RawImage::from_data(
        decoded.width, decoded.height,
        PixelFormat::Rgb8, ColorGamut::Bt709, ColorTransfer::Srgb,
        decoded.data,
    ).ok()?;
    // Gain Map 的 JPEG 也需要解碼成像素資料
    let gainmap = decode_gainmap_jpeg(&gainmap, ColorGamut::Bt709).ok()?;

    Some((metadata, sdr, gainmap, icc_profile))
}

幾個值得注意的設計:

  • catch_unwind_quiet:jpegli 是 C 庫的封裝,遇到格式損壞可能會 panic,用 catch_unwind 接住避免整個程式崩潰
  • MPF fallback:有些手機(特別是早期支援 Ultra HDR 的機型)把 Gain Map 存在 JPEG 的 Multi-Picture Format 區段而非 Extras 裡,需要額外處理
  • Metadata 補齊:某些裝置產生的 XMP metadata 不完整,程式會嘗試從 Gain Map 自身的 XMP 中提取更完整的參數

3. 幾何映射與分割

根據使用者要的比例(像 4:5 for Instagram),程式算出 SDR 主圖的裁剪區域。

這裡有個坑:Gain Map 的解析度通常比主圖小(常見是 1/4 大小),所以不能直接拿主圖的座標去切 Gain Map。需要根據兩者的比例做精確映射,不然切出來的 HDR 重建會位置偏移,高光對不準。

fn map_rect_to_gainmap(
    rect: Rect,
    source_width: u32,
    source_height: u32,
    gainmap_width: u32,
    gainmap_height: u32,
) -> Rect {
    // 用整數運算避免浮點誤差
    let x0 = (rect.x as u64 * gainmap_width as u64 / source_width as u64) as u32;
    let y0 = (rect.y as u64 * gainmap_height as u64 / source_height as u64) as u32;

    let right_edge = rect.x.saturating_add(rect.width);
    let bottom_edge = rect.y.saturating_add(rect.height);

    // 右下角用 ceiling division,確保不會少切到像素
    let mut x1 = div_ceil_u64(
        right_edge as u64 * gainmap_width as u64,
        source_width as u64,
    ) as u32;
    let mut y1 = div_ceil_u64(
        bottom_edge as u64 * gainmap_height as u64,
        source_height as u64,
    ) as u32;

    // 邊界保護
    x1 = x1.clamp(0, gainmap_width);
    y1 = y1.clamp(0, gainmap_height);

    if x1 <= x0 {
        x1 = (x0 + 1).min(gainmap_width);
    }

    if y1 <= y0 {
        y1 = (y0 + 1).min(gainmap_height);
    }

    Rect {
        x: x0,
        y: y0,
        width: x1.saturating_sub(x0),
        height: y1.saturating_sub(y0),
    }
}

4. 編碼與重組

最後一步是把切好的東西重新包裝成合法的 Ultra HDR JPEG。流程是:

  1. 把裁剪後的 Gain Map 編碼成獨立的 JPEG,並嵌入 XMP metadata
  2. jpegli 編碼 SDR 主圖,保留 ICC Color Profile
  3. 手動組裝 Ultra HDR 容器:插入 XMP APP1 和 MPF APP2,再接上 Gain Map JPEG

為什麼不用 jpegli.add_gainmap() 一行搞定?因為它生成的 MPF 偏移量是錯的(用了絕對偏移而非 MPF 規格要求的相對偏移),導致 viewer 找不到 Gain Map,HDR 就失效了。所以得自己手動組裝容器。

fn encode_ultrahdr_tile(
    sdr_tile: RawImage,
    gainmap_tile: GainMap,
    metadata: &ultrahdr::GainMapMetadata,
    source_icc_profile: Option<&[u8]>,
) -> Result<Vec<u8>, i32> {
    // 1. Gain Map 編碼成 JPEG,同時嵌入 XMP metadata
    let gainmap_jpeg = encode_gainmap_jpeg(&gainmap_tile, metadata)?;

    // 2. 編碼 SDR 主圖(不含 Gain Map,容器組裝交給 assemble_ultrahdr_jpeg)
    let mut config = EncoderConfig::ycbcr(SDR_TILE_JPEG_QUALITY, ChromaSubsampling::None);
    if let Some(icc_profile) = source_icc_profile
        && !icc_profile.is_empty()
    {
        config = config.icc_profile(icc_profile.to_vec());
    }

    let (pixel_layout, pixel_data) = match sdr_tile.format {
        PixelFormat::Rgb8 => (PixelLayout::Rgb8Srgb, Cow::Borrowed(&sdr_tile.data)),
        PixelFormat::Rgba8 => { /* RGBA → RGB 轉換 */ }
        _ => return Err(EXIT_IO_ERROR),
    };

    let sdr_jpeg = /* jpegli encode */;

    // 3. 手動組裝 Ultra HDR 容器
    assemble_ultrahdr_jpeg(&sdr_jpeg, &gainmap_jpeg, metadata)
}

assemble_ultrahdr_jpeg 做的事:把 XMP APP1 和 MPF APP2 插到主圖裡,再把 Gain Map JPEG 接在主圖後面。MPF header 裡的偏移量必須相對於 TIFF headermpf_marker_offset + 8),這個細節後面的「踩坑」章節會詳細說明。

這樣輸出的每一張切片都是完整的 Ultra HDR 檔案,可以在支援的設備上獨立顯示 HDR 效果。

關於重新編碼與畫質

你可能會問:「重新編碼會不會讓照片變糟?」

會。但可以把損失降到很低。

JPEG 每次重新編碼都會因為 DCT 量化而失去一些細節。為了盡量保住畫質,tilesplit 做了幾件事:

  • 用 Jpegli 編碼器:Google 開源的 jpegli 在同樣檔案大小下,畫質比傳統 libjpeg 好不少
  • 高畫質設定:預設 Quality 100,在 Instagram 上基本看不出和原圖的差異
  • 避免不必要的轉換:直接操作解碼後的原始像素,不做多餘的格式轉換

技術上不是「絕對無損」——要做到無損得用 jpegtran 那種直接操作 DCT 係數的方式,但那要同時處理 Ultra HDR 的 Metadata 封裝會極其困難。實際用起來,tilesplit 產出的畫質對攝影愛好者來說完全夠用。

關鍵相依庫

tilesplit 主要靠這三個 Rust crate:

  • image:Rust 生態系最常用的圖像處理庫,負責基礎的裁剪、格式轉換和非 HDR 圖片的處理。
  • jpegli-rs:Google jpegli 的 Rust 封裝,壓縮率比 libjpeg 好,而且支援 JPEG Extras——可以讀寫嵌在 JPEG 裡的 XMP Metadata 和 Gain Map。
  • ultrahdr-rs:Google libultrahdr 的封裝,包含 Ultra HDR 的核心邏輯(Metadata 解析、SDR/HDR 轉換公式等)。遇到格式比較特殊的圖片時,靠它來兜底。它的純 Rust 子 crate ultrahdr-core 也用來生成 XMP APP1 marker。

為什麼選擇 Rust?

Rust 剛好適合這種場景:記憶體安全(不用擔心 segfault)、執行效率接近 C++、而且 FFI 很方便——libultrahdrjpegli 都是 C/C++ 的庫,Rust 可以直接呼叫再包一層安全介面。加上 Result 型別強制處理所有錯誤情況,遇到檔案損壞或 metadata 缺失,不會直接 crash,而是給出有意義的錯誤訊息。

安裝與使用

如果你也有類似需求,可以直接從 GitHub 安裝:

cargo install --git https://github.com/p47t/rust-52-projects --branch tilesplit

用法很簡單:

# 基本用法,自動生成 photo-left.jpg 和 photo-right.jpg
tilesplit --input photo.jpg

# 指定輸出路徑
tilesplit --input photo.jpg --left-output left.jpg --right-output right.jpg

原始碼在 rust-52-projects/tilesplit 上。

踩過的坑:亮度為什麼不對?

做完初版後,切出來的圖在 HDR 螢幕上看起來比原圖暗很多。花了不少時間 debug 才搞清楚,「把 Gain Map 塞進去」跟「讓 viewer 正確解讀」是兩回事。以下是踩過的幾個主要坑:

MPF 偏移量的基準點

Ultra HDR JPEG 是兩張圖接在一起——主圖和 Gain Map。MPF (Multi-Picture Format) APP2 marker 裡有個 TIFF IFD 結構,記錄 Gain Map 的位置。關鍵是:偏移量是相對於 MPF 裡的 TIFF header,不是檔案開頭。

MPF APP2 的二進制結構:
FF E2          ← marker (2 bytes)
xx xx          ← length (2 bytes)
4D 50 46 00    ← "MPF\0" (4 bytes)
4D 4D 00 2A    ← TIFF header ← 偏移量從這裡算起

TIFF header 在 MPF marker 起始位置 +8 bytes 的地方。一開始用了 jpegli.add_gainmap() API,發現它寫入的是絕對偏移而非相對偏移,viewer 根本找不到 Gain Map。改成手動組裝容器才修好。

主圖和 Gain Map 都要有 XMP

原本以為只要主圖的 XMP 有完整參數就行。結果拿 Lightroom 匯出的 Ultra HDR 對比——主圖的 XMP 只有 hdrgm:Version="1.0",完整的 GainMapMinGainMapMaxGamma 等參數全在 Gain Map JPEG 自己的 XMP 裡

這表示很多 viewer(包括 Android 的 Photos 和 Chrome)是優先從 Gain Map 的 XMP 讀取參數的。Gain Map JPEG 如果沒嵌入 XMP,viewer 可能拿不到正確的 boost 參數,畫面就是暗的。

rdf:Seq 才是標準格式

Gain Map 參數是 per-channel 的(RGB 各通道可以不同),在 XMP 裡有兩種表達方式:

<!-- 逗號分隔(有些 viewer 不認) -->
<rdf:Description hdrgm:GainMapMax="-0.699, -0.615, -0.603" />

<!-- rdf:Seq(標準,所有 viewer 都認) -->
<hdrgm:GainMapMax>
  <rdf:Seq>
    <rdf:li>-0.699220</rdf:li>
    <rdf:li>-0.614892</rdf:li>
    <rdf:li>-0.603440</rdf:li>
  </rdf:Seq>
</hdrgm:GainMapMax>

ultrahdr-rs crate 的 generate_xmp() 用的是逗號分隔格式,實測某些 viewer 會 fallback 到預設值。改成 rdf:Seq 格式後就正常了。

Gain Map 品質不能省

Gain Map 每個像素代表 HDR boost 強度,套用公式大致是 boost = max_boost^(pixel/255)。因為是指數關係,JPEG 壓縮的量化誤差會被指數放大——pixel 值差個 5,亮度可能差 5% 以上。所以 Gain Map 一律用 quality 100 編碼,不管使用者設的 SDR 品質是多少。

教訓

Ultra HDR 的正確性散落在 MPF 規格、XMP schema、和各家 viewer 的實作細節裡。光看 ISO 21496-1 不夠,得拿 Lightroom、Google Photos 等軟體的實際輸出做二進制比對,才能確認哪些欄位是必要的、viewer 怎麼解析。

結語

HDR 螢幕越來越普及,Ultra HDR 這種格式以後只會更常見。現有的圖像處理工具大多還沒跟上,tilesplit 算是填補了這個小空白。如果你也有保留 HDR 切圖的需求,希望這個工具能幫到你。