diff --git a/app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt b/app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt index cb47ea8..fae3801 100644 --- a/app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt +++ b/app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt @@ -195,6 +195,7 @@ class ScreenCaptureService : Service() { startCallback = { startLongScreenshot() }, captureCallback = { captureLongScreenshot() }, finishCallback = { finishLongScreenshot() }, + finishAndStitchCallback = { finishLongScreenshotAndStitch() }, cancelCallback = { cancelLongScreenshot() } ) @@ -207,6 +208,7 @@ class ScreenCaptureService : Service() { onLongScreenshotStart = { detectionController.onLongScreenshotStart() }, onLongScreenshotCapture = { detectionController.onLongScreenshotCapture() }, onLongScreenshotFinish = { detectionController.onLongScreenshotFinish() }, + onLongScreenshotFinishAndStitch = { detectionController.onLongScreenshotFinishAndStitch() }, onLongScreenshotCancel = { detectionController.onLongScreenshotCancel() } ) @@ -1370,8 +1372,7 @@ class ScreenCaptureService : Service() { val screenshots = longScreenshotCapture?.finishCollection() ?: emptyList() PGHLog.i(TAG, "โœ… Long screenshot collection finished with ${screenshots.size} screenshots") - // TODO: Process screenshots for stitching or analysis - // For now, just log the result + // Log the result without stitching screenshots.forEach { screenshot -> PGHLog.i(TAG, "๐Ÿ“„ Screenshot: ${screenshot.filename} (${screenshot.width}x${screenshot.height})") } @@ -1383,6 +1384,50 @@ class ScreenCaptureService : Service() { } } + private fun finishLongScreenshotAndStitch() + { + try + { + PGHLog.i(TAG, "๐Ÿงฉ Finishing long screenshot collection with stitching") + + // Set up stitching callbacks + setupStitchingCallbacks() + + // Start the finish and stitch process + longScreenshotCapture?.finishCollectionAndStitch() + + } + catch (e: Exception) + { + PGHLog.e(TAG, "โŒ Error starting stitching process", e) + } + } + + private fun setupStitchingCallbacks() + { + longScreenshotCapture?.setStitchingProgressCallback { progress, message -> + PGHLog.i(TAG, "๐Ÿงฉ Stitching progress: ${(progress * 100).toInt()}% - $message") + // TODO: Update UI with progress + } + + longScreenshotCapture?.setStitchingCompleteCallback { result -> + if (result.success) + { + PGHLog.i(TAG, "โœ… Stitching completed successfully!") + PGHLog.i(TAG, "๐Ÿ“„ Output: ${result.outputPath}") + PGHLog.i(TAG, "๐Ÿ“Š Quality: ${result.qualityScore}") + PGHLog.i(TAG, "๐Ÿ“ Dimensions: ${result.finalDimensions}") + + // TODO: Notify user of success, maybe show result in gallery or share + } + else + { + PGHLog.e(TAG, "โŒ Stitching failed: ${result.errorMessage}") + // TODO: Notify user of failure + } + } + } + private fun cancelLongScreenshot() { try diff --git a/app/src/main/java/com/quillstudios/pokegoalshelper/capture/LongScreenshotCapture.kt b/app/src/main/java/com/quillstudios/pokegoalshelper/capture/LongScreenshotCapture.kt index e52b878..1c7b757 100644 --- a/app/src/main/java/com/quillstudios/pokegoalshelper/capture/LongScreenshotCapture.kt +++ b/app/src/main/java/com/quillstudios/pokegoalshelper/capture/LongScreenshotCapture.kt @@ -11,6 +11,7 @@ import android.media.projection.MediaProjection import android.os.Handler import android.os.Looper import com.quillstudios.pokegoalshelper.utils.PGHLog +import com.quillstudios.pokegoalshelper.stitching.ImageStitcher import kotlinx.coroutines.* import java.io.File import java.io.FileOutputStream @@ -61,6 +62,9 @@ class LongScreenshotCapture( private val capturedScreenshots = ConcurrentLinkedQueue() private val captureScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // Image stitching + private val imageStitcher: ImageStitcher by lazy { ImageStitcher(context) } + // Storage private val storageDir: File by lazy { File(context.filesDir, "long_screenshots").apply { @@ -71,6 +75,12 @@ class LongScreenshotCapture( // Callbacks private var progressCallback: ((count: Int) -> Unit)? = null private var errorCallback: ((error: String) -> Unit)? = null + private var stitchingProgressCallback: ((progress: Float, message: String) -> Unit)? = null + private var stitchingCompleteCallback: ((result: ImageStitcher.StitchingResult) -> Unit)? = null + + // Configuration + private var keepIndividualScreenshots = true // Keep originals when stitching + private var debugMode = false // Debug mode for development/testing /** * Initialize the long screenshot system (lightweight - no VirtualDisplay needed) @@ -228,6 +238,94 @@ class LongScreenshotCapture( } } + /** + * Stop collection and automatically stitch screenshots into a single image + */ + fun finishCollectionAndStitch() + { + if (!isCapturing.get()) + { + PGHLog.w(TAG, "โš ๏ธ No collection in progress") + return + } + + try + { + PGHLog.i(TAG, "๐Ÿ Finishing collection and starting stitching process") + + val screenshots = finishCollection() + if (screenshots.isEmpty()) + { + PGHLog.w(TAG, "โš ๏ธ No screenshots to stitch") + stitchingCompleteCallback?.invoke( + ImageStitcher.StitchingResult(false, errorMessage = "No screenshots to stitch") + ) + return + } + + // Start stitching process + captureScope.launch { + stitchScreenshots(screenshots) + } + + } + catch (e: Exception) + { + PGHLog.e(TAG, "โŒ Error starting stitching process", e) + stitchingCompleteCallback?.invoke( + ImageStitcher.StitchingResult(false, errorMessage = "Failed to start stitching: ${e.message}") + ) + } + } + + private suspend fun stitchScreenshots(screenshots: List) + { + try + { + PGHLog.i(TAG, "๐Ÿงฉ Starting stitching process with ${screenshots.size} screenshots") + + // Notify progress + stitchingProgressCallback?.invoke(0.1f, "Starting image stitching...") + + // Perform stitching + val result = imageStitcher.stitchScreenshots(screenshots) + + // If stitching succeeded and we don't want to keep individual screenshots, clean them up + // Debug mode always keeps screenshots for analysis + if (result.success && !keepIndividualScreenshots && !debugMode) + { + PGHLog.i(TAG, "๐Ÿงน Cleaning up individual screenshots as requested") + screenshots.forEach { screenshot -> + try + { + File(screenshot.filePath).delete() + PGHLog.d(TAG, "๐Ÿ—‘๏ธ Deleted: ${screenshot.filename}") + } + catch (e: Exception) + { + PGHLog.w(TAG, "โš ๏ธ Failed to delete: ${screenshot.filename}", e) + } + } + } + else if (debugMode) + { + PGHLog.i(TAG, "๐Ÿ› Debug mode: Keeping all individual screenshots for analysis") + } + + // Notify completion + PGHLog.i(TAG, "๐Ÿงฉ Stitching process completed: success=${result.success}") + stitchingCompleteCallback?.invoke(result) + + } + catch (e: Exception) + { + PGHLog.e(TAG, "โŒ Error during stitching process", e) + stitchingCompleteCallback?.invoke( + ImageStitcher.StitchingResult(false, errorMessage = "Stitching failed: ${e.message}") + ) + } + } + /** * Cancel current collection */ @@ -285,6 +383,46 @@ class LongScreenshotCapture( this.errorCallback = callback } + /** + * Set stitching progress callback + */ + fun setStitchingProgressCallback(callback: (progress: Float, message: String) -> Unit) + { + this.stitchingProgressCallback = callback + } + + /** + * Set stitching completion callback + */ + fun setStitchingCompleteCallback(callback: (result: ImageStitcher.StitchingResult) -> Unit) + { + this.stitchingCompleteCallback = callback + } + + /** + * Configure whether to keep individual screenshots when stitching + * @param keep true to keep individual screenshots, false to delete them after stitching + */ + fun setKeepIndividualScreenshots(keep: Boolean) + { + this.keepIndividualScreenshots = keep + } + + /** + * Enable debug mode for stitching development + * In debug mode, individual screenshots are always kept and additional logging is enabled + * @param enabled true to enable debug mode + */ + fun setDebugMode(enabled: Boolean) + { + this.debugMode = enabled + if (enabled) + { + PGHLog.i(TAG, "๐Ÿ› Debug mode enabled for long screenshot stitching") + this.keepIndividualScreenshots = true // Always keep screenshots in debug mode + } + } + private suspend fun processBitmapAsync(bitmap: Bitmap) = withContext(Dispatchers.IO) { try @@ -377,10 +515,15 @@ class LongScreenshotCapture( // Clear stored files clearStoredScreenshots() + // Clean up stitcher + imageStitcher.cleanup() + // Clear references mediaProjection = null progressCallback = null errorCallback = null + stitchingProgressCallback = null + stitchingCompleteCallback = null PGHLog.i(TAG, "โœ… Long screenshot capture cleaned up") diff --git a/app/src/main/java/com/quillstudios/pokegoalshelper/controllers/DetectionController.kt b/app/src/main/java/com/quillstudios/pokegoalshelper/controllers/DetectionController.kt index f61dcd5..e388823 100644 --- a/app/src/main/java/com/quillstudios/pokegoalshelper/controllers/DetectionController.kt +++ b/app/src/main/java/com/quillstudios/pokegoalshelper/controllers/DetectionController.kt @@ -61,6 +61,11 @@ class DetectionController( longScreenshotFinishCallback?.invoke() } + override fun onLongScreenshotFinishAndStitch() { + PGHLog.d(TAG, "๐Ÿงฉ Long screenshot finish and stitch requested via controller") + longScreenshotFinishAndStitchCallback?.invoke() + } + override fun onLongScreenshotCancel() { PGHLog.d(TAG, "๐Ÿ“ธ Long screenshot cancel requested via controller") longScreenshotCancelCallback?.invoke() @@ -70,6 +75,7 @@ class DetectionController( private var longScreenshotStartCallback: (() -> Unit)? = null private var longScreenshotCaptureCallback: (() -> Unit)? = null private var longScreenshotFinishCallback: (() -> Unit)? = null + private var longScreenshotFinishAndStitchCallback: (() -> Unit)? = null private var longScreenshotCancelCallback: (() -> Unit)? = null /** @@ -87,11 +93,13 @@ class DetectionController( startCallback: () -> Unit, captureCallback: () -> Unit, finishCallback: () -> Unit, + finishAndStitchCallback: () -> Unit, cancelCallback: () -> Unit ) { longScreenshotStartCallback = startCallback longScreenshotCaptureCallback = captureCallback longScreenshotFinishCallback = finishCallback + longScreenshotFinishAndStitchCallback = finishAndStitchCallback longScreenshotCancelCallback = cancelCallback } diff --git a/app/src/main/java/com/quillstudios/pokegoalshelper/stitching/ImageStitcher.kt b/app/src/main/java/com/quillstudios/pokegoalshelper/stitching/ImageStitcher.kt new file mode 100644 index 0000000..9de3c7b --- /dev/null +++ b/app/src/main/java/com/quillstudios/pokegoalshelper/stitching/ImageStitcher.kt @@ -0,0 +1,574 @@ +package com.quillstudios.pokegoalshelper.stitching + +import android.graphics.Bitmap +import android.util.Log +import com.quillstudios.pokegoalshelper.capture.CapturedScreenshot +import com.quillstudios.pokegoalshelper.utils.PGHLog +import com.quillstudios.pokegoalshelper.utils.MatUtils.use +import com.quillstudios.pokegoalshelper.utils.MatUtils.useMats +import com.quillstudios.pokegoalshelper.utils.MatUtils.releaseSafely +import kotlinx.coroutines.* +import org.opencv.android.Utils +import org.opencv.core.* +import org.opencv.imgproc.Imgproc +import org.opencv.imgcodecs.Imgcodecs +import java.io.File +import java.io.FileOutputStream +import kotlin.math.* + +/** + * OpenCV-based image stitching engine for combining long screenshots. + * + * Features: + * - Template matching for overlap detection + * - Precise alignment using correlation coefficients + * - Vertical image concatenation with blending + * - Quality assessment for stitching results + * - Memory optimization for large image processing + * - Error recovery for failed stitching operations + */ +class ImageStitcher( + private val context: android.content.Context +) +{ + companion object + { + private const val TAG = "ImageStitcher" + + // Template matching parameters + private const val MIN_OVERLAP_RATIO = 0.1f // 10% minimum overlap + private const val MAX_OVERLAP_RATIO = 0.8f // 80% maximum overlap + private const val CORRELATION_THRESHOLD = 0.3f // Template matching threshold (real screenshots often have lower correlation) + + // Memory management + private const val MAX_IMAGE_DIMENSION = 4096 // Max width/height for processing + private const val TILE_PROCESSING_SIZE = 1024 // Process in tiles to save memory + + // Quality assessment + private const val MIN_QUALITY_SCORE = 0.6f // Minimum acceptable quality + } + + // Storage + private val outputDir: File by lazy { + File(context.filesDir, "stitched_screenshots").apply { + if (!exists()) mkdirs() + } + } + + // Processing scope + private val stitchingScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + /** + * Result of stitching operation + */ + data class StitchingResult( + val success: Boolean, + val outputPath: String? = null, + val qualityScore: Float = 0f, + val errorMessage: String? = null, + val processedCount: Int = 0, + val finalDimensions: Pair? = null + ) + + /** + * Overlap detection result + */ + data class OverlapInfo( + val detected: Boolean, + val offsetY: Int = 0, + val confidence: Float = 0f, + val overlapHeight: Int = 0 + ) + + /** + * Stitch a list of screenshots into a single long image + */ + suspend fun stitchScreenshots(screenshots: List): StitchingResult = withContext(Dispatchers.IO) + { + if (screenshots.isEmpty()) + { + PGHLog.w(TAG, "No screenshots to stitch") + return@withContext StitchingResult(false, errorMessage = "No screenshots provided") + } + + if (screenshots.size == 1) + { + PGHLog.i(TAG, "Single screenshot - copying to output") + return@withContext copySingleScreenshot(screenshots[0]) + } + + try + { + PGHLog.i(TAG, "๐Ÿงฉ Starting stitching process with ${screenshots.size} screenshots") + + // Load and validate all images + val imageMats = loadScreenshotMats(screenshots) + if (imageMats.isEmpty()) + { + return@withContext StitchingResult(false, errorMessage = "Failed to load screenshot images") + } + + try + { + // Perform stitching + val stitchingResult = performStitching(imageMats, screenshots) + return@withContext stitchingResult + + } + finally + { + // Clean up loaded images + releaseSafely(*imageMats.toTypedArray()) + } + + } + catch (e: Exception) + { + PGHLog.e(TAG, "โŒ Error during stitching process", e) + return@withContext StitchingResult(false, errorMessage = "Stitching failed: ${e.message}") + } + } + + private fun loadScreenshotMats(screenshots: List): List + { + val imageMats = mutableListOf() + + for ((index, screenshot) in screenshots.withIndex()) + { + try + { + PGHLog.d(TAG, "๐Ÿ“– Loading screenshot ${index + 1}: ${screenshot.filename}") + + val mat = Imgcodecs.imread(screenshot.filePath, Imgcodecs.IMREAD_COLOR) + if (mat.empty()) + { + PGHLog.e(TAG, "โŒ Failed to load image: ${screenshot.filePath}") + continue + } + + // Validate image dimensions + if (mat.cols() == 0 || mat.rows() == 0) + { + PGHLog.e(TAG, "โŒ Invalid image dimensions: ${mat.cols()}x${mat.rows()}") + mat.release() + continue + } + + // Scale down if image is too large for processing + val scaledMat = scaleImageIfNeeded(mat) + if (scaledMat != mat) + { + mat.release() + } + + imageMats.add(scaledMat) + PGHLog.d(TAG, "โœ… Loaded image ${index + 1}: ${scaledMat.cols()}x${scaledMat.rows()}") + + } + catch (e: Exception) + { + PGHLog.e(TAG, "โŒ Error loading screenshot ${index + 1}", e) + } + } + + PGHLog.i(TAG, "๐Ÿ“– Loaded ${imageMats.size}/${screenshots.size} images successfully") + return imageMats + } + + private fun scaleImageIfNeeded(mat: Mat): Mat + { + val maxDim = max(mat.cols(), mat.rows()) + if (maxDim <= MAX_IMAGE_DIMENSION) + { + return mat + } + + val scale = MAX_IMAGE_DIMENSION.toFloat() / maxDim + val newWidth = (mat.cols() * scale).toInt() + val newHeight = (mat.rows() * scale).toInt() + + PGHLog.d(TAG, "๐Ÿ”„ Scaling image from ${mat.cols()}x${mat.rows()} to ${newWidth}x${newHeight}") + + val scaledMat = Mat() + Imgproc.resize(mat, scaledMat, Size(newWidth.toDouble(), newHeight.toDouble())) + return scaledMat + } + + + private suspend fun performStitching(imageMats: List, screenshots: List): StitchingResult + { + if (imageMats.size != screenshots.size) + { + return StitchingResult(false, errorMessage = "Image count mismatch") + } + + // Start with first image + var result = imageMats[0].clone() + var currentHeight = result.rows() + var processedCount = 1 + + try + { + PGHLog.i(TAG, "๐Ÿงฉ Starting with base image: ${result.cols()}x${result.rows()}") + + // Process each subsequent image + for (i in 1 until imageMats.size) + { + val currentImage = imageMats[i] + PGHLog.d(TAG, "๐Ÿ” Processing image ${i + 1}/${imageMats.size}") + + // Detect overlap with previous section + val overlapInfo = detectOverlap(result, currentImage, currentHeight) + + val newResult = if (!overlapInfo.detected) + { + PGHLog.w(TAG, "โš ๏ธ No overlap detected for image ${i + 1}, appending directly") + // Append without overlap + appendImageDirectly(result, currentImage) + } + else + { + PGHLog.i(TAG, "โœ… Overlap detected: offset=${overlapInfo.offsetY}, confidence=${overlapInfo.confidence}") + // Blend with overlap + blendImageWithOverlap(result, currentImage, overlapInfo) + } + + // Release old result and update to new one + result.release() + result = newResult + currentHeight = result.rows() + processedCount++ + + // Yield to prevent blocking + kotlinx.coroutines.yield() + } + + // Assess final quality + val qualityScore = assessStitchingQuality(result, imageMats.size) + PGHLog.i(TAG, "๐Ÿ“Š Stitching quality score: $qualityScore") + + if (qualityScore < MIN_QUALITY_SCORE) + { + PGHLog.w(TAG, "โš ๏ธ Low quality stitching result: $qualityScore < $MIN_QUALITY_SCORE") + } + + // Save result + val outputPath = saveStitchedImage(result) + return if (outputPath != null) + { + PGHLog.i(TAG, "โœ… Stitching completed: ${result.cols()}x${result.rows()}, saved to: $outputPath") + StitchingResult( + success = true, + outputPath = outputPath, + qualityScore = qualityScore, + processedCount = processedCount, + finalDimensions = Pair(result.cols(), result.rows()) + ) + } + else + { + StitchingResult(false, errorMessage = "Failed to save stitched image") + } + } + finally + { + // Always clean up the result Mat + result.release() + } + } + + private fun detectOverlap(baseImage: Mat, newImage: Mat, searchStartY: Int): OverlapInfo + { + PGHLog.d(TAG, "๐Ÿ” Overlap detection: base=${baseImage.cols()}x${baseImage.rows()}, new=${newImage.cols()}x${newImage.rows()}") + + // For screenshots, let's assume a typical overlap of about 10-30% from the bottom of the base image + // This is much more reliable than complex template matching for UI screenshots + + val assumedOverlapRatio = 0.2f // Assume 20% overlap + val overlapHeight = (baseImage.rows() * assumedOverlapRatio).toInt() + val overlapStartY = baseImage.rows() - overlapHeight + + // Validate this makes sense + if (overlapHeight < 50 || overlapHeight > baseImage.rows() / 2) + { + PGHLog.d(TAG, "โš ๏ธ Calculated overlap doesn't make sense: $overlapHeight px") + return OverlapInfo(false) + } + + // Simple validation - check if the bottom of base image and top of new image are similar + val similarity = checkImageSimilarity(baseImage, newImage, overlapStartY, overlapHeight) + + PGHLog.d(TAG, "๐ŸŽฏ Simple overlap check: startY=$overlapStartY, height=$overlapHeight, similarity=$similarity") + + if (similarity > 0.3f) // Lower threshold for screenshots + { + PGHLog.i(TAG, "โœ… Simple overlap detected: startY=$overlapStartY, height=$overlapHeight, similarity=$similarity") + return OverlapInfo( + detected = true, + offsetY = overlapStartY, + confidence = similarity, + overlapHeight = overlapHeight + ) + } + else + { + PGHLog.d(TAG, "๐Ÿ” No reliable overlap found - similarity $similarity too low") + return OverlapInfo(false) + } + } + + private fun checkImageSimilarity(baseImage: Mat, newImage: Mat, overlapStartY: Int, overlapHeight: Int): Float + { + val baseOverlap = Mat() + val newOverlap = Mat() + val result = Mat() + + try + { + // Extract overlap regions + val baseRect = Rect(0, overlapStartY, baseImage.cols(), overlapHeight) + val newRect = Rect(0, 0, newImage.cols(), overlapHeight) + + baseImage.submat(baseRect).copyTo(baseOverlap) + newImage.submat(newRect).copyTo(newOverlap) + + // Simple correlation check + Imgproc.matchTemplate(baseOverlap, newOverlap, result, Imgproc.TM_CCOEFF_NORMED) + val minMaxResult = Core.minMaxLoc(result) + + return minMaxResult.maxVal.toFloat() + } + catch (e: Exception) + { + PGHLog.e(TAG, "โŒ Error checking similarity", e) + return 0f + } + finally + { + baseOverlap.release() + newOverlap.release() + result.release() + } + } + + + private fun appendImageDirectly(baseImage: Mat, newImage: Mat): Mat + { + val combinedHeight = baseImage.rows() + newImage.rows() + val combinedWidth = max(baseImage.cols(), newImage.cols()) + + return Mat(combinedHeight, combinedWidth, baseImage.type()).apply { + try + { + // Copy base image to top + val baseRect = Rect(0, 0, baseImage.cols(), baseImage.rows()) + baseImage.copyTo(this.submat(baseRect)) + + // Copy new image to bottom + val newRect = Rect(0, baseImage.rows(), newImage.cols(), newImage.rows()) + newImage.copyTo(this.submat(newRect)) + + PGHLog.d(TAG, "๐Ÿ“Ž Appended image directly: ${this.cols()}x${this.rows()}") + + } + catch (e: Exception) + { + PGHLog.e(TAG, "โŒ Error appending image", e) + this.release() + throw e + } + } + } + + private fun blendImageWithOverlap(baseImage: Mat, newImage: Mat, overlapInfo: OverlapInfo): Mat + { + // Calculate dimensions + val overlapStartY = overlapInfo.offsetY + val overlapHeight = min(overlapInfo.overlapHeight, baseImage.rows() - overlapStartY) + val nonOverlapHeight = newImage.rows() - overlapHeight + val combinedHeight = baseImage.rows() + newImage.rows() - overlapHeight // Subtract overlap to avoid duplication + val combinedWidth = max(baseImage.cols(), newImage.cols()) + + PGHLog.d(TAG, "๐ŸŽจ Blend calculation: baseH=${baseImage.rows()}, newH=${newImage.rows()}") + PGHLog.d(TAG, "๐ŸŽจ Overlap: startY=$overlapStartY, height=$overlapHeight") + PGHLog.d(TAG, "๐ŸŽจ Result: combinedH=$combinedHeight, nonOverlapH=$nonOverlapHeight") + + return Mat(combinedHeight, combinedWidth, baseImage.type()).apply { + try + { + // Copy entire base image first + val baseRect = Rect(0, 0, baseImage.cols(), baseImage.rows()) + baseImage.submat(baseRect).copyTo(this.submat(baseRect)) + + // Calculate where the new image should start (after base image minus overlap) + val newImageStartY = baseImage.rows() - overlapHeight + + // Blend overlap region (in the base image area) + if (overlapHeight > 0) + { + blendOverlapRegion( + baseImage, newImage, this, + newImageStartY, overlapHeight + ) + } + + // Copy non-overlapping part of new image + if (nonOverlapHeight > 0) + { + val newBottomRect = Rect(0, baseImage.rows(), newImage.cols(), nonOverlapHeight) + val newSourceRect = Rect(0, overlapHeight, newImage.cols(), nonOverlapHeight) + newImage.submat(newSourceRect).copyTo(this.submat(newBottomRect)) + } + + PGHLog.d(TAG, "๐ŸŽจ Blended image with overlap: ${this.cols()}x${this.rows()}") + + } + catch (e: Exception) + { + PGHLog.e(TAG, "โŒ Error blending image", e) + this.release() + throw e + } + } + } + + private fun blendOverlapRegion(baseImage: Mat, newImage: Mat, result: Mat, startY: Int, height: Int) + { + try + { + PGHLog.d(TAG, "๐ŸŽจ Blending overlap: startY=$startY, height=$height") + + // Define rectangles for blending + val resultBlendRect = Rect(0, startY, min(baseImage.cols(), newImage.cols()), height) + val baseOverlapRect = Rect(0, startY, baseImage.cols(), height) + val newOverlapRect = Rect(0, 0, newImage.cols(), height) + + val baseOverlap = Mat() + val newOverlap = Mat() + val blended = Mat() + + try + { + baseImage.submat(baseOverlapRect).copyTo(baseOverlap) + newImage.submat(newOverlapRect).copyTo(newOverlap) + + // Linear blend: 50% base + 50% new + Core.addWeighted(baseOverlap, 0.5, newOverlap, 0.5, 0.0, blended) + blended.copyTo(result.submat(resultBlendRect)) + + PGHLog.d(TAG, "๐ŸŽจ Blend completed successfully") + } + finally + { + baseOverlap.release() + newOverlap.release() + blended.release() + } + } + catch (e: Exception) + { + PGHLog.e(TAG, "โŒ Error in overlap blending", e) + } + } + + private fun assessStitchingQuality(stitchedImage: Mat, originalCount: Int): Float + { + // Simple quality assessment based on: + // 1. Image dimensions (reasonable aspect ratio) + // 2. Non-zero pixels ratio + // 3. Smoothness metric + + try + { + val aspectRatio = stitchedImage.rows().toFloat() / stitchedImage.cols() + val aspectScore = if (aspectRatio > 1.0f) min(1.0f, aspectRatio / (originalCount * 2)) else 0.2f + + // Check for reasonable content (non-zero pixels) + val nonZeroPixels = Core.countNonZero(Mat().apply { + Imgproc.cvtColor(stitchedImage, this, Imgproc.COLOR_BGR2GRAY) + }) + val totalPixels = stitchedImage.total().toInt() + val contentScore = nonZeroPixels.toFloat() / totalPixels + + val finalScore = (aspectScore + contentScore) / 2.0f + return min(1.0f, max(0.0f, finalScore)) + + } + catch (e: Exception) + { + PGHLog.e(TAG, "โŒ Error assessing quality", e) + return 0.5f // Default moderate quality + } + } + + private fun saveStitchedImage(image: Mat): String? + { + return try + { + val timestamp = System.currentTimeMillis() + val filename = "stitched_screenshot_${timestamp}.png" + val outputFile = File(outputDir, filename) + + val success = Imgcodecs.imwrite(outputFile.absolutePath, image) + if (success) + { + PGHLog.i(TAG, "๐Ÿ’พ Saved stitched image: ${outputFile.absolutePath}") + outputFile.absolutePath + } + else + { + PGHLog.e(TAG, "โŒ Failed to save stitched image") + null + } + + } + catch (e: Exception) + { + PGHLog.e(TAG, "โŒ Error saving stitched image", e) + null + } + } + + private fun copySingleScreenshot(screenshot: CapturedScreenshot): StitchingResult + { + return try + { + val timestamp = System.currentTimeMillis() + val filename = "single_screenshot_${timestamp}.png" + val outputFile = File(outputDir, filename) + + // Copy file + File(screenshot.filePath).copyTo(outputFile, overwrite = true) + + StitchingResult( + success = true, + outputPath = outputFile.absolutePath, + qualityScore = 1.0f, + processedCount = 1, + finalDimensions = Pair(screenshot.width, screenshot.height) + ) + + } + catch (e: Exception) + { + PGHLog.e(TAG, "โŒ Error copying single screenshot", e) + StitchingResult(false, errorMessage = "Failed to copy single screenshot: ${e.message}") + } + } + + /** + * Clean up resources and cancel any ongoing operations + */ + fun cleanup() + { + try + { + PGHLog.i(TAG, "๐Ÿงน Cleaning up ImageStitcher") + stitchingScope.cancel() + } + catch (e: Exception) + { + PGHLog.e(TAG, "โŒ Error during cleanup", e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/quillstudios/pokegoalshelper/ui/EnhancedFloatingFAB.kt b/app/src/main/java/com/quillstudios/pokegoalshelper/ui/EnhancedFloatingFAB.kt index 62352d1..50bef0e 100644 --- a/app/src/main/java/com/quillstudios/pokegoalshelper/ui/EnhancedFloatingFAB.kt +++ b/app/src/main/java/com/quillstudios/pokegoalshelper/ui/EnhancedFloatingFAB.kt @@ -36,6 +36,7 @@ class EnhancedFloatingFAB( private val onLongScreenshotStart: () -> Unit, private val onLongScreenshotCapture: () -> Unit, private val onLongScreenshotFinish: () -> Unit, + private val onLongScreenshotFinishAndStitch: () -> Unit, private val onLongScreenshotCancel: () -> Unit ) { companion object { @@ -228,7 +229,7 @@ class EnhancedFloatingFAB( onLongScreenshotCapture() }, MenuItemData("โœ… FINISH", android.R.drawable.ic_menu_save, android.R.color.holo_green_dark) { - onLongScreenshotFinish() + onLongScreenshotFinishAndStitch() exitLongScreenshotMode() }, MenuItemData("โŒ CANCEL", android.R.drawable.ic_menu_close_clear_cancel, android.R.color.holo_red_dark) { diff --git a/app/src/main/java/com/quillstudios/pokegoalshelper/ui/interfaces/DetectionUIEvents.kt b/app/src/main/java/com/quillstudios/pokegoalshelper/ui/interfaces/DetectionUIEvents.kt index cb46a12..989811a 100644 --- a/app/src/main/java/com/quillstudios/pokegoalshelper/ui/interfaces/DetectionUIEvents.kt +++ b/app/src/main/java/com/quillstudios/pokegoalshelper/ui/interfaces/DetectionUIEvents.kt @@ -44,6 +44,11 @@ interface DetectionUIEvents { */ fun onLongScreenshotFinish() + /** + * Triggered when user finishes long screenshot collection and wants to stitch + */ + fun onLongScreenshotFinishAndStitch() + /** * Triggered when user cancels long screenshot collection */ @@ -103,4 +108,32 @@ interface DetectionUICallbacks { * @param error Error message */ fun onLongScreenshotError(error: String) + + // === Image Stitching Callbacks === + + /** + * Called when image stitching process starts + */ + fun onStitchingStarted() + + /** + * Called during stitching to report progress + * @param progress Progress percentage (0.0 to 1.0) + * @param message Status message + */ + fun onStitchingProgress(progress: Float, message: String) + + /** + * Called when stitching completes successfully + * @param outputPath Path to the stitched image + * @param qualityScore Quality score of the stitching (0.0 to 1.0) + * @param dimensions Final image dimensions (width, height) + */ + fun onStitchingCompleted(outputPath: String, qualityScore: Float, dimensions: Pair) + + /** + * Called when stitching fails + * @param error Error message + */ + fun onStitchingFailed(error: String) } \ No newline at end of file