Update app.py
Browse files
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 |
-
|
| 235 |
-
|
| 236 |
-
os.getenv("
|
| 237 |
)
|
| 238 |
-
|
| 239 |
-
|
| 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 |
-
"
|
| 280 |
-
"
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 782 |
-
part: dict[str, Any],
|
|
|
|
|
|
|
| 783 |
) -> dict[str, Any] | None:
|
| 784 |
-
"""
|
| 785 |
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
"""
|
| 791 |
-
|
|
|
|
| 792 |
return None
|
|
|
|
|
|
|
| 793 |
part_area = _box_area(part["box"])
|
| 794 |
-
if
|
| 795 |
return None
|
| 796 |
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
if
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 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 |
-
|
| 835 |
-
if
|
| 836 |
part["evidence_eligible"] = False
|
| 837 |
-
part["evidence_rejection"] = "
|
| 838 |
-
part["
|
| 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
|
| 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 |
-
|
| 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 |
-
"
|
| 1023 |
-
"
|
|
|
|
|
|
|
| 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.
|
| 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 |
|