diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 59e2c85..f64ba30 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -11,6 +11,7 @@
+
diff --git a/evaluation/.gitignore b/evaluation/.gitignore
new file mode 100644
index 0000000..6a6dc85
--- /dev/null
+++ b/evaluation/.gitignore
@@ -0,0 +1,5 @@
+/build
+/dataset/images
+/dataset/masks
+/python/venv
+/reports
diff --git a/evaluation/README.md b/evaluation/README.md
new file mode 100644
index 0000000..5c9e3bf
--- /dev/null
+++ b/evaluation/README.md
@@ -0,0 +1,17 @@
+# Tools to evaluate the image processing pipeline
+
+## Preparing the environment
+
+### Python
+
+```bash
+cd python
+python -m venv venv
+source venv/bin/activate # venv\Scripts\activate on Windows
+pip install -r requirements.txt
+```
+
+### Images
+
+Put JPEG images in `dataset/images`.
+
diff --git a/evaluation/build.gradle.kts b/evaluation/build.gradle.kts
new file mode 100644
index 0000000..d36decb
--- /dev/null
+++ b/evaluation/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ id("java-library")
+ alias(libs.plugins.jetbrains.kotlin.jvm)
+}
+java {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+}
+kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
+ }
+}
+dependencies {
+ implementation(project(":imageprocessing"))
+ implementation(libs.opencvjava)
+}
diff --git a/evaluation/python/generate_masks.py b/evaluation/python/generate_masks.py
new file mode 100755
index 0000000..217c6b8
--- /dev/null
+++ b/evaluation/python/generate_masks.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+
+import numpy as np
+import tensorflow as tf
+from PIL import Image, ImageOps
+from pathlib import Path
+
+SEG_MODEL_FILE_PATH = "../../app/build/downloads/fairscan-segmentation-model.tflite"
+DATASET_DIR = Path("../dataset")
+
+INPUT_WIDTH = 256
+INPUT_HEIGHT = 256
+
+def get_resized_image(image_path):
+ img = Image.open(image_path).convert("RGB")
+ img = ImageOps.exif_transpose(img)
+ img = img.convert("RGB").resize((INPUT_WIDTH, INPUT_HEIGHT), Image.BILINEAR)
+ return img
+
+def preprocess_image(img: Image.Image) -> np.ndarray:
+ img_np = np.asarray(img).astype(np.float32)
+ img_np = (img_np - 127.5) / 127.5 # Normalize to [-1, 1]
+ return np.expand_dims(img_np, axis=0)
+
+def postprocess_output(output: np.ndarray) -> np.ndarray:
+ output = np.squeeze(output).astype(np.float32) # Shape: (256, 256)
+ output = np.clip(output, 0, 1)
+ return output # float32 array, values in [0,1]
+
+def get_segmentation_mask(img):
+ input_tensor = preprocess_image(img)
+ interpreter.set_tensor(input_details['index'], input_tensor)
+ interpreter.invoke()
+ output_tensor = interpreter.get_tensor(output_details['index'])
+ return postprocess_output(output_tensor)
+
+interpreter = tf.lite.Interpreter(model_path=str(SEG_MODEL_FILE_PATH))
+interpreter.allocate_tensors()
+input_details = interpreter.get_input_details()[0]
+output_details = interpreter.get_output_details()[0]
+
+img_input_dir = DATASET_DIR / "images"
+mask_input_dir = DATASET_DIR / "masks"
+
+for image_path in sorted(img_input_dir.glob("*.jpg")):
+ print(f"Generating mask for {image_path}")
+ img = get_resized_image(image_path)
+ img = ImageOps.exif_transpose(img)
+ mask = get_segmentation_mask(img)
+ mask_path = mask_input_dir / (image_path.stem + ".png")
+ Image.fromarray((mask * 255).astype(np.uint8)).save(mask_path)
diff --git a/evaluation/python/requirements.txt b/evaluation/python/requirements.txt
new file mode 100644
index 0000000..4e3df80
--- /dev/null
+++ b/evaluation/python/requirements.txt
@@ -0,0 +1,3 @@
+tensorflow
+numpy
+Pillow
diff --git a/evaluation/src/main/java/org/fairscan/evaluation/DatasetEvaluator.kt b/evaluation/src/main/java/org/fairscan/evaluation/DatasetEvaluator.kt
new file mode 100644
index 0000000..511f972
--- /dev/null
+++ b/evaluation/src/main/java/org/fairscan/evaluation/DatasetEvaluator.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2025 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 org.fairscan.imageprocessing.Mask
+import org.fairscan.imageprocessing.detectDocumentQuad
+import org.fairscan.imageprocessing.extractDocument
+import org.fairscan.imageprocessing.scaledTo
+import org.opencv.core.Mat
+import org.opencv.imgcodecs.Imgcodecs
+import java.io.File
+
+fun main() {
+ nu.pattern.OpenCV.loadLocally()
+ DatasetEvaluator.runDatasetEvaluation()
+}
+
+class MatMask(private val mat: Mat) : Mask {
+ override val width: Int get() = mat.width()
+ override val height: Int get() = mat.height()
+
+ override fun toMat(): Mat = mat
+}
+
+object DatasetEvaluator {
+
+ data class Entry(
+ val name: String,
+ val inputFile: File,
+ val maskFile: File
+ )
+
+ fun runDatasetEvaluation() {
+ 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 corrected: Mat? = if (quad != null) {
+ extractDocument(inputMat, quad = quad, rotationDegrees = 0)
+ } else null
+
+ val inputOut = File(outputDir, "${e.name}_input.jpg")
+ Imgcodecs.imwrite(inputOut.absolutePath, inputMat)
+
+ val outputOut = File(outputDir, "${e.name}_output.jpg")
+ if (corrected != null) {
+ Imgcodecs.imwrite(outputOut.absolutePath, corrected)
+ }
+
+ htmlFragments += """
+
+
${e.name}
+
+

+

+
+
+ """.trimIndent()
+ }
+
+ buildHtmlReport(htmlFragments)
+ println("Done! report at: reports/index.html")
+ }
+
+ private fun buildHtmlReport(parts: List) {
+ val html = """
+
+
+
+
+ Dataset Evaluation
+
+
+
+ Dataset Evaluation
+ ${parts.joinToString("\n")}
+
+
+ """.trimIndent()
+
+ File("evaluation/reports/index.html").writeText(html)
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 8d6c3aa..79f66cc 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -22,3 +22,4 @@ dependencyResolutionManagement {
rootProject.name = "FairScan"
include(":app")
include(":imageprocessing")
+include(":evaluation")