File size: 7,904 Bytes
9fc67a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
"""P3 후처리 -- Stage 1/2 결과 통합 + 박스 기반 Part 귀속 (Phase 4)

Stage 1(Mask R-CNN)이 뱉는 평면 인스턴스 리스트를 "본체(Main) 의류 + 그에 달린
부위(Part)" 트리로 재구성한다. 핵심은 **단순 이미지 공존이 아니라 박스 IoU/포함도로
Part 를 가장 알맞은 Main 에 귀속**시키는 것 -- EDA 에서 본 부자연스러운 매핑
(pants→sleeve, shoe→sleeve 등 같은 사진에 있다는 이유만의 연결)을 차단한다.

구성:
    box_iou(a, b)                 -- 두 박스 [x1,y1,x2,y2] IoU
    box_containment(part, main)   -- Part 가 Main 에 포함된 비율 (intersection / part_area)
    group_instances(...)          -- 평면 리스트 → (mains_with_parts, orphan_parts)
    build_response(...)           -- 트리 + Stage 2 logits → 응답 JSON

[detection 형식] 각 인스턴스는 dict:
    {"category_id": int(0~45 원본), "category": str, "box": [x1,y1,x2,y2], "score": float}
    Main 인스턴스에는 pipeline 이 "attr_index"(Stage 2 logits 행 번호)를 달아 둔다.

- utils.py / model.py / dataset.py 등과 양식 통일.
"""

from __future__ import annotations

from typing import Dict, List, Optional, Sequence, Tuple

import numpy as np


# ============================================================================
# 1. 기하 -- 박스 IoU / 포함도
# ============================================================================

def box_iou(box_a: Sequence[float], box_b: Sequence[float]) -> float:
    """두 박스 [x1, y1, x2, y2] 의 IoU (교집합 / 합집합).

    Args:
        box_a, box_b: [x1, y1, x2, y2] 형식 박스.

    Returns:
        float: IoU (0.0 ~ 1.0). 겹치지 않으면 0.0.
    """
    inter = _intersection_area(box_a, box_b)
    if inter <= 0:
        return 0.0
    area_a = max(0.0, box_a[2] - box_a[0]) * max(0.0, box_a[3] - box_a[1])
    area_b = max(0.0, box_b[2] - box_b[0]) * max(0.0, box_b[3] - box_b[1])
    union = area_a + area_b - inter
    return inter / union if union > 0 else 0.0


def box_containment(part_box: Sequence[float], main_box: Sequence[float]) -> float:
    """Part 박스가 Main 박스에 얼마나 들어가 있나 = 교집합 / Part 넓이.

    IoU 와 달리 크기 차이에 둔감해, "작은 소매가 큰 상의 안에 거의 들어 있다"
    같은 포함 관계를 잘 잡는다.

    Args:
        part_box: 포함되는 쪽(작은) 박스 [x1,y1,x2,y2].
        main_box: 포함하는 쪽(큰) 박스 [x1,y1,x2,y2].

    Returns:
        float: 포함 비율 (0.0 ~ 1.0). Part 넓이가 0이면 0.0.
    """
    inter = _intersection_area(part_box, main_box)
    part_area = max(0.0, part_box[2] - part_box[0]) * max(0.0, part_box[3] - part_box[1])
    return inter / part_area if part_area > 0 else 0.0


def _intersection_area(box_a: Sequence[float], box_b: Sequence[float]) -> float:
    """두 박스의 교집합 넓이."""
    x1 = max(box_a[0], box_b[0])
    y1 = max(box_a[1], box_b[1])
    x2 = min(box_a[2], box_b[2])
    y2 = min(box_a[3], box_b[3])
    return max(0.0, x2 - x1) * max(0.0, y2 - y1)


# ============================================================================
# 2. 인스턴스 그룹화 -- 평면 리스트 → Main 트리 + orphan
# ============================================================================

def group_instances(
    detections: List[Dict],
    main_ids: set,
    part_ids: set,
    iou_threshold: float = 0.1,
    contain_threshold: float = 0.5,
) -> Tuple[List[Dict], List[Dict]]:
    """평면 detection 리스트를 Main(부위 포함) 트리로 재구성한다.

    각 Part 는 **IoU 와 포함도가 가장 큰 Main** 에 귀속된다. 단, 그 Main 과의
    관계가 `iou >= iou_threshold` 또는 `containment >= contain_threshold` 중
    하나라도 만족할 때만 귀속하고, 둘 다 아니면 orphan(고아 Part)으로 둔다.

    Args:
        detections: Stage 1 인스턴스 dict 리스트 ({category_id, box, score, ...}).
        main_ids: Main 카테고리 ID 집합 (utils.load_category_ids).
        part_ids: Part 카테고리 ID 집합.
        iou_threshold: 귀속 판정 IoU 하한.
        contain_threshold: 귀속 판정 포함도 하한.

    Returns:
        Tuple[List[Dict], List[Dict]]:
            - mains_with_parts: Main dict 리스트. 각 dict 에 "parts": [Part dict, ...] 추가.
            - orphan_parts: 어느 Main 에도 귀속되지 못한 Part dict 리스트.
    """
    mains = [dict(d, parts=[]) for d in detections if d["category_id"] in main_ids]
    parts = [d for d in detections if d["category_id"] in part_ids]

    orphan_parts: List[Dict] = []
    for part in parts:
        best_i, best_iou, best_cont, best_key = -1, 0.0, 0.0, -1.0
        for i, main in enumerate(mains):
            iou = box_iou(part["box"], main["box"])
            cont = box_containment(part["box"], main["box"])
            key = max(iou, cont)            # IoU·포함도 중 큰 값으로 최적 Main 선택
            if key > best_key:
                best_i, best_iou, best_cont, best_key = i, iou, cont, key

        # 최적 Main 과의 관계가 임계값을 하나라도 넘으면 귀속, 아니면 orphan
        if best_i >= 0 and (best_iou >= iou_threshold or best_cont >= contain_threshold):
            mains[best_i]["parts"].append(part)
        else:
            orphan_parts.append(part)

    return mains, orphan_parts


# ============================================================================
# 3. 응답 빌더 -- 트리 + Stage 2 속성 → JSON
# ============================================================================

def build_response(
    mains_with_parts: List[Dict],
    orphan_parts: List[Dict],
    attribute_logits: Optional[np.ndarray],
    threshold: float = 0.5,
    id2attr_name: Optional[Sequence[str]] = None,
) -> Dict:
    """Main 트리 + Stage 2 logits 를 최종 응답 JSON 으로 만든다.

    각 Main 의 속성은 `attribute_logits[main["attr_index"]]` 를 sigmoid 한 뒤
    threshold 를 넘는 속성만 모아 이름 리스트로 변환한다.

    Args:
        mains_with_parts: group_instances 의 첫 반환값 (각 Main 에 "attr_index" 포함).
        orphan_parts: group_instances 의 둘째 반환값.
        attribute_logits: Stage 2 raw logits [M, num_attrs] (M=Main 수). 없으면 속성 빈칸.
        threshold: 속성 판정 임계값 (sigmoid 확률 기준).
        id2attr_name: 속성 인덱스→이름. 없으면 "attr_<idx>" 로 표기.

    Returns:
        Dict: {"garments": [...], "orphan_parts": [...]}
    """
    logits = None if attribute_logits is None else np.asarray(attribute_logits)

    garments = []
    for main in mains_with_parts:
        attrs: List[str] = []
        idx = main.get("attr_index")
        if logits is not None and idx is not None and 0 <= idx < len(logits):
            probs = _sigmoid(logits[idx])
            for a in np.where(probs > threshold)[0]:
                attrs.append(id2attr_name[a] if id2attr_name is not None else f"attr_{a}")

        garments.append({
            "category": main.get("category", str(main["category_id"])),
            "box": [float(v) for v in main["box"]],
            "score": float(main["score"]),
            "attributes": attrs,
            "parts": [_part_dict(p) for p in main["parts"]],
        })

    return {
        "garments": garments,
        "orphan_parts": [_part_dict(p) for p in orphan_parts],
    }


def _part_dict(p: Dict) -> Dict:
    """Part 인스턴스를 응답용 최소 dict 로 정리."""
    return {
        "category": p.get("category", str(p["category_id"])),
        "box": [float(v) for v in p["box"]],
        "score": float(p["score"]),
    }


def _sigmoid(x: np.ndarray) -> np.ndarray:
    """수치 안정 sigmoid."""
    return np.where(x >= 0, 1.0 / (1.0 + np.exp(-x)), np.exp(x) / (1.0 + np.exp(x)))