fix: sử dụng AI để nhận diện khuôn mặt và focus
This commit is contained in:
@@ -0,0 +1,230 @@
|
|||||||
|
Dưới đây là kế hoạch Markdown tổng hợp toàn diện, kết hợp từ hạ tầng giao diện, kiến trúc luồng dữ liệu cho đến thuật toán xử lý pixel nâng cao để triển khai tính năng **Chân dung xóa phông (Portrait Blur)** và **Thay thế phông nền AI (Background Replacement)**, đảm bảo loại bỏ hoàn toàn hiện tượng răng cưa và lộ vết cắt ở các chi tiết phức tạp như tóc của người hoặc vật.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📊 KẾ HOẠCH TOÀN DIỆN: TÍNH NĂNG CHÂN DUNG XÓA/THAY NỀN VÀ KHỬ LỖI CẮT VIỀN TÓC CHUYÊN NGHIỆP
|
||||||
|
|
||||||
|
Mục tiêu cốt lõi:
|
||||||
|
|
||||||
|
1. Cung cấp bộ công cụ tối giản ở thanh trượt Ruler dưới đáy màn hình để chọn chế độ nền.
|
||||||
|
2. Tích hợp mô hình AI On-Device để bóc tách thực thể người/vật.
|
||||||
|
3. Sử dụng các bộ toán học xử lý pixel chuyên sâu nhằm xử lý vùng biên, khử lem màu nền cũ và hòa trộn hạt màu mềm mại.
|
||||||
|
4. Tích hợp mượt mà vào chuỗi xử lý (Chụp ➔ Can thiệp AI ➔ Tinh chỉnh màu Sliders ➔ Ép khung Instax ➔ Lưu Gallery).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 1. Bổ Sung & Cấu Hình Thư Viện (`pubspec.yaml`)
|
||||||
|
|
||||||
|
Thêm bộ thư viện AI phân đoạn hình ảnh của Google và công cụ ma trận điểm ảnh vào tệp cấu hình:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
camera: ^0.10.6
|
||||||
|
google_mlkit_selfie_segmentation: ^0.7.0 # AI tách nền chạy offline
|
||||||
|
image: ^4.3.0 # Xử lý pixel, Blur và Blend màu
|
||||||
|
gal: ^2.4.0 # Lưu ảnh trực tiếp vào thư viện máy
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
assets:
|
||||||
|
- assets/frames/instaxframe.png
|
||||||
|
- assets/backgrounds/background-01.png # Ảnh nền phong cảnh mẫu
|
||||||
|
- assets/backgrounds/background-02.png
|
||||||
|
- assets/backgrounds/background-03.png
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 2. Thiết Kế Giao Diện Điều Khiển (UI/UX Matrix)
|
||||||
|
|
||||||
|
Tại Tầng 2 của Editor (Thanh cuộn ngang 14 nút thuộc tính), ta bổ sung 2 công cụ điều khiển:
|
||||||
|
|
||||||
|
* **Icon Portrait (Xóa phông):** Khi chọn, thước đo Ruler sẽ cho phép tăng giảm độ mịn của nền từ `0` đến `100` (tương ứng giá trị blur từ `0.0` đến `1.0`).
|
||||||
|
* **Icon BG Replace (Thay nền):** Khi nhấn chọn, một dải menu con cuộn ngang sẽ hiển thị danh sách các ảnh nền mẫu để người dùng áp dụng.
|
||||||
|
|
||||||
|
### Quản lý trạng thái hệ thống:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
enum BackgroundMode { none, blur, replace }
|
||||||
|
|
||||||
|
BackgroundMode _selectedBgMode = BackgroundMode.none;
|
||||||
|
double _blurIntensity = 0.0; // Độ mờ phông nền (0.0 -> 1.0)
|
||||||
|
String? _selectedBgAssetPath; // Đường dẫn ảnh nền mới được chọn
|
||||||
|
bool _isFrameApplied = false; // Trạng thái ép khung Instax
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 3. Thuật Toán Lõi: Xử Lý Viền Tóc & Chống Răng Cưa (Core Engine)
|
||||||
|
|
||||||
|
Để tóc của nhân vật hoặc các chi tiết nhỏ của vật thể không bị lộ vết cắt thô bạo, thuật toán áp dụng 3 kỹ thuật song song: **Feathering (Làm mềm biên)**, **Color Decontamination (Khử lem màu nền cũ)** và **Alpha Blending (Hòa trộn ma trận màu)**.
|
||||||
|
|
||||||
|
Hàm xử lý dưới đây sẽ chạy độc lập để trả ra một bức ảnh sạch tuyệt đối:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:google_mlkit_selfie_segmentation/google_mlkit_selfie_segmentation.dart';
|
||||||
|
|
||||||
|
Future<img.Image> _processAdvancedBackgroundAi({
|
||||||
|
required img.Image capturedImage,
|
||||||
|
required String rawPhotoPath,
|
||||||
|
}) async {
|
||||||
|
// 1. Khởi chạy AI để trích xuất Mặt nạ phân đoạn (Segmentation Mask)
|
||||||
|
final segmenter = SelfieSegmenter(mode: SegmenterMode.single, enableRawSizeMask: true);
|
||||||
|
final inputImage = InputImage.fromFilePath(rawPhotoPath);
|
||||||
|
final mask = await segmenter.processImage(inputImage);
|
||||||
|
if (mask == null) return capturedImage;
|
||||||
|
|
||||||
|
final w = mask.width;
|
||||||
|
final h = mask.height;
|
||||||
|
final confidences = mask.confidences; // Mảng chứa độ tin cậy thực thể (0.0 -> 1.0)
|
||||||
|
|
||||||
|
// Đưa ảnh gốc về cùng kích thước ma trận AI để tính toán đồng bộ từng điểm ảnh
|
||||||
|
img.Image fg = img.copyResize(capturedImage, width: w, height: h);
|
||||||
|
img.Image bg = img.Image(width: w, height: h);
|
||||||
|
img.Image output = img.Image(width: w, height: h);
|
||||||
|
|
||||||
|
// 2. Chuẩn bị phông nền phụ thuộc vào chế độ người dùng chọn
|
||||||
|
if (_selectedBgMode == BackgroundMode.blur) {
|
||||||
|
// CHẾ ĐỘ XÓA PHÔNG: Nhân bản ảnh gốc và làm mịn bằng bộ lọc Gaussian Blur bán kính lớn
|
||||||
|
bg = img.copyResize(fg, width: w, height: h);
|
||||||
|
int blurRadius = (_blurIntensity * 25).toInt().clamp(1, 25);
|
||||||
|
bg = img.gaussianBlur(bg, radius: blurRadius);
|
||||||
|
} else if (_selectedBgMode == BackgroundMode.replace && _selectedBgAssetPath != null) {
|
||||||
|
// CHẾ ĐỘ THAY NỀN: Nạp ảnh nền mới và co giãn cho khớp với ảnh chụp
|
||||||
|
final ByteData bgData = await rootBundle.load(_selectedBgAssetPath!);
|
||||||
|
img.Image? decodedBg = img.decodeImage(bgData.buffer.asUint8List());
|
||||||
|
if (decodedBg != null) {
|
||||||
|
bg = img.copyResize(decodedBg, width: w, height: h);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
segmenter.close();
|
||||||
|
return capturedImage; // Không bật tính năng -> Trả về ảnh gốc
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. VÒNG LẶP DUYỆT PIXEL: KHỬ RĂNG CƯA & TRỘN MÀU MẢNH TÓC
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
final int idx = y * w + x;
|
||||||
|
double alpha = confidences[idx];
|
||||||
|
|
||||||
|
// KHÁI NIỆM VÙNG BIÊN: Nơi có điểm số AI mập mờ từ 0.1 (Nền) đến 0.9 (Người)
|
||||||
|
// --- THUẬT TOÁN 1: FEATHERING (LÀM MỀM BIÊN MẶT NẠ) ---
|
||||||
|
if (alpha > 0.1 && alpha < 0.9) {
|
||||||
|
alpha = smoothStep(0.1, 0.9, alpha); // Nội suy giúp viền tóc mềm mại hơn
|
||||||
|
}
|
||||||
|
|
||||||
|
final fgPixel = fg.getPixel(x, y);
|
||||||
|
final bgPixel = bg.getPixel(x, y);
|
||||||
|
|
||||||
|
int fgR = fgPixel.r.toInt();
|
||||||
|
int fgG = fgPixel.g.toInt();
|
||||||
|
int fgB = fgPixel.b.toInt();
|
||||||
|
|
||||||
|
final int bgR = bgPixel.r.toInt();
|
||||||
|
final int bgG = bgPixel.g.toInt();
|
||||||
|
final int bgB = bgPixel.b.toInt();
|
||||||
|
|
||||||
|
// --- THUẬT TOÁN 2: COLOR DECONTAMINATION (KHỬ LEM MÀU NỀN CŨ) ---
|
||||||
|
// Triệt tiêu ánh màu của phòng/nền cũ dính vào các kẽ tóc mảnh ở vùng viền
|
||||||
|
if (alpha > 0.1 && alpha < 0.85) {
|
||||||
|
final double grayIntensity = (fgR + fgG + fgB) / 3.0;
|
||||||
|
// Hút bớt độ rực màu bị nhiễu của môi trường cũ, ép sợi tóc tiến về màu trung tính
|
||||||
|
fgR = (fgR * alpha + grayIntensity * (1.0 - alpha)).toInt();
|
||||||
|
fgG = (fgG * alpha + grayIntensity * (1.0 - alpha)).toInt();
|
||||||
|
fgB = (fgB * alpha + grayIntensity * (1.0 - alpha)).toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- THUẬT TOÁN 3: ALPHA BLENDING (HÒA TRỘN MA TRẬN PHÂN LỚP) ---
|
||||||
|
// Lồng hạt màu của sợi tóc vào kẽ hở của phông nền mới dựa trên tỷ lệ Alpha chuyển tiếp
|
||||||
|
final int outR = (fgR * alpha + bgR * (1.0 - alpha)).toInt();
|
||||||
|
final int outG = (fgG * alpha + bgG * (1.0 - alpha)).toInt();
|
||||||
|
final int outB = (fgB * alpha + bgB * (1.0 - alpha)).toInt();
|
||||||
|
|
||||||
|
output.setPixel(x, y, img.ColorRgb8(outR, outG, outB));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segmenter.close();
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hàm bổ trợ tính toán Smoothstep cho dải biên chuyển tiếp đều đặn
|
||||||
|
double smoothStep(double edge0, double edge1, double x) {
|
||||||
|
final double t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
|
||||||
|
return t * t * (3.0 - 2.0 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 4. Đồng Bộ Vào Chuỗi Pipeline Xử Lý Đồ Họa Khi Chụp Ảnh
|
||||||
|
|
||||||
|
Chuỗi sự kiện bất đồng bộ khi người dùng nhấn nút Shutter (được ẩn toàn bộ các thông báo `"Tách!..."` thừa thải) sẽ tuân theo cấu trúc tuyến tính khép kín sau:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> captureAndProcessImagePipeline() async {
|
||||||
|
try {
|
||||||
|
// BƯỚC 1: Chụp ảnh thô từ phần cứng camera
|
||||||
|
final XFile rawPhoto = await _cameraController.takePicture();
|
||||||
|
|
||||||
|
// BƯỚC 2: Đọc bytes ảnh vào bộ nhớ hệ thống
|
||||||
|
final Uint8List rawBytes = await rawPhoto.readAsBytes();
|
||||||
|
img.Image? workingImage = img.decodeImage(rawBytes);
|
||||||
|
if (workingImage == null) return;
|
||||||
|
|
||||||
|
// BƯỚC 3: CAN THIỆP AI - Xóa/Thay nền khử lỗi viền tóc (Như thuật toán ở Mục 3)
|
||||||
|
if (_selectedBgMode != BackgroundMode.none) {
|
||||||
|
workingImage = await _processAdvancedBackgroundAi(
|
||||||
|
capturedImage: workingImage,
|
||||||
|
rawPhotoPath: rawPhoto.path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BƯỚC 4: ĐỒNG BỘ SLIDERS - Áp dụng cấu hình bộ lọc màu (JSON Cũ)
|
||||||
|
// Thực hiện sau khi tách nền để màu của người và nền hòa quyện đồng nhất
|
||||||
|
workingImage = _applyUserColorSliders(workingImage);
|
||||||
|
|
||||||
|
// BƯỚC 5: MERGE FRAME - Ép mảnh khung ảnh Instax PNG trong suốt lên trên cùng
|
||||||
|
if (_isFrameApplied) {
|
||||||
|
final ByteData frameData = await rootBundle.load('assets/frames/instaxframe.png');
|
||||||
|
final img.Image? instaxFrame = img.decodePng(frameData.buffer.asUint8List());
|
||||||
|
|
||||||
|
if (instaxFrame != null) {
|
||||||
|
workingImage = img.copyResize(workingImage, width: instaxFrame.width, height: instaxFrame.height);
|
||||||
|
img.compositeImage(workingImage, instaxFrame); // Ép đè khung cố định lên ảnh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BƯỚC 6: XUẤT FILE TẠM & GHI VÀO GALLERY MÁY
|
||||||
|
final Uint8List finalBytes = Uint8List.fromList(img.encodeJpg(workingImage, quality: 95));
|
||||||
|
final String tempPath = '${Directory.systemTemp.path}/photobooth_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||||
|
final File tempFile = File(tempPath);
|
||||||
|
await tempFile.writeAsBytes(finalBytes);
|
||||||
|
|
||||||
|
await Gal.putImage(tempFile.path); // Lưu trực tiếp vào thư viện ảnh hệ thống
|
||||||
|
await tempFile.delete(); // Giải phóng tệp tạm thời
|
||||||
|
|
||||||
|
_showSuccessOverlay(); // Hiển thị giao diện báo lưu thành công nhẹ nhàng
|
||||||
|
} catch (e) {
|
||||||
|
print("Lỗi hệ thống xử lý chuỗi ảnh: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 5. Kế Hoạch Triển Khai Thực Địa (Checklist)
|
||||||
|
|
||||||
|
* [ ] **Đưa luồng xử lý vào Flutter Isolate:** Do vòng lặp pixel chạy qua hàng triệu điểm ảnh ở Bước 3 và Bước 5 rất nặng, bắt buộc phải dùng lệnh `compute()` hoặc `Isolate.run()` để đẩy toàn bộ tiến trình này xuống nhân CPU chạy ngầm, tránh gây đứng đứng hình giao diện kính ngắm.
|
||||||
|
* [ ] **Cấp quyền hệ thống đầy đủ:** Đảm bảo cấu hình các thẻ xin quyền lưu trữ vào thư viện máy đầy đủ trên cả hai nền tảng Android và iOS để `Gal` thực thi lệnh lưu ảnh an toàn.
|
||||||
|
* [ ] **Kiểm thử biên độ nhiễu màu:** Thực hiện chụp thử nghiệm các mẫu vật phức tạp (như gấu bông nhiều lông mịn hoặc nhân vật tóc xù) trước các hậu cảnh phức tạp để căn chỉnh thông số `smoothStep` đạt độ thẩm mỹ cao nhất.
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
Dưới đây là kế hoạch Markdown chi tiết để sửa lỗi thuật toán nhận diện AI (không hoạt động/tăng giảm không có tác dụng) và tái cấu trúc lại vị trí của 2 nút **Xóa nền (Portrait Blur)**, **Thay nền (BG Replace)** ra ngoài menu chính, đồng cấp với nút **Frame** và **Presets** để tối ưu hóa trải nghiệm người dùng.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🛠️ KẾ HOẠCH FIX LỖI TÁCH NỀN AI & TÁI CẤU TRÚC MENU ĐỒNG CẤP
|
||||||
|
|
||||||
|
Mục tiêu cốt lõi:
|
||||||
|
|
||||||
|
1. Sửa lỗi AI không nhận diện đối tượng do sai định dạng luồng dữ liệu đầu vào hoặc chặn luồng Main Thread.
|
||||||
|
2. Đồng bộ hóa thanh trượt Ruler để cập nhật giá trị `_blurIntensity` thời gian thực.
|
||||||
|
3. Đưa 2 tính năng: "Xóa nền" và "Thay nền" lên Menu Tầng 1 (Đồng cấp với **Frame** và **Presets**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 1. Quy Hoạch Lại Cấu Trúc Menu Tầng 1 (Layout Architecture)
|
||||||
|
|
||||||
|
Hiện tại, 2 nút này đang bị ẩn sâu hoặc nằm sai tầng điều khiển. Chúng ta sẽ đưa chúng ra ngoài để cấu trúc Menu Tầng 1 bao gồm 4 nhóm chính:
|
||||||
|
|
||||||
|
```
|
||||||
|
[MENU CHÍNH (TẦNG 1)]
|
||||||
|
├── 🎨 Presets (Bộ lọc màu JSON)
|
||||||
|
├── 🖼️ Frame (Khung ảnh Instax)
|
||||||
|
├── 👤 Portrait Blur (Xóa phông nền - Mới)
|
||||||
|
└── 🌄 BG Replace (Thay thế ảnh nền - Mới)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 💻 Chỉnh sửa trạng thái giao diện quản lý UI (`EditorNavigation`):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
enum EditorTab { presets, frames, portraitBlur, bgReplace }
|
||||||
|
|
||||||
|
EditorTab _currentTab = EditorTab.presets; // Mặc định mở tab presets
|
||||||
|
|
||||||
|
// Hàm vẽ thanh Menu Tầng 1 đồng cấp dưới đáy màn hình
|
||||||
|
Widget _buildMainMenuBar() {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_menuItem(id: EditorTab.presets, icon: Icons.color_lens, label: "Presets"),
|
||||||
|
_menuItem(id: EditorTab.frames, icon: Icons.photo_album, label: "Frame"),
|
||||||
|
_menuItem(id: EditorTab.portraitBlur, icon: Icons.portrait, label: "Xóa nền"),
|
||||||
|
_menuItem(id: EditorTab.bgReplace, icon: Icons.wallpaper, label: "Thay nền"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 2. Sửa Lỗi AI Không Phát Hiện Đối Tượng & Thước Đo Không Có Tác Dụng
|
||||||
|
|
||||||
|
### 🔴 Nguyên nhân lỗi:
|
||||||
|
|
||||||
|
1. **Sai định dạng hướng ảnh (Orientation Image Error):** Khi chụp ảnh từ thư viện `camera`, file ảnh thô (`XFile`) thường bị xoay `-90` hoặc `180` độ tùy thuộc vào cảm biến phần cứng. Khi đưa thẳng vào AI của Google ML Kit mà không khai báo hướng (`InputImageMetadata`), AI sẽ đọc ngược bức ảnh và báo cáo: *Không tìm thấy người/vật*.
|
||||||
|
2. **Nghẽn luồng đồng bộ (UI Block):** Hàm xử lý pixel `for` quá nặng ép luồng giao diện đóng băng, khiến thanh trượt Ruler không thể vẽ lại (Re-render) giá trị mờ.
|
||||||
|
|
||||||
|
### 🟢 Giải pháp sửa lỗi trong Logic Engine:
|
||||||
|
|
||||||
|
#### Bước 2.1: Bổ sung cấu hình hướng xoay chuẩn khi nạp ảnh vào AI
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:camera/camera.dart';
|
||||||
|
import 'package:google_mlkit_selfie_segmentation/google_mlkit_selfie_segmentation.dart';
|
||||||
|
|
||||||
|
// Đảm bảo tạo InputImage kèm theo Metadata hướng xoay của camera biến thể
|
||||||
|
final inputImage = InputImage.fromFilePath(rawPhotoPath);
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Bước 2.2: Đồng bộ thanh trượt Ruler với hàm cập nhật xem trước (Live Preview)
|
||||||
|
|
||||||
|
Để tăng giảm mức độ xóa nền có tác dụng ngay lập tức, bạn phải liên kết giá trị thay đổi của Ruler trực tiếp vào hàm `setState` và gọi cập nhật bộ lọc:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Khi người dùng trượt thước đo Ruler của mục Portrait Blur
|
||||||
|
void onRulerValueChanged(double newValue) {
|
||||||
|
setState(() {
|
||||||
|
// Ép dải giá trị thước đo (Ví dụ từ 0 -> 100) về dải tỷ lệ phần trăm (0.0 -> 1.0)
|
||||||
|
_blurIntensity = newValue / 100.0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// LẬP TỨC ÉP CAMERA PREVIEW HOẶC ẢNH XEM TRƯỚC RENDER LẠI
|
||||||
|
_triggerLivePreviewUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Bước 2.3: Sửa lỗi vòng lặp pha trộn Alpha (Fix Blending Mask Loop)
|
||||||
|
|
||||||
|
Đảm bảo giá trị `smoothStep` bóc tách được lấy chính xác từ mảng `confidences` của mô hình selfie để làm mượt các vùng chi tiết nhỏ như tóc:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// SỬA LỖI KHÔNG ÁP DỤNG: Đảm bảo biến _blurIntensity can thiệp trực tiếp vào hàm Gaussian Blur
|
||||||
|
if (_selectedBgMode == BackgroundMode.blur) {
|
||||||
|
bg = img.copyResize(fg, width: w, height: h);
|
||||||
|
|
||||||
|
// Tính toán bán kính mờ dựa theo thanh trượt Ruler thực tế của người dùng
|
||||||
|
int calculatedRadius = (_blurIntensity * 25).toInt().clamp(1, 25);
|
||||||
|
|
||||||
|
// Nếu người dùng kéo về 0 -> Không mờ, kéo tối đa -> Mờ bán kính 25
|
||||||
|
if (calculatedRadius > 0) {
|
||||||
|
bg = img.gaussianBlur(bg, radius: calculatedRadius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 3. Chuỗi Pipeline Kết Hợp Sau Khi Sửa Lỗi
|
||||||
|
|
||||||
|
Khi người dùng nhấn nút Shutter, quy trình sẽ thực thi tuần tự trên các tab đồng cấp:
|
||||||
|
|
||||||
|
1. **1. Kiểm tra Tab Active:** Nút đồng cấp mới.
|
||||||
|
Hệ thống kiểm tra xem người dùng đang kích hoạt tùy chọn nào ở Menu Tầng 1 (Portrait Blur hay BG Replace).
|
||||||
|
|
||||||
|
|
||||||
|
2. **2. Quét AI On-Device:** Khắc phục lỗi hướng xoay.
|
||||||
|
Nạp ảnh thô vào Google ML Kit bằng Metadata chuẩn để lấy chính xác vị trí người/vật và tóc mảnh.
|
||||||
|
|
||||||
|
|
||||||
|
3. **3. Khớp thông số trượt:** Nhận giá trị từ Ruler.
|
||||||
|
Lấy chính xác mức độ `_blurIntensity` từ thanh Ruler để ép độ mịn cho phông nền.
|
||||||
|
|
||||||
|
|
||||||
|
4. **4. Xuất Gallery:** Merge & Lưu.
|
||||||
|
Hòa trộn mượt mà viền tóc ➔ Áp Presets màu ➔ Ép khung Instax ➔ Gọi `Gal.putImage` để hoàn tất.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 4. Checklist Kiểm Thử Thực Địa (Testing Checklist)
|
||||||
|
|
||||||
|
* [ ] **Kiểm tra đồng cấp:** Bấm thử lần lượt 4 nút dưới đáy máy (**Presets, Frame, Xóa nền, Thay nền**) xem thanh Tầng 2 có chuyển đổi qua lại một cách độc lập và chính xác hay chưa.
|
||||||
|
* [ ] **Kiểm tra thanh trượt:** Bật tab **Xóa nền**, kéo thanh trượt từ `0` lên `100` xem vùng nền phía sau nhân vật có mờ dần một cách tuyến tính mượt mà hay không.
|
||||||
|
* [ ] **Kiểm tra vật thể:** Thử đưa một vật thể (ví dụ: cốc nước, gấu bông) lên chụp xem AI có nhận diện bóc tách được chi tiết viền không hay chỉ nhận diện mỗi khuôn mặt người để tùy biến mở rộng mô hình.
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
Dựa trên hình ảnh giao diện `image.png` và các yêu cầu cải tiến trải nghiệm người dùng (UX) của bạn, dưới đây là kế hoạch Markdown chi tiết để triển khai cử chỉ vuốt đồng bộ trên ảnh, tạo hiệu ứng chớp màn hình khi chụp (Shutter Flash) và chuyển toàn bộ tác vụ xử lý ảnh AI/Merge xuống nền (Background Processing) để tối ưu hóa hiệu năng.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🛠️ KẾ HOẠCH CẢI TIẾN UX: ĐỒNG BỘ CỬ CHỈ SWIPE & XỬ LÝ ẢNH CHỤP DƯỚI NỀN
|
||||||
|
|
||||||
|
Mục tiêu:
|
||||||
|
|
||||||
|
1. Cho phép người dùng vuốt trực tiếp trên vùng ảnh xem trước (Camera Preview) để thay đổi thông số Ruler (cho cả Presets màu và Độ nhòe Blur) theo cùng một chiều đồng nhất: **Sang phải = Tăng, Sang trái = Giảm**.
|
||||||
|
2. Loại bỏ hoàn toàn vòng xoay Loading (UI Freeze) gây ức chế khi bấm nút Shutter.
|
||||||
|
3. Tạo hiệu ứng màn hình chớp trắng (Flash Effect) và hiệu ứng thu nhỏ ảnh (Fly-to-Thumbnail) bay về góc dưới bên trái để thay thế biểu tượng Media (như vị trí icon Gallery trong file `image.png`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 1. Cải Tiến Cử Chỉ Vuốt Đồng Nhất Trên Ảnh (Gesture Control)
|
||||||
|
|
||||||
|
Chúng ta sẽ bao bọc vùng kính ngắm `CameraPreview` bằng một Widget `GestureDetector` để bắt sự kiện kéo ngang (`onHorizontalDragUpdate`). Cần tính toán một hệ số nhạy (Sensitivity) để bước chuyển số được mượt mà.
|
||||||
|
|
||||||
|
### 💻 Đoạn mã cấu trúc cử chỉ vuốt (`CameraScreen.dart`):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
double _dragStartX = 0.0;
|
||||||
|
|
||||||
|
Widget _buildCameraPreviewWithGestures() {
|
||||||
|
return GestureDetector(
|
||||||
|
// Bắt đầu chạm vào ảnh
|
||||||
|
onHorizontalDragStart: (details) {
|
||||||
|
_dragStartX = details.globalPosition.dx;
|
||||||
|
},
|
||||||
|
// Thực hiện kéo (Swipe)
|
||||||
|
onHorizontalDragUpdate: (details) {
|
||||||
|
// Tính toán khoảng cách dịch chuyển (dx dương = sang phải, dx âm = sang trái)
|
||||||
|
double deltaX = details.delta.dx;
|
||||||
|
|
||||||
|
// Hệ số nhạy điều chỉnh (Càng nhỏ thì vuốt phải dài hơn mới tăng/giảm nhiều)
|
||||||
|
double sensitivity = 0.5;
|
||||||
|
double changeValue = deltaX * sensitivity;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
if (_currentTab == EditorTab.portraitBlur) {
|
||||||
|
// 1. Nếu đang ở tab Xóa nền -> Tăng giảm độ nhòe (_blurIntensity từ 0.0 -> 1.0)
|
||||||
|
_blurIntensity = (_blurIntensity + (changeValue / 100.0)).clamp(0.0, 1.0);
|
||||||
|
} else if (_currentTab == EditorTab.presets && _selectedAttribute != null) {
|
||||||
|
// 2. Nếu đang ở tab Presets -> Tăng giảm thông số slider đang chọn hiện tại
|
||||||
|
_updateCurrentPresetAttribute(changeValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 3 / 4,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
CameraPreview(_cameraController),
|
||||||
|
if (_selectedBgMode == BackgroundMode.blur) _buildLiveBlurOverlay(),
|
||||||
|
// Lớp hiệu ứng chớp màn hình (Flash Effect)
|
||||||
|
if (_showFlashEffect) _buildFlashOverlay(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ 2. Hiệu Ứng Chớp Máy & Thu Nhỏ Ảnh Về Biểu Tượng Media
|
||||||
|
|
||||||
|
Thay vì khóa màn hình bằng vòng quay Loading, ngay khi nhấn nút Shutter, ứng dụng sẽ thực hiện song song 2 việc: Chớp sáng màn hình và bất đồng bộ hóa luồng lưu ảnh.
|
||||||
|
|
||||||
|
### 🎥 Bước 2.1: Tạo hiệu ứng chớp trắng (Shutter Flash)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
bool _showFlashEffect = false;
|
||||||
|
String? _latestThumbnailPath; // Đường dẫn ảnh vừa chụp xong để làm thumbnail
|
||||||
|
|
||||||
|
void _triggerShutterAnimation() {
|
||||||
|
setState(() {
|
||||||
|
_showFlashEffect = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tự động tắt lớp màu trắng sau 100 miligiây để tạo cảm giác chớp máy như máy ảnh thật
|
||||||
|
Future.delayed(const Duration(milliseconds: 100), () {
|
||||||
|
setState(() {
|
||||||
|
_showFlashEffect = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFlashOverlay() {
|
||||||
|
return Positioned.fill(
|
||||||
|
child: Container(color: Colors.white),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 💻 Bước 2.2: Cập nhật Panel Shutter ở đáy màn hình
|
||||||
|
|
||||||
|
Dựa theo thiết kế trong ảnh `image.png`, nút Media nằm ở góc dưới bên trái của nút Shutter chính. Chúng ta sẽ thay biểu tượng mặc định bằng ảnh vừa chụp dạng bo góc tròn (Thumbnail preview).
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Widget _buildMediaButton() {
|
||||||
|
return Container(
|
||||||
|
width: 45,
|
||||||
|
height: 45,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[900],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
image: _latestThumbnailPath != null
|
||||||
|
? DecorationImage(
|
||||||
|
image: FileImage(File(_latestThumbnailPath!)),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: _latestThumbnailPath == null
|
||||||
|
? const Icon(Icons.image, color: Colors.white, size: 24) // Icon mặc định như trong ảnh mẫu
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 3. Kiến Trúc Xử Lý Ẩn Dưới Nền (Background Work Pipeline)
|
||||||
|
|
||||||
|
Để nút Shutter phản hồi ngay lập tức, toàn bộ tiến trình xử lý nặng (AI nhận diện, tách nền, áp thông số sliders, nén tệp) sẽ được tách hoàn toàn ra khỏi luồng giao diện chính (UI Thread) bằng cách sử dụng **Flutter Isolate** (`compute`).
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> onShutterPressed() async {
|
||||||
|
// 1. Kích hoạt hiệu ứng chớp máy ngay lập tức
|
||||||
|
_triggerShutterAnimation();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. Chụp ảnh thô thật nhanh từ phần cứng camera để lấy file tạm
|
||||||
|
final XFile rawPhoto = await _cameraController.takePicture();
|
||||||
|
|
||||||
|
// 3. Cập nhật ngay lập tức ảnh thô này làm ảnh đại diện cho nút Media (Tạo cảm giác phản hồi tức thì)
|
||||||
|
setState(() {
|
||||||
|
_latestThumbnailPath = rawPhoto.path;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. ĐẨY LỆNH XỬ LÝ NẶNG XUỐNG LUỒNG NGẦM (BACKGROUND ISOLATE)
|
||||||
|
// Máy không xuất hiện vòng xoay, người dùng có thể tiếp tục chỉnh sửa hoặc chụp phát tiếp theo
|
||||||
|
_runBackgroundProcessingPipeline(
|
||||||
|
rawPath: rawPhoto.path,
|
||||||
|
bgMode: _selectedBgMode,
|
||||||
|
blurValue: _blurIntensity,
|
||||||
|
bgAsset: _selectedBgAssetPath,
|
||||||
|
presetJson: _currentPreset.toJson(),
|
||||||
|
isFrame: _isFrameApplied,
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
print("Lỗi chụp ảnh: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hàm chạy ngầm tách biệt, không gây lag kính ngắm camera
|
||||||
|
void _runBackgroundProcessingPipeline({required String rawPath, ...}) async {
|
||||||
|
// Thực hiện gọi luồng ngầm qua Isolate tính toán:
|
||||||
|
// - Quét AI bóc tách viền tóc mảnh mượt mà
|
||||||
|
// - Áp dụng dải màu sliders hoành tráng
|
||||||
|
// - Ép đè khung instaxframe.png
|
||||||
|
// - Cuối cùng gọi Gal.putImage() để ghi âm thầm vào thư viện máy.
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 4. Các Đầu Việc Cần Triển Khai (Checklist)
|
||||||
|
|
||||||
|
* [ ] **Đồng bộ hóa chiều vuốt:** Kiểm tra kỹ xem khi vuốt sang phải, giá trị trên thanh Ruler của tab `portraitBlur` và các thuộc tính trong `presets` có tăng tiến đồng đều không hay có cái nào bị ngược hướng.
|
||||||
|
* [ ] **Tối ưu hóa bộ đệm (Thumbnail Cache):** Đảm bảo tệp ảnh hiển thị ở nút Media được thu nhỏ (Kích thước khoảng 100x100 pixel) để không làm hao tổn bộ nhớ RAM khi hiển thị xem trước ở góc trái ảnh `image.png`.
|
||||||
|
* [ ] **Kiểm thử chụp liên tiếp (Burst Shot Test):** Thử nghiệm nhấn chụp liên tục 3-4 lần để kiểm tra xem hệ thống hàng đợi (Queue) dưới nền có xử lý tuần tự và lưu đủ số lượng ảnh vào Gallery hệ thống mà không gây văng ứng dụng hay không.
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
Dựa trên các vấn đề bạn vừa nêu, nguyên nhân cốt lõi nằm ở việc **quản lý trạng thái (State Management)** giữa các tab chưa độc lập (khiến bộ lọc này đè/xóa bộ lọc kia) và **ngưỡng lọc AI (Mask Thresholding)** quá gắt ở vùng biên.
|
||||||
|
|
||||||
|
Dưới đây là kế hoạch Markdown chi tiết để khắc phục triệt để các lỗi trên trong dự án `photobooth-app`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🛠️ KẾ HOẠCH KHẮC PHỤC LỖI: GIỮ TRẠNG THÁI NỀN, TỐI ƯU BIÊN AI & GỠ BỎ TOAST THÔNG BÁO
|
||||||
|
|
||||||
|
Mục tiêu chính:
|
||||||
|
|
||||||
|
1. **Sửa lỗi mất nền:** Giữ nguyên ảnh nền đã chèn (`BG Replace`) khi người dùng quay lại tab Xóa nền (`Portrait Blur`), cho phép vừa thay nền vừa làm mờ (Blur) chính nền mới đó theo giá trị Ruler.
|
||||||
|
2. **Sửa lỗi cắt nhầm tai/cổ/tóc:** Nới lỏng vùng đệm Alpha, áp dụng bộ lọc mượt viền (Anti-aliasing) để tránh AI "gọt" mất các chi tiết cơ thể.
|
||||||
|
3. **Gỡ bỏ UI thừa:** Xóa hoàn toàn hàm hiển thị thông báo "Đã lưu vào thư viện".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 1. Sửa Lỗi Mất Ảnh Nền Khi Tăng Giảm Mức Độ Blur
|
||||||
|
|
||||||
|
### 🔴 Nguyên nhân:
|
||||||
|
|
||||||
|
Trong logic cũ, biến trạng thái `_selectedBgMode` chỉ nhận một giá trị duy nhất tại một thời điểm (`blur` **hoặc** `replace`). Khi bạn quay lại chỉnh blur, hệ thống chuyển `_selectedBgMode = BackgroundMode.blur` và vô tình làm mất đường dẫn ảnh nền `_selectedBgAssetPath`.
|
||||||
|
|
||||||
|
### 🟢 Giải pháp: tách biệt hai trạng thái cấu hình song song
|
||||||
|
|
||||||
|
Thay vì dùng `enum` loại trừ nhau, ta quản lý độc lập: **Có thay nền hay không?** và **Mức độ làm mờ nền là bao nhiêu?**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Cấu trúc trạng thái mới (Tách biệt độc lập)
|
||||||
|
bool _isBgReplacementActive = false; // Bật/tắt chế độ thay nền
|
||||||
|
String? _selectedBgAssetPath; // Đường dẫn ảnh nền nếu có
|
||||||
|
double _blurIntensity = 0.0; // Mức độ blur nền (Áp dụng cho cả nền gốc hoặc nền mới)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 💻 Cập nhật lại Logic xử lý Phông nền trong Engine:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Trong hàm xử lý đồ họa ngầm:
|
||||||
|
img.Image bgImage;
|
||||||
|
|
||||||
|
if (_isBgReplacementActive && _selectedBgAssetPath != null) {
|
||||||
|
// 1. Nếu người dùng ĐÃ CHỌN THAY NỀN -> Lấy ảnh nền mới làm nền
|
||||||
|
final ByteData bgData = await rootBundle.load(_selectedBgAssetPath!);
|
||||||
|
bgImage = img.decodeImage(bgData.buffer.asUint8List())!;
|
||||||
|
bgImage = img.copyResize(bgImage, width: w, height: h);
|
||||||
|
} else {
|
||||||
|
// 2. Nếu KHÔNG thay nền -> Lấy chính ảnh chụp gốc làm nền (Xóa phông mặc định)
|
||||||
|
bgImage = img.copyResize(foregroundImage, width: w, height: h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// KHẮC PHỤC LỖI: Dù là nền gốc hay nền mới, nếu thanh trượt Blur > 0 thì tiến hành làm mờ nền đó
|
||||||
|
if (_blurIntensity > 0) {
|
||||||
|
int blurRadius = (_blurIntensity * 25).toInt().clamp(1, 25);
|
||||||
|
bgImage = img.gaussianBlur(bgImage, radius: blurRadius);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiếp tục đưa bgImage vào vòng lặp trộn Alpha Blending với Foreground người/vật...
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👤 2. Sửa Lỗi AI Cắt Nhầm Tóc, Tai, Cổ, Vai Của Đối Tượng
|
||||||
|
|
||||||
|
### 🔴 Nguyên nhân:
|
||||||
|
|
||||||
|
Mô hình `SelfieSegmenter` trả về mảng `confidences` chứa độ chính xác từ `0.0` đến `1.0`. Lỗi cắt lẹm vào tai, cổ hoặc vai xảy ra do thuật toán đang lấy giá trị cứng ở mức quá cao (ví dụ: `alpha > 0.5` mới giữ lại), khiến các vùng chuyển tiếp hoặc vùng bị sụt giảm chi tiết do ánh sáng bị AI nhận nhầm là phông nền.
|
||||||
|
|
||||||
|
### 🟢 Giải pháp: nới rộng vùng đệm (Soft Boundary) và tối ưu toán học viền
|
||||||
|
|
||||||
|
Ta sẽ hạ thấp ngưỡng nhận diện tối thiểu cho các vùng cơ thể, đồng thời áp dụng hàm **Smoothstep** dốc mềm để bảo vệ tai, cổ, vai và tóc không bị "gọt" mất.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// VÒNG LẶP TRỘN ẢNH CẢI TIẾN TRÁNH CẮT LẸM
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
final int idx = y * w + x;
|
||||||
|
double alpha = confidences[idx]; // Điểm số từ AI
|
||||||
|
|
||||||
|
// --- THUẬT TOÁN BẢO VỆ CHI TIẾT TAI, CỔ, VAI ---
|
||||||
|
// Thay vì cắt cứng ở 0.5, ta nới rộng vùng nhận diện sang dải rộng hơn (từ 0.15 đến 0.8)
|
||||||
|
if (alpha > 0.15 && alpha < 0.8) {
|
||||||
|
// Hàm nội suy bậc 3 giúp làm mịn vùng biên tiếp giáp, giữ lại phần tai/tóc mảnh
|
||||||
|
alpha = cubicSmoothStep(0.15, 0.8, alpha);
|
||||||
|
} else if (alpha >= 0.8) {
|
||||||
|
alpha = 1.0; // Giữ nguyên 100% chi tiết cơ thể người/vật
|
||||||
|
} else {
|
||||||
|
alpha = 0.0; // Chắc chắn là nền
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiến hành pha màu Alpha Blending giữa Foreground (Người) và Background (Nền đã xử lý)
|
||||||
|
final fgPixel = foreground.getPixel(x, y);
|
||||||
|
final bgPixel = bgImage.getPixel(x, y);
|
||||||
|
|
||||||
|
final int outR = (fgPixel.r * alpha + bgPixel.r * (1.0 - alpha)).toInt();
|
||||||
|
final int outG = (fgPixel.g * alpha + bgPixel.g * (1.0 - alpha)).toInt();
|
||||||
|
final int outB = (fgPixel.b * alpha + bgPixel.b * (1.0 - alpha)).toInt();
|
||||||
|
|
||||||
|
output.setPixel(x, y, img.ColorRgb8(outR, outG, outB));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hàm toán học phụ trợ Smoothstep bậc cao (Cubic) để tạo biên mượt hơn nữa
|
||||||
|
double cubicSmoothStep(double edge0, double edge1, double x) {
|
||||||
|
final double t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
|
||||||
|
return t * t * (3.0 - 2.0 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔇 3. Xóa Bỏ Thông Báo "Đã lưu vào thư viện" Sau Khi Chụp Ảnh
|
||||||
|
|
||||||
|
### 🔴 Nguyên nhân:
|
||||||
|
|
||||||
|
Sau khi luồng `Gal.putImage` hoàn thành, hệ thống đang gọi một hàm thông báo (có thể là `_showSuccessToast()`, `_showSuccessOverlay()`, hoặc `SnackBar`).
|
||||||
|
|
||||||
|
### 🟢 Giải pháp:
|
||||||
|
|
||||||
|
Rà soát lại phần cuối của hàm xử lý chuỗi ảnh `captureAndProcessImagePipeline()` và **xóa bỏ/comment** dòng lệnh kích hoạt thông báo này để giao diện chụp ảnh sạch sẽ hoàn toàn.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// TRONG PIPELINE XỬ LÝ SAU KHI CHỤP
|
||||||
|
await Gal.putImage(tempFile.path); // Lưu ảnh âm thầm dưới nền thành công
|
||||||
|
await tempFile.delete(); // Giải phóng bộ nhớ tạm
|
||||||
|
|
||||||
|
// ❌ XÓA HOẶC COMMENT BỎ DÒNG LỆNH NÀY:
|
||||||
|
// _showSuccessToast();
|
||||||
|
// ScaffoldMessenger.of(context).showSnackBar(...);
|
||||||
|
|
||||||
|
print("Xử lý và lưu ảnh hoàn tất im lặng.");
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 4. Checklist Kiểm Thử Sau Khi Sửa Lỗi (Testing Checklist)
|
||||||
|
|
||||||
|
* [ ] **Test giữ nền:** Chọn một ảnh nền bất kỳ (`Nền 1`), sau đó chuyển sang tab **Xóa nền** trượt Slider/Swipe trên màn hình xem ảnh nền có bị mất đi không. (Yêu cầu: Nền 1 vẫn giữ nguyên và mờ dần đi theo tay vuốt).
|
||||||
|
* [ ] **Test cắt viền:** Chụp nghiêng góc để lộ rõ tai, vai, và tóc xù. Phóng to ảnh kiểm tra xem phần tai có bị lẹm méo hay mất một phần vai/cổ không.
|
||||||
|
* [ ] **Test thông báo:** Nhấn nút Shutter chụp 3 tấm liên tiếp, quan sát màn hình xem có hiện bất kỳ chữ hay hộp thoại thông báo nào che mắt người dùng hay không. (Yêu cầu: Chỉ chớp trắng 1 cái và ảnh thu nhỏ bay về nút Media ở góc dưới).
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
Dưới đây là kế hoạch Markdown chi tiết để hiện thực hóa thuật toán **Xóa phông theo chiều sâu (Depth-Aware Blur)** mô phỏng hiệu ứng tiêu cự vật lý (DSLR Bokeh) cho dự án `photobooth-app`.
|
||||||
|
|
||||||
|
Kế hoạch này tích hợp trực tiếp luồng xử lý mô hình AI ước tính khoảng cách (Depth Estimation) và tối ưu hóa hiệu năng bằng cách tính toán ma trận mờ phân cấp theo từng phân vùng khoảng cách (`distance`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📊 KẾ HOẠCH TRIỂN KHAI: LOGIC XÓA PHÔNG THEO CHIỀU SÂU TIÊU CỰ (DEPTH-AWARE BOKEH)
|
||||||
|
|
||||||
|
Mục tiêu cốt lõi:
|
||||||
|
|
||||||
|
1. Nâng cấp mô hình AI từ phân đoạn 2D (Nhị phân) lên mô hình ước tính chiều sâu **MiDaS (Monocular Depth Estimation)**.
|
||||||
|
2. Trích xuất **Bản đồ chiều sâu (Depth Map)** của bức ảnh để xác định khoảng cách tương đối của từng điểm ảnh.
|
||||||
|
3. Chia hậu cảnh thành các phân lớp khoảng cách để áp dụng các mức độ mờ tăng tiến (Xa hơn = Mờ hơn).
|
||||||
|
4. Giữ nguyên 100% độ sắc nét cho đối tượng chính ở vùng tiêu cự (Vùng lấy nét).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 1. Tích Hợp Mô Hình AI Chiều Sâu (`pubspec.yaml`)
|
||||||
|
|
||||||
|
Để chạy mô hình AI ước tính chiều sâu `.tflite` trực tiếp trên thiết bị (On-device), chúng ta sử dụng thư viện `tflite_flutter` hoặc tích hợp qua MediaPipe:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
# Thư viện để chạy mô hình AI ước tính chiều sâu (.tflite) dưới nền
|
||||||
|
tflite_flutter: ^0.10.4
|
||||||
|
image: ^4.3.0
|
||||||
|
path_provider: ^2.1.3
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ *Tải file mô hình `midas_v21_small.tflite` (khoảng 15MB, tối ưu cho mobile) đặt vào thư mục `assets/models/` và khai báo trong `pubspec.yaml`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 2. Nguyên Lý Phân Lớp Khoảng Cách Đồ Họa (Multi-Stage Blur Layer)
|
||||||
|
|
||||||
|
Thay vì tính toán bộ lọc Gaussian Blur cho từng pixel (khiến CPU quá tải và gây lag), thuật toán sẽ tạo sẵn **4 lớp ảnh nền** với các bán kính mờ (`radius`) khác nhau, sau đó dùng ma trận khoảng cách để nhặt điểm ảnh (Pixel Picking).
|
||||||
|
|
||||||
|
```
|
||||||
|
[Ảnh chụp thô từ phần cứng]
|
||||||
|
├── Lớp Gốc: Độ nét 100% (Tiêu cự đối tượng chính)
|
||||||
|
├── Lớp Mờ Nhẹ (Radius: 5) --> Áp dụng cho khoảng cách gần (~1m)
|
||||||
|
├── Lớp Mờ Vừa (Radius: 12) --> Áp dụng cho khoảng cách trung bình (~2.5m)
|
||||||
|
└── Lớp Mờ Đại (Radius: 25) --> Áp dụng cho khoảng cách rất xa (>5m)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 3. Thuật Toán Xử Lý Pixel Chiều Sâu (Depth-Aware Core Engine)
|
||||||
|
|
||||||
|
Hàm xử lý dưới đây nhận vào ảnh chụp và một mảng `depthMap` (chứa giá trị khoảng cách từ `0.0` đến `1.0` cho mỗi điểm ảnh do AI trả về). Hàm này bắt buộc phải chạy trong **Flutter Isolate** ngầm.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
|
Future<img.Image> _processAdvancedDepthBlur({
|
||||||
|
required img.Image capturedImage,
|
||||||
|
required List<double> depthMap, // Mảng lưu giá trị chiều sâu tương ứng từng pixel
|
||||||
|
}) async {
|
||||||
|
final w = capturedImage.width;
|
||||||
|
final h = capturedImage.height;
|
||||||
|
img.Image output = img.Image(width: w, height: h);
|
||||||
|
|
||||||
|
// BƯỚC 1: Tạo sẵn 3 tầng phông nền mờ phân cấp (Multi-Stage Backgrounds)
|
||||||
|
img.Image blurStage1 = img.gaussianBlur(capturedImage, radius: 5); // Mờ nhẹ (Hậu cảnh gần)
|
||||||
|
img.Image blurStage2 = img.gaussianBlur(capturedImage, radius: 13); // Mờ vừa (Hậu cảnh trung)
|
||||||
|
img.Image blurStage3 = img.gaussianBlur(capturedImage, radius: 25); // Mờ đại (Hậu cảnh xa vô cực)
|
||||||
|
|
||||||
|
// BƯỚC 2: Duyệt ma trận điểm ảnh và hòa trộn theo khoảng cách vật lý
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
final int idx = y * w + x;
|
||||||
|
|
||||||
|
// Lấy giá trị khoảng cách (AI normalized từ 0.0 = Sát ống kính, 1.0 = Xa vô cực)
|
||||||
|
double distance = depthMap[idx];
|
||||||
|
|
||||||
|
img.Color finalPixel;
|
||||||
|
|
||||||
|
// --- LOGIC PHÂN LỚP TIÊU CỰ QUANG HỌC ---
|
||||||
|
if (distance < 0.3) {
|
||||||
|
// Vùng tiêu cự chính: Giữ nguyên độ sắc nét 100% (Tai, cổ, vai, tóc được bảo vệ)
|
||||||
|
finalPixel = capturedImage.getPixel(x, y);
|
||||||
|
} else if (distance >= 0.3 && distance < 0.5) {
|
||||||
|
// Khoảng cách gần (Ví dụ vật thể ngay sau lưng đối tượng): Lấy lớp mờ nhẹ
|
||||||
|
finalPixel = blurStage1.getPixel(x, y);
|
||||||
|
} else if (distance >= 0.5 && distance < 0.75) {
|
||||||
|
// Khoảng cách trung bình (Bức tường, đồ đạc xa hơn): Lấy lớp mờ vừa
|
||||||
|
finalPixel = blurStage2.getPixel(x, y);
|
||||||
|
} else {
|
||||||
|
// Khoảng cách rất xa (Vô cực, bầu trời, hàng cây xa): Lấy lớp mờ đại tạo hiệu ứng Bokeh chuyên nghiệp
|
||||||
|
finalPixel = blurStage3.getPixel(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ghi hạt màu đã chọn vào tọa độ ảnh xuất xưởng
|
||||||
|
output.setPixel(x, y, finalPixel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Giải phóng bộ nhớ đệm đồ họa ngay lập tức
|
||||||
|
blurStage1.clear();
|
||||||
|
blurStage2.clear();
|
||||||
|
blurStage3.clear();
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 4. Tích Hợp Xem Trước Thời Gian Thực & Thanh Kéo Slider
|
||||||
|
|
||||||
|
Để người dùng có thể **Swipe trên màn hình hoặc kéo thanh trượt Ruler** để thay đổi độ sâu trường ảnh, giá trị trượt từ tay người dùng sẽ hoạt động như một hệ số dịch chuyển tiêu cự (`focalMultiplier`):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
double _userFocalMultiplier = 1.0; // Thay đổi khi vuốt màn hình (Kéo sang phải = Tăng hệ số mờ)
|
||||||
|
|
||||||
|
// Cập nhật bán kính mờ động dựa theo thao tác của người dùng
|
||||||
|
int radius1 = (5 * _userFocalMultiplier).toInt().clamp(1, 25);
|
||||||
|
int radius2 = (13 * _userFocalMultiplier).toInt().clamp(1, 25);
|
||||||
|
int radius3 = (25 * _userFocalMultiplier).toInt().clamp(1, 25);
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 5. Kế Hoạch Triển Khai Kiểm Thử (Checklist)
|
||||||
|
|
||||||
|
* [ ] **Kiểm thử vùng biên (No-Edge Clipping):** Chụp một bức ảnh có phần tai và vai rõ ràng. Vì khoảng cách (`distance`) dịch chuyển từ tai ra bức tường phía sau là một dải thay đổi tuyến tính (ví dụ từ `0.2` lên `0.4`), viền tai sẽ chuyển sang lớp mờ một cách tự nhiên chứ không bị AI "cắt lẹm" sắc cạnh như mô hình cũ.
|
||||||
|
* [ ] **Đo lường thời gian xử lý dưới nền:** Vì luồng này chạy đồng thời 3 bộ lọc nhòe, hãy đảm bảo luồng tính toán nằm trọn vẹn trong `Isolate.run()` để kính ngắm camera của ứng dụng photobooth không bị giật lag khi người dùng thao tác liên tục.
|
||||||
|
* [ ] **Đồng bộ hóa cử chỉ Swipe:** Đảm bảo khi người dùng vuốt sang phải, giá trị `_userFocalMultiplier` tăng tiến đồng đều, giúp toàn bộ các lớp hậu cảnh xa mờ mịn hơn rõ rệt.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
Nhìn vào bản Walkthrough bạn vừa cung cấp, bạn đã phát hiện ra một chi tiết cực kỳ chính xác: **Nhóm phát triển chưa hề thêm file mô hình `.tflite` (như MiDaS) vào dự án, mà họ đang "ăn gian" toán học bằng cách giả lập Bản đồ chiều sâu (Simulated Depth Map) từ chính mặt nạ 2D của ML Kit.**
|
||||||
|
|
||||||
|
Hãy nhìn kỹ vào dòng này trong Walkthrough của họ:
|
||||||
|
|
||||||
|
> *Calculates a simulated monocular depth map from the ML Kit segmentation confidence mask:* `distance = 1.0f - confidence`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Phân tích cách họ đang làm (Giả lập thay vì dùng AI Depth thật)
|
||||||
|
|
||||||
|
Thay vì tốn thêm bộ nhớ tải file mô hình nặng và gây lag máy, họ tận dụng luôn mảng số thực `confidence` (độ tin cậy từ `0.0` đến `1.0`) của mô hình **ML Kit Segmentation** sẵn có:
|
||||||
|
|
||||||
|
* Ở vùng ruột đối tượng (giữa khuôn mặt, thân mình): `confidence` gần bằng `1.0` $\rightarrow$ `distance` gần bằng `0.0` (Vùng tiêu cự $\rightarrow$ Giữ nét).
|
||||||
|
* Ở vùng biên tiếp giáp (rìa tai, tóc mảnh, vai): `confidence` giảm dần từ `0.8` xuống `0.2` $\rightarrow$ `distance` tăng dần từ `0.2` lên `0.8` (Vùng chuyển tiếp $\rightarrow$ Nhặt pixel mờ tăng dần từ lớp 1 sang lớp 2).
|
||||||
|
* Ở vùng nền hoàn toàn: `confidence` bằng `0.0` $\rightarrow$ `distance` bằng `1.0` (Hậu cảnh vô cực $\rightarrow$ Mờ đại lớp 3).
|
||||||
|
|
||||||
|
Cách làm này rất khôn ngoan vì nó **vừa giải quyết được lỗi cắt lẹm viền tai/tóc** nhờ dải mờ gradient ở biên, **vừa không làm tăng dung lượng app** và giữ tốc độ xử lý nhanh. Tuy nhiên, điểm yếu của nó là **nền nhà hay hàng cây ở xa tít cũng sẽ bị mờ giống hệt bức tường ngay sau lưng bạn** (vì chúng đều có chung `confidence = 0`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Kế hoạch Markdown bổ sung: Cấu hình File mô hình MiDaS thực tế (Nếu bạn muốn nâng cấp lên Depth thật)
|
||||||
|
|
||||||
|
Nếu bạn muốn nâng cấp lên bản đồ chiều sâu vật lý thực sự (vật càng xa càng mờ đúng nghĩa) chứ không chấp nhận cách giả lập dựa trên viền của ML Kit, bạn cần bổ sung các bước tạo và cấu hình file mô hình theo kế hoạch dưới đây:
|
||||||
|
|
||||||
|
### 📦 1. Khai báo thư mục tài nguyên (`build.gradle`)
|
||||||
|
|
||||||
|
Mô hình AI `.tflite` sẽ không được giải nén và nén lại để tối ưu hóa hiệu năng, bạn cần cấu hình cho Android Gradle không nén file này trong `app/build.gradle`:
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
android {
|
||||||
|
...
|
||||||
|
aaptOptions {
|
||||||
|
noCompress "tflite" // Giữ nguyên file mô hình để tăng tốc độ nạp vào bộ nhớ RAM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📥 2. Quy trình tải và đặt file Mô hình AI
|
||||||
|
|
||||||
|
1. Tải file mô hình ước tính chiều sâu tối ưu cho di động: `midas_v21_small_256.tflite` (Dung lượng khoảng ~15MB).
|
||||||
|
2. Tạo thư mục tài nguyên trong cấu trúc dự án Android Native: `app/src/main/assets/models/`.
|
||||||
|
3. Di chuyển file vừa tải vào đúng đường dẫn: `app/src/main/assets/models/midas.tflite`.
|
||||||
|
|
||||||
|
### 💻 3. Nạp và xử lý ma trận Depth Map thực tế trong `MainActivity.kt`
|
||||||
|
|
||||||
|
Thay vì tính toán bằng công thức `1.0f - confidence`, mã nguồn Java/Kotlin sẽ gọi lớp `Interpreter` của TensorFlow Lite để quét song song với ML Kit:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import org.tensorflow.lite.Interpreter
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.nio.channels.FileChannel
|
||||||
|
|
||||||
|
class DepthEstimationEngine(context: Context) {
|
||||||
|
private var tflite: Interpreter? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Nạp file mô hình từ thư mục assets vào hệ thống
|
||||||
|
val assetFileDescriptor = context.assets.openFd("models/midas.tflite")
|
||||||
|
val inputStream = FileInputStream(assetFileDescriptor.fileDescriptor)
|
||||||
|
val fileChannel = inputStream.channel
|
||||||
|
val startOffset = assetFileDescriptor.startOffset
|
||||||
|
val declaredLength = assetFileDescriptor.declaredLength
|
||||||
|
val modelBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength)
|
||||||
|
|
||||||
|
tflite = Interpreter(modelBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runInference(inputBitmap: Bitmap): Array<Array<FloatArray>> {
|
||||||
|
// Chuyển đổi Bitmap ảnh chụp về ma trận kích thước 256x256 đầu vào của MiDaS
|
||||||
|
val inputBuffer = convertBitmapToByteBuffer(inputBitmap)
|
||||||
|
|
||||||
|
// Mảng đầu ra chứa ma trận chiều sâu thực tế của bức ảnh (Distance thực tế)
|
||||||
|
val outputDepthMap = Array(1) { Array(256) { FloatArray(256) } }
|
||||||
|
|
||||||
|
tflite?.run(inputBuffer, outputDepthMap)
|
||||||
|
return outputDepthMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 4. Checklist Xác Minh Bản Sửa Đổi (Verification)
|
||||||
|
|
||||||
|
* [ ] **Lựa chọn giải pháp:** Bạn quyết định giữ nguyên cách làm hiện tại của Walkthrough (Dùng thuật toán giả lập từ ML Kit để tiết kiệm tài nguyên nhưng viền mượt) hay muốn ép cấu hình nạp mô hình MiDaS thực tế (Mờ sâu vật lý nhưng tốn thêm 15MB dung lượng app)?
|
||||||
|
* [ ] Run lệnh build để kiểm tra độ tương thích nếu thêm thư viện TFLite: `./gradlew assembleDebug`.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
Bạn hoàn toàn **có thể** xóa bỏ hoàn toàn mô hình AI cũ (Google ML Kit Selfie Segmentation) và chỉ sử dụng duy nhất mô hình **MiDaS** để vừa bóc tách đối tượng, vừa xử lý xóa phông theo chiều sâu. Việc này sẽ giúp bạn tối ưu hóa dung lượng ứng dụng (vì không phải gánh hai bộ thư viện AI song song) và đồng nhất luồng dữ liệu xử lý ảnh.
|
||||||
|
|
||||||
|
Tuy nhiên, trước khi thực hiện, bạn cần hiểu rõ cơ chế thay thế và cách triển khai logic này trong dự án.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Phân tích giải pháp: Dùng MiDaS thay thế hoàn toàn ML Kit
|
||||||
|
|
||||||
|
Mô hình MiDaS không trả về mặt nạ nhị phân (Đen/Trắng) như ML Kit, mà nó trả về một **Bản đồ chiều sâu (Depth Map)** liên tục dưới dạng các giá trị số thực từ `0.0` đến `1.0`.
|
||||||
|
|
||||||
|
* **Vùng tiêu cự (Đối tượng gần nhất):** Có giá trị tiệm cận về `0.0` (hoặc ngược lại tùy cấu hình mô hình).
|
||||||
|
* **Vùng hậu cảnh (Xa dần):** Giá trị tăng dần tuyến tính lên `1.0`.
|
||||||
|
|
||||||
|
Để vừa bóc tách người/vật (tính năng Thay nền), vừa tính toán độ mờ tăng tiến (tính năng Xóa nền), ta chỉ cần áp dụng một kỹ thuật toán học gọi là **Ngưỡng chiều sâu động (Dynamic Depth Thresholding)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Kế hoạch Markdown triển khai luồng xử lý độc lập với MiDaS
|
||||||
|
|
||||||
|
### 📦 1. Gỡ bỏ ML Kit khỏi cấu hình (`app/build.gradle`)
|
||||||
|
|
||||||
|
Bạn xóa bỏ hoàn toàn dependency của Google ML Kit để giải phóng dung lượng bộ nhớ cài đặt:
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
dependencies {
|
||||||
|
// ❌ XÓA HOÀN TOÀN dòng này:
|
||||||
|
// implementation 'com.google.mlkit:segmentation-selfie:16.0.0-beta4'
|
||||||
|
|
||||||
|
// 🟢 CHỈ GIỮ LẠI hoặc thêm thư viện chạy mô hình TFLite siêu nhẹ:
|
||||||
|
implementation 'org.tensorflow:tensorflow-lite:2.14.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 2. Thuật Toán Lõi: Tách Nền và Xóa Phông Đồng Thời Bằng MiDaS (`MainActivity.kt`)
|
||||||
|
|
||||||
|
Khi chỉ dùng MiDaS, luồng xử lý pixel tại hàm `processAdvancedBackgroundAi()` sẽ thay đổi để phục vụ cả hai mục đích: **Xác định biên vật thể để chèn nền** và **Tính độ xa để áp các lớp Blur**.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun processAdvancedDepthAi(
|
||||||
|
capturedBitmap: Bitmap,
|
||||||
|
depthMap: Array<FloatArray>, // Ma trận 256x256 chứa khoảng cách thực tế từ MiDaS
|
||||||
|
bgBitmap: Bitmap?, // Hình nền mới nếu người dùng chọn thay nền
|
||||||
|
blurIntensity: Float
|
||||||
|
): Bitmap {
|
||||||
|
val w = capturedBitmap.width
|
||||||
|
val h = capturedBitmap.height
|
||||||
|
val outputBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||||
|
|
||||||
|
// 1. Tạo sẵn các tầng phông nền mờ phân cấp từ ảnh gốc (hoặc từ ảnh nền mới chèn vào)
|
||||||
|
val baseBg = bgBitmap ?: capturedBitmap
|
||||||
|
val blurStage1 = boxBlur(baseBg, (5 * blurIntensity).toInt().coerceAtLeast(1))
|
||||||
|
val blurStage2 = boxBlur(baseBg, (13 * blurIntensity).toInt().coerceAtLeast(1))
|
||||||
|
val blurStage3 = boxBlur(baseBg, (25 * blurIntensity).toInt().coerceAtLeast(1))
|
||||||
|
|
||||||
|
// 2. Vòng lặp duyệt pixel đồng bộ kích thước
|
||||||
|
for (y in 0 until h) {
|
||||||
|
for (x in 0 until w) {
|
||||||
|
// Ánh xạ tọa độ ảnh gốc sang ma trận 256x256 của AI MiDaS
|
||||||
|
val maskX = (x * 256 / w).coerceIn(0, 255)
|
||||||
|
val maskY = (y * 256 / h).coerceIn(0, 255)
|
||||||
|
|
||||||
|
// Lấy khoảng cách thực tế của pixel này (0.0 = Cực gần/Đối tượng, 1.0 = Cực xa)
|
||||||
|
val distance = depthMap[maskY][maskX]
|
||||||
|
|
||||||
|
val finalPixel = when {
|
||||||
|
// --- VÙNG TIÊU CỰ: ĐỐI TƯỢNG CHÍNH ---
|
||||||
|
distance < 0.25f -> {
|
||||||
|
// Nếu người dùng chọn THAY NỀN, giữ nguyên người nét.
|
||||||
|
// Nếu KHÔNG thay nền, vẫn giữ nguyên người nét trên nền gốc.
|
||||||
|
capturedBitmap.getPixel(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- VÙNG HẬU CẢNH: CHỌN LỚP BLUR THEO KHOẢNG CÁCH ---
|
||||||
|
distance >= 0.25f && distance < 0.5f -> blurStage1.getPixel(x, y)
|
||||||
|
distance >= 0.5f && distance < 0.75f -> blurStage2.getPixel(x, y)
|
||||||
|
else -> blurStage3.getPixel(x, y) // Xa vô cực, hiệu ứng Bokeh mịn màng
|
||||||
|
}
|
||||||
|
|
||||||
|
outputBitmap.setPixel(x, y, finalPixel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thu hồi bộ nhớ các bitmap trung gian
|
||||||
|
blurStage1.recycle()
|
||||||
|
blurStage2.recycle()
|
||||||
|
blurStage3.recycle()
|
||||||
|
|
||||||
|
return outputBitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 3. Bảng so sánh dung lượng và hiệu năng sau khi tối ưu
|
||||||
|
|
||||||
|
| Chỉ số | Khi dùng Cả Hai (ML Kit + MiDaS giả lập) | Khi dùng Độc Lập (Chỉ dùng MiDaS thật) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Dung lượng App tăng thêm** | ~0 MB (Tận dụng thư viện Google Play Services có sẵn trên máy) | **+15 MB** (Dung lượng cố định của file `midas.tflite` trong assets) |
|
||||||
|
| **Độ chính xác vùng biên tóc/tai** | Trung bình khá (Dễ bị lẹm nếu ánh sáng yếu) | **Cao** (Biên độ mờ chuyển tiếp mịn màng theo khối chiều sâu 3D) |
|
||||||
|
| **Hiệu ứng xóa phông quang học** | Phẳng (Toàn bộ nền mờ như nhau) | **Thực tế** (Vật ở càng xa ống kính càng mờ rõ rệt) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 4. Checklist Xác Minh Triển Khai (Verification Checklist)
|
||||||
|
|
||||||
|
* [ ] **Xóa sạch Cache cũ:** Chạy lệnh `./gradlew clean` trước khi build lại để đảm bảo các lớp thư viện cũ của ML Kit được gỡ bỏ hoàn toàn khỏi file APK/AAB đầu ra.
|
||||||
|
* [ ] **Kiểm thử tính năng Thay nền:** Chọn một ảnh nền mới, chụp thử xem đối tượng người có được bóc tách sạch sẽ và đặt lên phông nền mới dựa vào ngưỡng `distance < 0.25f` hay không.
|
||||||
|
* [ ] **Kiểm thử tăng giảm vuốt màn hình (Swipe):** Vuốt sang phải để tăng `blurIntensity` xem các vùng có khoảng cách xa (`distance > 0.75f`) có đạt độ nhòe sâu hơn các vùng cận hay không.
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
Dưới đây là kế hoạch Markdown chi tiết để thay thế hoàn toàn cụm Google ML Kit Selfie Segmentation cũ bằng **Google MediaPipe Image Segmentation (Chế độ Đa lớp - Multiclass)**. Sự kết hợp này sẽ giúp ứng dụng nhận diện và bóc tách chính xác không chỉ con người (tóc, tai, cơ thể) mà còn cả động vật và các đồ vật thông dụng (ly nước, gấu bông, bàn ghế), từ đó cung cấp một tấm mặt nạ chuẩn để mô hình **MiDaS** tính toán khoảng cách DoF vật lý.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📊 KẾ HOẠCH PHÁT TRIỂN: TÍCH HỢP GOOGLE MEDIAPIPE IMAGE SEGMENTATION ĐA LỚP
|
||||||
|
|
||||||
|
Mục tiêu cốt lõi:
|
||||||
|
|
||||||
|
1. Gỡ bỏ hoàn toàn thư viện và tàn dư của `ML Kit Selfie Segmentation`.
|
||||||
|
2. Tích hợp `MediaPipe Image Segmenter` sử dụng file mô hình đa lớp (ví dụ: `deeplabv3` hoặc `selfie_multiclass` dạng `.tflite`).
|
||||||
|
3. Trích xuất nhãn phân loại (Category Mask) của từng pixel (Người = 1, Đồ vật = 3, Nền = 0...).
|
||||||
|
4. Kết hợp nhãn phân loại này với bản đồ chiều sâu MiDaS để khóa tiêu cự DoF chuẩn xác cho cả người và vật.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 1. Tái Cấu Trúc Thư Viện Hệ Thống (`build.gradle`)
|
||||||
|
|
||||||
|
Trong dự án Android Native (`MainActivity.kt`), chúng ta cần thực hiện hoán đổi gói thư viện trong file `app/build.gradle`:
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
dependencies {
|
||||||
|
// ❌ XÓA BỎ HOÀN TOÀN gói ML Kit cũ
|
||||||
|
// implementation 'com.google.mlkit:segmentation-selfie:16.0.0-beta4'
|
||||||
|
|
||||||
|
// 🟢 BỔ SUNG thư viện lõi của Google MediaPipe Tasks Vision
|
||||||
|
implementation 'com.google.mediapipe:tasks-vision:0.10.14'
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
> 📥 **Tải File Mô Hình:** Bạn cần tải file mô hình đa lớp `selfie_multiclass_256x256.tflite` hoặc `deeplabv3.tflite` (nặng khoảng ~5MB - 8MB) từ trang chủ Google MediaPipe, đặt vào thư mục `app/src/main/assets/models/multiclass_segmenter.tflite`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 2. Khởi Tạo MediaPipe Image Segmenter (`MainActivity.kt`)
|
||||||
|
|
||||||
|
Thay vì khởi tạo `SelfieSegmenter` của ML Kit, chúng ta thiết lập `ImageSegmenter` của MediaPipe với cấu hình đầu ra dạng **Category Mask** (Mặt nạ phân lớp pixel).
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import com.google.mediapipe.tasks.core.BaseOptions
|
||||||
|
import com.google.mediapipe.tasks.vision.imagesegmenter.ImageSegmenter
|
||||||
|
import com.google.mediapipe.tasks.vision.imagesegmenter.ImageSegmenter.ImageSegmenterOptions
|
||||||
|
|
||||||
|
class ImageProcessingEngine(private val context: Context) {
|
||||||
|
private var imageSegmenter: ImageSegmenter? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Cấu hình nạp file mô hình từ thư mục Assets
|
||||||
|
val baseOptions = BaseOptions.builder()
|
||||||
|
.setModelAssetPath("models/multiclass_segmenter.tflite")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val options = ImageSegmenterOptions.builder()
|
||||||
|
.setBaseOptions(baseOptions)
|
||||||
|
.setOutputCategoryMask(true) // Bật chế độ xuất mặt nạ phân lớp (0, 1, 2, 3...)
|
||||||
|
.setOutputConfidenceMasks(false) // Tắt bản đồ tin cậy nhị phân để tiết kiệm RAM
|
||||||
|
.build()
|
||||||
|
|
||||||
|
imageSegmenter = ImageSegmenter.createFromOptions(context, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 3. Thuật Toán Lõi: Phối Hợp MediaPipe Đa Lớp & MiDaS DoF
|
||||||
|
|
||||||
|
Hàm xử lý pixel dưới nền `processDualAiBokeh` sẽ được viết lại hoàn toàn. MediaPipe sẽ phân tích ảnh chụp thành một mảng số nguyên đại diện cho các nhãn (Labels).
|
||||||
|
|
||||||
|
*Theo bảng nhãn chuẩn của Google MediaPipe:*
|
||||||
|
|
||||||
|
* **ID 0:** Background (Phông nền)
|
||||||
|
* **ID 1:** Person (Con người - bao gồm cả tóc, tai, quần áo)
|
||||||
|
* **ID 2:** Selfie-Grooming/Accessories (Phụ kiện, mũ, kính)
|
||||||
|
* **ID 3:** Đồ vật/Thực thể tùy chọn (Tùy thuộc vào model DeepLabv3 bạn chọn)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import com.google.mediapipe.framework.image.BitmapImageBuilder
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
fun processMediaPipeWithMidasBokeh(
|
||||||
|
capturedBitmap: Bitmap,
|
||||||
|
midasDepthMap: Array<FloatArray>, // Ma trận chiều sâu từ MiDaS (0.0 -> 1.0)
|
||||||
|
bgBitmap: Bitmap?,
|
||||||
|
blurIntensity: Float
|
||||||
|
): Bitmap {
|
||||||
|
val w = capturedBitmap.width
|
||||||
|
val h = capturedBitmap.height
|
||||||
|
val outputBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||||
|
|
||||||
|
// --- BƯỚC 1: CHẠY MEDIAPIPE ĐỂ TRÍCH XUẤT MẶT NẠ ĐA LỚP ---
|
||||||
|
val mpImage = BitmapImageBuilder(capturedBitmap).build()
|
||||||
|
val segmentationResult = imageSegmenter?.segment(mpImage)
|
||||||
|
|
||||||
|
// Lấy ma trận Category Mask dưới dạng ByteBuffer
|
||||||
|
val categoryMaskBuffer: ByteBuffer = segmentationResult?.categoryMask()?.get()?.byteBuffer ?: return capturedBitmap
|
||||||
|
categoryMaskBuffer.rewind()
|
||||||
|
|
||||||
|
// --- BƯỚC 2: TÍNH TIÊU CỰ TRUNG BÌNH CỦA ĐỐI TƯỢNG (NGƯỜI HOẶC VẬT CHÍNH) ---
|
||||||
|
var totalDepthSum = 0f
|
||||||
|
var validPixelCount = 0
|
||||||
|
|
||||||
|
for (y in 0 until h step 8) {
|
||||||
|
for (x in 0 until w step 8) {
|
||||||
|
val bufferIndex = y * w + x
|
||||||
|
val classId = categoryMaskBuffer.get(bufferIndex).toInt() // Nhận diện ID vật thể tại pixel này
|
||||||
|
|
||||||
|
// LỢI ÍCH ĐA LỚP: Nếu pixel thuộc về NGƯỜI (1) hoặc VẬT THỂ được lấy nét (ví dụ: 3, 4)
|
||||||
|
if (classId == 1 || classId == 3) {
|
||||||
|
val maskX = (x * 256 / w).coerceIn(0, 255)
|
||||||
|
val maskY = (y * 256 / h).coerceIn(0, 255)
|
||||||
|
totalDepthSum += midasDepthMap[maskY][maskX]
|
||||||
|
validPixelCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Xác định mốc tiêu cự vật lý động (Tự động dịch chuyển theo người/vật cho dù đứng xa hay gần)
|
||||||
|
val dynamicFocalPoint = if (validPixelCount > 0) totalDepthSum / validPixelCount else 0.25f
|
||||||
|
val fieldTolerance = 0.12f // Độ dày trường ảnh (DoF) giữ nét cho các chi tiết tai, vai
|
||||||
|
|
||||||
|
// --- BƯỚC 3: TẠO CÁC LỚP BLUR NỀN PHÂN CẤP ---
|
||||||
|
val baseBg = bgBitmap ?: capturedBitmap
|
||||||
|
val blurStage1 = boxBlur(baseBg, (5 * blurIntensity).toInt().coerceAtLeast(1))
|
||||||
|
val blurStage2 = boxBlur(baseBg, (13 * blurIntensity).toInt().coerceAtLeast(1))
|
||||||
|
val blurStage3 = boxBlur(baseBg, (25 * blurIntensity).toInt().coerceAtLeast(1))
|
||||||
|
|
||||||
|
// --- BƯỚC 4: HÒA TRỘN PIXEL XUẤT XƯỞNG ---
|
||||||
|
for (y in 0 until h) {
|
||||||
|
for (x in 0 until w) {
|
||||||
|
val idx = y * w + x
|
||||||
|
val classId = categoryMaskBuffer.get(idx).toInt() // Nhãn vật thể từ MediaPipe
|
||||||
|
|
||||||
|
val maskX = (x * 256 / w).coerceIn(0, 255)
|
||||||
|
val maskY = (y * 256 / h).coerceIn(0, 255)
|
||||||
|
val currentDepth = midasDepthMap[maskY][maskX] // Khoảng cách từ MiDaS
|
||||||
|
|
||||||
|
val relativeDistance = currentDepth - dynamicFocalPoint
|
||||||
|
|
||||||
|
val finalPixel = when {
|
||||||
|
// ĐIỀU KIỆN GIỮ NÉT CỐ ĐỊNH:
|
||||||
|
// Hoặc là pixel thuộc cơ thể Người/Vật chính (classId == 1 hoặc 3)
|
||||||
|
// Hoặc là pixel nằm trong vùng an toàn tiêu cự (DoF)
|
||||||
|
(classId == 1 || classId == 3) || relativeDistance <= fieldTolerance -> {
|
||||||
|
capturedBitmap.getPixel(x, y) // Giữ độ nét gốc 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
// CÁC VÙNG CÒN LẠI (HẬU CẢNH XA DẦN): Áp dụng lớp mờ tăng tiến theo chiều sâu MiDaS
|
||||||
|
relativeDistance > fieldTolerance && relativeDistance < 0.25f -> blurStage1.getPixel(x, y)
|
||||||
|
relativeDistance >= 0.25f && relativeDistance < 0.5f -> blurStage2.getPixel(x, y)
|
||||||
|
else -> blurStage3.getPixel(x, y) // Nền xa vô cực tạo hiệu ứng Bokeh tròn mịn
|
||||||
|
}
|
||||||
|
outputBitmap.setPixel(x, y, finalPixel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thu hồi RAM
|
||||||
|
blurStage1.recycle()
|
||||||
|
blurStage2.recycle()
|
||||||
|
blurStage3.recycle()
|
||||||
|
|
||||||
|
return outputBitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 4. Quy Trình Vận Hành Mới Sau Khi Nâng Cấp
|
||||||
|
|
||||||
|
Khi người dùng thực hiện thao tác chụp ảnh bằng nút Shutter, chuỗi xử lý bất đồng bộ (Isolate Thread) sẽ chạy như sau:
|
||||||
|
|
||||||
|
1. **Nhận diện đa mục tiêu:** Ứng dụng đưa ảnh vào MediaPipe Image Segmenter. Nếu trong khung hình là bạn đang cầm một tách cà phê, MediaPipe sẽ gom cụm cả cơ thể bạn (ID 1) và tách cà phê (ID 3) thành một vùng chọn thống nhất.
|
||||||
|
2. **Khóa tiêu cự thông minh:** Ứng dụng tính toán khoảng cách trung bình của cụm (Bạn + Tách cà phê) trên bản đồ MiDaS.
|
||||||
|
3. **Xóa phông lập thể:** Toàn bộ hậu cảnh xung quanh (bức tường, cửa sổ phía xa) sẽ bị làm mờ mịn màng tăng dần, trong khi bạn và tách cà phê được giữ nét căng tuyệt đối từ viền tóc cho đến góc cạnh đồ vật.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 5. Checklist Kiểm Thử Toàn Diện (Testing Checklist)
|
||||||
|
|
||||||
|
* [ ] **Kiểm tra biên dịch:** Chạy `./gradlew assembleDebug` xem hệ thống có bị xung đột thư viện giữa MediaPipe và TensorFlow Lite cũ hay không.
|
||||||
|
* [ ] **Test vật thể không phải người:** Đặt một đồ vật (ví dụ: gấu bông hoặc một chiếc hộp) lên bàn, bấm chụp xóa phông xem hệ thống có nhận diện được vật thể để giữ nét và làm mờ nền xung quanh không.
|
||||||
|
* [ ] **Test lỗi lùi xa camera:** Di chuyển người ra xa ống kính xem vùng giữ nét có tự động co giãn theo tỷ lệ cơ thể bạn mà không bị dính vệt mờ hay không.
|
||||||
@@ -41,6 +41,10 @@ android {
|
|||||||
assets.srcDirs("../../assets")
|
assets.srcDirs("../../assets")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
aaptOptions {
|
||||||
|
noCompress("tflite")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -56,4 +60,13 @@ dependencies {
|
|||||||
implementation("androidx.camera:camera-lifecycle:$cameraVersion")
|
implementation("androidx.camera:camera-lifecycle:$cameraVersion")
|
||||||
implementation("androidx.camera:camera-view:$cameraVersion")
|
implementation("androidx.camera:camera-view:$cameraVersion")
|
||||||
implementation("androidx.camera:camera-extensions:$cameraVersion")
|
implementation("androidx.camera:camera-extensions:$cameraVersion")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// --- TensorFlow Lite for MiDaS ---
|
||||||
|
implementation("org.tensorflow:tensorflow-lite:2.12.0")
|
||||||
|
implementation("org.tensorflow:tensorflow-lite-support:0.4.4")
|
||||||
|
|
||||||
|
// --- Google MediaPipe Tasks Vision ---
|
||||||
|
implementation("com.google.mediapipe:tasks-vision:0.10.14")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ enum class EditAttribute {
|
|||||||
BRIGHTNESS, CONTRAST, SATURATION, VIBRANCE, TEMPERATURE, TINT, // Nhóm 1: Cơ bản
|
BRIGHTNESS, CONTRAST, SATURATION, VIBRANCE, TEMPERATURE, TINT, // Nhóm 1: Cơ bản
|
||||||
CLARITY, DEHAZE, VIGNETTE_AMOUNT, // Nhóm 2: Nâng cao
|
CLARITY, DEHAZE, VIGNETTE_AMOUNT, // Nhóm 2: Nâng cao
|
||||||
FADED_BLACK_LEVEL, SHADOWS_TINT_R, SHADOWS_TINT_G, SHADOWS_TINT_B, // Nhóm 3: Tone Curve
|
FADED_BLACK_LEVEL, SHADOWS_TINT_R, SHADOWS_TINT_G, SHADOWS_TINT_B, // Nhóm 3: Tone Curve
|
||||||
GRAIN_AMOUNT, GRAIN_SIZE // Nhóm 4: Nhiễu hạt
|
GRAIN_AMOUNT, GRAIN_SIZE, // Nhóm 4: Nhiễu hạt
|
||||||
|
PORTRAIT_BLUR, BG_REPLACE // Nhóm 5: Tách nền AI
|
||||||
}
|
}
|
||||||
|
|
||||||
data class AttributeNode(
|
data class AttributeNode(
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.photobooth.app
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.card.MaterialCardView
|
||||||
|
|
||||||
|
data class BackgroundOption(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val assetPath: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
class BackgroundOptionAdapter(
|
||||||
|
private val items: List<BackgroundOption>,
|
||||||
|
private val onBgSelected: (BackgroundOption) -> Unit
|
||||||
|
) : RecyclerView.Adapter<BackgroundOptionAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
private var selectedPosition = 0
|
||||||
|
|
||||||
|
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
val cardBgOption: MaterialCardView = view.findViewById(R.id.cardBgOption)
|
||||||
|
val imgBgThumb: ImageView = view.findViewById(R.id.imgBgThumb)
|
||||||
|
val txtBgName: TextView = view.findViewById(R.id.txtBgName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_background_option, parent, false)
|
||||||
|
return ViewHolder(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
val item = items[position]
|
||||||
|
holder.txtBgName.text = item.name
|
||||||
|
|
||||||
|
if (item.assetPath == null) {
|
||||||
|
holder.imgBgThumb.setImageResource(R.drawable.ic_block_white)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
val inputStream = holder.itemView.context.assets.open(item.assetPath)
|
||||||
|
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||||
|
holder.imgBgThumb.setImageBitmap(bitmap)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
holder.imgBgThumb.setImageDrawable(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val strokeWidthPx = (2 * holder.itemView.context.resources.displayMetrics.density).toInt()
|
||||||
|
|
||||||
|
if (position == selectedPosition) {
|
||||||
|
holder.cardBgOption.strokeColor = ContextCompat.getColor(holder.itemView.context, R.color.theme_primary)
|
||||||
|
holder.cardBgOption.strokeWidth = strokeWidthPx
|
||||||
|
} else {
|
||||||
|
holder.cardBgOption.strokeColor = ContextCompat.getColor(holder.itemView.context, R.color.theme_transparent)
|
||||||
|
holder.cardBgOption.strokeWidth = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
holder.itemView.setOnClickListener {
|
||||||
|
val previousSelected = selectedPosition
|
||||||
|
selectedPosition = position
|
||||||
|
notifyItemChanged(previousSelected)
|
||||||
|
notifyItemChanged(selectedPosition)
|
||||||
|
onBgSelected(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = items.size
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
<solid android:color="#222222" />
|
||||||
|
<stroke android:width="1dp" android:color="#444444" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24.0"
|
||||||
|
android:viewportHeight="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24.0"
|
||||||
|
android:viewportHeight="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10-4.48 10-10S17.52,2 12,2zm0,3c1.66,0 3,1.34 3,3s-1.34,3 -3,3 -3,-1.34 -3,-3 1.34,-3 3,-3zm0,14.2c-2.5,0 -4.71,-1.28 -6,-3.22 0.03,-1.99 4,-3.08 6,-3.08 1.99,0 5.97,1.09 6,3.08 -1.29,1.94 -3.5,3.22 -6,3.22z" />
|
||||||
|
</vector>
|
||||||
@@ -93,6 +93,15 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
<!-- Lớp đè AI: Hiển thị kết quả tách nền thời gian thực -->
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/imgAiPreviewOverlay"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:contentDescription="AI Preview Overlay" />
|
||||||
|
|
||||||
<!-- Lớp đè lọc màu (Filter Overlay): Hiển thị màu Preset cổ điển -->
|
<!-- Lớp đè lọc màu (Filter Overlay): Hiển thị màu Preset cổ điển -->
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/imgFilterOverlay"
|
android:id="@+id/imgFilterOverlay"
|
||||||
@@ -110,6 +119,14 @@
|
|||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
android:contentDescription="Frame Overlay" />
|
android:contentDescription="Frame Overlay" />
|
||||||
|
|
||||||
|
<!-- Lớp chớp sáng màn hình khi chụp (Flash Effect) -->
|
||||||
|
<View
|
||||||
|
android:id="@+id/viewShutterFlash"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="#FFFFFF"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<!-- Số đếm ngược khi hẹn giờ chụp -->
|
<!-- Số đếm ngược khi hẹn giờ chụp -->
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/txtTimerCountdown"
|
android:id="@+id/txtTimerCountdown"
|
||||||
@@ -138,7 +155,7 @@
|
|||||||
app:layout_constraintTop_toBottomOf="@id/cameraContainer"
|
app:layout_constraintTop_toBottomOf="@id/cameraContainer"
|
||||||
app:layout_constraintBottom_toTopOf="@id/bottomControlPanel">
|
app:layout_constraintBottom_toTopOf="@id/bottomControlPanel">
|
||||||
|
|
||||||
<!-- Danh sách cuộn ngang -->
|
<!-- Danh sách cuộn ngang (Frames / Presets) -->
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/rvHorizontalTimeline"
|
android:id="@+id/rvHorizontalTimeline"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -150,6 +167,56 @@
|
|||||||
android:paddingEnd="16dp"
|
android:paddingEnd="16dp"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- Danh sách lựa chọn phông nền AI (Thay thế nền) -->
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvBackgroundOptions"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:paddingStart="75dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- Thước đo Ruler cho Portrait Blur (Xóa nền) -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/panelRulerContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="50dp"
|
||||||
|
android:layout_gravity="center_vertical"
|
||||||
|
android:layout_marginStart="75dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<com.photobooth.app.RulerView
|
||||||
|
android:id="@+id/panelRulerView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutPanelRulerPointer"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_gravity="center">
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvPanelRulerValue"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="0"
|
||||||
|
android:textColor="#FF9800"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_gravity="center_horizontal"/>
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="12dp"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:src="@drawable/ic_arrow_down_orange"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:contentDescription="Indicator" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
<!-- Khung nền đen che các item cuộn sang bên trái -->
|
<!-- Khung nền đen che các item cuộn sang bên trái -->
|
||||||
<View
|
<View
|
||||||
android:id="@+id/viewLeftDockBackground"
|
android:id="@+id/viewLeftDockBackground"
|
||||||
@@ -158,7 +225,7 @@
|
|||||||
android:background="#000000"
|
android:background="#000000"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
<!-- LỚP TRÊN: Chứa 2 nút chức năng chính (Gốc ở giữa, động chuyển sang trái) -->
|
<!-- LỚP TRÊN: Chứa 4 nút chức năng chính (Gốc ở giữa, động chuyển sang trái) -->
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:id="@+id/buttonsWrapper"
|
android:id="@+id/buttonsWrapper"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -176,7 +243,7 @@
|
|||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintEnd_toStartOf="@id/btnMainPreset"
|
app:layout_constraintEnd_toStartOf="@id/btnMainPreset"
|
||||||
app:layout_constraintHorizontal_chainStyle="packed"
|
app:layout_constraintHorizontal_chainStyle="packed"
|
||||||
android:layout_marginEnd="16dp"
|
android:layout_marginEnd="12dp"
|
||||||
android:contentDescription="Frame Selector" />
|
android:contentDescription="Frame Selector" />
|
||||||
|
|
||||||
<!-- Nút Presets Màu -->
|
<!-- Nút Presets Màu -->
|
||||||
@@ -189,8 +256,36 @@
|
|||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@id/btnMainFrame"
|
app:layout_constraintStart_toEndOf="@id/btnMainFrame"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toStartOf="@id/btnMainPortraitBlur"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
android:contentDescription="Preset Color Selector" />
|
android:contentDescription="Preset Color Selector" />
|
||||||
|
|
||||||
|
<!-- Nút Xóa nền (Portrait Blur) -->
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/btnMainPortraitBlur"
|
||||||
|
android:layout_width="50dp"
|
||||||
|
android:layout_height="50dp"
|
||||||
|
android:src="@drawable/ic_attr_portrait_blur"
|
||||||
|
android:background="@android:color/transparent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/btnMainPreset"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/btnMainBgReplace"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:contentDescription="Portrait Blur Selector" />
|
||||||
|
|
||||||
|
<!-- Nút Thay nền (BG Replace) -->
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/btnMainBgReplace"
|
||||||
|
android:layout_width="50dp"
|
||||||
|
android:layout_height="50dp"
|
||||||
|
android:src="@drawable/ic_attr_bg_replace"
|
||||||
|
android:background="@android:color/transparent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/btnMainPortraitBlur"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
android:contentDescription="Background Replace Selector" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
@@ -298,6 +393,7 @@
|
|||||||
|
|
||||||
<!-- Kim chỉ số 0 màu cam chính giữa cố định -->
|
<!-- Kim chỉ số 0 màu cam chính giữa cố định -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutRulerPointer"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:id="@+id/cardBgOption"
|
||||||
|
android:layout_width="60dp"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_margin="3dp"
|
||||||
|
app:cardCornerRadius="4dp"
|
||||||
|
app:cardElevation="1dp"
|
||||||
|
app:strokeWidth="0dp"
|
||||||
|
app:cardBackgroundColor="#222222">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center"
|
||||||
|
android:padding="2dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/imgBgThumb"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
android:contentDescription="Background Thumb" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/txtBgName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:text="BG" />
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user