Shadow correction: Retinex-based processing to preserve flat tint areas

This commit is contained in:
Pierre-Yves Nicolas
2025-08-03 18:21:34 +02:00
parent 698cb47743
commit f8dbdffb12

View File

@@ -22,6 +22,7 @@ import org.opencv.core.MatOfDouble
import org.opencv.core.Scalar import org.opencv.core.Scalar
import org.opencv.core.Size import org.opencv.core.Size
import org.opencv.imgproc.Imgproc import org.opencv.imgproc.Imgproc
import kotlin.math.max
fun enhanceCapturedImage(img: Mat): Mat { fun enhanceCapturedImage(img: Mat): Mat {
return if (isColoredDocument(img)) { return if (isColoredDocument(img)) {
@@ -31,8 +32,8 @@ fun enhanceCapturedImage(img: Mat): Mat {
result result
} else { } else {
Log.i("PostProcessing", "grayscale document") Log.i("PostProcessing", "grayscale document")
val gray = correctLighting(img) val gray = multiScaleRetinex(img)
val contrastedGray = enhanceContrastGray(gray) val contrastedGray = enhanceContrastAuto(gray)
val result = Mat() val result = Mat()
Imgproc.cvtColor(contrastedGray, result, Imgproc.COLOR_GRAY2BGR) Imgproc.cvtColor(contrastedGray, result, Imgproc.COLOR_GRAY2BGR)
result result
@@ -54,35 +55,106 @@ fun isColoredDocument(img: Mat, threshold: Double = 4.0): Boolean {
return result > threshold return result > threshold
} }
// TODO the radius should depend on the image size private fun multiScaleRetinex(img: Mat, kernelSizes: List<Double> = listOf(30.0, 500.0)): Mat {
fun correctLighting(img: Mat, radius: Int = 100): Mat { // Convert to grayscale (1 channel)
val gray = Mat() val gray = Mat()
if (img.channels() == 4) {
Imgproc.cvtColor(img, gray, Imgproc.COLOR_BGRA2GRAY)
} else if (img.channels() == 3) {
Imgproc.cvtColor(img, gray, Imgproc.COLOR_BGR2GRAY) Imgproc.cvtColor(img, gray, Imgproc.COLOR_BGR2GRAY)
} else {
img.copyTo(gray)
}
val imgFloat = Mat()
gray.convertTo(imgFloat, CvType.CV_32F)
Core.add(imgFloat, Scalar(1.0), imgFloat) // img + 1
val weight = 1.0 / kernelSizes.size
val retinex = Mat.zeros(gray.size(), CvType.CV_32F)
val logImg = Mat()
Core.log(imgFloat, logImg)
val blur = Mat() val blur = Mat()
val kernelSize = 2 * radius + 1 val logBlur = Mat()
Imgproc.GaussianBlur(gray, blur, Size(kernelSize.toDouble(), kernelSize.toDouble()), 0.0) val diff = Mat()
val normalized = Mat() for (kernelSize in kernelSizes) {
Core.divide(gray, blur, normalized, 255.0) Imgproc.boxFilter(imgFloat, blur, -1, Size(kernelSize, kernelSize))
return normalized Core.add(blur, Scalar(1.0), blur)
Core.log(blur, logBlur)
Core.subtract(logImg, logBlur, diff)
val diffGray = Mat()
if (diff.channels() > 1) {
Imgproc.cvtColor(diff, diffGray, Imgproc.COLOR_BGRA2GRAY)
} else {
diff.copyTo(diffGray)
}
Core.addWeighted(retinex, 1.0, diffGray, weight, 0.0, retinex)
diffGray.release()
} }
fun enhanceContrastGray(img: Mat): Mat { // Normalize
val flat = img.reshape(0, 1) val minMax = Core.minMaxLoc(retinex)
val sorted = Mat() val normalized = Mat()
Core.sort(flat, sorted, Core.SORT_ASCENDING) Core.subtract(retinex, Scalar(minMax.minVal), normalized)
val scale = if (minMax.maxVal > minMax.minVal) 255.0 / (minMax.maxVal - minMax.minVal) else 1.0
Core.multiply(normalized, Scalar(scale), normalized)
val totalPixels = sorted.cols() val result = Mat()
val pLow = sorted[0, (totalPixels * 0.01).toInt()][0] normalized.convertTo(result, CvType.CV_8U)
val pHigh = sorted[0, (totalPixels * 0.95).toInt()][0]
// Cleanup
gray.release()
imgFloat.release()
retinex.release()
logImg.release()
blur.release()
logBlur.release()
diff.release()
normalized.release()
val result = Mat(img.size(), img.type())
img.convertTo(result, CvType.CV_32F)
Core.subtract(result, Scalar(pLow), result)
Core.multiply(result, Scalar(255.0 * 1.03 / (pHigh - pLow)), result)
Core.min(result, Scalar(255.0), result)
Core.max(result, Scalar(0.0), result)
result.convertTo(result, CvType.CV_8U)
return result return result
} }
private fun enhanceContrastAuto(img: Mat): Mat {
val gray = if (img.channels() == 1) img else {
val tmp = Mat()
Imgproc.cvtColor(img, tmp, Imgproc.COLOR_BGR2GRAY)
tmp
}
// Flatten and sort pixel values
val flat = Mat()
gray.reshape(1, 1).convertTo(flat, CvType.CV_32F)
val sortedVals = Mat()
Core.sort(flat, sortedVals, Core.SORT_ASCENDING)
val totalPixels = sortedVals.cols()
val pLow = sortedVals.get(0, (totalPixels * 0.005).toInt())[0]
val pHigh = sortedVals.get(0, (totalPixels * 0.80).toInt())[0]
flat.release()
sortedVals.release()
val imgF = Mat()
img.convertTo(imgF, CvType.CV_32F)
val adjusted = Mat()
Core.subtract(imgF, Scalar(pLow), adjusted)
Core.multiply(adjusted, Scalar(255.0 / max((pHigh - pLow), 1.0)), adjusted)
Core.min(adjusted, Scalar(255.0), adjusted)
Core.max(adjusted, Scalar(0.0), adjusted)
val result = Mat()
adjusted.convertTo(result, CvType.CV_8U)
imgF.release()
adjusted.release()
val final = Mat()
Core.convertScaleAbs(result, final, 1.15, -25.0)
result.release()
return final
}