# Review package: a5de09c4..a893daf5 ## Commits a893daf5 fix(lane): 방향 문구 앞/뒤 명시 + 선언 relation 우선 렌더 ## Files changed backend/app/modules/pipeline/outdoor_marker_map.py | 59 ++++++++++++++++--- backend/tests/pipeline/test_outdoor_marker_map.py | 68 +++++++++++++++++++++- 2 files changed, 118 insertions(+), 9 deletions(-) ## Diff diff --git a/backend/app/modules/pipeline/outdoor_marker_map.py b/backend/app/modules/pipeline/outdoor_marker_map.py index 45807296..74ba8f95 100644 --- a/backend/app/modules/pipeline/outdoor_marker_map.py +++ b/backend/app/modules/pipeline/outdoor_marker_map.py @@ -819,21 +819,27 @@ MARKER_MAP_SIZE = "1024x1024" # 임의 상수를 텍스트 SOT 로 승계하는 것은 하드코딩 금지 위반이자 샷별 # lens/framing(camera_direction SOT)과 충돌. 화각·프레이밍은 # camera_direction·샷 텍스트에 위임. # v3 (2026-07-24 Codex ②): anchor 병기 — 좌표는 coarse estimate 로 # 강등(마커 맵=raster 배치 SOT), anchor 존재 시 서술을 선행 표기. # v4 (2026-07-25 지적①): 시야 쐐기(좌/우 프레임 경계)·피사체 facing # 직렬화 — geometry v3 데이터가 있을 때만 표기(없으면 v3 byte-동일). # v5 (2026-07-25 사용자 지적): 맵 좌표 facing 이 화면 좌/우로 변환되지 # 않아 작화가 방향을 뒤집었다 — 카메라 기준 투영(프레임 좌/우·깊이· # 시선 대상)을 같은 라인에 병기. -GEOMETRY_TEXT_VERSION = 5 +# v6 (2026-07-26): 심도 문구가 앞/뒤를 명시하고(중의어 "back toward the +# camera" 제거), 선언 relation 이 있으면 eps 추론 대신 그 값을 문장화. +# ★bump 필수 — 심도 문구는 relation 필드가 없는 구 geometry 에서도 +# 바뀌므로 전 팩의 실질 입력이 달라진다. completed CP 는 sidecar 지문을 +# 타지 않아 이 상수의 hash 스탬프가 유일한 무효화 경로다 +# (test_config_hash_stamps_geometry_text_version). +GEOMETRY_TEXT_VERSION = 6 def _point_text(pt: Dict[str, Any], anchor: str) -> str: """anchor(권위)+coarse 분율 병기 — v3 표기 관례 재사용.""" coarse = f"x={pt['x']:.2f}, y={pt['y']:.2f}" return ( f"{anchor} (coarse estimate {coarse})" if anchor else f"({coarse})" ) @@ -875,20 +881,34 @@ def _facing_text(pl: Dict[str, Any]) -> str: # 코드가 순수 기하로 화면 축에 투영해 좌/우·깊이를 병기한다(좌표 변환 # 이지 의미 판단이 아니다). 실측 대조: v8(facing 없음)=정상 작화, # v11(맵 좌표 facing 병기)=반전 — 정보 추가가 오히려 방향을 꼬았다. # ★v4(2026-07-26)부터 이 값은 산문 임계값만이 아니다 — # _check_facing_relation 의 away/toward/profile accept·reject 경계까지 # 정의하는 validator 계약이다. 바꾸면 세 구간이 함께 움직여 기존 통과 # geometry 가 거부(또는 그 반대)로 뒤집히므로 _check_facing_relation 과 # 그 구간 테스트를 반드시 재검증할 것. _SCREEN_EPS = 0.25 +# 선언 relation → 화면 서술 (렌더링 전용 매핑, 의미 판단 아님) +_FACING_RELATION_PHRASES = { + "away_from_camera": ( + "away from the camera, into the depth of the shot, so the camera " + "sees this subject from behind" + ), + "toward_camera": ( + "toward the camera, so the camera sees this subject from the front" + ), + "profile": ( + "side-on to the camera, neither toward it nor away from it" + ), +} + def _screen_basis(cam: Dict[str, Any]): """(forward, right) 단위벡터 — 맵은 y 가 아래로 증가하므로 카메라가 북(위)을 보면 forward=(0,-1), right=(+1,0)=동쪽=화면 오른쪽.""" try: ox, oy = cam["origin"]["x"], cam["origin"]["y"] dx = cam["look_target"]["x"] - ox dy = cam["look_target"]["y"] - oy except (KeyError, TypeError): return None @@ -1017,45 +1037,70 @@ def _check_facing_relation( 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: - """피사체가 향한 쪽을 **화면 기준**으로 (순수 투영).""" + """피사체가 향한 쪽을 **화면 기준**으로. + + 좌/우는 언제나 순수 투영이고, 카메라 대면(앞/뒤/측면)만 선언 + relation 이 있으면 그 값에서 렌더한다 — 여기 도달한 선언값은 + _check_facing_relation 이 좌표와 정합을 확인한 뒤이므로 eps 추론보다 + 권위다. 코드가 의미를 새로 판정하는 것이 아니라 검증 통과한 선언을 + 문장으로 옮길 뿐이다. + """ 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 "" norm = math.hypot(*vec) if norm < 1e-9: return "" vec = (vec[0] / norm, vec[1] / norm) lat = vec[0] * right[0] + vec[1] * right[1] dep = vec[0] * fwd[0] + vec[1] * fwd[1] horiz = ( "toward the RIGHT of the frame" if lat > _SCREEN_EPS else "toward the LEFT of the frame" if lat < -_SCREEN_EPS else "" ) - depth = ( - "away from the camera, into the depth of the shot" - if dep > _SCREEN_EPS - else "back toward the camera" if dep < -_SCREEN_EPS else "" - ) + # v14/v4 (2026-07-26): 앞/뒤를 명시한다. 구 문구 "back toward the + # camera" 는 부사("다시 카메라 쪽으로")와 신체부위("등을 카메라로")로 + # 갈려 읽혔다 — 마네킹은 얼굴이 없어 몸통 방향이 유일한 신호라 + # 앞/뒤 오독이 곧 반전이다. + rel = pl.get("camera_facing_relation") + if rel in ("toward_camera", "away_from_camera", "profile"): + # 선언값은 validator 가 좌표와 정합을 확인한 뒤에만 도달한다 + # (Task 1) — eps 추론보다 이것이 권위다. unspecified·필드 부재· + # 계약 밖 문자열은 아래 eps 경로로 흐른다(매핑 조회로 예외를 + # 내지 않는다 — enum 거부는 validator 의 몫). + depth = _FACING_RELATION_PHRASES[rel] + elif dep > _SCREEN_EPS: + depth = ( + "away from the camera, into the depth of the shot, so the " + "camera sees this subject from behind" + ) + elif dep < -_SCREEN_EPS: + depth = ( + "toward the camera, so the camera sees this subject from " + "the front" + ) + else: + depth = "" if horiz and depth: return f"{horiz} and {depth}" return horiz or depth or "across the frame" def _facing_subject_en( placements: Any, pl: Dict[str, Any], tol: float = 0.06, ) -> str: """facing 좌표가 다른 피사체의 위치와 사실상 같은 점이면 그 피사체를 보는 것 — 좌표 근접 판정(의미 판단 아님).""" diff --git a/backend/tests/pipeline/test_outdoor_marker_map.py b/backend/tests/pipeline/test_outdoor_marker_map.py index f52ef1a4..dac39b89 100644 --- a/backend/tests/pipeline/test_outdoor_marker_map.py +++ b/backend/tests/pipeline/test_outdoor_marker_map.py @@ -439,21 +439,21 @@ def test_geometry_text_lines_have_no_numeric_fov(): """배치 리뷰 HIGH-6: 고정 화각 수치 하드코딩 금지 — 프레이밍은 camera_direction·샷 텍스트 SOT.""" from app.modules.pipeline.outdoor_marker_map import ( GEOMETRY_TEXT_VERSION, build_geometry_text_lines, ) text = "\n".join(build_geometry_text_lines(_geometry())) assert "degrees" not in text and "56" not in text assert "field of view" in text # 정성 서술은 유지 - assert GEOMETRY_TEXT_VERSION == 5 # 직렬화 계약 bump (지문 드리프트) + assert GEOMETRY_TEXT_VERSION == 6 # 직렬화 계약 bump (지문 드리프트) def test_validator_non_string_subject_is_violation_not_exception(): """재리뷰 NARROW-4: non-string subject_en = 예외가 아니라 위반 반환 (validator 자체 완결·fail-closed 재시도 계약).""" g = _geometry() g["entity_placements"][0]["subject_en"] = {"nested": "dict"} out = validate_marker_geometry(g) assert any("문자열 아님" in v for v in out) g2 = _geometry() @@ -669,21 +669,21 @@ def test_v6_check_and_text_lines_carry_anchors(): # NARROW-4: 문법 가정 없는 중립 라벨 — 이중 전치사 불가 assert "LOOK TARGET: toward the front of the shelter" in check assert "toward toward" not in check assert "coarse estimate" in check text = "\n".join(build_geometry_text_lines(g)) assert "the camera stands near the south-east corner" in text assert "LOOK TARGET: toward the front of the shelter" in text assert "toward toward" not in text assert "coarse estimate" in text assert "on the open ground beside the shelter" in text - assert GEOMETRY_TEXT_VERSION == 5 + assert GEOMETRY_TEXT_VERSION == 6 # anchor 부재 geometry=기존 수치 표기 (legacy 직렬화 경로 보존) legacy = "\n".join(build_geometry_text_lines(_geometry())) assert "the camera stands at (x=" in legacy assert "coarse estimate" not in legacy def test_v2_geometry_schema_versioning_contract(): """조건 ④: geometry v2 발행·계약 상수 — run 경로가 버전으로 스키마/ 검증을 게이트.""" from app.modules.pipeline.outdoor_marker_map import ( @@ -1332,10 +1332,74 @@ def test_lane_step_selector_raised_to_v4(): v4 계약이 사문화된다(canon v3·lane_plan v4 실측 교훈).""" from app.core.steps.shot_conti_light_step import ( LANE_GEOMETRY_PACK_VERSION, ) from app.modules.pipeline.outdoor_marker_map import ( _FACING_RELATION_GEOMETRY_PACKS, ) assert LANE_GEOMETRY_PACK_VERSION == "4" assert LANE_GEOMETRY_PACK_VERSION in _FACING_RELATION_GEOMETRY_PACKS + + +# ── v6 직렬화 (2026-07-26): 앞/뒤 명시 + 선언 relation 우선 렌더 ────── + + +def test_facing_text_states_front_or_back_explicitly(): + from app.modules.pipeline.outdoor_marker_map import ( + _facing_screen_text, + ) + + g = _fixture_geometry(facing=(0.80, 0.95)) # dep < -eps + txt = _facing_screen_text(g["camera"], g["entity_placements"][0]) + assert "from the front" in txt + assert "back toward the camera" not in txt # 중의성 어구 제거 + + g2 = _fixture_geometry(facing=(0.70, 0.20)) # dep > +eps + txt2 = _facing_screen_text(g2["camera"], g2["entity_placements"][0]) + assert "from behind" in txt2 + + +def test_facing_text_prefers_declared_relation(): + """선언 relation 이 있으면 eps 추론이 아니라 그것을 문장화한다.""" + from app.modules.pipeline.outdoor_marker_map import ( + _facing_screen_text, + ) + + # dep 는 0 근처(eps 미달)지만 선언은 profile — 침묵하지 않고 서술 + g = _fixture_geometry(relation="profile", evidence="뒷모습", + facing=(0.88, 0.52)) + txt = _facing_screen_text(g["camera"], g["entity_placements"][0]) + assert "side-on to the camera" in txt + + # unspecified 는 기존 eps 경로 — dep 미달이면 심도 문구 없음 + g2 = _fixture_geometry(relation="unspecified", facing=(0.88, 0.52)) + txt2 = _facing_screen_text(g2["camera"], g2["entity_placements"][0]) + assert "side-on to the camera" not in txt2 + assert "from behind" not in txt2 and "from the front" not in txt2 + + # 계약 밖 문자열은 조용히 삼키지 않고 eps 경로로 흘린다 — 매핑 + # 조회로 KeyError 를 내면 직렬화가 통째로 죽는다(validator 가 이미 + # enum 을 거부하므로 여기서 다시 예외를 낼 이유가 없다). + g3 = _fixture_geometry(relation="sideways", facing=(0.70, 0.20)) + txt3 = _facing_screen_text(g3["camera"], g3["entity_placements"][0]) + assert "from behind" in txt3 + + +def test_declared_relation_renders_into_geometry_text_lines(): + """소비자 배선 가드: 선언 relation 이 실제 직렬화 산문까지 도달한다 + (_facing_screen_text 만 고치고 호출부가 eps 값을 쓰면 무의미).""" + from app.modules.pipeline.outdoor_marker_map import ( + build_geometry_text_lines, + ) + + g = _fixture_geometry(relation="profile", evidence="뒷모습", + facing=(0.88, 0.52)) + text = "\n".join(build_geometry_text_lines(g)) + # 좌/우(순수 투영)와 대면(선언값)이 같은 절에 병기된다 + assert ("faces toward the RIGHT of the frame and side-on to the " + "camera, neither toward it nor away from it") in text + + # relation 필드가 없는 구 geometry = eps 경로 산문 (심도 문구만 교체) + legacy = "\n".join(build_geometry_text_lines(_geometry_view())) + assert "so the camera sees this subject from behind" in legacy + assert "back toward the camera" not in legacy