New module: evaluation (#78)
This commit is contained in:
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -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
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"
|
||||
include(":app")
|
||||
include(":imageprocessing")
|
||||
include(":evaluation")
|
||||
|
||||
Reference in New Issue
Block a user