6.6.3 - Alpha5 - 新增 images.compressToBytes/downsample; 修复 images.compress; ImageWrapper#saveTo 支持相对路径

This commit is contained in:
SuperMonster003
2025-05-21 00:41:15 +08:00
parent 4c7ba32646
commit 501c01a632
17 changed files with 563 additions and 231 deletions

View File

@@ -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)_",
"已忽略更新记录及客户端模式连接地址记录支持清空操作",
"版本更新信息支持多语言显示 (与当前显示语言同步)",

View File

@@ -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
}

View File

@@ -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() {

View File

@@ -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. */

View File

@@ -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)

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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<br>
* zh-CN: 快速参考 图像尺寸 / 质量相关辅助方法
* <hr>
* <b>scale</b><br>
* Multiply both width and height of an <b>already-decoded</b> Bitmap by
* the given scale factor(s).<br>
* zh-CN: 在 <b>已解码</b> 的 Bitmap 上, 按照给定比例同时缩放宽高.
* <p>
* <b>resize</b><br>
* Force an <b>already-decoded</b> Bitmap to the specified width/height.
* Aspect ratio may be kept or ignored depending on the overload.<br>
* zh-CN: 将 <b>已解码</b> Bitmap 调整为指定宽高; 可选择保持或忽略纵横比.
* <p>
* <b>downsample</b><br>
* Shrink <b>before decoding</b> by setting <code>inSampleSize</code>; pixels are
* skipped while reading, dramatically reducing memory.<br>
* zh-CN: <b>解码前</b> 通过设置 <code>inSampleSize</code> 跳读像素, 显著降低解码分辨率与内存.
* <p>
* <b>compress</b><br>
* Re-encode an existing Bitmap (or byte stream) into JPEG/PNG/WebP
* with the given format & quality to reduce <b>disk</b> footprint.<br>
* zh-CN: 使用指定格式与质量重新编码 Bitmap (或字节流), 以减小 <b>磁盘</b> 体积.
* <p>
* <b>save</b><br>
* Convenience wrapper that internally invokes <code>compress</code> (with
* default or user-supplied options) and then writes to storage.<br>
* zh-CN: 便捷封装, 内部调用 <code>compress</code> (默认或自定义参数) 后写入存储.
* <hr>
* 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);
}

View File

@@ -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 {
* <li>PNG 规范不支持在压缩阶段引入有损处理, 因此不会像 JPEG 那样再出现马赛克等失真.</li>
* </ul>
*
* @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 (0100, lower = smaller file &amp; 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);
}

View File

@@ -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);
}
}

View File

@@ -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<Any>(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<Any>(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<Any>(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<out Any?>): 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<out Any?>): ImageWrapper = ensureArgumentsLength(args, 2) {
val (o, compressLevelArg) = it
fun compress(scriptRuntime: ScriptRuntime, args: Array<out Any?>): 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<Any>(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<Any>(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<out Any?>): ByteArray = ensureArgumentsLengthInRange(args, 1..3) {
val (o, format, quality) = it
val image = if (o is String) read(scriptRuntime, arrayOf<Any>(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<out Any?>): 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

View File

@@ -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.<br>
* 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)
}

View File

@@ -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

View File

@@ -1,110 +1,156 @@
#include <jni.h>
#include <android/bitmap.h>
#include <android/log.h>
#include <vector>
#include <setjmp.h>
#include "libimagequant.h"
#include "png.h"
#include <android/log.h>
#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<int>(info.width);
const int height = static_cast<int>(info.height);
jsize len = env->GetArrayLength(srcRgba);
std::vector<uint8_t> rgba(len);
env->GetByteArrayRegion(srcRgba, 0, len, reinterpret_cast<jbyte *>(rgba.data()));
/* 提前声明所有资源. */
BitmapLocker locker(env, jbitmap);
liq_attr *attr = nullptr;
liq_image *image = nullptr;
liq_result *res = nullptr;
std::vector <uint8_t> indexed; // 后面 resize()
const liq_palette *pal = nullptr;
std::vector <uint8_t> 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<uint8_t *>(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<uint8_t> indexed(outSize);
liq_write_remapped_image(res, image, indexed.data(), outSize);
const liq_palette *pal = liq_get_palette(res);
/* Remap. */
indexed.resize(static_cast<size_t>(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<uint8_t> 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<std::vector<uint8_t> *>(png_get_io_ptr(png_ptr));
auto *vec = static_cast<std::vector <uint8_t> *>(png_get_io_ptr(png_ptr));
vec->insert(vec->end(), data, data + len);
}
static void PNGAPI flush(png_structp) {}
};
std::vector<uint8_t> 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<png_uint_32>(width),
static_cast<png_uint_32>(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<png_bytep> rows(height);
/* Rows. */
std::vector <png_bytep> 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<jsize>(compressed.size()));
env->SetByteArrayRegion(out, 0, static_cast<jsize>(compressed.size()),
reinterpret_cast<const jbyte *>(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<jbyte *>(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;
}

View File

@@ -40,6 +40,7 @@ android {
externalNativeBuild {
cmake {
cppFlags "-std=c++17"
cFlags "-ljnigraphics"
}
}

View File

@@ -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"

View File

@@ -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