From 87433fa96ad80251a8a1cc5c4340c29944bea7d1 Mon Sep 17 00:00:00 2001 From: pynicolas <6371790+pynicolas@users.noreply.github.com> Date: Sun, 7 Dec 2025 21:39:49 +0100 Subject: [PATCH] New module: evaluation (#78) --- .idea/gradle.xml | 1 + evaluation/.gitignore | 5 + evaluation/README.md | 17 +++ evaluation/build.gradle.kts | 17 +++ evaluation/python/generate_masks.py | 51 ++++++++ evaluation/python/requirements.txt | 3 + .../fairscan/evaluation/DatasetEvaluator.kt | 123 ++++++++++++++++++ settings.gradle.kts | 1 + 8 files changed, 218 insertions(+) create mode 100644 evaluation/.gitignore create mode 100644 evaluation/README.md create mode 100644 evaluation/build.gradle.kts create mode 100755 evaluation/python/generate_masks.py create mode 100644 evaluation/python/requirements.txt create mode 100644 evaluation/src/main/java/org/fairscan/evaluation/DatasetEvaluator.kt 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")