evaluation: add tools for segmentation and quads
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user