github-actions[bot] commited on
Commit
2747d77
·
1 Parent(s): 9d4a1d1

deploy: backend bundle from e30f205a156e2874746858ea018a9649ff80d3b5

Browse files
Code/Backend/src/validators/__init__.py CHANGED
@@ -1,5 +1,4 @@
1
  """Pokemon card validation modules"""
2
  from .feature_based_validator import FeatureBasedValidator
3
- from .multilayer_validation import run_multilayer_validation
4
 
5
- __all__ = ['FeatureBasedValidator', 'run_multilayer_validation']
 
1
  """Pokemon card validation modules"""
2
  from .feature_based_validator import FeatureBasedValidator
 
3
 
4
+ __all__ = ['FeatureBasedValidator']
Code/Backend/src/validators/multilayer_validation.py ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared multi-layer validation flow for API and Gradio paths.
3
+
4
+ This module centralizes pre-DL checks so both entry points make identical
5
+ validation decisions and emit consistent diagnostics.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ import cv2
14
+ import numpy as np
15
+
16
+ from src.preprocessing.card_detector import (
17
+ POKEMON_CARD_DETECTION_CONFIG,
18
+ detect_card_boundary_with_hand,
19
+ )
20
+ from src.preprocessing.image_utils import check_image_quality
21
+
22
+
23
+ FRONT_SATURATION_THRESHOLD = 35.0
24
+ BACK_SATURATION_THRESHOLD = 15.0
25
+ FRONT_BACK_BLUE_THRESHOLD = 0.25
26
+
27
+
28
+ @dataclass
29
+ class ColorValidation:
30
+ """Color/pattern validation result."""
31
+
32
+ passed: bool
33
+ confidence: float
34
+ reason: str
35
+
36
+
37
+ @dataclass
38
+ class SideValidation:
39
+ """Layered validation state for one image side."""
40
+
41
+ side: str
42
+ provided: bool
43
+ quality: Dict[str, Any]
44
+ corners_detected: bool
45
+ saturation: float
46
+ saturation_threshold: float
47
+ layer1_passed: bool
48
+ layer1_failure: str = ""
49
+ failure: str = ""
50
+
51
+
52
+ @dataclass
53
+ class MultiLayerValidationResult:
54
+ """Unified validation output consumed by API and Gradio."""
55
+
56
+ front: SideValidation
57
+ back: SideValidation
58
+ processed_sides: List[str]
59
+ pokemon_back_validation: Optional[ColorValidation] = None
60
+ front_not_back_validation: Optional[ColorValidation] = None
61
+ rejection_category: Optional[str] = None
62
+ rejection_message: Optional[str] = None
63
+ rejection_details: Dict[str, Any] = field(default_factory=dict)
64
+
65
+ @property
66
+ def rejected(self) -> bool:
67
+ return self.rejection_category is not None
68
+
69
+
70
+ def _default_quality() -> Dict[str, Any]:
71
+ return {
72
+ "blur_score": 0.0,
73
+ "brightness": 0.0,
74
+ "contrast": 0.0,
75
+ "is_acceptable": False,
76
+ }
77
+
78
+
79
+ def _extract_quality_info(image: Optional[np.ndarray]) -> Dict[str, Any]:
80
+ if image is None:
81
+ return _default_quality()
82
+
83
+ try:
84
+ quality = check_image_quality(image)
85
+ return {
86
+ "blur_score": float(quality["blur_score"]),
87
+ "brightness": float(quality["brightness"]),
88
+ "contrast": float(quality["contrast"]),
89
+ "is_acceptable": bool(quality["is_acceptable"]),
90
+ }
91
+ except Exception:
92
+ return _default_quality()
93
+
94
+
95
+ def _mean_saturation_in_region(image: np.ndarray, corners: np.ndarray, max_dim: int = 512) -> float:
96
+ """
97
+ Estimate mean HSV saturation inside the detected card polygon.
98
+ """
99
+ try:
100
+ height, width = image.shape[:2]
101
+ if height <= 0 or width <= 0:
102
+ return 0.0
103
+
104
+ scale = 1.0
105
+ work_image = image
106
+ if max(height, width) > max_dim:
107
+ scale = max_dim / float(max(height, width))
108
+ new_w = max(1, int(width * scale))
109
+ new_h = max(1, int(height * scale))
110
+ work_image = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
111
+
112
+ scaled_corners = (corners * scale).astype(np.int32)
113
+
114
+ hsv = cv2.cvtColor(work_image, cv2.COLOR_BGR2HSV)
115
+ mask = np.zeros(work_image.shape[:2], dtype=np.uint8)
116
+ cv2.fillPoly(mask, [scaled_corners], 255)
117
+
118
+ if cv2.countNonZero(mask) == 0:
119
+ return 0.0
120
+
121
+ saturation = hsv[:, :, 1]
122
+ return float(saturation[mask == 255].mean())
123
+ except Exception:
124
+ return 0.0
125
+
126
+
127
+ def _validate_layer1(side: str, image: Optional[np.ndarray], sat_threshold: float) -> SideValidation:
128
+ quality = _extract_quality_info(image)
129
+
130
+ if image is None:
131
+ return SideValidation(
132
+ side=side,
133
+ provided=False,
134
+ quality=quality,
135
+ corners_detected=False,
136
+ saturation=0.0,
137
+ saturation_threshold=sat_threshold,
138
+ layer1_passed=False,
139
+ layer1_failure="not provided",
140
+ failure="not provided",
141
+ )
142
+
143
+ corners = detect_card_boundary_with_hand(image, **POKEMON_CARD_DETECTION_CONFIG)
144
+ corners_detected = corners is not None
145
+ saturation = _mean_saturation_in_region(image, corners) if corners_detected else 0.0
146
+ layer1_passed = corners_detected and saturation >= sat_threshold
147
+
148
+ if layer1_passed:
149
+ failure = ""
150
+ layer1_failure = ""
151
+ elif not corners_detected:
152
+ failure = "aspect ratio"
153
+ layer1_failure = "aspect ratio"
154
+ else:
155
+ failure = "low saturation"
156
+ layer1_failure = "low saturation"
157
+
158
+ return SideValidation(
159
+ side=side,
160
+ provided=True,
161
+ quality=quality,
162
+ corners_detected=corners_detected,
163
+ saturation=saturation,
164
+ saturation_threshold=sat_threshold,
165
+ layer1_passed=layer1_passed,
166
+ layer1_failure=layer1_failure,
167
+ failure=failure,
168
+ )
169
+
170
+
171
+ def _build_no_side_rejection(
172
+ front: SideValidation,
173
+ back: SideValidation,
174
+ back_validation: Optional[ColorValidation],
175
+ front_not_back_validation: Optional[ColorValidation],
176
+ ) -> tuple[str, str, Dict[str, Any]]:
177
+ if front.failure == "front_is_back" and front_not_back_validation is not None:
178
+ return (
179
+ "front_is_back",
180
+ "Both images appear to be card backs",
181
+ {
182
+ "front_confidence": front_not_back_validation.confidence,
183
+ "reason": front_not_back_validation.reason,
184
+ },
185
+ )
186
+
187
+ if back.failure == "back_pattern" and back_validation is not None:
188
+ return (
189
+ "back_pattern",
190
+ "Back image does not show a Pokemon card",
191
+ {
192
+ "validation_confidence": back_validation.confidence,
193
+ "reason": back_validation.reason,
194
+ },
195
+ )
196
+
197
+ if front.provided and back.provided:
198
+ return (
199
+ "geometry",
200
+ "No Pokemon card detected in either image",
201
+ {
202
+ "front_failure": front.layer1_failure or front.failure,
203
+ "back_failure": back.layer1_failure or back.failure,
204
+ "front_saturation": front.saturation,
205
+ "back_saturation": back.saturation,
206
+ },
207
+ )
208
+
209
+ if front.provided:
210
+ return (
211
+ "geometry",
212
+ "No Pokemon card detected in front image",
213
+ {
214
+ "front_failure": front.layer1_failure or front.failure,
215
+ "front_saturation": front.saturation,
216
+ },
217
+ )
218
+
219
+ if back.provided:
220
+ return (
221
+ "geometry",
222
+ "No Pokemon card detected in back image",
223
+ {
224
+ "back_failure": back.layer1_failure or back.failure,
225
+ "back_saturation": back.saturation,
226
+ },
227
+ )
228
+
229
+ return (
230
+ "geometry",
231
+ "No input images provided",
232
+ {},
233
+ )
234
+
235
+
236
+ def run_multilayer_validation(
237
+ front_image: Optional[np.ndarray],
238
+ back_image: Optional[np.ndarray],
239
+ feature_validator: Optional[Any] = None,
240
+ require_both_sides: bool = False,
241
+ ) -> MultiLayerValidationResult:
242
+ """
243
+ Run shared pre-DL validation and return side-level decisions.
244
+
245
+ Args:
246
+ front_image: Front image in BGR format.
247
+ back_image: Back image in BGR format.
248
+ feature_validator: Optional FeatureBasedValidator instance.
249
+ require_both_sides: If True, reject when only one side is valid.
250
+ """
251
+ front = _validate_layer1("front", front_image, FRONT_SATURATION_THRESHOLD)
252
+ back = _validate_layer1("back", back_image, BACK_SATURATION_THRESHOLD)
253
+
254
+ processed_sides: List[str] = []
255
+ if front.layer1_passed:
256
+ processed_sides.append("front")
257
+ if back.layer1_passed:
258
+ processed_sides.append("back")
259
+
260
+ back_validation: Optional[ColorValidation] = None
261
+ if "back" in processed_sides and feature_validator is not None and back_image is not None:
262
+ is_pokemon_back, confidence, reason = feature_validator.validate_pokemon_back_colors(back_image)
263
+ back_validation = ColorValidation(
264
+ passed=bool(is_pokemon_back),
265
+ confidence=float(confidence),
266
+ reason=reason,
267
+ )
268
+ if not is_pokemon_back:
269
+ processed_sides.remove("back")
270
+ back.failure = "back_pattern"
271
+
272
+ front_not_back_validation: Optional[ColorValidation] = None
273
+ if "front" in processed_sides and feature_validator is not None and front_image is not None:
274
+ is_front_also_back, confidence, reason = feature_validator.validate_pokemon_back_colors(
275
+ front_image,
276
+ blue_threshold=FRONT_BACK_BLUE_THRESHOLD,
277
+ )
278
+ front_not_back_validation = ColorValidation(
279
+ passed=not bool(is_front_also_back),
280
+ confidence=float(confidence),
281
+ reason=reason,
282
+ )
283
+ if is_front_also_back:
284
+ processed_sides.remove("front")
285
+ front.failure = "front_is_back"
286
+
287
+ result = MultiLayerValidationResult(
288
+ front=front,
289
+ back=back,
290
+ processed_sides=processed_sides,
291
+ pokemon_back_validation=back_validation,
292
+ front_not_back_validation=front_not_back_validation,
293
+ )
294
+
295
+ if (
296
+ require_both_sides
297
+ and front.provided
298
+ and back.provided
299
+ and len(processed_sides) == 1
300
+ ):
301
+ passing_side = processed_sides[0]
302
+ failing_side = "back" if passing_side == "front" else "front"
303
+ result.rejection_category = "mismatch"
304
+ result.rejection_message = f"Only {passing_side} image shows a valid card"
305
+ result.rejection_details = {
306
+ "front_passed": passing_side == "front",
307
+ "back_passed": passing_side == "back",
308
+ "failing_side": failing_side,
309
+ }
310
+ return result
311
+
312
+ if not processed_sides:
313
+ category, message, details = _build_no_side_rejection(
314
+ front=front,
315
+ back=back,
316
+ back_validation=back_validation,
317
+ front_not_back_validation=front_not_back_validation,
318
+ )
319
+ result.rejection_category = category
320
+ result.rejection_message = message
321
+ result.rejection_details = details
322
+
323
+ return result