evaluation: add tools for segmentation and quads

This commit is contained in:
Pierre-Yves Nicolas
2026-02-25 16:23:40 +01:00
parent 75b677ebbc
commit 40f17eb571
2 changed files with 294 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.evaluation
import nu.pattern.OpenCV
import org.fairscan.imageprocessing.detectDocumentQuad
import org.fairscan.imageprocessing.scaledTo
import org.fairscan.imageprocessing.toCv
import org.opencv.core.Core
import org.opencv.core.Mat
import org.opencv.core.Scalar
import org.opencv.imgcodecs.Imgcodecs
import org.opencv.imgproc.Imgproc
import java.io.File
fun main() {
OpenCV.loadLocally()
QuadDetectionEvaluator.runEvaluation()
}
object QuadDetectionEvaluator {
data class Entry(
val name: String,
val inputFile: File,
val maskFile: File
)
fun runEvaluation() {
val inputDir = File("evaluation/dataset/images")
val maskDir = File("evaluation/dataset/masks")
val outputDir = File("evaluation/reports/results").apply { mkdirs() }
val entries = inputDir.listFiles { f -> f.extension.lowercase() in listOf("jpg", "jpeg") }
?.mapNotNull { img ->
val mask = File(maskDir, img.nameWithoutExtension + ".png")
if (mask.exists()) Entry(img.nameWithoutExtension, img, mask) else null
}
?: emptyList()
val htmlFragments = mutableListOf<String>()
for (e in entries) {
println("Processing ${e.name}...")
val inputMat = Imgcodecs.imread(e.inputFile.absolutePath)
if (inputMat.empty()) continue
val maskMat = Imgcodecs.imread(e.maskFile.absolutePath, Imgcodecs.IMREAD_UNCHANGED)
if (maskMat.empty()) continue
val mask = MatMask(maskMat)
val quad = detectDocumentQuad(mask, isLiveAnalysis = false)
?.scaledTo(mask.width, mask.height, inputMat.width(), inputMat.height())
val inputOut = File(outputDir, "${e.name}_input.jpg")
Imgcodecs.imwrite(inputOut.absolutePath, inputMat)
val outputMat = Mat()
overlayMask(inputMat, outputMat, maskMat)
if (quad != null) {
val quadVertices: List<org.opencv.core.Point> =
listOf(quad.topLeft, quad.topRight, quad.bottomRight, quad.bottomLeft)
.map { it.toCv() }
drawPolygon(quadVertices, outputMat)
}
val outputOut = File(outputDir, "${e.name}_output.jpg")
Imgcodecs.imwrite(outputOut.absolutePath, outputMat)
htmlFragments += """
<div class="entry">
<h3>${e.name}</h3>
<div class="row">
<img src="results/${e.name}_input.jpg" />
<img src="results/${e.name}_output.jpg" />
</div>
</div>
""".trimIndent()
}
buildHtmlReport(htmlFragments)
println("Done! report at: reports/index.html")
}
private fun drawPolygon(points: List<org.opencv.core.Point>, outputMat: Mat) {
// Draw edges
for (i in points.indices) {
val p1 = points[i]
val p2 = points[(i + 1) % points.size]
Imgproc.line(
outputMat,
p1,
p2,
Scalar(255.0, 0.0, 0.0),
3
)
}
// Draw corners
for (p in points) {
Imgproc.circle(
outputMat,
p,
6,
Scalar(0.0, 255.0, 255.0),
-1
)
}
}
private fun buildHtmlReport(parts: List<String>) {
val html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Dataset Evaluation</title>
<style>
body { font-family: sans-serif; padding: 20px; }
img { max-width: 400px; margin-right: 20px; }
.row { display: flex; flex-direction: row; align-items: center; }
.entry { margin-bottom: 40px; }
</style>
</head>
<body>
<h1>Dataset Evaluation</h1>
${parts.joinToString("\n")}
</body>
</html>
""".trimIndent()
File("evaluation/reports/index.html").writeText(html)
}
}
fun overlayMask(inputMat: Mat, outputMat: Mat, maskMat: Mat) {
val resizedMask = Mat()
Imgproc.resize(maskMat, resizedMask, inputMat.size(), 0.0, 0.0, Imgproc.INTER_NEAREST)
Core.convertScaleAbs(inputMat, outputMat, 1.0, -80.0)
val maskGray = Mat()
if (resizedMask.channels() == 1) {
resizedMask.copyTo(maskGray)
} else {
Imgproc.cvtColor(resizedMask, maskGray, Imgproc.COLOR_BGR2GRAY)
}
Core.normalize(maskGray, maskGray, 0.0, 255.0, Core.NORM_MINMAX)
val maskColor = Mat.zeros(outputMat.size(), outputMat.type())
maskColor.setTo(Scalar(0.0, 255.0, 0.0), maskGray)
val alpha = 0.6
Core.addWeighted(maskColor, alpha, outputMat, 1.0, 0.0, outputMat)
}

View File

@@ -0,0 +1,124 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.evaluation
import nu.pattern.OpenCV
import org.opencv.core.Mat
import org.opencv.imgcodecs.Imgcodecs
import java.io.File
fun main() {
OpenCV.loadLocally()
SegmentationComparison.runEvaluation()
}
object SegmentationComparison {
data class Entry(
val name: String,
val inputFile: File,
val maskA: File,
val maskB: File
)
fun runEvaluation() {
val inputDir = File("evaluation/dataset/images/val-dataset-v2.1")
val maskDirA = File("evaluation/dataset/masks/v1.1.0")
val maskDirB = File("evaluation/dataset/masks/v1.2.0")
val outputDir = File("evaluation/reports/results").apply { mkdirs() }
val entries = inputDir
.listFiles { f -> f.extension.lowercase() in listOf("jpg", "jpeg") }
?.mapNotNull { img ->
val maskA = File(maskDirA, img.nameWithoutExtension + ".png")
val maskB = File(maskDirB, img.nameWithoutExtension + ".png")
if (maskA.exists() && maskB.exists()) {
Entry(img.nameWithoutExtension, img, maskA, maskB)
} else null
} ?: emptyList()
val htmlFragments = mutableListOf<String>()
for (e in entries) {
println("Processing ${e.name}...")
val inputMat = Imgcodecs.imread(e.inputFile.absolutePath)
if (inputMat.empty()) continue
val inputOut = File(outputDir, "${e.name}_input.jpg")
Imgcodecs.imwrite(inputOut.absolutePath, inputMat)
val outA = File(outputDir, "${e.name}_modelA.jpg")
val outB = File(outputDir, "${e.name}_modelB.jpg")
renderOverlay(inputMat, e.maskA, outA)
renderOverlay(inputMat, e.maskB, outB)
htmlFragments += """
<div class="entry">
<h3>${e.name}</h3>
<div class="row">
<div>
<img src="results/${e.name}_input.jpg" />
</div>
<div>
<img src="results/${e.name}_modelA.jpg" />
</div>
<div>
<img src="results/${e.name}_modelB.jpg" />
</div>
</div>
</div>
""".trimIndent()
}
buildHtmlReport(htmlFragments)
}
}
private fun buildHtmlReport(parts: List<String>) {
val html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Dataset Evaluation</title>
<style>
body { font-family: sans-serif; padding: 20px; }
img { max-width: 400px; margin-right: 20px; }
.row { display: flex; gap: 20px;align-items: flex-start; }
.entry { margin-bottom: 40px; }
</style>
</head>
<body>
<h1>Dataset Evaluation</h1>
${parts.joinToString("\n")}
</body>
</html>
""".trimIndent()
File("evaluation/reports/segmentation-comparison.html").writeText(html)
}
private fun renderOverlay(
inputMat: Mat,
maskFile: File,
outputFile: File
) {
val maskMat = Imgcodecs.imread(maskFile.absolutePath, Imgcodecs.IMREAD_UNCHANGED)
if (maskMat.empty()) return
val outputMat = Mat()
overlayMask(inputMat, outputMat, maskMat)
Imgcodecs.imwrite(outputFile.absolutePath, outputMat)
}