docs: note the highlight/exposure pass order bug

HIGHLIGHT -10 then EXPOSURE +10 brought the bright end straight back to 1.0:
a SkPaint runs its shader before its colourFilter, so the exposure matrix on
the same paint as the tone shader landed after the tone pass. The note pins
the three places that carry the wrong order (exportEngine.ts:328/354,
Viewfinder.tsx:1207/1303, RecipescamExportModule.kt steps 2-4), the numbers
measured on the fixed web engine, and the matrix -> tone -> cinema fix.
This commit is contained in:
2026-09-18 10:34:27 +07:00
parent 3320513d7a
commit 2bfe8f1211
+234
View File
@@ -0,0 +1,234 @@
# 4 — FIX: HIGHLIGHT bị EXPOSURE kéo ngược (thứ tự pass màu sai)
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
Nguồn đối chiếu: nhánh `recipes-web`, commit **`8e6c149`** — bản web đã sửa và đo được.
---
## 1. Hiện tượng
Kéo **HIGHLIGHT −10** rồi **EXPOSURE +10**: vùng sáng đã được ghì xuống lại bị đẩy
trở lại mức cháy (1.0). Nhìn như thanh HIGHLIGHT "chết" — càng tăng EXPOSURE càng
mất khả năng giữ highlight.
---
## 2. Nguyên nhân gốc
Skia áp **shader TRƯỚC colorFilter** trong cùng một `SkPaint`. Ma trận màu
(chứa `adjustments.exposure` + gain EV) và tone shader đang nằm trên **cùng một
paint**, nên gain exposure chạy **sau** pass tone:
1. Pass tone: pixel sáng 1.0 → HIGHLIGHT −10 ghì về **0.78**
2. Ma trận exposure: **×1.2 + 0.15** → 1.086 → **kẹp về 1.0**
Thanh HIGHLIGHT vẫn chạy đúng — nó chỉ bị pass sau xoá sạch kết quả.
### 2.1 `src/utils/exportEngine.ts` (engine JS/Skia — đường export)
```ts
// dòng 328
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
paint.setColorFilter(colorFilter);
// dòng 330 — comment mô tả SAI thứ tự thực tế:
// "3b. Tone shader — DR / Highlight / Shadow curve on top of the matrix."
// dòng 354
paint.setShader(toneShader);
```
Comment nói tone nằm "on top of the matrix", nhưng Skia làm ngược lại.
### 2.2 `src/components/Viewfinder.tsx` (preview camera trực tiếp)
Cùng lỗi, cùng một paint:
```ts
// dòng 1207
paint.setColorFilter(baseColorFilter);
// dòng 1303
paint.setShader(fullShader);
```
Khối HDF (dòng ~1339) còn **cố tình nhân ma trận màu lần thứ hai**:
```ts
Skia.ImageFilter.MakeCompose(
glowBlur,
keep(Skia.ImageFilter.MakeColorFilter(baseColorFilter, null)) // "bloom lands on GRADED pixels"
)
```
→ vệt bloom cũng bị đẩy sáng sai. Sau khi sửa phải bỏ phần nhân lại này.
### 2.3 `modules/recipescam-export/.../RecipescamExportModule.kt` (module native Kotlin)
Thứ tự pass hiện tại là **tone → cinema → matrix**, tức matrix chạy **cuối** — đúng
cùng một lỗi:
| Dòng | Pass |
|---|---|
| 423–429 | `// 2. Tone pass` → `applyTone(bmp, dr, hl, sh)` |
| 434–438 | `// 3. Cinema seasonal grade` → `applyCinema(bmp, cinemaFlat)` |
| 443–452 | `// 4. Color matrix via paint pass` → `ColorMatrixColorFilter` |
| 423–426 | comment: "Engine order: the tone shader samples the base image, cinema samples the tone output, **then the color matrix filter applies on top**" |
---
## 3. Bằng chứng đo được (bản web, trước và sau khi sửa)
Nguồn: ảnh gradient dọc 0.98 → 0.30 (320×240 JPEG), đo p95 (vùng sáng) và trung vị
(midtone) của ảnh render. Engine thật, không phải mô phỏng.
| Trường hợp | top (p95) | mid |
|---|---|---|
| nguồn | 1.000 | 0.502 |
| HIGHLIGHT −10 | 0.780 | 0.518 |
| EXPOSURE +10 | 1.000 | 0.769 |
| HIGHLIGHT −10 + EXPOSURE +10 — **một paint (lỗi)** | **1.000** | 0.722 |
| HIGHLIGHT −10 + EXPOSURE +10 — **hai pass (đã sửa)** | **0.780** | 0.722 |
Sau khi sửa: midtone vẫn sáng lên (0.502 → 0.722) nhưng vùng sáng giữ nguyên 0.780.
---
## 4. Cách sửa
**Nguyên tắc:** ma trận màu phải tạo ra **ảnh đã grade** trước, rồi tone/cinema shader
mới lấy ảnh đó làm con. Đúng một câu: *matrix → tone → cinema*.
### 4.1 `src/utils/exportEngine.ts`
Thêm helper (đặt cạnh `screenSharpenImage`), rồi đổi bước 3:
```ts
// Ma trận màu, áp riêng vào một ảnh mới. SkPaint chạy shader TRƯỚC colorFilter,
// nên pixel đã grade phải tồn tại như một ảnh trước khi tone/cinema đọc nó.
function gradeThrough(image: any, w: number, h: number, colorFilter: any): any {
const surf = createSurface(w, h);
if (!surf) return null;
try {
const p = Skia.Paint();
p.setColorFilter(colorFilter);
surf.getCanvas().drawImage(image, 0, 0, p);
p.dispose();
return surf.makeImageSnapshot() ?? null;
} finally {
surf.dispose();
}
}
```
Trong `renderPhoto`:
```ts
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
const matrixIsIdentity = evMatrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
const tone = getToneUniforms(adjustments, recipe.baseFilter);
const cinemaOn = /* cinema && cinemaIsActive(cinema.flat) */;
let toneShader: any = null;
let paintShader: any = null;
// Ma trận phải thành ảnh riêng khi có pass phía sau đọc lại pixel.
let gradeImage = skImage;
const graded = (toneIsActive(tone) || cinemaOn) && !matrixIsIdentity;
if (graded) {
const snapshot = gradeThrough(skImage, width, height, colorFilter);
if (snapshot) { own(snapshot); gradeImage = snapshot; }
}
const imageShaderOf = () =>
own(gradeImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None));
if (toneIsActive(tone)) {
toneShader = own(toneEffect.makeShaderWithChildren(toneUniformArray(tone), [imageShaderOf()]));
}
if (cinemaOn) {
const cs = own(cinemaEffect.makeShaderWithChildren(cinema.flat, [toneShader ?? imageShaderOf()]));
if (cs) paintShader = cs;
}
if (!paintShader && toneShader) paintShader = toneShader;
// Chỉ MỘT trong hai được tô màu pixel: ảnh đã grade đã mang ma trận rồi,
// chỉ nhánh chưa grade mới cần colorFilter.
if (paintShader) paint.setShader(paintShader);
else if (!graded) paint.setColorFilter(colorFilter);
```
Và trong bước HDF (5b): bloom đọc pixel đã grade, nên **bỏ lần áp ma trận thứ hai**:
```ts
const srcShader = paintShader ?? imageShaderOf();
const bloomGrade = (inner?: any) =>
graded ? undefined : own(Skia.ImageFilter.MakeColorFilter(inner ?? colorFilter, null));
// ...
glowPaint.setImageFilter(grade ? own(Skia.ImageFilter.MakeCompose(blur, grade)) : blur);
```
Hai điều kiện đệm: khi **không có tone/cinema** hoặc **ma trận là identity** thì
`graded === false` → không tạo surface thừa, giữ nguyên đường vẽ cũ.
### 4.2 `src/components/Viewfinder.tsx` (preview mỗi frame)
Cùng nguyên tắc, nhưng đây là đường chạy **mỗi frame** nên phải cân nhắc chi phí:
ma trận phải được vẽ vào một surface phụ (hoặc texture trung gian) **trước**, rồi
tone/cinema shader lấy ảnh đó làm `imageShader` con.
- Đổi `paint.setColorFilter(baseColorFilter)` + `paint.setShader(fullShader)` trên
cùng một paint thành: grade trước vào texture `gradedTex`, `fullShader` dựng từ
`gradedTex.makeShaderOptions(...)`.
- `hasTone === false && hasCinema === false` (hoặc ma trận identity): giữ đường vẽ
một-pass như hiện tại, đừng thêm surface.
- Bỏ `MakeColorFilter(baseColorFilter, null)` khỏi chuỗi imageFilter của glow —
bloom giờ đọc thẳng pixel đã grade.
- **Đo lại fps sau khi sửa**: thêm 1 pass fullscreen/frame. Nếu tụt quá ngưỡng
chấp nhận, phương án rẻ hơn là nhân hệ số gain exposure **bên trong SKSL của tone
shader** (trước khi áp curve) — nhưng chỉ khi vẫn giữ được thứ tự *film matrix →
curve*; đừng quay lại nhét exposure vào sau curve.
### 4.3 `RecipescamExportModule.kt` (native)
Đổi thứ tự các bước cho khớp: **matrix → tone → cinema**. Matrix (bước "4." hiện
tại) phải chạy trước bước tone "2.":
```kotlin
// 2. Color matrix (grade phim + exposure) — PHẢI chạy trước tone.
val androidMatrix = FloatArray(20) { i -> if (i % 5 == 4) matrix[i] * 255f else matrix[i] }
val out = Bitmap.createBitmap(bmp.width, bmp.height, Bitmap.Config.ARGB_8888)
Canvas(out).drawBitmap(bmp, 0f, 0f, Paint().apply { colorFilter = ColorMatrixColorFilter(androidMatrix) })
bmp.recycle()
bmp = out
// 3. Tone (DR / highlight / shadow) trên pixel đã grade.
if (toneActive) applyTone(bmp, toneDr.toFloat(), toneHl.toFloat(), toneSh.toFloat())
// 4. Cinema seasonal grade.
if (cinemaActive) applyCinema(bmp, cinemaFlat)
```
`ensureMutable(bmp)` giữ nguyên vị trí (trước tone), và sửa lại comment dòng 423–426
— bỏ câu "then the color matrix filter applies on top".
---
## 5. Kiểm thử (bắt buộc trước khi coi là xong)
1. Nguồn: gradient dọc 0.98 → 0.30 (320×240 JPEG) — có headroom để thấy highlight.
2. Export với `highlight = -10`, `exposure = +10`.
3. Kỳ vọng: **p95 ≈ 0.78** (KHÔNG được 1.00), midtone tăng rõ so với ảnh gốc.
4. Chạy đủ **cả ba đường** (export engine JS, preview camera, module native Kotlin)
và so ảnh với nhau — parity gate của `PLAN.md`.
5. Đối chiếu với bản web: `recipes-web` @ `8e6c149`, file
`docker/frontend/src/engine/exportEngine.ts` (helper `gradeThrough`).
---
## 6. Ghi chú phụ
- Lỗi này **không** nằm ở công thức tone (`toneShader.ts`) hay ma trận màu
(`colorUtils.ts`) — cả hai đều đúng. Chỉ sai **thứ tự thực thi**.
- `HIGHLIGHT +10` không lộ lỗi vì kết quả dù sao cũng kẹp về 1.0 — chỉ nhánh âm mới
nhìn thấy. Đừng "kiểm thử" bằng HIGHLIGHT dương.
- Bất kỳ pass nào khác đọc lại pixel sau tone/cinema (bloom HDF, grain, vignette)
đều phải đọc **ảnh đã grade**, và ma trận không được áp lần hai.