@jikko/ai-camera 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/package.json +48 -0
- package/src/core/format.js +33 -0
- package/src/core/index.d.ts +66 -0
- package/src/core/index.js +20 -0
- package/src/core/protocol.js +132 -0
- package/src/core/results.js +112 -0
- package/src/index.d.ts +19 -0
- package/src/index.js +6 -0
- package/src/web/index.d.ts +95 -0
- package/src/web/index.js +6 -0
- package/src/web/web-serial.js +282 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MAKEITALL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @jikko/ai-camera
|
|
2
|
+
|
|
3
|
+
JIKKO AI Camera SDK입니다. 카메라 시리얼 프로토콜을 파싱하는 `core`와 브라우저 Web Serial API로 카메라에 연결하는 `web` 어댑터를 제공합니다.
|
|
4
|
+
|
|
5
|
+
이 패키지는 게임 규칙을 포함하지 않습니다. `CLEAR`, `BATTERY`, `ANTENA`, `COOLANT` 같은 카드 의미는 각 프로젝트에서 `classId`를 받아 매핑해야 합니다.
|
|
6
|
+
|
|
7
|
+
## 설치
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @jikko/ai-camera
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## WebSerial 사용
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { createWebSerialCamera } from '@jikko/ai-camera/web'
|
|
17
|
+
|
|
18
|
+
const camera = createWebSerialCamera({
|
|
19
|
+
baudRate: 9600,
|
|
20
|
+
emitRawChunks: true
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
camera.on('classification', (event) => {
|
|
24
|
+
console.log(event.classId, event.score)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
camera.on('detection', (event) => {
|
|
28
|
+
console.log(event.classId, event.centerX, event.centerY)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
camera.on('packet', (packet) => {
|
|
32
|
+
console.log(packet.command, packet.length, packet.data)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
camera.on('error', (event) => {
|
|
36
|
+
console.error(event.code, event.message)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
await camera.connect()
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Web Serial API는 Chrome/Edge 계열 브라우저와 HTTPS 또는 localhost 환경이 필요합니다.
|
|
43
|
+
|
|
44
|
+
## Parser만 사용
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
import { JikkoProtocolParser, parsePacketResults } from '@jikko/ai-camera/core'
|
|
48
|
+
|
|
49
|
+
const parser = new JikkoProtocolParser()
|
|
50
|
+
const packets = parser.feedChunk(bytes)
|
|
51
|
+
|
|
52
|
+
for (const packet of packets) {
|
|
53
|
+
const parsed = parsePacketResults(packet)
|
|
54
|
+
console.log(parsed.classifications, parsed.detections)
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 패킷 구조
|
|
59
|
+
|
|
60
|
+
```txt
|
|
61
|
+
START CMD LEN_L LEN_H DATA... CRC_0 CRC_1 CRC_2 CRC_3 END
|
|
62
|
+
0xFD 1B 1B 1B LEN 4B little-endian 0xED
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
명령:
|
|
66
|
+
|
|
67
|
+
```txt
|
|
68
|
+
0x00 KEYPOINT_BOX_DETECTION
|
|
69
|
+
0x01 CLASSIFICATION
|
|
70
|
+
0x02 DETECTION
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
현재 v0.1은 CRC 값을 읽어서 `packet.crc`로 제공합니다. CRC 검증은 아직 수행하지 않습니다.
|
|
74
|
+
|
|
75
|
+
## 개발
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npm install
|
|
79
|
+
npm test
|
|
80
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jikko/ai-camera",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JIKKO AI Camera protocol parser and WebSerial adapter.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"import": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./core": {
|
|
14
|
+
"types": "./src/core/index.d.ts",
|
|
15
|
+
"import": "./src/core/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./web": {
|
|
18
|
+
"types": "./src/web/index.d.ts",
|
|
19
|
+
"import": "./src/web/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/makeitall-dev/JIKKO_AI_CAMERA_SDK.git"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"jikko",
|
|
36
|
+
"ai-camera",
|
|
37
|
+
"webserial",
|
|
38
|
+
"serial"
|
|
39
|
+
],
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/makeitall-dev/JIKKO_AI_CAMERA_SDK/issues"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/makeitall-dev/JIKKO_AI_CAMERA_SDK#readme",
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getCommandName } from './protocol.js'
|
|
2
|
+
|
|
3
|
+
export function formatByteHex(byte) {
|
|
4
|
+
return Number(byte).toString(16).padStart(2, '0').toUpperCase()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function formatBytesAsHex(bytes) {
|
|
8
|
+
if (!bytes || bytes.length === 0) return ''
|
|
9
|
+
return Array.from(bytes, formatByteHex).join(' ')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatBytesAsAscii(bytes) {
|
|
13
|
+
if (!bytes || bytes.length === 0) return ''
|
|
14
|
+
|
|
15
|
+
return Array.from(bytes, (byte) => {
|
|
16
|
+
if (byte >= 32 && byte <= 126) return String.fromCharCode(byte)
|
|
17
|
+
if (byte === 10) return '\\n'
|
|
18
|
+
if (byte === 13) return '\\r'
|
|
19
|
+
return '.'
|
|
20
|
+
}).join('')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatCrc(value) {
|
|
24
|
+
return `0x${(Number(value) >>> 0).toString(16).padStart(8, '0').toUpperCase()}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatPacket(packet) {
|
|
28
|
+
if (!packet) return ''
|
|
29
|
+
|
|
30
|
+
const command = packet.command || getCommandName(packet.cmd)
|
|
31
|
+
return `${command} / len ${packet.length} / data ${formatBytesAsHex(packet.data)} / crc ${formatCrc(packet.crc)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const JIKKO_PACKET_START: 0xFD
|
|
2
|
+
export const JIKKO_PACKET_END: 0xED
|
|
3
|
+
|
|
4
|
+
export const JikkoCameraCommand: {
|
|
5
|
+
readonly KEYPOINT_BOX_DETECTION: 0x00
|
|
6
|
+
readonly CLASSIFICATION: 0x01
|
|
7
|
+
readonly DETECTION: 0x02
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type JikkoCommandName =
|
|
11
|
+
| 'KEYPOINT_BOX_DETECTION'
|
|
12
|
+
| 'CLASSIFICATION'
|
|
13
|
+
| 'DETECTION'
|
|
14
|
+
| 'UNKNOWN'
|
|
15
|
+
|
|
16
|
+
export type JikkoPacket = {
|
|
17
|
+
start: 0xFD
|
|
18
|
+
cmd: number
|
|
19
|
+
command: JikkoCommandName
|
|
20
|
+
length: number
|
|
21
|
+
data: Uint8Array
|
|
22
|
+
crc: number
|
|
23
|
+
end: 0xED
|
|
24
|
+
receivedAt: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ClassificationResult = {
|
|
28
|
+
type: number
|
|
29
|
+
classId: number
|
|
30
|
+
score: number
|
|
31
|
+
index: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type DetectionResult = {
|
|
35
|
+
type: number
|
|
36
|
+
classId: number
|
|
37
|
+
centerX: number
|
|
38
|
+
centerY: number
|
|
39
|
+
width: number
|
|
40
|
+
height: number
|
|
41
|
+
score: number
|
|
42
|
+
index: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ParsedPacketResults = {
|
|
46
|
+
packet: JikkoPacket
|
|
47
|
+
classifications: ClassificationResult[]
|
|
48
|
+
detections: DetectionResult[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class JikkoProtocolParser {
|
|
52
|
+
feed(byte: number): JikkoPacket | null
|
|
53
|
+
feedChunk(bytes: Iterable<number>): JikkoPacket[]
|
|
54
|
+
reset(): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getCommandName(cmd: number): JikkoCommandName
|
|
58
|
+
export function parseClassificationResults(packet: JikkoPacket): ClassificationResult[]
|
|
59
|
+
export function parseDetectionResults(packet: JikkoPacket): DetectionResult[]
|
|
60
|
+
export function parsePacketResults(packet: JikkoPacket): ParsedPacketResults
|
|
61
|
+
export function formatByteHex(byte: number): string
|
|
62
|
+
export function formatBytesAsAscii(bytes: Iterable<number>): string
|
|
63
|
+
export function formatBytesAsHex(bytes: Iterable<number>): string
|
|
64
|
+
export function formatCrc(value: number): string
|
|
65
|
+
export function formatPacket(packet: JikkoPacket): string
|
|
66
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export {
|
|
2
|
+
JIKKO_PACKET_END,
|
|
3
|
+
JIKKO_PACKET_START,
|
|
4
|
+
JikkoCameraCommand,
|
|
5
|
+
JikkoProtocolParser,
|
|
6
|
+
getCommandName
|
|
7
|
+
} from './protocol.js'
|
|
8
|
+
export {
|
|
9
|
+
parseClassificationResults,
|
|
10
|
+
parseDetectionResults,
|
|
11
|
+
parsePacketResults
|
|
12
|
+
} from './results.js'
|
|
13
|
+
export {
|
|
14
|
+
formatByteHex,
|
|
15
|
+
formatBytesAsAscii,
|
|
16
|
+
formatBytesAsHex,
|
|
17
|
+
formatCrc,
|
|
18
|
+
formatPacket
|
|
19
|
+
} from './format.js'
|
|
20
|
+
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export const JIKKO_PACKET_START = 0xFD
|
|
2
|
+
export const JIKKO_PACKET_END = 0xED
|
|
3
|
+
|
|
4
|
+
export const JikkoCameraCommand = Object.freeze({
|
|
5
|
+
KEYPOINT_BOX_DETECTION: 0x00,
|
|
6
|
+
CLASSIFICATION: 0x01,
|
|
7
|
+
DETECTION: 0x02
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const commandNames = Object.freeze({
|
|
11
|
+
[JikkoCameraCommand.KEYPOINT_BOX_DETECTION]: 'KEYPOINT_BOX_DETECTION',
|
|
12
|
+
[JikkoCameraCommand.CLASSIFICATION]: 'CLASSIFICATION',
|
|
13
|
+
[JikkoCameraCommand.DETECTION]: 'DETECTION'
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const ReceiveState = Object.freeze({
|
|
17
|
+
WAIT_START: 0,
|
|
18
|
+
HEAD: 1,
|
|
19
|
+
DATA: 2,
|
|
20
|
+
CRC: 3,
|
|
21
|
+
END: 4
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export function getCommandName(cmd) {
|
|
25
|
+
return commandNames[cmd] || 'UNKNOWN'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readUint32LE(bytes) {
|
|
29
|
+
return (
|
|
30
|
+
(bytes[0] |
|
|
31
|
+
(bytes[1] << 8) |
|
|
32
|
+
(bytes[2] << 16) |
|
|
33
|
+
(bytes[3] << 24)) >>> 0
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class JikkoProtocolParser {
|
|
38
|
+
constructor() {
|
|
39
|
+
this.reset()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
feed(byte) {
|
|
43
|
+
const normalizedByte = Number(byte) & 0xFF
|
|
44
|
+
|
|
45
|
+
switch (this.state) {
|
|
46
|
+
case ReceiveState.WAIT_START:
|
|
47
|
+
if (normalizedByte === JIKKO_PACKET_START) {
|
|
48
|
+
this.currentPacket = {
|
|
49
|
+
start: normalizedByte,
|
|
50
|
+
cmd: 0,
|
|
51
|
+
command: 'UNKNOWN',
|
|
52
|
+
length: 0,
|
|
53
|
+
data: new Uint8Array(),
|
|
54
|
+
crc: 0,
|
|
55
|
+
end: 0,
|
|
56
|
+
receivedAt: Date.now()
|
|
57
|
+
}
|
|
58
|
+
this.receiveBuffer = []
|
|
59
|
+
this.state = ReceiveState.HEAD
|
|
60
|
+
}
|
|
61
|
+
break
|
|
62
|
+
|
|
63
|
+
case ReceiveState.HEAD:
|
|
64
|
+
this.receiveBuffer.push(normalizedByte)
|
|
65
|
+
if (this.receiveBuffer.length === 3) {
|
|
66
|
+
const cmd = this.receiveBuffer[0]
|
|
67
|
+
this.currentPacket.cmd = cmd
|
|
68
|
+
this.currentPacket.command = getCommandName(cmd)
|
|
69
|
+
this.currentPacket.length = this.receiveBuffer[1] | (this.receiveBuffer[2] << 8)
|
|
70
|
+
this.receiveBuffer = []
|
|
71
|
+
this.state = this.currentPacket.length === 0 ? ReceiveState.CRC : ReceiveState.DATA
|
|
72
|
+
}
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
case ReceiveState.DATA:
|
|
76
|
+
this.receiveBuffer.push(normalizedByte)
|
|
77
|
+
if (this.receiveBuffer.length === this.currentPacket.length) {
|
|
78
|
+
this.currentPacket.data = Uint8Array.from(this.receiveBuffer)
|
|
79
|
+
this.receiveBuffer = []
|
|
80
|
+
this.state = ReceiveState.CRC
|
|
81
|
+
}
|
|
82
|
+
break
|
|
83
|
+
|
|
84
|
+
case ReceiveState.CRC:
|
|
85
|
+
this.receiveBuffer.push(normalizedByte)
|
|
86
|
+
if (this.receiveBuffer.length === 4) {
|
|
87
|
+
this.currentPacket.crc = readUint32LE(this.receiveBuffer)
|
|
88
|
+
this.receiveBuffer = []
|
|
89
|
+
this.state = ReceiveState.END
|
|
90
|
+
}
|
|
91
|
+
break
|
|
92
|
+
|
|
93
|
+
case ReceiveState.END:
|
|
94
|
+
if (normalizedByte === JIKKO_PACKET_END) {
|
|
95
|
+
const packet = {
|
|
96
|
+
...this.currentPacket,
|
|
97
|
+
end: normalizedByte,
|
|
98
|
+
receivedAt: Date.now()
|
|
99
|
+
}
|
|
100
|
+
this.reset()
|
|
101
|
+
return packet
|
|
102
|
+
}
|
|
103
|
+
this.reset()
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
default:
|
|
107
|
+
this.reset()
|
|
108
|
+
break
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
feedChunk(bytes) {
|
|
115
|
+
const packets = []
|
|
116
|
+
if (!bytes) return packets
|
|
117
|
+
|
|
118
|
+
for (const byte of bytes) {
|
|
119
|
+
const packet = this.feed(byte)
|
|
120
|
+
if (packet) packets.push(packet)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return packets
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
reset() {
|
|
127
|
+
this.state = ReceiveState.WAIT_START
|
|
128
|
+
this.currentPacket = null
|
|
129
|
+
this.receiveBuffer = []
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { JikkoCameraCommand } from './protocol.js'
|
|
2
|
+
|
|
3
|
+
function toByteArray(data) {
|
|
4
|
+
if (!data) return []
|
|
5
|
+
return Array.from(data, (byte) => Number(byte) & 0xFF)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function readUint16LE(data, offset) {
|
|
9
|
+
return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseClassificationResults(packet) {
|
|
13
|
+
if (!packet || packet.cmd !== JikkoCameraCommand.CLASSIFICATION) return []
|
|
14
|
+
|
|
15
|
+
const data = toByteArray(packet.data)
|
|
16
|
+
const results = []
|
|
17
|
+
|
|
18
|
+
if (data.length >= 3 && data.length % 3 === 0) {
|
|
19
|
+
for (let offset = 0; offset + 2 < data.length; offset += 3) {
|
|
20
|
+
results.push({
|
|
21
|
+
type: data[offset],
|
|
22
|
+
classId: data[offset + 1],
|
|
23
|
+
score: data[offset + 2],
|
|
24
|
+
index: results.length
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
return results
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (typeof data[1] !== 'undefined') {
|
|
31
|
+
results.push({
|
|
32
|
+
type: data[0] ?? 0,
|
|
33
|
+
classId: data[1],
|
|
34
|
+
score: data[2] ?? 0,
|
|
35
|
+
index: 0
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (let offset = 3; offset < data.length; offset += 2) {
|
|
40
|
+
results.push({
|
|
41
|
+
type: data[offset - 1] ?? 0,
|
|
42
|
+
classId: data[offset],
|
|
43
|
+
score: data[offset + 1] ?? 0,
|
|
44
|
+
index: results.length
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return results
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseDetectionResults(packet) {
|
|
52
|
+
if (!packet || packet.cmd !== JikkoCameraCommand.DETECTION) return []
|
|
53
|
+
|
|
54
|
+
const data = toByteArray(packet.data)
|
|
55
|
+
const results = []
|
|
56
|
+
|
|
57
|
+
if (data.length >= 11 && data.length % 11 === 0) {
|
|
58
|
+
for (let offset = 0; offset + 10 < data.length; offset += 11) {
|
|
59
|
+
results.push({
|
|
60
|
+
type: data[offset],
|
|
61
|
+
classId: data[offset + 1],
|
|
62
|
+
centerX: readUint16LE(data, offset + 2),
|
|
63
|
+
centerY: readUint16LE(data, offset + 4),
|
|
64
|
+
width: readUint16LE(data, offset + 6),
|
|
65
|
+
height: readUint16LE(data, offset + 8),
|
|
66
|
+
score: data[offset + 10],
|
|
67
|
+
index: results.length
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
return results
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (data.length >= 11 && (data.length - 1) % 10 === 0) {
|
|
74
|
+
for (let offset = 1; offset + 9 < data.length; offset += 10) {
|
|
75
|
+
results.push({
|
|
76
|
+
type: data[0],
|
|
77
|
+
classId: data[offset],
|
|
78
|
+
centerX: readUint16LE(data, offset + 1),
|
|
79
|
+
centerY: readUint16LE(data, offset + 3),
|
|
80
|
+
width: readUint16LE(data, offset + 5),
|
|
81
|
+
height: readUint16LE(data, offset + 7),
|
|
82
|
+
score: data[offset + 9],
|
|
83
|
+
index: results.length
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
return results
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (let offset = 1; offset + 5 < data.length; offset += 6) {
|
|
90
|
+
results.push({
|
|
91
|
+
type: data[offset - 1] ?? 0,
|
|
92
|
+
classId: data[offset],
|
|
93
|
+
centerX: data[offset + 1],
|
|
94
|
+
centerY: data[offset + 2],
|
|
95
|
+
width: data[offset + 3],
|
|
96
|
+
height: data[offset + 4],
|
|
97
|
+
score: data[offset + 5],
|
|
98
|
+
index: results.length
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return results
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parsePacketResults(packet) {
|
|
106
|
+
return {
|
|
107
|
+
packet,
|
|
108
|
+
classifications: parseClassificationResults(packet),
|
|
109
|
+
detections: parseDetectionResults(packet)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export * from './core/index.js'
|
|
2
|
+
export {
|
|
3
|
+
WebSerialCamera,
|
|
4
|
+
createWebSerialCamera,
|
|
5
|
+
isWebSerialSupported
|
|
6
|
+
} from './web/index.js'
|
|
7
|
+
export type {
|
|
8
|
+
CameraErrorCode,
|
|
9
|
+
CameraErrorEvent,
|
|
10
|
+
CameraEventMap,
|
|
11
|
+
CameraStatus,
|
|
12
|
+
CameraStatusEvent,
|
|
13
|
+
ClassificationEvent,
|
|
14
|
+
ClassificationsEvent,
|
|
15
|
+
DetectionEvent,
|
|
16
|
+
DetectionsEvent,
|
|
17
|
+
RawChunkEvent,
|
|
18
|
+
WebSerialCameraOptions
|
|
19
|
+
} from './web/index.js'
|
package/src/index.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClassificationResult,
|
|
3
|
+
DetectionResult,
|
|
4
|
+
JikkoPacket
|
|
5
|
+
} from '../core/index.js'
|
|
6
|
+
|
|
7
|
+
export type CameraStatus =
|
|
8
|
+
| 'idle'
|
|
9
|
+
| 'unsupported'
|
|
10
|
+
| 'connecting'
|
|
11
|
+
| 'connected'
|
|
12
|
+
| 'disconnecting'
|
|
13
|
+
| 'disconnected'
|
|
14
|
+
| 'error'
|
|
15
|
+
|
|
16
|
+
export type CameraErrorCode =
|
|
17
|
+
| 'UNSUPPORTED'
|
|
18
|
+
| 'PERMISSION_DENIED'
|
|
19
|
+
| 'PORT_OPEN_FAILED'
|
|
20
|
+
| 'READ_FAILED'
|
|
21
|
+
|
|
22
|
+
export type CameraStatusEvent = {
|
|
23
|
+
status: CameraStatus
|
|
24
|
+
isConnected: boolean
|
|
25
|
+
receivedAt: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type CameraErrorEvent = {
|
|
29
|
+
code: CameraErrorCode
|
|
30
|
+
message: string
|
|
31
|
+
cause?: unknown
|
|
32
|
+
receivedAt: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type RawChunkEvent = {
|
|
36
|
+
bytes: Uint8Array
|
|
37
|
+
receivedAt: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ClassificationEvent = ClassificationResult & {
|
|
41
|
+
packet: JikkoPacket
|
|
42
|
+
receivedAt: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ClassificationsEvent = {
|
|
46
|
+
results: ClassificationResult[]
|
|
47
|
+
packet: JikkoPacket
|
|
48
|
+
receivedAt: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type DetectionEvent = DetectionResult & {
|
|
52
|
+
packet: JikkoPacket
|
|
53
|
+
receivedAt: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type DetectionsEvent = {
|
|
57
|
+
results: DetectionResult[]
|
|
58
|
+
packet: JikkoPacket
|
|
59
|
+
receivedAt: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type CameraEventMap = {
|
|
63
|
+
status: CameraStatusEvent
|
|
64
|
+
error: CameraErrorEvent
|
|
65
|
+
raw: RawChunkEvent
|
|
66
|
+
packet: JikkoPacket
|
|
67
|
+
classification: ClassificationEvent
|
|
68
|
+
classifications: ClassificationsEvent
|
|
69
|
+
detection: DetectionEvent
|
|
70
|
+
detections: DetectionsEvent
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type WebSerialCameraOptions = {
|
|
74
|
+
baudRate?: number
|
|
75
|
+
emitRawChunks?: boolean
|
|
76
|
+
filters?: SerialPortFilter[]
|
|
77
|
+
serial?: Serial
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class WebSerialCamera {
|
|
81
|
+
constructor(options?: WebSerialCameraOptions)
|
|
82
|
+
connect(): Promise<void>
|
|
83
|
+
disconnect(): Promise<void>
|
|
84
|
+
getStatus(): CameraStatus
|
|
85
|
+
isConnected(): boolean
|
|
86
|
+
getLastPacket(): JikkoPacket | null
|
|
87
|
+
on<EventName extends keyof CameraEventMap>(
|
|
88
|
+
eventName: EventName,
|
|
89
|
+
handler: (event: CameraEventMap[EventName]) => void
|
|
90
|
+
): () => void
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createWebSerialCamera(options?: WebSerialCameraOptions): WebSerialCamera
|
|
94
|
+
export function isWebSerialSupported(serial?: Serial | null): boolean
|
|
95
|
+
|
package/src/web/index.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { JikkoProtocolParser } from '../core/protocol.js'
|
|
2
|
+
import { parsePacketResults } from '../core/results.js'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_BAUD_RATE = 9600
|
|
5
|
+
|
|
6
|
+
class EventEmitter {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.listeners = new Map()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
on(eventName, handler) {
|
|
12
|
+
if (!this.listeners.has(eventName)) {
|
|
13
|
+
this.listeners.set(eventName, new Set())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
this.listeners.get(eventName).add(handler)
|
|
17
|
+
|
|
18
|
+
return () => {
|
|
19
|
+
this.listeners.get(eventName)?.delete(handler)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
emit(eventName, event) {
|
|
24
|
+
const handlers = this.listeners.get(eventName)
|
|
25
|
+
if (!handlers) return
|
|
26
|
+
|
|
27
|
+
for (const handler of handlers) {
|
|
28
|
+
try {
|
|
29
|
+
handler(event)
|
|
30
|
+
} catch (error) {
|
|
31
|
+
setTimeout(() => {
|
|
32
|
+
throw error
|
|
33
|
+
}, 0)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
clear() {
|
|
39
|
+
this.listeners.clear()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getDefaultSerial() {
|
|
44
|
+
return globalThis.navigator?.serial ?? null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cloneBytes(bytes) {
|
|
48
|
+
return Uint8Array.from(bytes)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isWebSerialSupported(serial = getDefaultSerial()) {
|
|
52
|
+
return !!serial
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createWebSerialCamera(options = {}) {
|
|
56
|
+
return new WebSerialCamera(options)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class WebSerialCamera {
|
|
60
|
+
constructor(options = {}) {
|
|
61
|
+
this.options = {
|
|
62
|
+
baudRate: DEFAULT_BAUD_RATE,
|
|
63
|
+
emitRawChunks: false,
|
|
64
|
+
filters: undefined,
|
|
65
|
+
serial: undefined,
|
|
66
|
+
...options
|
|
67
|
+
}
|
|
68
|
+
this.serial = Object.prototype.hasOwnProperty.call(options, 'serial')
|
|
69
|
+
? options.serial
|
|
70
|
+
: getDefaultSerial()
|
|
71
|
+
this.parser = new JikkoProtocolParser()
|
|
72
|
+
this.emitter = new EventEmitter()
|
|
73
|
+
this.port = null
|
|
74
|
+
this.reader = null
|
|
75
|
+
this.status = isWebSerialSupported(this.serial) ? 'idle' : 'unsupported'
|
|
76
|
+
this.lastPacket = null
|
|
77
|
+
this.readLoopPromise = null
|
|
78
|
+
this.disconnecting = false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
on(eventName, handler) {
|
|
82
|
+
return this.emitter.on(eventName, handler)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getStatus() {
|
|
86
|
+
return this.status
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
isConnected() {
|
|
90
|
+
return this.status === 'connected'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getLastPacket() {
|
|
94
|
+
return this.lastPacket
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async connect() {
|
|
98
|
+
if (!isWebSerialSupported(this.serial)) {
|
|
99
|
+
const error = new Error('Web Serial API is not supported.')
|
|
100
|
+
this.setStatus('unsupported')
|
|
101
|
+
this.emitError('UNSUPPORTED', error.message, error)
|
|
102
|
+
throw error
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (this.isConnected()) return
|
|
106
|
+
|
|
107
|
+
this.disconnecting = false
|
|
108
|
+
this.setStatus('connecting')
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const requestOptions = this.options.filters
|
|
112
|
+
? { filters: this.options.filters }
|
|
113
|
+
: undefined
|
|
114
|
+
const port = await this.serial.requestPort(requestOptions)
|
|
115
|
+
await port.open({ baudRate: this.options.baudRate })
|
|
116
|
+
|
|
117
|
+
this.port = port
|
|
118
|
+
this.parser.reset()
|
|
119
|
+
this.setStatus('connected')
|
|
120
|
+
this.readLoopPromise = this.startReadLoop(port)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
this.setStatus('error')
|
|
123
|
+
this.emitError(
|
|
124
|
+
error?.name === 'NotFoundError' ? 'PERMISSION_DENIED' : 'PORT_OPEN_FAILED',
|
|
125
|
+
error?.message || 'Failed to open serial port.',
|
|
126
|
+
error
|
|
127
|
+
)
|
|
128
|
+
throw error
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async disconnect() {
|
|
133
|
+
this.disconnecting = true
|
|
134
|
+
this.setStatus('disconnecting')
|
|
135
|
+
|
|
136
|
+
const reader = this.reader
|
|
137
|
+
this.reader = null
|
|
138
|
+
if (reader) {
|
|
139
|
+
try {
|
|
140
|
+
await reader.cancel()
|
|
141
|
+
} catch {
|
|
142
|
+
// Some browsers reject cancel after the stream has already closed.
|
|
143
|
+
}
|
|
144
|
+
this.releaseReader(reader)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (this.readLoopPromise) {
|
|
148
|
+
try {
|
|
149
|
+
await this.readLoopPromise
|
|
150
|
+
} catch {
|
|
151
|
+
// Read loop errors are already emitted through the error event.
|
|
152
|
+
}
|
|
153
|
+
this.readLoopPromise = null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (this.port) {
|
|
157
|
+
try {
|
|
158
|
+
await this.port.close()
|
|
159
|
+
} catch {
|
|
160
|
+
// The OS or browser can close the port first.
|
|
161
|
+
}
|
|
162
|
+
this.port = null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this.parser.reset()
|
|
166
|
+
this.setStatus('disconnected')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async startReadLoop(port) {
|
|
170
|
+
if (!port?.readable) return
|
|
171
|
+
|
|
172
|
+
let reader = null
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
reader = port.readable.getReader()
|
|
176
|
+
this.reader = reader
|
|
177
|
+
|
|
178
|
+
while (true) {
|
|
179
|
+
const { value, done } = await reader.read()
|
|
180
|
+
if (done) break
|
|
181
|
+
if (!value) continue
|
|
182
|
+
|
|
183
|
+
this.handleChunk(value)
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (!this.disconnecting && error?.name !== 'AbortError') {
|
|
187
|
+
this.setStatus('error')
|
|
188
|
+
this.emitError('READ_FAILED', error?.message || 'Failed to read serial data.', error)
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
if (this.reader === reader) {
|
|
192
|
+
this.reader = null
|
|
193
|
+
}
|
|
194
|
+
this.releaseReader(reader)
|
|
195
|
+
|
|
196
|
+
if (!this.disconnecting && this.status === 'connected') {
|
|
197
|
+
this.setStatus('disconnected')
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
handleChunk(chunk) {
|
|
203
|
+
const bytes = cloneBytes(chunk)
|
|
204
|
+
|
|
205
|
+
if (this.options.emitRawChunks) {
|
|
206
|
+
this.emitter.emit('raw', {
|
|
207
|
+
bytes,
|
|
208
|
+
receivedAt: Date.now()
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const packets = this.parser.feedChunk(bytes)
|
|
213
|
+
|
|
214
|
+
for (const packet of packets) {
|
|
215
|
+
this.lastPacket = packet
|
|
216
|
+
this.emitter.emit('packet', packet)
|
|
217
|
+
|
|
218
|
+
const parsed = parsePacketResults(packet)
|
|
219
|
+
|
|
220
|
+
if (parsed.classifications.length > 0) {
|
|
221
|
+
const event = {
|
|
222
|
+
results: parsed.classifications,
|
|
223
|
+
packet,
|
|
224
|
+
receivedAt: packet.receivedAt
|
|
225
|
+
}
|
|
226
|
+
this.emitter.emit('classifications', event)
|
|
227
|
+
for (const result of parsed.classifications) {
|
|
228
|
+
this.emitter.emit('classification', {
|
|
229
|
+
...result,
|
|
230
|
+
packet,
|
|
231
|
+
receivedAt: packet.receivedAt
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (parsed.detections.length > 0) {
|
|
237
|
+
const event = {
|
|
238
|
+
results: parsed.detections,
|
|
239
|
+
packet,
|
|
240
|
+
receivedAt: packet.receivedAt
|
|
241
|
+
}
|
|
242
|
+
this.emitter.emit('detections', event)
|
|
243
|
+
for (const result of parsed.detections) {
|
|
244
|
+
this.emitter.emit('detection', {
|
|
245
|
+
...result,
|
|
246
|
+
packet,
|
|
247
|
+
receivedAt: packet.receivedAt
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
setStatus(status) {
|
|
255
|
+
this.status = status
|
|
256
|
+
this.emitter.emit('status', {
|
|
257
|
+
status,
|
|
258
|
+
isConnected: this.isConnected(),
|
|
259
|
+
receivedAt: Date.now()
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
emitError(code, message, cause) {
|
|
264
|
+
this.emitter.emit('error', {
|
|
265
|
+
code,
|
|
266
|
+
message,
|
|
267
|
+
cause,
|
|
268
|
+
receivedAt: Date.now()
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
releaseReader(reader) {
|
|
273
|
+
if (!reader) return
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
reader.releaseLock()
|
|
277
|
+
} catch {
|
|
278
|
+
// Ignore if the stream lock has already been released.
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|