Skip to content

Commit 9f3556a

Browse files
Yuzz1ecursoragent
andcommitted
Improve Pi4 camera setup and slim down release artifacts.
Pi4 gets IMX219 overlay autoselect, full-FOV 1640x1232 capture, and a fixed /etc/hosts update on hostname change. Board release tars no longer bundle YOLO weights; last.pt is published once per release and self-update skips existing models. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ecadd35 commit 9f3556a

15 files changed

Lines changed: 457 additions & 50 deletions

File tree

.goreleaser.yaml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
version: 2
22
before:
33
hooks:
4-
# The YOLO models live in the public ssl-YOLO-Detection submodule; fetch
5-
# them so the archives below can bundle camera/yolo/*.pt.
4+
# Fetch the YOLO submodule so release.extra_files can publish last.pt.
5+
# Model weights are NOT bundled in board archives (keeps self-update small).
66
- git submodule update --init --recursive
77
builds:
88
- id: racoon-pi4
@@ -37,11 +37,17 @@ archives:
3737
files: &camera_files
3838
- src: camera/*.py
3939
- src: camera/**/*.py
40+
exclude:
41+
- "camera/yolo/**"
4042
- src: camera/requirements.txt
41-
- src: camera/yolo/last.pt
42-
- src: camera/yolo/best.pt
4343
- id: rock5a
4444
ids:
4545
- racoon-rock5a
4646
name_template: "racoon-pi2-rock5a_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
4747
files: *camera_files
48+
49+
release:
50+
# One shared weight file per release (not duplicated in each board tar).
51+
extra_files:
52+
- glob: camera/yolo/last.pt
53+
name_template: "racoon-pi2-yolo_{{ .Version }}_last.pt"

README.md

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,41 @@ Rock5A 上で `go build` した場合は `GOOS`/`GOARCH` は不要です。Mac
7070

7171
| ボード | バックエンド | デバイス |
7272
| ------ | ------------ | -------- |
73-
| Pi 4B | Picamera2(MIPI CSI) | Picamera2 既定 |
73+
| Pi 4B | Picamera2(MIPI CSI) | Picamera2 既定(CSI ポートはオーバーレイで指定) |
7474
| Rock5A | OpenCV V4L2 | `/dev/video11`(既定)。`threshold.json``cameraDevice` で上書き可 |
7575

76+
### Pi 4B の CSI オーバーレイ(IMX219 / OV5647)
77+
78+
Pi 4B には CSI コネクタが **CAM0****CAM1** の 2 つあります。接続ポートに応じて `/boot/firmware/config.txt` でオーバーレイを指定する必要があります。`camera_auto_detect=1` だけでは検出に失敗することがあります(`rpicam-hello --list-cameras``No cameras available!` になる)。
79+
80+
| センサー | モジュール | オーバーレイ例 |
81+
| -------- | ---------- | -------------- |
82+
| IMX219 | Pi Camera v2 | `dtoverlay=imx219,cam0` または `dtoverlay=imx219,cam1` |
83+
| OV5647 | Pi Camera v1.3 | `dtoverlay=ov5647,cam0` または `dtoverlay=ov5647,cam1` |
84+
85+
手動設定の例(CAM1 に IMX219 を接続した場合):
86+
87+
```ini
88+
camera_auto_detect=0
89+
dtoverlay=imx219,cam1
90+
```
91+
92+
- **起動時のオーバーレイ自動選択**: `scripts/select-pi4-camera.sh``imx219,cam0``imx219,cam1``ov5647,cam0``ov5647,cam1` の順に試します。`scripts/racoon-camera-autoselect.service` は Pi(`/boot/firmware/config.txt`)と Rock5A(`/boot/dietpiEnv.txt`)のどちらでも動作します。
93+
94+
```bash
95+
sudo install -m 0755 scripts/select-pi4-camera.sh /usr/local/sbin/select-pi4-camera.sh
96+
sudo install -m 0755 scripts/select-rock5a-camera.sh /usr/local/sbin/select-rock5a-camera.sh
97+
sudo install -m 0644 scripts/racoon-camera-autoselect.service /etc/systemd/system/racoon-camera-autoselect.service
98+
sudo systemctl daemon-reload
99+
sudo systemctl enable racoon-camera-autoselect.service
100+
```
101+
102+
- **映像プレビュー**: `http://<robot>:9191/color-tuner` でライブプレビューと HSV しきい値調整ができます。
103+
104+
- **上下左右反転(180°回転)**: `threshold.json``"cameraFlip180": true/false` で制御します。IMX219 の既定は反転あり(RACOON 取り付け向きに合わせた設定)。
105+
106+
- **キャプチャ解像度(Pi 4B)**: IMX219 は全画角を維持する最小解像度 **1640×1232**(2×2 ビニング)を既定にしています。640×480 はセンサー中央の切り出しになるため使いません。`threshold.json``"frameWidth"` / `"frameHeight"` で上書きできます。
107+
76108
### Rock5A のセンサー自動判別(IMX219 / OV5647)
77109

78110
Rock5A は Raspberry Pi Camera v1.3(OV5647)と v2(IMX219)の両方に対応しますが、それぞれ別のデバイスツリーオーバーレイを使い、**同時に有効化すると CSI パイプラインが壊れます**`rkcif ... get remote terminal sensor failed`)。
@@ -82,10 +114,10 @@ Rock5A は Raspberry Pi Camera v1.3(OV5647)と v2(IMX219)の両方に対
82114
| IMX219 | Pi Camera v2 | `rpi-camera-v2` | `0x10` |
83115
| OV5647 | Pi Camera v1.3 | `rpi-camera-v1_3` | `0x36` |
84116

85-
- **起動時のオーバーレイ自動選択**: `scripts/select-rock5a-camera.sh` を systemd の oneshot(`scripts/racoon-camera-autoselect.service`)として `ssl-racoon.service` より前に実行します。センサーの subdev(`imx219` / `ov5647`)が出ていなければ `/boot/dietpiEnv.txt``overlays=` を片方だけになるよう書き換えて 1 回だけ再起動します。状態ファイル `/var/lib/racoon-camera-autoselect.state` で IMX219↔OV5647 を最大 1 巡しか試さないため、リブートループにはなりません。正しいオーバーレイが既に設定済み(通常運転)なら何もしません。
117+
- **起動時のオーバーレイ自動選択**: `scripts/select-rock5a-camera.sh` を systemd の oneshot(`scripts/racoon-camera-autoselect.service`)として `ssl-racoon.service` より前に実行します。センサーの subdev(`imx219` / `ov5647`)が出ていなければ `/boot/dietpiEnv.txt``overlays=` を片方だけになるよう書き換えて 1 回だけ再起動します。状態ファイル `/var/lib/racoon-camera-autoselect.state` で IMX219↔OV5647 を最大 1 巡しか試さないため、リブートループにはなりません。正しいオーバーレイが既に設定済み(通常運転)なら何もしません。Pi 4B 向けは上記「Pi 4B の CSI オーバーレイ」を参照してください。
86118

87119
```bash
88-
# 初回のみ(端末上で)
120+
# 初回のみ(Rock 5A 端末上で。Pi 4B は select-pi4-camera.sh も併せて install
89121
sudo install -m 0755 scripts/select-rock5a-camera.sh /usr/local/sbin/select-rock5a-camera.sh
90122
sudo install -m 0644 scripts/racoon-camera-autoselect.service /etc/systemd/system/racoon-camera-autoselect.service
91123
sudo systemctl daemon-reload
@@ -126,9 +158,17 @@ GitHub Release からボード別バイナリを取得します。Public リポ
126158
| Pi 4B | `racoon-pi2-pi4_v1.0.0_linux_arm64.tar.gz` | `^racoon-pi2-pi4_` |
127159
| Rock5A | `racoon-pi2-rock5a_v1.0.0_linux_arm64.tar.gz` | `^racoon-pi2-rock5a_` |
128160

129-
Release の tar には Go バイナリに加え、`camera/` パッケージ一式(Python ソース・`requirements.txt`・YOLO キャリブ用モデル `camera/yolo/last.pt` / `best.pt`)が同梱されます(モデルは Release ビルド時に submodule を取得して同梱)。展開するとバイナリと同じディレクトリに `camera/` が置かれるため、別端末でも `git clone` なしで動かせます(初回のみ `pip install -r camera/requirements.txt` は必要)。
161+
Release の tar には Go バイナリと `camera/` の Python ソース(**YOLO モデル `.pt` は含まない**)が同梱されます。キャリブレーション用の重みは Release ごとに 1 つだけ別アセット(`racoon-pi2-yolo_<version>_last.pt`、約 23 MiB)として公開します。初回セットアップ時にロボットへ配置してください。
162+
163+
```bash
164+
# バイナリと同じディレクトリで(git clone 時は submodule でも可)
165+
./scripts/install-yolo-model.sh v1.0.0
166+
# または手動:
167+
# curl -fL -o camera/yolo/last.pt \
168+
# https://github.com/Rione/ssl-RACOON-Pi2/releases/download/v1.0.0/racoon-pi2-yolo_1.0.0_last.pt
169+
```
130170

131-
同一バージョンタグ(例: `v1.0.0`)の Release に両方のアセットが含まれますが、実行中のビルドに応じて正しい方のみが選ばれます。自動アップデート時は**バイナリの差し替えに加え、tar 内の `camera/`(Python コード・モデル)もバイナリと同じディレクトリへ展開**して同期します(`camera/` 配下以外は上書きしません)。
171+
自動アップデートはバイナリと Python のみ同期し、既にある `camera/yolo/*.pt` は上書きしません(毎回 ~23 MiB×2 の転送を避けるため)。
132172

133173
## Robot IDの決定方法
134174

camera/capture/pi4.py

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,80 @@
88
from picamera2 import Picamera2
99

1010
from camera import debug
11+
from camera.frame_post import postprocess_frame
12+
from camera.sensor import SENSOR_PROFILES
13+
14+
# Fallback when the sensor model is unknown.
15+
_DEFAULT_CAPTURE_SIZE = (640, 480)
16+
17+
18+
def _camera_info():
19+
return Picamera2.global_camera_info()
20+
21+
22+
def _detect_sensor_model(camera_info):
23+
"""Best-effort sensor id from libcamera metadata (imx219, ov5647, ...)."""
24+
model = str(camera_info.get("Model", "")).lower()
25+
for name in ("imx219", "ov5647", "imx477", "imx708"):
26+
if name in model:
27+
return name
28+
return None
29+
30+
31+
def _resolve_capture_size(settings, sensor_model):
32+
"""Returns capture width/height.
33+
34+
``threshold.json`` may override with ``frameWidth`` / ``frameHeight``.
35+
Otherwise use the sensor profile's full-FOV default (IMX219: 1640x1232).
36+
"""
37+
if "frameWidth" in settings and "frameHeight" in settings:
38+
return int(settings["frameWidth"]), int(settings["frameHeight"])
39+
40+
profile = SENSOR_PROFILES.get(sensor_model or "", {})
41+
size = profile.get("default_capture_size")
42+
if size:
43+
return int(size[0]), int(size[1])
44+
return _DEFAULT_CAPTURE_SIZE
1145

1246

1347
class Pi4Capture:
1448
def __init__(self, settings=None):
1549
if settings is None:
1650
settings = {}
1751

52+
cameras = _camera_info()
53+
if not cameras:
54+
raise IOError(
55+
"No MIPI camera detected by libcamera. "
56+
"Check the CSI cable and config.txt overlay "
57+
"(e.g. dtoverlay=imx219,cam0 or cam1). "
58+
"Run scripts/select-pi4-camera.sh to auto-try overlays."
59+
)
60+
61+
self._settings = settings
62+
self._sensor_model = _detect_sensor_model(cameras[0])
63+
1864
self.cap = Picamera2()
19-
config = self.cap.create_preview_configuration({"format": "RGB888"})
65+
width, height = _resolve_capture_size(settings, self._sensor_model)
66+
config = self.cap.create_preview_configuration(
67+
main={"size": (width, height), "format": "RGB888"}
68+
)
2069
self.cap.configure(config)
2170
self.cap.start()
2271

23-
self._width = int(settings.get("frameWidth", 640))
24-
self._height = int(settings.get("frameHeight", 480))
25-
debug.log(f"Pi4 (Picamera2) capture started: target {self._width}x{self._height}")
72+
self._width = width
73+
self._height = height
74+
debug.log(
75+
f"Pi4 (Picamera2) capture started: {self._sensor_model or 'unknown'} "
76+
f"{self._width}x{self._height}"
77+
)
2678

2779
def read(self):
28-
return (True, self.cap.capture_array())
80+
frame = self.cap.capture_array()
81+
if frame is None:
82+
return False, None
83+
frame = postprocess_frame(frame, self._settings, self._sensor_model)
84+
return True, frame
2985

3086
def release(self):
3187
self.cap.close()

camera/sensor.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
"default_exposure": 900,
3838
"default_gain": 80,
3939
"default_flip180": False,
40+
# Pi 4 / libcamera: 1296x972 is binned full field of view.
41+
"default_capture_size": (1296, 972),
4042
},
4143
"imx219": {
4244
"gain_ctrl": "gain",
@@ -50,6 +52,9 @@
5052
"color_gains": (1.15, 0.78, 1.12),
5153
# RACOON mount: IMX219 module is upside-down; OV5647 is not.
5254
"default_flip180": True,
55+
# Pi 4 / libcamera: 1640x1232 is 2x2-binned full field of view.
56+
# 640x480 uses a centre window and crops heavily.
57+
"default_capture_size": (1640, 1232),
5358
},
5459
}
5560

camera/transport/encoder.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,10 @@ def encodeData(
4545

4646
frame_bytes_b64 = base64.b64encode(encoded_image.tobytes()).decode("utf-8")
4747

48+
if frame_width is None or frame_height is None:
49+
frame_height, frame_width = frame.shape[:2]
50+
4851
if center is not None:
49-
if frame_width is None or frame_height is None:
50-
frame_height, frame_width = frame.shape[:2]
5152
x_coord, y_coord = Encoder.pixel_to_graph(
5253
center[0], center[1], frame_width, frame_height
5354
)
@@ -57,7 +58,14 @@ def encodeData(
5758
y_coord = NO_BALL_COORD
5859
isball = False
5960

60-
data = {"frame": frame_bytes_b64, "x": x_coord, "y": y_coord, "isball": isball}
61+
data = {
62+
"frame": frame_bytes_b64,
63+
"x": x_coord,
64+
"y": y_coord,
65+
"isball": isball,
66+
"frameWidth": int(frame_width),
67+
"frameHeight": int(frame_height),
68+
}
6169

6270
try:
6371
return json.dumps(data)

cmd/spi_test/main.go

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
// sudo ./spi_test -once -kick 50 -dribble 30
1919
// sudo ./spi_test -sweep -interval 16ms # VelX: -1000→0→1000→0→-1000 を繰り返し
2020
// sudo ./spi_test -sweep -mismatch-only # フレームずれ時のみ表示
21+
// sudo ./spi_test -pattern -once # FF 01 02 ... 12 AA の固定パターン
2122
//
2223
// Ctrl+C 終了時に OK/NG パケット数の統計を表示する。
2324
package main
@@ -171,6 +172,7 @@ func main() {
171172
sweepMax := flag.Int("sweep-max", 1000, "スイープ時のVelX最大絶対値 [mm/s]")
172173
sweepStep := flag.Int("sweep-step", 100, "スイープ時のVelX刻み幅 [mm/s]")
173174
mismatchOnly := flag.Bool("mismatch-only", false, "受信フレームずれ(NG)時のみ詳細を表示")
175+
pattern := flag.Bool("pattern", false, "固定テストパターン送信 (255 01 02 ... 18 170 = FF + 1..18 + AA)")
174176

175177
flag.Parse()
176178

@@ -200,6 +202,9 @@ func main() {
200202
if *mismatchOnly {
201203
log.Println("Mismatch-only mode: NG frames only")
202204
}
205+
if *pattern {
206+
log.Println("Pattern mode: TX = FF 01 02 03 ... 12 AA (decimal: 255 1..18 170)")
207+
}
203208

204209
sig := make(chan os.Signal, 1)
205210
signal.Notify(sig, os.Interrupt)
@@ -230,12 +235,16 @@ loop:
230235
default:
231236
}
232237

233-
velXNow := *velX
234-
if sweeper != nil {
235-
velXNow = sweeper.next()
238+
var tx []byte
239+
if *pattern {
240+
tx = buildPatternFrame()
241+
} else {
242+
velXNow := *velX
243+
if sweeper != nil {
244+
velXNow = sweeper.next()
245+
}
246+
tx = buildFrame(velXNow, *velY, *velAng, *dribble, *kick, *chip, *camX, *camY, *charge, *signalRecv, *emgStop)
236247
}
237-
238-
tx := buildFrame(velXNow, *velY, *velAng, *dribble, *kick, *chip, *camX, *camY, *charge, *signalRecv, *emgStop)
239248
rx := make([]byte, spiFrameSize)
240249
txCopy := append([]byte(nil), tx...)
241250
if err := conn.Tx(tx, rx); err != nil {
@@ -255,7 +264,11 @@ loop:
255264
prevFrameOK = frameOK
256265

257266
if !*mismatchOnly || frameErr != nil {
258-
printResult(sent, txCopy, rx, frameErr)
267+
if *pattern {
268+
printPatternResult(sent, txCopy, rx, frameErr)
269+
} else {
270+
printResult(sent, txCopy, rx, frameErr)
271+
}
259272
}
260273

261274
if *count > 0 && sent >= *count {
@@ -271,6 +284,16 @@ loop:
271284
}
272285
}
273286

287+
func buildPatternFrame() []byte {
288+
tx := make([]byte, spiFrameSize)
289+
tx[0] = spiFrameHeader
290+
for i := range spiPayloadSize {
291+
tx[1+i] = byte(i + 1)
292+
}
293+
tx[spiFrameSize-1] = spiFrameFooter
294+
return tx
295+
}
296+
274297
func buildFrame(velX, velY, velAng, dribble, kick, chip, camX, camY int, charge, signal, emgStop bool) []byte {
275298
var info uint8
276299
if emgStop {
@@ -329,6 +352,26 @@ func validateSPIFrame(rx []byte) error {
329352
return nil
330353
}
331354

355+
func printPatternResult(n int, tx, rx []byte, frameErr error) {
356+
status := "OK"
357+
if frameErr != nil {
358+
status = "NG"
359+
}
360+
fmt.Printf("[%d] %s TX pattern: ", n, status)
361+
for i, b := range tx {
362+
if i > 0 {
363+
fmt.Print(" ")
364+
}
365+
fmt.Print(b)
366+
}
367+
fmt.Println()
368+
fmt.Printf(" TX raw (%dB): % x\n", len(tx), tx)
369+
fmt.Printf(" RX raw (%dB): % x\n", len(rx), rx)
370+
if frameErr != nil {
371+
fmt.Printf(" !! %v\n", frameErr)
372+
}
373+
}
374+
332375
func printResult(n int, tx, rx []byte, frameErr error) {
333376
var recv recvFrame
334377
if err := binary.Read(bytes.NewReader(rx[1:1+spiRecvSize]), binary.LittleEndian, &recv); err != nil {

internal/link/common.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,24 @@ var (
1717
prevPowerShutdown bool
1818
)
1919

20-
// Default capture resolution halves (640x480). Used to map graph coords to SPI bytes.
20+
// Default capture resolution halves (640x480 legacy fallback for SPI byte mapping).
2121
const (
2222
cameraFrameHalfWidth = 320
2323
cameraFrameHalfHeight = 240
2424
)
2525

26+
func cameraFrameHalfSizes() (halfW, halfH int) {
27+
halfW = cameraFrameHalfWidth
28+
halfH = cameraFrameHalfHeight
29+
if state.ImageDataPtr == nil {
30+
return halfW, halfH
31+
}
32+
if state.ImageDataPtr.FrameWidth > 0 && state.ImageDataPtr.FrameHeight > 0 {
33+
return state.ImageDataPtr.FrameWidth / 2, state.ImageDataPtr.FrameHeight / 2
34+
}
35+
return halfW, halfH
36+
}
37+
2638
func scaleCameraCoord(coord float32, halfSize int) int {
2739
scaled := 128 + int(coord*127/float32(halfSize))
2840
if scaled < 0 {
@@ -115,10 +127,11 @@ func updateCameraCoordinates(sendbytes []byte) {
115127
return
116128
}
117129

118-
scaledX := scaleCameraCoord(state.ImageDataPtr.ImageX, cameraFrameHalfWidth)
130+
halfW, halfH := cameraFrameHalfSizes()
131+
scaledX := scaleCameraCoord(state.ImageDataPtr.ImageX, halfW)
119132
sendbytes[frame.IdxCamBallX] = byte(scaledX)
120133

121-
scaledY := scaleCameraCoord(state.ImageDataPtr.ImageY, cameraFrameHalfHeight)
134+
scaledY := scaleCameraCoord(state.ImageDataPtr.ImageY, halfH)
122135
sendbytes[frame.IdxCamBallY] = byte(scaledY)
123136
}
124137

0 commit comments

Comments
 (0)