New module: evaluation (#78)
This commit is contained in:
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -11,6 +11,7 @@
|
|||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
<option value="$PROJECT_DIR$/evaluation" />
|
||||||
<option value="$PROJECT_DIR$/imageprocessing" />
|
<option value="$PROJECT_DIR$/imageprocessing" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
5
evaluation/.gitignore
vendored
Normal file
5
evaluation/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/build
|
||||||
|
/dataset/images
|
||||||
|
/dataset/masks
|
||||||
|
/python/venv
|
||||||
|
/reports
|
||||||
17
evaluation/README.md
Normal file
17
evaluation/README.md
Normal 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`.
|
||||||
|
|
||||||
17
evaluation/build.gradle.kts
Normal file
17
evaluation/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
51
evaluation/python/generate_masks.py
Executable file
51
evaluation/python/generate_masks.py
Executable 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)
|
||||||
3
evaluation/python/requirements.txt
Normal file
3
evaluation/python/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
tensorflow
|
||||||
|
numpy
|
||||||
|
Pillow
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,3 +22,4 @@ dependencyResolutionManagement {
|
|||||||
rootProject.name = "FairScan"
|
rootProject.name = "FairScan"
|
||||||
include(":app")
|
include(":app")
|
||||||
include(":imageprocessing")
|
include(":imageprocessing")
|
||||||
|
include(":evaluation")
|
||||||
|
|||||||
Reference in New Issue
Block a user