Compare commits
7 Commits
master
...
feature/pg
| Author | SHA1 | Date |
|---|---|---|
|
|
9e55ffa944 | 5 months ago |
|
|
3ed31a0d81 | 5 months ago |
|
|
e25eadc566 | 5 months ago |
|
|
e4ecfa6997 | 5 months ago |
|
|
17f0a1f202 | 5 months ago |
|
|
2d98941f63 | 5 months ago |
|
|
39635fedff | 5 months ago |
8 changed files with 1650 additions and 18 deletions
@ -0,0 +1,548 @@ |
|||||
|
package com.quillstudios.pokegoalshelper.capture |
||||
|
|
||||
|
import android.content.Context |
||||
|
import android.graphics.Bitmap |
||||
|
import android.graphics.PixelFormat |
||||
|
import android.hardware.display.DisplayManager |
||||
|
import android.hardware.display.VirtualDisplay |
||||
|
import android.media.Image |
||||
|
import android.media.ImageReader |
||||
|
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 |
||||
|
import java.util.concurrent.ConcurrentLinkedQueue |
||||
|
import java.util.concurrent.CountDownLatch |
||||
|
import java.util.concurrent.TimeUnit |
||||
|
import java.util.concurrent.atomic.AtomicBoolean |
||||
|
import java.util.concurrent.atomic.AtomicInteger |
||||
|
|
||||
|
/** |
||||
|
* Long screenshot capture system that works alongside existing MediaProjection setup. |
||||
|
* |
||||
|
* Features: |
||||
|
* - Independent capture system using shared MediaProjection |
||||
|
* - Manual screenshot collection management |
||||
|
* - Thread-safe screenshot queue |
||||
|
* - Proper memory management and cleanup |
||||
|
* - Error handling and recovery |
||||
|
* - Image storage and retrieval |
||||
|
*/ |
||||
|
class LongScreenshotCapture( |
||||
|
private val context: Context, |
||||
|
private val handler: Handler = Handler(Looper.getMainLooper()) |
||||
|
) |
||||
|
{ |
||||
|
companion object |
||||
|
{ |
||||
|
private const val TAG = "LongScreenshotCapture" |
||||
|
private const val BUFFER_COUNT = 2 |
||||
|
private const val CAPTURE_TIMEOUT_MS = 5000L |
||||
|
private const val MAX_SCREENSHOTS = 50 |
||||
|
} |
||||
|
|
||||
|
// Core components |
||||
|
private var mediaProjection: MediaProjection? = null |
||||
|
|
||||
|
// Screen dimensions |
||||
|
private var screenWidth = 0 |
||||
|
private var screenHeight = 0 |
||||
|
private var screenDensity = 0 |
||||
|
|
||||
|
// State management |
||||
|
private val isInitialized = AtomicBoolean(false) |
||||
|
private val isCapturing = AtomicBoolean(false) |
||||
|
private val screenshotCount = AtomicInteger(0) |
||||
|
|
||||
|
// Screenshot collection |
||||
|
private val capturedScreenshots = ConcurrentLinkedQueue<CapturedScreenshot>() |
||||
|
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 { |
||||
|
if (!exists()) mkdirs() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 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) |
||||
|
*/ |
||||
|
fun initialize(mediaProjection: MediaProjection, screenWidth: Int, screenHeight: Int, screenDensity: Int): Boolean |
||||
|
{ |
||||
|
if (isInitialized.get()) |
||||
|
{ |
||||
|
PGHLog.w(TAG, "Long screenshot capture already initialized") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
PGHLog.i(TAG, "🔧 Initializing long screenshot capture system") |
||||
|
|
||||
|
this.mediaProjection = mediaProjection |
||||
|
this.screenWidth = screenWidth |
||||
|
this.screenHeight = screenHeight |
||||
|
this.screenDensity = screenDensity |
||||
|
|
||||
|
// Note: We don't create our own VirtualDisplay/ImageReader since Android doesn't allow |
||||
|
// multiple VirtualDisplays from the same MediaProjection. Instead, we'll capture |
||||
|
// screenshots by requesting them from the existing screen capture system. |
||||
|
|
||||
|
isInitialized.set(true) |
||||
|
PGHLog.i(TAG, "✅ Long screenshot capture system initialized successfully (lightweight mode)") |
||||
|
return true |
||||
|
|
||||
|
} |
||||
|
catch (e: Exception) |
||||
|
{ |
||||
|
PGHLog.e(TAG, "❌ Error initializing long screenshot capture", e) |
||||
|
cleanup() |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Start long screenshot collection session |
||||
|
*/ |
||||
|
fun startCollection(): Boolean |
||||
|
{ |
||||
|
if (!isInitialized.get()) |
||||
|
{ |
||||
|
PGHLog.e(TAG, "❌ Cannot start collection - not initialized") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
if (isCapturing.get()) |
||||
|
{ |
||||
|
PGHLog.w(TAG, "⚠️ Collection already in progress") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
PGHLog.i(TAG, "🚀 Starting long screenshot collection") |
||||
|
|
||||
|
// Clear any previous screenshots |
||||
|
clearStoredScreenshots() |
||||
|
capturedScreenshots.clear() |
||||
|
screenshotCount.set(0) |
||||
|
|
||||
|
isCapturing.set(true) |
||||
|
PGHLog.i(TAG, "✅ Long screenshot collection started") |
||||
|
|
||||
|
return true |
||||
|
|
||||
|
} |
||||
|
catch (e: Exception) |
||||
|
{ |
||||
|
PGHLog.e(TAG, "❌ Error starting collection", e) |
||||
|
errorCallback?.invoke("Failed to start collection: ${e.message}") |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Capture a single frame manually (on-demand) |
||||
|
* This will be called by the service which will provide the actual image data |
||||
|
*/ |
||||
|
fun captureFrame(imageData: Bitmap?): Boolean |
||||
|
{ |
||||
|
if (!isCapturing.get()) |
||||
|
{ |
||||
|
PGHLog.w(TAG, "⚠️ Cannot capture - collection not active") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
if (screenshotCount.get() >= MAX_SCREENSHOTS) |
||||
|
{ |
||||
|
PGHLog.w(TAG, "⚠️ Maximum screenshots reached ($MAX_SCREENSHOTS)") |
||||
|
errorCallback?.invoke("Maximum screenshots reached") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
return try |
||||
|
{ |
||||
|
PGHLog.d(TAG, "📸 Processing manual frame capture") |
||||
|
|
||||
|
if (imageData != null) |
||||
|
{ |
||||
|
PGHLog.d(TAG, "📸 Image data received, processing...") |
||||
|
|
||||
|
// Process the bitmap in a coroutine |
||||
|
captureScope.launch { |
||||
|
processBitmapAsync(imageData) |
||||
|
} |
||||
|
return true |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
PGHLog.w(TAG, "⚠️ No image data provided") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
catch (e: Exception) |
||||
|
{ |
||||
|
PGHLog.e(TAG, "❌ Error capturing frame", e) |
||||
|
errorCallback?.invoke("Capture failed: ${e.message}") |
||||
|
false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Stop collection and return captured screenshots |
||||
|
*/ |
||||
|
fun finishCollection(): List<CapturedScreenshot> |
||||
|
{ |
||||
|
if (!isCapturing.get()) |
||||
|
{ |
||||
|
PGHLog.w(TAG, "⚠️ No collection in progress") |
||||
|
return emptyList() |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
PGHLog.i(TAG, "🏁 Finishing long screenshot collection") |
||||
|
|
||||
|
isCapturing.set(false) |
||||
|
|
||||
|
val screenshots = capturedScreenshots.toList() |
||||
|
PGHLog.i(TAG, "✅ Collection finished with ${screenshots.size} screenshots") |
||||
|
|
||||
|
return screenshots |
||||
|
|
||||
|
} |
||||
|
catch (e: Exception) |
||||
|
{ |
||||
|
PGHLog.e(TAG, "❌ Error finishing collection", e) |
||||
|
errorCallback?.invoke("Failed to finish collection: ${e.message}") |
||||
|
return emptyList() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 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<CapturedScreenshot>) |
||||
|
{ |
||||
|
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 |
||||
|
*/ |
||||
|
fun cancelCollection() |
||||
|
{ |
||||
|
if (!isCapturing.get()) |
||||
|
{ |
||||
|
PGHLog.w(TAG, "⚠️ No collection to cancel") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
PGHLog.i(TAG, "❌ Canceling long screenshot collection") |
||||
|
|
||||
|
isCapturing.set(false) |
||||
|
|
||||
|
// Clear captured screenshots |
||||
|
capturedScreenshots.clear() |
||||
|
screenshotCount.set(0) |
||||
|
clearStoredScreenshots() |
||||
|
|
||||
|
PGHLog.i(TAG, "✅ Collection canceled") |
||||
|
|
||||
|
} |
||||
|
catch (e: Exception) |
||||
|
{ |
||||
|
PGHLog.e(TAG, "❌ Error canceling collection", e) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get current screenshot count |
||||
|
*/ |
||||
|
fun getScreenshotCount(): Int = screenshotCount.get() |
||||
|
|
||||
|
/** |
||||
|
* Check if collection is active |
||||
|
*/ |
||||
|
fun isCollectionActive(): Boolean = isCapturing.get() |
||||
|
|
||||
|
/** |
||||
|
* Set progress callback |
||||
|
*/ |
||||
|
fun setProgressCallback(callback: (count: Int) -> Unit) |
||||
|
{ |
||||
|
this.progressCallback = callback |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Set error callback |
||||
|
*/ |
||||
|
fun setErrorCallback(callback: (error: String) -> Unit) |
||||
|
{ |
||||
|
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 |
||||
|
{ |
||||
|
val timestamp = System.currentTimeMillis() |
||||
|
val filename = "screenshot_${timestamp}.png" |
||||
|
val file = File(storageDir, filename) |
||||
|
|
||||
|
// Save bitmap to file |
||||
|
saveBitmapToFile(bitmap, file) |
||||
|
|
||||
|
val screenshot = CapturedScreenshot( |
||||
|
id = timestamp, |
||||
|
filename = filename, |
||||
|
filePath = file.absolutePath, |
||||
|
timestamp = timestamp, |
||||
|
width = bitmap.width, |
||||
|
height = bitmap.height |
||||
|
) |
||||
|
|
||||
|
capturedScreenshots.offer(screenshot) |
||||
|
val count = screenshotCount.incrementAndGet() |
||||
|
|
||||
|
PGHLog.i(TAG, "📸 Screenshot #$count captured: $filename") |
||||
|
|
||||
|
// Notify progress on main thread |
||||
|
handler.post { |
||||
|
progressCallback?.invoke(count) |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
catch (e: Exception) |
||||
|
{ |
||||
|
PGHLog.e(TAG, "❌ Error processing bitmap", e) |
||||
|
errorCallback?.invoke("Failed to save screenshot: ${e.message}") |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
// Always recycle the bitmap after processing |
||||
|
if (!bitmap.isRecycled) { |
||||
|
bitmap.recycle() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
private fun saveBitmapToFile(bitmap: Bitmap, file: File) |
||||
|
{ |
||||
|
FileOutputStream(file).use { stream -> |
||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun clearStoredScreenshots() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
storageDir.listFiles()?.forEach { file -> |
||||
|
if (file.name.startsWith("screenshot_") && file.name.endsWith(".png")) |
||||
|
{ |
||||
|
file.delete() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
catch (e: Exception) |
||||
|
{ |
||||
|
PGHLog.e(TAG, "❌ Error clearing stored screenshots", e) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Clean up resources |
||||
|
*/ |
||||
|
fun cleanup() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
PGHLog.i(TAG, "🧹 Cleaning up long screenshot capture") |
||||
|
|
||||
|
isCapturing.set(false) |
||||
|
isInitialized.set(false) |
||||
|
|
||||
|
// Cancel any ongoing coroutines |
||||
|
captureScope.cancel() |
||||
|
|
||||
|
// Clear collections |
||||
|
capturedScreenshots.clear() |
||||
|
screenshotCount.set(0) |
||||
|
|
||||
|
// 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") |
||||
|
|
||||
|
} |
||||
|
catch (e: Exception) |
||||
|
{ |
||||
|
PGHLog.e(TAG, "❌ Error during cleanup", e) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Data class representing a captured screenshot |
||||
|
*/ |
||||
|
data class CapturedScreenshot( |
||||
|
val id: Long, |
||||
|
val filename: String, |
||||
|
val filePath: String, |
||||
|
val timestamp: Long, |
||||
|
val width: Int, |
||||
|
val height: Int |
||||
|
) |
||||
@ -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<Int, Int>? = 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<CapturedScreenshot>): 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<CapturedScreenshot>): List<Mat> |
||||
|
{ |
||||
|
val imageMats = mutableListOf<Mat>() |
||||
|
|
||||
|
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<Mat>, screenshots: List<CapturedScreenshot>): 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) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue