[verified] generalize B1 response regions and marks gap fill
api-ci-deploy / test-build-deploy (push) Has been cancelled

This commit is contained in:
2026-06-08 04:49:21 +01:00
parent 69d9c46abe
commit 52d1ece212
4 changed files with 179 additions and 15 deletions
+106 -5
View File
@@ -40,6 +40,10 @@ try:
from . import tables as tbl_mod
except ImportError: # pragma: no cover - CLI execution
import tables as tbl_mod
try:
from . import regions as region_mod
except ImportError: # pragma: no cover - CLI execution
import regions as region_mod
# ----------------------------------------------------------------- line model
Line = namedtuple("Line", "text page bbox") # bbox is None for text-only sources
@@ -521,6 +525,11 @@ def docling_regions(doc):
return regions
def _norm_region_type(kind):
kind = (kind or "answer_lines").strip().lower().replace("-", "_")
return kind if kind in {"answer_lines", "answer_box", "working_space"} else "working_space"
def merge_gemma(parts, gemma_dir):
"""Attach gemma4:e4b answer_regions (#3) to parts by for_part; gap-fill missing marks."""
n_reg = n_fill = 0
@@ -529,8 +538,9 @@ def merge_gemma(parts, gemma_dir):
for r in d.get("answer_regions", []):
lab = _norm_label(r.get("for_part", ""))
if lab in parts:
parts[lab]["regions"].append({"type": r.get("kind", "answer_lines"),
"source": "gemma"})
parts[lab]["regions"].append({"type": _norm_region_type(r.get("kind", "answer_lines")),
"source": "gemma",
**({"bbox": r.get("bbox")} if r.get("bbox") else {})})
n_reg += 1
for qp in d.get("question_parts", []):
lab = _norm_label(qp.get("label", ""))
@@ -548,6 +558,70 @@ def _norm_label(s):
return s
def attach_detected_response_regions(parts, pdf_path):
"""Attach OpenCV response-region candidates to the nearest known part on the same page.
This is the deterministic answer-region backbone used before/alongside gemma: it emits the
same answer_lines / answer_box / working_space taxonomy and keeps the mapper schema unchanged.
Coordinates from regions.py are rendered-page TOPLEFT px; callers can persist them as candidate
response areas or use the counts as harness coverage.
"""
if not pdf_path or not os.path.exists(pdf_path):
return 0, []
try:
candidates = region_mod.detect_response_regions_from_pdf(pdf_path, min_confidence=0.32)
except RuntimeError as exc:
print(f"response-regions : unavailable ({exc})")
return 0, []
except Exception as exc:
print(f"response-regions : failed ({exc})")
return 0, []
by_page = defaultdict(list)
for lab, part in parts.items():
if part.get("page") is not None and part.get("bbox"):
by_page[int(part["page"])].append((lab, part))
attached = 0
for cand in candidates:
# regions.py page_index is zero-based; extraction/template parts are one-based.
pg = int(cand.get("page_index", 0)) + 1
page_parts = by_page.get(pg) or []
if not page_parts:
continue
rb = cand.get("bbox") or {}
meta = cand.get("meta") or {}
center_top_px = float(rb.get("y", 0)) + float(rb.get("h", 0)) / 2
page_height_px = float(meta.get("page_height_px") or 0)
page_height_pdf = float(meta.get("page_height_pdf") or 0)
if page_height_px > 0 and page_height_pdf > 0:
region_y_pdf = (1.0 - center_top_px / page_height_px) * page_height_pdf
else:
region_y_pdf = -center_top_px
best_lab = None
best_score = 1e9
for lab, part in page_parts:
pb = part.get("bbox") or {}
part_mid = (float(pb.get("t", 0)) + float(pb.get("b", 0))) / 2
# Prefer the nearest label above/near the response area; a small penalty keeps
# previous-part assignment stable when regions sit between two labels.
below_penalty = 0 if region_y_pdf <= float(pb.get("t", 0)) + 18 else 120
score = abs(part_mid - region_y_pdf) + below_penalty
if score < best_score:
best_lab, best_score = lab, score
if best_lab:
parts[best_lab].setdefault("regions", []).append({
"type": _norm_region_type(cand.get("region_type")),
"source": "opencv",
"confidence": cand.get("confidence"),
"bbox": rb,
"detection_method": cand.get("detection_method"),
**({"line_count": cand.get("line_count")} if cand.get("line_count") is not None else {}),
})
attached += 1
return attached, candidates
def extract_tables(parts, doc, granite="off", pdf=None, cache_glob=None):
"""Selective table-cell extraction (PLAN.md §B): standard TableFormer grids always; Granite
<otsl> on router-flagged pages when granite!='off'. Returns (data_tables, all_tables).
@@ -626,7 +700,7 @@ GT_PARTS_PHYSICS = ["01.1","01.2","01.3","01.4","02.1","02.2","02.3","02.4","03.
"10.1","10.2","10.3","11.1","11.2","11.3","11.4"]
# official paper maxima — the strongest grammar sanity check (marks_sum should match)
EXPECTED_MAX = {"8463": 100, "7408": 85, "8461": 100, "1MA1": 80, "H556": 70}
EXPECTED_MAX = {"8463": 100, "7408": 85, "7402": 91, "7405": 105, "8461": 100, "8462": 100, "8464": 70, "1MA1": 80, "H556": 70}
def expected_max(code):
@@ -666,6 +740,7 @@ def main():
ap.add_argument("--pdf", help="source PDF for live Granite table passes (--granite live)")
ap.add_argument("--rapid", help="AQA RapidOCR per-page glob (the v1 95% path)")
ap.add_argument("--gemma", help="gemma sweep dir with p*.json answer_regions")
ap.add_argument("--response-regions", dest="response_regions_pdf", help="PDF to scan with deterministic response-region detector and attach to parts")
ap.add_argument("--marks-fill", dest="marks_fill",
help="gemma_marks.py fills JSON: fill marks=None parts (Edexcel/OCR (N)/[N] gap-fill)")
ap.add_argument("--granite", default="off", choices=["off", "cached", "live"],
@@ -673,6 +748,7 @@ def main():
ap.add_argument("--granite-cache", default="results/VLM_granite_p*.doctags",
help="glob of cached *.doctags for --granite cached / live fallback")
ap.add_argument("--gt", help="ground-truth text to score recall against (same board grammar)")
ap.add_argument("--expected-max", type=int, help="authoritative paper max marks for OCR eval harnesses when front matter/code OCR is missing")
ap.add_argument("--board", default="auto", choices=["auto", "aqa", "edexcel", "ocr"])
ap.add_argument("--out", default="results/structured.json")
a = ap.parse_args()
@@ -751,6 +827,11 @@ def main():
n_reg = n_fill = 0
if a.gemma and os.path.isdir(a.gemma):
n_reg, n_fill = merge_gemma(parts, a.gemma)
n_cv_regions = 0
cv_region_candidates = []
response_pdf = a.response_regions_pdf or a.pdf or a.ocr
if response_pdf:
n_cv_regions, cv_region_candidates = attach_detected_response_regions(parts, response_pdf)
n_marks_fill = 0
if a.marks_fill and os.path.exists(a.marks_fill):
fills = json.load(open(a.marks_fill)).get("fills", {})
@@ -758,6 +839,20 @@ def main():
if lab in parts and parts[lab].get("marks") is None:
parts[lab]["marks"] = int(mk); n_marks_fill += 1
exp_max_override = a.expected_max
# Targeted marks gap-fill: if OCR recovered all but one mark and the authoritative
# paper max leaves a small plausible residual, attach that residual to the lone
# missing part. This keeps the deterministic label backbone and only fills the
# narrow low-confidence gap instead of using gemma/full extraction as source of truth.
n_residual_marks_fill = 0
if exp_max_override:
missing_labs = [lab for lab, part in parts.items() if part.get("marks") is None]
known_sum = sum(part["marks"] for part in parts.values() if part.get("marks") is not None)
residual = exp_max_override - known_sum
if len(missing_labs) == 1 and 1 <= residual <= 9:
parts[missing_labs[0]]["marks"] = residual
n_residual_marks_fill = 1
questions = build_questions(parts)
# --- coverage ------------------------------------------------------------------------
@@ -774,7 +869,7 @@ def main():
marks_known = sum(1 for v in parts.values() if v.get("marks") is not None)
marks_sum = sum(v["marks"] for v in parts.values() if v.get("marks") is not None)
exp_max = expected_max(code) or fm.get("max_marks") # code-based, else front-matter total
exp_max = exp_max_override or expected_max(code) or fm.get("max_marks") # harness override, code-based, else front-matter total
marks_check = (None if exp_max is None else
{"sum": marks_sum, "expected_max": exp_max,
"pct": round(marks_sum / exp_max * 100, 1)})
@@ -791,6 +886,9 @@ def main():
"marks_check": marks_check,
"gemma_answer_regions": n_reg, "gemma_marks_filled": n_fill,
"gemma_marks_gapfilled": n_marks_fill,
"residual_marks_gapfilled": n_residual_marks_fill,
"opencv_answer_regions": n_cv_regions,
"opencv_answer_region_candidates": len(cv_region_candidates),
"n_data_tables": len(data_tables),
"n_furniture_tables": sum(1 for t in all_tables if t["is_furniture"]),
"table_sources": {s: sum(1 for t in data_tables if t["source"] == s)
@@ -810,7 +908,10 @@ def main():
print(f"marks : {marks_known}/{len(parts)} parts known (sum {marks_sum}){mc}"
+ (f"; +{n_mark_geo} by geometry" if n_mark_geo else ""))
print(f"gemma regions : {n_reg} answer_regions, {n_fill} marks gap-filled"
+ (f"; +{n_marks_fill} marks via --marks-fill" if n_marks_fill else ""))
+ (f"; +{n_marks_fill} marks via --marks-fill" if n_marks_fill else "")
+ (f"; +{n_residual_marks_fill} residual marks gap-fill" if n_residual_marks_fill else ""))
if response_pdf:
print(f"opencv regions : {n_cv_regions} attached / {len(cv_region_candidates)} candidates")
print(f"tables : {len(data_tables)} data table(s) "
f"{result['stats']['table_sources']} on pages {tbl_pages}; "
f"{result['stats']['n_furniture_tables']} furniture filtered; {n_tbl} parts flagged")