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)."
),
)

View File

@@ -0,0 +1,196 @@
"""IMP-15 실행-1 (Gitea issue #45) — Step 14 image_aspect_mismatch detection.
Tests Selenium-driven `<img>` aspect measurement added to ``run_overflow_check``:
* Fixture A — 200×100 image rendered at 200×100 → ``abs(delta) < tol``, no fail
reason, ``passed=True``.
* Fixture B — 200×100 image forced to render 200×200 → ``abs(delta) > 0.30``,
fail reason includes ``image aspect mismatch in zone--primary:``,
``passed=False``.
* Fixture C — ``<img>`` with no ``.zone`` ancestor (attached directly under
``.slide``) → event reports ``zone_position == "unknown"``.
Chromedriver resolution mirrors the pipeline's order
(``PROJECT_ROOT/chromedriver{,.exe}`` → PATH fallback). When no driver is
resolvable the suite skips by default; under ``PHASE_Z_REQUIRE_SELENIUM=1`` the
tests are marked ``xfail(strict=True)`` so CI cannot silently lose coverage.
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
import pytest
from src.phase_z2_pipeline import (
IMAGE_ASPECT_DELTA_TOL,
PROJECT_ROOT,
run_overflow_check,
)
PIL_Image = pytest.importorskip("PIL.Image", reason="Pillow required for fixture PNGs")
# ─── chromedriver skip / xfail guard ─────────────────────────────────
def _selenium_manager_resolvable() -> bool:
"""Probe ``webdriver.Chrome(options=...)`` — pipeline's third tier.
``src/phase_z2_pipeline.py`` (run_overflow_check) tries
``PROJECT_ROOT/chromedriver{,.exe}`` first, then falls back to
``webdriver.Chrome(options=options)`` which delegates to Selenium Manager
for driver auto-resolution. The test resolver must mirror that fallback
or PHASE_Z_REQUIRE_SELENIUM=1 produces spurious strict-XPASS failures on
machines where Selenium Manager can satisfy the pipeline at runtime.
"""
try:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options as _Opts
except Exception:
return False
opts = _Opts()
opts.add_argument("--headless=new")
opts.add_argument("--no-sandbox")
opts.add_argument("--disable-dev-shm-usage")
try:
drv = webdriver.Chrome(options=opts)
except Exception:
return False
try:
drv.quit()
except Exception:
pass
return True
def _chromedriver_resolvable() -> bool:
"""Mirror pipeline order: PROJECT_ROOT/chromedriver{,.exe} → PATH → Selenium Manager."""
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
if candidate.is_file():
return True
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
return True
return _selenium_manager_resolvable()
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
_DRIVER_AVAILABLE = _chromedriver_resolvable()
if not _DRIVER_AVAILABLE:
if _REQUIRE_SELENIUM:
pytestmark = pytest.mark.xfail(
strict=True,
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
)
else:
pytestmark = pytest.mark.skip(
reason=(
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
),
)
# ─── HTML / PNG fixture helpers ──────────────────────────────────────
_SLIDE_CSS = """
html, body { margin: 0; padding: 0; }
.slide { width: 1280px; height: 720px; position: relative; box-sizing: border-box; }
.zone { display: block; }
"""
def _write_png(path: Path, width: int, height: int, colour=(120, 160, 200)) -> Path:
img = PIL_Image.new("RGB", (width, height), colour)
img.save(path, format="PNG")
return path
def _write_slide_html(tmp_path: Path, body_inner: str, name: str = "slide.html") -> Path:
html = (
"<!doctype html><html><head><meta charset='utf-8'>"
f"<style>{_SLIDE_CSS}</style></head><body>"
'<div class="slide" data-page="1">'
f"{body_inner}"
"</div></body></html>"
)
path = tmp_path / name
path.write_text(html, encoding="utf-8")
return path
def _find_event(events, src_basename: str) -> dict:
for ev in events:
if Path(ev.get("src", "")).name == src_basename:
return ev
raise AssertionError(f"image_events missing entry for {src_basename}; got {events}")
# ─── tests ───────────────────────────────────────────────────────────
def test_image_no_distortion(tmp_path: Path) -> None:
"""Fixture A — 200×100 image rendered at native 200×100. delta ≈ 0."""
png = _write_png(tmp_path / "ok.png", 200, 100)
body = (
'<div class="zone" data-zone-position="primary" data-template-id="t_ok">'
f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
"</div>"
)
html_path = _write_slide_html(tmp_path, body, name="ok.html")
result = run_overflow_check(html_path)
assert "error" not in result, result
assert result.get("image_events"), "image_events must be populated"
ev = _find_event(result["image_events"], png.name)
assert ev["zone_position"] == "primary"
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 100
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
assert image_fails == [], f"unexpected image fail_reasons: {image_fails}"
assert result["passed"] is True, result.get("fail_reasons")
def test_image_forced_distortion(tmp_path: Path) -> None:
"""Fixture B — 200×100 image forced to 200×200. delta > 0.30, fail emitted."""
png = _write_png(tmp_path / "bad.png", 200, 100, colour=(200, 80, 80))
body = (
'<div class="zone" data-zone-position="primary" data-template-id="t_bad">'
f'<img src="{png.name}" style="width:200px;height:200px;display:block">'
"</div>"
)
html_path = _write_slide_html(tmp_path, body, name="bad.html")
result = run_overflow_check(html_path)
assert "error" not in result, result
ev = _find_event(result["image_events"], png.name)
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 200
assert ev["delta"] is not None and abs(ev["delta"]) > 0.30
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
assert len(image_fails) == 1, f"expected one image fail_reason, got: {image_fails}"
msg = image_fails[0]
assert msg.startswith("image aspect mismatch in zone--primary:"), msg
assert "natural=2.000" in msg and "rendered=1.000" in msg
assert f"src={png.name}" in msg or png.name in msg
assert result["passed"] is False
def test_image_no_zone_ancestor(tmp_path: Path) -> None:
"""Fixture C — <img> attached directly under .slide → zone_position == 'unknown'."""
png = _write_png(tmp_path / "loose.png", 200, 100, colour=(80, 200, 120))
body = f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
html_path = _write_slide_html(tmp_path, body, name="loose.html")
result = run_overflow_check(html_path)
assert "error" not in result, result
ev = _find_event(result["image_events"], png.name)
assert ev["zone_position"] == "unknown"
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
assert image_fails == []