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 7738a62..ea6f3d1 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 @@ -16,6 +16,7 @@ import org.opencv.core.Core import org.opencv.core.CvType import org.opencv.core.Mat import org.opencv.core.Rect +import org.opencv.imgproc.Imgproc import java.util.concurrent.atomic.AtomicBoolean /** @@ -157,8 +158,12 @@ class ScanViewModel : ViewModel() { roiRect = Rect(0, 0, procW, procH) } - // Step 1: Locate the strip - val stripResult = StripLocator.locate(srcRgba, roiRect!!, drawAnnotations = true) + // 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() if (stripResult.errorCode != 0) { // Update preview bitmap even on failure (shows the source image) 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 42e14c5..2a17ae3 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 @@ -61,6 +61,10 @@ object StripLocator { // --- Constants --- private const val H_GREEN_MIN = 30.5 private const val H_GREEN_MAX = 85.0 + private const val H_YELLOW_MIN = 14.0 + private const val H_YELLOW_MAX = 29.0 + private const val H_BLUE_MIN = 95.0 + private const val H_BLUE_MAX = 139.5 private const val MIN_PIX_VAL = 4 // Morphology kernels (initialized in init block) @@ -111,48 +115,160 @@ object StripLocator { ) } - // --- Step 1: Convert to grayscale and OTSU threshold --- + // ===================================================================== + // GREEN BLOCK DETECTION — ported from ImageLocationColloidalGold.cpp + // Key improvements over the old grayscale+OTSU approach: + // 1. Uses HSV S-channel (saturation) for OTSU — green blocks have high + // saturation, making them stand out even under reflections/glare. + // 2. 5×5 morphology kernel (was 15×15) — preserves green block shapes + // instead of merging or destroying them. + // 3. Color pre-filtering — each contour is checked for green color BEFORE + // being used. Non-green contours (from reflections, shadows) are discarded. + // 4. Absolute area filtering (150–100000 px) — more robust than expecting + // exactly 2 contours. + // 5. Adaptive height filtering relative to the largest contour. + // ===================================================================== + + // --- Step 1: Convert to HSV, extract S (saturation) channel, OTSU --- + // OLD APPROACH (grayscale OTSU, commented out): + // val s = Mat() + // Imgproc.cvtColor(roiSrc, s, Imgproc.COLOR_RGBA2GRAY) + // Imgproc.threshold(s, sThresoldImg, 128.0, 255.0, THRESH_BINARY | THRESH_OTSU) + // Problem: grayscale is sensitive to brightness/reflections. Green blocks + // may not contrast well with background under glare. val s = Mat() - Imgproc.cvtColor(roiSrc, s, Imgproc.COLOR_RGBA2GRAY) + Imgproc.cvtColor(roiSrc, roiSrc, Imgproc.COLOR_RGBA2RGB) // RGBA → BGR + val hsv = Mat() + Imgproc.cvtColor(roiSrc, hsv, Imgproc.COLOR_RGB2HSV) // BGR → HSV + val hsvMats = mutableListOf() + Core.split(hsv, hsvMats) + val sChannel = hsvMats[1] // S channel — saturation is robust against brightness + hsvMats[0].release(); hsvMats[2].release(); hsv.release() + val sCpy = Mat() - s.copyTo(sCpy) + sChannel.copyTo(sCpy) val sThresoldImg = Mat() - Imgproc.threshold(s, sThresoldImg, 128.0, 255.0, + Imgproc.threshold(sChannel, sThresoldImg, 128.0, 255.0, Imgproc.THRESH_BINARY or Imgproc.THRESH_OTSU) - // --- Step 2: Morphological open --- - Imgproc.morphologyEx(sThresoldImg, sThresoldImg, Imgproc.MORPH_OPEN, m2) + // --- Step 2: Morphological open with 5×5 kernel (was 15×15) --- + // OLD: m2 = 15×15 kernel — too large, merges or destroys green blocks + // NEW: 5×5 kernel — cleans noise while preserving block shapes + val kernel5x5 = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(5.0, 5.0)) + Imgproc.morphologyEx(sThresoldImg, sThresoldImg, Imgproc.MORPH_OPEN, kernel5x5) // --- Step 3: Find external contours --- val contours0 = mutableListOf() 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.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release() + Log.d(TAG, "Step 3 fail: no contours found after OTSU+morph (S-channel)") + sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + roiSrc.release() return StripResult(errorCode = -9, paperColor = PaperColor.Unknown) } // --- Step 4: Calculate geometric features --- - val gfsList0 = mutableListOf>() - ContourSelector.calContoursGf(contours0, gfsList0, s) - if (gfsList0.size != 2) { - Log.d(TAG, "Step 4 fail: expected 2 contours, got ${gfsList0.size}") - s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release() - return StripResult(errorCode = -9, paperColor = PaperColor.Unknown) + val gfsListAll = mutableListOf>() + ContourSelector.calContoursGf(contours0, gfsListAll, sChannel) + + // Filter by position: RightBottomX + val gfsFiltered1 = mutableListOf>() + ContourSelector.selectContour(gfsListAll, gfsFiltered1, + sChannel.cols() * 0.05, sChannel.cols() * 0.95, GfFlag.RightBottomX) + + // Filter by position: LeftTopX + val gfsFiltered2 = mutableListOf>() + ContourSelector.selectContour(gfsFiltered1, gfsFiltered2, + sChannel.cols() * 0.05, sChannel.cols() * 0.95, GfFlag.LeftTopX) + + // Filter by absolute area (50–100000 px) — lowered from 150 to catch small blocks in camera frames + val gfsFiltered3 = mutableListOf>() + ContourSelector.selectContour(gfsFiltered2, gfsFiltered3, 50.0, 100000.0, GfFlag.Area) + + // Filter by rectangularity (0.45–1.1) — relaxed from 0.55 for camera + val gfsFiltered4 = mutableListOf>() + ContourSelector.selectContour(gfsFiltered3, gfsFiltered4, 0.45, 1.1, GfFlag.Rectangularity) + + // --- Color pre-filtering: only keep contours that are actually green --- + // This is the key anti-reflection measure — non-green contours + // (from glare, shadows, background) are discarded here. + val gfsGreenOnly = mutableListOf>() + for ((contour, gf) in gfsFiltered4) { + 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) { + gfsGreenOnly.add(Pair(contour, gf)) + } + } + } + Log.d(TAG, "locate filter stages: raw=${gfsListAll.size} → " + + "rbX=${gfsFiltered1.size} → ltX=${gfsFiltered2.size} → " + + "area=${gfsFiltered3.size} → rect=${gfsFiltered4.size} → green=${gfsGreenOnly.size}") + + // --- Fallback: if S-channel OTSU didn't find enough green contours, --- + // try direct HSV color thresholding (H channel for green + S channel for saturation). + // This is more robust for camera feeds where S-channel OTSU fails. + var effectiveGreenContours = gfsGreenOnly + if (gfsGreenOnly.size < 2) { + Log.d(TAG, "S-channel OTSU found <2 green contours, trying direct HSV thresholding fallback") + val hsvFallbackContours = tryDirectHsvThresholding(roiSrc) + if (hsvFallbackContours.size >= 2) { + Log.d(TAG, "Direct HSV fallback: found ${hsvFallbackContours.size} green contours") + effectiveGreenContours = hsvFallbackContours + } else { + Log.d(TAG, "Direct HSV fallback also failed: ${hsvFallbackContours.size} contours") + } } - // --- Step 5: Sort by area, validate area ratio [1.5, 4.5] --- - ContourSelector.sortContoursByGf(gfsList0, GfFlag.Area, true) - val maxArea = gfsList0[0].second.contourArea - val secondArea = gfsList0[1].second.contourArea - if (maxArea.toDouble() / secondArea < 1.2 || maxArea.toDouble() / secondArea > 12.0) { - Log.d(TAG, "Step 5 fail: area ratio ${maxArea.toDouble() / secondArea} not in [1.2, 12.0]") - s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release() - return StripResult(errorCode = -8, paperColor = PaperColor.Unknown) + 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() + roiSrc.release() + return StripResult(errorCode = -9, paperColor = PaperColor.Green) } - // --- Step 6: Validate aspect ratio and position --- + // --- Adaptive height filtering relative to the largest contour --- + // Sort by height, filter to keep contours with height >= 50% of largest + ContourSelector.sortContoursByGf(effectiveGreenContours, GfFlag.Height, true) + val maxHeight = effectiveGreenContours[0].second.size.height + val gfsFilteredH = mutableListOf>() + ContourSelector.selectContour(effectiveGreenContours, gfsFilteredH, + maxHeight * 0.50, maxHeight * 1.0, GfFlag.Height) + + 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() + roiSrc.release() + return StripResult(errorCode = -9, paperColor = PaperColor.Green) + } + + // Sort by center X for left/right ordering + ContourSelector.sortContoursByGf(gfsFilteredH, GfFlag.CenterX, false) + + // --- Area ratio validation (2.0–4.0) --- + val firstArea = gfsFilteredH[0].second.contourArea + val lastArea = gfsFilteredH[gfsFilteredH.size - 1].second.contourArea + val areaRatio = if (firstArea > lastArea) firstArea.toDouble() / lastArea + 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() + roiSrc.release() + return StripResult(errorCode = -8, paperColor = PaperColor.Green) + } + + // Keep only the largest and smallest (leftmost and rightmost) contours + val gfsList0 = mutableListOf(gfsFilteredH[0], gfsFilteredH[gfsFilteredH.size - 1]) + + // --- Step 6: Validate position (boundary check only) --- + // Note: whRatio is NOT checked on the large block (C++ doesn't check it). + // Small block whRatio and height similarity are checked later. val gfp0 = gfsList0[0] val leftX = gfp0.second.leftTop.x.toInt() val topY = gfp0.second.leftTop.y.toInt() @@ -165,21 +281,23 @@ object StripLocator { val realHalfWBig = (Math.max(rotRcW, rotRcH) * 0.5).toInt() val realHalfHBig = (Math.min(rotRcW, rotRcH) * 0.5).toInt() - val whRatio = realHalfWBig.toDouble() / realHalfHBig - - if (whRatio < 4.25 || whRatio > 13.5 - || realHalfHBig < MIN_PIX_VAL * 2 + 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, "Step 6 fail: whRatio=$whRatio, halfH=$realHalfHBig, bounds=($leftX,$topY)-($rightX,$bottomY) src=${srcWid}x${srcHei}") - s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release() + 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() return StripResult(errorCode = -10, paperColor = PaperColor.Unknown) } - // --- Step 7: Judge paper color --- + // --- Step 7: Auto white balance, then judge paper color --- + // OLD: judged paper color directly on roiSrc + // NEW: apply auto white balance first to compensate for lighting variations + val wbRoiSrc = Mat() + autoWhiteBalance(roiSrc, wbRoiSrc) + val rcColor1 = Rect( (rotRect0.center.x - realHalfHBig / 2).toInt(), (rotRect0.center.y - realHalfHBig / 2).toInt(), @@ -193,7 +311,8 @@ object StripLocator { || rcColor1.x <= 0 || rcColor1.x + rcColor1.width >= srcWid || rcColor1.y <= 0 || rcColor1.y + rcColor1.height >= srcHei ) { - s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release() + sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + wbRoiSrc.release(); roiSrc.release() return StripResult(errorCode = -11, paperColor = PaperColor.Unknown) } @@ -210,13 +329,14 @@ object StripLocator { // Continue anyway — original code warned but didn't always return } - // --- Step 9: Judge paper color of the larger block --- - val mc1 = roiSrc.submat(rcColor1) + // --- Step 9: Judge paper color of the larger block (on white-balanced image) --- + val mc1 = wbRoiSrc.submat(rcColor1) paperColor = judgePaperColor(mc1) mc1.release() if (paperColor == PaperColor.Unknown || paperColor != PaperColor.Green) { Log.d(TAG, "Step 9 fail: paperColor=$paperColor") - s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release() + sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + wbRoiSrc.release(); roiSrc.release() return StripResult(errorCode = -1, paperColor = paperColor) } @@ -230,11 +350,13 @@ object StripLocator { bWgtH = false realAngle = -angle - 90 } else { - s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release() + Log.d(TAG, "Step 10 fail: rotRcH == rotRcW (square block, cannot determine orientation)") + sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + wbRoiSrc.release(); roiSrc.release() return StripResult(errorCode = -1, paperColor = paperColor) } - // Verify second block is also green + // Verify second block is also green (on white-balanced image) val gfp1 = gfsList0[1] val rotRect1 = gfp1.second.rotRect val rotRcWSmall = rotRect1.size.width.toInt() @@ -246,11 +368,34 @@ object StripLocator { (rotRect1.center.y - realHalfHSmall / 2).toInt(), realHalfHSmall, realHalfHSmall ) - val mc2 = roiSrc.submat(rcColor2) + val mc2 = wbRoiSrc.submat(rcColor2) val color2 = judgePaperColor(mc2) mc2.release() if (paperColor != color2) { - s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release() + Log.d(TAG, "Step 10 fail: second block color mismatch: first=$paperColor second=$color2") + sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + wbRoiSrc.release(); roiSrc.release() + return StripResult(errorCode = -1, paperColor = paperColor) + } + + // --- Small block whRatio check (from C++ _filterAndSaveLocatorsInfo) --- + // Physical specs: small block 10mm wide, strip 3-5mm tall → whRatio 2~3.3 + // Relaxed to 1.5~7.0 — camera angle and OTSU segmentation can stretch the apparent ratio + 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() + wbRoiSrc.release(); roiSrc.release() + return StripResult(errorCode = -1, paperColor = paperColor) + } + + // --- Height similarity check: both blocks should have similar heights --- + // Physical specs: both blocks are on the same strip, heights should match within 20% + 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() + wbRoiSrc.release(); roiSrc.release() return StripResult(errorCode = -1, paperColor = paperColor) } @@ -261,7 +406,9 @@ object StripLocator { ) val distRadio = centerPointDist / (realHalfWBig * 2.0) if (distRadio < 1 || distRadio > 1.65) { - s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release() + Log.d(TAG, "Step 10c fail: center distance ratio $distRadio not in [1, 1.65]") + sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + wbRoiSrc.release(); roiSrc.release() return StripResult(errorCode = -1, paperColor = paperColor) } @@ -324,7 +471,8 @@ object StripLocator { || (autoRoi.x + autoRoi.width) >= (maskSrc.width() - 1) || (autoRoi.y + autoRoi.height) >= (maskSrc.height() - 1) ) { - s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release() + sChannel.release(); sCpy.release(); sThresoldImg.release(); kernel5x5.release() + wbRoiSrc.release(); roiSrc.release() mask.release(); maskSrc.release() return StripResult(errorCode = -11, paperColor = paperColor) } @@ -549,6 +697,91 @@ object StripLocator { ) } + /** + * Direct HSV color thresholding fallback for green block detection. + * + * When S-channel OTSU fails to find enough green contours (common in camera + * feeds where the strip is small), this method directly thresholds the H and S + * channels of HSV to find green regions. + * + * @param roiSrc The ROI image in BGR format (3 channels) + * @return List of (contour, geometric features) pairs that pass filtering + */ + private fun tryDirectHsvThresholding(roiSrc: Mat): MutableList> { + val result = mutableListOf>() + + // Convert BGR to HSV + val hsv = Mat() + Imgproc.cvtColor(roiSrc, hsv, Imgproc.COLOR_RGB2HSV) + val hsvChannels = mutableListOf() + Core.split(hsv, hsvChannels) + val hChannel = hsvChannels[0] // Hue + val sChannel = hsvChannels[1] // Saturation + + // Create green mask: H in [30, 85] AND S > 30 + 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) + + val hMask = Mat() + Core.bitwise_and(hMaskLow, hMaskHigh, hMask) + val greenMask = Mat() + Core.bitwise_and(hMask, sMask, greenMask) + + // Morphological open to clean noise + val kernel3x3 = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(3.0, 3.0)) + Imgproc.morphologyEx(greenMask, greenMask, Imgproc.MORPH_OPEN, kernel3x3) + + // Find contours + val contours = mutableListOf() + Imgproc.findContours(greenMask, contours, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE) + + if (contours.isEmpty()) { + hsv.release(); hChannel.release(); sChannel.release() + hMaskLow.release(); hMaskHigh.release(); sMask.release() + hMask.release(); greenMask.release(); kernel3x3.release() + hsvChannels[2].release() + return result + } + + // Calculate geometric features + val gfsAll = mutableListOf>() + ContourSelector.calContoursGf(contours, gfsAll, sChannel) + + // Filter by position and size (same pipeline as S-channel OTSU) + val gfs1 = mutableListOf>() + ContourSelector.selectContour(gfsAll, gfs1, + roiSrc.cols() * 0.05, roiSrc.cols() * 0.95, GfFlag.RightBottomX) + + val gfs2 = mutableListOf>() + ContourSelector.selectContour(gfs1, gfs2, + roiSrc.cols() * 0.05, roiSrc.cols() * 0.95, GfFlag.LeftTopX) + + val gfs3 = mutableListOf>() + ContourSelector.selectContour(gfs2, gfs3, 30.0, 100000.0, GfFlag.Area) + + val gfs4 = mutableListOf>() + ContourSelector.selectContour(gfs3, gfs4, 0.40, 1.1, GfFlag.Rectangularity) + + Log.d(TAG, "Direct HSV fallback stages: raw=${gfsAll.size} → " + + "rbX=${gfs1.size} → ltX=${gfs2.size} → " + + "area=${gfs3.size} → rect=${gfs4.size}") + + // All contours from HSV thresholding are already green by definition, + // so no need for color pre-filtering. Just return them. + result.addAll(gfs4) + + hsv.release(); hChannel.release(); sChannel.release() + hMaskLow.release(); hMaskHigh.release(); sMask.release() + hMask.release(); greenMask.release(); kernel3x3.release() + hsvChannels[2].release() + + return result + } + /** * Detect C/T lines and extract ROIs from an already-cropped strip image. * @@ -616,11 +849,11 @@ object StripLocator { val gfsList102 = mutableListOf>() ContourSelector.selectContour(gfsList101, gfsList102, - imgW * 0.01, imgW * 0.35, GfFlag.Width) + imgW * 0.005, imgW * 0.35, GfFlag.Width) val gfsList11 = mutableListOf>() ContourSelector.selectContour(gfsList102, gfsList11, - imgH * 0.15, imgH * 1.1, GfFlag.Height) + imgH * 0.02, imgH * 1.1, GfFlag.Height) Log.d(TAG, "locateCroppedStrip filter stages: raw=${gfsList10.size} → " + "rbX=${gfsList01.size} → ltX=${gfsList110.size} → " + @@ -737,9 +970,58 @@ object StripLocator { // --- Private helpers --- + /** + * Auto white balance — compensates for lighting variations. + * Scales each channel so their means equal the overall mean. + * Ported from ImageLocationColloidalGold::_autoWhitebalance. + */ + private fun autoWhiteBalance(src: Mat, dst: Mat) { + val avgRgb = Core.mean(src) + val scale = (avgRgb.`val`[0] + avgRgb.`val`[1] + avgRgb.`val`[2]) / 3.0 + val mv = mutableListOf() + Core.split(src, mv) + // OpenCV BGR order: mv[0]=B, mv[1]=G, mv[2]=R + Core.convertScaleAbs(mv[0], mv[0], scale / (avgRgb.`val`[0] + 0.001), 0.0) + Core.convertScaleAbs(mv[1], mv[1], scale / (avgRgb.`val`[1] + 0.001), 0.0) + Core.convertScaleAbs(mv[2], mv[2], scale / (avgRgb.`val`[2] + 0.001), 0.0) + Core.merge(mv, dst) + mv.forEach { it.release() } + } + + /** + * Judge paper color at a specific contour center point. + * Samples a 10×10 region around the point and checks HSV. + * Ported from ImageLocationColloidalGold::_readPaperColor(ContourGfExtend*). + */ + private fun judgePaperColorAtPoint(sample: Mat, hsvMats: List): PaperColor { + Imgproc.blur(sample, sample, Size(3.0, 3.0)) + val mcAvgBgr = Core.mean(sample) + val avgB = mcAvgBgr.`val`[0] + val avgG = mcAvgBgr.`val`[1] + val avgR = mcAvgBgr.`val`[2] + + // Get HSV H value at the sample center from the pre-computed HSV + val hsvSample = Mat() + Imgproc.cvtColor(sample, hsvSample, Imgproc.COLOR_RGB2HSV) + val hsvSplit = mutableListOf() + Core.split(hsvSample, hsvSplit) + val hVal = Core.mean(hsvSplit[0]).`val`[0] + hsvSplit.forEach { it.release() } + hsvSample.release() + + if ((avgR > avgB && avgG > avgB) && (hVal >= H_YELLOW_MIN && hVal <= H_YELLOW_MAX)) + return PaperColor.Yellow + if ((avgG > avgB && avgG > avgR) && (hVal >= H_GREEN_MIN && hVal <= H_GREEN_MAX)) + return PaperColor.Green + if ((avgB > avgG && avgB > avgR) && (hVal >= H_BLUE_MIN && hVal <= H_BLUE_MAX)) + return PaperColor.Blue + return PaperColor.Unknown + } + /** * Judge paper color from an HSV analysis of the image region. - * Only returns [PaperColor.Green] or [PaperColor.Unknown] for green strips. + * Uses relaxed H stddev threshold (35 instead of 22.5) for better tolerance + * of varying lighting — ported from ImageLocationColloidalGold::_readPaperColor. */ private fun judgePaperColor(img: Mat): PaperColor { Imgproc.blur(img, img, Size(3.0, 3.0)) @@ -760,7 +1042,8 @@ object StripLocator { // Release split channels mvHsv.forEach { it.release() } - if (d < 22.5) { + // C++ uses d < 35 (was 22.5) — more tolerant of lighting variation + if (d < 35.0) { if ((avgG > avgB && avgG > avgR) && (avgH >= H_GREEN_MIN && avgH <= H_GREEN_MAX)) { return PaperColor.Green }