攝影保真調色
何時使用
- 用戶給一張參考圖和一張待調照片,要求「照著這張的色調調」
- 用戶有一批已經修好的照片,想把其中的風格固化下來,套到新拍的一批
- 用戶要「只壓天空、別動地面和臉」這類分區調整
- 用戶要批量應用某個 Look,並要求「不要改變內容、不要重繪」
- 用戶要驗證某個風格是否保真:有沒有斷層、色偏、把高光或暗部壓死
- 用戶丟一張圖問「這張調得對不對」「哪裡有問題」,沒有參考圖
- 用戶給的素材可能是 log / 未轉色(相機 raw 裡導出的 S-Log3、V-Log、HLG 幀)
- 用戶抱怨生成式修圖「把人臉改掉了」「解析度掉了」——這類需求正是本 skill 的場景
不適用:需要改光線方向、換天空、加物體、語義重繪的任務。本 skill 只輸出色彩映射,做不到內容修改。
核心模型
- 一個「風格」= 基礎
.cube+ 可選的分區.cube(每個區域一個)+ 版本記錄 + 批次檔案 + 題材標籤,存放在color-styles/<name>/ - 應用風格 = 逐像素查表,再按語義遮罩把分區 LUT 蓋上去。輸出與原圖尺寸嚴格一致,結構 SSIM 恆等於 1.0
- 風格有兩種產生方式:
fit:從「原圖 / 修後圖」配對學習,精確(實測 ΔE2000 0.55–0.9),需要 3 對以上ref:從參考圖統計遷移,快速但是粗略(實測 ΔE2000 ≈ 4),單張圖即可,適合冷啟動
- 每次
fit都是一次「提交」,會自動沉澱:判定屬於哪個風格 → 併入則用全部歷史樣本重擬合 → 存檔 → 打題材標籤。不訓練模型、不需要 GPU
分區解決的是什麼
全域 LUT 只能回答「這個顏色映射成什麼」。當同一顏色在不同區域被期望成不同結果時——最典型的是灰白的天空要壓暗,而同樣灰白的路面要保留——一個全域映射在數學上無解,最小平方法只會取中間值,兩邊都不像。
實測(一張陰天街景,只壓暗天空 −28%):
| 天空 ΔE2000 | 非天空 ΔE2000 | |
|---|---|---|
| 全域 LUT | 11.27 | 0.322 |
| 全域 + sky 分區 | 1.50 | 0.324 |
非天空誤差完全一致,說明分區沒有引入額外污染,只是把「學不會的那部分」補上了。
分區維度:sky / people / veg / water / ground / arch,預設 sky,people,veg,由內建的 SegFormer-B0(4.4 MB)給出軟遮罩。
工作流程
1. 確認依賴
python -c "import numpy, PIL; print('ok')"
python -c "import onnxruntime; print('seg ok')" # 分區與題材標籤需要
python -c "import tifffile; print('tif ok')" # 16bit TIFF 讀寫需要
python -c "import rawpy; print('raw ok')" # RAW 直讀需要,可選
缺失時 python -m pip install numpy pillow onnxruntime tifffile rawpy。缺 onnxruntime 不會崩,會降級成純全域調色。
2. 先看一眼素材(推薦,省一次白工)
python <SKILL_DIR>/scripts/grade.py check --in ./photos/original --person
不需要參考圖,只回答兩件事:
- 這是什麼素材:
transfer.verdict=display/ambiguous/log。log 素材直接fit會把 log 曲線本身學進 LUT,畫面看著合理但是錯的。--person會跑一次分割,這樣膚色色相才有意義(沒人時不判)。 - 成片有沒有硬傷:中位亮度、黑位、死白/死黑比例、色偏檔數、色彩豐富度。越界的進
warnings。
apply 和 fit 內部都會自動跑這兩項,所以這一步在批量交付前做一次即可。
3. 提交一批素材(核心動作)
python <SKILL_DIR>/scripts/grade.py fit \
--src ./photos/original --tgt ./photos/retouched
--src/--tgt按檔案主幹配對;也可用--pair 原圖 修後圖重複指定單對- 目錄裡混入尺寸不同的照片沒有影響,但每一對內部必須尺寸一致
- 不寫
--name就是自動模式:和已有風格比距離,夠近就併入,不夠近就新建 --name warm_film是顯式指定:名字已存在就併入,不存在就新建--regions sky,people,veg啟用分區(預設值)。寫--regions ""退回純全域--auto-threshold 1.5是「算同一個風格」的 ΔE 門檻
4. 應用
python <SKILL_DIR>/scripts/grade.py apply \
--style warm_film --in ./photos/new --out ./out --strength 1.0
--style auto 會先識別輸入照片的題材,再挑標籤最匹配的風格(結果在 auto 欄位裡說明為什麼選它)。
先看 look 和 warnings 再交付。look.median_luma 從 [0.195, 0.302] 變成 [0.55, 0.95] 就是在說:這張片子比風格學過的素材亮太多,套上去高光頂穿了。這種情況只有三條路——--strength 0.7 收力度、--match-key on 先把曝光對到風格學過的水平(只適合同光比的連拍)、或者接受背景變亮。不要不問就替用戶選。
--match-key off|on|auto(預設 off):先把畫面按中位亮度對到風格學過的水平,再套 LUT。對逆光片會反向傷害主體,見「保真紅線」第 14 條。
輸出格式與畫質
預設每次落兩個檔案,別只發第一個:
| 產物 | 預設 | 用途 |
|---|---|---|
<名>__<風格>_v<N>.jpg | q98、4:4:4、保留源 ICC/EXIF/DPI | 交付、發人、發朋友圈 |
<名>__<風格>_v<N>__master.png | 8bit 無損、帶 ±0.5 LSB 抖動 | 還要繼續修、要放大、要印刷 |
--quality 98:JPEG 品質。q95→q98 實測 PSNR 48.7→50.6 dB,體積 337→512 KB。降到 q92 省一半體積、掉 3 dB,肉眼在漸變天空上能看出來。--master png|tif|none:tif走 16bit(需要tifffile,裝不上會自動降級成 8bit PNG 並寫master_note,不會毀掉已生成的 JPG)。批量出圖嫌佔地方用--master none。- 源檔案本來就是
.png/.tif/.bmp時不再生成母版——主產物已經無損。 - ICC、EXIF、DPI 從源圖透傳。丟掉 ICC 最像"畫質變差":P3/AdobeRGB 的圖寫成無標籤 RGB,任何看圖軟體都按 sRGB 解釋,顏色立刻發灰發悶。
- 抖動只在無損格式開。JPEG 的量化誤差本來就比 1 LSB 粗一個數量級,那裡抖動只是白燒碼率。
用戶說"被壓縮了",先查源圖,不要先調參數。 apply 會在 source_note 裡點出來:源 JPEG 低於 0.10 位元組/像素的,是聊天軟體轉發過的副本(1080p 只剩 100 KB 出頭),細節在進這一步之前就沒了,輸出再無損也補不回來。另外兩個常見誤判——拿聊天視窗裡的預覽圖看畫質(平台會二次壓),以及對著 final_sheet.jpg 這類拼版預覽判斷(縮過一半)。
5. 校驗
python <SKILL_DIR>/scripts/grade.py verify \
--style warm_film --src ./photos/original/a.jpg --tgt ./photos/retouched/a.jpg \
--threshold 2.0
帶 --threshold 時,超過門檻以退出碼 2 結束,便於流水線卡口。
6. 導出給別人用
python <SKILL_DIR>/scripts/grade.py export --style warm_film --out ./for_lightroom --full
--full 會額外寫一份接線說明(基礎層鋪全圖 → 分區層用軟蒙版疊加 → 羽化 1~2% 畫幅)。
分區風格不能只給一個 .cube:單個 3D LUT 是像素顏色的函數,表達不了「同一顏色在不同區域不同處理」。
7. 撤銷與追溯
python <SKILL_DIR>/scripts/grade.py batches --style warm_film # 看這個風格由哪些批次構成
python <SKILL_DIR>/scripts/grade.py undo --style warm_film --batch b20261108_101500
python <SKILL_DIR>/scripts/grade.py styles # 全部風格 + 標籤 + 一句話描述
python <SKILL_DIR>/scripts/grade.py styles --name warm_film # 單風格的完整 manifest
undo 不是刪版本:它把那一批從構成裡剔除、用剩下的批次重擬合、生成一個新版本。舊版本永遠保留。
輸出判讀
所有命令都往 stdout 列印 JSON。
| 欄位 | 含義 | 判斷 |
|---|---|---|
quality.dE2000_mean | 訓練對上的平均色差(含分區) | < 2 可用;2–4 勉強;> 4 說明配對不是純色彩關係 |
decision | 本次提交怎麼處理的 | create-new / merge-nearest / merge-into-named |
auto.distance | 與最近風格的距離 | 明顯小於門檻卻新建了,或者明顯大於門檻卻併入了,說明判錯了 |
auto.nearest | 最近三個風格及距離 | 用來看「為什麼這樣判」 |
learned_from | 併入時用了多少歷史樣本 | 只有 new_samples 沒有 archived 說明是首次 |
diagnostics.coverage_pct | LUT 格點被數據覆蓋的比例 | 30–50% 屬正常(自然圖像只佔色彩空間一部分) |
diagnostics.lut_clipped_pct_observed | 有數據的區域裡被壓到 0/1 的比例 | > 2% 要提示高光/暗部可能丟失 |
regions.active | 實際生效的分區 | 為空說明這些區域在本批素材裡沒出現 |
region_diagnostics.<r>.skipped | 區域被跳過的原因 | too few pixels 正常;no residual learned 說明該區域和全域表現一致 |
description.summary | 一句話風格描述 | 給用戶看的,直接轉述 |
description.region_extra | 各分區相對全域額外做了什麼 | 有分區時才有 |
tags | 自動判定的題材 | portrait / landscape / street / indoor / night |
diagnostics.prior_shrink | 先驗強度,1.0 表示完整採用 | 每次擬合會在 1.0 / 0.5 / 0.2 / 0.05 裡實測擇優,prior_trials 給出各自的 dE |
health.monotonic_violations | 單調性破壞次數 | 預設已強制歸零;用 --no-monotonic 關掉才會出現 |
fit_quality.geometry_preserved | 幾何是否保持 | 必須為 true |
key_match.applied | 是否先把這張圖對到風格的曝光上 | 預設 off;開了會給出 stops、gamma 和調整後的中位 |
transfer.verdict | 素材是顯示參考、log,還是拿不准 | log 必須先轉色再 fit;ambiguous 讓用戶確認來源 |
warnings[].metric / .note | 無參考自檢抓到的問題 | 按 note 原話轉述給用戶;空陣列就是沒發現硬傷 |
look | [原圖, 成片] 的中位亮度 / 死白比例 / 飽和度 | 用它回答「這個風格對這張圖做了什麼」 |
input_guard | fit 時發現原圖不是顯示參考編碼 | blocking: false,只是提醒,不攔 |
input_transfer | apply 時輸入裡混了非 display 的圖 | 逐檔案給 verdict,同上 |
保真紅線
- RAW 是近似解碼。libraw 用相機白平衡 + 通用色彩矩陣,不用相機 DCP。同一個風格套不同機身會偏,也不是 Lightroom 的渲染結果。要精確交付就讓用戶導 16bit TIFF。
- LUT 錨定空間是 sRGB gamma 2.2。導出
.cube給別人用時要說明,否則對方套錯空間顏色全錯。 - 分區風格不能被單個
.cube表達。vN.cube只是全域基礎層;分區映射在vN_<region>.cube裡,需要遮罩配合。導出時用export --full,它會同時給出分區層和接線說明。 - 過曝像素原樣通過。渲染末尾對最亮通道 ≥0.97 的像素做軟過渡回原圖:過曝區沒有顏色信息,任何映射都是發明。逆光太陽盤實測會被分區塊壓成灰藍色斑,加保護後恢復成暖白光源。代價是最亮的高飽和高光不再被重映射(色卡實測訓練 ΔE 1.15→1.21)。需要連過曝區一起壓時用
--no-clip-guard。 - 軟遮罩邊界有一個過渡帶,這是有意的:兩個不同映射硬碰會產生可見輪廓。代價是天空與樹梢交界處可能有十幾像素的「半調」。
- 遮罩只有 128×128 解析度。細於約 1/128 畫幅的結構(枯枝、電線、細欄)會被算進所在分區。這些結構通常接近剪影,被壓暗後看不出;要求像素級精確請交給外部蒙版工具。
- 低置信度的區域不做調整。逆光過曝到沒顏色的天空、被枝葉切碎的天空,模型給不出可靠遮罩,那部分退回全域 LUT。寧可少調,不調歪。
- 動物特寫可能被識別人像。ADE20K 會把特寫貓判成 person(實測佔 88% 畫面),本 skill 用「person 區域內膚色佔比」兜底,低於 5% 就歸零。極端的淺色寵物仍可能漏過。
ref模式不要宣傳成「精準複製」。它只對齊 Lab 分佈,實測 ΔE 約 4;需要精準必須走fit。- 描述層的滑塊數字是估算。
measured一欄可直接復現,estimate_lightroom不能——LUT 到滑塊沒有唯一解。 - 無參考自檢只能發現「明顯的錯」,判斷不了「好不好看」。它用的是經典統計目標(中位亮度 0.45、黑位 0.02、膚色 20°),而夜拍本來就該低中位、日落本來就該偏暖,所以每項都留了寬容帶,告警的含義是「看一眼」,不是「錯了」。兩條硬門限:膚色只在分割檢測到人時才判,否則暖色街景會把 30% 畫面誤判成皮膚;白平衡只在畫面裡存在足夠中性像素時才判。
- log 判別看的是直方圖形狀,不是元數據。圖到這一步通常已是 PNG 或 libraw 的渲染結果,EXIF 早丟了。所以它統計黑位抬高 / 高光收著 / 動態範圍窄 / 低飽和,三項以上一致才判
log,兩項判ambiguous。一條被壓平的褪色風格成片可能被報成ambiguous,這是已知的假陽性方向。 - 風格是帶曝光屬性的,LUT 只在它學過的明暗範圍裡可信。一個把中位從 0.19 提到 0.28 的 look,套到中位 0.55 的片上,中位會衝到 0.95 —— 不是 bug,是 look 本身沒有那麼多高光餘量。實測:擬合只觀測到源圖 0~0.42 的區間,源圖 p99 0.42 / 目標 p99 0.56;套到 p99 0.72 的片上,超出部分靠線性外推,等於把高光頂穿。
- 不要預設替用戶「對齊曝光」。
--match-key能先把畫面中位拉到風格學過的水平再套 LUT,但對逆光片會反過來:實拍裡一張逆光人像(臉 0.27、背景 0.62)按全畫面中位對齊後,臉被壓到 0.08,主體全黑。所以它預設 off,只在「同機位同光比的連拍裡曝光不一致」時才該打開。判斷依據很簡單:這張片的主體亮度和背景亮度的關係,是否和參考片一致。 - 一幀裡主體比背景暗、而參考幀裡主體最亮時,任何全域單調映射都兩頭討好不了。要兩者都滿足只能做局部調整。這種情況下優先保主體,接受背景變亮;反過來保背景會把臉壓死。
- 素材已有的暖偏會和暖調風格疊加成橙紅。一張本身偏暖 0.46 檔的暗調人像,套上暖調 look 後 face 從暖棕變橙紅、cast 衝到 0.77,臉頰出現色斑感。這時不要先做白平衡中和——畫面裡唯一的"中性色"往往是同一盞燈照亮的牆,按它校正會把牆推綠、膚色推黃綠(實測連試 0.5/0.75 兩檔都更糟)。正確順序是:先降
--strength(0.6 附近),仍不滿意就放棄套色、改走純亮度曲線或線性曝光增益——只動明暗不動色相,暖調原樣保留。 - JPEG 每存一次就少一點,且不可逆。交付走 q98 一次成型;還要再改就從
__master.png改,不要在已經存過的 JPG 上反覆疊。同一個 LUT 存三遍 q95,PSNR 從 48.7 掉到 48.6 看著不多,但每次都會在平滑漸變(被抬亮的天空、背景虛化)裡積一層塊狀台階。判定丟的是誰:源圖先被壓過(<0.10 位元組/像素)就認源頭,別在編碼參數上找原因。
性能基準
| 場景 | 10.8 MP 圖耗時 |
|---|---|
| 純全域渲染 | 3.6 s |
| 分區渲染(2 個區域生效) | 6.7 s |
| 分割推理(512px)+ 上取樣 | 0.7 s |
一次 fit(10 對圖,含分區與打標) | 約 12 s |
瓶頸在逐像素 LUT gather(8 次隨機讀 × 每像素),不在演算法分支。批量處理時用多行程按圖並行最划算。
常見任務
| 用戶說法 | 做法 |
|---|---|
| 「照這張的色調調我那批照片」 | ref 生成風格 → apply 批量 |
| 「我這些是修好的,以後都按這個風格」 | fit --src 原圖 --tgt 修後 → apply |
| 「又修了一批,也按這個風格來」 | fit 時 --name 指向同一風格,自動併入並重擬合 |
| 「只壓天空,別動地面和臉」 | 預設已啟用分區;確認 regions.active 含 sky |
| 「這風格太濃了」 | apply --strength 0.6,不用重新擬合 |
| 「這版不如上版」 | apply --version <舊版本號> |
| 「上一批弄錯了,撤掉」 | batches 找到 batch_id → undo |
| 「這批照片該用哪個風格」 | apply --style auto,看 auto.ranking |
| 「這個風格到底調了什麼」 | styles --name X,讀 description.summary |
| 「導出給達芬奇用」 | export --full;提醒錨定 sRGB gamma 2.2、分區層要配軟蒙版 |
| 「逆光/日出,太陽別被壓灰」 | 預設已做(過曝保護);若仍偏灰檢查太陽是否真的過曝 |
| 「這張調得對不對,問題在哪」 | check --in X --person,把 warnings 逐條轉述,別自己加判斷 |
| 「這是不是 log 素材」 | check --in X,看 transfer.verdict 與 transfer.signals |
| 「這個風格套上去會不會燒高光」 | apply 的 look 已給前後對比,warnings 會直接點出來 |
| 「這張比參考亮太多,套完發白」 | 先看 look.median_luma 落差;收 --strength,或同光比連拍用 --match-key on |
| 「主體比背景暗,套完主體黑了」 | 不要開 --match-key;這是參考片與這張片的光比不同,只能局部調整 |
| 「這張本身就偏暖,套完臉發橙」 | 降 --strength 到 0.6 先試;別做白平衡中和(會把牆推綠),見紅線 16 |
參考
references/style-schema.md:風格目錄、style.json欄位、版本與批次語義references/color-pipeline.md:色彩管理、演算法細節、已知局限與調參建議