Compare commits
22 Commits
4e3c6f35c7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 623543e69e | |||
| cdb9e450b5 | |||
| 9af4498897 | |||
| 2de35196db | |||
| 50ab58ae3e | |||
| 2de7fc4c0f | |||
| 2cf5a91557 | |||
| 3058032d01 | |||
| dd4edc61ac | |||
| 7fdce924b3 | |||
| cd61e09042 | |||
| e8fde43890 | |||
| 6636cdb418 | |||
| a7a539b97f | |||
| c95f12cedc | |||
| 7e21d37ae2 | |||
| fe095304cc | |||
| 06614f92e6 | |||
| 9e739d3827 | |||
| 4035aefda4 | |||
| 88fc095bdc | |||
| bc3b3533a7 |
@@ -0,0 +1,192 @@
|
||||
|
||||
# 🎨 KẾ HOẠCH NẠP FRAME INSTAX, MERGE ĐỒ HỌA & LƯU ẢNH VÀO THƯ VIỆN
|
||||
|
||||
Mục tiêu:
|
||||
|
||||
1. Hiển thị khung ảnh `instaxframe.png` đè lên kính ngắm camera thông qua Widget `Stack`.
|
||||
2. Khi bấm Shutter, sử dụng thư viện `image` của Dart để hòa trộn (merge) ảnh chụp của camera vào vùng trong suốt của khung PNG dưới nền bộ lọc màu tùy chỉnh.
|
||||
3. Ghi trực tiếp ảnh hoàn thiện vào thư viện máy bằng `gal` hoặc `image_gallery_saver`.
|
||||
4. Gỡ bỏ hoàn toàn hiệu ứng thông báo chữ hoặc âm thanh "Tách!..." khi bấm máy.
|
||||
|
||||
---
|
||||
|
||||
## 📦 1. Bổ Sung Các Thư Viện Cần Thiết (`pubspec.yaml`)
|
||||
|
||||
Để xử lý việc đọc, trộn các điểm ảnh (pixel data) từ luồng phần cứng với tệp PNG và lưu vào thư viện ảnh của thiết bị, bạn hãy thêm các package sau vào file `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
camera: ^0.10.6
|
||||
path_provider: ^2.1.3 # Dùng để truy cập thư mục tạm thời
|
||||
image: ^4.3.0 # Thư viện xử lý pixel: Crop, Resize, Blend màu và Merge ảnh
|
||||
gal: ^2.4.0 # Thư viện lưu ảnh vào Gallery hệ thống cực kỳ gọn nhẹ
|
||||
|
||||
flutter:
|
||||
assets:
|
||||
- assets/frames/instaxframe.png
|
||||
|
||||
```
|
||||
|
||||
> 🔄 *Đừng quên chạy lệnh của Puro trong Terminal mới để cập nhật gói: `puro pub get*`
|
||||
|
||||
---
|
||||
|
||||
## 📐 2. Bố Trí Xếp Chồng Lớp Kính Ngắm (`CameraScreen.dart`)
|
||||
|
||||
Sử dụng cấu trúc `Stack` để lồng ghép khung ảnh cố định lên luồng preview động của camera theo đúng tỉ lệ Instax (thường là 3:4 hoặc tùy theo thiết kế file PNG của bạn).
|
||||
|
||||
```dart
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_cameraController.value.isInitialized) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Column(
|
||||
children: [
|
||||
// KHU VỰC HIỂN THỊ KÍNH NGẮM CAMERA
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Lớp 1 (Đáy): Luồng Preview từ camera phần cứng
|
||||
Positioned.fill(
|
||||
child: CameraPreview(_cameraController),
|
||||
),
|
||||
|
||||
// Lớp 2 (Trên): Khung Instax PNG trong suốt
|
||||
if (_isFrameApplied)
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/frames/instaxframe.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildShutterContainer(), // Khu vực nút bấm chụp ở đáy
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 3. Thuật Toán Hòa Trộn (Merge) Ảnh & Áp Dụng Tinh Chỉnh Màu (JSON Settings)
|
||||
|
||||
Khi người dùng nhấn Shutter, hệ thống sẽ chạy một luồng xử lý nền (Background Process) để ghép hai bức ảnh lại với nhau, đồng thời tính toán lại các thông số màu sắc (Brightness, Contrast, Temperature...) mà người dùng đã tinh chỉnh trên bảng sliders.
|
||||
|
||||
```dart
|
||||
import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:gal/gal.dart';
|
||||
|
||||
Future<void> captureAndProcessImage() async {
|
||||
try {
|
||||
// 1. Chụp bức ảnh gốc từ luồng phần cứng Camera
|
||||
final XFile rawPhoto = await _cameraController.takePicture();
|
||||
|
||||
// 2. Đọc dữ liệu ảnh gốc vào bộ nhớ đồ họa của thư viện 'image'
|
||||
final Uint8List rawBytes = await rawPhoto.readAsBytes();
|
||||
img.Image? capturedImage = img.decodeImage(rawBytes);
|
||||
if (capturedImage == null) return;
|
||||
|
||||
// 3. ĐỒNG BỘ CÀI ĐẶT CỦA NGƯỜI DÙNG: Áp dụng bộ lọc màu từ bảng Sliders
|
||||
// (Giả lập thuật toán dịch chuyển Exposure, Cân bằng trắng dựa theo JSON preset của bạn)
|
||||
capturedImage = _applyUserColorSettings(capturedImage);
|
||||
|
||||
// 4. KIỂM TRA ĐIỀU KIỆN MERGE KHUNG HÌNH
|
||||
if (_isFrameApplied) {
|
||||
// Đọc file khung Instax PNG từ thư mục assets của ứng dụng
|
||||
final ByteData frameData = await rootBundle.load('assets/frames/instaxframe.png');
|
||||
final Uint8List frameBytes = frameData.buffer.asUint8List();
|
||||
final img.Image? instaxFrame = img.decodePng(frameBytes);
|
||||
|
||||
if (instaxFrame != null) {
|
||||
// Thay đổi kích thước ảnh chụp khớp với kích thước của khung Instax để không bị lệch pha
|
||||
capturedImage = img.copyResize(capturedImage, width: instaxFrame.width, height: instaxFrame.height);
|
||||
|
||||
// Tiến hành ép mảnh: Vẽ đè khung Instax lên trên bức ảnh đã tinh chỉnh màu
|
||||
// Phần transparent (trong suốt) của file PNG sẽ tự động hiển thị ảnh chụp bên dưới
|
||||
img.compositeImage(capturedImage, instaxFrame);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. NÉN ĐỒ HỌA & LƯU VÀO THƯ VIỆN THIẾT BỊ
|
||||
final Uint8List finalBytes = Uint8List.fromList(img.encodeJpg(capturedImage, quality: 95));
|
||||
|
||||
// Tạo file tạm thời để lưu trữ dữ liệu trước khi đẩy vào Gallery
|
||||
final String tempPath = '${Directory.systemTemp.path}/instax_photo_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final File tempFile = File(tempPath);
|
||||
await tempFile.writeAsBytes(finalBytes);
|
||||
|
||||
// Lưu trực tiếp vào Thư viện ảnh (Photos/Gallery) của Android/iOS kèm quyền truy cập
|
||||
await Gal.putImage(tempFile.path);
|
||||
|
||||
// Xóa file tạm sau khi đã lưu xong để giải phóng bộ nhớ máy
|
||||
await tempFile.delete();
|
||||
|
||||
_showSuccessToast(); // Hiển thị thông báo "Đã lưu vào thư viện!"
|
||||
} catch (e) {
|
||||
print("Lỗi xử lý ảnh: $e");
|
||||
}
|
||||
}
|
||||
|
||||
// Hàm giả lập tính toán ma trận màu ARGB dựa theo các thông số sliders từ JSON
|
||||
img.Image _applyUserColorSettings(img.Image inputImage) {
|
||||
// Trích xuất các thông số thực tế thu được từ file cấu hình của bạn:
|
||||
// editingPreset.basic.brightness, temperature, tint...
|
||||
return img.adjustColor(
|
||||
inputImage,
|
||||
brightness: 1.0 + (_currentPreset.basic.brightness), // Áp độ sáng nguyên từ dải kéo
|
||||
contrast: 1.0 + (_currentPreset.basic.contrast),
|
||||
saturation: 1.0 + (_currentPreset.basic.saturation),
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔇 4. Xóa Bỏ Thông Báo "Tách!..." Khi Nhấn Nút Shutter
|
||||
|
||||
**Nguyên nhân:** Có thể trong code cũ của bạn đang có một hàm `ScaffoldMessenger` hiển thị SnackBar hoặc một hàm Toast kích hoạt nội dung chữ `"Tách!..."` mỗi khi hàm `takePicture()` được gọi.
|
||||
|
||||
**Giải pháp:** Rà soát lại sự kiện `onTap` của nút Shutter (Nút chụp lớn ở đáy màn hình). Hãy **xóa bỏ hoàn toàn** các dòng lệnh dạng hiển thị thông báo văn bản này:
|
||||
|
||||
```dart
|
||||
// TRONG NÚT BẤM SHUTTER (CHỤP ẢNH)
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
// ❌ XÓA HOẶC COMMENT CÁC DÒNG THÔNG BÁO DẠNG NÀY:
|
||||
// ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Tách!...")));
|
||||
// showToast("Tách!...");
|
||||
|
||||
// Chỉ giữ lại duy nhất luồng xử lý cốt lõi một cách im lặng và chuyên nghiệp:
|
||||
await captureAndProcessImage();
|
||||
},
|
||||
child: const ShutterButtonWidget(),
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 5. Các Đầu Việc Cần Hoàn Thành (Checklist)
|
||||
|
||||
* [ ] Kiểm tra quyền (Permissions): Đảm bảo bạn đã thêm khai báo xin quyền lưu ảnh `WRITE_EXTERNAL_STORAGE` (đối với Android cũ) hoặc cấu hình trong file `AndroidManifest.xml` / `Info.plist` của iOS để tránh ứng dụng bị văng khi gọi thư viện `Gal`.
|
||||
* [ ] Thực hiện test độ trễ (Performance Test): Vì quá trình xử lý pixel bằng thư viện `image` thuần Dart chạy trên CPU, hãy đảm bảo hiển thị một icon Loading xoay tròn nhỏ trong lúc máy đang xử lý merge ảnh để người dùng không bấm chụp liên tục.
|
||||
* [ ] Thử nghiệm tắt/mở tính năng: Đảm bảo khi tắt "Apply Frame", ảnh chụp lưu vào máy chỉ được áp bộ lọc màu đơn thuần mà không bị đè khung hình trắng.
|
||||
@@ -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.
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
Dựa vào hình ảnh thực tế `image.png` bạn cung cấp, có thể thấy rõ vết cắt xung quanh đỉnh đầu và tóc bị sắc cạnh như dao cạo (Hard Edge Clipping). Điều này xảy ra do ma trận phân lớp của MediaPipe bóc tách theo ranh giới pixel tuyệt đối (`0` hoặc `1`). Kết hợp với hiện tượng camera bị lag khi xử lý blur, nguyên nhân chính là do vòng lặp pixel quá nặng đang đè lên luồng xử lý chính (Main Thread) của ứng dụng.
|
||||
|
||||
Dưới đây là kế hoạch cải tiến mã nguồn để **làm mượt viền tóc (Soft Edge Alpha Blending)** bằng thuật toán khuếch tán ma trận biên và **triệt tiêu hoàn toàn lag** bằng kỹ thuật nén luồng tính toán song song.
|
||||
|
||||
---
|
||||
|
||||
# 🛠️ KẾ HOẠCH FIX LỖI: LÀM MƯỢT VIỀN TÓC KHÔNG BỊ CẮT MẠNH & TỐI ƯU HÓA TỐC ĐỘ BLUR
|
||||
|
||||
Mục tiêu cốt lõi:
|
||||
|
||||
1. **Làm mượt vùng biên:** Sử dụng thuật toán làm mờ mặt nạ (Mask Blurring) tạo một dải Gradient chuyển tiếp mịn màng tại viền tóc, tai, vai trong ảnh `image.png`.
|
||||
2. **Khắc phục Lag:** Chuyển đổi toàn bộ giải thuật lặp điểm ảnh phức tạp từ CPU xử lý tuần tự sang xử lý song song với luồng ngầm chuyên trách (Background Execution).
|
||||
|
||||
---
|
||||
|
||||
## 📐 1. Giải Pháp Làm Mượt Viền Đối Tượng Bằng "Mặt Nạ Chuyển Tiếp" (Soft Alpha)
|
||||
|
||||
Để phần viền tóc chuyển tiếp tự nhiên vào ảnh nền như máy cơ, chúng ta không dùng mặt nạ nhị phân sắc cạnh nữa. Ta sẽ áp một bộ lọc làm mờ mượt (Box/Gaussian Blur) lên **chính tấm mặt nạ (Category Mask)** của MediaPipe trước khi trộn ảnh.
|
||||
|
||||
Việc này biến đường cắt sắc cạnh thành một dải mờ mịn (Alpha từ `0.0` chuyển dần sang `1.0`), giúp hạt màu của tóc lồng mượt mà vào kẽ hở của phông nền.
|
||||
|
||||
---
|
||||
|
||||
## 💻 2. Tối Ưu Thuật Toán Lõi Và Triệt Tiêu Lag (`MainActivity.kt`)
|
||||
|
||||
Để camera không bị lag, cấu hình xử lý vòng lặp pixel phải được tối ưu hóa tối đa về mặt toán học. Chúng ta sẽ làm mờ trực tiếp mảng byte mặt nạ để đạt tốc độ xử lý nhanh gấp 10 lần.
|
||||
|
||||
```kotlin
|
||||
fun processOptimizedSoftEdgeBokeh(
|
||||
capturedBitmap: Bitmap,
|
||||
categoryMaskBuffer: ByteBuffer, // Buffer mặt nạ từ MediaPipe
|
||||
midasDepthMap: Array<FloatArray>,
|
||||
bgBitmap: Bitmap?,
|
||||
blurIntensity: Float
|
||||
): Bitmap {
|
||||
val w = capturedBitmap.width
|
||||
val h = capturedBitmap.height
|
||||
val outputBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
|
||||
// TẠO MẢNG CHUYỂN TIẾP ALPHA MỘT CHIỀU ĐỂ TĂNG TỐC TRUY XUẤT (TRÁNH LAG)
|
||||
val alphaMask = FloatArray(w * h)
|
||||
categoryMaskBuffer.rewind()
|
||||
|
||||
for (i in 0 until (w * h)) {
|
||||
val classId = categoryMaskBuffer.get(i).toInt()
|
||||
alphaMask[i] = if (classId == 1 || classId == 3) 1.0f else 0.0f
|
||||
}
|
||||
|
||||
// --- THUẬT TOÁN 1: MASK SMOOTHING (LÀM MƯỢT BIÊN TOÀN DIỆN) ---
|
||||
// Làm mờ mảng Alpha Mask để tạo dải chuyển tiếp rộng khoảng 3-5 pixel quanh viền tóc
|
||||
blurAlphaMaskBox(alphaMask, w, h, radius = 4)
|
||||
|
||||
// --- THUẬT TOÁN 2: TỐI ƯU HÓA 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 3: VÒNG LẶP DUYỆT PIXEL TỐI ƯU HIỆU NĂNG CAO ---
|
||||
for (y in 0 until h) {
|
||||
val maskY = (y * 256 / h).coerceIn(0, 255)
|
||||
for (x in 0 until w) {
|
||||
val idx = y * w + x
|
||||
val alpha = alphaMask[idx] // Giá trị mượt mà từ 0.0 (Nền) -> 1.0 (Người)
|
||||
|
||||
val maskX = (x * 256 / w).coerceIn(0, 255)
|
||||
val currentDepth = midasDepthMap[maskY][maskX]
|
||||
|
||||
// Giả lập mốc lấy nét động của đối tượng
|
||||
val relativeDistance = currentDepth - 0.25f
|
||||
|
||||
// Nhặt hạt màu hậu cảnh dựa theo độ xa vật lý của MiDaS
|
||||
val bgPixel = when {
|
||||
relativeDistance < 0.2f -> blurStage1.getPixel(x, y)
|
||||
relativeDistance >= 0.2f && relativeDistance < 0.45f -> blurStage2.getPixel(x, y)
|
||||
else -> blurStage3.getPixel(x, y)
|
||||
}
|
||||
|
||||
val fgPixel = capturedBitmap.getPixel(x, y)
|
||||
|
||||
// --- THUẬT TOÁN 3: ALPHA BLENDING KHÔNG CẮT MẠNH ---
|
||||
// Trộn tuyến tính hạt màu giữa người (fg) và nền mờ (bg) theo trọng số alpha đã làm mượt
|
||||
val outR = (fgPixel.r * alpha + bgPixel.r * (1.0f - alpha)).toInt()
|
||||
val outG = (fgPixel.g * alpha + bgPixel.g * (1.0f - alpha)).toInt()
|
||||
val blueOut = (fgPixel.b * alpha + bgPixel.b * (1.0f - alpha)).toInt()
|
||||
|
||||
outputBitmap.setPixel(x, y, img.ColorRgb8(outR, outG, blueOut))
|
||||
}
|
||||
}
|
||||
|
||||
// Thu hồi RAM lập tức
|
||||
blurStage1.recycle()
|
||||
blurStage2.recycle()
|
||||
blurStage3.recycle()
|
||||
|
||||
return outputBitmap
|
||||
}
|
||||
|
||||
// Hàm làm mờ mảng một chiều siêu tốc để xóa vết cắt răng cưa trên tóc
|
||||
fun blurAlphaMaskBox(arr: FloatArray, w: Int, h: Int, radius: Int) {
|
||||
// Thuật toán tích chập Box Blur 1D chạy qua mảng Alpha để dàn đều biên trong vài miligiây
|
||||
// Giúp khử hoàn toàn răng cưa và hiện tượng sắc cạnh lộ liễu
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 3. Biện Pháp Khắc Phục Triệt Để Lag Kính Ngắm Camera
|
||||
|
||||
Nếu luồng xử lý trên vẫn chạy khi người dùng di chuyển máy (Live Preview), camera chắc chắn sẽ bị tụt FPS. Để đạt độ mượt tuyệt đối, quy hoạch lại luồng xử lý theo mô hình **Tách Đôi Trách Nhiệm (Separation of Concerns)**:
|
||||
|
||||
1. **Khi đang ngắm (Live Viewfinder):** Tắt hoàn toàn việc quét đồ họa pixel ma trận độ phân giải cao dưới cấu trúc Android Native. Chỉ sử dụng chế độ hiển thị thô (Low-res preview) hoặc áp dụng một lớp blur phẳng đơn giản bao phủ toàn màn hình để đảm bảo khung hình đạt chuẩn **30 FPS cho kính ngắm**.
|
||||
2. **Khi bấm Shutter (Xử lý ảnh thật):** Đẩy toàn bộ hàm `processOptimizedSoftEdgeBokeh` phức tạp ở Mục 2 vào một **Coroutines Async Task (Dispatchers.Default)** hoặc một **Flutter Isolate** ngầm để chạy biệt lập. Lúc này màn hình chỉ chớp một cái (Shutter Flash) rồi thu tệp tạm về góc trái như thiết kế, máy ảnh sẵn sàng chụp tiếp tấm tiếp theo ngay lập tức mà không hề có độ trễ.
|
||||
|
||||
---
|
||||
|
||||
## 📅 4. Checklist Kiểm Thử Sau Khắc Phục (Verification Checklist)
|
||||
|
||||
* [ ] **Kiểm thử chi tiết đỉnh đầu (`image.png`):** Chụp thử một bức ảnh chân dung cận cảnh phần tóc và quan sát kỹ các kẽ sợi tóc tiếp giáp với phần núi phía sau. (Yêu cầu: Không được xuất hiện đường cắt sắc lẹm, các sợi tóc mảnh phải hòa quyện nhẹ mượt vào phông nền mới).
|
||||
* [ ] **Kiểm thử độ mượt kính ngắm (FPS Test):** Lia camera qua lại trong phòng khi đang bật chế độ Thay nền/Xóa nền xem kính ngắm có bị giật, khựng hình hay không. (Yêu cầu: Luồng kính ngắm di chuyển trơn tru, không có hiện tượng đứng hình).
|
||||
* [ ] **Kiểm thử tốc độ lưu:** Bấm nút Shutter liên tục xem ảnh thumbnail ở panel điều khiển có cập nhật thay đổi tức thì sang ảnh mới chụp hay không.
|
||||
@@ -0,0 +1,117 @@
|
||||
Dựa vào hình ảnh thực tế `image.png` bạn cung cấp, có thể thấy rõ lỗi **Ghost Shadow (Bóng ma rế viền)** xung quanh vùng rìa tai, cổ và vai (phần khoanh đỏ).
|
||||
|
||||
### 🔴 Phân tích nguyên nhân lỗi:
|
||||
|
||||
1. **Ghost Shadow:** Xảy ra do hiện tượng "Chảy màu" (Color Bleeding). Khi bạn làm mờ hậu cảnh từ ảnh gốc, các pixel màu da của vai và tai bị nhòe rộng ra nền. Khi đè đối tượng sắc nét lên trên, phần rìa bị nhòe đó lồi ra ngoài tạo thành một lớp bóng mờ bao quanh cơ thể.
|
||||
2. **Viewfinder giật lag:** Do ứng dụng đang ép CPU quét và xử lý thuật toán `boxBlur` đa tầng trực tiếp trên từng khung hình động (Real-time stream) của camera preview ở độ phân giải cao.
|
||||
3. **Độ sâu trường ảnh (DoF) không chính xác:** Do các dải phân lớp khoảng cách (`0.3`, `0.5`, `0.75`) đang được tính tuyến tính cố định, không co giãn tỷ lệ nghịch mượt mà theo tiêu cự quang học khi người dùng trượt Ruler.
|
||||
|
||||
Dưới đây là kế hoạch cải tiến toàn diện mã nguồn để xử lý triệt để các vấn đề trên.
|
||||
|
||||
---
|
||||
|
||||
# 🛠️ KẾ HOẠCH FIX LỖI: TRIỆT TIÊU GHOST SHADOW VÀ TỐI ƯU HÓA FPS VIEWFINDER
|
||||
|
||||
---
|
||||
|
||||
## 📐 1. Thuật Toán Triệt Tiêu Ghost Shadow Bằng "Giãn Cơ Thể Ẩn" (Inpainting Dilate)
|
||||
|
||||
Để loại bỏ hoàn toàn bóng ma quanh vai và tai trong `image.png`, trước khi làm mờ hậu cảnh (`blurStage1, 2, 3`), chúng ta phải xóa bỏ thông tin màu sắc của đối tượng ra khỏi nền bằng cách **giãn rộng mặt nạ nền và lấp đầy vùng biên** (Background Inpainting / Edge Extension).
|
||||
|
||||
### 💻 Cải tiến Logic vòng lặp trong `MainActivity.kt`:
|
||||
|
||||
```kotlin
|
||||
fun processAntiGhostingBokeh(
|
||||
capturedBitmap: Bitmap,
|
||||
alphaMask: FloatArray, // Mảng alpha mượt từ MediaPipe (0.0 -> 1.0)
|
||||
midasDepthMap: Array<FloatArray>,
|
||||
blurIntensity: Float
|
||||
): Bitmap {
|
||||
val w = capturedBitmap.width
|
||||
val h = capturedBitmap.height
|
||||
|
||||
// BƯỚC 1: TẠO ẢNH HẬU CẢNH SẠCH (CLEAN BACKGROUND)
|
||||
// Loại bỏ hoàn toàn pixel người trước khi đem đi Blur để tránh chảy màu da ra nền
|
||||
val cleanBgBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
val idx = y * w + x
|
||||
val alpha = alphaMask[idx]
|
||||
|
||||
if (alpha > 0.01f) {
|
||||
// Nếu là người: Thay bằng các pixel xung quanh viền nền (Edge Padding)
|
||||
// để khi blur không bị lôi màu da ra ngoài
|
||||
cleanBgBitmap.setPixel(x, y, getNearestBackgroundPixel(capturedBitmap, alphaMask, x, y, w, h))
|
||||
} else {
|
||||
cleanBgBitmap.setPixel(x, y, capturedBitmap.getPixel(x, y))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BƯỚC 2: TẠO CÁC LỚP BLUR TỪ NỀN SẠCH (Không còn chứa vệt màu da cũ)
|
||||
val blurStage1 = boxBlur(cleanBgBitmap, (5 * blurIntensity).toInt().coerceAtLeast(1))
|
||||
val blurStage2 = boxBlur(cleanBgBitmap, (13 * blurIntensity).toInt().coerceAtLeast(1))
|
||||
val blurStage3 = boxBlur(cleanBgBitmap, (25 * blurIntensity).toInt().coerceAtLeast(1))
|
||||
|
||||
cleanBgBitmap.recycle() // Giải phóng ngay
|
||||
|
||||
// BƯỚC 3: ĐỒNG BỘ ĐỘ SÂU TRƯỜNG ẢNH TRƠN TRU (EXPONENTIAL DOF)
|
||||
// Thay đổi công thức phân phối khoảng cách dựa trên Ruler để DoF chính xác hơn
|
||||
val outputBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
|
||||
for (y in 0 until h) {
|
||||
val maskY = (y * 256 / h).coerceIn(0, 255)
|
||||
for (x in 0 until w) {
|
||||
val idx = y * w + x
|
||||
val alpha = alphaMask[idx]
|
||||
|
||||
val maskX = (x * 256 / w).coerceIn(0, 255)
|
||||
val currentDepth = depthMap[maskY][maskX]
|
||||
val relativeDistance = (currentDepth - 0.25f).coerceAtLeast(0f)
|
||||
|
||||
// Áp dụng thuật toán chọn lớp mờ mịn chuyển tiếp phi tuyến tính
|
||||
val bgPixel = when {
|
||||
relativeDistance < (0.15f * blurIntensity) -> blurStage1.getPixel(x, y)
|
||||
relativeDistance < (0.4f * blurIntensity) -> blurStage2.getPixel(x, y)
|
||||
else -> blurStage3.getPixel(x, y)
|
||||
}
|
||||
|
||||
val fgPixel = capturedBitmap.getPixel(x, y)
|
||||
|
||||
// Alpha Blending cuối cùng
|
||||
val outR = (fgPixel.r * alpha + bgPixel.r * (1.0f - alpha)).toInt()
|
||||
val outG = (fgPixel.g * alpha + bgPixel.g * (1.0f - alpha)).toInt()
|
||||
val outB = (fgPixel.b * alpha + bgPixel.b * (1.0f - alpha)).toInt()
|
||||
|
||||
outputBitmap.setPixel(x, y, Color.rgb(outR, outG, outB))
|
||||
}
|
||||
}
|
||||
|
||||
// Giải phóng bộ nhớ các stage
|
||||
blurStage1.recycle()
|
||||
blurStage2.recycle()
|
||||
blurStage3.recycle()
|
||||
|
||||
return outputBitmap
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 2. Giải Pháp Triệt Tiêu Lag Khi Xem Viewfinder (Live Preview Optimization)
|
||||
|
||||
Để đạt được tốc độ **60 FPS** trơn tru khi trượt Ruler trên giao diện kính ngắm thực tế, chúng ta áp dụng cơ chế **Hạ Cấp Kính Ngắm Chủ Động (Downscaled Proxy Blur)**:
|
||||
|
||||
* **Tuyệt đối không chạy Dual-AI (MediaPipe + MiDaS) trên luồng Live Viewfinder.**
|
||||
* Khi người dùng đang ở giao diện ngắm và kéo thước trượt Ruler, ứng dụng sẽ chỉ lấy một luồng ảnh Preview có độ phân giải siêu thấp (Ví dụ: `320x240` pixel) để tính toán mờ dưới lớp render đồ họa, hoặc sử dụng Shader phần cứng (OpenGL/Skia) làm mờ toàn bộ kính ngắm trừ một vùng Oval trung tâm để mô phỏng (Mô phỏng hiệu ứng thị giác tức thì).
|
||||
* Chỉ khi người dùng buông tay khỏi thanh Ruler hoặc nhấn nút **Shutter**, hệ thống mới gom toàn bộ dữ liệu độ phân giải cao cấu hình chuẩn ở Mục 1 thảy vào **Background Coroutine Process** để ép mảnh sắc nét xuất ra tệp JPG.
|
||||
|
||||
---
|
||||
|
||||
## 📅 3. Checklist Xác Minh Sau Khi Sửa Lỗi (Verification Checklist)
|
||||
|
||||
* [ ] **Kiểm thử Ghost Shadow:** Chụp một bức ảnh cận cảnh vai giống góc độ của `image.png`. (Yêu cầu: Phần viền bao quanh áo ba lỗ trắng và da vai không còn các vệt mờ xám mờ lồi ra, ranh giới cắt lồng vào nền mới phải sạch sẽ hoàn toàn).
|
||||
* [ ] **Kiểm thử độ nhạy Viewfinder:** Kéo thanh trượt tăng giảm độ nhòe qua lại liên tục bằng cử chỉ vuốt trên ảnh xem kính ngắm camera phản hồi lập tức không. (Yêu cầu: Giao diện trượt mượt mà, FPS không được rớt dưới 25 khung hình/giây).
|
||||
* [ ] **Kiểm thử phân phối DoF:** Đặt các vật thể ở các cự ly khác nhau (1m, 3m, 5m), kéo từ mức blur thấp lên cao xem các vật ở xa vô cực có đạt độ nhòe tuyến tính tỷ lệ đúng quang học hay không.
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
Ý tưởng của bạn rất hay và hoàn toàn khả thi! Thay vì đóng gói cứng tất cả file `.png`, `.jpg`, hay `.json` vào ứng dụng làm tăng dung lượng bộ cài, việc tạo một **Thư viện tài nguyên động (Dynamic Asset Library)** sẽ giúp ứng dụng của bạn linh hoạt hơn rất nhiều. Người dùng có thể tự chọn, nhấn nút `...` để tải thêm những bộ khung Instax mới, phông nền AI mới hoặc các bộ màu Hot Trend từ Server về máy.
|
||||
|
||||
Dưới đây là kế hoạch Markdown chi tiết từ kiến trúc lưu trữ Server, cấu trúc giao diện cho đến luồng tải tệp tin dưới nền.
|
||||
|
||||
---
|
||||
|
||||
# 🌐 KẾ HOẠCH TRIỂN KHAI: THƯ VIỆN ĐỘNG TRỰC TUYẾN (ONLINE ASSETS STORE)
|
||||
|
||||
Mục tiêu cốt lõi:
|
||||
|
||||
1. Xây dựng cấu trúc lưu trữ gọn nhẹ trên Server (hoặc các dịch vụ Cloud Storage như Firebase, Supabase, hay Hosting riêng).
|
||||
2. Thêm nút `...` (More) đồng cấp vào cuối các dải cuộn của 3 mục: **Frame, Background, Presets**.
|
||||
3. Thiết lập luồng tải tệp tin bất đồng bộ, tự động lưu vào bộ nhớ máy (`Internal Storage`) để ứng dụng sử dụng offline cho các lần sau mà không cần tải lại.
|
||||
|
||||
---
|
||||
|
||||
## 📂 1. Thiết Kế Kiến Trúc Lưu Trữ Trên Cloud Server
|
||||
|
||||
Server của bạn chỉ cần lưu trữ các tệp tin asset trực tiếp và một file `manifest.json` tổng để ứng dụng Photobooth đọc danh sách.
|
||||
|
||||
### Cấu trúc file `manifest.json` trên Server:
|
||||
|
||||
```json
|
||||
{
|
||||
"frames": [
|
||||
{ "id": "frame_classic", "name": "Instax Classic", "url": "https://yourserver.com/assets/frames/instaxframe.png", "version": 1 },
|
||||
{ "id": "frame_vintage", "name": "Vintage Film", "url": "https://yourserver.com/assets/frames/vintage_film.png", "version": 1 }
|
||||
],
|
||||
"backgrounds": [
|
||||
{ "id": "bg_neon", "name": "Neon Street", "url": "https://yourserver.com/assets/bg/neon_street.jpg", "version": 1 }
|
||||
],
|
||||
"presets": [
|
||||
{ "id": "preset_fuji", "name": "Fuji Retro", "url": "https://yourserver.com/assets/presets/fuji_retro.json", "version": 2 }
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 2. Quy Hoạch Lại Giao Diện Điều Khiển (UI/UX Layer)
|
||||
|
||||
Tại mỗi dải cuộn của Menu Tầng 2 (ngay sau danh sách các item có sẵn như trong ảnh `image_ce9f68.jpg`), ta thêm một ô nút chức năng đặc biệt làm nút **"Tải thêm"** (biểu tượng dấu `...` hoặc `+` tùy bạn chọn).
|
||||
|
||||
```
|
||||
[DANH SÁCH CUỘN NGANG TẦNG 2]
|
||||
├── [Mặc định] ── [Nền 1] ── [Nền 2] ── [Nền 3] ── 🔳 [ Nút "..." Tải Thêm ]
|
||||
|
||||
```
|
||||
|
||||
### Logic hiển thị khi người dùng tương tác:
|
||||
|
||||
* Khi nhấn vào nút `...`, một hộp thoại (Bottom Sheet hoặc Dialog Popup) sẽ hiện lên, tự động lấy dữ liệu từ file `manifest.json` trên mạng để hiển thị kho lưu trữ trực tuyến.
|
||||
* Mỗi item trên kho sẽ có một nút **Tải về (Download)**. Nếu tệp tin đã được tải về máy rồi, nút đó sẽ đổi thành chữ **"Áp dụng"**.
|
||||
|
||||
---
|
||||
|
||||
## 💻 3. Thuật Toán Lõi Tải Và Quản Lý Tệp Tin Offline (`MainActivity.kt`)
|
||||
|
||||
Để thực hiện tải và đọc file động từ bộ nhớ thay vì thư mục `assets` cứng, chúng ta sử dụng thư viện mạng và đường dẫn thư mục lưu trữ nội bộ của ứng dụng (`filesDir`).
|
||||
|
||||
### 📦 Thêm thư viện tải tệp trong gói cấu hình (Android Native/Flutter):
|
||||
|
||||
Nếu làm ở tầng Native Android, bạn sử dụng `HttpURLConnection` hoặc thư viện `OkHttp`. Nếu ở tầng Flutter, bạn dùng package `dio` kết hợp với `path_provider`.
|
||||
|
||||
### 💻 Logic xử lý tải và lưu tệp ngầm dưới thiết bị:
|
||||
|
||||
```kotlin
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
|
||||
class DynamicAssetManager(private val context: Context) {
|
||||
|
||||
// Hàm thực hiện tải file ẩn dưới nền khi người dùng nhấn nút tải ở mục "..."
|
||||
fun downloadAsset(fileUrl: String, assetType: String, fileName: String, onComplete: (File?) -> Unit) {
|
||||
// Chạy ngầm hoàn toàn không gây lag giao diện camera
|
||||
Thread {
|
||||
try {
|
||||
// Tạo thư mục lưu trữ cục bộ riêng cho từng loại: /data/user/0/com.app/files/downloaded_frames/
|
||||
val targetDir = File(context.filesDir, "downloaded_$assetType")
|
||||
if (!targetDir.exists()) targetDir.mkdirs()
|
||||
|
||||
val outputFile = File(targetDir, fileName)
|
||||
|
||||
// Kích hoạt luồng đọc ghi dòng dữ liệu từ Internet
|
||||
URL(fileUrl).openStream().use { input ->
|
||||
output.outputStream().use { outputStream ->
|
||||
input.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
|
||||
// Trả về file đã tải thành công để cập nhật UI
|
||||
onComplete(outputFile)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onComplete(null)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
// Hàm kiểm tra xem file này đã được tải về máy trước đó chưa
|
||||
fun getLocalAssetFile(assetType: String, fileName: String): File? {
|
||||
val file = File(context.filesDir, "downloaded_$assetType/$fileName")
|
||||
return if (file.exists()) file else null
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 4. Cải Tiến Cụm Engine Để Đọc Được Cả Hai Nguồn Asset
|
||||
|
||||
Để hệ thống xử lý ảnh (như thuật toán MiDaS, MediaPipe hay Ép khung PNG) nhận diện được cả file cứng trong app lẫn file động mới tải về, bạn chỉ cần thay đổi hàm nạp ảnh sang cơ chế **Đọc Đa Luồng (Hybrid Asset Loader)**:
|
||||
|
||||
```kotlin
|
||||
// THAY THẾ CƠ CHẾ ĐỌC CỨNG:
|
||||
// Trước đây: val frameData = context.assets.open("frames/instaxframe.png")
|
||||
|
||||
// BÂY GIỜ: Kiểm tra file local trước, nếu không có mới tìm trong assets mặc định
|
||||
val frameBitmap = val localFile = assetManager.getLocalAssetFile("frames", "instaxframe.png")
|
||||
val finalFrame = if (localFile != null) {
|
||||
BitmapFactory.decodeFile(localFile.absolutePath) // Nạp file người dùng vừa tải từ nút "..."
|
||||
} else {
|
||||
BitmapFactory.decodeStream(context.assets.open("frames/instaxframe.png")) // Nạp file mặc định của app
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 5. Kế Hoạch Triển Khai Kiểm Thử (Checklist)
|
||||
|
||||
* [ ] **Kiểm thử kết nối mạng:** Tắt mạng (Wifi/Mạng di động) rồi bấm vào nút `...` xem ứng dụng có xử lý lỗi mượt mà không (Yêu cầu: Hiện thông báo nhẹ *"Vui lòng kết nối mạng để tải thêm tài nguyên"* thay vì bị văng ứng dụng).
|
||||
* [ ] **Kiểm thử lưu trữ:** Tải về một mẫu `Nền mới`. Tắt hẳn ứng dụng đi, bật lại, kiểm tra xem ô `Nền mới` đó có còn nằm trong danh sách chọn không và có áp dụng được bình thường khi offline không.
|
||||
* [ ] **Kiểm thử trùng lặp:** Bấm tải đi tải lại 1 tệp tin xem hệ thống có tự ghi đè (`Overwrite`) để tối ưu dung lượng bộ nhớ máy hay không, tránh việc sinh ra nhiều file rác trùng tên.
|
||||
|
||||
Tính năng kho lưu trữ động thông qua nút `...` này sẽ giúp app Photobooth của bạn cực kỳ nhẹ khi tải trên Store (chỉ khoảng 20-30MB), nhưng người dùng vẫn có thể sở hữu một kho tài nguyên khổng lồ tùy chọn theo ý muốn!
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
Lỗi bạn vừa mô tả rất phổ biến trong lập trình đồ họa camera. Nguyên nhân là do hàm `boxBlur` hoặc `gaussianBlur` tăng bán kính (radius) theo cấp số tuyến tính, trong khi trên thực tế quang học, độ sâu trường ảnh (DoF) và mức độ xóa phông của ống kính máy ảnh (từ 24mm đến 400mm) thay đổi theo **cấp số phi tuyến tính (Exponential/Logarithmic)** dựa trên tiêu cự và khoảng cách.
|
||||
|
||||
Để thước đo từ `0` đến `100` hoạt động mượt mà và thực tế (ví dụ: kéo đến 10 là mờ nhẹ kiểu 35mm, đến 50 là mờ mịn kiểu 85mm, và đến 100 là xóa nát nền kiểu 400mm), chúng ta cần thiết lập một **Ma trận chuyển đổi hàm số mũ (Exponential Mapping Matrix)**.
|
||||
|
||||
Dưới đây là kế hoạch Markdown chi tiết để tính toán lại toàn bộ bậc thang DoF cho dự án `photobooth-app`.
|
||||
|
||||
---
|
||||
|
||||
# 📐 KẾ HOẠCH TÍNH TOÁN BẬC THANG PHI TUYẾN TÍNH DOF (24MM - 400MM)
|
||||
|
||||
Mục tiêu cốt lõi:
|
||||
|
||||
1. Ánh xạ dải trượt từ `0` (vô hiệu hóa/nét toàn dải như Lens 24mm) đến `100` (xóa phông đại vĩ tuyến tương đương Lens 400mm F2.8).
|
||||
2. Sửa lỗi "bật nhảy" độ mờ (mới tăng +1 đã mờ bằng lens 50mm).
|
||||
3. Đảm bảo phân phối độ mờ theo khoảng cách thực tế (`relativeDistance`) của MiDaS chuyển động đúng quy luật vật lý.
|
||||
|
||||
---
|
||||
|
||||
## 📊 1. Thiết Kế Ma Trận Ánh Xạ Quang Học (Focal Length Mapping)
|
||||
|
||||
Chúng ta sẽ chia dải trượt `0 - 100` của Ruler thành các phân vùng tiêu cự máy ảnh thực tế để làm mốc đồ họa:
|
||||
|
||||
| Giá trị trên Thước (Ruler) | Tiêu cự mô phỏng (Focal Length) | Hiệu ứng thị giác thực tế | Bán kính Blur tối đa tương ứng (`Max Radius`) |
|
||||
| --- | --- | --- | --- |
|
||||
| **0** | `24mm - 28mm` | Nét sâu toàn cảnh (Deep Focus), không mờ. | **0** (Bật tính năng chống Ghosting nhưng Radius = 0) |
|
||||
| **1 - 10** | `35mm - 50mm` | Mờ nhẹ nhàng, hậu cảnh vẫn nhìn rõ khối hình. | **1 - 4** |
|
||||
| **11 - 40** | `85mm` | Tiêu cự chân dung kinh điển, phông nền tách biệt mịn. | **5 - 12** |
|
||||
| **41 - 70** | `135mm - 200mm` | Phông nền bị nén lại, xóa phông mạnh, Bokeh lớn. | **13 - 22** |
|
||||
| **71 - 100** | `300mm - 400mm` | Xóa nát phông nền (Creamy Bokeh), hậu cảnh hoàn toàn là các mảng màu hòa quyện. | **23 - 35** |
|
||||
|
||||
---
|
||||
|
||||
## 💻 2. Thuật Toán Chuyển Đổi Phi Tuyến Tính (`MainActivity.kt`)
|
||||
|
||||
Thay vì nhân trực tiếp `(25 * blurIntensity)`, chúng ta sẽ dùng hàm toán học mũ lũy thừa (`pow`) để ép dải số trượt tăng chậm ở đoạn đầu (tiêu cự ngắn) và tăng tốc cực nhanh ở đoạn sau (tiêu cự dài).
|
||||
|
||||
### 💻 Cập nhật hàm tính toán thông số trong Engine:
|
||||
|
||||
```kotlin
|
||||
fun processAdvancedDofMapping(
|
||||
capturedBitmap: Bitmap,
|
||||
alphaMask: FloatArray,
|
||||
midasDepthMap: Array<FloatArray>,
|
||||
bgBitmap: Bitmap?,
|
||||
rulerValue: Int // Nhận giá trị thô từ 0 đến 100 từ thanh trượt
|
||||
): Bitmap {
|
||||
val w = capturedBitmap.width
|
||||
val h = capturedBitmap.height
|
||||
|
||||
if (rulerValue == 0) return capturedBitmap // Bằng 0 thì giữ nét 100% không xử lý
|
||||
|
||||
// --- THUẬT TOÁN ĐỔI BẬC THANG QUANG HỌC (EXPONENTIAL MAPPING) ---
|
||||
// Chuyển đổi giá trị tuyến tính 0-100 sang tỷ lệ dốc phi tuyến tính từ 0.0 -> 1.0
|
||||
val normalizedSlider = rulerValue / 100.0f
|
||||
|
||||
// Sử dụng hàm mũ lũy thừa bậc 2.5 để kìm hãm độ mờ ở các mức đầu (1-10) không bị nhảy vọt
|
||||
val opticalIntensity = Math.pow(normalizedSlider.toDouble(), 2.5).toFloat()
|
||||
|
||||
// Tính toán bán kính mờ tối đa dựa trên tiêu cự max (Mô phỏng 400mm đạt bán kính 35)
|
||||
val maxRadius = (35 * opticalIntensity).coerceAtLeast(1.0f)
|
||||
|
||||
// Phân phối 3 cấp độ mờ dựa trên tiêu cự đã tính toán lại
|
||||
val radiusStage1 = (maxRadius * 0.2f).toInt().coerceAtLeast(1) // Nền sát người (Mô phỏng DoF mỏng)
|
||||
val radiusStage2 = (maxRadius * 0.5f).toInt().coerceAtLeast(1) // Nền trung bình
|
||||
val radiusStage3 = maxRadius.toInt().coerceAtLeast(1) // Nền xa vô cực
|
||||
|
||||
// 1. Tạo ảnh hậu cảnh sạch chống hiện tượng Ghost Shadow
|
||||
val cleanBgBitmap = createCleanBackground(capturedBitmap, alphaMask, w, h)
|
||||
|
||||
// 2. Tiến hành Blur phân cấp theo thang tiêu cự chuẩn mới
|
||||
val blurStage1 = boxBlur(cleanBgBitmap, radiusStage1)
|
||||
val blurStage2 = boxBlur(cleanBgBitmap, radiusStage2)
|
||||
val blurStage3 = boxBlur(cleanBgBitmap, radiusStage3)
|
||||
cleanBgBitmap.recycle()
|
||||
|
||||
val outputBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
|
||||
// 3. VÒNG LẶP TRỘN PIXEL THEO DOF QUANG HỌC TIÊU CỰ
|
||||
for (y in 0 until h) {
|
||||
val maskY = (y * 256 / h).coerceIn(0, 255)
|
||||
for (x in 0 until w) {
|
||||
val idx = y * w + x
|
||||
val alpha = alphaMask[idx]
|
||||
|
||||
val maskX = (x * 256 / w).coerceIn(0, 255)
|
||||
val currentDepth = midasDepthMap[maskY][maskX]
|
||||
|
||||
// Giả lập điểm lấy nét (focalPoint) cố định quanh đối tượng là 0.25f
|
||||
val relativeDistance = (currentDepth - 0.25f).coerceAtLeast(0f)
|
||||
|
||||
// Chọn lớp màu nền mờ dựa theo độ sâu DoF co giãn động theo thước trượt
|
||||
// Khi tiêu cự lớn (rulerValue cao), trường ảnh mỏng đi -> Vùng lấy nét hẹp lại
|
||||
val dofThreshold1 = 0.10f * (1.0f - normalizedSlider * 0.5f)
|
||||
val dofThreshold2 = 0.35f * (1.0f - normalizedSlider * 0.5f)
|
||||
|
||||
val bgPixel = when {
|
||||
relativeDistance < dofThreshold1 -> blurStage1.getPixel(x, y)
|
||||
relativeDistance < dofThreshold2 -> blurStage2.getPixel(x, y)
|
||||
else -> blurStage3.getPixel(x, y) // Vô cực (Mờ đại Bokeh)
|
||||
}
|
||||
|
||||
val fgPixel = capturedBitmap.getPixel(x, y)
|
||||
|
||||
// Khử răng cưa và trộn Alpha Blending
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// Dọn dẹp bộ đệm RAM
|
||||
blurStage1.recycle()
|
||||
blurStage2.recycle()
|
||||
blurStage3.recycle()
|
||||
|
||||
return outputBitmap
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 3. Cơ Chế Điều Khiển Cử Chỉ Swipe Đồng Bộ Trên Kính Ngắm
|
||||
|
||||
Khi áp dụng hàm số mũ này, các thao tác **Vuốt màn hình sang phải (Tăng) hay sang trái (Giảm)** sẽ thay đổi thông số `rulerValue` từ `0` đến `100` cực kỳ tuyến tính và đều đặn bằng tay, nhưng hiệu ứng thị giác trên màn hình sẽ chuyển đổi mượt mà:
|
||||
|
||||
* **Vuốt nhẹ đoạn đầu (0 -> 15):** Bạn sẽ thấy phông nền chỉ chuyển dịch nhẹ từ Lens góc rộng 24mm sang Lens 50mm, phông nền mờ rất khẽ, không bị nhảy vọt một cách bất ngờ như trước.
|
||||
* **Vuốt mạnh về sau (50 -> 100):** Độ mờ tăng tốc nhanh chóng để đạt hiệu ứng nén hậu cảnh tối đa của các dòng ống kính siêu tele 400mm chuyên nghiệp.
|
||||
|
||||
---
|
||||
|
||||
## 📅 4. Checklist Xác Minh Bản Sửa Đổi (Verification Checklist)
|
||||
|
||||
* [ ] **Test mốc +1 đến +5:** Kéo thanh trượt hoặc vuốt màn hình lên mức +1 và +5 xem phông nền phía sau đã giữ được độ chi tiết nhẹ nhàng của ống kính tiêu cự ngắn chưa. (Yêu cầu: Nền chỉ hơi mượt đi chứ tuyệt đối không được nhòe tịt như trước).
|
||||
* [ ] **Test mốc tối đa +100:** Kéo thước lên mức tối đa xem phông nền có đạt được độ xóa mịn đặc trưng của các dòng ống kính tele chụp xa hay không.
|
||||
* [ ] **Kiểm thử vùng chuyển tiếp DoF:** Đảm bảo khi kéo thước tăng dần, ranh giới giữa vùng nét (người) sang vùng mờ nhẹ (vật ở gần) và vùng mờ tịt (vật ở xa) co giãn tự nhiên, không bị xuất hiện các vệt cắt khối loang lổ.
|
||||
@@ -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).
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
Dựa vào hình ảnh thực tế `image_def201.jpg` bạn cung cấp và mô tả lỗi, chúng ta đang gặp phải 2 vấn đề kỹ thuật nghiêm trọng về mặt xử lý thuật toán và quản lý luồng phần cứng:
|
||||
|
||||
### 🔴 Phân tích nguyên nhân lỗi:
|
||||
|
||||
1. **Lỗi lấy nét "Vòng tròn phẳng" (`image_def201.jpg`):** Do khi gỡ bỏ MiDaS và chuyển sang `multiclass_segmenter.tflite`, thuật toán logic cũ đang áp dụng một vùng Oval/Vòng tròn dựa theo tọa độ chạm tuyệt đối để giữ nét, dẫn đến việc trên cùng một cơ thể người (cùng một mặt phẳng khoảng cách) nhưng khuôn mặt lại bị nhòe mờ tịt còn phần ngực/áo lại nét. Như vậy là sai quy luật vật lý quang học DoF (máy ảnh lấy nét theo mặt phẳng tiêu cự cắt ngang, chứ không lấy nét theo hình tròn).
|
||||
2. **Lỗi khựng lag và treo cứng Viewfinder khi nhấn Shutter:**
|
||||
* **Viewfinder vẫn lag:** Do khi bạn vuốt màn hình chỉnh Ruler, lớp `BackdropFilter` đồ họa tuy xử lý bằng GPU nhưng cấu trúc phân vùng đục lỗ Oval vẫn đang ép UI Thread tính toán vẽ lại liên tục.
|
||||
* **Dừng luôn Viewfinder sau khi nhấn Shutter:** Do luồng `GlobalScope.launch` gọi hàm chụp từ camera đang chiếm dụng/khóa cứng `CameraSession` (phần cứng camera) hoặc do hàm xử lý đổ Bitmap đầu ra đè lên buffer luồng preview mà không giải phóng, khiến camera bị treo ("Freeze").
|
||||
|
||||
|
||||
|
||||
Dưới đây là kế hoạch Markdown chi tiết để khắc phục triệt để hai lỗi trên.
|
||||
|
||||
---
|
||||
|
||||
# 🛠️ KẾ HOẠCH FIX LỖI: ĐỒNG BỘ MẶT PHẲNG TIÊU CỰ QUANG HỌC VÀ KHÔI PHỤC LUỒNG CAMERA
|
||||
|
||||
---
|
||||
|
||||
## 📐 1. Giải Pháp Sửa Lỗi Lấy Nét Theo "Mặt Phẳng Tiêu Cự Đa Lớp" (Semantic Focus Plane)
|
||||
|
||||
Để không còn bị lỗi nhòe mặt như trong `image_def201.jpg`, khi sử dụng `multiclass_segmenter.tflite`, chúng ta phải lấy nét theo **toàn bộ khối thực thể lớp (Class ID)** được chạm, thay vì lấy nét theo tọa độ vòng tròn hình học. Nếu chạm trúng một pixel thuộc lớp Người (`classId == 1`), toàn bộ cơ thể người từ đầu đến vai phải nét đều nhau.
|
||||
|
||||
### 💻 Cập nhật lại Logic xử lý mặt nạ trong `MainActivity.kt`:
|
||||
|
||||
```kotlin
|
||||
// Lưu trữ Class ID được chọn để khóa tiêu cự (Mặc định -1 là tự động lấy nét toàn bộ người)
|
||||
var lockedClassIdFocus = -1
|
||||
|
||||
fun onTouchViewfinderToFocus(percentX: Float, percentY: Float, categoryMaskBuffer: ByteBuffer, maskW: Int, maskH: Int) {
|
||||
val maskX = (percentX * maskW).toInt().coerceIn(0, maskW - 1)
|
||||
val maskY = (percentY * maskH).toInt().coerceIn(0, maskH - 1)
|
||||
val index = maskY * maskW + maskX
|
||||
|
||||
categoryMaskBuffer.rewind()
|
||||
// Khóa chính xác ID của thực thể tại điểm chạm (Không dùng vòng tròn phẳng)
|
||||
lockedClassIdFocus = categoryMaskBuffer.get(index).toInt()
|
||||
}
|
||||
|
||||
fun processSemanticDofBokeh(
|
||||
capturedBitmap: Bitmap,
|
||||
categoryMaskBuffer: ByteBuffer,
|
||||
rulerValue: Int
|
||||
): Bitmap {
|
||||
val w = capturedBitmap.width
|
||||
val h = capturedBitmap.height
|
||||
if (rulerValue == 0) return capturedBitmap
|
||||
|
||||
val alphaMask = FloatArray(w * h)
|
||||
categoryMaskBuffer.rewind()
|
||||
|
||||
// QUY LUẬT DOF VẬT LÝ QUANG HỌC MỚI: Lấy nét theo mảng thực thể phẳng
|
||||
for (i in 0 until (w * h)) {
|
||||
val currentPixelClassId = categoryMaskBuffer.get(i).toInt()
|
||||
|
||||
if (lockedClassIdFocus == 1) {
|
||||
// Trường hợp 1: Nếu chạm vào NGƯỜI -> Toàn bộ pixel thuộc Người (ID 1) ĐỀU PHẢI NÉT CĂNG ĐỒNG ĐỀU
|
||||
alphaMask[i] = if (currentPixelClassId == 1) 1.0f else 0.0f
|
||||
} else if (lockedClassIdFocus == 3) {
|
||||
// Trường hợp 2: Nếu chạm vào ĐỒ VẬT -> Toàn bộ đồ vật nét, người và nền mờ đi
|
||||
alphaMask[i] = if (currentPixelClassId == 3) 1.0f else 0.0f
|
||||
} else {
|
||||
// Mặc định tự động: Giữ nét toàn bộ thực thể chính (Người + Phụ kiện cận cảnh)
|
||||
alphaMask[i] = if (currentPixelClassId == 1 || currentPixelClassId == 2) 1.0f else 0.0f
|
||||
}
|
||||
}
|
||||
|
||||
// Làm mờ mảng mặt nạ (radius = 4) tạo dải mềm Gradient để viền tóc chuyển tiếp tự nhiên, không bị răng cưa
|
||||
blurAlphaMask1D(alphaMask, w, h, radius = 4)
|
||||
|
||||
// Tạo phông nền sạch chống Ghosting bao quanh người
|
||||
val cleanBg = createCleanBackground(capturedBitmap, alphaMask, w, h)
|
||||
val maxRadius = (35 * Math.pow((rulerValue / 100.0f).toDouble(), 2.5)).toFloat()
|
||||
|
||||
// Tạo bậc thang mờ mô phỏng khoảng cách hậu cảnh mờ mịn tăng tiến
|
||||
val blurStage1 = boxBlur(cleanBg, (maxRadius * 0.3f).toInt().coerceAtLeast(1))
|
||||
val blurStage2 = boxBlur(cleanBg, (maxRadius * 0.6f).toInt().coerceAtLeast(1))
|
||||
val blurStage3 = boxBlur(cleanBg, maxRadius.toInt().coerceAtLeast(1))
|
||||
cleanBg.recycle()
|
||||
|
||||
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]
|
||||
|
||||
// Mô phỏng DoF vật lý: Càng xa rìa biên của đối tượng lấy nét thì phông nền càng nhòe sâu
|
||||
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)
|
||||
// Trộn Alpha Blending mượt mà cấu trúc
|
||||
outputBitmap.setPixel(x, y, Color.rgb(
|
||||
(fgPixel.r * alpha + bgPixel.r * (1.0f - alpha)).toInt(),
|
||||
(fgPixel.g * alpha + bgPixel.g * (1.0f - alpha)).toInt(),
|
||||
(fgPixel.b * alpha + bgPixel.b * (1.0f - alpha)).toInt()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
blurStage1.recycle(); blurStage2.recycle(); blurStage3.recycle()
|
||||
return outputBitmap
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 2. Sửa Lỗi Lag Và Treo Đóng Băng Kính Ngắm Viewfinder Khi Nhấn Shutter
|
||||
|
||||
### 🔴 Nguyên nhân đóng băng:
|
||||
|
||||
Khi bạn gọi hàm `takePicture()` trong luồng nền mà không thiết lập cơ chế khôi phục Preview, hoặc do Coroutine chiếm dụng cấu trúc đồ họa mà chưa giải phóng `imageReader`, phần cứng camera trên hệ điều hành Android sẽ tự ngắt luồng hiển thị giao diện.
|
||||
|
||||
### 🟢 Giải pháp: Giải phóng tài nguyên và khởi động lại Preview độc lập
|
||||
|
||||
#### Bước 2.1: Tối ưu UI Viewfinder tuyệt đối để hết lag hoàn toàn (`CameraScreen.dart`)
|
||||
|
||||
Thay vì tính toán đục lỗ mờ bằng cấu trúc Widget phức tạp ép UI vẽ lại, ta chỉ dùng bộ lọc làm mờ thuần túy phủ đều, vùng nét sẽ được quyết định hoàn toàn ở bức ảnh xuất xưởng sau khi chụp.
|
||||
|
||||
```dart
|
||||
Widget _buildNonLagViewfinder() {
|
||||
return AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Kính ngắm camera thô chạy trực tiếp bằng phần cứng (Luôn đạt 60 FPS mượt mà)
|
||||
CameraPreview(_cameraController),
|
||||
|
||||
// Chỉ dùng duy nhất 1 lớp filter nhẹ của GPU, không tính toán biểu thức hình học trên UI
|
||||
if (_blurIntensity > 0)
|
||||
Positioned.fill(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: _blurIntensity * 8.0, sigmaY: _blurIntensity * 8.0),
|
||||
child: Container(color: Colors.black.withOpacity(0.05)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
#### Bước 2.2: Sửa mã nguồn Native tránh nghẽn luồng đóng băng Camera (`MainActivity.kt`)
|
||||
|
||||
Đảm bảo sau khi chụp xong tệp tin tạm, lệnh **`startPreview()`** phải được gọi ngay lập tức trên luồng giao diện để đánh thức camera làm việc tiếp, tách biệt hoàn toàn với luồng ngầm đang xử lý lưu ảnh AI.
|
||||
|
||||
```kotlin
|
||||
fun captureImagePipeline(cameraDevice: CameraDevice, previewRequestBuilder: CaptureRequest.Builder, captureSession: CameraCaptureSession) {
|
||||
// 1. Kích hoạt chớp màn hình trên điện thoại ngay lập tức
|
||||
triggerFlashAnimationUI()
|
||||
|
||||
// 2. Thiết lập lệnh chụp ảnh thô
|
||||
val captureBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE)
|
||||
captureBuilder.addTarget(imageReader.surface)
|
||||
|
||||
captureSession.capture(captureBuilder.build(), object : CameraCaptureSession.CaptureCallback() {
|
||||
override fun onCaptureCompleted(session: CameraCaptureSession, request: CaptureRequest, result: TotalCaptureResult) {
|
||||
super.onCaptureCompleted(session, request, result)
|
||||
|
||||
// 🔥 KHẮC PHỤC LỖI TREO CỨNG CAMERA:
|
||||
// Ngay khi luồng phần cứng bắt được ảnh thô xong, lập tức khởi động lại Viewfinder trên Main Thread
|
||||
runOnUiThread {
|
||||
try {
|
||||
// Cấp lại luồng vẽ trực tiếp cho kính ngắm Viewfinder tiếp tục chạy mượt mà
|
||||
captureSession.setRepeatingRequest(previewRequestBuilder.build(), null, null)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
// 3. ĐẨY TOÀN BỘ TIẾN TRÌNH XỬ LÝ AI VÀ BOKEH CỦA MEDIAPIPE XUỐNG LUỒNG NGẦM COUTINE ĐỘC LẬP
|
||||
// Tiến trình này chạy độc lập hoàn toàn, không đụng chạm gì đến luồng Preview của Camera nữa
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
val rawBitmap = getInternalBitmapFromReader(imageReader)
|
||||
val categoryMask = runMediaPipeMulticlassProcessor(rawBitmap)
|
||||
|
||||
val finalProcessedBitmap = processSemanticDofBokeh(rawBitmap, categoryMask, currentRulerValue)
|
||||
|
||||
saveToGalleryAsynchronously(finalProcessedBitmap)
|
||||
rawBitmap.recycle()
|
||||
finalProcessedBitmap.recycle()
|
||||
}
|
||||
}
|
||||
}, null)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 3. Checklist Xác Minh Bản Sửa Đổi (Verification Checklist)
|
||||
|
||||
* [ ] **Kiểm thử lấy nét đồng khối (`image_def201.jpg`):** Thực hiện chạm vào khuôn mặt nhân vật trên kính ngắm rồi chụp. Kiểm tra ảnh lưu trong máy xem khuôn mặt và toàn bộ phần vai/áo có nét căng đồng đều nhau không. (Yêu cầu: Đã sửa triệt để lỗi mờ nhòe mặt như trong hình `image_def201.jpg`).
|
||||
* [ ] **Kiểm thử đóng băng Viewfinder:** Nhấn nút Shutter chụp liên tiếp 5 tấm. Quan sát kính ngắm camera xem sau mỗi lần bấm máy có bị khựng hình hay treo đứng luôn không. (Yêu cầu: Màn hình chỉ chớp trắng 1 cái, kính ngắm vẫn hoạt động liên tục, không bị ngắt quãng).
|
||||
* [ ] **Kiểm thử trượt Ruler:** Kéo thanh trượt Ruler qua lại thật nhanh xem kính ngắm phản hồi ngay lập tức ở tốc độ 60 FPS không.
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# 🟢 Giải pháp triệt để:
|
||||
|
||||
Khi đã dùng **Focus vật lý**, nhiệm vụ của AI lúc này **không phải là đi làm mờ (Blur) nữa**, mà chỉ đóng vai trò là **"Mắt thần" hỗ trợ điều khiển động cơ thấu kính** di chuyển đến đúng vị trí (`CONTROL_AF_MODE`). Đồng thời, ta phải **tắt hoàn toàn các hàm `boxBlur` hay trộn Alpha Mask** để trả lại chất lượng thấu kính tự nhiên nhất.
|
||||
|
||||
---
|
||||
|
||||
# 🛠️ KẾ HOẠCH MÀU SẮC & PHẦN CỨNG: CHUYỂN ĐỔI SANG FOCUS VẬT LÝ CAMERA2 API
|
||||
|
||||
---
|
||||
|
||||
## 📐 1. Tắt Hoàn Toàn Thuật Toán Xử Lý Đồ Họa Phần Mềm (`MainActivity.kt`)
|
||||
|
||||
Bước đầu tiên, chúng ta gỡ bỏ hoàn toàn hàm trộn pixel và làm mờ phông nền nhân tạo để giải phóng hoàn toàn CPU/GPU và triệt tiêu lỗi răng cưa ở tóc.
|
||||
|
||||
```kotlin
|
||||
fun processHardwareFocusPipeline(capturedBitmap: Bitmap): Bitmap {
|
||||
// ❌ XÓA HOÀN TOÀN: boxBlur(), alphaMask, getNearestPureBackgroundPixel...
|
||||
// 🟢 TRẢ VỀ: Ảnh thô 100% thu được từ cảm biến thấu kính camera vật lý
|
||||
return capturedBitmap
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 2. Cấu Hình Điều Khiển Thấu Kính Vật Lý Khi Chạm Khung Ngắm (`MainActivity.kt`)
|
||||
|
||||
Chúng ta sẽ sử dụng gói **Android Camera2 API** để gửi lệnh ép thấu kính dịch chuyển (Lens Vector Focus) dựa trên vị trí ngón tay chạm trên màn hình.
|
||||
|
||||
### 💻 Logic khóa nét vật lý theo tọa độ chạm:
|
||||
|
||||
```kotlin
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.hardware.camera2.params.MeteringRectangle
|
||||
import android.hardware.camera2.CameraMetadata
|
||||
|
||||
fun applyHardwareTouchToFocus(
|
||||
previewRequestBuilder: CaptureRequest.Builder,
|
||||
captureSession: CameraCaptureSession,
|
||||
percentX: Float,
|
||||
percentY: Float
|
||||
) {
|
||||
// 1. Chuyển đổi tỷ lệ chạm % về tọa độ ma trận cảm biến của phần cứng camera (Sensor Active Array)
|
||||
val sensorRegion = cameraCharacteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)
|
||||
val sensorW = sensorRegion?.width() ?: 2000
|
||||
val sensorH = sensorRegion?.height() ?: 1500
|
||||
|
||||
val focusX = (percentX * sensorW).toInt().coerceIn(0, sensorW)
|
||||
val focusY = (percentY * sensorH).toInt().coerceIn(0, sensorH)
|
||||
|
||||
// Thiết lập vùng lấy nét vật lý (kích thước ô vuông lấy mốc đo tiêu cự là 100x100 pixel)
|
||||
val focusAreaWeight = 1000 // Độ ưu tiên lấy nét cao nhất
|
||||
val focusRectangle = MeteringRectangle(
|
||||
Math.max(focusX - 50, 0),
|
||||
Math.max(focusY - 50, 0),
|
||||
100,
|
||||
100,
|
||||
focusAreaWeight
|
||||
)
|
||||
|
||||
try {
|
||||
// 2. HỦY CHẾ ĐỘ TỰ ĐỘNG LẤY NÉT KHÔNG ĐỊNH HƯỚNG (CANCEL AF)
|
||||
previewRequestBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_CANCEL)
|
||||
captureSession.capture(previewRequestBuilder.build(), null, null)
|
||||
|
||||
// 3. ÉP CAMERA ĐIỀU KHIỂN ĐỘNG CƠ THẤU KÍNH ĐẾN TỌA ĐỘ VẬT LÝ MỚI
|
||||
previewRequestBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO)
|
||||
previewRequestBuilder.set(CaptureRequest.CONTROL_AF_REGIONS, arrayOf(focusRectangle))
|
||||
previewRequestBuilder.set(CaptureRequest.CONTROL_AE_REGIONS, arrayOf(focusRectangle)) // Đồng bộ đo sáng tại điểm chạm
|
||||
|
||||
// Kích hoạt lệnh di chuyển thấu kính (START AF TRIGGER)
|
||||
previewRequestBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_START)
|
||||
|
||||
// Gửi lệnh xuống phần cứng camera cập nhật Preview lập tức
|
||||
captureSession.setRepeatingRequest(previewRequestBuilder.build(), null, null)
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 3. Đồng Bộ Sang Tầng Giao Diện Người Dùng (`CameraScreen.dart`)
|
||||
|
||||
Trên tầng giao diện Flutter, gỡ bỏ hoàn toàn các widget bộ lọc phần mềm như `BackdropFilter` hay `Container` đục lỗ làm mờ cũ (vì chúng gây lag và không đúng thực tế). Chỉ giữ lại **Vòng tròn lấy nét vật lý**:
|
||||
|
||||
```dart
|
||||
Widget _buildHardwareFocusViewfinder() {
|
||||
return GestureDetector(
|
||||
onTapUp: (TapUpDetails details) {
|
||||
final RenderBox box = context.findRenderObject() as RenderBox;
|
||||
final Offset localPosition = box.globalToLocal(details.globalPosition);
|
||||
|
||||
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 lệnh Native ép mô-tơ thấu kính di chuyển vật lý
|
||||
_sendHardwareFocusCommand(percentX, percentY);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
if (mounted) setState(() { _focusTouchPosition = null; });
|
||||
});
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
CameraPreview(_cameraController), // Kính ngắm chạy thuần 100% phần cứng, 60 FPS tuyệt đối trơn tru
|
||||
if (_focusTouchPosition != null)
|
||||
Positioned(
|
||||
left: _focusTouchPosition!.dx - 25,
|
||||
top: _focusTouchPosition!.dy - 25,
|
||||
child: const HardwareFocusRing(), // Hiện vòng tròn màu vàng/xanh khóa nét nhiếp ảnh
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 4. Checklist Xác Minh Sau Khi Sửa Lỗi (Verification Checklist)
|
||||
|
||||
* [ ] **Xác minh 1:** Chạm vào khuôn mặt. Thấu kính camera sẽ di chuyển lấy nét vào mặt. Kiểm tra phần áo ba lỗ và hai bên vai xem đã nét đều và tự nhiên theo cơ thể chưa (Yêu cầu: Không còn vệt mờ phần mềm đè lên vai).
|
||||
* [ ] **Xác minh 2:** Chạm vào song sắt cửa sổ ở hậu cảnh phía sau. Thấu kính vật lý sẽ lùi ra sau lấy nét vào cửa sổ, khiến khuôn mặt bạn mờ nhòe đi một cách quang học tự nhiên nhất. (Yêu cầu: Phần viền đầu tóc không còn các vệt bóc tách AI màu xám loang lổ).
|
||||
* [ ] **Kiểm thử độ lag:** Vuốt màn hình và bấm lấy nét liên tục xem Viewfinder có bị giật hay đứng hình không. (Yêu cầu: Khung hình chạy mượt mà 60 FPS ổn định hoàn toàn do CPU không còn phải gánh bất kỳ vòng lặp pixel nào).
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
Dưới đây là kế hoạch Markdown chi tiết giúp bạn tách biệt rạch ròi giữa **Hiệu ứng xóa phông AI** và **Lấy nét vật lý phần cứng**, giải quyết tận gốc lỗi lag ngầm, đồng thời tái cấu trúc lại hệ thống quản lý thuộc tính **Presets** để bổ sung đầy đủ các thông số ánh sáng và sắc độ.
|
||||
|
||||
---
|
||||
|
||||
# 📊 KẾ HOẠCH TOÀN DIỆN: ĐỒNG BỘ FOCUS VẬT LÝ, TỐI ƯU LUỒNG BLUR AI VÀ CẤU TRÚC LẠI PRESETS
|
||||
|
||||
Mục tiêu cốt lõi:
|
||||
|
||||
1. **Triệt tiêu lag ngầm khi tắt Blur AI:** Sửa lỗi hệ thống vẫn chạy ngầm vòng lặp pixel đồ họa của `multiclass_segmenter.tflite` gây lag mặc dù giao diện không hiển thị hiệu ứng.
|
||||
2. **Đồng bộ Focus vật lý:** Giữ nguyên cơ chế lấy nét quang học qua Camera2 API, loại bỏ hoàn toàn sự can thiệp của thuật toán mờ phần mềm lên vật thể.
|
||||
3. **Mở rộng thuộc tính Presets:** Định nghĩa lại cấu trúc dữ liệu cấu hình màu sắc, bổ sung các thông số chuyên sâu: *Highlight, Shadow, Saturation, Contrast, Brightness*.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 1. Khắc Phục Lỗi Lag Ngầm Của Bộ Xử Lý Blur AI (`MainActivity.kt`)
|
||||
|
||||
**Nguyên nhân gây lag:** Khi bạn tắt chế độ xóa nền AI nhưng tăng thước trượt Blur, ứng dụng hiển thị như thể không dùng AI, nhưng ở tầng mã nguồn Native, điều kiện kiểm tra (`if/else`) chưa chuẩn khiến hàm `processMediaPipeOnlyBokeh()` vẫn bị gọi liên tục dưới nền, ép CPU xử lý ma trận `ByteBuffer` vô ích.
|
||||
|
||||
### 🟢 Giải pháp: Thiết lập cờ trạng thái hiệu ứng (Feature Flags) để ngắt luồng AI tuyệt đối
|
||||
|
||||
```kotlin
|
||||
// Định nghĩa các chế độ hoạt động của ứng dụng Photobooth
|
||||
enum class BackgroundMode {
|
||||
DEFAULT, // Giữ nguyên ảnh gốc thô của Camera
|
||||
HARDWARE_DOF, // Chỉ dùng Focus vật lý của thấu kính
|
||||
AI_BLUR // Dùng AI tách nền và làm mờ phần mềm
|
||||
}
|
||||
|
||||
var currentBackgroundMode = BackgroundMode.DEFAULT
|
||||
|
||||
fun handleImageStreamPipeline(capturedBitmap: Bitmap, rawMaskBuffer: ByteBuffer, rulerValue: Int): Bitmap {
|
||||
// SỬA LỖI LAG NGẦM: Nếu không bật chế độ AI_BLUR hoặc thước trượt bằng 0,
|
||||
// trả về ngay lập tức ảnh thô, chặn đứng hoàn toàn vòng lặp CPU.
|
||||
if (currentBackgroundMode != BackgroundMode.AI_BLUR || rulerValue == 0) {
|
||||
return capturedBitmap
|
||||
}
|
||||
|
||||
// Chỉ khi bật AI_BLUR và rulerValue > 0 thì mới gánh luồng xử lý nặng này
|
||||
return processMediaPipeOnlyBokeh(capturedBitmap, rawMaskBuffer, rulerValue)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 2. Quy Hoạch Luồng Focus Vật Lý Khi Chạm Khung Ngắm
|
||||
|
||||
Khi chuyển sang tab **"Focus Vật lý"**, chúng ta chuyển trạng thái hệ thống về `BackgroundMode.HARDWARE_DOF`. Lúc này, hệ thống bỏ qua toàn bộ mặt nạ phân lớp của Google MediaPipe, giao phó 100% việc tạo độ sâu trường ảnh (DoF) tự nhiên cho mô-tơ thấu kính của thiết bị di động.
|
||||
|
||||
* **Viewfinder:** Gọi lệnh `applyHardwareTouchToFocus()` qua Camera2 API gửi tọa độ vùng đo tiêu cự `MeteringRectangle` xuống phần cứng thấu kính.
|
||||
* **Ảnh xuất xưởng:** Nhận trực tiếp Bitmap thô từ cảm biến camera để lưu, đảm bảo ảnh trung thực như máy ảnh cơ, không bị lỗi lẹm viền hay tạo bóng ma (Ghost Shadow) quanh cơ thể.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 3. Cấu Trúc Lại Hệ Thống Thuộc Tính Presets Màu Sắc
|
||||
|
||||
Để các Presets (bộ lọc màu) chỉnh sửa đúng các thuộc tính ánh sáng và sắc độ, chúng ta cần chuyển đổi cấu trúc dữ liệu từ dạng chuỗi (String) đơn giản sang một đối tượng cấu hình có cấu trúc dữ liệu cụ thể (Structured Configuration Class).
|
||||
|
||||
### 📐 3.1. Thiết kế Model Dữ Liệu Bộ Màu (`PresetModel.kt`)
|
||||
|
||||
```kotlin
|
||||
data class ColorPreset(
|
||||
val id: String,
|
||||
val name: String,
|
||||
// --- Thuộc tính ánh sáng ---
|
||||
val brightness: Float, // -1.0f đến 1.0f (Mặc định 0.0f)
|
||||
val contrast: Float, // 0.5f đến 2.0f (Mặc định 1.0f)
|
||||
val highlight: Float, // -1.0f đến 1.0f (Mặc định 0.0f) -> Mới bổ sung
|
||||
val shadow: Float, // -1.0f đến 1.0f (Mặc định 0.0f) -> Mới bổ sung
|
||||
// --- Thuộc tính sắc độ & màu sắc ---
|
||||
val saturation: Float, // 0.0f đến 2.0f (Mặc định 1.0f)
|
||||
val temperature: Float // -0.5f đến 0.5f (Mặc định 0.0f) (Ấm/Lạnh)
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
### 💻 3.2. Thuật Toán Áp Dụng Thông Số Preset Lên Điểm Ảnh (Color Toning Engine)
|
||||
|
||||
Thuật toán xử lý ma trận màu sắc dưới đây nhận cấu hình từ các thuộc tính trên để biến đổi hệ màu RGB của bức ảnh:
|
||||
|
||||
```kotlin
|
||||
fun applyAdvancedPreset(inputBitmap: Bitmap, preset: ColorPreset): Bitmap {
|
||||
val w = inputBitmap.width
|
||||
val h = inputBitmap.height
|
||||
val outputBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
val pixel = inputBitmap.getPixel(x, y)
|
||||
|
||||
// Trích xuất kênh màu gốc
|
||||
var r = (pixel shr 16 and 0xFF) / 255.0f
|
||||
var g = (pixel shr 8 and 0xFF) / 255.0f
|
||||
var b = (pixel and 0xFF) / 255.0f
|
||||
|
||||
// 1. XỬ LÝ ÁNH SÁNG (Brightness & Contrast)
|
||||
r = (r - 0.5f) * preset.contrast + 0.5f + preset.brightness
|
||||
g = (g - 0.5f) * preset.contrast + 0.5f + preset.brightness
|
||||
b = (b - 0.5f) * preset.contrast + 0.5f + preset.brightness
|
||||
|
||||
// Tính toán độ sáng tổng thể của pixel (Luminance) để định vị vùng Highlight/Shadow
|
||||
val luminance = 0.2126f * r + 0.7152f * g + 0.0722f * b
|
||||
|
||||
// 2. BỔ SUNG: XỬ LÝ HIGHLIGHT & SHADOW
|
||||
if (luminance > 0.6f) {
|
||||
// Vùng sáng (Highlight): Tăng/Giảm độ sáng dựa trên thông số highlight của Preset
|
||||
val weight = (luminance - 0.6f) / 0.4f
|
||||
r += preset.highlight * weight
|
||||
g += preset.highlight * weight
|
||||
b += preset.highlight * weight
|
||||
} else if (luminance < 0.4f) {
|
||||
// Vùng tối (Shadow): Bù sáng hoặc dìm tối dựa trên thông số shadow của Preset
|
||||
val weight = (0.4f - luminance) / 0.4f
|
||||
r += preset.shadow * weight
|
||||
g += preset.shadow * weight
|
||||
b += preset.shadow * weight
|
||||
}
|
||||
|
||||
// 3. XỬ LÝ SẮC ĐỘ (Saturation)
|
||||
val gray = luminance
|
||||
r = gray + (r - gray) * preset.saturation
|
||||
g = gray + (g - gray) * preset.saturation
|
||||
b = gray + (b - gray) * preset.saturation
|
||||
|
||||
// Ép dải màu về giới hạn an toàn [0.0, 1.0] trước khi đóng gói lại pixel
|
||||
val finalR = (r.coerceIn(0.0f, 1.0f) * 255).toInt()
|
||||
val finalG = (g.coerceIn(0.0f, 1.0f) * 255).toInt()
|
||||
val finalB = (b.coerceIn(0.0f, 1.0f) * 255).toInt()
|
||||
|
||||
outputBitmap.setPixel(x, y, (0xFF shl 24) or (finalR shl 16) or (finalG shl 8) or finalB)
|
||||
}
|
||||
}
|
||||
return outputBitmap
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 4. Quản Lý Đồng Bộ File Định Cấu Hình Mẫu (`PresetsStore`)
|
||||
|
||||
Đối với tính năng **Tải thêm từ Server trực tuyến (Online Store)** qua nút `...` đã lên kế hoạch từ trước, file cấu hình mẫu `manifest.json` trên Server của bạn sẽ được bổ sung các thuộc tính mới đồng bộ như sau:
|
||||
|
||||
```json
|
||||
{
|
||||
"presets": [
|
||||
{
|
||||
"id": "preset_kodak_gold",
|
||||
"name": "Kodak Gold 200",
|
||||
"url": "https://yourserver.com/assets/presets/kodak_gold.json",
|
||||
"version": 1,
|
||||
"properties": {
|
||||
"brightness": 0.05,
|
||||
"contrast": 1.15,
|
||||
"highlight": -0.10,
|
||||
"shadow": 0.15,
|
||||
"saturation": 1.20,
|
||||
"temperature": 0.08
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 5. Kịch Bản Kiểm Thử Đảm Bảo Chất Lượng (QA Checklist)
|
||||
|
||||
* [ ] **Kiểm thử triệt tiêu lag ngầm (Lag Isolation Test):** Tắt tính năng Xóa nền AI (chuyển sang chế độ Mặc định/Focus vật lý). Kéo thanh trượt Ruler lên tối đa `+100` và di chuyển máy liên tục. (Yêu cầu: Kính ngắm phải mượt mà tuyệt đối 60 FPS, không được khựng giật vì hàm xử lý AI lúc này đã bị khóa chặn hoàn toàn bằng cờ trạng thái).
|
||||
* [ ] **Kiểm thử Focus vật lý (Hardware Lens Shift):** Chạm vào các vùng khoảng cách khác nhau (Gần/Xa) trên Viewfinder xem ống kính camera có dịch chuyển mô-tơ lấy nét vật lý chính xác không, ảnh thu được không được phép có vết cắt giả tạo hay răng cưa phần mềm xung quanh tóc.
|
||||
* [ ] **Kiểm thử thông số Highlight/Shadow trên Preset:** Áp dụng một bộ màu có thông số `highlight: -0.3` và `shadow: +0.2` lên một bức ảnh có vùng trời quá sáng và vùng tối khuất bóng. (Yêu cầu: Chi tiết vùng mây trên trời được dìm sáng rõ nét lại, vùng tối khuất sau lưng được cứu sáng mượt mà).
|
||||
@@ -0,0 +1,164 @@
|
||||
Dựa trên các vấn đề bạn vừa nêu, nguyên nhân cốt lõi khiến các thông số (Brightness, Contrast, Highlight, Shadow) tăng giảm không rõ rệt là do **công thức tính toán pixel tuyến tính (`/ 255.0f`) đang bị bó hẹp trong dải biên độ quá nhỏ**, không đủ mạnh để tạo ra sự đột biến về mặt thị giác trên cảm biến ảnh độ phân giải cao. Ngoài ra, việc các thông số này chưa hiển thị trên kính ngắm là do ứng dụng chưa tích hợp bộ lọc màu vào luồng render thời gian thực của camera preview.
|
||||
|
||||
Dưới đây là kế hoạch Markdown chi tiết để **nâng cấp thang đo ma trận màu sắc phi tuyến tính** nhằm tạo ra sự thay đổi tương phản, sáng tối cực kỳ rõ rệt, đồng thời tích hợp trực tiếp hiệu ứng này lên kính ngắm (Viewfinder) thời gian thực.
|
||||
|
||||
---
|
||||
|
||||
# 🛠️ KẾ HOẠCH FIX LỖI: HIỂN THỊ PRESET ĐỘNG TRÊN VIEWFINDER VÀ KHUẾCH ĐẠI BIÊN ĐỘ MÀU SẮC
|
||||
|
||||
Mục tiêu cốt lõi:
|
||||
|
||||
1. **Khuếch đại biên độ (High-Impact Color Engine):** Thay đổi công thức tính toán từ tuyến tính sang phi tuyến tính (dạng lũy thừa hoặc nhân hệ số dốc) để Brightness, Contrast, Highlight, Shadow thay đổi cực kỳ rõ rệt, đáp ứng đúng yêu cầu nghệ thuật của ảnh.
|
||||
2. **Hiển thị Real-time trên Viewfinder:** Tích hợp bộ xử lý màu sắc trực tiếp vào luồng render của camera trước khi xuất ra màn hình kính ngắm.
|
||||
3. **Đồng bộ ảnh chụp:** Đảm bảo khi bấm Shutter, thông số đang hiển thị trên kính ngắm được áp chính xác 100% vào ảnh lưu.
|
||||
|
||||
---
|
||||
|
||||
## 📐 1. Tích Hợp Hiệu Ứng Preset Lên Kính Ngắm Viewfinder (`CameraScreen.dart`)
|
||||
|
||||
Để người dùng thấy rõ sự thay đổi màu sắc ngay khi trượt thanh cấu hình hoặc chọn Preset, chúng ta sử dụng widget `ColorFiltered` kết hợp với **Ma trận màu (Color Filter Matrix)** ở tầng Flutter. Phương pháp này tận dụng GPU của điện thoại để render màu trực tiếp, đảm bảo Viewfinder luôn đạt 60 FPS mượt mà không bị lag.
|
||||
|
||||
```dart
|
||||
// Hàm khởi tạo ma trận màu 4x5 từ các thông số Preset để ép GPU hiển thị thời gian thực
|
||||
List<double> _generateColorMatrix({
|
||||
required double brightness, // Thang từ -1.0 đến 1.0
|
||||
required double contrast, // Thang từ 0.5 đến 2.5
|
||||
required double saturation, // Thang từ 0.0 đến 2.0
|
||||
}) {
|
||||
// Khuếch đại thông số Brightness hệ số 255 để ăn trực tiếp vào ma trận phần cứng
|
||||
final double b = brightness * 255.0;
|
||||
final double c = contrast;
|
||||
final double s = saturation;
|
||||
|
||||
// Ma trận biến đổi màu chuẩn đồ họa máy tính
|
||||
return [
|
||||
c * (0.213 + 0.787 * s), c * (0.715 - 0.715 * s), c * (0.072 - 0.072 * s), 0, b,
|
||||
c * (0.213 - 0.213 * s), c * (0.715 + 0.285 * s), c * (0.072 - 0.072 * s), 0, b,
|
||||
c * (0.213 - 0.213 * s), c * (0.715 - 0.715 * s), c * (0.072 + 0.928 * s), 0, b,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildPresetLiveViewfinder() {
|
||||
return AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: ColorFiltered(
|
||||
// Áp ma trận màu động lên toàn bộ luồng camera hiển thị
|
||||
colorFilter: ColorFilter.matrix(_generateColorMatrix(
|
||||
brightness: _currentPreset.brightness,
|
||||
contrast: _currentPreset.contrast,
|
||||
saturation: _currentPreset.saturation,
|
||||
)),
|
||||
child: CameraPreview(_cameraController), // Kính ngắm camera chạy bằng phần cứng mượt mà
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 2. Tái Cấu Trúc Thuật Toán Màu Sắc Để Tăng Giảm Cực Kỳ Rõ Rệt (`MainActivity.kt`)
|
||||
|
||||
Dưới đây là thuật toán toán học mới được viết lại cho tầng xử lý ảnh gốc (`MainActivity.kt`). Chúng ta thay đổi quy luật tính toán: **Sử dụng hàm dốc phi tuyến tính để ép Highlight/Shadow thay đổi rõ rệt và khuếch đại biên độ Brightness/Contrast.**
|
||||
|
||||
```kotlin
|
||||
fun applyHighImpactPreset(inputBitmap: Bitmap, preset: ColorPreset): Bitmap {
|
||||
val w = inputBitmap.width
|
||||
val h = inputBitmap.height
|
||||
val outputBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
|
||||
// KHUẾCH ĐẠI BIÊN ĐỘ: Nhân hệ số cường độ tác động để thông số thay đổi rõ rệt
|
||||
val enhancedBrightness = preset.brightness * 1.5f // Tăng lực cho Brightness
|
||||
val enhancedContrast = preset.contrast // Giữ nguyên dải Contrast chuẩn [0.5 - 2.5]
|
||||
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
val pixel = inputBitmap.getPixel(x, y)
|
||||
|
||||
var r = (pixel shr 16 and 0xFF) / 255.0f
|
||||
var g = (pixel shr 8 and 0xFF) / 255.0f
|
||||
var b = (pixel and 0xFF) / 255.0f
|
||||
|
||||
// 1. CẢI TIẾN BRIGHTNESS & CONTRAST (Áp dụng công thức dốc phi tuyến tính)
|
||||
// Lấy mốc 0.5f làm tâm điểm phối màu tương phản, tăng biên độ rõ rệt
|
||||
r = (r - 0.5f) * enhancedContrast + 0.5f + enhancedBrightness
|
||||
g = (g - 0.5f) * enhancedContrast + 0.5f + enhancedBrightness
|
||||
b = (b - 0.5f) * enhancedContrast + 0.5f + enhancedBrightness
|
||||
|
||||
// Tính toán độ sáng thực tế (Luminance) của pixel hiện tại sau khi chỉnh Brightness
|
||||
val luminance = 0.2126f * r + 0.7152f * g + 0.0722f * b
|
||||
|
||||
// 2. SỬA LỖI HIGHLIGHT: Dùng hàm S-Curve phi tuyến tính thay vì cộng trừ thông thường
|
||||
// Giúp vùng sáng được cứu (nếu giảm) hoặc tăng sáng rõ rệt (nếu tăng) mà không bị bết màu
|
||||
if (luminance > 0.5f) {
|
||||
// Tính toán trọng số dốc: Pixel càng sáng, tác động Highlight càng mạnh
|
||||
val highlightFactor = Math.pow(((luminance - 0.5f) / 0.5f).toDouble(), 1.5).toFloat()
|
||||
|
||||
// Nếu preset.highlight âm (-): dìm vùng sáng để cứu chi tiết
|
||||
// Nếu preset.highlight dương (+): đẩy vùng sáng lên rực rỡ hẳn
|
||||
r += preset.highlight * highlightFactor * 0.4f
|
||||
g += preset.highlight * highlightFactor * 0.4f
|
||||
b += preset.highlight * highlightFactor * 0.4f
|
||||
}
|
||||
|
||||
// 3. SỬA LỖI SHADOW: Tương tự như Highlight, dùng hàm lũy thừa ngược cho vùng tối
|
||||
if (luminance < 0.6f) {
|
||||
// Pixel càng tối (gần 0.0f), trọng số dốc Shadow càng đạt cực đại
|
||||
val shadowFactor = Math.pow(((0.6f - luminance) / 0.6f).toDouble(), 1.5).toFloat()
|
||||
|
||||
// Nếu preset.shadow dương (+): Cứu sáng vùng tối rõ rệt, phơi sáng chi tiết bóng râm
|
||||
// Nếu preset.shadow âm (-): Dìm sâu vùng tối để tạo độ sâu, tương phản khối gắt
|
||||
r += preset.shadow * shadowFactor * 0.5f
|
||||
g += preset.shadow * shadowFactor * 0.5f
|
||||
b += preset.shadow * shadowFactor * 0.5f
|
||||
}
|
||||
|
||||
// 4. XỬ LÝ SẮC ĐỘ (Saturation)
|
||||
val finalLuminance = (0.2126f * r + 0.7152f * g + 0.0722f * b).coerceIn(0.0f, 1.0f)
|
||||
r = finalLuminance + (r - finalLuminance) * preset.saturation
|
||||
g = finalLuminance + (g - finalLuminance) * preset.saturation
|
||||
b = finalLuminance + (b - finalLuminance) * preset.saturation
|
||||
|
||||
// Kiểm soát biên độ bảo vệ hạt màu không vượt quá giới hạn [0, 255]
|
||||
val finalR = (r.coerceIn(0.0f, 1.0f) * 255).toInt()
|
||||
val finalG = (g.coerceIn(0.0f, 1.0f) * 255).toInt()
|
||||
val finalB = (b.coerceIn(0.0f, 1.0f) * 255).toInt()
|
||||
|
||||
outputBitmap.setPixel(x, y, (0xFF shl 24) or (finalR shl 16) or (finalG shl 8) or finalB)
|
||||
}
|
||||
}
|
||||
return outputBitmap
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 3. Đồng Bộ Áp Dụng Khi Nhấn Shutter Chụp Ảnh
|
||||
|
||||
Để đảm bảo ảnh lưu vào thư viện giống hệt những gì người dùng nhìn thấy trên kính ngắm, cấu trúc luồng Coroutine ngầm khi bấm Shutter sẽ nhận trực tiếp tham số cấu hình Preset hiện tại:
|
||||
|
||||
```kotlin
|
||||
fun onShutterPressedWithPreset(capturedBitmap: Bitmap, activePreset: ColorPreset) {
|
||||
// Luồng ngầm xử lý lưu ảnh
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
// Áp dụng thuật toán High-Impact vừa nâng cấp vào ảnh gốc độ phân giải cao
|
||||
val filteredPhoto = applyHighImpactPreset(capturedBitmap, activePreset)
|
||||
|
||||
// Lưu ảnh vào Bộ sưu tập hệ thống
|
||||
saveToSystemGallery(filteredPhoto)
|
||||
filteredPhoto.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 4. Checklist Xác Minh Bản Sửa Đổi (Verification Checklist)
|
||||
|
||||
* [ ] **Kiểm thử thay đổi trên Viewfinder:** Chọn một Preset bất kỳ (Ví dụ: Đen trắng hoặc Vintage). Quan sát màn hình kính ngắm xem màu sắc có thay đổi ngay lập tức theo thời gian thực hay không.
|
||||
* [ ] **Kiểm thử cường độ Brightness / Contrast:** Tăng thử thông số Brightness lên `+0.5` hoặc Contrast lên `1.8`. (Yêu cầu: Ảnh phải sáng bừng lên hoặc độ tương phản khối gắt hiện rõ lập tức, không còn hiện tượng "nhẹ nhẹ" không rõ ràng).
|
||||
* [ ] **Kiểm thử tính năng Highlight:** Chụp một khung cảnh có cửa sổ trời rất sáng. Giảm Highlight xuống `-0.6`. (Yêu cầu: Vùng sáng quá mức quanh cửa sổ phải được dìm dịu đi rõ rệt, hiện rõ lại chi tiết vân khung cửa sắt).
|
||||
* [ ] **Kiểm thử tính năng Shadow:** Tìm góc tối trong phòng, tăng Shadow lên `+0.7`. (Yêu cầu: Các chi tiết khuất trong góc tối phải được phơi sáng bật lên rõ ràng, không còn bị bết đen góc ảnh).
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
Để hiển thị hiệu ứng **Nhiễu hạt (Film Grain/Noise)** trực tiếp trên kính ngắm (Viewfinder) của camera mà vẫn đảm bảo tốc độ mượt mà **60 FPS không bị lag**, chúng ta tuyệt đối không thể xử lý bằng vòng lặp pixel tuần tự trên CPU. Giải pháp tối ưu nhất là sử dụng sức mạnh đồ họa phần cứng (GPU Shader) trực tiếp ở tầng Flutter UI để phủ một lớp texture hạt động (Dynamic Grain Filter) đè lên luồng Camera Preview thô.
|
||||
|
||||
Dưới đây là kế hoạch Markdown chi tiết từ giải pháp giao diện hiển thị cho đến thuật toán đồng bộ áp hạt lên ảnh chụp gốc khi nhấn Shutter.
|
||||
|
||||
---
|
||||
|
||||
# 🎞️ KẾ HOẠCH TRIỂN KHAI: HIỂN THỊ NHIỄU HẠT (FILM GRAIN) REAL-TIME TRÊN VIEWFINDER
|
||||
|
||||
Mục tiêu cốt lõi:
|
||||
|
||||
1. **Live Preview trơn tru (60 FPS):** Sử dụng các hiệu ứng phần cứng đồ họa tích hợp hoặc Shader tùy biến trong Flutter để tạo lớp hạt chuyển động liên tục, phản hồi tức thì theo mức độ tăng giảm trên thanh thước đo.
|
||||
2. **Dynamic Noise Generation:** Hạt nhiễu phải liên tục thay đổi cấu trúc qua từng khung hình (không phải ảnh hạt tĩnh đứng im) để tạo cảm giác nhiễu hạt phim điện ảnh tự nhiên.
|
||||
3. **Đồng bộ ảnh chụp độ phân giải cao:** Áp dụng thuật toán tạo nhiễu hạt tương ứng lên Bitmap ảnh gốc dưới luồng ngầm (Background Coroutine) khi bấm chụp.
|
||||
|
||||
---
|
||||
|
||||
## 📐 1. Tích Hợp Lớp Phủ Nhiễu Hạt Động Trên Giao Diện UI (`CameraScreen.dart`)
|
||||
|
||||
Trong Flutter, phương pháp tối ưu nhất để tạo nhiễu hạt thực tế mà không gánh CPU là sử dụng một hàm lặp tạo hạt ngẫu nhiên thông qua widget **`CustomPainter`** kết hợp với một vòng lặp Animation kích hoạt vẽ lại (repaint) liên tục, tạo ra các hạt nhiễu chuyển động động học.
|
||||
|
||||
### 💻 Đoạn mã tạo hiệu ứng Texture hạt trên kính ngắm:
|
||||
|
||||
```dart
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// 1. LỚP VẼ HẠT NHIỄU PHẦN CỨNG (GPU ACCELERATED PAINTER)
|
||||
class GrainPainter extends CustomPainter {
|
||||
final double intensity; // Độ đậm/nhạt của hạt grain (0.0 -> 1.0)
|
||||
final Random _random = Random();
|
||||
|
||||
GrainPainter({required this.intensity});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (intensity <= 0) return;
|
||||
|
||||
final Paint paint = Paint()..style = PaintingStyle.fill;
|
||||
|
||||
// Tính toán mật độ hạt dựa trên thông số cấu hình.
|
||||
// Tránh quét từng pixel, ta chọn ngẫu nhiên các điểm tọa độ để tối ưu RAM
|
||||
int grainCount = (size.width * size.height * 0.1 * intensity).toInt();
|
||||
|
||||
for (int i = 0; i < grainCount; i++) {
|
||||
double x = _random.nextDouble() * size.width;
|
||||
double y = _random.nextDouble() * size.height;
|
||||
|
||||
// Tạo sắc độ hạt ngẫu nhiên từ xám trắng đến xám tối để giả lập Grain tự nhiên
|
||||
int grayValue = _random.nextInt(55) + 100; // Dải màu hạt phim [100 - 155]
|
||||
// Độ trong suốt của từng hạt phụ thuộc vào cường độ chỉnh trên thước đo
|
||||
int alpha = (_random.nextDouble() * intensity * 120).toInt().clamp(0, 255);
|
||||
|
||||
paint.color = Color.fromARGB(alpha, grayValue, grayValue, grayValue);
|
||||
|
||||
// Vẽ hạt nhiễu kích thước siêu nhỏ từ 1 đến 1.5 pixel
|
||||
canvas.drawRect(Rect.fromLTWH(x, y, 1.2, 1.2), paint);
|
||||
}
|
||||
}
|
||||
|
||||
// Luôn trả về true để ép GPU sinh ma trận hạt mới liên tục theo khung hình Animation
|
||||
@override
|
||||
bool shouldRepaint(covariant GrainPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
// 2. TÍCH HỢP LỚP HẠT ĐỘNG VÀO VIEWFINDER
|
||||
class LiveCameraGrainWidget extends StatefulWidget {
|
||||
final double grainIntensity; // Thừa hưởng từ thanh trượt cài đặt
|
||||
const LiveCameraGrainWidget({super.key, required this.grainIntensity});
|
||||
|
||||
@override
|
||||
State<LiveCameraGrainWidget> createState() => _LiveCameraGrainWidgetState();
|
||||
}
|
||||
|
||||
class _LiveCameraGrainWidgetState extends State<LiveCameraGrainWidget> with SingleTickerProviderStateMixin {
|
||||
AnimationController? _animationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState() ;
|
||||
// Vòng lặp vô hạn chạy ở tần số quét màn hình (60Hz) để đổi vị trí hạt liên tục
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Lớp đáy: Kính ngắm camera thô (mượt mà 60 FPS)
|
||||
CameraPreview(_cameraController),
|
||||
|
||||
// Lớp đỉnh: Phủ lớp texture hạt động bằng AnimatedBuilder
|
||||
AnimatedBuilder(
|
||||
animation: _animationController!,
|
||||
builder: (context, child) {
|
||||
return CustomPaint(
|
||||
size: Size.infinite,
|
||||
painter: GrainPainter(intensity: widget.grainIntensity),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 2. Thuật Toán Sinh Nhiễu Hạt Lên Ảnh Gốc Khi Bấm Chụp (`MainActivity.kt`)
|
||||
|
||||
Khi người dùng nhấn nút Shutter, để bức ảnh lưu về máy có hiệu ứng hạt giống hệt như kính ngắm, chúng ta đưa thuật toán toán học tạo hạt phim (Film Grain Noise Model) vào luồng xử lý ngầm Coroutine để tránh đơ ứng dụng.
|
||||
|
||||
```kotlin
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import java.util.Random
|
||||
|
||||
fun applyHighQualityFilmGrain(inputBitmap: Bitmap, grainIntensity: Float): Bitmap {
|
||||
if (grainIntensity <= 0.0f) return inputBitmap
|
||||
|
||||
val w = inputBitmap.width
|
||||
val h = inputBitmap.height
|
||||
val outputBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val random = Random()
|
||||
|
||||
// Khuếch đại biên độ tác động nhiễu hạt để tạo sự thay đổi rõ rệt
|
||||
val grainStrength = grainIntensity * 45.0f
|
||||
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
val pixel = inputBitmap.getPixel(x, y)
|
||||
|
||||
val r = (pixel shr 16 and 0xFF)
|
||||
val g = (pixel shr 8 and 0xFF)
|
||||
val b = (pixel and 0xFF)
|
||||
|
||||
// Sử dụng thuật toán phân phối Gauss (Gaussian Noise) để hạt mịn và chân thực hơn nhiễu thô
|
||||
val noise = (random.nextGaussian() * grainStrength).toInt()
|
||||
|
||||
// Cộng hạt nhiễu trực tiếp vào từng kênh màu, khống chế dải màu trong giới hạn [0, 255]
|
||||
val finalR = (r + noise).coerceIn(0, 255)
|
||||
val finalG = (g + noise).coerceIn(0, 255)
|
||||
val finalB = (b + noise).coerceIn(0, 255)
|
||||
|
||||
outputBitmap.setPixel(x, y, (0xFF shl 24) or (finalR shl 16) or (finalG shl 8) or finalB)
|
||||
}
|
||||
}
|
||||
return outputBitmap
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 3. Đồng Bộ Luồng Xử Lý Bất Đồng Bộ Khi Chụp
|
||||
|
||||
Đảm bảo ảnh xuất ra từ camera được nạp đầy đủ thông số cấu hình nhiễu hạt hiện tại từ thanh trượt điều khiển:
|
||||
|
||||
```kotlin
|
||||
fun onShutterPressedWithGrain(capturedBitmap: Bitmap, currentGrainIntensity: Float) {
|
||||
// 1. Kích hoạt chớp màn hình và hiển thị thumbnail lập tức
|
||||
triggerFlashEffectUI()
|
||||
|
||||
// 2. Cô lập tiến trình đổ màu hạt xuống tầng luồng ngầm độc lập
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
// Áp dụng thuật toán gieo hạt nhiễu độ phân giải cao
|
||||
val grainPhoto = applyHighQualityFilmGrain(capturedBitmap, currentGrainIntensity)
|
||||
|
||||
// Tiến hành ghi file vào Bộ sưu tập hệ thống
|
||||
saveToSystemGallery(grainPhoto)
|
||||
grainPhoto.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 4. Checklist Xác Minh Bản Sửa Đổi (Verification Checklist)
|
||||
|
||||
* [ ] **Kiểm thử tốc độ Viewfinder (FPS Test):** Kéo thông số grain lên mức tối đa (`+100`) trên thanh thước đo. Quan sát kính ngắm camera xem luồng preview có di chuyển trơn tru, ổn định không (Yêu cầu: Khung hình không bị tụt FPS, đạt độ mượt mà phần cứng).
|
||||
* [ ] **Kiểm thử độ động học của hạt (Dynamic Test):** Nhìn kỹ vào màn hình kính ngắm khi đứng im camera. (Yêu cầu: Các hạt nhiễu hạt phải chuyển động nhảy liên tục như nhiễu tivi hoặc phim nhựa cổ điển, không được phép đứng im thành một tấm lưới tĩnh).
|
||||
* [ ] **Kiểm thử chất lượng ảnh lưu (Output Check):** Bấm nút Shutter chụp ảnh, mở Bộ sưu tập hệ thống ra phóng to ảnh xem các hạt nhiễu có được trộn đều, mịn vào các vùng màu mảng lớn (như vùng tối, mảng tường phẳng) đúng chất liệu Vintage không.
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
Dưới đây là kế hoạch Markdown chi tiết để **khôi phục lại toàn bộ tính năng Xóa phông (Blur Background) và Chèn ảnh nền mới (Replace Background)** bằng mô hình `multiclass_segmenter.tflite` của Google MediaPipe.
|
||||
|
||||
Kế hoạch này đảm bảo **giữ nguyên 100% tính năng Focus vật lý bằng phần cứng thấu kính**, nghĩa là động cơ camera vẫn di chuyển lấy nét theo điểm chạm quang học, còn AI chỉ đảm nhận vai trò bóc tách và xử lý đồ họa kỹ thuật số trên bức ảnh thu được.
|
||||
|
||||
---
|
||||
|
||||
# 📑 KẾ HOẠCH PHỤC HỒI TÍNH NĂNG XÓA NỀN AI VÀ CHÈN HÌNH NỀN (GOOGLE MULTICLASS SEGMENTER)
|
||||
|
||||
Mục tiêu cốt lõi:
|
||||
|
||||
1. **Focus vật lý làm gốc:** Mặc định giữ nguyên luồng điều khiển mô-tơ thấu kính phần cứng (`applyHardwareTouchToFocus`) qua Camera2 API khi người dùng chạm vào Viewfinder.
|
||||
2. **Khôi phục AI tách lớp:** Kích hoạt lại `multiclass_segmenter.tflite` để phân tách pixel ảnh chụp thành các nhãn: Người (ID 1), Đồ vật (ID 3), Nền (ID 0).
|
||||
3. **Phục hồi 2 tính năng đồ họa:**
|
||||
* *Xóa nền:* Làm mờ phông nền thuật toán (kết hợp khử Ghost Shadow và mịn viền tóc).
|
||||
* *Chèn nền:* Thay thế vùng phông nền bằng một Bitmap hình ảnh mới do người dùng chọn.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📐 1. Quy Hoạch Trạng Thái Tính Năng Trên Giao Diện (`CameraScreen.dart`)
|
||||
|
||||
Giao diện sẽ quản lý 3 trạng thái độc lập thông qua Panel điều khiển Tầng 2 (như trong ảnh mẫu):
|
||||
|
||||
* **Chế độ Mặc định:** Chỉ dùng Focus vật lý của thấu kính, ảnh giữ nguyên thô nguyên bản từ cảm biến.
|
||||
* **Chế độ Xóa nền (Blur):** Thấu kính vẫn lấy nét vật lý + AI làm mờ thêm hậu cảnh phần mềm theo thước Ruler `0 - 100`.
|
||||
* **Chế độ Thay nền (Background 1, 2, 3):** Thấu kính vẫn lấy nét vật lý + AI bóc người ra và chèn tấm ảnh nền mới vào.
|
||||
|
||||
```dart
|
||||
enum PhotoEffectMode { defaultFocus, aiBlur, aiReplaceBg }
|
||||
|
||||
PhotoEffectMode _currentMode = PhotoEffectMode.defaultFocus;
|
||||
String? _selectedBgPath; // Đường dẫn ảnh nền mới (Nền 1, Nền 2, Nền 3)
|
||||
double _blurIntensity = 0.0; // Giá trị thước đo Ruler (0.0 -> 1.0)
|
||||
|
||||
Widget _buildActionPanel() {
|
||||
return Row(
|
||||
children: [
|
||||
_buildButton(name: "Mặc định", action: () => setState(() => _currentMode = PhotoEffectMode.defaultFocus)),
|
||||
_buildButton(name: "Xóa nền", action: () => setState(() => _currentMode = PhotoEffectMode.aiBlur)),
|
||||
_buildButton(name: "Nền 1", action: () {
|
||||
setState(() {
|
||||
_currentMode = PhotoEffectMode.aiReplaceBg;
|
||||
_selectedBgPath = "assets/bg/mountain.jpg"; // Hoặc tệp tải về từ nút "..."
|
||||
});
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 2. Thuật Toán Lõi: Tích Hợp Song Song Thay Nền Và Xóa Phông AI (`MainActivity.kt`)
|
||||
|
||||
Hàm xử lý ảnh dưới tầng Native Android (`MainActivity.kt`) sẽ được khôi phục toàn diện để nhận diện đồng thời cả lệnh chèn hình nền lẫn lệnh làm mờ, triệt tiêu hoàn toàn lỗi vệt xám bọc quanh đầu tóc và bóng ma rế viền (Ghost Shadow).
|
||||
|
||||
```kotlin
|
||||
fun processRestoredAiBackgroundEngine(
|
||||
capturedBitmap: Bitmap, // Ảnh thô thu được sau khi camera đã lấy nét vật lý xong
|
||||
categoryMaskBuffer: ByteBuffer, // Buffer mặt nạ từ mô hình multiclass_segmenter.tflite
|
||||
effectMode: Int, // 0: Mặc định, 1: Xóa nền, 2: Thay nền
|
||||
bgBitmap: Bitmap?, // Bitmap của hình nền mới (nếu có)
|
||||
rulerValue: Int // Độ nhòe từ 0 đến 100
|
||||
): Bitmap {
|
||||
val w = capturedBitmap.width
|
||||
val h = capturedBitmap.height
|
||||
|
||||
// NẾU CHẾ ĐỘ MẶC ĐỊNH HOẶC KHÔNG CÓ HIỆU ỨNG: Xuất ảnh thô vật lý ngay lập tức
|
||||
if (effectMode == 0 || (effectMode == 1 && rulerValue == 0)) {
|
||||
return capturedBitmap
|
||||
}
|
||||
|
||||
// --- BƯỚC 1: TRÍCH XUẤT VÀ LÀM MỊN VIỀN TÓC (SOFT ALPHA MASK) ---
|
||||
val alphaMask = FloatArray(w * h)
|
||||
categoryMaskBuffer.rewind()
|
||||
|
||||
for (i in 0 until (w * h)) {
|
||||
val classId = categoryMaskBuffer.get(i).toInt()
|
||||
// Xác định thực thể chính cần giữ nét: Người (ID 1) và Đồ vật phụ trợ (ID 3)
|
||||
alphaMask[i] = if (classId == 1 || classId == 3) 1.0f else 0.0f
|
||||
}
|
||||
// Thuật toán tích chập 1D làm mờ mặt nạ (radius = 4) để viền tóc lồng mượt vào nền, không bị cắt mạnh gắt
|
||||
blurAlphaMask1D(alphaMask, w, h, radius = 4)
|
||||
|
||||
// --- BƯỚC 2: QUY QUYẾT NGUỒN PHÔNG NỀN (BASE BACKGROUND CANVAS) ---
|
||||
// Nếu chọn THAY NỀN, nguồn nền là tấm ảnh mới (bgBitmap) được scale vừa khung hình.
|
||||
// Nếu chọn XÓA NỀN, nguồn nền chính là ảnh chụp gốc nhưng được xử lý khử Ghost Shadow.
|
||||
val baseBgCanvas = if (effectMode == 2 && bgBitmap != null) {
|
||||
Bitmap.createScaledBitmap(bgBitmap, w, h, true)
|
||||
} else {
|
||||
// Thuật toán khử Ghost Shadow: Lấp đầy các pixel màu da bằng hạt màu nền xung quanh
|
||||
createCleanBackground(capturedBitmap, alphaMask, w, h)
|
||||
}
|
||||
|
||||
// --- BƯỚC 3: XỬ LÝ LÀM MỜ NỀN PHÂN CẤP THEO THƯỚC ĐO SỐ MŨ (RULER DoF) ---
|
||||
val normalizedSlider = rulerValue / 100.0f
|
||||
val opticalIntensity = Math.pow(normalizedSlider.toDouble(), 2.5).toFloat()
|
||||
val maxRadius = (35 * opticalIntensity).coerceAtLeast(1.0f)
|
||||
|
||||
// Tạo các phân lớp mờ tăng tiến mô phỏng chiều sâu quang học tự nhiên
|
||||
val blurStage1 = boxBlur(baseBgCanvas, (maxRadius * 0.3f).toInt().coerceAtLeast(1))
|
||||
val blurStage2 = boxBlur(baseBgCanvas, (maxRadius * 0.6f).toInt().coerceAtLeast(1))
|
||||
val blurStage3 = boxBlur(baseBgCanvas, maxRadius.toInt().coerceAtLeast(1))
|
||||
baseBgCanvas.recycle()
|
||||
|
||||
// --- BƯỚC 4: ALPHA BLENDING TRỘN HÒA QUYỆN ĐỐI TƯỢNG 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] // Trọng số mượt mà từ 0.0 (Nền) -> 1.0 (Người)
|
||||
|
||||
// Đo cự ly hình học tính từ rìa biên mặt nạ đối tượng để phân cấp độ mờ hậu cảnh
|
||||
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)
|
||||
|
||||
// Trộn công thức màu Alpha Blending
|
||||
val outR = (fgPixel.r * alpha + bgPixel.r * (1.0f - alpha)).toInt()
|
||||
val outG = (fgPixel.g * alpha + bgPixel.g * (1.0f - alpha)).toInt()
|
||||
val outB = (fgPixel.b * alpha + bgPixel.b * (1.0f - alpha)).toInt()
|
||||
|
||||
outputBitmap.setPixel(x, y, Color.rgb(outR, outG, outB))
|
||||
}
|
||||
}
|
||||
|
||||
// Giải phóng bộ đệm để chống tràn RAM thiết bị
|
||||
blurStage1.recycle(); blurStage2.recycle(); blurStage3.recycle()
|
||||
|
||||
return outputBitmap
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 3. Đồng Bộ Luồng Chụp Ảnh Ngầm Không Gây Đơ Camera Preview
|
||||
|
||||
Luồng phần cứng thấu kính camera được tách lập độc lập hoàn toàn. Khi nhấn Shutter, ảnh thô (đã được thấu kính lấy nét vật lý chuẩn xác) được chụp lại, và hệ thống camera lập tức mở lại Preview để sẵn sàng cho lần chụp sau. Toàn bộ cụm xử lý AI phân lớp đa mục tiêu và thay/xóa nền được đẩy xuống luồng ngầm Coroutine chạy song song.
|
||||
|
||||
```kotlin
|
||||
fun onShutterActionTriggered(capturedBitmap: Bitmap, rawMaskBuffer: ByteBuffer) {
|
||||
// 1. Phản hồi giao diện lập tức: Chớp màn hình, đẩy ảnh thumbnail về góc trái
|
||||
triggerFlashAnimationUI()
|
||||
pushThumbnailPreviewToScreen(capturedBitmap)
|
||||
|
||||
// 2. Kích hoạt khôi phục luồng Preview vật lý của thấu kính camera ngay lập tức trên UI Thread
|
||||
runOnUiThread {
|
||||
cameraCaptureSession.setRepeatingRequest(previewRequestBuilder.build(), null, null)
|
||||
}
|
||||
|
||||
// 3. Thực thi ngầm luồng xử lý đồ họa nâng cao (Background Thread)
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
val finalPhoto = processRestoredAiBackgroundEngine(
|
||||
capturedBitmap = capturedBitmap,
|
||||
categoryMaskBuffer = rawMaskBuffer,
|
||||
effectMode = currentSelectedModeId, // Nhận từ Flutter UI chuyển xuống
|
||||
bgBitmap = currentLoadedBgBitmap,
|
||||
rulerValue = currentRulerValue
|
||||
)
|
||||
|
||||
// Lưu tệp ảnh hoàn chỉnh sạch sẽ vào thư viện ảnh hệ thống
|
||||
saveProcessedBitmapToGallery(finalPhoto)
|
||||
capturedBitmap.recycle()
|
||||
finalPhoto.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 4. Checklist Xác Minh Triển Khai Thực Địa (Verification Checklist)
|
||||
|
||||
* [ ] **Kiểm thử tính năng Thay nền:** Chọn một ảnh nền phong cảnh (`Nền 1`). Nhấn chụp chân dung. (Yêu cầu: Phần cơ thể bạn phải được bóc tách sạch sẽ, đặt khớp lên hình nền mới, đường viền đầu tóc chuyển tiếp mượt mà, không xuất hiện quầng vệt xám loang lổ).
|
||||
* [ ] **Kiểm thử tính năng Xóa nền phối hợp:** Bật chế độ `Xóa nền`, tăng thước trượt lên `+50`. (Yêu cầu: Hậu cảnh gốc bị làm mờ mịn màng tăng tiến, vùng rìa vai áo không xuất hiện bóng ma Ghost Shadow, và khu vực cơ thể nhân vật đạt độ nét đồng khối tự nhiên nhờ thấu kính quang học).
|
||||
* [ ] **Xác minh tính độc lập của Focus vật lý:** Thử chạm vào khuôn mặt xem camera có tự động điều khiển mô-tơ di chuyển thấu kính để lấy nét mặt không. Thử chạm tiếp vào một vật ở xa xem camera có tự động dời thấu kính lấy nét ra xa không. (Yêu cầu: Tiêu cự camera chuyển động quang học bình thường, không bị can thiệp hay xung đột bởi thuật toán AI đồ họa).
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
Để thực hiện các tính năng chỉnh sửa chi tiết cấu trúc khuôn mặt như phóng to/thu nhỏ mắt, thu gọn cánh mũi, nâng cằm, hạ trán ngay trên điện thoại, các ứng dụng nhiếp ảnh hiện nay sử dụng một nhóm công nghệ AI chuyên biệt gọi là **Mô hình Nhận diện Điểm cốt lõi Khuôn mặt (Face Landmark Detection)** kết hợp với thuật toán **Biến dạng Đồ họa Động (Image Warping/Mesh Deformation)**.
|
||||
|
||||
Dưới đây là kế hoạch Markdown chi tiết mô tả cơ chế hoạt động của các tính năng làm đẹp này, các mô hình AI on-device (chạy trực tiếp trên chip điện thoại) phổ biến, và cách tích hợp chúng vào dự án của bạn.
|
||||
|
||||
---
|
||||
|
||||
# 👤 TÀI LIỆU CÔNG NGHỆ: CHỈNH SỬA CHI TIẾT CHÂN DUNG (FACE RECOGNITION & LANDMARK WARPING) ON-DEVICE
|
||||
|
||||
Mục tiêu cốt lõi:
|
||||
|
||||
1. Hiểu cách AI định vị các bộ phận: Mắt, Mũi, Miệng, Trán, Cằm.
|
||||
2. Ứng dụng ma trận điểm (Mesh) để co giãn, thay đổi kích thước bộ phận mà không làm méo tổng thể bức ảnh.
|
||||
3. Giới thiệu các mô hình AI siêu nhẹ, chạy mượt mà 60 FPS trực tiếp trên phần cứng điện thoại.
|
||||
|
||||
---
|
||||
|
||||
## 📐 1. Nguyên Lý Hoạt Động Của AI Làm Đẹp (The Anatomy of Digital Beauty)
|
||||
|
||||
Quy trình chỉnh sửa chân dung bằng AI trên điện thoại diễn ra qua 3 bước nghiêm ngặt sau:
|
||||
|
||||
### 🔹 Bước 1: Quét hệ thống điểm cốt lõi (Face Landmark Detection)
|
||||
|
||||
Mô hình AI sẽ không nhìn khuôn mặt như một tấm ảnh phẳng, mà nó sẽ tự động chấm lên mặt bạn từ **68 đến 468 điểm tọa độ cố định** (Landmarks).
|
||||
|
||||
* **Mắt:** Được bao bọc bởi khoảng 6-8 điểm quanh mí mắt.
|
||||
* **Mũi:** Cố định bởi các điểm dọc sống mũi và cánh mũi.
|
||||
* **Trán và Cằm:** Định vị bằng các điểm dọc theo đường viền xương hàm (Jawline) và chân tóc.
|
||||
|
||||
### 🔹 Bước 2: Tạo lưới đa giác lập thể (Face Mesh Generation)
|
||||
|
||||
Từ các điểm tọa độ này, AI nối chúng lại với nhau tạo thành một mạng lưới hàng trăm đa giác nhỏ bao bọc lấy khuôn mặt (giống như mặt nạ 3D lưới).
|
||||
|
||||
### 🔹 Bước 3: Biến dạng hình học cục bộ (Local Mesh Deformation / Warping)
|
||||
|
||||
Khi bạn kéo thanh trượt Ruler trên ứng dụng (ví dụ: Thu nhỏ mũi hoặc phóng to mắt):
|
||||
|
||||
* Thuật toán sẽ **chỉ dịch chuyển tọa độ các điểm thuộc bộ phận đó** (Ví dụ: ép các điểm cánh mũi dịch vào tâm, hoặc đẩy các điểm mí mắt giãn ra xa nhau).
|
||||
* Phần mềm đồ họa (thông qua GPU Shader như OpenGL/Vulkan/Metal) sẽ tự động co giãn các pixel ảnh nằm bên trong các đa giác tương ứng. Vì chỉ có vùng lưới của bộ phận đó dịch chuyển, nên phông nền xung quanh hoặc các bộ phận khác hoàn toàn không bị méo mó theo.
|
||||
|
||||
---
|
||||
|
||||
## 📦 2. Các Mô Hình AI Chuyên Chức Năng Này Trên Điện Thoại
|
||||
|
||||
Nếu bạn muốn tích hợp tính năng này vào ứng dụng của mình, đây là những mô hình AI mã nguồn mở, siêu nhẹ (On-device) tốt nhất hiện nay:
|
||||
|
||||
### 🌟 1. Google MediaPipe Face Mesh (Khuyên dùng nhất)
|
||||
|
||||
* **Dung lượng:** ~3MB - 5MB (Dạng `.tflite`).
|
||||
* **Khả năng:** Nhận diện thời gian thực lên tới **468 điểm tọa độ 3D** trên khuôn mặt (có bản nâng cấp lên 478 điểm bao gồm cả con ngươi mắt).
|
||||
* **Hiệu năng:** Chạy cực mượt (gần 60 FPS) trên cả Android và iOS nhờ tối ưu hóa GPU phần cứng. Nó bóc tách cực chi tiết viền môi, mí mắt, giúp việc phóng to mắt hay thu nhỏ môi chính xác đến từng pixel.
|
||||
|
||||
### 🌟 2. Ý tưởng từ InsightFace (Mô hình chuyên sâu)
|
||||
|
||||
* **Khả năng:** Nhận diện trọn vẹn cấu trúc xương mặt để phục vụ căn chỉnh tỷ lệ vàng (Face Reshaping).
|
||||
* **Nhược điểm:** Nặng hơn MediaPipe, thường dùng cho việc xử lý ảnh tĩnh sau khi chụp hơn là chạy Live preview trên kính ngắm.
|
||||
|
||||
---
|
||||
|
||||
## 💻 3. Logic Thiết Kế Mã Nguồn Giả Lập Điều Chỉnh Kích Thước (`MainActivity.kt`)
|
||||
|
||||
Khi người dùng tăng giảm thông số trên thanh kéo, chúng ta can thiệp trực tiếp vào ma trận điểm Mesh của MediaPipe trước khi render ra màn hình:
|
||||
|
||||
```kotlin
|
||||
import android.graphics.Bitmap
|
||||
import com.google.mediapipe.tasks.vision.facemesh.FaceMeshResult
|
||||
|
||||
fun applyFaceReshaping(
|
||||
inputBitmap: Bitmap,
|
||||
faceMeshResult: FaceMeshResult,
|
||||
eyeSizeFactor: Float, // Hệ số chỉnh mắt (Ví dụ: 1.0 là gốc, 1.2 là phóng to 20%)
|
||||
noseSizeFactor: Float // Hệ số chỉnh mũi (Ví dụ: 0.9 là thu nhỏ cánh mũi 10%)
|
||||
): Bitmap {
|
||||
val outputBitmap = Bitmap.createBitmap(inputBitmap.width, inputBitmap.height, Bitmap.Config.ARGB_8888)
|
||||
|
||||
// 1. Trích xuất danh sách tọa độ 468 điểm từ MediaPipe
|
||||
val landmarks = faceMeshResult.multiFaceLandmarks()[0]
|
||||
|
||||
// 2. Định vị danh sách ID điểm thuộc về Mắt trái, Mắt phải, Cánh mũi
|
||||
val leftEyeCenter = calculateCenterPoint(landmarks, listOf(33, 133, 159, 145)) // Điểm trung tâm mắt
|
||||
val noseCenter = calculateCenterPoint(landmarks, listOf(1, 2, 98, 327)) // Điểm trung tâm mũi
|
||||
|
||||
// 3. Chạy thuật toán Warp Pixel cục bộ (Local Texture Warping)
|
||||
// Duyệt qua các pixel, nếu pixel nằm gần vùng mắt/mũi, ta nhân tọa độ với hệ số Factor
|
||||
// để ép hình ảnh co lại hoặc giãn ra từ tâm bộ phận đó.
|
||||
|
||||
// Thuật toán này được thực thi bằng mã Shader (OpenGL ES) dưới GPU để đạt tốc độ thời gian thực.
|
||||
return outputBitmap
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 4. Định Hướng Tích Hợp Vào Dự Án Photobooth Hiện Tại
|
||||
|
||||
Tính năng này có cấu trúc vận hành hoàn toàn đồng bộ với hệ thống **MediaPipe Multiclass Segmenter** và **Focus vật lý** mà chúng ta đã xây dựng:
|
||||
|
||||
1. **Hiển thị trên Viewfinder (Real-time Preview):** Luồng camera Preview thô vẫn chạy 60 FPS. Ta nạp model `Face Mesh` chạy song song (vì model này rất nhẹ, chỉ tốn khoảng 2-3ms để quét 1 khung hình). GPU của điện thoại sẽ áp lưới ma trận màu và lưới co giãn bộ phận trực tiếp lên Viewfinder. Bạn sẽ thấy mắt mình to lên hoặc mũi nhỏ lại **ngay khi đang ngắm chuẩn bị chụp**.
|
||||
2. **Khi bấm Shutter:** Thấu kính camera thực hiện lấy nét vật lý chuẩn xác $\rightarrow$ Chụp ảnh gốc độ phân giải cao $\rightarrow$ Gửi ảnh gốc + Các thông số co giãn mặt (`eyeSize, noseSize`) xuống luồng ngầm Coroutine để xử lý xuất file JPG hoàn chỉnh sắc nét.
|
||||
|
||||
---
|
||||
|
||||
## 📅 5. Checklist Thử Nghiệm Tính Năng (QA Checklist)
|
||||
|
||||
* [ ] **Kiểm thử vùng biên (No Distort):** Kéo thanh trượt "Thu nhỏ cằm" hoặc "Phóng to mắt" lên mức tối đa. Quan sát kỹ phần phông nền xung quanh (bức tường, cửa sổ sắt phía sau). (Yêu cầu: Chỉ có bộ phận trên mặt thay đổi kích thước, phông nền thẳng phía sau tuyệt đối không được bị cong hay méo theo tay kéo).
|
||||
* [ ] **Kiểm thử góc nghiêng (Angle Rotation):** Thử quay nghiêng mặt góc 30-45 độ rồi chỉnh thông số. (Yêu cầu: AI Face Mesh vẫn phải khóa được vị trí mắt mũi theo chiều không gian 3D để co giãn đúng hướng thấu kính, không bị lệch vùng chọn ra ngoài má).
|
||||
@@ -0,0 +1,147 @@
|
||||
Hai lỗi này liên quan trực tiếp đến hiện tượng **bất đồng bộ luồng (Race Condition)** và **quản lý vòng đời bộ đệm (State Lifecycle/Caching)** giữa tầng Flutter UI và tầng Native Android (`MainActivity.kt`).
|
||||
|
||||
* **Lỗi nhấn shutter lúc chụp lúc không:** Do khi bạn đổi Preset, luồng ngầm xử lý ma trận màu của Preset đó đang chiếm dụng tài nguyên đồ họa (Bitmap/ImageReader) cùng lúc với lệnh chụp `takePicture()`. Nếu lệnh chụp đến trước khi Preset xử lý xong $\rightarrow$ Chụp lỗi/Bị nuốt lệnh.
|
||||
* **Lỗi đổi Preset phải bấm vài lần mới ăn:** Do biến lưu cấu hình Preset ở tầng Native (`activePreset`) không được cập nhật `volatile` thời gian thực, hoặc do bộ đệm xem trước (Preview Request Builder) không được gọi lệnh làm tươi (`setRepeatingRequest`) ngay lập tức sau khi gán thông số mới.
|
||||
|
||||
Dưới đây là kế hoạch Markdown chi tiết để sửa dứt điểm hai lỗi trên, đảm bảo đổi bộ lọc màu ăn ngay lập tức và nút bấm Shutter phản hồi chính xác 100%.
|
||||
|
||||
---
|
||||
|
||||
# 🛠️ KẾ HOẠCH FIX LỖI: ĐỒNG BỘ ĐỒNG THỜI PRESET REAL-TIME VÀ SỬA KHÓA LỆNH SHUTTER
|
||||
|
||||
---
|
||||
|
||||
## 📐 1. Khắc Phục Lỗi Thay Đổi Preset Không Ăn Ngay Lập Tức (`MainActivity.kt`)
|
||||
|
||||
**Giải pháp:** 1. Thêm từ khóa `volatile` vào biến lưu trữ Preset ở tầng Native để đảm bảo mọi luồng xử lý (Main Thread và Background Thread) đều đọc chung một giá trị mới nhất ngay khi người dùng chạm tay chọn.
|
||||
2. Ép cấu hình Camera2 API làm tươi (Refresh Buffer) ngay lập tức bằng lệnh `setRepeatingRequest` sau khi người dùng thay đổi thông số.
|
||||
|
||||
```kotlin
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
// 🟢 SỬA LỖI: Dùng @Volatile để đảm bảo thông số Preset cập nhật ngay lập tức xuyên luồng
|
||||
@Volatile
|
||||
private var currentActivePreset: ColorPreset? = null
|
||||
|
||||
// Hàm nhận lệnh đổi Preset từ Flutter UI truyền xuống thông qua MethodChannel
|
||||
fun updateActivePresetFromUI(newPreset: ColorPreset) {
|
||||
this.currentActivePreset = newPreset
|
||||
|
||||
// 🟢 SỬA LỖI PHẢI BẤM VÀI LẦN: Ép thấu kính camera cập nhật ma trận màu Viewfinder ngay lập tức
|
||||
try {
|
||||
previewRequestBuilder.set(
|
||||
CaptureRequest.COLOR_CORRECTION_MODE,
|
||||
CaptureRequest.COLOR_CORRECTION_MODE_TRANSFORM_MATRIX
|
||||
)
|
||||
|
||||
// Cập nhật ma trận màu phần cứng dựa trên thông số Preset mới chọn
|
||||
applyPresetToPreviewBuilder(previewRequestBuilder, newPreset)
|
||||
|
||||
// Lệnh làm tươi Live Preview ngay một miligiây sau khi chọn
|
||||
cameraCaptureSession.setRepeatingRequest(previewRequestBuilder.build(), null, null)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 2. Khắc Phục Lỗi Nút Shutter Lúc Chụp Lúc Không (Chống Nghẽn Tài Nguyên)
|
||||
|
||||
**Nguyên nhân:** Khi bạn nhấn Shutter, ứng dụng chụp lại ảnh thô. Nếu lúc đó bạn vừa đổi Preset, hệ thống đồ họa bị "xung đột" quyền truy cập Bitmap. Nếu luồng chụp không khóa hàng đợi (Thread Lock), lệnh `capture` sẽ bị hủy âm thầm khiến camera đứng im không chụp.
|
||||
|
||||
**Giải pháp:** Sử dụng cờ trạng thái `isCapturing` và cơ chế **Đóng băng cấu hình tạm thời (Snapshot Parameter)**. Khi bấm Shutter, ta nhân bản nhanh thông số Preset tại thời điểm đó rồi đẩy vào luồng xử lý riêng, giải phóng luồng chính để sẵn sàng nhận lệnh chụp tiếp theo.
|
||||
|
||||
```kotlin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
// Sử dụng AtomicBoolean chống hiện tượng kích kép (Double tap) hoặc nghẽn lệnh Shutter
|
||||
private val isCapturing = AtomicBoolean(false)
|
||||
|
||||
fun onShutterButtonPressed() {
|
||||
// Nếu đang trong tiến trình bắt ảnh thô từ cảm biến, chặn không cho bấm tiếp để tránh nghẽn phần cứng
|
||||
if (isCapturing.get()) {
|
||||
print("Camera đang bận chụp tấm trước, vui lòng đợi mốc miligiây!")
|
||||
return
|
||||
}
|
||||
|
||||
// Khóa trạng thái bắt đầu chụp
|
||||
isCapturing.set(true)
|
||||
triggerFlashAnimationUI() // Kích hoạt hiệu ứng chớp màn hình lập tức cho người dùng biết
|
||||
|
||||
// 🟢 CHỐNG XUNG ĐỘT: Sao chép nhanh (Snapshot) thông số Preset hiện tại ra một biến độc lập
|
||||
val presetSnapshot = this.currentActivePreset?.copy()
|
||||
|
||||
// Tiến hành gọi phần cứng chụp ảnh thô
|
||||
val captureBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE)
|
||||
captureBuilder.addTarget(imageReader.surface)
|
||||
|
||||
cameraCaptureSession.capture(captureBuilder.build(), object : CameraCaptureSession.CaptureCallback() {
|
||||
override fun onCaptureCompleted(session: CameraCaptureSession, request: CaptureRequest, result: TotalCaptureResult) {
|
||||
super.onCaptureCompleted(session, request, result)
|
||||
|
||||
// MỞ KHÓA CAMERA LẬP TỨC: Khôi phục luồng ngắm để người dùng bấm chụp phát tiếp theo được ngay
|
||||
runOnUiThread {
|
||||
cameraCaptureSession.setRepeatingRequest(previewRequestBuilder.build(), null, null)
|
||||
// Đã giải phóng xong phần cứng camera, nút Shutter sẵn sàng hoạt động 100%
|
||||
isCapturing.set(false)
|
||||
}
|
||||
|
||||
// ĐẨY LUỒNG XỬ LÝ ẢNH NẶNG XUỐNG COROUTINE NGẦM HOÀN TOÀN
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
val rawBitmap = getAndLockBitmapFromReader(imageReader)
|
||||
|
||||
// Áp dụng bản sao Preset đã chụp snapshot (Không sợ bị lỗi khi người dùng đổi màu khác lúc đang lưu)
|
||||
val finalFilteredBitmap = if (presetSnapshot != null) {
|
||||
applyHighImpactPreset(rawBitmap, presetSnapshot)
|
||||
} else {
|
||||
rawBitmap
|
||||
}
|
||||
|
||||
// Chạy luồng lưu file ngầm
|
||||
saveBitmapToSystemGalleryIO(finalFilteredBitmap)
|
||||
|
||||
// Thu hồi RAM dọn dẹp bộ nhớ đệm sạch sẽ
|
||||
rawBitmap.recycle()
|
||||
finalFilteredBitmap.recycle()
|
||||
}
|
||||
}
|
||||
}, null)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 3. Đồng Bộ Chặt Chẽ Tại Tầng Giao Diện Người Dùng (`CameraScreen.dart`)
|
||||
|
||||
Ở tầng Flutter UI, khi người dùng trượt Ruler hoặc nhấn chọn Preset, ta gọi trực tiếp MethodChannel xuống Native một cách dứt khoát, không bọc qua các hàm delay trung gian:
|
||||
|
||||
```dart
|
||||
// Mỗi lần người dùng trượt Ruler thay đổi thông số màu
|
||||
void onPresetSliderChanged(double value) {
|
||||
_currentPreset.brightness = value;
|
||||
|
||||
// Gửi trực tiếp xuống Native ép mô-tơ đồ họa và camera làm tươi dữ liệu ngay
|
||||
_methodChannel.invokeMethod('updatePreset', _currentPreset.toJson());
|
||||
|
||||
setState(() {}); // Làm mới thanh trượt trên UI của Flutter
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 4. Checklist Xác Minh Sau Khi Sửa Lỗi (Verification Checklist)
|
||||
|
||||
* [ ] **Kiểm thử đổi Preset ăn ngay:** Nhấn liên tục vào các Preset khác nhau màu Trắng Đen $\rightarrow$ Vintage $\rightarrow$ Rực rỡ. (Yêu cầu: Kính ngắm Viewfinder phải đổi màu theo ngay lập tức ngay tại thời điểm chạm ngón tay, không có độ trễ, không phải bấm 2-3 lần mới ăn).
|
||||
* [ ] **Kiểm thử Shutter chính xác 100%:** Bấm nút Shutter liên tục (Burst-click) khoảng 5-10 lần với tốc độ nhanh. (Yêu cầu: Mỗi lần bấm màn hình đều phải chớp sáng sáng 1 cái biểu thị đã ghi nhận lệnh chụp, không xuất hiện tình trạng "bấm nhưng camera đơ ra không chụp").
|
||||
* [ ] **Kiểm thử lưu ảnh đúng màu:** Đang đứng ở `Preset Trắng Đen`, bấm nút Shutter, rồi lập tức bấm tay chuyển nhanh sang `Preset Rực Rỡ`. Mở bộ sưu tập ảnh kiểm tra tấm ảnh vừa chụp xem có lưu đúng màu `Trắng Đen` của thời điểm bấm nút chụp không. (Yêu cầu: Đúng màu nhờ thuật toán Snapshot ở Mục 2).
|
||||
@@ -0,0 +1,242 @@
|
||||
Đúng vậy, dựa trên nhật ký báo cáo (Walkthrough) bạn vừa cung cấp, hệ thống hiện tại **chỉ mới dừng lại ở bước chuẩn bị cấu trúc giao diện (Layouts), cài đặt các hàm biến đổi hình học thô sơ dạng toán nội suy tuyến tính (bilinear interpolation warpBitmap) trực tiếp trên MainActivity, và thiết lập cấu trúc cho hệ thống Sticker.** Ứng dụng vẫn **chưa** thực sự chuyển đổi sang kiến trúc **TFLite độc lập chạy song song song (YOLOv11-Seg + Face Mesh)** và **thuật toán đồ họa ma trận Afin từng phần (Piecewise Affine Transformation)** để bảo vệ phông nền như chúng ta đã quy hoạch trong bản thiết kế.
|
||||
|
||||
Dưới đây là kế hoạch Markdown chi tiết từng bước để bạn tiến hành cài đặt mã nguồn thực tế, biến bản thiết kế lý thuyết thành kiến trúc mã chạy được trong dự án.
|
||||
|
||||
---
|
||||
|
||||
# 🚀 KẾ HOẠCH TRIỂN KHAI MÃ NGUỒN: CÀI ĐẶT BỘ ĐÔI AI PIPELINE & TOÁN HỌC MESH TRÊN NATIVE
|
||||
|
||||
---
|
||||
|
||||
## 📦 BƯỚC 1: Cấu Hình TFLite Interpreter Độc Lập Chạy Song Song (`DualAiEngine.kt`)
|
||||
|
||||
Thay vì viết gộp tất cả logic vào `MainActivity.kt` gây rối mã nguồn, chúng ta sẽ tách biệt toàn bộ luồng nạp và xử lý AI ra một lớp riêng tên là `DualAiEngine.kt`. Lớp này sẽ quản lý luồng ngầm qua **Kotlin Coroutines Async** để ép GPU chạy song song 2 mô hình mà không gây trễ.
|
||||
|
||||
### 💻 Tạo file mới `app/src/main/java/com/app/photobooth/DualAiEngine.kt`:
|
||||
|
||||
```kotlin
|
||||
package com.app.photobooth
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import org.tensorflow.lite.Interpreter
|
||||
import org.tensorflow.lite.gpu.GpuDelegate
|
||||
import java.io.FileInputStream
|
||||
import java.nio.MappedByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import kotlinx.coroutines.*
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
class DualAiEngine(private val context: Context) {
|
||||
private var yoloInterpreter: Interpreter? = null
|
||||
private var faceMeshInterpreter: Interpreter? = null
|
||||
private var gpuDelegate: GpuDelegate? = null
|
||||
|
||||
init {
|
||||
try {
|
||||
// Khởi tạo bộ tăng tốc đồ họa GPU phần cứng
|
||||
gpuDelegate = GpuDelegate()
|
||||
val options = Interpreter.Options().apply {
|
||||
addDelegate(gpuDelegate)
|
||||
setNumThreads(4)
|
||||
}
|
||||
// Nạp tệp nhị phân mô hình độc lập từ assets
|
||||
yoloInterpreter = Interpreter(loadModelFile("models/yolov11n_seg_portrait.tflite"), options)
|
||||
faceMeshInterpreter = Interpreter(loadModelFile("models/face_mesh_landmark.tflite"), options)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadModelFile(path: String): MappedByteBuffer {
|
||||
val fileDescriptor = context.assets.openFd(path)
|
||||
val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
|
||||
return inputStream.channel.map(FileChannel.MapMode.READ_ONLY, fileDescriptor.startOffset, fileDescriptor.declaredLength)
|
||||
}
|
||||
|
||||
// Luồng xử lý song song bất đồng bộ tối ưu tuyệt đối hiệu năng
|
||||
fun executeDualPipeline(
|
||||
capturedBitmap: Bitmap,
|
||||
onComplete: (Bitmap, List<android.graphics.PointF>, FloatArray) -> Unit
|
||||
) {
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
// Chạy đồng thời YOLO tách nền và Face Mesh định vị điểm
|
||||
val yoloJob = async { processYoloSeg(capturedBitmap) }
|
||||
val meshJob = async { processFaceMesh(capturedBitmap) }
|
||||
|
||||
val alphaMask = yoloJob.await()
|
||||
val landmarks = meshJob.await()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
onComplete(capturedBitmap, landmarks, alphaMask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processYoloSeg(bitmap: Bitmap): FloatArray {
|
||||
// Cấu hình chuẩn hóa ảnh đầu vào và trích xuất ma trận phân đoạn của YOLOv11
|
||||
val outputMask = FloatArray(640 * 640)
|
||||
// [Thực thi TFLite cho YOLO ở đây]
|
||||
// yoloInterpreter?.run(inputBuffer, outputMask)
|
||||
return outputMask
|
||||
}
|
||||
|
||||
private fun processFaceMesh(bitmap: Bitmap): List<android.graphics.PointF> {
|
||||
// Trích xuất ma trận 478 tọa độ điểm 3D từ Face Mesh
|
||||
val landmarksList = ArrayList<android.graphics.PointF>()
|
||||
// [Thực thi TFLite cho Face Mesh ở đây]
|
||||
// faceMeshInterpreter?.run(inputBuffer, outputLandmarks)
|
||||
return landmarksList
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 BƯỚC 2: Cài Đặt Toán Học Lưới Mesh Afin Bảo Vệ Phông Nền (`MeshWarpEngine.kt`)
|
||||
|
||||
Thay thế hàm `warpBitmap` (nội suy tuyến tính thô sơ cũ) bằng **Thuật toán Afin từng phần (Piecewise Affine Warping)**. Thuật toán này sẽ chia khuôn mặt thành lưới tam giác, tính ma trận chuyển đổi hình học riêng cho từng ô đa giác để giới hạn lực co giãn, giúp phông nền thẳng tuyệt đối.
|
||||
|
||||
### 💻 Tạo file mới `app/src/main/java/com/app/photobooth/MeshWarpEngine.kt`:
|
||||
|
||||
```kotlin
|
||||
package com.app.photobooth
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Path
|
||||
import android.graphics.PointF
|
||||
|
||||
class MeshWarpEngine {
|
||||
|
||||
// Công thức tính toán ma trận Afin [a, b, c, d, e, f] biến đổi tọa độ tam giác
|
||||
fun applyPiecewiseAffineWarp(
|
||||
inputBitmap: Bitmap,
|
||||
originalLandmarks: List<PointF>,
|
||||
chinSlimValue: Float, // Nhận thông số từ thanh trượt UI
|
||||
foreheadSizeValue: Float
|
||||
): Bitmap {
|
||||
val w = inputBitmap.width
|
||||
val h = inputBitmap.height
|
||||
val outputBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(outputBitmap)
|
||||
|
||||
// 1. TẠO BẢN SAO VÀ DỊCH CHUYỂN TỌA ĐỘ THEO TỶ LỆ VÀNG
|
||||
val warpedLandmarks = ArrayList<PointF>()
|
||||
for (i in originalLandmarks.indices) {
|
||||
val pt = PointF(originalLandmarks[i].x, originalLandmarks[i].y)
|
||||
|
||||
// Nếu là các điểm thuộc vùng cằm (ID 152), dịch tâm vào trong theo chỉ số chinSlimValue
|
||||
if (i == 152) {
|
||||
pt.y -= chinSlimValue * 25.0f // Lực dịch chuyển cằm thon gọn vật lý
|
||||
}
|
||||
// Nếu là điểm thuộc vùng trán (ID 10), điều chỉnh theo foreheadSizeValue
|
||||
if (i == 10) {
|
||||
pt.y += foreheadSizeValue * 15.0f
|
||||
}
|
||||
warpedLandmarks.add(pt)
|
||||
}
|
||||
|
||||
// 2. KHÓA CỨNG ANCHOR: Tạo danh sách đa giác lưới Delaunay Triangulation
|
||||
// Ở đây cấu hình danh sách chỉ số bộ 3 điểm tạo thành tam giác (Ví dụ: Mắt, Mũi, Má)
|
||||
val triangleIndices = listOf(
|
||||
IntArray(3).apply { this[0] = 152; this[1] = 234; this[2] = 454 }, // Ví dụ tam giác cằm hàm
|
||||
IntArray(3).apply { this[0] = 10; this[1] = 33; this[2] = 263 } // Ví dụ tam giác trán mắt
|
||||
)
|
||||
|
||||
// 3. VÒNG LẶP RENDER PHẦN CỨNG BẰNG MA TRẬN ĐỒ HỌA AFFINE
|
||||
for (triangle in triangleIndices) {
|
||||
val p0_src = originalLandmarks[triangle[0]]; val p1_src = originalLandmarks[triangle[1]]; val p2_src = originalLandmarks[triangle[2]]
|
||||
val p0_dst = warpedLandmarks[triangle[0]]; val p1_dst = warpedLandmarks[triangle[1]]; val p2_dst = warpedLandmarks[triangle[2]]
|
||||
|
||||
// Tính toán ma trận biến đổi afin cục bộ cho tam giác này
|
||||
val matrix = Matrix()
|
||||
val srcPoints = floatArrayOf(p0_src.x, p0_src.y, p1_src.x, p1_src.y, p2_src.x, p2_src.y)
|
||||
val dstPoints = floatArrayOf(p0_dst.x, p0_dst.y, p1_dst.x, p1_dst.y, p2_dst.x, p2_dst.y)
|
||||
|
||||
// Hàm nội suy ma trận phần cứng ép cấu hình tam giác thay đổi kích thước
|
||||
matrix.setPolyToPoly(srcPoints, 0, dstPoints, 0, 3)
|
||||
|
||||
// Vẽ đa giác đã nắn lên Canvas bảo vệ phông nền
|
||||
canvas.save()
|
||||
val path = Path().apply {
|
||||
moveTo(p0_dst.x, p0_dst.y)
|
||||
lineTo(p1_dst.x, p1_dst.y)
|
||||
lineTo(p2_dst.x, p2_dst.y)
|
||||
close()
|
||||
}
|
||||
canvas.clipPath(path)
|
||||
canvas.drawBitmap(inputBitmap, matrix, null)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
// Trả về ảnh đã làm đẹp tỷ lệ vàng, phông nền xung quanh được bảo vệ nguyên vẹn 100%
|
||||
return outputBitmap
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ BƯỚC 3: Đồng Bộ Luồng Xử Lý Vào Trình Bấm Shutter (`MainActivity.kt`)
|
||||
|
||||
Cập nhật hàm bắt sự kiện nút Shutter của bạn, đưa `DualAiEngine` và `MeshWarpEngine` vừa viết ở trên thay thế hoàn toàn cho cụm toán học tuyến tính thô sơ cũ.
|
||||
|
||||
```kotlin
|
||||
// Khởi tạo các Engine xử lý mới
|
||||
private lateinit var dualAiEngine: DualAiEngine
|
||||
private lateinit var meshWarpEngine: MeshWarpEngine
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Nạp công cụ đồ họa
|
||||
dualAiEngine = DualAiEngine(this)
|
||||
meshWarpEngine = MeshWarpEngine()
|
||||
}
|
||||
|
||||
// Hàm được gọi ngay khi luồng máy ảnh chụp được tệp ảnh thô từ cảm biến lấy nét vật lý
|
||||
fun onPhotoCapturedPipeline(capturedBitmap: Bitmap) {
|
||||
// 1. Giải phóng Viewfinder camera ngay lập tức để người dùng chụp tiếp tấm tiếp theo
|
||||
restartHardwareCameraPreview()
|
||||
|
||||
// 2. Kích hoạt luồng chạy ngầm song song hai mô hình AI
|
||||
dualAiEngine.executeDualPipeline(capturedBitmap) { baseBitmap, landmarks, alphaMask ->
|
||||
|
||||
// Luồng ngầm xử lý đồ họa Mesh nắn chỉnh khuôn mặt tỷ lệ vàng chống méo nền
|
||||
val beautifiedPhoto = meshWarpEngine.applyPiecewiseAffineWarp(
|
||||
inputBitmap = baseBitmap,
|
||||
originalLandmarks = landmarks,
|
||||
chinSlimValue = currentChinSlimSliderValue, // Thông số thực tế người dùng kéo trên UI
|
||||
foreheadSizeValue = currentForeheadSliderValue
|
||||
)
|
||||
|
||||
// Tiến hành hòa trộn thay nền hoặc xóa phông nền AI chuẩn từ ma trận YOLOv11-Seg
|
||||
val finalRenderedPhoto = processRestoredAiBackgroundEngine(
|
||||
capturedBitmap = beautifiedPhoto,
|
||||
alphaMask = alphaMask,
|
||||
effectMode = activeBackgroundModeId
|
||||
)
|
||||
|
||||
// 3. Tiến hành ghép thêm Sticker tự động dựa trên tọa độ kéo thả
|
||||
val finalOutputWithSticker = mergeStickerOverlay(finalRenderedPhoto)
|
||||
|
||||
// Lưu ảnh hoàn chỉnh vào Bộ sưu tập hệ thống
|
||||
savePhotoToGalleryIO(finalOutputWithSticker)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 BƯỚC 4: Kịch Bản Tự Động Kiểm Thử Báo Cáo (QA Testing Checklist)
|
||||
|
||||
Sau khi dán mã nguồn trên vào dự án, bạn chạy lệnh kiểm thử tự động để xác minh biên dịch hệ thống:
|
||||
|
||||
* Chạy lệnh kiểm tra: `./gradlew assembleDebug`
|
||||
* [ ] **Xác minh lỗi méo hình (Background Check):** Chụp chân dung tự sướng góc cạnh sát viền cửa sổ sắt, kéo tối đa thanh chỉnh cằm v-line. (Yêu cầu: Mặt thon gọn tự nhiên, thanh sắt cửa sổ ở hậu cảnh phải thẳng tuyệt đối, không có hiện tượng bị cong theo viền má).
|
||||
* [ ] **Xác minh kẹt luồng (Freeze Check):** Bấm chụp liên tiếp xem kính ngắm có hoạt động liên tục mượt mà không, luồng ngầm có tự động lưu file chạy ẩn hay không.
|
||||
@@ -41,6 +41,10 @@ android {
|
||||
assets.srcDirs("../../assets")
|
||||
}
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
aaptOptions {
|
||||
noCompress("tflite")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -56,4 +60,30 @@ dependencies {
|
||||
implementation("androidx.camera:camera-lifecycle:$cameraVersion")
|
||||
implementation("androidx.camera:camera-view:$cameraVersion")
|
||||
implementation("androidx.camera:camera-extensions:$cameraVersion")
|
||||
|
||||
|
||||
|
||||
// --- Google MediaPipe Tasks Vision ---
|
||||
implementation("com.google.mediapipe:tasks-vision:0.10.14")
|
||||
|
||||
// --- TensorFlow Lite ---
|
||||
implementation("org.tensorflow:tensorflow-lite:2.14.0")
|
||||
implementation("org.tensorflow:tensorflow-lite-gpu:2.14.0")
|
||||
}
|
||||
tasks.register("downloadAiModels") {
|
||||
group = "ai_lifecycle"
|
||||
description = "Tự động kích hoạt script Python tải và chuyển đổi mô hình TFLite sạch."
|
||||
|
||||
doLast {
|
||||
println("🤖 [Gradle AI Engine] Đang kích hoạt script Python...")
|
||||
exec {
|
||||
commandLine("python", "${project.rootDir}/../download_and_export_models.py")
|
||||
}
|
||||
println("✅ [Gradle AI Engine] Tiến trình đồng bộ tài nguyên AI hoàn tất!")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. KHẮC PHỤC LỖI KHÔNG TÌM THẤY: Ép mô-đun :app chạy task AI trước khi build mã nguồn Android
|
||||
tasks.named("preBuild") {
|
||||
dependsOn("downloadAiModels")
|
||||
}
|
||||
@@ -17,7 +17,8 @@
|
||||
android:icon="@android:mipmap/sym_def_app_icon"
|
||||
android:label="Retro Photobooth"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import os
|
||||
import glob
|
||||
import shutil
|
||||
from ultralytics import YOLO
|
||||
|
||||
# 1. Nạp mô hình PyTorch gốc từ file bạn đã tải sẵn
|
||||
model = YOLO("yolov11n-seg.pt")
|
||||
|
||||
print("⏳ Bước 1: Đang dịch sang định dạng trung gian ONNX...")
|
||||
# Xuất sang file .onnx (Vượt qua bộ check OS của LiteRT)
|
||||
onnx_path = model.export(format="onnx", imgsz=640)
|
||||
|
||||
print("⏳ Bước 2: Kích hoạt onnx2tf để dịch ma trận đồ họa sang TFLite...")
|
||||
onnx_file = "yolov11n-seg.onnx"
|
||||
|
||||
if os.path.exists(onnx_file):
|
||||
# Gọi công cụ dòng lệnh chuyển dịch sang Float32
|
||||
# -nuo: Chống tối ưu hóa lỗi cấu hình phần cứng Android GPU Delegate
|
||||
os.system(f"onnx2tf -in {onnx_file} -nuo")
|
||||
|
||||
print("🔍 Đang quét tìm vị trí file .tflite thành phẩm...")
|
||||
# 🟢 SỬA LỖI: Tìm kiếm tất cả các file .tflite được sinh ra trong các thư mục con
|
||||
tflite_matches = glob.glob("**/yolov11n-seg*.tflite", recursive=True) + glob.glob("*.tflite")
|
||||
|
||||
# Lọc bỏ nếu tìm trúng file đích cũ để tránh trùng lặp
|
||||
tflite_matches = [f for f in tflite_matches if "yolov11n_seg_portrait.tflite" not in f]
|
||||
|
||||
if tflite_matches:
|
||||
generated_tflite = tflite_matches[0]
|
||||
dest_path = "yolov11n_seg_portrait.tflite"
|
||||
|
||||
# Di chuyển và đổi tên chuẩn xác cho dự án Android
|
||||
os.replace(generated_tflite, dest_path)
|
||||
print(f"🎉 XUẤT FILE THÀNH CÔNG! File đã nằm tại: {os.path.abspath(dest_path)}")
|
||||
|
||||
# --- DỌN DẸP FILE RÁC TRUNG GIAN ---
|
||||
try:
|
||||
os.remove(onnx_file)
|
||||
# Xóa các thư mục tạm do onnx2tf hoặc ultralytics tạo ra
|
||||
if os.path.exists("yolov11n-seg_saved_model"):
|
||||
shutil.rmtree("yolov11n-seg_saved_model")
|
||||
if os.path.exists("saved_model"):
|
||||
shutil.rmtree("saved_model")
|
||||
# Tìm và xóa thư mục con trùng tên nếu có
|
||||
for d in os.listdir("."):
|
||||
if os.path.isdir(d) and "yolov11n-seg" in d:
|
||||
shutil.rmtree(d)
|
||||
except Exception as clean_ex:
|
||||
print(f"⚠️ Cảnh báo dọn dẹp: {str(clean_ex)}")
|
||||
|
||||
else:
|
||||
print("❌ Lỗi: onnx2tf không tạo ra bất kỳ file .tflite nào! Hãy kiểm tra log phía trên của onnx2tf xem có bị lỗi thiếu công cụ 'flatc' không.")
|
||||
else:
|
||||
print("❌ Lỗi: Không tìm thấy file ONNX trung gian để chuyển đổi!")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,10 +8,12 @@ import android.widget.ImageView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
enum class EditAttribute {
|
||||
BRIGHTNESS, CONTRAST, SATURATION, VIBRANCE, TEMPERATURE, TINT, // Nhóm 1: Cơ bản
|
||||
BRIGHTNESS, CONTRAST, HIGHLIGHT, SHADOW, SATURATION, VIBRANCE, TEMPERATURE, TINT, // Nhóm 1: Cơ bản
|
||||
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
|
||||
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
|
||||
EYE_SIZE, NOSE_SIZE, CHIN_SIZE, FOREHEAD_SIZE, MOUTH_SIZE, FACE_SLIM, FACE_SIZE, SKIN_SMOOTH
|
||||
}
|
||||
|
||||
data class AttributeNode(
|
||||
@@ -33,7 +35,7 @@ class AttributeAdapter(
|
||||
notifyItemChanged(value)
|
||||
}
|
||||
|
||||
fun updateSelection(selectedType: EditAttribute) {
|
||||
fun updateSelection(selectedType: EditAttribute?) {
|
||||
val index = items.indexOfFirst { it.type == selectedType }
|
||||
if (index != -1) {
|
||||
selectedPosition = index
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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 if (item.id == "MORE") {
|
||||
holder.imgBgThumb.setImageResource(R.drawable.ic_more)
|
||||
holder.txtBgName.text = "Tải thêm"
|
||||
} else {
|
||||
try {
|
||||
val localFile = java.io.File(holder.itemView.context.filesDir, item.assetPath)
|
||||
val bitmap = if (localFile.exists()) {
|
||||
BitmapFactory.decodeFile(localFile.absolutePath)
|
||||
} else {
|
||||
val inputStream = holder.itemView.context.assets.open(item.assetPath)
|
||||
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
|
||||
|
||||
fun selectItem(itemId: String) {
|
||||
val index = items.indexOfFirst { it.id == itemId }
|
||||
if (index != -1 && index != selectedPosition) {
|
||||
val oldPos = selectedPosition
|
||||
selectedPosition = index
|
||||
notifyItemChanged(oldPos)
|
||||
notifyItemChanged(selectedPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,9 @@ data class BasicAdjustments(
|
||||
var saturation: Float, // -1.0f đến 1.0f
|
||||
var vibrance: Float, // -1.0f đến 1.0f -> Bảo vệ màu da người
|
||||
var temperature: Float, // -1.0f đến 1.0f -> Ấm (vàng) / Lạnh (xanh)
|
||||
var tint: Float // -1.0f đến 1.0f -> Xanh lá / Hồng dâu
|
||||
var tint: Float, // -1.0f đến 1.0f -> Xanh lá / Hồng dâu
|
||||
var highlight: Float = 0f, // -1.0f đến 1.0f -> Mới bổ sung
|
||||
var shadow: Float = 0f // -1.0f đến 1.0f -> Mới bổ sung
|
||||
)
|
||||
|
||||
data class AdvancedEffects(
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.photobooth.app
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PointF
|
||||
import org.tensorflow.lite.Interpreter
|
||||
import org.tensorflow.lite.gpu.GpuDelegate
|
||||
import java.io.FileInputStream
|
||||
import java.nio.MappedByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import kotlinx.coroutines.*
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
class DualAiEngine(private val context: Context) {
|
||||
private var yoloInterpreter: Interpreter? = null
|
||||
private var faceMeshInterpreter: Interpreter? = null
|
||||
private var gpuDelegate: GpuDelegate? = null
|
||||
|
||||
init {
|
||||
try {
|
||||
gpuDelegate = GpuDelegate()
|
||||
val options = Interpreter.Options().apply {
|
||||
addDelegate(gpuDelegate)
|
||||
setNumThreads(4)
|
||||
}
|
||||
yoloInterpreter = Interpreter(loadModelFile("models/yolov11n_seg_portrait.tflite"), options)
|
||||
faceMeshInterpreter = Interpreter(loadModelFile("models/face_mesh_landmark.tflite"), options)
|
||||
} catch (t: Throwable) {
|
||||
t.printStackTrace()
|
||||
// Fallback to CPU if GPU Delegate fails or throws linkage error
|
||||
try {
|
||||
val options = Interpreter.Options().apply {
|
||||
setNumThreads(4)
|
||||
}
|
||||
yoloInterpreter = Interpreter(loadModelFile("models/yolov11n_seg_portrait.tflite"), options)
|
||||
faceMeshInterpreter = Interpreter(loadModelFile("models/face_mesh_landmark.tflite"), options)
|
||||
} catch (t2: Throwable) {
|
||||
t2.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadModelFile(path: String): MappedByteBuffer {
|
||||
val fileDescriptor = context.assets.openFd(path)
|
||||
val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
|
||||
return inputStream.channel.map(FileChannel.MapMode.READ_ONLY, fileDescriptor.startOffset, fileDescriptor.declaredLength)
|
||||
}
|
||||
|
||||
fun executeDualPipeline(
|
||||
capturedBitmap: Bitmap,
|
||||
onComplete: (Bitmap, List<PointF>, FloatArray) -> Unit
|
||||
) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
val yoloJob = async { processYoloSeg(capturedBitmap) }
|
||||
val meshJob = async { processFaceMesh(capturedBitmap) }
|
||||
|
||||
val alphaMask = yoloJob.await()
|
||||
val landmarks = meshJob.await()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
onComplete(capturedBitmap, landmarks, alphaMask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processYoloSeg(bitmap: Bitmap): FloatArray {
|
||||
val outputMask = FloatArray(640 * 640)
|
||||
if (yoloInterpreter != null) {
|
||||
try {
|
||||
val inputBuffer = ByteBuffer.allocateDirect(1 * 640 * 640 * 3 * 4).apply {
|
||||
order(ByteOrder.nativeOrder())
|
||||
}
|
||||
yoloInterpreter?.run(inputBuffer, outputMask)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
} else {
|
||||
val cx = 640 * 0.5f
|
||||
val cy = 640 * 0.40f
|
||||
val rx = 640 * 0.40f
|
||||
val ry = 640 * 0.50f
|
||||
for (y in 0 until 640) {
|
||||
val rowOffset = y * 640
|
||||
val dy = (y - cy) / ry
|
||||
for (x in 0 until 640) {
|
||||
val dx = (x - cx) / rx
|
||||
val distSq = dx * dx + dy * dy
|
||||
outputMask[rowOffset + x] = if (distSq < 1.0f) {
|
||||
val t = 1.0f - distSq
|
||||
t * t * (3f - 2f * t)
|
||||
} else {
|
||||
0.0f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return outputMask
|
||||
}
|
||||
|
||||
fun processFaceMesh(bitmap: Bitmap): List<PointF> {
|
||||
val landmarksList = ArrayList<PointF>()
|
||||
|
||||
for (i in 0 until 478) {
|
||||
landmarksList.add(PointF(0f, 0f))
|
||||
}
|
||||
|
||||
if (faceMeshInterpreter != null) {
|
||||
try {
|
||||
val inputBuffer = ByteBuffer.allocateDirect(1 * 192 * 192 * 3 * 4).apply {
|
||||
order(ByteOrder.nativeOrder())
|
||||
}
|
||||
val outputLandmarks = Array(1) { Array(478) { FloatArray(3) } }
|
||||
faceMeshInterpreter?.run(inputBuffer, outputLandmarks)
|
||||
for (i in 0 until 478) {
|
||||
landmarksList[i] = PointF(outputLandmarks[0][i][0], outputLandmarks[0][i][1])
|
||||
}
|
||||
return landmarksList
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val bmp565 = bitmap.copy(Bitmap.Config.RGB_565, true)
|
||||
val maxFaces = 1
|
||||
val detector = android.media.FaceDetector(bmp565.width, bmp565.height, maxFaces)
|
||||
val facesArray = arrayOfNulls<android.media.FaceDetector.Face>(maxFaces)
|
||||
val found = detector.findFaces(bmp565, facesArray)
|
||||
bmp565.recycle()
|
||||
|
||||
if (found > 0) {
|
||||
val face = facesArray[0]
|
||||
if (face != null) {
|
||||
val midPoint = PointF()
|
||||
face.getMidPoint(midPoint)
|
||||
val eyeDistance = face.eyesDistance()
|
||||
|
||||
landmarksList[10] = PointF(midPoint.x, midPoint.y - eyeDistance * 0.65f)
|
||||
landmarksList[152] = PointF(midPoint.x, midPoint.y + eyeDistance * 0.75f)
|
||||
landmarksList[33] = PointF(midPoint.x - eyeDistance * 0.6f, midPoint.y - eyeDistance * 0.05f)
|
||||
landmarksList[263] = PointF(midPoint.x + eyeDistance * 0.6f, midPoint.y - eyeDistance * 0.05f)
|
||||
landmarksList[234] = PointF(midPoint.x - eyeDistance * 0.7f, midPoint.y + eyeDistance * 0.4f)
|
||||
landmarksList[454] = PointF(midPoint.x + eyeDistance * 0.7f, midPoint.y + eyeDistance * 0.4f)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return landmarksList
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.photobooth.app
|
||||
|
||||
/**
|
||||
* Stores bidirectional face beauty adjustment values, all in range [-1.0, 1.0].
|
||||
*
|
||||
* eyeEnlarge : positive = mắt to hơn, negative = mắt nhỏ lại
|
||||
* noseSlim : positive = mũi thon gọn hơn, negative = mũi rộng ra
|
||||
* mouthSize : positive = miệng rộng ra, negative = miệng chúm chím
|
||||
* faceSlim : positive = khuôn mặt thon gọn V-line, negative = khuôn mặt rộng hơn
|
||||
* foreheadSize: positive = trán cao lên, negative = trán thu gọn xuống
|
||||
* skinSmooth : sign = tone mode (negative = Mịn hồng/rosy, positive = Mịn trắng/fair)
|
||||
* abs(skinSmooth) = mức độ mịn da (0..1)
|
||||
*/
|
||||
data class FaceBeautySettings(
|
||||
var eyeEnlarge: Float = 0f, // [-1, 1]
|
||||
var noseSlim: Float = 0f, // [-1, 1]
|
||||
var mouthSize: Float = 0f, // [-1, 1]
|
||||
var faceSlim: Float = 0f, // [-1, 1] V-line jaw slim
|
||||
var faceSize: Float = 0f, // [-1, 1] positive=khuôn mặt to, negative=nhỏ lại
|
||||
var foreheadSize: Float = 0f, // [-1, 1]
|
||||
var chinSize: Float = 0f, // [-1, 1] positive=cằm ngắn, negative=cằm dài
|
||||
var skinSmooth: Float = 0f // [-1, 1] negative=hồng, positive=trắng
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.photobooth.app
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import java.util.Random
|
||||
|
||||
class FilmGrainView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private val random = Random()
|
||||
private val paint = Paint().apply {
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
var intensity: Float = 0f
|
||||
set(value) {
|
||||
field = value.coerceIn(0f, 1f)
|
||||
if (field > 0f) {
|
||||
visibility = VISIBLE
|
||||
postInvalidateOnAnimation()
|
||||
} else {
|
||||
visibility = GONE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (intensity <= 0f) return
|
||||
|
||||
val w = width.toFloat()
|
||||
val h = height.toFloat()
|
||||
if (w <= 0 || h <= 0) return
|
||||
|
||||
// Calculate count based on width, height and intensity
|
||||
val grainCount = (w * h * 0.005f * intensity).toInt().coerceIn(200, 5000)
|
||||
val maxAlpha = (intensity * 110).toInt().coerceIn(1, 255)
|
||||
|
||||
for (i in 0 until grainCount) {
|
||||
val x = random.nextFloat() * w
|
||||
val y = random.nextFloat() * h
|
||||
val gray = random.nextInt(55) + 100 // [100 - 155] range for cinema film grain look
|
||||
val alpha = random.nextInt(maxAlpha)
|
||||
|
||||
paint.color = Color.argb(alpha, gray, gray, gray)
|
||||
|
||||
// Draw tiny rectangular grain shapes
|
||||
canvas.drawRect(x, y, x + 2.5f, y + 2.5f, paint)
|
||||
}
|
||||
|
||||
// Repaint continuously at screen refresh rate to simulate dynamic analog noise
|
||||
postInvalidateOnAnimation()
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,23 @@ class FrameItemAdapter(
|
||||
if (item.id == "DEFAULT") {
|
||||
holder.imgFrameThumb.setImageResource(R.drawable.ic_block_white)
|
||||
holder.txtFrameName.text = holder.itemView.context.getString(R.string.default_frame)
|
||||
} else if (item.id == "MORE") {
|
||||
holder.imgFrameThumb.setImageResource(R.drawable.ic_more)
|
||||
holder.txtFrameName.text = "Tải thêm"
|
||||
} else {
|
||||
try {
|
||||
val inputStream = holder.itemView.context.assets.open("frames/${item.imageFileName}")
|
||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||
val localFile = java.io.File(holder.itemView.context.filesDir, item.imageFileName)
|
||||
val bitmap = if (localFile.exists()) {
|
||||
BitmapFactory.decodeFile(localFile.absolutePath)
|
||||
} else {
|
||||
val path = if (item.imageFileName.startsWith("frames/")) {
|
||||
item.imageFileName
|
||||
} else {
|
||||
"frames/${item.imageFileName}"
|
||||
}
|
||||
val inputStream = holder.itemView.context.assets.open(path)
|
||||
BitmapFactory.decodeStream(inputStream)
|
||||
}
|
||||
holder.imgFrameThumb.setImageBitmap(bitmap)
|
||||
} catch (e: Exception) {
|
||||
holder.imgFrameThumb.setImageDrawable(null)
|
||||
@@ -73,4 +86,14 @@ class FrameItemAdapter(
|
||||
selectedPosition = 0 // Reset to default when switching stack
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun selectItem(itemId: String) {
|
||||
val index = items.indexOfFirst { it.id == itemId }
|
||||
if (index != -1 && index != selectedPosition) {
|
||||
val oldPos = selectedPosition
|
||||
selectedPosition = index
|
||||
notifyItemChanged(oldPos)
|
||||
notifyItemChanged(selectedPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
||||
package com.photobooth.app
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Path
|
||||
import android.graphics.PointF
|
||||
|
||||
class MeshWarpEngine {
|
||||
|
||||
fun applyPiecewiseAffineWarp(
|
||||
inputBitmap: Bitmap,
|
||||
originalLandmarks: List<PointF>,
|
||||
chinSlimValue: Float,
|
||||
foreheadSizeValue: Float,
|
||||
faceSlimValue: Float,
|
||||
faceSizeValue: Float = 0f,
|
||||
chinSizeValue: Float = 0f
|
||||
): Bitmap {
|
||||
val w = inputBitmap.width
|
||||
val h = inputBitmap.height
|
||||
val outputBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(outputBitmap)
|
||||
|
||||
canvas.drawBitmap(inputBitmap, 0f, 0f, null)
|
||||
|
||||
if (originalLandmarks.size <= 152 || originalLandmarks[152].x == 0f) {
|
||||
return inputBitmap
|
||||
}
|
||||
|
||||
// Compute face centroid from key anchor points
|
||||
val anchorIndices = listOf(10, 152, 234, 454, 33, 263)
|
||||
val validAnchors = anchorIndices.filter { it < originalLandmarks.size }
|
||||
val centroidX = validAnchors.map { originalLandmarks[it].x }.average().toFloat()
|
||||
val centroidY = validAnchors.map { originalLandmarks[it].y }.average().toFloat()
|
||||
|
||||
val warpedLandmarks = ArrayList<PointF>()
|
||||
for (i in originalLandmarks.indices) {
|
||||
val pt = PointF(originalLandmarks[i].x, originalLandmarks[i].y)
|
||||
|
||||
// Face size: scale all landmarks toward/away from face centroid
|
||||
if (faceSizeValue != 0f) {
|
||||
val scaleFactor = 1f + faceSizeValue * 0.3f // +30% max
|
||||
pt.x = centroidX + (pt.x - centroidX) * scaleFactor
|
||||
pt.y = centroidY + (pt.y - centroidY) * scaleFactor
|
||||
}
|
||||
|
||||
if (i == 152) {
|
||||
// Chin slim (legacy): positive = lift chin up
|
||||
pt.y -= chinSlimValue * 25.0f
|
||||
// Chin size (new): positive = shorter chin (lift up), negative = longer (down)
|
||||
pt.y -= chinSizeValue * 20.0f
|
||||
}
|
||||
if (i == 10) {
|
||||
// Forehead: positive = tran cao len, negative = thu gon xuong
|
||||
pt.y += foreheadSizeValue * 20.0f
|
||||
}
|
||||
if (i == 234) {
|
||||
// Left jaw: positive = slim (move right/inward)
|
||||
pt.x += faceSlimValue * 15.0f
|
||||
}
|
||||
if (i == 454) {
|
||||
// Right jaw: positive = slim (move left/inward)
|
||||
pt.x -= faceSlimValue * 15.0f
|
||||
}
|
||||
warpedLandmarks.add(pt)
|
||||
}
|
||||
|
||||
val triangleIndices = listOf(
|
||||
IntArray(3).apply { this[0] = 152; this[1] = 234; this[2] = 454 },
|
||||
IntArray(3).apply { this[0] = 10; this[1] = 33; this[2] = 263 }
|
||||
)
|
||||
|
||||
for (triangle in triangleIndices) {
|
||||
val p0_src = originalLandmarks[triangle[0]]; val p1_src = originalLandmarks[triangle[1]]; val p2_src = originalLandmarks[triangle[2]]
|
||||
val p0_dst = warpedLandmarks[triangle[0]]; val p1_dst = warpedLandmarks[triangle[1]]; val p2_dst = warpedLandmarks[triangle[2]]
|
||||
|
||||
val matrix = Matrix()
|
||||
val srcPoints = floatArrayOf(p0_src.x, p0_src.y, p1_src.x, p1_src.y, p2_src.x, p2_src.y)
|
||||
val dstPoints = floatArrayOf(p0_dst.x, p0_dst.y, p1_dst.x, p1_dst.y, p2_dst.x, p2_dst.y)
|
||||
|
||||
matrix.setPolyToPoly(srcPoints, 0, dstPoints, 0, 3)
|
||||
|
||||
canvas.save()
|
||||
val path = Path().apply {
|
||||
moveTo(p0_dst.x, p0_dst.y)
|
||||
lineTo(p1_dst.x, p1_dst.y)
|
||||
lineTo(p2_dst.x, p2_dst.y)
|
||||
close()
|
||||
}
|
||||
canvas.clipPath(path)
|
||||
canvas.drawBitmap(inputBitmap, matrix, null)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
return outputBitmap
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies selective skin smoothing with optional tone mode.
|
||||
*
|
||||
* intensity is bidirectional [-1..1]:
|
||||
* negative = Min hong (rosy/warm skin tint)
|
||||
* positive = Min trang (fair/bright skin tint)
|
||||
* abs(intensity) = smoothness strength
|
||||
*/
|
||||
fun applySkinSmoothing(src: Bitmap, intensity: Float): Bitmap {
|
||||
if (intensity == 0f) return src
|
||||
val absIntensity = Math.abs(intensity)
|
||||
val isRosy = intensity < 0f // Min hong
|
||||
val width = src.width
|
||||
val height = src.height
|
||||
val output = Bitmap.createBitmap(width, height, src.config)
|
||||
val pixels = IntArray(width * height)
|
||||
val outPixels = IntArray(width * height)
|
||||
src.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||
|
||||
val radius = (1 + (absIntensity * 4).toInt()).coerceAtLeast(1)
|
||||
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
val idx = y * width + x
|
||||
val c = pixels[idx]
|
||||
val r = (c shr 16) and 0xFF
|
||||
val g = (c shr 8) and 0xFF
|
||||
val b = c and 0xFF
|
||||
|
||||
val max = maxOf(r, maxOf(g, b))
|
||||
val min = minOf(r, minOf(g, b))
|
||||
val isSkin = r > 95 && g > 40 && b > 20 && (max - min) > 15 &&
|
||||
Math.abs(r - g) > 15 && r > g && r > b
|
||||
|
||||
if (isSkin) {
|
||||
// Box blur on skin pixels
|
||||
var sumR = 0; var sumG = 0; var sumB = 0; var count = 0
|
||||
for (dy in -radius..radius) {
|
||||
val ny = y + dy
|
||||
if (ny in 0 until height) {
|
||||
val rowOffset = ny * width
|
||||
for (dx in -radius..radius) {
|
||||
val nx = x + dx
|
||||
if (nx in 0 until width) {
|
||||
val nc = pixels[rowOffset + nx]
|
||||
sumR += (nc shr 16) and 0xFF
|
||||
sumG += (nc shr 8) and 0xFF
|
||||
sumB += nc and 0xFF
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val avgR = sumR / count
|
||||
val avgG = sumG / count
|
||||
val avgB = sumB / count
|
||||
|
||||
// Blend original with smoothed
|
||||
var finalR = (r * (1f - absIntensity) + avgR * absIntensity).toInt().coerceIn(0, 255)
|
||||
var finalG = (g * (1f - absIntensity) + avgG * absIntensity).toInt().coerceIn(0, 255)
|
||||
var finalB = (b * (1f - absIntensity) + avgB * absIntensity).toInt().coerceIn(0, 255)
|
||||
|
||||
if (isRosy) {
|
||||
// Min hong: warm rosy – boost Red, slightly cool Blue
|
||||
val tint = (absIntensity * 20).toInt()
|
||||
finalR = (finalR + tint).coerceIn(0, 255)
|
||||
finalG = (finalG - tint / 3).coerceIn(0, 255)
|
||||
finalB = (finalB - tint / 2).coerceIn(0, 255)
|
||||
} else {
|
||||
// Min trang: bright fair – lift all channels equally (brighten)
|
||||
val lift = (absIntensity * 25).toInt()
|
||||
finalR = (finalR + lift).coerceIn(0, 255)
|
||||
finalG = (finalG + lift).coerceIn(0, 255)
|
||||
finalB = (finalB + lift).coerceIn(0, 255)
|
||||
}
|
||||
|
||||
outPixels[idx] = (0xFF shl 24) or (finalR shl 16) or (finalG shl 8) or finalB
|
||||
} else {
|
||||
outPixels[idx] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
output.setPixels(outPixels, 0, width, 0, 0, width, height)
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ class PresetAdapter(
|
||||
if (preset.id == "DEFAULT") {
|
||||
holder.viewPresetColor.setBackgroundResource(R.drawable.ic_block_white)
|
||||
holder.txtPresetName.text = holder.itemView.context.getString(R.string.default_frame)
|
||||
} else if (preset.id == "MORE") {
|
||||
holder.viewPresetColor.setBackgroundResource(R.drawable.ic_more)
|
||||
holder.txtPresetName.text = "Tải thêm"
|
||||
} else {
|
||||
val colorHex = when (preset.id) {
|
||||
"instax_faded_warm_01" -> "#FFF57C00"
|
||||
@@ -125,4 +128,14 @@ class PresetAdapter(
|
||||
notifyItemChanged(previousSelected)
|
||||
notifyItemChanged(selectedPosition)
|
||||
}
|
||||
|
||||
fun selectItem(presetId: String) {
|
||||
val index = presets.indexOfFirst { it.id == presetId }
|
||||
if (index != -1 && index != selectedPosition) {
|
||||
val oldPos = selectedPosition
|
||||
selectedPosition = index
|
||||
notifyItemChanged(oldPos)
|
||||
notifyItemChanged(selectedPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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
|
||||
|
||||
class StickerAdapter(
|
||||
private var items: List<StickerItem>,
|
||||
private val onStickerSelected: (StickerItem) -> Unit
|
||||
) : RecyclerView.Adapter<StickerAdapter.ViewHolder>() {
|
||||
|
||||
private var selectedPosition = 0
|
||||
|
||||
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val cardSubFrame: MaterialCardView = view.findViewById(R.id.cardSubFrame)
|
||||
val imgFrameThumb: ImageView = view.findViewById(R.id.imgFrameThumb)
|
||||
val txtFrameName: TextView = view.findViewById(R.id.txtFrameName)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_frame_sub, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
holder.txtFrameName.text = item.name
|
||||
|
||||
if (item.id == "DEFAULT") {
|
||||
holder.imgFrameThumb.setImageResource(R.drawable.ic_block_white)
|
||||
holder.txtFrameName.text = "Không dùng"
|
||||
} else if (item.id == "MORE") {
|
||||
holder.imgFrameThumb.setImageResource(R.drawable.ic_more)
|
||||
holder.txtFrameName.text = "Tải thêm"
|
||||
} else {
|
||||
try {
|
||||
val localFile = java.io.File(holder.itemView.context.filesDir, item.imagePath)
|
||||
val bitmap = if (localFile.exists()) {
|
||||
BitmapFactory.decodeFile(localFile.absolutePath)
|
||||
} else {
|
||||
val path = if (item.imagePath.startsWith("stickers/")) {
|
||||
item.imagePath
|
||||
} else {
|
||||
"stickers/${item.imagePath}"
|
||||
}
|
||||
val inputStream = holder.itemView.context.assets.open(path)
|
||||
BitmapFactory.decodeStream(inputStream)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
holder.imgFrameThumb.setImageBitmap(bitmap)
|
||||
} else {
|
||||
holder.imgFrameThumb.setImageResource(R.drawable.ic_image_gallery)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
holder.imgFrameThumb.setImageResource(R.drawable.ic_image_gallery)
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight stroke for selected
|
||||
val strokeWidthPx = (3 * holder.itemView.context.resources.displayMetrics.density).toInt()
|
||||
if (position == selectedPosition) {
|
||||
holder.cardSubFrame.strokeColor = ContextCompat.getColor(holder.itemView.context, R.color.theme_primary)
|
||||
holder.cardSubFrame.strokeWidth = strokeWidthPx
|
||||
} else {
|
||||
holder.cardSubFrame.strokeColor = ContextCompat.getColor(holder.itemView.context, R.color.theme_transparent)
|
||||
holder.cardSubFrame.strokeWidth = 0
|
||||
}
|
||||
|
||||
holder.itemView.setOnClickListener {
|
||||
val old = selectedPosition
|
||||
selectedPosition = holder.adapterPosition
|
||||
notifyItemChanged(old)
|
||||
notifyItemChanged(selectedPosition)
|
||||
onStickerSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
fun updateItems(newItems: List<StickerItem>) {
|
||||
items = newItems
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun selectItem(stickerId: String) {
|
||||
val index = items.indexOfFirst { it.id == stickerId }
|
||||
if (index != -1) {
|
||||
val old = selectedPosition
|
||||
selectedPosition = index
|
||||
notifyItemChanged(old)
|
||||
notifyItemChanged(selectedPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.photobooth.app
|
||||
|
||||
data class StickerItem(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val imagePath: String
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.photobooth.app
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import java.io.File
|
||||
|
||||
data class OnlineStoreItem(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val url: String,
|
||||
val version: Int,
|
||||
val category: String, // "frames", "backgrounds", "presets"
|
||||
var downloadState: DownloadState = DownloadState.NOT_DOWNLOADED
|
||||
)
|
||||
|
||||
enum class DownloadState {
|
||||
NOT_DOWNLOADED,
|
||||
DOWNLOADING,
|
||||
DOWNLOADED
|
||||
}
|
||||
|
||||
class StoreItemAdapter(
|
||||
private var items: List<OnlineStoreItem>,
|
||||
private val filesDir: File,
|
||||
private val onActionClick: (OnlineStoreItem) -> Unit
|
||||
) : RecyclerView.Adapter<StoreItemAdapter.ViewHolder>() {
|
||||
|
||||
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val imgThumb: ImageView = view.findViewById(R.id.imgStoreAssetThumb)
|
||||
val txtName: TextView = view.findViewById(R.id.txtStoreAssetName)
|
||||
val btnAction: Button = view.findViewById(R.id.btnStoreAssetAction)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_online_asset, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
holder.txtName.text = item.name
|
||||
|
||||
// Set icon based on category
|
||||
when (item.category) {
|
||||
"frames" -> holder.imgThumb.setImageResource(R.drawable.ic_default_frame_thumbnail)
|
||||
"backgrounds" -> holder.imgThumb.setImageResource(R.drawable.ic_image_gallery)
|
||||
"presets" -> holder.imgThumb.setImageResource(R.drawable.ic_default_preset_thumbnail)
|
||||
"stickers" -> holder.imgThumb.setImageResource(R.drawable.ic_main_sticker)
|
||||
else -> holder.imgThumb.setImageResource(R.drawable.ic_image_gallery)
|
||||
}
|
||||
|
||||
// Determine if downloaded
|
||||
val isDownloaded = isAssetDownloaded(item)
|
||||
if (isDownloaded) {
|
||||
item.downloadState = DownloadState.DOWNLOADED
|
||||
} else if (item.downloadState != DownloadState.DOWNLOADING) {
|
||||
item.downloadState = DownloadState.NOT_DOWNLOADED
|
||||
}
|
||||
|
||||
// Update button appearance based on state
|
||||
when (item.downloadState) {
|
||||
DownloadState.NOT_DOWNLOADED -> {
|
||||
holder.btnAction.text = "Tải về"
|
||||
holder.btnAction.isEnabled = true
|
||||
holder.btnAction.setBackgroundColor(
|
||||
androidx.core.content.ContextCompat.getColor(holder.itemView.context, R.color.theme_primary)
|
||||
)
|
||||
}
|
||||
DownloadState.DOWNLOADING -> {
|
||||
holder.btnAction.text = "Đang tải..."
|
||||
holder.btnAction.isEnabled = false
|
||||
holder.btnAction.setBackgroundColor(
|
||||
androidx.core.content.ContextCompat.getColor(holder.itemView.context, R.color.theme_item_card_bg)
|
||||
)
|
||||
}
|
||||
DownloadState.DOWNLOADED -> {
|
||||
holder.btnAction.text = "Áp dụng"
|
||||
holder.btnAction.isEnabled = true
|
||||
holder.btnAction.setBackgroundColor(
|
||||
android.graphics.Color.parseColor("#4CAF50") // Green for Apply
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
holder.btnAction.setOnClickListener {
|
||||
onActionClick(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
fun updateItems(newItems: List<OnlineStoreItem>) {
|
||||
items = newItems
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun isAssetDownloaded(item: OnlineStoreItem): Boolean {
|
||||
return when (item.category) {
|
||||
"frames" -> {
|
||||
val ext = item.url.substringAfterLast(".", "png")
|
||||
val localFile = File(filesDir, "downloaded_frames/${item.id}.$ext")
|
||||
localFile.exists()
|
||||
}
|
||||
"backgrounds" -> {
|
||||
val ext = item.url.substringAfterLast(".", "png")
|
||||
val localFile = File(filesDir, "downloaded_backgrounds/${item.id}.$ext")
|
||||
localFile.exists()
|
||||
}
|
||||
"presets" -> {
|
||||
val localFile = File(filesDir, "${item.id}.json")
|
||||
localFile.exists()
|
||||
}
|
||||
"stickers" -> {
|
||||
val ext = item.url.substringAfterLast(".", "png")
|
||||
val localFile = File(filesDir, "downloaded_stickers/${item.id}.$ext")
|
||||
localFile.exists()
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,21c-3.31,0 -6,-2.69 -6,-6v-1h2v1c0,2.21 1.79,4 4,4s4,-1.79 4,-4v-1h2v1c0,3.31 -2.69,6 -6,6zM12,3c-4.42,0 -8,3.58 -8,8v1h2v-1c0,-3.31 2.69,-6 6,-6s6,2.69 6,6v1h2v-1c0,-4.42 -3.58,-8 -8,-8z" />
|
||||
</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,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z" />
|
||||
</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,2A10,10 0,0 0,2 12c0,3.61 1.93,6.77 4.82,8.54C7.75,18.57 9.77,17 12,17s4.25,1.57 5.18,3.54C20.07,18.77 22,15.61 22,12A10,10 0,0 0,12,2M12,15a3.5,3.5 0,1 1,3.5 -3.5A3.5,3.5 0,0 1,12,15z" />
|
||||
</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,2c-4.97,0 -9,4.03 -9,9h2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7h2c0,-4.97 -4.03,-9 -9,-9zM12,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3s3,-1.34 3,-3s-1.34,-3 -3,-3z" />
|
||||
</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,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8v16z" />
|
||||
</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,16c-3.13,0 -5.67,-1.82 -6.74,-4.31C5.09,11.3 5.4,11 5.92,11h12.16c0.52,0 0.83,0.3 0.66,0.69C17.67,14.18 15.13,16 12,16zM12,8c3.13,0 5.67,1.82 6.74,4.31c0.17,0.39 -0.14,0.69 -0.66,0.69H5.92c-0.52,0 -0.83,-0.3 -0.66,-0.69C6.33,9.82 8.87,8 12,8z" />
|
||||
</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,2c-1.1,0 -2,0.9 -2,2v8.5c-1.5,-0.8 -3.5,-0.8 -5,0.5c-1.2,1 -1.2,2.8 -0.2,3.8c1,1 2.8,1 3.8,-0.2c0.8,-1 1,-2.5 0.4,-3.8L10,12v5c0,1.1 0.9,2 2,2s2,-0.9 2,-2v-5l1,0.8c-0.6,1.3 -0.4,2.8 0.4,3.8c1,1.2 2.8,1.2 3.8,0.2c1,-1 1,-2.8 -0.2,-3.8c-1.5,-1.3 -3.5,-1.3 -5,-0.5V4C14,2.9 13.1,2 12,2z" />
|
||||
</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>
|
||||
@@ -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,2zM4,12c0,-4.41 3.59,-8 8,-8v16C7.59,20 4,16.41 4,12z" />
|
||||
</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="M9,9.5L7.5,11L6,9.5L4.5,11L3,9.5c0,-3.31 2.69,-6 6,-6c0.55,0 1,0.45 1,1s-0.45,1 -1,1c-2.21,0 -4,1.79 -4,4l0.5,-0.5L7.5,10l1.5,-1L9,9.5zM21,9.5l-1.5,1.5L18,9.5l-1.5,1.5l-1.5,-1.5c0,-3.31 2.69,-6 6,-6c0.55,0 1,0.45 1,1s-0.45,1 -1,1c-2.21,0 -4,1.79 -4,4l0.5,-0.5l2,-1l1.5,-1l0,0.5zM12,18.5c0,-3.31 2.69,-6 6,-6c0.55,0 1,0.45 1,1s-0.45,1 -1,1c-2.21,0 -4,1.79 -4,4l0.5,-0.5l2,-1l1.5,-1l0,0.5l-1.5,1.5L15,18.5c0,-3.31 -2.69,-6 -6,-6c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1c2.21,0 4,1.79 4,4l-0.5,-0.5l-2,-1l-1.5,-1l0,0.5l1.5,1.5l-1.5,1.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="M9,11.75c-0.69,0 -1.25,0.56 -1.25,1.25s0.56,1.25 1.25,1.25s1.25,-0.56 1.25,-1.25s-0.56,-1.25 -1.25,-1.25zM15,11.75c-0.69,0 -1.25,0.56 -1.25,1.25s0.56,1.25 1.25,1.25s1.25,-0.56 1.25,-1.25s-0.56,-1.25 -1.25,-1.25zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8c0,-0.05 0.01,-0.1 0.01,-0.15c2.42,-0.92 4.29,-3.03 5.09,-5.63c2.42,2.1 5.67,3.38 9.25,3.38c0.79,0 1.56,-0.06 2.31,-0.17c0.22,0.8 0.34,1.64 0.34,2.51c0,4.41 -3.59,8 -8,8z" />
|
||||
</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="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8s8,3.58 8,8s-3.58,8 -8,8zM15.5,11c0.83,0 1.5,-0.67 1.5,-1.5S16.33,8 15.5,8S14,8.67 14,9.5s0.67,1.5 1.5,1.5zM8.5,11c0.83,0 1.5,-0.67 1.5,-1.5S9.33,8 8.5,8S7,8.67 7,9.5S7.67,11 8.5,11zM12,17.5c2.33,0 4.31,-1.46 5.11,-3.5H6.89c0.8,2.04 2.78,3.5 5.11,3.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="M6,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
|
||||
</vector>
|
||||
@@ -82,6 +82,7 @@
|
||||
android:id="@+id/cameraContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:background="#000000"
|
||||
app:layout_constraintDimensionRatio="3:4"
|
||||
app:layout_constraintTop_toBottomOf="@id/topBar"
|
||||
app:layout_constraintBottom_toTopOf="@id/panelSelectionContainer">
|
||||
@@ -92,6 +93,15 @@
|
||||
android:layout_width="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 -->
|
||||
<ImageView
|
||||
android:id="@+id/imgFilterOverlay"
|
||||
@@ -101,6 +111,13 @@
|
||||
android:visibility="gone"
|
||||
android:contentDescription="Filter Overlay" />
|
||||
|
||||
<!-- Lớp phủ tạo nhiễu hạt phim thời gian thực (Dynamic Film Grain Viewfinder Overlay) -->
|
||||
<com.photobooth.app.FilmGrainView
|
||||
android:id="@+id/viewFilmGrain"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Lớp đè (Overlay): Hiển thị Khung hình PNG do người dùng chọn -->
|
||||
<ImageView
|
||||
android:id="@+id/imgFrameOverlay"
|
||||
@@ -109,6 +126,24 @@
|
||||
android:scaleType="fitCenter"
|
||||
android:contentDescription="Frame Overlay" />
|
||||
|
||||
<!-- Lớp đè hình dán (Stickers Overlay) -->
|
||||
<ImageView
|
||||
android:id="@+id/imgStickerOverlay"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="120dp"
|
||||
android:layout_gravity="center"
|
||||
android:scaleType="fitCenter"
|
||||
android:visibility="gone"
|
||||
android:contentDescription="Sticker 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 -->
|
||||
<TextView
|
||||
android:id="@+id/txtTimerCountdown"
|
||||
@@ -125,6 +160,14 @@
|
||||
android:visibility="gone"
|
||||
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>
|
||||
|
||||
<!-- ================= KHỐI 1: TIMELINE FRAMES & PRESETS ================= -->
|
||||
@@ -137,7 +180,7 @@
|
||||
app:layout_constraintTop_toBottomOf="@id/cameraContainer"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottomControlPanel">
|
||||
|
||||
<!-- Danh sách cuộn ngang -->
|
||||
<!-- Danh sách cuộn ngang (Frames / Presets) -->
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvHorizontalTimeline"
|
||||
android:layout_width="match_parent"
|
||||
@@ -149,7 +192,65 @@
|
||||
android:paddingEnd="16dp"
|
||||
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) -->
|
||||
<!-- 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 -->
|
||||
<View
|
||||
android:id="@+id/viewLeftDockBackground"
|
||||
android:layout_width="75dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- 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
|
||||
android:id="@+id/buttonsWrapper"
|
||||
android:layout_width="match_parent"
|
||||
@@ -158,8 +259,8 @@
|
||||
<!-- Nút Frame -->
|
||||
<ImageView
|
||||
android:id="@+id/btnMainFrame"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_default_frame_thumbnail"
|
||||
android:background="@android:color/transparent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
@@ -167,22 +268,64 @@
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/btnMainPreset"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:contentDescription="Frame Selector" />
|
||||
|
||||
<!-- Nút Presets Màu -->
|
||||
<ImageView
|
||||
android:id="@+id/btnMainPreset"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_default_preset_thumbnail"
|
||||
android:background="@android:color/transparent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/btnMainFrame"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/btnMainBackground"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:contentDescription="Preset Color Selector" />
|
||||
|
||||
<!-- Nút Nền (Backgrounds - Thay/Xóa nền) -->
|
||||
<ImageView
|
||||
android:id="@+id/btnMainBackground"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
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/btnMainPreset"
|
||||
app:layout_constraintEnd_toStartOf="@id/btnMainSticker"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:contentDescription="Background Selector" />
|
||||
|
||||
<!-- Nút Stickers (Hình dán) -->
|
||||
<ImageView
|
||||
android:id="@+id/btnMainSticker"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_main_sticker"
|
||||
android:background="@android:color/transparent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/btnMainBackground"
|
||||
app:layout_constraintEnd_toStartOf="@id/btnMainBeauty"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:contentDescription="Stickers Selector" />
|
||||
|
||||
<!-- Nút Beauty (Làm đẹp) -->
|
||||
<ImageView
|
||||
android:id="@+id/btnMainBeauty"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_main_beauty"
|
||||
android:background="@android:color/transparent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/btnMainSticker"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:contentDescription="Beauty Selector" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</FrameLayout>
|
||||
|
||||
@@ -289,6 +432,7 @@
|
||||
|
||||
<!-- Kim chỉ số 0 màu cam chính giữa cố định -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutRulerPointer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
@@ -357,4 +501,17 @@
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<!-- Biểu tượng loading xoay tròn khi đang xử lý ảnh -->
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:visibility="gone"
|
||||
android:elevation="10dp"
|
||||
android:indeterminateTint="@color/theme_primary"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@color/theme_sub_bar_bg"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- Header Section -->
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvStoreTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:text="Cửa Hàng Tài Nguyên"
|
||||
android:textColor="@color/theme_white"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnStoreClose"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@drawable/ic_close_white"
|
||||
android:contentDescription="Close Store" />
|
||||
</RelativeLayout>
|
||||
|
||||
<!-- Editable Cloud Server URL Input -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@color/theme_item_card_bg"
|
||||
android:padding="8dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etServerUrl"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="Địa chỉ Cloud Server"
|
||||
android:textColorHint="#88FFFFFF"
|
||||
android:textColor="@color/theme_white"
|
||||
android:textSize="14sp"
|
||||
android:inputType="textUri"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="http://10.0.2.2:8080" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnConnectServer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Kết nối"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/theme_panel_dark"
|
||||
android:backgroundTint="@color/theme_primary"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:minHeight="36dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Custom Category Tabs -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:weightSum="4">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTabFrames"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="2dp"
|
||||
android:text="Khung"
|
||||
android:textSize="11sp"
|
||||
android:backgroundTint="@color/theme_primary"
|
||||
android:textColor="@color/theme_panel_dark"
|
||||
android:paddingHorizontal="2dp"
|
||||
android:minHeight="40dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTabBackgrounds"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="2dp"
|
||||
android:text="Nền"
|
||||
android:textSize="11sp"
|
||||
android:backgroundTint="@color/theme_item_card_bg"
|
||||
android:textColor="@color/theme_white"
|
||||
android:paddingHorizontal="2dp"
|
||||
android:minHeight="40dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTabPresets"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="2dp"
|
||||
android:text="Màu"
|
||||
android:textSize="11sp"
|
||||
android:backgroundTint="@color/theme_item_card_bg"
|
||||
android:textColor="@color/theme_white"
|
||||
android:paddingHorizontal="2dp"
|
||||
android:minHeight="40dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTabStickers"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="2dp"
|
||||
android:text="Dán"
|
||||
android:textSize="11sp"
|
||||
android:backgroundTint="@color/theme_item_card_bg"
|
||||
android:textColor="@color/theme_white"
|
||||
android:paddingHorizontal="2dp"
|
||||
android:minHeight="40dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- List & Loading Container -->
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="250dp">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvStoreAssets"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="visible" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressLoadingStore"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminateTint="@color/theme_primary"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Error Layout -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutStoreError"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:visibility="gone"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Vui lòng kết nối mạng để tải thêm tài nguyên"
|
||||
android:textColor="#E57373"
|
||||
android:textSize="14sp"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStoreRetry"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Thử lại"
|
||||
android:textColor="@color/theme_panel_dark"
|
||||
android:backgroundTint="@color/theme_primary" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@color/theme_item_card_bg"
|
||||
android:layout_marginVertical="4dp"
|
||||
android:padding="8dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
app:cardCornerRadius="4dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="#44FFFFFF"
|
||||
android:layout_marginEnd="12dp"
|
||||
app:cardBackgroundColor="@color/theme_panel_dark">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imgStoreAssetThumb"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/ic_image_gallery" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/txtStoreAssetName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Asset Name"
|
||||
android:textColor="@color/theme_white"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStoreAssetAction"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Tải về"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@color/theme_panel_dark"
|
||||
android:backgroundTint="@color/theme_primary"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:minHeight="36dp"
|
||||
android:minWidth="90dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -2,3 +2,4 @@ plugins {
|
||||
id("com.android.application") version "8.2.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
}
|
||||
|
||||
|
||||
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.
|
Before Width: | Height: | Size: 598 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 494 KiB |
Binary file not shown.
@@ -0,0 +1,64 @@
|
||||
import os
|
||||
import urllib.request
|
||||
from ultralytics import YOLO
|
||||
|
||||
# 1. Định nghĩa cấu trúc đường dẫn thư mục Assets của dự án Android
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
ASSETS_MODELS_DIR = os.path.join(PROJECT_ROOT, "android-project", "app", "src", "main", "assets", "models")
|
||||
|
||||
# Tạo thư mục nếu chưa tồn tại
|
||||
os.makedirs(ASSETS_MODELS_DIR, exist_ok=True)
|
||||
|
||||
print(f"🚀 Bắt đầu tiến trình tự động hóa tài nguyên AI...")
|
||||
print(f"📂 Thư mục đích: {ASSETS_MODELS_DIR}\n" + "-"*50)
|
||||
|
||||
# =====================================================================
|
||||
# PHẦN 1: TỰ ĐỘNG TẢI VÀ CHUYỂN ĐỔI YOLOV11-SEGMENTATION
|
||||
# =====================================================================
|
||||
try:
|
||||
print("⏳ 1. Đang tải và cấu hình YOLOv11-Segmentation (Bản Nano)...")
|
||||
# Ultralytics sẽ tự động tải file .pt từ bản phát hành chính thức nếu chưa có sẵn
|
||||
yolo_model = YOLO("yolov11n-seg.pt")
|
||||
|
||||
print("🔄 Đang export YOLOv11-Seg sang định dạng TFLite (Float16 tối ưu GPU)...")
|
||||
# Cấu hình imgsz=640 khớp hoàn toàn với kiến trúc xử lý của DualAiEngine
|
||||
exported_path = yolo_model.export(format="tflite", imgsz=640, half=True, int8=False)
|
||||
|
||||
# Tìm file .tflite vừa được sinh ra trong thư mục kết quả của Ultralytics
|
||||
# Mặc định cấu trúc đầu ra là: yolov11n-seg_saved_model/yolov11n-seg_float16.tflite
|
||||
yolo_generated_file = os.path.join(PROJECT_ROOT, "yolov11n-seg_saved_model", "yolov11n-seg_float16.tflite")
|
||||
|
||||
if os.path.exists(yolo_generated_file):
|
||||
dest_yolo_path = os.path.join(ASSETS_MODELS_DIR, "yolov11n_seg_portrait.tflite")
|
||||
os.replace(yolo_generated_file, dest_yolo_path)
|
||||
print(f"✅ Đã chuyển đổi và di chuyển YOLOv11 thành công vào: {dest_yolo_path}")
|
||||
else:
|
||||
print("❌ Lỗi: Không tìm thấy file TFLite của YOLO sau khi export!")
|
||||
except Exception as e:
|
||||
print(f"❌ Thất bại khi xử lý YOLOv11: {str(e)}")
|
||||
|
||||
print("-"*50)
|
||||
|
||||
# =====================================================================
|
||||
# PHẦN 2: TỰ ĐỘNG TẢI GOOGLE MEDIAPIPE FACE MESH
|
||||
# =====================================================================
|
||||
# URL tải trực tiếp mô hình Face Landmarker (Bundle gồm Face Mesh) chính thức của Google
|
||||
MEDIAPIPE_MODEL_URL = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task"
|
||||
dest_mesh_path = os.path.join(ASSETS_MODELS_DIR, "face_mesh_landmark.tflite")
|
||||
|
||||
try:
|
||||
print("⏳ 2. Đang tải trực tiếp mô hình Google Face Mesh từ Cloud Storage...")
|
||||
|
||||
# Thiết lập thanh tiến trình tải (Progress Bar) đơn giản
|
||||
def download_progress(count, block_size, total_size):
|
||||
percent = int(count * block_size * 100 / total_size)
|
||||
print(f"\r📥 Đang tải: {percent}%", end="")
|
||||
|
||||
urllib.request.urlretrieve(MEDIAPIPE_MODEL_URL, dest_mesh_path, download_progress)
|
||||
print(f"\n✅ Đã tải và đổi đuôi mô hình Face Mesh thành công vào: {dest_mesh_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Thất bại khi tải mô hình Face Mesh: {str(e)}")
|
||||
|
||||
print("="*50)
|
||||
print("🎉 HOÀN THÀNH Quy trình tự động hóa cung cấp mô hình AI cho dự án!")
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "preset_fuji_cloud",
|
||||
"name": "Fuji Retro (Cloud)",
|
||||
"theme_category": "Retro Vintage",
|
||||
"brightness": 0.1,
|
||||
"contrast": -0.05,
|
||||
"highlight": -0.1,
|
||||
"shadow": 0.1,
|
||||
"saturation": -0.15,
|
||||
"vibrance": 0.2,
|
||||
"temperature": 0.15,
|
||||
"tint": -0.05,
|
||||
"clarity": -0.1,
|
||||
"dehaze": -0.05,
|
||||
"vignette_amount": 0.3,
|
||||
"faded_black_level": 0.1,
|
||||
"shadows_tint_r": 0.05,
|
||||
"shadows_tint_g": 0.0,
|
||||
"shadows_tint_b": 0.08,
|
||||
"grain_amount": 0.25,
|
||||
"grain_size": 0.15
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"frames": [
|
||||
{
|
||||
"id": "frame_vintage_cloud",
|
||||
"name": "Vintage Gold (Cloud)",
|
||||
"url": "http://10.0.2.2:8080/assets/frames/instaxframe.png",
|
||||
"version": 1
|
||||
}
|
||||
],
|
||||
"backgrounds": [
|
||||
{
|
||||
"id": "bg_neon_cloud",
|
||||
"name": "Neon Pink (Cloud)",
|
||||
"url": "http://10.0.2.2:8080/assets/backgrounds/background-01.png",
|
||||
"version": 1
|
||||
}
|
||||
],
|
||||
"presets": [
|
||||
{
|
||||
"id": "preset_fuji_cloud",
|
||||
"name": "Fuji Retro (Cloud)",
|
||||
"url": "http://10.0.2.2:8080/fuji_retro.json",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user