Skip to content

Commit 5c5b797

Browse files
committed
feat(mahjong): release lightweight tile classifier v1.3.0
1 parent 23d0261 commit 5c5b797

24 files changed

Lines changed: 1287 additions & 234 deletions

plugin/plugins/mahjong_companion/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## v1.3.0 - 2026-05-17
4+
5+
### Lightweight ONNX tile classifier
6+
7+
- Added a deployed MobileNetV3-Small ONNX tile classifier trained from the public `pjura/mahjong_souls_tiles` Mahjong Soul crop dataset plus local empty/fixture augmentation.
8+
- Added `scripts/prepare_hf_tile_dataset.py` and fixed `scripts/train_tile_classifier.py` CLI args so epochs, batch size, and learning rate are honored during training.
9+
- Updated runtime dispatch so discard/river classification uses ONNX by default with template fallback, while hand-tile ONNX remains opt-in via `MAHJONG_COMPANION_ONNX_HAND_ENABLED=1`.
10+
- Raised the ONNX discard occupancy gate to `0.90`; current release gate result is `346/348`, `P=0.94`, `R=0.99`, `F1=0.97`.
11+
- Reworked ONNX and discard eval scripts around current runtime crop helpers, and tightened the discard pipeline gate to `precision>=0.90`, `recall>=0.95`, `F1>=0.94`.
12+
- Added model provenance metadata and runtime diagnostics for ONNX model availability, label count, model size, hand-ONNX policy, and occupancy threshold.
13+
314
## v1.2.0 - 2026-05-06
415

516
### ONNX 置信度门控 + 批处理重构
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{
2+
"backbone": "mobilenetv3_small",
3+
"timm_name": "mobilenetv3_small_100",
4+
"input_size": 224,
5+
"num_classes": 35,
6+
"class_names": [
7+
"1m",
8+
"2m",
9+
"3m",
10+
"4m",
11+
"5m",
12+
"6m",
13+
"7m",
14+
"8m",
15+
"9m",
16+
"1p",
17+
"2p",
18+
"3p",
19+
"4p",
20+
"5p",
21+
"6p",
22+
"7p",
23+
"8p",
24+
"9p",
25+
"1s",
26+
"2s",
27+
"3s",
28+
"4s",
29+
"5s",
30+
"6s",
31+
"7s",
32+
"8s",
33+
"9s",
34+
"1z",
35+
"2z",
36+
"3z",
37+
"4z",
38+
"5z",
39+
"6z",
40+
"7z",
41+
"empty"
42+
],
43+
"mean": [
44+
0.485,
45+
0.456,
46+
0.406
47+
],
48+
"std": [
49+
0.229,
50+
0.224,
51+
0.225
52+
]
53+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"0": "1m",
3+
"1": "2m",
4+
"2": "3m",
5+
"3": "4m",
6+
"4": "5m",
7+
"5": "6m",
8+
"6": "7m",
9+
"7": "8m",
10+
"8": "9m",
11+
"9": "1p",
12+
"10": "2p",
13+
"11": "3p",
14+
"12": "4p",
15+
"13": "5p",
16+
"14": "6p",
17+
"15": "7p",
18+
"16": "8p",
19+
"17": "9p",
20+
"18": "1s",
21+
"19": "2s",
22+
"20": "3s",
23+
"21": "4s",
24+
"22": "5s",
25+
"23": "6s",
26+
"24": "7s",
27+
"25": "8s",
28+
"26": "9s",
29+
"27": "1z",
30+
"28": "2z",
31+
"29": "3z",
32+
"30": "4z",
33+
"31": "5z",
34+
"32": "6z",
35+
"33": "7z",
36+
"34": "empty"
37+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
1m
2+
2m
3+
3m
4+
4m
5+
5m
6+
6m
7+
7m
8+
8m
9+
9m
10+
1p
11+
2p
12+
3p
13+
4p
14+
5p
15+
6p
16+
7p
17+
8p
18+
9p
19+
1s
20+
2s
21+
3s
22+
4s
23+
5s
24+
6s
25+
7s
26+
8s
27+
9s
28+
1z
29+
2z
30+
3z
31+
4z
32+
5z
33+
6z
34+
7z
35+
empty
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"schema_version": "mahjong-companion-tile-model-v1",
3+
"created_at": "2026-05-17",
4+
"model_type": "mobilenetv3_small",
5+
"timm_name": "mobilenetv3_small_100",
6+
"purpose": "discard_onnx_default_hand_opt_in",
7+
"runtime_policy": {
8+
"discard_tiles": "onnx_default_with_template_fallback",
9+
"hand_tiles": "template_default_onnx_opt_in",
10+
"hand_onnx_env": "MAHJONG_COMPANION_ONNX_HAND_ENABLED",
11+
"discard_occupancy_confidence": 0.9
12+
},
13+
"training": {
14+
"source_dataset": "pjura/mahjong_souls_tiles",
15+
"source_dataset_license": "Apache-2.0",
16+
"prepared_by": "scripts/prepare_hf_tile_dataset.py",
17+
"train_script": "scripts/train_tile_classifier.py",
18+
"train_output": "tmp/mahjong_tile_model_mobilenetv3_small_aug30",
19+
"augmentation_sources": [
20+
"pjura/mahjong_souls_tiles",
21+
"tests/fixtures/multi_theme"
22+
],
23+
"classes": 35,
24+
"notes": [
25+
"Public Mahjong Soul crops provide 34 tile classes.",
26+
"The empty class is added for occupancy gating.",
27+
"Red fives are handled by runtime color post-processing instead of model labels."
28+
]
29+
},
30+
"validation": {
31+
"onnx_crop_validation": {
32+
"correct": 913,
33+
"total": 929,
34+
"accuracy": 0.9828
35+
},
36+
"aligned_discard_eval": {
37+
"correct": 335,
38+
"total": 348,
39+
"f1": 0.96
40+
},
41+
"discard_pipeline_gate": {
42+
"correct": 346,
43+
"total": 348,
44+
"precision": 0.94,
45+
"recall": 0.99,
46+
"f1": 0.97,
47+
"status": "passed"
48+
},
49+
"hand_crop_eval": {
50+
"f1": 0.48,
51+
"default_enabled": false
52+
},
53+
"gate_thresholds": {
54+
"precision": 0.9,
55+
"recall": 0.95,
56+
"f1": 0.94
57+
}
58+
}
59+
}
Binary file not shown.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"image_mean": [
3+
0.485,
4+
0.456,
5+
0.406
6+
],
7+
"image_std": [
8+
0.229,
9+
0.224,
10+
0.225
11+
],
12+
"size": {
13+
"shortest_edge": 224
14+
},
15+
"do_normalize": true,
16+
"do_resize": true,
17+
"do_rescale": true,
18+
"rescale_factor": 0.00392156862745098
19+
}

plugin/plugins/mahjong_companion/diagnostics.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
import json
44
import os
5+
from importlib.util import find_spec
56
from pathlib import Path
67
from typing import Any
78

89
from utils.logger_config import get_module_logger
910

1011
from .action.action_registry import ActionRegistry
1112
from .perception.calibration import load_calibration_profile
13+
from .perception.discard_parser import ONNX_OCCUPANCY_CONFIDENCE
1214
from .perception.external_discard_recognizer import ENV_COMMAND, ENV_ENDPOINT, ENV_TIMEOUT
15+
from .perception.vit_tile_classifier_onnx import REQUIRED_FILES
1316

1417
logger = get_module_logger(__name__)
1518

@@ -40,6 +43,7 @@ def build_runtime_diagnostics(
4043
_check_data_directories(data_root),
4144
_check_calibration_profiles(data_root / "calibration" / "profiles"),
4245
_check_button_templates(plugin_dir / "perception" / "templates"),
46+
_check_onnx_tile_classifier(data_root),
4347
_check_external_discard_recognizer(),
4448
_check_runtime_config(config),
4549
_check_recent_status(status),
@@ -59,6 +63,65 @@ def build_runtime_diagnostics(
5963
}
6064

6165

66+
def _check_onnx_tile_classifier(data_root: Path) -> dict[str, Any]:
67+
env_dir = os.environ.get("MAHJONG_COMPANION_VIT_ONNX_DIR", "").strip()
68+
model_dir = Path(env_dir).expanduser().resolve() if env_dir else (
69+
data_root / "models" / "vit_tile_classifier"
70+
)
71+
required_paths = {filename: model_dir / filename for filename in REQUIRED_FILES}
72+
missing_required = [
73+
filename for filename, path in required_paths.items()
74+
if not path.is_file()
75+
]
76+
optional_files = ("labels.txt", "config.json", "metadata.json")
77+
optional_present = [
78+
filename for filename in optional_files
79+
if (model_dir / filename).is_file()
80+
]
81+
labels_payload = _read_json_dict(model_dir / "labels.json")
82+
metadata = _read_json_dict(model_dir / "metadata.json")
83+
labels_count = len(labels_payload)
84+
model_path = model_dir / "model.onnx"
85+
model_size_mb = (
86+
round(model_path.stat().st_size / (1024 * 1024), 2)
87+
if model_path.is_file()
88+
else None
89+
)
90+
issues = []
91+
if missing_required:
92+
severity = "warning" if model_dir.exists() else "info"
93+
issues.append(_issue(
94+
severity,
95+
"onnx_tile_model_missing",
96+
"ONNX tile classifier artifacts are missing; runtime will fall back to templates.",
97+
{"model_dir": str(model_dir), "missing": missing_required},
98+
))
99+
elif not labels_payload:
100+
issues.append(_issue(
101+
"warning",
102+
"onnx_tile_labels_unreadable",
103+
"ONNX tile classifier labels.json is missing or unreadable.",
104+
{"labels_path": str(model_dir / "labels.json")},
105+
))
106+
return {
107+
"check_id": "onnx_tile_classifier",
108+
"ok": not missing_required and bool(labels_payload),
109+
"available": not missing_required,
110+
"model_dir": str(model_dir),
111+
"env_override": bool(env_dir),
112+
"required_files": list(REQUIRED_FILES),
113+
"missing_required": missing_required,
114+
"optional_present": optional_present,
115+
"model_size_mb": model_size_mb,
116+
"labels_count": labels_count,
117+
"onnxruntime_installed": find_spec("onnxruntime") is not None,
118+
"hand_onnx_enabled": _env_truthy("MAHJONG_COMPANION_ONNX_HAND_ENABLED"),
119+
"discard_occupancy_confidence": ONNX_OCCUPANCY_CONFIDENCE,
120+
"metadata": metadata,
121+
"issues": issues,
122+
}
123+
124+
62125
def _check_data_directories(data_root: Path) -> dict[str, Any]:
63126
required = {
64127
"session_cache": data_root / "session_cache",
@@ -388,6 +451,10 @@ def _source_sample_count(payload: dict[str, Any]) -> int:
388451
return total
389452

390453

454+
def _env_truthy(name: str) -> bool:
455+
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
456+
457+
391458
def _read_json_dict(path: Path) -> dict[str, Any]:
392459
try:
393460
payload = json.loads(path.read_text(encoding="utf-8"))

plugin/plugins/mahjong_companion/meld_selection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from .perception.calibration import resolve_calibration_profile
1616
from .perception.hand_layout import TileSlot, build_hand_layout
1717
from .perception.tile_parser import _collect_slot_metrics
18-
from .perception.tile_classifier_dispatch import classify_tile
18+
from .perception.tile_classifier_dispatch import classify_hand_tile
1919
from .perception.tile_templates import is_probably_occupied_hand_slot
2020
from .session_state import now_iso
2121
from .state_transitions import _image_region_signature
@@ -87,7 +87,7 @@ def _maybe_emit_fast_meld_selection_locked(self, frame_path: Path) -> bool:
8787
for entry in highlighted_slots:
8888
slot = entry["slot"]
8989
crop = image.crop((slot.box.left, slot.box.top, slot.box.right, slot.box.bottom))
90-
match = classify_tile(crop, template_payload)
90+
match = classify_hand_tile(crop, template_payload)
9191
entry["tile"] = _normalize_tile(str(getattr(match, "tile", "") or "").strip())
9292
entry["confidence"] = float(getattr(match, "confidence", 0.0) or 0.0)
9393

plugin/plugins/mahjong_companion/perception/discard_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919

2020
DEFAULT_MIN_DISCARD_CONFIDENCE = 0.55
21-
ONNX_OCCUPANCY_CONFIDENCE = 0.55
21+
ONNX_OCCUPANCY_CONFIDENCE = 0.90
2222
AMBIGUOUS_DISCARD_TEMPLATE_PAIRS = {
2323
frozenset({"5p", "6p"}),
2424
frozenset({"6p", "7p"}),

0 commit comments

Comments
 (0)