diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/ScanPage.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/ScanPage.kt index 94c861f..f16f869 100644 --- a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/ScanPage.kt +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/ScanPage.kt @@ -45,6 +45,15 @@ fun ScanPage(onBack: () -> Unit) { val scanState by viewModel.state.collectAsStateWithLifecycle() val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + // Unbind camera when leaving the page (prevents ImageReader queue overflow) + DisposableEffect(Unit) { + onDispose { + try { + ProcessCameraProvider.getInstance(context).get().unbindAll() + } catch (_: Exception) { } + cameraExecutor.shutdown() + } + } val analyzer = remember { OvImageAnalyzer(viewModel) } // Track whether camera should be bound diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/ContourSelector.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/ContourSelector.kt index eaa41da..0ad7497 100644 --- a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/ContourSelector.kt +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/ContourSelector.kt @@ -51,7 +51,8 @@ object ContourSelector { contourGf.rightBottom = Point((rect.x + rect.width).toDouble(), (rect.y + rect.height).toDouble()) val roi = Rect(roteRect.center.x.toInt(), roteRect.center.y.toInt(), MIN_VALUE, MIN_VALUE) - if ((roi.x + MIN_VALUE) > (src.width() - 1) + if (roi.x < 0 || roi.y < 0 + || (roi.x + MIN_VALUE) > (src.width() - 1) || (roi.y + MIN_VALUE) > (src.height() - 1) || width < MIN_VALUE || height < MIN_VALUE diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/ScanViewModel.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/ScanViewModel.kt index ea6f3d1..2b133c9 100644 --- a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/ScanViewModel.kt +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/ScanViewModel.kt @@ -158,12 +158,11 @@ class ScanViewModel : ViewModel() { roiRect = Rect(0, 0, procW, procH) } - // Step 1: Locate the strip — skip locate() for camera (always fails with center distance ratio) - // go directly to locateCroppedStrip which works better on full camera frames - val rgbMat = Mat() - Imgproc.cvtColor(srcRgba, rgbMat, Imgproc.COLOR_RGBA2RGB) - var stripResult = StripLocator.locateCroppedStrip(rgbMat) - rgbMat.release() + // Step 1: Locate the strip using C++ pipeline (find green blocks → crop → detect C/T lines) + var stripResult = StripLocator.locateAndDetectCT(srcRgba, roiRect!!) + if (stripResult.errorCode != 0) { + Log.d(TAG, "locateAndDetectCT failed (errorCode=${stripResult.errorCode}), skipping frame") + } if (stripResult.errorCode != 0) { // Update preview bitmap even on failure (shows the source image) @@ -202,6 +201,12 @@ class ScanViewModel : ViewModel() { // Step 3: Classify val result = OvClassifier.classify(features) + // Log first 3 frames' features for diagnostics + val frameIdx = synchronized(results) { results.size + 1 } + if (frameIdx <= 3) { + Log.d(TAG, "Frame $frameIdx features: ${features.joinToString(",") { "%.4f".format(it) }}") + } + synchronized(results) { results.add(result) val count = results.size diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/StripLocator.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/StripLocator.kt index 2a17ae3..046114f 100644 --- a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/StripLocator.kt +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/StripLocator.kt @@ -105,7 +105,7 @@ object StripLocator { val srcHei = roiRect.height // Extract ROI - val roiSrc = srcRgba.submat(roiRect) + val roiSrcRaw = srcRgba.submat(roiRect) if (drawAnnotations) { Imgproc.rectangle(srcRgba, @@ -137,7 +137,9 @@ object StripLocator { // Problem: grayscale is sensitive to brightness/reflections. Green blocks // may not contrast well with background under glare. val s = Mat() - Imgproc.cvtColor(roiSrc, roiSrc, Imgproc.COLOR_RGBA2RGB) // RGBA → BGR + val roiSrc = Mat() + Imgproc.cvtColor(roiSrcRaw, roiSrc, Imgproc.COLOR_RGBA2RGB) + roiSrcRaw.release() val hsv = Mat() Imgproc.cvtColor(roiSrc, hsv, Imgproc.COLOR_RGB2HSV) // BGR → HSV val hsvMats = mutableListOf() @@ -163,7 +165,7 @@ object StripLocator { Imgproc.findContours(sThresoldImg, contours0, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE) if (contours0.isEmpty()) { Log.d(TAG, "Step 3 fail: no contours found after OTSU+morph (S-channel)") - sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() roiSrc.release() return StripResult(errorCode = -9, paperColor = PaperColor.Unknown) } @@ -228,7 +230,7 @@ object StripLocator { if (effectiveGreenContours.size < 2) { Log.d(TAG, "Step 4 fail: need >=2 green contours, got ${effectiveGreenContours.size}") - sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() roiSrc.release() return StripResult(errorCode = -9, paperColor = PaperColor.Green) } @@ -243,7 +245,7 @@ object StripLocator { if (gfsFilteredH.size < 2) { Log.d(TAG, "Step 4b fail: height filter left ${gfsFilteredH.size} contours (maxH=$maxHeight)") - sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() roiSrc.release() return StripResult(errorCode = -9, paperColor = PaperColor.Green) } @@ -258,7 +260,7 @@ object StripLocator { else lastArea.toDouble() / firstArea if (areaRatio < 1.1 || areaRatio > 8.0) { Log.d(TAG, "Step 5 fail: area ratio $areaRatio not in [1.1, 8.0]") - sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() roiSrc.release() return StripResult(errorCode = -8, paperColor = PaperColor.Green) } @@ -288,7 +290,7 @@ object StripLocator { || bottomY + MIN_PIX_VAL >= srcHei ) { Log.d(TAG, "Step 6 fail: halfH=$realHalfHBig, bounds=($leftX,$topY)-($rightX,$bottomY) src=${srcWid}x${srcHei}") - sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release(); roiSrc.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release(); roiSrc.release() return StripResult(errorCode = -10, paperColor = PaperColor.Unknown) } @@ -311,7 +313,7 @@ object StripLocator { || rcColor1.x <= 0 || rcColor1.x + rcColor1.width >= srcWid || rcColor1.y <= 0 || rcColor1.y + rcColor1.height >= srcHei ) { - sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() wbRoiSrc.release(); roiSrc.release() return StripResult(errorCode = -11, paperColor = PaperColor.Unknown) } @@ -335,7 +337,7 @@ object StripLocator { mc1.release() if (paperColor == PaperColor.Unknown || paperColor != PaperColor.Green) { Log.d(TAG, "Step 9 fail: paperColor=$paperColor") - sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() wbRoiSrc.release(); roiSrc.release() return StripResult(errorCode = -1, paperColor = paperColor) } @@ -351,7 +353,7 @@ object StripLocator { realAngle = -angle - 90 } else { Log.d(TAG, "Step 10 fail: rotRcH == rotRcW (square block, cannot determine orientation)") - sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() wbRoiSrc.release(); roiSrc.release() return StripResult(errorCode = -1, paperColor = paperColor) } @@ -373,7 +375,7 @@ object StripLocator { mc2.release() if (paperColor != color2) { Log.d(TAG, "Step 10 fail: second block color mismatch: first=$paperColor second=$color2") - sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() wbRoiSrc.release(); roiSrc.release() return StripResult(errorCode = -1, paperColor = paperColor) } @@ -384,7 +386,7 @@ object StripLocator { val whRadioSmall = realHalfWSmall.toDouble() / (realHalfHSmall + 0.01) if (whRadioSmall < 1.3 || whRadioSmall > 8.0) { Log.d(TAG, "Small block whRatio fail: $whRadioSmall not in [1.3, 8.0]") - sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() wbRoiSrc.release(); roiSrc.release() return StripResult(errorCode = -1, paperColor = paperColor) } @@ -394,7 +396,7 @@ object StripLocator { val heightDiff = Math.abs(realHalfHBig - realHalfHSmall).toDouble() / (realHalfHSmall + 0.01) if (heightDiff > 0.30) { Log.d(TAG, "Block height mismatch: bigH=$realHalfHBig smallH=$realHalfHSmall diff=$heightDiff") - sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() wbRoiSrc.release(); roiSrc.release() return StripResult(errorCode = -1, paperColor = paperColor) } @@ -407,7 +409,7 @@ object StripLocator { val distRadio = centerPointDist / (realHalfWBig * 2.0) if (distRadio < 1 || distRadio > 1.65) { Log.d(TAG, "Step 10c fail: center distance ratio $distRadio not in [1, 1.65]") - sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() wbRoiSrc.release(); roiSrc.release() return StripResult(errorCode = -1, paperColor = paperColor) } @@ -471,7 +473,7 @@ object StripLocator { || (autoRoi.x + autoRoi.width) >= (maskSrc.width() - 1) || (autoRoi.y + autoRoi.height) >= (maskSrc.height() - 1) ) { - sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() wbRoiSrc.release(); roiSrc.release() mask.release(); maskSrc.release() return StripResult(errorCode = -11, paperColor = paperColor) @@ -718,20 +720,23 @@ object StripLocator { val hChannel = hsvChannels[0] // Hue val sChannel = hsvChannels[1] // Saturation - // Create green mask: H in [30, 85] AND S > 30 + // Create green mask: H in [30, 85] AND S > 20 (lowered from 30 for reflections) val hMaskLow = Mat() val hMaskHigh = Mat() val sMask = Mat() Imgproc.threshold(hChannel, hMaskLow, H_GREEN_MIN, 255.0, Imgproc.THRESH_BINARY) Imgproc.threshold(hChannel, hMaskHigh, H_GREEN_MAX, 255.0, Imgproc.THRESH_BINARY_INV) - Imgproc.threshold(sChannel, sMask, 30.0, 255.0, Imgproc.THRESH_BINARY) + Imgproc.threshold(sChannel, sMask, 20.0, 255.0, Imgproc.THRESH_BINARY) val hMask = Mat() Core.bitwise_and(hMaskLow, hMaskHigh, hMask) val greenMask = Mat() Core.bitwise_and(hMask, sMask, greenMask) - // Morphological open to clean noise + // Morphological close + open: close merges regions split by reflections, + // open cleans up noise. Use 5x5 kernel for close (stronger merge). + val kernel5x5 = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(5.0, 5.0)) + Imgproc.morphologyEx(greenMask, greenMask, Imgproc.MORPH_CLOSE, kernel5x5) val kernel3x3 = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(3.0, 3.0)) Imgproc.morphologyEx(greenMask, greenMask, Imgproc.MORPH_OPEN, kernel3x3) @@ -740,9 +745,9 @@ object StripLocator { Imgproc.findContours(greenMask, contours, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE) if (contours.isEmpty()) { - hsv.release(); hChannel.release(); sChannel.release() + hsv.release(); hChannel.release(); sChannel?.release() hMaskLow.release(); hMaskHigh.release(); sMask.release() - hMask.release(); greenMask.release(); kernel3x3.release() + hMask.release(); greenMask.release(); kernel5x5.release(); kernel3x3.release() hsvChannels[2].release() return result } @@ -758,10 +763,10 @@ object StripLocator { val gfs2 = mutableListOf>() ContourSelector.selectContour(gfs1, gfs2, - roiSrc.cols() * 0.05, roiSrc.cols() * 0.95, GfFlag.LeftTopX) + roiSrc.cols() * 0.02, roiSrc.cols() * 0.98, GfFlag.LeftTopX) val gfs3 = mutableListOf>() - ContourSelector.selectContour(gfs2, gfs3, 30.0, 100000.0, GfFlag.Area) + ContourSelector.selectContour(gfs2, gfs3, 15.0, 100000.0, GfFlag.Area) val gfs4 = mutableListOf>() ContourSelector.selectContour(gfs3, gfs4, 0.40, 1.1, GfFlag.Rectangularity) @@ -774,9 +779,9 @@ object StripLocator { // so no need for color pre-filtering. Just return them. result.addAll(gfs4) - hsv.release(); hChannel.release(); sChannel.release() + hsv.release(); hChannel.release(); sChannel?.release() hMaskLow.release(); hMaskHigh.release(); sMask.release() - hMask.release(); greenMask.release(); kernel3x3.release() + hMask.release(); greenMask.release(); kernel5x5.release(); kernel3x3.release() hsvChannels[2].release() return result @@ -968,6 +973,718 @@ object StripLocator { ) } + /** + * Full C++ pipeline: locate green blocks → crop strip → detect C/T lines. + * + * Aligns with ImageLocationColloidalGold.cpp GetPositions(): + * 1. Find green locator blocks via S-channel OTSU + color pre-filtering + * 2. Compute rotation, crop and warp the strip region + * 3. Detect C/T lines on the cropped strip with C++ filter thresholds + * + * C++ C/T filter order: RightBottomX → LeftTopX → Rectangularity → Height(0.7) → Width(0.045) + * C++ dedup: keep one contour per side (left/right of center) + * C++ single-line validation: stddev check on artificial T line region + * + * @param srcRgba Full RGBA camera frame (will be modified internally) + * @param roiRect Region of interest (typically full frame) + * @return StripResult with extracted ROIs, or errorCode != 0 on failure + */ + fun locateAndDetectCT(srcRgba: Mat, roiRect: Rect): StripResult { + val srcWid = roiRect.width + val srcHei = roiRect.height + val roiSubmat = srcRgba.submat(roiRect) + val roiSrc = Mat() + Imgproc.cvtColor(roiSubmat, roiSrc, Imgproc.COLOR_RGBA2RGB) + roiSubmat.release() + + // ===================================================================== + // PHASE 1: GREEN BLOCK DETECTION (aligned with C++ _filterAndJudgeVertical) + // ===================================================================== + + // Try HSV first (more reliable on camera), OTSU as fallback + var sChannel: Mat? = null + var sCpy: Mat? = null + var sThresoldImg: Mat? = null + var kernel5x5: Mat? = null + var effectiveContours = tryDirectHsvThresholding(roiSrc) + if (effectiveContours.size >= 2) { + Log.d(TAG, "locateAndDetectCT: HSV primary found ${effectiveContours.size} green contours") + } else { + Log.d(TAG, "locateAndDetectCT: HSV found <2 green, trying OTSU fallback") + + // Convert to HSV, extract S channel for OTSU + val hsv = Mat() + Imgproc.cvtColor(roiSrc, hsv, Imgproc.COLOR_RGB2HSV) + val hsvMats = mutableListOf() + Core.split(hsv, hsvMats) + sChannel = hsvMats[1] + hsvMats[0].release(); hsvMats[2].release(); hsv.release() + + sCpy = Mat() + sChannel!!.copyTo(sCpy) + + // OTSU on S channel (C++: threshold(s, sThresoldImg, 128, 255, THRESH_BINARY | THRESH_OTSU)) + sThresoldImg = Mat() + Imgproc.threshold(sChannel!!, sThresoldImg, 128.0, 255.0, + Imgproc.THRESH_BINARY or Imgproc.THRESH_OTSU) + + // Morphology open with 5×5 kernel (C++: morphologyEx(sThresoldImg, sThresoldImg, MORPH_OPEN, 5x5)) + kernel5x5 = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(5.0, 5.0)) + Imgproc.morphologyEx(sThresoldImg, sThresoldImg, Imgproc.MORPH_OPEN, kernel5x5) + + // Find external contours + val contours0 = mutableListOf() + Imgproc.findContours(sThresoldImg, contours0, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE) + if (contours0.isEmpty()) { + Log.d(TAG, "locateAndDetectCT: no contours after OTSU") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release(); roiSrc.release() + return StripResult(errorCode = -9, paperColor = PaperColor.Unknown) + } + + // Calculate geometric features + val gfsAll = mutableListOf>() + ContourSelector.calContoursGf(contours0, gfsAll, sChannel) + + // Filter pipeline (C++ thresholds) + val gfsRbX = mutableListOf>() + ContourSelector.selectContour(gfsAll, gfsRbX, + sChannel.cols() * 0.05, sChannel.cols() * 0.95, GfFlag.RightBottomX) + + val gfsLtX = mutableListOf>() + ContourSelector.selectContour(gfsRbX, gfsLtX, + sChannel.cols() * 0.05, sChannel.cols() * 0.95, GfFlag.LeftTopX) + + // C++: Area 50–100000 (lowered from 150 for small blocks in camera) + val gfsArea = mutableListOf>() + ContourSelector.selectContour(gfsLtX, gfsArea, 50.0, 100000.0, GfFlag.Area) + + // C++: Rectangularity 0.55–1.1 + val gfsRect = mutableListOf>() + ContourSelector.selectContour(gfsArea, gfsRect, 0.55, 1.1, GfFlag.Rectangularity) + + // Color pre-filtering: keep only green contours (C++: _readPaperColor at contour center) + val gfsGreen = mutableListOf>() + for ((contour, gf) in gfsRect) { + val cx = gf.contourCenter.x.toInt() + val cy = gf.contourCenter.y.toInt() + if (cx > 10 && cy > 10 && cx + 5 < roiSrc.cols() && cy + 5 < roiSrc.rows()) { + val sampleRect = Rect(cx - 5, cy - 5, 10, 10) + val sample = roiSrc.submat(sampleRect) + val color = judgePaperColorAtPoint(sample, hsvMats) + sample.release() + if (color == PaperColor.Green) { + gfsGreen.add(Pair(contour, gf)) + } + } + } + Log.d(TAG, "locateAndDetectCT green blocks: raw=${gfsAll.size} → " + + "rbX=${gfsRbX.size} → ltX=${gfsLtX.size} → " + + "area=${gfsArea.size} → rect=${gfsRect.size} → green=${gfsGreen.size}") + effectiveContours = gfsGreen + } + + if (effectiveContours.size < 2) { + Log.d(TAG, "locateAndDetectCT fail: need >=2 green contours, got ${effectiveContours.size}") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release(); roiSrc.release() + return StripResult(errorCode = -9, paperColor = PaperColor.Green) + } + + // Safety: real strips have 2-5 green contours; excessive = noise, reject early + if (effectiveContours.size > 8) { + Log.d(TAG, "locateAndDetectCT fail: too many green contours (${effectiveContours.size}), likely noise") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release(); roiSrc.release() + return StripResult(errorCode = -9, paperColor = PaperColor.Unknown) + } + + // --- Height filtering: keep contours with height >= 30% of max (C++: 72.5%, relaxed for camera) --- + ContourSelector.sortContoursByGf(effectiveContours, GfFlag.Height, true) + val maxHeight = effectiveContours[0].second.size.height + val gfsH = mutableListOf>() + ContourSelector.selectContour(effectiveContours, gfsH, + maxHeight * 0.30, maxHeight * 1.0, GfFlag.Height) + + if (gfsH.size < 2) { + Log.d(TAG, "locateAndDetectCT fail: height filter left ${gfsH.size} contours (maxH=$maxHeight)") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release(); roiSrc.release() + return StripResult(errorCode = -9, paperColor = PaperColor.Green) + } + + // Sort by center X for left/right ordering + ContourSelector.sortContoursByGf(gfsH, GfFlag.CenterX, false) + + // --- Area ratio validation (C++: 2.0–4.0, relaxed to 1.0–30.0 for camera) --- + val firstArea = gfsH[0].second.contourArea + val lastArea = gfsH[gfsH.size - 1].second.contourArea + val areaRatio = if (firstArea > lastArea) firstArea.toDouble() / lastArea + else lastArea.toDouble() / firstArea + if (areaRatio < 1.0 || areaRatio > 30.0) { + Log.d(TAG, "locateAndDetectCT fail: area ratio $areaRatio not in [1.0, 30.0]") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release(); roiSrc.release() + return StripResult(errorCode = -8, paperColor = PaperColor.Green) + } + + // Keep only the leftmost and rightmost contours (C++ approach) + val gfsPair = mutableListOf(gfsH[0], gfsH[gfsH.size - 1]) + + // ===================================================================== + // PHASE 2: VALIDATE AND COMPUTE STRIP GEOMETRY + // ===================================================================== + + // C++: Determine big/small by contour area, NOT by position + // Physical specs: big block 30mm wide, small block 10mm wide, strip height 3-5mm + // Big block whRatio: 30/5 ~ 30/3 = 6~10, tolerance 4.5~12.5 + // Small block whRatio: 10/5 ~ 10/3 = 2~3.3, tolerance 1.5~4.5 + val gfp0 = gfsPair[0] + val gfp1 = gfsPair[1] + val rotRect0 = gfp0.second.rotRect + val rotRect1 = gfp1.second.rotRect + + val halfW0 = (Math.max(rotRect0.size.width, rotRect0.size.height) * 0.5).toInt() + val halfH0 = (Math.min(rotRect0.size.width, rotRect0.size.height) * 0.5).toInt() + val halfW1 = (Math.max(rotRect1.size.width, rotRect1.size.height) * 0.5).toInt() + val halfH1 = (Math.min(rotRect1.size.width, rotRect1.size.height) * 0.5).toInt() + + // C++: if left area > right area → left is big, right is small (bPaperRotated=true) + val bigGfp: Pair + val smallGfp: Pair + val realHalfWBig: Int; val realHalfHBig: Int + val realHalfWSmall: Int; val realHalfHSmall: Int + val bigRotRect: RotatedRect; val smallRotRect: RotatedRect + + if (gfp0.second.contourArea > gfp1.second.contourArea) { + bigGfp = gfp0; smallGfp = gfp1 + realHalfWBig = halfW0; realHalfHBig = halfH0 + realHalfWSmall = halfW1; realHalfHSmall = halfH1 + bigRotRect = rotRect0; smallRotRect = rotRect1 + } else { + bigGfp = gfp1; smallGfp = gfp0 + realHalfWBig = halfW1; realHalfHBig = halfH1 + realHalfWSmall = halfW0; realHalfHSmall = halfH0 + bigRotRect = rotRect1; smallRotRect = rotRect0 + } + + val leftX = bigGfp.second.leftTop.x.toInt() + val topY = bigGfp.second.leftTop.y.toInt() + val rightX = bigGfp.second.rightBottom.x.toInt() + val bottomY = bigGfp.second.rightBottom.y.toInt() + + if (realHalfHBig < MIN_PIX_VAL + || leftX - MIN_PIX_VAL <= 0 + || rightX + MIN_PIX_VAL >= srcWid + || topY - MIN_PIX_VAL <= 0 + || bottomY + MIN_PIX_VAL >= srcHei + ) { + Log.d(TAG, "locateAndDetectCT fail: boundary check") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release(); roiSrc.release() + return StripResult(errorCode = -10, paperColor = PaperColor.Unknown) + } + + // Auto white balance + paper color check (C++: _filterByLocatorColor) + val wbRoiSrc = Mat() + autoWhiteBalance(roiSrc, wbRoiSrc) + + val rcColor1 = Rect( + (bigRotRect.center.x - realHalfHBig / 2).toInt(), + (bigRotRect.center.y - realHalfHBig / 2).toInt(), + realHalfHBig, realHalfHBig + ) + if (rcColor1.x < 0 || rcColor1.y < 0 + || rcColor1.x + rcColor1.width > wbRoiSrc.cols() + || rcColor1.y + rcColor1.height > wbRoiSrc.rows() + ) { + Log.d(TAG, "locateAndDetectCT fail: rcColor1 out of bounds") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() + wbRoiSrc.release(); roiSrc.release() + return StripResult(errorCode = -11, paperColor = PaperColor.Unknown) + } + val mc1 = wbRoiSrc.submat(rcColor1) + val paperColor = judgePaperColor(mc1) + mc1.release() + if (paperColor != PaperColor.Green) { + Log.d(TAG, "locateAndDetectCT fail: paperColor=$paperColor") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() + wbRoiSrc.release(); roiSrc.release() + return StripResult(errorCode = -1, paperColor = paperColor) + } + + // --- Rotation correction (C++: _retrieveCtAreaInfo step 1, uses big block) --- + val bigRotRcW = bigRotRect.size.width.toInt() + val bigRotRcH = bigRotRect.size.height.toInt() + val angle = bigRotRect.angle + val bWgtH: Boolean + val realAngle: Double + if (bigRotRcH < bigRotRcW) { + bWgtH = true + realAngle = -angle + } else if (bigRotRcH > bigRotRcW) { + bWgtH = false + realAngle = -angle - 90 + } else { + Log.d(TAG, "locateAndDetectCT fail: square block") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() + wbRoiSrc.release(); roiSrc.release() + return StripResult(errorCode = -1, paperColor = paperColor) + } + + // Verify second (small) block is also green + val rcColor2 = Rect( + (smallRotRect.center.x - realHalfHSmall / 2).toInt(), + (smallRotRect.center.y - realHalfHSmall / 2).toInt(), + realHalfHSmall, realHalfHSmall + ) + if (rcColor2.x < 0 || rcColor2.y < 0 + || rcColor2.x + rcColor2.width > wbRoiSrc.cols() + || rcColor2.y + rcColor2.height > wbRoiSrc.rows() + ) { + Log.d(TAG, "locateAndDetectCT fail: rcColor2 out of bounds") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() + wbRoiSrc.release(); roiSrc.release() + return StripResult(errorCode = -11, paperColor = PaperColor.Unknown) + } + val mc2 = wbRoiSrc.submat(rcColor2) + val color2 = judgePaperColor(mc2) + mc2.release() + if (paperColor != color2) { + Log.d(TAG, "locateAndDetectCT fail: second block color mismatch") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() + wbRoiSrc.release(); roiSrc.release() + return StripResult(errorCode = -1, paperColor = paperColor) + } + + // --- Big block whRatio: 30mm / (3~5mm) = 6~10, relaxed to 3.0–15.0 for camera --- + val whRadioBig = realHalfWBig.toDouble() / (realHalfHBig + 0.01) + if (whRadioBig < 3.0 || whRadioBig > 15.0) { + Log.d(TAG, "locateAndDetectCT fail: big whRatio=$whRadioBig not in [3.0, 15.0]") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() + wbRoiSrc.release(); roiSrc.release() + return StripResult(errorCode = -1, paperColor = paperColor) + } + + // --- Small block whRatio: 10mm / (3~5mm) = 2~3.3, relaxed to 1.3–5.5 for camera --- + val whRadioSmall = realHalfWSmall.toDouble() / (realHalfHSmall + 0.01) + if (whRadioSmall < 1.3 || whRadioSmall > 5.5) { + Log.d(TAG, "locateAndDetectCT fail: small whRatio=$whRadioSmall not in [1.3, 5.5]") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() + wbRoiSrc.release(); roiSrc.release() + return StripResult(errorCode = -1, paperColor = paperColor) + } + + // --- Height similarity (C++: 12%, relaxed to 40% for camera) --- + val heightDiff = Math.abs(realHalfHBig - realHalfHSmall).toDouble() / (realHalfHSmall + 0.01) + if (heightDiff > 0.40) { + Log.d(TAG, "locateAndDetectCT fail: height diff=$heightDiff > 0.40") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() + wbRoiSrc.release(); roiSrc.release() + return StripResult(errorCode = -1, paperColor = paperColor) + } + + // --- Center distance ratio (C++: 1.0–1.7, relaxed to 0.8–1.8 for camera) --- + val centerPointDist = Math.sqrt( + (bigRotRect.center.x - smallRotRect.center.x) * (bigRotRect.center.x - smallRotRect.center.x) + + (bigRotRect.center.y - smallRotRect.center.y) * (bigRotRect.center.y - smallRotRect.center.y) + ) + val distRadio = centerPointDist / (realHalfWBig * 2.0) + if (distRadio < 0.8 || distRadio > 1.8) { + Log.d(TAG, "locateAndDetectCT fail: distRadio=$distRadio not in [0.8, 1.8]") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() + wbRoiSrc.release(); roiSrc.release() + return StripResult(errorCode = -13, paperColor = paperColor) + } + + // --- Draw green block bounding rectangles on srcRgba for visual feedback --- + // These are drawn on the full camera frame (srcRgba) so the user can see + // that positioning succeeded. Uses the boundingRect() of each RotatedRect. + val bigBlockRect = bigRotRect.boundingRect() + val smallBlockRect = smallRotRect.boundingRect() + Imgproc.rectangle(srcRgba, + Point((roiRect.x + bigBlockRect.x).toDouble(), (roiRect.y + bigBlockRect.y).toDouble()), + Point((roiRect.x + bigBlockRect.x + bigBlockRect.width).toDouble(), (roiRect.y + bigBlockRect.y + bigBlockRect.height).toDouble()), + Scalar(255.0, 0.0, 0.0, 255.0), 3) + Imgproc.rectangle(srcRgba, + Point((roiRect.x + smallBlockRect.x).toDouble(), (roiRect.y + smallBlockRect.y).toDouble()), + Point((roiRect.x + smallBlockRect.x + smallBlockRect.width).toDouble(), (roiRect.y + smallBlockRect.y + smallBlockRect.height).toDouble()), + Scalar(255.0, 0.0, 0.0, 255.0), 3) + + // ===================================================================== + // PHASE 3: CROP AND WARP THE STRIP REGION (C++: _retrieveCtAreaInfo) + // ===================================================================== + + var x0: Double; var y0: Double; var x1: Double; var y1: Double + val phi = realAngle * Math.PI / 180 + val sin = Math.sin(phi); val cos = Math.cos(phi) + + var shiftBig = 1.1; val shiftSmall = 1.65 + when { + distRadio > 1.4 -> shiftBig = 1.25 + distRadio > 1.3 -> shiftBig = 1.2 + distRadio > 1.2 -> shiftBig = 1.15 + } + + val bRot: Boolean + if (bigRotRect.center.x > smallRotRect.center.x) { + // Big block on the right, small block on the left + bRot = false + x0 = smallRotRect.center.x + (realHalfWSmall * shiftSmall) * cos + y0 = smallRotRect.center.y - (realHalfWSmall * shiftSmall) * sin + x1 = bigRotRect.center.x - cos * (realHalfWBig * shiftBig) + y1 = bigRotRect.center.y + sin * (realHalfWBig * shiftBig) + } else { + // Big block on the left, small block on the right + bRot = true + x0 = bigRotRect.center.x + (realHalfWBig * shiftBig) * cos + y0 = bigRotRect.center.y - (realHalfWBig * shiftBig) * sin + x1 = smallRotRect.center.x - cos * (realHalfWSmall * shiftSmall) + y1 = smallRotRect.center.y + sin * (realHalfWSmall * shiftSmall) + } + + val len1 = Math.sqrt((x0 - x1) * (x0 - x1) + (y0 - y1) * (y0 - y1)).toInt() + val len2 = (realHalfHBig * 1.5).toInt() + val centerX = ((x0 + x1) * 0.5).toInt() + val centerY = ((y0 + y1) * 0.5).toInt() + + // Build rotated rect and mask + val rotRect1C = Point(centerX.toDouble(), centerY.toDouble()) + val rotRc1Size = if (bWgtH) Size(len1.toDouble(), len2.toDouble()) + else Size(len2.toDouble(), len1.toDouble()) + val rotRect2 = RotatedRect(rotRect1C, rotRc1Size, angle) + val vPoints = arrayOfNulls(4) + rotRect2.points(vPoints) + val buildContour = MatOfPoint(*vPoints.requireNoNulls()) + + val mask = Mat(roiRect.height, roiRect.width, CvType.CV_8UC3) + mask.setTo(black) + Imgproc.drawContours(mask, listOf(buildContour), 0, white, -1) + + val maskSrc = Mat() + Core.bitwise_and(roiSrc, mask, maskSrc) + + val autoRoi = Imgproc.boundingRect(buildContour) + // Keep buildContour alive — used to draw C/T area outline on srcRgba later + if (autoRoi.x <= 0 || autoRoi.y <= 0 + || (autoRoi.x + autoRoi.width) >= (maskSrc.width() - 1) + || (autoRoi.y + autoRoi.height) >= (maskSrc.height() - 1) + ) { + Log.d(TAG, "locateAndDetectCT fail: autoRoi out of bounds") + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() + wbRoiSrc.release(); roiSrc.release(); mask.release(); maskSrc.release() + buildContour.release() + return StripResult(errorCode = -11, paperColor = paperColor) + } + + val autoRoiSrc = maskSrc.submat(autoRoi) + + // Warp affine for rotation correction + var rotAngle = angle + if (rotAngle < -45) rotAngle += 90.0 + val autoRoiCenter = Point(autoRoiSrc.width() * 0.5, autoRoiSrc.height() * 0.5) + val waMat = Imgproc.getRotationMatrix2D(autoRoiCenter, rotAngle, 1.0) + val waRoiSrc = Mat() + Imgproc.warpAffine(autoRoiSrc, waRoiSrc, waMat, autoRoiSrc.size(), Imgproc.INTER_LINEAR) + waMat.release() + + val rotRect2W = Math.max(rotRect2.size.width, rotRect2.size.height).toInt() + val rotRect2H = Math.min(rotRect2.size.width, rotRect2.size.height).toInt() + val roi = Rect( + (autoRoiCenter.x - rotRect2W * 0.45).toInt(), + (autoRoiCenter.y - rotRect2H * 0.45).toInt(), + (rotRect2W * 0.9).toInt(), + (rotRect2H * 0.9).toInt() + ) + + // Clamp roi to waRoiSrc bounds (prevents crash when green blocks are imprecise) + val clampedRoi = Rect( + maxOf(0, roi.x), + maxOf(0, roi.y), + minOf(roi.width, waRoiSrc.cols() - maxOf(0, roi.x)), + minOf(roi.height, waRoiSrc.rows() - maxOf(0, roi.y)) + ) + if (clampedRoi.width <= 0 || clampedRoi.height <= 0) { + Log.d(TAG, "locateAndDetectCT fail: roi out of waRoiSrc bounds") + waRoiSrc.release(); autoRoiSrc.release(); maskSrc.release(); mask.release() + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() + wbRoiSrc.release(); roiSrc.release() + return StripResult(errorCode = -12, paperColor = paperColor) + } + val ctMat = waRoiSrc.submat(clampedRoi) // This is the cropped strip (C++: ctMat) + + // ===================================================================== + // PHASE 4: C/T LINE DETECTION ON CROPPED STRIP (C++ thresholds) + // ===================================================================== + // C++ filter order: RightBottomX → LeftTopX → Rectangularity → Height(0.7) → Width(0.045) + + val ctChannels = mutableListOf() + Core.split(ctMat, ctChannels) + val lastRoiG = ctChannels[1] // G channel + ctChannels[0].release(); ctChannels[2].release() + + // Denoise (C++: medianBlur 3, GaussianBlur 5x5) + Imgproc.medianBlur(lastRoiG, lastRoiG, 3) + Imgproc.GaussianBlur(lastRoiG, lastRoiG, Size(5.0, 5.0), 0.0, 0.0) + + // Adaptive threshold (C++: ADAPTIVE_THRESH_MEAN_C, 37, 4) + val matInternal = Mat() + Imgproc.adaptiveThreshold(lastRoiG, matInternal, 255.0, + Imgproc.ADAPTIVE_THRESH_MEAN_C, Imgproc.THRESH_BINARY_INV, 37, 4.0) + + // Find C/T contours + val ctContours = mutableListOf() + Imgproc.findContours(matInternal, ctContours, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE) + + if (ctContours.isEmpty()) { + Log.d(TAG, "locateAndDetectCT: no C/T contours found") + cleanupCT(ctMat, lastRoiG, matInternal, waRoiSrc, autoRoiSrc, maskSrc, mask) + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() + wbRoiSrc.release(); roiSrc.release() + buildContour.release() + return StripResult(errorCode = -7, paperColor = paperColor) + } + + val ctGfs = mutableListOf>() + ContourSelector.calContoursGf(ctContours, ctGfs, lastRoiG) + + // C++ filter pipeline for C/T lines + val ctRbX = mutableListOf>() + ContourSelector.selectContour(ctGfs, ctRbX, + lastRoiG.cols() * 0.15, lastRoiG.cols() * 0.95, GfFlag.RightBottomX) + + val ctLtX = mutableListOf>() + ContourSelector.selectContour(ctRbX, ctLtX, + lastRoiG.cols() * 0.05, lastRoiG.cols() * 0.9, GfFlag.LeftTopX) + + val ctRect = mutableListOf>() + ContourSelector.selectContour(ctLtX, ctRect, 0.30, 1.15, GfFlag.Rectangularity) + + // C++: Height filter FIRST (0.7–1.1 of strip height), THEN Width (0.045–0.20 of strip width) + val ctHeight = mutableListOf>() + ContourSelector.selectContour(ctRect, ctHeight, + lastRoiG.rows() * 0.7, lastRoiG.rows() * 1.1, GfFlag.Height) + + Log.d(TAG, "locateAndDetectCT C/T filter: raw=${ctGfs.size} → " + + "rbX=${ctRbX.size} → ltX=${ctLtX.size} → " + + "rect=${ctRect.size} → h(0.7)=${ctHeight.size}") + + // Sort by CenterX + ContourSelector.sortContoursByGf(ctHeight, GfFlag.CenterX, false) + + // C++ dedup: keep one contour per side (left/right of center) + val ctWidth = mutableListOf>() + ctWidth.addAll(ctHeight) + if (ctWidth.size > 2) { + val midX = lastRoiG.cols() / 2.0 + val leftContours = ctWidth.filter { it.second.contourCenter.x <= midX } + val rightContours = ctWidth.filter { it.second.contourCenter.x > midX } + ctWidth.clear() + if (leftContours.isNotEmpty()) { + ctWidth.add(leftContours.maxByOrNull { it.second.contourCenter.x }!!) + } + if (rightContours.isNotEmpty()) { + ctWidth.add(rightContours.minByOrNull { it.second.contourCenter.x }!!) + } + ctWidth.sortBy { it.second.contourCenter.x } + } + + Log.d(TAG, "locateAndDetectCT: found ${ctWidth.size} C/T line(s) after dedup") + + // ===================================================================== + // RETRY: If only 0-1 C/T lines found, try adaptive threshold with + // smaller block size (21 vs 37) to pick up faint T lines. + // Also relax height filter (0.4 vs 0.7) and width filter (0.02 vs 0.045). + // ===================================================================== + var ctRetryWidth = ctWidth + if (ctWidth.size < 2) { + Log.d(TAG, "locateAndDetectCT: retry C/T with relaxed params (block=21, h=0.4, w=0.02)") + + // Release previous C/T detection mats + matInternal.release() + ctContours.forEach { it.release() } + ctContours.clear() + + // Retry with smaller block size for adaptive threshold + val matInternal2 = Mat() + Imgproc.adaptiveThreshold(lastRoiG, matInternal2, 255.0, + Imgproc.ADAPTIVE_THRESH_MEAN_C, Imgproc.THRESH_BINARY_INV, 21, 4.0) + + val ctContours2 = mutableListOf() + Imgproc.findContours(matInternal2, ctContours2, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE) + + if (ctContours2.isNotEmpty()) { + val ctGfs2 = mutableListOf>() + ContourSelector.calContoursGf(ctContours2, ctGfs2, lastRoiG) + + // Relaxed filter pipeline + val ctRbX2 = mutableListOf>() + ContourSelector.selectContour(ctGfs2, ctRbX2, + lastRoiG.cols() * 0.15, lastRoiG.cols() * 0.95, GfFlag.RightBottomX) + + val ctLtX2 = mutableListOf>() + ContourSelector.selectContour(ctRbX2, ctLtX2, + lastRoiG.cols() * 0.05, lastRoiG.cols() * 0.9, GfFlag.LeftTopX) + + val ctRect2 = mutableListOf>() + ContourSelector.selectContour(ctLtX2, ctRect2, 0.30, 1.15, GfFlag.Rectangularity) + + // Relaxed height: 0.4 instead of 0.7 (no width filter) + val ctHeight2 = mutableListOf>() + ContourSelector.selectContour(ctRect2, ctHeight2, + lastRoiG.rows() * 0.4, lastRoiG.rows() * 1.1, GfFlag.Height) + + Log.d(TAG, "locateAndDetectCT retry C/T filter: raw=${ctGfs2.size} → " + + "rbX=${ctRbX2.size} → ltX=${ctLtX2.size} → " + + "rect=${ctRect2.size} → h(0.4)=${ctHeight2.size}") + + // Sort and dedup + ContourSelector.sortContoursByGf(ctHeight2, GfFlag.CenterX, false) + val ctWidth2 = mutableListOf>() + ctWidth2.addAll(ctHeight2) + if (ctWidth2.size > 2) { + val midX = lastRoiG.cols() / 2.0 + val leftContours = ctWidth2.filter { it.second.contourCenter.x <= midX } + val rightContours = ctWidth2.filter { it.second.contourCenter.x > midX } + ctWidth2.clear() + if (leftContours.isNotEmpty()) { + ctWidth2.add(leftContours.maxByOrNull { it.second.contourCenter.x }!!) + } + if (rightContours.isNotEmpty()) { + ctWidth2.add(rightContours.minByOrNull { it.second.contourCenter.x }!!) + } + ctWidth2.sortBy { it.second.contourCenter.x } + } + + Log.d(TAG, "locateAndDetectCT retry: found ${ctWidth2.size} C/T line(s) after dedup") + ctRetryWidth = ctWidth2 + } + + // Cleanup retry intermediate mats (but keep ctRetryWidth entries) + matInternal2.release() + ctContours2.forEach { it.release() } + } + + // ===================================================================== + // PHASE 5: EXTRACT ROIs (C++ style) + // ===================================================================== + + val leftImg = Mat() + val rightImg = Mat() + val wbImg = Mat() + var errorCode = 100 + var isNegative = false + + when (ctRetryWidth.size) { + 2 -> { + // Normal case: both C and T lines detected + val gfLeft = ctRetryWidth[0]; val gfRight = ctRetryWidth[1] + val leftRotRect = gfLeft.second.rotRect + val rightRotRect = gfRight.second.rotRect + val r1 = leftRotRect.boundingRect() + val r2 = rightRotRect.boundingRect() + + val wbCenterX = ((leftRotRect.center.x + rightRotRect.center.x) * 0.5).toInt() + val wbCenterY = ((leftRotRect.center.y + rightRotRect.center.y) * 0.5).toInt() + val w = ((r1.width + r2.width) * 0.2).toInt() + val wbRoi = Rect(wbCenterX - w, wbCenterY - w, w * 2, w * 2) + + val r1C = clampRect(r1, ctMat.cols(), ctMat.rows()) + val r2C = clampRect(r2, ctMat.cols(), ctMat.rows()) + val wbRoiC = clampRect(wbRoi, ctMat.cols(), ctMat.rows()) + + if (wbRoiC.height < MIN_PIX_VAL || wbRoiC.width < MIN_PIX_VAL) { + errorCode = 3 + } else { + ctMat.submat(r1C).copyTo(leftImg) + ctMat.submat(r2C).copyTo(rightImg) + ctMat.submat(wbRoiC).copyTo(wbImg) + errorCode = 0 + } + } + + 1 -> { + // Single line detected — check if it's the C line (C++ approach) + isNegative = true + val gfInternal = ctRetryWidth[0] + val rotRect = gfInternal.second.rotRect + val innerRoi = rotRect.boundingRect() + val innerRoiC = clampRect(innerRoi, ctMat.cols(), ctMat.rows()) + + val gMidX = ctMat.cols() / 2 + // C++: C line is on the right when not rotated, or on the left when rotated + val bRight = !bRot && rotRect.center.x > gMidX + val bLeft = bRot && rotRect.center.x < gMidX + + if (bRight || bLeft) { + // Extract white balance ROI from middle of strip + val midX = ctMat.cols() / 2; val midY = ctMat.rows() / 2 + val wbSize = minOf(ctMat.cols() / 6, ctMat.rows() / 2) + val wbRoiC = clampRect(Rect(maxOf(0, midX - wbSize), maxOf(0, midY - wbSize), + minOf(wbSize * 2, ctMat.cols()), minOf(wbSize * 2, ctMat.rows())), + ctMat.cols(), ctMat.rows()) + ctMat.submat(wbRoiC).copyTo(wbImg) + + // Create artificial T line on the opposite side (C++ logic) + val artRoi = Rect(innerRoiC.x, innerRoiC.y, innerRoiC.width, innerRoiC.height) + if (bRight) { + val p = innerRoiC.x / maxOf(innerRoiC.width, 1) + artRoi.x = when { + p > 3 -> innerRoiC.x - innerRoiC.width * 3 + p > 2 -> innerRoiC.x - innerRoiC.width * 2 + else -> 1 + } + } + if (bLeft) { + val p = (ctMat.cols() - innerRoiC.x) / maxOf(innerRoiC.width, 1) + artRoi.x = when { + p > 4 -> innerRoiC.x + innerRoiC.width * 3 + p > 3 -> innerRoiC.x + innerRoiC.width * 2 + else -> innerRoiC.x + innerRoiC.width + } + } + val artRoiC = clampRect(artRoi, ctMat.cols(), ctMat.rows()) + + ctMat.submat(innerRoiC).copyTo(rightImg) + ctMat.submat(artRoiC).copyTo(leftImg) + errorCode = 0 + } + } + + else -> { + errorCode = 100 + } + } + + Log.d(TAG, "locateAndDetectCT: errorCode=$errorCode, isNegative=$isNegative") + + // --- Draw C/T area contour on srcRgba for visual feedback --- + // When C/T detection succeeds, draw the rotated rect outline of the + // C/T analysis area on the camera frame. This matches the C++ DrawRect() + // pattern (drawContours with offset = rcRoi). + if (errorCode == 0) { + Imgproc.drawContours(srcRgba, listOf(buildContour), 0, + Scalar(255.0, 0.0, 0.0, 255.0), 3, Imgproc.LINE_8, Mat(), 0, + Point(roiRect.x.toDouble(), roiRect.y.toDouble())) + } + buildContour.release() + + // Cleanup + cleanupCT(ctMat, lastRoiG, matInternal, waRoiSrc, autoRoiSrc, maskSrc, mask) + sChannel?.release(); sCpy?.release(); sThresoldImg?.release(); kernel5x5?.release() + wbRoiSrc.release(); roiSrc.release() + + return StripResult( + errorCode = errorCode, + isNegative = isNegative, + leftImg = if (leftImg.empty()) null else leftImg, + rightImg = if (rightImg.empty()) null else rightImg, + wbImg = if (wbImg.empty()) null else wbImg, + paperColor = paperColor + ) + } + + /** Cleanup helper for C/T detection intermediate mats */ + private fun cleanupCT(ctMat: Mat, lastRoiG: Mat, matInternal: Mat, + waRoiSrc: Mat, autoRoiSrc: Mat, maskSrc: Mat, mask: Mat) { + ctMat.release(); lastRoiG.release(); matInternal.release() + waRoiSrc.release(); autoRoiSrc.release(); maskSrc.release(); mask.release() + } + // --- Private helpers --- /** @@ -1128,7 +1845,7 @@ object StripLocator { lastRoiSrc: Mat, lastRoiG: Mat, matInternal: Mat, lastRoiSrcMats: List ) { - s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release() + s.release(); sCpy?.release(); sThresoldImg?.release(); roiSrc.release() mask.release(); maskSrc.release(); autoRoiSrc.release(); waRoiSrc.release() lastRoiSrc.release(); lastRoiG.release(); matInternal.release() lastRoiSrcMats.forEach { it.release() }