From 501c01a6328a33bdf11fbafec3eed33f4f3b61ce Mon Sep 17 00:00:00 2001 From: SuperMonster003 Date: Wed, 21 May 2025 00:41:15 +0800 Subject: [PATCH] =?UTF-8?q?6.6.3=20-=20Alpha5=20-=20=E6=96=B0=E5=A2=9E=20i?= =?UTF-8?q?mages.compressToBytes/downsample;=20=E4=BF=AE=E5=A4=8D=20images?= =?UTF-8?q?.compress;=20ImageWrapper#saveTo=20=E6=94=AF=E6=8C=81=E7=9B=B8?= =?UTF-8?q?=E5=AF=B9=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changelog/lang_zh-Hans.json | 9 +- .../accessibility/SimpleActionAutomator.kt | 2 +- .../autojs/core/graphics/ScriptCanvas.java | 22 +- .../autojs/autojs/core/image/CapturedImage.kt | 4 +- .../autojs/autojs/core/image/ImageWrapper.kt | 62 ++--- .../autojs/core/ui/widget/JsCanvasView.kt | 2 +- .../autojs/extension/StringExtensions.kt | 9 + .../org/autojs/autojs/runtime/api/Images.java | 138 +++++++---- .../autojs/runtime/api/PngQuantBridge.java | 8 +- .../api/ScreenCaptureAvailableHandler.java | 7 +- .../runtime/api/augment/images/Images.kt | 114 +++++++-- .../org/autojs/autojs/util/BitmapUtils.kt | 166 ++++++++++++++ .../java/org/autojs/autojs/util/ImageUtils.kt | 24 -- libs/imagequant/PngQuantBridge.cpp | 216 +++++++++++------- libs/imagequant/build.gradle | 1 + settings.gradle.kts | 4 +- version.properties | 6 +- 17 files changed, 563 insertions(+), 231 deletions(-) diff --git a/.changelog/lang_zh-Hans.json b/.changelog/lang_zh-Hans.json index 2e2ff768..557e762b 100644 --- a/.changelog/lang_zh-Hans.json +++ b/.changelog/lang_zh-Hans.json @@ -1,13 +1,15 @@ { "$data": { "v6.6.3": { - "released_date": "2025/05/14", + "released_date": "2025/05/21", "feature": [ "版本历史功能, 可查看发行版本历史更新记录 (多语言) 与统计数据", "timers.keepAlive 方法 (已全局化), 用于保持脚本活跃状态", "engines.on('start/stop/error', callback) 等事件监听方法, 用于监听脚本引擎全局事件", "images.detectMultiColors 方法, 用于多点颜色校验 _[`issue #374`](http://issues.autojs6.com/374)_", - "images.matchFeatures/detectAndComputeFeatures 方法, 支持全分辨率找图 (Ref to [Auto.js Pro](https://g.pro.autojs.org/)) _[`issue #366`](http://issues.autojs6.com/366)_" + "images.matchFeatures/detectAndComputeFeatures 方法, 支持全分辨率找图 (Ref to [Auto.js Pro](https://g.pro.autojs.org/)) _[`issue #366`](http://issues.autojs6.com/366)_", + "images.compressToBytes 方法, 用于压缩图像并生成字节数组", + "images.downsample 方法, 用于像素降采样并生成新的 ImageWrapper" ], "fix": [ "主页文档标签显示在线文档时部分内容被系统导航栏遮挡的问题", @@ -21,6 +23,8 @@ "使用 JavaAdapter 时导致 ClassLoader 调用栈溢出的问题 _[`issue #376`](http://issues.autojs6.com/376)_", "console.setContentTextColor 方法导致日志字体颜色丢失默认值的问题 _[`issue #346`](http://issues.autojs6.com/346)_", "console.setContentBackgroundColor 方法无法接受颜色名称参数的问题 _[`issue #384`](http://issues.autojs6.com/384)_", + "images.compress 方法实现原理由像素降采样修正为编码质量变化", + "images.resize 方法无法正常使用的问题", "README.md 中部分语言日期格式不正确的问题", "Gradle 构建脚本可能因获取到无效库档案文件长度而导致构建失败的问题 _[`issue #389`](http://issues.autojs6.com/389)_" ], @@ -34,6 +38,7 @@ "文件管理器浮动按钮展开后点击其他区域可自动隐藏", "打包单文件时自动读取并勾选已安装应用的声明权限 _[`issue #362`](http://issues.autojs6.com/362)_", "意图相关操作 (编辑/查看/安装/发送/播放等) 增加操作异常提示", + "ImageWrapper#saveTo 方法的路径参数支持相对路径", "images.save 方法使用 quality 参数时支持 png 格式的文件体积压缩 _[`issue #367`](http://issues.autojs6.com/367)_", "已忽略更新记录及客户端模式连接地址记录支持清空操作", "版本更新信息支持多语言显示 (与当前显示语言同步)", diff --git a/app/src/main/java/org/autojs/autojs/core/accessibility/SimpleActionAutomator.kt b/app/src/main/java/org/autojs/autojs/core/accessibility/SimpleActionAutomator.kt index d2026247..393fb424 100644 --- a/app/src/main/java/org/autojs/autojs/core/accessibility/SimpleActionAutomator.kt +++ b/app/src/main/java/org/autojs/autojs/core/accessibility/SimpleActionAutomator.kt @@ -242,7 +242,7 @@ class SimpleActionAutomator(private val accessibilityBridge: AccessibilityBridge val bitmap = hardwareBuffer.copy(Bitmap.Config.ARGB_8888, true) hardwareBuffer.recycle() - promiseAdapter.resolve(ImageWrapper.ofBitmap(bitmap)) + promiseAdapter.resolve(ImageWrapper.ofBitmap(scriptRuntime, bitmap)) } mPromiseAdapter = null } diff --git a/app/src/main/java/org/autojs/autojs/core/graphics/ScriptCanvas.java b/app/src/main/java/org/autojs/autojs/core/graphics/ScriptCanvas.java index 49a75dd2..9a0eae36 100644 --- a/app/src/main/java/org/autojs/autojs/core/graphics/ScriptCanvas.java +++ b/app/src/main/java/org/autojs/autojs/core/graphics/ScriptCanvas.java @@ -11,11 +11,10 @@ import android.graphics.PorterDuff; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.Region; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; - import org.autojs.autojs.core.image.ImageWrapper; +import org.autojs.autojs.runtime.ScriptRuntime; /** * Created by Stardust on Mar 22, 2018. @@ -23,24 +22,27 @@ import org.autojs.autojs.core.image.ImageWrapper; @SuppressWarnings("unused") public class ScriptCanvas { + private final ScriptRuntime mScriptRuntime; + private Canvas mCanvas; private Bitmap mBitmap; - public ScriptCanvas(int width, int height) { - this(Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)); + public ScriptCanvas(ScriptRuntime scriptRuntime, int width, int height) { + this(scriptRuntime, Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)); } - public ScriptCanvas(@NonNull Bitmap bitmap) { + public ScriptCanvas(ScriptRuntime scriptRuntime, @NonNull Bitmap bitmap) { + mScriptRuntime = scriptRuntime; mCanvas = new Canvas(bitmap); mBitmap = bitmap; } - public ScriptCanvas(@NonNull ImageWrapper image) { - this(image.getBitmap().copy(image.getBitmap().getConfig(), true)); + public ScriptCanvas(ScriptRuntime scriptRuntime, @NonNull ImageWrapper image) { + this(scriptRuntime, image.getBitmap().copy(image.getBitmap().getConfig(), true)); } - public ScriptCanvas() { - /* Empty body. */ + public ScriptCanvas(ScriptRuntime scriptRuntime) { + mScriptRuntime = scriptRuntime; } public Canvas getAndroidCanvas() { @@ -52,7 +54,7 @@ public class ScriptCanvas { } public ImageWrapper toImage() { - return ImageWrapper.ofBitmap(mBitmap.copy(mBitmap.getConfig(), true)); + return ImageWrapper.ofBitmap(mScriptRuntime, mBitmap.copy(mBitmap.getConfig(), true)); } public boolean isHardwareAccelerated() { diff --git a/app/src/main/java/org/autojs/autojs/core/image/CapturedImage.kt b/app/src/main/java/org/autojs/autojs/core/image/CapturedImage.kt index 4406722a..bfba107d 100644 --- a/app/src/main/java/org/autojs/autojs/core/image/CapturedImage.kt +++ b/app/src/main/java/org/autojs/autojs/core/image/CapturedImage.kt @@ -1,12 +1,14 @@ package org.autojs.autojs.core.image import android.media.Image +import org.autojs.autojs.runtime.ScriptRuntime /** * Created by SuperMonster003 on Dec 15, 2023. + * Modified by SuperMonster003 as of May 20, 2025. */ // @Reference to Auto.js Pro 9.3.11 by SuperMonster003 on Dec 15, 2023. -class CapturedImage(image: Image) : ImageWrapper(image) { +class CapturedImage(scriptRuntime: ScriptRuntime, image: Image) : ImageWrapper(scriptRuntime, image) { override fun recycle() { /* Doing nothing to suppress default recycle method. */ diff --git a/app/src/main/java/org/autojs/autojs/core/image/ImageWrapper.kt b/app/src/main/java/org/autojs/autojs/core/image/ImageWrapper.kt index c5c5b1cc..86342825 100644 --- a/app/src/main/java/org/autojs/autojs/core/image/ImageWrapper.kt +++ b/app/src/main/java/org/autojs/autojs/core/image/ImageWrapper.kt @@ -4,12 +4,15 @@ import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat import android.graphics.Color import android.media.Image +import androidx.core.graphics.createBitmap +import androidx.core.graphics.get import org.autojs.autojs.annotation.ScriptInterface import org.autojs.autojs.core.opencv.Mat import org.autojs.autojs.core.opencv.OpenCVHelper import org.autojs.autojs.core.ref.MonitorResource import org.autojs.autojs.core.ref.NativeObjectReference import org.autojs.autojs.pio.UncheckedIOException +import org.autojs.autojs.runtime.ScriptRuntime import org.autojs.autojs.runtime.api.Images import org.autojs.autojs.util.StringUtils.str import org.autojs.autojs6.R @@ -31,6 +34,8 @@ import java.util.concurrent.atomic.AtomicLong // @Reference to Auto.js Pro 9.3.11 by SuperMonster003 on Dec 20, 2023. open class ImageWrapper : Recyclable, MonitorResource { + private var mScriptRuntime: ScriptRuntime + private var mMat: Mat? = null private var mBgrMat: Mat? = null private var mBitmap: Bitmap? = null @@ -61,7 +66,7 @@ open class ImageWrapper : Recyclable, MonitorResource { ensureNotRecycled() if (mBitmap == null) { if (mMat != null) { - mBitmap = Bitmap.createBitmap(mMat!!.width(), mMat!!.height(), Bitmap.Config.ARGB_8888) + mBitmap = createBitmap(mMat!!.width(), mMat!!.height()) Utils.matToBitmap(mMat, mBitmap) } else { mBitmap = mediaImage?.let { toBitmap(it) } @@ -100,23 +105,26 @@ open class ImageWrapper : Recyclable, MonitorResource { } get() = mPlane ?: mediaImage?.planes?.get(0) - constructor(width: Int, height: Int) : this(Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)) + constructor(scriptRuntime: ScriptRuntime, width: Int, height: Int) : this(scriptRuntime, createBitmap(width, height)) - constructor(bitmap: Bitmap) { + constructor(scriptRuntime: ScriptRuntime, bitmap: Bitmap) { + mScriptRuntime = scriptRuntime mId = mNextId.incrementAndGet() mBitmap = bitmap.also { addToList(it) } mWidth = bitmap.width mHeight = bitmap.height } - constructor(mat: Mat) { + constructor(scriptRuntime: ScriptRuntime, mat: Mat) { + mScriptRuntime = scriptRuntime mId = mNextId.incrementAndGet() mMat = mat.also { addToList(it) } mWidth = mat.cols() mHeight = mat.rows() } - constructor(mat: org.opencv.core.Mat) { + constructor(scriptRuntime: ScriptRuntime, mat: org.opencv.core.Mat) { + mScriptRuntime = scriptRuntime mId = mNextId.incrementAndGet() mMat = when (mat.nativeObj != 0L) { true -> Mat(mat.nativeObj) @@ -126,7 +134,8 @@ open class ImageWrapper : Recyclable, MonitorResource { mHeight = mat.rows() } - constructor(bitmap: Bitmap, mat: Mat?) { + constructor(scriptRuntime: ScriptRuntime, bitmap: Bitmap, mat: Mat?) { + mScriptRuntime = scriptRuntime mId = mNextId.incrementAndGet() mMat = mat?.also { addToList(it) } mBitmap = bitmap.also { addToList(it) } @@ -134,7 +143,8 @@ open class ImageWrapper : Recyclable, MonitorResource { mHeight = bitmap.height } - constructor(mediaImage: Image) { + constructor(scriptRuntime: ScriptRuntime, mediaImage: Image) { + mScriptRuntime = scriptRuntime mId = mNextId.incrementAndGet() this.mediaImage = mediaImage.also { addToList(it) } mWidth = mediaImage.width @@ -149,27 +159,23 @@ open class ImageWrapper : Recyclable, MonitorResource { imageList.add(WeakReference(image)) } - fun saveTo(path: String?): Boolean { + fun saveTo(path: String): Boolean { ensureNotRecycled() - path ?: return false + val fullPath = mScriptRuntime.files.nonNullPath(path) if (mBitmap == null) { if (mMat != null) { - return Imgcodecs.imwrite(path, mMat) + return Imgcodecs.imwrite(fullPath, mMat) } bitmap /* Getter, for initializing `mBitmap`. */ } - return try { - true.also { saveWithBitmap(path) } - } catch (_: Exception) { - false - } + return runCatching { saveWithBitmap(fullPath) }.isSuccess } - private fun saveWithBitmap(path: String?) { + private fun saveWithBitmap(fullPath: String?) { try { - path ?: throw Exception("Argument \"path\" cannot be null") + fullPath ?: throw Exception("Argument \"path\" cannot be null") mBitmap ?: throw Exception("Member \"bitmap\" cannot be null") - mBitmap!!.compress(CompressFormat.PNG, 100, FileOutputStream(path)) + mBitmap!!.compress(CompressFormat.PNG, 100, FileOutputStream(fullPath)) } catch (e: FileNotFoundException) { throw UncheckedIOException(e) } @@ -181,7 +187,7 @@ open class ImageWrapper : Recyclable, MonitorResource { throw ArrayIndexOutOfBoundsException("Point ($x, $y) out of bounds of $this") } mBitmap?.let { oBitmap -> - return oBitmap.getPixel(x, y) + return oBitmap[x, y] } mMat?.let { oMat -> @@ -282,8 +288,8 @@ open class ImageWrapper : Recyclable, MonitorResource { fun clone(): ImageWrapper { ensureNotRecycled() return when (val bitmap = mBitmap) { - null -> ofMat(mat.clone()) - else -> ofBitmap(bitmap.copy(bitmap.config ?: Bitmap.Config.ARGB_8888, true)) + null -> ofMat(mScriptRuntime, mat.clone()) + else -> ofBitmap(mScriptRuntime, bitmap.copy(bitmap.config ?: Bitmap.Config.ARGB_8888, true)) } } @@ -312,16 +318,16 @@ open class ImageWrapper : Recyclable, MonitorResource { } @JvmStatic - fun ofImage(image: Image) = ImageWrapper(image) + fun ofImage(scriptRuntime: ScriptRuntime, image: Image) = ImageWrapper(scriptRuntime, image) @JvmStatic - fun ofMat(mat: Mat) = ImageWrapper(mat) + fun ofMat(scriptRuntime: ScriptRuntime, mat: Mat) = ImageWrapper(scriptRuntime, mat) @JvmStatic - fun ofMat(mat: org.opencv.core.Mat) = ImageWrapper(mat) + fun ofMat(scriptRuntime: ScriptRuntime, mat: org.opencv.core.Mat) = ImageWrapper(scriptRuntime, mat) @JvmStatic - fun ofBitmap(bitmap: Bitmap) = ImageWrapper(bitmap) + fun ofBitmap(scriptRuntime: ScriptRuntime, bitmap: Bitmap) = ImageWrapper(scriptRuntime, bitmap) @ScriptInterface fun toBitmap(image: Image): Bitmap { @@ -329,11 +335,7 @@ open class ImageWrapper : Recyclable, MonitorResource { val buffer = plane.buffer.apply { position(0) } val pixelStride = plane.pixelStride val rowPadding = plane.rowStride - pixelStride * image.width - val bitmap = Bitmap.createBitmap( - image.width + rowPadding / pixelStride, - image.height, - Bitmap.Config.ARGB_8888, - ).apply { copyPixelsFromBuffer(buffer) } + val bitmap = createBitmap(image.width + rowPadding / pixelStride, image.height).apply { copyPixelsFromBuffer(buffer) } return when (rowPadding == 0) { true -> bitmap else -> Bitmap.createBitmap(bitmap, 0, 0, image.width, image.height) diff --git a/app/src/main/java/org/autojs/autojs/core/ui/widget/JsCanvasView.kt b/app/src/main/java/org/autojs/autojs/core/ui/widget/JsCanvasView.kt index 819e02bf..4dcc7288 100644 --- a/app/src/main/java/org/autojs/autojs/core/ui/widget/JsCanvasView.kt +++ b/app/src/main/java/org/autojs/autojs/core/ui/widget/JsCanvasView.kt @@ -62,7 +62,7 @@ class JsCanvasView : TextureView, TextureView.SurfaceTextureListener { execute { var canvas: Canvas? = null var time = SystemClock.uptimeMillis() - val scriptCanvas = ScriptCanvas() + val scriptCanvas = ScriptCanvas(mScriptRuntime) try { while (mDrawing) { canvas = lockCanvas() diff --git a/app/src/main/java/org/autojs/autojs/extension/StringExtensions.kt b/app/src/main/java/org/autojs/autojs/extension/StringExtensions.kt index aee8c628..bb5a08de 100644 --- a/app/src/main/java/org/autojs/autojs/extension/StringExtensions.kt +++ b/app/src/main/java/org/autojs/autojs/extension/StringExtensions.kt @@ -1,5 +1,6 @@ package org.autojs.autojs.extension +import androidx.core.net.toUri import java.text.Normalizer object StringExtensions { @@ -59,4 +60,12 @@ object StringExtensions { else -> false } + @JvmStatic + fun String.isUri(): Boolean { + if (isBlank()) return false + val uri = runCatching { this.toUri() }.getOrNull() ?: return false + uri.scheme?.lowercase() ?: return false + return true + } + } diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/Images.java b/app/src/main/java/org/autojs/autojs/runtime/api/Images.java index fb4a580c..210e0e49 100644 --- a/app/src/main/java/org/autojs/autojs/runtime/api/Images.java +++ b/app/src/main/java/org/autojs/autojs/runtime/api/Images.java @@ -62,9 +62,37 @@ import static org.autojs.autojs.util.RhinoUtils.isMainThread; import static org.autojs.autojs.util.StringUtils.str; /** + * Quick Reference – Image Size / Quality Helpers
+ * zh-CN: 快速参考 – 图像尺寸 / 质量相关辅助方法 + *
+ * scale
+ * Multiply both width and height of an already-decoded Bitmap by + * the given scale factor(s).
+ * zh-CN: 在 已解码 的 Bitmap 上, 按照给定比例同时缩放宽高. + *

+ * resize
+ * Force an already-decoded Bitmap to the specified width/height. + * Aspect ratio may be kept or ignored depending on the overload.
+ * zh-CN: 将 已解码 Bitmap 调整为指定宽高; 可选择保持或忽略纵横比. + *

+ * downsample
+ * Shrink before decoding by setting inSampleSize; pixels are + * skipped while reading, dramatically reducing memory.
+ * zh-CN: 解码前 通过设置 inSampleSize 跳读像素, 显著降低解码分辨率与内存. + *

+ * compress
+ * Re-encode an existing Bitmap (or byte stream) into JPEG/PNG/WebP + * with the given format & quality to reduce disk footprint.
+ * zh-CN: 使用指定格式与质量重新编码 Bitmap (或字节流), 以减小 磁盘 体积. + *

+ * save
+ * Convenience wrapper that internally invokes compress (with + * default or user-supplied options) and then writes to storage.
+ * zh-CN: 便捷封装, 内部调用 compress (默认或自定义参数) 后写入存储. + *


* Created by Stardust on May 20, 2017. + * Modified by SuperMonster003 as of Dec 1, 2021. */ -@SuppressWarnings("unused") public class Images { private static final String TAG = Images.class.getSimpleName(); @@ -130,7 +158,7 @@ public class Images { return pixel; } - public static ImageWrapper concat(ImageWrapper imgA, ImageWrapper imgB, int direction) { + public static ImageWrapper concat(ScriptRuntime scriptRuntime, ImageWrapper imgA, ImageWrapper imgB, int direction) { if (!Arrays.asList(Gravity.START, Gravity.END, Gravity.TOP, Gravity.BOTTOM).contains(direction)) { throw new IllegalArgumentException(str(R.string.error_illegal_argument, "direction", direction)); } @@ -160,7 +188,7 @@ public class Images { } imgA.shoot(); imgB.shoot(); - return ImageWrapper.ofBitmap(bitmap); + return ImageWrapper.ofBitmap(scriptRuntime, bitmap); } public static void saveBitmap(@NonNull Bitmap bitmap, String path) { @@ -184,8 +212,8 @@ public class Images { return Bitmap.createBitmap(origin, 0, 0, width, height, matrix, false); } - protected static void setImageCaptureCallback(OnScreenCaptureAvailableListener onScreenCaptureAvailableListener, Image image) { - ImageWrapper ofImage = ImageWrapper.ofImage(image); + protected static void setImageCaptureCallback(ScriptRuntime scriptRuntime, OnScreenCaptureAvailableListener onScreenCaptureAvailableListener, Image image) { + ImageWrapper ofImage = ImageWrapper.ofImage(scriptRuntime, image); onScreenCaptureAvailableListener.onCaptureAvailable(ofImage); // ofImage.recycle(); } @@ -238,7 +266,7 @@ public class Images { mPreCaptureImage.recycleInternal(); } if (capture != null) { - mPreCaptureImage = new CapturedImage(capture); + mPreCaptureImage = new CapturedImage(mScriptRuntime, capture); } } return mPreCaptureImage; @@ -247,7 +275,7 @@ public class Images { public boolean captureScreen(String path) { ImageWrapper image = captureScreen(); - return image != null && image.saveTo(mScriptRuntime.files.nonNullPath(path)); + return image != null && image.saveTo(path); } public ImageWrapper copy(@NonNull ImageWrapper image) { @@ -256,18 +284,12 @@ public class Images { return imageWrapper; } - public boolean save(@NonNull ImageWrapper image, - @NonNull String path, - @NonNull String format, - int quality) throws IOException { - + public boolean save(@NonNull ImageWrapper image, @NonNull String path, @NonNull String format, int quality) throws IOException { Bitmap bitmap = image.getBitmap(); Bitmap.CompressFormat compressFormat = parseImageFormat(format); if (compressFormat == Bitmap.CompressFormat.PNG && quality != 100) { - // ARGB_8888 -> RGBA[] - byte[] rgba = BitmapUtils.bitmapToRgba(bitmap); - byte[] compressed = PngQuantBridge.quantize(rgba, bitmap.getWidth(), bitmap.getHeight(), quality); + byte[] compressed = PngQuantBridge.quantize(bitmap, quality); if (compressed != null) { try (FileOutputStream fos = new FileOutputStream(path)) { fos.write(compressed); @@ -290,6 +312,36 @@ public class Images { } } + public byte[] compressToBytes(@NotNull ImageWrapper image, @NotNull String format, int quality) { + Bitmap bitmap = image.getBitmap(); + Bitmap.CompressFormat compressFormat = parseImageFormat(format); + + if (compressFormat == Bitmap.CompressFormat.PNG && quality != 100) { + byte[] compressed = PngQuantBridge.quantize(bitmap, quality); + image.shoot(); + if (compressed != null) { + return compressed; + } + throw new WrappedRuntimeException("PNG quantization failed"); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (compressFormat == Bitmap.CompressFormat.WEBP_LOSSLESS && quality != 100) { + image.shoot(); + throw new IllegalArgumentException(mContext.getString(R.string.error_webp_lossless_quality_not_supported)); + } + } + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + bitmap.compress(compressFormat, quality, outputStream); + image.shoot(); + return outputStream.toByteArray(); + } + + public ImageWrapper compress(@NotNull ImageWrapper image, @NotNull String format, int quality) throws BitmapUtils.DecodeException { + return fromBytes(compressToBytes(image, format, quality)); + } + // public EventEmitter select() { // EventEmitter eventEmitter = new EventEmitter(scriptRuntime.bridges, scriptRuntime.timers.getTimerForCurrentThread()); // StartForResultActivity.start(mContext, new SelectImageCallback(this, eventEmitter, mContext, mContext.getString(R.string.text_select_image))); @@ -303,7 +355,7 @@ public class Images { public ImageWrapper rotate(@NonNull ImageWrapper img, float x, float y, float degree) { Matrix matrix = new Matrix(); matrix.postRotate(degree, x, y); - ImageWrapper imageWrapper = ImageWrapper.ofBitmap(Bitmap.createBitmap(img.getBitmap(), 0, 0, img.getWidth(), img.getHeight(), matrix, true)); + ImageWrapper imageWrapper = ImageWrapper.ofBitmap(mScriptRuntime, Bitmap.createBitmap(img.getBitmap(), 0, 0, img.getWidth(), img.getHeight(), matrix, true)); img.shoot(); return imageWrapper; } @@ -313,23 +365,27 @@ public class Images { Bitmap original = img.getBitmap(); Matrix matrix = new Matrix(); - // 根据传入参数设置缩放比例 + // Set scaling ratio according to input parameters. + // zh-CN: 根据传入参数设置缩放比例. float sx = horizontal ? -1.0f : 1.0f; float sy = vertical ? -1.0f : 1.0f; matrix.preScale(sx, sy); - // 水平方向翻转后, 平移到图像宽度的位置 + // After horizontal flip, translate to image width position. + // zh-CN: 水平方向翻转后, 平移到图像宽度的位置. if (horizontal) matrix.postTranslate(original.getWidth(), 0); - // 垂直方向翻转后, 平移到图像高度的位置 + + // After vertical flip, translate to image height position. + // zh-CN: 垂直方向翻转后, 平移到图像高度的位置. if (vertical) matrix.postTranslate(0, original.getHeight()); Bitmap flipped = Bitmap.createBitmap(original, 0, 0, original.getWidth(), original.getHeight(), matrix, true); img.shoot(); - return ImageWrapper.ofBitmap(flipped); + return ImageWrapper.ofBitmap(mScriptRuntime, flipped); } public ImageWrapper clip(@NonNull ImageWrapper img, int x, int y, int w, int h) { - ImageWrapper imageWrapper = ImageWrapper.ofBitmap(Bitmap.createBitmap(img.getBitmap(), x, y, w, h)); + ImageWrapper imageWrapper = ImageWrapper.ofBitmap(mScriptRuntime, Bitmap.createBitmap(img.getBitmap(), x, y, w, h)); img.shoot(); return imageWrapper; } @@ -346,7 +402,7 @@ public class Images { if (!isStrict) return null; throw new RuntimeException(mContext.getString(R.string.error_file_in_path_does_not_exist, fullPath)); } - return ImageWrapper.ofBitmap(bitmap); + return ImageWrapper.ofBitmap(mScriptRuntime, bitmap); } @NotNull @@ -357,7 +413,7 @@ public class Images { } public ImageWrapper fromBase64(String data) { - return ImageWrapper.ofBitmap(Drawables.loadBase64Data(data)); + return ImageWrapper.ofBitmap(mScriptRuntime, Drawables.loadBase64Data(data)); } public String toBase64(ImageWrapper img, String format, int quality) { @@ -375,8 +431,8 @@ public class Images { return outputStream.toByteArray(); } - public ImageWrapper fromBytes(byte[] bytes) { - return ImageWrapper.ofBitmap(BitmapFactory.decodeByteArray(bytes, 0, bytes.length)); + public ImageWrapper fromBytes(byte[] bytes) throws BitmapUtils.DecodeException { + return ImageWrapper.ofBitmap(mScriptRuntime, BitmapUtils.bitmapFromByteArrayOrThrow(bytes)); } private Bitmap.CompressFormat parseImageFormat(String format) { @@ -405,7 +461,7 @@ public class Images { HttpURLConnection connection = (HttpURLConnection) new URL(src).openConnection(); connection.setDoInput(true); connection.connect(); - return ImageWrapper.ofBitmap(BitmapFactory.decodeStream(connection.getInputStream())); + return ImageWrapper.ofBitmap(mScriptRuntime, BitmapFactory.decodeStream(connection.getInputStream())); } catch (IOException e) { return null; } @@ -441,7 +497,7 @@ public class Images { channels.add(3, alphaChannel); // 将原来的 alpha 通道放回最后一个位置 org.opencv.core.Mat destMat = new org.opencv.core.Mat(); - Core.merge(channels, destMat); // 合并 4 个通道(BGR + 原始 alpha) + Core.merge(channels, destMat); // 合并 4 个通道 (BGR + 原始 alpha) // 转换反色后的 Mat 回 Bitmap Bitmap invertedBitmap = Bitmap.createBitmap(originalWidth, originalHeight, Bitmap.Config.ARGB_8888); @@ -449,7 +505,7 @@ public class Images { image.shoot(); - return ImageWrapper.ofBitmap(invertedBitmap); + return ImageWrapper.ofBitmap(mScriptRuntime, invertedBitmap); } public void releaseScreenCapturer() { @@ -521,18 +577,20 @@ public class Images { shouldReleaseMat = true; } @Nullable - org.opencv.core.Point point = TemplateMatching.singleTemplateMatching( - src, - template.getMat(), - new TemplateMatching.Options(-1, weakThreshold, strictThreshold, maxLevel) - ); - - if (shouldReleaseMat) { - OpenCVHelper.release(src); + org.opencv.core.Point point; + try { + point = TemplateMatching.singleTemplateMatching( + src, + template.getMat(), + new TemplateMatching.Options(-1, weakThreshold, strictThreshold, maxLevel) + ); + } finally { + if (shouldReleaseMat) { + OpenCVHelper.release(src); + } + image.shoot(); + template.shoot(); } - image.shoot(); - template.shoot(); - if (point != null) { if (rect != null) { point.x += rect.x; @@ -605,7 +663,7 @@ public class Images { } public void setImageCaptureCallback(OnScreenCaptureAvailableListener onScreenCaptureAvailableListener) { - mOnScreenCaptureAvailableListener = new ScreenCaptureAvailableHandler(onScreenCaptureAvailableListener); + mOnScreenCaptureAvailableListener = new ScreenCaptureAvailableHandler(mScriptRuntime, onScreenCaptureAvailableListener); if (mScreenCapturer != null) { mScreenCapturer.setImageCaptureCallback(mOnScreenCaptureAvailableListener); } diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/PngQuantBridge.java b/app/src/main/java/org/autojs/autojs/runtime/api/PngQuantBridge.java index 8dbc2b9b..15756f7f 100644 --- a/app/src/main/java/org/autojs/autojs/runtime/api/PngQuantBridge.java +++ b/app/src/main/java/org/autojs/autojs/runtime/api/PngQuantBridge.java @@ -1,5 +1,7 @@ package org.autojs.autojs.runtime.api; +import android.graphics.Bitmap; + public class PngQuantBridge { static { @@ -35,12 +37,8 @@ public class PngQuantBridge { *
  • PNG 规范不支持在压缩阶段引入有损处理, 因此不会像 JPEG 那样再出现马赛克等失真.
  • * * - * @param rgba contiguous RGBA8888 buffer; length must equal {@code width * height * 4} - * @param width image width in pixels - * @param height image height in pixels - * @param quality libimagequant quality (0–100, lower = smaller file & potentially higher error) * @return palette-based PNG bytes (may contain transparency) */ - public static native byte[] quantize(byte[] rgba, int width, int height, int quality); + public static native byte[] quantize(Bitmap bitmap, int quality); } \ No newline at end of file diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/ScreenCaptureAvailableHandler.java b/app/src/main/java/org/autojs/autojs/runtime/api/ScreenCaptureAvailableHandler.java index 4fe93d1b..eadf8368 100644 --- a/app/src/main/java/org/autojs/autojs/runtime/api/ScreenCaptureAvailableHandler.java +++ b/app/src/main/java/org/autojs/autojs/runtime/api/ScreenCaptureAvailableHandler.java @@ -4,18 +4,17 @@ import android.content.Context; import android.media.Image; import android.view.View; import android.view.ViewGroup; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; - import org.autojs.autojs.core.floaty.BaseResizableFloatyWindow; import org.autojs.autojs.core.image.capture.ScreenCapturer; +import org.autojs.autojs.runtime.ScriptRuntime; /** * Created by SuperMonster003 on Dec 18, 2023. */ // @Reference to Auto.js Pro 9.3.11 by SuperMonster003 on Dec 18, 2023. -public record ScreenCaptureAvailableHandler(Images.OnScreenCaptureAvailableListener listener) implements BaseResizableFloatyWindow.ViewSupplier, ScreenCapturer.OnScreenCaptureAvailableListener { +public record ScreenCaptureAvailableHandler(ScriptRuntime scriptRuntime, Images.OnScreenCaptureAvailableListener listener) implements BaseResizableFloatyWindow.ViewSupplier, ScreenCapturer.OnScreenCaptureAvailableListener { @NonNull @Override @@ -25,6 +24,6 @@ public record ScreenCaptureAvailableHandler(Images.OnScreenCaptureAvailableListe @Override public void onCaptureAvailable(Image image) { - Images.setImageCaptureCallback(listener, image); + Images.setImageCaptureCallback(scriptRuntime, listener, image); } } diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/augment/images/Images.kt b/app/src/main/java/org/autojs/autojs/runtime/api/augment/images/Images.kt index 6e79d03e..60f57265 100644 --- a/app/src/main/java/org/autojs/autojs/runtime/api/augment/images/Images.kt +++ b/app/src/main/java/org/autojs/autojs/runtime/api/augment/images/Images.kt @@ -3,8 +3,10 @@ package org.autojs.autojs.runtime.api.augment.images import android.annotation.SuppressLint import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.net.Uri import android.util.Log import android.view.Gravity +import androidx.core.net.toUri import org.autojs.autojs.annotation.RhinoRuntimeFunctionInterface import org.autojs.autojs.core.image.ColorDetector import org.autojs.autojs.core.image.ImageWrapper @@ -18,6 +20,7 @@ import org.autojs.autojs.extension.ArrayExtensions.toNativeObject import org.autojs.autojs.extension.ScriptableExtensions.hasProp import org.autojs.autojs.extension.ScriptableExtensions.prop import org.autojs.autojs.extension.ScriptableObjectExtensions.inquire +import org.autojs.autojs.extension.StringExtensions.isUri import org.autojs.autojs.runtime.ScriptRuntime import org.autojs.autojs.runtime.api.ImageFeatureMatching import org.autojs.autojs.runtime.api.ImageSimilarity @@ -30,6 +33,8 @@ import org.autojs.autojs.runtime.api.augment.colors.Colors import org.autojs.autojs.runtime.api.augment.s13n.S13n import org.autojs.autojs.runtime.exception.ShouldNeverHappenException import org.autojs.autojs.runtime.exception.WrappedIllegalArgumentException +import org.autojs.autojs.runtime.exception.WrappedRuntimeException +import org.autojs.autojs.util.BitmapUtils import org.autojs.autojs.util.RhinoUtils.callFunction import org.autojs.autojs.util.RhinoUtils.coerceBoolean import org.autojs.autojs.util.RhinoUtils.coerceFloatNumber @@ -47,16 +52,9 @@ import org.mozilla.javascript.ScriptableObject import org.opencv.core.CvType import org.opencv.features2d.DescriptorMatcher import org.opencv.imgproc.Imgproc -import java.io.ByteArrayOutputStream -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.component3 -import kotlin.collections.contains -import kotlin.math.floor -import kotlin.math.ln +import java.net.URL import kotlin.math.max import kotlin.math.min -import kotlin.math.pow import kotlin.math.round import kotlin.math.roundToInt import kotlin.math.sqrt @@ -101,7 +99,7 @@ class Images(scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime), AsEmitt ::bilateralFilter.name, ::cvtColor.name, ::findCircles.name, - ::resizeInternal.name, + ::resize.name, ::scale.name, ::rotate.name, ::flip.name, @@ -135,6 +133,8 @@ class Images(scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime), AsEmitt ::isRecycled.name, ::recycle.name, ::compress.name, + ::compressToBytes.name, + ::downsample.name, ::getSize.name, ::getWidth.name, ::getHeight.name, @@ -160,6 +160,9 @@ class Images(scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime), AsEmitt @JvmField val DEFAULT_IMAGE_SAVE_QUALITY = 100 + @JvmField + val DEFAULT_IMAGE_COMPRESS_QUALITY = 60 + @JvmField val DEFAULT_IMAGE_TO_BYTES_QUALITY = 100 @@ -308,7 +311,12 @@ class Images(scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime), AsEmitt val image = if (o is String) read(scriptRuntime, arrayOf(o, true)) else o require(image is ImageWrapper) { "Argument image for images.save must be a ImageWrapper" } require(!path.isJsNullish()) { "Argument path for images.save must be non-nullish" } - scriptRuntime.images.save(image, scriptRuntime.files.nonNullPath(coerceString(path)), parseImageFormat(format), parseQuality(quality, DEFAULT_IMAGE_SAVE_QUALITY)) + scriptRuntime.images.save( + /* image = */ image, + /* path = */ scriptRuntime.files.nonNullPath(coerceString(path)), + /* format = */ parseImageFormat(format), + /* quality = */ parseQuality(quality, DEFAULT_IMAGE_SAVE_QUALITY), + ) } @JvmStatic @@ -318,7 +326,12 @@ class Images(scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime), AsEmitt val image = if (o is String) read(scriptRuntime, arrayOf(o, true)) else o require(image is ImageWrapper) { "Argument image for images.saveImage must be a ImageWrapper" } require(!path.isJsNullish()) { "Argument path for images.saveImage must be non-nullish" } - scriptRuntime.images.save(image, scriptRuntime.files.nonNullPath(coerceString(path)), parseImageFormat(format), parseQuality(quality, DEFAULT_IMAGE_SAVE_QUALITY)) + scriptRuntime.images.save( + /* image = */ image, + /* path = */ scriptRuntime.files.nonNullPath(coerceString(path)), + /* format = */ parseImageFormat(format), + /* quality = */ parseQuality(quality, DEFAULT_IMAGE_SAVE_QUALITY), + ) } @JvmStatic @@ -658,7 +671,7 @@ class Images(scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime), AsEmitt val imageB = if (oB is String) read(scriptRuntime, arrayOf(oB, true)) else oB require(imageB is ImageWrapper) { "Argument imageB for images.concat must be a ImageWrapper" } initOpenCvIfNeeded() - ApiImages.concat(imageA, imageB, directionToGravityToConcat(direction)) + ApiImages.concat(scriptRuntime, imageA, imageB, directionToGravityToConcat(direction)) } @JvmStatic @@ -988,8 +1001,8 @@ class Images(scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime), AsEmitt fun matToImage(scriptRuntime: ScriptRuntime, args: Array): ImageWrapper = ensureArgumentsOnlyOne(args) { initOpenCvIfNeeded() when (it) { - is AutoJsMat -> ImageWrapper.ofMat(it) - is OpencvMat -> ImageWrapper.ofMat(it) + is AutoJsMat -> ImageWrapper.ofMat(scriptRuntime, it) + is OpencvMat -> ImageWrapper.ofMat(scriptRuntime, it) else -> throw WrappedIllegalArgumentException("Argument mat ${it.jsBrief()} is invalid for images.matToImage()") } } @@ -1077,18 +1090,71 @@ class Images(scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime), AsEmitt @JvmStatic @RhinoRuntimeFunctionInterface - fun compress(scriptRuntime: ScriptRuntime, args: Array): ImageWrapper = ensureArgumentsLength(args, 2) { - val (o, compressLevelArg) = it + fun compress(scriptRuntime: ScriptRuntime, args: Array): ImageWrapper = ensureArgumentsLengthInRange(args, 1..3) { + + // @Comment by SuperMonster003 on May 19, 2025. + // ! Option `inSampleSize` performs pixel downsampling during decoding + // ! by discarding pixels to reduce resolution, rather than changing encoding quality. + // ! zh-CN: + // ! 选项 `inSampleSize` 在解码阶段做像素降采样, + // ! 通过降低分辨率丢弃像素的方式来实现文件压缩, 而非改变编码质量. + // ! + // # val (o, compressLevelArg) = it + // # val image = if (o is String) read(scriptRuntime, arrayOf(o, true)) else o + // # require(image is ImageWrapper) { "Argument image for images.compress must be a ImageWrapper" } + // # val compressLevel = coerceNumber(compressLevelArg, 1.0) + // # val level = 2.0.pow(floor(ln(compressLevel.coerceAtLeast(1.0)) / ln(2.0))).toInt() + // # val outputStream = ByteArrayOutputStream() + // # image.bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + // # val byteArray = outputStream.toByteArray() + // # val options = BitmapFactory.Options().apply { inSampleSize = level } + // # val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size, options) + // # ImageWrapper.ofBitmap(bitmap).also { image.shoot() } + + val (o, format, quality) = it val image = if (o is String) read(scriptRuntime, arrayOf(o, true)) else o require(image is ImageWrapper) { "Argument image for images.compress must be a ImageWrapper" } - val compressLevel = coerceNumber(compressLevelArg, 1.0) - val level = 2.0.pow(floor(ln(compressLevel.coerceAtLeast(1.0)) / ln(2.0))).toInt() - val outputStream = ByteArrayOutputStream() - image.bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) - val byteArray = outputStream.toByteArray() - val options = BitmapFactory.Options().apply { inSampleSize = level } - val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size, options) - ImageWrapper.ofBitmap(bitmap).also { image.shoot() } + scriptRuntime.images.compress( + /* image = */ image, + /* format = */ parseImageFormat(format), + /* quality = */ parseQuality(quality, DEFAULT_IMAGE_COMPRESS_QUALITY), + ) + } + + @JvmStatic + @RhinoRuntimeFunctionInterface + fun compressToBytes(scriptRuntime: ScriptRuntime, args: Array): ByteArray = ensureArgumentsLengthInRange(args, 1..3) { + val (o, format, quality) = it + val image = if (o is String) read(scriptRuntime, arrayOf(o, true)) else o + require(image is ImageWrapper) { "Argument image for images.compressToBytes must be a ImageWrapper" } + scriptRuntime.images.compressToBytes( + /* image = */ image, + /* format = */ parseImageFormat(format), + /* quality = */ parseQuality(quality, DEFAULT_IMAGE_COMPRESS_QUALITY), + ) + } + + @JvmStatic + @RhinoRuntimeFunctionInterface + fun downsample(scriptRuntime: ScriptRuntime, args: Array): ImageWrapper = ensureArgumentsLengthInRange(args, 3..4) { argList -> + val (src, reqWidthArg, reqHeightArg, withAlphaArg) = argList + val reqWidth = coerceIntNumber(reqWidthArg) + val reqHeight = coerceIntNumber(reqHeightArg) + val withAlpha = coerceBoolean(withAlphaArg, true) + when (src) { + is ByteArray -> BitmapUtils.downsample(src, reqWidth, reqHeight, withAlpha) + is String -> when { + src.isUri() -> BitmapUtils.downsample(scriptRuntime.uiHandler.applicationContext, src.toUri(), reqWidth, reqHeight, withAlpha) + else -> BitmapUtils.downsample(scriptRuntime.files.nonNullPath(src), reqWidth, reqHeight, withAlpha) + } + is URL -> BitmapUtils.downsample(src, reqWidth, reqHeight, withAlpha) + is Uri -> BitmapUtils.downsample(scriptRuntime.uiHandler.applicationContext, src, reqWidth, reqHeight, withAlpha) + is Bitmap -> BitmapUtils.downsample(src, reqWidth, reqHeight, withAlpha) + is ImageWrapper -> BitmapUtils.downsample(src.bitmap, reqWidth, reqHeight, withAlpha).also { src.shoot() } + else -> throw WrappedIllegalArgumentException("Argument src ${src.jsBrief()} is invalid for images.downsample()") + }?.let { + ImageWrapper.ofBitmap(scriptRuntime, it) + } ?: throw WrappedRuntimeException("Failed to downsample image from ${src.jsBrief()}") } @JvmStatic diff --git a/app/src/main/java/org/autojs/autojs/util/BitmapUtils.kt b/app/src/main/java/org/autojs/autojs/util/BitmapUtils.kt index aa852cbc..beb2254e 100644 --- a/app/src/main/java/org/autojs/autojs/util/BitmapUtils.kt +++ b/app/src/main/java/org/autojs/autojs/util/BitmapUtils.kt @@ -1,14 +1,20 @@ package org.autojs.autojs.util +import android.content.Context import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.Matrix import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable +import android.net.Uri import androidx.core.graphics.createBitmap +import androidx.core.graphics.scale +import java.net.URL /** * Created by Stardust on Apr 22, 2017. + * Modified by SuperMonster003 as of Jan 21, 2023. */ object BitmapUtils { @@ -58,4 +64,164 @@ object BitmapUtils { return rgba } + @JvmStatic + @Throws(DecodeException::class) + fun bitmapFromByteArrayOrThrow(bytes: ByteArray): Bitmap { + return bitmapFromByteArray(bytes) ?: throw DecodeException("Failed to decode bitmap from bytes") + } + + @JvmStatic + fun bitmapFromByteArray(bytes: ByteArray): Bitmap? { + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } + + /** + * Decode and downsample based on inSampleSize (zh-CN: 解码并按 inSampleSize 降采样) + * + * @param data Compressed image byte stream (zh-CN: 压缩格式图片字节流) + * @param reqWidth Target max width (zh-CN: 目标最大宽) + * @param reqHeight Target max height (zh-CN: 目标最大高) + * @param withAlpha Whether to keep alpha channel; default true=ARGB_8888, false=RGB_565.
    + * zh-CN: 是否保留透明通道; 默认 true=ARGB_8888, false=RGB_565. + */ + @JvmStatic + @JvmOverloads + fun downsample( + data: ByteArray, + reqWidth: Int, + reqHeight: Int, + withAlpha: Boolean = true, + ): Bitmap? { + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(data, 0, data.size, opts) + + opts.inSampleSize = computeSampleSize(opts.outWidth, opts.outHeight, reqWidth, reqHeight) + opts.inJustDecodeBounds = false + opts.inPreferredConfig = if (withAlpha) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565 + + val bmp = BitmapFactory.decodeByteArray(data, 0, data.size, opts) + if (!withAlpha) bmp?.setHasAlpha(false) + return bmp + } + + @JvmStatic + @JvmOverloads + fun downsample( + filePath: String, + reqWidth: Int, + reqHeight: Int, + withAlpha: Boolean = true, + ): Bitmap? { + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(filePath, opts) + + opts.inSampleSize = computeSampleSize(opts.outWidth, opts.outHeight, reqWidth, reqHeight) + opts.inJustDecodeBounds = false + opts.inPreferredConfig = if (withAlpha) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565 + + val bmp = BitmapFactory.decodeFile(filePath, opts) + if (!withAlpha) bmp?.setHasAlpha(false) + return bmp + } + + @JvmStatic + @JvmOverloads + fun downsample( + url: URL, + reqWidth: Int, + reqHeight: Int, + withAlpha: Boolean = true, + ): Bitmap? = runCatching { + // Need to run in coroutine or thread in production to avoid blocking main thread. + // zh-CN: 生产环境需放到协程或线程中, 避免阻塞主线程. + val bytes = url.openStream().use { it.readBytes() } + downsample(bytes, reqWidth, reqHeight, withAlpha) + }.getOrNull() + + @JvmStatic + @JvmOverloads + fun downsample( + src: Bitmap, + reqWidth: Int, + reqHeight: Int, + withAlpha: Boolean = true, + ): Bitmap { + // Already a Bitmap, cannot use inSampleSize, can only scale proportionally. + // zh-CN: 已经是 Bitmap, 无法再用 inSampleSize, 只能等比缩放. + val scale = calcScale(src.width, src.height, reqWidth, reqHeight) + val dstW = (src.width / scale).coerceAtLeast(1) + val dstH = (src.height / scale).coerceAtLeast(1) + + val scaled = src.scale(dstW, dstH) + + // If need to adjust pixel format / alpha channel. + // zh-CN: 如需调整像素格式 / 透明通道. + return when (withAlpha) { + scaled.hasAlpha() -> scaled + else -> scaled.copy( + if (withAlpha) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565, + /* isMutable = */ false, + ).apply { if (!withAlpha) setHasAlpha(false) } + } + } + + @JvmStatic + @JvmOverloads + fun downsample( + context: Context, + uri: Uri, + reqWidth: Int, + reqHeight: Int, + withAlpha: Boolean = true, + ): Bitmap? = runCatching { + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, opts) + } ?: return@runCatching null + + opts.inSampleSize = computeSampleSize(opts.outWidth, opts.outHeight, reqWidth, reqHeight) + opts.inJustDecodeBounds = false + opts.inPreferredConfig = if (withAlpha) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565 + + val bmp = context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, opts) + } + if (!withAlpha) bmp?.setHasAlpha(false) + bmp + }.getOrNull() + + private fun calcScale( + srcW: Int, srcH: Int, + dstW: Int, dstH: Int, + ): Int { + var scale = 1 + if (srcH > dstH || srcW > dstW) { + while ((srcH / scale) > dstH || (srcW / scale) > dstW) { + scale++ + } + } + return scale + } + + @JvmStatic + fun computeSampleSize(outWidth: Int, outHeight: Int, reqWidth: Int, reqHeight: Int): Int { + + var inSampleSize = 1 + + if (outHeight > reqHeight || outWidth > reqWidth) { + val halfHeight = outHeight / 2 + val halfWidth = outWidth / 2 + + // Calculate the largest inSampleSize value that is a power of 2 and keeps both + // height and width larger than the requested height and width. + // zh-CN: 计算满足所需高度和宽度的2的幂次方的最大采样率. + while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { + inSampleSize *= 2 + } + } + return inSampleSize + } + + class DecodeException(message: String) : Exception(message) + } \ No newline at end of file diff --git a/app/src/main/java/org/autojs/autojs/util/ImageUtils.kt b/app/src/main/java/org/autojs/autojs/util/ImageUtils.kt index 0c805ee6..b814e5f6 100644 --- a/app/src/main/java/org/autojs/autojs/util/ImageUtils.kt +++ b/app/src/main/java/org/autojs/autojs/util/ImageUtils.kt @@ -1,6 +1,5 @@ package org.autojs.autojs.util -import android.graphics.BitmapFactory import org.opencv.core.Core import org.opencv.core.CvType import org.opencv.core.Mat @@ -8,29 +7,6 @@ import org.opencv.imgproc.Imgproc object ImageUtils { - @JvmStatic - fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { - - // Raw height and width of image (zh-CN: 图像的原始高度和宽度) - val height = options.outHeight - val width = options.outWidth - - var inSampleSize = 1 - - if (height > reqHeight || width > reqWidth) { - val halfHeight = height / 2 - val halfWidth = width / 2 - - // Calculate the largest inSampleSize value that is a power of 2 and keeps both - // height and width larger than the requested height and width. - // zh-CN: 计算满足所需高度和宽度的2的幂次方的最大采样率. - while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { - inSampleSize *= 2 - } - } - return inSampleSize - } - @JvmStatic fun Mat.to8UC3(): Mat { val src = this diff --git a/libs/imagequant/PngQuantBridge.cpp b/libs/imagequant/PngQuantBridge.cpp index 35a3501d..79b6ce83 100644 --- a/libs/imagequant/PngQuantBridge.cpp +++ b/libs/imagequant/PngQuantBridge.cpp @@ -1,110 +1,156 @@ #include +#include +#include + #include +#include #include "libimagequant.h" #include "png.h" -#include #define ALOGE(fmt, ...) __android_log_print(ANDROID_LOG_ERROR, "PNGQ", fmt, ##__VA_ARGS__) +/* RAII: 保证函数结束时一定会 unlockPixels(). */ +class BitmapLocker { +public: + BitmapLocker(JNIEnv *env, jobject bmp) + : mEnv(env), mBmp(bmp), mPixels(nullptr), mLocked(false) {} + + bool lock() { + if (AndroidBitmap_lockPixels(mEnv, mBmp, &mPixels) != 0 || !mPixels) { + return false; + } + mLocked = true; + return true; + } + + void *pixels() const { return mPixels; } + + ~BitmapLocker() { + if (mLocked) { + AndroidBitmap_unlockPixels(mEnv, mBmp); + } + } + +private: + JNIEnv *mEnv; + jobject mBmp; + void *mPixels; + bool mLocked; +}; + extern "C" -JNIEXPORT jbyteArray JNICALL +JNIEXPORT jbyteArray + +JNICALL Java_org_autojs_autojs_runtime_api_PngQuantBridge_quantize( - JNIEnv *env, jclass, jbyteArray srcRgba, - jint width, jint height, jint quality) { + JNIEnv *env, jclass /*cls*/, jobject jbitmap, jint quality) { - /* 读取像素. */ + /* 参数检查 & Bitmap 信息. */ + if (!jbitmap) { + env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), "Bitmap is null"); + return nullptr; + } + AndroidBitmapInfo info{}; + if (AndroidBitmap_getInfo(env, jbitmap, &info) != 0 || + info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) { + env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), + "Bitmap must be RGBA_8888"); + return nullptr; + } + const int width = static_cast(info.width); + const int height = static_cast(info.height); - jsize len = env->GetArrayLength(srcRgba); - std::vector rgba(len); - env->GetByteArrayRegion(srcRgba, 0, len, reinterpret_cast(rgba.data())); + /* 提前声明所有资源. */ + BitmapLocker locker(env, jbitmap); + liq_attr *attr = nullptr; + liq_image *image = nullptr; + liq_result *res = nullptr; + std::vector indexed; // 后面 resize() + const liq_palette *pal = nullptr; + std::vector compressed; // 输出 PNG - if (len != width * height * 4) { - ALOGE("rgba length mismatch, len=%d, expect=%d", len, width * height * 4); + /* 锁定像素. */ + if (!locker.lock()) { + ALOGE("AndroidBitmap_lockPixels failed"); + env->ThrowNew(env->FindClass("java/io/IOException"), + "AndroidBitmap_lockPixels failed"); return nullptr; } - /* libimagequant 量化. */ - - liq_attr *attr = liq_attr_create(); + /* libimagequant. */ + attr = liq_attr_create(); if (!attr) { ALOGE("liq_attr_create failed"); - return nullptr; + goto fail; } - + if (quality < 0) quality = 0; + if (quality > 100) quality = 100; liq_set_speed(attr, 8); liq_set_quality(attr, quality, quality); liq_set_max_colors(attr, 256); - liq_image *image = liq_image_create_rgba(attr, rgba.data(), width, height, 0); - liq_result *res; - - liq_error err = liq_image_quantize(image, attr, &res); - if (err != LIQ_OK) { - ALOGE("liq_image_quantize failed, code=%d", err); - liq_image_destroy(image); - liq_attr_destroy(attr); - return nullptr; + image = liq_image_create_rgba(attr, + static_cast(locker.pixels()), + width, height, 0); + if (!image) { + ALOGE("liq_image_create_rgba failed"); + goto fail; + } + if (liq_image_quantize(image, attr, &res) != LIQ_OK || !res) { + ALOGE("liq_image_quantize failed"); + goto fail; } - size_t outSize = width * height; - std::vector indexed(outSize); - liq_write_remapped_image(res, image, indexed.data(), outSize); - const liq_palette *pal = liq_get_palette(res); + /* Remap. */ + indexed.resize(static_cast(width) * height); + liq_write_remapped_image(res, image, indexed.data(), indexed.size()); - /* 写索引色 PNG 到内存. */ + pal = liq_get_palette(res); + if (!pal || pal->count == 0 || pal->count > 256) { + ALOGE("invalid palette, count=%d", pal ? pal->count : -1); + goto fail; + } - std::vector compressed; - do { - /* palette 检查. */ - if (!pal || pal->count == 0 || pal->count > 256) { - ALOGE("invalid palette count=%d", pal ? pal->count : -1); - goto png_fail; - } - - /* libpng 结构体. */ + /* libpng 写 PNG 到内存. */ + { png_structp png_ptr = png_create_write_struct(PNG_LIBPNG_VER_STRING, - nullptr, - [](png_structp png_ptr, png_const_charp msg) { - ALOGE("libpng error: %s", msg); - longjmp(png_jmpbuf(png_ptr), 1); - }, - nullptr); + nullptr, + [](png_structp png_ptr, png_const_charp msg) { + __android_log_print(ANDROID_LOG_ERROR, "PNGQ", "libpng error: %s", msg); + longjmp(png_jmpbuf(png_ptr), 1); + }, + nullptr); if (!png_ptr) { ALOGE("png_create_write_struct failed"); - goto png_fail; + goto fail; } - png_infop info_ptr = png_create_info_struct(png_ptr); if (!info_ptr) { png_destroy_write_struct(&png_ptr, nullptr); - goto png_fail; + ALOGE("png_create_info_struct failed"); + goto fail; } - - /* longjmp error handler. */ - if (setjmp(png_jmpbuf(png_ptr))) { + if (setjmp(png_jmpbuf(png_ptr))) { // libpng error png_destroy_write_struct(&png_ptr, &info_ptr); - goto png_fail; + ALOGE("libpng longjmp error"); + goto fail; } - /* custom mem writer. */ + /* 自定义写函数 => vector. */ struct Writer { static void PNGAPI write(png_structp png_ptr, png_bytep data, png_size_t len) { - auto *vec = static_cast *>(png_get_io_ptr(png_ptr)); + auto *vec = static_cast *>(png_get_io_ptr(png_ptr)); vec->insert(vec->end(), data, data + len); } + static void PNGAPI flush(png_structp) {} }; - std::vector tmpBuf; - png_set_write_fn(png_ptr, &tmpBuf, Writer::write, Writer::flush); + png_set_write_fn(png_ptr, &compressed, Writer::write, Writer::flush); /* IHDR. */ - png_set_IHDR(png_ptr, info_ptr, - static_cast(width), - static_cast(height), - 8, // bit depth - PNG_COLOR_TYPE_PALETTE, PNG_INTERLACE_NONE, - PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT); - + png_set_IHDR(png_ptr, info_ptr, width, height, + 8, PNG_COLOR_TYPE_PALETTE, PNG_INTERLACE_NONE, + PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT); png_set_compression_level(png_ptr, 3); png_set_filter(png_ptr, 0, PNG_FILTER_NONE); @@ -124,36 +170,38 @@ Java_org_autojs_autojs_runtime_api_PngQuantBridge_quantize( png_write_info(png_ptr, info_ptr); - /* rows. */ - std::vector rows(height); + /* Rows. */ + std::vector rows(height); for (int y = 0; y < height; ++y) { rows[y] = indexed.data() + y * width; } png_write_image(png_ptr, rows.data()); - png_write_end(png_ptr, info_ptr); - compressed.swap(tmpBuf); - /* 无论是否成功, 均销毁. */ png_destroy_write_struct(&png_ptr, &info_ptr); - } while (false); - -png_fail: - if (compressed.empty()) { - ALOGE("compress failed, return null to Java"); - env->ThrowNew(env->FindClass("java/io/IOException"), - "pngquant native: compress failed"); - return nullptr; } - /* 清理. */ - liq_result_destroy(res); - liq_image_destroy(image); - liq_attr_destroy(attr); + /* 成功: 回传 Java. */ + if (compressed.empty()) { + ALOGE("compression produced empty data"); + goto fail; + } + { + jbyteArray out = env->NewByteArray(static_cast(compressed.size())); + env->SetByteArrayRegion(out, 0, static_cast(compressed.size()), + reinterpret_cast(compressed.data())); + /* 释放 libimagequant 资源. */ + liq_result_destroy(res); + liq_image_destroy(image); + liq_attr_destroy(attr); + return out; + } - /* 回 JNI. */ - jbyteArray out = env->NewByteArray(compressed.size()); - env->SetByteArrayRegion(out, 0, compressed.size(), - reinterpret_cast(compressed.data())); - return out; + fail: + /* 统一错误处理. */ + if (res) liq_result_destroy(res); + if (image) liq_image_destroy(image); + if (attr) liq_attr_destroy(attr); + env->ThrowNew(env->FindClass("java/io/IOException"), "pngquant native failed"); + return nullptr; } \ No newline at end of file diff --git a/libs/imagequant/build.gradle b/libs/imagequant/build.gradle index eb220102..5cfc281a 100644 --- a/libs/imagequant/build.gradle +++ b/libs/imagequant/build.gradle @@ -40,6 +40,7 @@ android { externalNativeBuild { cmake { cppFlags "-std=c++17" + cFlags "-ljnigraphics" } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6c779eb7..32e968e7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -204,8 +204,8 @@ pluginManagement { /* Print concerned info by `System.getProperties()`. */ val isShowConcernedSystemProperties = true - val isCleanupPaddleOcr = true - val isCleanupRapidOcr = true + val isCleanupPaddleOcr = false + val isCleanupRapidOcr = false val fallbackAgpVersion = "7.4.2" val fallbackKotlinVersion = "1.7.10" diff --git a/version.properties b/version.properties index 6a3098b1..a883cb0f 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -#Sun May 18 20:20:48 CST 2025 -BUILD_TIME=1747570848298 +#Tue May 20 22:30:54 CST 2025 +BUILD_TIME=1747751454685 COMPILE_SDK_VERSION=35 IMAGE_QUANT_CMAKE_VERSION=3.22.1 IMAGE_QUANT_NDK_VERSION=26.1.10909125 @@ -19,6 +19,6 @@ RAPID_OCR_OPENCV_MOBILE_LABEL_VERSION=13 RAPID_OCR_OPENCV_MOBILE_VERSION=4.5.3 TARGET_SDK_VERSION=35 TARGET_SDK_VERSION_INRT=29 -VERSION_BUILD=3234 +VERSION_BUILD=3238 VERSION_NAME=6.6.3 Alpha5 VSCODE_EXT_REQUIRED_VERSION=1.0.8