diff --git a/36_PRESET_APPLY.md b/36_PRESET_APPLY.md new file mode 100644 index 0000000..b8cb00c --- /dev/null +++ b/36_PRESET_APPLY.md @@ -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). \ No newline at end of file diff --git a/37_AI_REPLACE.md b/37_AI_REPLACE.md new file mode 100644 index 0000000..e69de29 diff --git a/38_AI_PIPELINE.md b/38_AI_PIPELINE.md new file mode 100644 index 0000000..8ce22bb --- /dev/null +++ b/38_AI_PIPELINE.md @@ -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, 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 { + // Trích xuất ma trận 478 tọa độ điểm 3D từ Face Mesh + val landmarksList = ArrayList() + // [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, + 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() + 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. \ No newline at end of file diff --git a/android-project/app/build.gradle.kts b/android-project/app/build.gradle.kts index 6d5592e..0c55300 100644 --- a/android-project/app/build.gradle.kts +++ b/android-project/app/build.gradle.kts @@ -65,4 +65,25 @@ dependencies { // --- 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") +} \ No newline at end of file diff --git a/android-project/app/src/main/assets/models/conver.py b/android-project/app/src/main/assets/models/conver.py new file mode 100644 index 0000000..7bb222b --- /dev/null +++ b/android-project/app/src/main/assets/models/conver.py @@ -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!") \ No newline at end of file diff --git a/android-project/app/src/main/assets/models/face_mesh_landmark.tflite b/android-project/app/src/main/assets/models/face_mesh_landmark.tflite new file mode 100644 index 0000000..c50c845 Binary files /dev/null and b/android-project/app/src/main/assets/models/face_mesh_landmark.tflite differ diff --git a/android-project/app/src/main/assets/models/yolov11n-seg.pt b/android-project/app/src/main/assets/models/yolov11n-seg.pt new file mode 100644 index 0000000..628f517 Binary files /dev/null and b/android-project/app/src/main/assets/models/yolov11n-seg.pt differ diff --git a/android-project/app/src/main/assets/models/yolov11n_seg_portrait.tflite b/android-project/app/src/main/assets/models/yolov11n_seg_portrait.tflite new file mode 100644 index 0000000..c50c845 Binary files /dev/null and b/android-project/app/src/main/assets/models/yolov11n_seg_portrait.tflite differ diff --git a/android-project/app/src/main/java/com/photobooth/app/AttributeAdapter.kt b/android-project/app/src/main/java/com/photobooth/app/AttributeAdapter.kt index 11fc872..de25bcd 100644 --- a/android-project/app/src/main/java/com/photobooth/app/AttributeAdapter.kt +++ b/android-project/app/src/main/java/com/photobooth/app/AttributeAdapter.kt @@ -12,7 +12,8 @@ enum class EditAttribute { 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 - PORTRAIT_BLUR, BG_REPLACE // Nhóm 5: Tách nền AI + 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( @@ -34,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 diff --git a/android-project/app/src/main/java/com/photobooth/app/DualAiEngine.kt b/android-project/app/src/main/java/com/photobooth/app/DualAiEngine.kt new file mode 100644 index 0000000..5bac571 --- /dev/null +++ b/android-project/app/src/main/java/com/photobooth/app/DualAiEngine.kt @@ -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, 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 { + val landmarksList = ArrayList() + + 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(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 + } +} diff --git a/android-project/app/src/main/java/com/photobooth/app/FaceBeautySettings.kt b/android-project/app/src/main/java/com/photobooth/app/FaceBeautySettings.kt new file mode 100644 index 0000000..a020d91 --- /dev/null +++ b/android-project/app/src/main/java/com/photobooth/app/FaceBeautySettings.kt @@ -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 +) diff --git a/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt b/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt index 3d75b3f..e7e31b4 100644 --- a/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt +++ b/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt @@ -53,20 +53,38 @@ class MainActivity : AppCompatActivity() { private var isPresetModeOpen = false private var isPortraitBlurModeOpen = false private var isBgReplaceModeOpen = false + private var isBackgroundModeOpen = false + private var isStickerModeOpen = false + private var isBeautyModeOpen = false + private var isPortraitBlurRulerOpen = false private var currentSelectedFramePath: String? = null - private var currentSelectedPreset: ColorPreset? = null + private var currentSelectedStickerPath: String? = null + private lateinit var stickerAdapter: StickerAdapter + private lateinit var allStickers: List + private var activeBeautySettings = FaceBeautySettings() + private lateinit var beautyItems: List + private lateinit var beautyAdapter: AttributeAdapter + private var activeBeautyAttribute: EditAttribute? = null + private var dX = 0f + private var dY = 0f + @Volatile private var currentSelectedPreset: ColorPreset? = null + private val isCapturing = java.util.concurrent.atomic.AtomicBoolean(false) private var editingPreset: ColorPreset? = null private var currentAttribute: EditAttribute = EditAttribute.BRIGHTNESS private lateinit var attributeAdapter: AttributeAdapter private var startX = 0f + private var startY = 0f private var startVal = 0 // Background replacement and Portrait blur state private var blurIntensity = 0.0f private var selectedBgAssetPath: String? = null + // AI Beauty (Face Reshaping) state private lateinit var backgroundOptionAdapter: BackgroundOptionAdapter private lateinit var backgroundOptions: List + private lateinit var dualAiEngine: DualAiEngine + private lateinit var meshWarpEngine: MeshWarpEngine private val aiInferenceLock = Any() private var imageSegmenter: ImageSegmenter? = null private var isAiSupported = true @@ -91,7 +109,7 @@ class MainActivity : AppCompatActivity() { val oldMode = currentBackgroundMode currentBackgroundMode = when { selectedBgAssetPath != null || blurIntensity > 0f -> BackgroundMode.AI_BLUR - isPortraitBlurModeOpen -> BackgroundMode.HARDWARE_DOF + isPortraitBlurRulerOpen -> BackgroundMode.HARDWARE_DOF else -> BackgroundMode.DEFAULT } if (currentBackgroundMode == BackgroundMode.AI_BLUR && oldMode != BackgroundMode.AI_BLUR) { @@ -151,8 +169,12 @@ class MainActivity : AppCompatActivity() { // Setup Frame/Preset selection lists initFrameData() + initStickerData() setupRecyclerViews() + dualAiEngine = DualAiEngine(this) + meshWarpEngine = MeshWarpEngine() + // Request camera permission if (allPermissionsGranted()) { startCamera() @@ -169,7 +191,16 @@ class MainActivity : AppCompatActivity() { FrameItem("DEFAULT", "Mặc định", ""), FrameItem("instax_mini_single", "Instax Mini", "frames/instaxframe.png") ) + loadDownloadedFrames() + listOf(FrameItem("MORE", "Tải thêm", "")) + } + private fun initStickerData() { + allStickers = listOf( + StickerItem("DEFAULT", "Mặc định", ""), + StickerItem("st_sunglasses", "Kính râm", "stickers/sunglasses.png"), + StickerItem("st_vintage_camera", "Máy ảnh", "stickers/vintage_camera.png"), + StickerItem("st_vintage_heart", "Trái tim", "stickers/vintage_heart.png") + ) + loadDownloadedStickers() + listOf(StickerItem("MORE", "Tải thêm", "")) + // Initialize default Color Presets val defaultPresets = listOf( ColorPreset( @@ -312,6 +343,19 @@ class MainActivity : AppCompatActivity() { openAdvancedSliders(preset) }) + stickerAdapter = StickerAdapter(allStickers) { sticker -> + if (sticker.id == "MORE") { + openOnlineStoreDialog("stickers") + } else { + setStickerOverlay(sticker.imagePath) + if (sticker.id != "DEFAULT") { + binding.imgStickerOverlay.visibility = android.view.View.VISIBLE + } else { + binding.imgStickerOverlay.visibility = android.view.View.GONE + } + } + } + // Setup Attributes List and Ruler View for the editor panel val attributeItems = listOf( AttributeNode(EditAttribute.BRIGHTNESS, "Brightness (Độ sáng)", R.drawable.ic_attr_brightness), @@ -337,6 +381,20 @@ class MainActivity : AppCompatActivity() { attributeAdapter = AttributeAdapter(attributeItems) { node -> onAttributeSelected(node) } + + beautyItems = listOf( + AttributeNode(EditAttribute.EYE_SIZE, "Mắt", R.drawable.ic_attr_eye_size), + AttributeNode(EditAttribute.NOSE_SIZE, "Mũi", R.drawable.ic_attr_nose_size), + AttributeNode(EditAttribute.MOUTH_SIZE, "Miệng", R.drawable.ic_attr_mouth_size), + AttributeNode(EditAttribute.FACE_SLIM, "V-Line", R.drawable.ic_attr_face_slim), + AttributeNode(EditAttribute.FACE_SIZE, "Kích thước mặt", R.drawable.ic_attr_face_slim), + AttributeNode(EditAttribute.FOREHEAD_SIZE, "Trán", R.drawable.ic_attr_forehead_size), + AttributeNode(EditAttribute.CHIN_SIZE, "Cằm", R.drawable.ic_attr_nose_size), + AttributeNode(EditAttribute.SKIN_SMOOTH, "Mịn da", R.drawable.ic_attr_skin_smooth) + ) + beautyAdapter = AttributeAdapter(beautyItems) { node -> + onBeautyAttributeSelected(node) + } binding.rvAttributeIcons.layoutManager = LinearLayoutManager( this, LinearLayoutManager.HORIZONTAL, false ) @@ -345,6 +403,7 @@ class MainActivity : AppCompatActivity() { // Initialize Background replacement options list backgroundOptions = listOf( BackgroundOption("NONE", "Mặc định", null), + BackgroundOption("PORTRAIT_BLUR", "Xóa phông", null), BackgroundOption("BG_01", "Nền 1", "backgrounds/background-01.png"), BackgroundOption("BG_02", "Nền 2", "backgrounds/background-02.png"), BackgroundOption("BG_03", "Nền 3", "backgrounds/background-03.png") @@ -352,8 +411,22 @@ class MainActivity : AppCompatActivity() { backgroundOptionAdapter = BackgroundOptionAdapter(backgroundOptions) { option -> if (option.id == "MORE") { openOnlineStoreDialog("backgrounds") + } else if (option.id == "PORTRAIT_BLUR") { + isPortraitBlurRulerOpen = true + selectedBgAssetPath = null + binding.rvBackgroundOptions.visibility = android.view.View.GONE + binding.panelRulerContainer.visibility = android.view.View.VISIBLE + + binding.panelRulerView.value = (blurIntensity * 100f).toInt() + binding.tvPanelRulerValue.text = "${(blurIntensity * 100f).toInt()}" + binding.tvCurrentAttributeName.text = "Portrait Blur (Xóa phông)" + binding.layoutEditorTitleBar.visibility = android.view.View.VISIBLE + updateBackgroundMode() } else { + blurIntensity = 0f + isPortraitBlurRulerOpen = false selectedBgAssetPath = option.assetPath + binding.layoutEditorTitleBar.visibility = android.view.View.GONE updateBackgroundMode() } } @@ -366,13 +439,30 @@ class MainActivity : AppCompatActivity() { editingPreset?.let { applyPresetFilter(it) } } - binding.panelRulerView.minVal = 0 + binding.panelRulerView.minVal = -100 binding.panelRulerView.maxVal = 100 binding.panelRulerView.onValueChangeListener = { valInt -> binding.tvPanelRulerValue.text = "$valInt" - blurIntensity = valInt / 100f - updateBackgroundMode() + if (isBeautyModeOpen) { + val valFloat = valInt / 100f // maps [-100..100] -> [-1f..1f] + when (activeBeautyAttribute) { + EditAttribute.EYE_SIZE -> activeBeautySettings.eyeEnlarge = valFloat + EditAttribute.NOSE_SIZE -> activeBeautySettings.noseSlim = valFloat + EditAttribute.MOUTH_SIZE -> activeBeautySettings.mouthSize = valFloat + EditAttribute.FACE_SLIM -> activeBeautySettings.faceSlim = valFloat + EditAttribute.FACE_SIZE -> activeBeautySettings.faceSize = valFloat + EditAttribute.FOREHEAD_SIZE -> activeBeautySettings.foreheadSize = valFloat + EditAttribute.CHIN_SIZE -> activeBeautySettings.chinSize = valFloat + EditAttribute.SKIN_SMOOTH -> activeBeautySettings.skinSmooth = valFloat + else -> {} + } + } else { + blurIntensity = (valInt.coerceAtLeast(0)) / 100f + updateBackgroundMode() + } } + + } private fun setFrameOverlay(fileName: String) { @@ -632,6 +722,9 @@ class MainActivity : AppCompatActivity() { applyColorMatrixToViewFinder(preset) binding.viewFilmGrain.intensity = preset.grain.grainAmount } + binding.viewFinder.invalidate() + binding.imgFilterOverlay.invalidate() + binding.viewFilmGrain.invalidate() } private fun applyColorMatrixToViewFinder(preset: ColorPreset) { @@ -684,12 +777,16 @@ class MainActivity : AppCompatActivity() { togglePresetMode() } - binding.btnMainPortraitBlur.setOnClickListener { - togglePortraitBlurMode() + binding.btnMainBackground.setOnClickListener { + toggleBackgroundMode() } - binding.btnMainBgReplace.setOnClickListener { - toggleBgReplaceMode() + binding.btnMainSticker.setOnClickListener { + toggleStickerMode() + } + + binding.btnMainBeauty.setOnClickListener { + toggleBeautyMode() } // Top Bar @@ -732,34 +829,20 @@ class MainActivity : AppCompatActivity() { onCaptureClick() } - // Camera viewfinder container swipe gesture listener to adjust parameters - binding.cameraContainer.setOnTouchListener { _, event -> - val isRulerVisible = binding.layoutTimelineEditor.visibility == android.view.View.VISIBLE - val isBlurVisible = isPortraitBlurModeOpen - - if (!isRulerVisible && !isBlurVisible) { - return@setOnTouchListener false - } - + + + binding.imgStickerOverlay.setOnTouchListener { view, event -> when (event.action) { android.view.MotionEvent.ACTION_DOWN -> { - startX = event.x - startVal = if (isBlurVisible) { - binding.panelRulerView.value - } else { - binding.rulerView.value - } + dX = view.x - event.rawX + dY = view.y - event.rawY } android.view.MotionEvent.ACTION_MOVE -> { - val deltaX = event.x - startX - val sensitivity = 5f // pixels per unit value change - val deltaVal = (deltaX / sensitivity).toInt() - - if (isBlurVisible) { - binding.panelRulerView.value = startVal + deltaVal - } else { - binding.rulerView.value = startVal + deltaVal - } + view.animate() + .x(event.rawX + dX) + .y(event.rawY + dY) + .setDuration(0) + .start() } } true @@ -775,58 +858,115 @@ class MainActivity : AppCompatActivity() { } } - // Touch-to-Focus: Chạm vào kính ngắm để chọn điểm lấy nét + // Unified Gesture Handler on Viewfinder: + // 1. Swipe horizontal to change parameters of the active ruler (presets, beauty, or portrait blur). + // 2. Long-press (hold 500ms) toggles precision mode (10% sensitivity) for fine adjustments. + // 3. Short tap triggers tap-to-focus and animates the focus ring. + val swipeHandler = android.os.Handler(mainLooper) + var isPrecisionMode = false + val touchSlop = android.view.ViewConfiguration.get(this).scaledTouchSlop + var isDragging = false + binding.viewFinder.setOnTouchListener { v, event -> - if (event.action == android.view.MotionEvent.ACTION_UP) { - val percentX = (event.x / v.width).coerceIn(0f, 1f) - val percentY = (event.y / v.height).coerceIn(0f, 1f) + val isRulerVisible = binding.layoutTimelineEditor.visibility == android.view.View.VISIBLE + val isPanelRulerVisible = isPortraitBlurRulerOpen || (isBeautyModeOpen && binding.panelRulerContainer.visibility == android.view.View.VISIBLE) + val isEditingActive = isRulerVisible || isPanelRulerVisible - // Cập nhật Class ID lấy nét từ điểm chạm (chỉ khi không sử dụng tính năng AI tách/xóa nền) - if (currentBackgroundMode != BackgroundMode.AI_BLUR) { - val mask = cachedCategoryMask - val mW = cachedMaskWidth - val mH = cachedMaskHeight - if (mask != null && mW > 0 && mH > 0) { - val maskX = (percentX * mW).toInt().coerceIn(0, mW - 1) - val maskY = (percentY * mH).toInt().coerceIn(0, mH - 1) - userSelectedClassFocus = mask[maskY * mW + maskX].toInt() + when (event.actionMasked) { + android.view.MotionEvent.ACTION_DOWN -> { + startX = event.x + startY = event.y + startVal = if (isPanelRulerVisible) { + binding.panelRulerView.value + } else { + binding.rulerView.value } + isDragging = false + isPrecisionMode = false + swipeHandler.removeCallbacksAndMessages(null) + // Hold 500ms without dragging to enter precision mode + swipeHandler.postDelayed({ isPrecisionMode = true }, 500) + } + android.view.MotionEvent.ACTION_MOVE -> { + val dx = event.x - startX + val dy = event.y - startY - val cameraCtrl = this@MainActivity.cameraControl - if (cameraCtrl != null) { - try { - val factory = binding.viewFinder.meteringPointFactory - val point = factory.createPoint(event.x, event.y) - val action = androidx.camera.core.FocusMeteringAction.Builder(point, androidx.camera.core.FocusMeteringAction.FLAG_AF or androidx.camera.core.FocusMeteringAction.FLAG_AE) - .setAutoCancelDuration(4, java.util.concurrent.TimeUnit.SECONDS) - .build() - cameraCtrl.startFocusAndMetering(action) - } catch (e: Exception) { - e.printStackTrace() + if (isEditingActive) { + if (!isDragging && (Math.abs(dx) > touchSlop || Math.abs(dy) > touchSlop)) { + isDragging = true + swipeHandler.removeCallbacksAndMessages(null) // Cancel precision mode if swiped early + } + + if (isDragging) { + // Sensitivity: 5px/unit normal, 30px/unit precision + val sensitivity = if (isPrecisionMode) 30f else 5f + val deltaVal = (dx / sensitivity).toInt() + + if (isPanelRulerVisible) { + binding.panelRulerView.value = (startVal + deltaVal).coerceIn(binding.panelRulerView.minVal, binding.panelRulerView.maxVal) + } else { + binding.rulerView.value = (startVal + deltaVal).coerceIn(binding.rulerView.minVal, binding.rulerView.maxVal) + } + } + } else { + if (Math.abs(dx) > touchSlop || Math.abs(dy) > touchSlop) { + isDragging = true } } - - // Hiện vòng tròn lấy nét màu cam tại vị trí chạm - val focusRing = binding.viewFocusRing - focusRing.x = event.x - focusRing.width / 2f - focusRing.y = event.y - focusRing.height / 2f - focusRing.visibility = android.view.View.VISIBLE - focusRing.alpha = 1f - focusRing.scaleX = 1.4f - focusRing.scaleY = 1.4f - focusRing.animate() - .scaleX(1f).scaleY(1f) - .alpha(0f) - .setDuration(900) - .withEndAction { focusRing.visibility = android.view.View.GONE } - .start() - } else { - userSelectedClassFocus = -1 - // Đảm bảo hủy bỏ lấy nét thấu kính vật lý để camera tự động lấy nét toàn cảnh rộng (AF liên tục) - cameraControl?.cancelFocusAndMetering() } + android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> { + swipeHandler.removeCallbacksAndMessages(null) + if (!isDragging) { + // It is a tap -> Perform focus and metering + val percentX = (event.x / v.width).coerceIn(0f, 1f) + val percentY = (event.y / v.height).coerceIn(0f, 1f) - v.performClick() + if (currentBackgroundMode != BackgroundMode.AI_BLUR) { + val mask = cachedCategoryMask + val mW = cachedMaskWidth + val mH = cachedMaskHeight + if (mask != null && mW > 0 && mH > 0) { + val maskX = (percentX * mW).toInt().coerceIn(0, mW - 1) + val maskY = (percentY * mH).toInt().coerceIn(0, mH - 1) + userSelectedClassFocus = mask[maskY * mW + maskX].toInt() + } + + val cameraCtrl = this@MainActivity.cameraControl + if (cameraCtrl != null) { + try { + val factory = binding.viewFinder.meteringPointFactory + val point = factory.createPoint(event.x, event.y) + val action = androidx.camera.core.FocusMeteringAction.Builder(point, androidx.camera.core.FocusMeteringAction.FLAG_AF or androidx.camera.core.FocusMeteringAction.FLAG_AE) + .setAutoCancelDuration(4, java.util.concurrent.TimeUnit.SECONDS) + .build() + cameraCtrl.startFocusAndMetering(action) + } catch (e: Exception) { + e.printStackTrace() + } + } + + // Show focus ring animation + val focusRing = binding.viewFocusRing + focusRing.x = event.x - focusRing.width / 2f + focusRing.y = event.y - focusRing.height / 2f + focusRing.visibility = android.view.View.VISIBLE + focusRing.alpha = 1f + focusRing.scaleX = 1.4f + focusRing.scaleY = 1.4f + focusRing.animate() + .scaleX(1f).scaleY(1f) + .alpha(0f) + .setDuration(900) + .withEndAction { focusRing.visibility = android.view.View.GONE } + .start() + } else { + userSelectedClassFocus = -1 + cameraControl?.cancelFocusAndMetering() + } + v.performClick() + } + isDragging = false + } } true } @@ -851,7 +991,7 @@ class MainActivity : AppCompatActivity() { paramsFrame.endToStart = binding.btnMainPreset.id paramsFrame.endToEnd = ConstraintLayout.LayoutParams.UNSET paramsFrame.leftMargin = 0 - paramsFrame.rightMargin = (12f * density).toInt() + paramsFrame.rightMargin = (10f * density).toInt() binding.btnMainFrame.layoutParams = paramsFrame binding.btnMainFrame.visibility = android.view.View.VISIBLE @@ -859,34 +999,45 @@ class MainActivity : AppCompatActivity() { val paramsPreset = binding.btnMainPreset.layoutParams as ConstraintLayout.LayoutParams paramsPreset.startToStart = ConstraintLayout.LayoutParams.UNSET paramsPreset.startToEnd = binding.btnMainFrame.id - paramsPreset.endToStart = binding.btnMainPortraitBlur.id + paramsPreset.endToStart = binding.btnMainBackground.id paramsPreset.endToEnd = ConstraintLayout.LayoutParams.UNSET paramsPreset.leftMargin = 0 - paramsPreset.rightMargin = (12f * density).toInt() + paramsPreset.rightMargin = (10f * density).toInt() binding.btnMainPreset.layoutParams = paramsPreset binding.btnMainPreset.visibility = android.view.View.VISIBLE - // 3. Restore btnMainPortraitBlur - val paramsPortrait = binding.btnMainPortraitBlur.layoutParams as ConstraintLayout.LayoutParams - paramsPortrait.startToStart = ConstraintLayout.LayoutParams.UNSET - paramsPortrait.startToEnd = binding.btnMainPreset.id - paramsPortrait.endToStart = binding.btnMainBgReplace.id - paramsPortrait.endToEnd = ConstraintLayout.LayoutParams.UNSET - paramsPortrait.leftMargin = 0 - paramsPortrait.rightMargin = (12f * density).toInt() - binding.btnMainPortraitBlur.layoutParams = paramsPortrait - binding.btnMainPortraitBlur.visibility = android.view.View.VISIBLE - - // 4. Restore btnMainBgReplace - val paramsBg = binding.btnMainBgReplace.layoutParams as ConstraintLayout.LayoutParams + // 3. Restore btnMainBackground + val paramsBg = binding.btnMainBackground.layoutParams as ConstraintLayout.LayoutParams paramsBg.startToStart = ConstraintLayout.LayoutParams.UNSET - paramsBg.startToEnd = binding.btnMainPortraitBlur.id - paramsBg.endToStart = ConstraintLayout.LayoutParams.UNSET - paramsBg.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID + paramsBg.startToEnd = binding.btnMainPreset.id + paramsBg.endToStart = binding.btnMainSticker.id + paramsBg.endToEnd = ConstraintLayout.LayoutParams.UNSET paramsBg.leftMargin = 0 - paramsBg.rightMargin = 0 - binding.btnMainBgReplace.layoutParams = paramsBg - binding.btnMainBgReplace.visibility = android.view.View.VISIBLE + paramsBg.rightMargin = (10f * density).toInt() + binding.btnMainBackground.layoutParams = paramsBg + binding.btnMainBackground.visibility = android.view.View.VISIBLE + + // 4. Restore btnMainSticker + val paramsSticker = binding.btnMainSticker.layoutParams as ConstraintLayout.LayoutParams + paramsSticker.startToStart = ConstraintLayout.LayoutParams.UNSET + paramsSticker.startToEnd = binding.btnMainBackground.id + paramsSticker.endToStart = binding.btnMainBeauty.id + paramsSticker.endToEnd = ConstraintLayout.LayoutParams.UNSET + paramsSticker.leftMargin = 0 + paramsSticker.rightMargin = (10f * density).toInt() + binding.btnMainSticker.layoutParams = paramsSticker + binding.btnMainSticker.visibility = android.view.View.VISIBLE + + // 5. Restore btnMainBeauty + val paramsBeauty = binding.btnMainBeauty.layoutParams as ConstraintLayout.LayoutParams + paramsBeauty.startToStart = ConstraintLayout.LayoutParams.UNSET + paramsBeauty.startToEnd = binding.btnMainSticker.id + paramsBeauty.endToStart = ConstraintLayout.LayoutParams.UNSET + paramsBeauty.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID + paramsBeauty.leftMargin = 0 + paramsBeauty.rightMargin = 0 + binding.btnMainBeauty.layoutParams = paramsBeauty + binding.btnMainBeauty.visibility = android.view.View.VISIBLE } private fun toggleFrameMode() { @@ -897,13 +1048,15 @@ class MainActivity : AppCompatActivity() { if (!isFrameModeOpen) { isFrameModeOpen = true isPresetModeOpen = false - isPortraitBlurModeOpen = false - isBgReplaceModeOpen = false + isBackgroundModeOpen = false + isStickerModeOpen = false + isBeautyModeOpen = false // Hide other buttons binding.btnMainPreset.visibility = android.view.View.GONE - binding.btnMainPortraitBlur.visibility = android.view.View.GONE - binding.btnMainBgReplace.visibility = android.view.View.GONE + binding.btnMainBackground.visibility = android.view.View.GONE + binding.btnMainSticker.visibility = android.view.View.GONE + binding.btnMainBeauty.visibility = android.view.View.GONE translateButtonToDock(binding.btnMainFrame) @@ -929,13 +1082,15 @@ class MainActivity : AppCompatActivity() { if (!isPresetModeOpen) { isPresetModeOpen = true isFrameModeOpen = false - isPortraitBlurModeOpen = false - isBgReplaceModeOpen = false + isBackgroundModeOpen = false + isStickerModeOpen = false + isBeautyModeOpen = false // Hide other buttons binding.btnMainFrame.visibility = android.view.View.GONE - binding.btnMainPortraitBlur.visibility = android.view.View.GONE - binding.btnMainBgReplace.visibility = android.view.View.GONE + binding.btnMainBackground.visibility = android.view.View.GONE + binding.btnMainSticker.visibility = android.view.View.GONE + binding.btnMainBeauty.visibility = android.view.View.GONE translateButtonToDock(binding.btnMainPreset) @@ -954,73 +1109,125 @@ class MainActivity : AppCompatActivity() { } } - private fun togglePortraitBlurMode() { + private fun toggleBackgroundMode() { if (isCountingDown) return val wrapper = binding.buttonsWrapper TransitionManager.beginDelayedTransition(wrapper) - if (!isPortraitBlurModeOpen) { - isPortraitBlurModeOpen = true + if (!isBackgroundModeOpen) { + isBackgroundModeOpen = true isFrameModeOpen = false isPresetModeOpen = false - isBgReplaceModeOpen = false + isStickerModeOpen = false + isBeautyModeOpen = false + isPortraitBlurRulerOpen = false // Hide other buttons binding.btnMainFrame.visibility = android.view.View.GONE binding.btnMainPreset.visibility = android.view.View.GONE - binding.btnMainBgReplace.visibility = android.view.View.GONE + binding.btnMainSticker.visibility = android.view.View.GONE + binding.btnMainBeauty.visibility = android.view.View.GONE - translateButtonToDock(binding.btnMainPortraitBlur) - - // Show ruler container & solid dock background - binding.viewLeftDockBackground.visibility = android.view.View.VISIBLE - binding.panelRulerContainer.visibility = android.view.View.VISIBLE - binding.rvHorizontalTimeline.visibility = android.view.View.GONE - binding.rvBackgroundOptions.visibility = android.view.View.GONE - - // Sync ruler value - binding.panelRulerView.value = (blurIntensity * 100f).toInt() - binding.tvPanelRulerValue.text = "${(blurIntensity * 100f).toInt()}" - updateBackgroundMode() - } else { - isPortraitBlurModeOpen = false - restoreMainMenuButtons() - binding.viewLeftDockBackground.visibility = android.view.View.GONE - binding.panelRulerContainer.visibility = android.view.View.GONE - updateBackgroundMode() - } - } - - private fun toggleBgReplaceMode() { - if (isCountingDown) return - val wrapper = binding.buttonsWrapper - TransitionManager.beginDelayedTransition(wrapper) - - if (!isBgReplaceModeOpen) { - isBgReplaceModeOpen = true - isFrameModeOpen = false - isPresetModeOpen = false - isPortraitBlurModeOpen = false - - // Hide other buttons - binding.btnMainFrame.visibility = android.view.View.GONE - binding.btnMainPreset.visibility = android.view.View.GONE - binding.btnMainPortraitBlur.visibility = android.view.View.GONE - - translateButtonToDock(binding.btnMainBgReplace) + translateButtonToDock(binding.btnMainBackground) // Show background options recycler view & solid dock background binding.viewLeftDockBackground.visibility = android.view.View.VISIBLE binding.rvBackgroundOptions.visibility = android.view.View.VISIBLE binding.rvHorizontalTimeline.visibility = android.view.View.GONE binding.panelRulerContainer.visibility = android.view.View.GONE - updateBackgroundMode() } else { - isBgReplaceModeOpen = false + if (isPortraitBlurRulerOpen) { + isPortraitBlurRulerOpen = false + binding.panelRulerContainer.visibility = android.view.View.GONE + binding.rvBackgroundOptions.visibility = android.view.View.VISIBLE + } else { + isBackgroundModeOpen = false + restoreMainMenuButtons() + binding.viewLeftDockBackground.visibility = android.view.View.GONE + binding.rvBackgroundOptions.visibility = android.view.View.GONE + } + } + } + + private fun toggleStickerMode() { + if (isCountingDown) return + val wrapper = binding.buttonsWrapper + TransitionManager.beginDelayedTransition(wrapper) + + if (!isStickerModeOpen) { + isStickerModeOpen = true + isFrameModeOpen = false + isPresetModeOpen = false + isBackgroundModeOpen = false + isBeautyModeOpen = false + + // Hide other buttons + binding.btnMainFrame.visibility = android.view.View.GONE + binding.btnMainPreset.visibility = android.view.View.GONE + binding.btnMainBackground.visibility = android.view.View.GONE + binding.btnMainBeauty.visibility = android.view.View.GONE + + translateButtonToDock(binding.btnMainSticker) + + // Show horizontal timeline with stickerAdapter & solid dock background + binding.viewLeftDockBackground.visibility = android.view.View.VISIBLE + binding.rvHorizontalTimeline.visibility = android.view.View.VISIBLE + binding.rvBackgroundOptions.visibility = android.view.View.GONE + binding.panelRulerContainer.visibility = android.view.View.GONE + binding.rvHorizontalTimeline.adapter = stickerAdapter + } else { + isStickerModeOpen = false restoreMainMenuButtons() binding.viewLeftDockBackground.visibility = android.view.View.GONE + binding.rvHorizontalTimeline.visibility = android.view.View.GONE + } + } + + private fun toggleBeautyMode() { + if (isCountingDown) return + val wrapper = binding.buttonsWrapper + TransitionManager.beginDelayedTransition(wrapper) + + if (!isBeautyModeOpen) { + isBeautyModeOpen = true + isFrameModeOpen = false + isPresetModeOpen = false + isBackgroundModeOpen = false + isStickerModeOpen = false + + // Hide other buttons + binding.btnMainFrame.visibility = android.view.View.GONE + binding.btnMainPreset.visibility = android.view.View.GONE + binding.btnMainBackground.visibility = android.view.View.GONE + binding.btnMainSticker.visibility = android.view.View.GONE + + translateButtonToDock(binding.btnMainBeauty) + + // Show horizontal timeline with beautyAdapter & solid dock background + binding.viewLeftDockBackground.visibility = android.view.View.VISIBLE + binding.rvHorizontalTimeline.visibility = android.view.View.VISIBLE binding.rvBackgroundOptions.visibility = android.view.View.GONE - updateBackgroundMode() + binding.panelRulerContainer.visibility = android.view.View.GONE + + beautyAdapter.updateSelection(null) + binding.rvHorizontalTimeline.adapter = beautyAdapter + } else { + if (binding.panelRulerContainer.visibility == android.view.View.VISIBLE) { + // If ruler is open, go back to beauty categories timeline list + binding.panelRulerContainer.visibility = android.view.View.GONE + binding.rvHorizontalTimeline.visibility = android.view.View.VISIBLE + activeBeautyAttribute = null + beautyAdapter.updateSelection(null) + // Hide attribute name bar when returning to categories + binding.layoutEditorTitleBar.visibility = android.view.View.GONE + } else { + isBeautyModeOpen = false + restoreMainMenuButtons() + binding.viewLeftDockBackground.visibility = android.view.View.GONE + binding.rvHorizontalTimeline.visibility = android.view.View.GONE + // Hide attribute name bar when fully exiting beauty mode + binding.layoutEditorTitleBar.visibility = android.view.View.GONE + } } } @@ -1066,6 +1273,34 @@ class MainActivity : AppCompatActivity() { .start() } + private fun onBeautyAttributeSelected(selectedNode: AttributeNode) { + activeBeautyAttribute = selectedNode.type + binding.rvHorizontalTimeline.visibility = android.view.View.GONE + binding.panelRulerContainer.visibility = android.view.View.VISIBLE + + // Show attribute name in the shared top bar (same position as preset attribute names) + binding.tvCurrentAttributeName.text = selectedNode.displayName + binding.layoutEditorTitleBar.visibility = android.view.View.VISIBLE + + val currentVal = when (selectedNode.type) { + EditAttribute.EYE_SIZE -> (activeBeautySettings.eyeEnlarge * 100f).toInt() + EditAttribute.NOSE_SIZE -> (activeBeautySettings.noseSlim * 100f).toInt() + EditAttribute.MOUTH_SIZE -> (activeBeautySettings.mouthSize * 100f).toInt() + EditAttribute.FACE_SLIM -> (activeBeautySettings.faceSlim * 100f).toInt() + EditAttribute.FACE_SIZE -> (activeBeautySettings.faceSize * 100f).toInt() + EditAttribute.FOREHEAD_SIZE -> (activeBeautySettings.foreheadSize * 100f).toInt() + EditAttribute.CHIN_SIZE -> (activeBeautySettings.chinSize * 100f).toInt() + EditAttribute.SKIN_SMOOTH -> (activeBeautySettings.skinSmooth * 100f).toInt() + else -> 0 + } + binding.panelRulerView.minVal = -100 + binding.panelRulerView.maxVal = 100 + binding.panelRulerView.value = currentVal + binding.tvPanelRulerValue.text = "$currentVal" + + beautyAdapter.updateSelection(selectedNode.type) + } + private fun onAttributeSelected(selectedNode: AttributeNode) { currentAttribute = selectedNode.type binding.tvCurrentAttributeName.text = selectedNode.displayName @@ -1088,7 +1323,11 @@ class MainActivity : AppCompatActivity() { selectedNode.type == EditAttribute.SHADOWS_TINT_B || selectedNode.type == EditAttribute.GRAIN_AMOUNT || selectedNode.type == EditAttribute.GRAIN_SIZE || - selectedNode.type == EditAttribute.PORTRAIT_BLUR + selectedNode.type == EditAttribute.PORTRAIT_BLUR || + selectedNode.type == EditAttribute.EYE_SIZE || + selectedNode.type == EditAttribute.NOSE_SIZE || + selectedNode.type == EditAttribute.CHIN_SIZE || + selectedNode.type == EditAttribute.FOREHEAD_SIZE binding.rulerView.minVal = if (isNonNegative) 0 else -100 binding.rulerView.maxVal = 100 @@ -1291,9 +1530,17 @@ class MainActivity : AppCompatActivity() { .build() imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy -> - // SỬA LỖI LAG NGẦM: Chỉ chạy MediaPipe phân đoạn AI khi thực sự cần dùng chế độ AI_BLUR val isBgActive = currentBackgroundMode == BackgroundMode.AI_BLUR - if (isBgActive) { + val isBeautyActive = activeBeautySettings.eyeEnlarge != 0f || + activeBeautySettings.noseSlim != 0f || + activeBeautySettings.mouthSize != 0f || + activeBeautySettings.faceSlim != 0f || + activeBeautySettings.faceSize != 0f || + activeBeautySettings.foreheadSize != 0f || + activeBeautySettings.chinSize != 0f || + activeBeautySettings.skinSmooth != 0f + + if (isBgActive || isBeautyActive) { val rawProxyBitmap = imageProxy.toBitmap() val rotationDegrees = imageProxy.imageInfo.rotationDegrees val processed = processPreviewFrame(rawProxyBitmap, rotationDegrees) @@ -1349,8 +1596,9 @@ class MainActivity : AppCompatActivity() { // Reset Selector UI states if (isFrameModeOpen) toggleFrameMode() if (isPresetModeOpen) togglePresetMode() - if (isPortraitBlurModeOpen) togglePortraitBlurMode() - if (isBgReplaceModeOpen) toggleBgReplaceMode() + if (isBackgroundModeOpen) toggleBackgroundMode() + if (isStickerModeOpen) toggleStickerMode() + binding.imgStickerOverlay.visibility = android.view.View.GONE setFrameOverlay("") applyPresetFilter(colorPresets[0]) updateFrameModeIcon(FrameItem("DEFAULT", "", "")) @@ -1437,6 +1685,10 @@ class MainActivity : AppCompatActivity() { private fun onCaptureClick() { if (isCountingDown) return + if (isCapturing.get()) { + Toast.makeText(this, "Camera đang bận xử lý, vui lòng chờ giây lát!", Toast.LENGTH_SHORT).show() + return + } if (timerSeconds > 0) { startCountDown() @@ -1472,6 +1724,13 @@ class MainActivity : AppCompatActivity() { private fun takePicture() { val imageCapture = this.imageCapture ?: return + if (!isCapturing.compareAndSet(false, true)) { + return + } + + // Snapshot current selected preset + val activePreset = currentSelectedPreset ?: colorPresets[0] + val presetSnapshot = activePreset.deepCopy() // 1. Show flash overlay for 100ms runOnUiThread { @@ -1491,6 +1750,7 @@ class MainActivity : AppCompatActivity() { ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + isCapturing.set(false) // Update gallery button thumbnail instantly on UI thread try { val rawBitmap = android.graphics.BitmapFactory.decodeFile(tempFile.absolutePath) @@ -1508,11 +1768,12 @@ class MainActivity : AppCompatActivity() { // ĐẨY TOÀN BỘ XỬ LÝ NẶNG (AI + LƯU ẢNH) SANG LUỒNG ĐỘC LẬP // cameraExecutor giờ rảnh hoàn toàn để tiếp tục chạy preview 60fps photoProcessExecutor.execute { - processAndSaveCapturedPhoto(tempFile) + processAndSaveCapturedPhoto(tempFile, presetSnapshot) } } override fun onError(exception: ImageCaptureException) { + isCapturing.set(false) Toast.makeText(this@MainActivity, "Lỗi chụp ảnh: ${exception.message}", Toast.LENGTH_SHORT).show() if (tempFile.exists()) { tempFile.delete() @@ -1522,7 +1783,7 @@ class MainActivity : AppCompatActivity() { ) } - private fun processAndSaveCapturedPhoto(file: java.io.File) { + private fun processAndSaveCapturedPhoto(file: java.io.File, preset: ColorPreset) { try { // 1. Decode captured file to Bitmap val rawBitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath) @@ -1570,6 +1831,47 @@ class MainActivity : AppCompatActivity() { // Ignore exif exceptions } + // 2.3. AI Beauty Face Reshaping (Eye, Nose & Mouth Local Warping) + val eyeEnlargeVal = activeBeautySettings.eyeEnlarge + val noseSlimVal = activeBeautySettings.noseSlim + val mouthSizeVal = activeBeautySettings.mouthSize + if (eyeEnlargeVal > 0f || noseSlimVal > 0f || mouthSizeVal > 0f) { + val faces = detectFaces(processedBitmap) + if (faces.isNotEmpty()) { + val warped = warpBitmap(processedBitmap, faces, eyeEnlargeVal, noseSlimVal, 0f, 0f, mouthSizeVal) + if (warped != processedBitmap) { + processedBitmap.recycle() + processedBitmap = warped + } + } + } + + // 2.4. Piecewise Affine Face Mesh Warping (Face Slim, Face Size, Forehead, Chin) + val faceSlimVal = activeBeautySettings.faceSlim + val foreheadSizeVal = activeBeautySettings.foreheadSize + val faceSizeVal2 = activeBeautySettings.faceSize + val chinSizeVal2 = activeBeautySettings.chinSize + if (faceSlimVal != 0f || foreheadSizeVal != 0f || faceSizeVal2 != 0f || chinSizeVal2 != 0f) { + val landmarks = dualAiEngine.processFaceMesh(processedBitmap) + val warped = meshWarpEngine.applyPiecewiseAffineWarp( + processedBitmap, landmarks, faceSlimVal, foreheadSizeVal, faceSlimVal, faceSizeVal2, chinSizeVal2 + ) + if (warped != processedBitmap) { + processedBitmap.recycle() + processedBitmap = warped + } + } + + // 2.4.5. Skin Smoothing (Selective Blur filter) + val skinSmoothVal = activeBeautySettings.skinSmooth + if (skinSmoothVal != 0f) { + val smoothed = meshWarpEngine.applySkinSmoothing(processedBitmap, skinSmoothVal) + if (smoothed != processedBitmap) { + processedBitmap.recycle() + processedBitmap = smoothed + } + } + // 2.5. AI Portrait Blur / Background Replacement val isBgActive = (selectedBgAssetPath != null) || (blurIntensity > 0f) if (isBgActive) { @@ -1580,7 +1882,6 @@ class MainActivity : AppCompatActivity() { } // 3. Apply Current Preset Filters (High-Impact Color Engine) - val preset = currentSelectedPreset ?: colorPresets[0] val filteredBitmap = if (preset.id != "DEFAULT") { applyHighImpactPreset(processedBitmap, preset) } else { @@ -1661,6 +1962,46 @@ class MainActivity : AppCompatActivity() { } } + // 5.5. Merge Stickers Overlay + val stickerPath = currentSelectedStickerPath + if (stickerPath != null && stickerPath.isNotEmpty()) { + try { + val localFile = java.io.File(filesDir, stickerPath) + val stickerBitmap = if (localFile.exists()) { + android.graphics.BitmapFactory.decodeFile(localFile.absolutePath) + } else { + val p = if (stickerPath.startsWith("stickers/")) stickerPath else "stickers/$stickerPath" + val inputStream = assets.open(p) + android.graphics.BitmapFactory.decodeStream(inputStream) + } + if (stickerBitmap != null) { + val viewW = binding.cameraContainer.width.toFloat() + val viewH = binding.cameraContainer.height.toFloat() + val stickerX = binding.imgStickerOverlay.x + val stickerY = binding.imgStickerOverlay.y + val stickerW = binding.imgStickerOverlay.width.toFloat() + val stickerH = binding.imgStickerOverlay.height.toFloat() + + val relX = stickerX / viewW + val relY = stickerY / viewH + val relW = stickerW / viewW + val relH = stickerH / viewH + + val photoCanvas = android.graphics.Canvas(finalBitmap) + val drawW = (finalBitmap.width * relW).toInt() + val drawH = (finalBitmap.height * relH).toInt() + val drawX = (finalBitmap.width * relX).toInt() + val drawY = (finalBitmap.height * relY).toInt() + + val destRect = android.graphics.Rect(drawX, drawY, drawX + drawW, drawY + drawH) + photoCanvas.drawBitmap(stickerBitmap, null, destRect, null) + stickerBitmap.recycle() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + // 6. Save image to system Gallery val savedSuccess = saveBitmapToGallery(finalBitmap, "RetroPhoto") finalBitmap.recycle() @@ -1747,13 +2088,57 @@ class MainActivity : AppCompatActivity() { } val rotated = android.graphics.Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true) + // Apply facial beauty adjustments (Eye, Nose, Mouth, Chin, Forehead, Face V-Line, Skin Smooth) + var beautyApplied = rotated + + // 1. Eye Enlarge, Nose Slim, Mouth Size + val eyeVal = activeBeautySettings.eyeEnlarge + val noseVal = activeBeautySettings.noseSlim + val mouthVal = activeBeautySettings.mouthSize + if (eyeVal > 0f || noseVal > 0f || mouthVal > 0f) { + val faces = detectFaces(beautyApplied) + if (faces.isNotEmpty()) { + val warped = warpBitmap(beautyApplied, faces, eyeVal, noseVal, 0f, 0f, mouthVal) + if (warped != beautyApplied) { + if (beautyApplied != rotated) beautyApplied.recycle() + beautyApplied = warped + } + } + } + + // 2. Piecewise Affine Face Mesh Warping (Chin Slim, Forehead Size, Face Slim & Face Size) + val faceVal = activeBeautySettings.faceSlim + val foreheadVal = activeBeautySettings.foreheadSize + val faceSizeVal = activeBeautySettings.faceSize + val chinSizeVal = activeBeautySettings.chinSize + if (faceVal != 0f || foreheadVal != 0f || faceSizeVal != 0f || chinSizeVal != 0f) { + val landmarks = dualAiEngine.processFaceMesh(beautyApplied) + val warped = meshWarpEngine.applyPiecewiseAffineWarp( + beautyApplied, landmarks, faceVal, foreheadVal, faceVal, faceSizeVal, chinSizeVal + ) + if (warped != beautyApplied) { + if (beautyApplied != rotated) beautyApplied.recycle() + beautyApplied = warped + } + } + + // 3. Skin smoothing (Bilateral Skin smoothing filter) + val skinVal = activeBeautySettings.skinSmooth + if (skinVal != 0f) { + val smoothed = meshWarpEngine.applySkinSmoothing(beautyApplied, skinVal) + if (smoothed != beautyApplied) { + if (beautyApplied != rotated) beautyApplied.recycle() + beautyApplied = smoothed + } + } + // Tối ưu hóa giật lag: Downscale ảnh preview xuống 2 lần để giảm tải CPU xử lý vòng lặp pixel val scaleFactor = 2 - val downscaledW = (rotated.width / scaleFactor).coerceAtLeast(1) - val downscaledH = (rotated.height / scaleFactor).coerceAtLeast(1) - val downscaled = android.graphics.Bitmap.createScaledBitmap(rotated, downscaledW, downscaledH, true) - if (downscaled != rotated) { - rotated.recycle() + val downscaledW = (beautyApplied.width / scaleFactor).coerceAtLeast(1) + val downscaledH = (beautyApplied.height / scaleFactor).coerceAtLeast(1) + val downscaled = android.graphics.Bitmap.createScaledBitmap(beautyApplied, downscaledW, downscaledH, true) + if (downscaled != beautyApplied) { + if (beautyApplied != rotated) beautyApplied.recycle() } val ai = processAdvancedBackgroundAi(downscaled, isStreamMode = true) @@ -1848,29 +2233,28 @@ class MainActivity : AppCompatActivity() { for (idx in 0 until (w * h)) { val alpha = alphaMask[idx] - - val fgPixel = fgPixels[idx] - var fgR = android.graphics.Color.red(fgPixel) - var fgG = android.graphics.Color.green(fgPixel) - var fgB = android.graphics.Color.blue(fgPixel) - + val x = idx % w + val y = idx / w + val bgPixel = bgPixels[idx] val bgR = android.graphics.Color.red(bgPixel) val bgG = android.graphics.Color.green(bgPixel) val bgB = android.graphics.Color.blue(bgPixel) - - // Color decontamination for edges - if (alpha > 0f && alpha < 1.0f) { - val grayIntensity = (fgR + fgG + fgB) / 3f - fgR = (fgR * alpha + grayIntensity * (1f - alpha)).toInt() - fgG = (fgG * alpha + grayIntensity * (1f - alpha)).toInt() - fgB = (fgB * alpha + grayIntensity * (1f - alpha)).toInt() + + val fgPixel = if (alpha > 0f && alpha < 0.95f) { + // Edge pixel: sample nearest fully-opaque foreground to avoid ghost shadow + nearestForegroundColor(fgPixels, alphaMask, x, y, w, h) + } else { + fgPixels[idx] } - + val fgR = android.graphics.Color.red(fgPixel) + val fgG = android.graphics.Color.green(fgPixel) + val fgB = android.graphics.Color.blue(fgPixel) + val outR = (fgR * alpha + bgR * (1f - alpha)).toInt().coerceIn(0, 255) val outG = (fgG * alpha + bgG * (1f - alpha)).toInt().coerceIn(0, 255) val outB = (fgB * alpha + bgB * (1f - alpha)).toInt().coerceIn(0, 255) - + outputPixels[idx] = android.graphics.Color.rgb(outR, outG, outB) } @@ -1953,29 +2337,28 @@ class MainActivity : AppCompatActivity() { for (idx in 0 until (w * h)) { val alpha = alphaMask[idx] - - val fgPixel = fgPixels[idx] - var fgR = android.graphics.Color.red(fgPixel) - var fgG = android.graphics.Color.green(fgPixel) - var fgB = android.graphics.Color.blue(fgPixel) - + val x = idx % w + val y = idx / w + val bgPixel = bgPixels[idx] val bgR = android.graphics.Color.red(bgPixel) val bgG = android.graphics.Color.green(bgPixel) val bgB = android.graphics.Color.blue(bgPixel) - - // Color decontamination for edges - if (alpha > 0f && alpha < 1.0f) { - val grayIntensity = (fgR + fgG + fgB) / 3f - fgR = (fgR * alpha + grayIntensity * (1f - alpha)).toInt() - fgG = (fgG * alpha + grayIntensity * (1f - alpha)).toInt() - fgB = (fgB * alpha + grayIntensity * (1f - alpha)).toInt() + + val fgPixel = if (alpha > 0f && alpha < 0.95f) { + // Edge pixel: sample nearest fully-opaque foreground to avoid ghost shadow + nearestForegroundColor(fgPixels, alphaMask, x, y, w, h) + } else { + fgPixels[idx] } - + val fgR = android.graphics.Color.red(fgPixel) + val fgG = android.graphics.Color.green(fgPixel) + val fgB = android.graphics.Color.blue(fgPixel) + val outR = (fgR * alpha + bgR * (1f - alpha)).toInt().coerceIn(0, 255) val outG = (fgG * alpha + bgG * (1f - alpha)).toInt().coerceIn(0, 255) val outB = (fgB * alpha + bgB * (1f - alpha)).toInt().coerceIn(0, 255) - + outputPixels[idx] = android.graphics.Color.rgb(outR, outG, outB) } @@ -2023,52 +2406,114 @@ class MainActivity : AppCompatActivity() { return src.getPixel(0, 0) } + /** + * Feathers the alpha mask using morphological erosion + dilation to clean up + * rough edges, then applies a Gaussian-weighted blend only in the transition zone. + * This produces smooth, anti-aliased edges without ghost halos/shadows. + * + * Replaces the previous simple box blur approach. + */ private fun blurAlphaMaskBox(arr: FloatArray, w: Int, h: Int, radius: Int) { if (radius <= 0) return - val temp = FloatArray(w * h) - - // Horizontal pass + + // Step 1: Morphological ERODE — shrink fg by 1px to remove stray edge pixels + val erodeR = 1 + val eroded = FloatArray(w * h) for (y in 0 until h) { - val rowOffset = y * w - var sum = 0f - val windowSize = radius * 2 + 1 - - // Initialize window sum - for (dx in -radius..radius) { - val px = dx.coerceIn(0, w - 1) - sum += arr[rowOffset + px] - } - temp[rowOffset] = sum / windowSize - - // Slide window - for (x in 1 until w) { - val oldPixelX = (x - 1 - radius).coerceIn(0, w - 1) - val newPixelX = (x + radius).coerceIn(0, w - 1) - sum = sum - arr[rowOffset + oldPixelX] + arr[rowOffset + newPixelX] - temp[rowOffset + x] = sum / windowSize + for (x in 0 until w) { + var minVal = arr[y * w + x] + for (dy in -erodeR..erodeR) { + val ny = (y + dy).coerceIn(0, h - 1) + for (dx in -erodeR..erodeR) { + val nx = (x + dx).coerceIn(0, w - 1) + if (arr[ny * w + nx] < minVal) minVal = arr[ny * w + nx] + } + } + eroded[y * w + x] = minVal } } - - // Vertical pass - for (x in 0 until w) { - var sum = 0f - val windowSize = radius * 2 + 1 - - // Initialize window sum - for (dy in -radius..radius) { - val py = dy.coerceIn(0, h - 1) - sum += temp[py * w + x] - } - arr[x] = sum / windowSize - - // Slide window - for (y in 1 until h) { - val oldPixelY = (y - 1 - radius).coerceIn(0, h - 1) - val newPixelY = (y + radius).coerceIn(0, h - 1) - sum = sum - temp[oldPixelY * w + x] + temp[newPixelY * w + x] - arr[y * w + x] = sum / windowSize + + // Step 2: Morphological DILATE eroded mask — expand it back by transition zone width + val dilateR = radius + 1 + val dilated = FloatArray(w * h) + for (y in 0 until h) { + for (x in 0 until w) { + var maxVal = eroded[y * w + x] + for (dy in -dilateR..dilateR) { + val ny = (y + dy).coerceIn(0, h - 1) + for (dx in -dilateR..dilateR) { + val nx = (x + dx).coerceIn(0, w - 1) + if (eroded[ny * w + nx] > maxVal) maxVal = eroded[ny * w + nx] + } + } + dilated[y * w + x] = maxVal } } + + // Step 3: Gaussian-weighted feather — smoothly blend between eroded (inner) and dilated (outer) + // Only pixels in the transition zone (eroded=0, dilated=1) get feathered + val sigma = radius.toFloat() + val gaussKernelSize = radius * 2 + 1 + val gaussKernel = FloatArray(gaussKernelSize * gaussKernelSize) + var kernelSum = 0f + for (ky in -radius..radius) { + for (kx in -radius..radius) { + val g = Math.exp(-(kx * kx + ky * ky) / (2.0 * sigma * sigma)).toFloat() + gaussKernel[(ky + radius) * gaussKernelSize + (kx + radius)] = g + kernelSum += g + } + } + + val temp = FloatArray(w * h) + for (y in 0 until h) { + for (x in 0 until w) { + val erodedVal = eroded[y * w + x] + val dilatedVal = dilated[y * w + x] + // Only apply Gaussian in transition zone + if (erodedVal >= 1f) { + temp[y * w + x] = 1f // fully inside: keep opaque + } else if (dilatedVal <= 0f) { + temp[y * w + x] = 0f // fully outside: keep transparent + } else { + // Transition zone: Gaussian-weighted average of original mask + var weightedSum = 0f + var wSum = 0f + for (ky in -radius..radius) { + val ny = (y + ky).coerceIn(0, h - 1) + for (kx in -radius..radius) { + val nx = (x + kx).coerceIn(0, w - 1) + val w2 = gaussKernel[(ky + radius) * gaussKernelSize + (kx + radius)] + weightedSum += arr[ny * w + nx] * w2 + wSum += w2 + } + } + temp[y * w + x] = if (wSum > 0f) (weightedSum / wSum) else 0f + } + } + } + // Write result back + temp.copyInto(arr) + } + + /** + * Finds the nearest pixel that is fully foreground (alpha ≈ 1) within a small search radius. + * Used for edge color sampling to avoid ghost shadow / color bleeding from background. + */ + private fun nearestForegroundColor(fgPixels: IntArray, alphaMask: FloatArray, x: Int, y: Int, w: Int, h: Int): Int { + val searchR = 4 + for (r in 1..searchR) { + for (dy in -r..r) { + for (dx in -r..r) { + if (Math.abs(dx) != r && Math.abs(dy) != r) continue // only perimeter + val nx = (x + dx).coerceIn(0, w - 1) + val ny = (y + dy).coerceIn(0, h - 1) + if (alphaMask[ny * w + nx] >= 0.95f) { + return fgPixels[ny * w + nx] + } + } + } + } + return fgPixels[y * w + x] // fallback: use own pixel } private fun cubicSmoothStep(edge0: Float, edge1: Float, x: Float): Float { @@ -2430,6 +2875,250 @@ class MainActivity : AppCompatActivity() { return solidBg } + private fun ColorPreset.deepCopy(): ColorPreset { + return ColorPreset( + id = this.id, + name = this.name, + themeCategory = this.themeCategory, + isEditable = this.isEditable, + basic = BasicAdjustments( + brightness = this.basic.brightness, + contrast = this.basic.contrast, + saturation = this.basic.saturation, + vibrance = this.basic.vibrance, + temperature = this.basic.temperature, + tint = this.basic.tint, + highlight = this.basic.highlight, + shadow = this.basic.shadow + ), + advanced = AdvancedEffects( + clarity = this.advanced.clarity, + dehaze = this.advanced.dehaze, + vignetteAmount = this.advanced.vignetteAmount + ), + toneCurve = ToneCurveEmulation( + fadedBlackLevel = this.toneCurve.fadedBlackLevel, + shadowsTintR = this.toneCurve.shadowsTintR, + shadowsTintG = this.toneCurve.shadowsTintG, + shadowsTintB = this.toneCurve.shadowsTintB + ), + grain = FilmGrain( + grainAmount = this.grain.grainAmount, + grainSize = this.grain.grainSize + ) + ) + } + + private data class FaceDetails( + val leftEyeX: Float, + val leftEyeY: Float, + val rightEyeX: Float, + val rightEyeY: Float, + val noseX: Float, + val noseY: Float, + val chinX: Float, + val chinY: Float, + val foreheadX: Float, + val foreheadY: Float, + val eyeDistance: Float + ) + + private fun detectFaces(bitmap: android.graphics.Bitmap): List { + val list = mutableListOf() + try { + val bmp565 = bitmap.copy(android.graphics.Bitmap.Config.RGB_565, true) + val maxFaces = 5 + val detector = android.media.FaceDetector(bmp565.width, bmp565.height, maxFaces) + val facesArray = arrayOfNulls(maxFaces) + val found = detector.findFaces(bmp565, facesArray) + bmp565.recycle() + + for (i in 0 until found) { + val face = facesArray[i] ?: continue + val midPoint = android.graphics.PointF() + face.getMidPoint(midPoint) + val eyeDistance = face.eyesDistance() + + val leftEyeX = midPoint.x - eyeDistance * 0.5f + val leftEyeY = midPoint.y - eyeDistance * 0.05f + val rightEyeX = midPoint.x + eyeDistance * 0.5f + val rightEyeY = midPoint.y - eyeDistance * 0.05f + + val noseX = midPoint.x + val noseY = midPoint.y + eyeDistance * 0.25f + + val chinX = midPoint.x + val chinY = midPoint.y + eyeDistance * 0.75f + + val foreheadX = midPoint.x + val foreheadY = midPoint.y - eyeDistance * 0.65f + + list.add(FaceDetails(leftEyeX, leftEyeY, rightEyeX, rightEyeY, noseX, noseY, chinX, chinY, foreheadX, foreheadY, eyeDistance)) + } + } catch (e: Exception) { + e.printStackTrace() + } + return list + } + + private fun warpBitmap( + src: android.graphics.Bitmap, + faces: List, + eyeEnlarge: Float, + noseSlim: Float, + chinSlim: Float, + foreheadSize: Float, + mouthSize: Float = 0f + ): android.graphics.Bitmap { + if (eyeEnlarge == 0f && noseSlim == 0f && chinSlim == 0f && foreheadSize == 0f && mouthSize == 0f) return src + if (faces.isEmpty()) return src + + val width = src.width + val height = src.height + val output = android.graphics.Bitmap.createBitmap(width, height, src.config) + val srcPixels = IntArray(width * height) + val destPixels = IntArray(width * height) + src.getPixels(srcPixels, 0, width, 0, 0, width, height) + + System.arraycopy(srcPixels, 0, destPixels, 0, srcPixels.size) + + for (y in 0 until height) { + for (x in 0 until width) { + var srcX = x.toFloat() + var srcY = y.toFloat() + + for (face in faces) { + val eyeDistance = face.eyeDistance + val rEye = eyeDistance * 0.45f + val rNose = eyeDistance * 0.45f + val rChin = eyeDistance * 0.5f + val rForehead = eyeDistance * 0.5f + val rMouth = eyeDistance * 0.5f + + // --- Eye: positive = to hơn (k<1 = swell), negative = nhỏ lại (k>1 = pinch) --- + if (eyeEnlarge != 0f) { + val eyeK = 1f - 0.2f * eyeEnlarge // enlarge: k<1; shrink: k>1 + + // Left eye + val dxL = srcX - face.leftEyeX + val dyL = srcY - face.leftEyeY + val dL = Math.sqrt((dxL * dxL + dyL * dyL).toDouble()).toFloat() + if (dL > 0.001f && dL < rEye) { + val uNew = Math.pow((dL / rEye).toDouble(), eyeK.toDouble()).toFloat() + srcX = face.leftEyeX + (dxL / dL) * rEye * uNew + srcY = face.leftEyeY + (dyL / dL) * rEye * uNew + } + + // Right eye + val dxR = srcX - face.rightEyeX + val dyR = srcY - face.rightEyeY + val dR = Math.sqrt((dxR * dxR + dyR * dyR).toDouble()).toFloat() + if (dR > 0.001f && dR < rEye) { + val uNew = Math.pow((dR / rEye).toDouble(), eyeK.toDouble()).toFloat() + srcX = face.rightEyeX + (dxR / dR) * rEye * uNew + srcY = face.rightEyeY + (dyR / dR) * rEye * uNew + } + } + + // --- Nose: positive = thon gọn (k>1 = pinch), negative = rộng ra (k<1 = swell) --- + if (noseSlim != 0f) { + val noseK = 1f + 0.2f * noseSlim // slim: k>1; widen: k<1 + val dxN = srcX - face.noseX + val dyN = srcY - face.noseY + val dN = Math.sqrt((dxN * dxN + dyN * dyN).toDouble()).toFloat() + if (dN > 0.001f && dN < rNose) { + val uNew = Math.pow((dN / rNose).toDouble(), noseK.toDouble()).toFloat() + srcX = face.noseX + (dxN / dN) * rNose * uNew + srcY = face.noseY + (dyN / dN) * rNose * uNew + } + } + + // --- Chin: legacy positive-only pass-through --- + if (chinSlim > 0f) { + val dx = srcX - face.chinX + val dy = srcY - face.chinY + val d = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat() + if (d > 0.001f && d < rChin) { + val u = d / rChin + val k = 1f + 0.2f * chinSlim + val uNew = Math.pow(u.toDouble(), k.toDouble()).toFloat() + srcX = face.chinX + (dx / d) * rChin * uNew + srcY = face.chinY + (dy / d) * rChin * uNew + } + } + + // --- Forehead: positive = cao lên (k<1 = swell), negative = thu gọn (k>1 = pinch) --- + if (foreheadSize != 0f) { + val foreK = 1f - 0.2f * foreheadSize // raise: k<1; lower: k>1 + val dxF = srcX - face.foreheadX + val dyF = srcY - face.foreheadY + val dF = Math.sqrt((dxF * dxF + dyF * dyF).toDouble()).toFloat() + if (dF > 0.001f && dF < rForehead) { + val uNew = Math.pow((dF / rForehead).toDouble(), foreK.toDouble()).toFloat() + srcX = face.foreheadX + (dxF / dF) * rForehead * uNew + srcY = face.foreheadY + (dyF / dF) * rForehead * uNew + } + } + + // --- Mouth: positive = rộng ra (k<1 = swell), negative = chúm chím (k>1 = pinch) --- + if (mouthSize != 0f) { + val mouthK = 1f - 0.2f * mouthSize // widen: k<1; pinch: k>1 + val mouthX = (face.leftEyeX + face.rightEyeX) * 0.5f + val mouthY = (face.leftEyeY + face.rightEyeY) * 0.5f + face.eyeDistance * 0.9f + val dxM = srcX - mouthX + val dyM = srcY - mouthY + val dM = Math.sqrt((dxM * dxM + dyM * dyM).toDouble()).toFloat() + if (dM > 0.001f && dM < rMouth) { + val uNew = Math.pow((dM / rMouth).toDouble(), mouthK.toDouble()).toFloat() + srcX = mouthX + (dxM / dM) * rMouth * uNew + srcY = mouthY + (dyM / dM) * rMouth * uNew + } + } + } + + val sx = srcX.toInt().coerceIn(0, width - 2) + val sy = srcY.toInt().coerceIn(0, height - 2) + val ax = srcX - sx + val ay = srcY - sy + + val idx00 = sy * width + sx + val idx10 = sy * width + (sx + 1) + val idx01 = (sy + 1) * width + sx + val idx11 = (sy + 1) * width + (sx + 1) + + val c00 = srcPixels[idx00] + val c10 = srcPixels[idx10] + val c01 = srcPixels[idx01] + val c11 = srcPixels[idx11] + + val r00 = (c00 shr 16) and 0xFF + val g00 = (c00 shr 8) and 0xFF + val b00 = c00 and 0xFF + + val r10 = (c10 shr 16) and 0xFF + val g10 = (c10 shr 8) and 0xFF + val b10 = c10 and 0xFF + + val r01 = (c01 shr 16) and 0xFF + val g01 = (c01 shr 8) and 0xFF + val b01 = c01 and 0xFF + + val r11 = (c11 shr 16) and 0xFF + val g11 = (c11 shr 8) and 0xFF + val b11 = c11 and 0xFF + + val r = ((1 - ax) * (1 - ay) * r00 + ax * (1 - ay) * r10 + (1 - ax) * ay * r01 + ax * ay * r11).toInt().coerceIn(0, 255) + val g = ((1 - ax) * (1 - ay) * g00 + ax * (1 - ay) * g10 + (1 - ax) * ay * g01 + ax * ay * g11).toInt().coerceIn(0, 255) + val b = ((1 - ax) * (1 - ay) * b00 + ax * (1 - ay) * b10 + (1 - ax) * ay * b01 + ax * ay * b11).toInt().coerceIn(0, 255) + + destPixels[y * width + x] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + } + + output.setPixels(destPixels, 0, width, 0, 0, width, height) + return output + } + private fun loadDownloadedFrames(): List { val list = mutableListOf() val dir = java.io.File(filesDir, "downloaded_frames") @@ -2460,6 +3149,47 @@ class MainActivity : AppCompatActivity() { return list } + private fun loadDownloadedStickers(): List { + val list = mutableListOf() + val dir = java.io.File(filesDir, "downloaded_stickers") + if (dir.exists() && dir.isDirectory) { + dir.listFiles()?.forEach { file -> + if (file.isFile && (file.name.endsWith(".png") || file.name.endsWith(".jpg"))) { + val id = file.nameWithoutExtension + val name = id.replace("_", " ").replace("st ", "").replaceFirstChar { if (it.isLowerCase()) it.titlecase(java.util.Locale.getDefault()) else it.toString() } + list.add(StickerItem(id, name, "downloaded_stickers/${file.name}")) + } + } + } + return list + } + + private fun setStickerOverlay(path: String) { + currentSelectedStickerPath = if (path.isEmpty()) null else path + if (path.isEmpty()) { + binding.imgStickerOverlay.setImageDrawable(null) + } else { + try { + val localFile = java.io.File(filesDir, path) + val bitmap = if (localFile.exists()) { + android.graphics.BitmapFactory.decodeFile(localFile.absolutePath) + } else { + val p = if (path.startsWith("stickers/")) path else "stickers/$path" + val inputStream = assets.open(p) + android.graphics.BitmapFactory.decodeStream(inputStream) + } + binding.imgStickerOverlay.setImageBitmap(bitmap) + // Center the sticker initially + binding.imgStickerOverlay.post { + binding.imgStickerOverlay.x = (binding.cameraContainer.width - binding.imgStickerOverlay.width) / 2f + binding.imgStickerOverlay.y = (binding.cameraContainer.height - binding.imgStickerOverlay.height) / 2f + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + private var storeDialog: androidx.appcompat.app.AlertDialog? = null private fun getServerUrl(): String { @@ -2479,6 +3209,7 @@ class MainActivity : AppCompatActivity() { val btnTabFrames = dialogView.findViewById(R.id.btnTabFrames) val btnTabBackgrounds = dialogView.findViewById(R.id.btnTabBackgrounds) val btnTabPresets = dialogView.findViewById(R.id.btnTabPresets) + val btnTabStickers = dialogView.findViewById(R.id.btnTabStickers) val rvStoreAssets = dialogView.findViewById(R.id.rvStoreAssets) val progressLoadingStore = dialogView.findViewById(R.id.progressLoadingStore) val layoutStoreError = dialogView.findViewById(R.id.layoutStoreError) @@ -2516,6 +3247,9 @@ class MainActivity : AppCompatActivity() { btnTabPresets.setBackgroundColor(if (currentTab == "presets") primaryColor else cardColor) btnTabPresets.setTextColor(if (currentTab == "presets") darkColor else whiteColor) + btnTabStickers.setBackgroundColor(if (currentTab == "stickers") primaryColor else cardColor) + btnTabStickers.setTextColor(if (currentTab == "stickers") darkColor else whiteColor) + val filtered = allFetchedItems.filter { it.category == currentTab } storeAdapter.updateItems(filtered) } @@ -2585,6 +3319,20 @@ class MainActivity : AppCompatActivity() { } } + if (json.has("stickers")) { + val arr = json.getJSONArray("stickers") + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + itemsList.add(OnlineStoreItem( + id = obj.getString("id"), + name = obj.getString("name"), + url = obj.getString("url"), + version = obj.optInt("version", 1), + category = "stickers" + )) + } + } + runOnUiThread { allFetchedItems = itemsList progressLoadingStore.visibility = android.view.View.GONE @@ -2616,6 +3364,10 @@ class MainActivity : AppCompatActivity() { currentTab = "presets" updateTabButtons() } + btnTabStickers.setOnClickListener { + currentTab = "stickers" + updateTabButtons() + } btnStoreClose.setOnClickListener { storeDialog?.dismiss() } fetchManifest() @@ -2637,6 +3389,7 @@ class MainActivity : AppCompatActivity() { val targetDir = when (item.category) { "frames" -> java.io.File(filesDir, "downloaded_frames") "backgrounds" -> java.io.File(filesDir, "downloaded_backgrounds") + "stickers" -> java.io.File(filesDir, "downloaded_stickers") else -> filesDir } if (!targetDir.exists()) targetDir.mkdirs() @@ -2677,6 +3430,7 @@ class MainActivity : AppCompatActivity() { backgroundOptions = listOf( BackgroundOption("NONE", "Mặc định", null), + BackgroundOption("PORTRAIT_BLUR", "Xóa phông", null), BackgroundOption("BG_01", "Nền 1", "backgrounds/background-01.png"), BackgroundOption("BG_02", "Nền 2", "backgrounds/background-02.png"), BackgroundOption("BG_03", "Nền 3", "backgrounds/background-03.png") @@ -2684,13 +3438,35 @@ class MainActivity : AppCompatActivity() { backgroundOptionAdapter = BackgroundOptionAdapter(backgroundOptions) { option -> if (option.id == "MORE") { openOnlineStoreDialog("backgrounds") + } else if (option.id == "PORTRAIT_BLUR") { + isPortraitBlurRulerOpen = true + selectedBgAssetPath = null + binding.rvBackgroundOptions.visibility = android.view.View.GONE + binding.panelRulerContainer.visibility = android.view.View.VISIBLE + + binding.panelRulerView.value = (blurIntensity * 100f).toInt() + binding.tvPanelRulerValue.text = "${(blurIntensity * 100f).toInt()}" + binding.tvCurrentAttributeName.text = "Portrait Blur (Xóa phông)" + binding.layoutEditorTitleBar.visibility = android.view.View.VISIBLE + updateBackgroundMode() } else { + blurIntensity = 0f + isPortraitBlurRulerOpen = false selectedBgAssetPath = option.assetPath + binding.layoutEditorTitleBar.visibility = android.view.View.GONE updateBackgroundMode() } } binding.rvBackgroundOptions.adapter = backgroundOptionAdapter + allStickers = listOf( + StickerItem("DEFAULT", "Mặc định", ""), + StickerItem("st_sunglasses", "Kính râm", "stickers/sunglasses.png"), + StickerItem("st_vintage_camera", "Máy ảnh", "stickers/vintage_camera.png"), + StickerItem("st_vintage_heart", "Trái tim", "stickers/vintage_heart.png") + ) + loadDownloadedStickers() + listOf(StickerItem("MORE", "Tải thêm", "")) + stickerAdapter.updateItems(allStickers) + val defaultPresets = listOf( ColorPreset( id = "DEFAULT", @@ -2771,6 +3547,13 @@ class MainActivity : AppCompatActivity() { presetAdapter.selectItem(item.id) } } + "stickers" -> { + val ext = item.url.substringAfterLast(".", "png") + val path = "downloaded_stickers/${item.id}.$ext" + setStickerOverlay(path) + binding.imgStickerOverlay.visibility = android.view.View.VISIBLE + stickerAdapter.selectItem(item.id) + } } } diff --git a/android-project/app/src/main/java/com/photobooth/app/MeshWarpEngine.kt b/android-project/app/src/main/java/com/photobooth/app/MeshWarpEngine.kt new file mode 100644 index 0000000..6fcb6bc --- /dev/null +++ b/android-project/app/src/main/java/com/photobooth/app/MeshWarpEngine.kt @@ -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, + 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() + 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 + } +} + diff --git a/android-project/app/src/main/java/com/photobooth/app/StickerAdapter.kt b/android-project/app/src/main/java/com/photobooth/app/StickerAdapter.kt new file mode 100644 index 0000000..cc59b85 --- /dev/null +++ b/android-project/app/src/main/java/com/photobooth/app/StickerAdapter.kt @@ -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, + private val onStickerSelected: (StickerItem) -> Unit +) : RecyclerView.Adapter() { + + 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) { + 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) + } + } +} diff --git a/android-project/app/src/main/java/com/photobooth/app/StickerModels.kt b/android-project/app/src/main/java/com/photobooth/app/StickerModels.kt new file mode 100644 index 0000000..1474380 --- /dev/null +++ b/android-project/app/src/main/java/com/photobooth/app/StickerModels.kt @@ -0,0 +1,7 @@ +package com.photobooth.app + +data class StickerItem( + val id: String, + val name: String, + val imagePath: String +) diff --git a/android-project/app/src/main/java/com/photobooth/app/StoreItemAdapter.kt b/android-project/app/src/main/java/com/photobooth/app/StoreItemAdapter.kt index 5e281ae..e4aeadf 100644 --- a/android-project/app/src/main/java/com/photobooth/app/StoreItemAdapter.kt +++ b/android-project/app/src/main/java/com/photobooth/app/StoreItemAdapter.kt @@ -51,6 +51,7 @@ class StoreItemAdapter( "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) } @@ -115,6 +116,11 @@ class StoreItemAdapter( 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 } } diff --git a/android-project/app/src/main/res/drawable/ic_attr_chin_size.xml b/android-project/app/src/main/res/drawable/ic_attr_chin_size.xml new file mode 100644 index 0000000..cb9a2d9 --- /dev/null +++ b/android-project/app/src/main/res/drawable/ic_attr_chin_size.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-project/app/src/main/res/drawable/ic_attr_eye_size.xml b/android-project/app/src/main/res/drawable/ic_attr_eye_size.xml new file mode 100644 index 0000000..88e86bc --- /dev/null +++ b/android-project/app/src/main/res/drawable/ic_attr_eye_size.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-project/app/src/main/res/drawable/ic_attr_face_slim.xml b/android-project/app/src/main/res/drawable/ic_attr_face_slim.xml new file mode 100644 index 0000000..16f4fd8 --- /dev/null +++ b/android-project/app/src/main/res/drawable/ic_attr_face_slim.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-project/app/src/main/res/drawable/ic_attr_forehead_size.xml b/android-project/app/src/main/res/drawable/ic_attr_forehead_size.xml new file mode 100644 index 0000000..9034e63 --- /dev/null +++ b/android-project/app/src/main/res/drawable/ic_attr_forehead_size.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-project/app/src/main/res/drawable/ic_attr_mouth_size.xml b/android-project/app/src/main/res/drawable/ic_attr_mouth_size.xml new file mode 100644 index 0000000..c477e95 --- /dev/null +++ b/android-project/app/src/main/res/drawable/ic_attr_mouth_size.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-project/app/src/main/res/drawable/ic_attr_nose_size.xml b/android-project/app/src/main/res/drawable/ic_attr_nose_size.xml new file mode 100644 index 0000000..630daee --- /dev/null +++ b/android-project/app/src/main/res/drawable/ic_attr_nose_size.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-project/app/src/main/res/drawable/ic_attr_skin_smooth.xml b/android-project/app/src/main/res/drawable/ic_attr_skin_smooth.xml new file mode 100644 index 0000000..19e4eaa --- /dev/null +++ b/android-project/app/src/main/res/drawable/ic_attr_skin_smooth.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-project/app/src/main/res/drawable/ic_main_beauty.xml b/android-project/app/src/main/res/drawable/ic_main_beauty.xml new file mode 100644 index 0000000..13cc855 --- /dev/null +++ b/android-project/app/src/main/res/drawable/ic_main_beauty.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-project/app/src/main/res/drawable/ic_main_sticker.xml b/android-project/app/src/main/res/drawable/ic_main_sticker.xml new file mode 100644 index 0000000..28615af --- /dev/null +++ b/android-project/app/src/main/res/drawable/ic_main_sticker.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-project/app/src/main/res/layout/activity_main.xml b/android-project/app/src/main/res/layout/activity_main.xml index 854336e..6b72de1 100644 --- a/android-project/app/src/main/res/layout/activity_main.xml +++ b/android-project/app/src/main/res/layout/activity_main.xml @@ -126,6 +126,16 @@ android:scaleType="fitCenter" android:contentDescription="Frame Overlay" /> + + + - + - - - + + + + + + + android:contentDescription="Beauty Selector" /> diff --git a/android-project/app/src/main/res/layout/dialog_online_store.xml b/android-project/app/src/main/res/layout/dialog_online_store.xml index 30224ca..9ba779d 100644 --- a/android-project/app/src/main/res/layout/dialog_online_store.xml +++ b/android-project/app/src/main/res/layout/dialog_online_store.xml @@ -76,16 +76,16 @@ android:layout_height="wrap_content" android:orientation="horizontal" android:layout_marginBottom="16dp" - android:weightSum="3"> + android:weightSum="4">