From 40f17eb5716e998a4018f2558af1c5aa7a397a6b Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:23:40 +0100 Subject: [PATCH] evaluation: add tools for segmentation and quads --- .../evaluation/QuadDetectionEvaluator.kt | 170 ++++++++++++++++++ .../evaluation/SegmentationComparison.kt | 124 +++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 evaluation/src/main/java/org/fairscan/evaluation/QuadDetectionEvaluator.kt create mode 100644 evaluation/src/main/java/org/fairscan/evaluation/SegmentationComparison.kt diff --git a/evaluation/src/main/java/org/fairscan/evaluation/QuadDetectionEvaluator.kt b/evaluation/src/main/java/org/fairscan/evaluation/QuadDetectionEvaluator.kt new file mode 100644 index 0000000..1a7ff18 --- /dev/null +++ b/evaluation/src/main/java/org/fairscan/evaluation/QuadDetectionEvaluator.kt @@ -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 . + */ +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() + + 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 = + 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 += """ +
+

${e.name}

+
+ + +
+
+ """.trimIndent() + } + + buildHtmlReport(htmlFragments) + println("Done! report at: reports/index.html") + } + + private fun drawPolygon(points: List, 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) { + val html = """ + + + + + Dataset Evaluation + + + +

Dataset Evaluation

+ ${parts.joinToString("\n")} + + + """.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) +} diff --git a/evaluation/src/main/java/org/fairscan/evaluation/SegmentationComparison.kt b/evaluation/src/main/java/org/fairscan/evaluation/SegmentationComparison.kt new file mode 100644 index 0000000..c6e7b0c --- /dev/null +++ b/evaluation/src/main/java/org/fairscan/evaluation/SegmentationComparison.kt @@ -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 . + */ +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() + 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 += """ +
+

${e.name}

+
+
+ +
+
+ +
+
+ +
+
+
+ """.trimIndent() + } + buildHtmlReport(htmlFragments) + } +} + +private fun buildHtmlReport(parts: List) { + val html = """ + + + + + Dataset Evaluation + + + +

Dataset Evaluation

+ ${parts.joinToString("\n")} + + + """.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) +} \ No newline at end of file