webolavo commited on
Commit
befcabf
·
verified ·
1 Parent(s): ae7b51f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +129 -123
app.py CHANGED
@@ -42,34 +42,12 @@ PERSON_CLASS_SPECS: tuple[tuple[str, str, str, str], ...] = (
42
  ("man lying down", "person", "male", "person"),
43
  ("man taking a bath", "person", "male", "person"),
44
  ("young child", "person", "child", "person"),
45
- ("child", "person", "child", "person"),
46
- ("kid", "person", "child", "person"),
47
  ("baby", "person", "child", "person"),
48
  ("toddler", "person", "child", "person"),
49
- ("preschool child", "person", "child", "person"),
50
- ("school-age child", "person", "child", "person"),
51
- ("young boy", "person", "child", "person"),
52
- ("young girl", "person", "child", "person"),
53
  ("little girl", "person", "child", "person"),
54
  ("little boy", "person", "child", "person"),
55
  )
56
 
57
- # Non-human prompts are not moderation classes. They are visual counterevidence
58
- # used to stop a trophy, bottle, appliance, toy or package from becoming a
59
- # highly confident "female body" merely because it sits inside a broad person
60
- # box. The rejection rule below requires strong spatial and score agreement, so
61
- # a real person printed on product packaging is not discarded automatically.
62
- OBJECT_CLASS_SPECS: tuple[tuple[str, str, str, str], ...] = (
63
- ("trophy", "object", "neutral", "trophy"),
64
- ("gold trophy", "object", "neutral", "trophy"),
65
- ("bottle", "object", "neutral", "bottle"),
66
- ("product bottle", "object", "neutral", "bottle"),
67
- ("product packaging", "object", "neutral", "packaging"),
68
- ("box package", "object", "neutral", "packaging"),
69
- ("appliance", "object", "neutral", "appliance"),
70
- ("toy", "object", "neutral", "toy"),
71
- )
72
-
73
  PART_CONCEPTS = (
74
  "face",
75
  "head",
@@ -90,34 +68,34 @@ PART_CONCEPTS = (
90
  "foot",
91
  )
92
 
93
- OBJECT_GATED_CONCEPTS = {
94
- "body",
95
- "chest",
96
- "torso",
97
- "abdomen",
98
- "midriff",
99
- "stomach",
100
- "waist",
101
- "belly",
102
- "arm",
103
- "leg",
104
- "thigh",
105
- }
106
-
107
- # Generic words such as "child" and "kid" raise recall, but the fallback age
108
- # consensus uses only visually specific age phrases. This prevents one weak
109
- # generic child response on adult hands or an object from changing the age.
110
  CHILD_CONSENSUS_LABELS = {
 
111
  "baby",
112
  "toddler",
113
- "preschool child",
114
- "school-age child",
115
- "young boy",
116
- "young girl",
117
  "little boy",
118
  "little girl",
119
  }
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
  def _build_class_specs() -> tuple[tuple[str, str, str, str], ...]:
123
  specs = list(PERSON_CLASS_SPECS)
@@ -138,15 +116,8 @@ def _build_class_specs() -> tuple[tuple[str, str, str, str], ...]:
138
  ("man's upper body", "part", "male", "body"),
139
  ("woman's profile face", "part", "female", "face"),
140
  ("man's profile face", "part", "male", "face"),
141
- ("woman's hand holding an object", "part", "female", "hand"),
142
- ("man's hand holding an object", "part", "male", "hand"),
143
- ("woman's hands holding an object", "part", "female", "hand"),
144
- ("man's hands holding an object", "part", "male", "hand"),
145
- ("woman's hand wearing jewelry", "part", "female", "hand"),
146
- ("man's hand wearing jewelry", "part", "male", "hand"),
147
  )
148
  )
149
- specs.extend(OBJECT_CLASS_SPECS)
150
  return tuple(specs)
151
 
152
 
@@ -180,6 +151,9 @@ CHILD_CONSENSUS_MAX_ADULT_SCORE = float(
180
  CHILD_CONSENSUS_MAX_ADULT_MARGIN = float(
181
  os.getenv("CHILD_CONSENSUS_MAX_ADULT_MARGIN", "0.08")
182
  )
 
 
 
183
  STRONG_FEMALE_PERSON_THRESHOLD = float(
184
  os.getenv("STRONG_FEMALE_PERSON_THRESHOLD", "0.30")
185
  )
@@ -219,6 +193,21 @@ FUSION_MIN_TOTAL_MARGIN = float(
219
  )
220
  FUSION_MIN_TOP_SCORE = float(os.getenv("FUSION_MIN_TOP_SCORE", "0.13"))
221
  HUMAN_ANCHOR_THRESHOLD = float(os.getenv("HUMAN_ANCHOR_THRESHOLD", "0.12"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  ROBUST_MALE_OWNER_THRESHOLD = float(
223
  os.getenv("ROBUST_MALE_OWNER_THRESHOLD", "0.25")
224
  )
@@ -231,13 +220,12 @@ STRONG_UNANCHORED_PART_THRESHOLD = float(
231
  STRONG_UNANCHORED_PART_MARGIN = float(
232
  os.getenv("STRONG_UNANCHORED_PART_MARGIN", "0.03")
233
  )
234
- OBJECT_CONFLICT_THRESHOLD = float(os.getenv("OBJECT_CONFLICT_THRESHOLD", "0.12"))
235
- OBJECT_CONFLICT_SCORE_RATIO = float(
236
- os.getenv("OBJECT_CONFLICT_SCORE_RATIO", "0.65")
237
  )
238
- OBJECT_CONFLICT_IOU = float(os.getenv("OBJECT_CONFLICT_IOU", "0.55"))
239
- OBJECT_CONFLICT_AREA_SIMILARITY = float(
240
- os.getenv("OBJECT_CONFLICT_AREA_SIMILARITY", "0.55")
241
  )
242
  MODEL_IOU_THRESHOLD = float(os.getenv("MODEL_IOU_THRESHOLD", "0.50"))
243
  CLUSTER_IOU_THRESHOLD = float(os.getenv("CLUSTER_IOU_THRESHOLD", "0.45"))
@@ -265,6 +253,7 @@ for threshold_name, threshold_value in {
265
  "CHILD_CONSENSUS_THRESHOLD": CHILD_CONSENSUS_THRESHOLD,
266
  "CHILD_CONSENSUS_MAX_ADULT_SCORE": CHILD_CONSENSUS_MAX_ADULT_SCORE,
267
  "CHILD_CONSENSUS_MAX_ADULT_MARGIN": CHILD_CONSENSUS_MAX_ADULT_MARGIN,
 
268
  "STRONG_FEMALE_PERSON_THRESHOLD": STRONG_FEMALE_PERSON_THRESHOLD,
269
  "STRONG_FEMALE_PART_THRESHOLD": STRONG_FEMALE_PART_THRESHOLD,
270
  "STRONG_FEMALE_MARGIN": STRONG_FEMALE_MARGIN,
@@ -272,14 +261,17 @@ for threshold_name, threshold_value in {
272
  "FUSION_MIN_TOTAL_MARGIN": FUSION_MIN_TOTAL_MARGIN,
273
  "FUSION_MIN_TOP_SCORE": FUSION_MIN_TOP_SCORE,
274
  "HUMAN_ANCHOR_THRESHOLD": HUMAN_ANCHOR_THRESHOLD,
 
 
 
 
 
275
  "ROBUST_MALE_OWNER_THRESHOLD": ROBUST_MALE_OWNER_THRESHOLD,
276
  "ROBUST_MALE_OWNER_MARGIN": ROBUST_MALE_OWNER_MARGIN,
277
  "STRONG_UNANCHORED_PART_THRESHOLD": STRONG_UNANCHORED_PART_THRESHOLD,
278
  "STRONG_UNANCHORED_PART_MARGIN": STRONG_UNANCHORED_PART_MARGIN,
279
- "OBJECT_CONFLICT_THRESHOLD": OBJECT_CONFLICT_THRESHOLD,
280
- "OBJECT_CONFLICT_SCORE_RATIO": OBJECT_CONFLICT_SCORE_RATIO,
281
- "OBJECT_CONFLICT_IOU": OBJECT_CONFLICT_IOU,
282
- "OBJECT_CONFLICT_AREA_SIMILARITY": OBJECT_CONFLICT_AREA_SIMILARITY,
283
  "MODEL_IOU_THRESHOLD": MODEL_IOU_THRESHOLD,
284
  "CLUSTER_IOU_THRESHOLD": CLUSTER_IOU_THRESHOLD,
285
  "CLUSTER_CONTAINMENT_THRESHOLD": CLUSTER_CONTAINMENT_THRESHOLD,
@@ -295,6 +287,8 @@ if FUSION_MIN_SIGNALS < 2:
295
  raise RuntimeError("FUSION_MIN_SIGNALS must be at least 2")
296
  if CHILD_CONSENSUS_MIN_LABELS < 2:
297
  raise RuntimeError("CHILD_CONSENSUS_MIN_LABELS must be at least 2")
 
 
298
  if MAX_DETECTIONS <= 0:
299
  raise RuntimeError("MAX_DETECTIONS must be positive")
300
 
@@ -605,6 +599,10 @@ def _person_clusters(detections: list[dict[str, Any]]) -> list[dict[str, Any]]:
605
  people = [item for item in detections if item["kind"] == "person"]
606
  results: list[dict[str, Any]] = []
607
  for cluster in _spatial_clusters(people):
 
 
 
 
608
  scores = {
609
  gender: max(
610
  (item["score"] for item in cluster if item["gender"] == gender),
@@ -633,6 +631,7 @@ def _person_clusters(detections: list[dict[str, Any]]) -> list[dict[str, Any]]:
633
  and len(child_support_labels) >= CHILD_CONSENSUS_MIN_LABELS
634
  and adult_top_score <= CHILD_CONSENSUS_MAX_ADULT_SCORE
635
  and adult_gender_margin <= CHILD_CONSENSUS_MAX_ADULT_MARGIN
 
636
  )
637
  if age_consensus:
638
  winner = "child"
@@ -645,7 +644,6 @@ def _person_clusters(detections: list[dict[str, Any]]) -> list[dict[str, Any]]:
645
  )
646
  if winner == "female" and score >= STRONG_FEMALE_PERSON_THRESHOLD:
647
  confirmed = margin >= STRONG_FEMALE_MARGIN
648
- anchor = max(cluster, key=lambda item: item["score"])
649
  results.append(
650
  {
651
  "winner": winner,
@@ -657,6 +655,7 @@ def _person_clusters(detections: list[dict[str, Any]]) -> list[dict[str, Any]]:
657
  "confirmed": confirmed,
658
  "age_consensus": age_consensus,
659
  "child_support_labels": child_support_labels,
 
660
  "box": anchor["box"],
661
  "labels": sorted({item["label"] for item in cluster}),
662
  }
@@ -710,22 +709,6 @@ def _part_clusters(detections: list[dict[str, Any]]) -> list[dict[str, Any]]:
710
  return sorted(results, key=lambda item: item["score"], reverse=True)
711
 
712
 
713
- def _object_clusters(detections: list[dict[str, Any]]) -> list[dict[str, Any]]:
714
- objects = [item for item in detections if item["kind"] == "object"]
715
- results: list[dict[str, Any]] = []
716
- for cluster in _spatial_clusters(objects):
717
- anchor = max(cluster, key=lambda item: item["score"])
718
- results.append(
719
- {
720
- "concept": anchor["concept"],
721
- "score": anchor["score"],
722
- "box": anchor["box"],
723
- "labels": sorted({item["label"] for item in cluster}),
724
- }
725
- )
726
- return sorted(results, key=lambda item: item["score"], reverse=True)
727
-
728
-
729
  def _best_owner(
730
  part: dict[str, Any], people: list[dict[str, Any]]
731
  ) -> dict[str, Any] | None:
@@ -749,7 +732,14 @@ def _best_owner(
749
 
750
  def _has_human_anchor(owner: dict[str, Any] | None) -> bool:
751
  """Require an actual person-like response before trusting body-part labels."""
752
- return owner is not None and owner["score"] >= HUMAN_ANCHOR_THRESHOLD
 
 
 
 
 
 
 
753
 
754
 
755
  def _robust_nonfemale_owner(owner: dict[str, Any] | None) -> bool:
@@ -778,46 +768,46 @@ def _strong_unanchored_female_part(part: dict[str, Any]) -> bool:
778
  )
779
 
780
 
781
- def _best_object_conflict(
782
- part: dict[str, Any], objects: list[dict[str, Any]]
 
 
783
  ) -> dict[str, Any] | None:
784
- """Return strong visual evidence that a broad body label is an object.
785
 
786
- This deliberately excludes precise face/hand/foot concepts. It also
787
- requires the object and part boxes to have similar area and substantial
788
- IoU; a large package containing a smaller printed woman therefore does not
789
- erase the woman.
790
  """
791
- if part["concept"] not in OBJECT_GATED_CONCEPTS:
 
792
  return None
 
 
793
  part_area = _box_area(part["box"])
794
- if part_area <= 0.0:
795
  return None
796
 
797
- candidates: list[dict[str, Any]] = []
798
- for item in objects:
799
- object_area = _box_area(item["box"])
800
- if object_area <= 0.0:
801
- continue
802
- area_similarity = min(part_area, object_area) / max(part_area, object_area)
803
- if (
804
- _box_iou(part["box"], item["box"]) >= OBJECT_CONFLICT_IOU
805
- and area_similarity >= OBJECT_CONFLICT_AREA_SIMILARITY
806
- and item["score"] >= OBJECT_CONFLICT_THRESHOLD
807
- and item["score"]
808
- >= part["female_score"] * OBJECT_CONFLICT_SCORE_RATIO
809
- ):
810
- candidate = dict(item)
811
- candidate["iou"] = round(_box_iou(part["box"], item["box"]), 4)
812
- candidate["area_similarity"] = round(area_similarity, 4)
813
- candidates.append(candidate)
814
- return max(candidates, key=lambda item: item["score"], default=None)
815
 
816
 
817
  def _female_part_evidence(
818
  parts: list[dict[str, Any]],
819
  people: list[dict[str, Any]],
820
- objects: list[dict[str, Any]],
821
  ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
822
  confirmed: list[dict[str, Any]] = []
823
  weak: list[dict[str, Any]] = []
@@ -825,17 +815,14 @@ def _female_part_evidence(
825
  for part in parts:
826
  owner = _best_owner(part, people)
827
  part["owner"] = owner
828
- if part["winner"] != "female":
829
- continue
830
-
831
  anchored = _has_human_anchor(owner)
832
  part["human_anchor_score"] = round(owner["score"], 4) if owner else 0.0
833
 
834
- object_conflict = _best_object_conflict(part, objects)
835
- if object_conflict is not None:
836
  part["evidence_eligible"] = False
837
- part["evidence_rejection"] = "non_human_object_conflict"
838
- part["object_conflict"] = object_conflict
839
  continue
840
 
841
  if _robust_nonfemale_owner(owner):
@@ -843,6 +830,24 @@ def _female_part_evidence(
843
  part["evidence_rejection"] = f"robust_{owner['winner']}_owner"
844
  continue
845
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
846
  # Product bottles, packaging and appliances often receive many body
847
  # labels on one rectangular box. Do not accept those labels unless the
848
  # region also looks like a person. A very strong standalone part remains
@@ -917,7 +922,6 @@ def _female_part_evidence(
917
  def _decision_from_clusters(
918
  people: list[dict[str, Any]],
919
  parts: list[dict[str, Any]],
920
- objects: list[dict[str, Any]],
921
  ) -> tuple[str, str, str, list[dict[str, Any]], dict[str, Any] | None]:
922
  confirmed_females = [
923
  item for item in people if item["confirmed"] and item["winner"] == "female"
@@ -928,7 +932,7 @@ def _decision_from_clusters(
928
  confirmed_children = [
929
  item for item in people if item["confirmed"] and item["winner"] == "child"
930
  ]
931
- female_parts, fusion = _female_part_evidence(parts, people, objects)
932
 
933
  if confirmed_females:
934
  return "BLOCK", "confirmed_female_person", "female", female_parts, fusion
@@ -972,10 +976,7 @@ def _run_omdet(image: Image.Image, include_debug: bool = False) -> dict[str, Any
972
  raw = _raw_detections(image)
973
  people = _person_clusters(raw)
974
  parts = _part_clusters(raw)
975
- objects = _object_clusters(raw)
976
- decision, reason, gender, female_parts, fusion = _decision_from_clusters(
977
- people, parts, objects
978
- )
979
 
980
  person_female_score = max(
981
  (item["female_score"] for item in people), default=0.0
@@ -1007,11 +1008,14 @@ def _run_omdet(image: Image.Image, include_debug: bool = False) -> dict[str, Any
1007
  "prompt_count": len(CLASS_NAMES),
1008
  "persons": people,
1009
  "body_parts": parts,
1010
- "object_regions": objects,
1011
  "female_evidence": female_parts,
1012
  "fusion": fusion,
1013
  "decision_policy": {
1014
  "human_anchor_threshold": HUMAN_ANCHOR_THRESHOLD,
 
 
 
 
1015
  "robust_male_owner_threshold": ROBUST_MALE_OWNER_THRESHOLD,
1016
  "robust_male_owner_margin": ROBUST_MALE_OWNER_MARGIN,
1017
  "fusion_min_signals": FUSION_MIN_SIGNALS,
@@ -1019,8 +1023,10 @@ def _run_omdet(image: Image.Image, include_debug: bool = False) -> dict[str, Any
1019
  "fusion_min_top_score": FUSION_MIN_TOP_SCORE,
1020
  "child_consensus_threshold": CHILD_CONSENSUS_THRESHOLD,
1021
  "child_consensus_min_labels": CHILD_CONSENSUS_MIN_LABELS,
1022
- "object_conflict_threshold": OBJECT_CONFLICT_THRESHOLD,
1023
- "object_conflict_score_ratio": OBJECT_CONFLICT_SCORE_RATIO,
 
 
1024
  },
1025
  "raw_detection_count": len(raw),
1026
  }
@@ -1044,7 +1050,7 @@ async def lifespan(_: FastAPI):
1044
  app = FastAPI(
1045
  title="AI Shield - OmDet-Turbo Swin-Tiny Test",
1046
  description="Human-anchored female-person and female body-part detector",
1047
- version="0.3.0",
1048
  lifespan=lifespan,
1049
  )
1050
 
 
42
  ("man lying down", "person", "male", "person"),
43
  ("man taking a bath", "person", "male", "person"),
44
  ("young child", "person", "child", "person"),
 
 
45
  ("baby", "person", "child", "person"),
46
  ("toddler", "person", "child", "person"),
 
 
 
 
47
  ("little girl", "person", "child", "person"),
48
  ("little boy", "person", "child", "person"),
49
  )
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  PART_CONCEPTS = (
52
  "face",
53
  "head",
 
68
  "foot",
69
  )
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  CHILD_CONSENSUS_LABELS = {
72
+ "young child",
73
  "baby",
74
  "toddler",
 
 
 
 
75
  "little boy",
76
  "little girl",
77
  }
78
 
79
+ ANATOMICAL_ZONES = {
80
+ "face": "head",
81
+ "head": "head",
82
+ "eye": "head",
83
+ "eyebrow": "head",
84
+ "chest": "central",
85
+ "torso": "central",
86
+ "abdomen": "central",
87
+ "midriff": "central",
88
+ "stomach": "central",
89
+ "waist": "central",
90
+ "belly": "central",
91
+ "body": "central",
92
+ "arm": "limb",
93
+ "hand": "limb",
94
+ "leg": "limb",
95
+ "thigh": "limb",
96
+ "foot": "limb",
97
+ }
98
+
99
 
100
  def _build_class_specs() -> tuple[tuple[str, str, str, str], ...]:
101
  specs = list(PERSON_CLASS_SPECS)
 
116
  ("man's upper body", "part", "male", "body"),
117
  ("woman's profile face", "part", "female", "face"),
118
  ("man's profile face", "part", "male", "face"),
 
 
 
 
 
 
119
  )
120
  )
 
121
  return tuple(specs)
122
 
123
 
 
151
  CHILD_CONSENSUS_MAX_ADULT_MARGIN = float(
152
  os.getenv("CHILD_CONSENSUS_MAX_ADULT_MARGIN", "0.08")
153
  )
154
+ CHILD_CONSENSUS_MIN_ASPECT_RATIO = float(
155
+ os.getenv("CHILD_CONSENSUS_MIN_ASPECT_RATIO", "1.10")
156
+ )
157
  STRONG_FEMALE_PERSON_THRESHOLD = float(
158
  os.getenv("STRONG_FEMALE_PERSON_THRESHOLD", "0.30")
159
  )
 
193
  )
194
  FUSION_MIN_TOP_SCORE = float(os.getenv("FUSION_MIN_TOP_SCORE", "0.13"))
195
  HUMAN_ANCHOR_THRESHOLD = float(os.getenv("HUMAN_ANCHOR_THRESHOLD", "0.12"))
196
+ FEMALE_OWNER_ANCHOR_THRESHOLD = float(
197
+ os.getenv("FEMALE_OWNER_ANCHOR_THRESHOLD", "0.08")
198
+ )
199
+ FEMALE_OWNER_ANCHOR_MARGIN = float(
200
+ os.getenv("FEMALE_OWNER_ANCHOR_MARGIN", "0.02")
201
+ )
202
+ CONTRASTIVE_FEMALE_PART_THRESHOLD = float(
203
+ os.getenv("CONTRASTIVE_FEMALE_PART_THRESHOLD", "0.25")
204
+ )
205
+ CONTRASTIVE_FEMALE_MAX_DEFICIT = float(
206
+ os.getenv("CONTRASTIVE_FEMALE_MAX_DEFICIT", "0.04")
207
+ )
208
+ CONTRASTIVE_OWNER_MIN_SCORE = float(
209
+ os.getenv("CONTRASTIVE_OWNER_MIN_SCORE", "0.05")
210
+ )
211
  ROBUST_MALE_OWNER_THRESHOLD = float(
212
  os.getenv("ROBUST_MALE_OWNER_THRESHOLD", "0.25")
213
  )
 
220
  STRONG_UNANCHORED_PART_MARGIN = float(
221
  os.getenv("STRONG_UNANCHORED_PART_MARGIN", "0.03")
222
  )
223
+ SEMANTIC_COLLAPSE_IOU = float(os.getenv("SEMANTIC_COLLAPSE_IOU", "0.75"))
224
+ SEMANTIC_COLLAPSE_MIN_CONCEPTS = int(
225
+ os.getenv("SEMANTIC_COLLAPSE_MIN_CONCEPTS", "5")
226
  )
227
+ SEMANTIC_COLLAPSE_MAX_OWNER_AREA_RATIO = float(
228
+ os.getenv("SEMANTIC_COLLAPSE_MAX_OWNER_AREA_RATIO", "0.55")
 
229
  )
230
  MODEL_IOU_THRESHOLD = float(os.getenv("MODEL_IOU_THRESHOLD", "0.50"))
231
  CLUSTER_IOU_THRESHOLD = float(os.getenv("CLUSTER_IOU_THRESHOLD", "0.45"))
 
253
  "CHILD_CONSENSUS_THRESHOLD": CHILD_CONSENSUS_THRESHOLD,
254
  "CHILD_CONSENSUS_MAX_ADULT_SCORE": CHILD_CONSENSUS_MAX_ADULT_SCORE,
255
  "CHILD_CONSENSUS_MAX_ADULT_MARGIN": CHILD_CONSENSUS_MAX_ADULT_MARGIN,
256
+ "CHILD_CONSENSUS_MIN_ASPECT_RATIO": CHILD_CONSENSUS_MIN_ASPECT_RATIO,
257
  "STRONG_FEMALE_PERSON_THRESHOLD": STRONG_FEMALE_PERSON_THRESHOLD,
258
  "STRONG_FEMALE_PART_THRESHOLD": STRONG_FEMALE_PART_THRESHOLD,
259
  "STRONG_FEMALE_MARGIN": STRONG_FEMALE_MARGIN,
 
261
  "FUSION_MIN_TOTAL_MARGIN": FUSION_MIN_TOTAL_MARGIN,
262
  "FUSION_MIN_TOP_SCORE": FUSION_MIN_TOP_SCORE,
263
  "HUMAN_ANCHOR_THRESHOLD": HUMAN_ANCHOR_THRESHOLD,
264
+ "FEMALE_OWNER_ANCHOR_THRESHOLD": FEMALE_OWNER_ANCHOR_THRESHOLD,
265
+ "FEMALE_OWNER_ANCHOR_MARGIN": FEMALE_OWNER_ANCHOR_MARGIN,
266
+ "CONTRASTIVE_FEMALE_PART_THRESHOLD": CONTRASTIVE_FEMALE_PART_THRESHOLD,
267
+ "CONTRASTIVE_FEMALE_MAX_DEFICIT": CONTRASTIVE_FEMALE_MAX_DEFICIT,
268
+ "CONTRASTIVE_OWNER_MIN_SCORE": CONTRASTIVE_OWNER_MIN_SCORE,
269
  "ROBUST_MALE_OWNER_THRESHOLD": ROBUST_MALE_OWNER_THRESHOLD,
270
  "ROBUST_MALE_OWNER_MARGIN": ROBUST_MALE_OWNER_MARGIN,
271
  "STRONG_UNANCHORED_PART_THRESHOLD": STRONG_UNANCHORED_PART_THRESHOLD,
272
  "STRONG_UNANCHORED_PART_MARGIN": STRONG_UNANCHORED_PART_MARGIN,
273
+ "SEMANTIC_COLLAPSE_IOU": SEMANTIC_COLLAPSE_IOU,
274
+ "SEMANTIC_COLLAPSE_MAX_OWNER_AREA_RATIO": SEMANTIC_COLLAPSE_MAX_OWNER_AREA_RATIO,
 
 
275
  "MODEL_IOU_THRESHOLD": MODEL_IOU_THRESHOLD,
276
  "CLUSTER_IOU_THRESHOLD": CLUSTER_IOU_THRESHOLD,
277
  "CLUSTER_CONTAINMENT_THRESHOLD": CLUSTER_CONTAINMENT_THRESHOLD,
 
287
  raise RuntimeError("FUSION_MIN_SIGNALS must be at least 2")
288
  if CHILD_CONSENSUS_MIN_LABELS < 2:
289
  raise RuntimeError("CHILD_CONSENSUS_MIN_LABELS must be at least 2")
290
+ if SEMANTIC_COLLAPSE_MIN_CONCEPTS < 3:
291
+ raise RuntimeError("SEMANTIC_COLLAPSE_MIN_CONCEPTS must be at least 3")
292
  if MAX_DETECTIONS <= 0:
293
  raise RuntimeError("MAX_DETECTIONS must be positive")
294
 
 
599
  people = [item for item in detections if item["kind"] == "person"]
600
  results: list[dict[str, Any]] = []
601
  for cluster in _spatial_clusters(people):
602
+ anchor = max(cluster, key=lambda item: item["score"])
603
+ box_width = max(anchor["box"]["x_max"] - anchor["box"]["x_min"], 1e-6)
604
+ box_height = max(anchor["box"]["y_max"] - anchor["box"]["y_min"], 0.0)
605
+ aspect_ratio = box_height / box_width
606
  scores = {
607
  gender: max(
608
  (item["score"] for item in cluster if item["gender"] == gender),
 
631
  and len(child_support_labels) >= CHILD_CONSENSUS_MIN_LABELS
632
  and adult_top_score <= CHILD_CONSENSUS_MAX_ADULT_SCORE
633
  and adult_gender_margin <= CHILD_CONSENSUS_MAX_ADULT_MARGIN
634
+ and aspect_ratio >= CHILD_CONSENSUS_MIN_ASPECT_RATIO
635
  )
636
  if age_consensus:
637
  winner = "child"
 
644
  )
645
  if winner == "female" and score >= STRONG_FEMALE_PERSON_THRESHOLD:
646
  confirmed = margin >= STRONG_FEMALE_MARGIN
 
647
  results.append(
648
  {
649
  "winner": winner,
 
655
  "confirmed": confirmed,
656
  "age_consensus": age_consensus,
657
  "child_support_labels": child_support_labels,
658
+ "box_aspect_ratio": round(aspect_ratio, 4),
659
  "box": anchor["box"],
660
  "labels": sorted({item["label"] for item in cluster}),
661
  }
 
709
  return sorted(results, key=lambda item: item["score"], reverse=True)
710
 
711
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
712
  def _best_owner(
713
  part: dict[str, Any], people: list[dict[str, Any]]
714
  ) -> dict[str, Any] | None:
 
732
 
733
  def _has_human_anchor(owner: dict[str, Any] | None) -> bool:
734
  """Require an actual person-like response before trusting body-part labels."""
735
+ return owner is not None and (
736
+ owner["score"] >= HUMAN_ANCHOR_THRESHOLD
737
+ or (
738
+ owner["winner"] == "female"
739
+ and owner["female_score"] >= FEMALE_OWNER_ANCHOR_THRESHOLD
740
+ and owner["score_margin"] >= FEMALE_OWNER_ANCHOR_MARGIN
741
+ )
742
+ )
743
 
744
 
745
  def _robust_nonfemale_owner(owner: dict[str, Any] | None) -> bool:
 
768
  )
769
 
770
 
771
+ def _semantic_collapse_conflict(
772
+ part: dict[str, Any],
773
+ parts: list[dict[str, Any]],
774
+ people: list[dict[str, Any]],
775
  ) -> dict[str, Any] | None:
776
+ """Reject an anatomically impossible prompt collapse inside a male box.
777
 
778
+ OmDet can assign head, torso and limb labels to the exact same small object
779
+ (for example a trophy). This check reuses existing detections, so it costs
780
+ no extra model prompts. Requiring a confirmed male owner and a small
781
+ owner-area ratio preserves isolated body-part crops and printed women.
782
  """
783
+ owner = _best_owner(part, people)
784
+ if owner is None or not owner["confirmed"] or owner["winner"] != "male":
785
  return None
786
+
787
+ owner_area = _box_area(owner["box"])
788
  part_area = _box_area(part["box"])
789
+ if owner_area <= 0.0 or part_area / owner_area > SEMANTIC_COLLAPSE_MAX_OWNER_AREA_RATIO:
790
  return None
791
 
792
+ peers = [
793
+ item
794
+ for item in parts
795
+ if _box_iou(part["box"], item["box"]) >= SEMANTIC_COLLAPSE_IOU
796
+ ]
797
+ concepts = sorted({item["concept"] for item in peers})
798
+ zones = sorted({ANATOMICAL_ZONES[item] for item in concepts})
799
+ if len(concepts) < SEMANTIC_COLLAPSE_MIN_CONCEPTS or len(zones) < 3:
800
+ return None
801
+ return {
802
+ "concepts": concepts,
803
+ "zones": zones,
804
+ "owner_area_ratio": round(part_area / owner_area, 4),
805
+ }
 
 
 
 
806
 
807
 
808
  def _female_part_evidence(
809
  parts: list[dict[str, Any]],
810
  people: list[dict[str, Any]],
 
811
  ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
812
  confirmed: list[dict[str, Any]] = []
813
  weak: list[dict[str, Any]] = []
 
815
  for part in parts:
816
  owner = _best_owner(part, people)
817
  part["owner"] = owner
 
 
 
818
  anchored = _has_human_anchor(owner)
819
  part["human_anchor_score"] = round(owner["score"], 4) if owner else 0.0
820
 
821
+ collapse_conflict = _semantic_collapse_conflict(part, parts, people)
822
+ if collapse_conflict is not None:
823
  part["evidence_eligible"] = False
824
+ part["evidence_rejection"] = "semantic_collapse_inside_male_owner"
825
+ part["semantic_collapse"] = collapse_conflict
826
  continue
827
 
828
  if _robust_nonfemale_owner(owner):
 
830
  part["evidence_rejection"] = f"robust_{owner['winner']}_owner"
831
  continue
832
 
833
+ contrastive_female = (
834
+ part["winner"] == "male"
835
+ and part["female_score"] >= CONTRASTIVE_FEMALE_PART_THRESHOLD
836
+ and part["male_score"] - part["female_score"]
837
+ <= CONTRASTIVE_FEMALE_MAX_DEFICIT
838
+ and owner is not None
839
+ and owner["score"] >= CONTRASTIVE_OWNER_MIN_SCORE
840
+ )
841
+ if part["winner"] != "female" and not contrastive_female:
842
+ continue
843
+
844
+ if contrastive_female:
845
+ part["evidence_eligible"] = True
846
+ part["evidence_source"] = "strong_contrastive_female_alternative"
847
+ part["moderation_winner"] = "female"
848
+ confirmed.append(part)
849
+ continue
850
+
851
  # Product bottles, packaging and appliances often receive many body
852
  # labels on one rectangular box. Do not accept those labels unless the
853
  # region also looks like a person. A very strong standalone part remains
 
922
  def _decision_from_clusters(
923
  people: list[dict[str, Any]],
924
  parts: list[dict[str, Any]],
 
925
  ) -> tuple[str, str, str, list[dict[str, Any]], dict[str, Any] | None]:
926
  confirmed_females = [
927
  item for item in people if item["confirmed"] and item["winner"] == "female"
 
932
  confirmed_children = [
933
  item for item in people if item["confirmed"] and item["winner"] == "child"
934
  ]
935
+ female_parts, fusion = _female_part_evidence(parts, people)
936
 
937
  if confirmed_females:
938
  return "BLOCK", "confirmed_female_person", "female", female_parts, fusion
 
976
  raw = _raw_detections(image)
977
  people = _person_clusters(raw)
978
  parts = _part_clusters(raw)
979
+ decision, reason, gender, female_parts, fusion = _decision_from_clusters(people, parts)
 
 
 
980
 
981
  person_female_score = max(
982
  (item["female_score"] for item in people), default=0.0
 
1008
  "prompt_count": len(CLASS_NAMES),
1009
  "persons": people,
1010
  "body_parts": parts,
 
1011
  "female_evidence": female_parts,
1012
  "fusion": fusion,
1013
  "decision_policy": {
1014
  "human_anchor_threshold": HUMAN_ANCHOR_THRESHOLD,
1015
+ "female_owner_anchor_threshold": FEMALE_OWNER_ANCHOR_THRESHOLD,
1016
+ "female_owner_anchor_margin": FEMALE_OWNER_ANCHOR_MARGIN,
1017
+ "contrastive_female_part_threshold": CONTRASTIVE_FEMALE_PART_THRESHOLD,
1018
+ "contrastive_female_max_deficit": CONTRASTIVE_FEMALE_MAX_DEFICIT,
1019
  "robust_male_owner_threshold": ROBUST_MALE_OWNER_THRESHOLD,
1020
  "robust_male_owner_margin": ROBUST_MALE_OWNER_MARGIN,
1021
  "fusion_min_signals": FUSION_MIN_SIGNALS,
 
1023
  "fusion_min_top_score": FUSION_MIN_TOP_SCORE,
1024
  "child_consensus_threshold": CHILD_CONSENSUS_THRESHOLD,
1025
  "child_consensus_min_labels": CHILD_CONSENSUS_MIN_LABELS,
1026
+ "child_consensus_min_aspect_ratio": CHILD_CONSENSUS_MIN_ASPECT_RATIO,
1027
+ "semantic_collapse_iou": SEMANTIC_COLLAPSE_IOU,
1028
+ "semantic_collapse_min_concepts": SEMANTIC_COLLAPSE_MIN_CONCEPTS,
1029
+ "semantic_collapse_max_owner_area_ratio": SEMANTIC_COLLAPSE_MAX_OWNER_AREA_RATIO,
1030
  },
1031
  "raw_detection_count": len(raw),
1032
  }
 
1050
  app = FastAPI(
1051
  title="AI Shield - OmDet-Turbo Swin-Tiny Test",
1052
  description="Human-anchored female-person and female body-part detector",
1053
+ version="0.4.0",
1054
  lifespan=lifespan,
1055
  )
1056