tatsu1710 Claude Fable 5 commited on
Commit
bd15030
·
1 Parent(s): 1679a24

多クラス損傷セグメンテーション: 剥離・鉄筋露出・遊離石灰の検出+比較スライダーUI+PDF調書反映

Browse files

- dacl10k(実橋梁点検データ)でYOLOv8s-segを24エポック学習した3クラスモデルを追加
- crack(既存)+damage(新規)の2段推論。モデル未配置環境はcrack-onlyに自動デグレード
- UI: 元画像/AI解析の比較スライダー・クラス別色分けマスク・信頼度タグ・面的損傷テーブル
- PDF: 表紙に面的損傷数、面的損傷テーブル、その5損傷図に⑦剥離・鉄筋露出/⑧漏水・遊離石灰
- モバイル: 表の横スクロール化とグリッドはみ出し修正

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

.dockerignore CHANGED
@@ -14,3 +14,4 @@ docs/
14
  *.mov
15
  *.zip
16
  .DS_Store
 
 
14
  *.mov
15
  *.zip
16
  .DS_Store
17
+ dataset/dacl_damage/
.gitignore CHANGED
@@ -19,3 +19,7 @@ docs/
19
  # misc large/local-only files
20
  *.mov
21
  *.zip
 
 
 
 
 
19
  # misc large/local-only files
20
  *.mov
21
  *.zip
22
+
23
+ # dacl10k training data (local only)
24
+ dataset/dacl_damage/
25
+ yolov8s-seg.pt
app.py CHANGED
@@ -141,16 +141,20 @@ def detect():
141
  label = name
142
 
143
  t0 = time.time()
144
- _, ovp, cracks = R.analyze(path, tiled=tiled, out_dir=GEN, conf=conf)
145
  dt = time.time() - t0
146
  total_len = sum(c["length"] for c in cracks)
 
147
  return jsonify(
148
  label=label,
149
  ref=os.path.basename(path), # name to pass back to /api/report
150
  overlay_url="/generated/" + os.path.basename(ovp) + f"?t={int(time.time())}",
151
  source_url=("/generated/uploads/" + os.path.basename(path)) if path.startswith(UPLOAD) else ("/dataset/" + label),
152
  cracks=_serialize(cracks),
153
- summary=dict(count=len(cracks), total_len_px=round(total_len, 0),
 
 
 
154
  mode=("tiled" if tiled else "fast"), seconds=round(dt, 2)),
155
  )
156
 
@@ -214,8 +218,8 @@ def make_report():
214
  # 画像を1回だけ解析(その5損傷図と写真台帳明細で共用 → 二重推論を避ける)
215
  sections = []
216
  for p in paths:
217
- name, ovp, cracks = R.analyze(p, tiled=tiled, out_dir=GEN, conf=conf)
218
- sections.append((name, ovp, cracks))
219
 
220
  uid = uuid.uuid4().hex[:8]
221
  map_pdf = os.path.join(GEN, f"rmap_{uid}.pdf")
 
141
  label = name
142
 
143
  t0 = time.time()
144
+ _, ovp, cracks, damages = R.analyze(path, tiled=tiled, out_dir=GEN, conf=conf)
145
  dt = time.time() - t0
146
  total_len = sum(c["length"] for c in cracks)
147
+ import detect_damage_ml as DD
148
  return jsonify(
149
  label=label,
150
  ref=os.path.basename(path), # name to pass back to /api/report
151
  overlay_url="/generated/" + os.path.basename(ovp) + f"?t={int(time.time())}",
152
  source_url=("/generated/uploads/" + os.path.basename(path)) if path.startswith(UPLOAD) else ("/dataset/" + label),
153
  cracks=_serialize(cracks),
154
+ damages=DD.serialize(damages),
155
+ damage_model=DD.available(),
156
+ summary=dict(count=len(cracks), damage_count=len(damages),
157
+ total_len_px=round(total_len, 0),
158
  mode=("tiled" if tiled else "fast"), seconds=round(dt, 2)),
159
  )
160
 
 
218
  # 画像を1回だけ解析(その5損傷図と写真台帳明細で共用 → 二重推論を避ける)
219
  sections = []
220
  for p in paths:
221
+ name, ovp, cracks, damages = R.analyze(p, tiled=tiled, out_dir=GEN, conf=conf)
222
+ sections.append((name, ovp, cracks, damages))
223
 
224
  uid = uuid.uuid4().hex[:8]
225
  map_pdf = os.path.join(GEN, f"rmap_{uid}.pdf")
models/damage_seg/best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3b53a19c05e130f7f0798fcf60630f1b9d6b22eb65cc24cca0354f0dc4279bed
3
+ size 23836596
src/detect_damage_ml.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Area-type damage segmentation (剥離 / 鉄筋露出 / 遊離石灰) with a YOLO-seg model
3
+ fine-tuned on dacl10k (Spalling / ExposedRebars / Efflorescence).
4
+
5
+ Runs alongside the crack model: cracks keep their thin-line pipeline in
6
+ detect_2d_ml / report; this module contributes per-instance polygonal masks with
7
+ class + confidence for the surface damages the crack model cannot represent.
8
+
9
+ The model file is optional — deployments without it degrade to crack-only.
10
+ """
11
+ import os
12
+ import numpy as np
13
+ import cv2
14
+
15
+ _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
16
+ MODEL_PATH = os.environ.get(
17
+ "DAMAGE_MODEL", os.path.join(_ROOT, "models", "damage_seg", "best.pt"))
18
+
19
+ # class id (training order) -> ledger label / overlay BGR color
20
+ CLASS_INFO = {
21
+ 0: dict(key="spalling", label="剥離", color=(0, 122, 255)), # orange
22
+ 1: dict(key="exposed_rebars", label="鉄筋露出", color=(180, 60, 200)), # purple
23
+ 2: dict(key="efflorescence", label="遊離石灰", color=(200, 180, 40)), # teal-blue
24
+ }
25
+ # 損傷種類番号(damage_map.DAMAGE_TYPES 準拠): ⑦剥離・鉄筋露出 / ⑧漏水・遊離石灰
26
+ CLASS_DAMAGE_NO = {"spalling": 7, "exposed_rebars": 7, "efflorescence": 8}
27
+
28
+ _MODEL = [None]
29
+
30
+
31
+ def available():
32
+ return os.path.isfile(MODEL_PATH)
33
+
34
+
35
+ def _model():
36
+ if _MODEL[0] is None:
37
+ from ultralytics import YOLO
38
+ _MODEL[0] = YOLO(MODEL_PATH)
39
+ return _MODEL[0]
40
+
41
+
42
+ def detect(bgr, conf=0.30):
43
+ """Return a list of per-instance damages on the (possibly resized) image:
44
+ [{key,label,conf,mask(bool HxW),area_px,cx,cy}] sorted by area desc."""
45
+ if not available():
46
+ return []
47
+ H, W = bgr.shape[:2]
48
+ # 640 = training imgsz; keeps CPU inference a few seconds on the demo Space
49
+ r = _model().predict(bgr, conf=conf, imgsz=640, verbose=False)[0]
50
+ out = []
51
+ if r.masks is None:
52
+ return out
53
+ for m, box in zip(r.masks.data.cpu().numpy(), r.boxes):
54
+ cid = int(box.cls[0])
55
+ info = CLASS_INFO.get(cid)
56
+ if info is None:
57
+ continue
58
+ mm = cv2.resize(m, (W, H), interpolation=cv2.INTER_NEAREST) > 0.5
59
+ area = int(mm.sum())
60
+ if area < 400: # ignore speckle
61
+ continue
62
+ ys, xs = np.where(mm)
63
+ out.append(dict(key=info["key"], label=info["label"],
64
+ conf=float(box.conf[0]), mask=mm, area_px=area,
65
+ cx=int(xs.mean()), cy=int(ys.mean())))
66
+ out.sort(key=lambda d: d["area_px"], reverse=True)
67
+ return out
68
+
69
+
70
+ def paint(vis, damages, alpha=0.45):
71
+ """Blend class-colored damage masks into `vis` (same blend ratio as cracks)
72
+ and stamp a small `ラベル 0.93` tag at each instance centroid."""
73
+ if not damages:
74
+ return vis
75
+ ov = vis.copy()
76
+ for d in damages:
77
+ ov[d["mask"]] = CLASS_INFO_BY_KEY[d["key"]]["color"]
78
+ vis = cv2.addWeighted(vis, 1 - alpha, ov, alpha, 0)
79
+ for d in damages:
80
+ tag = f"{d['key']} {d['conf']:.2f}"
81
+ (tw, th), _ = cv2.getTextSize(tag, cv2.FONT_HERSHEY_DUPLEX, 0.5, 1)
82
+ x = max(2, min(d["cx"] - tw // 2, vis.shape[1] - tw - 2))
83
+ y = max(th + 4, min(d["cy"], vis.shape[0] - 4))
84
+ cv2.rectangle(vis, (x - 3, y - th - 4), (x + tw + 3, y + 3), (30, 30, 30), -1)
85
+ cv2.putText(vis, tag, (x, y), cv2.FONT_HERSHEY_DUPLEX, 0.5,
86
+ (255, 255, 255), 1, cv2.LINE_AA)
87
+ return vis
88
+
89
+
90
+ CLASS_INFO_BY_KEY = {v["key"]: v for v in CLASS_INFO.values()}
91
+
92
+
93
+ def serialize(damages, scale=None):
94
+ """JSON-safe rows for the API / ledger. scale = mm per px -> area in m2."""
95
+ rows = []
96
+ for i, d in enumerate(damages, 1):
97
+ area_m2 = (d["area_px"] * (scale ** 2) / 1e6) if scale else None
98
+ rows.append(dict(no=i, id=f"S-{i:03d}", key=d["key"], label=d["label"],
99
+ damage_no=CLASS_DAMAGE_NO[d["key"]],
100
+ conf=round(d["conf"], 2), area_px=d["area_px"],
101
+ area_m2=(round(area_m2, 3) if area_m2 else None),
102
+ cx=d["cx"], cy=d["cy"]))
103
+ return rows
src/detect_to_map.py CHANGED
@@ -41,6 +41,36 @@ def summarize(cracks, scale, structure):
41
  n=len(cracks), wmax_px=wmax_px, tot_len_px=tot_len_px)
42
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  def build(bridge, assignments, scale=DEMO_SCALE_MM_PER_PX, notes=None,
45
  ncols=4, tiled=True):
46
  """assignments を検出→要約。損傷図 data・サマリー・写真台帳エントリを返す。
@@ -49,22 +79,28 @@ def build(bridge, assignments, scale=DEMO_SCALE_MM_PER_PX, notes=None,
49
  for i, a in enumerate(assignments):
50
  sc = a.get("scale", scale)
51
  struct = a.get("structure", "RC")
52
- name, ov, cracks = R.analyze(a["image"], tiled=tiled)
53
  s = summarize(cracks, sc, struct)
54
  photo_no = a.get("photo", f"{i+1:02d}")
55
  dtype = a.get("type", 11 if a["member"].startswith("床版") else 6)
56
- if s is None:
 
57
  summary.append((a["member"], name, 0, None, "検出なし"))
58
  continue
59
- dm = {"type": dtype, "grade": s["grade"] or "?", "dim": s["dim"]}
 
60
  callouts.append(dict(
61
  anchor=a["anchor"], label_at=a.get("label_at"),
62
- items=[{"member": a["member"], "damages": [dm]}],
63
  photo=photo_no, align=a.get("align", "left"), size=a.get("size", 2.4)))
64
- summary.append((a["member"], name, s["n"], s["grade"], s["dim"]))
 
 
65
  # 写真台帳エントリ(番号付きオーバーレイ画像を使う)
66
- photos.append(dict(photo=photo_no, member=a["member"], type=dtype,
67
- grade=s["grade"] or "?", dim=s["dim"], image=ov,
 
 
68
  span=bridge.get("span", "")))
69
  data = dict(bridge=bridge, ncols=ncols, callouts=callouts,
70
  notes=notes or [])
@@ -87,23 +123,31 @@ def build_from_sections(bridge, sections, scale=DEMO_SCALE_MM_PER_PX,
87
  /api/report 用: 部材名・パネル位置を未指定なら自動割当する(再解析しない)。
88
  戻り: (data, summary, photos)"""
89
  callouts, summary, photos = [], [], []
90
- for i, (name, ov, cracks) in enumerate(sections):
 
 
91
  member = (members[i] if members and i < len(members) and members[i]
92
  else "(部材未記入)")
93
  dtype = 11 if member.startswith("床版") else 6
94
  photo_no = f"{i+1:02d}"
95
  anchor = _auto_anchor(i, ncols)
96
  s = summarize(cracks, scale, structure)
97
- if s is None:
 
98
  summary.append((member, name, 0, None, "検出なし"))
99
  continue
100
- dm = {"type": dtype, "grade": s["grade"] or "?", "dim": s["dim"]}
 
101
  callouts.append(dict(anchor=anchor, label_at=None,
102
- items=[{"member": member, "damages": [dm]}],
103
  photo=photo_no, align="left", size=2.4))
104
- summary.append((member, name, s["n"], s["grade"], s["dim"]))
105
- photos.append(dict(photo=photo_no, member=member, type=dtype,
106
- grade=s["grade"] or "?", dim=s["dim"], image=ov,
 
 
 
 
107
  span=bridge.get("span", "")))
108
  data = dict(bridge=bridge, ncols=ncols, callouts=callouts, notes=notes or [])
109
  return data, summary, photos
 
41
  n=len(cracks), wmax_px=wmax_px, tot_len_px=tot_len_px)
42
 
43
 
44
+ def damage_items(damages, scale):
45
+ """面的損傷(剥離/鉄筋露出/遊離石灰) -> 損傷図 callout の damages 配列。
46
+ 同じ損傷種類番号はまとめて面積を合算する(⑦剥離・鉄筋露出 / ⑧漏水・遊離石灰)。"""
47
+ if not damages:
48
+ return []
49
+ import detect_damage_ml as DD
50
+ by_no = {}
51
+ for d in damages:
52
+ no = int(d.get("damage_no") or DD.CLASS_DAMAGE_NO.get(d.get("key"), 7))
53
+ by_no.setdefault(no, []).append(d)
54
+ items = []
55
+ for no, ds in sorted(by_no.items()):
56
+ area_px = sum(x["area_px"] for x in ds)
57
+ if scale:
58
+ dim = f"A={area_px * (scale ** 2) / 1e6:.2f}m2"
59
+ else:
60
+ dim = f"A={area_px}px2・要校正"
61
+ # 面的損傷の損傷度は写真からの自動確定が難しい -> 区分は「?」で診断員判断
62
+ items.append({"type": no, "grade": "?", "dim": dim})
63
+ return items
64
+
65
+
66
+ def _dm_note(dms):
67
+ if not dms:
68
+ return ""
69
+ import damage_map as DM
70
+ names = "・".join(DM.DAMAGE_TYPES.get(d["type"], "") for d in dms)
71
+ return f"/{names}あり"
72
+
73
+
74
  def build(bridge, assignments, scale=DEMO_SCALE_MM_PER_PX, notes=None,
75
  ncols=4, tiled=True):
76
  """assignments を検出→要約。損傷図 data・サマリー・写真台帳エントリを返す。
 
79
  for i, a in enumerate(assignments):
80
  sc = a.get("scale", scale)
81
  struct = a.get("structure", "RC")
82
+ name, ov, cracks, damages = R.analyze(a["image"], tiled=tiled)
83
  s = summarize(cracks, sc, struct)
84
  photo_no = a.get("photo", f"{i+1:02d}")
85
  dtype = a.get("type", 11 if a["member"].startswith("床版") else 6)
86
+ dms = damage_items(damages, sc)
87
+ if s is None and not dms:
88
  summary.append((a["member"], name, 0, None, "検出なし"))
89
  continue
90
+ items = ([{"type": dtype, "grade": s["grade"] or "?", "dim": s["dim"]}]
91
+ if s else []) + dms
92
  callouts.append(dict(
93
  anchor=a["anchor"], label_at=a.get("label_at"),
94
+ items=[{"member": a["member"], "damages": items}],
95
  photo=photo_no, align=a.get("align", "left"), size=a.get("size", 2.4)))
96
+ summary.append((a["member"], name, (s["n"] if s else 0),
97
+ (s["grade"] if s else None),
98
+ (s["dim"] if s else "") + _dm_note(dms)))
99
  # 写真台帳エントリ(番号付きオーバーレイ画像を使う)
100
+ photos.append(dict(photo=photo_no, member=a["member"],
101
+ type=(dtype if s else dms[0]["type"]),
102
+ grade=(s["grade"] or "?") if s else "?",
103
+ dim=(s["dim"] if s else dms[0]["dim"]), image=ov,
104
  span=bridge.get("span", "")))
105
  data = dict(bridge=bridge, ncols=ncols, callouts=callouts,
106
  notes=notes or [])
 
123
  /api/report 用: 部材名・パネル位置を未指定なら自動割当する(再解析しない)。
124
  戻り: (data, summary, photos)"""
125
  callouts, summary, photos = [], [], []
126
+ for i, sec in enumerate(sections):
127
+ name, ov, cracks = sec[0], sec[1], sec[2]
128
+ damages = sec[3] if len(sec) > 3 else []
129
  member = (members[i] if members and i < len(members) and members[i]
130
  else "(部材未記入)")
131
  dtype = 11 if member.startswith("床版") else 6
132
  photo_no = f"{i+1:02d}"
133
  anchor = _auto_anchor(i, ncols)
134
  s = summarize(cracks, scale, structure)
135
+ dms = damage_items(damages, scale)
136
+ if s is None and not dms:
137
  summary.append((member, name, 0, None, "検出なし"))
138
  continue
139
+ items = ([{"type": dtype, "grade": s["grade"] or "?", "dim": s["dim"]}]
140
+ if s else []) + dms
141
  callouts.append(dict(anchor=anchor, label_at=None,
142
+ items=[{"member": member, "damages": items}],
143
  photo=photo_no, align="left", size=2.4))
144
+ summary.append((member, name, (s["n"] if s else 0),
145
+ (s["grade"] if s else None),
146
+ (s["dim"] if s else "") + _dm_note(dms)))
147
+ photos.append(dict(photo=photo_no, member=member,
148
+ type=(dtype if s else dms[0]["type"]),
149
+ grade=(s["grade"] or "?") if s else "?",
150
+ dim=(s["dim"] if s else dms[0]["dim"]), image=ov,
151
  span=bridge.get("span", "")))
152
  data = dict(bridge=bridge, ncols=ncols, callouts=callouts, notes=notes or [])
153
  return data, summary, photos
src/report.py CHANGED
@@ -114,8 +114,12 @@ def crack_metrics(comp, gray):
114
  area=core_area, cx=cx, cy=cy)
115
 
116
 
117
- def analyze(path, tiled=True, out_dir=OUT, conf=0.15):
 
 
 
118
  from detect_2d_ml import infer_whole
 
119
  bgr = cv2.imread(path); H, W = bgr.shape[:2]
120
  s = min(1.0, 1600 / max(H, W))
121
  if s < 1: bgr = cv2.resize(bgr, (int(W*s), int(H*s)))
@@ -132,9 +136,12 @@ def analyze(path, tiled=True, out_dir=OUT, conf=0.15):
132
  for k, c in enumerate(cracks, 1):
133
  c["id"] = f"C-{k:03d}"; c["no"] = k
134
 
135
- # numbered overlay (badge shows the sequential No., centered)
 
 
136
  ov = bgr.copy(); ov[mask > 0] = (0, 0, 255)
137
  vis = cv2.addWeighted(bgr, 0.55, ov, 0.45, 0)
 
138
  for c in cracks:
139
  t = str(c["no"]); r = 18 if len(t) >= 2 else 15
140
  cv2.circle(vis, (c["cx"], c["cy"]), r, (255, 255, 255), -1)
@@ -146,7 +153,7 @@ def analyze(path, tiled=True, out_dir=OUT, conf=0.15):
146
  os.makedirs(out_dir, exist_ok=True)
147
  ovp = os.path.join(out_dir, f"rep_{name}.png")
148
  cv2.imwrite(ovp, vis)
149
- return name, ovp, cracks
150
 
151
 
152
  def fmt_mm(px, scale=None):
@@ -182,6 +189,7 @@ def build_pdf(sections, out_pdf=PDF, scale=None, meta=META):
182
  el = []
183
  total = sum(len(s[2]) for s in sections)
184
  total_len = sum(c["length"] for s in sections for c in s[2])
 
185
 
186
  # per-section worst grade -> overall bridge health (worst member governs)
187
  def section_grade(cracks):
@@ -192,8 +200,8 @@ def build_pdf(sections, out_pdf=PDF, scale=None, meta=META):
192
  order = "ABCDE"
193
  return max(grades, key=lambda g: order.index(g)) if grades else None
194
  worst = None
195
- for _, _, cr in sections:
196
- g = section_grade(cr)
197
  if g and (worst is None or "ABCDE".index(g) > "ABCDE".index(worst)):
198
  worst = g
199
  health = GRADE_TO_HEALTH[worst] if worst else None
@@ -214,6 +222,7 @@ def build_pdf(sections, out_pdf=PDF, scale=None, meta=META):
214
  ["点検日", meta["date"]], ["点検者", meta["inspector"]],
215
  *([["検出モード", meta["mode"]]] if meta.get("mode") else []),
216
  ["検出ひび総数", f"{total} 本"],
 
217
  ["ひび総延長(損傷寸法)", len_txt],
218
  ["最大損傷度区分", (f"{worst}({GRADE_NAME[worst]})" if worst else "—(要校正)")],
219
  ["健全性の診断(目安)", health_txt]]
@@ -240,7 +249,9 @@ def build_pdf(sections, out_pdf=PDF, scale=None, meta=META):
240
  el += [PageBreak()]
241
 
242
  hdr = ["No.", "ひびID", "長さ(px)", "長さ(mm)", "最大幅(px)", "幅(mm)", "損傷度"]
243
- for name, ovp, cracks in sections:
 
 
244
  sgrade = section_grade(cracks)
245
  shealth = GRADE_TO_HEALTH[sgrade] if sgrade else None
246
  head = f"損傷図:{name}"
@@ -280,7 +291,37 @@ def build_pdf(sections, out_pdf=PDF, scale=None, meta=META):
280
  note = "ひび1本のため最小間隔は評価対象外(区分は「間隔大」として算定)"
281
  else:
282
  note = "損傷度・健全性はスケール(mm/px)入力後に算出されます"
283
- el += [tb, Spacer(1, 2*mm), Paragraph(note, small), Spacer(1, 6*mm), PageBreak()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
  doc.build(el)
286
  print("PDF ->", out_pdf)
@@ -291,8 +332,7 @@ def generate(image_paths, out_pdf=PDF, scale=None, tiled=True, out_dir=OUT, meta
291
  """Analyze images and build a report PDF. Returns the PDF path."""
292
  sections = []
293
  for f in image_paths:
294
- name, ovp, cracks = analyze(f, tiled=tiled, out_dir=out_dir, conf=conf)
295
- sections.append((name, ovp, cracks))
296
  return build_pdf(sections, out_pdf=out_pdf, scale=scale, meta=meta)
297
 
298
 
 
114
  area=core_area, cx=cx, cy=cy)
115
 
116
 
117
+ def analyze(path, tiled=True, out_dir=OUT, conf=0.15, with_damage=True):
118
+ """Detect thin cracks (line pipeline) + area damages (剥離/鉄筋露出/遊離石灰).
119
+ Returns (name, overlay_path, cracks, damages). Older 3-tuple callers keep
120
+ working via the Sections helper below."""
121
  from detect_2d_ml import infer_whole
122
+ import detect_damage_ml as DD
123
  bgr = cv2.imread(path); H, W = bgr.shape[:2]
124
  s = min(1.0, 1600 / max(H, W))
125
  if s < 1: bgr = cv2.resize(bgr, (int(W*s), int(H*s)))
 
136
  for k, c in enumerate(cracks, 1):
137
  c["id"] = f"C-{k:03d}"; c["no"] = k
138
 
139
+ damages = DD.detect(bgr) if (with_damage and DD.available()) else []
140
+
141
+ # overlay: crack mask in red + class-colored damage masks, then instance tags
142
  ov = bgr.copy(); ov[mask > 0] = (0, 0, 255)
143
  vis = cv2.addWeighted(bgr, 0.55, ov, 0.45, 0)
144
+ vis = DD.paint(vis, damages)
145
  for c in cracks:
146
  t = str(c["no"]); r = 18 if len(t) >= 2 else 15
147
  cv2.circle(vis, (c["cx"], c["cy"]), r, (255, 255, 255), -1)
 
153
  os.makedirs(out_dir, exist_ok=True)
154
  ovp = os.path.join(out_dir, f"rep_{name}.png")
155
  cv2.imwrite(ovp, vis)
156
+ return name, ovp, cracks, damages
157
 
158
 
159
  def fmt_mm(px, scale=None):
 
189
  el = []
190
  total = sum(len(s[2]) for s in sections)
191
  total_len = sum(c["length"] for s in sections for c in s[2])
192
+ all_damages = [d for s in sections for d in (s[3] if len(s) > 3 else [])]
193
 
194
  # per-section worst grade -> overall bridge health (worst member governs)
195
  def section_grade(cracks):
 
200
  order = "ABCDE"
201
  return max(grades, key=lambda g: order.index(g)) if grades else None
202
  worst = None
203
+ for sec in sections:
204
+ g = section_grade(sec[2])
205
  if g and (worst is None or "ABCDE".index(g) > "ABCDE".index(worst)):
206
  worst = g
207
  health = GRADE_TO_HEALTH[worst] if worst else None
 
222
  ["点検日", meta["date"]], ["点検者", meta["inspector"]],
223
  *([["検出モード", meta["mode"]]] if meta.get("mode") else []),
224
  ["検出ひび総数", f"{total} 本"],
225
+ ["面的損傷(剥離・鉄筋露出/遊離石灰)", f"{len(all_damages)} 箇所" if all_damages else "検出なし"],
226
  ["ひび総延長(損傷寸法)", len_txt],
227
  ["最大損傷度区分", (f"{worst}({GRADE_NAME[worst]})" if worst else "—(要校正)")],
228
  ["健全性の診断(目安)", health_txt]]
 
249
  el += [PageBreak()]
250
 
251
  hdr = ["No.", "ひびID", "長さ(px)", "長さ(mm)", "最大幅(px)", "幅(mm)", "損傷度"]
252
+ for sec in sections:
253
+ name, ovp, cracks = sec[0], sec[1], sec[2]
254
+ damages = sec[3] if len(sec) > 3 else []
255
  sgrade = section_grade(cracks)
256
  shealth = GRADE_TO_HEALTH[sgrade] if sgrade else None
257
  head = f"損傷図:{name}"
 
291
  note = "ひび1本のため最小間隔は評価対象外(区分は「間隔大」として算定)"
292
  else:
293
  note = "損傷度・健全性はスケール(mm/px)入力後に算出されます"
294
+ el += [tb, Spacer(1, 2*mm), Paragraph(note, small)]
295
+
296
+ # 面的損傷(剥離・鉄筋露出/遊離石灰): AI検出の一次データ。損傷度区分は
297
+ # 写真のみでは確定できないため空欄(診断員判断)とし、面積と信頼度を記載
298
+ if damages:
299
+ import detect_damage_ml as DD
300
+ drows = [["No.", "損傷ID", "損傷種類", "面積(px²)", "面積(m²)", "AI信頼度"]]
301
+ for r in DD.serialize(damages, scale=scale):
302
+ drows.append([str(r["no"]), r["id"],
303
+ f"{r['damage_no']} {r['label']}",
304
+ f"{r['area_px']:,}",
305
+ (f"{r['area_m2']:.3f}" if r["area_m2"] else "—(要校正)"),
306
+ f"{r['conf']:.2f}"])
307
+ dtb = Table(drows, colWidths=[10*mm, 22*mm, 40*mm, 26*mm, 30*mm, 24*mm],
308
+ repeatRows=1)
309
+ dtb.setStyle(TableStyle([
310
+ ("FONTNAME", (0, 0), (-1, -1), "HeiseiKakuGo-W5"),
311
+ ("FONTSIZE", (0, 0), (-1, -1), 8.5),
312
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#5c3a1a")),
313
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
314
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#faf6f2")]),
315
+ ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cccccc")),
316
+ ("ALIGN", (3, 0), (-1, -1), "CENTER"),
317
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
318
+ ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))
319
+ el += [Spacer(1, 4*mm),
320
+ Paragraph("面的損傷(AI検出・一次データ)", h2), Spacer(1, 2*mm), dtb,
321
+ Spacer(1, 2*mm),
322
+ Paragraph("※損傷度区分は面的損傷では写真のみで確定できないため記載しない(診断員判断)。"
323
+ "面積はマスク画素数×スケール²による概算。", small)]
324
+ el += [Spacer(1, 6*mm), PageBreak()]
325
 
326
  doc.build(el)
327
  print("PDF ->", out_pdf)
 
332
  """Analyze images and build a report PDF. Returns the PDF path."""
333
  sections = []
334
  for f in image_paths:
335
+ sections.append(analyze(f, tiled=tiled, out_dir=out_dir, conf=conf))
 
336
  return build_pdf(sections, out_pdf=out_pdf, scale=scale, meta=meta)
337
 
338
 
src/resume_damage.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resume damage_seg training from the last checkpoint, then deploy best.pt.
2
+ Launched detached (nohup + caffeinate) so it survives the editor session."""
3
+ import os, shutil
4
+ from ultralytics import YOLO
5
+
6
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
7
+ last = os.path.join(ROOT, "runs", "damage_seg", "weights", "last.pt")
8
+ model = YOLO(last)
9
+ model.train(resume=True)
10
+ best = os.path.join(ROOT, "runs", "damage_seg", "weights", "best.pt")
11
+ dst = os.path.join(ROOT, "models", "damage_seg", "best.pt")
12
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
13
+ shutil.copy2(best, dst)
14
+ print("deployed ->", dst)
src/train_damage.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fine-tune YOLOv8s-seg on dacl10k (spalling / exposed_rebars / efflorescence).
2
+
3
+ Dataset is built by the one-off prep script (see 作業記録): dacl10k FiftyOne
4
+ polylines -> YOLO-seg labels under dataset/dacl_damage/. Trains on Apple MPS.
5
+ Deployed weights go to models/damage_seg/best.pt (read by detect_damage_ml).
6
+ """
7
+ import os, shutil
8
+ from ultralytics import YOLO
9
+
10
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
11
+ DATA = os.path.join(ROOT, "dataset", "dacl_damage", "data.yaml")
12
+
13
+ def main():
14
+ model = YOLO("yolov8s-seg.pt")
15
+ model.train(data=DATA, epochs=24, imgsz=640, batch=8, device="mps",
16
+ project=os.path.join(ROOT, "runs"), name="damage_seg",
17
+ patience=8, workers=4, cos_lr=True, plots=False)
18
+ best = os.path.join(ROOT, "runs", "damage_seg", "weights", "best.pt")
19
+ dst = os.path.join(ROOT, "models", "damage_seg", "best.pt")
20
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
21
+ shutil.copy2(best, dst)
22
+ print("deployed ->", dst)
23
+
24
+ if __name__ == "__main__":
25
+ main()
web/index.html CHANGED
@@ -244,11 +244,25 @@
244
  <div id="placeholder" class="placeholder">
245
  <div class="ph-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.3 15.3L21 21"/></svg></div><p>ここに検出結果が表示されます</p>
246
  </div>
247
- <img id="image" alt="" hidden />
 
 
 
 
 
 
248
  <div id="spinner" class="spinner" hidden><div class="ring"></div><span>解析中…</span></div>
249
  </div>
 
 
 
 
 
 
 
250
  <div id="summary" class="summary" hidden>
251
  <div class="chip"><span class="k">検出ひび</span><span id="sCount" class="v">–</span></div>
 
252
  <div class="chip"><span class="k">総延長</span><span id="sLen" class="v">–</span></div>
253
  <div class="chip"><span class="k">最大損傷度</span><span id="sGrade" class="v">–</span></div>
254
  <div class="chip"><span class="k">健全性(目安)</span><span id="sHealth" class="v">–</span></div>
@@ -261,6 +275,16 @@
261
  </tr></thead>
262
  <tbody id="tbody"></tbody>
263
  </table>
 
 
 
 
 
 
 
 
 
 
264
  <p class="note">損傷の種類「06 ひびわれ」(道路橋定期点検要領H31/熊本県マニュアル準拠)。損傷度A〜Eは <b>ひびわれ幅×最小間隔</b> で評価(RC:幅0.2/0.3mm・間隔0.5m/PC:0.1/0.2mm)。健全性Ⅰ〜Ⅳは最も厳しい損傷度からの<b>自動目安</b>で、最終判定は橋梁診断員が行います。mm・損傷度はスケール入力後に算出。貫通深さは2D写真では測定不可。PDF調書は検出時と同じモード(標準/高精度)で再解析して作成します。<b>生成したPDFは必ずダウンロード保存してください</b>(サーバ上のリンクは一定期間で無効になります)。</p>
265
  </div>
266
  </section>
 
244
  <div id="placeholder" class="placeholder">
245
  <div class="ph-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.3 15.3L21 21"/></svg></div><p>ここに検出結果が表示されます</p>
246
  </div>
247
+ <div id="cmpWrap" class="cmp-wrap" hidden>
248
+ <img id="image" alt="解析結果" />
249
+ <img id="imageBefore" alt="元画像" hidden />
250
+ <div id="cmpHandle" class="cmp-handle" hidden><span class="cmp-grip"></span></div>
251
+ <span id="cmpTagL" class="cmp-tag l" hidden>元画像</span>
252
+ <span id="cmpTagR" class="cmp-tag r" hidden>AI解析</span>
253
+ </div>
254
  <div id="spinner" class="spinner" hidden><div class="ring"></div><span>解析中…</span></div>
255
  </div>
256
+ <div id="legend" class="legend" hidden>
257
+ <span class="lg"><i style="background:#e02020"></i>ひびわれ</span>
258
+ <span class="lg"><i style="background:#ff7a00"></i>剥離</span>
259
+ <span class="lg"><i style="background:#c83cb4"></i>鉄筋露出</span>
260
+ <span class="lg"><i style="background:#28b4c8"></i>遊離石灰</span>
261
+ <span class="lg muted" id="legendNote">マスク=AI検出領域/数字=ひびNo./タグ=損傷種類と信頼度</span>
262
+ </div>
263
  <div id="summary" class="summary" hidden>
264
  <div class="chip"><span class="k">検出ひび</span><span id="sCount" class="v">–</span></div>
265
+ <div class="chip"><span class="k">面的損傷</span><span id="sDamage" class="v">–</span></div>
266
  <div class="chip"><span class="k">総延長</span><span id="sLen" class="v">–</span></div>
267
  <div class="chip"><span class="k">最大損傷度</span><span id="sGrade" class="v">–</span></div>
268
  <div class="chip"><span class="k">健全性(目安)</span><span id="sHealth" class="v">–</span></div>
 
275
  </tr></thead>
276
  <tbody id="tbody"></tbody>
277
  </table>
278
+ <div id="dTableWrap" hidden>
279
+ <h2 class="dtable-head">面的損傷(剥離・鉄筋露出・遊離石灰)</h2>
280
+ <table class="ctable dtable">
281
+ <thead><tr>
282
+ <th>No.</th><th>損傷ID</th><th>損傷種類</th><th>面積(px²)</th><th>面積(m²)</th><th>AI信頼度</th>
283
+ </tr></thead>
284
+ <tbody id="dtbody"></tbody>
285
+ </table>
286
+ <p class="note">損傷の種類「⑦剥離・鉄筋露出」「⑧漏水・遊離石灰」。面的損傷の損傷度区分は写真のみでは確定できないため表示しません(診断員判断)。面積(m²)はスケール入力後に算出される概算値です。</p>
287
+ </div>
288
  <p class="note">損傷の種類「06 ひびわれ」(道路橋定期点検要領H31/熊本県マニュアル準拠)。損傷度A〜Eは <b>ひびわれ幅×最小間隔</b> で評価(RC:幅0.2/0.3mm・間隔0.5m/PC:0.1/0.2mm)。健全性Ⅰ〜Ⅳは最も厳しい損傷度からの<b>自動目安</b>で、最終判定は橋梁診断員が行います。mm・損傷度はスケール入力後に算出。貫通深さは2D写真では測定不可。PDF調書は検出時と同じモード(標準/高精度)で再解析して作成します。<b>生成したPDFは必ずダウンロード保存してください</b>(サーバ上のリンクは一定期間で無効になります)。</p>
289
  </div>
290
  </section>
web/static/app.js CHANGED
@@ -1,5 +1,5 @@
1
  const $ = (id) => document.getElementById(id);
2
- const state = { sample: null, file: null, ref: null, cracks: [], selected: new Set() };
3
 
4
  // 損傷図(その5)ヘッダの入力欄をまとめて payload 化する
5
  const bridgeFields = () => ({
@@ -165,6 +165,8 @@ $("batchReportBtn").onclick = async () => {
165
  function showSource(url, label) {
166
  const im = $("image");
167
  im.src = url; im.hidden = false;
 
 
168
  $("placeholder").hidden = true;
169
  $("stage").classList.remove("empty");
170
  $("title").textContent = label;
@@ -173,8 +175,45 @@ function showSource(url, label) {
173
  $("reportBtn").disabled = true;
174
  $("summary").hidden = true;
175
  $("tableWrap").hidden = true;
 
 
176
  }
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  // ---------- upload ----------
179
  const dz = $("dropzone"), fi = $("fileInput");
180
  dz.onclick = () => fi.click();
@@ -204,14 +243,18 @@ $("detectBtn").onclick = async () => {
204
  const r = await fetch("/api/detect", { method: "POST", body: fd });
205
  if (!r.ok) throw new Error("detect failed");
206
  const d = await r.json();
207
- state.ref = d.ref; state.cracks = d.cracks;
208
  window.markStep("view2d");
209
  if (window.LDD) window.LDD.touch({ crackCount: d.summary.count });
210
  $("image").src = d.overlay_url;
 
211
  $("title").textContent = d.label;
212
- $("subtitle").textContent = `${d.summary.count} 本のひびを検出`;
 
 
213
  renderSummary(d.summary);
214
  renderTable();
 
215
  $("reportBtn").disabled = false;
216
  } catch (e) {
217
  alert("検出に失敗しました: " + e.message);
@@ -223,10 +266,32 @@ $("detectBtn").onclick = async () => {
223
 
224
  function renderSummary(s) {
225
  $("sCount").textContent = s.count + " 本";
 
226
  $("sLen").textContent = Math.round(s.total_len_px).toLocaleString() + " px";
227
  $("summary").hidden = false;
228
  }
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  // ---------- official 06 ひびわれ grading (道路橋定期点検要領H31 / 熊本県) ----------
231
  const GRADE_NAME = { A: "良好", B: "ほぼ良好", C: "軽度", D: "顕著", E: "深刻" };
232
  const GRADE_TO_HEALTH = { A: "I", B: "II", C: "II", D: "III", E: "IV" };
@@ -296,7 +361,7 @@ function renderTable() {
296
  }
297
  $("tableWrap").hidden = false;
298
  }
299
- $("scale").oninput = () => { if (!$("tableWrap").hidden) renderTable(); };
300
  $("structure").onchange = () => { if (!$("tableWrap").hidden) renderTable(); };
301
  $("conf").oninput = () => { $("confVal").textContent = parseFloat($("conf").value).toFixed(2); };
302
 
 
1
  const $ = (id) => document.getElementById(id);
2
+ const state = { sample: null, file: null, ref: null, cracks: [], damages: [], selected: new Set() };
3
 
4
  // 損傷図(その5)ヘッダの入力欄をまとめて payload 化する
5
  const bridgeFields = () => ({
 
165
  function showSource(url, label) {
166
  const im = $("image");
167
  im.src = url; im.hidden = false;
168
+ $("cmpWrap").hidden = false;
169
+ compareOff();
170
  $("placeholder").hidden = true;
171
  $("stage").classList.remove("empty");
172
  $("title").textContent = label;
 
175
  $("reportBtn").disabled = true;
176
  $("summary").hidden = true;
177
  $("tableWrap").hidden = true;
178
+ $("legend").hidden = true;
179
+ $("dTableWrap").hidden = true;
180
  }
181
 
182
+ // ---------- before/after comparison slider ----------
183
+ function compareOff() {
184
+ $("imageBefore").hidden = true;
185
+ $("cmpHandle").hidden = true;
186
+ $("cmpTagL").hidden = true;
187
+ $("cmpTagR").hidden = true;
188
+ }
189
+ function compareOn(beforeUrl) {
190
+ const b = $("imageBefore");
191
+ b.src = beforeUrl; b.hidden = false;
192
+ $("cmpHandle").hidden = false;
193
+ $("cmpTagL").hidden = false;
194
+ $("cmpTagR").hidden = false;
195
+ setCut(50);
196
+ }
197
+ function setCut(pct) {
198
+ pct = Math.max(0, Math.min(100, pct));
199
+ $("cmpWrap").style.setProperty("--cut", pct + "%");
200
+ }
201
+ (() => {
202
+ const wrap = $("cmpWrap");
203
+ let dragging = false;
204
+ const move = (clientX) => {
205
+ const r = wrap.getBoundingClientRect();
206
+ setCut(((clientX - r.left) / r.width) * 100);
207
+ };
208
+ wrap.addEventListener("pointerdown", (e) => {
209
+ if ($("cmpHandle").hidden) return;
210
+ dragging = true; wrap.setPointerCapture(e.pointerId); move(e.clientX);
211
+ });
212
+ wrap.addEventListener("pointermove", (e) => { if (dragging) move(e.clientX); });
213
+ wrap.addEventListener("pointerup", () => { dragging = false; });
214
+ wrap.addEventListener("pointercancel", () => { dragging = false; });
215
+ })();
216
+
217
  // ---------- upload ----------
218
  const dz = $("dropzone"), fi = $("fileInput");
219
  dz.onclick = () => fi.click();
 
243
  const r = await fetch("/api/detect", { method: "POST", body: fd });
244
  if (!r.ok) throw new Error("detect failed");
245
  const d = await r.json();
246
+ state.ref = d.ref; state.cracks = d.cracks; state.damages = d.damages || [];
247
  window.markStep("view2d");
248
  if (window.LDD) window.LDD.touch({ crackCount: d.summary.count });
249
  $("image").src = d.overlay_url;
250
+ if (d.source_url) compareOn(d.source_url);
251
  $("title").textContent = d.label;
252
+ const dmTxt = d.summary.damage_count ? `・面的損傷 ${d.summary.damage_count} 箇所` : "";
253
+ $("subtitle").textContent = `${d.summary.count} 本のひびを検出${dmTxt}(スライダーで元画像と比較できます)`;
254
+ $("legend").hidden = false;
255
  renderSummary(d.summary);
256
  renderTable();
257
+ renderDamageTable();
258
  $("reportBtn").disabled = false;
259
  } catch (e) {
260
  alert("検出に失敗しました: " + e.message);
 
266
 
267
  function renderSummary(s) {
268
  $("sCount").textContent = s.count + " 本";
269
+ $("sDamage").textContent = (s.damage_count || 0) + " 箇所";
270
  $("sLen").textContent = Math.round(s.total_len_px).toLocaleString() + " px";
271
  $("summary").hidden = false;
272
  }
273
 
274
+ const DAMAGE_NO_MARK = { 7: "⑦", 8: "⑧" };
275
+ function renderDamageTable() {
276
+ const list = state.damages || [];
277
+ if (!list.length) { $("dTableWrap").hidden = true; return; }
278
+ const scale = parseFloat($("scale").value);
279
+ const hasScale = !isNaN(scale) && scale > 0;
280
+ const tb = $("dtbody");
281
+ tb.innerHTML = "";
282
+ list.forEach((d) => {
283
+ const m2 = hasScale ? ((d.area_px * scale * scale) / 1e6).toFixed(3) : "—(要校正)";
284
+ const tr = document.createElement("tr");
285
+ tr.innerHTML =
286
+ `<td><span class="badge dmg">${d.no}</span></td>` +
287
+ `<td>${d.id}</td>` +
288
+ `<td><span class="dm-${d.key}">${DAMAGE_NO_MARK[d.damage_no] || d.damage_no} ${d.label}</span></td>` +
289
+ `<td>${d.area_px.toLocaleString()}</td><td>${m2}</td><td>${d.conf.toFixed(2)}</td>`;
290
+ tb.appendChild(tr);
291
+ });
292
+ $("dTableWrap").hidden = false;
293
+ }
294
+
295
  // ---------- official 06 ひびわれ grading (道路橋定期点検要領H31 / 熊本県) ----------
296
  const GRADE_NAME = { A: "良好", B: "ほぼ良好", C: "軽度", D: "顕著", E: "深刻" };
297
  const GRADE_TO_HEALTH = { A: "I", B: "II", C: "II", D: "III", E: "IV" };
 
361
  }
362
  $("tableWrap").hidden = false;
363
  }
364
+ $("scale").oninput = () => { if (!$("tableWrap").hidden) { renderTable(); renderDamageTable(); } };
365
  $("structure").onchange = () => { if (!$("tableWrap").hidden) renderTable(); };
366
  $("conf").oninput = () => { $("confVal").textContent = parseFloat($("conf").value).toFixed(2); };
367
 
web/static/style.css CHANGED
@@ -362,6 +362,9 @@ button:disabled{opacity:.45; cursor:not-allowed}
362
 
363
  /* ---- table ---- */
364
  .table-wrap{margin-top:20px}
 
 
 
365
  .ctable{width:100%; border-collapse:collapse; font-size:13px}
366
  .ctable th{background:var(--navy); color:#fff; padding:9px 10px; text-align:center; font-weight:600; white-space:nowrap}
367
  .ctable th:nth-child(2),.ctable td:nth-child(2){text-align:left}
@@ -436,3 +439,34 @@ button:disabled{opacity:.45; cursor:not-allowed}
436
  #viewmap #m_image{max-width:100%; max-height:62vh; width:auto; height:auto; object-fit:contain}
437
  #viewmap #m_summary{flex:0 0 auto; margin-top:10px}
438
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
 
363
  /* ---- table ---- */
364
  .table-wrap{margin-top:20px}
365
+ .table-wrap table{min-width:520px}
366
+ .table-wrap, #dTableWrap{overflow-x:auto}
367
+ .layout>.panel{min-width:0}
368
  .ctable{width:100%; border-collapse:collapse; font-size:13px}
369
  .ctable th{background:var(--navy); color:#fff; padding:9px 10px; text-align:center; font-weight:600; white-space:nowrap}
370
  .ctable th:nth-child(2),.ctable td:nth-child(2){text-align:left}
 
439
  #viewmap #m_image{max-width:100%; max-height:62vh; width:auto; height:auto; object-fit:contain}
440
  #viewmap #m_summary{flex:0 0 auto; margin-top:10px}
441
  }
442
+
443
+ /* ---- before/after comparison slider (2D detection) ---- */
444
+ .cmp-wrap{position:relative; --cut:50%; max-width:100%; line-height:0; touch-action:none;
445
+ display:inline-block}
446
+ .cmp-wrap img{width:100%; max-width:100%; height:auto; display:block; max-height:64vh; object-fit:contain}
447
+ #imageBefore{position:absolute; inset:0; height:100%;
448
+ clip-path:inset(0 calc(100% - var(--cut)) 0 0); pointer-events:none}
449
+ .cmp-handle{position:absolute; top:0; bottom:0; left:var(--cut); width:2px;
450
+ background:#fff; box-shadow:0 0 0 1px rgba(0,0,0,.35); cursor:ew-resize}
451
+ .cmp-grip{position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
452
+ width:30px; height:30px; border-radius:50%; background:#fff;
453
+ box-shadow:0 1px 6px rgba(0,0,0,.4); cursor:ew-resize}
454
+ .cmp-grip::before{content:"◂ ▸"; position:absolute; inset:0; display:grid;
455
+ place-items:center; font-size:10px; color:#334; letter-spacing:-1px}
456
+ .cmp-tag{position:absolute; top:10px; padding:3px 9px; border-radius:6px;
457
+ font-size:11px; line-height:1.4; background:rgba(13,27,42,.72); color:#fff}
458
+ .cmp-tag.l{left:10px}
459
+ .cmp-tag.r{right:10px}
460
+
461
+ /* ---- detection legend / damage table ---- */
462
+ .legend{display:flex; flex-wrap:wrap; gap:8px 16px; align-items:center;
463
+ margin-top:10px; font-size:12px; color:var(--ink)}
464
+ .legend .lg{display:inline-flex; align-items:center; gap:6px}
465
+ .legend .lg i{width:12px; height:12px; border-radius:3px; display:inline-block}
466
+ .legend .muted{color:var(--muted)}
467
+ .dtable-head{font-size:14px; margin:18px 0 8px; color:#5c3a1a}
468
+ .dtable thead th{background:#5c3a1a}
469
+ .badge.dmg{background:#5c3a1a}
470
+ .dm-spalling{color:#c95f00; font-weight:600}
471
+ .dm-exposed_rebars{color:#a02c96; font-weight:600}
472
+ .dm-efflorescence{color:#12808f; font-weight:600}