的方式

This commit is contained in:
coco
2026-07-14 11:37:18 +08:00
parent 88c0fbefef
commit 40a4c70223
2 changed files with 334 additions and 46 deletions
@@ -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)
@@ -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 (150100000 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<Mat>()
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<MatOfPoint>()
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<Pair<MatOfPoint, ContourGf>>()
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<Pair<MatOfPoint, ContourGf>>()
ContourSelector.calContoursGf(contours0, gfsListAll, sChannel)
// Filter by position: RightBottomX
val gfsFiltered1 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsListAll, gfsFiltered1,
sChannel.cols() * 0.05, sChannel.cols() * 0.95, GfFlag.RightBottomX)
// Filter by position: LeftTopX
val gfsFiltered2 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsFiltered1, gfsFiltered2,
sChannel.cols() * 0.05, sChannel.cols() * 0.95, GfFlag.LeftTopX)
// Filter by absolute area (50100000 px) — lowered from 150 to catch small blocks in camera frames
val gfsFiltered3 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsFiltered2, gfsFiltered3, 50.0, 100000.0, GfFlag.Area)
// Filter by rectangularity (0.451.1) — relaxed from 0.55 for camera
val gfsFiltered4 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
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<Pair<MatOfPoint, ContourGf>>()
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<Pair<MatOfPoint, ContourGf>>()
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.04.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<Pair<MatOfPoint, ContourGf>> {
val result = mutableListOf<Pair<MatOfPoint, ContourGf>>()
// Convert BGR to HSV
val hsv = Mat()
Imgproc.cvtColor(roiSrc, hsv, Imgproc.COLOR_RGB2HSV)
val hsvChannels = mutableListOf<Mat>()
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<MatOfPoint>()
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<Pair<MatOfPoint, ContourGf>>()
ContourSelector.calContoursGf(contours, gfsAll, sChannel)
// Filter by position and size (same pipeline as S-channel OTSU)
val gfs1 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsAll, gfs1,
roiSrc.cols() * 0.05, roiSrc.cols() * 0.95, GfFlag.RightBottomX)
val gfs2 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfs1, gfs2,
roiSrc.cols() * 0.05, roiSrc.cols() * 0.95, GfFlag.LeftTopX)
val gfs3 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfs2, gfs3, 30.0, 100000.0, GfFlag.Area)
val gfs4 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
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<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsList101, gfsList102,
imgW * 0.01, imgW * 0.35, GfFlag.Width)
imgW * 0.005, imgW * 0.35, GfFlag.Width)
val gfsList11 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
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<Mat>()
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<Mat>): 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<Mat>()
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
}