feat(IMP-15): 실행-1 — Step 14 image_aspect_mismatch detection

Issue: #45 (IMP-15 실행-1, image axis only).

Adds Selenium-based <img> aspect ratio measurement to Step 14
run_overflow_check + numeric tolerance gate. Tolerance lives as
module-scope constant so tests can import it.

src/phase_z2_pipeline.py (+73/-2):
- L131-L135  IMAGE_ASPECT_DELTA_TOL = 0.05 (module scope, importable)
- L2216-L2261  JS payload extension: image_events[] per <img>
  (src, zone_position via closest('.zone') with 'unknown' fallback,
   zone_template_id, natural/rendered w+h+ratio, delta, slide-rel bbox)
- L2262  run_overflow_check return extended with image_events
- L2302-L2320  Python aggregation: abs(delta) > TOL ⇒ fail_reasons
  append 'image aspect mismatch in zone--<pos>: natural=<n> rendered=<r>
  delta=<+d> (template=<tid>, tol=0.05, src=<src>)'.
  Null-delta entries (image not loaded) are skipped — no false positive.
  Branch placed AFTER existing non-image branches; ordering & strings
  for slide/slide-body/zone/clipped_inner unchanged.
- L4425-L4429  Step 14 note: image half closed, table half deferred
  to 실행-2.

tests/phase_z2/test_phase_z2_step14_image_check.py (+196, new):
- 3-tier chromedriver resolver mirroring pipeline (PROJECT_ROOT/
  chromedriver{,.exe} → PATH → Selenium Manager probe).
- pytestmark: skip when chromedriver unresolvable AND
  PHASE_Z_REQUIRE_SELENIUM != '1'; xfail(strict=True) opt-in when =='1'.
- Fixture A: 200×100 img rendered 200×100 → aspect_delta < 0.05, passed.
- Fixture B: 200×100 intrinsic forced to 200×200 → delta > 0.30,
  fail_reason present.
- Fixture C: <img> with no .zone ancestor → zone_position == 'unknown'.

Verification (Stage 4 PASS, Claude + Codex independent):
- pytest -q tests/phase_z2/test_phase_z2_step14_image_check.py → 3 passed
- PHASE_Z_REQUIRE_SELENIUM=1 same suite → 3 passed (strict opt-in)
- pytest -q tests/phase_z2 → 90 passed (no regression)
- pytest -q --ignore=tests/matching → 174 passed

Scope-locked: no slide_base.html / catalog / classifier / debug.json /
spec-doc changes. table_events (실행-2), visual_check_passed flip
(실행-3), debug.json image_events surfacing + PHASE-Z spec doc row
(실행-4) remain queued as separate IMP-15 child execution issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:01:28 +09:00
parent 7a52cebfaa
commit e9b3d2e9c0
2 changed files with 269 additions and 2 deletions

View File

@@ -128,6 +128,11 @@ GRID_GAP = 14 # zone 간격 (사용자 직설 2026-05-07)
# token-based font (var(--font-body) 11px 등) 기준 최소 가독 높이.
DEFAULT_ZONE_MIN_HEIGHT_PX = 100
# Step 14 image_aspect_mismatch tolerance — |natural_ratio - rendered_ratio| > TOL ⇒ fail.
# Local anchor : IMP-15 실행-1 (Gitea issue #45) — image axis acceptance criteria.
# Spec doc row (PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC) update deferred to IMP-15 실행-4.
IMAGE_ASPECT_DELTA_TOL = 0.05
# content_weight 계산 가중치
CONTENT_WEIGHT_COEFFS = {
"text_per_chars": 800, # text_len / 800 = score
@@ -2208,7 +2213,53 @@ def run_overflow_check(html_path: Path) -> dict:
});
});
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px };
// IMP-15 실행-1 (issue #45) — image_events[] for image_aspect_mismatch detection.
// 하나의 entry per <img> under .slide. natural vs rendered aspect 비교.
// zone_position : closest('.zone') data-zone-position. 없으면 literal "unknown".
const image_events = [];
slide.querySelectorAll('img').forEach((img) => {
const parentZone = img.closest('.zone');
const zonePos = parentZone
? (parentZone.getAttribute('data-zone-position') || 'unknown')
: 'unknown';
const zoneTid = parentZone
? (parentZone.getAttribute('data-template-id') || '?')
: '?';
const imgRect = img.getBoundingClientRect();
const rendered_w = imgRect.width;
const rendered_h = imgRect.height;
const natural_w = img.naturalWidth;
const natural_h = img.naturalHeight;
const natural_ratio = (natural_w > 0 && natural_h > 0)
? (natural_w / natural_h)
: null;
const rendered_ratio = (rendered_w > 0 && rendered_h > 0)
? (rendered_w / rendered_h)
: null;
const delta = (natural_ratio !== null && rendered_ratio !== null)
? (rendered_ratio - natural_ratio)
: null;
image_events.push({
src: img.getAttribute('src') || '',
zone_position: zonePos,
zone_template_id: zoneTid,
natural_w: natural_w,
natural_h: natural_h,
rendered_w: Math.round(rendered_w),
rendered_h: Math.round(rendered_h),
natural_ratio: natural_ratio,
rendered_ratio: rendered_ratio,
delta: delta,
bbox: {
x: Math.round(imgRect.left - slideRect.left),
y: Math.round(imgRect.top - slideRect.top),
w: Math.round(rendered_w),
h: Math.round(rendered_h),
},
});
});
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px, image_events };
""")
screenshot_path = html_path.parent / "preview.png"
@@ -2248,6 +2299,25 @@ def run_overflow_check(html_path: Path) -> dict:
f"(content {c['scrollHeight']} vs container {c['clientHeight']})"
)
# IMP-15 실행-1 (issue #45) — image_aspect_mismatch aggregation.
# |natural_ratio - rendered_ratio| > IMAGE_ASPECT_DELTA_TOL ⇒ fail_reason append.
# Entries with null ratio (image not loaded / natural dims = 0) are skipped (no false positive).
for ev in result.get("image_events", []):
delta = ev.get("delta")
if delta is None:
continue
if abs(delta) > IMAGE_ASPECT_DELTA_TOL:
n_ratio = ev.get("natural_ratio")
r_ratio = ev.get("rendered_ratio")
src = ev.get("src", "")
pos = ev.get("zone_position", "unknown")
tid = ev.get("zone_template_id", "?")
fail_reasons.append(
f"image aspect mismatch in zone--{pos}: "
f"natural={n_ratio:.3f} rendered={r_ratio:.3f} delta={delta:+.3f} "
f"(template={tid}, tol={IMAGE_ASPECT_DELTA_TOL}, src={src})"
)
result["passed"] = len(fail_reasons) == 0
result["fail_reasons"] = fail_reasons
return result
@@ -4355,7 +4425,8 @@ def run_phase_z2_mvp1(
note=(
"Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics. "
"Step 8 의 계획값과 비교 시 어느 cell 이 overflow 했는지 박힘. "
"image / table 검사 부재 — Step 14 ⚠ partial."
"image_aspect_mismatch 검사 추가 (IMP-15 실행-1, issue #45) — image_events[] + fail_reasons. "
"table 검사 부재 (실행-2 잔류) — Step 14 ⚠ partial (table only)."
),
)