多クラス損傷セグメンテーション: 剥離・鉄筋露出・遊離石灰の検出+比較スライダー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 +1 -0
- .gitignore +4 -0
- app.py +8 -4
- models/damage_seg/best.pt +3 -0
- src/detect_damage_ml.py +103 -0
- src/detect_to_map.py +58 -14
- src/report.py +49 -9
- src/resume_damage.py +14 -0
- src/train_damage.py +25 -0
- web/index.html +25 -1
- web/static/app.js +69 -4
- web/static/style.css +34 -0
.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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 57 |
summary.append((a["member"], name, 0, None, "検出なし"))
|
| 58 |
continue
|
| 59 |
-
|
|
|
|
| 60 |
callouts.append(dict(
|
| 61 |
anchor=a["anchor"], label_at=a.get("label_at"),
|
| 62 |
-
items=[{"member": a["member"], "damages":
|
| 63 |
photo=photo_no, align=a.get("align", "left"), size=a.get("size", 2.4)))
|
| 64 |
-
summary.append((a["member"], name, s["n"]
|
|
|
|
|
|
|
| 65 |
# 写真台帳エントリ(番号付きオーバーレイ画像を使う)
|
| 66 |
-
photos.append(dict(photo=photo_no, member=a["member"],
|
| 67 |
-
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 98 |
summary.append((member, name, 0, None, "検出なし"))
|
| 99 |
continue
|
| 100 |
-
|
|
|
|
| 101 |
callouts.append(dict(anchor=anchor, label_at=None,
|
| 102 |
-
items=[{"member": member, "damages":
|
| 103 |
photo=photo_no, align="left", size=2.4))
|
| 104 |
-
summary.append((member, name, s["n"]
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 196 |
-
g = section_grade(
|
| 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
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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}
|