Skip to content

Commit 88e6f60

Browse files
committed
Add student-facing image quality scores
1 parent 6dee51f commit 88e6f60

3 files changed

Lines changed: 208 additions & 17 deletions

File tree

evaluation_function/evaluation.py

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,20 @@
3737
},
3838
"precheck": {
3939
"report": (
40-
"Photo quality reference values:\n"
41-
"- brightness: {brightness_mean}\n"
42-
"- contrast: {contrast_std}\n"
43-
"- sharpness: {sharpness_score}\n"
44-
"- noise: {noise_score}\n"
45-
"These values are provided as reference only and do not determine whether the assembly is correct."
40+
"Photo quality score: {quality_score}/100.\n"
41+
"Brightness: {brightness_score}/100\n"
42+
"Contrast: {contrast_score}/100\n"
43+
"Sharpness: {sharpness_score}/100\n"
44+
"Noise control: {noise_score}/100\n"
45+
"{quality_advice}"
46+
),
47+
"fail": (
48+
"Photo quality score: {quality_score}/100.\n"
49+
"Brightness: {brightness_score}/100\n"
50+
"Contrast: {contrast_score}/100\n"
51+
"Sharpness: {sharpness_score}/100\n"
52+
"Noise control: {noise_score}/100\n"
53+
"{quality_advice}"
4654
),
4755
},
4856
"single_stage": {
@@ -274,6 +282,7 @@ def keep(e: Dict[str, Any]) -> bool:
274282
if task == "precheck":
275283
return code in {
276284
"E_PRECHECK_BIG_SMALL_INCONSISTENT",
285+
"E_PHOTO_QUALITY_LOW",
277286
"E_NO_GEARS",
278287
}
279288

@@ -521,18 +530,36 @@ def _build_student_message(
521530

522531
if task == "precheck":
523532
quality = out.get("quality", {}) if isinstance(out.get("quality"), dict) else {}
533+
codes = {
534+
str(e.get("code", "")).upper()
535+
for e in selected_errors
536+
if isinstance(e, dict)
537+
}
524538

525-
def qfmt(key: str) -> str:
539+
def qint(key: str, default: int = 0) -> str:
526540
try:
527-
return f"{float(quality.get(key, 0.0)):.2f}"
541+
return str(int(round(float(quality.get(key, default)))))
528542
except Exception:
529-
return str(quality.get(key, "N/A"))
530-
531-
return True, MESSAGE_POLICY["precheck"]["report"].format(
532-
brightness_mean=qfmt("brightness_mean"),
533-
contrast_std=qfmt("contrast_std"),
534-
sharpness_score=qfmt("sharpness_score"),
535-
noise_score=qfmt("noise_score"),
543+
return str(default)
544+
545+
def advice_text() -> str:
546+
advice = quality.get("quality_advice", [])
547+
if isinstance(advice, list):
548+
clean = [str(item).strip() for item in advice if str(item).strip()]
549+
else:
550+
clean = [str(advice).strip()] if str(advice).strip() else []
551+
if not clean:
552+
clean = ["The photo is clear enough for the next check."]
553+
return "\n".join(f"- {item}" for item in clean[:3])
554+
555+
policy_key = "fail" if ("E_PHOTO_QUALITY_LOW" in codes or task_has_error) else "report"
556+
return policy_key == "report", MESSAGE_POLICY["precheck"][policy_key].format(
557+
quality_score=qint("quality_score"),
558+
brightness_score=qint("brightness_score"),
559+
contrast_score=qint("contrast_score"),
560+
sharpness_score=qint("sharpness_score_100"),
561+
noise_score=qint("noise_score_100"),
562+
quality_advice=advice_text(),
536563
)
537564

538565
if task == "single_stage":

evaluation_function/evaluation_test.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
import unittest
33
from pathlib import Path
44

5+
import cv2
6+
import numpy as np
57
from lf_toolkit.evaluation import Params
68
from .evaluation import evaluation_function
9+
from .yolo_pipeline import compute_image_quality_metrics
710

811

912
def _as_file_uri(path: str) -> str:
@@ -113,6 +116,31 @@ def test_evaluation_bad_url(self):
113116
fb_text = _feedback_as_text(fb)
114117
self.assertIn("could not be loaded", fb_text.lower())
115118

119+
def test_image_quality_score_uses_student_friendly_threshold(self):
120+
sharp = np.zeros((120, 120, 3), dtype=np.uint8)
121+
sharp[:, :60] = 30
122+
sharp[:, 60:] = 230
123+
cv2.line(sharp, (0, 0), (119, 119), (255, 255, 255), 3)
124+
125+
blurry = np.full((120, 120, 3), 120, dtype=np.uint8)
126+
blurry = cv2.GaussianBlur(blurry, (31, 31), 0)
127+
128+
sharp_quality = compute_image_quality_metrics(sharp)
129+
blurry_quality = compute_image_quality_metrics(blurry)
130+
131+
self.assertIn("quality_score", sharp_quality)
132+
self.assertIn("quality_accept_score", sharp_quality)
133+
self.assertEqual(sharp_quality["quality_score_max"], 100)
134+
self.assertIn("quality_advice", sharp_quality)
135+
self.assertGreaterEqual(
136+
sharp_quality["quality_score"],
137+
sharp_quality["quality_accept_score"],
138+
)
139+
self.assertLess(
140+
blurry_quality["quality_score"],
141+
blurry_quality["quality_accept_score"],
142+
)
143+
116144

117145
if __name__ == "__main__":
118-
unittest.main()
146+
unittest.main()

evaluation_function/yolo_pipeline.py

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@
6666

6767
ENABLE_ERROR_CHECKS: bool = True
6868

69+
# Image-quality precheck thresholds. Scores are normalized to 0-100 for display.
70+
# The fail threshold is intentionally permissive so borderline usable photos are
71+
# not rejected while obviously dark/blurry/noisy ones are sent back for retake.
72+
QUALITY_ACCEPT_SCORE: int = int(os.environ.get("QUALITY_ACCEPT_SCORE", "40"))
73+
QUALITY_BRIGHTNESS_USABLE_MIN: float = 35.0
74+
QUALITY_BRIGHTNESS_IDEAL_MIN: float = 60.0
75+
QUALITY_BRIGHTNESS_IDEAL_MAX: float = 210.0
76+
QUALITY_BRIGHTNESS_USABLE_MAX: float = 235.0
77+
QUALITY_CONTRAST_USABLE_MIN: float = 10.0
78+
QUALITY_CONTRAST_IDEAL_MIN: float = 25.0
79+
QUALITY_SHARPNESS_USABLE_MIN: float = 40.0
80+
QUALITY_SHARPNESS_IDEAL_MIN: float = 250.0
81+
QUALITY_NOISE_IDEAL_MAX: float = 12.0
82+
QUALITY_NOISE_USABLE_MAX: float = 35.0
83+
6984
# Geometry / ambiguity thresholds
7085
SHAFT_DISTANCE_AMBIG_RATIO: float = 0.08
7186
SPACER_ASSIGN_AXIS_DIST_RATIO: float = 0.90
@@ -711,7 +726,74 @@ def get_spacer_counts(spacers: List[Dict[str, Any]]) -> Dict[str, int]:
711726
# =========================
712727
# Precheck helpers
713728
# =========================
714-
def compute_image_quality_metrics(img_bgr: np.ndarray) -> Dict[str, float]:
729+
def _clip01(value: float) -> float:
730+
return max(0.0, min(1.0, float(value)))
731+
732+
733+
def _score_band(value: float, usable_min: float, ideal_min: float, ideal_max: float, usable_max: float) -> float:
734+
if ideal_min <= value <= ideal_max:
735+
return 1.0
736+
if usable_min <= value < ideal_min:
737+
return _clip01((value - usable_min) / (ideal_min - usable_min))
738+
if ideal_max < value <= usable_max:
739+
return _clip01((usable_max - value) / (usable_max - ideal_max))
740+
return 0.0
741+
742+
743+
def _score_min(value: float, usable_min: float, ideal_min: float) -> float:
744+
if value >= ideal_min:
745+
return 1.0
746+
if value <= usable_min:
747+
return 0.0
748+
return _clip01((value - usable_min) / (ideal_min - usable_min))
749+
750+
751+
def _score_max(value: float, ideal_max: float, usable_max: float) -> float:
752+
if value <= ideal_max:
753+
return 1.0
754+
if value >= usable_max:
755+
return 0.0
756+
return _clip01((usable_max - value) / (usable_max - ideal_max))
757+
758+
759+
def _score100(component: float) -> int:
760+
return int(round(100.0 * _clip01(component)))
761+
762+
763+
def _quality_advice(
764+
*,
765+
brightness_mean: float,
766+
contrast_component: float,
767+
sharpness_component: float,
768+
noise_component: float,
769+
) -> List[str]:
770+
advice: List[str] = []
771+
772+
if brightness_mean < QUALITY_BRIGHTNESS_USABLE_MIN:
773+
advice.append("The photo is too dark. Retake it in brighter light.")
774+
elif brightness_mean < QUALITY_BRIGHTNESS_IDEAL_MIN:
775+
advice.append("The photo is a little dark. Add more light if possible.")
776+
elif brightness_mean > QUALITY_BRIGHTNESS_USABLE_MAX:
777+
advice.append("The photo is overexposed. Reduce glare or strong direct light.")
778+
elif brightness_mean > QUALITY_BRIGHTNESS_IDEAL_MAX:
779+
advice.append("The photo is a little bright. Try reducing glare.")
780+
781+
if contrast_component < 0.5:
782+
advice.append("The parts do not stand out clearly. Use a plain background and avoid shadows.")
783+
784+
if sharpness_component < 0.5:
785+
advice.append("The photo is blurry. Hold the camera still and refocus before taking the photo.")
786+
787+
if noise_component < 0.5:
788+
advice.append("The photo looks noisy or grainy. Use better lighting and avoid digital zoom.")
789+
790+
if not advice:
791+
advice.append("The photo is clear enough for the next check.")
792+
793+
return advice
794+
795+
796+
def compute_image_quality_metrics(img_bgr: np.ndarray) -> Dict[str, Any]:
715797
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
716798

717799
brightness_mean = float(np.mean(gray))
@@ -721,11 +803,57 @@ def compute_image_quality_metrics(img_bgr: np.ndarray) -> Dict[str, float]:
721803
noise_residual = gray.astype(np.float32) - blur.astype(np.float32)
722804
noise_score = float(np.std(noise_residual))
723805

806+
brightness_component = _score_band(
807+
brightness_mean,
808+
QUALITY_BRIGHTNESS_USABLE_MIN,
809+
QUALITY_BRIGHTNESS_IDEAL_MIN,
810+
QUALITY_BRIGHTNESS_IDEAL_MAX,
811+
QUALITY_BRIGHTNESS_USABLE_MAX,
812+
)
813+
contrast_component = _score_min(
814+
contrast_std,
815+
QUALITY_CONTRAST_USABLE_MIN,
816+
QUALITY_CONTRAST_IDEAL_MIN,
817+
)
818+
sharpness_component = _score_min(
819+
sharpness_score,
820+
QUALITY_SHARPNESS_USABLE_MIN,
821+
QUALITY_SHARPNESS_IDEAL_MIN,
822+
)
823+
noise_component = _score_max(
824+
noise_score,
825+
QUALITY_NOISE_IDEAL_MAX,
826+
QUALITY_NOISE_USABLE_MAX,
827+
)
828+
quality_score = int(round(100.0 * (
829+
0.25 * brightness_component
830+
+ 0.20 * contrast_component
831+
+ 0.40 * sharpness_component
832+
+ 0.15 * noise_component
833+
)))
834+
if contrast_component <= 0.0 and sharpness_component <= 0.0:
835+
quality_score = min(quality_score, QUALITY_ACCEPT_SCORE - 1)
836+
advice = _quality_advice(
837+
brightness_mean=brightness_mean,
838+
contrast_component=contrast_component,
839+
sharpness_component=sharpness_component,
840+
noise_component=noise_component,
841+
)
842+
724843
return {
725844
"brightness_mean": brightness_mean,
726845
"contrast_std": contrast_std,
727846
"sharpness_score": sharpness_score,
728847
"noise_score": noise_score,
848+
"brightness_score": _score100(brightness_component),
849+
"contrast_score": _score100(contrast_component),
850+
"sharpness_score_100": _score100(sharpness_component),
851+
"noise_score_100": _score100(noise_component),
852+
"quality_score": quality_score,
853+
"quality_score_max": 100,
854+
"quality_accept_score": QUALITY_ACCEPT_SCORE,
855+
"quality_pass": quality_score >= QUALITY_ACCEPT_SCORE,
856+
"quality_advice": advice,
729857
}
730858

731859

@@ -2104,6 +2232,14 @@ def _need_shafts() -> bool:
21042232
quality = compute_image_quality_metrics(img_bgr)
21052233
counts = get_gear_counts(gears)
21062234
errors: List[Dict[str, Any]] = []
2235+
if not bool(quality.get("quality_pass", True)):
2236+
errors.append({
2237+
"code": "E_PHOTO_QUALITY_LOW",
2238+
"message": (
2239+
"The photo quality is too low for reliable checking. "
2240+
"Please retake it with clearer focus and better lighting."
2241+
),
2242+
})
21072243

21082244
out = {
21092245
"summary": {

0 commit comments

Comments
 (0)