From 6ff7e5548b60fd1d8b1633842b36239a93f9bc0e Mon Sep 17 00:00:00 2001 From: PM Date: Thu, 12 Nov 2026 15:54:37 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(image):=20=E5=9B=BE=E5=83=8F=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=9B=9E=E6=94=B6=E5=8F=8A=E7=9B=B8=E5=85=B3=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 upstream/setup-v7 (4c396b21) 重建,内容取自已合并上游的 setup-v7 (8f18853e) 中的 6 个图像相关文件,一次性包含: - 脚本结束时回收全部图像资源(原 PR 主题) - ImageWrapper 追踪上限不再误回收刚创建的 Bitmap - requestScreenCaptureLegacy 统一为单一同步语义、15s 默认超时, 并支持 0/1/2 参重载(修复 Rhino 传入 Undefined 时的重载歧义) - getBrightness 改用 Double 累加、修正区域边界与采样数收敛 - ScreenCaptureManager 绑定加超时、失败不伪装成功、连接不泄漏 - TemplateMatching 拒绝 useGrayscale 与 transparentMask 同时使用 - images/index.ts 的 matchMethod 保留合法的 0 (TM_SQDIFF) JS 产物(dist/assets)由构建任务生成,未纳入提交。 --- .../autojs/core/image/ImageWrapper.java | 117 +++++++- .../autojs/core/image/TemplateMatching.java | 187 +++++++------ .../image/capture/ScreenCaptureManager.kt | 75 +++-- .../core/image/capture/ScreenCapturer.kt | 1 + .../com/stardust/autojs/runtime/api/Images.kt | 257 ++++++++++++++---- autojs/src/main/js/v6-api/src/images/index.ts | 70 ++++- 6 files changed, 523 insertions(+), 184 deletions(-) diff --git a/autojs/src/main/java/com/stardust/autojs/core/image/ImageWrapper.java b/autojs/src/main/java/com/stardust/autojs/core/image/ImageWrapper.java index 46d3e8165..48e28c2cb 100644 --- a/autojs/src/main/java/com/stardust/autojs/core/image/ImageWrapper.java +++ b/autojs/src/main/java/com/stardust/autojs/core/image/ImageWrapper.java @@ -3,7 +3,9 @@ import android.graphics.Bitmap; import android.graphics.Color; import android.media.Image; -import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; import com.stardust.autojs.core.opencv.Mat; import com.stardust.autojs.core.opencv.OpenCVHelper; @@ -14,31 +16,100 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; +import java.lang.ref.WeakReference; import java.nio.ByteBuffer; - -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; /** * Created by Stardust on 2017/11/25. */ public class ImageWrapper { + private static final List> sAllImages = new ArrayList<>(); + private static final Object sLock = new Object(); + private static boolean sTrackingEnabled = false; + private Mat mMat; private int mWidth; private int mHeight; private Bitmap mBitmap; + private boolean mRecycled; + + public static void enableTracking() { + Log.d("ImageWrapper", "enableTracking called, sAllImages size before clear: " + sAllImages.size()); + synchronized (sLock) { + sTrackingEnabled = true; + Log.d("ImageWrapper", "sTrackingEnabled set to true"); + int size = sAllImages.size(); + sAllImages.clear(); + Log.d("ImageWrapper", "sAllImages cleared, size before: " + size); + } + Log.d("ImageWrapper", "enableTracking done"); + } + + public static void disableTracking() { + synchronized (sLock) { + sTrackingEnabled = false; + } + } + + public static void recycleAllTrackedImages() { + synchronized (sLock) { + sAllImages.removeIf(new Predicate() { // from class: com.stardust.autojs.core.image.ImageWrapper$$ExternalSyntheticLambda0 + @Override // java.util.function.Predicate + public final boolean test(Object obj) { + return ImageWrapper.lambda$recycleAllTrackedImages$0((WeakReference) obj); + } + }); + for (WeakReference ref : sAllImages) { + ImageWrapper img = ref.get(); + if (img != null && img.isAlive()) { + try { + img.recycle(); + } catch (Exception e) { + } + } + } + sAllImages.clear(); + } + } + + static /* synthetic */ boolean lambda$recycleAllTrackedImages$0(WeakReference ref) { + return ref.get() == null; + } + + public static int getTrackedImageCount() { + int size; + synchronized (sLock) { + sAllImages.removeIf(new Predicate() { // from class: com.stardust.autojs.core.image.ImageWrapper$$ExternalSyntheticLambda1 + @Override // java.util.function.Predicate + public final boolean test(Object obj) { + return ImageWrapper.lambda$getTrackedImageCount$1((WeakReference) obj); + } + }); + size = sAllImages.size(); + } + return size; + } + + static /* synthetic */ boolean lambda$getTrackedImageCount$1(WeakReference ref) { + return ref.get() == null; + } protected ImageWrapper(Mat mat) { mMat = mat; mWidth = mat.cols(); mHeight = mat.rows(); + trackThis(); } protected ImageWrapper(Bitmap bitmap) { mBitmap = bitmap; mWidth = bitmap.getWidth(); mHeight = bitmap.getHeight(); + trackThis(); } protected ImageWrapper(Bitmap bitmap, Mat mat) { @@ -46,12 +117,39 @@ protected ImageWrapper(Bitmap bitmap, Mat mat) { mMat = mat; mWidth = bitmap.getWidth(); mHeight = bitmap.getHeight(); + trackThis(); } public ImageWrapper(int width, int height) { this(Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)); } + private void trackThis() { + synchronized (sLock) { + if (sTrackingEnabled) { + sAllImages.removeIf(new Predicate() { // from class: com.stardust.autojs.core.image.ImageWrapper$$ExternalSyntheticLambda2 + @Override // java.util.function.Predicate + public final boolean test(Object obj) { + return ImageWrapper.lambda$trackThis$2((WeakReference) obj); + } + }); + // 先回收再登记:原先先 add 再判阈值,触发全量回收时会把 + // 刚创建、即将返回给脚本的这张图也回收掉。 + if (sAllImages.size() >= 10000) { + recycleAllTrackedImages(); + } + sAllImages.add(new WeakReference<>(this)); + } + } + } + + static /* synthetic */ boolean lambda$trackThis$2(WeakReference ref) { + return ref.get() == null; + } + + public boolean isAlive() { + return (mRecycled || (mBitmap == null && mMat == null)) ? false : true; + } public static ImageWrapper ofImage(Image image) { if (image == null) { @@ -69,7 +167,6 @@ public static ImageWrapper ofMat(Mat mat) { return new ImageWrapper(mat); } - public static ImageWrapper ofBitmap(Bitmap bitmap) { if (bitmap == null) { return null; @@ -142,17 +239,21 @@ public Bitmap getBitmap() { } public void recycle() { + if (mRecycled) { + return; + } + mRecycled = true; mBitmap = null; if (mMat != null) { OpenCVHelper.release(mMat); mMat = null; } - } public void ensureNotRecycled() { - if (mBitmap == null && mMat == null) + if (mRecycled || (mBitmap == null && mMat == null)) { throw new IllegalStateException("image has been recycled"); + } } @Override @@ -167,4 +268,4 @@ public ImageWrapper clone() { } return new ImageWrapper(mBitmap.copy(mBitmap.getConfig(), true), mMat.clone()); } -} +} \ No newline at end of file diff --git a/autojs/src/main/java/com/stardust/autojs/core/image/TemplateMatching.java b/autojs/src/main/java/com/stardust/autojs/core/image/TemplateMatching.java index c25e58060..844bd1fcf 100644 --- a/autojs/src/main/java/com/stardust/autojs/core/image/TemplateMatching.java +++ b/autojs/src/main/java/com/stardust/autojs/core/image/TemplateMatching.java @@ -3,13 +3,10 @@ import android.util.Log; import android.util.TimingLogger; -import androidx.annotation.NonNull; - import com.stardust.util.Nath; import org.opencv.core.Core; import org.opencv.core.CvType; - import org.opencv.core.Mat; import org.opencv.core.Point; import org.opencv.core.Rect; @@ -22,30 +19,30 @@ import java.util.Iterator; import java.util.List; - -/** - * Created by Stardust on 2017/11/25. - */ - public class TemplateMatching { public static class Match { public final Point point; public final double similarity; public Integer templateIndex = 0; + public int width; + public int height; - public Match(Point point, double similarity) { + public Match(Point point, double similarity, int width, int height) { this.point = point; this.similarity = similarity; + this.width = width; + this.height = height; } - @NonNull @Override public String toString() { return "Match{" + "templateIndex=" + templateIndex + ", point=" + point + ", similarity=" + similarity + + ", width=" + width + + ", height=" + height + '}'; } } @@ -55,29 +52,23 @@ public String toString() { public static final int MAX_LEVEL_AUTO = -1; public static final int MATCHING_METHOD_DEFAULT = Imgproc.TM_CCOEFF_NORMED; - public static Point fastTemplateMatching(Mat img, Mat template, int matchMethod, float weakThreshold, float strictThreshold, int maxLevel, Boolean transparentMask) { - List result = fastTemplateMatching(img, template, matchMethod, weakThreshold, strictThreshold, maxLevel, 1, transparentMask); + public static Point fastTemplateMatching(Mat img, Mat template, int matchMethod, float weakThreshold, + float strictThreshold, int maxLevel, Boolean transparentMask, + boolean useGrayscale) { + List result = fastTemplateMatching(img, template, matchMethod, weakThreshold, + strictThreshold, maxLevel, 1, transparentMask, useGrayscale); if (result.isEmpty()) { return null; } return result.get(0).point; } - /** - * 采用图像金字塔算法快速找图 - * - * @param img 图片 - * @param template 模板图片 - * @param matchMethod 匹配算法 - * @param weakThreshold 弱阈值。该值用于在每一轮模板匹配中检验是否继续匹配。如果相似度小于该值,则不再继续匹配。 - * @param strictThreshold 强阈值。该值用于检验最终匹配结果,以及在每一轮匹配中如果相似度大于该值则直接返回匹配结果。 - * @param maxLevel 图像金字塔的层数 - * @return - */ - public static List fastTemplateMatching(Mat img, Mat template, int matchMethod, float weakThreshold, float strictThreshold, int maxLevel, int limit, Boolean transparentMask) { + public static List fastTemplateMatching(Mat img, Mat template, int matchMethod, float weakThreshold, + float strictThreshold, int maxLevel, int limit, + Boolean transparentMask, boolean useGrayscale) { List templates = Collections.singletonList(template); return fastMultiTemplateMatching(img, templates, matchMethod, weakThreshold, - strictThreshold, maxLevel, limit, transparentMask); + strictThreshold, maxLevel, limit, transparentMask, useGrayscale); } /** @@ -94,50 +85,60 @@ public static List fastTemplateMatching(Mat img, Mat template, int matchM * @param transparentMask 是否启用透明蒙版 * @return 按模板顺序返回匹配结果列表;若某模板无匹配,则对应列表为空 */ - public static List fastMultiTemplateMatching(Mat img, - List templates, - int matchMethod, - float weakThreshold, - float strictThreshold, - int maxLevel, - int limit, - Boolean transparentMask) { + public static List fastMultiTemplateMatching(Mat img, List templates, int matchMethod, + float weakThreshold, float strictThreshold, + int maxLevel, int limit, Boolean transparentMask, + boolean useGrayscale) { + // 灰度化会把 RGBA 转成单通道,alpha 不复存在,processingAlphaChannel() + // 之后只能拿到错误的蒙版。与其静默给出错误匹配结果,不如明确拒绝。 + if (useGrayscale && Boolean.TRUE.equals(transparentMask)) { + throw new IllegalArgumentException( + "useGrayscale and transparentMask cannot be used together: " + + "grayscale conversion drops the alpha channel"); + } TimingLogger logger = new TimingLogger(LOG_TAG, "fast_multi_tm"); List resourcesToRelease = new ArrayList<>(); + List allResults = new ArrayList<>(); try { + Mat workingImg = img; + List workingTemplates = templates; + + if (useGrayscale) { + workingImg = toGrayscaleIfNeeded(img, resourcesToRelease); + List grayTemplates = new ArrayList<>(); + for (Mat t : templates) { + grayTemplates.add(toGrayscaleIfNeeded(t, resourcesToRelease)); + } + workingTemplates = grayTemplates; + } // 确定全局最大金字塔层数(取所有模板允许层数的最大值) int globalMaxLevel; if (maxLevel == MAX_LEVEL_AUTO) { globalMaxLevel = 0; - for (Mat template : templates) { - int level = selectPyramidLevel(img, template); - if (level > globalMaxLevel) globalMaxLevel = level; + for (Mat template : workingTemplates) { + int level = selectPyramidLevel(workingImg, template); + if (level > globalMaxLevel) { + globalMaxLevel = level; + } } } else { globalMaxLevel = maxLevel; } - // 构建主图金字塔(0 ~ globalMaxLevel) List imgPyramid = new ArrayList<>(); for (int level = 0; level <= globalMaxLevel; level++) { - Mat src = getPyramidDownAtLevel(img, level); + Mat src = getPyramidDownAtLevel(workingImg, level); imgPyramid.add(src); - if (src != img) { + if (src != workingImg) { resourcesToRelease.add(src); } } - List allResults = new ArrayList<>(); - for (int i = 0; i < templates.size(); i++) { - List result = matchTemplateWithPyramid(imgPyramid, - templates.get(i), - globalMaxLevel, - matchMethod, - weakThreshold, - strictThreshold, - limit, - transparentMask, + for (int i = 0; i < workingTemplates.size(); i++) { + Mat template = workingTemplates.get(i); + List result = matchTemplateWithPyramid(imgPyramid, template, globalMaxLevel, + matchMethod, weakThreshold, strictThreshold, limit, transparentMask, resourcesToRelease); for (Match r : result) { r.templateIndex = i; @@ -147,7 +148,6 @@ public static List fastMultiTemplateMatching(Mat img, logger.addSplit("multi_templates: " + templates.size()); logger.dumpToLog(); - return allResults; } finally { for (Mat mat : resourcesToRelease) { try { @@ -157,6 +157,8 @@ public static List fastMultiTemplateMatching(Mat img, } } } + + return allResults; } /** @@ -173,20 +175,14 @@ public static List fastMultiTemplateMatching(Mat img, * @param resourcesToRelease 资源回收列表 * @return 匹配结果(坐标已映射回原图) */ - private static List matchTemplateWithPyramid(List imgPyramid, - Mat template, - int globalMaxLevel, - int matchMethod, - float weakThreshold, - float strictThreshold, - int limit, - Boolean transparentMask, + private static List matchTemplateWithPyramid(List imgPyramid, Mat template, + int globalMaxLevel, int matchMethod, + float weakThreshold, float strictThreshold, + int limit, Boolean transparentMask, List resourcesToRelease) { - // 计算模板自身的最大可行层数 int tMaxLevel = selectPyramidLevel(imgPyramid.get(0), template); tMaxLevel = Math.min(tMaxLevel, globalMaxLevel); - // 构建模板金字塔 List templatePyramid = new ArrayList<>(); for (int level = 0; level <= tMaxLevel; level++) { Mat t = getPyramidDownAtLevel(template, level); @@ -204,25 +200,25 @@ private static List matchTemplateWithPyramid(List imgPyramid, List currentMatchResult = new ArrayList<>(); Mat src = imgPyramid.get(level); Mat currentTemplate = templatePyramid.get(level); - Mat currentMask = null; + Mat currentMask = null; if (transparentMask) { currentMask = TemplateMatchingKt.INSTANCE.processingAlphaChannel(currentTemplate); resourcesToRelease.add(currentMask); } + int actualWidth = currentTemplate.width() << level; + int actualHeight = currentTemplate.height() << level; + if (previousMatchResult.isEmpty()) { - // 决定是否继续匹配(根据弱阈值跳过部分层) if (!isFirstMatching && !shouldContinueMatching(level, tMaxLevel)) { break; } - // 全图匹配 Mat matchResult = matchTemplate(src, currentTemplate, matchMethod, currentMask); resourcesToRelease.add(matchResult); getBestMatched(matchResult, currentTemplate, matchMethod, weakThreshold, - currentMatchResult, limit, null); + currentMatchResult, limit, null, actualWidth, actualHeight); } else { - // 在上一层的匹配点附近进行局部匹配 for (Match match : previousMatchResult) { Rect r = getROI(match.point, src, currentTemplate); Mat m = new Mat(src, r); @@ -230,23 +226,22 @@ private static List matchTemplateWithPyramid(List imgPyramid, resourcesToRelease.add(m); resourcesToRelease.add(matchResult); getBestMatched(matchResult, currentTemplate, matchMethod, weakThreshold, - currentMatchResult, limit, r); + currentMatchResult, limit, r, actualWidth, actualHeight); } } - // 强阈值处理:达到 strictThreshold 的结果直接加入最终列表,并停止该模板后续匹配 if (!currentMatchResult.isEmpty()) { Iterator iterator = currentMatchResult.iterator(); while (iterator.hasNext()) { Match match = iterator.next(); if (match.similarity >= strictThreshold) { - pyrUp(match.point, level); // 坐标还原至原图尺寸 + pyrUp(match.point, level); finalMatchResult.add(match); iterator.remove(); } } if (currentMatchResult.isEmpty()) { - break; // 所有候选都满足强阈值,提前终止 + break; } } @@ -257,6 +252,16 @@ private static List matchTemplateWithPyramid(List imgPyramid, return finalMatchResult; } + private static Mat toGrayscaleIfNeeded(Mat image, List resources) { + if (image.channels() == 1 && image.type() == CvType.CV_8UC1) { + return image; + } + Mat gray = new Mat(); + Imgproc.cvtColor(image, gray, Imgproc.COLOR_RGBA2GRAY); + resources.add(gray); + return gray; + } + private static Mat getPyramidDownAtLevel(Mat m, int level) { if (level == 0) { return m; @@ -297,26 +302,23 @@ private static Rect getROI(Point p, Mat src, Mat currentTemplate) { int w = (int) (currentTemplate.cols() * 1.5); int h = (int) (currentTemplate.rows() * 1.5); if (x + w >= src.cols()) { - w = src.cols() - x - 1; + w = src.cols() - x; } if (y + h >= src.rows()) { - h = src.rows() - y - 1; + h = src.rows() - y; } return new Rect(x, y, w, h); } private static int selectPyramidLevel(Mat img, Mat template) { int minDim = Nath.min(img.rows(), img.cols(), template.rows(), template.cols()); - //这里选取16为图像缩小后的最小宽高,从而用log(2, minDim / 16)得到最多可以经过几次缩小。 int maxLevel = (int) (Math.log(minDim / 16) / Math.log(2)); if (maxLevel < 0) { return 0; } - //上限为6 return Math.min(6, maxLevel); } - private static Mat matchTemplate(Mat img, Mat temp, int match_method, Mat mask) { int result_cols = img.cols() - temp.cols() + 1; int result_rows = img.rows() - temp.rows() + 1; @@ -329,22 +331,30 @@ private static Mat matchTemplate(Mat img, Mat temp, int match_method, Mat mask) return result; } - private static void getBestMatched(Mat tmResult, Mat template, int matchMethod, float weakThreshold, List outResult, int limit, Rect rect) { + private static void getBestMatched(Mat tmResult, Mat template, int matchMethod, float weakThreshold, + List outResult, int limit, Rect rect, + int actualWidth, int actualHeight) { for (int i = 0; i < limit; i++) { - Match bestMatched = getBestMatched(tmResult, matchMethod, weakThreshold, rect); + Match bestMatched = getBestMatched(tmResult, matchMethod, weakThreshold, rect, + actualWidth, actualHeight); if (bestMatched == null) { break; } outResult.add(bestMatched); - Point start = new Point(Math.max(0, bestMatched.point.x - template.width() + 1), - Math.max(0, bestMatched.point.y - template.height() + 1)); - Point end = new Point(Math.min(tmResult.width(), bestMatched.point.x + template.width()), - Math.min(tmResult.height(), bestMatched.point.y + template.height())); + Point start = new Point( + Math.max(0, bestMatched.point.x - template.width() + 1), + Math.max(0, bestMatched.point.y - template.height() + 1) + ); + Point end = new Point( + Math.min(tmResult.width(), bestMatched.point.x + template.width()), + Math.min(tmResult.height(), bestMatched.point.y + template.height()) + ); Imgproc.rectangle(tmResult, start, end, new Scalar(0, 255, 0), -1); } } - private static Match getBestMatched(Mat tmResult, int matchMethod, float weakThreshold, Rect rect) { + private static Match getBestMatched(Mat tmResult, int matchMethod, float weakThreshold, + Rect rect, int actualWidth, int actualHeight) { TimingLogger logger = new TimingLogger(LOG_TAG, "best_matched_point"); Core.MinMaxLocResult mmr = Core.minMaxLoc(tmResult); logger.addSplit("minMaxLoc"); @@ -366,18 +376,20 @@ private static Match getBestMatched(Mat tmResult, int matchMethod, float weakThr } logger.addSplit("value:" + value); if (!Double.isFinite(value)) { - return getBestMatched(replaceNoFinite(tmResult), matchMethod, weakThreshold, rect); + return getBestMatched(replaceNoFinite(tmResult), matchMethod, weakThreshold, + rect, actualWidth, actualHeight); } - return new Match(pos, value); + return new Match(pos, value, actualWidth, actualHeight); } - private static Mat replaceNoFinite(Mat mat) { - if (mat.empty()) return null; + if (mat.empty()) { + return mat; + } if (mat.type() == CvType.CV_32FC1) { Core.patchNaNs(mat, 0.0); Mat infMask = new Mat(); - Core.compare(mat, new Scalar(255), infMask, Core.CMP_GT); // mat > threshold -> 255 + Core.compare(mat, new Scalar(255), infMask, Core.CMP_GT); Mat zeros = Mat.zeros(mat.size(), mat.type()); zeros.copyTo(mat, infMask); infMask.release(); @@ -385,5 +397,4 @@ private static Mat replaceNoFinite(Mat mat) { } return mat; } - -} +} \ No newline at end of file diff --git a/autojs/src/main/java/com/stardust/autojs/core/image/capture/ScreenCaptureManager.kt b/autojs/src/main/java/com/stardust/autojs/core/image/capture/ScreenCaptureManager.kt index a922b6d9f..ce73622ab 100644 --- a/autojs/src/main/java/com/stardust/autojs/core/image/capture/ScreenCaptureManager.kt +++ b/autojs/src/main/java/com/stardust/autojs/core/image/capture/ScreenCaptureManager.kt @@ -19,8 +19,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import java.lang.ref.WeakReference import java.util.concurrent.CancellationException +import kotlin.time.Duration.Companion.milliseconds + +/** 绑定前台截图服务的最长等待时间;超时后失败返回,避免脚本线程永久挂起。 */ +private const val BIND_TIMEOUT_MILLIS = 10_000L class ScreenCaptureManager : ScreenCaptureRequester { @Volatile @@ -64,29 +69,45 @@ class ScreenCaptureManager : ScreenCaptureRequester { .getMediaProjection(Activity.RESULT_OK, result) CaptureForegroundService.setMediaProjection(context, mediaProjection!!) screenCapture = ScreenCapturer(mediaProjection!!, orientation) - } finally { serviceConnected.complete(Unit) - context.unbindService(this) + } catch (e: Exception) { + // 原来 finally 里无条件 complete(Unit),getMediaProjection 抛异常时 + // 会伪装成成功,脚本随后拿到不可用的截屏器。 + serviceConnected.completeExceptionally(e) + } finally { + runCatching { context.unbindService(this) } + currentConnection = null } } override fun onServiceDisconnected(name: ComponentName?) { - // 服务意外断开时处理 + currentConnection = null serviceConnected.completeExceptionally(IllegalStateException("Service disconnected unexpectedly")) } } - // 先绑定服务再启动(避免延迟) val serviceIntent = Intent(context, CaptureForegroundService::class.java) - context.bindService( - serviceIntent, - connection, - Context.BIND_AUTO_CREATE - ) - - // 绑定后立即启动服务 - context.startForegroundService(serviceIntent) - serviceConnected.await() + val bound = context.bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE) + if (!bound) { + serviceConnected.completeExceptionally( + IllegalStateException("bind CaptureForegroundService failed") + ) + } else { + currentConnection = connection + context.startForegroundService(serviceIntent) + } + + try { + // 服务被系统拒绝或迟迟不回调时不能无限挂起脚本线程。 + withTimeout(BIND_TIMEOUT_MILLIS) { serviceConnected.await() } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + // 从未回调时 onServiceConnected 的 finally 不会解绑,这里补上。 + currentConnection?.let { + runCatching { context.unbindService(it) } + currentConnection = null + } + throw IllegalStateException("CaptureForegroundService bind timeout", e) + } } override fun requestScreenCaptureLegacy(context: Context, orientation: Int): ScriptPromiseAdapter { @@ -127,7 +148,7 @@ class ScreenCaptureManager : ScreenCaptureRequester { if (e.message?.contains("non-current") == true && retryCount < maxRetry) { retryCount++ Log.w("SCREEN_LEGACY", "MediaProjection expired, retrying ($retryCount/$maxRetry)") - delay(100) + delay(100.milliseconds) doRequest() } else { Log.e("SCREEN_LEGACY", "Failed after $maxRetry retries", e) @@ -163,20 +184,20 @@ class ScreenCaptureManager : ScreenCaptureRequester { } } - override fun recycle() { - // 取消协程 - currentCoroutineScope?.cancel() - currentCoroutineScope = null + override fun recycle() { + // 取消协程 + currentCoroutineScope?.cancel() + currentCoroutineScope = null - // 清理连接 - currentConnection = null + // 清理连接 + currentConnection = null - // 释放截图器 - screenCapture?.release() - screenCapture = null + // 释放截图器 + screenCapture?.release() + screenCapture = null - // 停止并释放 MediaProjection - mediaProjection?.stop() - mediaProjection = null - } + // 停止并释放 MediaProjection + mediaProjection?.stop() + mediaProjection = null + } } \ No newline at end of file diff --git a/autojs/src/main/java/com/stardust/autojs/core/image/capture/ScreenCapturer.kt b/autojs/src/main/java/com/stardust/autojs/core/image/capture/ScreenCapturer.kt index f601144e1..193e855fa 100644 --- a/autojs/src/main/java/com/stardust/autojs/core/image/capture/ScreenCapturer.kt +++ b/autojs/src/main/java/com/stardust/autojs/core/image/capture/ScreenCapturer.kt @@ -108,6 +108,7 @@ class ScreenCapturer( private fun refreshVirtualDisplay(orientation: Int) = synchronized(this) { latestImage.set(null) + cachedImageBitmap.set(null) mImageReader.close() val screenHeight = ScreenMetrics.getOrientationAwareScreenHeight(orientation) val screenWidth = ScreenMetrics.getOrientationAwareScreenWidth(orientation) diff --git a/autojs/src/main/java/com/stardust/autojs/runtime/api/Images.kt b/autojs/src/main/java/com/stardust/autojs/runtime/api/Images.kt index a01c458af..b40f4bdf2 100644 --- a/autojs/src/main/java/com/stardust/autojs/runtime/api/Images.kt +++ b/autojs/src/main/java/com/stardust/autojs/runtime/api/Images.kt @@ -15,6 +15,8 @@ import com.stardust.autojs.annotation.ScriptVariable import com.stardust.autojs.core.image.ColorFinder import com.stardust.autojs.core.image.ImageWrapper import com.stardust.autojs.core.image.TemplateMatching +import com.stardust.autojs.core.image.TemplateMatching.MATCHING_METHOD_DEFAULT +import com.stardust.autojs.core.image.capture.ScreenCapturer import com.stardust.autojs.core.image.capture.ScreenCaptureRequester import com.stardust.autojs.core.opencv.Mat import com.stardust.autojs.core.opencv.OpenCVHelper @@ -36,6 +38,13 @@ import java.io.FileOutputStream import java.io.IOException import java.net.HttpURLConnection import java.net.URL +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.sqrt + +/** legacy 截图权限请求的默认超时:给系统授权与内部重试留足时间。 */ +private const val DEFAULT_CAPTURE_TIMEOUT_MS = 15_000L /** * Created by Stardust on 2017/5/20. @@ -65,44 +74,8 @@ class Images( } } - fun requestScreenCaptureLegacy(orientation: Int): ScriptPromiseAdapter { - val outerPromise = ScriptPromiseAdapter() - val weakImages = java.lang.ref.WeakReference(this) - - try { - val images = weakImages.get() ?: run { - outerPromise.resolve(false) - return outerPromise - } - - val innerPromise = images.mScreenCaptureRequester.requestScreenCaptureLegacy( - images.mContext, - orientation - ) - - innerPromise.onResolve(object : ScriptPromiseAdapter.Callback { - override fun call(arg: Any?) { - outerPromise.resolve(arg) - } - }) - - innerPromise.onReject(object : ScriptPromiseAdapter.Callback { - override fun call(arg: Any?) { - val img = weakImages.get() - img?.mScriptRuntime?.toast(arg as String?) - Log.e(Images::class.java.name, "请求截图权限失败: $arg") - outerPromise.resolve(false) - } - }) - } catch (e: Exception) { - val images = weakImages.get() - images?.mScriptRuntime?.toast(e.message) - Log.e(Images::class.java.name, "请求截图权限失败", e) - outerPromise.resolve(false) - } - - return outerPromise - } + // legacy 截图权限统一走文件末尾的同步版本(Boolean),不再提供 + // 同名 1 参 Promise 重载,避免两种返回语义与 Rhino 重载歧义。 fun stopScreenCapturer() { mScreenCaptureRequester.recycle() @@ -241,6 +214,11 @@ class Images( fun releaseScreenCapturer() { disposables.forEach { it.dispose() } disposables.clear() + try { + mScreenCaptureRequester.recycle() + } catch (e: Exception) { + Log.e(Images::class.java.name, "Error recycling screen capture", e) + } } @JvmOverloads @@ -261,7 +239,9 @@ class Images( threshold: Float, rect: Rect?, maxLevel: Int, - transparentMask: Boolean = false + transparentMask: Boolean = false, + useGrayscale: Boolean = false, + matchMethod: Int = MATCHING_METHOD_DEFAULT ): Point? { initOpenCvIfNeeded() if (image == null) throw NullPointerException("image = null") @@ -271,8 +251,8 @@ class Images( src = Mat(src, rect) } val point = TemplateMatching.fastTemplateMatching( - src, template.mat, TemplateMatching.MATCHING_METHOD_DEFAULT, - weakThreshold, threshold, maxLevel, transparentMask + src, template.mat, matchMethod, + weakThreshold, threshold, maxLevel, transparentMask, useGrayscale ) if (point != null) { if (rect != null) { @@ -297,18 +277,21 @@ class Images( rect: Rect?, maxLevel: Int, limit: Int, - transparentMask: Boolean = false + transparentMask: Boolean = false, + useGrayscale: Boolean = false, + matchMethod: Int = MATCHING_METHOD_DEFAULT ): List { - val templates: List = listOf(template) return matchMultiTemplates( image, - templates, + listOf(template), weakThreshold, threshold, rect, maxLevel, limit, - transparentMask + transparentMask, + useGrayscale, + matchMethod ) } @@ -321,7 +304,9 @@ class Images( rect: Rect?, maxLevel: Int, limit: Int, - transparentMask: Boolean = false + transparentMask: Boolean = false, + useGrayscale: Boolean = false, + matchMethod: Int = MATCHING_METHOD_DEFAULT ): List { initOpenCvIfNeeded() if (image == null) throw NullPointerException("image = null") @@ -332,8 +317,8 @@ class Images( } val result = TemplateMatching.fastMultiTemplateMatching( - src, templates.map { it?.mat }, Imgproc.TM_CCOEFF_NORMED, - weakThreshold, threshold, maxLevel, limit, transparentMask + src, templates.map { it?.mat }, matchMethod, + weakThreshold, threshold, maxLevel, limit, transparentMask, useGrayscale ) for (match in result) { val point = match.point @@ -341,8 +326,10 @@ class Images( point.x += rect.x.toDouble() point.y += rect.y.toDouble() } + match.width *= mScreenMetrics.scaleX(1) + match.height *= mScreenMetrics.scaleY(1) point.x = mScreenMetrics.scaleX(point.x.toInt()).toDouble() - point.y = mScreenMetrics.scaleX(point.y.toInt()).toDouble() + point.y = mScreenMetrics.scaleY(point.y.toInt()).toDouble() } if (src !== image.mat) { OpenCVHelper.release(src) @@ -369,7 +356,6 @@ class Images( mScriptRuntime.console.info("opencv initialized") } - fun pixel(image: ImageWrapper?, x: Int, y: Int): Int { if (image == null) { throw NullPointerException("image = null") @@ -446,4 +432,173 @@ class Images( return Bitmap.createBitmap(origin, 0, 0, width, height, matrix, false) } -} + // ========== 新增方法 ========== + + /** + * 获取图像的亮度值 + * @param image 图像 + * @param sampleCount 采样数量 + * @param region 采样区域 + * @return 亮度值 + */ + @JvmOverloads + fun getBrightness(image: ImageWrapper?, sampleCount: Int = 50, region: Rect? = null): Float { + if (image == null) { + throw NullPointerException("image = null") + } + val img = image.mat + val w = img.cols() + val h = img.rows() + var startX = 0 + var startY = 0 + var endX = w + var endY = h + + if (region != null) { + startX = region.x.coerceAtLeast(0) + startY = region.y.coerceAtLeast(0) + endX = (region.x + region.width).coerceAtMost(w) + endY = (region.y + region.height).coerceAtMost(h) + } + + val regionW = endX - startX + val regionH = endY - startY + if (regionW <= 0 || regionH <= 0) return 0f + + // sampleCount 为 0/负数时原本会除零得到 Infinity,这里收敛到合法区间 + val samples = sampleCount.coerceIn(1, regionW * regionH) + val step = maxOf(1, sqrt((regionW * regionH).toDouble() / samples).toInt()) + var total = 0.0 + var count = 0 + val channels = img.channels() + + // OpenCV Mat.get(row, col) = (y, x) + for (y in startY until endY step step) { + for (x in startX until endX step step) { + val pixel = img.get(y, x) + val brightness = when (channels) { + 1, 2 -> pixel[0] + 3, 4 -> (pixel[0] + pixel[1] + pixel[2]) / 3.0 + else -> pixel[0] + } + total += brightness + count++ + } + } + + return if (count > 0) (total / count).toFloat() else 0f + } + + /** + * 请求截图权限(legacy 通道,不跳转应用),同步等待结果。 + * + * 提供 0 / 1 / 2 参三个重载,统一返回 Boolean: + * - 无参:自动方向 + 默认超时 + * - 1 参:Boolean 当作横屏开关,Number 当作方向常量,其它值回退自动方向 + * - 2 参:(方向或横屏开关, 超时毫秒) + * + * 参数一律用 Any? 接收:JS 省略实参时 Rhino 传的是 + * org.mozilla.javascript.Undefined,基本类型无法匹配会报「找不到方法」。 + * 三个重载 arity 不同,Rhino 按实参个数选择,不会歧义;不要再引入返回 + * ScriptPromiseAdapter 的同名重载,那会重演仅返回类型不同的歧义问题。 + * + * 用 CountDownLatch 而不是 runBlocking:脚本 forceStop 会 interrupt 线程, + * latch.await 能响应中断;结果回调由 ScreenCaptureManager 在主线程触发, + * 不依赖脚本线程的事件循环,因此不会死锁。 + */ + fun requestScreenCaptureLegacy(): Boolean = + requestLegacyWithTimeout(ScreenCapturer.ORIENTATION_AUTO, DEFAULT_CAPTURE_TIMEOUT_MS) + + fun requestScreenCaptureLegacy(arg: Any?): Boolean = + requestLegacyWithTimeout(normalizeOrientation(arg), DEFAULT_CAPTURE_TIMEOUT_MS) + + fun requestScreenCaptureLegacy(orientation: Any?, timeout: Any?): Boolean = + requestLegacyWithTimeout(normalizeOrientation(orientation), normalizeTimeout(timeout)) + + private fun normalizeOrientation(value: Any?): Int = when (value) { + is Boolean -> if (value) ScreenCapturer.ORIENTATION_LANDSCAPE + else ScreenCapturer.ORIENTATION_PORTRAIT + is Number -> value.toInt() + else -> ScreenCapturer.ORIENTATION_AUTO + } + + private fun normalizeTimeout(value: Any?): Long = + (value as? Number)?.toLong()?.takeIf { it > 0 } ?: DEFAULT_CAPTURE_TIMEOUT_MS + + private fun requestLegacyWithTimeout(orientation: Int, timeoutMs: Long): Boolean { + val latch = CountDownLatch(1) + val result = AtomicBoolean(false) + val delivered = AtomicBoolean(false) + + requestLegacyCapture(orientation) { ok -> + if (delivered.compareAndSet(false, true)) { + result.set(ok) + latch.countDown() + } + } + + return try { + if (latch.await(timeoutMs, TimeUnit.MILLISECONDS)) { + result.get() + } else { + Log.w(Images::class.java.name, "请求截图权限超时: ${timeoutMs}ms") + false + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + Log.w(Images::class.java.name, "请求截图权限等待被中断") + false + } + } + + /** 内部实现:把 ScreenCaptureRequester 的 Promise 转成一次性回调。 */ + private fun requestLegacyCapture(orientation: Int, callback: (Boolean) -> Unit) { + try { + val innerPromise = mScreenCaptureRequester.requestScreenCaptureLegacy(mContext, orientation) + innerPromise.onResolve(object : ScriptPromiseAdapter.Callback { + override fun call(arg: Any?) { + callback((arg as? Boolean) ?: true) + } + }) + innerPromise.onReject(object : ScriptPromiseAdapter.Callback { + override fun call(arg: Any?) { + mScriptRuntime.toast(arg?.toString()) + Log.e(Images::class.java.name, "请求截图权限失败: $arg") + callback(false) + } + }) + } catch (e: Exception) { + mScriptRuntime.toast(e.message) + Log.e(Images::class.java.name, "请求截图权限失败", e) + callback(false) + } + } + + /** + * 回收所有追踪的图像 + */ + fun recycleAllImages() { + ImageWrapper.recycleAllTrackedImages() + } + + /** + * 获取存活图像数量 + */ + fun getAliveImageCount(): Int { + return ImageWrapper.getTrackedImageCount() + } + + /** + * 启用图像追踪 + */ + fun enableImageTracking() { + ImageWrapper.enableTracking() + } + + /** + * 禁用图像追踪 + */ + fun disableImageTracking() { + ImageWrapper.disableTracking() + } +} \ No newline at end of file diff --git a/autojs/src/main/js/v6-api/src/images/index.ts b/autojs/src/main/js/v6-api/src/images/index.ts index f8288cae1..1ac1be47b 100644 --- a/autojs/src/main/js/v6-api/src/images/index.ts +++ b/autojs/src/main/js/v6-api/src/images/index.ts @@ -16,6 +16,8 @@ interface FindImageOptions { level?: number weakThreshold?: number transparentMask?: boolean + useGrayscale?: boolean + matchMethod?: number } function images() { @@ -56,7 +58,12 @@ images.requestScreenCapture = function (landscape: boolean) { return javaImages.requestScreenCapture(orientation); } -images.requestScreenCaptureLegacy = function (landscape: boolean) { +images.requestScreenCaptureLegacy = function (landscape?: boolean | number, timeout?: number) { + // 只给一个数字时视为超时(landscape 是 boolean,传数字只能是 timeout)。 + if (typeof landscape === 'number' && timeout === undefined) { + timeout = landscape; + landscape = undefined; + } const ScreenCapturer = com.stardust.autojs.core.image.capture.ScreenCapturer; var orientation = ScreenCapturer.ORIENTATION_AUTO; if (landscape === true) { @@ -65,7 +72,15 @@ images.requestScreenCaptureLegacy = function (landscape: boolean) { if (landscape === false) { orientation = ScreenCapturer.ORIENTATION_PORTRAIT; } - return javaImages.requestScreenCaptureLegacy(orientation); + // 按实参个数分发到 Java 的 0 / 1 / 2 参重载,不把 undefined 交给 + // Rhino 做 Java 方法匹配(基本类型无法匹配 Undefined)。 + if (landscape === undefined && timeout === undefined) { + return javaImages.requestScreenCaptureLegacy(); + } + if (timeout === undefined) { + return javaImages.requestScreenCaptureLegacy(orientation); + } + return javaImages.requestScreenCaptureLegacy(orientation, timeout); }; images.save = function (img: Image, path: string, format?: ImageFormat, quality?: number) { @@ -327,11 +342,14 @@ images.findImage = function (img: Image, template: Image, options?: FindImageOpt maxLevel = options.level; } var weakThreshold = options.weakThreshold || 0.6; - const transparentMask = !!options.transparentMask + const transparentMask = !!options.transparentMask; + const useGrayscale = !!options.useGrayscale; + // 0 是合法的 TM_SQDIFF,不能用 || 回退默认值。 + const matchMethod = options.matchMethod != null ? options.matchMethod : 5; if (options.region) { - return javaImages.findImage(img, template, weakThreshold, threshold, buildRegion(options.region, img), maxLevel, transparentMask); + return javaImages.findImage(img, template, weakThreshold, threshold, buildRegion(options.region, img), maxLevel, transparentMask, useGrayscale, matchMethod); } else { - return javaImages.findImage(img, template, weakThreshold, threshold, null, maxLevel, transparentMask); + return javaImages.findImage(img, template, weakThreshold, threshold, null, maxLevel, transparentMask, useGrayscale, matchMethod); } } @@ -352,11 +370,13 @@ images.matchMultiTemplates = function (img: Image, templates: Image[], var max = options.max || 5; var weakThreshold = options.weakThreshold || 0.6; var result; - const transparentMask = !!options.transparentMask + const transparentMask = !!options.transparentMask; + const useGrayscale = !!options.useGrayscale; + const matchMethod = options.matchMethod != null ? options.matchMethod : 5; if (options.region) { - result = javaImages.matchMultiTemplates(img, templates, weakThreshold, threshold, buildRegion(options.region, img), maxLevel, max, transparentMask); + result = javaImages.matchMultiTemplates(img, templates, weakThreshold, threshold, buildRegion(options.region, img), maxLevel, max, transparentMask, useGrayscale, matchMethod); } else { - result = javaImages.matchMultiTemplates(img, templates, weakThreshold, threshold, null, maxLevel, max, transparentMask); + result = javaImages.matchMultiTemplates(img, templates, weakThreshold, threshold, null, maxLevel, max, transparentMask, useGrayscale, matchMethod); } return new MatchingResult(result); } @@ -409,6 +429,37 @@ images.matToImage = function (img: Image) { return ImageWrapper.ofMat(img); } +images.getBrightness = function (img: Image, sampleCount?: number, region?: [number, number, number?, number?]) { + initIfNeeded(); + sampleCount = sampleCount || 50; + + var rect = null; + if (region && region.length >= 2) { + var x = region[0] || 0; + var y = region[1] || 0; + var w = region[2] !== undefined ? region[2] : img.width - x; + var h = region[3] !== undefined ? region[3] : img.height - y; + rect = new org.opencv.core.Rect(x, y, w, h); + } + + return javaImages.getBrightness(img, sampleCount, rect); +}; + +images.enableImageTracking = function () { + return javaImages.enableImageTracking(); +}; + +images.disableImageTracking = function () { + return javaImages.disableImageTracking(); +}; + +images.recycleAllImages = function () { + return javaImages.recycleAllImages(); +}; + +images.getAliveImageCount = function () { + return javaImages.getAliveImageCount(); +}; function toPointArray(points: unknown[]) { var arr = []; @@ -463,5 +514,4 @@ declare global { var findColorEquals: typeof images['findColorEquals'] } -export default images; - +export default images; \ No newline at end of file From e2b98014e3f4759f2cd1dab481cb7e5056510605 Mon Sep 17 00:00:00 2001 From: PM Date: Sat, 14 Nov 2026 18:05:59 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs(image):=20=E6=81=A2=E5=A4=8D=20Templat?= =?UTF-8?q?eMatching=20=E4=B8=AD=E8=AF=AF=E5=88=A0=E7=9A=84=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../autojs/core/image/TemplateMatching.java | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/autojs/src/main/java/com/stardust/autojs/core/image/TemplateMatching.java b/autojs/src/main/java/com/stardust/autojs/core/image/TemplateMatching.java index 844bd1fcf..99429e1bb 100644 --- a/autojs/src/main/java/com/stardust/autojs/core/image/TemplateMatching.java +++ b/autojs/src/main/java/com/stardust/autojs/core/image/TemplateMatching.java @@ -7,6 +7,7 @@ import org.opencv.core.Core; import org.opencv.core.CvType; + import org.opencv.core.Mat; import org.opencv.core.Point; import org.opencv.core.Rect; @@ -19,6 +20,11 @@ import java.util.Iterator; import java.util.List; + +/** + * Created by Stardust on 2017/11/25. + */ + public class TemplateMatching { public static class Match { @@ -63,6 +69,20 @@ public static Point fastTemplateMatching(Mat img, Mat template, int matchMethod, return result.get(0).point; } + /** + * 采用图像金字塔算法快速找图 + * + * @param img 图片 + * @param template 模板图片 + * @param matchMethod 匹配算法 + * @param weakThreshold 弱阈值。该值用于在每一轮模板匹配中检验是否继续匹配。如果相似度小于该值,则不再继续匹配。 + * @param strictThreshold 强阈值。该值用于检验最终匹配结果,以及在每一轮匹配中如果相似度大于该值则直接返回匹配结果。 + * @param maxLevel 图像金字塔的层数 + * @param limit 每层最多返回的候选点数量(控制计算量) + * @param transparentMask 是否启用透明蒙版 + * @param useGrayscale 是否先转为灰度图再匹配 + * @return + */ public static List fastTemplateMatching(Mat img, Mat template, int matchMethod, float weakThreshold, float strictThreshold, int maxLevel, int limit, Boolean transparentMask, boolean useGrayscale) { @@ -83,6 +103,7 @@ public static List fastTemplateMatching(Mat img, Mat template, int matchM * @param maxLevel 金字塔层数(设为 MAX_LEVEL_AUTO 自动计算) * @param limit 每层最多返回的候选点数量(控制计算量) * @param transparentMask 是否启用透明蒙版 + * @param useGrayscale 是否先转为灰度图再匹配 * @return 按模板顺序返回匹配结果列表;若某模板无匹配,则对应列表为空 */ public static List fastMultiTemplateMatching(Mat img, List templates, int matchMethod, @@ -126,6 +147,7 @@ public static List fastMultiTemplateMatching(Mat img, List templates globalMaxLevel = maxLevel; } + // 构建主图金字塔(0 ~ globalMaxLevel) List imgPyramid = new ArrayList<>(); for (int level = 0; level <= globalMaxLevel; level++) { Mat src = getPyramidDownAtLevel(workingImg, level); @@ -180,9 +202,11 @@ private static List matchTemplateWithPyramid(List imgPyramid, Mat te float weakThreshold, float strictThreshold, int limit, Boolean transparentMask, List resourcesToRelease) { + // 计算模板自身的最大可行层数 int tMaxLevel = selectPyramidLevel(imgPyramid.get(0), template); tMaxLevel = Math.min(tMaxLevel, globalMaxLevel); + // 构建模板金字塔 List templatePyramid = new ArrayList<>(); for (int level = 0; level <= tMaxLevel; level++) { Mat t = getPyramidDownAtLevel(template, level); @@ -211,14 +235,17 @@ private static List matchTemplateWithPyramid(List imgPyramid, Mat te int actualHeight = currentTemplate.height() << level; if (previousMatchResult.isEmpty()) { + // 决定是否继续匹配(根据弱阈值跳过部分层) if (!isFirstMatching && !shouldContinueMatching(level, tMaxLevel)) { break; } + // 全图匹配 Mat matchResult = matchTemplate(src, currentTemplate, matchMethod, currentMask); resourcesToRelease.add(matchResult); getBestMatched(matchResult, currentTemplate, matchMethod, weakThreshold, currentMatchResult, limit, null, actualWidth, actualHeight); } else { + // 在上一层的匹配点附近进行局部匹配 for (Match match : previousMatchResult) { Rect r = getROI(match.point, src, currentTemplate); Mat m = new Mat(src, r); @@ -230,18 +257,19 @@ private static List matchTemplateWithPyramid(List imgPyramid, Mat te } } + // 强阈值处理:达到 strictThreshold 的结果直接加入最终列表,并停止该模板后续匹配 if (!currentMatchResult.isEmpty()) { Iterator iterator = currentMatchResult.iterator(); while (iterator.hasNext()) { Match match = iterator.next(); if (match.similarity >= strictThreshold) { - pyrUp(match.point, level); + pyrUp(match.point, level); // 坐标还原至原图尺寸 finalMatchResult.add(match); iterator.remove(); } } if (currentMatchResult.isEmpty()) { - break; + break; // 所有候选都满足强阈值,提前终止 } } @@ -312,10 +340,12 @@ private static Rect getROI(Point p, Mat src, Mat currentTemplate) { private static int selectPyramidLevel(Mat img, Mat template) { int minDim = Nath.min(img.rows(), img.cols(), template.rows(), template.cols()); + //这里选取16为图像缩小后的最小宽高,从而用log(2, minDim / 16)得到最多可以经过几次缩小。 int maxLevel = (int) (Math.log(minDim / 16) / Math.log(2)); if (maxLevel < 0) { return 0; } + //上限为6 return Math.min(6, maxLevel); } @@ -389,7 +419,7 @@ private static Mat replaceNoFinite(Mat mat) { if (mat.type() == CvType.CV_32FC1) { Core.patchNaNs(mat, 0.0); Mat infMask = new Mat(); - Core.compare(mat, new Scalar(255), infMask, Core.CMP_GT); + Core.compare(mat, new Scalar(255), infMask, Core.CMP_GT); // mat > threshold -> 255 Mat zeros = Mat.zeros(mat.size(), mat.type()); zeros.copyTo(mat, infMask); infMask.release(); @@ -397,4 +427,5 @@ private static Mat replaceNoFinite(Mat mat) { } return mat; } -} \ No newline at end of file + +} From f5b54de83337c77eca84c2d69e395e0af92f8ebb Mon Sep 17 00:00:00 2001 From: PM Date: Sat, 14 Nov 2026 18:25:26 +0800 Subject: [PATCH 3/3] =?UTF-8?q?perf(image):=20=E7=94=A8=20ReferenceQueue?= =?UTF-8?q?=20=E6=B8=85=E7=90=86=E5=A4=B1=E6=95=88=E5=BC=95=E7=94=A8?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=E8=BF=BD=E8=B8=AA=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E6=80=81=E7=9A=84=E9=94=81=E5=BC=80=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../autojs/core/image/ImageWrapper.java | 89 +++++++++---------- 1 file changed, 41 insertions(+), 48 deletions(-) diff --git a/autojs/src/main/java/com/stardust/autojs/core/image/ImageWrapper.java b/autojs/src/main/java/com/stardust/autojs/core/image/ImageWrapper.java index 48e28c2cb..941e6f04b 100644 --- a/autojs/src/main/java/com/stardust/autojs/core/image/ImageWrapper.java +++ b/autojs/src/main/java/com/stardust/autojs/core/image/ImageWrapper.java @@ -16,20 +16,24 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; +import java.lang.ref.Reference; +import java.lang.ref.ReferenceQueue; import java.lang.ref.WeakReference; import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.List; -import java.util.function.Predicate; +import java.util.HashSet; +import java.util.Set; /** * Created by Stardust on 2017/11/25. */ public class ImageWrapper { - private static final List> sAllImages = new ArrayList<>(); + private static final String TAG = "ImageWrapper"; + private static final int MAX_TRACKED_IMAGES = 10000; + private static final Set> sAllImages = new HashSet<>(); + private static final ReferenceQueue sStaleImages = new ReferenceQueue<>(); private static final Object sLock = new Object(); - private static boolean sTrackingEnabled = false; + private static volatile boolean sTrackingEnabled = false; private Mat mMat; private int mWidth; @@ -38,15 +42,11 @@ public class ImageWrapper { private boolean mRecycled; public static void enableTracking() { - Log.d("ImageWrapper", "enableTracking called, sAllImages size before clear: " + sAllImages.size()); synchronized (sLock) { sTrackingEnabled = true; - Log.d("ImageWrapper", "sTrackingEnabled set to true"); - int size = sAllImages.size(); + drainStaleImages(); sAllImages.clear(); - Log.d("ImageWrapper", "sAllImages cleared, size before: " + size); } - Log.d("ImageWrapper", "enableTracking done"); } public static void disableTracking() { @@ -55,20 +55,20 @@ public static void disableTracking() { } } + /** + * 回收所有被追踪且仍存活的图像。仅由脚本显式调用(images.recycleAllImages()); + * 追踪数量达到上限时不会自动调用,避免回收脚本仍在使用的图像。 + */ public static void recycleAllTrackedImages() { synchronized (sLock) { - sAllImages.removeIf(new Predicate() { // from class: com.stardust.autojs.core.image.ImageWrapper$$ExternalSyntheticLambda0 - @Override // java.util.function.Predicate - public final boolean test(Object obj) { - return ImageWrapper.lambda$recycleAllTrackedImages$0((WeakReference) obj); - } - }); + drainStaleImages(); for (WeakReference ref : sAllImages) { ImageWrapper img = ref.get(); if (img != null && img.isAlive()) { try { img.recycle(); } catch (Exception e) { + Log.w(TAG, "Failed to recycle image", e); } } } @@ -76,26 +76,22 @@ public final boolean test(Object obj) { } } - static /* synthetic */ boolean lambda$recycleAllTrackedImages$0(WeakReference ref) { - return ref.get() == null; - } - public static int getTrackedImageCount() { - int size; synchronized (sLock) { - sAllImages.removeIf(new Predicate() { // from class: com.stardust.autojs.core.image.ImageWrapper$$ExternalSyntheticLambda1 - @Override // java.util.function.Predicate - public final boolean test(Object obj) { - return ImageWrapper.lambda$getTrackedImageCount$1((WeakReference) obj); - } - }); - size = sAllImages.size(); + drainStaleImages(); + return sAllImages.size(); } - return size; } - static /* synthetic */ boolean lambda$getTrackedImageCount$1(WeakReference ref) { - return ref.get() == null; + /** + * 借助 ReferenceQueue 移除已被 GC 的弱引用,均摊 O(1), + * 不再需要每次创建图像时遍历整个集合做 removeIf。 + */ + private static void drainStaleImages() { + Reference ref; + while ((ref = sStaleImages.poll()) != null) { + sAllImages.remove(ref); + } } protected ImageWrapper(Mat mat) { @@ -125,28 +121,25 @@ public ImageWrapper(int width, int height) { } private void trackThis() { + // 追踪默认关闭;锁外快速返回,避免每张图像都进入全局锁。 + if (!sTrackingEnabled) { + return; + } synchronized (sLock) { - if (sTrackingEnabled) { - sAllImages.removeIf(new Predicate() { // from class: com.stardust.autojs.core.image.ImageWrapper$$ExternalSyntheticLambda2 - @Override // java.util.function.Predicate - public final boolean test(Object obj) { - return ImageWrapper.lambda$trackThis$2((WeakReference) obj); - } - }); - // 先回收再登记:原先先 add 再判阈值,触发全量回收时会把 - // 刚创建、即将返回给脚本的这张图也回收掉。 - if (sAllImages.size() >= 10000) { - recycleAllTrackedImages(); - } - sAllImages.add(new WeakReference<>(this)); + // 双重检查:等待锁期间可能已被 disableTracking()。 + if (!sTrackingEnabled) { + return; } + drainStaleImages(); + if (sAllImages.size() == MAX_TRACKED_IMAGES) { + Log.w(TAG, "tracked images reached " + MAX_TRACKED_IMAGES + + "; images may not be released, call images.recycleAllImages()"); + } + // 达到上限只告警、继续登记,绝不在内部回收脚本仍在使用的图像。 + sAllImages.add(new WeakReference<>(this, sStaleImages)); } } - static /* synthetic */ boolean lambda$trackThis$2(WeakReference ref) { - return ref.get() == null; - } - public boolean isAlive() { return (mRecycled || (mBitmap == null && mMat == null)) ? false : true; }