Files

12 KiB

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.

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.

// 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).

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).