Skip to content

Commit 60e854f

Browse files
committed
add: 初回の判定失敗時用のエンドポイントを作成
1 parent 4ee1ff0 commit 60e854f

File tree

5 files changed

+478
-7
lines changed

5 files changed

+478
-7
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
.coverage
33
coverage*
44
.venv
5-
image_data/
5+
image_data/
6+
models/

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ dependencies = [
1414
"requests>=2.32.4",
1515
"pillow>=11.3.0",
1616
"fastapi[standard]>=0.116.1",
17+
"onnxruntime>=1.22.1",
18+
"numpy>=2.3.2",
19+
"opencv-python>=4.11.0.86",
1720
]
1821

1922
[dependency-groups]

src/minifig_detector.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""minifig向き検出クラス.
2+
3+
@file minifig_detector.py
4+
@author Hara1274
5+
"""
6+
import cv2
7+
import numpy as np
8+
import onnxruntime as ort
9+
import os
10+
import json
11+
12+
13+
class MinifigDetector:
14+
"""minifigの向きを検出するクラス."""
15+
16+
def __init__(self):
17+
"""検出器を初期化."""
18+
self.session = None
19+
self.input_name = None
20+
self.labels = ["front", "back", "right", "left"]
21+
self.conf_threshold = 0.25
22+
self.nms_threshold = 0.45
23+
self.input_size = 640
24+
25+
# YOLO11 ONNXモデルのパス
26+
project_root = os.path.dirname(os.path.dirname(__file__))
27+
model_path = os.path.join(
28+
project_root, "models", "11s_100epoch_&_650imgsz_fig.onnx")
29+
30+
# モデルファイルが存在する場合のみonnxモデルの設定
31+
if os.path.exists(model_path):
32+
# 推論セッションを作成
33+
self.session = ort.InferenceSession(model_path)
34+
# ONNXモデルの入力層の名前を取得
35+
self.input_name = self.session.get_inputs()[0].name
36+
37+
def preprocess_image(self, img):
38+
"""推論用に画像を前処理.
39+
40+
Args:
41+
img: 入力画像
42+
43+
Returns:
44+
tuple: (処理後画像, スケール比, パディング情報)
45+
"""
46+
# アスペクト比を保持するスケール計算
47+
shape = img.shape[:2] # 元画像サイズ (H, W)
48+
# アスペクト比維持のスケール
49+
r = min(self.input_size / shape[0], self.input_size / shape[1])
50+
# スケール後サイズ (W, H)
51+
new_unpad = (int(round(shape[1] * r)), int(round(shape[0] * r)))
52+
img_resized = cv2.resize(
53+
img, new_unpad, interpolation=cv2.INTER_LINEAR)
54+
55+
# 640x640にするための余白計算
56+
dw = self.input_size - new_unpad[0] # 水平余白
57+
dh = self.input_size - new_unpad[1] # 垂直余白
58+
top, bottom = dh // 2, dh - dh // 2 # 上下分割
59+
left, right = dw // 2, dw - dw // 2 # 左右分割
60+
61+
# 灰色(114)でパディングして640x640に調整
62+
img_padded = cv2.copyMakeBorder(
63+
img_resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(114, 114, 114))
64+
65+
# YOLO入力形式に変換: HWC→CHW, [0,1]正規化, バッチ次元追加
66+
img_input = img_padded.transpose(2, 0, 1).astype(
67+
np.float32) / 255.0 # (3,640,640)
68+
img_input = np.expand_dims(img_input, axis=0) # (1,3,640,640)
69+
70+
return img_input, r, (left, top)
71+
72+
def postprocess(self, pred, scale, pad):
73+
"""推論結果を後処理.
74+
75+
Args:
76+
pred: 推論出力 [1, 8, N] (YOLO11形式)
77+
scale: レターボックスのスケール比
78+
pad: パディング情報 (left, top)
79+
80+
Returns:
81+
str: 検出された向き
82+
"""
83+
if pred.size == 0:
84+
return "front"
85+
86+
# YOLO11出力解析: [1,8,N] -> [8,N](バッチ次元除去)
87+
data = pred[0]
88+
89+
attributes = 8 # [cx,cy,w,h,class0,class1,class2,class3]
90+
num_classes = attributes - 4 # 座標4つ以外はクラススコア
91+
num_boxes = data.shape[1] # 検出候補数を取得
92+
93+
boxes = []
94+
confidences = []
95+
class_ids = []
96+
97+
# 各検出候補を処理
98+
for i in range(num_boxes):
99+
max_score = -1.0
100+
best_class = -1
101+
102+
# クラススコアの最大値とクラスIDを取得
103+
for j in range(num_classes):
104+
score = data[4 + j, i]
105+
if score > max_score:
106+
max_score = score
107+
best_class = j
108+
109+
# 信頼度閾値による候補フィルタリング
110+
if max_score < self.conf_threshold:
111+
continue
112+
113+
# バウンディングボックス座標抽出(YOLO形式:中心座標+幅高さ)
114+
cx = data[0, i] # 中心X座標(640x640空間)
115+
cy = data[1, i] # 中心Y座標(640x640空間)
116+
w = data[2, i] # 幅(640x640空間)
117+
h = data[3, i] # 高さ(640x640空間)
118+
119+
# レターボックス座標系から元画像座標系に逆変換
120+
center_x = int((cx - pad[0]) / scale) # パディング補正+スケール逆変換
121+
center_y = int((cy - pad[1]) / scale)
122+
width = int(w / scale)
123+
height = int(h / scale)
124+
125+
# 中心座標形式から左上座標形式(OpenCV形式)に変換
126+
left = center_x - width // 2
127+
top = center_y - height // 2
128+
129+
# NMS用データに追加
130+
boxes.append([left, top, width, height]) # [x,y,w,h]形式
131+
confidences.append(max_score) # 最高クラススコア
132+
class_ids.append(best_class) # 最高スコアのクラスID
133+
134+
# 信頼度閾値を満たす検出がない場合
135+
if not boxes:
136+
return {"wasDetected": False, "direction": "NONE", "confidence": 0.0}
137+
138+
# Non-Maximum Suppression で重複検出を除去
139+
indices = cv2.dnn.NMSBoxes(
140+
boxes, confidences, self.conf_threshold, self.nms_threshold)
141+
142+
# NMS後に有効な検出が残っている場合
143+
if len(indices) > 0:
144+
best_idx = indices.flatten()[0] # 最も信頼度の高い検出を選択
145+
best_class_id = class_ids[best_idx] # 対応するクラスID
146+
best_confidence = confidences[best_idx] # 対応する信頼度
147+
direction = self.labels[best_class_id] # ミニフィグの向き
148+
149+
return {
150+
"wasDetected": True,
151+
"direction": direction, # "front", "back", "right", "left"
152+
"confidence": float(best_confidence) # 0.0-1.0の信頼度
153+
}
154+
155+
return {"wasDetected": False, "direction": "NONE", "confidence": 0.0}
156+
157+
def detect(self, image_path: str) -> dict:
158+
"""minifigの向きを判定.
159+
160+
Args:
161+
image_path (str): 画像ファイルのパス
162+
163+
Returns:
164+
dict: 検出結果 {"wasDetected": bool, "direction": str, "confidence": float}
165+
"""
166+
# 共通エラーレスポンス
167+
error_result = {"wasDetected": False,
168+
"direction": "NONE", "confidence": 0.0}
169+
170+
# 入力ファイル存在チェック
171+
if not os.path.exists(image_path):
172+
return error_result
173+
174+
# モデル読み込み状態チェック
175+
if self.session is None:
176+
return error_result
177+
178+
# 画像読み込み(BGR形式)
179+
img = cv2.imread(image_path)
180+
if img is None: # 読み込み失敗(不正ファイル等)
181+
return error_result
182+
183+
# 前処理:レターボックス+正規化
184+
img_input, scale, pad = self.preprocess_image(img)
185+
186+
# YOLO11推論実行
187+
outputs = self.session.run(None, {self.input_name: img_input})
188+
pred = outputs[0] # メイン出力 [1,8,N]
189+
190+
# 後処理:NMS+結果構築
191+
result = self.postprocess(pred, scale, pad)
192+
193+
return result

src/server/fastapi_server.py

Lines changed: 130 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
import socket
1010
import os
1111
import uvicorn
12+
import random
1213

1314
from fastapi import FastAPI, UploadFile, File, status
1415
from fastapi.middleware.cors import CORSMiddleware
1516
from fastapi.responses import JSONResponse
17+
from ..minifig_detector import MinifigDetector
1618
from ..official_interface import OfficialInterface
1719

1820

@@ -25,6 +27,20 @@
2527
allow_headers=["*"], # すべてのヘッダーを許可
2628
)
2729

30+
# MinifigDetectorのインスタンスを生成
31+
minifig_detector = MinifigDetector()
32+
33+
# ミニフィグ検出結果の最良結果を保持
34+
best_minifig_result = {
35+
"image_count": 0,
36+
"best_image_path": None,
37+
"best_confidence": 0.0,
38+
"best_direction": None
39+
}
40+
41+
# アップロードされた画像パスを保存するリスト
42+
uploaded_image_paths = []
43+
2844

2945
@app.get("/", response_class=JSONResponse)
3046
def health_check() -> JSONResponse:
@@ -43,7 +59,7 @@ def health_check() -> JSONResponse:
4359
@app.post("/images", response_class=JSONResponse)
4460
def get_image(file: UploadFile = File(...)) -> JSONResponse:
4561
"""
46-
走行体から、画像ファイルを取得するための関数.
62+
走行体から、画像ファイルを取得し、競技システムにアップロードする関数.
4763
4864
Args:
4965
file (UploadFile): アップロードされた画像ファイル、FastAPIのFileで受け取る
@@ -60,10 +76,8 @@ def get_image(file: UploadFile = File(...)) -> JSONResponse:
6076
# 画像のファイル名の取得
6177
file_name = file.filename
6278

63-
# プロジェクトルートディレクトリのパスを取得(3階層上に移動)
64-
project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
6579
# 画像保存用ディレクトリのパスを設定
66-
image_data_dir = os.path.join(project_root, 'image_data')
80+
image_data_dir = os.path.join('image_data')
6781

6882
# image_dataディレクトリが存在しない場合は作成
6983
os.makedirs(image_data_dir, exist_ok=True)
@@ -103,6 +117,111 @@ def get_image(file: UploadFile = File(...)) -> JSONResponse:
103117
)
104118

105119

120+
@app.post("/minifig/detect", response_class=JSONResponse)
121+
def upload_minifig_image(file: UploadFile = File(...)) -> JSONResponse:
122+
"""
123+
走行体から、受け取った4枚のミニフィグの画像から一番正面らしいものを競技システムにアップロードする関数.
124+
125+
Args:
126+
file (UploadFile): アップロードされた画像ファイル、FastAPIのFileで受け取る
127+
128+
Returns:
129+
JSONResponse: 結果メッセージとステータスコード
130+
"""
131+
132+
# 画像のファイル名の取得
133+
file_name = file.filename
134+
135+
# 画像保存用ディレクトリのパスを設定
136+
image_data_dir = os.path.join("image_data")
137+
138+
# image_dataディレクトリが存在しない場合は作成
139+
os.makedirs(image_data_dir, exist_ok=True)
140+
141+
# etrobocon2025-comm-device-system\image_dataに画像を保存
142+
file_path = os.path.join(image_data_dir, file_name)
143+
try:
144+
with open(file_path, "wb") as buffer:
145+
buffer.write(file.file.read())
146+
except Exception as error:
147+
return JSONResponse(
148+
content={"error": f"Failed to save file: {str(error)}"},
149+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
150+
)
151+
152+
# MinifigDetectorで推論を実行
153+
detection_result = minifig_detector.detect(file_path)
154+
155+
# 画像カウントを増加
156+
best_minifig_result["image_count"] += 1
157+
158+
# アップロードされた画像パスをリストに追加
159+
uploaded_image_paths.append(file_path)
160+
161+
# 最良画像を更新(front優先、次に信頼度優先)
162+
if detection_result["wasDetected"]:
163+
# 検出結果がfrontの場合
164+
if detection_result["direction"] == "front":
165+
# frontは既存がfront以外なら即更新、frontなら高信頼度で更新
166+
if (best_minifig_result["best_direction"] != "front" or
167+
detection_result["confidence"] > best_minifig_result["best_confidence"]):
168+
# それぞれの値を更新
169+
best_minifig_result["best_image_path"] = file_path
170+
best_minifig_result["best_confidence"] = detection_result["confidence"]
171+
best_minifig_result["best_direction"] = detection_result["direction"]
172+
173+
# 既存の最良画像がfrontでなく、検出結果の信頼度が高い場合
174+
elif (best_minifig_result["best_direction"] != "front" and
175+
detection_result["confidence"] > best_minifig_result["best_confidence"]):
176+
# それぞれの値を更新
177+
best_minifig_result["best_image_path"] = file_path
178+
best_minifig_result["best_confidence"] = detection_result["confidence"]
179+
best_minifig_result["best_direction"] = detection_result["direction"]
180+
181+
# 4枚未満の場合
182+
if best_minifig_result["image_count"] < 4:
183+
return JSONResponse(
184+
content={
185+
"message": f"Image {best_minifig_result['image_count']} processed successfully",
186+
"detection_result": detection_result,
187+
"images_received": best_minifig_result["image_count"],
188+
"remaining": 4 - best_minifig_result["image_count"]
189+
},
190+
status_code=status.HTTP_200_OK
191+
)
192+
193+
# アップロード対象画像を決定
194+
if best_minifig_result["best_image_path"]:
195+
upload_image_path = best_minifig_result["best_image_path"]
196+
else:
197+
# 4枚すべて検出失敗時はランダムで選択
198+
upload_image_path = random.choice(uploaded_image_paths)
199+
200+
# アップロード実行
201+
upload_success = OfficialInterface.upload_snap(upload_image_path)
202+
203+
# リセット
204+
best_minifig_result["image_count"] = 0
205+
best_minifig_result["best_image_path"] = None
206+
best_minifig_result["best_confidence"] = 0.0
207+
best_minifig_result["best_direction"] = None
208+
uploaded_image_paths.clear()
209+
210+
if upload_success:
211+
return JSONResponse(
212+
content={
213+
"message": "Image uploaded successfully",
214+
"imagePath": upload_image_path
215+
},
216+
status_code=status.HTTP_200_OK
217+
)
218+
else:
219+
return JSONResponse(
220+
content={"error": "Failed to upload image to official system"},
221+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
222+
)
223+
224+
106225
# ポート番号の設定
107226
if __name__ == "__main__":
108227
ip = "127.0.0.1"
@@ -120,5 +239,10 @@ def get_image(file: UploadFile = File(...)) -> JSONResponse:
120239
ip = connect_interface.getsockname()[0]
121240
connect_interface.close()
122241

123-
uvicorn.run("src.server.fastapi_server:app",
124-
host=ip, port=8000, reload=True)
242+
uvicorn.run(
243+
"src.server.fastapi_server:app",
244+
host=ip,
245+
port=8000,
246+
reload=True,
247+
reload_excludes=[".venv/*"], # ← これを追加
248+
)

0 commit comments

Comments
 (0)