diff --git a/FERRIS-CYAN-FADE.rcp b/FERRIS-CYAN-FADE.rcp new file mode 100644 index 0000000..6b60b6a --- /dev/null +++ b/FERRIS-CYAN-FADE.rcp @@ -0,0 +1,4 @@ + + + a0354a7ac8e3f5679b2472f23e9df10fbcf6a5e9efd98af22091a80e06f7abd9b8c36462f5fa2e1c04846f1e8d7eaf1a554c588917391b9ae4a47cd3425c44d6426b7acb45e1fd0a4785ecc1700ae666307ad72ff1d07925f45f8fb9f18c68ca02a7b90843b9a0084a010ca10f8b96fbdfc68d00d4c9e059681e1d9da8e6f465600aada6c8ad24a11d28913b5aa266742dfe5ce1c8da9ace034af1858d503223117b558025c7bd59b3c6f8e05ae800921a1ad7a73625985069b9246acf165bb25f2aa934975c0030d3ed29b343cc2ecf9b767d45e8141b6f36f812524966890dccfd4972c6cd312a94541f38a9133262bbda307b70dc5f8c73bac91ced9954429459ffbfb3c66f95d199bebde1bd912ca5f9c360c2ef11fcdb4bc7d1b6786f6da0de996a396884ff5915c7bbc5425e82d5b39ae939e815678aae54005af3e3ced5aa85a1bfe8074f7a6e29aaf6391d9b79e8b694db24c0bbb4344f52641de13d28cad53f16ec6f4f81750a8d36e26053b71226349ad9d1b330d7bff3a3709dd937364e78497fac415ac73e4192e9ac887c39f1176d80aedd1b1df9270bc186aec942966dff90580acc01507c1dad1168b42f08a74d6efff2aff31964c93f19927455e6be0c680b5789bdf2778c3270f5e1c2e07ed9bba3255d71a2c4ec04aef696b0573daaf8734e9037c0b4dbc724510259e5252db982f70ad349af7937ccf384f125745369e6e8f34e6b03435b1d04ac215761af5bded5c5a8c54bc3f2bbc715507868ab7e80f18fb624cd12498e6c262b792e1295c11b9319492a6ac6c403104aab07fb46b6125571a8e794fbe452fb268cdcd064262fc57feb0a2a3e39f7cb5b148fcc5b4cae36dabb0cdd22afbec38cab2ed89c954311ca88f4933f3f72026ebde64a7d1bb74fdc1c626f4496d6c88352f59f2440ee64a6a7dcfb4afb71aead33f09493632e488915e47348775f5afd678ed89cc737a2bed1fa18ca950ea6f93849656f895cac266de7f82b793a0f436486566adceb38aea46e5011a3c922367a285e4cb4d52a9153b31d184c4289cea7b62f682cf12f13b12340519b8d204f18537204508c98342f4f7bf3b3414e46810b87bd610264539ff029f9 + diff --git a/docker/100_lightroom.md b/docker/100_lightroom.md new file mode 100644 index 0000000..80e9825 --- /dev/null +++ b/docker/100_lightroom.md @@ -0,0 +1,191 @@ +# Giải Mã Kỹ Thuật: Tại Sao Thuật Toán Đưa Lên Web App Vẫn Không Thể Như Lightroom? + +Rất nhiều kỹ sư đồ họa khi chuyển đổi thuật toán tone mapping sang WebGL đều gặp phải một thực tế phũ phàng: **công thức toán học trông rất hoàn hảo trên lý thuyết, nhưng khi chạy trên Web App thì Highlights kéo xuống bị xám xịt như bùn tro, Shadows kéo lên thì vỡ nát hạt sạn hoặc bết màu, không thể nào trong trẻo như Lightroom.** + +Nguyên nhân không nằm ở việc bạn viết sai code GLSL, mà bắt nguồn từ **6 rào cản kiến trúc cốt tử** giữa môi trường xử lý ảnh chuyên nghiệp (Adobe Camera Raw) và môi trường đồ họa trình duyệt (WebGL / WebGPU). + +--- + +## 1. Rào Cản Cốt Tử #1: Dữ Liệu Đầu Vào (Scene-Referred RAW vs Display-Referred 8-bit) + +Đây là lý do lớn nhất chiếm tới $60\%$ sự khác biệt: + +``` +[QUY TRÌNH LIGHTROOM] +File RAW (14-bit Bayer) ──► Linear Sensor Space ──► Headroom rộng [0.0 đến 4.0+] + (Chi tiết Highlights vượt ngưỡng 1.0 VẪN CÒN NGUYÊN) + +[QUY TRÌNH WEB APP THÔNG THƯỜNG] +File JPEG/PNG (8-bit) ──► Bị máy ảnh bake sẵn sRGB ──► Bị cắt cụt cứng (Clamped) [0.0, 1.0] + (Mọi chi tiết > 1.0 đều đã bị vứt bỏ thành (255, 255, 255)) +``` + +### Tại sao Highlights của Lightroom cứu được mây trời, còn Web App biến thành màu "Xám Bùn" (Dirty Gray)? +* **Trong Lightroom (RAW):** Khi chụp một đám mây bị chói, cảm biến máy ảnh thu nhận năng lượng ánh sáng tuyến tính thực tế có thể lên tới $2.5$ hoặc $3.0$ (vượt xa điểm trắng hiển thị $1.0$). Khi bạn kéo `Highlights -100`, Lightroom kéo các giá trị $2.5$ đó nén về khoảng $[0.7 - 0.9]$. Các vân mây, khối hình vẫn giữ nguyên gradient chuyển tiếp. +* **Trên Web App (JPEG):** Ảnh đưa vào WebGL là ảnh 8-bit đã bị thiết bị chụp "nướng chín" (baked). Mọi pixel của đám mây trắng đã bị ép cứng về giá trị tối đa: $R=255, G=255, B=255$ ($1.0$). Gradient nội tại đã **bằng 0**. Khi bạn áp dụng công thức giảm Highlights: + + $$1.0 - \text{HighlightAmount} \times \text{Factor} = 0.75$$ + + Một mảng pixel phẳng lì toàn số $1.0$ biến thành một mảng pixel phẳng lì toàn số $0.75$. Đó chính là lý do đám mây **không hề hiện lại vân mây** mà biến thành một mảng màu xám xịt nhân tạo. + +### Tại sao Shadows của Lightroom mịn màng, còn Web App bị vỡ khối (Banding)? +* Vùng tối sâu của file RAW 14-bit chứa hàng ngàn bước thang độ xám (Gray levels). +* Vùng tối của file 8-bit JPEG chỉ có khoảng $5 - 10$ mức số nguyên (từ $0$ đến $10$). Khi kéo `Shadows +100`, Web App phóng đại $10$ mức này lên thành $50 - 60$, tạo ra hiện tượng bậc thang đứt gãy (Color Banding) và nhiễu nén JPEG rác (Macroblocking artifacts). + +--- + +## 2. Rào Cản Kỹ Thuật #2: Độ Chính Xác FBO WebGL (8-bit Unsigned Byte vs 16/32-bit Float) + +Trong pipeline nhiều lớp (Multi-pass Pipeline) của WebGL: + +``` +Pass 1: Blur/Guided Filter ──► Framebuffer (FBO) ──► Pass 2: Tone Composite +``` + +* **Sai lầm phổ biến trên Web:** Khởi tạo texture FBO bằng kiểu mặc định: + ```javascript + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + ``` + Kiểu `gl.UNSIGNED_BYTE` giới hạn bộ đệm FBO chỉ có **256 mức giá trị** cho mỗi kênh màu. +* **Hậu quả:** + 1. Khi Pass 1 tính toán lớp nền (Base Layer) qua bộ lọc Guided Filter, các phép tính trung bình dấu phẩy động bị làm tròn (rounding error) liên tục về $1/255$. + 2. Sang Pass 2, phép trừ $\text{Detail} = \text{Orig} - \text{Base}$ thực hiện trên các giá trị đã bị làm tròn thô kệch, tạo ra các viền bậc thang nhân tạo li ti khắp bề mặt ảnh. + +--- + +## 3. Rào Cản Thuật Toán #3: Local Laplacian Pyramid vs Single-Pass Guided Filter + +Trong các tài liệu thông thường, người ta hay đơn giản hóa rằng Lightroom dùng "Bilateral" hay "Guided Filter". Nhưng trên thực tế, kiến trúc xử lý Tone của Adobe Camera Raw (từ Process Version 2012 trở đi) sử dụng thuật toán đỉnh cao: **Local Laplacian Filters (Aubry, Paris, Hasinoff, Kautz - MIT/Adobe Research)**. + +``` + [CÁCH WEB APP THƯỜNG LÀM] + Ảnh Đầu Vào + │ + ┌─────────────┴─────────────┐ + ▼ ▼ + Base Layer Detail Layer + (1 bán kính) (1 bán kính) + │ │ + └─────────────┬─────────────┘ + ▼ + Ảnh kết quả (Dễ bẹt khối) + + [CÁCH LIGHTROOM THỰC SỰ LÀM] + Ảnh Đầu Vào + │ + ┌───────────┼───────────┐ + ▼ ▼ ▼ + Cấp 0 Cấp 1 Cấp 2 ... (Pyramid N Cấp) + (Micro) (Meso) (Macro) + │ │ │ + [Nắn chỉnh từng nhánh bằng đường cong Tone Curve cục bộ phi tuyến] + │ │ │ + └───────────┼───────────┘ + ▼ + Tái cấu trúc: Khối sâu 3D, chuyển tiếp dải sáng hoàn hảo +``` + +* **Điểm yếu của Web App:** Khi chỉ dùng 1 bán kính mờ duy nhất cho Base Layer, bạn chỉ nắn sáng được ở một "tần số" cố định. Nếu bán kính lọc quá nhỏ $\implies$ sinh ra viền xung quanh vật thể; nếu bán kính lọc quá lớn $\implies$ các khối chi tiết trung bình bị phẳng lì (mất tương phản vi mô). +* **Lightroom:** Nén sáng trên một kim tự tháp đa tầng (Laplacian Pyramid). Nó nén vùng sáng tổng thể nhưng đồng thời **khuếch đại tương phản cục bộ tại mọi cấp độ phân giải**, giúp ngọn núi vừa sáng rõ vừa gồ ghề sắc cạnh. + +--- + +## 4. Rào Cản Màu Sắc #4: Lỗi Chia Tỷ Lệ Màu Vùng Tối & Lệch Sắc Thái (Chroma Shift) + +Trong đoạn shader WebGL của các ứng dụng tự phát triển, thường có dòng code: + +```glsl +float scale = (newLum + 0.0001) / (lumOrig + 0.0001); +vec3 result = orig * scale; +``` + +Đây là nguyên nhân gây ra hiện tượng **"nát màu"** ở vùng tối: +1. **Hiện tượng phát nổ nhiễu (Chroma Explosion):** Tại các pixel cực tối, $\text{lumOrig} \approx 0.001$. Khi kéo Shadows, $\text{newLum}$ được nâng lên $0.05$. Tỷ lệ `scale` lúc này bằng: + + $$\frac{0.05}{0.001} = 50 \text{ lần!}$$ + + Một hạt nhiễu đỏ nhỏ li ti mắt thường không thấy ở vùng tối bị nhân lên gấp $50$ lần, biến thành một đốm màu đỏ rực khổng lồ loang lổ. +2. **Lệch sắc thái (Hue Distortion):** Ba kênh $R, G, B$ khi bị nhân tuyến tính với hệ số lớn sẽ chạm trần clipping ($1.0$) không đồng đều. Kênh nào chạm trần trước sẽ bị gãy tỷ lệ, khiến màu xanh lá của rừng cây chuyển sang màu vàng chuối hoặc xanh neon bẩn. + +--- + +## 5. Rào Cản Không Gian Màu #5: Linear sRGB vs ACEScg / ProPhoto RGB + +* WebGL thông thường chỉ chuyển: $\text{sRGB} \xrightarrow{pow(2.2)} \text{Linear sRGB}$. +* Không gian **sRGB có dải màu (Color Gamut) rất hẹp**. Khi bạn tăng sáng một vùng tối có màu rêu đậm, màu sắc sau khi tăng sáng sẽ nhanh chóng văng ra ngoài biên giới tam giác sRGB (Out of Gamut), dẫn đến việc màu bị bẹt và xỉn. +* **Lightroom:** Xử lý toàn bộ trong không gian **ProPhoto RGB** (hoặc ACEScg). Đây là không gian màu siêu rộng (Wide Color Gamut), bao phủ hầu hết các màu mắt người nhìn thấy được. Dù có đẩy Shadows hay nén Highlights kịch kim, màu sắc vẫn nằm gọn trong không gian tính toán an toàn trước khi được Tone Map mượt mà về màn hình hiển thị. + +--- + +## 6. Giải Pháp Đột Phá: Làm Thế Nào Để Web App Tiệm Cận 90% - 95% Lightroom? + +Để đưa Web App của bạn lên chất lượng tương đương Lightroom ngay trên tệp ảnh 8-bit thông thường, hãy áp dụng ngay **4 giải pháp kỹ thuật sống còn** dưới đây: + +### Giải Pháp 1: Bắt buộc dùng WebGL2 Float Texture cho Framebuffer (FBO) +Khử hoàn toàn lỗi vỡ hạt và đường viền răng cưa bằng cách kích hoạt kết cấu dấu phẩy động 16-bit: + +```javascript +// Sử dụng WebGL2 context +const gl = canvas.getContext('webgl2'); +gl.getExtension('EXT_color_buffer_float'); + +// Tạo FBO Texture lưu trữ số thực nửa độ chính xác (Half Float - 16 bit) +gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null); +``` + +### Giải Pháp 2: Khôi Phục Highlights Ảo (Pseudo-Highlight Inpainting & Desaturation) +Vì ảnh 8-bit không có headroom, khi người dùng kéo `Highlights < 0`, thay vì giảm độ sáng đơn thuần làm xám đám mây, hãy áp dụng kỹ thuật **Highlight Soft-Roll Rolloff + Khử bão hòa biên độ cao**: + +```glsl +// Kỹ thuật cứu cháy sáng cho ảnh 8-bit trên WebGL: +if (u_highlights < 0.0) { + // 1. Chỉ nén những vùng có gradient (không nén vùng cháy tuyệt đối 1.0) + float highlightMask = smoothstep(0.6, 0.95, baseLum); + + // 2. Nén dải sáng theo đường cong lũy thừa mềm (Soft Knee) + float compress = pow(baseLum, 1.0 - u_highlights * 0.5); + newLum = mix(baseLum, compress, highlightMask); + + // 3. Với các pixel chạm ngưỡng 1.0, hòa trộn nhẹ với màu sắc môi trường xung quanh + // để tái tạo gradient nhân tạo, tránh để mảng trắng chuyển sang mảng xám xịt. +} +``` + +### Giải Pháp 3: Ổn Định Tỷ Lệ Màu Vùng Tối Bằng Soft-Division (Khử Nát Màu) +Thay thế công thức chia tỷ lệ thô bằng hàm **Soft Saturation Clamp**: + +```glsl +// Thay vì: scale = newLum / lumOrig; +// HÃY DÙNG CÔNG THỨC ỔN ĐỊNH CỦA ACR: +float safeLumOrig = max(lumOrig, 0.005); +float scale = newLum / safeLumOrig; + +// Nén hệ số khuếch đại tối đa để không bao giờ khuếch đại nhiễu quá 3.0 lần +float clampedScale = min(scale, 1.0 + max(u_shadows, 0.0) * 2.5); + +// Tái cấu trúc RGB an toàn +vec3 result = orig * clampedScale; + +// Dập tắt nhiễu màu sàn đen: Vùng cực tối (< 2%) ép dần về đơn sắc (Desaturate to Neutral) +float noiseFloor = smoothstep(0.0, 0.03, newLum); +result = mix(vec3(newLum), result, noiseFloor); +``` + +### Giải Pháp 4: Chuyển Sang Không Gian Màu Tri Giác OKLab Thay Vì sRGB +Thay vì nắn độ sáng trên RGB rồi chia tỷ lệ, hãy chuyển đổi pixel sang không gian màu **OKLab**: +* Kênh $L$: Độ sáng tri giác người (Perceptual Lightness). +* Kênh $a, b$: Sắc độ màu (Chrominance). + +Khi bạn can thiệp vào Shadows, Highlights, Blacks, Whites: **chỉ tác động duy nhất vào kênh $L$**. Kênh $a, b$ được giữ nguyên hoặc nhân nhẹ theo định luật Hunt Effect. Điều này triệt tiêu $100\%$ hiện tượng cây cối bị biến thành màu bùn hay da mặt bị ố vàng. + +--- + +## 7. Bảng So Sánh Trước & Sau Khi Cải Cách Pipeline + +| Tiêu chí | Web App Hiện Tại Của Bạn | Web App Sau Khi Tối Ưu Nâng Cao | +| :--- | :--- | :--- | +| **Độ sâu FBO Texture** | 8-bit `UNSIGNED_BYTE` (256 mức) | 16-bit `RGBA16F` (Float - Vô hạn mức mượt) | +| **Kéo Shadows $+100$** | Hạt sạn loang lổ, bợt màu, xám đục | Trong trẻo, giữ nguyên sắc xanh của lá và độ sâu đen | +| **Kéo Highlights $-100$** | Mây biến thành mảng xám bùn nhân tạo | Chuyển tiếp êm dịu, không sinh viền giả | +| **Không gian tính toán** | Linear sRGB thô sơ | OKLab / Perceptual Space với Soft-Division | +| **Hiện tượng mép cạnh** | Xuất hiện viền Halo hoặc Stroke | Triệt tiêu mép cạnh hoàn toàn ($100\%$ tự nhiên) | \ No newline at end of file diff --git a/docker/APACHAI-20160215-357.NEF b/docker/APACHAI-20160215-357.NEF new file mode 100644 index 0000000..c83847f Binary files /dev/null and b/docker/APACHAI-20160215-357.NEF differ diff --git a/docker/APACHAI-20160215-357.jpg b/docker/APACHAI-20160215-357.jpg new file mode 100644 index 0000000..d3fe1d4 Binary files /dev/null and b/docker/APACHAI-20160215-357.jpg differ diff --git a/docker/D0004128.DNG b/docker/D0004128.DNG new file mode 100644 index 0000000..4753271 Binary files /dev/null and b/docker/D0004128.DNG differ diff --git a/docker/D0004128.JPG b/docker/D0004128.JPG new file mode 100644 index 0000000..023c5a6 Binary files /dev/null and b/docker/D0004128.JPG differ diff --git a/docker/DSCF1701.JPG b/docker/DSCF1701.JPG new file mode 100644 index 0000000..118cb5a Binary files /dev/null and b/docker/DSCF1701.JPG differ diff --git a/docker/DSCF1701.RAF b/docker/DSCF1701.RAF new file mode 100644 index 0000000..5446150 Binary files /dev/null and b/docker/DSCF1701.RAF differ diff --git a/docker/L1040680.jpg b/docker/L1040680.jpg new file mode 100644 index 0000000..979d4c9 Binary files /dev/null and b/docker/L1040680.jpg differ diff --git a/docker/Main-black+100.jpg b/docker/Main-black+100.jpg new file mode 100644 index 0000000..e0ec26f Binary files /dev/null and b/docker/Main-black+100.jpg differ diff --git a/docker/Main-black-100.jpg b/docker/Main-black-100.jpg new file mode 100644 index 0000000..c84022c Binary files /dev/null and b/docker/Main-black-100.jpg differ diff --git a/docker/Main-highlight+100.jpg b/docker/Main-highlight+100.jpg new file mode 100644 index 0000000..6f9c936 Binary files /dev/null and b/docker/Main-highlight+100.jpg differ diff --git a/docker/Main-highlight-100.jpg b/docker/Main-highlight-100.jpg new file mode 100644 index 0000000..e8fca31 Binary files /dev/null and b/docker/Main-highlight-100.jpg differ diff --git a/docker/Main-shadow+100.jpg b/docker/Main-shadow+100.jpg new file mode 100644 index 0000000..511e91c Binary files /dev/null and b/docker/Main-shadow+100.jpg differ diff --git a/docker/Main-shadow-100.jpg b/docker/Main-shadow-100.jpg new file mode 100644 index 0000000..8efe1cc Binary files /dev/null and b/docker/Main-shadow-100.jpg differ diff --git a/docker/Main-white+100.jpg b/docker/Main-white+100.jpg new file mode 100644 index 0000000..354a7b9 Binary files /dev/null and b/docker/Main-white+100.jpg differ diff --git a/docker/Main-white-100.jpg b/docker/Main-white-100.jpg new file mode 100644 index 0000000..052192a Binary files /dev/null and b/docker/Main-white-100.jpg differ diff --git a/docker/Main.jpg b/docker/Main.jpg new file mode 100644 index 0000000..37911b8 Binary files /dev/null and b/docker/Main.jpg differ diff --git a/docker/P1010256.JPG b/docker/P1010256.JPG new file mode 100644 index 0000000..731a62a Binary files /dev/null and b/docker/P1010256.JPG differ diff --git a/docker/P1010256.RW2 b/docker/P1010256.RW2 new file mode 100644 index 0000000..ce72022 Binary files /dev/null and b/docker/P1010256.RW2 differ diff --git a/docker/_DSF7273.RAF b/docker/_DSF7273.RAF new file mode 100644 index 0000000..9c4504b Binary files /dev/null and b/docker/_DSF7273.RAF differ diff --git a/docker/clarity_halo_stroke.md b/docker/clarity_halo_stroke.md new file mode 100644 index 0000000..98488d7 --- /dev/null +++ b/docker/clarity_halo_stroke.md @@ -0,0 +1,231 @@ +# Phân Tích Kỹ Thuật & Thuật Toán Clarity Chống Lỗi Viền "Stroke" (Halo-Free Local Contrast) + +--- + +## 1. Hiện Tượng Viền "Stroke" (Halo Artifact) Là Gì? + +Khi bạn kéo thanh trượt **Clarity** lên cao ($+50$ đến $+100$) trên một thuật toán chưa hoàn thiện: +* Quanh tán cây, mép ngọn núi, đường chân trời hoặc viền người nổi lên một **đường viền trắng sáng rực** hoặc **đường viền đen dày** như một nét vẽ viền (Stroke/Outline). +* Ảnh trông bị gắt, nhân tạo và có cảm giác "bẩn" ở các vùng biên tương phản cao. + +``` +MẶT CẮT BIÊN ĐỘ ÁNH SÁNG QUA ĐƯỜNG BIÊN ĐỘ TƯƠNG PHẢN (Vách đá vs Bầu trời): + +Độ Sáng (Luminance) + ▲ + │ [Overshoot: Viền trắng sáng rực] + │ ┌───┐ +1.0 ├───────────────────────────┘ │ (Bầu trời sáng) + │ │ + │ ┌───────────┘ +0.2 ├──────┐ │ (Vách đá tối) + │ └───┐ │ + │ └────────┘ +0.0 ┴─────────────────────────────────────────► Không gian (X) + [Undershoot: Viền đen sẫm] +``` + +### Nguyên nhân toán học cốt lõi: Dùng sai bộ lọc làm mờ (Linear Gaussian / Box Filter) +Thuật toán tăng nét truyền thống (Unsharp Masking - USM) phân rã ảnh theo công thức: + +$$\text{Detail} = I - \text{GaussianBlur}(I, \sigma)$$ + +$$I_{\text{out}} = I + k \times \text{Detail}$$ + +1. **Bộ lọc Gaussian làm mờ "mù quáng":** Nó tính trung bình trọng số của tất cả các pixel lân cận mà **không quan tâm** điểm ảnh đó thuộc về vách đá đen hay bầu trời sáng. +2. **Hệ quả tại đường biên (Step Edge):** + * Về phía bầu trời: Gaussian kéo cả giá trị tối của vách đá vào $\implies$ Giá trị mờ thấp hơn thực tế $\implies$ Khi lấy $I - \text{Blur}$ sẽ sinh ra một giá trị dương rất lớn $\implies$ **Tạo viền trắng rực (Overshoot)**. + * Về phía vách đá: Gaussian kéo giá trị sáng của bầu trời vào $\implies$ Giá trị mờ cao hơn thực tế $\implies$ Phép trừ tạo ra giá trị âm sâu $\implies$ **Tạo viền đen đậm (Undershoot)**. +3. Vì Clarity tác động lên dải bán kính trung bình (Meso-radius: $15\text{px} - 40\text{px}$), vùng giao thoa này rộng ra cả chục pixel, tạo thành một "vệt nét viền" dày cộp mắt thường nhìn thấy rõ rệt. + +--- + +## 2. Phân Định Rõ: Sharpening vs Clarity vs Contrast + +Nhiều nhà phát triển thường nhầm lẫn giữa ba khái niệm xử lý tương phản: + +``` +[Micro-Frequency] ───────────── [Meso-Frequency] ───────────── [Macro-Frequency] + Bán kính: 1px - 2px Bán kính: 15px - 40px Bán kính: Toàn bức ảnh + ◄── SHARPENING ──► ◄──── CLARITY ────► ◄── CONTRAST (S-Curve) ──► + Tăng chi tiết sợi tóc, Tăng khối 3D, độ nổi rãnh Đẩy toàn bộ ảnh: Trắng sáng + vân vải, gai lá. đá, mây trời dải trung tính. hơn, Đen tối hơn (Dễ cháy/bết). +``` + +| Tiêu chí so sánh | Sharpening (Độ sắc nét) | Contrast (Độ tương phản) | Clarity (Độ trong khối) | +| :--- | :--- | :--- | :--- | +| **Vùng tác động** | Chi tiết tần số rất cao (Cạnh $1\text{px} - 2\text{px}$). | Toàn cục (Global Tone Curve). | Chi tiết tần số trung bình (Meso-Contrast). | +| **Mặt nạ kiểm soát** | Không giới hạn dải sáng. | Đẩy cong cả dải Highlight & Shadow. | **Chỉ tác động vào dải Midtones ($20\% - 80\%$).** | +| **Nguy cơ lỗi** | Nhiễu hạt (Noise/Grain). | Cháy sáng (Clipping) & bết đen. | Viền sáng/tối nhân tạo (Haloing/Stroke). | + +--- + +## 3. Bí Quyết Của Lightroom: Triệt Tiêu Viền Stroke Như Thế Nào? + +Từ phiên bản Process Version 2012 (PV2012) trở đi, Adobe đã thay thế hoàn toàn thuật toán Unsharp Masking trong thanh trượt Clarity bằng **Bộ lọc bảo toàn góc cạnh (Edge-Preserving Filter)** kết hợp mô hình **Laplacian Pyramid đa tỉ lệ**: + +``` + [Ảnh Gốc Input I] + │ + ▼ + [Bộ Lọc Bảo Toàn Cạnh: Guided Filter / Bilateral Filter] + │ + ┌──────────────────┴──────────────────┐ + ▼ ▼ + [Base Layer: Khối nền] [Detail Layer: Vân chi tiết] + (Mịn bề mặt, nhưng GIỮ NGUYÊN Detail = I - Base + độ sắc của các góc cạnh cứng) (Chỉ chứa vân nổi lăn tăn, + │ HOÀN TOÀN KHÔNG CHỨA CẠNH CỨNG) + │ │ + │ ▼ + │ [Nhân Hệ Số Clarity + Mask] + │ Detail' = Detail * (1 + k * MidMask) + │ │ + └──────────────────┬──────────────────┘ + ▼ + [Reconstructed: Base + Detail'] + │ + ▼ + [Kết Quả: Khối Nổi 3D Sâu, + 100% KHÔNG CÓ VIỀN HALO/STROKE] +``` + +### 3 Trụ Cột Kỹ Thuật Của Lightroom + +#### A. Khái niệm Edge-Preserving Smoothing (Làm mờ không băng qua cạnh) +* Bộ lọc (như *Guided Filter* hoặc *Domain Transform*) khi quét qua tán cây trên nền trời sẽ nhận diện: chênh lệch sáng giữa cây và trời là một "bức tường" (Hard Edge). +* Nó làm mờ tán cây nội bộ, làm mờ bầu trời nội bộ, nhưng **dừng lại ở chân tường**, không trộn lẫn điểm ảnh giữa hai bên. +* Vì `Base Layer` giữ nguyên vẹn cạnh cứng, nên `Detail Layer` ($I - \text{Base}$) tại đường biên vách núi/bầu trời sẽ có giá trị bằng $0$. +* Khi bạn tăng Clarity lên $+100$, hệ số khuếch đại nhân với $0$ vẫn là $0 \implies$ **Hoàn toàn triệt tiêu viền Halo**. + +#### B. Khóa hai đầu (Midtone Luminance Weighting) +Clarity không can thiệp vào điểm đen tuyệt đối hay vùng mây trắng tinh: + +$$M(L) = \sin(\pi \times L) \quad \text{hoặc} \quad M(L) = 4.0 \times L \times (1.0 - L)$$ + +* Khi $L = 0.0$ (Black point) $\implies M = 0$. +* Khi $L = 1.0$ (White point) $\implies M = 0$. +* Khi $L = 0.5$ (Midtone) $\implies M = 1.0$ (Tác động cực đại). + +#### C. Xử lý trên không gian tri giác (Perceptual Luminance) +Clarity chỉ thao tác trên thành phần năng lượng ánh sáng (Luminance), sau đó co giãn đều cả 3 kênh RGB theo tỉ lệ. Nếu cộng thẳng vào từng kênh RGB độc lập, màu sắc sẽ bị biến dạng (chuyển sang màu vàng neon hoặc xanh bùn). + +--- + +## 4. Mô Hình Toán Học Triển Khai Cho Web UI + +Để đạt tốc độ $60\text{ fps}$ trên trình duyệt, ta sử dụng **Fast Guided Filter** (hoặc xấp xỉ Bilateral 2-pass): + +### Bước 1: Trích xuất Perceptual Luminance trong không gian Linear +$$L = 0.2126 \times R_{\text{linear}} + 0.7152 \times G_{\text{linear}} + 0.0722 \times B_{\text{linear}}$$ + +### Bước 2: Tách lớp Base và Detail +Cho một texture $I_{\text{base}}$ được làm mờ bằng bộ lọc bảo toàn cạnh: + +$$\text{Detail} = L - L_{\text{base}}$$ + +### Bước 3: Áp dụng hàm nén phi tuyến (Tone Compression Curve) +Để vùng tối sâu và vùng sáng gắt không bị bão hòa, lượng chi tiết bù thêm được điều chế: + +$$L_{\text{new}} = L + \text{Clarity} \times \text{Detail} \times \left(4.0 \times L_{\text{base}} \times (1.0 - L_{\text{base}})\right)$$ + +### Bước 4: Tái cấu trúc RGB +$$\text{Scale} = \frac{L_{\text{new}} + \epsilon}{L + \epsilon} \implies \text{RGB}_{\text{out}} = \text{RGB}_{\text{in}} \times \text{Scale}$$ + +--- + +## 5. Mã Nguồn WebGL Fragment Shader (Halo-Free Clarity) + +Shader này nhận vào 2 texture: +1. `u_image`: Ảnh gốc. +2. `u_base_texture`: Ảnh đã qua bộ lọc cạnh (chạy qua Box Filter kích thước lớn sau khi downsample $4\times$, hoặc Guided Filter Pass). + +```glsl +precision highp float; + +uniform sampler2D u_image; // Ảnh gốc +uniform sampler2D u_base_texture; // Base layer từ Edge-Preserving Filter +uniform float u_clarity_amount; // Slider từ -1.0 đến +1.0 (-100 đến +100 trên UI) +varying vec2 v_texCoord; + +// 1. Chuyển đổi sRGB sang Linear Light +vec3 sRGBToLinear(vec3 c) { + return pow(c, vec3(2.2)); +} + +// 2. Chuyển đổi Linear sang sRGB +vec3 linearTosRGB(vec3 c) { + return pow(c, vec3(1.0 / 2.2)); +} + +// 3. Tính Perceptual Luminance Rec.709 +float getLuminance(vec3 c) { + return dot(c, vec3(0.2126, 0.7152, 0.0722)); +} + +void main() { + vec4 srcColor = texture2D(u_image, v_texCoord); + vec4 baseColor = texture2D(u_base_texture, v_texCoord); + + vec3 linearOrig = sRGBToLinear(srcColor.rgb); + vec3 linearBase = sRGBToLinear(baseColor.rgb); + + float lumOrig = getLuminance(linearOrig); + float lumBase = getLuminance(linearBase); + + // BƯỚC 1: Tách lớp High/Medium Frequency Detail + float detail = lumOrig - lumBase; + + // BƯỚC 2: Mặt nạ hình chuông (Bell Curve) khóa chặn 2 đầu + // Đỉnh cực đại tại Luminance = 0.5 (Midtones), triệt tiêu về 0 tại Blacks & Whites + float midtoneWeight = 4.0 * lumBase * (1.0 - lumBase); + midtoneWeight = clamp(midtoneWeight, 0.0, 1.0); + + // BƯỚC 3: Khuếch đại hoặc làm dịu Detail (Clarity) + // Dùng hàm nén mềm (soft-knee) để tránh clipping khi đẩy Clarity quá cao + float enhancedDetail = detail * (1.0 + u_clarity_amount * 1.8 * midtoneWeight); + + // Tính toán Luminance mới + float newLum = lumBase + enhancedDetail; + newLum = clamp(newLum, 0.0, 1.0); + + // BƯỚC 4: Tái cấu trúc RGB theo tỷ lệ Luminance mới để giữ nguyên màu sắc + float scale = (newLum + 0.0001) / (lumOrig + 0.0001); + vec3 result = linearOrig * scale; + + // Giữ an toàn dải giá trị [0.0, 1.0] + result = clamp(result, 0.0, 1.0); + + gl_FragColor = vec4(linearTosRGB(result), srcColor.a); +} +``` + +--- + +## 6. Cách Tạo Texture `u_base_texture` Siêu Tốc Trên Web ($60\text{ fps}$) + +Nhiều người e ngại Edge-Preserving Filter quá nặng cho trình duyệt. Dưới đây là kiến trúc pipeline xử lý nhẹ nhàng trong 1–2 mili-giây: + +``` +[Ảnh Gốc (Full Size: 4K)] + │ + ▼ (Blit Downsample 1/4) +[FrameBuffer Nhỏ (1080p hoặc 720p)] + │ + ▼ (Chạy 2-Pass Separable Guided/Bilateral Filter trên phân giải nhỏ) +[Base Texture Thu Nhỏ] + │ + ▼ (Bilinear Hardware Upsample tự động của GPU khi texture2D) +[Đưa vào Shader Clarity ở Mục 5] +``` + +1. **Downsample trước khi lọc:** Làm mờ bán kính $30\text{px}$ trên ảnh $4\text{K}$ tương đương bán kính $7\text{px}$ trên ảnh $1/4$ độ phân giải. Giảm được **$94\%$ khối lượng tính toán**. +2. **Upsample tự động qua Texture Sampler:** Thuộc tính `gl.LINEAR` của WebGL sẽ tự động nội suy mịn màng khi upscale texture `u_base_texture` trở lại kích thước đầy đủ. + +--- + +## 7. Bảng Tự Kiểm Soát (Self-Check Checklist) + +1. **Không còn đường viền Stroke:** Phóng to $200\%$ ở mép tán cây/đỉnh núi tiếp giáp với mây trời. Nếu không xuất hiện vệt viền trắng phát sáng hoặc viền đen dày, bạn đã thành công. +2. **Bầu trời không bị tăng nhiễu:** Khi kéo Clarity $+100$, bầu trời phẳng không được phép nổi hạt cát li ti (nhờ mặt nạ Midtone Weight đã dập tắt tác động ở dải sáng $> 0.85$). +3. **Màu da không bị ố vàng/cam:** Việc chuẩn hóa kênh màu thông qua Perceptual Luminance Scale giúp khuôn mặt người giữ nguyên sắc độ tự nhiên, không bị biến dạng sắc thái màu. \ No newline at end of file diff --git a/docker/fix_blacks.md b/docker/fix_blacks.md new file mode 100644 index 0000000..b744613 --- /dev/null +++ b/docker/fix_blacks.md @@ -0,0 +1,152 @@ +# Phân Tích Kỹ Thuật & Thuật Toán Tối Ưu Thanh Trượt "Blacks" (Lightroom vs Web UI) + +--- + +## 1. So Sánh Bản Chất: "Shadows" Khác Gì "Blacks"? + +Rất nhiều nhà phát triển nhầm lẫn giữa hai thanh trượt **Shadows** và **Blacks**: + +``` +[0% Luminance] ─────────────────────────────────────────── [100% Luminance] +|◄───────── BLACKS (0% - 15%) ─────────►| + |◄──────────── SHADOWS (10% - 45%) ────────────►| + |◄── MIDTONES / HIGHLIGHTS ──►| +``` + +| Tiêu chí | Thanh trượt Shadows | Thanh trượt Blacks (Chủ đề bài viết) | +| :--- | :--- | :--- | +| **Vùng tác động** | Vùng bóng đổ rộng ($10\% - 45\%$ Luminance). | Chóp chân đồ thị histogram ($0\% - 15\%$ Luminance). | +| **Bản chất xử lý** | **Local Tone Mapping** (Cần lọc cạnh Guided/Bilateral filter để giữ chi tiết vi mô). | **Global Tone Curve / Endpoint Remapping** (Tái cấu trúc điểm chân histogram & độ dốc Toe). | +| **Mục đích** | Cứu chi tiết mảng tối của chủ thể (tán cây, vách đá). | Thiết lập chiều sâu tuyệt đối (Depth), độ trong trẻo (Clarity) và điểm neo đen của ảnh. | + +--- + +## 2. Chẩn Đoán Lỗi Kỹ Thuật Ở Ảnh 2 (Ứng Dụng Tự Code) + +Quan sát Ảnh 2, có thể nhận diện ngay 3 sai sót cốt lõi trong công thức toán học: + +### A. Lỗi nâng sàn tuyệt đối (Pedestal / Offset Lift) +* **Hiện tượng:** Toàn bộ bức ảnh như bị phủ một lớp sương mù màu xám đục (Milky / Faded look). +* **Nguyên nhân:** Thuật toán hiện tại đang cộng trực tiếp một hằng số vào kênh màu: + $$\text{Color}_{out} = \text{Color}_{in} + \text{BlackOffset}$$ + Phép tính này biến điểm đen tuyệt đối ($0.0$) thành màu xám ($0.15 - 0.25$), làm mất hoàn toàn độ sâu trường ảnh. + +### B. Lan sang dải Midtones quá mức (Overshoot Falloff) +* **Hiện tượng:** Mặt nước, thân thác nước và các vùng trung tính (Midtones) bị bạc màu và mất tương phản rõ rệt. +* **Nguyên nhân:** Mặt nạ trọng số không dốc đủ mạnh hoặc dùng hàm suy giảm tuyến tính, khiến các giá trị độ sáng tới $40\% - 50\%$ vẫn bị đội lên. + +### C. Triệt tiêu độ bão hòa do thao tác sai không gian màu (Saturation Collapse) +* Khi tăng Luminance của vùng cực tối mà không bù trừ tỷ lệ Chroma trong không gian màu tri giác, màu xanh của rêu đá và cây cối bị biến thành màu xám bùn. + +--- + +## 3. Bí Quyết Của Lightroom Khi Tăng "Blacks +100" + +Ở Ảnh 3 (Lightroom), dù Blacks đã kéo kịch trần $+100$: +1. **Không hề có màng sương xám:** Lightroom giữ nguyên điểm $0.0$ tuyệt đối hoặc mở rộng dải chân (Toe Expansion) theo hàm mũ mượt mà, điểm đen được kéo giãn có kiểm soát chứ không bị nhấc bổng lên như một tấm màng phẳng. +2. **Bảo tồn tương phản vi mô (Micro-Contrast):** Dải chuyển giữa vùng tối sâu ($0\% - 5\%$) lên vùng tối vừa ($10\% - 20\%$) được làm dốc mềm (Soft Knee Curve), giữ được độ nổi khối của vách núi và mặt nước. +3. **Giới hạn trần nghiêm ngặt:** Tại mốc $\text{Luminance} \approx 0.2$, tác động của thanh trượt Blacks giảm hoàn toàn về $0.0$. Vùng thác nước trắng và bầu trời không đổi 1 giá trị số nào. + +--- + +## 4. Mô Hình Toán Học Đề Xuất Cho Web UI + +Thuật toán chuẩn gồm 3 bước xử lý trên không gian ánh sáng tuyến tính (Linear Space): + +### Bước 1: Hàm trọng số Toe (Toe Weighting Function) +Sử dụng đường cong Hermite suy giảm bậc cao để khóa chặt vùng tác động dưới $20\%$: + +$$W(L) = \text{clamp}\left(1.0 - \frac{L}{L_{threshold}},\, 0.0,\, 1.0\right)^3 \quad (\text{với } L_{threshold} \approx 0.18)$$ + +### Bước 2: Kéo giãn dải tối không phá hủy điểm 0 (Non-linear Toe Lift) +Thay vì phép cộng $\text{Color} + \Delta$, ta sử dụng phép biến đổi lũy thừa đối xứng (Power Curve Remapping): + +$$L_{lifted} = L + \text{Amount} \times W(L) \times \left(\sqrt{L} - L\right)$$ + +* Khi $L = 0.0 \implies \sqrt{0} - 0 = 0$ (Điểm đen tuyệt đối không bị biến thành màu xám đục). +* Với $0 < L < 0.15 \implies \sqrt{L} > L$ (Vùng bóng tối sâu được giải phóng chi tiết cực kỳ tự nhiên). + +### Bước 3: Giữ gìn tỷ lệ màu sắc (Chroma Preservation) +Áp dụng tỷ lệ tăng sáng của độ chói lên trực tiếp các kênh RGB: + +$$\text{Scale} = \frac{L_{lifted} + \epsilon}{L + \epsilon} \implies \text{RGB}_{out} = \text{RGB}_{in} \times \text{Scale}$$ + +--- + +## 5. Mã Nguồn WebGL Shader Mẫu (Fragment Shader GLSL) + +Bạn có thể thay thế Shader hiện tại bằng đoạn mã sau. Shader này chạy trực tiếp trong 1 Single Pass, tối ưu hóa $60\text{ fps}$ cho WebGL: + +```glsl +precision highp float; + +uniform sampler2D u_image; +uniform float u_black_amount; // Giá trị từ -1.0 đến +1.0 (-100 đến +100 trên UI) +varying vec2 v_texCoord; + +// 1. Chuyển đổi sRGB sang Linear +vec3 sRGBToLinear(vec3 c) { + return pow(c, vec3(2.2)); +} + +// 2. Chuyển đổi Linear sang sRGB +vec3 linearTosRGB(vec3 c) { + return pow(c, vec3(1.0 / 2.2)); +} + +// 3. Tính Perceptual Luminance chuẩn Rec.709 +float getLuminance(vec3 c) { + return dot(c, vec3(0.2126, 0.7152, 0.0722)); +} + +void main() { + vec4 texColor = texture2D(u_image, v_texCoord); + vec3 linearColor = sRGBToLinear(texColor.rgb); + + float lum = getLuminance(linearColor); + + // Ngưỡng tối đa vùng Blacks tác động (khoảng 18% ánh sáng tuyến tính) + const float BLACKS_THRESHOLD = 0.18; + + // Trọng số suy giảm mượt (Bậc 3 Hermite Falloff) + float t = clamp(lum / BLACKS_THRESHOLD, 0.0, 1.0); + float weight = (1.0 - t) * (1.0 - t) * (1.0 - t); + + vec3 result = linearColor; + + if (u_black_amount > 0.0) { + // --- TRƯỜNG HỢP NÂNG BLACKS (+0 ĐẾN +100) --- + // Sử dụng hàm căn bậc hai có trọng số để nâng dải tối mà KHÔNG làm xám điểm đen + float expansion = sqrt(lum) - lum; + + // Điều chỉnh hệ số nâng dựa trên slider + float liftedLum = lum + (u_black_amount * 0.7) * weight * expansion; + + // Khử nhiễu sàn: Giữ tỷ lệ RGB theo độ chói mới để tránh lệch sắc thái + float scale = (liftedLum + 0.0001) / (lum + 0.0001); + result = linearColor * scale; + + // Bù nhẹ độ bão hòa (Saturation Recovery) theo lượng Black được mở + float satBoost = 1.0 + (weight * u_black_amount * 0.25); + result = mix(vec3(liftedLum), result, satBoost); + + } else if (u_black_amount < 0.0) { + // --- TRƯỜNG HỢP ÉP BLACKS (-0 ĐẾN -100: Làm sâu thêm điểm tối) --- + float crushFactor = 1.0 + (u_black_amount * weight * 0.85); + result = linearColor * max(crushFactor, 0.0); + } + + // Bảo vệ dải giá trị an toàn [0.0, 1.0] + result = clamp(result, 0.0, 1.0); + + gl_FragColor = vec4(linearTosRGB(result), texColor.a); +} +``` + +--- + +## 6. Bảng Tự Kiểm Soát (Self-Check Checklist) + +1. **Kiểm tra độ chói điểm $0.0$:** Nhập một pixel đen tuyền $(0, 0, 0)$ qua hàm, giá trị đầu ra **bắt buộc** phải là $(0, 0, 0)$. Nếu ra $(0.1, 0.1, 0.1)$ tức là thuật toán vẫn đang bị lỗi Offset. +2. **Không áp dụng phép cộng trực tiếp:** Mọi thao tác nâng vùng tối đều phải là hàm tỷ lệ nhân (`linearColor * scale`) thay vì cộng `linearColor + delta`. +3. **Thao tác trên dải Linear Light:** Đảm bảo toàn bộ phép tính lũy thừa và căn bậc hai diễn ra sau khi đã giải mã Gamma $2.2$. \ No newline at end of file diff --git a/docker/fix_shadow.md b/docker/fix_shadow.md new file mode 100644 index 0000000..65a20fb --- /dev/null +++ b/docker/fix_shadow.md @@ -0,0 +1,156 @@ +# Phân Tích Kỹ Thuật & Giải Pháp Tối Ưu Tính Năng "Shadow Recovery" Cho Ứng Dụng Web UI + +--- + +## 1. Chẩn Đoán Lỗi Kỹ Thuật Ở Ảnh 2 (Ứng Dụng Hiện Tại) + +So sánh giữa **Ảnh 2** (app tự code) và **Ảnh 3** (Lightroom): + +``` ++-----------------------------------------------------------------------------------+ +| Hiện tượng ở Ảnh 2 | Nguyên nhân kỹ thuật cốt lõi | ++--------------------------------+--------------------------------------------------+ +| Vùng tối biến thành mảng xám đục| Cộng giá trị tuyến tính vào sRGB (Không gian màu | +| (Milky / Foggy haze) | phi tuyến, nâng cả Black point tuyệt đối lên). | ++--------------------------------+--------------------------------------------------+ +| Đường phân tách loang lổ, gãy nét| Dùng ngưỡng cứng (Hard Threshold) hoặc hàm bước | +| (Posterization / Color Banding)| (Step function) thay vì mặt nạ suy giảm mượt mà. | ++--------------------------------+--------------------------------------------------+ +| Mất hoàn toàn khối & vân chi | Áp dụng phép biến đổi toàn cục (Global Operator) | +| tiết (Mặt núi, vách đá, tán cây)| thay vì tách lớp tương phản cục bộ (Local Contrast)| ++--------------------------------+--------------------------------------------------+ +| Cây cối đổi màu bùn, mất sắc độ| Thay đổi Luminance nhưng không bù Saturation | +| (Chroma wash-out) | hoặc thao tác sai trên từng kênh R, G, B độc lập.| ++--------------------------------+--------------------------------------------------+ +``` + +--- + +## 2. Bí Quyết Của Lightroom: Cơ Chế "Shadow Recovery" Chuyên Nghiệp + +Lightroom **không** dùng công thức đơn giản như `RGB = RGB + shadow * mask`. Thuật toán của Adobe dựa trên 3 trụ cột: + +### A. Tách lớp đa tỉ lệ (Multi-Scale Tone Decomposition) +Thay vì nâng sáng trực tiếp từng điểm ảnh, ảnh được phân rã thành: +$$\text{Image} = \text{Base Layer (Ánh sáng tổng thể)} \times \text{Detail Layer (Vân bề mặt & Góc cạnh)}$$ + +* **Base Layer:** Được trích xuất bằng bộ lọc bảo toàn góc cạnh (**Edge-Preserving Filter** như *Guided Filter* hoặc *Bilateral Filter*). +* Khi kéo **Shadow +100**: Thuật toán chỉ nâng độ sáng trên **Base Layer**, trong khi giữ nguyên hoặc khuếch đại nhẹ **Detail Layer**. Nhờ đó, các rãnh đá, tán lá và gợn sóng giữ nguyên độ sắc nét và chiều sâu. + +### B. Không gian màu tính toán (Scene-Referred Linear Space) +* Ảnh sRGB trên web có sẵn Gamma nén ($EOM \approx 2.2$). Mọi phép tính phải đưa về **Linear Light** trước khi thao tác, sau đó mới ánh xạ ngược lại sRGB: + $$I_{linear} = (I_{sRGB})^{2.2} \quad \xrightarrow{\text{Xử lý}} \quad I_{out} = (I_{processed})^{1/2.2}$$ + +### C. Khóa điểm đen (Anchor Black Point) & Bù trừ bão hòa (Chroma Boost) +* Giữ điểm đen ($0.0$) cố định để ảnh không bị bạc nhược (milky). +* Hiện tượng sinh lý thị giác (*Hunt Effect*): Khi vùng tối sáng lên, mắt người sẽ thấy nó nhạt hơn thực tế. Lightroom tự động tăng nhẹ độ bão hòa màu sắc (Saturation) tỉ lệ thuận với lượng Shadow được kéo lên. + +--- + +## 3. Kiến Trúc Thuật Toán Đề Xuất Cho Web UI (WebGL/GLSL) + +Để chạy mượt mà $60\text{ fps}$ trên trình duyệt, quy trình xử lý tối ưu gồm 2 Pass Shader: + +``` +[Ảnh Đầu Vào sRGB] + │ + ▼ (Pass 1) +[Chuyển sang Linear Light] ───> [Tạo Base Layer bằng Fast Guided Filter / Box Blur] + │ + ┌────────────────────────────────────┘ + ▼ (Pass 2: Composite & Tone Mapping Shader) +[1. Tính Detail Layer: Detail = Input / (Base + ε)] +[2. Tính Hệ số Shadow Mask: w = SmoothFalloff(Base)] +[3. Nâng Base Layer: Base' = Base * (1.0 + ShadowAmount * Curve(Base))] +[4. Khôi phục chi tiết: Reconstructed = Base' * Detail] +[5. Bù trừ Saturation trên không gian OKLab / HSV] +[6. Chuyển ngược về sRGB Output] +``` + +--- + +## 4. Mã Nguồn Shader Mẫu (Fragment Shader GLSL) + +Đoạn Shader WebGL dưới đây có thể tích hợp trực tiếp vào Canvas WebGL của bạn để khắc phục hoàn toàn lỗi bết màu: + +```glsl +precision highp float; + +uniform sampler2D u_texture; // Ảnh gốc +uniform sampler2D u_blur_texture; // Ảnh đã qua lọc mờ (Base layer từ Guided Filter / Gaussian) +uniform float u_shadow_amount; // Giá trị từ 0.0 đến 1.0 (tương ứng +0 đến +100) +varying vec2 v_texCoord; + +// 1. Chuyển sRGB sang Tuyến tính (Linear) +vec3 sRGBToLinear(vec3 c) { + return pow(c, vec3(2.2)); +} + +// 2. Chuyển Tuyến tính sang sRGB +vec3 linearTosRGB(vec3 c) { + return pow(c, vec3(1.0 / 2.2)); +} + +// 3. Tính độ sáng tri giác (Perceptual Luminance) +float getLuminance(vec3 c) { + return dot(c, vec3(0.2126, 0.7152, 0.0722)); +} + +// 4. Đường cong suy giảm mượt (Smooth Shadow Mask) +// Chỉ tác động mạnh vào vùng tối (0.05 - 0.4), không đụng vào Highlight (>0.6) và khóa điểm đen +float getShadowWeight(float lum) { + // Khóa điểm đen tuyệt đối để tránh màng sương đục (milky haze) + float blackAnchor = smoothstep(0.0, 0.08, lum); + + // Suy giảm mượt về 0 khi tiến tới vùng sáng + float highlightFalloff = 1.0 - smoothstep(0.2, 0.65, lum); + + return blackAnchor * highlightFalloff; +} + +void main() { + vec4 srcColor = texture2D(u_texture, v_texCoord); + vec4 baseColor = texture2D(u_blur_texture, v_texCoord); + + // Chuyển sang không gian ánh sáng tuyến tính + vec3 I_orig = sRGBToLinear(srcColor.rgb); + vec3 I_base = sRGBToLinear(baseColor.rgb); + + float baseLum = getLuminance(I_base); + float origLum = getLuminance(I_orig); + + // Bước A: Tách lớp vân bề mặt (Detail Layer) + // Detail = I_orig / (I_base + eps) + vec3 detail = I_orig / (I_base + vec3(0.001)); + + // Bước B: Tính toán lượng sáng cần bù vào Base Layer + float mask = getShadowWeight(baseLum); + + // Sử dụng công thức mũ tự nhiên thay vì cộng dồn tuyến tính + // Gain = (1.0 - baseLum)^2 giúp kéo vùng tối sâu mà không làm cháy vùng trung gian + float liftFactor = pow(1.0 - baseLum, 1.8) * u_shadow_amount * 2.2; + vec3 liftedBase = I_base * (1.0 + mask * liftFactor); + + // Bước C: Tái kết hợp Detail với Base đã được nâng sáng + vec3 result = liftedBase * detail; + + // Bước D: Bù trừ màu sắc (Chroma Compensation) để tránh bợt màu + float newLum = getLuminance(result); + if (origLum > 0.001) { + float boost = 1.0 + (mask * u_shadow_amount * 0.35); // Bù màu nhẹ cho vùng tối + result = mix(vec3(newLum), result, boost); + } + + // Bước E: Giữ điểm đen và chuyển ngược lại sRGB + result = clamp(result, 0.0, 1.0); + gl_FragColor = vec4(linearTosRGB(result), srcColor.a); +} +``` + +--- + +## 5. Danh Sách Kiểm Tra Khi Triển Khai (Action Plan) + +1. **Khử ngay phép cộng tuyến tính trên 8-bit RGB:** Không bao giờ viết dạng `r = r + shadowValue`. +2. **Triển khai Box Filter / Guided Filter trước:** Dùng 1 texture phụ chứa bản blur bán kính lớn ($\approx 2\% - 5\%$ chiều rộng ảnh) để làm `u_blur_texture`. +3. **Độ chính xác dấu phẩy động:** Thiết lập WebGL Context với cấu hình Float Texture (`OES_texture_float` hoặc `WebGL2`) để khử triệt để hiện tượng vỡ khối viền (banding). \ No newline at end of file diff --git a/docker/frontend/public/sw.js b/docker/frontend/public/sw.js index 66b3885..5d00396 100644 --- a/docker/frontend/public/sw.js +++ b/docker/frontend/public/sw.js @@ -10,7 +10,7 @@ // hashed bundle with it, which the STATIC rule below then served cache-first // forever. Every navigation here now goes past the browser's own cache, so the // build a visitor gets is the one the server has. -const VERSION = 'recipescam-v2'; +const VERSION = 'recipescam-v3'; // nginx answers all three with the same index.html (SPA fallback), so they are one // document under three keys: an offline navigation finds it whichever key it asks. diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index de7a072..508903b 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -1,4 +1,4 @@ -// Highlight roll-off in the develop, and the tonal-range ramp in the tone pass. +// Highlight roll-off in the develop, and the four tonal knobs in the tone pass. // // THE DEVELOP holds the knee: // @@ -9,119 +9,137 @@ // asymptote on 1.0) so the two stops the sensor holds above its white level are // COMPRESSED into the frame instead of being thrown away by the old // fade-to-white — which is also the only reason HIGHLIGHT has detail left at the -// top to move. Measured on DSC03453.ARW, where the camera's own preview is -// clipped, the develop's luma was 253.4 with a standard deviation of 2.4, against -// 251.2 / 10.0 through the knee. +// top to move. // -// THE TONE PASS builds the luma a new ramp instead. The four knobs -// (HIGHLIGHT, SHADOW, WHITE, BLACK) are the four zones of the tone-mapping doc, -// and each one owns a COMPACT WINDOW of the ramp — a black toe that dies on 0.18, -// a shadow bell over the deep tones, a highlight bell over the bright ones, a -// white shoulder from 0.80 — so each is exactly the identity outside its own -// stretch and the four are disjoint, which is what makes them independent -// without a guard. The four moves are applied ONE AFTER THE OTHER -// (BLACK -> SHADOW -> HIGHLIGHT -> WHITE), each clamped on the way to the next, -// and every one of them lands on 0.0 and 1.0 without moving either: a composition -// of monotone maps is monotone by construction, and the two ends of the ramp are -// FIXED POINTS of the whole thing whatever the sliders say. There is no fixed -// midpoint any more — the shadow window spans the middle, so a mid-grey moves -// with SHADOW (0.510636 at +100) where the old sum of bumps left it alone. +// THE TONE PASS moves the luma by the four knobs (HIGHLIGHT, SHADOW, WHITE, +// BLACK), and each one is the MOVE ITS OWN LIGHTROOM EXPORT MADE, measured: +// TONE_KNOBS in toneShader.ts holds, per knob and per side of zero, the knot +// table of that move on the encoded luma. They are NOT the doc's four compact +// windows any more — the windows were confined to a quarter of the ramp each and +// were worth a few hundredths of luma inside it and the exact identity outside, +// which is the "sau khi chỉnh sửa 4 thông số này không đúng, không thay đổi" +// report: WHITE ±100 was a literal no-op. The reference exports say something +// else: WHITE +100 takes everything above luma 0.50 to 1.0 (mean +0.43 over the +// frame), BLACK -100 takes everything below 0.21 to 0.0 (mean -0.14), HIGHLIGHT +// +100 is a +0.24 lift peaking on luma 0.18 and dead by 0.72. // -// Both are SkSL, so the SHAPE is pinned on the source; the arithmetic is then -// checked against the source's own constants, and the ramp re-run here as a twin -// so monotonicity, the neutral identity, the partition of the four masks and the -// slopes SHADOW and HIGHLIGHT leave the quarters around them are checked rather -// than asserted in a comment. +// The four are applied ONE AFTER THE OTHER (BLACK -> SHADOW -> HIGHLIGHT -> +// WHITE), each clamped on the way to the next, so a composition of monotone maps +// is monotone by construction and the ramp needs no guard whatever the four +// sliders say — and the knot tables are cut so that L + move is non-decreasing on +// every segment, which the sweep below measures rather than asserts. +// +// Both are SkSL, so the SHAPE is pinned on the source; the tables are then +// re-run here as a twin and checked against the measurement they were FITTED to +// (the nine exports in the repo root), so a table that drifts off the export is a +// red check. The frame-through-the-real-shader diff lives in the scratchpad +// harness render.mjs, which needs the nine JPEGs. // // node scripts/highlight-knee-check.mjs import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import ts from 'typescript'; -const develop = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8'); -const tone = readFileSync(new URL('../shared/utils/toneShader.ts', import.meta.url), 'utf8'); - -// The develop: knee on the sensor's max channel, the channel ratios kept, so the -// hue and the saturation of a blown area survive the pull-down. -const dev = develop.match(/if \(mx > 0\.7\) \{[\s\S]*?\n \}/)?.[0]; -assert.ok(dev, 'the develop knee is gone — a blown sky is flat 1.0 again'); -assert.match(dev, /float over = mx - 0\.7;/); -assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/); -assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back'); - -// The tone pass, read as the string it actually emits: the six window edges and -// rates are interpolated, so the template has to be resolved before it can be -// matched — and read off the SOURCE, so the numbers here are the ones the shader -// is built with. -const num = (re) => { - const m = tone.match(re); - assert.ok(m, 'missing ' + re); - return Number(m[1]); -}; +const read = (p) => readFileSync(new URL(p, import.meta.url), 'utf8'); +const develop = read('../src/engine/rawDevelop.ts'); +const tone = read('../shared/utils/toneShader.ts'); +// The tone shader is IMPORTED, transpiled, so the numbers below are the ones the +// shader is built with and not a regex's reading of them. +const transpile = (path) => + ts.transpileModule(read(path), { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }).outputText; +const dir = mkdtempSync(join(tmpdir(), 'highlight-knee-check-')); +writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts')); +writeFileSync( + join(dir, 'toneShader.mjs'), + transpile('../shared/utils/toneShader.ts').replace( + /^import .*from ['"]\.\/colorUtils['"];$/m, + 'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";', + ), +); const { - TONE_BLACK_EDGE, TONE_WHITE_EDGE, TONE_BLACK_LIFT, TONE_BLACK_CRUSH, TONE_HIGH_GAIN, TONE_WHITE_GAIN, -} = Object.fromEntries( - ['TONE_BLACK_EDGE', 'TONE_WHITE_EDGE', 'TONE_BLACK_LIFT', 'TONE_BLACK_CRUSH', 'TONE_HIGH_GAIN', 'TONE_WHITE_GAIN'] - .map((name) => [name, num(new RegExp(`export const ${name} = ([0-9.]+);`))]), -); -// The two edges are the doc's and the ramp is drawn in quarters of a code value -// around them: the toe window must die inside the first quarter of the ramp and -// the shoulder must not start below the middle, or a knob reaches past its zone. -assert.ok(TONE_BLACK_EDGE <= 0.25, `TONE_BLACK_EDGE ${TONE_BLACK_EDGE} is wider than a quarter of the ramp`); -assert.ok(TONE_WHITE_EDGE >= 0.5, `TONE_WHITE_EDGE ${TONE_WHITE_EDGE} starts under the middle of the ramp`); -const tmpl = tone.match(/export const TONE_SKSL = `([\s\S]*?)`;/)?.[1]; -assert.ok(tmpl, 'TONE_SKSL is gone'); -// The ramp, the hue-preserving rebuild and the exposure move live in -// TONE_MATH_SKSL, the one copy the whole-frame pass and a gradient mask both -// interpolate — so the shape is pinned there, and TONE_SKSL has to reach for it -// rather than carry a second version of its own (that is the divergence the -// compat doc §3.3 warns the Android port about). -const mathTmpl = tone.match(/export const TONE_MATH_SKSL = `([\s\S]*?)`;/)?.[1]; -assert.ok(mathTmpl, 'TONE_MATH_SKSL is gone — the frame and a mask no longer share the maths'); -assert.equal((mathTmpl.match(/\$\{TONE_[A-Z_]+\}/g) ?? []).length, 8, 'a window edge or a rate is pinned to a literal, not to its TONE_ constant'); -assert.ok(tmpl.includes('${TONE_MATH_SKSL}'), 'the frame pass carries its own copy of the ramp again'); -assert.match(tmpl, /rgb = toneRamp\(rgb, t, baseLuma\(xy\), bl, sh, hl, wh, dr\);/); -// The BASE layer the ramp is drawn through. It is ONE tap of a blurred child, -// and the blur is the caller's (blurredBase in exportEngine.ts) — a ring of point -// samples in here was the mottle bug: the luma aliased on a textured frame, the -// gain o(base)/base carried the alias, and the reconstruction painted it back. -// So the shader must read `base` once and must NOT grow a sampling loop again, -// and `bx` — the step only a loop ever needed — must stay gone. -assert.match(tmpl, /float baseLuma\(vec2 xy\) \{/); -assert.match(tmpl, /vec3 s = clamp\(base\.eval\(xy\)\.rgb, 0\.0, 1\.0\);/); -assert.match(tmpl, /uniform shader base;/); -assert.doesNotMatch(tmpl, /uniform float2 bx;/, 'the base is a sampling loop again — that is what mottled'); -assert.doesNotMatch(tmpl, /baseLuma\(xy, t\)/, 'baseLuma grew its neighbourhood back'); -// One tap of the base is a FRACTION of the frame, so the preview and the file -// look at the same neighbourhood: the pass has the frame size and turns it into -// the blur's sigma. -assert.match(tone, /export const TONE_BASE_RADIUS = ([0-9.]+);/); -const baseRadius = Number(tone.match(/export const TONE_BASE_RADIUS = ([0-9.]+);/)[1]); -assert.ok(baseRadius >= 0.02 && baseRadius <= 0.05, `the base reads ${baseRadius} of the frame — the doc asks for 2%..5%`); -assert.match(tone, /export const TONE_BASE_SIGMA = ([0-9.]+);/); -const baseSigma = Number(tone.match(/export const TONE_BASE_SIGMA = ([0-9.]+);/)[1]); -assert.ok(baseSigma > 0 && baseSigma <= 0.5, `TONE_BASE_SIGMA ${baseSigma} is not a sigma under the radius`); -const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8'); + TONE_SKSL: sksl, + TONE_MATH_SKSL: maths, + EXPOSURE_SKSL, + TONE_KNOBS, + TONE_BLACK_EDGE, + TONE_BASE_RADIUS, + TONE_BASE_SIGMA, + toneUniformArray, +} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href); + +const text = (s) => new RegExp(s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); +const codeOf = (s) => s.replace(/\/\/[^\n]*/g, ''); + +// THE FOUR KNOBS, as the source declares them. Each curve is a knot table on the +// encoded luma: positions ascending from the floor to the head, values the move +// at +100 (the minus side is read with an amount below zero). +const KNOBS = ['bl', 'sh', 'hl', 'wh']; +for (const k of KNOBS) { + for (const side of ['plus', 'minus']) { + const c = TONE_KNOBS[k][side]; + assert.equal(c.p.length, c.v.length, `${k}.${side}: ${c.p.length} knots against ${c.v.length} values`); + assert.equal(c.p[0], 0, `${k}.${side} does not start on the floor`); + assert.equal(c.p[c.p.length - 1], 1, `${k}.${side} does not end on the head`); + for (let i = 1; i < c.p.length; i++) + assert.ok(c.p[i] > c.p[i - 1], `${k}.${side} knot ${i} is not past the one before it`); + } +} +// One knob as the shader gets it: the table drawn STRAIGHT between the knots — a +// hinge per segment, written as a sum of clamps because SkSL indexes arrays by +// constant only — scaled by the amount, added to the luma it reads and clamped. +// The side of zero picks the table, and the clamp is what makes a -- knob's own +// clip a plateau instead of a fold. +for (const k of KNOBS) + assert.match( + maths, + text(`float tone${k}(float L, float amt) {\n return clamp(L + (amt >= 0.0 ? amt * (`), + `${k} is not the measured table read as a polyline` + ); +assert.match(maths, /: -amt \* \(/); +// ...pinned at the SOURCE's own numbers: the hinge from knot 0.045 to knot 0.185 +// on BLACK's plus side is worth 0.1155 - 0.0876, over a segment 0.140 wide. +assert.match(maths, text(' + (0.0279) * clamp((L - 0.045) / 0.140, 0.0, 1.0)')); +// The composition, move for move: the four in the order the exports were read, +// each self-clamping (tone* does it), so a knob that has driven the luma to an +// end leaves the ones after it nothing to fold. assert.match( - engine, - /const sigma = width \* TONE_BASE_RADIUS \* TONE_BASE_SIGMA;\s*\n\s*const base = own\(blurredBase\(baseShaderOf, width, height, sigma\)\);/, - 'the tone pass no longer blurs a frame-sized base' + maths, + text('float toneCurve(float L, float bl, float sh, float hl, float wh) {\n L = tonebl(L, bl);\n L = tonesh(L, sh);\n L = tonehl(L, hl);\n L = tonewh(L, wh);\n return clamp(L, 0.0, 1.0);\n}') ); -assert.match( - engine, - /effect\.makeShaderWithChildren\(toneUniformArray\(tone\), \[\s*baseShaderOf\(\),\s*base \? own\(imageShaderChild\(base\)\) : baseShaderOf\(\),\s*\]\)/, - 'the blurred base is not handed to the tone pass as its second child' +// ...and the four COMPACT WINDOWS the knobs used to be are gone from the CODE — +// they were the "không thay đổi" bug. The prose is not read for these: +// TONE_MATH_SKSL still tells the story of what replaced them. +assert.doesNotMatch(codeOf(maths), /toneBlackW|toneBlack\(|toneShadowW|toneHighW|toneWhiteW|toneBump|holdLo|holdHi/); +assert.doesNotMatch( + tone, + /export const TONE_WHITE_EDGE|export const TONE_BLACK_LIFT|export const TONE_BLACK_CRUSH|export const TONE_HIGH_GAIN|export const TONE_WHITE_GAIN|export const TONE_ANCHOR|export const TONE_BUMP_SLOPE/, + 'a window edge or rate is still declared' ); -assert.match( - engine, - /function blurredBase\([\s\S]*?Skia\.ImageFilter\.MakeBlur\(sigma, sigma, Skia\.TileMode\.Clamp, null\)/, - 'the base is no longer Skia’s own blur' +// The BLACK knob's Hunt chroma mask is the one thing that still reads the doc's +// 0.18 — it is a chroma floor, not a knob window. +assert.match(maths, new RegExp(text(`float uBl = clamp(1.0 - effBase / ${TONE_BLACK_EDGE}, 0.0, 1.0);`))); +const knobBlock = maths.slice(maths.indexOf('float tonebl'), maths.indexOf('float toneCurve')); +assert.ok(knobBlock.includes('float tonewh'), 'the four knob functions are gone'); +assert.doesNotMatch( + knobBlock, + /smoothstep|pow\(|exp\(/, + 'a knob is not the measured table any more — it bends again' ); -assert.match(engine, /getToneUniforms\(adjustments, recipe\.baseFilter\)/, 'the tone pass still steps a sampling ring by hand'); +// DR rides the same four moves with a share of each instead of masked terms of +// its own, so it cannot fight a knob over a band or invert the ramp. +assert.match(maths, text('float o = toneCurve(effBase, clamp(bl + dr * 0.12, -1.0, 1.0), clamp(sh + dr * 0.06, -1.0, 1.0),')); +assert.match(maths, /\n\s*clamp\(hl - dr \* 0\.09, -1\.0, 1\.0\), clamp\(wh - dr \* 0\.18, -1\.0, 1\.0\)\);/); +assert.doesNotMatch(maths, /o = clamp\(base/, 'the ramp clamps the base before the curve again — the ends of the ramp are the anchors'); +assert.doesNotMatch(codeOf(sksl), /float a4 = /, 'the ramp is back inside the pass — one copy, not two'); // The uniform block: the shader's declarations, arrays expanded and in // declaration order, have to be the numbers `toneUniformArray` writes — a // mismatch is a silent off-by-one down the whole block. -const declared = [...tmpl.matchAll(/uniform (float2|float) (\w+)(?:\[(\d+)\])?;/g)].reduce( +const declared = [...sksl.matchAll(/uniform (float2|float) (\w+)(?:\[(\d+)\])?;/g)].reduce( (n, [, kind, , len]) => n + (len ? Number(len) : kind === 'float2' ? 2 : 1), 0 ); @@ -133,60 +151,14 @@ const written = arrayFn .filter(Boolean) .reduce((n, s) => n + (s.startsWith('...u.hsl') ? 8 : 1), 0); assert.equal(written, declared, `toneUniformArray writes ${written} floats, the pass declares ${declared}`); -const resolve = (s) => - s - .replace('${TONE_MATH_SKSL}', mathTmpl) - .replaceAll('${TONE_BLACK_EDGE}', String(TONE_BLACK_EDGE)) - .replaceAll('${TONE_WHITE_EDGE}', String(TONE_WHITE_EDGE)) - .replaceAll('${TONE_BLACK_LIFT}', String(TONE_BLACK_LIFT)) - .replaceAll('${TONE_BLACK_CRUSH}', String(TONE_BLACK_CRUSH)) - .replaceAll('${TONE_HIGH_GAIN}', String(TONE_HIGH_GAIN)) - .replaceAll('${TONE_WHITE_GAIN}', String(TONE_WHITE_GAIN)); -const sksl = resolve(tmpl); -const maths = resolve(mathTmpl); -// The four windows, as the resolved maths emits them: each is compactly -// supported on its own stretch and ZERO outside it — a knob is the exact -// identity off its own band, which is what the monochrome stock's BLACK travel -// rides on (see the note in toneShader.ts) — and the black window is zero at -// L = 0 while the white one is zero at L = 1, so the two ends cannot move. The -// edges are matched at the SOURCE's numbers, not at literals of their own. -const text = (s) => new RegExp(s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); -assert.match(maths, text(`float toneBlackW(float L) {\n float u = clamp(1.0 - L / ${TONE_BLACK_EDGE}, 0.0, 1.0);`)); -assert.match(maths, /return u \* u \* u;\n\}/); -assert.match(maths, text('float toneShadowW(float L) {\n return smoothstep(0.02, 0.12, L) * (1.0 - smoothstep(0.25, 0.55, L));\n}')); -assert.match(maths, text('float toneHighW(float L) {\n return smoothstep(0.45, 0.65, L) * (1.0 - smoothstep(0.92, 1.0, L));\n}')); -assert.match(maths, text(`float toneWhiteW(float L) {\n float u = clamp((L - ${TONE_WHITE_EDGE}) / (1.0 - ${TONE_WHITE_EDGE}), 0.0, 1.0);\n return u * u;\n}`)); -// ...and the windows are the ONLY shape: the kernel the four bumps shared, the -// amplitudes they were summed with and the ceiling that guard held them under are -// gone from the CODE. The prose is not read for these — TONE_MATH_SKSL still -// tells the story of the guard it replaced, so a check on the comments would be -// checking the wrong thing. -const codeOf = (s) => s.replace(/\/\/[^\n]*/g, ''); -assert.doesNotMatch(codeOf(sksl), /toneBump/, 'the summed kernel is back — the four moves must be sequential'); -assert.doesNotMatch(codeOf(sksl), /holdLo|holdHi/, 'the shared ceiling is back — a stock drags another knob with it'); -assert.doesNotMatch(tone, /export const TONE_ANCHOR|export const TONE_BUMP_SLOPE/, 'the old anchor and guard constants are still declared'); -// The composition, move for move: the black pair (lift through the doc's square -// root, crush by the toe's own exponent, one or the other — both zero at L = 0, so -// the black point stays the black point and no knob leaves a pedestal), the shadow -// gain, the highlight knee against the headroom that is LEFT (the (1 - L)^2 is the -// one deliberate departure from the doc's raw pow, which overshoots the cube), and -// the white Hermite (1 - L) * L — zero on BOTH ends, so the head cannot move. -assert.match(maths, text('float toneCurve(float L, float bl, float sh, float hl, float wh) {')); -assert.match(maths, text(' L = toneBlack(L, bl);\n L = clamp(L, 0.0, 1.0);')); -// ...and the black pair itself, pinned on the source as the function the curve now -// calls: the doc's lift at its rate, and the exponent that replaces the doc's -// crush (the rate is on the exponent, so the closed form cannot run backwards). -assert.match(maths, text(`float toneBlack(float L, float bl) {\n const float W = ${TONE_BLACK_EDGE};\n float q = toneBlackW(L);\n if (bl > 0.0) return L + ${TONE_BLACK_LIFT} * bl * q * (sqrt(L) - L);\n if (L >= W) return L;\n return W * pow(L / W, 1.0 + ${TONE_BLACK_CRUSH} * (-bl) * q);\n}`)); -assert.match(maths, text(' q = toneShadowW(L);\n L *= 1.0 + sh * q * pow(1.0 - L, 1.8);\n L = clamp(L, 0.0, 1.0);')); -assert.match(maths, text(` q = toneHighW(L);\n L += ${TONE_HIGH_GAIN} * hl * q * pow(max(L - 0.5, 0.0), 1.5) * (1.0 - L) * (1.0 - L);\n L = clamp(L, 0.0, 1.0);`)); -assert.match(maths, text(` q = toneWhiteW(L);\n L += ${TONE_WHITE_GAIN} * wh * q * (1.0 - L) * L;`)); -// DR rides the same four moves with a share of each instead of masked terms of -// its own, so it cannot fight a knob over a band or invert the ramp. -assert.match(maths, text('float o = toneCurve(base, clamp(bl + dr * 0.12, -1.0, 1.0), clamp(sh + dr * 0.06, -1.0, 1.0),')); -assert.match(maths, /\n\s*clamp\(hl - dr \* 0\.09, -1\.0, 1\.0\), clamp\(wh - dr \* 0\.18, -1\.0, 1\.0\)\);/); -assert.doesNotMatch(maths, /o = clamp\(base/, 'the ramp clamps the base before the curve again — the ends of the ramp are the anchors'); -assert.doesNotMatch(tmpl, /float a4 = /, 'the ramp is back inside the pass — one copy, not two'); +// The develop: knee on the sensor's max channel, the channel ratios kept, so the +// hue and the saturation of a blown area survive the pull-down. +const dev = develop.match(/if \(mx > 0\.7\) \{[\s\S]*?\n \}/)?.[0]; +assert.ok(dev, 'the develop knee is gone — a blown sky is flat 1.0 again'); +assert.match(dev, /float over = mx - 0\.7;/); +assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/); +assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back'); const mask = readFileSync(new URL('../shared/utils/gradientMask.ts', import.meta.url), 'utf8'); assert.ok(mask.includes('${TONE_MATH_SKSL}'), 'the mask pass does not read the shared maths'); assert.match(mask, /c = half3\(exposureMove\(vec3\(c\), a\.x\)\);/); @@ -199,22 +171,21 @@ assert.doesNotMatch(mask, /0\.55, 1\.35/, 'the mask kept its own arbitrary satur assert.doesNotMatch(mask, /cg = clamp\(lifted/, 'the mask is back on its own tone formula'); // The four tents, one per quarter of the ramp, each clipped by its neighbour so -// no luma is counted by two of them. +// no luma is counted by two of them. These weight the stock SPLIT TONES now — the +// four knobs no longer ride them, they ride their own measured curves (TONE_KNOBS). assert.match(sksl, /float blMask = 1\.0 - smoothstep\(0\.00, 0\.25, t\);/); assert.match(sksl, /float shMask = clamp\(1\.0 - smoothstep\(0\.25, 0\.50, t\) - blMask, 0\.0, 1\.0\);/); assert.match(sksl, /float whMask = smoothstep\(0\.75, 1\.00, t\);/); assert.match(sksl, /float hlMask = clamp\(smoothstep\(0\.50, 0\.75, t\) - whMask, 0\.0, 1\.0\);/); -// The composition, and NOT a sum of bumps on the identity: the four moves are -// applied in sequence through their own windows, which is what makes one knob's -// travel independent of another's (the shared ceiling used to take 0.663 of -// BLACK's travel off on the monochrome stock) and the ramp monotone by -// construction rather than by a guard. Nor straight segments between knots: an -// angle in a tone curve is a Mach band — and neither a smoothstep through the -// knots, which bends the ramp by six code values in the quarter-tones with every -// knob on zero, where this pass also runs for the stock split tones and DR alone. +// The composition, and NOT a sum of bumps on the identity: the four MEASURED +// curves are applied one after the other, each self-clamping via its own tone*() +// helper, which is what makes one knob's travel independent of another's (the +// shared ceiling used to take 0.663 of BLACK's travel off on the monochrome +// stock) and the ramp monotone by construction rather than by a guard. No +// linear-light knee runs ahead of them either — CURVE_SKSL is a different pass. assert.doesNotMatch(sksl, /float blackA = |float whiteA = /, 'the amplitudes are summed again — the moves must be sequential'); assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238'); -assert.doesNotMatch(sksl, /float lin\(/, 'straight segments between the knots are back — a knot is an angle in a tone curve'); +assert.doesNotMatch(sksl, /float lin\(/, 'the linear-light knee ahead of the knobs came back'); assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again'); // ...and the pixel rides the neighbourhood's move with its own difference from // it: Base' + Detail, ADDED and not scaled. Multiplying by the gain o / base is @@ -224,9 +195,9 @@ assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a // blurred base itself. The ramp's own luma is still not what is handed to the // rebuild — the neighbourhood's is — or the move would be global again and the // band above SHADOW would be drawn flat, which is the whole bug. -assert.match(sksl, /float target = o \+ \(t - base\);/); +assert.match(sksl, /float target = clamp\(o \+ \(t - effBase\), 0\.0, 1\.0\);/); assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again'); -assert.match(sksl, /return lightMove\(c, t, clamp\(target, 0\.0, 1\.0\)\);/); +assert.match(sksl, /vec3 res = lightMove\(c, t, target\);/); assert.doesNotMatch(sksl, /lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\)/, 'the ramp is read at the pixel again — a global curve'); assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped again'); // The linear-light knee that used to run ahead of all this is GONE from the tone @@ -243,11 +214,12 @@ assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK a // scratchpad hl-variants.mjs) — and the scale is held at 1.0 only below // t = 0.0004, where the ratio would multiply a near-black pixel's cast by // whatever pedestal BLACK has just lifted. -assert.match(maths, /float k = t > 0\.0004 \? o \/ t : 1\.0;/); -assert.match(maths, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ \(hiC - t\)\);/); -assert.match(maths, /if \(loC < t\) k = min\(k, o \/ \(t - loC\)\);/); +assert.match(maths, /float safeT = max\(t, 0\.005\);/); +assert.match(maths, /float k = t > 0\.0004 \? min\(o \/ safeT, 3\.5\) : 1\.0;/); +assert.match(maths, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ max\(hiC - t, 0\.0001\)\);/); +assert.match(maths, /if \(loC < t\) k = min\(k, o \/ max\(t - loC, 0\.0001\)\);/); assert.match(maths, /return clamp\(vec3\(o\) \+ \(c - vec3\(t\)\) \* k, 0\.0, 1\.0\);/); -assert.match(maths, /return lightMove\(c, t, clamp\(target, 0\.0, 1\.0\)\);/); +assert.match(maths, /vec3 res = lightMove\(c, t, target\);/); assert.doesNotMatch(tone, /0\.55, 1\.35/, 'the arbitrary saturation clamp came back'); assert.doesNotMatch(maths, /float k = 1\.0;/, 'the chroma-constant scale came back — a shadow lift drains the colour'); // The transfer pair has to be the accurate one where it is still used (the @@ -307,292 +279,164 @@ const smoothstep = (e0, e1, x) => { const u = clamp01((x - e0) / (e1 - e0)); return u * u * (3 - 2 * u); }; -// The four windows, as TONE_MATH_SKSL emits them — zero outside their own band. -const toneBlackW = (L) => { - const u = clamp01(1 - L / TONE_BLACK_EDGE); - return u * u * u; -}; -const toneShadowW = (L) => smoothstep(0.02, 0.12, L) * (1 - smoothstep(0.25, 0.55, L)); -const toneHighW = (L) => smoothstep(0.45, 0.65, L) * (1 - smoothstep(0.92, 1.0, L)); -const toneWhiteW = (L) => { - const u = clamp01((L - TONE_WHITE_EDGE) / (1 - TONE_WHITE_EDGE)); - return u * u; -}; -// toneBlack, move for move — the doc's lift through the square root, and the -// toe's own exponent for the crush, which is what the shader carries (see -// toneShader.ts): the doc's multiplicative crush is bounded by its own window and -// cannot be seen. It is a function of its own so the arithmetic below and the -// source's own text can be pinned to one shape. -const toneBlack = (L, bl) => { - const q = toneBlackW(L); - if (bl > 0) return L + TONE_BLACK_LIFT * bl * q * (Math.sqrt(L) - L); - if (L >= TONE_BLACK_EDGE) return L; - return TONE_BLACK_EDGE * (L / TONE_BLACK_EDGE) ** (1 + TONE_BLACK_CRUSH * -bl * q); -}; -// toneCurve, move for move, with the clamp the shader puts after each one: BLACK -// (toneBlack), SHADOW (a gain on the light), HIGHLIGHT (the doc's knee against the -// headroom that is left, squared) and WHITE (the Hermite (1 - L) * L). -// `afterBlack` is the L the shadow move reads — the value a stock's own SHADOW -// cannot drag, because BLACK runs before it. -function tones(t, k) { - const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = k; - let L = t; - let q; - const blackA = clamp(bl + dr * 0.12, -1, 1); - L = toneBlack(L, blackA); - const afterBlack = clamp01(L); - L = afterBlack; - q = toneShadowW(L); - L *= 1 + clamp(sh + dr * 0.06, -1, 1) * q * (1 - L) ** 1.8; - L = clamp01(L); - q = toneHighW(L); - L += TONE_HIGH_GAIN * clamp(hl - dr * 0.09, -1, 1) * q * Math.max(L - 0.5, 0) ** 1.5 * (1 - L) ** 2; - L = clamp01(L); - q = toneWhiteW(L); - L += TONE_WHITE_GAIN * clamp(wh - dr * 0.18, -1, 1) * q * (1 - L) * L; - return { o: clamp01(L), afterBlack }; -} -// ...plus the four ZONE masks, which the ramp does NOT read any more: they are -// the partition the stock split tones ride, one per quarter, each the doc's own -// smoothstep minus the tent before it. They are carried here because the checks -// below measure that partition; `o` is delegated to the shared maths above. -function ramp(t, k) { - const blMask = 1 - smoothstep(0, 0.25, t); - const shMask = clamp01(1 - smoothstep(0.25, 0.5, t) - blMask); - const whMask = smoothstep(0.75, 1, t); - const hlMask = clamp01(smoothstep(0.5, 0.75, t) - whMask); - return { ...tones(t, k), blMask, shMask, hlMask, whMask, maskSum: blMask + shMask + hlMask + whMask }; -} -// The tents never overlap — each is the doc's smoothstep minus the tent before -// it, so the four together never count a luma twice — and the middle is the -// quiet part: the ends of the ramp are weighted at 1, the 0.50 midpoint by -// nothing at all. That is what leaves DR and the stock split tones on the two -// ends and the mid-grey still. -for (let t = 0; t <= 1; t += 1 / 512) { - const { maskSum } = ramp(t, {}); - assert.ok(maskSum >= -1e-15 && maskSum <= 1 + 1e-15, `masks overlap at ${t}: ${maskSum}`); - if (t <= 0.25 || t >= 0.75) assert.ok(Math.abs(maskSum - 1) < 1e-12, `end of the ramp unweighted at ${t}`); - if (Math.abs(t - 0.5) < 1e-12) assert.equal(maskSum, 0, 'the midpoint is weighted'); -} -// The neighbouring tents cross at half weight ON the knot between them, and the -// 0.50 midpoint is where all four are on zero — the quiet value for the split -// tones, which is all these masks weight. (The RAMP is another matter: nothing -// feeds it from here, and SHADOW's own window spans the middle.) -assert.equal(ramp(0.125, {}).blMask, 0.5); -assert.equal(ramp(0.125, {}).blMask, ramp(0.125, {}).shMask); -assert.equal(ramp(0.25, {}).shMask, 1); -assert.equal(ramp(0.25, {}).blMask, 0); -assert.equal(ramp(0.875, {}).hlMask, ramp(0.875, {}).whMask); -assert.equal(ramp(0.5, {}).maskSum, 0); -assert.equal(ramp(0.75, {}).hlMask, 1); -// Every knob on zero is EXACTLY the identity — the pass also runs for the stock -// split tones and for DR alone, so a neutral setting must not curve the frame. -for (let t = 0; t <= 1; t += 1 / 256) close(ramp(t, {}).o, t, `identity broke at ${t}`); -// Neither END of the ramp moves, at any setting: both are fixed points of every -// move above — the black pair is zero at L = 0 and the white pair is zero at -// L = 1 — so no combination of the four sliders can lift the toe or roll the -// head. There is no fixed midpoint any more: SHADOW is a gain on the light and -// its window spans the middle, so at t = 0.5 SHADOW +1 lands on 0.510636 and -1 -// on 0.489364, where the old sum of bumps met on zero. -close(ramp(0, { bl: 1 }).o, 0, 'BLACK lifted the toe off the floor'); -close(ramp(0, { bl: -1, sh: -1, hl: -1, wh: -1 }).o, 0, 'a knob moved the floor'); -close(ramp(1, { wh: 1 }).o, 1, 'WHITE moved the head'); -close(ramp(1, { wh: -1 }).o, 1, 'a knob moved the head'); -near4(ramp(0.5, { sh: 1 }).o, 0.51064, 'SHADOW no longer spans the middle'); -near4(ramp(0.5, { sh: -1 }).o, 0.48936, 'SHADOW no longer spans the middle'); - -// Monotone under EVERY combination of the four at full deflection, DR included. -// The composition of monotone moves is monotone by construction, but each move is -// only piecewise — a window edge is a slope change — so this is the measurement of -// that, and the measurement is the DROP FROM THE RUNNING MAXIMUM rather than the -// step between two cells. A fold of a tenth of a code value spread over a dozen -// cells hides from a cell-sized step: the step this file used to read here was -// -0.00119 while the drop under SHADOW -100 alone was 0.018, three times the guard -// and the thing a gradient wears as a band. The drop is what the eye reads. +// --------------------------------------------------------------------------- +// The ramp as arithmetic — the same tables, the same order and the same clamps +// the SkSL above carries, so the shape is measured and not described. // -// Every rate this pass sets is fold-free: the worst any of them can do is 3.4e-6 -// of luma (BLACK's lift, at the square root's own corner). The one fold left in -// the ramp is SHADOWS' and it is the doc's own arithmetic, not a rate: §2.2's -// crush multiplies what a window leaves, that window RISES as the luma does over -// 0.02..0.12, so the move outruns the light and the ramp comes back down by 0.018 -// of luma (4.6 code values) around 0.06..0.11 at full -100 — at any rate, since -// scaling it only scales the same shape. It predates this pass and is pinned here -// so a rate change cannot quietly deepen it: the assertion below is that no rate -// in this file folds the ramp at all, and that no composition of the four is worse -// than the doc's own crush. -const combos = []; -for (const bl of [-1, 0, 1]) - for (const sh of [-1, 0, 1]) - for (const hl of [-1, 0, 1]) - for (const wh of [-1, 0, 1]) - for (const dr of [0, 1]) combos.push({ bl, sh, hl, wh, dr }); -const fold = (k, n) => { - let top = -Infinity; - let worst = 0; - let at = 0; - for (let i = 0; i <= n; i++) { - const t = i / n; - const o = ramp(t, k).o; - if (o > top) top = o; - else if (top - o > worst) { - worst = top - o; - at = t; - } - } - return { worst, at }; +// ONE knob's curve, as KNOB_SKSL emits it: the table drawn straight between the +// knots, scaled by the amount, added to the luma it reads and clamped. +const poly = (c, L) => { + let v = c.v[0]; + for (let i = 1; i < c.p.length; i++) + v += (c.v[i] - c.v[i - 1]) * clamp((L - c.p[i - 1]) / (c.p[i] - c.p[i - 1]), 0, 1); + return v; }; -// A knob's own rate, at full deflection either way: the rates are set so this is -// zero to the rounding — 2e-3 is half a code value, under what 8-bit can carry. -for (const [knob, amount] of [['bl', 1], ['bl', -1], ['hl', 1], ['hl', -1], ['wh', 1], ['wh', -1]]) { - const { worst, at } = fold({ [knob]: amount }, 32768); - assert.ok(worst <= 2e-3, `${knob} at ${amount} folds the ramp by ${worst} at ${at} — a rate past what its own shape allows`); +const knob = (L, k, amt) => + clamp01(L + (amt >= 0 ? amt * poly(TONE_KNOBS[k].plus, L) : -amt * poly(TONE_KNOBS[k].minus, L))); +// toneCurve, move for move, with the clamp the shader puts after each one: +// BLACK, then SHADOW, then HIGHLIGHT, then WHITE. `afterBlack` is the luma the +// SHADOW move reads — the value a stock's own SHADOW cannot drag, because BLACK +// runs before it. +function ramp(t, k = {}) { + const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = k; + const afterBlack = knob(t, 'bl', clamp(bl + dr * 0.12, -1, 1)); + const o = knob( + knob(knob(afterBlack, 'sh', clamp(sh + dr * 0.06, -1, 1)), 'hl', clamp(hl - dr * 0.09, -1, 1)), + 'wh', + clamp(wh - dr * 0.18, -1, 1) + ); + return { o, afterBlack }; } -// SHADOW's crush, the doc's own — see the note above. Pinned: this number is a -// property of §2.2's form, so a change here means the form changed. -near4(fold({ sh: -1 }, 32768).worst, 0.01795, 'SHADOW no longer folds where the doc\'s own crush folds — re-pin, and see toneShader.ts'); -let worstFold = 0; -let foldAt = 0; -let foldCombo = null; -for (const k of combos) { - const { worst, at } = fold(k, 8192); - if (worst > worstFold) { - worstFold = worst; - foldAt = at; - foldCombo = k; - } + +// THE MEASUREMENT THE TABLES WERE FITTED TO. Nine genuine Lightroom Classic +// 14.2 exports of Main.jpg — the frame at the repo root — each knob at +100 and +// at -100, the per-pixel luma difference against Main.jpg binned by base luma and +// averaged. The row is that move at nine luma positions, off the dense grid the +// tables were cut from (0.008 of luma is the worst the fit is allowed anywhere); +// the tolerance below is 0.01, a little over a code value at ±100. +const MEASURED_L = [0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]; +const MEASURED = { + 'hl+': [0.22, 0.2228, 0.2357, 0.2323, 0.1607, 0.1011, 0.078, 0.0452, 0.0108], + 'hl-': [-0.0201, -0.0363, -0.0475, -0.0589, -0.0701, -0.0808, -0.1298, -0.1627, -0.1461], + 'sh+': [0.0286, 0.0424, 0.0544, 0.066, 0.0716, 0.0899, 0.137, 0.1651, 0.1733], + 'sh-': [-0.0434, -0.0874, -0.1285, -0.1583, -0.1359, -0.0912, -0.0734, -0.0473, -0.012], + 'wh+': [0.2036, 0.2901, 0.3714, 0.4422, 0.51, 0.5623, 0.4998, 0.3999, 0.3022], + 'wh-': [-0.0019, -0.0038, -0.0058, -0.0085, -0.0123, -0.0203, -0.03, -0.0461, -0.0609], + 'bl+': [0.0905, 0.1049, 0.1132, 0.1151, 0.099, 0.0843, 0.0702, 0.052, 0.0328], + 'bl-': [-0.0501, -0.0997, -0.1498, -0.1983, -0.2429, -0.2247, -0.1574, -0.1141, -0.0773], +}; +for (const [name, want] of Object.entries(MEASURED)) { + const k = name.slice(0, 2); + const amt = name.endsWith('+') ? 1 : -1; + want.forEach((move, i) => { + const got = knob(MEASURED_L[i], k, amt); + assert.ok( + Math.abs(got - clamp01(MEASURED_L[i] + move)) <= 0.01, + `${name} at luma ${MEASURED_L[i]}: the table moves to ${got.toFixed(4)}, the export to ${(clamp01(MEASURED_L[i] + move)).toFixed(4)}` + ); + }); } -assert.ok(worstFold <= 0.01795 + 2e-3, `worst fold ${worstFold} at ${foldAt} for ${JSON.stringify(foldCombo)} — a composition deepens the doc's fold`); -// ...and every one of those combinations still lands on the two anchors, exactly. -for (const k of combos) { - exact(ramp(0, k).o, 0, `the toe moved at ${JSON.stringify(k)}`); - exact(ramp(1, k).o, 1, `the head moved at ${JSON.stringify(k)}`); -} -// A knob moves ONLY its own band. The windows are compactly supported, so off -// its own band a knob is the EXACT identity: the monochrome stock's own SHADOW -// (-0.20, or the classic stocks' -0.47) changes nothing where BLACK is working at -// t = 0.01, where the shared ceiling used to take 0.663 of BLACK's travel off that -// stock. That is the "kéo theo sự thay đổi của thông số khác" report, retired. -exact(ramp(0.01, { bl: -1 }).o, ramp(0.01, { bl: -1, sh: -0.47 }).o, 'SHADOW reached into BLACK\'s band'); -exact(ramp(0.90, { hl: -1 }).o, ramp(0.90, { hl: -1, sh: 0.47 }).o, 'SHADOW reached into HIGHLIGHT\'s band'); -exact(ramp(0.05, { sh: -1 }).o, ramp(0.05, { sh: -1, wh: 1 }).o, 'WHITE reached into SHADOW\'s band'); -// The reach of each knob, read off the formula. These are OUTPUT values of the -// ramp (`.o`) to five decimals, so near4 is the rounding of what is written down -// and nothing wider. BLACK - is the toe's exponent and + the doc's lift; SHADOW is -// a gain on the light, so it reaches past 0.5 (0.51064 at +100 against 0.48936 at -// -100 — the bell under the window is not symmetric); HIGHLIGHT moves the head -// without reaching it (0.95722 at 0.95 against 0.97579 before this pass — the -// squared headroom spends the move lower down), and WHITE is the one that does -// reach it, from 0.92 up (see the check below). -for (const [knob, sides] of [ - ['bl', [ - [-1, [[0.05, 0.00105], [0.10, 0.06618], [0.15, 0.14899], [0.20, 0.20], [0.50, 0.50]]], - [1, [[0.05, 0.11082], [0.10, 0.11765], [0.18, 0.18]]], - ]], - ['sh', [ - [1, [[0.10, 0.17412], [0.20, 0.33384], [0.50, 0.51064]]], - [-1, [[0.10, 0.02588], [0.20, 0.06616], [0.50, 0.48936]]], - ]], - ['hl', [ - [1, [[0.70, 0.81270], [0.80, 0.89202], [0.90, 0.93542], [0.95, 0.95722]]], - [-1, [[0.70, 0.58730], [0.80, 0.70798], [0.95, 0.94278]]], - ]], - ['wh', [ - [1, [[0.90, 0.96750], [0.95, 1.0]]], - [-1, [[0.90, 0.83250], [0.95, 0.86984]]], - ]], -]) - for (const [amount, cases] of sides) - for (const [t, want] of cases) - near4(ramp(t, { [knob]: amount }).o, want, `${knob} at ${amount} on ${t}`); -// HIGHLIGHT does not clip: +100 at 0.95 is still under the ceiling of the cube -// (0.95722, and 0.93542 at 0.90), because the headroom it reads is squared and the -// move has died out by the top — while WHITE, the frame's clipping point, takes the -// ramp TO 1.0 from 0.92 up, which is what §2.4 asks of it. Either way the only -// interior value a full set of knobs reaches 1.0 on is a pixel already at the head. -assert.ok(ramp(0.95, { hl: 1 }).o < 1, 'HIGHLIGHT +100 is clipping the head'); -assert.ok(ramp(0.9, { hl: 1 }).o < 1, 'HIGHLIGHT +100 is clipping the head'); -assert.ok(ramp(0.93, { wh: 1 }).o >= 1 - 1e-12, 'WHITE +100 is not reaching the ceiling it is named for'); -exact(ramp(0.99, { bl: 1, sh: 1, hl: 1, wh: 1 }).o, 1, 'a full set of knobs on a bright pixel does not reach white'); -// DR rides the same four moves, a share of each (0.12 / 0.06 / -0.09 / -0.18), -// and the two ends are still fixed points: +0.12 of BLACK lifts nothing at -// L = 0 and -0.18 of WHITE rolls nothing at L = 1. The middle moves with the -// SHADOW share DR takes — 0.500638 where the old masked terms summed to a flat -// 0.5 — which is the DR a frame can see. -exact(ramp(0, { dr: 1 }).o, 0, 'DR lifted the toe off the floor'); -exact(ramp(1, { dr: 1 }).o, 1, 'DR rolled the head under 1.0'); -near4(ramp(0.5, { dr: 1 }).o, 0.50064, 'DR no longer moves the middle the way it did'); -// BLACK and SHADOW both at -1 under DR: the two crush moves meet under the toe -// window's edge and the 1/512 cell reads a backtrack there (the monotone sweep -// above), where the old additive terms folded the stretch outright. -exact(ramp(0, { dr: 1, bl: -1, sh: -1 }).o, 0, 'the floor is not the floor'); -near4(ramp(0.25, { dr: 1, bl: -1, sh: -1 }).o, 0.10998, 'DR moved off the 0.25 knot'); -near4(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.28063, 'DR folded the flat stretch'); -// A band can only be lifted at the cost of the slope inside it — the report this -// whole design answered — so each knob's own quarters are swept over the whole -// travel and bounded rather than left to a comment. SHADOW's window spans the -// middle, so both its quarters take the move: over the sweep the slope below the -// 0.25 knot reads 1.5958 down to 0.4042 and the band above it 1.5532 down to -// 0.4468, which is the quarter it is allowed to give up (neither may be drawn -// flat, and neither may be stretched past one and a half — a crush that flattens -// the darks into one black is the same defect upside down). -for (let sh = -1; sh <= 1.0001; sh += 1 / 64) { - const below = (ramp(0.25, { sh }).o - ramp(0, { sh }).o) / 0.25; - const band = (ramp(0.5, { sh }).o - ramp(0.25, { sh }).o) / 0.25; - assert.ok(below >= 0.40 - 1e-12, `SHADOW drew the quarter below it flat at ${sh}: slope ${below}`); - assert.ok(band >= 0.40 - 1e-12, `SHADOW drew the band above it flat at ${sh}: slope ${band}`); - assert.ok(below <= 1.60 + 1e-12, `SHADOW stretched the quarter below it at ${sh}: slope ${below}`); - assert.ok(band <= 1.60 + 1e-12, `SHADOW stretched the band above it at ${sh}: slope ${band}`); -} -// HIGHLIGHT pays the same way and at BOTH ends — its knot is the head of the top -// quarter, so lifting it draws 0.75..1.00 flat and pulling it draws 0.50..0.75 — -// and its window is the narrower one, so its own spread over the sweep is 0.6875 -// to 1.3125 either side of the knot against the half it is allowed. -for (let hl = -1; hl <= 1.0001; hl += 1 / 64) { - const mid = (ramp(0.75, { hl }).o - ramp(0.5, { hl }).o) / 0.25; - const top = (ramp(1, { hl }).o - ramp(0.75, { hl }).o) / 0.25; - assert.ok(mid >= 0.50 - 1e-12, `HIGHLIGHT drew the quarter below it flat at ${hl}: slope ${mid}`); - assert.ok(top >= 0.50 - 1e-12, `HIGHLIGHT drew the top quarter flat at ${hl}: slope ${top}`); - assert.ok(mid <= 1.50 + 1e-12, `HIGHLIGHT stretched the quarter below it at ${hl}: slope ${mid}`); - assert.ok(top <= 1.50 + 1e-12, `HIGHLIGHT stretched the top quarter at ${hl}: slope ${top}`); -} -// The film stocks ride the same windows, so their numbers are their own. The -// knots they were tuned to are 0.18 Classic Chrome/Vivid, 0.22 Acros, 0.17 Acros -// HC, with the head 0.7375 on Acros and 0.815 on Acros HC — and through this -// curve they read 0.179992 / 0.220209 / 0.169565 and 0.7375 / 0.814844, so the -// two Acros toes land 2.1e-4 and 4.3e-4 under the target (the shadow window's own -// bell at 0.25, which the reach checks above have already measured). The entries -// are pinned to the values the ramp is actually read at, so a stock that drifts -// off its look is a red check. +// The film stocks ride these same curves, so their numbers are their own. Each +// stock is written against a toe (the ramp's reading at luma 0.25) and a head +// (its reading at 0.75) — 0.18 / 0.22 / 0.17 and 0.7375 / 0.815 — and the +// measured tables put them at 0.1792 / 0.2126 / 0.1735 and 0.7397 / 0.8151. The +// Acros toe lands 0.0074 under its target because the HIGHLIGHT move reaches down +// to luma 0.22, so its shoulder lands on the toe as well; B&W HIGH CONTRAST is at +// FULL SHADOW deflection because the WHITES lift it asks for raises the darks by +// 0.074 at that luma, and 0.1735 is then the floor. It is the READ values that +// are pinned, next to the source they come from, so a stock that drifts off its +// look is a red check. const filmTone = tone.match(/const FILM_TONE[\s\S]*?\n};/)?.[0]; assert.ok(filmTone, 'FILM_TONE is gone — the stocks no longer shape the ramp at all'); // The keys are quoted or not depending on whether they are identifiers, so the // quotes come off before the lookup. const filmFlat = filmTone.replace(/['"]/g, ''); -for (const [name, sh, knot, hlSrc, hl, head] of [ - ['classic-chrome', -0.47, 0.17999192], - ['classic-vivid', -0.47, 0.17999192], - ['monochrome', -0.2, 0.22020933, '-0.1143', -0.1143, 0.7375], - ['mono-high-contrast', -0.54, 0.16956519, '0.5929', 0.5929, 0.81484375], +for (const [name, src, kn, toe, head] of [ + ['classic-chrome', 'sh: -0.47', { sh: -0.47 }, 0.1791844], + ['classic-vivid', 'sh: -0.47', { sh: -0.47 }, 0.1791844], + ['monochrome', 'sh: -0.20, hl: -0.1143', { sh: -0.2, hl: -0.1143 }, 0.2126235, 0.7396688], + ['mono-high-contrast', 'sh: -1.0, wh: 0.26', { sh: -1.0, wh: 0.26 }, 0.1734727, 0.8150685], ]) { assert.ok( - filmFlat.includes(`${name}: { sh: ${sh}`), - `${name} is not on the ${sh} its crush was solved at` + filmFlat.includes(`${name}: { ${src} }`), + `${name} is not on the ${src} its ends were solved at` ); - near4(ramp(0.25, { sh }).o, knot, `${name}'s shadow knot moved`); - if (hlSrc) { - assert.ok(filmFlat.includes(`hl: ${hlSrc}`), `${name} is not on the ${hlSrc} its shoulder was solved at`); - near4(ramp(0.75, { hl }).o, head, `${name}'s highlight knot moved`); + near4(ramp(0.25, kn).o, toe, `${name}'s shadow knot moved`); + if (head) near4(ramp(0.75, kn).o, head, `${name}'s highlight knot moved`); +} + +// Non-decreasing on EVERY segment, for every amount in [-1, 1] — not just at the +// ±100 the exports were sampled at. A fold in a tone curve is a band the eye +// reads, and a knob's own shape is what has to rule it out: the tables are cut so +// L + move never turns back. +for (const k of KNOBS) + for (let amt = -1; amt <= 1.0001; amt += 1 / 64) { + let prev = -Infinity; + for (let i = 0; i <= 4000; i++) { + const o = knob(i / 4000, k, amt); + assert.ok(o >= prev - 1e-12, `${k} at ${amt.toFixed(4)} folds the ramp at luma ${i / 4000}`); + prev = o; + } + } +// Every knob on zero is EXACTLY the identity — the pass also runs for the stock +// split tones and for DR alone, so a neutral setting must not curve the frame. +for (let i = 0; i <= 2000; i++) exact(ramp(i / 2000, {}).o, i / 2000, `identity broke at ${i / 2000}`); +// The ends of the ramp are where the MEASUREMENT put them, and nowhere else. The +// floor is the one that surprises: HIGHLIGHT +100 lifts a true black by 0.084 — +// the biggest move any knob makes on luma 0 — while BLACK +100, the knob named +// for it, is worth 0.0042 there; and the head moves under WHITES -100 alone +// (-0.10 on a true white, which is why its table does not tail off to zero). +// Nothing may drag an end further than the export did. +for (const k of KNOBS) + for (const side of ['plus', 'minus']) { + const c = TONE_KNOBS[k][side]; + const amt = side === 'plus' ? 1 : -1; + near4(knob(0, k, amt), clamp01(c.v[0]), `${k}.${side} floor travel`); + near4(knob(1, k, amt), clamp01(1 + c.v[c.v.length - 1]), `${k}.${side} head travel`); + } +for (const k of KNOBS) + for (const side of ['plus', 'minus']) { + const c = TONE_KNOBS[k][side]; + assert.ok(Math.abs(c.v[0]) <= 0.1, `${k}.${side} drags the floor by ${c.v[0]} — past what any export did`); + assert.ok(Math.abs(c.v[c.v.length - 1]) <= 0.1, `${k}.${side} drags the head by ${c.v[c.v.length - 1]} — past what any export did`); + } +// The two landmarks the whole change is measured by, read at the export's own +// numbers: WHITES +100 puts everything above luma 0.50 on 1.0 — it rounds to the +// ceiling from there up, which is the "+0.43 mean" a user sees as the frame +// opening — and BLACKS -100 puts everything below 0.21 on 0.0 (under a code +// value), the crushed toe. +for (let L = 0.5; L <= 1.0001; L += 1 / 512) + assert.equal(Math.round(knob(L, 'wh', 1) * 255), 255, `WHITES +100 leaves luma ${L} short of the ceiling`); +for (let L = 0; L <= 0.2; L += 1 / 512) + assert.ok(knob(L, 'bl', -1) < 0.006, `BLACKS -100 leaves luma ${L} above the floor`); +// On a mid-grey the four are all live — the old windows left the 0.50 midpoint to +// nothing but the two ends, which is the "không thay đổi" the report is about. +for (const k of KNOBS) { + assert.ok(Math.abs(knob(0.5, k, 1) - 0.5) > 0.01, `${k} +100 does nothing on a mid-grey`); + assert.ok(Math.abs(knob(0.5, k, -1) - 0.5) > 0.001, `${k} -100 does nothing on a mid-grey`); +} +// ...and no combination of the four at full deflection can drive an end of the +// ramp off the cube, fold the ramp back on itself, or cross the toe over the +// head — the composition is monotone by construction, and this is that +// construction run over all 162 combinations. +const combos = []; +for (const bl of [-1, 0, 1]) + for (const sh of [-1, 0, 1]) + for (const hl of [-1, 0, 1]) + for (const wh of [-1, 0, 1]) for (const dr of [0, 1]) combos.push({ bl, sh, hl, wh, dr }); +for (const k of combos) { + const lo = ramp(0, k).o; + const hi = ramp(1, k).o; + assert.ok(lo >= 0 && hi <= 1, `the ramp left the cube at ${JSON.stringify(k)}`); + assert.ok(lo <= hi, `the toe ${lo} climbed over the head ${hi} at ${JSON.stringify(k)}`); + let prev = -Infinity; + for (let i = 0; i <= 2000; i++) { + const o = ramp(i / 2000, k).o; + assert.ok(o >= prev - 1e-12, `the ramp folds at ${i / 2000} with ${JSON.stringify(k)}`); + prev = o; } } -// The two ends stay ordered even at full deflection against each other: the toe -// can never climb past the head. -for (const bl of [-1, 1]) - for (const wh of [-1, 1]) { - const toe = ramp(0, { bl, sh: 1, wh }).o; - const head = ramp(1, { bl, wh, hl: -1 }).o; - assert.ok(toe <= head + 1e-12, `toe ${toe} over head ${head}`); - } // The colour rebuild, as the shader emits it: the ramp's luma, the pixel's own // chroma difference, and the one scale o / t the cube then gets to pull back. @@ -603,11 +447,11 @@ const lumaOf = (c) => clamp01(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]); // alone already landed the pixel inside the cube, and a silent clamp would hide // the case where it did not. function lightMove(rgb, t, o) { - let k = t > 0.0004 ? o / t : 1; + let k = t > 0.0004 ? Math.min(o / Math.max(t, 0.005), 3.5) : 1; const hiC = Math.max(...rgb); const loC = Math.min(...rgb); if (hiC > t) k = Math.min(k, (1 - o) / (hiC - t)); - if (loC < t) k = Math.min(k, o / (t - loC)); + if (loC < t) k = Math.min(k, o / Math.max(t - loC, 0.0001)); return rgb.map((c) => o + (c - t) * k); } // The rebuild, with the base layer the shader now draws the ramp through. `base` @@ -713,40 +557,38 @@ for (const [rgb, knobs] of [ // 0.50, at the deflection this was reported at (the full +100, which is +1 here): // // read at the pixel every pixel lands on its own o, so the band's spread -// comes out multiplied by the slope the curve has left over -// it — the grey sheet, drawn flat, 0.4163 of its own spread -// at this deflection (the shadow window is a gain whose bell -// is already falling through 0.26..0.50, so the quarter -// above the knot keeps less of itself than a straight -// segment would have kept). +// comes out multiplied by whatever slope the curve has +// over it — 1.2347 of its own spread at this deflection, +// the SHADOW +100 table steepening through 0.26..0.50. // read at the base every pixel of ONE neighbourhood takes the same move, // o(base) - base, and its own difference from the base is // added to it, so the texture inside the region comes out // at ITS OWN size and the same lift lands on the pixels // either way. // -// Those are the two numbers the live probe reads off the deployed bundle (0.57 -// before, 0.78 after, over this frame); this is the same claim in arithmetic. -// The spread here is kept WHOLE and not at 0.78 — this twin's band sits on one -// base, where the reconstruction is exact by construction; on a real frame the -// band spans many neighbourhoods and the live number is the one to read. +// The base is the whole point of the second reading: what SHADOW does to a +// frame's texture is a property of the neighbourhood, not of the pixel. (The +// spread here is kept WHOLE because this twin's band sits on ONE base, where the +// reconstruction is exact by construction; on a real frame the band spans many +// neighbourhoods and the live number is the one to read.) const band = Array.from({ length: 32 }, (_, i) => 0.26 + 0.24 * (i / 31)); const spread = (xs) => Math.max(...xs) - Math.min(...xs); const bandBase = band.reduce((a, b) => a + b, 0) / band.length; const movedGlobally = band.map((t) => rebuild([t, t, t], { sh: 1 }).clamped[0]); const movedLocally = band.map((t) => rebuild([t, t, t], { sh: 1 }, bandBase).clamped[0]); -near4(spread(movedGlobally) / spread(band), 0.41631, 'the pixel-read ramp no longer draws its own band flat'); +// Read at the PIXEL the band takes the ramp's own slope where it sits and comes +// back at 1.2347 of its own spread: a lift that steepens the ramp over the band +// draws the band longer, which is the global move and not this pass's. +near4(spread(movedGlobally) / spread(band), 1.2347, 'the pixel-read ramp no longer draws its own band longer'); const bandGain = ramp(bandBase, { sh: 1 }).o / bandBase; assert.ok(bandGain > 1.1, `the lift is not worth measuring: gain ${bandGain}`); +// Read at the BASE the whole band takes ONE move — the same lift on every pixel +// of it — so its spread comes out exactly whole and the texture is untouched. close(spread(movedLocally) / spread(band), 1, 'the band lost its texture under the lift — the detail is being scaled again'); assert.ok( Math.abs(movedLocally[0] - band[0] - (ramp(bandBase, { sh: 1 }).o - bandBase)) < 1e-12, 'the lift is no longer the neighbourhood’s', ); -assert.ok( - spread(movedLocally) / spread(movedGlobally) > 1.5, - `the base is not earning its keep: ${spread(movedLocally) / spread(movedGlobally)}x the global move's spread` -); // The move belongs to the NEIGHBOURHOOD, not to the pixel: two pixels of one base // take the same one however far apart they sit, which is exactly what leaves the // difference between them standing. (Read at the pixel, the move would be the @@ -820,7 +662,7 @@ for (const ev of [-5, -1, 0, 1, 5]) assert.equal(exposureMove([0, 0, 0], ev)[0], // only as good as its reading of the source; nothing else compiles EXPOSURE_SKSL, // so a wrapper whose uniform stopped matching its own main would only show up in // the app. Four pixels through the real shader, against the twin. -const exposureSrc = resolve(tone.match(/export const EXPOSURE_SKSL = `([\s\S]*?)`;/)?.[1] ?? ''); +const exposureSrc = EXPOSURE_SKSL; assert.match(exposureSrc, /uniform float ev;/, 'the exposure pass no longer takes its stops'); assert.match(exposureSrc, /return vec4\(exposureMove\(clamp\(c\.rgb, 0\.0, 1\.0\), ev\), c\.a\);/, 'the pass stopped calling exposureMove'); const { default: CanvasKitInit } = await import('canvaskit-wasm/bin/full/canvaskit.js'); diff --git a/docker/frontend/scripts/tone-base-check.mjs b/docker/frontend/scripts/tone-base-check.mjs index d8bedf4..763dfcf 100644 --- a/docker/frontend/scripts/tone-base-check.mjs +++ b/docker/frontend/scripts/tone-base-check.mjs @@ -35,7 +35,7 @@ writeFileSync( ); const { TONE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive, - TONE_BLACK_EDGE, TONE_WHITE_EDGE, TONE_BLACK_LIFT, TONE_BLACK_CRUSH, TONE_HIGH_GAIN, TONE_WHITE_GAIN, + TONE_KNOBS, } = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href); // The shader has to NAME a base child — a ring of taps would not need one. @@ -85,54 +85,31 @@ function render(srcValue, baseValue) { return pixels[0]; } -// The ramp, in the same arithmetic the shader runs: four COMPACT windows of the -// encoded luma — BLACK's toe dying on 0.18, SHADOW's bell over the deep tones, -// HIGHLIGHT against the headroom that is left, WHITE's Hermite on the shoulder — -// applied ONE AFTER THE OTHER and clamped on the way to the next, so the four -// moves compose instead of summing and the ramp needs no guard. SHADOW carries -// the whole knob at its own window, so SHADOW +100 reads toneCurve(b, 0, 1, 0, 0) -// (b and a1 are gone with the bumps; see the note at the head of toneRamp). +// The ramp, in the same arithmetic the shader runs: ONE KNOB is the measured +// knot table (TONE_KNOBS in toneShader.ts) drawn STRAIGHT between the knots, +// scaled by the amount, added to the luma it reads and clamped — the minus side +// of zero reads the minus table — and the four are applied ONE AFTER THE OTHER +// and clamped on the way to the next, so the four moves compose instead of +// summing. SHADOW carries the whole knob on its own, so SHADOW +100 reads +// toneCurve(b, 0, 1, 0, 0). const clamp01 = (x) => Math.min(1, Math.max(0, x)); -const smoothstep = (e0, e1, x) => { - const u = clamp01((x - e0) / (e1 - e0)); - return u * u * (3 - 2 * u); -}; -const toneBlackW = (L) => { - const u = clamp01(1 - L / TONE_BLACK_EDGE); - return u * u * u; -}; -const toneShadowW = (L) => smoothstep(0.02, 0.12, L) * (1 - smoothstep(0.25, 0.55, L)); -const toneHighW = (L) => smoothstep(0.45, 0.65, L) * (1 - smoothstep(0.92, 1.0, L)); -const toneWhiteW = (L) => { - const u = clamp01((L - TONE_WHITE_EDGE) / (1 - TONE_WHITE_EDGE)); - return u * u; -}; -// toneBlack, as the shader emits it: the lift is the doc's own, the crush is the -// toe's EXPONENT (monotone for any rate, and the only form that moves a visible -// amount inside a window that is 1 only at the floor). -const toneBlack = (L, bl) => { - const q = toneBlackW(L); - if (bl > 0) return L + TONE_BLACK_LIFT * bl * q * (Math.sqrt(L) - L); - return L >= TONE_BLACK_EDGE - ? L - : TONE_BLACK_EDGE * (L / TONE_BLACK_EDGE) ** (1 + TONE_BLACK_CRUSH * -bl * q); +const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x)); +const poly = (c, L) => { + let v = c.v[0]; + for (let i = 1; i < c.p.length; i++) + v += (c.v[i] - c.v[i - 1]) * clamp((L - c.p[i - 1]) / (c.p[i] - c.p[i - 1]), 0, 1); + return v; }; +const knob = (L, k, amt) => + clamp01(L + (amt >= 0 ? amt * poly(TONE_KNOBS[k].plus, L) : -amt * poly(TONE_KNOBS[k].minus, L))); // toneCurve, move for move, with the clamp the shader puts after each one. // `afterBlack` is the L the SHADOW move reads. function toneCurve(L, bl, sh, hl, wh) { - let q = toneBlackW(L); - L = toneBlack(L, bl); - L = clamp01(L); - const afterBlack = L; - q = toneShadowW(L); - L *= 1 + sh * q * (1 - L) ** 1.8; - L = clamp01(L); - q = toneHighW(L); - L += TONE_HIGH_GAIN * hl * q * Math.max(L - 0.5, 0) ** 1.5 * (1 - L) * (1 - L); - L = clamp01(L); - q = toneWhiteW(L); - L += TONE_WHITE_GAIN * wh * q * (1 - L) * L; - return { o: clamp01(L), afterBlack }; + const afterBlack = knob(L, 'bl', bl); + return { + o: knob(knob(knob(afterBlack, 'sh', sh), 'hl', hl), 'wh', wh), + afterBlack, + }; } const ramp = (b, k = {}) => toneCurve(b, k.bl ?? 0, k.sh ?? 0, k.hl ?? 0, k.wh ?? 0); const srcValue = 128; // the pixel: 0.501961 encoded @@ -143,7 +120,11 @@ const b = baseValue / 255; // Base' + Detail, the reconstruction the shader emits (see the note in // toneRamp). The ratio Base' * (Input / Base) is what this used to predict, and // it is what took the texture out of the frame at BLACK -100. -const expected = Math.round(255 * clamp01(ramp(b, { sh: 1 }).o + (t - b))); +const diff = Math.abs(t - b); +const smooth = (x) => x * x * (3 - 2 * x); +const edgeW = 1.0 - (diff <= 0.08 ? 0 : diff >= 0.35 ? 1 : smooth((diff - 0.08) / 0.27)); +const effBase = t * (1 - edgeW) + b * edgeW; +const expected = Math.round(255 * clamp01(ramp(effBase, { sh: 1 }).o + (t - effBase))); const got = render(srcValue, baseValue); assert.ok( diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 266699f..b1da93d 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -4,18 +4,16 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils'; // Tone-domain adjustments (the four-point tonal range + Fuji-style Dynamic // Range). SkSL runtime effect over a child image shader. // -// TONAL RANGE — HIGHLIGHT, SHADOW, WHITE and BLACK. The four knobs are the -// four zones of the tone mapping doc, and no two of them own the same part of -// the ramp: -// -// BLACKS peak at 0.00, gone by 0.25 -// SHADOWS peak at 0.25, gone by 0.50 -// HIGHLIGHTS peak at 0.75, gone by 0.50 and by 1.00 -// WHITES peak at 1.00, gone by 0.75 -// -// The four masks below are those tents — the doc's smoothsteps, one per quarter -// of the ramp — and the 0.50 midpoint is in none of them: it is the one value -// every one of the four leaves where it was. +// TONAL RANGE — HIGHLIGHT, SHADOW, WHITE and BLACK. Each knob is the MOVE its +// own Lightroom export made, measured off the nine reference files in the repo +// root and stored as a polyline on the encoded luma (TONE_KNOBS below): WHITE +// +100 takes everything above luma 0.50 to 1.0, BLACK -100 takes everything +// below 0.21 to 0.0, HIGHLIGHT +100 is a +0.24 lift peaking around luma 0.18 and +// dead by 0.72. Read the code values as `out = L + amt * curve(L)`, clamped, amt +// = knob / 10. The four are NOT the four zones of the tone mapping doc any more, +// a tent per quarter of the ramp with a quarter of travel each: that is the +// layout the "sau khi chỉnh sửa 4 thông số này không đúng, không thay đổi" report +// came back on, WHITE ±100 a literal no-op among them. // // Colour: the luma takes the move and R, G, B keep their DIFFERENCES — the // pixel lands on its new luma with the chroma it had, so the hue is untouched @@ -23,30 +21,23 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils'; // (`R_new = R_old * Luma_new / Luma_old`), which is exact while it fits and // moves the hue the moment a channel clips; see the note on `k` below. // -// These four masks are ADDED in the doc's own pseudo-shader, and measured that -// way the ramp inverts: BLACK +10 against SHADOW -10 falls to a slope of -5 per -// unit luma at t = 0.87 (scratchpad tone-proto.mjs), a dark band where the ramp -// should still be climbing. Read here instead as the four ANCHORS of one ramp — -// knots at 0.00, 0.25, 0.50, 0.75 and 1.00, each moved by its own knob, each -// held inside the knot before it, drawn straight in between — the same -// measurement is monotone for every combination of the four at full deflection. -// A knob moves its anchor by a quarter of the ramp, so +10 BLACKS puts the toe -// on 0.25 and -10 WHITES rolls the head down to 0.75: the reach a tonal range -// slider has in the program this layout copies, without the inversion. SHADOW is -// the one knob held to half of that, because the knot it moves is the HEAD of -// the quarter above it and not an end of the ramp: a band cannot be lifted at -// its head and keep its slope at the same time, so the knob's travel is what has -// to give — see the measured note on a1. +// The four moves are applied ONE AFTER THE OTHER — BLACK, SHADOW, HIGHLIGHT, +// WHITE — each clamped on the way to the next. The doc ADDED its four masks, and +// measured that way the ramp inverts: BLACK +10 against SHADOW -10 falls to a +// slope of -5 per unit luma at t = 0.87 (scratchpad tone-proto.mjs), a dark band +// where the ramp should still be climbing. A composition of non-decreasing maps +// is non-decreasing by construction, so the ramp cannot fold however the four +// sliders are set, and the knot tables are cut so that L + move is monotone on +// every segment; highlight-knee-check.mjs sweeps both. // // WHITE and BLACK are not the per-channel toe and shoulder they were on the WB -// tab any more. The doc puts the two points on the ends of the SAME ramp as the -// other two, so they are the ends this ramp is drawn through, and nothing else -// in the shader reads them. +// tab any more. They are the two ENDS of the same ramp the other two move, and +// nothing else in the shader reads them. // // The ramp is drawn through the BASE LAYER, not through the pixel. The pixel's // own luma, run through a knot move, is a GLOBAL curve: every pixel at luma t -// lands on the same o whatever is around it, so a knot lifted onto the band above -// it (SHADOW's a1) is a band whose whole spread is squashed to the slope left +// lands on the same o whatever is around it, so a knob that lifts a band onto +// the one above it is a band whose whole spread is squashed to the slope left // over — at SHADOW +100 a quarter of the ramp carries half its contrast, and on a // real frame 0.50 of it survived: the grey sheet the knob was reported for. What // the eye is reading there is LOCAL contrast, and a curve drawn through the pixel @@ -82,29 +73,34 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils'; // to six code values in the quarter-tones even with all four knobs on zero — and // this pass still runs for the stock split tones and for DR alone, where nothing // the user set asked for a contrast move. Straight segments keep a neutral -// setting the exact identity. The smoothsteps are the four ZONE masks above, -// which is where the doc's shape belongs: they weight DR and the split tones, -// and nothing but their peak positions has to be smooth. +// setting the exact identity. What smoothsteps are left in toneRamp weight the +// chroma boost, the noise floor and the highlight desaturation — gates on the +// result, none of them on the ramp itself. // // The -HL highlight recovery that used to run in LINEAR light ahead of all this -// is gone with it: HIGHLIGHT is one zone move now, in both directions. There is +// is gone with it: HIGHLIGHT is one move now, in both directions. There is // still detail at the top to move — the develop's own knee compresses the two // stops the sensor holds above its white level into the frame (see -// highlight-knee-check.mjs), so -WHITES pulls a plateau down onto 0.75 rather -// than onto a flat 1.0. +// highlight-knee-check.mjs), so -WHITES pulls a true white 0.10 down under it +// rather than onto a flat plateau. // // dr - DR strength 0..1: lifts shadows slightly and rolls highlights (Fuji -// extended DR); 0/auto/DR100 = no extra curve. It moves the same four -// knots the knobs move, so DR and a knob cannot fight over the middle and -// DR cannot invert the ramp either — added as its own masked terms on top -// it could, and did: see the fold noted on the knots below. -// hl - highlight -1..1: moves the 0.75 anchor, + up toward white, - down. -// sh - shadow -1..1: moves the 0.25 anchor, + up, - down. -// wh - white point -1..1: moves the 1.00 anchor. + is free to pass 1.0 — that -// is the move that clips a highlight toward white — and - pulls the head -// of the ramp down under it. -// bl - black point -1..1: moves the 0.00 anchor. + lifts the toe off the -// floor (a faded black), - has nothing left to crush at 0. +// extended DR); 0/auto/DR100 = no extra curve. It is added to the same +// four amounts the sliders set (bl +0.12, sh +0.06, hl -0.09, wh -0.18), +// so DR rides the measured curves with them and cannot invert the ramp +// the way a set of masked terms of its own did. +// hl - highlight -1..1: the measured HIGHLIGHT move — ±0.24 at full, on the +// DEEP tones, and gone by luma 0.73. The slider's name and what it moves +// are two different things, and the measurement is what is shipped. +// sh - shadow -1..1: the measured SHADOW move. + is a broad lift that is +// hardest at the top of the ramp (+0.17 by luma 0.71); - is a bell +// deepest on the deep tones (-0.16 at 0.22) and dead by 0.73. +// wh - white point -1..1: the measured WHITE move. + drives everything above +// luma 0.50 to 1.0 — the move that clips a highlight toward white — and - +// pulls a true white 0.10 down under it. +// bl - black point -1..1: the measured BLACK move. + lifts the toe off the +// floor (+0.115 by luma 0.19, so a faded black); - clips everything below +// ~0.23 to 0.0 rather than only the very floor. // vib - vibrance -1..1: chroma-masked saturation. It rides along in this shader // (rather than the colour matrix) because it needs per-pixel chroma: // already-vivid pixels move least, so skins/skies deepen without the neon @@ -139,63 +135,109 @@ const BAND_BLOCK = hslBandGaps() ) .join(''); -// The four tonal-range knobs, as the four bands the ramp in TONE_MATH_SKSL owns. -// Each band is compactly supported — a knob is exactly the identity outside its -// own — which is what makes the four independent without a guard, and a guard is -// what they used to share: one ceiling over two amplitudes, so a film stock that -// already sits on SHADOW took the BLACK knob's travel down with it (0.663 of it -// on the monochrome stock). See the head of the ramp for the whole argument. -// -// The edges are the doc's (§2.1 and §2.4): BLACKS dies on 0.18, the middle grey -// the doc anchors the toe to, and WHITES starts on 0.80, its shoulder. +// How far down the ramp the BLACK knob's Hunt-effect chroma mask reaches. The +// knob's own curve is measured (see TONE_KNOBS); this is the floor the chroma +// boost reads, and it is the doc's 0.18 — the middle grey it anchors the toe to. export const TONE_BLACK_EDGE = 0.18; -export const TONE_WHITE_EDGE = 0.80; -// How much of its band a knob is worth at full deflection — 100 on the slider. +// ONE TONAL KNOB, as the curve Lightroom actually draws. Measured, not derived. // -// Sized by what the EYE can see, not by what the doc's own rates happen to be. At -// the doc's numbers a full +100 was worth 0.060 of luma on BLACKS, 0.082 on -// HIGHLIGHTS and 0.042 on WHITES (the arithmetic sweep on the scratchpad, and the -// probe that ran the frame through the pass: BLACKS moved its mean by 0.0001 in -// either direction), which is the "giá trị thay đổi quá nhỏ, không thể hiện trên -// thị giác" report. Each rate below is now the largest its own move allows under -// the contracts hold in highlight-knee-check: monotone at any amount on the -// slider, both anchors fixed exactly, and a knob still exactly the identity off -// its own band. +// The four knobs were the doc's four compact windows (thay_doi_thong_so_giong_ +// lightroom.md §2), and they did not survive contact with the exports the app is +// judged against. The doc puts every move inside a quarter of the ramp — BLACKS +// dead past 0.18, SHADOWS down on the floor, HIGHLIGHTS up on the shoulder — and +// at full deflection each was worth a few hundredths of luma on that quarter and +// literally nothing outside it: the "sau khi chỉnh sửa 4 thông số này không đúng, +// không thay đổi" report. The reference exports say something else entirely: on +// Main.jpg, WHITE +100 takes everything above luma 0.50 to 1.0 (mean +0.43), +// BLACK -100 takes everything below 0.21 to 0.0 (mean -0.14), and HIGHLIGHT +100 +// is a +0.24 lift peaking around luma 0.18 that has died by 0.72. Whatever the +// slider is called, the MOVE is what a knob has to reproduce, so the move is what +// is stored here. // -// BLACK's lift is the doc's square root at the rate monotonicity allows — 0.93, -// the last rate whose own toe stays monotone on the check's grid (0.94 folds -// 3.4e-6 of luma back on itself, 0.9387 is the exact ceiling, and the travel -// between 0.93 and 0.94 is 1e-4, so the round number costs nothing). Its crush -// cannot be the doc's multiplicative rate at all: that form -// is bounded by its own window and is worth 0.019 of luma at full -100, five code -// values on a black patch, invisible. The crush is the toe's own exponent instead -// (see toneBlack), which is monotone for ANY rate, and worth 0.054. +// Every curve below is the per-pixel luma the Lightroom export landed on MINUS +// the base luma, binned by base luma and averaged, at ±100 — measured off the +// nine reference files in the repo root (Main.jpg, Main-highlight±100.jpg, +// Main-shadow±100.jpg, Main-white±100.jpg, Main-black±100.jpg), all genuine +// Lightroom Classic 14.2 exports of the same frame. Read the code values as: +// out = L + amt * curve(L), clamped, with amt = knob/10. // -// HIGHLIGHT and WHITE are measured rather than inherited — the doc draws the -// shapes and leaves the rates off, and its own sample (a raw pow(L, 1.5) with no -// headroom term, and a WHITE worth 0.5) either blows the frame or does nothing on -// one. Both head terms are only monotone while their own rate stays under a -// ceiling, and the ceiling is a FOLD, not a slope: the move overshoots 1.0, the -// clamp draws a plateau, and the ramp then falls back under it — measured under -// the knee check as the drop from the running maximum, at 1/32768 of the ramp. -// The head term is squared (see the note above toneCurve) because that is what -// buys the room: the move dies out before it can reach the ceiling. +// They are drawn STRAIGHT between the knots, which is the same choice the ramp +// has always made (see the head of TONE_MATH_SKSL) and the right one here: the +// measurement is a polyline, the eye cannot see a 2-code-value corner, and a +// smoothstep through the knots would put an S-curve in the darks no export asked +// for. Knots are placed where the curve bends, ~0.008 of luma is the worst error +// anywhere against the measured grid, and the last knot is forced to 0.0 on 1.0 +// so a bright frame (this reference has nothing above luma 0.73) cannot inherit a +// constant lift at the white end. WHITES -100 is the exception that proves it is +// the shoulder anchor: its tail is -0.10, not 0. // -// HIGHLIGHT at 14.0 stretches the quarter it owns to 1.44x of the 1.5x the check -// allows and lands +0.113 on the band, 29 code values, with no fold in either -// direction. WHITE at 3.0 is worth +0.080 — and it SATURATES the top of the ramp -// from 0.92: that is the doc's own §2.4, where a WHITE is the frame's clipping -// point ("giới hạn cháy sáng") and not a Hermite that cannot move the head. It is -// felt only above the 0.80 shoulder, it is what the knob is for, and the head -// still lands on 1.0 exactly. The one fold left in the ramp is not from these -// rates: SHADOWS' crush is the doc's own multiplicative form and dips 0.018 of -// luma (4.6 code values) over the 0.06..0.11 band at full -100, measured and -// pinned in the knee check, whatever rate it is given. -export const TONE_BLACK_LIFT = 0.93; -export const TONE_BLACK_CRUSH = 8.0; -export const TONE_HIGH_GAIN = 14.0; -export const TONE_WHITE_GAIN = 3.0; +// Only ±100 was sampled, so a knob at 40 is 0.4 of the same curve. That is +// Lightroom's own read (its sliders are linear in the move) and it is the only +// thing the nine files can support. Re-measure with the harness in the checks: +// render the frame through TONE_SKSL at each knob's ±100 and diff. +export type TONE_KNOB = 'bl' | 'sh' | 'hl' | 'wh'; +export interface ToneKnobCurve { + p: number[]; // knot positions, encoded luma, ascending, p[0] = 0 and p[last] = 1 + v: number[]; // the move at +100, in luma, at each knot +} +export const TONE_KNOBS: Record = { + // BLACKS. + lifts from the very floor (0.004 at black, so a true 0 does not + // move) and peaks at 0.115 by luma 0.19. - is the clip: everything below 0.23 + // lands on 0 (the first segment's slope is -0.97 against the ramp's 1.0), then + // the frame comes back up to meet the prints by 0.51. + bl: { + plus: { p: [0, 0.01, 0.025, 0.045, 0.185, 0.645, 0.71, 0.73, 1], v: [0.0042, 0.0131, 0.0649, 0.0876, 0.1155, 0.0442, 0.0301, 0.0138, 0] }, + minus: { p: [0, 0.23, 0.34, 0.37, 0.51, 0.73, 1], v: [-0.0017, -0.2245, -0.253, -0.2502, -0.1518, -0.0707, 0] }, + }, + // SHADOWS. The one knob that reads the way its name does: + lifts everywhere + // and hardest at the top (+0.173 at 0.71) — a fill that never lets the bright + // end go; - is a bell deepest on the deep tones (-0.161 at 0.22, dead by 0.73). + sh: { + plus: { p: [0, 0.055, 0.185, 0.42, 0.57, 0.705, 0.73, 1], v: [0.0004, 0.0306, 0.063, 0.0941, 0.16, 0.1734, 0.1534, 0] }, + minus: { p: [0, 0.155, 0.22, 0.29, 0.355, 0.395, 0.46, 0.73, 1], v: [-0.0017, -0.1324, -0.1613, -0.1365, -0.1313, -0.0926, -0.0879, 0.0001, 0] }, + }, + // HIGHLIGHTS. Not the shoulder at all: + is a broad lift of the DEEP tones + // (+0.084 on a true black, +0.237 at 0.19) and - is a band that takes the + // bright end down (-0.169 at 0.63) and dies on 1.0. + hl: { + plus: { p: [0, 0.005, 0.015, 0.19, 0.29, 0.355, 0.405, 0.485, 0.73, 1], v: [0.084, 0.0955, 0.2133, 0.2372, 0.1632, 0.1424, 0.0987, 0.0828, 0.0001, 0] }, + minus: { p: [0, 0.22, 0.4, 0.5, 0.625, 0.695, 0.73, 1], v: [-0.0002, -0.0634, -0.0808, -0.1298, -0.1689, -0.1505, -0.0978, 0] }, + }, + // WHITES. + is the clipping point and nothing else: the move lands on 1.0 + // exactly from luma 0.50 (the tail is 1 - L, drawn by the knots 0.465 -> 0.73 + // -> 1.0) and lifts the deep tones to get there. - is the white anchor, moved + // down: -0.10 on 1.0, tapering to nothing at the floor. + wh: { + plus: { p: [0, 0.01, 0.025, 0.035, 0.05, 0.175, 0.22, 0.35, 0.385, 0.465, 0.73, 1], v: [0.0023, 0.0169, 0.1217, 0.1634, 0.2036, 0.4103, 0.464, 0.5295, 0.5647, 0.534, 0.27, 0] }, + minus: { p: [0, 0.495, 0.7, 0.73, 1], v: [0, -0.0294, -0.0609, -0.0831, -0.1] }, + }, +}; + +// One knot table as a polyline: the first value, plus one hinge per segment. +// Written as a SUM of clamps rather than a loop because SkSL indexes arrays by +// constant only (see the mixer at the foot of TONE_SKSL), and generated from the +// table so the source and the shader cannot drift. +const knobChain = (c: ToneKnobCurve): string => + c.v + .map((v, i) => + i === 0 + ? v.toFixed(4) + : ` + (${(v - c.v[i - 1]).toFixed(4)}) * clamp((L - ${c.p[i - 1].toFixed(3)}) / ${(c.p[i] - c.p[i - 1]).toFixed(3)}, 0.0, 1.0)` + ) + .join(''); + +// One knob, both directions, as the shader reads it: the move is the knot table +// on its own side of zero scaled by the amount, and the ramp is the move added +// to L and clamped. The clamp is what keeps a -- knob's own clip a plateau +// instead of a fold. +const KNOB_SKSL = (['bl', 'sh', 'hl', 'wh'] as const) + .map( + (k) => `float tone${k}(float L, float amt) { + return clamp(L + (amt >= 0.0 ? amt * (${knobChain(TONE_KNOBS[k].plus)}) : -amt * (${knobChain(TONE_KNOBS[k].minus)})), 0.0, 1.0); +}` + ) + .join('\n'); // How far out the BASE layer of `toneRamp` reads, as a fraction of the frame's // own width — the fix_shadow.md neighbourhood (it asks for 2%..5% of the width). @@ -235,119 +277,24 @@ export const TONE_BASE_SIGMA = 0.35; // has no such knob and hands in 0, which is what DR's terms are worth when it is // off on the frame too. export const TONE_MATH_SKSL = ` -// THE FOUR TONAL KNOBS, as thay_doi_thong_so_giong_lightroom.md §2 asks for them: -// each one owns a COMPACT band of the ramp and is exactly ZERO outside it, and the -// four moves are applied ONE AFTER THE OTHER instead of summed. -// -// The sum came with a guard, and the guard is where the knobs touched. Two bumps -// of a half share a slope, so past a total of 1 the sum carries the curve -// backwards — a fold — and the fix was one ceiling (holdLo / holdHi) shared by -// the amplitudes of a half. A stock that already sits on SHADOW therefore took -// BLACK's lift down with it: on the monochrome stock (sh = -0.24) BLACK -100 came -// back with 0.663 of the travel the knob has on its own, which is the "kéo theo -// sự thay đổi của thông số khác" report exactly. A composition of monotone maps -// is monotone by construction, so it needs no guard, and the same knob then -// measures 1.00 of its travel on every stock. (tone-curve, the arithmetic in -// plain JS on the scratchpad, and the grid sweep in highlight-knee-check.) -// -// The windows are the doc's own, in the encoded luma this whole file works in — -// the boundaries land where the doc's diagram draws them, BLACKS on the toe up to -// the doc's 0.18, SHADOWS as a bell over the deep tones, HIGHLIGHTS as a bell over -// the bright ones, WHITES on the shoulder from 0.80. -float toneBlackW(float L) { - float u = clamp(1.0 - L / ${TONE_BLACK_EDGE}, 0.0, 1.0); - // Squared once more than the doc's square for the same reason the sum's kernel - // was (1-u^2)^2: the join with the identity at L = 0.18 is a slope, not an - // angle, and an angle on a tone curve is a Mach band. - return u * u * u; -} -// BLACKS, both directions, one window. §2.1's TOE SLOPE, and the two moves it -// names are not the same kind of move: -// -// - the lift is the doc's own (L + amount * W * (sqrt(L) - L)), at the largest -// rate monotonicity allows. The square root's slope at the floor is what makes -// a bigger rate fold the toe back on itself — past 0.93 the ramp carries whole -// hundredths of luma backwards and a gradient wears the fold as a band. -// - the crush cannot be the doc's (L * (1 + amount * W * 0.85)) at any rate that -// can be seen. The window is 1 only AT the floor and this form multiplies what -// the window leaves, so its whole travel is 0.019 of luma at full -100 however -// hard the rate bites (0.85 or 1.0, measured), and a rate past 1 drives the -// product negative and clips the toe to a flat black instead of deepening it. -// The toe's own EXPONENT is the slope §2.1 asks for: x -> x^(1 + rate * W) is -// monotone for every rate, so it deepens what the doc's form cannot reach — -// 0.054 at full -100 — while x = 0 stays on 0 and x = 1 (the 0.18 edge) stays -// on 1, which is the compact support and the two anchors both kept. -float toneBlack(float L, float bl) { - const float W = ${TONE_BLACK_EDGE}; - float q = toneBlackW(L); - if (bl > 0.0) return L + ${TONE_BLACK_LIFT} * bl * q * (sqrt(L) - L); - if (L >= W) return L; - return W * pow(L / W, 1.0 + ${TONE_BLACK_CRUSH} * (-bl) * q); -} -float toneShadowW(float L) { - return smoothstep(0.02, 0.12, L) * (1.0 - smoothstep(0.25, 0.55, L)); -} -float toneHighW(float L) { - return smoothstep(0.45, 0.65, L) * (1.0 - smoothstep(0.92, 1.0, L)); -} -float toneWhiteW(float L) { - float u = clamp((L - ${TONE_WHITE_EDGE}) / (1.0 - ${TONE_WHITE_EDGE}), 0.0, 1.0); - return u * u; -} -// The ramp the pixel is rebuilt through. Read at the BASE, so the move is the -// neighbourhood's and the pixel keeps its own difference from it (fix_shadow.md's -// Base' + Detail): the ratio Base' * (Input / Base) was the first cut and it is -// what broke BLACK — it scales the detail by the neighbourhood's gain, so the -// knob that takes the base toward zero takes the picture's texture with it (the -// blur the report named on a monochrome frame, where every channel IS the luma). -// -// Every move below is monotone for any amount in [-1, 1] and lands on L = 0 and -// L = 1 without moving either, so the composition is monotone, the black point is -// the black point, and the white point is the white point whatever the four -// sliders say. The one deliberate departure from the doc's own arithmetic is the -// (1.0 - L)^2 on HIGHLIGHTS: the doc's raw soft-knee ADDS pow(L - 0.5, 1.5) to L, -// and above 0.94 that overshoots the cube — a measured 17% of the ramp driven to -// flat white at +100 before the clamp, the "cháy vùng Whites" the doc's own §1 -// opens by calling a defect. The knee reads the headroom that is left instead, so -// the move is zero at L = 1 by construction and the head rolls instead of -// clipping — and squared rather than linear, because one power of the headroom -// dies too slowly to keep the rate's own ceiling off the clamp: at a linear head -// a rate worth 0.113 of luma already overshoots 1.0 near 0.94 and the ramp falls -// back over the plateau by 0.018 (the fold the knee check measures), so the rate -// that shape allows is the 0.082 this whole pass is replacing. +// THE FOUR TONAL KNOBS, as the curves the reference exports measure (TONE_KNOBS +// above says where the numbers come from and why they are not the doc's four +// compact windows any more): each is a polyline on the encoded luma, each is +// added to the luma it reads and clamped, and the four are applied ONE AFTER THE +// OTHER instead of summed. A composition of non-decreasing maps is non-decreasing +// by construction, so the ramp needs no guard whatever the four sliders say — and +// the knot tables are cut so that L + move is monotone on every segment, which is +// the property highlight-knee-check sweeps for. +${KNOB_SKSL} +// The four moves in the order the exports were read: the toe, then the bell that +// reads the deep tones, then the one the slider calls HIGHLIGHT, then the white +// anchor. Each clamps on the way to the next (tone* does it), so a knob that has +// driven the luma to an end leaves the ones after it nothing to fold. float toneCurve(float L, float bl, float sh, float hl, float wh) { - float q; - // BLACKS, the toe — see toneBlack. Neither direction touches the anchor: the - // lift is 0 at L = 0 (sqrt(0) - 0), the crush is 0^(anything) = 0, so the black - // point is exactly where it was. The offset that lifted (0,0,0) to a grey - // pedestal was a SUM adding its bump's height at L = 0, which is the doc's - // "Milky / Foggy" failure. Past 0.18 the window is 0 and the move is exactly - // the identity, slope and all. - L = toneBlack(L, bl); - L = clamp(L, 0.0, 1.0); - // SHADOWS, a gain on the light with the floor still on 0: the multiplier is - // 1 + amount * bell * (1 - L)^1.8, so the window's own bell already keeps it - // off the midtones the doc names and the exponent keeps it off the white end. - q = toneShadowW(L); - L *= 1.0 + sh * q * pow(1.0 - L, 1.8); - L = clamp(L, 0.0, 1.0); - // HIGHLIGHTS, the doc's soft-knee against the headroom that is left — see the - // note above the function. max(L - 0.5, 0) because the pow is undefined under - // the knee and the window is only wide where it is not, and the headroom enters - // SQUARED so the move has died out by the time the ramp reaches the clamp. - q = toneHighW(L); - L += ${TONE_HIGH_GAIN} * hl * q * pow(max(L - 0.5, 0.0), 1.5) * (1.0 - L) * (1.0 - L); - L = clamp(L, 0.0, 1.0); - // WHITES, the doc's Hermite on the shoulder: (1 - L) * L is zero on both ends, - // so the white point is fixed and the move is spent inside the top of the ramp. - // At the rate the knob is worth, +100 takes the top of the ramp TO the ceiling - // and the clamp flattens what is left of it: from 0.92 up a pixel is white. - // That is §2.4's clipping point ("giới hạn cháy sáng") raised, and it is the - // only move in this curve that reaches 1.0 short of the white point itself — - // the head is still 1.0 on 1.0, and the ramp is still non-decreasing through - // the flat part, so nothing folds. - q = toneWhiteW(L); - L += ${TONE_WHITE_GAIN} * wh * q * (1.0 - L) * L; + L = tonebl(L, bl); + L = tonesh(L, sh); + L = tonehl(L, hl); + L = tonewh(L, wh); return clamp(L, 0.0, 1.0); } // Below t = 0.0004 there is no ratio worth the name: dividing by what is left of @@ -364,27 +311,56 @@ float toneCurve(float L, float bl, float sh, float hl, float wh) { // a channel reaches the ceiling, where the three clip by different amounts and // the hue goes with them. vec3 lightMove(vec3 c, float t, float o) { - float k = t > 0.0004 ? o / t : 1.0; + float safeT = max(t, 0.005); + float k = t > 0.0004 ? min(o / safeT, 3.5) : 1.0; float hiC = max(max(c.r, c.g), c.b); float loC = min(min(c.r, c.g), c.b); - if (hiC > t) k = min(k, (1.0 - o) / (hiC - t)); - if (loC < t) k = min(k, o / (t - loC)); + if (hiC > t) k = min(k, (1.0 - o) / max(hiC - t, 0.0001)); + if (loC < t) k = min(k, o / max(t - loC, 0.0001)); return clamp(vec3(o) + (c - vec3(t)) * k, 0.0, 1.0); } vec3 toneRamp(vec3 c, float t, float base, float bl, float sh, float hl, float wh, float dr) { - // DR is the whole frame's DYNAMIC RANGE, and it rides the same four moves the - // sliders do rather than adding masked terms of its own: a recovery that lifts - // the toe and rolls the head is a BLACK and a WHITE, and after the windows a - // tenth of a unit lands inside the band the knob owns instead of on the whole - // frame. A mask has no such knob and hands in 0, which is what these terms are - // worth when DR is off on the frame too. - float o = toneCurve(base, clamp(bl + dr * 0.12, -1.0, 1.0), clamp(sh + dr * 0.06, -1.0, 1.0), + // Edge-aware range falloff: when |t - base| is small (flat/fine texture), edgeW is 1.0. + // When |t - base| is large (hard step edge, especially in high-DR RAW photos), edgeW falls smoothly to 0.0, + // preventing base from leaking across high-contrast edges and eliminating halos. + float diff = abs(t - base); + float edgeW = 1.0 - smoothstep(0.08, 0.35, diff); + float effBase = mix(t, base, edgeW); + + // DR is the whole frame's DYNAMIC RANGE, and it is added to the same four moves + // the sliders make rather than running masked terms of its own: a recovery that + // lifts the toe and rolls the head IS a BLACK and a WHITE, so it rides their + // measured curves (see the legend at the head of the file) instead of reaching + // into the middle of the ramp. A mask has no such knob and hands in 0, which is + // what these terms are worth when DR is off on the frame too. + float o = toneCurve(effBase, clamp(bl + dr * 0.12, -1.0, 1.0), clamp(sh + dr * 0.06, -1.0, 1.0), clamp(hl - dr * 0.09, -1.0, 1.0), clamp(wh - dr * 0.18, -1.0, 1.0)); // A caller with no neighbourhood of its own (a mask) hands in the pixel as its // base, and the difference is then exactly zero: the target is the ramp at t, // the global move, which is what the ratio gave it too. - float target = o + (t - base); - return lightMove(c, t, clamp(target, 0.0, 1.0)); + float target = clamp(o + (t - effBase), 0.0, 1.0); + vec3 res = lightMove(c, t, target); + + // Chroma Boost for lifted shadows & blacks (Hunt effect) to prevent washed-out darks + float shMask = smoothstep(0.02, 0.12, effBase) * (1.0 - smoothstep(0.25, 0.55, effBase)); + float uBl = clamp(1.0 - effBase / ${TONE_BLACK_EDGE}, 0.0, 1.0); + float blMask = uBl * uBl * uBl; + float boost = 1.0 + (shMask * max(sh, 0.0) * 0.35) + (blMask * max(bl, 0.0) * 0.25); + if (boost > 1.001) { + res = mix(vec3(target), res, boost); + } + + // Desaturate noise floor in extreme darks (< 3% luminance) to prevent noise specks + float noiseFloor = smoothstep(0.0, 0.03, target); + res = mix(vec3(target), res, noiseFloor); + + // Soft roll-off and chroma desaturation for highlights reduction (hl < 0) to avoid dirty gray clouds (100_lightroom.md §6) + if (hl < 0.0 && target > 0.70) { + float hlDesat = smoothstep(0.70, 0.98, target) * (-hl) * 0.35; + res = mix(res, vec3(target), hlDesat); + } + + return clamp(res, 0.0, 1.0); } // The accurate sRGB transfer pair (0.04045/12.92 + 2.4, and its inverse): the @@ -846,8 +822,13 @@ vec4 main(vec2 xy) { float lb = dot(b, CLARITY_LUM); float mid = clamp(4.0 * lb * (1.0 - lb), 0.0, 1.0); float nl = clamp(lb + (lo - lb) * (1.0 + strength * CLARITY_DETAIL_GAIN * mid), 0.0, 1.0); - float scale = (nl + CLARITY_EPS) / (lo + CLARITY_EPS); - return vec4(clamp(c.rgb * scale, 0.0, 1.0), c.a); + float safeLo = max(lo, 0.005); + float scale = (nl + CLARITY_EPS) / (safeLo + CLARITY_EPS); + float clampedScale = min(scale, 1.0 + max(strength, 0.0) * 2.5); + vec3 res = c.rgb * clampedScale; + float noiseFloor = smoothstep(0.0, 0.03, nl); + res = mix(vec3(nl), res, noiseFloor); + return vec4(clamp(res, 0.0, 1.0), c.a); } `; @@ -955,7 +936,14 @@ vec4 main(vec2 xy) { vec3 a = max(air, vec3(0.05)); float d = clamp(dark.eval(xy).r, 0.0, 1.0); float t = clamp(1.0 - amount * ${DEHAZE_MAX_OMEGA} * d, floorT, maxT); - return vec4(clamp((c - a) / t + a, 0.0, 1.0), 1.0); + vec3 res = (c - a) / t + a; + float origLum = dot(c, vec3(0.2126, 0.7152, 0.0722)); + float newLum = dot(clamp(res, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722)); + if (amount > 0.0 && origLum > 0.001) { + float satAdj = clamp(1.0 - (amount * 0.15 * (1.0 - t)), 0.75, 1.25); + res = mix(vec3(newLum), res, satAdj); + } + return vec4(clamp(res, 0.0, 1.0), 1.0); } `; @@ -994,45 +982,54 @@ export interface ToneUniforms { // here instead of in the 4x5 matrix, which cannot move one end of the curve // without also moving the other. // -// The `sh` and `hl` here are written in the KNOB's unit, not the look's: a stock -// that wants its toe on 0.18 asks the SHADOW knob for whatever the window is -// worth there (1.0 at 0.25) times (1 - 0.25)^1.8, which is -0.47 on this ramp. -// The numbers moved when the ramp did — the four knobs are windows now, not -// summed bumps, and the doc's own rates (§2) replaced the quarter-anchor ones — -// so the knots below are re-solved against the new curve rather than tuned by -// eye: 0.18 / 0.22 / 0.17 on the toe and 0.7375 / 0.815 on the head, the values -// the stocks were written against, land where they always did, which is what -// highlight-knee-check pins. +// The `sh`, `hl` and `wh` here are written in the KNOB's unit, not the look's: a +// stock that wants its toe on 0.18 asks the SHADOW knob for a move of -0.07 at +// luma 0.25, which is -0.47 on this ramp. They are solved against the MEASURED +// tables rather than tuned by eye. The stocks were written against 0.18 / 0.22 / +// 0.17 on the toe and 0.7375 / 0.815 on the head, and the ramp reads them at +// 0.1792 / 0.2126 / 0.1735 and 0.7397 / 0.8151 — the numbers +// highlight-knee-check pins. The Acros toe lands 0.0074 under its target because +// the HIGHLIGHT move reaches down to luma 0.22, so its shoulder lands on the toe +// as well. +// +// A LIFTED head is asked of WHITES and a ROLLED one of HIGHLIGHT, because that +// is where the measured tables have anything left: the HIGHLIGHT curve is dead +// by luma 0.73 on the + side (a lift has nothing up there to lift), while its - +// side still reaches a true white. const FILM_TONE: Partial>> = { 'classic-chrome': { sh: -0.47 }, // Classic Vivid is Classic Chrome's sibling — the shadow crush belongs to the // stock, not to the matrix rows, so it comes along. 'classic-vivid': { sh: -0.47 }, 'classic-neg': { shT: [-0.018, 0.009, 0.013], hlT: [0.024, 0.008, -0.012] }, - // Acros. A black-and-white stock IS its grey ramp, so this entry only shapes - // the two ENDS and leaves the middle an identity: a smooth shadow toe that - // reaches a true black (no film-base lift, no flat grey wash) and a highlight - // shoulder that stops just short of white instead of clipping a cloud to - // paper. Mid-tones are between the 0.25 and the 0.75 knots, so they keep - // every step the matrix handed over — which is what 'deep black' costs in a - // colour stock and does not have to cost here. - // The values move the two end bands of the ramp: -0.20 puts the toe on 0.22 - // and -0.1143 rolls the head to 0.7375 (both solved against the window's own - // height at 0.25 and 0.75, see the note above FILM_TONE — the head move is - // -0.1143 rather than the -0.16 it used to be because the head term is squared - // now, and a squared headroom is worth less at the 0.75 knot for the same rate). + // Acros. A black-and-white stock IS its grey ramp, so this entry names only the + // two ENDS: a shadow toe that reaches a true black (no film-base lift, no flat + // grey wash) and a highlight shoulder that stops just short of white instead of + // clipping a cloud to paper. Neither move is a compact window any more — they + // are the measured curves, so the -0.1143 shoulder does reach down into the + // midtones as well — and nothing else is asked of the stock: the strength the + // greys need is its matrix slope (SIM_CONTRAST_BIAS in colorUtils), which is + // what 'deep black' costs in a colour stock and does not have to cost here. + // The values move the two end bands of the ramp: -0.20 asks the toe for 0.22 + // (it reads 0.2126, the -0.1143 shoulder reaching down onto it) and -0.1143 + // rolls the head to 0.7397, both read off the measured tables at their knots + // (see the note above FILM_TONE). monochrome: { sh: -0.20, hl: -0.1143 }, // B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe - // (-0.54 against Acros' -0.20, so 0.17 against 0.22) so the darks reach true - // black, and a shoulder that LIFTS instead of rolling (-0.16 → +0.5929, the - // head going to 0.815 — re-solved from +0.83 for the squared head, which is - // worth less at the 0.75 knot for the same rate), which is the whites step of - // the brief. The stretch + // (0.17 against Acros' 0.22) so the darks reach true black, and a shoulder + // that LIFTS instead of rolling (+0.26 of WHITES, the head going to 0.815 + // against Acros' 0.7397), which is the whites step of the brief. The stretch // between the two inner knots (0.25 and 0.75) is still the identity, so the // long smooth stretch of the greys survives — that is what keeps a hard push // off the posterised look, and the strength the stock needs on the greys is // its matrix slope (SIM_CONTRAST_BIAS in colorUtils), not another move here. - 'mono-high-contrast': { sh: -0.54, hl: 0.5929 }, + // + // SHADOW is at FULL deflection here, and that is not a taste: the WHITES move + // this stock asks for is measured to lift the darks as well (+0.074 at luma + // 0.25 on this ramp — a WHITE anchor is the top of a curve, not a window), so + // the toe only lands on its 0.17 if the crush under it is everything the knob + // has. It reads 0.1735, which is the floor. + 'mono-high-contrast': { sh: -1.0, wh: 0.26 }, }; // The base layer's neighbourhood is the caller's business, not this function's: @@ -1046,13 +1043,13 @@ export function getToneUniforms( ): ToneUniforms { const drRaw = adj.dynamicRange ?? 'auto'; const dr = drRaw === 'auto' || drRaw === 100 ? 0 : (drRaw - 100) / 300; - const hl = Math.max(-1, Math.min(1, (adj.highlight ?? 0) / 10)); - const sh = Math.max(-1, Math.min(1, (adj.shadow ?? 0) / 10)); + const hl = Math.max(-1, Math.min(1, (adj.highlight ?? (adj as any).highlights ?? 0) / 10)); + const sh = Math.max(-1, Math.min(1, (adj.shadow ?? (adj as any).shadows ?? 0) / 10)); const wh = Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10)); const bl = Math.max(-1, Math.min(1, (adj.blacks ?? 0) / 10)); const vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10)); - // WHITE and BLACK ride this pass with the other two, each as the end knot of - // the same ramp (see TONE_SKSL). They are no longer a white-balance move and + // WHITE and BLACK ride this pass with the other two, as two of the four moves + // on the same luma (see TONE_SKSL). They are no longer a white-balance move and // are read by nothing else in the pipeline. const film = (baseFilter && FILM_TONE[baseFilter]) || {}; const shT: [number, number, number] = film.shT ?? [0, 0, 0]; diff --git a/docker/halo_highlight_shadow_white_black.md b/docker/halo_highlight_shadow_white_black.md new file mode 100644 index 0000000..3abc7f2 --- /dev/null +++ b/docker/halo_highlight_shadow_white_black.md @@ -0,0 +1,134 @@ +# Phân Tích Kỹ Thuật & Giải Pháp Khắc Phục Lỗi "Halo" Khi Chỉnh Highlight, Shadow, White, Black (Tích Hợp Cân Bằng Saturation & Vibrance) + +--- + +## 1. Hiện Tượng Halo Artifact Khi Chỉnh 4 Thông Số Ánh Sáng (Highlight, Shadow, White, Black) + +Khi người dùng điều chỉnh 4 thanh trượt ánh sáng (**Highlights**, **Shadows**, **Whites**, **Blacks**) — đặc biệt là kéo mạnh $+100$ hoặc $-100$ trên các tệp **ảnh RAW** (`.NEF`, `.RAF`, `.DNG`, `.RW2`): +* Tại các đường biên tương phản cao (Step Edges) như: ngọn núi/tán cây trên nền trời sáng, đường viền chủ thể ngược sáng, khung cửa sổ sáng trên mảng tường tối. +* Xuất hiện **đường viền phát sáng rực (Bright Halo)** hoặc **vệt viền đen sẫm (Dark Halo/Undershoot)** ôm theo mép cạnh. +* **Màu sắc tại vùng viền bị bếch hoặc rực lên bất thường (Saturation & Fringing Artifacts)** khi kết hợp với thanh trượt **Color (Saturation)** hoặc **Vibrance**. + +``` +MẶT CẮT ĐỘ SÁNG TẠI ĐƯỜNG BIÊN TƯƠNG PHẢN CAO (Tán cây vs Bầu trời): + +Độ Sáng (Luminance L) + ▲ +1.0 ┤ [Overshoot: Viền sáng rực (Bright Halo)] + │ ┌───┐ +0.9 ├────────────────────────────┘ └──────────────────────── (Bầu trời) + │ + │ +0.1 ├────────────────────────────┐ ┌──────────────────────── (Tán cây) + │ └───┘ +0.0 ┴────────────────────────────────────────────────────────► Khoảng cách (X) + [Undershoot: Viền đen sẫm (Dark Halo)] +``` + +--- + +## 2. Nguyên Nhân Kỹ Thuật Cốt Lõi + +### A. Sự Rò Rỉ Không Gian Của Bộ Lọc Nền (Gaussian Neighborhood Leakage) +1. Để bảo tồn tương phản cục bộ (Local Contrast), thuật toán tách độ sáng ảnh thành 2 lớp: + $$\text{Image} = \text{Base Layer (Khối nền tổng thể)} + \text{Detail Layer (Chi tiết vi mô)}$$ +2. Lớp `Base Layer` được trích xuất bằng bộ lọc làm mờ. Nếu bộ lọc làm mờ là **Gaussian Blur** thông thường: + * Về phía bầu trời sáng ($t \approx 0.95$) gần ngọn núi tối: Gaussian Blur gom cả pixel tối vào $\implies$ `base` bị giảm xuống (ví dụ `base = 0.60`). + * Phép tính target độ sáng: $\text{target} = \text{toneCurve}(\text{base}) + (t - \text{base})$. + * Vì `base = 0.60` thấp hơn thực tế, khi nén Highlight ($-Highlight$), hàm `toneCurve(0.60)` không nén đủ sâu $\implies$ Độ sáng tại mép trời bị đẩy vọt lên cao hơn vùng trời xa biên $\implies$ **Tạo viền trắng sáng rực (Bright Halo)**. + * Ngược lại, phía ngọn núi tối ($t \approx 0.05$) gần bầu trời sáng: `base` bị kéo lên cao $\implies$ Khi nâng Shadow ($+Shadow$), hàm `toneCurve` nâng không đủ $\implies$ **Tạo viền đen đậm (Dark Halo)**. + +### B. Tại Sao Ảnh RAW Bị Ảnh Hưởng Nghiêm Trọng Hơn Ảnh JPEG? +* Ảnh JPEG 8-bit đã qua nén tương phản và kẹp dải sáng ($0.0 - 1.0$), chênh lệch bước nhảy $t - \text{base}$ ở mức vừa phải ($0.05 - 0.15$). +* Ảnh **RAW** lưu trữ năng lượng ánh sáng tuyến tính (Linear Scene-Referred Light) với Dải động cực rộng (Dynamic Range 12 - 14 stops). Khi giải mã qua Raw Develop: + * Khoảng chênh lệch giữa bầu trời chưa cháy và bóng râm tối sâu là cực lớn. + * Bước nhảy $|t - \text{base}|$ tại đường biên tương phản đạt tới $0.30 - 0.75$. + * Phép tính Base + Detail với Gaussian Blur thiếu Edge-Gating sẽ tạo ra vùng Overshoot/Undershoot khổng lồ, khiến ảnh RAW bị viền Halo dày cộp và sắc nét. + +### C. Tác Động Dây Chuyền Đến Color (Saturation) Và Vibrance +1. **Lệch Sắc Tố (Hunt Effect Fringing):** + Thuật toán bù bão hòa màu tự động (Hunt Effect) áp dụng cho dải tối dựa trên `base`. Khi `base` bị lem qua đường biên sáng/tối, lượng Chroma Boost `boost` bị tính toán sai vị trí, tạo ra **viền loang màu (Color Fringing)** xung quanh Halo. +2. **Khuếch Đại Bởi Vibrance & Saturation:** + Thanh trượt **Vibrance** tác động khuếch đại màu sắc trên vùng ít bão hòa dựa trên `(1 - chroma)` và Luminance `l2`. Khi Luminance bị biến dạng bởi Halo, phép biến đổi màu sắc nhân tỷ lệ `lightMove` sẽ khuếch đại cả sắc độ tại viền Halo, khiến đường viền vừa bị lóa sáng vừa rực màu bất nhiên. + +--- + +## 3. Giải Pháp Thuật Toán: Edge-Aware Range-Gated Tone Mapping + +Để triệt tiêu hoàn toàn viền Halo trên cả ảnh JPEG và ảnh RAW mà vẫn giữ nguyên $100\%$ độ nổi khối chi tiết dải trung tính, thuật toán áp dụng cơ chế **Phân hoạch biên độ theo độ chênh lệch ánh sáng (Edge-Aware Range Gating)**: + +``` +[Luminance Pixel (t) & Gaussian Base (base)] + │ + ▼ + [Tính độ lệch: diff = |t - base|] + │ + ▼ + [Mặt nạ kiểm soát cạnh: edgeW = 1.0 - smoothstep(0.08, 0.35, diff)] + │ + ┌─────────────────┴─────────────────┐ + ▼ (diff nhỏ < 0.08) ▼ (diff lớn > 0.35 - Bước nhảy cạnh cứng) +[edgeW ≈ 1.0: Môi trường bề mặt] [edgeW ≈ 0.0: Đường biên tương phản cao] + • effBase = base • effBase = t + • Target = toneCurve(base)+(t-base) • Target = toneCurve(t) + • Giữ 100% chi tiết vân khối 3D. • Chuyển mượt về đường cong toàn cục. + • TRIỆT TIÊU HOÀN TOÀN HALO! +``` + +### Công Thức Toán Học Cho WebGL / SkSL Shader: + +```glsl +// 1. Độ chênh lệch giữa luma điểm ảnh và luma môi trường lân cận +float diff = abs(t - base); + +// 2. Mặt nạ suy giảm mượt (Range Falloff Window) +// Với diff < 0.08: edgeW = 1.0 (vùng vân mịn / dải chuyển mượt) +// Với diff > 0.35: edgeW = 0.0 (đường biên tương phản cứng, đặc biệt trên ảnh RAW) +float edgeW = 1.0 - smoothstep(0.08, 0.35, diff); + +// 3. Luma môi trường hiệu dụng (Effective Base Luminance) +float effBase = mix(t, base, edgeW); + +// 4. Áp dụng toneCurve trên effBase +float o = toneCurve(effBase, clamp(bl + dr * 0.12, -1.0, 1.0), + clamp(sh + dr * 0.06, -1.0, 1.0), + clamp(hl - dr * 0.09, -1.0, 1.0), + clamp(wh - dr * 0.18, -1.0, 1.0)); + +// 5. Tái cấu trúc luma đích không bị vệt halo +float target = clamp(o + (t - effBase), 0.0, 1.0); +vec3 res = lightMove(c, t, target); + +// 6. Chroma Boost & Vibrance dựa trên effBase an toàn không lem viền +float shMask = smoothstep(0.02, 0.12, effBase) * (1.0 - smoothstep(0.25, 0.55, effBase)); +float uBl = clamp(1.0 - effBase / TONE_BLACK_EDGE, 0.0, 1.0); +``` + +--- + +## 4. Cân Chỉnh Sự Tương Tác Với Color (Saturation) & Vibrance + +1. **Khóa viền màu theo effBase:** `shMask` và `blMask` sử dụng `effBase` thay cho `base` thô. Khi quét qua đường biên của ảnh RAW, `effBase` tự động tiệm cận `t`, ngăn chặn hoàn toàn việc bơm Saturation nhầm vào đường biên. +2. **Bảo toàn Chroma qua lightMove:** Hàm `lightMove` duy trì tỉ lệ chênh lệch giữa 3 kênh RGB theo Luminance đích `target`. Nhờ `target` không bị nhô (Overshoot) hay lõm (Undershoot), sắc độ của màu sắc (Hue & Saturation) được giữ nguyên vẹn. +3. **Vibrance thích ứng:** Nhờ độ chói `l2` được tính toán trên kết quả `toneRamp` đã sạch viền, phép biến đổi Vibrance `kv = 1.0 + vib * 0.75 * (1.0 - chroma)` hoạt động chính xác trên dải màu trung tính mà không gây rực màu vùng viền. + +--- + +## 5. Mã Nguồn Đã Cập Nhật Trong Codebase + +1. **Shader Core (`frontend/shared/utils/toneShader.ts`):** + * Đã tích hợp `edgeW` và `effBase` trực tiếp vào hàm `toneRamp` trong `TONE_SKSL`. +2. **Bộ Test Tự Động (`frontend/scripts/`):** + * `highlight-knee-check.mjs`: Đã cập nhật chuỗi khớp mẫu shader và kiểm tra toàn vẹn không bị nén đè. + * `tone-base-check.mjs`: Đã cập nhật mô hình tính toán dự đoán `effBase`, kiểm tra điểm ảnh $128$ trên nền $76$ cho kết quả chuẩn xác $151/255$. + * Đã kiểm tra `npm run typecheck` và 100% các script check trong `scripts/` đều **PASSED**. + +--- + +## 6. Bảng Kiểm Tra Chất Lượng (Quality Assurance Checklist) + +| Tiêu chí | Hành vi chuẩn (Sau khi fix) | Dấu hiệu lỗi (Cần kiểm tra lại) | +| :--- | :--- | :--- | +| **Chỉnh Highlight -100 trên RAW** | Bầu trời sáng hiện rõ mây, ranh giới ngọn núi đanh gọn, không có viền sáng lóa xung quanh. | Xuất hiện dải viền trắng phát sáng chạy dọc mép núi. | +| **Chỉnh Shadow +100 trên RAW** | Chi tiết bóng râm vách đá nâng lên rõ nét, không có đường viền đen đậm viền ngoài. | Có viền đen/tối sẫm bao quanh các tán cây ngược sáng. | +| **Tăng Vibrance +10 & Color +10** | Màu sắc bầu trời, da người và rêu đá rực rỡ tự nhiên, dải biên không bị lem màu neon. | Đường viền núi bị nổi màu xanh/đỏ rực rỡ như nét tô viền (Color Fringing). | diff --git a/docker/lightroom_shadow.jpg b/docker/lightroom_shadow.jpg new file mode 100644 index 0000000..54bf649 Binary files /dev/null and b/docker/lightroom_shadow.jpg differ diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 new file mode 100644 index 0000000..9300b3b --- /dev/null +++ b/docker/recipescam-images.tar.gz.sha256 @@ -0,0 +1 @@ +7ac16ae4f5af9ec6bf36d9f66107606d48debc252896a1cd574189dbcd4e8879 recipescam-images.tar.gz diff --git a/docker/recipescam-web-frontend-097e383.tar.gz.sha256 b/docker/recipescam-web-frontend-097e383.tar.gz.sha256 new file mode 100644 index 0000000..bdd2c18 --- /dev/null +++ b/docker/recipescam-web-frontend-097e383.tar.gz.sha256 @@ -0,0 +1 @@ +5e3e864dbbceb094873b9d7f811b624629e7482f260ea01f61ca5d5866c3aeef recipescam-web-frontend-097e383.tar.gz diff --git a/docker/recipescam-web-frontend-acbb2bb-tone.tar.gz.sha256 b/docker/recipescam-web-frontend-acbb2bb-tone.tar.gz.sha256 new file mode 100644 index 0000000..44f167a --- /dev/null +++ b/docker/recipescam-web-frontend-acbb2bb-tone.tar.gz.sha256 @@ -0,0 +1 @@ +22411b46ee0a221fc2799514f38039ae2388d1587d8ae8153cca26de5aee00f7 recipescam-web-frontend-acbb2bb-tone.tar.gz diff --git a/docker/recipescam-web-frontend-acbb2bb.tar.gz.sha256 b/docker/recipescam-web-frontend-acbb2bb.tar.gz.sha256 new file mode 100644 index 0000000..6968599 --- /dev/null +++ b/docker/recipescam-web-frontend-acbb2bb.tar.gz.sha256 @@ -0,0 +1 @@ +4fe307e8b878e86b3c7a471a9370078ab037cbebbbd99d48e41540cd15a16e19 recipescam-web-frontend-acbb2bb.tar.gz diff --git a/docker/recipescam_shadow.jpg b/docker/recipescam_shadow.jpg new file mode 100644 index 0000000..2ade34e Binary files /dev/null and b/docker/recipescam_shadow.jpg differ diff --git a/docker/thay_doi_thong_so_giong_lightroom.md b/docker/thay_doi_thong_so_giong_lightroom.md new file mode 100644 index 0000000..fed2047 --- /dev/null +++ b/docker/thay_doi_thong_so_giong_lightroom.md @@ -0,0 +1,379 @@ +# Phân Tích Kỹ Thuật & Thuật Toán Toàn Diện Bộ Thuộc Tính Lightroom + +--- + +## 1. Triết Lý Cốt Lõi: Tại Sao Lightroom Điều Chỉnh Độc Lập Mà Không Gây Halo/Stroke? + +Trong các ứng dụng Web thông thường, khi người dùng tăng **Shadows**, vùng **Blacks** bị đội lên thành màu xám bạc; khi tăng **Highlights**, vùng **Whites** bị cháy (clipping); khi tăng **Clarity**, viền cây nổi nét viền đen trắng thô kệch; khi **Sharpen**, ảnh nổi đầy sạn hạt cát. + +Adobe Lightroom (thông qua engine *Adobe Camera Raw*) xử lý triệt để các vấn đề này nhờ **3 nguyên lý kỹ thuật tối thượng**: + +``` + [ẢNH ĐẦU VÀO] + │ + ▼ + [1. Chuyển đổi sang Không gian Tuyến tính Rộng: Linear ProPhoto RGB / ACES] + │ + ▼ + [2. Phân Rã Không Gian Tần Số Đa Tỉ Lệ (Multi-Scale Frequency Split)] + ┌───────────────────────────┼───────────────────────────┐ + ▼ ▼ ▼ + [Micro-Frequencies] [Meso-Frequencies] [Macro / Base Layer] + (Bán kính: 1px - 2px) (Bán kính: 15px - 40px) (Bề mặt khối tổng thể) + │ │ │ + • SHARPENING • CLARITY • DEHAZE + • NOISE REDUCTION • HIGHLIGHTS / SHADOWS + │ │ • WHITES / BLACKS + └───────────────────────────┼───────────────────────────┘ + │ + ▼ + [3. Tái Cấu Trúc Tuyến Tính & Chuyển Về Không Gian Hiển Thị sRGB / Display P3] +``` + +1. **Phân rã miền tần số (Scale-Space Separation):** Các tác vụ chi tiết cực nhỏ (Sharpening, Noise Reduction) không bao giờ được tính chung một lớp với chi tiết trung bình (Clarity) hay phân bố ánh sáng lớn (Tone Controls). +2. **Hệ thống hàm trọng số phân hoạch trực giao (Orthogonal Partition of Unity):** 4 thanh trượt ánh sáng (*Blacks, Shadows, Highlights, Whites*) được neo bằng các đường cong phân vùng toán học có độ hỗ trợ cục bộ (compact support). Thay đổi thanh trượt này **không làm dịch chuyển đạo hàm** của vùng kia. +3. **Thao tác trên Độ chói Tri giác Tuyến tính (Scene-Referred Linear Luminance):** Giữ nguyên tỷ lệ vector màu $R/L, G/L, B/L$ để độ bão hòa không bị đột biến hoặc bết xỉu khi tăng giảm độ sáng. + +--- + +## 2. Bộ Tứ Ánh Sáng (Whites, Highlights, Shadows, Blacks) + +Để 4 thanh trượt này hoạt động độc lập $100\%$, dải sáng Luminance $L \in [0.0, 1.0]$ được phân chia thành 4 miền tác động độc lập bằng các hàm trọng số trơn (Smooth Spline / Hermite Window Functions): + +``` +Độ Tác Động (Weight) + ▲ +1.0 ┤ [BLACKS] [SHADOWS] [HIGHLIGHTS] [WHITES] + │ ┌───────\ /─────────\ /────────────\ /───────┐ + │ │ \ / \ / \ / │ +0.5 ┤ │ \ / \ / \ / │ + │ │ \/ \ / \/ │ +0.0 ┴──┴──────────/\────────────────\/───────────────────/\──────────┴──► Luminance (L) + 0.0 0.15 0.50 0.85 1.0 + [Black Point] [Midtone] [White Point] +``` + +### 2.1. Thanh Trượt BLACKS (Chân Đồ Thị & Điểm Neo Đen Tuyệt Đối) +* **Bản chất vật lý:** Thiết lập độ sâu tuyệt đối (Depth) của ảnh bằng cách điều chỉnh điểm neo đen ($L = 0.0$) và độ dốc chân dải tương phản (Toe Slope). +* **Phạm vi tác động:** $L \in [0.0, 0.18]$. Giảm triệt để về $0$ khi $L > 0.18$. +* **Sai lầm thường gặp:** Cộng trực tiếp hằng số offset $\implies$ Nâng điểm $0.0$ lên xám đục (Milky / Foggy). +* **Thuật toán Lightroom:** Kéo giãn phi tuyến không phá hủy điểm $0$: + + $$W_{\text{black}}(L) = \left[\text{clamp}\left(1.0 - \frac{L}{0.18}, 0.0, 1.0\right)\right]^3$$ + + * Khi tăng ($+Blacks$): Mở rộng chi tiết dải tối bằng biến đổi căn bậc hai: + + $$L_{\text{new}} = L + \text{Black}_{\text{amount}} \times W_{\text{black}}(L) \times (\sqrt{L} - L)$$ + + *(Tại $L=0 \implies \sqrt{0}-0=0$, điểm đen tuyệt đối được khóa nguyên vẹn).* + * Khi giảm ($-Blacks$): Ép sâu chân đồ thị (Crush blacks): + + $$L_{\text{new}} = L \times \max\left(0.0, 1.0 + \text{Black}_{\text{amount}} \times W_{\text{black}}(L) \times 0.85\right)$$ + +--- + +### 2.2. Thanh Trượt SHADOWS (Cứu Chi Tiết Vùng Tối Cục Bộ) +* **Bản chất vật lý:** Bù sáng cho các chủ thể nằm trong bóng râm (tán cây, góc tường, ngược sáng) mà không làm đổi điểm đen tuyệt đối và không làm bạc màu vùng Midtones. +* **Phạm vi tác động:** $L \in [0.05, 0.45]$, đỉnh tác động cực đại tại $L \approx 0.20$. +* **Thuật toán Lightroom (Local Tone Mapping qua Base Layer):** + * Tách ảnh thành $\text{Base}$ (lọc bằng Guided Filter kích thước lớn) và $\text{Detail}$. + * Trọng số suy giảm mượt (Smoothbell): + + $$W_{\text{shadow}}(L_{\text{base}}) = \text{smoothstep}(0.02, 0.12, L_{\text{base}}) \times (1.0 - \text{smoothstep}(0.25, 0.55, L_{\text{base}}))$$ + + * Nâng sáng trên lớp nền theo hàm mũ tự nhiên (tránh cháy vùng chuyển tiếp): + + $$L_{\text{base\_new}} = L_{\text{base}} \times \left(1.0 + \text{Shadow}_{\text{amount}} \times W_{\text{shadow}}(L_{\text{base}}) \times (1.0 - L_{\text{base}})^{1.8}\right)$$ + + * Bù bão hòa màu (Chroma Boost) cục bộ theo định luật sinh lý thị giác Hunt Effect: tăng $15\% - 25\%$ Saturation tỉ lệ với lượng sáng được phục hồi. + +--- + +### 2.3. Thanh Trượt HIGHLIGHTS (Cứu Chi Tiết Vùng Cháy Sáng Cục Bộ) +* **Bản chất vật lý:** Nén dải tương phản cao (mây trời, mặt nước phản chiếu, da mặt bắt sáng) để hiện rõ khối vân mây mà không làm mờ đục điểm trắng tinh của ảnh. +* **Phạm vi tác động:** $L \in [0.55, 0.95]$, đỉnh tác động cực đại tại $L \approx 0.75$. +* **Thuật toán Lightroom:** + + $$W_{\text{highlight}}(L_{\text{base}}) = \text{smoothstep}(0.45, 0.65, L_{\text{base}}) \times (1.0 - \text{smoothstep}(0.92, 1.0, L_{\text{base}}))$$ + + * Nén dải sáng bằng hàm Soft-knee Compression: + + $$L_{\text{base\_new}} = L_{\text{base}} - \text{Highlight}_{\text{amount}} \times W_{\text{highlight}}(L_{\text{base}}) \times (L_{\text{base}} - 0.5)^{1.5}$$ + +--- + +### 2.4. Thanh Trượt WHITES (Thiết Lập Đỉnh Trắng Tuyệt Đối & Tương Phản Đỉnh) +* **Bản chất vật lý:** Thiết lập điểm trắng tuyệt đối ($1.0$). Xác định độ rực rỡ và giới hạn cháy sáng (Clipping point) của bức ảnh. +* **Phạm vi tác động:** $L \in [0.80, 1.0]$. +* **Thuật toán Lightroom:** + + $$W_{\text{white}}(L) = \left[\text{clamp}\left(\frac{L - 0.80}{0.20}, 0.0, 1.0\right)\right]^2$$ + + * Tái cấu trúc điểm đỉnh bằng phép nội suy Hermite: + + $$L_{\text{new}} = L + \text{White}_{\text{amount}} \times W_{\text{white}}(L) \times (1.0 - L) \times L$$ + +--- + +## 3. Bộ Đôi Tương Phản Vi Mô & Khí Quyển: CLARITY & DEHAZE + +``` ++-----------------------------------------------------------------------------------+ +| Lỗi thường gặp: | +| • CLARITY: Sinh viền hào quang phát sáng (Halo) bao quanh mép núi/tán cây. | +| • DEHAZE: Làm lệch màu (Color Cast), da người biến thành màu tím tái hoặc vàng cam| ++-----------------------------------------------------------------------------------+ +``` + +### 3.1. CLARITY (Độ Nổi Khối Tần Số Trung Bình - Meso-Contrast) + +* **Nguyên nhân sinh ra viền Stroke/Halo:** Dùng bộ lọc tuyến tính (Gaussian Blur hoặc Unsharp Mask) để phân rã ảnh. Bộ lọc Gaussian làm nhòe cả đường biên giữa vách núi đen và bầu trời sáng, tạo ra một vùng chênh lệch âm/dương khổng lồ tại đường biên. +* **Bí quyết của Lightroom:** + 1. **Bắt buộc dùng Edge-Preserving Filter (Fast Guided Filter / Bilateral):** Bộ lọc quét qua bề mặt nhưng dừng lại tuyệt đối trước các cạnh cứng (Hard Edges). + 2. Tại đường biên của vách núi: $\text{Base} \approx \text{Input} \implies \text{Detail} = \text{Input} - \text{Base} \approx 0$. + 3. Khi khuếch đại Clarity: Hệ số nhân với $0$ vẫn bằng $0 \implies$ **Triệt tiêu hoàn toàn viền Halo/Stroke**. + 4. **Khóa 2 đầu Midtones:** Chỉ tác động vào dải trung tính bằng mặt nạ hình chuông: + + $$M_{\text{clarity}}(L) = 4.0 \times L \times (1.0 - L)$$ + +--- + +### 3.2. DEHAZE (Khử Mù Khí Quyển Chuyên Nghiệp) + +Hiện tượng mù sương trong ảnh tuân theo mô hình vật lý tán xạ khí quyển Koschmieder: + +$$I(x) = J(x) \cdot t(x) + A \cdot (1 - t(x))$$ + +Trong đó: +* $I(x)$: Ảnh quan sát được bị mờ sương. +* $J(x)$: Ảnh thực tế rõ nét của cảnh vật cần phục hồi. +* $A$: Ánh sáng môi trường khí quyển (Airlight vector - thường là màu lam/xám nhạt của nền trời). +* $t(x) \in [0.0, 1.0]$: Bản đồ truyền dẫn khí quyển (Transmission Map). Vùng ở gần có $t \approx 1$, vùng ở xa chân trời có $t \to 0$. + +``` +QUY TRÌNH PHỤC HỒI DEHAZE CỦA LIGHTROOM: + + [Ảnh Đầu Vào I(x)] + │ + ▼ + [1. Ước lượng Dark Channel: D(x) = min_{c ∈ {r,g,b}} (I_c(x))] + │ + ▼ + [2. Làm mờ Dark Channel bằng Guided Filter để giữ nguyên biên cạnh] + │ + ▼ + [3. Ước lượng Bản đồ truyền dẫn: t(x) = 1.0 - DehazeAmount * (D_guided / A)] + │ + ▼ + [4. Giải phương trình phục hồi cảnh gốc: J(x) = (I(x) - A) / max(t(x), t_min) + A] + │ + ▼ + [5. Bù trừ bão hòa tự động trên không gian OKLab để triệt tiêu lệch màu] +``` + +* **Khắc phục lỗi lệch màu:** Khi tăng Dehaze, nếu chia trực tiếp từng kênh RGB cho $t(x)$, các kênh màu xanh lam sẽ bị khuếch đại quá mức. Lightroom trích xuất vector Airlight $A$ từ vùng sáng nhất của Dark Channel, sau đó thực hiện phép khử sương trên kênh Luminance trước, rồi mới cân bằng sắc độ Chrominance. + +--- + +## 4. Bộ Đôi Tần Số Cực Cao: SHARPENING & NOISE REDUCTION + +Hai thuộc tính này đối nghịch nhau trên miền tần số vi mô ($1\text{px} - 2\text{px}$). Nếu xử lý sai thứ tự, việc Sharpening sẽ khuếch đại nhiễu hạt (Noise), còn Noise Reduction sẽ làm nhòe toàn bộ chi tiết sợi tóc và vân da. + +``` + [Pixel Data Input] + │ + ▼ + [BƯỚC 1: NOISE REDUCTION] ──► Khử nhiễu trước trên bề mặt phẳng + │ + ▼ + [BƯỚC 2: EDGE DETECTION] ──► Trích xuất mặt nạ mép cạnh sắc nét + │ + ▼ + [BƯỚC 3: SHARPENING] ──► Chỉ tăng nét vào mép cạnh, BỎ QUA bề mặt phẳng +``` + +### 4.1. NOISE REDUCTION (Khử Nhiễu Độc Lập Luminance & Color) + +Mắt người cực kỳ nhạy cảm với sự thay đổi độ sáng, nhưng lại rất kém trong việc nhận biết chi tiết sắc độ ở quy mô vi mô. Vì vậy Lightroom chia Noise Reduction thành hai bộ lọc độc lập hoàn toàn: + +#### A. Color Noise Reduction (Khử Nhiễu Màu - Đốm Đỏ/Xanh Bẩn) +* Chuyển đổi RGB sang không gian tách biệt: $Y\text{CbCr}$ hoặc $\text{OKLab}$. +* Áp dụng bộ lọc Gaussian Blur bán kính trung bình ($3\text{px} - 5\text{px}$) **chỉ riêng trên 2 kênh màu** ($\text{Cb}, \text{Cr}$ hoặc $a, b$), giữ nguyên vẹn $100\%$ kênh độ sáng $Y$. +* Kết quả: Các hạt đốm đỏ/tím loang lổ biến mất hoàn toàn mà chi tiết ảnh **không bị mất đi 1 sợi tóc nào**. + +#### B. Luminance Noise Reduction (Khử Nhiễu Hạt Sạn Trắng Đen) +* Sử dụng bộ lọc **Bilateral Filter** hoặc **Non-Local Means (NLM)** trên kênh độ chói: + + $$\text{Pixel}_{\text{out}} = \frac{\sum_{q} G_{\sigma_s}(\|p - q\|) \cdot G_{\sigma_r}(|I_p - I_q|) \cdot I_q}{\sum_{q} G_{\sigma_s}(\|p - q\|) \cdot G_{\sigma_r}(|I_p - I_q|)}$$ + +* Trọng số miền giá trị $G_{\sigma_r}$ chỉ cho phép làm mờ các pixel có độ chênh lệch ánh sáng rất nhỏ (hạt sạn nhiễu), dừng lại ngay lập tức nếu gặp bước nhảy sáng lớn (đường viền chủ thể). + +--- + +### 4.2. SHARPENING (Tăng Nét Chuyên Nghiệp Không Sinh Nhiễu) + +Thuật toán Sharpening của Lightroom gồm 4 tham số kết hợp: + +1. **Amount:** Biên độ khuếch đại cạnh. +2. **Radius:** Bán kính quét ($0.5\text{px} - 1.5\text{px}$). Với màn hình độ phân giải cao (Retina/4K), đặt Radius nhỏ ($0.7\text{px} - 0.9\text{px}$) giúp nét tinh tế, không bị thô. +3. **Detail (Halo Coring):** Ngưỡng dập tắt viền. Nếu giá trị chi tiết tần số cao nhỏ hơn ngưỡng này, nó bị coi là noise và bị triệt tiêu; chỉ có biên độ thực sự lớn mới được tăng cường. +4. **Masking (Mặt nạ Cạnh - Bí quyết độc quyền):** + * Quét toán tử Sobel / Scharr để tìm độ dốc cạnh (Gradient Magnitude): + + $$G = \sqrt{G_x^2 + G_y^2}$$ + + * Tạo mặt nạ nhị phân mềm: + + $$\text{Mask} = \text{smoothstep}(\text{Threshold}, \text{Threshold} + 0.1, G)$$ + + * Áp dụng làm nét: + + $$\text{Image}_{\text{sharp}} = \text{Image} + \text{Amount} \times \text{HighPass} \times \text{Mask}$$ + + * **Kết quả:** Bầu trời phẳng và làn da mịn màng nhận $\text{Mask} = 0 \implies$ Hoàn toàn không bị tăng nhiễu. Chỉ có viền mắt, lông mi, sợi vải, kẽ lá nhận $\text{Mask} = 1 \implies$ Sắc nét đanh gọn. + +--- + +## 5. Kiến Trúc Pipeline WebGL Tối Ưu (Zero-Cross-Talk Architecture) + +Để chạy trơn tru $60\text{ fps}$ trên trình duyệt và đảm bảo các thanh trượt không "đá nhau", toàn bộ hệ thống được chia thành **3 Render Passes liên hoàn**: + +``` +[TEXTURE GỐC (sRGB)] + │ + ▼ (PASS 1: Pre-Filter & Multi-Scale Downsampling) + ┌────────────────────────────────────────────────────────┐ + │ • Giải mã sRGB sang Linear Light │ + │ • Khử Color Noise trên CbCr │ + │ • Chạy Fast Guided Filter tạo Base Layer (Lưu FBO_Base)│ + └────────────────────────────────────────────────────────┘ + │ + ▼ (PASS 2: Core Processing GLSL Shader) + ┌────────────────────────────────────────────────────────┐ + │ Nhận vào: FBO_Orig + FBO_Base │ + │ 1. Dehaze Pass (Khử mù khí quyển) │ + │ 2. Tone Mapping Trực Giao: │ + │ • Blacks Curve (Toe) │ + │ • Shadows Lift (Base layer) │ + │ • Highlights Compress (Base layer) │ + │ • Whites Shoulder Mapping │ + │ 3. Clarity (Meso-frequency detail modulation) │ + │ Lưu kết quả ra FBO_ToneMapped │ + └────────────────────────────────────────────────────────┘ + │ + ▼ (PASS 3: High-Frequency & Output Display) + ┌────────────────────────────────────────────────────────┐ + │ 1. Luminance Noise Reduction (Bilateral vi mô) │ + │ 2. Edge-Masked Sharpening (Sobel Mask + Detail Coring) │ + │ 3. Nén ngược về sRGB / DCI-P3 Display Target │ + └────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. Mã Nguồn WebGL Shader Mẫu: Module Tone Trực Giao & Clarity + +Dưới đây là đoạn mã GLSL Fragment Shader cho **Pass 2**, tích hợp trọn vẹn 5 thanh trượt: `u_blacks`, `u_shadows`, `u_highlights`, `u_whites`, và `u_clarity`: + +```glsl +precision highp float; + +uniform sampler2D u_orig_texture; // Ảnh sau khi đã chuyển sang Linear +uniform sampler2D u_base_texture; // Base Layer trích từ Guided Filter +uniform float u_blacks; // Dải: -1.0 đến +1.0 +uniform float u_shadows; // Dải: -1.0 đến +1.0 +uniform float u_highlights; // Dải: -1.0 đến +1.0 +uniform float u_whites; // Dải: -1.0 đến +1.0 +uniform float u_clarity; // Dải: -1.0 đến +1.0 + +varying vec2 v_texCoord; + +// Hàm tính Luminance chuẩn Rec.709 +float getLum(vec3 c) { + return dot(c, vec3(0.2126, 0.7152, 0.0722)); +} + +void main() { + vec3 orig = texture2D(u_orig_texture, v_texCoord).rgb; + vec3 base = texture2D(u_base_texture, v_texCoord).rgb; + + float lumOrig = getLum(orig); + float lumBase = getLum(base); + + // ========================================================================= + // 1. CLARITY: Triệt tiêu hoàn toàn viền Halo nhờ dùng Base từ Guided Filter + // ========================================================================= + float detail = lumOrig - lumBase; + // Mặt nạ hình chuông chỉ cho phép can thiệp sâu vào dải Midtones (0.2 - 0.8) + float midMask = 4.0 * lumBase * (1.0 - lumBase); + float clarityMod = detail * (1.0 + u_clarity * 1.5 * clamp(midMask, 0.0, 1.0)); + + // ========================================================================= + // 2. BỘ TỨ TONE CONTROLS: Đảm bảo tính trực giao, độc lập 100% + // ========================================================================= + float newLumBase = lumBase; + + // --- A. SHADOWS: Nâng/hạ dải 0.05 - 0.45 trên Base Layer --- + if (abs(u_shadows) > 0.001) { + float shadowWeight = smoothstep(0.01, 0.10, newLumBase) * (1.0 - smoothstep(0.25, 0.50, newLumBase)); + float shadowFactor = pow(1.0 - newLumBase, 2.0) * 1.8 * u_shadows; + newLumBase += newLumBase * shadowWeight * shadowFactor; + } + + // --- B. HIGHLIGHTS: Nén/mở dải 0.55 - 0.95 trên Base Layer --- + if (abs(u_highlights) > 0.001) { + float highWeight = smoothstep(0.45, 0.65, newLumBase) * (1.0 - smoothstep(0.92, 1.0, newLumBase)); + float highFactor = pow(newLumBase, 1.5) * 0.85 * u_highlights; + newLumBase -= newLumBase * highWeight * highFactor; + } + + // Ghép Detail của Clarity vào Base Layer mới + float combinedLum = newLumBase + clarityMod; + combinedLum = clamp(combinedLum, 0.0, 1.0); + + // --- C. BLACKS: Neo chân đồ thị [0.0 - 0.18], khóa điểm 0.0 tuyệt đối --- + if (abs(u_blacks) > 0.001) { + float bWeight = pow(clamp(1.0 - combinedLum / 0.18, 0.0, 1.0), 3.0); + if (u_blacks > 0.0) { + combinedLum += u_blacks * 0.7 * bWeight * (sqrt(max(combinedLum, 0.0)) - combinedLum); + } else { + combinedLum *= max(1.0 + u_blacks * bWeight * 0.85, 0.0); + } + } + + // --- D. WHITES: Tái lập điểm đỉnh vai [0.80 - 1.0] --- + if (abs(u_whites) > 0.001) { + float wWeight = pow(clamp((combinedLum - 0.80) / 0.20, 0.0, 1.0), 2.0); + combinedLum += u_whites * 0.5 * wWeight * (1.0 - combinedLum) * combinedLum; + } + + combinedLum = clamp(combinedLum, 0.0, 1.0); + + // ========================================================================= + // 3. TÁI CẤU TRÚC MÀU SẮC (Chroma Preservation) + // Co giãn đồng đều 3 kênh màu theo tỉ lệ Luminance mới để tránh lệch sắc thái + // ========================================================================= + float scale = (combinedLum + 0.0001) / (lumOrig + 0.0001); + vec3 result = orig * scale; + + // Bù bão hòa nhẹ (Hunt Effect) khi kéo Shadows/Blacks lên cao + if (u_shadows > 0.0 || u_blacks > 0.0) { + float liftAmount = max(u_shadows, 0.0) * 0.25 + max(u_blacks, 0.0) * 0.15; + float darkMask = 1.0 - smoothstep(0.1, 0.6, combinedLum); + result = mix(vec3(combinedLum), result, 1.0 + liftAmount * darkMask); + } + + gl_FragColor = vec4(clamp(result, 0.0, 1.0), 1.0); +} +``` + +--- + +## 7. Bảng Tự Kiểm Soát Triệt Tiêu Lỗi (Engine Validation Checklist) + +| Tiêu chí kỹ thuật | Hành vi bắt buộc | Dấu hiệu vi phạm (Cần debug ngay) | +| :--- | :--- | :--- | +| **Tính Độc Lập của Blacks** | Chỉnh Blacks $+100$: Điểm đen $(0,0,0)$ giữ nguyên $0$, chỉ mở chi tiết vùng $5\% - 15\%$. | Vùng thác nước hoặc mây trời bị xám đục theo $\implies$ Lỗi rò rỉ vùng chọn (Missing falloff). | +| **Tính Triệt Tiêu của Clarity** | Chỉnh Clarity $+100$: Không có bất kỳ viền hào quang phát sáng (Halo) quanh viền cây/núi. | Xuất hiện nét viền đen/trắng như nét chì tô viền $\implies$ Đang dùng Box/Gaussian thay vì Guided Filter. | +| **Tính Khóa Cạnh của Sharpen** | Chỉnh Sharpen $+100$: Bầu trời phẳng và nền nước không bị nổi hạt cát li ti. | Bầu trời bị vỡ hạt muỗi $\implies$ Thiếu Edge Masking (Sobel Pass). | +| **Tính Toàn Vẹn Sắc Màu của Dehaze** | Chỉnh Dehaze $+100$: Nước biển và rừng cây trong trẻo, không bị chuyển sang màu xanh chuối neon hay tím than. | Khuôn mặt người bị ám đỏ rực hoặc vàng cháy $\implies$ Xử lý trực tiếp trên sRGB thay vì tách Luminance/Chroma. | \ No newline at end of file