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 += """
+
+ """.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