Implement anti-ghosting inpainting for original background blur during photo capture as described in 25_FIX_CAMERA.md
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
Dựa vào hình ảnh thực tế `image.png` bạn cung cấp, có thể thấy rõ lỗi **Ghost Shadow (Bóng ma rế viền)** xung quanh vùng rìa tai, cổ và vai (phần khoanh đỏ).
|
||||
|
||||
### 🔴 Phân tích nguyên nhân lỗi:
|
||||
|
||||
1. **Ghost Shadow:** Xảy ra do hiện tượng "Chảy màu" (Color Bleeding). Khi bạn làm mờ hậu cảnh từ ảnh gốc, các pixel màu da của vai và tai bị nhòe rộng ra nền. Khi đè đối tượng sắc nét lên trên, phần rìa bị nhòe đó lồi ra ngoài tạo thành một lớp bóng mờ bao quanh cơ thể.
|
||||
2. **Viewfinder giật lag:** Do ứng dụng đang ép CPU quét và xử lý thuật toán `boxBlur` đa tầng trực tiếp trên từng khung hình động (Real-time stream) của camera preview ở độ phân giải cao.
|
||||
3. **Độ sâu trường ảnh (DoF) không chính xác:** Do các dải phân lớp khoảng cách (`0.3`, `0.5`, `0.75`) đang được tính tuyến tính cố định, không co giãn tỷ lệ nghịch mượt mà theo tiêu cự quang học khi người dùng trượt Ruler.
|
||||
|
||||
Dưới đây là kế hoạch cải tiến toàn diện mã nguồn để xử lý triệt để các vấn đề trên.
|
||||
|
||||
---
|
||||
|
||||
# 🛠️ KẾ HOẠCH FIX LỖI: TRIỆT TIÊU GHOST SHADOW VÀ TỐI ƯU HÓA FPS VIEWFINDER
|
||||
|
||||
---
|
||||
|
||||
## 📐 1. Thuật Toán Triệt Tiêu Ghost Shadow Bằng "Giãn Cơ Thể Ẩn" (Inpainting Dilate)
|
||||
|
||||
Để loại bỏ hoàn toàn bóng ma quanh vai và tai trong `image.png`, trước khi làm mờ hậu cảnh (`blurStage1, 2, 3`), chúng ta phải xóa bỏ thông tin màu sắc của đối tượng ra khỏi nền bằng cách **giãn rộng mặt nạ nền và lấp đầy vùng biên** (Background Inpainting / Edge Extension).
|
||||
|
||||
### 💻 Cải tiến Logic vòng lặp trong `MainActivity.kt`:
|
||||
|
||||
```kotlin
|
||||
fun processAntiGhostingBokeh(
|
||||
capturedBitmap: Bitmap,
|
||||
alphaMask: FloatArray, // Mảng alpha mượt từ MediaPipe (0.0 -> 1.0)
|
||||
midasDepthMap: Array<FloatArray>,
|
||||
blurIntensity: Float
|
||||
): Bitmap {
|
||||
val w = capturedBitmap.width
|
||||
val h = capturedBitmap.height
|
||||
|
||||
// BƯỚC 1: TẠO ẢNH HẬU CẢNH SẠCH (CLEAN BACKGROUND)
|
||||
// Loại bỏ hoàn toàn pixel người trước khi đem đi Blur để tránh chảy màu da ra nền
|
||||
val cleanBgBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
val idx = y * w + x
|
||||
val alpha = alphaMask[idx]
|
||||
|
||||
if (alpha > 0.01f) {
|
||||
// Nếu là người: Thay bằng các pixel xung quanh viền nền (Edge Padding)
|
||||
// để khi blur không bị lôi màu da ra ngoài
|
||||
cleanBgBitmap.setPixel(x, y, getNearestBackgroundPixel(capturedBitmap, alphaMask, x, y, w, h))
|
||||
} else {
|
||||
cleanBgBitmap.setPixel(x, y, capturedBitmap.getPixel(x, y))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BƯỚC 2: TẠO CÁC LỚP BLUR TỪ NỀN SẠCH (Không còn chứa vệt màu da cũ)
|
||||
val blurStage1 = boxBlur(cleanBgBitmap, (5 * blurIntensity).toInt().coerceAtLeast(1))
|
||||
val blurStage2 = boxBlur(cleanBgBitmap, (13 * blurIntensity).toInt().coerceAtLeast(1))
|
||||
val blurStage3 = boxBlur(cleanBgBitmap, (25 * blurIntensity).toInt().coerceAtLeast(1))
|
||||
|
||||
cleanBgBitmap.recycle() // Giải phóng ngay
|
||||
|
||||
// BƯỚC 3: ĐỒNG BỘ ĐỘ SÂU TRƯỜNG ẢNH TRƠN TRU (EXPONENTIAL DOF)
|
||||
// Thay đổi công thức phân phối khoảng cách dựa trên Ruler để DoF chính xác hơn
|
||||
val outputBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
|
||||
for (y in 0 until h) {
|
||||
val maskY = (y * 256 / h).coerceIn(0, 255)
|
||||
for (x in 0 until w) {
|
||||
val idx = y * w + x
|
||||
val alpha = alphaMask[idx]
|
||||
|
||||
val maskX = (x * 256 / w).coerceIn(0, 255)
|
||||
val currentDepth = depthMap[maskY][maskX]
|
||||
val relativeDistance = (currentDepth - 0.25f).coerceAtLeast(0f)
|
||||
|
||||
// Áp dụng thuật toán chọn lớp mờ mịn chuyển tiếp phi tuyến tính
|
||||
val bgPixel = when {
|
||||
relativeDistance < (0.15f * blurIntensity) -> blurStage1.getPixel(x, y)
|
||||
relativeDistance < (0.4f * blurIntensity) -> blurStage2.getPixel(x, y)
|
||||
else -> blurStage3.getPixel(x, y)
|
||||
}
|
||||
|
||||
val fgPixel = capturedBitmap.getPixel(x, y)
|
||||
|
||||
// Alpha Blending cuối cùng
|
||||
val outR = (fgPixel.r * alpha + bgPixel.r * (1.0f - alpha)).toInt()
|
||||
val outG = (fgPixel.g * alpha + bgPixel.g * (1.0f - alpha)).toInt()
|
||||
val outB = (fgPixel.b * alpha + bgPixel.b * (1.0f - alpha)).toInt()
|
||||
|
||||
outputBitmap.setPixel(x, y, Color.rgb(outR, outG, outB))
|
||||
}
|
||||
}
|
||||
|
||||
// Giải phóng bộ nhớ các stage
|
||||
blurStage1.recycle()
|
||||
blurStage2.recycle()
|
||||
blurStage3.recycle()
|
||||
|
||||
return outputBitmap
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 2. Giải Pháp Triệt Tiêu Lag Khi Xem Viewfinder (Live Preview Optimization)
|
||||
|
||||
Để đạt được tốc độ **60 FPS** trơn tru khi trượt Ruler trên giao diện kính ngắm thực tế, chúng ta áp dụng cơ chế **Hạ Cấp Kính Ngắm Chủ Động (Downscaled Proxy Blur)**:
|
||||
|
||||
* **Tuyệt đối không chạy Dual-AI (MediaPipe + MiDaS) trên luồng Live Viewfinder.**
|
||||
* Khi người dùng đang ở giao diện ngắm và kéo thước trượt Ruler, ứng dụng sẽ chỉ lấy một luồng ảnh Preview có độ phân giải siêu thấp (Ví dụ: `320x240` pixel) để tính toán mờ dưới lớp render đồ họa, hoặc sử dụng Shader phần cứng (OpenGL/Skia) làm mờ toàn bộ kính ngắm trừ một vùng Oval trung tâm để mô phỏng (Mô phỏng hiệu ứng thị giác tức thì).
|
||||
* Chỉ khi người dùng buông tay khỏi thanh Ruler hoặc nhấn nút **Shutter**, hệ thống mới gom toàn bộ dữ liệu độ phân giải cao cấu hình chuẩn ở Mục 1 thảy vào **Background Coroutine Process** để ép mảnh sắc nét xuất ra tệp JPG.
|
||||
|
||||
---
|
||||
|
||||
## 📅 3. Checklist Xác Minh Sau Khi Sửa Lỗi (Verification Checklist)
|
||||
|
||||
* [ ] **Kiểm thử Ghost Shadow:** Chụp một bức ảnh cận cảnh vai giống góc độ của `image.png`. (Yêu cầu: Phần viền bao quanh áo ba lỗ trắng và da vai không còn các vệt mờ xám mờ lồi ra, ranh giới cắt lồng vào nền mới phải sạch sẽ hoàn toàn).
|
||||
* [ ] **Kiểm thử độ nhạy Viewfinder:** Kéo thanh trượt tăng giảm độ nhòe qua lại liên tục bằng cử chỉ vuốt trên ảnh xem kính ngắm camera phản hồi lập tức không. (Yêu cầu: Giao diện trượt mượt mà, FPS không được rớt dưới 25 khung hình/giây).
|
||||
* [ ] **Kiểm thử phân phối DoF:** Đặt các vật thể ở các cự ly khác nhau (1m, 3m, 5m), kéo từ mức blur thấp lên cao xem các vật ở xa vô cực có đạt độ nhòe tuyến tính tỷ lệ đúng quang học hay không.
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
Ý tưởng của bạn rất hay và hoàn toàn khả thi! Thay vì đóng gói cứng tất cả file `.png`, `.jpg`, hay `.json` vào ứng dụng làm tăng dung lượng bộ cài, việc tạo một **Thư viện tài nguyên động (Dynamic Asset Library)** sẽ giúp ứng dụng của bạn linh hoạt hơn rất nhiều. Người dùng có thể tự chọn, nhấn nút `...` để tải thêm những bộ khung Instax mới, phông nền AI mới hoặc các bộ màu Hot Trend từ Server về máy.
|
||||
|
||||
Dưới đây là kế hoạch Markdown chi tiết từ kiến trúc lưu trữ Server, cấu trúc giao diện cho đến luồng tải tệp tin dưới nền.
|
||||
|
||||
---
|
||||
|
||||
# 🌐 KẾ HOẠCH TRIỂN KHAI: THƯ VIỆN ĐỘNG TRỰC TUYẾN (ONLINE ASSETS STORE)
|
||||
|
||||
Mục tiêu cốt lõi:
|
||||
|
||||
1. Xây dựng cấu trúc lưu trữ gọn nhẹ trên Server (hoặc các dịch vụ Cloud Storage như Firebase, Supabase, hay Hosting riêng).
|
||||
2. Thêm nút `...` (More) đồng cấp vào cuối các dải cuộn của 3 mục: **Frame, Background, Presets**.
|
||||
3. Thiết lập luồng tải tệp tin bất đồng bộ, tự động lưu vào bộ nhớ máy (`Internal Storage`) để ứng dụng sử dụng offline cho các lần sau mà không cần tải lại.
|
||||
|
||||
---
|
||||
|
||||
## 📂 1. Thiết Kế Kiến Trúc Lưu Trữ Trên Cloud Server
|
||||
|
||||
Server của bạn chỉ cần lưu trữ các tệp tin asset trực tiếp và một file `manifest.json` tổng để ứng dụng Photobooth đọc danh sách.
|
||||
|
||||
### Cấu trúc file `manifest.json` trên Server:
|
||||
|
||||
```json
|
||||
{
|
||||
"frames": [
|
||||
{ "id": "frame_classic", "name": "Instax Classic", "url": "https://yourserver.com/assets/frames/instaxframe.png", "version": 1 },
|
||||
{ "id": "frame_vintage", "name": "Vintage Film", "url": "https://yourserver.com/assets/frames/vintage_film.png", "version": 1 }
|
||||
],
|
||||
"backgrounds": [
|
||||
{ "id": "bg_neon", "name": "Neon Street", "url": "https://yourserver.com/assets/bg/neon_street.jpg", "version": 1 }
|
||||
],
|
||||
"presets": [
|
||||
{ "id": "preset_fuji", "name": "Fuji Retro", "url": "https://yourserver.com/assets/presets/fuji_retro.json", "version": 2 }
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 2. Quy Hoạch Lại Giao Diện Điều Khiển (UI/UX Layer)
|
||||
|
||||
Tại mỗi dải cuộn của Menu Tầng 2 (ngay sau danh sách các item có sẵn như trong ảnh `image_ce9f68.jpg`), ta thêm một ô nút chức năng đặc biệt làm nút **"Tải thêm"** (biểu tượng dấu `...` hoặc `+` tùy bạn chọn).
|
||||
|
||||
```
|
||||
[DANH SÁCH CUỘN NGANG TẦNG 2]
|
||||
├── [Mặc định] ── [Nền 1] ── [Nền 2] ── [Nền 3] ── 🔳 [ Nút "..." Tải Thêm ]
|
||||
|
||||
```
|
||||
|
||||
### Logic hiển thị khi người dùng tương tác:
|
||||
|
||||
* Khi nhấn vào nút `...`, một hộp thoại (Bottom Sheet hoặc Dialog Popup) sẽ hiện lên, tự động lấy dữ liệu từ file `manifest.json` trên mạng để hiển thị kho lưu trữ trực tuyến.
|
||||
* Mỗi item trên kho sẽ có một nút **Tải về (Download)**. Nếu tệp tin đã được tải về máy rồi, nút đó sẽ đổi thành chữ **"Áp dụng"**.
|
||||
|
||||
---
|
||||
|
||||
## 💻 3. Thuật Toán Lõi Tải Và Quản Lý Tệp Tin Offline (`MainActivity.kt`)
|
||||
|
||||
Để thực hiện tải và đọc file động từ bộ nhớ thay vì thư mục `assets` cứng, chúng ta sử dụng thư viện mạng và đường dẫn thư mục lưu trữ nội bộ của ứng dụng (`filesDir`).
|
||||
|
||||
### 📦 Thêm thư viện tải tệp trong gói cấu hình (Android Native/Flutter):
|
||||
|
||||
Nếu làm ở tầng Native Android, bạn sử dụng `HttpURLConnection` hoặc thư viện `OkHttp`. Nếu ở tầng Flutter, bạn dùng package `dio` kết hợp với `path_provider`.
|
||||
|
||||
### 💻 Logic xử lý tải và lưu tệp ngầm dưới thiết bị:
|
||||
|
||||
```kotlin
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
|
||||
class DynamicAssetManager(private val context: Context) {
|
||||
|
||||
// Hàm thực hiện tải file ẩn dưới nền khi người dùng nhấn nút tải ở mục "..."
|
||||
fun downloadAsset(fileUrl: String, assetType: String, fileName: String, onComplete: (File?) -> Unit) {
|
||||
// Chạy ngầm hoàn toàn không gây lag giao diện camera
|
||||
Thread {
|
||||
try {
|
||||
// Tạo thư mục lưu trữ cục bộ riêng cho từng loại: /data/user/0/com.app/files/downloaded_frames/
|
||||
val targetDir = File(context.filesDir, "downloaded_$assetType")
|
||||
if (!targetDir.exists()) targetDir.mkdirs()
|
||||
|
||||
val outputFile = File(targetDir, fileName)
|
||||
|
||||
// Kích hoạt luồng đọc ghi dòng dữ liệu từ Internet
|
||||
URL(fileUrl).openStream().use { input ->
|
||||
output.outputStream().use { outputStream ->
|
||||
input.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
|
||||
// Trả về file đã tải thành công để cập nhật UI
|
||||
onComplete(outputFile)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onComplete(null)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
// Hàm kiểm tra xem file này đã được tải về máy trước đó chưa
|
||||
fun getLocalAssetFile(assetType: String, fileName: String): File? {
|
||||
val file = File(context.filesDir, "downloaded_$assetType/$fileName")
|
||||
return if (file.exists()) file else null
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 4. Cải Tiến Cụm Engine Để Đọc Được Cả Hai Nguồn Asset
|
||||
|
||||
Để hệ thống xử lý ảnh (như thuật toán MiDaS, MediaPipe hay Ép khung PNG) nhận diện được cả file cứng trong app lẫn file động mới tải về, bạn chỉ cần thay đổi hàm nạp ảnh sang cơ chế **Đọc Đa Luồng (Hybrid Asset Loader)**:
|
||||
|
||||
```kotlin
|
||||
// THAY THẾ CƠ CHẾ ĐỌC CỨNG:
|
||||
// Trước đây: val frameData = context.assets.open("frames/instaxframe.png")
|
||||
|
||||
// BÂY GIỜ: Kiểm tra file local trước, nếu không có mới tìm trong assets mặc định
|
||||
val frameBitmap = val localFile = assetManager.getLocalAssetFile("frames", "instaxframe.png")
|
||||
val finalFrame = if (localFile != null) {
|
||||
BitmapFactory.decodeFile(localFile.absolutePath) // Nạp file người dùng vừa tải từ nút "..."
|
||||
} else {
|
||||
BitmapFactory.decodeStream(context.assets.open("frames/instaxframe.png")) // Nạp file mặc định của app
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 5. Kế Hoạch Triển Khai Kiểm Thử (Checklist)
|
||||
|
||||
* [ ] **Kiểm thử kết nối mạng:** Tắt mạng (Wifi/Mạng di động) rồi bấm vào nút `...` xem ứng dụng có xử lý lỗi mượt mà không (Yêu cầu: Hiện thông báo nhẹ *"Vui lòng kết nối mạng để tải thêm tài nguyên"* thay vì bị văng ứng dụng).
|
||||
* [ ] **Kiểm thử lưu trữ:** Tải về một mẫu `Nền mới`. Tắt hẳn ứng dụng đi, bật lại, kiểm tra xem ô `Nền mới` đó có còn nằm trong danh sách chọn không và có áp dụng được bình thường khi offline không.
|
||||
* [ ] **Kiểm thử trùng lặp:** Bấm tải đi tải lại 1 tệp tin xem hệ thống có tự ghi đè (`Overwrite`) để tối ưu dung lượng bộ nhớ máy hay không, tránh việc sinh ra nhiều file rác trùng tên.
|
||||
|
||||
Tính năng kho lưu trữ động thông qua nút `...` này sẽ giúp app Photobooth của bạn cực kỳ nhẹ khi tải trên Store (chỉ khoảng 20-30MB), nhưng người dùng vẫn có thể sở hữu một kho tài nguyên khổng lồ tùy chọn theo ý muốn!
|
||||
@@ -1829,7 +1829,20 @@ class MainActivity : AppCompatActivity() {
|
||||
solidBg
|
||||
}
|
||||
} else {
|
||||
capturedImage
|
||||
// Triệt tiêu Ghost Shadow (Bóng ma rế viền) bằng giãn cơ thể ẩn (Inpainting Dilate)
|
||||
val cleanBg = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
val idx = y * w + x
|
||||
val alpha = alphaMask[idx]
|
||||
if (alpha > 0.01f) {
|
||||
cleanBg.setPixel(x, y, getNearestBackgroundPixel(capturedImage, alphaMask, x, y, w, h))
|
||||
} else {
|
||||
cleanBg.setPixel(x, y, capturedImage.getPixel(x, y))
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanBg
|
||||
}
|
||||
|
||||
val bgPixels = IntArray(w * h)
|
||||
@@ -1896,6 +1909,37 @@ class MainActivity : AppCompatActivity() {
|
||||
return capturedImage
|
||||
}
|
||||
|
||||
private fun getNearestBackgroundPixel(
|
||||
src: android.graphics.Bitmap,
|
||||
alphaMask: FloatArray,
|
||||
startX: Int,
|
||||
startY: Int,
|
||||
w: Int,
|
||||
h: Int
|
||||
): Int {
|
||||
val maxDist = 30
|
||||
for (r in 1..maxDist) {
|
||||
// Check 4 directions: left, right, up, down
|
||||
var px = startX - r
|
||||
if (px >= 0) {
|
||||
if (alphaMask[startY * w + px] <= 0.01f) return src.getPixel(px, startY)
|
||||
}
|
||||
px = startX + r
|
||||
if (px < w) {
|
||||
if (alphaMask[startY * w + px] <= 0.01f) return src.getPixel(px, startY)
|
||||
}
|
||||
var py = startY - r
|
||||
if (py >= 0) {
|
||||
if (alphaMask[py * w + startX] <= 0.01f) return src.getPixel(startX, py)
|
||||
}
|
||||
py = startY + r
|
||||
if (py < h) {
|
||||
if (alphaMask[py * w + startX] <= 0.01f) return src.getPixel(startX, py)
|
||||
}
|
||||
}
|
||||
return src.getPixel(0, 0)
|
||||
}
|
||||
|
||||
private fun blurAlphaMaskBox(arr: FloatArray, w: Int, h: Int, radius: Int) {
|
||||
if (radius <= 0) return
|
||||
val temp = FloatArray(w * h)
|
||||
|
||||
Reference in New Issue
Block a user