# Review package: 9364e810..88b03f41 ## Commits 88b03f41 feat(lane): geometry 카메라 대면 관계 — 선언 필드+비중첩 3분할 검증+인용 provenance ## Files changed backend/app/modules/pipeline/outdoor_lane_plan.py | 12 +- backend/app/modules/pipeline/outdoor_marker_map.py | 140 ++++++++++++++++++++- backend/tests/pipeline/test_outdoor_marker_map.py | 132 +++++++++++++++++++ 3 files changed, 279 insertions(+), 5 deletions(-) ## Diff diff --git a/backend/app/modules/pipeline/outdoor_lane_plan.py b/backend/app/modules/pipeline/outdoor_lane_plan.py index 71ed7e93..692576ab 100644 --- a/backend/app/modules/pipeline/outdoor_lane_plan.py +++ b/backend/app/modules/pipeline/outdoor_lane_plan.py @@ -110,26 +110,34 @@ def build_lane_schema( } required = ["site"] + required return { "type": "object", "properties": props, "required": required, "additionalProperties": False, } -def _ws_normalize(text: str) -> str: +def ws_normalize(text: str) -> str: """연속 whitespace(개행 포함)→단일 공백. 원문 줄바꿈을 LLM 이 공백으로 - 인용하는 정상 케이스(5회차 실측 2건) 허용 — 의미 판단 없음.""" + 인용하는 정상 케이스(5회차 실측 2건) 허용 — 의미 판단 없음. + + 2026-07-26: outdoor_marker_map 의 camera_facing evidence provenance 도 + 같은 정규화를 쓴다 — 사설 이름 교차 import 를 피해 공개로 승격. + """ return re.sub(r"\s+", " ", text).strip() +# 기존 호출부·테스트 호환 별칭 (동일 객체) +_ws_normalize = ws_normalize + + def _check_evidence( label: str, ev: Any, scene_texts: Dict[int, str] ) -> List[str]: """evidence 무결성 — 공급 씬 범위 + literal 인용 실재 (Codex BLOCKING). substring 으로 의미를 판정하는 것이 아니라, LLM 이 제시한 literal 인용이 실제 원문에 존재하는지(증거 진위)만 확인하는 결정론 게이트. """ if not isinstance(ev, dict) or not (ev.get("quote_ko") or "").strip(): return [f"{label} evidence(씬 인용) 누락"] diff --git a/backend/app/modules/pipeline/outdoor_marker_map.py b/backend/app/modules/pipeline/outdoor_marker_map.py index 52568cbb..5dd58ed5 100644 --- a/backend/app/modules/pipeline/outdoor_marker_map.py +++ b/backend/app/modules/pipeline/outdoor_marker_map.py @@ -3,21 +3,21 @@ LLM 은 normalized(0..1) geometry JSON 만 저작한다(합의 결정 2). 코드는 스키마 잠금·fail-closed 검증·PIL 결정론 합성만 담당 — base map PNG 는 immutable SOT(복사본에만 그림), 이미지 안에 고유명·장소명 0(슬롯 코드 E1../CAM 만). """ from __future__ import annotations import io import math import re -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional MAX_SLOTS = 6 _ALLOWED_SLOTS = frozenset(f"E{i}" for i in range(1, MAX_SLOTS + 1)) _MIN_CAMERA_DIST = 0.02 # v3 (2026-07-25 사용자 지적①): facing/시야 쐐기 degenerate 가드. # ★화각 값 자체가 아니다 — 화각은 샷별 LLM 저작(view_left/right)이고, # 여기 상수는 "방향 벡터가 성립하지 않는 입력"만 거르는 무결성 경계 # (구 PIL 콘의 고정 56° 상수 승계 금지 교훈, GEOMETRY_TEXT_VERSION v2). _MIN_FACING_DIST = 0.02 _MIN_VIEW_SPAN_RAD = math.radians(4.0) @@ -46,20 +46,29 @@ PROMPT_VERSION_MAP = { # look_target 선 하나뿐이라 화각·향한 방향이 데이터에 없었다. "3": "3.202607251321", } # anchor 저작 계약이 실리는 geometry 팩 (스키마·validator 게이트) _ANCHOR_GEOMETRY_PACKS = {"2", "3"} # v3+: 시야 쐐기·facing 저작 계약이 실리는 geometry 팩 _VIEW_GEOMETRY_PACKS = {"3"} +# v4 (2026-07-26): SHOT TEXT 가 말한 카메라 대면 관계를 선언 필드로 올려 +# 좌표와 결정론 대조한다. 프롬프트 문구·rationale 만으로는 틀린 facing 을 +# validator 가 잡을 수 없다 — 실측(S15sh5): SHOT TEXT 가 '뒷모습'인데 +# 저작 facing 의 카메라 축 성분이 dep=-0.211(부호가 오히려 카메라 쪽) +# 이었고 _SCREEN_EPS 미달로 심도 문구가 침묵해 은폐됐다. +CAMERA_FACING_RELATIONS = ( + "toward_camera", "away_from_camera", "profile", "unspecified") +_FACING_RELATION_GEOMETRY_PACKS = {"4"} + # geometry 스키마·validator 계약 버전 (Codex ④): anchor required 화 등 # 구조 변화 시 bump — config hash·sidecar 지문 스탬프 대상. # 3 (2026-07-25): view wedge·facing required 화. MARKER_GEOMETRY_CONTRACT_VERSION = 3 # 렌더 스타일 상수 — 결정론의 일부 (변경 시 canary 재검증) _STYLE = { "entity_fill": (220, 50, 50, 230), "entity_outline": (255, 255, 255, 255), "camera_color": (40, 90, 220, 255), @@ -71,29 +80,31 @@ _STYLE = { def resolve_prompt_version(version: str) -> str: if version not in PROMPT_VERSION_MAP: raise ValueError(f"outdoor_marker_geometry 프롬프트 버전 없음: {version}") return PROMPT_VERSION_MAP[version] def build_marker_geometry_schema( max_slots: int = MAX_SLOTS, *, include_anchors: bool = False, - include_view: bool = False, + include_view: bool = False, include_facing_relation: bool = False, ) -> Dict[str, Any]: """normalized geometry 스키마 — slot enum·0..1 bounds 잠금. include_anchors (v2+, Codex ①): per-marker landmark anchor 를 required 로 강제. False=기존 byte-identical. include_view (v3+, 2026-07-25 사용자 지적①): 시야 쐐기(camera view_left/view_right)와 피사체 facing 을 required 로 강제 — 화각·향한 방향이 저작되지 않으면 마커 맵이 그릴 데이터가 없다. + include_facing_relation (v4+): 카메라 대면 관계 선언 필드 2개를 + required 로 강제. False=기존 byte-identical. """ point = { "type": "object", "properties": { "x": {"type": "number", "minimum": 0, "maximum": 1}, "y": {"type": "number", "minimum": 0, "maximum": 1}, }, "required": ["x", "y"], "additionalProperties": False, } @@ -124,20 +135,28 @@ def build_marker_geometry_schema( "type": "string", "minLength": 8} placement_req = placement_req + ["facing", "facing_anchor_en"] cam_props["view_left"] = point cam_props["view_right"] = point cam_props["view_left_anchor_en"] = {"type": "string", "minLength": 8} cam_props["view_right_anchor_en"] = {"type": "string", "minLength": 8} cam_req = cam_req + [ "view_left", "view_right", "view_left_anchor_en", "view_right_anchor_en", ] + if include_facing_relation: + # 관계는 enum, 근거는 SHOT TEXT 원문 인용 — unspecified 는 빈 + # 문자열이어야 한다(validator 가 양방향 검증). + placement_props["camera_facing_relation"] = { + "enum": list(CAMERA_FACING_RELATIONS)} + placement_props["camera_facing_evidence"] = {"type": "string"} + placement_req = placement_req + [ + "camera_facing_relation", "camera_facing_evidence"] placement = { "type": "object", "properties": placement_props, "required": placement_req, "additionalProperties": False, } return { "type": "object", "properties": { "entity_placements": { @@ -275,37 +294,43 @@ def _check_view_wedge( "샷이 담는 피사체는 프레임 안에 있어야 한다. 카메라를 " "피사체 맞은편으로 옮기거나(origin), 시선/시야 경계를 " "피사체 쪽으로 다시 잡아라" ) except (KeyError, TypeError): pass def validate_marker_geometry( geometry: Any, *, require_anchors: bool = False, - require_view: bool = False, + require_view: bool = False, require_facing_relation: bool = False, + shot_text: str = "", ) -> List[str]: """결정론 무결성 검증 — 위반 리스트 반환 (fail-closed 재시도용). 스키마(call_structured strict)가 정상 경로를 막더라도, render 등 public 소비자가 이 validator 를 직접 신뢰하므로 자체 완결이어야 한다 (Codex Stage B N3): root/placement 타입, MAX_SLOTS 상한, slot enum, bool 좌표 배제까지 여기서 검증. require_anchors (v2+, Codex ①③): per-marker anchor(entity anchor_en, camera origin/look_target anchor)를 필수로 검증. False 여도 anchor 필드가 존재하면 형식(문자열·제어문자·구조 코드) 은 검증한다 — 오염된 선택 필드 통과 차단. require_view (v3+): 시야 쐐기(camera view_left/view_right)와 피사체 facing 을 필수로 검증. False 여도 존재하면 형식·기하 (쐐기 개폐·look_target 포함·facing 자기겹침)는 검증한다. + + require_facing_relation (v4+): per-placement 카메라 대면 관계 선언과 + 좌표의 정합 + 근거 인용 provenance 를 필수로 검증. shot_text 는 그 + 샷의 SHOT TEXT 원문(인용 대조용) — require_facing_relation=True 인데 + 미공급이면 모든 명시 relation 이 거부된다(fail-closed). """ if not isinstance(geometry, dict): return ["geometry 가 객체 아님"] violations: List[str] = [] placements = geometry.get("entity_placements") if not isinstance(placements, list): violations.append("entity_placements 가 배열 아님") placements = [] if not placements: violations.append("entity_placements 비어 있음") @@ -401,20 +426,34 @@ def validate_marker_geometry( dist = math.hypot( cam["look_target"]["x"] - cam["origin"]["x"], cam["look_target"]["y"] - cam["origin"]["y"], ) if dist < _MIN_CAMERA_DIST: violations.append( f"camera 방향 벡터 미성립 — origin≈look_target " f"(dist={dist:.4f} < {_MIN_CAMERA_DIST})") except (KeyError, TypeError): pass # 좌표 결측은 위 range 검사가 이미 보고 + # v4: 카메라 대면 관계 — 카메라 dict 와 검증 끝난 placements 를 모두 + # 확보한 뒤여야 하므로 반환 직전에 per-placement 로 돈다. + if require_facing_relation: + cam = geometry.get("camera") + if not isinstance(cam, dict): + violations.append( + "camera 가 없어 카메라 대면 관계를 검증할 수 없음") + else: + for idx, pl in enumerate(placements): + if not isinstance(pl, dict): + continue + label = f"placement[{idx}]({pl.get('slot') or '?'})" + violations.extend( + _check_facing_relation(cam, pl, label, shot_text)) return violations def render_marker_map(base_png: bytes, geometry: Dict[str, Any]) -> bytes: """(DEPRECATED — 프로덕션 호출 금지) base map 위 마커 PIL 합성. 2026-07-16 사용자 확정: 코드(PIL 등)로 이미지에 그리는 행위 절대 금지 — 마커·콘·텍스트 오버레이 전부. 시각 요소는 항상 이미지 모델 몫. geometry 는 build_geometry_text_lines(ID-free 텍스트 직렬화)로만 소비한다. 함수는 모듈 삭제 금지 원칙에 따라 보존만. @@ -827,20 +866,115 @@ def _screen_side_text(cam: Dict[str, Any], pl: Dict[str, Any]) -> str: if depth <= 0: return "" # 카메라 뒤 — 쐐기 검사가 이미 보고 lateral = (rel[0] * right[0] + rel[1] * right[1]) / depth if lateral > 0.18: return "in the RIGHT part of the frame" if lateral < -0.18: return "in the LEFT part of the frame" return "near the CENTER of the frame" +def facing_camera_depth( + cam: Dict[str, Any], pl: Dict[str, Any], +) -> Optional[float]: + """피사체 facing 의 카메라 축 성분 (-1..+1). + + 양수 = 카메라에서 멀어지는 쪽(카메라는 등을 본다), + 음수 = 카메라 쪽으로 도는 쪽(카메라는 앞을 본다), + 0 근처 = 카메라 축에 직각(측면). + + 좌표가 없거나 facing 이 자기 위치와 같으면 None — 순수 벡터 연산이며 + 의미 판단은 하지 않는다. + """ + basis = _screen_basis(cam) + if basis is None or not isinstance(pl.get("facing"), dict): + return None + fwd, _right = basis + try: + vec = (pl["facing"]["x"] - pl["x"], pl["facing"]["y"] - pl["y"]) + except (KeyError, TypeError): + return None + norm = math.hypot(*vec) + if norm < 1e-9: + return None + return (vec[0] / norm) * fwd[0] + (vec[1] / norm) * fwd[1] + + +def _check_facing_relation( + cam: Dict[str, Any], pl: Dict[str, Any], label: str, shot_text: str, +) -> List[str]: + """선언된 대면 관계 ↔ 좌표 정합 + 근거 provenance (v4). + + 구간은 하나의 named epsilon(_SCREEN_EPS)으로 **완전 비중첩**이다: + away_from_camera : dep > +eps + toward_camera : dep < -eps + profile : abs(dep) <= eps + 겹치게 정의하면(예: away 를 dep>0 으로) eps=0.25·dep=0.1 이 away 와 + profile 양쪽을 통과해 보증이 무너진다. + + evidence 는 필드 존재로 통과시키지 않는다 — 명시 relation 이면 SHOT + TEXT 원문에 실재하는 인용이어야 하고, unspecified 면 비어 있어야 + 한다. substring 으로 의미를 판정하는 것이 아니라 LLM 이 제시한 인용의 + 진위만 확인하는 결정론 게이트다(outdoor_lane_plan._check_evidence 와 + 동형). + """ + from app.modules.pipeline.outdoor_lane_plan import ws_normalize + + rel = pl.get("camera_facing_relation") + if rel not in CAMERA_FACING_RELATIONS: + return [f"{label} camera_facing_relation 이 계약 값 아님: {rel!r}"] + ev_raw = pl.get("camera_facing_evidence") + if not isinstance(ev_raw, str): + return [f"{label} camera_facing_evidence 가 문자열 아님"] + ev = ws_normalize(ev_raw) + out: List[str] = [] + if rel == "unspecified": + if ev: + out.append( + f"{label} camera_facing_relation=unspecified 인데 " + "camera_facing_evidence 가 비어 있지 않음 — 근거만 있고 " + "선언이 없는 상태 금지" + ) + return out + if not ev: + out.append( + f"{label} camera_facing_relation={rel} 인데 " + "camera_facing_evidence(SHOT TEXT 원문 인용) 누락" + ) + elif ev not in ws_normalize(shot_text or ""): + out.append( + f"{label} camera_facing_evidence 인용이 SHOT TEXT 원문에 없음 " + f"— 원문 그대로 인용할 것: {ev!r}" + ) + dep = facing_camera_depth(cam, pl) + if dep is None: + out.append( + f"{label} facing 으로 카메라 축 성분을 계산할 수 없음 " + "(facing/좌표 결손 또는 자기 위치와 동일)" + ) + return out + ok = { + "away_from_camera": dep > _SCREEN_EPS, + "toward_camera": dep < -_SCREEN_EPS, + "profile": abs(dep) <= _SCREEN_EPS, + }[rel] + if not ok: + out.append( + f"{label} camera_facing_relation={rel} 인데 facing 좌표의 " + f"카메라 축 성분이 {dep:.3f} — 관계를 만족하지 않는다 " + f"(away>{_SCREEN_EPS}, toward<-{_SCREEN_EPS}, " + f"profile |dep|<={_SCREEN_EPS}). SHOT TEXT 가 말한 대면 " + "관계대로 facing 점을 옮길 것" + ) + return out + + def _facing_screen_text(cam: Dict[str, Any], pl: Dict[str, Any]) -> str: """피사체가 향한 쪽을 **화면 기준**으로 (순수 투영).""" basis = _screen_basis(cam) if basis is None or not isinstance(pl.get("facing"), dict): return "" fwd, right = basis try: vec = (pl["facing"]["x"] - pl["x"], pl["facing"]["y"] - pl["y"]) except (KeyError, TypeError): return "" diff --git a/backend/tests/pipeline/test_outdoor_marker_map.py b/backend/tests/pipeline/test_outdoor_marker_map.py index dcc27f30..2d9a774b 100644 --- a/backend/tests/pipeline/test_outdoor_marker_map.py +++ b/backend/tests/pipeline/test_outdoor_marker_map.py @@ -966,10 +966,142 @@ def test_v9_annotate_and_check_carry_wedge_and_facing(): assert {"9", "10", "11", "12"} <= _VIEW_SKETCH_PACKS p = build_marker_annotate_prompt( geometry=_geometry_view(), prompt_version="12") assert "E1 (woman calling): circle" in p # 슬롯+설명 라벨 assert "LEFT edge cuts through" in p # 쐐기 경계 assert "facing toward the apron right of the shelter" in p check = "\n".join(build_marker_check_user_lines( _geometry_view(), require_anchors=True, require_view=True)) assert "view wedge" in check and "facing" in check + + +SAMPLE_FIXTURE_SHOT_TEXT = ( + "한쪽 발이 공중에 뜬 달리기 자세인 인물 B의 뒷모습을 배경으로, " + "두 손을 입가에 모으고 입을 크게 벌린 인물 A의 상반신." +) + + +def _fixture_geometry(relation="unspecified", evidence="", + facing=(0.88, 0.52)): + """카메라가 (0.64,0.72)에서 (0.56,0.575)를 보는 기준 배치. + + fwd 는 위-왼쪽을 향한다 — facing 을 바꿔 dep 부호를 조절한다. + """ + return { + "camera": { + "origin": {"x": 0.64, "y": 0.72}, + "look_target": {"x": 0.56, "y": 0.575}, + "origin_anchor_en": "On the roadway in front of the site.", + "look_target_anchor_en": "Inside the site by the sign.", + "view_left": {"x": 0.45, "y": 0.51}, + "view_right": {"x": 0.83, "y": 0.56}, + "view_left_anchor_en": "Inside the site left of the shelter.", + "view_right_anchor_en": "Inside the site at its right edge.", + }, + "entity_placements": [{ + "slot": "E1", + "subject_en": "running figure with one foot airborne", + "x": 0.78, "y": 0.55, + "anchor_en": "Inside the right side of the site.", + "facing": {"x": facing[0], "y": facing[1]}, + "facing_anchor_en": "Farther right inside the site.", + "camera_facing_relation": relation, + "camera_facing_evidence": evidence, + }], + "rationale_ko": "픽스처 배치.", + } + + +def test_facing_relation_schema_is_pack_gated(): + from app.modules.pipeline.outdoor_marker_map import ( + CAMERA_FACING_RELATIONS, + _FACING_RELATION_GEOMETRY_PACKS, + build_marker_geometry_schema, + ) + + assert _FACING_RELATION_GEOMETRY_PACKS == {"4"} + off = build_marker_geometry_schema( + include_anchors=True, include_view=True) + pl_off = off["properties"]["entity_placements"]["items"] + assert "camera_facing_relation" not in pl_off["properties"] + + on = build_marker_geometry_schema( + include_anchors=True, include_view=True, + include_facing_relation=True) + pl_on = on["properties"]["entity_placements"]["items"] + assert pl_on["properties"]["camera_facing_relation"]["enum"] == list( + CAMERA_FACING_RELATIONS) + assert pl_on["properties"]["camera_facing_evidence"] == { + "type": "string"} + for key in ("camera_facing_relation", "camera_facing_evidence"): + assert key in pl_on["required"] + + +def test_facing_camera_depth_sign(): + from app.modules.pipeline.outdoor_marker_map import facing_camera_depth + + g = _fixture_geometry() + cam, pl = g["camera"], g["entity_placements"][0] + dep = facing_camera_depth(cam, pl) + # facing 이 오른쪽-위: 카메라 축 성분이 살짝 카메라 쪽(음수) + assert dep is not None and -0.25 < dep < 0 + + +def test_facing_relation_intervals_are_mutually_exclusive(): + """away / toward / profile 은 하나의 eps 로 완전 비중첩이어야 한다. + + dep=0.1, eps=0.25 는 'dep>0'(away) 과 'abs(dep)<=eps'(profile) 를 + 동시에 만족하던 구간 — away 는 반드시 거부돼야 한다. + """ + from app.modules.pipeline.outdoor_marker_map import ( + _SCREEN_EPS, + validate_marker_geometry, + ) + + def _v(relation, facing): + # facing 을 카메라 축 방향으로 dep 만큼 기울인 픽스처를 만드는 + # 대신, 대표 좌표 3종으로 부호·크기 구간을 덮는다. + g = _fixture_geometry(relation=relation, evidence="뒷모습", + facing=facing) + return validate_marker_geometry( + g, require_anchors=True, require_view=True, + require_facing_relation=True, + shot_text=SAMPLE_FIXTURE_SHOT_TEXT) + + assert _SCREEN_EPS == 0.25 + # (0.70, 0.20) → facing 이 카메라 반대쪽으로 크게: dep > +eps + assert _v("away_from_camera", (0.70, 0.20)) == [] + assert _v("profile", (0.70, 0.20)) != [] + # (0.88, 0.52) → dep 가 0 근처: profile 만 통과 + assert _v("profile", (0.88, 0.52)) == [] + assert _v("away_from_camera", (0.88, 0.52)) != [] + assert _v("toward_camera", (0.88, 0.52)) != [] + # (0.80, 0.95) → facing 이 카메라 쪽으로 크게: dep < -eps + assert _v("toward_camera", (0.80, 0.95)) == [] + assert _v("profile", (0.80, 0.95)) != [] + + +def test_facing_relation_evidence_provenance_both_ways(): + from app.modules.pipeline.outdoor_marker_map import ( + validate_marker_geometry, + ) + + def _v(relation, evidence, facing=(0.70, 0.20)): + return validate_marker_geometry( + _fixture_geometry(relation=relation, evidence=evidence, + facing=facing), + require_anchors=True, require_view=True, + require_facing_relation=True, + shot_text=SAMPLE_FIXTURE_SHOT_TEXT) + + # 명시 relation + 원문에 실재하는 인용 = 통과 + assert _v("away_from_camera", "뒷모습") == [] + # 명시 relation + evidence 누락 = 거부 + assert any("evidence" in v for v in _v("away_from_camera", "")) + # 명시 relation + 원문에 없는 인용(발명) = 거부 + assert any("원문에 없음" in v + for v in _v("away_from_camera", "정면을 향해 선다")) + # unspecified + evidence 존재 = 거부 (근거 없는 선언 세탁 차단) + assert any("unspecified" in v for v in _v("unspecified", "뒷모습")) + # unspecified + 빈 evidence = 통과 (기하 검사 대상 아님) + assert _v("unspecified", "", facing=(0.88, 0.52)) == []