| from __future__ import annotations |
|
|
| import argparse |
| import json |
| from collections import Counter, defaultdict |
| from datetime import datetime |
| from pathlib import Path |
| from typing import Any |
|
|
|
|
| PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| DEFAULT_REFERENCE_PATH = ( |
| PROJECT_ROOT |
| / "runs/validation_eval/qwen3_5_35b_a3b_exp_a_official/reference/reference_subset.jsonl" |
| ) |
| DEFAULT_PATCH_PATH = ( |
| PROJECT_ROOT |
| / "runs/analysis/missing_label_candidates_20260524/review_export/accepted_missing_label_patch.jsonl" |
| ) |
| DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "runs/gold/gold_release_v1_1_reviewed_val" |
|
|
|
|
| def main() -> None: |
| """把人工接受的漏标 patch 应用到验证 reference 副本。""" |
| parser = argparse.ArgumentParser(description="生成补漏标后的验证 reference。") |
| parser.add_argument("--reference-path", default=str(DEFAULT_REFERENCE_PATH)) |
| parser.add_argument("--patch-path", default=str(DEFAULT_PATCH_PATH)) |
| parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT_DIR)) |
| args = parser.parse_args() |
|
|
| reference_path = Path(args.reference_path).expanduser().resolve() |
| patch_path = Path(args.patch_path).expanduser().resolve() |
| output_dir = Path(args.output_dir).expanduser().resolve() |
| output_dir.mkdir(parents=True, exist_ok=True) |
|
|
| reference_rows = _read_jsonl(reference_path) |
| patch_rows = _read_jsonl(patch_path) |
| patched_rows, applied_rows = _apply_patch(reference_rows=reference_rows, patch_rows=patch_rows) |
|
|
| reference_out = output_dir / "reference_subset_reviewed.jsonl" |
| patch_applied_out = output_dir / "applied_missing_label_patch.jsonl" |
| summary_out = output_dir / "summary.json" |
| report_out = output_dir / "report.md" |
|
|
| _write_jsonl(reference_out, patched_rows) |
| _write_jsonl(patch_applied_out, applied_rows) |
|
|
| summary = _build_summary( |
| reference_path=reference_path, |
| patch_path=patch_path, |
| output_dir=output_dir, |
| reference_out=reference_out, |
| patch_applied_out=patch_applied_out, |
| reference_rows=reference_rows, |
| patched_rows=patched_rows, |
| patch_rows=patch_rows, |
| applied_rows=applied_rows, |
| ) |
| summary_out.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") |
| report_out.write_text(_build_report(summary), encoding="utf-8") |
| print(json.dumps(summary, ensure_ascii=False, indent=2)) |
|
|
|
|
| def _apply_patch( |
| *, |
| reference_rows: list[dict[str, Any]], |
| patch_rows: list[dict[str, Any]], |
| ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: |
| rows_by_image_key = {Path(str(row["image_path"])).name: row for row in reference_rows} |
| patches_by_image_key: defaultdict[str, list[dict[str, Any]]] = defaultdict(list) |
| for patch_row in patch_rows: |
| patch_type = str(patch_row.get("patch_type", "add_missing_label")) |
| if patch_type != "add_missing_label": |
| raise ValueError(f"当前 patch 应用器只允许安全追加 add_missing_label,收到: {patch_type}") |
| patches_by_image_key[str(patch_row["image_key"])].append(patch_row) |
|
|
| applied_rows = [] |
| patched_rows = [] |
| for row in reference_rows: |
| image_key = Path(str(row["image_path"])).name |
| row_copy = json.loads(json.dumps(row, ensure_ascii=False)) |
| source_records = _extract_source_records(row_copy) |
| existing_count = len(source_records) |
| image_patches = sorted( |
| patches_by_image_key.get(image_key, []), |
| key=lambda item: int(item.get("target_index", 0)), |
| ) |
| for patch_index, patch_row in enumerate(image_patches): |
| source_record = _patch_to_source_record(patch_row, fallback_target_index=existing_count + patch_index) |
| source_records.append(source_record) |
| applied_rows.append( |
| { |
| "candidate_id": patch_row["candidate_id"], |
| "image_key": image_key, |
| "assigned_target_index": source_record["target_index"], |
| "bbox_1000": source_record["bbox_1000"], |
| "maturity_level": source_record["maturity_level"], |
| "occlusion_degree": source_record["occlusion_degree"], |
| "support_count": patch_row.get("support_count"), |
| "support_runs": patch_row.get("support_runs", []), |
| } |
| ) |
| source_records = sorted(source_records, key=lambda item: int(item["target_index"])) |
| row_copy["source_records"] = source_records |
| row_copy["messages"] = _replace_assistant_annotations(row_copy.get("messages", []), source_records) |
| row_copy["review_patch_metadata"] = { |
| "patch_version": "missing_label_review_20260524", |
| "applied_missing_labels": len(image_patches), |
| "original_annotation_count": existing_count, |
| "reviewed_annotation_count": len(source_records), |
| } |
| patched_rows.append(row_copy) |
|
|
| missing_image_keys = sorted(set(patches_by_image_key) - set(rows_by_image_key)) |
| if missing_image_keys: |
| raise ValueError(f"patch 中存在 reference 未覆盖图像: {missing_image_keys[:5]}") |
| return patched_rows, applied_rows |
|
|
|
|
| def _patch_to_source_record(patch_row: dict[str, Any], fallback_target_index: int) -> dict[str, Any]: |
| target_index = int(patch_row.get("target_index", fallback_target_index)) |
| return { |
| "image_path": patch_row["image_path"], |
| "image_width": int(patch_row["image_width"]), |
| "image_height": int(patch_row["image_height"]), |
| "target_index": target_index, |
| "bbox": patch_row["bbox"], |
| "maturity_level": patch_row["maturity_level"], |
| "maturity_ratio": patch_row["maturity_ratio"], |
| "occlusion_degree": patch_row["occlusion_degree"], |
| "reasoning": patch_row["reasoning"], |
| "bbox_1000": patch_row["bbox_1000"], |
| "is_valid": True, |
| "source": "human_accepted_missing_label_patch", |
| "candidate_id": patch_row["candidate_id"], |
| "support_count": patch_row.get("support_count"), |
| "support_runs": patch_row.get("support_runs", []), |
| } |
|
|
|
|
| def _replace_assistant_annotations( |
| messages: list[dict[str, Any]], |
| source_records: list[dict[str, Any]], |
| ) -> list[dict[str, Any]]: |
| messages_copy = json.loads(json.dumps(messages, ensure_ascii=False)) |
| payload = { |
| "annotations": [ |
| { |
| "bbox": record["bbox_1000"], |
| "maturity_level": record["maturity_level"], |
| "maturity_ratio": record["maturity_ratio"], |
| "occlusion_degree": record["occlusion_degree"], |
| "reasoning": record["reasoning"], |
| } |
| for record in source_records |
| ] |
| } |
| if messages_copy: |
| messages_copy[-1]["content"] = json.dumps(payload, ensure_ascii=False) |
| return messages_copy |
|
|
|
|
| def _extract_source_records(row: dict[str, Any]) -> list[dict[str, Any]]: |
| records = row.get("source_records") |
| if isinstance(records, list): |
| return records |
| messages = row.get("messages", []) |
| if not messages: |
| return [] |
| payload = json.loads(messages[-1].get("content", "{}")) |
| annotations = payload.get("annotations", []) |
| return annotations if isinstance(annotations, list) else [] |
|
|
|
|
| def _build_summary( |
| *, |
| reference_path: Path, |
| patch_path: Path, |
| output_dir: Path, |
| reference_out: Path, |
| patch_applied_out: Path, |
| reference_rows: list[dict[str, Any]], |
| patched_rows: list[dict[str, Any]], |
| patch_rows: list[dict[str, Any]], |
| applied_rows: list[dict[str, Any]], |
| ) -> dict[str, Any]: |
| original_count = sum(len(_extract_source_records(row)) for row in reference_rows) |
| reviewed_count = sum(len(_extract_source_records(row)) for row in patched_rows) |
| applied_by_maturity = Counter(row.get("maturity_level") or "未知" for row in patch_rows) |
| applied_by_support = Counter(int(row.get("support_count", 0)) for row in patch_rows) |
| return { |
| "created_at": datetime.now().isoformat(timespec="seconds"), |
| "reference_path": str(reference_path), |
| "patch_path": str(patch_path), |
| "output_dir": str(output_dir), |
| "reference_subset_reviewed_path": str(reference_out), |
| "applied_missing_label_patch_path": str(patch_applied_out), |
| "image_count": len(patched_rows), |
| "original_annotations_total": original_count, |
| "applied_missing_labels_total": len(applied_rows), |
| "reviewed_annotations_total": reviewed_count, |
| "patched_image_total": len({row["image_key"] for row in applied_rows}), |
| "applied_by_maturity": dict(applied_by_maturity), |
| "applied_by_support_count": dict(sorted(applied_by_support.items())), |
| "quality_checks": { |
| "annotation_count_matches": reviewed_count == original_count + len(applied_rows), |
| "all_patch_rows_applied": len(applied_rows) == len(patch_rows), |
| "messages_count_matches_source_records": all( |
| _assistant_annotation_count(row) == len(_extract_source_records(row)) for row in patched_rows |
| ), |
| }, |
| } |
|
|
|
|
| def _assistant_annotation_count(row: dict[str, Any]) -> int: |
| messages = row.get("messages", []) |
| if not messages: |
| return -1 |
| payload = json.loads(messages[-1].get("content", "{}")) |
| annotations = payload.get("annotations", []) |
| return len(annotations) if isinstance(annotations, list) else -1 |
|
|
|
|
| def _build_report(summary: dict[str, Any]) -> str: |
| lines = [ |
| "# Gold v1.1 Reviewed Validation Reference", |
| "", |
| f"- created_at: `{summary['created_at']}`", |
| f"- image_count: `{summary['image_count']}`", |
| f"- original_annotations_total: `{summary['original_annotations_total']}`", |
| f"- applied_missing_labels_total: `{summary['applied_missing_labels_total']}`", |
| f"- reviewed_annotations_total: `{summary['reviewed_annotations_total']}`", |
| f"- patched_image_total: `{summary['patched_image_total']}`", |
| "", |
| "## Artifacts", |
| "", |
| f"- reference_subset_reviewed_path: `{summary['reference_subset_reviewed_path']}`", |
| f"- applied_missing_label_patch_path: `{summary['applied_missing_label_patch_path']}`", |
| "", |
| "## Quality Checks", |
| "", |
| ] |
| for key, value in summary["quality_checks"].items(): |
| lines.append(f"- {key}: `{value}`") |
| lines.append("") |
| return "\n".join(lines) |
|
|
|
|
| def _read_jsonl(path: Path) -> list[dict[str, Any]]: |
| rows = [] |
| with path.open("r", encoding="utf-8") as file: |
| for line in file: |
| line = line.strip() |
| if line: |
| rows.append(json.loads(line)) |
| return rows |
|
|
|
|
| def _write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: |
| with path.open("w", encoding="utf-8") as file: |
| for row in rows: |
| file.write(json.dumps(row, ensure_ascii=False)) |
| file.write("\n") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|