from __future__ import annotations import json import tempfile import unittest from pathlib import Path from agents.semantic_evaluator import SemanticEvaluator from pipelines.evaluate_semantic_predictions import run_semantic_evaluation from pipelines.prepare_validation_reference import run_validation_reference_build from pipelines.run_validation_inference import ValidationInferencePipeline class SemanticEvaluatorTest(unittest.TestCase): def test_evaluate_predictions_matches_targets_and_reports_metrics(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) reference_path = temp_path / "reference.jsonl" prediction_path = temp_path / "prediction.jsonl" reference_rows = [ { "image_path": "/tmp/img_a.jpg", "annotations": [ { "bbox_1000": [100, 100, 200, 200], "maturity_level": "未成熟", "maturity_ratio": 0.1, "occlusion_degree": "无", "reasoning": "ref1", }, { "bbox_1000": [300, 300, 400, 400], "maturity_level": "完熟", "maturity_ratio": 0.9, "occlusion_degree": "轻度", "reasoning": "ref2", }, ], } ] prediction_rows = [ { "image_path": "/workspace/img_a.jpg", "annotations": [ { "bbox_1000": [102, 102, 198, 198], "maturity_level": "未成熟", "maturity_ratio": 0.12, "occlusion_degree": "无", "reasoning": "pred1", }, { "bbox_1000": [302, 302, 398, 398], "maturity_level": "半成熟", "maturity_ratio": 0.5, "occlusion_degree": "轻度", "reasoning": "pred2", }, { "bbox_1000": [700, 700, 800, 800], "maturity_level": "未成熟", "maturity_ratio": 0.1, "occlusion_degree": "无", "reasoning": "extra", }, ], } ] reference_path.write_text("\n".join(json.dumps(row, ensure_ascii=False) for row in reference_rows) + "\n", encoding="utf-8") prediction_path.write_text("\n".join(json.dumps(row, ensure_ascii=False) for row in prediction_rows) + "\n", encoding="utf-8") evaluator = SemanticEvaluator(config={"match_iou_threshold": 0.5, "risk_top_k": 5, "bbox_coordinate_space": "bbox_1000"}) result = evaluator.evaluate_predictions( reference_dataset_path=str(reference_path), prediction_dataset_path=str(prediction_path), ) self.assertEqual(result["summary"]["image_count"], 1) self.assertEqual(result["summary"]["reference_annotations_total"], 2) self.assertEqual(result["summary"]["prediction_annotations_total"], 3) self.assertEqual(result["summary"]["matched_annotations_total"], 2) self.assertAlmostEqual(result["summary"]["precision"], 0.6667, places=4) self.assertAlmostEqual(result["summary"]["recall"], 1.0, places=4) self.assertAlmostEqual(result["summary"]["f1"], 0.8, places=4) self.assertAlmostEqual(result["summary"]["maturity_accuracy_on_matched"], 0.5, places=4) per_image = result["per_image_results"][0] self.assertEqual(per_image["image_key"], "img_a.jpg") self.assertEqual(per_image["matched_count"], 2) self.assertEqual(per_image["unmatched_prediction_count"], 1) self.assertAlmostEqual(per_image["f1"], 0.8, places=4) def test_evaluator_can_compare_pixel_predictions_against_bbox_1000_reference(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) reference_path = temp_path / "reference.jsonl" prediction_path = temp_path / "prediction.jsonl" reference_rows = [ { "image_path": "/tmp/img_c.jpg", "annotations": [ { "bbox_1000": [100, 100, 300, 300], "image_width": 1000, "image_height": 500, } ], } ] prediction_rows = [ { "image_path": "/tmp/img_c.jpg", "annotations": [ { "bbox": [100, 50, 300, 150], "image_width": 1000, "image_height": 500, } ], } ] reference_path.write_text(json.dumps(reference_rows[0], ensure_ascii=False) + "\n", encoding="utf-8") prediction_path.write_text(json.dumps(prediction_rows[0], ensure_ascii=False) + "\n", encoding="utf-8") evaluator = SemanticEvaluator(config={"bbox_coordinate_space": "bbox_1000"}) result = evaluator.evaluate_predictions(str(reference_path), str(prediction_path)) self.assertEqual(result["summary"]["matched_annotations_total"], 1) self.assertAlmostEqual(result["summary"]["precision"], 1.0, places=4) self.assertAlmostEqual(result["summary"]["recall"], 1.0, places=4) def test_pipeline_writes_summary_and_jsonl_outputs(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) reference_path = temp_path / "reference.jsonl" prediction_path = temp_path / "prediction.jsonl" output_dir = temp_path / "semantic_eval" reference_gold_row = { "image_path": "/tmp/img_b.jpg", "source_records": [ { "bbox": [40, 50, 80, 100], "bbox_1000": [100, 100, 200, 200], "image_width": 400, "image_height": 500, "maturity_level": "未成熟", "maturity_ratio": 0.1, "occlusion_degree": "无", "reasoning": "gold", } ], "messages": [ {"role": "assistant", "content": json.dumps({"annotations": [{"bbox": [100, 100, 200, 200]}]}, ensure_ascii=False)} ], } prediction_row = { "image_path": "/tmp/img_b.jpg", "annotations": [ { "bbox_1000": [100, 100, 200, 200], "maturity_level": "未成熟", "maturity_ratio": 0.1, "occlusion_degree": "无", "reasoning": "pred", } ], } reference_path.write_text(json.dumps(reference_gold_row, ensure_ascii=False) + "\n", encoding="utf-8") prediction_path.write_text(json.dumps(prediction_row, ensure_ascii=False) + "\n", encoding="utf-8") summary = run_semantic_evaluation( reference_dataset_path=reference_path, prediction_dataset_path=prediction_path, output_dir=output_dir, bbox_coordinate_space="bbox_1000", ) self.assertTrue((output_dir / "summary.json").exists()) self.assertTrue((output_dir / "report.md").exists()) self.assertTrue((output_dir / "per_image_results.jsonl").exists()) self.assertTrue((output_dir / "risk_cases.jsonl").exists()) self.assertEqual(summary["matched_annotations_total"], 1) self.assertEqual(summary["bbox_coordinate_space"], "bbox_1000") def test_validation_reference_builder_keeps_only_val_images(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) gold_path = temp_path / "gold.jsonl" val_path = temp_path / "val.jsonl" output_dir = temp_path / "reference" gold_rows = [ {"image_path": "/tmp/a.jpg", "source_records": []}, {"image_path": "/tmp/b.jpg", "source_records": []}, ] val_rows = [ {"images": ["/tmp/b.jpg"], "metadata": {"image_path": "/tmp/b.jpg"}}, ] gold_path.write_text("\n".join(json.dumps(row, ensure_ascii=False) for row in gold_rows) + "\n", encoding="utf-8") val_path.write_text(json.dumps(val_rows[0], ensure_ascii=False) + "\n", encoding="utf-8") summary = run_validation_reference_build(gold_path, val_path, output_dir) subset_lines = (output_dir / "reference_subset.jsonl").read_text(encoding="utf-8").splitlines() self.assertEqual(summary["matched_reference_rows"], 1) self.assertEqual(len(subset_lines), 1) self.assertIn("/tmp/b.jpg", subset_lines[0]) def test_validation_inference_parser_converts_bbox_1000_to_pixel(self) -> None: pipeline = ValidationInferencePipeline() annotations, error = pipeline._parse_prediction_annotations( raw_text='[{"bbox_2d": [100, 200, 300, 400], "label": "未成熟番茄"}]', image_width=1000, image_height=500, ) self.assertIsNone(error) self.assertEqual(len(annotations), 1) self.assertEqual(annotations[0]["bbox_1000"], [100.0, 200.0, 300.0, 400.0]) self.assertEqual(annotations[0]["bbox"], [100.0, 100.0, 300.0, 200.0]) self.assertEqual(annotations[0]["maturity_level"], "未成熟") def test_validation_inference_parser_accepts_string_bbox_inside_think(self) -> None: pipeline = ValidationInferencePipeline() raw_text = ( '\n' '[{"bbox_2d": "[290, 526, 380, 754]", "label": "未成熟番茄"}]\n' '\n\n' '[{"bbox_2d": "[290, 526, 380, 754]", "label": "未成熟番茄"}]' ) annotations, error = pipeline._parse_prediction_annotations( raw_text=raw_text, image_width=4480, image_height=2016, ) self.assertIsNone(error) self.assertEqual(len(annotations), 1) self.assertEqual(annotations[0]["bbox_1000"], [290.0, 526.0, 380.0, 754.0]) self.assertEqual(annotations[0]["maturity_level"], "未成熟") def test_validation_inference_parser_ignores_bbox_lists_in_reasoning(self) -> None: pipeline = ValidationInferencePipeline() raw_text = ( '\n' '候选目标位于 [290, 526, 380, 754],另一个位于 [452, 703, 544, 926]。\n' '\n\n' '[{"bbox_2d": "[452, 703, 544, 926]", "label": "未成熟番茄", ' '"attribute_text": "无遮挡,局部可见绿色"}]' ) annotations, error = pipeline._parse_prediction_annotations( raw_text=raw_text, image_width=4480, image_height=2016, ) self.assertIsNone(error) self.assertEqual(len(annotations), 1) self.assertEqual(annotations[0]["bbox_1000"], [452.0, 703.0, 544.0, 926.0]) self.assertEqual(annotations[0]["occlusion_degree"], "无") def test_validation_inference_request_keeps_output_schema_as_user_constraint(self) -> None: class DummyInferRequest: def __init__(self, messages: list[dict[str, str]], images: list[str]) -> None: self.messages = messages self.images = images pipeline = ValidationInferencePipeline() row = { "messages": [ {"role": "user", "content": "找到图像中的番茄。"}, { "role": "assistant", "content": '[{"bbox_2d": "", "label": "", "attribute_text": "无遮挡,局部可见绿色"}]', }, ], "images": ["/tmp/demo.jpg"], } request = pipeline._build_infer_request(row, DummyInferRequest) self.assertEqual(len(request.messages), 2) self.assertEqual(request.messages[0]["role"], "user") self.assertEqual(request.messages[0]["content"], "找到图像中的番茄。") self.assertEqual(request.messages[1]["role"], "user") self.assertIn("严格按照下面的 JSON 模板输出最终答案", request.messages[1]["content"]) self.assertIn('"attribute_text"', request.messages[1]["content"]) self.assertEqual(request.images, ["/tmp/demo.jpg"]) def test_validation_inference_can_override_output_template_for_ablation(self) -> None: pipeline = ValidationInferencePipeline(output_template_mode="official") prompt = pipeline._build_output_format_prompt( '[{"bbox_2d": "", "label": "", "attribute_text": "无遮挡,局部可见绿色"}]' ) self.assertIn('"bbox_2d"', prompt) self.assertIn('"label"', prompt) self.assertNotIn("attribute_text", prompt) def test_validation_inference_parser_accepts_structured_occlusion_field(self) -> None: pipeline = ValidationInferencePipeline() annotations, parse_error = pipeline._parse_prediction_annotations( raw_text='[{"bbox_2d": "[100, 200, 300, 400]", "label": "未成熟番茄", ' '"occlusion_degree": "轻度", "visible_color": "绿色"}]', image_width=1000, image_height=1000, ) self.assertIsNone(parse_error) self.assertEqual(annotations[0]["occlusion_degree"], "轻度") self.assertEqual(annotations[0]["visible_color"], "绿色") if __name__ == "__main__": unittest.main()