Improve processing of color documents: retinex + contrast

This commit is contained in:
Pierre-Yves Nicolas
2026-02-10 21:42:56 +01:00
parent 933f00dba6
commit 0456a5329f

View File

@@ -17,16 +17,17 @@ package org.fairscan.imageprocessing
import org.opencv.core.Core import org.opencv.core.Core
import org.opencv.core.CvType import org.opencv.core.CvType
import org.opencv.core.Mat import org.opencv.core.Mat
import org.opencv.core.MatOfFloat
import org.opencv.core.MatOfInt
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 import kotlin.math.max
import kotlin.math.min
fun enhanceCapturedImage(img: Mat, isColored: Boolean): Mat { fun enhanceCapturedImage(img: Mat, isColored: Boolean): Mat {
return if (isColored) { return if (isColored) {
val result = Mat() multiScaleRetinexOnL(img)
Core.convertScaleAbs(img, result, 1.2, 10.0)
result
} else { } else {
val gray = multiScaleRetinex(img) val gray = multiScaleRetinex(img)
val contrastedGray = enhanceContrastAuto(gray) val contrastedGray = enhanceContrastAuto(gray)
@@ -36,6 +37,168 @@ fun enhanceCapturedImage(img: Mat, isColored: Boolean): Mat {
} }
} }
fun multiScaleRetinexOnL(bgr: Mat): Mat {
// --- 1. BGR -> Lab ---
val lab = Mat()
Imgproc.cvtColor(bgr, lab, Imgproc.COLOR_BGR2Lab)
val labChannels = ArrayList<Mat>(3)
Core.split(lab, labChannels)
val l = labChannels[0] // CV_8U [0..255]
// --- 2. Prepare L (float) ---
val lFloat = Mat()
l.convertTo(lFloat, CvType.CV_32F)
Core.add(lFloat, Scalar(1.0), lFloat)
val scaleFactor = 2.0
val smallSize = Size(
lFloat.cols() / scaleFactor,
lFloat.rows() / scaleFactor
)
val lSmall = Mat()
Imgproc.resize(lFloat, lSmall, smallSize, 0.0, 0.0, Imgproc.INTER_AREA)
// --- 3. log(L) once ---
val logLSmall = Mat()
Core.log(lSmall, logLSmall)
val maxDimSmall = max(smallSize.width, smallSize.height)
val kernelSizes = listOf(
maxDimSmall / 80.0,
maxDimSmall / 10.0,
maxDimSmall / 2.0,
)
val weight = 1.0 / kernelSizes.size
val retinexSmall = Mat.zeros(lSmall.size(), CvType.CV_32F)
val blurLog = Mat()
val diff = Mat()
for (ks in kernelSizes) {
val k = ks.toInt().coerceAtLeast(3) or 1
Imgproc.boxFilter(
logLSmall,
blurLog,
-1,
Size(k.toDouble(), k.toDouble())
)
Core.subtract(logLSmall, blurLog, diff)
Core.addWeighted(retinexSmall, 1.0, diff, weight, 0.0, retinexSmall)
}
// --- 4. Normalize Retinex (relative [0..1]) ---
val minMax = Core.minMaxLoc(retinexSmall)
val retinexNormSmall = Mat()
Core.subtract(retinexSmall, Scalar(minMax.minVal), retinexNormSmall)
val range = minMax.maxVal - minMax.minVal
if (range > 1e-6) {
Core.multiply(retinexNormSmall, Scalar(1.0 / range), retinexNormSmall)
}
// --- Upscale Retinex back to full resolution ---
val retinexNorm = Mat()
Imgproc.resize(
retinexNormSmall,
retinexNorm,
lFloat.size(),
0.0,
0.0,
Imgproc.INTER_CUBIC
)
// --- 5. Re-center around original luminance ---
val lOriginalFloat = Mat()
l.convertTo(lOriginalFloat, CvType.CV_32F)
val meanL = Core.mean(lOriginalFloat).`val`[0]
val amplitude = 60.0
val correctedL = Mat()
Core.multiply(retinexNorm, Scalar(amplitude), correctedL)
Core.add(correctedL, Scalar(meanL - amplitude / 2.0), correctedL)
// --- 6. Blend with original L ---
val alpha = 0.6
Core.addWeighted(
lOriginalFloat, 1.0 - alpha,
correctedL, alpha,
0.0,
correctedL
)
// --- 7. Restore contrast ---
val pLowOrig = percentileL(lOriginalFloat, 0.001)
val pLow = percentileL(correctedL, 0.001)
val pHigh = percentileL(correctedL, 0.995)
val targetLow = min(pLow, pLowOrig)
val targetHigh = 245.0
val scale = (targetHigh - targetLow) / (pHigh - pLow + 1e-6)
Core.subtract(correctedL, Scalar(pLow), correctedL)
Core.multiply(correctedL, Scalar(scale), correctedL)
Core.add(correctedL, Scalar(targetLow), correctedL)
// --- 8. Clamp and write back ---
Core.min(correctedL, Scalar(255.0), correctedL)
Core.max(correctedL, Scalar(0.0), correctedL)
correctedL.convertTo(labChannels[0], CvType.CV_8U)
// --- 9. Lab -> BGR ---
Core.merge(labChannels, lab)
val result = Mat()
Imgproc.cvtColor(lab, result, Imgproc.COLOR_Lab2BGR)
// --- Cleanup ---
lab.release()
lFloat.release()
lSmall.release()
logLSmall.release()
blurLog.release()
diff.release()
retinexSmall.release()
retinexNormSmall.release()
retinexNorm.release()
lOriginalFloat.release()
correctedL.release()
labChannels.forEach { it.release() }
return result
}
fun percentileL(l: Mat, p: Double): Double {
val hist = Mat()
Imgproc.calcHist(
listOf(l),
MatOfInt(0),
Mat(),
hist,
MatOfInt(256),
MatOfFloat(0f, 256f)
)
val total = l.total()
var sum = 0.0
for (i in 0 until 256) {
sum += hist.get(i, 0)[0]
if (sum / total >= p) {
hist.release()
return i.toDouble()
}
}
hist.release()
return 255.0
}
private fun multiScaleRetinex(img: Mat): Mat { private fun multiScaleRetinex(img: Mat): Mat {
val imageSize = img.size() val imageSize = img.size()
val maxDim = max(imageSize.width, imageSize.height) val maxDim = max(imageSize.width, imageSize.height)