diff --git a/STEP_RECORD.md b/STEP_RECORD.md
index 96bd229..cf4d873 100644
--- a/STEP_RECORD.md
+++ b/STEP_RECORD.md
@@ -1,6 +1,6 @@
# PBF Step Record
-Last Updated: 2026-03-31
+Last Updated: 2026-04-01
Repo: `/root/sourceserver/pbf`
Remote: `ssh://git@nas:2222/tei/pbf.git`
@@ -12,13 +12,13 @@ Remote: `ssh://git@nas:2222/tei/pbf.git`
当前页面版本体系:
-- `HTML: compare-r10-20260331-2213`
+- `HTML: compare-r11-20260401-0115`
当前页面支持 2 个 profile,但仍是同一张 HTML:
1. 唐津 20 海里
- - `Style: karatsu-style-r7-20260331-2045 (style.navsea-delivery-karatsu-10nm.json)`
- - `PBF: karatsu-pbf-20nm-20260325-1757 (pbf-delivery-karatsu-20nm)`
+ - `Style: karatsu-final-style-r1-20260401-0115 (style.navsea-delivery-karatsu-20nm-final.json)`
+ - `PBF: karatsu-final-pbf-20nm-20260401-0115 (pbf-delivery-karatsu-20nm-final)`
2. 九州
- `Style: kyushu-style-r1-20260331-2205 (style.navsea-delivery-kyushu.json)`
@@ -45,6 +45,7 @@ Remote: `ssh://git@nas:2222/tei/pbf.git`
当前继续收敛的主 style 文件:
- [`src/pbf/style.navsea-delivery-karatsu-10nm.json`](/root/sourceserver/pbf/src/pbf/style.navsea-delivery-karatsu-10nm.json)
+- [`src/pbf/style.navsea-delivery-karatsu-20nm-final.json`](/root/sourceserver/pbf/src/pbf/style.navsea-delivery-karatsu-20nm-final.json)
当前九州 delivery style:
@@ -64,6 +65,65 @@ Remote: `ssh://git@nas:2222/tei/pbf.git`
- 当前主矛盾是 `style`
- 不是 `PBF 不见了`
+## Final Release Candidate
+
+当前已经产出一版 `20nm final release` 候选:
+
+- 输出目录:
+ - `/home/wwwroot/pbf-delivery-karatsu-20nm-final`
+- 当前在线对比页:
+ - `http://192.168.200.184/newpec/navsea-compare-karatsu-20nm.html`
+- 当前候选 style:
+ - [`src/pbf/style.navsea-delivery-karatsu-20nm-final.json`](/root/sourceserver/pbf/src/pbf/style.navsea-delivery-karatsu-20nm-final.json)
+
+当前候选的生成方式:
+
+- 以当前稳定 `/home/wwwroot/pbf-delivery-karatsu-20nm` 为输入
+- 使用 protobuf 层属性裁剪脚本生成
+- 脚本:
+ - [`navsea_minimize_release_tiles.py`](/root/sourceserver/pbf/navsea_minimize_release_tiles.py)
+
+当前 final candidate 只保留 `18` 个字段:
+
+- `canonical_object_type`
+- `chart_fill_pattern`
+- `chart_fill_style`
+- `chart_icon_image`
+- `chart_label_position_code`
+- `chart_label_subtext`
+- `chart_label_text`
+- `chart_line_color`
+- `chart_line_width`
+- `chart_symbol_code`
+- `chart_text_color`
+- `chart_text_style`
+- `depth_value_m`
+- `least_depth_m`
+- `light_color_code`
+- `light_sector_mode`
+- `name_ja`
+- `place_name_en`
+
+当前 final candidate 量化结果:
+
+- feature instances:
+ - `269883`
+- distinct `canonical_object_type`:
+ - `137`
+- 原始 `20nm delivery` 大小:
+ - `29,527,981` bytes
+- final candidate 大小:
+ - `23,988,432` bytes
+- 压缩幅度约:
+ - `18.8%`
+
+当前判断:
+
+- 这版已经满足“字段显著裁剪”
+- 但当前 AOI 图像审计仍显示大差异
+- 所以它目前只能算 `final release candidate`
+- 还不能直接认定为稳定最终版
+
## Audit Method
当前认可的可信审计方法:
diff --git a/navsea_minimize_release_tiles.py b/navsea_minimize_release_tiles.py
new file mode 100644
index 0000000..b629dd6
--- /dev/null
+++ b/navsea_minimize_release_tiles.py
@@ -0,0 +1,127 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from mapbox_vector_tile.Mapbox import vector_tile_pb2
+
+
+FINAL_RELEASE_PROPERTY_ALLOWLIST = frozenset(
+ {
+ "canonical_object_type",
+ "chart_fill_pattern",
+ "chart_fill_style",
+ "chart_icon_image",
+ "chart_label_position_code",
+ "chart_label_subtext",
+ "chart_label_text",
+ "chart_line_color",
+ "chart_line_width",
+ "chart_symbol_code",
+ "chart_text_color",
+ "chart_text_style",
+ "depth_value_m",
+ "least_depth_m",
+ "light_color_code",
+ "light_sector_mode",
+ "name_ja",
+ "place_name_en",
+ }
+)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Create a final release tile set by stripping non-style properties from an existing PBF tree."
+ )
+ parser.add_argument("--src-root", type=Path, required=True)
+ parser.add_argument("--dst-root", type=Path, required=True)
+ return parser.parse_args()
+
+
+def clone_value(dst_value: vector_tile_pb2.tile.value, src_value: vector_tile_pb2.tile.value) -> None:
+ dst_value.CopyFrom(src_value)
+
+
+def strip_tile_properties(raw_tile: bytes, allowlist: set[str] | frozenset[str]) -> bytes:
+ src_tile = vector_tile_pb2.tile()
+ src_tile.ParseFromString(raw_tile)
+
+ dst_tile = vector_tile_pb2.tile()
+
+ for src_layer in src_tile.layers:
+ dst_layer = dst_tile.layers.add()
+ dst_layer.version = src_layer.version
+ dst_layer.name = src_layer.name
+ dst_layer.extent = src_layer.extent
+
+ kept_key_index: dict[int, int] = {}
+ kept_value_index: dict[tuple[int, bytes], int] = {}
+
+ for src_feature in src_layer.features:
+ dst_feature = dst_layer.features.add()
+ if src_feature.HasField("id"):
+ dst_feature.id = src_feature.id
+ dst_feature.type = src_feature.type
+ dst_feature.geometry.extend(src_feature.geometry)
+
+ tags = list(src_feature.tags)
+ for pos in range(0, len(tags), 2):
+ src_key_idx = tags[pos]
+ src_val_idx = tags[pos + 1]
+ key = src_layer.keys[src_key_idx]
+ if key not in allowlist:
+ continue
+
+ dst_key_idx = kept_key_index.get(src_key_idx)
+ if dst_key_idx is None:
+ dst_key_idx = len(dst_layer.keys)
+ dst_layer.keys.append(key)
+ kept_key_index[src_key_idx] = dst_key_idx
+
+ src_value = src_layer.values[src_val_idx]
+ value_sig = (src_val_idx, src_value.SerializeToString())
+ dst_val_idx = kept_value_index.get(value_sig)
+ if dst_val_idx is None:
+ dst_val_idx = len(dst_layer.values)
+ clone_value(dst_layer.values.add(), src_value)
+ kept_value_index[value_sig] = dst_val_idx
+
+ dst_feature.tags.extend([dst_key_idx, dst_val_idx])
+
+ return dst_tile.SerializeToString()
+
+
+def main() -> None:
+ args = parse_args()
+ src_root = args.src_root
+ dst_root = args.dst_root
+
+ if not src_root.exists():
+ raise SystemExit(f"src root not found: {src_root}")
+
+ if dst_root.exists():
+ for path in sorted(dst_root.glob("**/*"), reverse=True):
+ if path.is_file() or path.is_symlink():
+ path.unlink()
+ elif path.is_dir():
+ try:
+ path.rmdir()
+ except OSError:
+ pass
+ dst_root.mkdir(parents=True, exist_ok=True)
+
+ written = 0
+ for src_path in sorted(src_root.glob("*/*/*.pbf")):
+ rel = src_path.relative_to(src_root)
+ dst_path = dst_root / rel
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
+ dst_path.write_bytes(strip_tile_properties(src_path.read_bytes(), FINAL_RELEASE_PROPERTY_ALLOWLIST))
+ written += 1
+
+ print(f"minimized release tiles written={written} dst={dst_root}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/navsea_strict_audit.py b/navsea_strict_audit.py
index 448ea15..017f621 100644
--- a/navsea_strict_audit.py
+++ b/navsea_strict_audit.py
@@ -53,6 +53,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--toolbar-height", type=int, default=150)
parser.add_argument("--bottom-trim", type=int, default=170)
parser.add_argument("--timeout-sec", type=int, default=120)
+ parser.add_argument("--virtual-time-budget-ms", type=int, default=30000)
parser.add_argument("--hotspots-json", type=Path, default=DEFAULT_HOTSPOTS_PATH)
return parser.parse_args()
@@ -62,11 +63,18 @@ def fetch_compare_version(compare_url: str) -> str | None:
text = requests.get(compare_url, timeout=20).text
except Exception:
return None
- match = re.search(r'const\s+COMPARE_VERSION\s*=\s*"([^"]+)"', text)
+ match = re.search(r'const\s+(?:COMPARE_VERSION|HTML_VERSION)\s*=\s*"([^"]+)"', text)
return match.group(1) if match else None
-def run_browser_screenshot(compare_url: str, output_png: Path, width: int, height: int, timeout_sec: int) -> None:
+def run_browser_screenshot(
+ compare_url: str,
+ output_png: Path,
+ width: int,
+ height: int,
+ timeout_sec: int,
+ virtual_time_budget_ms: int,
+) -> None:
cmd = [
"timeout",
f"{timeout_sec}s",
@@ -75,7 +83,7 @@ def run_browser_screenshot(compare_url: str, output_png: Path, width: int, heigh
"--disable-gpu",
"--enable-unsafe-swiftshader",
"--no-sandbox",
- "--virtual-time-budget=15000",
+ f"--virtual-time-budget={virtual_time_budget_ms}",
f"--window-size={width},{height}",
f"--screenshot={output_png}",
compare_url,
@@ -354,7 +362,14 @@ def main() -> None:
right_png = args.output_dir / "compare_right.png"
diff_png = args.output_dir / "compare_diff.png"
- run_browser_screenshot(args.compare_url, full_png, args.width, args.height, args.timeout_sec)
+ run_browser_screenshot(
+ args.compare_url,
+ full_png,
+ args.width,
+ args.height,
+ args.timeout_sec,
+ args.virtual_time_budget_ms,
+ )
visual_metrics = save_visual_outputs(
full_png=full_png,
left_png=left_png,
diff --git a/navsea_tile_builder.py b/navsea_tile_builder.py
index 648cdab..84db77b 100644
--- a/navsea_tile_builder.py
+++ b/navsea_tile_builder.py
@@ -52,6 +52,29 @@ LIGHT_COLOR_REMARK_MAP = {
"A": "amber",
}
+FINAL_RELEASE_PROPERTY_ALLOWLIST = frozenset(
+ {
+ "canonical_object_type",
+ "chart_fill_pattern",
+ "chart_fill_style",
+ "chart_icon_image",
+ "chart_label_position_code",
+ "chart_label_subtext",
+ "chart_label_text",
+ "chart_line_color",
+ "chart_line_width",
+ "chart_symbol_code",
+ "chart_text_color",
+ "chart_text_style",
+ "depth_value_m",
+ "least_depth_m",
+ "light_color_code",
+ "light_sector_mode",
+ "name_ja",
+ "place_name_en",
+ }
+)
+
@dataclass(frozen=True)
class DbConfig:
@@ -163,6 +186,18 @@ def parse_number(value: object) -> float | None:
return None
+def feature_id_from_current_fid(fid_value: object) -> int | None:
+ text = text_or_none(fid_value)
+ if not text:
+ return None
+ try:
+ if re.fullmatch(r"[0-9A-Fa-f]{8}", text):
+ return int(text, 16)
+ return int(text)
+ except ValueError:
+ return None
+
+
def infer_light_color_code_from_remark(light_remark: object) -> str | None:
remark_text = text_or_none(light_remark)
if not remark_text:
@@ -213,8 +248,10 @@ class NavSeaTileBuilder:
zmax: int,
workers: int,
all_tiles: bool,
+ reference_tile_root: Path | None,
engineering_mode: bool,
strip_legacy_japanese_delivery: bool,
+ release_minimal: bool,
fid_codec: NavSeaFidCodec | None,
fid_key_id: str | None,
bundle_id: str | None,
@@ -229,8 +266,10 @@ class NavSeaTileBuilder:
self.zmax = zmax
self.workers = workers
self.all_tiles = all_tiles
+ self.reference_tile_root = reference_tile_root
self.engineering_mode = engineering_mode
self.strip_legacy_japanese_delivery = strip_legacy_japanese_delivery
+ self.release_minimal = release_minimal
self.fid_codec = fid_codec
self.fid_key_id = fid_key_id
self.bundle_id = bundle_id
@@ -305,6 +344,8 @@ class NavSeaTileBuilder:
return [int(row["z"]) for row in cur.fetchall()]
def build_jobs(self, supported_zooms: list[int]) -> list[TileJob]:
+ if self.reference_tile_root is not None:
+ return self.build_reference_jobs(supported_zooms)
if self.all_tiles:
return self.build_all_jobs(supported_zooms)
@@ -335,6 +376,26 @@ class NavSeaTileBuilder:
return jobs
+ def build_reference_jobs(self, supported_zooms: list[int]) -> list[TileJob]:
+ jobs: list[TileJob] = []
+ zoom_set = set(supported_zooms)
+ assert self.reference_tile_root is not None
+
+ for path in sorted(self.reference_tile_root.glob("*/*/*.pbf")):
+ try:
+ z = int(path.parent.parent.name)
+ x = int(path.parent.name)
+ y = int(path.stem)
+ except ValueError:
+ continue
+ if z not in zoom_set or z < self.zmin or z > self.zmax:
+ continue
+ if not (self.source_root / str(z) / str(x) / f"{y}.pbf").exists():
+ continue
+ jobs.append(TileJob(z=z, x=x, y=y))
+
+ return jobs
+
def aoi_bbox(self) -> tuple[float, float, float, float]:
radius_km = self.radius_nm * 1.852
lat_delta = radius_km / 111.32
@@ -380,7 +441,6 @@ class NavSeaTileBuilder:
COALESCE(fid.v, '') AS fid,
r.source_layer,
r.geom_type,
- COALESCE(NULLIF(r.render_layer, ''), r.source_layer) AS output_layer,
r.canonical_object_type,
r.canonical_family,
r.semantic_key,
@@ -438,27 +498,27 @@ class NavSeaTileBuilder:
)
for source_feature, row in zip(source_features, db_rows):
- output_layer = str(row["output_layer"])
+ source_layer_jp = str(row["source_layer"])
+ output_layer, source_layer_rule_id = self.mapping_registry.resolve_source_layer(source_layer_jp)
properties = dict(source_feature.get("properties") or {})
legacy_fid_raw = properties.get("fid", row["fid"])
+ current_feature_id = feature_id_from_current_fid(legacy_fid_raw)
navsea_fid_pair = None
if self.fid_codec is not None:
navsea_fid_pair = self.build_engineering_fid(legacy_fid_raw, row, job)
navsea_fid_int, navsea_fid_hex = navsea_fid_pair
properties["fid"] = navsea_fid_hex
- properties["fid_algo_id"] = "feistel32_aes_cyclewalk_v1"
- properties["fid_key_id"] = self.fid_key_id
+ current_feature_id = navsea_fid_int
if self.engineering_mode:
if navsea_fid_pair is None:
raise RuntimeError("engineering mode requires encrypted fid generation")
navsea_fid_int, _ = navsea_fid_pair
+ properties["fid_algo_id"] = "feistel32_aes_cyclewalk_v1"
+ properties["fid_key_id"] = self.fid_key_id
properties["fid_legacy_raw"] = legacy_fid_raw
properties["fid_navsea_int"] = navsea_fid_int
- properties["source_layer_jp"] = row["source_layer"]
- source_layer_std, source_layer_rule_id = self.mapping_registry.resolve_source_layer(
- str(row["source_layer"])
- )
- properties["source_layer_std"] = source_layer_std
+ properties["source_layer_jp"] = source_layer_jp
+ properties["source_layer_std"] = output_layer
properties["normalization_bundle_id"] = self.bundle_id
properties["source_layer_rule_id"] = source_layer_rule_id
properties["feature_id"] = row["feature_id"]
@@ -470,7 +530,7 @@ class NavSeaTileBuilder:
properties["render_layer"] = output_layer
chart_properties, feature_unresolved, trace_status = self.build_chart_properties(
properties=properties,
- source_layer=str(row["source_layer"]),
+ source_layer=source_layer_jp,
output_layer=output_layer,
canonical_object_type=text_or_none(row["canonical_object_type"]) or "",
canonical_family=text_or_none(row["canonical_family"]) or "",
@@ -483,13 +543,15 @@ class NavSeaTileBuilder:
properties["trace_status"] = trace_status
properties, field_name_unresolved = self.standardize_output_properties(
properties=properties,
- source_layer=str(row["source_layer"]),
+ source_layer=source_layer_jp,
canonical_object_type=text_or_none(row["canonical_object_type"]) or "",
canonical_family=text_or_none(row["canonical_family"]) or "",
geom_type=str(source_feature.get("geometry", {}).get("type", "")),
feature_id=int(row["feature_id"]),
tile=job,
)
+ if self.release_minimal and not self.engineering_mode:
+ properties = self.minimize_delivery_properties(properties)
unresolved.extend(feature_unresolved)
unresolved.extend(field_name_unresolved)
@@ -497,11 +559,13 @@ class NavSeaTileBuilder:
"geometry": source_feature["geometry"],
"properties": properties,
}
- if source_feature.get("id") is not None:
+ if current_feature_id is not None:
+ output_feature["id"] = current_feature_id
+ elif source_feature.get("id") is not None:
output_feature["id"] = source_feature["id"]
layer_features[output_layer].append(output_feature)
- layer_extents.setdefault(output_layer, source_extents[str(row["source_layer"])])
+ layer_extents.setdefault(output_layer, source_extents[source_layer_jp])
encoded_layers = []
per_layer_options: dict[str, dict[str, int]] = {}
@@ -779,9 +843,10 @@ class NavSeaTileBuilder:
for key, value in properties.items():
if key == "fid":
- # By the time we normalize output properties, fid already means
- # the NavSea public ID, not the original source fid field.
- normalized[key] = value
+ if self.engineering_mode:
+ # Engineering output keeps the public NavSea fid in properties
+ # so it can be inspected alongside trace metadata.
+ normalized[key] = value
continue
rule = self.mapping_registry.get_field_name_rule(str(key))
if rule is None:
@@ -820,6 +885,14 @@ class NavSeaTileBuilder:
return normalized, unresolved
+ @staticmethod
+ def minimize_delivery_properties(properties: dict[str, object]) -> dict[str, object]:
+ return {
+ key: value
+ for key, value in properties.items()
+ if key in FINAL_RELEASE_PROPERTY_ALLOWLIST
+ }
+
@staticmethod
def make_unresolved_event(
*,
@@ -1376,6 +1449,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--source-root", type=Path, default=SOURCE_TILE_ROOT)
parser.add_argument("--workers", type=int, default=4)
parser.add_argument("--all-tiles", action="store_true")
+ parser.add_argument("--reference-tile-root", type=Path)
parser.add_argument("--engineering", action="store_true")
parser.add_argument(
"--strip-legacy-japanese-delivery",
@@ -1389,13 +1463,18 @@ def parse_args() -> argparse.Namespace:
dest="strip_legacy_japanese_delivery",
help="keep mapped Japanese structural keys in delivery tiles for legacy compatibility",
)
+ parser.add_argument(
+ "--release-minimal",
+ action="store_true",
+ help="emit a final release tile payload with only the minimal style-driven delivery fields",
+ )
parser.add_argument("--fid-key")
parser.add_argument("--fid-key-id", default="navsea-fid-key-v1")
parser.add_argument("--bundle-id", default="navsea-reversible-v1")
parser.set_defaults(strip_legacy_japanese_delivery=True)
args = parser.parse_args()
- if not args.all_tiles:
+ if not args.all_tiles and args.reference_tile_root is None:
missing = [
name
for name in ("center_lat", "center_lon", "radius_nm")
@@ -1405,8 +1484,8 @@ def parse_args() -> argparse.Namespace:
parser.error(
"--center-lat, --center-lon, and --radius-nm are required unless --all-tiles is used"
)
- if args.engineering and not args.fid_key:
- parser.error("--fid-key is required when --engineering is used")
+ if not args.fid_key:
+ parser.error("--fid-key is required for NavSea delivery and engineering builds")
return args
@@ -1425,8 +1504,10 @@ def main() -> None:
zmax=args.zmax,
workers=max(1, args.workers),
all_tiles=args.all_tiles,
+ reference_tile_root=args.reference_tile_root,
engineering_mode=args.engineering,
strip_legacy_japanese_delivery=args.strip_legacy_japanese_delivery,
+ release_minimal=args.release_minimal,
fid_codec=fid_codec,
fid_key_id=args.fid_key_id,
bundle_id=args.bundle_id,
diff --git a/report/strict_audit_final_20nm/strict_audit_summary.json b/report/strict_audit_final_20nm/strict_audit_summary.json
new file mode 100644
index 0000000..0114ff9
--- /dev/null
+++ b/report/strict_audit_final_20nm/strict_audit_summary.json
@@ -0,0 +1,323 @@
+{
+ "compare_url": "http://192.168.200.184/newpec/navsea-compare-karatsu-20nm.html",
+ "compare_version": "compare-r11-20260401-0115",
+ "backend_audit_json": "/root/sourceserver/pbf/NavSea_Original_vs_Delivery_Render_Audit_Karatsu_20nm_2026-03-31.r7.json",
+ "visual_metrics": {
+ "image_width": 640,
+ "image_height": 400,
+ "total_pixels": 256000,
+ "changed_pixels": 235857,
+ "changed_ratio": 0.921316,
+ "mean_abs_rgb": [
+ 32.7825,
+ 31.9123,
+ 51.3612
+ ],
+ "rms_rgb": [
+ 56.2597,
+ 51.4488,
+ 69.7204
+ ],
+ "diff_bbox": [
+ 0,
+ 0,
+ 640,
+ 400
+ ],
+ "full_crop_left": [
+ 0,
+ 150,
+ 640,
+ 550
+ ],
+ "full_crop_right": [
+ 640,
+ 150,
+ 1280,
+ 550
+ ],
+ "hotspots": [
+ {
+ "image_width": 109,
+ "image_height": 64,
+ "total_pixels": 6976,
+ "changed_pixels": 6975,
+ "changed_ratio": 0.999857,
+ "mean_abs_rgb": [
+ 111.3999,
+ 94.5026,
+ 86.0499
+ ],
+ "rms_rgb": [
+ 148.6028,
+ 125.1472,
+ 112.7118
+ ],
+ "diff_bbox": [
+ 0,
+ 0,
+ 109,
+ 64
+ ],
+ "id": "east_breakwater_marks",
+ "label": "东侧防波堤灯台区",
+ "notes": "防波堤灯台和港口标记区。优先验证远一点的灯台/航标是否也能稳定打出来。",
+ "backend_focus": [
+ "p航路標識群",
+ "safety_icon_missing"
+ ],
+ "pane_box_norm": [
+ 0.83,
+ 0.0,
+ 1.0,
+ 0.16
+ ],
+ "pane_box_pixels": [
+ 531,
+ 0,
+ 640,
+ 64
+ ],
+ "left_image": "report/strict_audit_final_20nm/hotspots/east_breakwater_marks_left.png",
+ "right_image": "report/strict_audit_final_20nm/hotspots/east_breakwater_marks_right.png",
+ "diff_image": "report/strict_audit_final_20nm/hotspots/east_breakwater_marks_diff.png"
+ },
+ {
+ "image_width": 147,
+ "image_height": 80,
+ "total_pixels": 11760,
+ "changed_pixels": 9533,
+ "changed_ratio": 0.810629,
+ "mean_abs_rgb": [
+ 24.2571,
+ 22.39,
+ 27.698
+ ],
+ "rms_rgb": [
+ 48.344,
+ 45.8837,
+ 50.5034
+ ],
+ "diff_bbox": [
+ 0,
+ 0,
+ 147,
+ 80
+ ],
+ "id": "takashima_main_harbor_marks",
+ "label": "高岛主港航标区",
+ "notes": "主港口灯塔、红绿标识、近岸障碍标记。优先验证灯塔、小灯、红绿标识是否真正恢复。",
+ "backend_focus": [
+ "p航路標識群",
+ "safety_icon_missing",
+ "text_missing_in_engineering"
+ ],
+ "pane_box_norm": [
+ 0.34,
+ 0.05,
+ 0.57,
+ 0.25
+ ],
+ "pane_box_pixels": [
+ 218,
+ 20,
+ 365,
+ 100
+ ],
+ "left_image": "report/strict_audit_final_20nm/hotspots/takashima_main_harbor_marks_left.png",
+ "right_image": "report/strict_audit_final_20nm/hotspots/takashima_main_harbor_marks_right.png",
+ "diff_image": "report/strict_audit_final_20nm/hotspots/takashima_main_harbor_marks_diff.png"
+ },
+ {
+ "image_width": 115,
+ "image_height": 68,
+ "total_pixels": 7820,
+ "changed_pixels": 5708,
+ "changed_ratio": 0.729923,
+ "mean_abs_rgb": [
+ 21.7578,
+ 22.3706,
+ 25.4146
+ ],
+ "rms_rgb": [
+ 36.6022,
+ 39.835,
+ 43.5517
+ ],
+ "diff_bbox": [
+ 0,
+ 0,
+ 115,
+ 68
+ ],
+ "id": "takashima_inner_nearshore_hazards",
+ "label": "高岛近岸碍航与鱼礁区",
+ "notes": "近岸鱼礁、碍航点、灯塔与小灯混合区。优先验证鱼礁和碍航物是否还在。",
+ "backend_focus": [
+ "p投錨注意障害物",
+ "p航行危険障害物",
+ "p航路標識群"
+ ],
+ "pane_box_norm": [
+ 0.44,
+ 0.14,
+ 0.62,
+ 0.31
+ ],
+ "pane_box_pixels": [
+ 282,
+ 56,
+ 397,
+ 124
+ ],
+ "left_image": "report/strict_audit_final_20nm/hotspots/takashima_inner_nearshore_hazards_left.png",
+ "right_image": "report/strict_audit_final_20nm/hotspots/takashima_inner_nearshore_hazards_right.png",
+ "diff_image": "report/strict_audit_final_20nm/hotspots/takashima_inner_nearshore_hazards_diff.png"
+ }
+ ]
+ },
+ "backend_summary": {
+ "original_feature_instances": 258317,
+ "engineering_feature_instances": 258317,
+ "result_count": 516634,
+ "status_counts": {
+ "extra_in_engineering": 258317,
+ "missing_in_engineering": 258317
+ },
+ "top_annotation_issues": [
+ {
+ "issue": "extra_text_in_engineering",
+ "source_layer": "bathymetry_line",
+ "count": 24168
+ },
+ {
+ "issue": "text_missing_in_engineering",
+ "source_layer": "L海底地形",
+ "count": 24168
+ },
+ {
+ "issue": "extra_text_in_engineering",
+ "source_layer": "depth_contour",
+ "count": 11929
+ },
+ {
+ "issue": "text_missing_in_engineering",
+ "source_layer": "L等深線",
+ "count": 11929
+ },
+ {
+ "issue": "extra_text_in_engineering",
+ "source_layer": "seabed_text_point",
+ "count": 4499
+ },
+ {
+ "issue": "text_missing_in_engineering",
+ "source_layer": "p底質",
+ "count": 4499
+ },
+ {
+ "issue": "extra_text_in_engineering",
+ "source_layer": "navigation_marks",
+ "count": 1516
+ },
+ {
+ "issue": "text_missing_in_engineering",
+ "source_layer": "p航路標識群",
+ "count": 1516
+ },
+ {
+ "issue": "text_missing_in_engineering",
+ "source_layer": "p地名",
+ "count": 1444
+ },
+ {
+ "issue": "extra_text_in_engineering",
+ "source_layer": "place_label_sea",
+ "count": 1444
+ }
+ ],
+ "top_style_semantic_issues": [
+ {
+ "issue": "depth_numeric_missing",
+ "source_layer": "L海底地形",
+ "count": 24168
+ },
+ {
+ "issue": "depth_numeric_missing",
+ "source_layer": "L等深線",
+ "count": 11929
+ },
+ {
+ "issue": "safety_icon_missing",
+ "source_layer": "p航路標識群",
+ "count": 1885
+ },
+ {
+ "issue": "depth_numeric_missing",
+ "source_layer": "L概略等深線",
+ "count": 350
+ },
+ {
+ "issue": "clearance_numeric_missing",
+ "source_layer": "p高さ制限",
+ "count": 33
+ }
+ ],
+ "top_source_layer_issues": [
+ {
+ "status": "extra_in_engineering",
+ "source_layer": "baseline_outline",
+ "count": 87709
+ },
+ {
+ "status": "missing_in_engineering",
+ "source_layer": "P基本線ククリ",
+ "count": 87709
+ },
+ {
+ "status": "extra_in_engineering",
+ "source_layer": "baseline_area",
+ "count": 50421
+ },
+ {
+ "status": "missing_in_engineering",
+ "source_layer": "P基本線",
+ "count": 50421
+ },
+ {
+ "status": "extra_in_engineering",
+ "source_layer": "depth_contour",
+ "count": 36460
+ },
+ {
+ "status": "missing_in_engineering",
+ "source_layer": "L等深線",
+ "count": 36460
+ },
+ {
+ "status": "extra_in_engineering",
+ "source_layer": "bathymetry_line",
+ "count": 35940
+ },
+ {
+ "status": "missing_in_engineering",
+ "source_layer": "L海底地形",
+ "count": 35940
+ },
+ {
+ "status": "extra_in_engineering",
+ "source_layer": "onshore_structure_line",
+ "count": 10544
+ },
+ {
+ "status": "missing_in_engineering",
+ "source_layer": "L陸上構造物陸",
+ "count": 10544
+ }
+ ]
+ },
+ "notes": [
+ "Visual metrics are browser-rendered screenshot diff metrics from the fixed compare page.",
+ "Backend metrics are current render-audit summary metrics and may remain blind to sprite-load failures."
+ ]
+}
\ No newline at end of file
diff --git a/report/strict_audit_final_20nm/strict_audit_summary.md b/report/strict_audit_final_20nm/strict_audit_summary.md
new file mode 100644
index 0000000..869de4b
--- /dev/null
+++ b/report/strict_audit_final_20nm/strict_audit_summary.md
@@ -0,0 +1,104 @@
+# NavSea Strict Audit Summary
+
+## Scope
+
+- Compare page: `http://192.168.200.184/newpec/navsea-compare-karatsu-20nm.html`
+- Compare version: `compare-r11-20260401-0115`
+- Backend render audit: `/root/sourceserver/pbf/NavSea_Original_vs_Delivery_Render_Audit_Karatsu_20nm_2026-03-31.r7.json`
+
+## Visual Audit
+
+- changed pixels: `235857` / `256000`
+- changed ratio: `0.921316`
+- mean abs rgb: `[32.7825, 31.9123, 51.3612]`
+- rms rgb: `[56.2597, 51.4488, 69.7204]`
+- left crop: `[0, 150, 640, 550]`
+- right crop: `[640, 150, 1280, 550]`
+
+Artifacts:
+
+- `report/strict_audit_final_20nm/compare_full.png`
+- `report/strict_audit_final_20nm/compare_left.png`
+- `report/strict_audit_final_20nm/compare_right.png`
+- `report/strict_audit_final_20nm/compare_diff.png`
+
+## Hotspot AOI Audit
+
+### 东侧防波堤灯台区
+
+- id: `east_breakwater_marks`
+- notes: `防波堤灯台和港口标记区。优先验证远一点的灯台/航标是否也能稳定打出来。`
+- backend focus: `['p航路標識群', 'safety_icon_missing']`
+- changed pixels: `6975` / `6976`
+- changed ratio: `0.999857`
+- mean abs rgb: `[111.3999, 94.5026, 86.0499]`
+- pane box norm: `[0.83, 0.0, 1.0, 0.16]`
+- pane box pixels: `[531, 0, 640, 64]`
+- left image: `report/strict_audit_final_20nm/hotspots/east_breakwater_marks_left.png`
+- right image: `report/strict_audit_final_20nm/hotspots/east_breakwater_marks_right.png`
+- diff image: `report/strict_audit_final_20nm/hotspots/east_breakwater_marks_diff.png`
+
+### 高岛主港航标区
+
+- id: `takashima_main_harbor_marks`
+- notes: `主港口灯塔、红绿标识、近岸障碍标记。优先验证灯塔、小灯、红绿标识是否真正恢复。`
+- backend focus: `['p航路標識群', 'safety_icon_missing', 'text_missing_in_engineering']`
+- changed pixels: `9533` / `11760`
+- changed ratio: `0.810629`
+- mean abs rgb: `[24.2571, 22.39, 27.698]`
+- pane box norm: `[0.34, 0.05, 0.57, 0.25]`
+- pane box pixels: `[218, 20, 365, 100]`
+- left image: `report/strict_audit_final_20nm/hotspots/takashima_main_harbor_marks_left.png`
+- right image: `report/strict_audit_final_20nm/hotspots/takashima_main_harbor_marks_right.png`
+- diff image: `report/strict_audit_final_20nm/hotspots/takashima_main_harbor_marks_diff.png`
+
+### 高岛近岸碍航与鱼礁区
+
+- id: `takashima_inner_nearshore_hazards`
+- notes: `近岸鱼礁、碍航点、灯塔与小灯混合区。优先验证鱼礁和碍航物是否还在。`
+- backend focus: `['p投錨注意障害物', 'p航行危険障害物', 'p航路標識群']`
+- changed pixels: `5708` / `7820`
+- changed ratio: `0.729923`
+- mean abs rgb: `[21.7578, 22.3706, 25.4146]`
+- pane box norm: `[0.44, 0.14, 0.62, 0.31]`
+- pane box pixels: `[282, 56, 397, 124]`
+- left image: `report/strict_audit_final_20nm/hotspots/takashima_inner_nearshore_hazards_left.png`
+- right image: `report/strict_audit_final_20nm/hotspots/takashima_inner_nearshore_hazards_right.png`
+- diff image: `report/strict_audit_final_20nm/hotspots/takashima_inner_nearshore_hazards_diff.png`
+
+## Backend Render Audit
+
+- original feature instances: `258317`
+- delivery feature instances: `258317`
+- result count: `516634`
+
+Status counts:
+
+- `extra_in_engineering`: `258317`
+- `missing_in_engineering`: `258317`
+
+Top annotation issues:
+
+- `extra_text_in_engineering` | `bathymetry_line` | `24168`
+- `text_missing_in_engineering` | `L海底地形` | `24168`
+- `extra_text_in_engineering` | `depth_contour` | `11929`
+- `text_missing_in_engineering` | `L等深線` | `11929`
+- `extra_text_in_engineering` | `seabed_text_point` | `4499`
+- `text_missing_in_engineering` | `p底質` | `4499`
+- `extra_text_in_engineering` | `navigation_marks` | `1516`
+- `text_missing_in_engineering` | `p航路標識群` | `1516`
+- `text_missing_in_engineering` | `p地名` | `1444`
+- `extra_text_in_engineering` | `place_label_sea` | `1444`
+
+Top style semantic issues:
+
+- `depth_numeric_missing` | `L海底地形` | `24168`
+- `depth_numeric_missing` | `L等深線` | `11929`
+- `safety_icon_missing` | `p航路標識群` | `1885`
+- `depth_numeric_missing` | `L概略等深線` | `350`
+- `clearance_numeric_missing` | `p高さ制限` | `33`
+
+## Interpretation
+
+- This report intentionally puts browser-rendered visual output and backend render-audit summary in one place.
+- If the browser screenshot improves but backend counts do not, the current backend audit is likely blind to a browser/runtime issue such as sprite resolution.
diff --git a/src/pbf/navsea-compare-karatsu-20nm.html b/src/pbf/navsea-compare-karatsu-20nm.html
index f893314..050a560 100644
--- a/src/pbf/navsea-compare-karatsu-20nm.html
+++ b/src/pbf/navsea-compare-karatsu-20nm.html
@@ -314,21 +314,21 @@