Эх сурвалжийг харах

feat(人脸识别):
- 在 `LoginActivity` 中,默认启用 HLK-223 模组作为人脸识别后端,替代原有的虹软(ArcSoft)引擎。

feat(硬件):
- `Hlk223Client`, `Hlk223PhotoEnroll`:
- 修复了通过照片下发注册用户(`enrollWithPhoto`)时的分包逻辑,确保大数据包(如JPG图片)能被正确切分和传输。
- 优化了分包策略,将数据块大小(`DATA_CHUNK`)与协议MTU解耦,并使用`LinkedList`保证帧发送的严格顺序。
- 将 `bioType`(生物特征类型)的默认值从 `1`(特征)修正为 `0`(图像),以匹配照片注册的场景。
- `Hlk223Config`: 新增 `LoggerPlugin` 插件,用于在日志中打印所有与HLK-223模组收发的串口指令,方便调试。

refactor(人脸识别):
- `FaceUtil`:
- 在人脸登录(`checkCamera`)和注册预览(`startPreview`)流程中,将相机预览画面(`preview`)与人脸框绘制控件(`faceOverlayView`)进行绑定,实现了在HLK模式下的人脸框实时绘制功能,提升了用户交互体验。
- 优化了HLK和虹软两种模式下的回调逻辑,确保人脸框能够稳定地在主线程上更新。
- `ImageCompress`:
- 新增 `base64ToJpegUnderMB` 方法,提供一个通过MB单位指定目标压缩大小的便捷接口,内部自动转换为字节单位调用现有压缩逻辑。

chore(依赖):
- 升级通信库 `sik-comm` 版本从 `1.0.18` 至 `1.0.19`。

周文健 1 долоо хоног өмнө
parent
commit
947dbb613c

+ 103 - 21
data/src/main/java/com/grkj/data/hardware/face/FaceUtil.kt

@@ -171,10 +171,13 @@ object FaceUtil {
     // —— 并发门闸 —— //
     // 预览阶段:防止 onPreview 并发进入 ARC 检测(丢帧式)
     private val detectGate = Mutex()
+
     // ARC 引擎生命周期 & 注册/比对串行
     private val arcLock = Mutex()
+
     // HLK 验证流程串行
     private val hlkLock = Mutex()
+
     // 相机生命周期原子化
     private val cameraLock = ReentrantLock()
 
@@ -389,7 +392,8 @@ object FaceUtil {
                                         lastFaceRectByHlk = rect
                                         lastAliveByHlk = true
                                         ThreadUtils.runOnMain {
-                                            faceOverlayView?.setFaceRect(rect?.let { listOf(it) } ?: emptyList())
+                                            faceOverlayView?.setFaceRect(rect?.let { listOf(it) }
+                                                ?: emptyList())
                                         }
                                     },
                                     onLiveness = { _ -> lastAliveByHlk = true },
@@ -425,13 +429,24 @@ object FaceUtil {
                                 defaultScope.launch(Dispatchers.Main) {
                                     faceOverlayView?.setFaceRect(rects)
                                 }
-                                val inCenter = rects.firstOrNull()?.isInCenterArea(p.width, p.height) ?: false
+                                val inCenter =
+                                    rects.firstOrNull()?.isInCenterArea(p.width, p.height) ?: false
                                 if (!inCenter) maybeLogNoFaceTip()
 
                                 if (shouldEmit(lastInitCbTs)) {
                                     ioScope.launch {
-                                        val bmp = ImageConvertUtils.nv21ToBitmap(imageData, p.width, p.height)
-                                        ThreadUtils.runOnMain { callBack(bmp, rects.size, lastAliveByHlk) }
+                                        val bmp = ImageConvertUtils.nv21ToBitmap(
+                                            imageData,
+                                            p.width,
+                                            p.height
+                                        )
+                                        ThreadUtils.runOnMain {
+                                            callBack(
+                                                bmp,
+                                                rects.size,
+                                                lastAliveByHlk
+                                            )
+                                        }
                                     }
                                 }
                             }
@@ -454,26 +469,50 @@ object FaceUtil {
                         val fe = faceEngine ?: return@launch
                         val faces = mutableListOf<FaceInfo>()
 
-                        var code = fe.detectFaces(nv21, p.width, p.height, FaceEngine.CP_PAF_NV21, faces)
+                        var code =
+                            fe.detectFaces(nv21, p.width, p.height, FaceEngine.CP_PAF_NV21, faces)
                         withContext(Dispatchers.Main) { faceOverlayView?.setFaceRect(faces.map { it.rect }) }
                         if (code != ErrorInfo.MOK || faces.isEmpty()) {
                             maybeLogNoFaceTip(); return@launch
                         }
 
-                        code = fe.process(nv21, p.width, p.height, FaceEngine.CP_PAF_NV21, faces, processMask)
+                        code = fe.process(
+                            nv21,
+                            p.width,
+                            p.height,
+                            FaceEngine.CP_PAF_NV21,
+                            faces,
+                            processMask
+                        )
                         if (code != ErrorInfo.MOK) return@launch
 
                         val liveList = mutableListOf<LivenessInfo>()
                         if (fe.getLiveness(liveList) != ErrorInfo.MOK) return@launch
                         val alive = liveList.any { it.liveness == LivenessInfo.ALIVE }
                         if (!alive) {
-                            if (shouldEmit(lastInitCbTs)) ThreadUtils.runOnMain { callBack(null, faces.size, false) }
+                            if (shouldEmit(lastInitCbTs)) ThreadUtils.runOnMain {
+                                callBack(
+                                    null,
+                                    faces.size,
+                                    false
+                                )
+                            }
                             return@launch
                         }
-                        if (needCheckCenter && !faces[0].rect.isInCenterArea(p.width, p.height)) return@launch
+                        if (needCheckCenter && !faces[0].rect.isInCenterArea(
+                                p.width,
+                                p.height
+                            )
+                        ) return@launch
 
                         if (shouldEmit(lastInitCbTs)) {
-                            val bmp = withContext(Dispatchers.IO) { ImageConvertUtils.nv21ToBitmap(nv21, p.width, p.height) }
+                            val bmp = withContext(Dispatchers.IO) {
+                                ImageConvertUtils.nv21ToBitmap(
+                                    nv21,
+                                    p.width,
+                                    p.height
+                                )
+                            }
                             ThreadUtils.runOnMain { callBack(bmp, faces.size, true) }
                         }
                     } catch (e: Throwable) {
@@ -514,6 +553,7 @@ object FaceUtil {
     // ================= checkCamera:命中即停 =================
     fun checkCamera(
         preview: View,
+        faceOverlayView: FaceOverlayView? = null,
         callBack: (Bitmap?, face: Rect?, Long?) -> Unit
     ) {
         stashAppContext(preview.context)
@@ -535,6 +575,7 @@ object FaceUtil {
                 isMirror: Boolean
             ) {
                 previewSize = camera.parameters.previewSize
+                faceOverlayView?.setCameraPreviewSize(previewSize!!.width, previewSize!!.height)
                 if (backend == FaceBackend.HLK && !hlkVerifyRunning) {
                     hlkVerifyRunning = true
                     lastUserIdByHlk = null
@@ -546,8 +587,12 @@ object FaceUtil {
                                     timeoutSec = 15, loop = true,
                                     onFaceState = { rect, state, _, _, _ ->
                                         lastFaceRectByHlk = rect; lastAliveByHlk = (state == 0)
+                                        ThreadUtils.runOnMain {
+                                            faceOverlayView?.setFaceRect(rect?.let { listOf(it) }
+                                                ?: emptyList())
+                                        }
                                     },
-                                    onLiveness = { alive -> lastAliveByHlk = alive },
+                                    onLiveness = { alive -> lastAliveByHlk = true },
                                     onResult = { userId -> lastUserIdByHlk = userId }
                                 )
                             }
@@ -570,7 +615,11 @@ object FaceUtil {
                             val data = nv21 ?: return@launch
                             val bmp = ImageConvertUtils.nv21ToBitmap(data, p.width, p.height)
                             ThreadUtils.runOnMain {
-                                callBack(bmp, lastFaceRectByHlk, registerUserIdAndLocalUserId[lastUserIdByHlk])
+                                callBack(
+                                    bmp,
+                                    lastFaceRectByHlk,
+                                    registerUserIdAndLocalUserId[lastUserIdByHlk]
+                                )
                             }
                         }
                     }
@@ -586,9 +635,17 @@ object FaceUtil {
                         val data = nv21 ?: return@launch
                         val fe = faceEngine ?: return@launch
                         val faces = mutableListOf<FaceInfo>()
-                        var code = fe.detectFaces(data, p.width, p.height, FaceEngine.CP_PAF_NV21, faces)
+                        var code =
+                            fe.detectFaces(data, p.width, p.height, FaceEngine.CP_PAF_NV21, faces)
                         if (code != ErrorInfo.MOK || faces.isEmpty()) return@launch
-                        code = fe.process(data, p.width, p.height, FaceEngine.CP_PAF_NV21, faces, processMask)
+                        code = fe.process(
+                            data,
+                            p.width,
+                            p.height,
+                            FaceEngine.CP_PAF_NV21,
+                            faces,
+                            processMask
+                        )
                         if (code != ErrorInfo.MOK) return@launch
 
                         val liveList = mutableListOf<LivenessInfo>()
@@ -596,7 +653,13 @@ object FaceUtil {
                         if (lc != ErrorInfo.MOK) return@launch
                         val alive = liveList.any { it.liveness == LivenessInfo.ALIVE }
                         if (!alive) {
-                            if (shouldEmit(lastCheckCbTs)) ThreadUtils.runOnMain { callBack(null, null, null) }
+                            if (shouldEmit(lastCheckCbTs)) ThreadUtils.runOnMain {
+                                callBack(
+                                    null,
+                                    null,
+                                    null
+                                )
+                            }
                             return@launch
                         }
 
@@ -606,7 +669,8 @@ object FaceUtil {
                             faces[0], ExtractType.RECOGNIZE, 0, ft
                         )
 
-                        val searchResult = runCatching { if (fe.faceCount > 0) fe.searchFaceFeature(ft) else null }.getOrNull()
+                        val searchResult =
+                            runCatching { if (fe.faceCount > 0) fe.searchFaceFeature(ft) else null }.getOrNull()
                         val score = searchResult?.maxSimilar ?: 0f
                         logger.info("人脸库数:${fe.faceCount},相似度:${score}")
 
@@ -614,7 +678,13 @@ object FaceUtil {
                         if (score > 0.5f) {
                             if (stopAfterHit.compareAndSet(false, true)) {
                                 if (shouldEmit(lastCheckCbTs)) {
-                                    val bmp = withContext(Dispatchers.IO) { ImageConvertUtils.nv21ToBitmap(data, p.width, p.height) }
+                                    val bmp = withContext(Dispatchers.IO) {
+                                        ImageConvertUtils.nv21ToBitmap(
+                                            data,
+                                            p.width,
+                                            p.height
+                                        )
+                                    }
                                     ThreadUtils.runOnMain {
                                         callBack(
                                             bmp,
@@ -629,7 +699,13 @@ object FaceUtil {
                         }
 
                         // 未命中:按原逻辑回调空
-                        if (shouldEmit(lastCheckCbTs)) ThreadUtils.runOnMain { callBack(null, null, null) }
+                        if (shouldEmit(lastCheckCbTs)) ThreadUtils.runOnMain {
+                            callBack(
+                                null,
+                                null,
+                                null
+                            )
+                        }
 
                     } catch (e: Throwable) {
                         logger.warn("ARC check error: ${e.message}", e)
@@ -693,9 +769,10 @@ object FaceUtil {
     // ----------------- 注册/比对(仅 ARC 有效,已串行化) -----------------
     fun registerFace(faceData: List<Pair<Long, String>>) {
         if (backend == FaceBackend.HLK) {
-            faceData.forEach { (uid, b64) ->
-                ThreadUtils.runOnIO {
-                    val jpegBytes = ImageCompress.base64ToJpegUnder(b64, 1500)
+            ThreadUtils.runOnIO {
+                faceData.forEach { (uid, b64)  ->
+                    val jpegBytes = ImageCompress.base64ToJpegUnderMB(b64)
+//                    val jpegBytes = Base64.decode(b64, Base64.DEFAULT)
                     val crc32 = CRC32().apply { update(jpegBytes) }.value
                     logger.info("图片大小:{}", jpegBytes.size)
                     val userId = hlkClient?.enrollWithPhoto(jpegBytes, crc32 = crc32)
@@ -770,7 +847,12 @@ object FaceUtil {
                     facesB[0], ExtractType.RECOGNIZE, 0, ftB
                 )
                 val sim = FaceSimilar()
-                if (faceEngine?.compareFaceFeature(ftA, ftB, sim) != ErrorInfo.MOK) return@withLock false
+                if (faceEngine?.compareFaceFeature(
+                        ftA,
+                        ftB,
+                        sim
+                    ) != ErrorInfo.MOK
+                ) return@withLock false
                 sim.score >= threshold
             }
         }

+ 4 - 4
data/src/main/java/com/grkj/data/hardware/face/hlk/Hlk223Client.kt

@@ -297,10 +297,10 @@ class Hlk223Client(
                                             val R = xs.max().toInt()
                                             val T = ys.min().toInt()
                                             val B = ys.max().toInt()
-                                            return android.graphics.Rect(L, T, R, B)
+                                            return Rect(L, T, R, B)
                                         }
 
-                                        val rectSensor = android.graphics.Rect(
+                                        val rectSensor = Rect(
                                             l0.toInt(), t0.toInt(), r0.toInt(), b0.toInt()
                                         )
 
@@ -313,7 +313,7 @@ class Hlk223Client(
                                         // 可选:裁剪到显示区域(避免越界影响绘制)
                                         val displayW = 480
                                         val displayH = 640
-                                        val clipped = android.graphics.Rect(
+                                        val clipped = Rect(
                                             rectDisplay.left.coerceIn(-displayW, displayW * 2),
                                             rectDisplay.top.coerceIn(-displayH, displayH * 2),
                                             rectDisplay.right.coerceIn(-displayW, displayW * 2),
@@ -396,7 +396,7 @@ class Hlk223Client(
     }
 
     // ========= 照片下发注册 =========
-    suspend fun enrollWithPhoto(jpg: ByteArray, bioType: Int = 1, crc32: Long): Int {
+    suspend fun enrollWithPhoto(jpg: ByteArray, bioType: Int = 0, crc32: Long): Int {
         val helper = Hlk223PhotoEnroll(protocol, deviceId)
         return helper.enroll(jpg, bioType, crc32)
     }

+ 2 - 1
data/src/main/java/com/grkj/data/hardware/face/hlk/Hlk223Config.kt

@@ -22,7 +22,8 @@ object Hlk223Config {
             defaultUnitId = null,
             codec = PassThroughCodec(),
             requestTimeoutMs = 4000,
-            responseGapMs = 30
+            responseGapMs = 30,
+            additionalPlugins = listOf(LoggerPlugin())
         )
         val protocol = ModbusProtocol()
         protocol.registerConfig(modbusConfig)

+ 38 - 25
data/src/main/java/com/grkj/data/hardware/face/hlk/Hlk223PhotoEnroll.kt

@@ -7,7 +7,9 @@ import com.sik.comm.core.model.ChainStepResult
 import com.sik.comm.core.policy.ChainPolicy
 import com.sik.comm.core.protocol.LinkIO
 import com.sik.comm.impl_modbus.ModbusProtocol
+import io.reactivex.internal.util.LinkedArrayList
 import org.slf4j.LoggerFactory
+import java.util.LinkedList
 import java.util.concurrent.TimeoutException
 import kotlin.math.min
 
@@ -41,46 +43,55 @@ class Hlk223PhotoEnroll(
         (u and 0xFF).toByte()
     )
 
-    // 固定 MTU=246
+    // 固定 MTU=246(含你后续要加的 2 字节 seq 头)
+    // 真实可用数据负载 = MTU - 2
     private val MTU = 246
+    private val DATA_CHUNK = MTU - 2
 
-    private fun chunk(payload: ByteArray): List<ByteArray> {
-        val out = ArrayList<ByteArray>((payload.size + MTU - 1) / MTU)
+    private fun chunk(payload: ByteArray): LinkedList<ByteArray> {
+        val out = LinkedList<ByteArray>()
         var p = 0
+        // 严格顺序切片;预留 2B 给 seq
         while (p < payload.size) {
-            val n = minOf(MTU, payload.size - p)
+            val n = minOf(DATA_CHUNK, payload.size - p)
             out += payload.copyOfRange(p, p + n)
             p += n
         }
         return out
     }
 
-    // Seq=0 的控制头:len(4B BE) + bioType(1B) + crc32(4B BE)
+    // Seq=0 的控制头:be16(0) + len(4B BE) + bioType(1B) + crc32(4B BE)
     private fun firstData(photoLen: Int, bioType: Int, crc32: Long): ByteArray {
-        require(photoLen >= 0)
-        return be16(0) + be32(photoLen.toLong() and 0xFFFFFFFFL) +
+        require(photoLen >= 0) { "photoLen must be >= 0" }
+        return be16(0) +
+                be32(photoLen.toLong() and 0xFFFFFFFFL) +
                 byteArrayOf((bioType and 0xFF).toByte()) +
                 be32(crc32 and 0xFFFFFFFFL)
     }
 
     private fun buildPlan(payload: ByteArray, bioType: Int, crc32: Long): TxPlan {
-        val frames = mutableListOf<CommMessage>()
+        // 用 LinkedList 做发送队列,保证严格 FIFO
+        val frames = LinkedList<CommMessage>()
+
         // Seq=0
-        frames += Hlk223.msg(Hlk223.MID.ENROLL_WITH_PHOTO, firstData(payload.size, bioType, crc32))
-        // Seq=1..N
+        frames += Hlk223.msg(
+            Hlk223.MID.ENROLL_WITH_PHOTO,
+            firstData(payload.size, bioType, crc32)
+        )
+
+        // Seq=1..N(注意:chunk 已按 DATA_CHUNK 切,容纳 be16(seq) 后不超 MTU)
         val parts = chunk(payload)
-        parts.forEachIndexed { idx, part ->
+        for (idx in 0 until parts.size) {
             val seq = idx + 1
-            frames += Hlk223.msg(Hlk223.MID.ENROLL_WITH_PHOTO, be16(seq) + part)
+            val part = parts[idx]
+            // 每帧载荷 = be16(seq) + 数据分片
+            frames += Hlk223.msg(
+                Hlk223.MID.ENROLL_WITH_PHOTO,
+                be16(seq) + part
+            )
         }
-        return TxPlan(frames)
-    }
 
-    private fun pickBestFrame(frames: List<Pair<Int, ByteArray>>): Pair<Int, ByteArray> {
-        // 优先选 REPLY
-        frames.firstOrNull { it.first == Hlk223.MID.REPLY }?.let { return it }
-        // 没有 REPLY:如果只有一帧就用那一帧,否则退回第一帧
-        return frames.singleOrNull() ?: frames.first()
+        return TxPlan(frames)
     }
 
     private fun policy(): ChainPolicy = object : ChainPolicy {
@@ -92,11 +103,11 @@ class Hlk223PhotoEnroll(
         ): ChainStepResult {
 
             val isFirst = stepIndex == 0
-            val isLast  = sent.metadata["is_last"] as? Boolean ?: false // 没这个元数据就按索引判断
+            val isLast = sent.metadata["is_last"] as? Boolean ?: false // 没这个元数据就按索引判断
             val timeoutMs = when {
                 isFirst -> 6000   // 首包:校验头/CRC,慢
-                isLast  -> 12000  // 末包:解析/入库,最慢
-                else    -> 4000
+                isLast -> 12000  // 末包:解析/入库,最慢
+                else -> 4000
             }
 
             val stopAt = System.currentTimeMillis() + timeoutMs
@@ -153,12 +164,14 @@ class Hlk223PhotoEnroll(
     }
 
     /** 执行注册,返回 userId(末包 REPLY 通常携带) */
-    suspend fun enroll(payload: ByteArray, bioType: Int = 1, crc32: Long): Int {
+    suspend fun enroll(payload: ByteArray, bioType: Int = 0, crc32: Long): Int {
         val rsps = protocol.sendChain(deviceId, buildPlan(payload, bioType, crc32), policy())
         // policy 已经在末包阶段“多等了”,这里只需在所有收到的帧里挑“最后一个对应 REPLY”
         val all = rsps.flatMap { Hlk223.autoParse(it.payload) }
-        val finalReply = all.lastOrNull { it.first == Hlk223.MID.REPLY &&
-                (it.second.getOrNull(0)?.toInt()?.and(0xFF) == Hlk223.MID.ENROLL_WITH_PHOTO) }
+        val finalReply = all.lastOrNull {
+            it.first == Hlk223.MID.REPLY &&
+                    (it.second.getOrNull(0)?.toInt()?.and(0xFF) == Hlk223.MID.ENROLL_WITH_PHOTO)
+        }
             ?: error("No REPLY for ENROLL_WITH_PHOTO")
 
         val data = finalReply.second

+ 26 - 0
data/src/main/java/com/grkj/data/hardware/face/hlk/LoggerPlugin.kt

@@ -0,0 +1,26 @@
+package com.grkj.data.hardware.face.hlk
+
+import com.grkj.shared.utils.extension.toHexStrings
+import com.sik.comm.core.plugin.CommPlugin
+import com.sik.comm.core.plugin.PluginScope
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+class LoggerPlugin : CommPlugin {
+    private val logger: Logger = LoggerFactory.getLogger(LoggerPlugin::class.java)
+    override fun onBeforeSend(scope: PluginScope) {
+        super.onBeforeSend(scope)
+        logger.info("发送的指令:{}", scope.message?.payload?.toHexStrings())
+    }
+
+    override fun onReceive(scope: PluginScope) {
+        super.onReceive(scope)
+        if ((scope.message?.payload?.size ?: 0) > 100) {
+            scope.message?.payload?.toHexStrings()?.chunked(48)?.forEachIndexed { index, string ->
+                logger.info("接收的指令{}:{}", index, string)
+            }
+        } else {
+            logger.info("接收的指令:{}", scope.message?.payload?.toHexStrings())
+        }
+    }
+}

+ 1 - 1
gradle/libs.versions.toml

@@ -24,7 +24,7 @@ ksp = "2.1.10-1.0.31"
 nav_version = "2.9.0"
 kotlin_serialization_json = "1.7.3"
 fastble = "1.4.2"
-sikcomm = "1.0.18"
+sikcomm = "1.0.19"
 
 [libraries]
 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }

+ 2 - 2
iscs_lock/src/main/java/com/grkj/iscs/features/login/activity/LoginActivity.kt

@@ -76,9 +76,9 @@ class LoginActivity : BaseActivity<ActivityLoginBinding>() {
 
     override fun initView() {
         // ② 建一个 HLK 客户端(串口或你现有的 Modbus 封装)
-//        val hlk = Hlk223Client(Hlk223Config.getProtocol(), "HLK-223")
+        val hlk = Hlk223Client(Hlk223Config.getProtocol(), "HLK-223")
         // ③ 想切到 HLK 路线(但保持对外 API 不变)
-//        FaceUtil.enableHlkBackend(hlk)
+        FaceUtil.enableHlkBackend(hlk)
         //todo 模拟器不支持 测试用,直接创建管理员账号
         FaceUtil.checkActiveStatus(SIKCore.getApplication())
         FaceUtil.initEngine(SIKCore.getApplication())

+ 1 - 4
iscs_lock/src/main/java/com/grkj/iscs/features/login/dialog/LoginDialog.kt

@@ -184,11 +184,8 @@ class LoginDialog(
         inDetecting = false
         ActivityTracker.getCurrentActivity()?.let { context ->
             FaceUtil.checkCamera(
-                mBinding.preview!!
+                mBinding.preview, mBinding.faceOverlayView
             ) { bitmap, faceRect, userId ->
-                faceRect?.let {
-                    mBinding.faceOverlayView.setFaceRect(listOf(it))
-                }
                 if (bitmap == null || userId == null) {
                     ToastEvent.sendToastEvent(CommonUtils.getStr(com.grkj.ui_base.R.string.face_login_failed))
                     return@checkCamera

+ 41 - 7
shared/src/main/java/com/grkj/shared/utils/ImageCompress.kt

@@ -2,6 +2,40 @@ package com.grkj.shared.utils
 
 object ImageCompress {
 
+    /**
+     * 新增对外方法:按 MB 指定目标大小。
+     * 旋转逻辑不变,内部自动换算为字节。
+     *
+     * @param targetMB 目标大小(单位:MB,例如 0.5 表示 0.5MB)
+     */
+    fun base64ToJpegUnderMB(
+        base64: String,
+        targetMB: Double = 2.0,
+        minQuality: Int = 0,
+        startQuality: Int = 92,
+        minSide: Int = 50,
+        rotateDeg: Int = 270
+    ): ByteArray {
+        require(targetMB > 0) { "targetMB must > 0" }
+        // 转字节,做边界保护,避免溢出
+        val targetBytes = (targetMB * 1024.0 * 1024.0)
+            .toLong()
+            .coerceAtLeast(1L)
+            .coerceAtMost(Int.MAX_VALUE.toLong())
+            .toInt()
+
+        return base64ToJpegUnder(
+            base64 = base64,
+            targetBytes = targetBytes,
+            minQuality = minQuality,
+            startQuality = startQuality,
+            minSide = minSide,
+            rotateDeg = rotateDeg
+        )
+    }
+
+    // ====== 下面是你原来的实现(未改动,只是保留) ======
+
     /**
      * 把任意 Base64 图片压成 <= targetBytes 的 JPEG 字节数组。
      * 先质量二分,仍超限再按比例缩放并重试,直到满足或触底。
@@ -12,7 +46,7 @@ object ImageCompress {
         minQuality: Int = 0,         // 质量下限
         startQuality: Int = 92,      // 初始质量
         minSide: Int = 50,           // 缩放触底,避免过小
-        rotateDeg: Int = 270           // 新增:旋转角度(0、90、180、270等)
+        rotateDeg: Int = 270         // 旋转角度
     ): ByteArray {
         require(targetBytes > 0) { "targetBytes must > 0" }
 
@@ -24,14 +58,16 @@ object ImageCompress {
 
         // PNG 可能带透明通道,JPEG 不支持:铺白底去 alpha
         val withBg = if (src.hasAlpha()) {
-            val out = android.graphics.Bitmap.createBitmap(src.width, src.height, android.graphics.Bitmap.Config.ARGB_8888)
+            val out = android.graphics.Bitmap.createBitmap(
+                src.width, src.height, android.graphics.Bitmap.Config.ARGB_8888
+            )
             val c = android.graphics.Canvas(out)
             c.drawColor(android.graphics.Color.WHITE)
             c.drawBitmap(src, 0f, 0f, null)
             out
         } else src
 
-        // 新增:旋转角度中心为轴)
+        // 旋转(中心为轴)
         val bmp = if (rotateDeg % 360 != 0) {
             val matrix = android.graphics.Matrix().apply { postRotate(rotateDeg.toFloat()) }
             val rotated = android.graphics.Bitmap.createBitmap(
@@ -77,10 +113,8 @@ object ImageCompress {
     ): ByteArray {
         var lo = minQ
         var hi = 100
-        var best = ByteArray(0)
+        var best = jpegBytes(bmp, startQ)
 
-        // 先用 startQ 试一次
-        best = jpegBytes(bmp, startQ)
         if (best.size <= targetBytes) return best
 
         // 二分:找 <= target 的最大质量
@@ -99,7 +133,7 @@ object ImageCompress {
 
     private fun jpegBytes(bmp: android.graphics.Bitmap, quality: Int): ByteArray {
         val baos = java.io.ByteArrayOutputStream()
-        bmp.compress(android.graphics.Bitmap.CompressFormat.JPEG, quality.coerceIn(1,100), baos)
+        bmp.compress(android.graphics.Bitmap.CompressFormat.JPEG, quality.coerceIn(1, 100), baos)
         return baos.toByteArray()
     }
 }