diff --git a/README.md b/README.md index cb91df1..cf17ef3 100644 --- a/README.md +++ b/README.md @@ -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": { @@ -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": [] } } @@ -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 diff --git a/app/core/context.py b/app/core/context.py index 4605e49..06667ec 100644 --- a/app/core/context.py +++ b/app/core/context.py @@ -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, ...] = () diff --git a/app/core/roi_config.py b/app/core/roi_config.py index 1f04766..a78fb5d 100644 --- a/app/core/roi_config.py +++ b/app/core/roi_config.py @@ -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: @@ -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")), ) diff --git a/app/image/preprocess.py b/app/image/preprocess.py index 5be9df4..f199eef 100644 --- a/app/image/preprocess.py +++ b/app/image/preprocess.py @@ -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) @@ -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": diff --git a/app/parser/result_merger.py b/app/parser/result_merger.py index e46811f..d2c4497 100644 --- a/app/parser/result_merger.py +++ b/app/parser/result_merger.py @@ -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 @@ -34,4 +35,5 @@ def merge_result( map_variant=right.map_variant, difficulty=right.difficulty, version=right.version, + run_code=run_code, ) diff --git a/app/parser/run_code.py b/app/parser/run_code.py new file mode 100644 index 0000000..333fd14 --- /dev/null +++ b/app/parser/run_code.py @@ -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(?P{_GROUP})\s*(?P{_SEPARATOR})\s*" + rf"(?P{_GROUP})\s*(?P{_SEPARATOR})\s*(?P{_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 diff --git a/app/schemas/response.py b/app/schemas/response.py index 2c5f128..c14520b 100644 --- a/app/schemas/response.py +++ b/app/schemas/response.py @@ -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): diff --git a/app/service.py b/app/service.py index 78d76f6..3548d06 100644 --- a/app/service.py +++ b/app/service.py @@ -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 @@ -32,6 +33,7 @@ "map_variant": ("right_panel",), "difficulty": ("right_panel",), "version": ("right_panel",), + "run_code": ("run_code_panel",), } @@ -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( @@ -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( @@ -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"]) @@ -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( @@ -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"], diff --git a/configs/roi_1280x720.manifest.json b/configs/roi_1280x720.manifest.json index 15df4b7..0c90484 100644 --- a/configs/roi_1280x720.manifest.json +++ b/configs/roi_1280x720.manifest.json @@ -1,6 +1,6 @@ { "schema_version": "1", - "layout_version": "1280x720-v5", + "layout_version": "1280x720-v6", "standard_size": { "width": 1280, "height": 720 @@ -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, diff --git a/configs/roi_1280x720.yaml b/configs/roi_1280x720.yaml index 5348a85..0033ba7 100644 --- a/configs/roi_1280x720.yaml +++ b/configs/roi_1280x720.yaml @@ -1,4 +1,4 @@ -layout_version: 1280x720-v5 +layout_version: 1280x720-v6 standard_size: width: 1280 @@ -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 diff --git a/scripts/batch_eval.py b/scripts/batch_eval.py index a28c044..e5eab6b 100644 --- a/scripts/batch_eval.py +++ b/scripts/batch_eval.py @@ -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() @@ -76,11 +80,16 @@ 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") @@ -88,6 +97,11 @@ def main() -> None: 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)) diff --git a/tests/fixtures/run_code/cases.json b/tests/fixtures/run_code/cases.json new file mode 100644 index 0000000..c45caa3 --- /dev/null +++ b/tests/fixtures/run_code/cases.json @@ -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} + } +] diff --git a/tests/fixtures/run_code/run_code_ambiguous_1280.png b/tests/fixtures/run_code/run_code_ambiguous_1280.png new file mode 100644 index 0000000..c2133cb Binary files /dev/null and b/tests/fixtures/run_code/run_code_ambiguous_1280.png differ diff --git a/tests/fixtures/run_code/run_code_clean_1280.png b/tests/fixtures/run_code/run_code_clean_1280.png new file mode 100644 index 0000000..4df20d9 Binary files /dev/null and b/tests/fixtures/run_code/run_code_clean_1280.png differ diff --git a/tests/fixtures/run_code/run_code_compressed.jpg b/tests/fixtures/run_code/run_code_compressed.jpg new file mode 100644 index 0000000..0d626ff Binary files /dev/null and b/tests/fixtures/run_code/run_code_compressed.jpg differ diff --git a/tests/fixtures/run_code/run_code_cropped_1200.png b/tests/fixtures/run_code/run_code_cropped_1200.png new file mode 100644 index 0000000..55602f7 Binary files /dev/null and b/tests/fixtures/run_code/run_code_cropped_1200.png differ diff --git a/tests/fixtures/run_code/run_code_high_res_2560.png b/tests/fixtures/run_code/run_code_high_res_2560.png new file mode 100644 index 0000000..30127c8 Binary files /dev/null and b/tests/fixtures/run_code/run_code_high_res_2560.png differ diff --git a/tests/fixtures/run_code/run_code_malformed_1280.png b/tests/fixtures/run_code/run_code_malformed_1280.png new file mode 100644 index 0000000..5616666 Binary files /dev/null and b/tests/fixtures/run_code/run_code_malformed_1280.png differ diff --git a/tests/fixtures/run_code/run_code_missing_1280.png b/tests/fixtures/run_code/run_code_missing_1280.png new file mode 100644 index 0000000..88fe3e7 Binary files /dev/null and b/tests/fixtures/run_code/run_code_missing_1280.png differ diff --git a/tests/fixtures/run_code/run_code_scaled_1600.png b/tests/fixtures/run_code/run_code_scaled_1600.png new file mode 100644 index 0000000..02b4b1a Binary files /dev/null and b/tests/fixtures/run_code/run_code_scaled_1600.png differ diff --git a/tests/test_api.py b/tests/test_api.py index b0fcc57..2bafcc3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -33,6 +33,31 @@ def recognize(self, image: np.ndarray) -> OcrResult: return OcrResult(text="", confidence=0.5, chunks=[]) +class RunCodeEngine: + def recognize(self, image: np.ndarray) -> OcrResult: + if image.shape[:2] == (190, 660): + return OcrResult(text="本局代码:4821-7354-1926", confidence=0.96, chunks=[]) + return OcrResult(text="", confidence=0.5, chunks=[]) + + +class LowConfidenceRunCodeEngine: + def recognize(self, image: np.ndarray) -> OcrResult: + if image.shape[:2] == (190, 660): + return OcrResult(text="本局代码:4821-7354-1926", confidence=0.89, chunks=[]) + return OcrResult(text="", confidence=0.5, chunks=[]) + + +class AmbiguousRunCodeEngine: + def recognize(self, image: np.ndarray) -> OcrResult: + if image.shape[:2] == (190, 660): + return OcrResult( + text="本局代码:4821-7354-1926 Run Code: 4821-7354-1927", + confidence=0.97, + chunks=[], + ) + return OcrResult(text="", confidence=0.5, chunks=[]) + + class StubObjectStore: def __init__(self, payload: bytes | None = None, err: Exception | None = None) -> None: self.payload = payload @@ -115,6 +140,7 @@ def test_extract_uses_viewer_player_only(monkeypatch) -> None: assert response.data.viewer_player == "viewer-player" assert response.data.map_variant == "classic" assert response.fields["map_variant"].value == "classic" + assert response.fields["run_code"].status == "missing" assert response.warnings == [ "left_panel.hero_progress_missing", "left_panel.deaths_skips_missing", @@ -144,6 +170,70 @@ def test_extract_returns_dedicated_achievement_panel_evidence() -> None: assert response.fields["achievement_panel_text"].source_roi == ["achievement_panel"] +def test_extract_returns_structured_run_code_evidence() -> None: + context = _make_context() + response = extract_structured( + np.zeros((720, 1280, 3), dtype=np.uint8), + context.roi_config, + context.map_names, + context.map_aliases, + RunCodeEngine(), + False, + "request-run-code-1", + "rapidocr", + "builtin", + context.roi_config.version, + ) + + assert response.data.run_code == "4821-7354-1926" + assert response.fields["run_code"].value == "4821-7354-1926" + assert response.fields["run_code"].confidence == 0.96 + assert response.fields["run_code"].source_roi == ["run_code_panel"] + assert response.fields["run_code"].normalization == [] + assert response.fields["run_code"].status == "ok" + + +def test_extract_rejects_low_confidence_run_code() -> None: + context = _make_context() + response = extract_structured( + np.zeros((720, 1280, 3), dtype=np.uint8), + context.roi_config, + context.map_names, + context.map_aliases, + LowConfidenceRunCodeEngine(), + False, + "request-run-code-low-confidence-1", + "rapidocr", + "builtin", + context.roi_config.version, + ) + + assert response.data.run_code is None + assert response.fields["run_code"].confidence == 0.89 + assert response.fields["run_code"].status == "low_confidence" + assert "run_code.low_confidence" in response.warnings + + +def test_extract_marks_conflicting_run_code_candidates_ambiguous() -> None: + context = _make_context() + response = extract_structured( + np.zeros((720, 1280, 3), dtype=np.uint8), + context.roi_config, + context.map_names, + context.map_aliases, + AmbiguousRunCodeEngine(), + False, + "request-run-code-ambiguous-1", + "rapidocr", + "builtin", + context.roi_config.version, + ) + + assert response.data.run_code is None + assert response.fields["run_code"].status == "ambiguous" + assert "run_code.ambiguous" in response.warnings + + def test_health() -> None: client = TestClient(app) res = client.get("/health") @@ -197,7 +287,7 @@ def test_extract_ok_with_debug() -> None: assert payload["request_id"] == "request-upload-1" assert payload["engine"] == "rapidocr" assert payload["model_version"] == "builtin" - assert payload["layout_version"] == "1280x720-v5" + assert payload["layout_version"] == "1280x720-v6" assert payload["quality"] == { "original_size": [1, 1], "aspect_ratio": 1.0, @@ -205,7 +295,7 @@ def test_extract_ok_with_debug() -> None: "cropped": True, "blur_score": 1.0, "normalized_size": [1280, 720], - "layout_version": "1280x720-v5", + "layout_version": "1280x720-v6", "warnings": payload["warnings"], } assert payload["fields"]["viewer_player"]["status"] == "missing" @@ -232,7 +322,7 @@ def test_by_object_ok_with_debug() -> None: assert payload["schema_version"] == "1" assert payload["engine"] == "rapidocr" assert payload["model_version"] == "builtin" - assert payload["layout_version"] == "1280x720-v5" + assert payload["layout_version"] == "1280x720-v6" assert set(payload["fields"]) == { "challenge_completed", "heroes_completed", @@ -250,6 +340,7 @@ def test_by_object_ok_with_debug() -> None: "map_variant", "difficulty", "version", + "run_code", } assert payload["debug"] is not None assert object_store.last_bucket == "owbastion-codes-evidence" diff --git a/tests/test_batch_eval.py b/tests/test_batch_eval.py index 9a28d53..05bdbcb 100644 --- a/tests/test_batch_eval.py +++ b/tests/test_batch_eval.py @@ -86,3 +86,17 @@ def test_main_writes_report_before_rejecting_gate(monkeypatch: pytest.MonkeyPatc batch_eval.main() assert json.loads(report.read_text(encoding="utf-8"))["matched_fields"] == 9 + + +def test_main_rejects_run_code_fixture_accuracy_below_gate(monkeypatch: pytest.MonkeyPatch) -> None: + results = iter( + [ + {"field_accuracy": 1.0, "matched_fields": 10, "total_fields": 10}, + {"field_accuracy": 0.5, "matched_fields": 1, "total_fields": 2}, + ] + ) + monkeypatch.setattr(batch_eval, "evaluate", lambda *_args: next(results)) + monkeypatch.setattr(sys, "argv", ["batch_eval.py", "--min-run-code-accuracy", "1.0"]) + + with pytest.raises(SystemExit, match="run-code fixture exact-match accuracy"): + batch_eval.main() diff --git a/tests/test_parser_run_code.py b/tests/test_parser_run_code.py new file mode 100644 index 0000000..7dd012c --- /dev/null +++ b/tests/test_parser_run_code.py @@ -0,0 +1,46 @@ +from app.parser.run_code import RUN_CODE_MIN_CONFIDENCE, enforce_run_code_confidence, parse_run_code + + +def test_parse_run_code_normalizes_safe_separator_and_whitespace_variants() -> None: + parsed = parse_run_code("本局代码:4821 - 7354 — 1926") + + assert parsed.value == "4821-7354-1926" + assert parsed.status == "ok" + assert parsed.normalization == ("separator:canonical-hyphen", "whitespace:trimmed") + + +def test_parse_run_code_requires_the_visible_label_and_complete_numeric_groups() -> None: + no_label = parse_run_code( + "版本 26.0613.3 总计耗时 2小时20分38秒 总计阵亡/跳过 114/0 增益/减益/总计 29/49/106 4821-7354-1926" + ) + malformed = parse_run_code("本局代码:4821-7354-192") + leading_zero = parse_run_code("Run Code: 0821-7354-1926") + merged = parse_run_code("本局代码:482173541926") + + assert no_label.status == "missing" + assert malformed.status == "invalid" + assert leading_zero.status == "invalid" + assert merged.status == "invalid" + + +def test_parse_run_code_marks_conflicting_candidates_ambiguous() -> None: + parsed = parse_run_code("本局代码:4821-7354-1926 Run Code: 4821-7354-1927") + + assert parsed.value is None + assert parsed.status == "ambiguous" + assert parsed.warning == "run_code.ambiguous" + + +def test_parse_run_code_accepts_a_repeated_identical_candidate() -> None: + parsed = parse_run_code("本局代码:4821-7354-1926 Run Code: 4821-7354-1926") + + assert parsed.value == "4821-7354-1926" + assert parsed.status == "ok" + + +def test_enforce_run_code_confidence_rejects_below_threshold_without_guessing() -> None: + parsed = enforce_run_code_confidence(parse_run_code("本局代码:4821-7354-1926"), RUN_CODE_MIN_CONFIDENCE - 0.01) + + assert parsed.value is None + assert parsed.status == "low_confidence" + assert parsed.warning == "run_code.low_confidence" diff --git a/tests/test_result_merger.py b/tests/test_result_merger.py index a158398..994549f 100644 --- a/tests/test_result_merger.py +++ b/tests/test_result_merger.py @@ -164,3 +164,15 @@ def test_merge_result_uses_viewer_player_without_center_player() -> None: out = merge_result(center, left, BottomLeftHero(player="训犬大师"), right) assert out.viewer_player == "训犬大师" + + +def test_merge_result_keeps_run_code_as_optional_evidence() -> None: + out = merge_result( + CenterSummary(True, None, None, None, None), + LeftPanel(None, None, None, None, None, None, None), + BottomLeftHero(player=None), + RightPanel(None, None, None), + run_code="4821-7354-1926", + ) + + assert out.run_code == "4821-7354-1926" diff --git a/tests/test_run_code_fixtures.py b/tests/test_run_code_fixtures.py new file mode 100644 index 0000000..57cde0d --- /dev/null +++ b/tests/test_run_code_fixtures.py @@ -0,0 +1,22 @@ +import json +from pathlib import Path + + +def test_run_code_fixture_set_covers_supported_and_conservative_paths() -> None: + fixture_dir = Path(__file__).parent / "fixtures" / "run_code" + cases = json.loads((fixture_dir / "cases.json").read_text(encoding="utf-8")) + cases_by_id = {case["id"]: case for case in cases} + + assert set(cases_by_id) == { + "clean_1280", + "high_res_2560", + "compressed", + "scaled_1600", + "missing", + "cropped", + "ambiguous", + "malformed", + } + assert cases_by_id["clean_1280"]["expected"]["run_code"] == "4821-7354-1926" + assert cases_by_id["high_res_2560"]["expected"]["run_code"] == "7246-3815-9472" + assert all((fixture_dir / case["image"]).is_file() for case in cases) diff --git a/training/README.md b/training/README.md index 9bb6de4..3a0196a 100644 --- a/training/README.md +++ b/training/README.md @@ -158,6 +158,11 @@ The screenshots in `datasets/fixtures/challenge` are the service regression set. They are not automatically training data and must not be replaced by production evidence without an approved private-dataset change. +`tests/fixtures/run_code` contains synthetic, non-player settlement-layout +fixtures for the run-code field. The standard batch evaluation reports these +separately and requires an exact-match run-code result, including missing, +malformed, ambiguous, compressed, and scaled cases. + ## Standalone recognition candidate workflow This is the script equivalent of the Studio candidate step. It currently