Files
laya-opencv/tests/test_numeric.py
T
2026-10-05 18:24:57 +08:00

246 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""数值层的测试。只用标准库:python -m unittest discover -s tests"""
import glob
import json
import os
import sys
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "app"))
sys.path.insert(0, os.path.join(ROOT, "tests"))
import numeric as N # noqa: E402
import mock_laya # noqa: E402
RECIPE = {
"subject": "番茄",
"measurements": {
"ripe": {"label": "成熟色占比", "unit": "%", "decimals": 1, "guard": 2,
"bands": [{"max": 30, "label": "未熟"}, {"max": 70, "label": "转色"}, {"label": "成熟"}]},
"spot": {"label": "病斑占比", "unit": "%", "decimals": 1},
"dia": {"label": "果径", "unit": "mm", "decimals": 0, "range": [0, 150]},
"kind": {"label": "识别类别"},
},
"rules": {
"pick": {"label": "可采摘", "when": {"all": [{"m": "ripe", "op": ">=", "value": 70}, {"m": "spot", "op": "<=", "value": 3},
{"m": "dia", "op": ">=", "value": 50}]}},
"grade": {"label": "等级", "cases": [
{"value": "A", "when": {"all": [{"m": "ripe", "op": ">=", "value": 85}, {"m": "spot", "op": "<=", "value": 1}]}},
{"value": "B", "when": {"m": "ripe", "op": ">=", "value": 70}},
{"value": "C"}]},
},
"questions": {
"pick": {"type": "noul", "instructions": "是否可以采摘?"},
"grade": {"type": "choice", "instructions": "等级?", "criteria": {"A": "", "B": "", "C": ""}},
"free": {"type": "noul", "instructions": "外观是否适合鲜食?"},
},
"bindings": {"pick": {"rule": "pick"}, "grade": {"rule": "grade", "mode": "advise"}},
}
def shipped_recipes():
out = []
for lang in ("zh", "en"):
for path in sorted(glob.glob(os.path.join(ROOT, "recipes", lang, "*.json"))):
with open(path, encoding="utf-8") as handle:
out.append((lang, os.path.basename(path), json.load(handle)))
return out
class CompareTest(unittest.TestCase):
def test_operators(self):
for op, value, expected in ((">=", 70, True), (">", 70, False), ("<=", 70, True), ("<", 70, False), ("==", 70, True), ("!=", 70, False)):
got = N.compare(RECIPE, {"m": "spot", "op": op, "value": 70}, {"spot": 70})
self.assertEqual(got["ok"], expected, op)
self.assertTrue(N.compare(RECIPE, {"m": "spot", "op": "between", "value": [1, 3]}, {"spot": 2})["ok"])
self.assertFalse(N.compare(RECIPE, {"m": "spot", "op": "between", "value": [1, 3]}, {"spot": 3.1})["ok"])
self.assertTrue(N.compare(RECIPE, {"m": "kind", "op": "in", "value": ["ripe", "turning"]}, {"kind": "Ripe"})["ok"])
self.assertIsNone(N.compare(RECIPE, {"m": "spot", "op": ">=", "value": 1}, {})["ok"])
def test_guard_band(self):
self.assertTrue(N.compare(RECIPE, {"m": "ripe", "op": ">=", "value": 70}, {"ripe": 71.5})["edge"])
self.assertTrue(N.compare(RECIPE, {"m": "ripe", "op": ">=", "value": 70}, {"ripe": 68.2})["edge"])
self.assertFalse(N.compare(RECIPE, {"m": "ripe", "op": ">=", "value": 70}, {"ripe": 75})["edge"])
self.assertFalse(N.compare(RECIPE, {"m": "spot", "op": "<=", "value": 3}, {"spot": 3})["edge"]) # 没设 guard 的测量值没有临界带
def test_rules(self):
good = {"ripe": 90, "spot": 0.5, "dia": 60}
self.assertTrue(N.run_rule(RECIPE, "pick", good)["result"])
self.assertEqual(N.run_rule(RECIPE, "grade", good)["result"], "A")
self.assertEqual(N.run_rule(RECIPE, "grade", dict(good, spot=2))["result"], "B")
self.assertEqual(N.run_rule(RECIPE, "grade", dict(good, ripe=40))["result"], "C")
self.assertFalse(N.run_rule(RECIPE, "pick", dict(good, dia=49))["result"])
edge = N.run_rule(RECIPE, "pick", dict(good, ripe=71))
self.assertTrue(edge["result"])
self.assertTrue(edge["marginal"])
self.assertFalse(N.run_rule(RECIPE, "pick", dict(good, ripe=71, dia=10))["marginal"]) # 果径已经不合格,成熟度临界不影响结论
missing = N.run_rule(RECIPE, "pick", {"ripe": 90, "spot": 1})
self.assertIsNone(missing["result"])
self.assertEqual(missing["missing"], ["dia"])
def test_not_and_any(self):
recipe = {"measurements": {"a": {"guard": 1}, "b": {}},
"rules": {"r": {"when": {"not": {"any": [{"m": "a", "op": ">", "value": 10}, {"m": "b", "op": ">=", "value": 2}]}}}}}
self.assertTrue(N.run_rule(recipe, "r", {"a": 5, "b": 1})["result"])
self.assertFalse(N.run_rule(recipe, "r", {"a": 5, "b": 2})["result"])
self.assertTrue(N.run_rule(recipe, "r", {"a": 9.5, "b": 1})["marginal"])
def test_normalize(self):
values = N.normalize(RECIPE, {"ripe": 69.96, "dia": 54.5, "kind": "ripe", "_sharpness": 12.345})
self.assertEqual(values["ripe"], 70.0)
self.assertEqual(values["kind"], "ripe")
self.assertEqual(values["_sharpness"], 12.345)
self.assertTrue(N.run_rule(RECIPE, "pick", dict(values, spot=0, dia=60))["result"])
class TextTest(unittest.TestCase):
def test_state_mirrors_standard(self):
values = {"ripe": 71.2, "spot": 4.7, "dia": 62}
for lang in ("zh", "en"):
state = N.build_state(RECIPE, values, lang)
block = [v for v in state.values() if isinstance(v, dict)][0]
questions = N.bound_questions(RECIPE, lang)
for rule_id in RECIPE["rules"]:
rule = RECIPE["rules"][rule_id]
nodes = [rule.get("when")] + [c.get("when") for c in rule.get("cases") or []]
for node in nodes:
for leaf in N.leaves(node):
clause = N.threshold_text(RECIPE, leaf["m"], leaf["op"], leaf["value"])
self.assertIn(clause, block[N.label_of(RECIPE, leaf["m"])])
self.assertIn(clause, questions["pick"]["instructions"] + questions["grade"]["instructions"])
self.assertNotIn(N.tx(lang, "standard"), questions["free"]["instructions"])
def test_phrases(self):
text = N.describe(RECIPE, "ripe", 71.2, "zh")
self.assertIn("≥ 70 %:是,高出 1.2,临界", text)
self.assertIn("≥ 85 %:否,低 13.8", text)
self.assertIn("档位:成熟", text)
self.assertEqual(N.describe(RECIPE, "ripe", 71.2, "zh", "raw"), "71.2 %")
self.assertIn("≤ 3 %: no, 1.7 above", N.describe(RECIPE, "spot", 4.7, "en"))
self.assertIn("正好相等", N.describe(RECIPE, "dia", 50, "zh"))
self.assertEqual(N.describe(RECIPE, "kind", "ripe", "zh", detail={"prob": 0.934}), "ripe(置信度 0.93)")
def test_state_skips_unmeasured_optional(self):
state = N.build_state(RECIPE, {"ripe": 80, "spot": 1, "dia": None, "kind": None}, "zh")
block = [v for v in state.values() if isinstance(v, dict)][0]
self.assertNotIn("识别类别", block) # 规则用不到、又没有值:不写
self.assertIn("果径", block) # 规则要用:写明没有测到
class DecideTest(unittest.TestCase):
values = {"ripe": 90, "spot": 0.5, "dia": 60}
def test_agree(self):
out = N.decide(RECIPE, self.values, {"pick": {"noul": 0.95}, "grade": {"choice": "A"}, "free": {"noul": 0.8}})
self.assertEqual(out["verdicts"]["pick"]["source"], "rule")
self.assertTrue(out["verdicts"]["pick"]["agree"])
self.assertEqual(out["verdicts"]["grade"]["source"], "model") # advise:模型为准
self.assertEqual(out["verdicts"]["free"]["source"], "model")
self.assertFalse(out["review"])
def test_conflict_rule_wins(self):
out = N.decide(RECIPE, self.values, {"pick": {"noul": 0.1}, "grade": {"choice": "A"}, "free": {"noul": 0.8}})
item = out["verdicts"]["pick"]
self.assertIs(item["final"], True)
self.assertIs(item["agree"], False)
self.assertTrue(out["review"])
def test_low_confidence_only_matters_for_model_verdicts(self):
answers = {"pick": {"noul": 0.55}, "grade": {"choice": "A", "probabilities": {"A": 0.5, "B": 0.3, "C": 0.2}}, "free": {"noul": 0.9}}
out = N.decide(RECIPE, self.values, answers, min_confidence=0.7)
self.assertFalse(out["verdicts"]["pick"]["review"])
self.assertTrue(out["verdicts"]["grade"]["review"])
def test_model_down(self):
out = N.decide(RECIPE, self.values, None, model_error=True)
self.assertIs(out["verdicts"]["pick"]["final"], True)
self.assertEqual(out["verdicts"]["grade"]["final"], "A") # advise 模式下模型没答:退回规则
self.assertIsNone(out["verdicts"]["free"]["final"])
self.assertTrue(out["review"])
def test_quality_forces_review(self):
quality = N.check_quality({"quality": {"min_sharpness": 50, "brightness": [40, 220]}}, {"_sharpness": 10, "_brightness": 30})
self.assertFalse(quality["ok"])
self.assertEqual(len(quality["problems"]), 2)
out = N.decide(RECIPE, self.values, {"pick": {"noul": 0.95}, "grade": {"choice": "A"}, "free": {"noul": 0.8}}, quality=quality)
self.assertTrue(out["review"])
class ProbeTest(unittest.TestCase):
def test_cases_follow_rules(self):
cases = N.probe_cases(RECIPE, limit=48)
self.assertGreaterEqual(len(cases), 24)
seen = set()
for case in cases:
self.assertEqual(case["gold"]["pick"], N.run_rule(RECIPE, "pick", case["values"])["result"])
self.assertEqual(case["gold"]["grade"], N.run_rule(RECIPE, "grade", case["values"])["result"])
self.assertLessEqual(case["values"]["ripe"], 100)
seen.add((case["gold"]["pick"], case["gold"]["grade"]))
self.assertGreaterEqual(len(seen), 4) # 各种结果都有用例
self.assertTrue(any(c["distance"] is not None and c["distance"] <= 0.01 for c in cases))
def test_scoring(self):
cases = N.probe_cases(RECIPE, limit=24)
perfect = [{"pick": {"noul": 0.9 if c["gold"]["pick"] else 0.1}, "grade": {"choice": c["gold"]["grade"]}} for c in cases]
report = N.score_probe(RECIPE, cases, perfect)
self.assertEqual(report["pick"]["accuracy"], 1.0)
self.assertEqual(report["grade"]["accuracy"], 1.0)
always_yes = [{"pick": {"noul": 0.9}, "grade": {"choice": "A"}} for _ in cases]
report = N.score_probe(RECIPE, cases, always_yes)
self.assertLess(report["pick"]["accuracy"], 1.0)
self.assertTrue(report["pick"]["misses"])
def test_finetune_records(self):
records = N.finetune_records(RECIPE, N.probe_cases(RECIPE, limit=8))
self.assertTrue(records)
self.assertEqual(set(records[0]), {"state", "questions", "gold"})
self.assertEqual(set(records[0]["gold"]), set(records[0]["questions"]))
class ShippedRecipesTest(unittest.TestCase):
def test_valid(self):
recipes = shipped_recipes()
self.assertEqual(len(recipes), 10)
for lang, name, recipe in recipes:
self.assertEqual(N.validate(recipe), [], "%s/%s" % (lang, name))
self.assertTrue(N.probe_cases(recipe, limit=16), "%s/%s has no probe cases" % (lang, name))
def test_same_structure_in_both_languages(self):
by_file = {}
for lang, name, recipe in shipped_recipes():
by_file.setdefault(name, {})[lang] = recipe
for name, pair in by_file.items():
self.assertEqual(json.dumps(pair["zh"]["pipeline"], sort_keys=True), json.dumps(pair["en"]["pipeline"], sort_keys=True), name)
self.assertEqual(list(pair["zh"]["measurements"]), list(pair["en"]["measurements"]), name)
self.assertEqual(list(pair["zh"]["questions"]), list(pair["en"]["questions"]), name)
def test_written_comparisons_are_machine_readable(self):
"""素材里的比较结论和题目里的判定标准必须能逐条对上:用一个只会查字面的假模型来验证。"""
for lang, name, recipe in shipped_recipes():
cases = N.probe_cases(recipe, limit=32)
bound = {q: s for q, s in N.bound_questions(recipe, lang).items() if q in (recipe.get("bindings") or {})}
answers = [mock_laya.predict(N.build_state(recipe, c["values"], lang, "compared"), bound)["answers"] for c in cases]
report = N.score_probe(recipe, cases, answers)
for qid, row in report.items():
self.assertEqual(row["accuracy"], 1.0, "%s/%s %s: %s" % (lang, name, qid, row["misses"][:1]))
raw = [mock_laya.predict(N.build_state(recipe, c["values"], lang, "raw"), bound)["answers"] for c in cases]
worst = min(row["accuracy"] for row in N.score_probe(recipe, cases, raw).values())
self.assertLess(worst, 1.0, "%s/%s: bare numbers should not be readable by a literal matcher" % (lang, name))
class ValidateTest(unittest.TestCase):
def test_catches_mistakes(self):
bad = json.loads(json.dumps(RECIPE))
bad["rules"]["pick"]["when"]["all"][0]["m"] = "nope"
bad["bindings"]["free"] = {"rule": "missing"}
bad["bindings"]["grade"] = {"rule": "pick"}
problems = "\n".join(N.validate(bad))
self.assertIn("unknown measurement 'nope'", problems)
self.assertIn("unknown rule 'missing'", problems)
self.assertIn("returns yes/no", problems)
if __name__ == "__main__":
unittest.main()