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:
@@ -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)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
196
tests/phase_z2/test_phase_z2_step14_image_check.py
Normal file
196
tests/phase_z2/test_phase_z2_step14_image_check.py
Normal 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 == []
|
||||
Reference in New Issue
Block a user