Spaces:
Sleeping
Sleeping
Add PDF upload and YOLO routing for Spaces
Browse files- README.md +20 -15
- app.py +176 -73
- detection.py +264 -18
- models/yolobest.pt +3 -0
- requirements.txt +7 -4
README.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: 🏗️
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 5.
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
license: AItech
|
|
@@ -14,36 +15,39 @@ DEMO URL
|
|
| 14 |
https://huggingface.co/spaces/himipo/gpu_symbol
|
| 15 |
|
| 16 |
|
| 17 |
-
#
|
| 18 |
|
| 19 |
-
DEIMv2
|
| 20 |
|
| 21 |
## 機能
|
| 22 |
|
| 23 |
-
- PNG形式の図面
|
| 24 |
-
-
|
|
|
|
| 25 |
- 検出結果の可視化(バウンディングボックス + ラベル + スコア)
|
| 26 |
- 記号ごとの個数集計表示
|
| 27 |
- スコア閾値の調整(デフォルト: 0.9)
|
| 28 |
-
- クラスフィルタリング機能(
|
| 29 |
- NMS(Non-Maximum Suppression)による重複検出の自動マージ
|
| 30 |
|
| 31 |
## 使用方法
|
| 32 |
|
| 33 |
-
1. 左側に **PNG形式の図面** をアップロード
|
|
|
|
| 34 |
2. **詳細設定**を開いて以下を調整(必要に応じて):
|
| 35 |
- **スコア閾値**: 検出の信頼度閾値(デフォルト: 0.9)
|
| 36 |
-
- **
|
| 37 |
3. 「検出を実行」ボタンをクリック
|
| 38 |
4. 中央に **検出結果付き図面**、右側に **記号名称と個数** が表示されます
|
| 39 |
|
| 40 |
## モデル
|
| 41 |
|
| 42 |
- **DEIMv2**: DINOv3STAsバックボーン(ViT-Tiny)を使用した物体検出モデル
|
| 43 |
-
- **
|
|
|
|
| 44 |
- `kanki`, `kanki_shikaku`, `kanki_regisuta`
|
| 45 |
-
- `window1`, `window2`
|
| 46 |
-
- `door1`, `door2`
|
| 47 |
- `bathtub1`, `konro1`, `sink1`, `toilet1`
|
| 48 |
- `kasaikeihou1`, `kasaikeihou2`
|
| 49 |
- `houi1`, `houi2`, `houi3`
|
|
@@ -58,9 +62,10 @@ DEIMv2 (Detection Enhanced by Interaction Module v2) を使用した図面記号
|
|
| 58 |
## ファイル構成
|
| 59 |
|
| 60 |
- `app.py`: Gradio UI + 推論パイプライン
|
| 61 |
-
- `detection.py`: DEIMv2推論
|
| 62 |
- `configs/deimv2_floorplan.yaml`: モデル設定ファイル
|
| 63 |
- `models/best_stg2.pth`: モデル重みファイル(Git LFS)
|
|
|
|
| 64 |
- `engine/`: DEIMv2エンジンモジュール
|
| 65 |
- `core/`: YAMLConfig関連モジュール
|
| 66 |
- `yaml_config.py`: YAMLConfigクラス
|
|
@@ -77,7 +82,7 @@ DEIMv2 (Detection Enhanced by Interaction Module v2) を使用した図面記号
|
|
| 77 |
## 技術詳細
|
| 78 |
|
| 79 |
### タイル推論
|
| 80 |
-
大きな画像を
|
| 81 |
|
| 82 |
### NMS(Non-Maximum Suppression)
|
| 83 |
タイル推論により生じる重複検出を、クラスごとにIoU閾値0.4でNMSを適用して統合します。
|
|
@@ -94,6 +99,6 @@ DEIMv2 (Detection Enhanced by Interaction Module v2) を使用した図面記号
|
|
| 94 |
|
| 95 |
- **モデルファイルが見つからない**: Git LFSが正しく設定されているか確認してください
|
| 96 |
- **メモリ不足**: CPU UpgradeまたはGPUオプションの使用を検討してください
|
| 97 |
-
- **推論エラー**:
|
| 98 |
- **検出結果が表示されない**: スコア閾値を下げる(例: 0.5)か、検出するクラスをすべて選択してください
|
| 99 |
- **デバッグモード**: 環境変数 `DEBUG_DEIMV2=1` を設定すると、詳細��デバッグ情報が出力されます
|
|
|
|
| 1 |
---
|
| 2 |
+
title: PDF/PNG Floorplan Symbol Detection
|
| 3 |
emoji: 🏗️
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.49.1
|
| 8 |
+
python_version: 3.12.12
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
license: AItech
|
|
|
|
| 15 |
https://huggingface.co/spaces/himipo/gpu_symbol
|
| 16 |
|
| 17 |
|
| 18 |
+
# PDF/PNG 図面記号検出デモ
|
| 19 |
|
| 20 |
+
DEIMv2 と YOLO26L を自動ルーティングして使用する図面記号検出デモアプリケーションです。
|
| 21 |
|
| 22 |
## 機能
|
| 23 |
|
| 24 |
+
- PDF/PNG形式の図面からの記号検出(PDFは1ページ目のみ400dpiで変換)
|
| 25 |
+
- 選択クラスに応じたDEIMv2 / YOLO26Lの自動ルーティング
|
| 26 |
+
- タイル推論による大サイズ画像の処理
|
| 27 |
- 検出結果の可視化(バウンディングボックス + ラベル + スコア)
|
| 28 |
- 記号ごとの個数集計表示
|
| 29 |
- スコア閾値の調整(デフォルト: 0.9)
|
| 30 |
+
- クラスフィルタリング機能(対象クラスを選択可能)
|
| 31 |
- NMS(Non-Maximum Suppression)による重複検出の自動マージ
|
| 32 |
|
| 33 |
## 使用方法
|
| 34 |
|
| 35 |
+
1. 左側に **PDFまたはPNG形式の図面** をアップロード
|
| 36 |
+
- PDFの場合は1ページ目のみを400dpiで変換して推論します
|
| 37 |
2. **詳細設定**を開いて以下を調整(必要に応じて):
|
| 38 |
- **スコア閾値**: 検出の信頼度閾値(デフォルト: 0.9)
|
| 39 |
+
- **対象クラス**: チェックボックスで検出したいクラスを選択(デフォルト: door1のみ)
|
| 40 |
3. 「検出を実行」ボタンをクリック
|
| 41 |
4. 中央に **検出結果付き図面**、右側に **記号名称と個数** が表示されます
|
| 42 |
|
| 43 |
## モデル
|
| 44 |
|
| 45 |
- **DEIMv2**: DINOv3STAsバックボーン(ViT-Tiny)を使用した物体検出モデル
|
| 46 |
+
- **YOLO26L**: 窓・ドア・サッシ系クラスを担当する物体検出モデル
|
| 47 |
+
- **検出対象**: DEIMv2既存クラス + YOLO26L追加クラス
|
| 48 |
- `kanki`, `kanki_shikaku`, `kanki_regisuta`
|
| 49 |
+
- `window1`, `window2`, `window3`, `window4`, `window_hiki`, `window_hiki2`
|
| 50 |
+
- `door1`, `door2`, `sash_circle`, `sash_rect`
|
| 51 |
- `bathtub1`, `konro1`, `sink1`, `toilet1`
|
| 52 |
- `kasaikeihou1`, `kasaikeihou2`
|
| 53 |
- `houi1`, `houi2`, `houi3`
|
|
|
|
| 62 |
## ファイル構成
|
| 63 |
|
| 64 |
- `app.py`: Gradio UI + 推論パイプライン
|
| 65 |
+
- `detection.py`: DEIMv2 / YOLO26L推論ルータ(タイル推論、NMS統合)
|
| 66 |
- `configs/deimv2_floorplan.yaml`: モデル設定ファイル
|
| 67 |
- `models/best_stg2.pth`: モデル重みファイル(Git LFS)
|
| 68 |
+
- `models/yolobest.pt`: YOLO26Lモデル重みファイル(Git LFS)
|
| 69 |
- `engine/`: DEIMv2エンジンモジュール
|
| 70 |
- `core/`: YAMLConfig関連モジュール
|
| 71 |
- `yaml_config.py`: YAMLConfigクラス
|
|
|
|
| 82 |
## 技術詳細
|
| 83 |
|
| 84 |
### タイル推論
|
| 85 |
+
大きな画像をタイルに分割して推論します。DEIMv2は640×640、YOLO26Lは960×960を基本タイルとして使用し、境界付近の記号も検出できるようにオーバーラップを持たせます。
|
| 86 |
|
| 87 |
### NMS(Non-Maximum Suppression)
|
| 88 |
タイル推論により生じる重複検出を、クラスごとにIoU閾値0.4でNMSを適用して統合します。
|
|
|
|
| 99 |
|
| 100 |
- **モデルファイルが見つからない**: Git LFSが正しく設定されているか確認してください
|
| 101 |
- **メモリ不足**: CPU UpgradeまたはGPUオプションの使用を検討してください
|
| 102 |
+
- **推論エラー**: 入力がPDFまたはPNG形式であることを確認してください
|
| 103 |
- **検出結果が表示されない**: スコア閾値を下げる(例: 0.5)か、検出するクラスをすべて選択してください
|
| 104 |
- **デバッグモード**: 環境変数 `DEBUG_DEIMV2=1` を設定すると、詳細��デバッグ情報が出力されます
|
app.py
CHANGED
|
@@ -1,10 +1,17 @@
|
|
| 1 |
# app.py
|
| 2 |
from collections import Counter
|
| 3 |
from functools import lru_cache
|
| 4 |
-
|
|
|
|
|
|
|
| 5 |
import os
|
|
|
|
| 6 |
import yaml
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
import gradio as gr
|
| 9 |
import numpy as np
|
| 10 |
import spaces
|
|
@@ -12,31 +19,23 @@ from PIL import Image, ImageDraw, ImageFont
|
|
| 12 |
|
| 13 |
from detection import run_inference, Detection
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
if isinstance(schema, bool):
|
| 33 |
-
return "Any"
|
| 34 |
-
return _orig_json_schema_to_python_type_public(schema)
|
| 35 |
-
|
| 36 |
-
grc_utils.json_schema_to_python_type = _json_schema_to_python_type_safe_public
|
| 37 |
-
except Exception:
|
| 38 |
-
# パッチが失敗してもアプリ起動は継続する
|
| 39 |
-
pass
|
| 40 |
|
| 41 |
|
| 42 |
@lru_cache(maxsize=1)
|
|
@@ -44,30 +43,147 @@ def load_class_names() -> List[str]:
|
|
| 44 |
"""
|
| 45 |
設定ファイルからクラスリストを読み込む
|
| 46 |
"""
|
|
|
|
|
|
|
|
|
|
| 47 |
config_path = "configs/deimv2_floorplan.yaml"
|
| 48 |
try:
|
| 49 |
with open(config_path, 'r', encoding='utf-8') as f:
|
| 50 |
config = yaml.safe_load(f)
|
| 51 |
# Modelセクションからclass_namesを取得
|
| 52 |
if 'Model' in config and 'class_names' in config['Model']:
|
| 53 |
-
|
| 54 |
else:
|
| 55 |
# フォールバック: デフォルトのクラスリスト
|
| 56 |
-
|
| 57 |
-
"door1", "door2", "bathtub1", "konro1", "sink1", "toilet1",
|
| 58 |
-
"kasaikeihou1", "kasaikeihou2", "houi1", "houi2", "houi3"]
|
| 59 |
except Exception as e:
|
| 60 |
# エラー時はデフォルトのクラスリストを返す
|
| 61 |
print(f"Warning: Failed to load class names from config: {e}")
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def pil_to_np(img: Image.Image) -> np.ndarray:
|
| 68 |
return np.array(img.convert("RGB"))
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
def draw_detections(
|
| 72 |
image_pil: Image.Image,
|
| 73 |
detections: List[Detection],
|
|
@@ -130,38 +246,21 @@ def summarize_detections(detections: List[Detection]) -> List[List[Any]]:
|
|
| 130 |
|
| 131 |
|
| 132 |
def inference_pipeline(
|
| 133 |
-
|
| 134 |
score_thresh: float = 0.8,
|
| 135 |
selected_classes: List[str] = None,
|
| 136 |
) -> Tuple[Image.Image, List[List[Any]]]:
|
| 137 |
"""Gradio から呼ばれるメイン処理"""
|
| 138 |
-
if image is None:
|
| 139 |
-
raise gr.Error("PNG形式の図面をアップロードしてください。")
|
| 140 |
-
|
| 141 |
try:
|
| 142 |
-
|
| 143 |
-
if isinstance(image, str):
|
| 144 |
-
try:
|
| 145 |
-
img_pil = Image.open(image).convert("RGB")
|
| 146 |
-
except Exception as e:
|
| 147 |
-
raise gr.Error(f"画像ファイルの読み込みに失敗しました: {str(e)}")
|
| 148 |
-
else:
|
| 149 |
-
# 既にPIL Imageオブジェクトの場合
|
| 150 |
-
img_pil = image.convert("RGB")
|
| 151 |
-
|
| 152 |
img_np = pil_to_np(img_pil)
|
| 153 |
|
| 154 |
-
# DEIMv2
|
| 155 |
-
detections = run_inference(
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
filtered_detections = [
|
| 161 |
-
det for det in detections
|
| 162 |
-
if det[4] in selected_classes # det[4]はlabel_name
|
| 163 |
-
]
|
| 164 |
-
detections = filtered_detections
|
| 165 |
|
| 166 |
# 描画
|
| 167 |
vis_pil = draw_detections(img_pil.copy(), detections)
|
|
@@ -170,32 +269,35 @@ def inference_pipeline(
|
|
| 170 |
summary = summarize_detections(detections)
|
| 171 |
|
| 172 |
return vis_pil, summary
|
|
|
|
|
|
|
| 173 |
except Exception as e:
|
| 174 |
error_msg = f"エラーが発生しました: {str(e)}"
|
| 175 |
raise gr.Error(error_msg)
|
| 176 |
|
| 177 |
|
| 178 |
-
@spaces.GPU
|
| 179 |
def gpu_inference(
|
| 180 |
-
|
| 181 |
score_thresh: float = 0.9, # UIのデフォルト値と統一
|
| 182 |
selected_classes: List[str] = None,
|
| 183 |
):
|
| 184 |
"""Spaces ZeroGPU が検出できるようにデコレータ付きの推論関数を用意"""
|
| 185 |
-
return inference_pipeline(
|
| 186 |
|
| 187 |
|
| 188 |
# =========================
|
| 189 |
# Gradio UI
|
| 190 |
# =========================
|
| 191 |
-
with gr.Blocks(title="
|
| 192 |
gr.Markdown(
|
| 193 |
"""
|
| 194 |
# 図面記号検出デモ(by AItech)
|
| 195 |
|
| 196 |
-
1. 左側に **PNG図面** をアップロード
|
| 197 |
-
2.
|
| 198 |
-
3.
|
|
|
|
| 199 |
"""
|
| 200 |
)
|
| 201 |
|
|
@@ -205,10 +307,10 @@ with gr.Blocks(title="DEIMv2 Floorplan Symbol Detection") as demo:
|
|
| 205 |
with gr.Row():
|
| 206 |
# 左: 入力
|
| 207 |
with gr.Column(scale=1):
|
| 208 |
-
|
| 209 |
-
label="
|
| 210 |
-
|
| 211 |
-
|
| 212 |
)
|
| 213 |
|
| 214 |
# 詳細設定タブ(デフォルトは閉じた状態)
|
|
@@ -223,8 +325,8 @@ with gr.Blocks(title="DEIMv2 Floorplan Symbol Detection") as demo:
|
|
| 223 |
selected_classes = gr.CheckboxGroup(
|
| 224 |
choices=class_names,
|
| 225 |
value=["door1"], # デフォルトでdoor1のみ選択
|
| 226 |
-
label="
|
| 227 |
-
info="選択したクラス
|
| 228 |
)
|
| 229 |
|
| 230 |
run_button = gr.Button("検出を実行", variant="primary")
|
|
@@ -247,14 +349,15 @@ with gr.Blocks(title="DEIMv2 Floorplan Symbol Detection") as demo:
|
|
| 247 |
# ボタンの動作
|
| 248 |
run_button.click(
|
| 249 |
fn=gpu_inference,
|
| 250 |
-
inputs=[
|
| 251 |
outputs=[output_image, summary_dataframe],
|
| 252 |
)
|
| 253 |
|
| 254 |
# Gradio 5では、Spaces上ではdemoオブジェクトを直接エクスポートするだけで動作します
|
| 255 |
# ローカルテスト時のみdemo.launch()を呼び出します
|
| 256 |
if __name__ == "__main__":
|
| 257 |
-
|
|
|
|
| 258 |
|
| 259 |
# Spaces上では、demoオブジェクトを直接エクスポートします
|
| 260 |
# Gradio 5は自動的にdemoオブジェクトを検出して起動します
|
|
|
|
| 1 |
# app.py
|
| 2 |
from collections import Counter
|
| 3 |
from functools import lru_cache
|
| 4 |
+
import math
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Tuple, Any, List
|
| 7 |
import os
|
| 8 |
+
import warnings
|
| 9 |
import yaml
|
| 10 |
|
| 11 |
+
os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib")
|
| 12 |
+
os.environ.setdefault("YOLO_CONFIG_DIR", "/tmp/ultralytics")
|
| 13 |
+
os.makedirs(os.environ["YOLO_CONFIG_DIR"], exist_ok=True)
|
| 14 |
+
|
| 15 |
import gradio as gr
|
| 16 |
import numpy as np
|
| 17 |
import spaces
|
|
|
|
| 19 |
|
| 20 |
from detection import run_inference, Detection
|
| 21 |
|
| 22 |
+
PDF_DPI = 400
|
| 23 |
+
MAX_RENDERED_PIXELS = int(os.getenv("MAX_RENDERED_PIXELS", "120000000"))
|
| 24 |
+
SUPPORTED_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"}
|
| 25 |
+
Image.MAX_IMAGE_PIXELS = MAX_RENDERED_PIXELS
|
| 26 |
+
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
| 27 |
+
YOLO_CLASS_NAMES = [
|
| 28 |
+
"window1",
|
| 29 |
+
"window2",
|
| 30 |
+
"window3",
|
| 31 |
+
"window4",
|
| 32 |
+
"window_hiki",
|
| 33 |
+
"window_hiki2",
|
| 34 |
+
"door1",
|
| 35 |
+
"door2",
|
| 36 |
+
"sash_circle",
|
| 37 |
+
"sash_rect",
|
| 38 |
+
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
@lru_cache(maxsize=1)
|
|
|
|
| 43 |
"""
|
| 44 |
設定ファイルからクラスリストを読み込む
|
| 45 |
"""
|
| 46 |
+
default_class_names = ["kanki", "kanki_shikaku", "kanki_regisuta", "window1", "window2",
|
| 47 |
+
"door1", "door2", "bathtub1", "konro1", "sink1", "toilet1",
|
| 48 |
+
"kasaikeihou1", "kasaikeihou2", "houi1", "houi2", "houi3"]
|
| 49 |
config_path = "configs/deimv2_floorplan.yaml"
|
| 50 |
try:
|
| 51 |
with open(config_path, 'r', encoding='utf-8') as f:
|
| 52 |
config = yaml.safe_load(f)
|
| 53 |
# Modelセクションからclass_namesを取得
|
| 54 |
if 'Model' in config and 'class_names' in config['Model']:
|
| 55 |
+
class_names = config['Model']['class_names']
|
| 56 |
else:
|
| 57 |
# フォールバック: デフォルトのクラスリスト
|
| 58 |
+
class_names = default_class_names
|
|
|
|
|
|
|
| 59 |
except Exception as e:
|
| 60 |
# エラー時はデフォルトのクラスリストを返す
|
| 61 |
print(f"Warning: Failed to load class names from config: {e}")
|
| 62 |
+
class_names = default_class_names
|
| 63 |
+
|
| 64 |
+
merged = list(class_names)
|
| 65 |
+
for class_name in YOLO_CLASS_NAMES:
|
| 66 |
+
if class_name not in merged:
|
| 67 |
+
merged.append(class_name)
|
| 68 |
+
return merged
|
| 69 |
|
| 70 |
|
| 71 |
def pil_to_np(img: Image.Image) -> np.ndarray:
|
| 72 |
return np.array(img.convert("RGB"))
|
| 73 |
|
| 74 |
|
| 75 |
+
def detect_upload_kind(path: Path) -> str:
|
| 76 |
+
"""マジックバイト優先でアップロード種別を判定する。"""
|
| 77 |
+
suffix = path.suffix.lower()
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
with open(path, "rb") as f:
|
| 81 |
+
header = f.read(16)
|
| 82 |
+
except Exception:
|
| 83 |
+
return "unknown"
|
| 84 |
+
|
| 85 |
+
if header.startswith(b"%PDF"):
|
| 86 |
+
return "pdf"
|
| 87 |
+
if header.startswith(b"\x89PNG\r\n\x1a\n"):
|
| 88 |
+
return "image"
|
| 89 |
+
if header.startswith(b"\xff\xd8\xff"):
|
| 90 |
+
return "image"
|
| 91 |
+
if header[:4] in {b"II*\x00", b"MM\x00*"}:
|
| 92 |
+
return "image"
|
| 93 |
+
if header.startswith(b"RIFF") and header[8:12] == b"WEBP":
|
| 94 |
+
return "image"
|
| 95 |
+
if suffix == ".pdf":
|
| 96 |
+
return "pdf"
|
| 97 |
+
if suffix in SUPPORTED_IMAGE_SUFFIXES:
|
| 98 |
+
return "image"
|
| 99 |
+
return "unknown"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def ensure_pixel_limit(width: int, height: int, source_label: str) -> None:
|
| 103 |
+
pixels = int(width) * int(height)
|
| 104 |
+
if pixels > MAX_RENDERED_PIXELS:
|
| 105 |
+
raise gr.Error(
|
| 106 |
+
f"{source_label}が大きすぎます。"
|
| 107 |
+
f"サイズ: {width}x{height}px / 上限: {MAX_RENDERED_PIXELS:,}px"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def extract_upload_path(upload: Any) -> Path:
|
| 112 |
+
if isinstance(upload, dict):
|
| 113 |
+
upload_path = upload.get("path")
|
| 114 |
+
elif isinstance(upload, (str, os.PathLike)):
|
| 115 |
+
upload_path = upload
|
| 116 |
+
else:
|
| 117 |
+
upload_path = getattr(upload, "path", None)
|
| 118 |
+
if upload_path is None:
|
| 119 |
+
candidate = getattr(upload, "name", None)
|
| 120 |
+
if isinstance(candidate, (str, os.PathLike)) and Path(candidate).exists():
|
| 121 |
+
upload_path = candidate
|
| 122 |
+
|
| 123 |
+
if not isinstance(upload_path, (str, os.PathLike)):
|
| 124 |
+
raise gr.Error("アップロードファイルを読み込めませんでした。")
|
| 125 |
+
|
| 126 |
+
path = Path(upload_path)
|
| 127 |
+
if not path.exists():
|
| 128 |
+
raise gr.Error("アップロードファイルが見つかりません。")
|
| 129 |
+
return path
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def normalize_upload_to_pil(upload: Any) -> Image.Image:
|
| 133 |
+
"""PDF/画像アップロードを推論用のRGB PIL画像へ正規化する。"""
|
| 134 |
+
if upload is None:
|
| 135 |
+
raise gr.Error("PDFまたはPNG形式の図面をアップロードしてください。")
|
| 136 |
+
|
| 137 |
+
if isinstance(upload, Image.Image):
|
| 138 |
+
ensure_pixel_limit(upload.width, upload.height, "画像")
|
| 139 |
+
return upload.convert("RGB")
|
| 140 |
+
|
| 141 |
+
path = extract_upload_path(upload)
|
| 142 |
+
upload_kind = detect_upload_kind(path)
|
| 143 |
+
|
| 144 |
+
if upload_kind == "pdf":
|
| 145 |
+
try:
|
| 146 |
+
import fitz
|
| 147 |
+
|
| 148 |
+
with fitz.open(str(path)) as doc:
|
| 149 |
+
if doc.needs_pass or doc.is_encrypted:
|
| 150 |
+
raise gr.Error("暗号化またはパスワード保護されたPDFには対応していません。")
|
| 151 |
+
if doc.page_count < 1:
|
| 152 |
+
raise gr.Error("PDFにページがありません。")
|
| 153 |
+
page = doc.load_page(0)
|
| 154 |
+
scale = PDF_DPI / 72.0
|
| 155 |
+
expected_width = math.ceil(page.rect.width * scale)
|
| 156 |
+
expected_height = math.ceil(page.rect.height * scale)
|
| 157 |
+
ensure_pixel_limit(expected_width, expected_height, "PDFの400dpi変換結果")
|
| 158 |
+
pix = page.get_pixmap(dpi=PDF_DPI, colorspace=fitz.csRGB, alpha=False)
|
| 159 |
+
ensure_pixel_limit(pix.width, pix.height, "PDFの400dpi変換結果")
|
| 160 |
+
if pix.n != 3:
|
| 161 |
+
raise gr.Error("PDFのRGB変換に失敗しました。")
|
| 162 |
+
image = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
| 163 |
+
return image.convert("RGB")
|
| 164 |
+
except gr.Error:
|
| 165 |
+
raise
|
| 166 |
+
except Exception as e:
|
| 167 |
+
raise gr.Error(f"PDFの読み込みに失敗しました: {str(e)}")
|
| 168 |
+
|
| 169 |
+
if upload_kind == "image":
|
| 170 |
+
try:
|
| 171 |
+
with Image.open(path) as img:
|
| 172 |
+
ensure_pixel_limit(img.width, img.height, "画像")
|
| 173 |
+
img.verify()
|
| 174 |
+
with Image.open(path) as img:
|
| 175 |
+
ensure_pixel_limit(img.width, img.height, "画像")
|
| 176 |
+
return img.convert("RGB")
|
| 177 |
+
except Image.DecompressionBombWarning:
|
| 178 |
+
raise gr.Error("画像が大きすぎるため処理できません。")
|
| 179 |
+
except Image.DecompressionBombError:
|
| 180 |
+
raise gr.Error("画像が大きすぎるため処理できません。")
|
| 181 |
+
except Exception as e:
|
| 182 |
+
raise gr.Error(f"画像ファイルの読み込みに失敗しました: {str(e)}")
|
| 183 |
+
|
| 184 |
+
raise gr.Error("対応形式はPDFまたはPNG画像です。")
|
| 185 |
+
|
| 186 |
+
|
| 187 |
def draw_detections(
|
| 188 |
image_pil: Image.Image,
|
| 189 |
detections: List[Detection],
|
|
|
|
| 246 |
|
| 247 |
|
| 248 |
def inference_pipeline(
|
| 249 |
+
upload: Any,
|
| 250 |
score_thresh: float = 0.8,
|
| 251 |
selected_classes: List[str] = None,
|
| 252 |
) -> Tuple[Image.Image, List[List[Any]]]:
|
| 253 |
"""Gradio から呼ばれるメイン処理"""
|
|
|
|
|
|
|
|
|
|
| 254 |
try:
|
| 255 |
+
img_pil = normalize_upload_to_pil(upload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
img_np = pil_to_np(img_pil)
|
| 257 |
|
| 258 |
+
# 選択クラスに応じて detection.py 側で YOLO26L / DEIMv2 を自動ルーティング
|
| 259 |
+
detections = run_inference(
|
| 260 |
+
img_np,
|
| 261 |
+
score_thresh=score_thresh,
|
| 262 |
+
selected_classes=selected_classes,
|
| 263 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
# 描画
|
| 266 |
vis_pil = draw_detections(img_pil.copy(), detections)
|
|
|
|
| 269 |
summary = summarize_detections(detections)
|
| 270 |
|
| 271 |
return vis_pil, summary
|
| 272 |
+
except gr.Error:
|
| 273 |
+
raise
|
| 274 |
except Exception as e:
|
| 275 |
error_msg = f"エラーが発生しました: {str(e)}"
|
| 276 |
raise gr.Error(error_msg)
|
| 277 |
|
| 278 |
|
| 279 |
+
@spaces.GPU(duration=180)
|
| 280 |
def gpu_inference(
|
| 281 |
+
upload: Any,
|
| 282 |
score_thresh: float = 0.9, # UIのデフォルト値と統一
|
| 283 |
selected_classes: List[str] = None,
|
| 284 |
):
|
| 285 |
"""Spaces ZeroGPU が検出できるようにデコレータ付きの推論関数を用意"""
|
| 286 |
+
return inference_pipeline(upload, score_thresh, selected_classes)
|
| 287 |
|
| 288 |
|
| 289 |
# =========================
|
| 290 |
# Gradio UI
|
| 291 |
# =========================
|
| 292 |
+
with gr.Blocks(title="PDF/PNG Floorplan Symbol Detection") as demo:
|
| 293 |
gr.Markdown(
|
| 294 |
"""
|
| 295 |
# 図面記号検出デモ(by AItech)
|
| 296 |
|
| 297 |
+
1. 左側に **PDFまたはPNG図面** をアップロード
|
| 298 |
+
2. PDFの場合は **1ページ目のみ400dpiで変換** して推論します
|
| 299 |
+
3. 対象クラスを選択して「検出を実行」を押す
|
| 300 |
+
4. 中央に **検出結果付き図面**、右側に **記号名称+個数** が表示されます。
|
| 301 |
"""
|
| 302 |
)
|
| 303 |
|
|
|
|
| 307 |
with gr.Row():
|
| 308 |
# 左: 入力
|
| 309 |
with gr.Column(scale=1):
|
| 310 |
+
input_file = gr.File(
|
| 311 |
+
label="PDF/PNG図面アップロード",
|
| 312 |
+
file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"],
|
| 313 |
+
type="filepath",
|
| 314 |
)
|
| 315 |
|
| 316 |
# 詳細設定タブ(デフォルトは閉じた状態)
|
|
|
|
| 325 |
selected_classes = gr.CheckboxGroup(
|
| 326 |
choices=class_names,
|
| 327 |
value=["door1"], # デフォルトでdoor1のみ選択
|
| 328 |
+
label="対象クラス",
|
| 329 |
+
info="選択したクラスに応じてYOLO26LまたはDEIMv2で自動推論します",
|
| 330 |
)
|
| 331 |
|
| 332 |
run_button = gr.Button("検出を実行", variant="primary")
|
|
|
|
| 349 |
# ボタンの動作
|
| 350 |
run_button.click(
|
| 351 |
fn=gpu_inference,
|
| 352 |
+
inputs=[input_file, score_thresh, selected_classes],
|
| 353 |
outputs=[output_image, summary_dataframe],
|
| 354 |
)
|
| 355 |
|
| 356 |
# Gradio 5では、Spaces上ではdemoオブジェクトを直接エクスポートするだけで動作します
|
| 357 |
# ローカルテスト時のみdemo.launch()を呼び出します
|
| 358 |
if __name__ == "__main__":
|
| 359 |
+
server_port = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
|
| 360 |
+
demo.launch(server_name="0.0.0.0", server_port=server_port)
|
| 361 |
|
| 362 |
# Spaces上では、demoオブジェクトを直接エクスポートします
|
| 363 |
# Gradio 5は自動的にdemoオブジェクトを検出して起動します
|
detection.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
# detection.py
|
|
|
|
| 2 |
from functools import lru_cache
|
| 3 |
-
from typing import List, Tuple
|
| 4 |
import os
|
|
|
|
| 5 |
import numpy as np
|
| 6 |
import torch
|
| 7 |
import torch.nn as nn
|
|
@@ -10,6 +12,9 @@ import torchvision.transforms as T
|
|
| 10 |
|
| 11 |
# デバッグ出力制御フラグ(環境変数で制御)
|
| 12 |
DEBUG_DEIMV2 = os.getenv("DEBUG_DEIMV2", "0") == "1"
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
# YAMLConfigをインポート(engineパッケージ経由でレジストリをロード)
|
| 15 |
# モジュール登録のために、すべての必要なモジュールを明示的にインポート
|
|
@@ -50,6 +55,26 @@ Detection = Tuple[float, float, float, float, str, float]
|
|
| 50 |
# ★ここを自分のファイル名に合わせる
|
| 51 |
MODEL_CONFIG_PATH = "configs/deimv2_floorplan.yaml"
|
| 52 |
MODEL_WEIGHTS_PATH = "models/best_stg2.pth"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
def _get_device():
|
|
@@ -60,13 +85,28 @@ def _get_device():
|
|
| 60 |
"""
|
| 61 |
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
@lru_cache(maxsize=1)
|
|
@@ -427,7 +467,7 @@ def run_inference_single_tile(
|
|
| 427 |
unique_labels, label_counts = np.unique(labels, return_counts=True)
|
| 428 |
print(f"[DEBUG] ラベル分布:")
|
| 429 |
for label_id, count in zip(unique_labels, label_counts):
|
| 430 |
-
label_name =
|
| 431 |
print(f" - クラスID {int(label_id)} ({label_name}): {count}件")
|
| 432 |
|
| 433 |
# スコア閾値以上の検出数
|
|
@@ -440,7 +480,7 @@ def run_inference_single_tile(
|
|
| 440 |
print(f"[DEBUG] 上位5件の検出結果:")
|
| 441 |
for rank, idx in enumerate(top_indices, 1):
|
| 442 |
label_id = int(labels[idx])
|
| 443 |
-
label_name =
|
| 444 |
score = float(scores[idx])
|
| 445 |
x1, y1, x2, y2 = boxes[idx]
|
| 446 |
print(f" [{rank}] {label_name}, スコア={score:.4f}, bbox=({x1:.1f},{y1:.1f},{x2:.1f},{y2:.1f})")
|
|
@@ -457,10 +497,10 @@ def run_inference_single_tile(
|
|
| 457 |
|
| 458 |
x1, y1, x2, y2 = [float(v) for v in box.tolist()]
|
| 459 |
label_id = int(label)
|
| 460 |
-
label_name =
|
| 461 |
|
| 462 |
-
#
|
| 463 |
-
if label_id not in
|
| 464 |
filtered_by_label += 1
|
| 465 |
|
| 466 |
# タイル座標を元の画像座標に変換
|
|
@@ -472,16 +512,17 @@ def run_inference_single_tile(
|
|
| 472 |
detections.append((x1_orig, y1_orig, x2_orig, y2_orig, label_name, score))
|
| 473 |
|
| 474 |
if DEBUG_DEIMV2 and (filtered_by_thresh > 0 or filtered_by_label > 0):
|
| 475 |
-
print(f"[DEBUG] フィ
|
| 476 |
|
| 477 |
return detections
|
| 478 |
|
| 479 |
|
| 480 |
-
def
|
| 481 |
image_np: np.ndarray,
|
| 482 |
score_thresh: float = 0.8,
|
| 483 |
-
|
| 484 |
-
|
|
|
|
| 485 |
) -> List[Detection]:
|
| 486 |
"""
|
| 487 |
タイル推論を実行する。
|
|
@@ -490,6 +531,7 @@ def run_inference(
|
|
| 490 |
Args:
|
| 491 |
image_np: RGB np.ndarray (H, W, 3)
|
| 492 |
score_thresh: スコア閾値
|
|
|
|
| 493 |
tile_size: タイルサイズ(デフォルト: 640)
|
| 494 |
tile_overlap: タイル間のオーバーラップ(デフォルト: 128)
|
| 495 |
|
|
@@ -511,6 +553,7 @@ def run_inference(
|
|
| 511 |
|
| 512 |
im_pil = Image.fromarray(image_np).convert("RGB")
|
| 513 |
img_w, img_h = im_pil.size
|
|
|
|
| 514 |
|
| 515 |
if DEBUG_DEIMV2:
|
| 516 |
print(f"[DEBUG] ===== タイル推論開始 =====")
|
|
@@ -574,7 +617,10 @@ def run_inference(
|
|
| 574 |
|
| 575 |
if DEBUG_DEIMV2:
|
| 576 |
print(f"[DEBUG] 検出数: {len(tile_detections)}件")
|
| 577 |
-
all_detections.extend(
|
|
|
|
|
|
|
|
|
|
| 578 |
|
| 579 |
if DEBUG_DEIMV2:
|
| 580 |
print(f"[DEBUG] 総検出数(重複あり): {len(all_detections)}件")
|
|
@@ -670,3 +716,203 @@ def run_inference(
|
|
| 670 |
return merged_detections
|
| 671 |
except Exception as e:
|
| 672 |
raise RuntimeError(f"推論の実行に失敗しました: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# detection.py
|
| 2 |
+
from collections import Counter
|
| 3 |
from functools import lru_cache
|
| 4 |
+
from typing import Dict, List, Optional, Set, Tuple
|
| 5 |
import os
|
| 6 |
+
import yaml
|
| 7 |
import numpy as np
|
| 8 |
import torch
|
| 9 |
import torch.nn as nn
|
|
|
|
| 12 |
|
| 13 |
# デバッグ出力制御フラグ(環境変数で制御)
|
| 14 |
DEBUG_DEIMV2 = os.getenv("DEBUG_DEIMV2", "0") == "1"
|
| 15 |
+
YOLO_CONFIG_DIR = os.getenv("YOLO_CONFIG_DIR", "/tmp/ultralytics")
|
| 16 |
+
os.makedirs(YOLO_CONFIG_DIR, exist_ok=True)
|
| 17 |
+
os.environ.setdefault("YOLO_CONFIG_DIR", YOLO_CONFIG_DIR)
|
| 18 |
|
| 19 |
# YAMLConfigをインポート(engineパッケージ経由でレジストリをロード)
|
| 20 |
# モジュール登録のために、すべての必要なモジュールを明示的にインポート
|
|
|
|
| 55 |
# ★ここを自分のファイル名に合わせる
|
| 56 |
MODEL_CONFIG_PATH = "configs/deimv2_floorplan.yaml"
|
| 57 |
MODEL_WEIGHTS_PATH = "models/best_stg2.pth"
|
| 58 |
+
YOLO_WEIGHTS_PATH = "models/yolobest.pt"
|
| 59 |
+
YOLO_TILE_SIZE = 960
|
| 60 |
+
YOLO_TILE_OVERLAP = 192
|
| 61 |
+
DEIM_TILE_SIZE = 640
|
| 62 |
+
DEIM_TILE_OVERLAP = 128
|
| 63 |
+
TILE_MERGE_IOU_THRESHOLD = 0.4
|
| 64 |
+
|
| 65 |
+
YOLO_CLASS_NAMES = [
|
| 66 |
+
"window1",
|
| 67 |
+
"window2",
|
| 68 |
+
"window3",
|
| 69 |
+
"window4",
|
| 70 |
+
"window_hiki",
|
| 71 |
+
"window_hiki2",
|
| 72 |
+
"door1",
|
| 73 |
+
"door2",
|
| 74 |
+
"sash_circle",
|
| 75 |
+
"sash_rect",
|
| 76 |
+
]
|
| 77 |
+
YOLO_CLASSES: Set[str] = set(YOLO_CLASS_NAMES)
|
| 78 |
|
| 79 |
|
| 80 |
def _get_device():
|
|
|
|
| 85 |
"""
|
| 86 |
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 87 |
|
| 88 |
+
|
| 89 |
+
def _get_device_key() -> str:
|
| 90 |
+
return "cuda:0" if torch.cuda.is_available() else "cpu"
|
| 91 |
+
|
| 92 |
+
def load_deimv2_class_names() -> List[str]:
|
| 93 |
+
default_class_names = ["kanki", "kanki_shikaku", "kanki_regisuta", "window1", "window2",
|
| 94 |
+
"door1", "door2", "bathtub1", "konro1", "sink1", "toilet1",
|
| 95 |
+
"kasaikeihou1", "kasaikeihou2", "houi1", "houi2", "houi3"]
|
| 96 |
+
try:
|
| 97 |
+
with open(MODEL_CONFIG_PATH, "r", encoding="utf-8") as f:
|
| 98 |
+
config = yaml.safe_load(f)
|
| 99 |
+
class_names = config.get("Model", {}).get("class_names")
|
| 100 |
+
if isinstance(class_names, list) and class_names:
|
| 101 |
+
return [str(name) for name in class_names]
|
| 102 |
+
except Exception as e:
|
| 103 |
+
print(f"Warning: Failed to load DEIMv2 class names: {e}")
|
| 104 |
+
return default_class_names
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
DEIM_CLASS_NAMES = load_deimv2_class_names()
|
| 108 |
+
DEIM_LABEL_MAP: Dict[int, str] = {idx: name for idx, name in enumerate(DEIM_CLASS_NAMES)}
|
| 109 |
+
DEIM_CLASSES: Set[str] = set(DEIM_CLASS_NAMES) - YOLO_CLASSES
|
| 110 |
|
| 111 |
|
| 112 |
@lru_cache(maxsize=1)
|
|
|
|
| 467 |
unique_labels, label_counts = np.unique(labels, return_counts=True)
|
| 468 |
print(f"[DEBUG] ラベル分布:")
|
| 469 |
for label_id, count in zip(unique_labels, label_counts):
|
| 470 |
+
label_name = DEIM_LABEL_MAP.get(int(label_id), f"class_{int(label_id)}")
|
| 471 |
print(f" - クラスID {int(label_id)} ({label_name}): {count}件")
|
| 472 |
|
| 473 |
# スコア閾値以上の検出数
|
|
|
|
| 480 |
print(f"[DEBUG] 上位5件の検出結果:")
|
| 481 |
for rank, idx in enumerate(top_indices, 1):
|
| 482 |
label_id = int(labels[idx])
|
| 483 |
+
label_name = DEIM_LABEL_MAP.get(label_id, f"class_{label_id}")
|
| 484 |
score = float(scores[idx])
|
| 485 |
x1, y1, x2, y2 = boxes[idx]
|
| 486 |
print(f" [{rank}] {label_name}, スコア={score:.4f}, bbox=({x1:.1f},{y1:.1f},{x2:.1f},{y2:.1f})")
|
|
|
|
| 497 |
|
| 498 |
x1, y1, x2, y2 = [float(v) for v in box.tolist()]
|
| 499 |
label_id = int(label)
|
| 500 |
+
label_name = DEIM_LABEL_MAP.get(label_id, f"class_{label_id}")
|
| 501 |
|
| 502 |
+
# DEIM_LABEL_MAPに存在しないクラスもカウント(デバッグ用)
|
| 503 |
+
if label_id not in DEIM_LABEL_MAP:
|
| 504 |
filtered_by_label += 1
|
| 505 |
|
| 506 |
# タイル座標を元の画像座標に変換
|
|
|
|
| 512 |
detections.append((x1_orig, y1_orig, x2_orig, y2_orig, label_name, score))
|
| 513 |
|
| 514 |
if DEBUG_DEIMV2 and (filtered_by_thresh > 0 or filtered_by_label > 0):
|
| 515 |
+
print(f"[DEBUG] フィルタリング: スコア閾値で{filtered_by_thresh}件、DEIM_LABEL_MAPで{filtered_by_label}件除外")
|
| 516 |
|
| 517 |
return detections
|
| 518 |
|
| 519 |
|
| 520 |
+
def run_deimv2_inference(
|
| 521 |
image_np: np.ndarray,
|
| 522 |
score_thresh: float = 0.8,
|
| 523 |
+
selected_classes: Optional[Set[str]] = None,
|
| 524 |
+
tile_size: int = DEIM_TILE_SIZE,
|
| 525 |
+
tile_overlap: int = DEIM_TILE_OVERLAP,
|
| 526 |
) -> List[Detection]:
|
| 527 |
"""
|
| 528 |
タイル推論を実行する。
|
|
|
|
| 531 |
Args:
|
| 532 |
image_np: RGB np.ndarray (H, W, 3)
|
| 533 |
score_thresh: スコア閾値
|
| 534 |
+
selected_classes: DEIMv2で返すクラス。Noneまたは空の場合はDEIM担当クラスすべて
|
| 535 |
tile_size: タイルサイズ(デフォルト: 640)
|
| 536 |
tile_overlap: タイル間のオーバーラップ(デフォルト: 128)
|
| 537 |
|
|
|
|
| 553 |
|
| 554 |
im_pil = Image.fromarray(image_np).convert("RGB")
|
| 555 |
img_w, img_h = im_pil.size
|
| 556 |
+
allowed_classes = set(selected_classes) if selected_classes else set(DEIM_CLASSES)
|
| 557 |
|
| 558 |
if DEBUG_DEIMV2:
|
| 559 |
print(f"[DEBUG] ===== タイル推論開始 =====")
|
|
|
|
| 617 |
|
| 618 |
if DEBUG_DEIMV2:
|
| 619 |
print(f"[DEBUG] 検出数: {len(tile_detections)}件")
|
| 620 |
+
all_detections.extend(
|
| 621 |
+
det for det in tile_detections
|
| 622 |
+
if det[4] in allowed_classes
|
| 623 |
+
)
|
| 624 |
|
| 625 |
if DEBUG_DEIMV2:
|
| 626 |
print(f"[DEBUG] 総検出数(重複あり): {len(all_detections)}件")
|
|
|
|
| 716 |
return merged_detections
|
| 717 |
except Exception as e:
|
| 718 |
raise RuntimeError(f"推論の実行に失敗しました: {e}")
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
@lru_cache(maxsize=2)
|
| 722 |
+
def load_yolo_model(device_key: str):
|
| 723 |
+
"""YOLO26Lモデルをデバイス単位で遅延ロードする。"""
|
| 724 |
+
weights_path = os.path.abspath(YOLO_WEIGHTS_PATH)
|
| 725 |
+
if not os.path.exists(weights_path):
|
| 726 |
+
raise FileNotFoundError(f"YOLOモデルファイルが見つかりません: {weights_path}")
|
| 727 |
+
|
| 728 |
+
try:
|
| 729 |
+
from ultralytics import YOLO
|
| 730 |
+
|
| 731 |
+
model = YOLO(weights_path)
|
| 732 |
+
names = getattr(model.model, "names", None) or getattr(model, "names", None)
|
| 733 |
+
class_map = normalize_yolo_class_map(names)
|
| 734 |
+
expected_map = {idx: name for idx, name in enumerate(YOLO_CLASS_NAMES)}
|
| 735 |
+
if class_map != expected_map:
|
| 736 |
+
raise RuntimeError(
|
| 737 |
+
f"YOLOクラス名が想定と異なります: {class_map}. "
|
| 738 |
+
f"expected={expected_map}"
|
| 739 |
+
)
|
| 740 |
+
return model, class_map
|
| 741 |
+
except Exception as e:
|
| 742 |
+
raise RuntimeError(f"YOLOモデルの読み込みに失敗しました: {e}")
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
def normalize_yolo_class_map(names) -> Dict[int, str]:
|
| 746 |
+
if isinstance(names, dict):
|
| 747 |
+
return {int(idx): str(name) for idx, name in names.items()}
|
| 748 |
+
if isinstance(names, (list, tuple)):
|
| 749 |
+
return {idx: str(name) for idx, name in enumerate(names)}
|
| 750 |
+
raise RuntimeError(f"YOLOクラス名を読み取れません: {names}")
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
def clamp_detection(det: Detection, img_w: int, img_h: int) -> Optional[Detection]:
|
| 754 |
+
x1, y1, x2, y2, label_name, score = det
|
| 755 |
+
x1 = max(0.0, min(float(x1), float(img_w)))
|
| 756 |
+
y1 = max(0.0, min(float(y1), float(img_h)))
|
| 757 |
+
x2 = max(0.0, min(float(x2), float(img_w)))
|
| 758 |
+
y2 = max(0.0, min(float(y2), float(img_h)))
|
| 759 |
+
if x2 <= x1 or y2 <= y1:
|
| 760 |
+
return None
|
| 761 |
+
return (x1, y1, x2, y2, label_name, float(score))
|
| 762 |
+
|
| 763 |
+
|
| 764 |
+
def merge_detections_by_class(
|
| 765 |
+
detections: List[Detection],
|
| 766 |
+
device: Optional[torch.device] = None,
|
| 767 |
+
iou_threshold: float = TILE_MERGE_IOU_THRESHOLD,
|
| 768 |
+
) -> List[Detection]:
|
| 769 |
+
if not detections:
|
| 770 |
+
return []
|
| 771 |
+
|
| 772 |
+
from torchvision.ops import nms
|
| 773 |
+
|
| 774 |
+
target_device = device or torch.device("cpu")
|
| 775 |
+
detections_by_class: Dict[str, List[Tuple[float, float, float, float, float]]] = {}
|
| 776 |
+
for x1, y1, x2, y2, label_name, score in detections:
|
| 777 |
+
detections_by_class.setdefault(label_name, []).append((x1, y1, x2, y2, score))
|
| 778 |
+
|
| 779 |
+
merged: List[Detection] = []
|
| 780 |
+
for label_name, boxes_scores in detections_by_class.items():
|
| 781 |
+
boxes_tensor = torch.tensor(
|
| 782 |
+
[[x1, y1, x2, y2] for x1, y1, x2, y2, _ in boxes_scores],
|
| 783 |
+
device=target_device,
|
| 784 |
+
dtype=torch.float32,
|
| 785 |
+
)
|
| 786 |
+
scores_tensor = torch.tensor(
|
| 787 |
+
[score for _, _, _, _, score in boxes_scores],
|
| 788 |
+
device=target_device,
|
| 789 |
+
dtype=torch.float32,
|
| 790 |
+
)
|
| 791 |
+
keep_indices = nms(boxes_tensor, scores_tensor, iou_threshold=iou_threshold)
|
| 792 |
+
for idx in keep_indices.cpu().numpy():
|
| 793 |
+
x1, y1, x2, y2, score = boxes_scores[int(idx)]
|
| 794 |
+
merged.append((x1, y1, x2, y2, label_name, score))
|
| 795 |
+
return merged
|
| 796 |
+
|
| 797 |
+
|
| 798 |
+
def run_yolo_inference(
|
| 799 |
+
image_np: np.ndarray,
|
| 800 |
+
score_thresh: float = 0.8,
|
| 801 |
+
selected_classes: Optional[Set[str]] = None,
|
| 802 |
+
tile_size: int = YOLO_TILE_SIZE,
|
| 803 |
+
tile_overlap: int = YOLO_TILE_OVERLAP,
|
| 804 |
+
) -> List[Detection]:
|
| 805 |
+
"""YOLO26Lタイル推論を実行し、既存Detection形式で返す。"""
|
| 806 |
+
if image_np.dtype != np.uint8:
|
| 807 |
+
if image_np.max() <= 1.0:
|
| 808 |
+
image_np = (image_np * 255).astype(np.uint8)
|
| 809 |
+
else:
|
| 810 |
+
image_np = image_np.astype(np.uint8)
|
| 811 |
+
|
| 812 |
+
im_pil = Image.fromarray(image_np).convert("RGB")
|
| 813 |
+
img_w, img_h = im_pil.size
|
| 814 |
+
allowed_classes = set(selected_classes) if selected_classes else set(YOLO_CLASSES)
|
| 815 |
+
if not allowed_classes:
|
| 816 |
+
return []
|
| 817 |
+
|
| 818 |
+
device = _get_device()
|
| 819 |
+
device_key = _get_device_key()
|
| 820 |
+
model, class_map = load_yolo_model(device_key)
|
| 821 |
+
selected_class_ids = sorted(
|
| 822 |
+
class_id for class_id, label_name in class_map.items()
|
| 823 |
+
if label_name in allowed_classes
|
| 824 |
+
)
|
| 825 |
+
if not selected_class_ids:
|
| 826 |
+
return []
|
| 827 |
+
|
| 828 |
+
step = tile_size - tile_overlap
|
| 829 |
+
all_detections: List[Detection] = []
|
| 830 |
+
|
| 831 |
+
for tile_y in range(0, img_h, step):
|
| 832 |
+
for tile_x in range(0, img_w, step):
|
| 833 |
+
tile_x_end = min(tile_x + tile_size, img_w)
|
| 834 |
+
tile_y_end = min(tile_y + tile_size, img_h)
|
| 835 |
+
tile = im_pil.crop((tile_x, tile_y, tile_x_end, tile_y_end))
|
| 836 |
+
|
| 837 |
+
with torch.inference_mode():
|
| 838 |
+
results = model.predict(
|
| 839 |
+
tile,
|
| 840 |
+
imgsz=tile_size,
|
| 841 |
+
conf=score_thresh,
|
| 842 |
+
iou=TILE_MERGE_IOU_THRESHOLD,
|
| 843 |
+
classes=selected_class_ids,
|
| 844 |
+
verbose=False,
|
| 845 |
+
device=device_key,
|
| 846 |
+
)
|
| 847 |
+
|
| 848 |
+
if not results:
|
| 849 |
+
continue
|
| 850 |
+
boxes = getattr(results[0], "boxes", None)
|
| 851 |
+
if boxes is None or len(boxes) == 0:
|
| 852 |
+
continue
|
| 853 |
+
|
| 854 |
+
xyxy = boxes.xyxy.detach().cpu().numpy()
|
| 855 |
+
confs = boxes.conf.detach().cpu().numpy()
|
| 856 |
+
classes = boxes.cls.detach().cpu().numpy().astype(int)
|
| 857 |
+
for box, conf, class_id in zip(xyxy, confs, classes):
|
| 858 |
+
label_name = class_map.get(int(class_id), f"class_{int(class_id)}")
|
| 859 |
+
if label_name not in allowed_classes:
|
| 860 |
+
continue
|
| 861 |
+
x1, y1, x2, y2 = [float(v) for v in box.tolist()]
|
| 862 |
+
det = clamp_detection(
|
| 863 |
+
(x1 + tile_x, y1 + tile_y, x2 + tile_x, y2 + tile_y, label_name, float(conf)),
|
| 864 |
+
img_w,
|
| 865 |
+
img_h,
|
| 866 |
+
)
|
| 867 |
+
if det is not None:
|
| 868 |
+
all_detections.append(det)
|
| 869 |
+
|
| 870 |
+
return merge_detections_by_class(all_detections, device=device)
|
| 871 |
+
|
| 872 |
+
|
| 873 |
+
def resolve_requested_classes(selected_classes: Optional[List[str]]) -> Tuple[Set[str], Set[str]]:
|
| 874 |
+
requested = set(selected_classes or [])
|
| 875 |
+
if not requested:
|
| 876 |
+
return set(DEIM_CLASSES), set(YOLO_CLASSES)
|
| 877 |
+
|
| 878 |
+
deim_requested = requested & DEIM_CLASSES
|
| 879 |
+
yolo_requested = requested & YOLO_CLASSES
|
| 880 |
+
unknown_requested = requested - DEIM_CLASSES - YOLO_CLASSES
|
| 881 |
+
if unknown_requested:
|
| 882 |
+
print(f"Warning: Unknown selected classes ignored: {sorted(unknown_requested)}")
|
| 883 |
+
return deim_requested, yolo_requested
|
| 884 |
+
|
| 885 |
+
|
| 886 |
+
def run_inference(
|
| 887 |
+
image_np: np.ndarray,
|
| 888 |
+
score_thresh: float = 0.8,
|
| 889 |
+
selected_classes: Optional[List[str]] = None,
|
| 890 |
+
) -> List[Detection]:
|
| 891 |
+
"""
|
| 892 |
+
選択クラスに応じてDEIMv2/YOLO26Lを自動ルーティングして推論する。
|
| 893 |
+
"""
|
| 894 |
+
try:
|
| 895 |
+
deim_classes, yolo_classes = resolve_requested_classes(selected_classes)
|
| 896 |
+
all_detections: List[Detection] = []
|
| 897 |
+
|
| 898 |
+
if deim_classes:
|
| 899 |
+
all_detections.extend(
|
| 900 |
+
run_deimv2_inference(
|
| 901 |
+
image_np,
|
| 902 |
+
score_thresh=score_thresh,
|
| 903 |
+
selected_classes=deim_classes,
|
| 904 |
+
)
|
| 905 |
+
)
|
| 906 |
+
|
| 907 |
+
if yolo_classes:
|
| 908 |
+
all_detections.extend(
|
| 909 |
+
run_yolo_inference(
|
| 910 |
+
image_np,
|
| 911 |
+
score_thresh=score_thresh,
|
| 912 |
+
selected_classes=yolo_classes,
|
| 913 |
+
)
|
| 914 |
+
)
|
| 915 |
+
|
| 916 |
+
return all_detections
|
| 917 |
+
except Exception as e:
|
| 918 |
+
raise RuntimeError(f"推論の実行に失敗しました: {e}")
|
models/yolobest.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1e80c2026193a5cd7d6ea341d958dc43a70a40ee0c51028b9a6959a48a5a1d5e
|
| 3 |
+
size 52647821
|
requirements.txt
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
-
gradio
|
|
|
|
| 2 |
spaces
|
| 3 |
-
torch==2.
|
| 4 |
-
torchvision==0.
|
| 5 |
numpy
|
| 6 |
Pillow>=9.0.0
|
| 7 |
opencv-python-headless
|
| 8 |
-
huggingface_hub=
|
| 9 |
PyYAML>=6.0
|
| 10 |
tensorboard
|
| 11 |
scipy>=1.7.0
|
|
@@ -13,6 +14,8 @@ faster-coco-eval>=1.6.7
|
|
| 13 |
calflops
|
| 14 |
transformers
|
| 15 |
onnxruntime>=1.16.0
|
|
|
|
|
|
|
| 16 |
# DEIMv2関連の依存関係
|
| 17 |
timm
|
| 18 |
omegaconf
|
|
|
|
| 1 |
+
gradio==5.49.1
|
| 2 |
+
python-multipart>=0.0.20
|
| 3 |
spaces
|
| 4 |
+
torch==2.8.0
|
| 5 |
+
torchvision==0.23.0
|
| 6 |
numpy
|
| 7 |
Pillow>=9.0.0
|
| 8 |
opencv-python-headless
|
| 9 |
+
huggingface_hub>=0.33.5
|
| 10 |
PyYAML>=6.0
|
| 11 |
tensorboard
|
| 12 |
scipy>=1.7.0
|
|
|
|
| 14 |
calflops
|
| 15 |
transformers
|
| 16 |
onnxruntime>=1.16.0
|
| 17 |
+
PyMuPDF>=1.24.0
|
| 18 |
+
ultralytics>=8.3.0
|
| 19 |
# DEIMv2関連の依存関係
|
| 20 |
timm
|
| 21 |
omegaconf
|