6666
6767ENABLE_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
7085SHAFT_DISTANCE_AMBIG_RATIO : float = 0.08
7186SPACER_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