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:
@@ -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.
|
||||
Reference in New Issue
Block a user