Implement Touch-to-Focus (28_FOCUS_POINT.md): cache MediaPipe category mask, add focus ring view, class-based alpha selection

This commit is contained in:
2026-07-06 20:40:19 +07:00
parent 6636cdb418
commit e8fde43890
4 changed files with 344 additions and 5 deletions
+261
View File
@@ -0,0 +1,261 @@
Dưới đây là kế hoạch Markdown chi tiết và hoàn chỉnh để triển khai tính năng **Chạm để lấy nét (Touch-to-Focus)**, tối ưu riêng cho cấu trúc chỉ sử dụng mô hình **Google MediaPipe Multiclass Segmenter (`multiclass_segmenter.tflite`)** và khắc phục triệt để tình trạng giật lag trên kính ngắm (Viewfinder) khi thay đổi độ nhòe DoF.
---
# 🎯 KẾ HOẠCH TRIỂN KHAI: CHẠM LẤY NÉT ĐA LỚP & KHẮC PHỤC LAG VIEWFINDER REAL-TIME
Mục tiêu cốt lõi:
1. Bắt tọa độ chạm `(X, Y)` trên Viewfinder để xác định người dùng muốn ưu tiên lấy nét vào thực thể nào (Người hoặc Đồ vật cụ thể).
2. Vẽ vòng tròn định vị lấy nét màu cam (Focus Ring Animation) và tự ẩn sau 1 giây.
3. **Triệt tiêu lag Viewfinder (60 FPS):** Thay vì ép CPU quét ma trận pixel liên tục khi người dùng ngắm hoặc trượt Ruler, ứng dụng sẽ sử dụng **GPU Shader giả lập (BackdropFilter Proxy)** trên UI để phản hồi mượt mà tức thì.
4. **Xử lý bất đồng bộ (Async Coroutine):** Cô lập luồng xử lý ảnh độ phân giải cao, làm mịn viền tóc và khử Ghost Shadow chạy ngầm hoàn toàn sau khi nhấn nút **Shutter**.
---
## 📐 1. Quy Hoạch Giao Diện Viewfinder & Cử Chỉ Chạm Lấy Nét (`CameraScreen.dart`)
Bao bọc khu vực hiển thị kính ngắm bằng `GestureDetector` để bắt đồng thời hai hành động: **Chạm để lấy nét** (`onTapUp`) và **Vuốt màn hình để tăng giảm Ruler** (`onHorizontalDragUpdate`).
Tận dụng phần cứng đồ họa (GPU) để làm mờ nhanh giao diện hiển thị nhằm phản hồi tức thì theo tay người dùng mà không gây gánh nặng cho CPU.
```dart
double _blurIntensity = 0.0; // Đồng bộ từ thanh trượt Ruler (0.0 -> 1.0)
Offset? _focusTouchPosition; // Tọa độ điểm chạm để vẽ vòng tròn nét trên UI
Widget _buildOptimizedTouchFocusViewfinder() {
return GestureDetector(
// 1. CỬ CHỈ VUỐT: Điều chỉnh tăng giảm Ruler cùng chiều (Sang phải là tăng)
onHorizontalDragUpdate: (details) {
setState(() {
_blurIntensity = (_blurIntensity + (details.delta.dx / 200.0)).clamp(0.0, 1.0);
});
},
// 2. CỬ CHỈ CHẠM: Chọn điểm lấy nét
onTapUp: (TapUpDetails details) {
final RenderBox box = context.findRenderObject() as RenderBox;
final Offset localPosition = box.globalToLocal(details.globalPosition);
// Tính tỷ lệ % tọa độ chạm (0.0 -> 1.0) để gửi xuống tầng Native Engine định vị ID vật thể
double percentX = (localPosition.dx / box.size.width).clamp(0.0, 1.0);
double percentY = (localPosition.dy / box.size.height).clamp(0.0, 1.0);
setState(() {
_focusTouchPosition = localPosition;
});
// Gửi tọa độ xuống Native để xác định lớp đối tượng (Người/Vật) tại điểm chạm
_sendTouchFocusToNative(percentX, percentY);
// Ẩn vòng tròn định vị lấy nét sau 1 giây
Future.delayed(const Duration(milliseconds: 1000), () {
if (mounted) {
setState(() {
_focusTouchPosition = null;
});
}
});
},
child: AspectRatio(
aspectRatio: 3 / 4,
child: Stack(
children: [
// Luồng Camera thô từ phần cứng - Luôn mượt mà 60 FPS
CameraPreview(_cameraController),
// GIẢI PHÁP TRIỆT TIÊU LAG: Làm mờ toàn màn hình siêu tốc bằng GPU Shader
if (_blurIntensity > 0)
Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: _blurIntensity * 15.0,
sigmaY: _blurIntensity * 15.0
),
child: Container(color: Colors.transparent),
),
),
// GIẢ LẬP ĐỤC LỖ TIÊU CỰ: Tạo vùng nét giả lập tại vị trí người dùng chạm tay
// Nếu chưa chạm, mặc định đục lỗ ở tâm màn hình (Mô phỏng lấy nét tự động vào Người)
if (_blurIntensity > 0)
Positioned(
left: (_focusTouchPosition?.dx ?? box.size.width / 2) - 110,
top: (_focusTouchPosition?.dy ?? box.size.height / 2) - 160,
child: Container(
width: 220,
height: 320,
decoration: BoxDecoration(
shape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(160),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 30,
spreadRadius: 20,
)
],
),
),
),
// Vẽ vòng tròn định vị lấy nét màu cam nhiếp ảnh khi user chạm vào kính ngắm
if (_focusTouchPosition != null)
Positioned(
left: _focusTouchPosition!.dx - 25,
top: _focusTouchPosition!.dy - 25,
child: const FocusRingWidget(), // Hiệu ứng vòng tròn thu nhỏ nhẹ
),
],
),
),
);
}
```
---
## 💻 2. Thuật Toán Lõi: Khóa Tiêu Cự Vùng Chọn Đa Lớp & Xử Lý Ảnh Chụp Ngầm (`MainActivity.kt`)
Vì không còn mô hình chiều sâu MiDaS, cơ chế chọn điểm lấy nét khi sử dụng duy nhất MediaPipe sẽ dựa trên **Phân loại ID thực thể (Class ID Preference)**.
Khi nhận tọa độ chạm, hệ thống tra cứu xem điểm đó là **Người (ID 1)** hay **Đồ vật (ID 3)** để khóa mục tiêu giữ nét căng, đồng thời đẩy toàn bộ vòng lặp pixel nặng xuống luồng ngầm (Coroutine) khi bấm Shutter.
```kotlin
// Biến lưu trữ ID của lớp đối tượng được chọn lấy nét (-1: Tự động lấy nét mặc định vào Người)
var userSelectedClassFocus = -1
fun updateFocusFromTouchPoint(percentX: Float, percentY: Float, categoryMaskBuffer: ByteBuffer, w: Int, h: Int) {
// Ánh xạ tọa độ tỷ lệ chạm về kích thước ma trận của MediaPipe
val maskX = (percentX * w).toInt().coerceIn(0, w - 1)
val maskY = (percentY * h).toInt().coerceIn(0, h - 1)
val bufferIndex = maskY * w + maskX
categoryMaskBuffer.rewind()
// Khóa Class ID tại điểm chạm (Ví dụ chạm vào gấu bông -> Khóa ID 3, chạm vào người -> Khóa ID 1)
userSelectedClassFocus = categoryMaskBuffer.get(bufferIndex).toInt()
}
fun processMediaPipeTouchBokeh(
capturedBitmap: Bitmap,
categoryMaskBuffer: ByteBuffer,
rulerValue: Int
): Bitmap {
val w = capturedBitmap.width
val h = capturedBitmap.height
if (rulerValue == 0) return capturedBitmap
// --- BƯỚC 1: ĐỊNH VỊ VÙNG TIÊU CỰ THEO ĐIỂM CHẠM ---
val alphaMask = FloatArray(w * h)
categoryMaskBuffer.rewind()
// Nếu chưa chạm, mặc định đích lấy nét là Người (ID 1) và Đồ vật bổ trợ (ID 3)
val targetClassId1 = if (userSelectedClassFocus >= 0) userSelectedClassFocus else 1
val targetClassId2 = if (userSelectedClassFocus >= 0) userSelectedClassFocus else 3
for (i in 0 until (w * h)) {
val classId = categoryMaskBuffer.get(i).toInt()
// Pixel nào trùng với ID thực thể được chạm chọn sẽ nhận trọng số giữ nét = 1.0f
alphaMask[i] = if (classId == targetClassId1 || classId == targetClassId2) 1.0f else 0.0f
}
// Làm mờ mảng mặt nạ (radius = 4) để tạo dải Gradient chuyển tiếp viền tóc mềm mại, khử hoàn toàn răng cưa
blurAlphaMask1D(alphaMask, w, h, radius = 4)
// --- BƯỚC 2: TÍNH THANG ĐỘ MỜ PHI TUYẾN TÍNH THEO RULER ---
val normalizedSlider = rulerValue / 100.0f
val opticalIntensity = Math.pow(normalizedSlider.toDouble(), 2.5).toFloat()
val maxRadius = (35 * opticalIntensity).coerceAtLeast(1.0f)
// --- BƯỚC 3: XÓA BỎ BÓNG MA VIỀN (GHOST SHADOW) ---
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
if (alphaMask[idx] > 0.05f) {
// Thay thế pixel người/vật bằng màu nền gần nhất để khi làm mờ không bị lem vệt màu da ra rìa vai
cleanBgBitmap.setPixel(x, y, getNearestPureBackgroundPixel(capturedBitmap, alphaMask, x, y, w, h))
} else {
cleanBgBitmap.setPixel(x, y, capturedBitmap.getPixel(x, y))
}
}
}
// Tạo 3 tầng mờ phân cấp mô phỏng độ sâu trường ảnh DoF dựa theo khoảng cách rìa biên mặt nạ
val blurStage1 = boxBlur(cleanBgBitmap, (maxRadius * 0.3f).toInt().coerceAtLeast(1)) // Nền sát biên mờ nhẹ
val blurStage2 = boxBlur(cleanBgBitmap, (maxRadius * 0.6f).toInt().coerceAtLeast(1)) // Nền trung cảnh mờ vừa
val blurStage3 = boxBlur(cleanBgBitmap, maxRadius.toInt().coerceAtLeast(1)) // Nền xa vô cực mờ sâu
cleanBgBitmap.recycle()
// --- BƯỚC 4: ALPHA BLENDING TRỘN ẢNH XUẤT XƯỞNG ---
val outputBitmap = 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]
val edgeDistanceFactor = calculateEdgeDistanceFactor(x, y, alphaMask, w, h)
val bgPixel = when {
edgeDistanceFactor < 0.2f -> blurStage1.getPixel(x, y)
edgeDistanceFactor < 0.5f -> blurStage2.getPixel(x, y)
else -> blurStage3.getPixel(x, y)
}
val fgPixel = capturedBitmap.getPixel(x, y)
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))
}
}
blurStage1.recycle()
blurStage2.recycle()
blurStage3.recycle()
return outputBitmap
}
```
---
## ⚡ 3. Cơ Chế Cô Lập Luồng Khi Chụp (Shutter Background Queue)
Khi người dùng nhấn nút Shutter, để camera không bị đứng hình, toàn bộ hàm xử lý đồ họa pixel phức tạp ở trên sẽ được đóng gói và đẩy xuống luồng xử lý ngầm (Coroutine Background Task).
```kotlin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
fun onShutterPressed(capturedBitmap: Bitmap, rawMaskBuffer: ByteBuffer, rulerValue: Int) {
// 1. UI phản hồi chớp sáng màn hình và thu nhỏ ảnh về thumbnail lập tức
triggerFlashAnimation()
updateMediaThumbnailPreview(capturedBitmap)
// 2. Đẩy xử lý AI đa lớp và thuật toán khử Ghost Shadow xuống luồng ngầm độc lập
GlobalScope.launch(Dispatchers.Default) {
val finalPhoto = processMediaPipeOnlyBokeh(
capturedBitmap = capturedBitmap,
categoryMaskBuffer = rawMaskBuffer,
rulerValue = rulerValue
)
// Lưu im lặng âm thầm vào thư viện máy
saveToSystemGallery(finalPhoto)
finalPhoto.recycle()
}
}
```
---
## 📅 4. Checklist Xác Minh Sau Khi Sửa Lỗi (Verification Checklist)
* [ ] **Kiểm thử độ mượt Viewfinder (60 FPS):** Vuốt màn hình qua lại liên tục để chỉnh Ruler độ nhòe. Giao diện kính ngắm phải di chuyển trơn tru, thước số chạy mượt, không có hiện tượng khựng hay giật lag khung hình.
* [ ] **Kiểm thử chạm lấy nét (Touch-to-Focus):** Đặt một đồ vật (như ly nước) trước camera. Chạm tay vào ly nước xem phông nền và con người phía sau có mờ đi không. Sau đó chạm lại vào khuôn mặt xem ly nước có nhòe đi đúng quy luật quang học không.
* [ ] **Kiểm thử chất lượng viền tóc & Ghost Shadow:** Chụp một bức ảnh chân dung, phóng to ảnh trong thư viện máy lên kiểm tra viền vai và tóc. (Yêu cầu: Không còn vệt bóng mờ màu da lồi ra nền, các sợi tóc mảnh hòa quyện mượt mà vào phông nền).
@@ -69,6 +69,13 @@ class MainActivity : AppCompatActivity() {
private var imageSegmenter: ImageSegmenter? = null private var imageSegmenter: ImageSegmenter? = null
private var isAiSupported = true private var isAiSupported = true
// Touch-to-Focus: ID lớp đối tượng được chạm chọn lấy nét (-1 = mặc định lấy nét người ID 1)
private var userSelectedClassFocus = -1
// Cache mặt nạ phân lớp mới nhất từ MediaPipe để phục vụ Touch-to-Focus
private var cachedCategoryMask: ByteArray? = null
private var cachedMaskWidth = 0
private var cachedMaskHeight = 0
private fun getImageSegmenter(): ImageSegmenter? { private fun getImageSegmenter(): ImageSegmenter? {
synchronized(aiInferenceLock) { synchronized(aiInferenceLock) {
if (!isAiSupported) return null if (!isAiSupported) return null
@@ -705,6 +712,42 @@ class MainActivity : AppCompatActivity() {
showSavePresetDialog(preset) showSavePresetDialog(preset)
} }
} }
// Touch-to-Focus: Chạm vào kính ngắm để chọn điểm lấy nét
binding.viewFinder.setOnTouchListener { v, event ->
if (event.action == android.view.MotionEvent.ACTION_UP) {
val percentX = (event.x / v.width).coerceIn(0f, 1f)
val percentY = (event.y / v.height).coerceIn(0f, 1f)
// Cập nhật Class ID lấy nét từ điểm chạm
val mask = cachedCategoryMask
val mW = cachedMaskWidth
val mH = cachedMaskHeight
if (mask != null && mW > 0 && mH > 0) {
val maskX = (percentX * mW).toInt().coerceIn(0, mW - 1)
val maskY = (percentY * mH).toInt().coerceIn(0, mH - 1)
userSelectedClassFocus = mask[maskY * mW + maskX].toInt()
}
// Hiện vòng tròn lấy nét màu cam tại vị trí chạm
val focusRing = binding.viewFocusRing
focusRing.x = event.x - focusRing.width / 2f
focusRing.y = event.y - focusRing.height / 2f
focusRing.visibility = android.view.View.VISIBLE
focusRing.alpha = 1f
focusRing.scaleX = 1.4f
focusRing.scaleY = 1.4f
focusRing.animate()
.scaleX(1f).scaleY(1f)
.alpha(0f)
.setDuration(900)
.withEndAction { focusRing.visibility = android.view.View.GONE }
.start()
v.performClick()
}
true
}
} }
private fun translateButtonToDock(button: android.view.View) { private fun translateButtonToDock(button: android.view.View) {
@@ -1690,11 +1733,25 @@ class MainActivity : AppCompatActivity() {
val h = categoryMask.height val h = categoryMask.height
val bufferCapacity = categoryMaskBuffer.capacity() val bufferCapacity = categoryMaskBuffer.capacity()
// Create and blur alpha mask // Cache mask bytes for Touch-to-Focus
val rawBytes = ByteArray(bufferCapacity)
categoryMaskBuffer.rewind()
categoryMaskBuffer.get(rawBytes)
cachedCategoryMask = rawBytes
cachedMaskWidth = w
cachedMaskHeight = h
categoryMaskBuffer.rewind()
// Create and blur alpha mask with Touch-to-Focus class selection
val focusClassId = if (userSelectedClassFocus >= 0) userSelectedClassFocus else -1
val alphaMask = FloatArray(w * h) val alphaMask = FloatArray(w * h)
for (i in 0 until (w * h)) { for (i in 0 until (w * h)) {
val classId = if (i < bufferCapacity) categoryMaskBuffer.get(i).toInt() else 0 val classId = if (i < bufferCapacity) rawBytes[i].toInt() else 0
alphaMask[i] = if (classId in 1..5) 1.0f else 0.0f alphaMask[i] = if (focusClassId >= 0) {
if (classId == focusClassId) 1.0f else 0.0f
} else {
if (classId in 1..5) 1.0f else 0.0f
}
} }
blurAlphaMaskBox(alphaMask, w, h, radius = 3) blurAlphaMaskBox(alphaMask, w, h, radius = 3)
@@ -1804,11 +1861,16 @@ class MainActivity : AppCompatActivity() {
val h = categoryMask.height val h = categoryMask.height
val bufferCapacity = categoryMaskBuffer.capacity() val bufferCapacity = categoryMaskBuffer.capacity()
// Create and blur alpha mask // Create and blur alpha mask with Touch-to-Focus class selection
val focusClassId = if (userSelectedClassFocus >= 0) userSelectedClassFocus else -1
val alphaMask = FloatArray(w * h) val alphaMask = FloatArray(w * h)
for (i in 0 until (w * h)) { for (i in 0 until (w * h)) {
val classId = if (i < bufferCapacity) categoryMaskBuffer.get(i).toInt() else 0 val classId = if (i < bufferCapacity) categoryMaskBuffer.get(i).toInt() else 0
alphaMask[i] = if (classId in 1..5) 1.0f else 0.0f alphaMask[i] = if (focusClassId >= 0) {
if (classId == focusClassId) 1.0f else 0.0f
} else {
if (classId in 1..5) 1.0f else 0.0f
}
} }
blurAlphaMaskBox(alphaMask, w, h, radius = 5) blurAlphaMaskBox(alphaMask, w, h, radius = 5)
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<stroke
android:width="2dp"
android:color="#FFCC00" />
<solid android:color="#00000000" />
</shape>
@@ -143,6 +143,14 @@
android:visibility="gone" android:visibility="gone"
android:text="3" /> android:text="3" />
<!-- Vòng tròn định vị lấy nét (Focus Ring) khi chạm vào Viewfinder -->
<View
android:id="@+id/viewFocusRing"
android:layout_width="60dp"
android:layout_height="60dp"
android:background="@drawable/bg_focus_ring"
android:visibility="gone" />
</FrameLayout> </FrameLayout>
<!-- ================= KHỐI 1: TIMELINE FRAMES & PRESETS ================= --> <!-- ================= KHỐI 1: TIMELINE FRAMES & PRESETS ================= -->