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