diff --git "a/docs/03_TECH/TECH_04_\347\267\232\346\244\234\345\207\272\347\262\276\345\272\246\345\220\221\344\270\212\346\226\271\351\207\235.txt" "b/docs/03_TECH/TECH_04_\347\267\232\346\244\234\345\207\272\347\262\276\345\272\246\345\220\221\344\270\212\346\226\271\351\207\235.txt" index 80f03ce..afea42c 100644 --- "a/docs/03_TECH/TECH_04_\347\267\232\346\244\234\345\207\272\347\262\276\345\272\246\345\220\221\344\270\212\346\226\271\351\207\235.txt" +++ "b/docs/03_TECH/TECH_04_\347\267\232\346\244\234\345\207\272\347\262\276\345\272\246\345\220\221\344\270\212\346\226\271\351\207\235.txt" @@ -73,7 +73,38 @@ これらにより,黒線と背景の輝度差が局所的に縮小・逆転し, 固定閾値の2値化では正しい線形状を得られないことがある. - 2-3. 実環境で発生する2つの典型的な劣化 + 2-3. カメラ高さ・線幅の固定による幾何学的制約 + + 本システムでは以下の2点が実走行中に変化しない. + + ・カメラの地面からの高さ(固定マウント) + ・黒線の実際の幅(コース仕様による固定値) + + 透視投影の関係から,行(y 座標)が変わると + カメラから黒線までの距離が変化し,画像上の線幅(ピクセル)も変わる. + + 近距離(画像下端,y = Y_END): 線幅 = width_near [px] + 遠距離(画像上端,y = Y_START): 線幅 = width_far [px] + + この関係は画像の y 座標を入力として線形補間で近似できる. + + expected_width(y) = width_near + + (width_far - width_near) * (Y_END - y) / (Y_END - Y_START) + + この既知の幾何学情報を利用することで,以下が可能になる. + + ・各行の「正常な線幅」の範囲を事前に計算できる + ・陰による線幅の広がりを行ごとに検出できる + ・モルフォロジーパラメータ(Black-hat カーネル,等方クロージングサイズ等) + を線幅から自動的に決定でき,手動調整の余地を減らせる + + ■ width_near / width_far の測定方法 + + 実際のカメラ映像の二値化画像を観察し, + 画像下端付近の行と上端付近の行それぞれで白ピクセルの幅を計測する. + 計測後はコードまたはパラメータファイルに固定値として記録する. + + 2-4. 実環境で発生する2つの典型的な劣化 ■ 穴(光による欠損) @@ -406,7 +437,7 @@ ・備考: 線から離れた場所の大きな誤検出に有効. 線に隣接した陰は連結成分が線と結合するため効かない - 6-7. 幅フィルタ(行ごと) + 6-7. 幅フィルタ(行ごと,固定閾値) 各行の白ピクセルの幅(右端 - 左端)を計算し, 期待される線幅の範囲外の行を除外する. @@ -425,7 +456,52 @@ ・パラメータ: max_line_width は線幅の最大許容値(ピクセル) ・備考: 実装が最も簡単で陰への効果が高い. - ただし陰が線の片側だけに広がった場合は中心がずれる + ただし単一の固定閾値では透視投影により近距離の行で + 上限が厳しすぎる,遠距離の行では甘すぎる問題がある. + 透視補正付き(6-8 節)を推奨する + + 6-8. 幅フィルタ(行ごと,透視補正付き) + + 「2-3. カメラ高さ・線幅の固定による幾何学的制約」で述べた + 線幅の透視変化を考慮し,各行の期待幅を線形補間で算出して + フィルタ閾値を行ごとに動的に決定する. + + ・計算量: 極低 + ・穴への効果: -(幅フィルタは穴の補間をしない) + ・陰への効果: ◎(行ごとに正確な上限を設定できる) + ・実装: + + def _apply_width_filter( + binary: np.ndarray, + width_near: int, # 画像下端での期待線幅 [px] + width_far: int, # 画像上端での期待線幅 [px] + tolerance: float, # 上限倍率(例: 1.8) + ) -> np.ndarray: + result = binary.copy() + h = binary.shape[0] + for y_local in range(h): + xs = np.where(binary[y_local] > 0)[0] + if len(xs) == 0: + continue + # 行ごとの期待幅を線形補間 + t = (h - 1 - y_local) / max(h - 1, 1) + expected = width_far + (width_near - width_far) * (1.0 - t) + max_w = expected * tolerance + actual_w = int(xs[-1]) - int(xs[0]) + 1 + if actual_w > max_w: + result[y_local] = 0 + return result + + ・パラメータ: + - width_near: 画像下端での期待線幅(実測値,px) + - width_far: 画像上端での期待線幅(実測値,px) + - width_tolerance: 上限倍率(推奨 1.8〜2.0) + + ・備考: width_near / width_far は実際の二値化画像から一度計測して + 固定値に設定する.以後はパラメータ調整が不要になる. + 陰の片側広がりは除外されずに中心がずれる弱点は残るが, + 距離変換マスク(6-4 節)と組み合わせることで軽減できる. + 6-7 よりも適用精度が高く,こちらを優先して使用すること 7. Stage 4: 特徴抽出の手法比較 (Feature Extraction) @@ -671,6 +747,22 @@ まず案A を実装して効果を確認し,不足があれば 案B・案C に段階的に進めることを推奨する + 9-5. 共通オプション: 透視補正付き幅フィルタの追加 + + 上記3案はいずれも等方クロージング + 距離変換マスク後に + 「透視補正付き幅フィルタ(6-8 節)」を追加できる. + カメラ高さ・線幅が固定されているため,width_near・width_far を + 一度実測して固定すれば,パラメータ調整なしに + 陰で広がった行を正確に除外できる. + + ・処理時間への影響: 極小(+0.05ms 以下) + ・効果: 距離変換では除去しきれなかった広がり行を補完的に除外 + ・適用場所: _apply_dist_mask の後,_fit_row_centers の前 + ・追加パラメータ: + - width_near: 画像下端での期待線幅(px,実測値) + - width_far: 画像上端での期待線幅(px,実測値) + - width_tolerance: 上限倍率(デフォルト 1.8) + 10. 評価方法 (Evaluation) ------------------------------------------------------------------------ diff --git a/src/pc/gui/main_window.py b/src/pc/gui/main_window.py index 7e206a0..81e35a6 100644 --- a/src/pc/gui/main_window.py +++ b/src/pc/gui/main_window.py @@ -436,6 +436,33 @@ {"robust"}, ) + # --- 幅フィルタ(透視補正) --- + self._spin_width_near = QSpinBox() + self._spin_width_near.setRange(0, 200) + self._spin_width_near.setValue(ip.width_near) + self._spin_width_near.setSpecialValueText("無効") + self._add_image_row( + "線幅(近)px:", self._spin_width_near, + {"blackhat", "dual_norm", "robust"}, + ) + + self._spin_width_far = QSpinBox() + self._spin_width_far.setRange(0, 200) + self._spin_width_far.setValue(ip.width_far) + self._spin_width_far.setSpecialValueText("無効") + self._add_image_row( + "線幅(遠)px:", self._spin_width_far, + {"blackhat", "dual_norm", "robust"}, + ) + + self._spin_width_tolerance = self._create_spin( + ip.width_tolerance, 1.0, 5.0, 0.1, + ) + self._add_image_row( + "幅フィルタ倍率:", self._spin_width_tolerance, + {"blackhat", "dual_norm", "robust"}, + ) + # --- プリセット管理 --- self._image_preset_combo = QComboBox() self._image_preset_combo.setPlaceholderText( @@ -574,6 +601,11 @@ self._spin_ransac_thresh.setValue( ip.ransac_thresh, ) + self._spin_width_near.setValue(ip.width_near) + self._spin_width_far.setValue(ip.width_far) + self._spin_width_tolerance.setValue( + ip.width_tolerance, + ) finally: self._auto_save_enabled = True @@ -614,6 +646,12 @@ ip.ransac_thresh = ( self._spin_ransac_thresh.value() ) + # 幅フィルタ(透視補正) + ip.width_near = self._spin_width_near.value() + ip.width_far = self._spin_width_far.value() + ip.width_tolerance = ( + self._spin_width_tolerance.value() + ) if self._auto_save_enabled: save_detect_params(ip.method, ip) diff --git a/src/pc/vision/line_detector.py b/src/pc/vision/line_detector.py index b5c3b1b..5a97102 100644 --- a/src/pc/vision/line_detector.py +++ b/src/pc/vision/line_detector.py @@ -50,6 +50,9 @@ min_line_width: 行ごと中心抽出の最小線幅 ransac_thresh: RANSAC の外れ値判定閾値 ransac_iter: RANSAC の反復回数 + width_near: 画像下端での期待線幅(px,0 で無効) + width_far: 画像上端での期待線幅(px,0 で無効) + width_tolerance: 幅フィルタの上限倍率 """ # 検出手法 @@ -83,6 +86,11 @@ ransac_thresh: float = 5.0 ransac_iter: int = 50 + # 透視補正付き幅フィルタ(0 で無効) + width_near: int = 0 + width_far: int = 0 + width_tolerance: float = 1.8 + @dataclass class LineDetectResult: @@ -220,13 +228,20 @@ cv2.THRESH_BINARY, ) - # 等方クロージング + 距離変換マスク + # 等方クロージング + 距離変換マスク + 幅フィルタ binary = _apply_iso_closing( binary, params.iso_close_size, ) binary = _apply_dist_mask( binary, params.dist_thresh, ) + if params.width_near > 0 and params.width_far > 0: + binary = _apply_width_filter( + binary, + params.width_near, + params.width_far, + params.width_tolerance, + ) # 行ごと中心抽出 + フィッティング return _fit_row_centers( @@ -266,13 +281,20 @@ block, params.adaptive_c, ) - # 等方クロージング + 距離変換マスク + # 等方クロージング + 距離変換マスク + 幅フィルタ binary = _apply_iso_closing( binary, params.iso_close_size, ) binary = _apply_dist_mask( binary, params.dist_thresh, ) + if params.width_near > 0 and params.width_far > 0: + binary = _apply_width_filter( + binary, + params.width_near, + params.width_far, + params.width_tolerance, + ) # 行ごと中心抽出 + フィッティング return _fit_row_centers( @@ -308,13 +330,20 @@ block, -params.adaptive_c, ) - # 等方クロージング + 距離変換マスク + # 等方クロージング + 距離変換マスク + 幅フィルタ binary = _apply_iso_closing( binary, params.iso_close_size, ) binary = _apply_dist_mask( binary, params.dist_thresh, ) + if params.width_near > 0 and params.width_far > 0: + binary = _apply_width_filter( + binary, + params.width_near, + params.width_far, + params.width_tolerance, + ) # 行ごと中央値抽出 + RANSAC フィッティング return _fit_row_centers( @@ -351,6 +380,47 @@ ) +def _apply_width_filter( + binary: np.ndarray, + width_near: int, + width_far: int, + tolerance: float, +) -> np.ndarray: + """透視補正付き幅フィルタで広がりすぎた行を除外する + + 各行の期待線幅を線形補間で算出し, + 実際の幅が上限(期待幅 × tolerance)を超える行をマスクする + + Args: + binary: 二値画像 + width_near: 画像下端での期待線幅(px) + width_far: 画像上端での期待線幅(px) + tolerance: 上限倍率 + + Returns: + 幅フィルタ適用後の二値画像 + """ + result = binary.copy() + h = binary.shape[0] + denom = max(h - 1, 1) + + for y_local in range(h): + xs = np.where(binary[y_local] > 0)[0] + if len(xs) == 0: + continue + # 画像下端(近距離)ほど t=1,上端(遠距離)ほど t=0 + t = (h - 1 - y_local) / denom + expected = float(width_far) + ( + float(width_near) - float(width_far) + ) * t + max_w = expected * tolerance + actual_w = int(xs[-1]) - int(xs[0]) + 1 + if actual_w > max_w: + result[y_local] = 0 + + return result + + def _apply_dist_mask( binary: np.ndarray, thresh: float, ) -> np.ndarray: