New module: evaluation (#78)

This commit is contained in:
pynicolas
2025-12-07 21:39:49 +01:00
committed by GitHub
parent 3d9d5565f1
commit 87433fa96a
8 changed files with 218 additions and 0 deletions

1
.idea/gradle.xml generated
View File

@@ -11,6 +11,7 @@
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/evaluation" />
<option value="$PROJECT_DIR$/imageprocessing" />
</set>
</option>

5
evaluation/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/build
/dataset/images
/dataset/masks
/python/venv
/reports

17
evaluation/README.md Normal file
View File

@@ -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`.

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
tensorflow
numpy
Pillow

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<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 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 += """
<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 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)
}
}

View File

@@ -22,3 +22,4 @@ dependencyResolutionManagement {
rootProject.name = "FairScan"
include(":app")
include(":imageprocessing")
include(":evaluation")