Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ and also include traceable contract metadata:
"request_id": "...",
"engine": "rapidocr",
"model_version": "builtin",
"layout_version": "1280x720-v5",
"layout_version": "1280x720-v6",
"ok": true,
"data": {},
"fields": {
Expand All @@ -64,7 +64,7 @@ and also include traceable contract metadata:
"cropped": false,
"blur_score": 0.08,
"normalized_size": [1280, 720],
"layout_version": "1280x720-v5",
"layout_version": "1280x720-v6",
"warnings": []
}
}
Expand All @@ -91,6 +91,13 @@ central completion banner is not an authoritative player source, so its player n
not included in `ChallengeData` or field evidence.
`map_variant` is `"classic"` when the right-panel map label contains `经典版` or `经典`,
and `null` when the variant is not detected.
`run_code` is additive, optional OCR evidence from the dedicated settlement `run_code_panel` ROI.
When its visible label and all three numeric groups are reliable, OCRKit returns canonical
`NNNN-NNNN-NNNN`; it records the ROI confidence, source, normalization, and one of
`ok`, `missing`, `invalid`, `ambiguous`, or `low_confidence`. Legacy supported layouts
without that field return `missing` rather than an error. OCRKit never decides whether a
run code is eligible for mastery, duplicate, or worth XP. The compatible Bastion release
version is intentionally not recorded until the HUD field is actually released.

### R2 Object Mode

Expand Down
2 changes: 1 addition & 1 deletion app/core/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class AppContext:
object_store: R2ObjectStore | None = None
model_version: str = "builtin"
engine_name: str = "rapidocr"
layout_version: str = "1280x720-v5"
layout_version: str = "1280x720-v6"
achievement_titles: tuple[str, ...] = ()


Expand Down
4 changes: 2 additions & 2 deletions app/core/roi_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class RoiConfig:
width: int
height: int
rois: dict[str, RoiBox]
version: str = "1280x720-v5"
version: str = "1280x720-v6"


def load_roi_config(path: Path) -> RoiConfig:
Expand All @@ -39,7 +39,7 @@ def load_roi_config(path: Path) -> RoiConfig:
width=int(size["width"]),
height=int(size["height"]),
rois=rois,
version=str(data.get("layout_version", "1280x720-v5")),
version=str(data.get("layout_version", "1280x720-v6")),
)


Expand Down
6 changes: 6 additions & 0 deletions app/image/preprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ def preprocess_achievement_panel(image: np.ndarray) -> np.ndarray:
return clahe.apply(gray)


def preprocess_run_code_panel(image: np.ndarray) -> np.ndarray:
return preprocess_left_panel(image)


def preprocess_right_panel(image: np.ndarray) -> np.ndarray:
up = _upscale(image, 2.0)
gray = cv2.cvtColor(up, cv2.COLOR_BGR2GRAY)
Expand All @@ -41,6 +45,8 @@ def preprocess_by_roi(roi_name: str, image: np.ndarray) -> np.ndarray:
return preprocess_left_panel(image)
if roi_name == "achievement_panel":
return preprocess_achievement_panel(image)
if roi_name == "run_code_panel":
return preprocess_run_code_panel(image)
if roi_name == "bottom_left_hero":
return preprocess_left_panel(image)
if roi_name == "right_panel":
Expand Down
2 changes: 2 additions & 0 deletions app/parser/result_merger.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def merge_result(
bottom_left: BottomLeftHero,
right: RightPanel,
achievement_panel_text: str | None = None,
run_code: str | None = None,
) -> ChallengeData:
duration_text = left.clear_time if left.clear_time_seconds is not None else center.duration_text
duration_seconds = left.clear_time_seconds if left.clear_time_seconds is not None else center.duration_seconds
Expand All @@ -34,4 +35,5 @@ def merge_result(
map_variant=right.map_variant,
difficulty=right.difficulty,
version=right.version,
run_code=run_code,
)
68 changes: 68 additions & 0 deletions app/parser/run_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from __future__ import annotations

from dataclasses import dataclass, replace
import re


RUN_CODE_MIN_CONFIDENCE = 0.9

_LABEL = r"(?:本局\s*代码|run\s*code)"
_GROUP = r"[1-9]\d{3}"
_SEPARATOR = r"[-‐‑‒–—−-]"
_LABEL_PATTERN = re.compile(_LABEL, re.IGNORECASE)
_CANDIDATE_PATTERN = re.compile(
rf"{_LABEL}\s*[::]?\s*"
rf"(?P<raw>(?P<first>{_GROUP})\s*(?P<separator_one>{_SEPARATOR})\s*"
rf"(?P<second>{_GROUP})\s*(?P<separator_two>{_SEPARATOR})\s*(?P<third>{_GROUP})(?!\d))",
re.IGNORECASE,
)


@dataclass(frozen=True)
class ParsedRunCode:
value: str | None
status: str
normalization: tuple[str, ...] = ()
warning: str | None = None


def parse_run_code(text: str) -> ParsedRunCode:
labels = list(_LABEL_PATTERN.finditer(text))
if not labels:
return ParsedRunCode(value=None, status="missing")

candidates = list(_CANDIDATE_PATTERN.finditer(text))
if len(candidates) != len(labels):
return ParsedRunCode(value=None, status="invalid", warning="run_code.invalid")

normalized_codes = {
"-".join(match.group(name) for name in ("first", "second", "third"))
for match in candidates
}
if len(normalized_codes) != 1:
return ParsedRunCode(value=None, status="ambiguous", warning="run_code.ambiguous")

candidate = candidates[0]
raw = candidate.group("raw")
normalization: list[str] = []
if candidate.group("separator_one") != "-" or candidate.group("separator_two") != "-":
normalization.append("separator:canonical-hyphen")
if re.search(r"\s", raw):
normalization.append("whitespace:trimmed")

return ParsedRunCode(
value=normalized_codes.pop(),
status="ok",
normalization=tuple(normalization),
)


def enforce_run_code_confidence(parsed: ParsedRunCode, confidence: float) -> ParsedRunCode:
if parsed.value is not None and confidence < RUN_CODE_MIN_CONFIDENCE:
return replace(
parsed,
value=None,
status="low_confidence",
warning="run_code.low_confidence",
)
return parsed
1 change: 1 addition & 0 deletions app/schemas/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class ChallengeData(BaseModel):
map_variant: str | None
difficulty: str | None
version: str | None
run_code: str | None = None


class ChallengeResponse(BaseModel):
Expand Down
28 changes: 25 additions & 3 deletions app/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from app.parser.left_panel import parse_left_panel
from app.parser.result_merger import merge_result
from app.parser.right_panel import parse_right_panel
from app.parser.run_code import ParsedRunCode, enforce_run_code_confidence, parse_run_code
from app.schemas.response import ChallengeResponse, DebugPayload, FieldEvidence, QualityPayload


Expand All @@ -32,6 +33,7 @@
"map_variant": ("right_panel",),
"difficulty": ("right_panel",),
"version": ("right_panel",),
"run_code": ("run_code_panel",),
}


Expand All @@ -47,11 +49,24 @@ def _field_evidence(name: str, value: Any, confidences: dict[str, float]) -> Fie
)


def _build_field_evidence(data, confidences: dict[str, float]) -> dict[str, FieldEvidence]:
return {
def _build_field_evidence(
data,
confidences: dict[str, float],
run_code: ParsedRunCode,
) -> dict[str, FieldEvidence]:
fields = {
name: _field_evidence(name, getattr(data, name), confidences)
for name in _FIELD_ROIS
}
run_code_confidence = confidences.get("run_code_panel", 0.0)
fields["run_code"] = FieldEvidence(
value=data.run_code,
confidence=run_code_confidence if run_code.status != "missing" else 0.0,
source_roi=list(_FIELD_ROIS["run_code"]),
normalization=list(run_code.normalization),
status=run_code.status,
)
return fields


def extract_structured(
Expand Down Expand Up @@ -84,6 +99,10 @@ def extract_structured(
achievement_text = raw_text.get("achievement_panel", "")
title_text = " ".join(part for part in (left_text, achievement_text) if part)
left = parse_left_panel(title_text, achievement_titles) if achievement_titles else parse_left_panel(title_text)
run_code = enforce_run_code_confidence(
parse_run_code(raw_text.get("run_code_panel", "")),
confidences.get("run_code_panel", 0.0),
)
bottom_left = parse_bottom_left_hero(raw_text.get("bottom_left_hero", ""))
right = parse_right_panel(raw_text.get("right_panel", ""), map_names, map_aliases)
data = merge_result(
Expand All @@ -92,6 +111,7 @@ def extract_structured(
bottom_left,
right,
achievement_panel_text=achievement_text.strip() or None,
run_code=run_code.value,
)

warnings: list[str] = list(input_quality["warnings"])
Expand All @@ -101,6 +121,8 @@ def extract_structured(
warnings.append("left_panel.deaths_skips_missing")
if data.version is None:
warnings.append("right_panel.version_missing")
if run_code.warning is not None:
warnings.append(run_code.warning)
debug_payload = None
if include_debug:
debug_payload = DebugPayload(
Expand All @@ -125,7 +147,7 @@ def extract_structured(
layout_version=layout_version,
ok=True,
data=data,
fields=_build_field_evidence(data, confidences),
fields=_build_field_evidence(data, confidences, run_code),
warnings=warnings,
quality=QualityPayload(
original_size=input_quality["original_size"],
Expand Down
8 changes: 7 additions & 1 deletion configs/roi_1280x720.manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schema_version": "1",
"layout_version": "1280x720-v5",
"layout_version": "1280x720-v6",
"standard_size": {
"width": 1280,
"height": 720
Expand All @@ -12,6 +12,12 @@
"x2": 220,
"y2": 470
},
"run_code_panel": {
"x1": 30,
"y1": 190,
"x2": 360,
"y2": 285
},
"achievement_panel": {
"x1": 35,
"y1": 10,
Expand Down
8 changes: 7 additions & 1 deletion configs/roi_1280x720.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
layout_version: 1280x720-v5
layout_version: 1280x720-v6

standard_size:
width: 1280
Expand All @@ -11,6 +11,12 @@ rois:
x2: 220
y2: 470

run_code_panel:
x1: 30
y1: 190
x2: 360
y2: 285

achievement_panel:
x1: 35
y1: 10
Expand Down
14 changes: 14 additions & 0 deletions scripts/batch_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
from app.service import extract_structured


DEFAULT_RUN_CODE_CASES = Path("tests/fixtures/run_code/cases.json")
DEFAULT_RUN_CODE_IMAGES_DIR = Path("tests/fixtures/run_code")


def evaluate(cases_path: Path, images_dir: Path, model_config: Path | None = None) -> dict[str, object]:
cases = json.loads(cases_path.read_text(encoding="utf-8"))
context = create_context()
Expand Down Expand Up @@ -76,18 +80,28 @@ def main() -> None:
parser = ArgumentParser(description="Evaluate OCRKit against the checked-in challenge fixtures.")
parser.add_argument("--cases", type=Path, default=Path("datasets/fixtures/challenge/cases.json"))
parser.add_argument("--images-dir", type=Path, default=Path("datasets/fixtures/challenge"))
parser.add_argument("--run-code-cases", type=Path, default=DEFAULT_RUN_CODE_CASES)
parser.add_argument("--run-code-images-dir", type=Path, default=DEFAULT_RUN_CODE_IMAGES_DIR)
parser.add_argument("--model-config", type=Path)
parser.add_argument("--report", type=Path)
parser.add_argument("--min-field-accuracy", type=float)
parser.add_argument("--min-run-code-accuracy", type=float, default=1.0)
args = parser.parse_args()
result = evaluate(args.cases, args.images_dir, args.model_config)
run_code_result = evaluate(args.run_code_cases, args.run_code_images_dir, args.model_config)
result["run_code"] = run_code_result
if args.report is not None:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if args.min_field_accuracy is not None and result["field_accuracy"] < args.min_field_accuracy:
raise SystemExit(
f"fixture field accuracy {result['field_accuracy']:.6f} is below {args.min_field_accuracy:.6f}"
)
if args.min_run_code_accuracy is not None and run_code_result["field_accuracy"] < args.min_run_code_accuracy:
raise SystemExit(
"run-code fixture exact-match accuracy "
f"{run_code_result['field_accuracy']:.6f} is below {args.min_run_code_accuracy:.6f}"
)
print(json.dumps(result, ensure_ascii=False, indent=2))


Expand Down
50 changes: 50 additions & 0 deletions tests/fixtures/run_code/cases.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
[
{
"id": "clean_1280",
"image": "run_code_clean_1280.png",
"notes": "Synthetic 1280x720 settlement-panel fixture with surrounding numeric statistics.",
"expected": {"run_code": "4821-7354-1926"}
},
{
"id": "high_res_2560",
"image": "run_code_high_res_2560.png",
"notes": "Synthetic 2560x1440 source normalized into the standard layout.",
"expected": {"run_code": "7246-3815-9472"}
},
{
"id": "compressed",
"image": "run_code_compressed.jpg",
"notes": "Synthetic JPEG with mild compression artifacts.",
"expected": {"run_code": "1642-7395-8206"}
},
{
"id": "scaled_1600",
"image": "run_code_scaled_1600.png",
"notes": "Synthetic 1600x900 source with a slightly different screenshot scale.",
"expected": {"run_code": "9134-2687-5410"}
},
{
"id": "missing",
"image": "run_code_missing_1280.png",
"notes": "Synthetic supported legacy-style panel without a run-code field.",
"expected": {"run_code": null}
},
{
"id": "cropped",
"image": "run_code_cropped_1200.png",
"notes": "Synthetic cropped source without the expected run-code region.",
"expected": {"run_code": null}
},
{
"id": "ambiguous",
"image": "run_code_ambiguous_1280.png",
"notes": "Synthetic conflicting candidates; neither must be accepted.",
"expected": {"run_code": null}
},
{
"id": "malformed",
"image": "run_code_malformed_1280.png",
"notes": "Synthetic labelled value with a short final group; it must not be repaired.",
"expected": {"run_code": null}
}
]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/fixtures/run_code/run_code_clean_1280.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/fixtures/run_code/run_code_compressed.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/fixtures/run_code/run_code_cropped_1200.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/fixtures/run_code/run_code_missing_1280.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/fixtures/run_code/run_code_scaled_1600.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading