Files
pbf/tools/style-visual-object/reviewer/review_server.py

749 lines
32 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.
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import difflib
import re
import json
import mimetypes
import subprocess
import sys
import urllib.parse
from datetime import datetime, timezone
from collections import Counter
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from PIL import Image
TOOL_DIR = Path(__file__).resolve().parents[1]
OUTPUT_DIR = TOOL_DIR / "output"
CM95_CATALOG_PATH = OUTPUT_DIR / "cm95-anonymous" / "anonymous-catalog.json"
STD_CATALOG_PATH = OUTPUT_DIR / "navsea-standard" / "standard-catalog.json"
SUGGESTION_PATH = OUTPUT_DIR / "cm95-visual-anchor-match" / "reviewed-anchors-v1.json"
VISUAL_CANDIDATE_PATH = OUTPUT_DIR / "cm95-visual-anchor-match" / "visual-anchor-matches.json"
REFERENCE_NAME_PATH = OUTPUT_DIR / "reference-name-enrichment-v1.json"
SEMANTIC_RULES_PATH = TOOL_DIR / "cm95_semantic_layer_rules_v1.json"
MANUAL_DIR = OUTPUT_DIR / "cm95-manual-review"
MANUAL_REVIEW_PATH = MANUAL_DIR / "manual-review-v1.json"
MANUAL_ANCHOR_PATH = MANUAL_DIR / "reviewed-anchors-manual-v1.json"
MANUAL_ASSIGNMENT_DIR = MANUAL_DIR / "assignment"
WEB_DIR = Path(__file__).resolve().parent / "web"
VALID_STATUS = {"MATCH", "PARTIAL", "AMBIGUOUS", "COMPONENT", "UNKNOWN"}
def _normalize_name(text: str | None) -> str:
if not text:
return ""
return re.sub(r"[^0-9a-zA-Z\u4e00-\u9fff]+", "", str(text).lower())
def _text_similarity(a: str | None, b: str | None) -> float:
na = _normalize_name(a)
nb = _normalize_name(b)
if not na or not nb:
return 0.0
seq = difflib.SequenceMatcher(None, na, nb).ratio()
sa, sb = set(na), set(nb)
jac = len(sa & sb) / max(1, len(sa | sb))
contain = 0.0
if na in nb or nb in na:
contain = min(len(na), len(nb)) / max(len(na), len(nb))
contain = 0.70 + 0.30 * contain
return max(seq, jac, contain)
COMPONENT_KIND_ZH = {
"topmark": "顶标",
"buoy_body": "浮标主体",
"beacon_body": "立标主体",
"light": "灯光",
"auxiliary_mark": "附加标记",
"fill_pattern": "面填充图案",
"line_pattern": "线型图案",
"text": "文字",
"other": "其他组件",
}
def read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def write_json_atomic(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp.replace(path)
class ReviewState:
def __init__(self) -> None:
self.cm = read_json(CM95_CATALOG_PATH)
self.std = read_json(STD_CATALOG_PATH)
self.suggestions = read_json(SUGGESTION_PATH) if SUGGESTION_PATH.exists() else {"anchors": []}
self.visual_candidates = read_json(VISUAL_CANDIDATE_PATH) if VISUAL_CANDIDATE_PATH.exists() else {"top_candidates": []}
self.reference_names = read_json(REFERENCE_NAME_PATH) if REFERENCE_NAME_PATH.exists() else {"cm95": {}, "navsea_standard": {}, "standard_layer_zh": {}}
self.semantic_rules = read_json(SEMANTIC_RULES_PATH).get("rules", []) if SEMANTIC_RULES_PATH.exists() else []
self.cm_names = self.reference_names.get("cm95", {})
self.std_names = self.reference_names.get("navsea_standard", {})
self.layer_zh = self.reference_names.get("standard_layer_zh", {})
self.cm_atlas = Image.open(OUTPUT_DIR / "cm95-anonymous" / "_sprite" / "sprite.png").convert("RGBA")
self.std_atlas = Image.open(OUTPUT_DIR / "navsea-anonymous" / "_sprite" / "sprite.png").convert("RGBA")
self.cm_resources = self._build_cm_resources()
self.cm_by_id = {x["anonymous_id"]: x for x in self.cm_resources}
self.cm_by_key = {x["resource_key"]: x for x in self.cm_resources}
self.std_resources = self._build_std_resources()
self.std_by_key = {x["resource_key"]: x for x in self.std_resources}
self.standard_layers = sorted({
str(obj.get("standard_layer_name"))
for obj in self.std.get("visual_objects", [])
if obj.get("standard_layer_name")
})
self.suggestion_by_key = {
x["cm95_resource_key"]: x
for x in self.suggestions.get("anchors", [])
}
self.candidate_by_key = {
x["cm95_resource_key"]: x
for x in self.visual_candidates.get("top_candidates", [])
}
self.review = self._load_or_initialize_review()
def _anonymous_id(self, resource_key: str, visual_hash: str | None) -> str:
seed = f"{resource_key}|{visual_hash or ''}".encode("utf-8")
return "CM95-VO-" + hashlib.sha256(seed).hexdigest()[:10].upper()
def _build_cm_resources(self) -> list[dict[str, Any]]:
grouped: dict[str, dict[str, Any]] = {}
for obj in self.cm.get("visual_objects", []):
key = obj.get("resource_key")
meta = obj.get("resource_meta")
if not key or not meta:
continue
item = grouped.setdefault(key, {
"resource_key": key,
"resource_meta": meta,
"visual_hash": obj.get("visual_hash"),
"component_roles": set(),
"geometry_roles": set(),
"occurrence_count": 0,
"group_ids": set(),
"provenance": [],
})
if obj.get("component_role"):
item["component_roles"].add(obj["component_role"])
if obj.get("geometry_role"):
item["geometry_roles"].add(obj["geometry_role"])
item["occurrence_count"] += 1
item["group_ids"].add(obj["group_id"])
if obj.get("provenance"):
item["provenance"].append(obj["provenance"])
out = []
for key in sorted(grouped):
item = grouped[key]
item["anonymous_id"] = self._anonymous_id(key, item.get("visual_hash"))
item["component_roles"] = sorted(item["component_roles"])
item["geometry_roles"] = sorted(item["geometry_roles"])
item["group_ids"] = sorted(item["group_ids"])
out.append(item)
return out
def _build_std_resources(self) -> list[dict[str, Any]]:
grouped: dict[tuple[str, str], dict[str, Any]] = {}
for obj in self.std.get("visual_objects", []):
key = obj.get("resource_key")
layer = obj.get("standard_layer_name")
meta = obj.get("resource_meta")
if not key or not layer or not meta:
continue
gkey = (key, layer)
item = grouped.setdefault(gkey, {
"resource_key": key,
"standard_layer": layer,
"resource_meta": meta,
"visual_hash": obj.get("visual_hash"),
"component_role": obj.get("component_role"),
"geometry_role": obj.get("geometry_role"),
"visual_object_id": obj.get("visual_object_id"),
})
return sorted(grouped.values(), key=lambda x: (x["standard_layer"], x["resource_key"]))
def _semantic_info(self, resource_key: str) -> dict[str, Any]:
info = self.cm_names.get(resource_key, {}) or {}
desc = str(info.get("official_description_en") or "")
resource_type = str(info.get("resource_type") or "")
for rule in self.semantic_rules:
rule_type = rule.get("resource_type")
if rule_type and rule_type != resource_type:
continue
pattern = str(rule.get("pattern") or "")
if pattern and not re.search(pattern, desc, flags=re.IGNORECASE):
continue
return {
"rule_id": rule.get("id"),
"category_zh": rule.get("category_zh") or "未分类",
"reason_zh": rule.get("reason_zh") or "",
"component_kind": rule.get("component_kind"),
"layer_candidates": [
{"layer": str(x[0]), "semantic_score": float(x[1])}
for x in rule.get("layer_candidates", [])
if isinstance(x, list) and len(x) >= 2
],
}
return {
"rule_id": None,
"category_zh": "尚未建立语义分类",
"reason_zh": "当前 S-52 说明没有命中已声明的 Layer 语义规则,需人工确认。",
"component_kind": None,
"layer_candidates": [],
}
def _layer_suggestions(
self,
semantic: dict[str, Any],
candidates: list[dict[str, Any]],
suggestion: dict[str, Any] | None,
) -> list[dict[str, Any]]:
valid_layers = set(self.standard_layers)
semantic_map = {
x["layer"]: float(x.get("semantic_score") or 0.0)
for x in semantic.get("layer_candidates", [])
if x.get("layer") in valid_layers
}
visual_map: dict[str, tuple[float, str | None]] = {}
for c in candidates:
layer = c.get("standard_layer")
if layer not in valid_layers:
continue
score = float(c.get("score") or 0.0)
prev = visual_map.get(layer)
if prev is None or score > prev[0]:
visual_map[layer] = (score, c.get("resource_key"))
prior_layers: set[str] = set()
if suggestion:
if suggestion.get("standard_layer") in valid_layers:
prior_layers.add(str(suggestion.get("standard_layer")))
for layer in suggestion.get("standard_layers") or []:
if layer in valid_layers:
prior_layers.add(str(layer))
layers = set(semantic_map) | set(visual_map) | prior_layers
rows: list[dict[str, Any]] = []
has_semantic = bool(semantic_map)
for layer in layers:
sem = semantic_map.get(layer, 0.0)
vis, best_resource = visual_map.get(layer, (0.0, None))
prior = 1.0 if layer in prior_layers else 0.0
if has_semantic:
combined = 0.75 * sem + 0.15 * vis + 0.10 * prior
else:
combined = 0.72 * vis + 0.28 * prior
reasons = []
if sem > 0:
reasons.append(f"S-52 语义 {sem*100:.0f}%")
if vis > 0:
reasons.append(f"视觉证据 {vis*100:.0f}%")
if prior > 0:
reasons.append("已有 reviewed suggestion 支持")
rows.append({
"standard_layer": layer,
"standard_layer_zh": self.layer_zh.get(layer, layer),
"semantic_score": round(sem, 4),
"visual_score": round(vis, 4),
"prior_support": bool(prior),
"combined_score": round(min(1.0, combined), 4),
"best_standard_resource_key": best_resource,
"reason_zh": ";".join(reasons) or "辅助证据不足",
})
rows.sort(key=lambda x: (x["combined_score"], x["semantic_score"], x["visual_score"]), reverse=True)
return rows[:8]
def _load_or_initialize_review(self) -> dict[str, Any]:
if MANUAL_REVIEW_PATH.exists():
data = read_json(MANUAL_REVIEW_PATH)
return data
data = {
"schema": "cm95-manual-review/v1",
"created_at": datetime.now(timezone.utc).isoformat(),
"updated_at": datetime.now(timezone.utc).isoformat(),
"cm95_style_sha256": self.cm.get("style", {}).get("sha256"),
"standard_style_sha256": self.std.get("style", {}).get("sha256"),
"standard_catalog_status": self.std.get("catalog_status"),
"policy": {
"source_layer_hidden_from_main_review": True,
"provider_field_semantics_used": False,
"standard_catalog_mutated": False,
"cm95_anonymous_catalog_mutated": False,
},
"decisions": {},
}
write_json_atomic(MANUAL_REVIEW_PATH, data)
return data
def save_decision(self, payload: dict[str, Any]) -> dict[str, Any]:
anonymous_id = str(payload.get("anonymous_id") or "")
item = self.cm_by_id.get(anonymous_id)
if not item:
raise ValueError("未知 anonymous_id")
status = str(payload.get("status") or "")
if status not in VALID_STATUS:
raise ValueError("status 必须是 MATCH / PARTIAL / AMBIGUOUS / COMPONENT / UNKNOWN")
layers = payload.get("standard_layers") or []
if not isinstance(layers, list):
raise ValueError("standard_layers 必须是数组")
layers = sorted({str(x) for x in layers if x})
std_resource = payload.get("standard_resource_key")
if std_resource is not None:
std_resource = str(std_resource)
if std_resource not in self.std_by_key:
raise ValueError("未知 standard_resource_key")
valid_layers = set(self.standard_layers)
if any(x not in valid_layers for x in layers):
raise ValueError("standard_layers 包含未知标准 Layer")
if status in {"MATCH", "PARTIAL"} and len(layers) != 1:
raise ValueError(f"{status} 必须且只能选择 1 个标准 Layer")
if status == "AMBIGUOUS" and len(layers) < 2:
raise ValueError("AMBIGUOUS 至少选择 2 个标准 Layer")
component_kind = payload.get("component_kind")
if component_kind is not None:
component_kind = str(component_kind)
if component_kind not in COMPONENT_KIND_ZH:
raise ValueError("未知 component_kind")
if status in {"UNKNOWN", "COMPONENT"}:
layers = []
std_resource = None
if status != "COMPONENT":
component_kind = None
if std_resource:
std_layer = self.std_by_key[std_resource]["standard_layer"]
if status in {"MATCH", "PARTIAL"} and layers and std_layer != layers[0]:
raise ValueError("选择的 Standard Visual Object 与 Layer 不一致")
now = datetime.now(timezone.utc).isoformat()
decision = {
"anonymous_id": anonymous_id,
"resource_key_internal": item["resource_key"],
"status": status,
"standard_layers": layers,
"standard_resource_key": std_resource,
"component_kind": component_kind,
"assignment_mode": (
"COMPONENT_ONLY" if status == "COMPONENT"
else ("VISUAL_OBJECT_MATCH" if std_resource else ("LAYER_ONLY" if layers else "UNKNOWN"))
),
"note": str(payload.get("note") or ""),
"reviewed_at": now,
}
self.review["decisions"][anonymous_id] = decision
self.review["updated_at"] = now
write_json_atomic(MANUAL_REVIEW_PATH, self.review)
return decision
def delete_decision(self, anonymous_id: str) -> None:
self.review["decisions"].pop(anonymous_id, None)
self.review["updated_at"] = datetime.now(timezone.utc).isoformat()
write_json_atomic(MANUAL_REVIEW_PATH, self.review)
def abandon_unreviewed(self, dry_run: bool = False) -> dict[str, Any]:
decisions = self.review.get("decisions", {})
pending = [x for x in self.cm_resources if x["anonymous_id"] not in decisions]
if dry_run:
return {"affected": len(pending), "applied": False}
now = datetime.now(timezone.utc).isoformat()
for item in pending:
anonymous_id = item["anonymous_id"]
decisions[anonymous_id] = {
"anonymous_id": anonymous_id,
"resource_key_internal": item["resource_key"],
"status": "UNKNOWN",
"standard_layers": [],
"standard_resource_key": None,
"component_kind": None,
"assignment_mode": "UNKNOWN",
"note": "批量放弃未确认项",
"reviewed_at": now,
"bulk_action": "ABANDON_UNREVIEWED",
}
self.review["updated_at"] = now
write_json_atomic(MANUAL_REVIEW_PATH, self.review)
return {"affected": len(pending), "applied": True}
def bootstrap(self) -> dict[str, Any]:
decisions = self.review.get("decisions", {})
items = []
for item in self.cm_resources:
anon = item["anonymous_id"]
suggestion = self.suggestion_by_key.get(item["resource_key"])
candidate = self.candidate_by_key.get(item["resource_key"])
candidates = []
if candidate:
best = candidate.get("best_standard_resource_key")
best_layer = candidate.get("best_standard_layer")
if best and best_layer:
name_info_std = self.std_names.get(best, {}) or {}
candidates.append({
"resource_key": best,
"standard_layer": best_layer,
"standard_layer_zh": self.layer_zh.get(best_layer, best_layer),
"display_name_zh": name_info_std.get("display_name_zh") or self.layer_zh.get(best_layer, best_layer),
"score": candidate.get("score"),
"margin": candidate.get("margin"),
"source": "visual_candidate",
})
for alt in candidate.get("alternatives", []):
key = alt.get("standard_resource_key")
layer = alt.get("standard_layer")
if key and layer:
name_info_std = self.std_names.get(key, {}) or {}
candidates.append({
"resource_key": key,
"standard_layer": layer,
"standard_layer_zh": self.layer_zh.get(layer, layer),
"display_name_zh": name_info_std.get("display_name_zh") or self.layer_zh.get(layer, layer),
"score": alt.get("score"),
"source": "visual_candidate",
})
name_info = self.cm_names.get(item["resource_key"], {})
cm_name_zh = name_info.get("display_name_zh") or "未命名视觉对象"
suggestion_layers = set()
suggestion_resource = None
if suggestion:
suggestion_resource = suggestion.get("standard_resource_key")
if suggestion.get("standard_layer"):
suggestion_layers.add(str(suggestion.get("standard_layer")))
for layer_name in suggestion.get("standard_layers") or []:
if layer_name:
suggestion_layers.add(str(layer_name))
# UI-only recommendation: actual visual score + Chinese name similarity +
# existing reviewed visual suggestion. This never writes Layer Assignment.
for c in candidates:
visual_score = float(c.get("score") or 0.0)
text_score = _text_similarity(cm_name_zh, c.get("display_name_zh"))
bonus = 0.0
if suggestion_resource and c.get("resource_key") == suggestion_resource:
bonus += 0.16
elif c.get("standard_layer") in suggestion_layers:
bonus += 0.08
c["text_score"] = round(text_score, 4)
c["recommendation_score"] = round(min(1.0, 0.62 * visual_score + 0.38 * text_score + bonus), 4)
candidates.sort(key=lambda x: (float(x.get("recommendation_score") or 0.0), float(x.get("score") or 0.0)), reverse=True)
recommended_resource_key = candidates[0]["resource_key"] if candidates else suggestion_resource
semantic = self._semantic_info(item["resource_key"])
layer_suggestions = self._layer_suggestions(semantic, candidates, suggestion)
top_layer = layer_suggestions[0]["standard_layer"] if layer_suggestions else None
current_decision = decisions.get(anon)
decision_conflict = False
if current_decision and top_layer and layer_suggestions and layer_suggestions[0]["combined_score"] >= 0.72:
selected = current_decision.get("standard_layers") or []
if current_decision.get("status") in {"MATCH", "PARTIAL"} and len(selected) == 1 and selected[0] != top_layer:
decision_conflict = True
items.append({
"anonymous_id": anon,
"display_name_zh": cm_name_zh,
"official_description_en": name_info.get("official_description_en"),
"name_source": name_info.get("display_name_source"),
"semantic": semantic,
"layer_suggestions": layer_suggestions,
"recommended_layer": top_layer,
"decision_conflict": decision_conflict,
"component_roles": item["component_roles"],
"geometry_roles": item["geometry_roles"],
"occurrence_count": item["occurrence_count"],
"suggestion": {
"status": suggestion.get("status"),
"standard_layer": suggestion.get("standard_layer"),
"standard_layers": suggestion.get("standard_layers"),
"standard_resource_key": suggestion.get("standard_resource_key"),
"standard_name_zh": (self.std_names.get(suggestion.get("standard_resource_key") or "", {}) or {}).get("display_name_zh"),
"standard_layer_zh": self.layer_zh.get(suggestion.get("standard_layer") or ""),
"review_stage": suggestion.get("review_stage"),
} if suggestion else None,
"candidates": candidates,
"recommended_resource_key": recommended_resource_key,
"decision": current_decision,
})
std_layers = list(self.standard_layers)
std_resources = [
{
"resource_key": x["resource_key"],
"standard_layer": x["standard_layer"],
"standard_layer_zh": self.layer_zh.get(x["standard_layer"], x["standard_layer"]),
"display_name_zh": (self.std_names.get(x["resource_key"], {}) or {}).get("display_name_zh") or self.layer_zh.get(x["standard_layer"], x["standard_layer"]),
"component_role": x.get("component_role"),
"geometry_role": x.get("geometry_role"),
"visual_object_id": x.get("visual_object_id"),
}
for x in self.std_resources
]
summary = Counter()
for d in decisions.values():
summary[d.get("status", "UNKNOWN")] += 1
return {
"items": items,
"standard_layers": std_layers,
"standard_layer_zh": {x: self.layer_zh.get(x, x) for x in std_layers},
"standard_resources": std_resources,
"progress": {
"total": len(items),
"reviewed": len(decisions),
"unreviewed": len(items) - len(decisions),
"MATCH": summary["MATCH"],
"PARTIAL": summary["PARTIAL"],
"AMBIGUOUS": summary["AMBIGUOUS"],
"COMPONENT": summary["COMPONENT"],
"UNKNOWN": summary["UNKNOWN"],
},
"component_kinds": [
{"id": key, "name_zh": value}
for key, value in COMPONENT_KIND_ZH.items()
],
"policy": self.review.get("policy"),
}
def audit_info(self, anonymous_id: str) -> dict[str, Any]:
item = self.cm_by_id.get(anonymous_id)
if not item:
raise ValueError("未知 anonymous_id")
unique_prov = []
seen = set()
for p in item["provenance"]:
key = json.dumps(p, ensure_ascii=False, sort_keys=True)
if key not in seen:
seen.add(key)
unique_prov.append(p)
return {
"anonymous_id": anonymous_id,
"resource_key": item["resource_key"],
"visual_hash": item.get("visual_hash"),
"group_ids": item["group_ids"],
"provenance": unique_prov,
}
def crop_png(self, which: str, identifier: str) -> bytes:
if which == "cm95":
item = self.cm_by_id.get(identifier)
if not item:
raise KeyError(identifier)
atlas = self.cm_atlas
meta = item["resource_meta"]
elif which == "std":
item = self.std_by_key.get(identifier)
if not item:
raise KeyError(identifier)
atlas = self.std_atlas
meta = item["resource_meta"]
else:
raise KeyError(which)
crop = atlas.crop((
int(meta["x"]), int(meta["y"]),
int(meta["x"]) + int(meta["width"]),
int(meta["y"]) + int(meta["height"]),
)).convert("RGBA")
from io import BytesIO
buf = BytesIO()
crop.save(buf, format="PNG")
return buf.getvalue()
def finalize(self) -> dict[str, Any]:
decisions = self.review.get("decisions", {})
anchors = []
for anon, d in decisions.items():
item = self.cm_by_id.get(anon)
if not item:
continue
status = d["status"]
if status == "UNKNOWN":
continue
layers = d.get("standard_layers") or []
row: dict[str, Any] = {
"cm95_resource_key": item["resource_key"],
"status": status,
"standard_resource_key": d.get("standard_resource_key"),
"component_kind": d.get("component_kind"),
"assignment_mode": d.get("assignment_mode") or (
"COMPONENT_ONLY" if status == "COMPONENT"
else ("VISUAL_OBJECT_MATCH" if d.get("standard_resource_key") else "LAYER_ONLY")
),
"review_stage": "user_manual_ui_review",
"note": d.get("note", ""),
}
if status in {"MATCH", "PARTIAL"} and len(layers) == 1:
row["standard_layer"] = layers[0]
elif status == "AMBIGUOUS":
row["standard_layers"] = layers
anchors.append(row)
exported = {
"schema": "cm95-reviewed-visual-anchors/manual-v1",
"generated_at": datetime.now(timezone.utc).isoformat(),
"source_manual_review": str(MANUAL_REVIEW_PATH),
"rules": {
"source_layer_used": False,
"provider_field_semantics_used": False,
"manual_review_is_authoritative": True,
"unreviewed_resources_are_unknown": True,
},
"anchors": anchors,
}
write_json_atomic(MANUAL_ANCHOR_PATH, exported)
script = TOOL_DIR / "apply_reviewed_layer_assignment.py"
cmd = [
sys.executable, str(script),
"--anonymous", str(CM95_CATALOG_PATH),
"--reviewed-anchors", str(MANUAL_ANCHOR_PATH),
"--output", str(MANUAL_ASSIGNMENT_DIR),
]
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=120)
if proc.returncode != 0:
raise RuntimeError(proc.stderr or proc.stdout or "assignment 生成失败")
return {
"ok": True,
"manual_review_path": str(MANUAL_REVIEW_PATH),
"anchor_path": str(MANUAL_ANCHOR_PATH),
"assignment_dir": str(MANUAL_ASSIGNMENT_DIR),
"stdout": proc.stdout,
}
STATE = ReviewState()
class Handler(BaseHTTPRequestHandler):
server_version = "StyleVisualObjectReviewer/1.0"
def _send_json(self, obj: Any, status: int = 200) -> None:
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
def _send_bytes(self, data: bytes, content_type: str, status: int = 200) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
def _read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0") or 0)
if length <= 0 or length > 2_000_000:
raise ValueError("请求体大小不合法")
return json.loads(self.rfile.read(length).decode("utf-8"))
def do_GET(self) -> None:
try:
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path == "/api/bootstrap":
return self._send_json(STATE.bootstrap())
if path == "/api/audit":
q = urllib.parse.parse_qs(parsed.query)
anon = (q.get("id") or [""])[0]
return self._send_json(STATE.audit_info(anon))
if path.startswith("/sprite/cm95/") and path.endswith(".png"):
anon = urllib.parse.unquote(path[len("/sprite/cm95/"):-4])
return self._send_bytes(STATE.crop_png("cm95", anon), "image/png")
if path.startswith("/sprite/std/") and path.endswith(".png"):
key = urllib.parse.unquote(path[len("/sprite/std/"):-4])
return self._send_bytes(STATE.crop_png("std", key), "image/png")
if path == "/" or path == "/index.html":
file_path = WEB_DIR / "index.html"
else:
rel = path.lstrip("/")
if ".." in Path(rel).parts:
return self._send_json({"error": "invalid path"}, 400)
file_path = WEB_DIR / rel
if not file_path.exists() or not file_path.is_file():
return self._send_json({"error": "not found"}, 404)
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
return self._send_bytes(file_path.read_bytes(), content_type)
except Exception as exc:
return self._send_json({"error": str(exc)}, 500)
def do_POST(self) -> None:
try:
parsed = urllib.parse.urlparse(self.path)
payload = self._read_json()
if parsed.path == "/api/review":
decision = STATE.save_decision(payload)
return self._send_json({"ok": True, "decision": decision, "progress": STATE.bootstrap()["progress"]})
if parsed.path == "/api/unreview":
anon = str(payload.get("anonymous_id") or "")
STATE.delete_decision(anon)
return self._send_json({"ok": True, "progress": STATE.bootstrap()["progress"]})
if parsed.path == "/api/abandon-unreviewed":
dry_run = bool(payload.get("dry_run"))
result = STATE.abandon_unreviewed(dry_run=dry_run)
return self._send_json({"ok": True, **result, "progress": STATE.bootstrap()["progress"]})
if parsed.path == "/api/finalize":
return self._send_json(STATE.finalize())
return self._send_json({"error": "not found"}, 404)
except ValueError as exc:
return self._send_json({"error": str(exc)}, 400)
except Exception as exc:
return self._send_json({"error": str(exc)}, 500)
def log_message(self, fmt: str, *args: Any) -> None:
sys.stdout.write("%s - %s\n" % (self.address_string(), fmt % args))
sys.stdout.flush()
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="0.0.0.0")
ap.add_argument("--port", type=int, default=18083)
args = ap.parse_args()
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"CM95 Visual Object Reviewer: http://{args.host}:{args.port}/")
server.serve_forever()
if __name__ == "__main__":
main()