feat: add Java JRXML-to-PNG rendering pipeline with pixel-level SSIM comparison
- lib/java/: Java renderer (JrxmlRenderer) using JasperReports 6.21.0 - JrxmlDebug for diagnostics, JrxmlGen for format reference - download_jars.sh for one-time dependency setup - agent/nodes.py: _render_jrxml_to_png() and _compute_pixel_similarity() - Pixel comparison integrates into validate node (SSIM < 0.4 fails) - Pixel fidelity context injected into correct_jrxml for targeted fixes - tests/test_pixel_comparison.py: 15 unit tests (render, SSIM, integration) - .gitignore: exclude lib/java/*.jar, lib/java/*.class, tmp/ - CLAUDE.md: v11 changelog documenting the rendering pipeline - All non-LLM tests pass (97/97)
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""Tests for pixel-level JRXML-to-image comparison pipeline."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import pytest
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
import cv2
|
||||
from agent.nodes import _render_jrxml_to_png, _compute_pixel_similarity, _check_ocr_fidelity
|
||||
|
||||
|
||||
# ── Test JRXML fixture ─────────────────────────────────────────────────
|
||||
|
||||
VALID_JRXML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd"
|
||||
name="TestReport" pageWidth="595" pageHeight="842"
|
||||
columnWidth="555" leftMargin="20" rightMargin="20"
|
||||
topMargin="20" bottomMargin="20">
|
||||
<queryString><![CDATA[SELECT 1]]></queryString>
|
||||
<title>
|
||||
<band height="50">
|
||||
<staticText>
|
||||
<reportElement x="0" y="0" width="555" height="30"/>
|
||||
<text><![CDATA[HELLO]]></text>
|
||||
</staticText>
|
||||
</band>
|
||||
</title>
|
||||
<detail>
|
||||
<band height="20">
|
||||
<staticText>
|
||||
<reportElement x="0" y="0" width="555" height="20"/>
|
||||
<text><![CDATA[test]]></text>
|
||||
</staticText>
|
||||
</band>
|
||||
</detail>
|
||||
</jasperReport>"""
|
||||
|
||||
INVALID_JRXML = "<notJRXML></notJRXML>"
|
||||
|
||||
|
||||
# ── _render_jrxml_to_png ────────────────────────────────────────────────
|
||||
|
||||
class TestRenderJrxmlToPng:
|
||||
def test_valid_jrxml_renders_successfully(self, tmp_path):
|
||||
output_png = str(tmp_path / "valid_output.png")
|
||||
result = _render_jrxml_to_png(VALID_JRXML, output_png, scale=1.0)
|
||||
assert result, "Valid JRXML should render successfully"
|
||||
assert os.path.exists(output_png), f"Output PNG should exist at {output_png}"
|
||||
assert os.path.getsize(output_png) > 1000, "Output PNG should be non-trivial"
|
||||
|
||||
def test_invalid_jrxml_returns_false(self, tmp_path):
|
||||
output_png = str(tmp_path / "invalid_output.png")
|
||||
result = _render_jrxml_to_png(INVALID_JRXML, output_png)
|
||||
assert not result, "Invalid JRXML should return False"
|
||||
|
||||
def test_render_output_is_readable_image(self, tmp_path):
|
||||
output_png = str(tmp_path / "readable.png")
|
||||
_render_jrxml_to_png(VALID_JRXML, output_png, scale=1.0)
|
||||
img = cv2.imread(output_png)
|
||||
assert img is not None, "Rendered PNG should be readable by OpenCV"
|
||||
assert img.shape[0] > 0 and img.shape[1] > 0, "Image should have non-zero dimensions"
|
||||
assert img.shape[2] == 3, "Should be a 3-channel (BGR) image"
|
||||
|
||||
def test_high_scale_produces_larger_image(self, tmp_path):
|
||||
png1 = str(tmp_path / "scale1.png")
|
||||
png2 = str(tmp_path / "scale2.png")
|
||||
_render_jrxml_to_png(VALID_JRXML, png1, scale=1.0)
|
||||
_render_jrxml_to_png(VALID_JRXML, png2, scale=2.0)
|
||||
img1 = cv2.imread(png1)
|
||||
img2 = cv2.imread(png2)
|
||||
assert img2.shape[0] >= img1.shape[0], "Higher scale should produce >= height"
|
||||
assert img2.shape[1] >= img1.shape[1], "Higher scale should produce >= width"
|
||||
|
||||
|
||||
# ── _compute_pixel_similarity ───────────────────────────────────────────
|
||||
|
||||
class TestComputePixelSimilarity:
|
||||
def test_identical_images_have_high_ssim(self, tmp_path):
|
||||
img_path = str(tmp_path / "test.png")
|
||||
white = np.full((100, 200, 3), 255, dtype=np.uint8)
|
||||
cv2.imwrite(img_path, white)
|
||||
result = _compute_pixel_similarity(img_path, img_path)
|
||||
assert result["error"] is None, f"Should have no error: {result['error']}"
|
||||
assert result["ssim"] == 1.0, f"SSIM of identical images should be 1.0, got {result['ssim']}"
|
||||
assert result["diff_pct"] == 0.0, f"Diff% of identical images should be 0, got {result['diff_pct']}"
|
||||
|
||||
def test_completely_different_images_have_low_ssim(self, tmp_path):
|
||||
white_path = str(tmp_path / "white.png")
|
||||
black_path = str(tmp_path / "black.png")
|
||||
cv2.imwrite(white_path, np.full((100, 200, 3), 255, dtype=np.uint8))
|
||||
cv2.imwrite(black_path, np.full((100, 200, 3), 0, dtype=np.uint8))
|
||||
result = _compute_pixel_similarity(white_path, black_path)
|
||||
assert result["error"] is None, f"Should have no error: {result['error']}"
|
||||
assert result["ssim"] < 0.3, f"Different images should have low SSIM, got {result['ssim']}"
|
||||
assert result["diff_pct"] > 0.9, f"Different images should have high diff%, got {result['diff_pct']}"
|
||||
|
||||
def test_different_size_images_are_resized(self, tmp_path):
|
||||
img1_path = str(tmp_path / "img1.png")
|
||||
img2_path = str(tmp_path / "img2.png")
|
||||
cv2.imwrite(img1_path, np.full((50, 100, 3), 128, dtype=np.uint8))
|
||||
cv2.imwrite(img2_path, np.full((100, 200, 3), 128, dtype=np.uint8))
|
||||
result = _compute_pixel_similarity(img1_path, img2_path)
|
||||
assert result["error"] is None, f"Resize should work: {result['error']}"
|
||||
|
||||
def test_missing_file_returns_error(self, tmp_path):
|
||||
result = _compute_pixel_similarity(
|
||||
str(tmp_path / "does_not_exist.png"),
|
||||
str(tmp_path / "also_missing.png"),
|
||||
)
|
||||
assert result["error"] is not None, "Missing files should set error"
|
||||
assert result["ssim"] == 0.0
|
||||
|
||||
def test_non_image_file_returns_error(self, tmp_path):
|
||||
text_path = str(tmp_path / "text.txt")
|
||||
with open(text_path, "w") as f:
|
||||
f.write("not an image")
|
||||
png_path = str(tmp_path / "ref.png")
|
||||
cv2.imwrite(png_path, np.full((100, 100, 3), 255, dtype=np.uint8))
|
||||
result = _compute_pixel_similarity(text_path, png_path)
|
||||
assert result["error"] is not None, "Non-image should set error"
|
||||
|
||||
|
||||
# ── _check_ocr_fidelity ─────────────────────────────────────────────────
|
||||
|
||||
class TestCheckOcrFidelity:
|
||||
def test_no_ocr_data_returns_full_score(self):
|
||||
state = {}
|
||||
result = _check_ocr_fidelity(VALID_JRXML, state)
|
||||
assert result["score"] == 1.0
|
||||
assert result["issues"] == []
|
||||
|
||||
def test_missing_ocr_fields_flagged(self):
|
||||
state = {
|
||||
"ocr_extraction_result": {
|
||||
"fields": [
|
||||
{"name": "invoice_code"},
|
||||
{"name": "invoice_number"},
|
||||
{"name": "amount"},
|
||||
]
|
||||
}
|
||||
}
|
||||
result = _check_ocr_fidelity(VALID_JRXML, state)
|
||||
assert result["field_coverage"] < 1.0, "Missing fields should reduce coverage"
|
||||
assert len(result["issues"]) > 0, "Should have issues about missing fields"
|
||||
|
||||
def test_matched_fields_increase_coverage(self):
|
||||
state = {
|
||||
"ocr_extraction_result": {
|
||||
"fields": [
|
||||
{"name": "invoice_code"},
|
||||
{"name": "invoice_number"},
|
||||
]
|
||||
}
|
||||
}
|
||||
jrxml_with_fields = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports"
|
||||
name="TestReport" pageWidth="595" pageHeight="842"
|
||||
columnWidth="555" leftMargin="20" rightMargin="20"
|
||||
topMargin="20" bottomMargin="20">
|
||||
<field name="invoice_code" class="java.lang.String"/>
|
||||
<field name="invoice_number" class="java.lang.String"/>
|
||||
<queryString><![CDATA[SELECT 1]]></queryString>
|
||||
<detail><band height="20"><textField><reportElement x="0" y="0" width="200" height="20"/><textFieldExpression><![CDATA[$F{invoice_code}]]></textFieldExpression></textField></band></detail>
|
||||
</jasperReport>"""
|
||||
result = _check_ocr_fidelity(jrxml_with_fields, state)
|
||||
assert result["field_coverage"] == 1.0, f"All fields matched, got {result['field_coverage']}"
|
||||
assert len(result["issues"]) == 0, f"No issues expected, got {result['issues']}"
|
||||
|
||||
def test_element_count_mismatch_flagged(self):
|
||||
state = {
|
||||
"ocr_elements": [
|
||||
{"text": "a"}, {"text": "b"}, {"text": "c"},
|
||||
{"text": "d"}, {"text": "e"},
|
||||
]
|
||||
}
|
||||
result = _check_ocr_fidelity(VALID_JRXML, state)
|
||||
assert result["element_coverage"] < 1.0, \
|
||||
"Fewer JRXML elements than OCR should reduce coverage"
|
||||
|
||||
|
||||
# ── validate node integration ───────────────────────────────────────────
|
||||
|
||||
class TestValidatePixelIntegration:
|
||||
def test_validate_skips_pixel_when_no_image(self):
|
||||
from agent.nodes import validate
|
||||
|
||||
state = {
|
||||
"current_jrxml": VALID_JRXML,
|
||||
"uploaded_file_path": None,
|
||||
"ocr_elements": [],
|
||||
"layout_schema": {},
|
||||
"conversation_history": [],
|
||||
}
|
||||
result = validate(state)
|
||||
assert result.get("pixel_fidelity") is None, \
|
||||
"Should not set pixel_fidelity without uploaded_file_path"
|
||||
assert result["status"] == "pass", "Valid JRXML should pass XSD"
|
||||
|
||||
def test_validate_skips_pixel_when_xsd_fails(self):
|
||||
from agent.nodes import validate
|
||||
|
||||
state = {
|
||||
"current_jrxml": INVALID_JRXML,
|
||||
"uploaded_file_path": os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "tmp", "test_output.png"
|
||||
),
|
||||
"ocr_elements": [],
|
||||
"layout_schema": {},
|
||||
"conversation_history": [],
|
||||
}
|
||||
result = validate(state)
|
||||
assert result["status"] == "fail", "Invalid JRXML should fail XSD"
|
||||
Reference in New Issue
Block a user