CameraMap.jsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. import { h } from 'preact';
  2. import Box from './components/Box';
  3. import Button from './components/Button';
  4. import Heading from './components/Heading';
  5. import Switch from './components/Switch';
  6. import { route } from 'preact-router';
  7. import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
  8. import { ApiHost, Config } from './context';
  9. export default function CameraMasks({ camera, url }) {
  10. const config = useContext(Config);
  11. const apiHost = useContext(ApiHost);
  12. const imageRef = useRef(null);
  13. const [imageScale, setImageScale] = useState(1);
  14. const [snap, setSnap] = useState(true);
  15. if (!(camera in config.cameras)) {
  16. return <div>{`No camera named ${camera}`}</div>;
  17. }
  18. const cameraConfig = config.cameras[camera];
  19. const {
  20. width,
  21. height,
  22. motion: { mask: motionMask },
  23. objects: { filters: objectFilters },
  24. zones,
  25. } = cameraConfig;
  26. useEffect(() => {
  27. if (!imageRef.current) {
  28. return;
  29. }
  30. const scaledWidth = imageRef.current.width;
  31. const scale = scaledWidth / width;
  32. setImageScale(scale);
  33. }, [imageRef.current, setImageScale]);
  34. const [motionMaskPoints, setMotionMaskPoints] = useState(
  35. Array.isArray(motionMask)
  36. ? motionMask.map((mask) => getPolylinePoints(mask))
  37. : motionMask
  38. ? [getPolylinePoints(motionMask)]
  39. : []
  40. );
  41. const [zonePoints, setZonePoints] = useState(
  42. Object.keys(zones).reduce((memo, zone) => ({ ...memo, [zone]: getPolylinePoints(zones[zone].coordinates) }), {})
  43. );
  44. const [objectMaskPoints, setObjectMaskPoints] = useState(
  45. Object.keys(objectFilters).reduce(
  46. (memo, name) => ({
  47. ...memo,
  48. [name]: Array.isArray(objectFilters[name].mask)
  49. ? objectFilters[name].mask.map((mask) => getPolylinePoints(mask))
  50. : objectFilters[name].mask
  51. ? [getPolylinePoints(objectFilters[name].mask)]
  52. : [],
  53. }),
  54. {}
  55. )
  56. );
  57. const [editing, setEditing] = useState({ set: motionMaskPoints, key: 0, fn: setMotionMaskPoints });
  58. const handleUpdateEditable = useCallback(
  59. (newPoints) => {
  60. let newSet;
  61. if (Array.isArray(editing.set)) {
  62. newSet = [...editing.set];
  63. newSet[editing.key] = newPoints;
  64. } else if (editing.subkey !== undefined) {
  65. newSet = { ...editing.set };
  66. newSet[editing.key][editing.subkey] = newPoints;
  67. } else {
  68. newSet = { ...editing.set, [editing.key]: newPoints };
  69. }
  70. editing.set = newSet;
  71. editing.fn(newSet);
  72. },
  73. [editing]
  74. );
  75. const handleSelectEditable = useCallback(
  76. (name) => {
  77. setEditing(name);
  78. },
  79. [setEditing]
  80. );
  81. const handleRemoveEditable = useCallback(
  82. (name) => {
  83. const filteredZonePoints = Object.keys(zonePoints)
  84. .filter((zoneName) => zoneName !== name)
  85. .reduce((memo, name) => {
  86. memo[name] = zonePoints[name];
  87. return memo;
  88. }, {});
  89. setZonePoints(filteredZonePoints);
  90. },
  91. [zonePoints, setZonePoints]
  92. );
  93. // Motion mask methods
  94. const handleAddMask = useCallback(() => {
  95. const newMotionMaskPoints = [...motionMaskPoints, []];
  96. setMotionMaskPoints(newMotionMaskPoints);
  97. setEditing({ set: newMotionMaskPoints, key: newMotionMaskPoints.length - 1, fn: setMotionMaskPoints });
  98. }, [motionMaskPoints, setMotionMaskPoints]);
  99. const handleEditMask = useCallback(
  100. (key) => {
  101. setEditing({ set: motionMaskPoints, key, fn: setMotionMaskPoints });
  102. },
  103. [setEditing, motionMaskPoints, setMotionMaskPoints]
  104. );
  105. const handleRemoveMask = useCallback(
  106. (key) => {
  107. const newMotionMaskPoints = [...motionMaskPoints];
  108. newMotionMaskPoints.splice(key, 1);
  109. setMotionMaskPoints(newMotionMaskPoints);
  110. },
  111. [motionMaskPoints, setMotionMaskPoints]
  112. );
  113. const handleCopyMotionMasks = useCallback(async () => {
  114. await window.navigator.clipboard.writeText(` motion:
  115. mask:
  116. ${motionMaskPoints.map((mask, i) => ` - ${polylinePointsToPolyline(mask)}`).join('\n')}`);
  117. }, [motionMaskPoints]);
  118. // Zone methods
  119. const handleEditZone = useCallback(
  120. (key) => {
  121. setEditing({ set: zonePoints, key, fn: setZonePoints });
  122. },
  123. [setEditing, zonePoints, setZonePoints]
  124. );
  125. const handleAddZone = useCallback(() => {
  126. const n = Object.keys(zonePoints).filter((name) => name.startsWith('zone_')).length;
  127. const zoneName = `zone_${n}`;
  128. const newZonePoints = { ...zonePoints, [zoneName]: [] };
  129. setZonePoints(newZonePoints);
  130. setEditing({ set: newZonePoints, key: zoneName, fn: setZonePoints });
  131. }, [zonePoints, setZonePoints]);
  132. const handleRemoveZone = useCallback(
  133. (key) => {
  134. const newZonePoints = { ...zonePoints };
  135. delete newZonePoints[key];
  136. setZonePoints(newZonePoints);
  137. },
  138. [zonePoints, setZonePoints]
  139. );
  140. const handleCopyZones = useCallback(async () => {
  141. await window.navigator.clipboard.writeText(` zones:
  142. ${Object.keys(zonePoints)
  143. .map(
  144. (zoneName) => ` ${zoneName}:
  145. coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`
  146. )
  147. .join('\n')}`);
  148. }, [zonePoints]);
  149. // Object methods
  150. const handleEditObjectMask = useCallback(
  151. (key, subkey) => {
  152. setEditing({ set: objectMaskPoints, key, subkey, fn: setObjectMaskPoints });
  153. },
  154. [setEditing, objectMaskPoints, setObjectMaskPoints]
  155. );
  156. const handleAddObjectMask = useCallback(() => {
  157. const n = Object.keys(objectMaskPoints).filter((name) => name.startsWith('object_')).length;
  158. const newObjectName = `object_${n}`;
  159. const newObjectMaskPoints = { ...objectMaskPoints, [newObjectName]: [] };
  160. setObjectMaskPoints(newObjectMaskPoints);
  161. setEditing({ set: newObjectMaskPoints, key: newObjectName, subkey: 0, fn: setObjectMaskPoints });
  162. }, [objectMaskPoints, setObjectMaskPoints, setEditing]);
  163. const handleRemoveObjectMask = useCallback(
  164. (key, subkey) => {
  165. const newObjectMaskPoints = { ...objectMaskPoints };
  166. delete newObjectMaskPoints[key];
  167. setObjectMaskPoints(newObjectMaskPoints);
  168. },
  169. [objectMaskPoints, setObjectMaskPoints]
  170. );
  171. const handleCopyObjectMasks = useCallback(async () => {
  172. await window.navigator.clipboard.writeText(` objects:
  173. filters:
  174. ${Object.keys(objectMaskPoints)
  175. .map((objectName) =>
  176. objectMaskPoints[objectName].length
  177. ? ` ${objectName}:
  178. mask: ${polylinePointsToPolyline(objectMaskPoints[objectName])}`
  179. : ''
  180. )
  181. .filter(Boolean)
  182. .join('\n')}`);
  183. }, [objectMaskPoints]);
  184. const handleChangeSnap = useCallback(
  185. (id, value) => {
  186. setSnap(value);
  187. },
  188. [setSnap]
  189. );
  190. return (
  191. <div class="flex-col space-y-4">
  192. <Heading size="2xl">{camera} mask & zone creator</Heading>
  193. <Box>
  194. <p>
  195. This tool can help you create masks & zones for your {camera} camera. When done, copy each mask configuration
  196. into your <code className="font-mono">config.yml</code> file restart your Frigate instance to save your
  197. changes.
  198. </p>
  199. </Box>
  200. <Box className="space-y-4">
  201. <div className="relative">
  202. <img ref={imageRef} className="w-full" src={`${apiHost}/api/${camera}/latest.jpg`} />
  203. <EditableMask
  204. onChange={handleUpdateEditable}
  205. points={editing.subkey ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]}
  206. scale={imageScale}
  207. snap={snap}
  208. width={width}
  209. height={height}
  210. />
  211. </div>
  212. <Switch checked={snap} label="Snap to edges" onChange={handleChangeSnap} />
  213. </Box>
  214. <div class="flex-col space-y-4">
  215. <MaskValues
  216. editing={editing}
  217. title="Motion masks"
  218. onCopy={handleCopyMotionMasks}
  219. onCreate={handleAddMask}
  220. onEdit={handleEditMask}
  221. onRemove={handleRemoveMask}
  222. points={motionMaskPoints}
  223. yamlPrefix={'motion:\n mask:'}
  224. yamlKeyPrefix={maskYamlKeyPrefix}
  225. />
  226. <MaskValues
  227. editing={editing}
  228. title="Zones"
  229. onCopy={handleCopyZones}
  230. onCreate={handleAddZone}
  231. onEdit={handleEditZone}
  232. onRemove={handleRemoveZone}
  233. points={zonePoints}
  234. yamlPrefix="zones:"
  235. yamlKeyPrefix={zoneYamlKeyPrefix}
  236. />
  237. <MaskValues
  238. isMulti
  239. editing={editing}
  240. title="Object masks"
  241. onCopy={handleCopyObjectMasks}
  242. onCreate={handleAddObjectMask}
  243. onEdit={handleEditObjectMask}
  244. onRemove={handleRemoveObjectMask}
  245. points={objectMaskPoints}
  246. yamlPrefix={'objects:\n filters:'}
  247. yamlKeyPrefix={objectYamlKeyPrefix}
  248. />
  249. </div>
  250. </div>
  251. );
  252. }
  253. function maskYamlKeyPrefix(points) {
  254. return ` - `;
  255. }
  256. function zoneYamlKeyPrefix(points, key) {
  257. return ` ${key}:
  258. coordinates: `;
  259. }
  260. function objectYamlKeyPrefix(points, key, subkey) {
  261. return ` - `;
  262. }
  263. const MaskInset = 20;
  264. function EditableMask({ onChange, points, scale, snap, width, height }) {
  265. if (!points) {
  266. return null;
  267. }
  268. const boundingRef = useRef(null);
  269. function boundedSize(value, maxValue) {
  270. const newValue = Math.min(Math.max(0, Math.round(value)), maxValue);
  271. if (snap) {
  272. if (newValue <= MaskInset) {
  273. return 0;
  274. } else if (maxValue - newValue <= MaskInset) {
  275. return maxValue;
  276. }
  277. }
  278. return newValue;
  279. }
  280. const handleMovePoint = useCallback(
  281. (index, newX, newY) => {
  282. if (newX < 0 && newY < 0) {
  283. return;
  284. }
  285. let x = boundedSize(newX / scale, width, snap);
  286. let y = boundedSize(newY / scale, height, snap);
  287. const newPoints = [...points];
  288. newPoints[index] = [x, y];
  289. onChange(newPoints);
  290. },
  291. [scale, points, snap]
  292. );
  293. // Add a new point between the closest two other points
  294. const handleAddPoint = useCallback(
  295. (event) => {
  296. const { offsetX, offsetY } = event;
  297. const scaledX = boundedSize((offsetX - MaskInset) / scale, width, snap);
  298. const scaledY = boundedSize((offsetY - MaskInset) / scale, height, snap);
  299. const newPoint = [scaledX, scaledY];
  300. let closest;
  301. const { index } = points.reduce(
  302. (result, point, i) => {
  303. const nextPoint = points.length === i + 1 ? points[0] : points[i + 1];
  304. const distance0 = Math.sqrt(Math.pow(point[0] - newPoint[0], 2) + Math.pow(point[1] - newPoint[1], 2));
  305. const distance1 = Math.sqrt(Math.pow(point[0] - nextPoint[0], 2) + Math.pow(point[1] - nextPoint[1], 2));
  306. const distance = distance0 + distance1;
  307. return distance < result.distance ? { distance, index: i } : result;
  308. },
  309. { distance: Infinity, index: -1 }
  310. );
  311. const newPoints = [...points];
  312. newPoints.splice(index, 0, newPoint);
  313. onChange(newPoints);
  314. },
  315. [scale, points, onChange, snap]
  316. );
  317. const handleRemovePoint = useCallback(
  318. (index) => {
  319. const newPoints = [...points];
  320. newPoints.splice(index, 1);
  321. onChange(newPoints);
  322. },
  323. [points, onChange]
  324. );
  325. const scaledPoints = useMemo(() => scalePolylinePoints(points, scale), [points, scale]);
  326. return (
  327. <div className="absolute" style={`inset: -${MaskInset}px`}>
  328. {!scaledPoints
  329. ? null
  330. : scaledPoints.map(([x, y], i) => (
  331. <PolyPoint
  332. boundingRef={boundingRef}
  333. index={i}
  334. onMove={handleMovePoint}
  335. onRemove={handleRemovePoint}
  336. x={x + MaskInset}
  337. y={y + MaskInset}
  338. />
  339. ))}
  340. <div className="absolute inset-0 right-0 bottom-0" onclick={handleAddPoint} ref={boundingRef} />
  341. <svg width="100%" height="100%" className="absolute pointer-events-none" style={`inset: ${MaskInset}px`}>
  342. {!scaledPoints ? null : (
  343. <g>
  344. <polyline points={polylinePointsToPolyline(scaledPoints)} fill="rgba(244,0,0,0.5)" />
  345. </g>
  346. )}
  347. </svg>
  348. </div>
  349. );
  350. }
  351. function MaskValues({
  352. isMulti = false,
  353. editing,
  354. title,
  355. onCopy,
  356. onCreate,
  357. onEdit,
  358. onRemove,
  359. points,
  360. yamlPrefix,
  361. yamlKeyPrefix,
  362. }) {
  363. const [showButtons, setShowButtons] = useState(false);
  364. const handleMousein = useCallback(() => {
  365. setShowButtons(true);
  366. }, [setShowButtons]);
  367. const handleMouseout = useCallback(
  368. (event) => {
  369. const el = event.toElement || event.relatedTarget;
  370. if (!el || el.parentNode === event.target) {
  371. return;
  372. }
  373. setShowButtons(false);
  374. },
  375. [setShowButtons]
  376. );
  377. const handleEdit = useCallback(
  378. (event) => {
  379. const { key, subkey } = event.target.dataset;
  380. onEdit(key, subkey);
  381. },
  382. [onEdit]
  383. );
  384. const handleRemove = useCallback(
  385. (event) => {
  386. const { key, subkey } = event.target.dataset;
  387. onRemove(key, subkey);
  388. },
  389. [onRemove]
  390. );
  391. return (
  392. <Box className="overflow-hidden" onmouseover={handleMousein} onmouseout={handleMouseout}>
  393. <div class="flex space-x-4">
  394. <Heading className="flex-grow self-center" size="base">
  395. {title}
  396. </Heading>
  397. <Button onClick={onCopy}>Copy</Button>
  398. <Button onClick={onCreate}>Add</Button>
  399. </div>
  400. <pre class="relative overflow-auto font-mono text-gray-900 dark:text-gray-100 rounded bg-gray-100 dark:bg-gray-800 p-2">
  401. {yamlPrefix}
  402. {Object.keys(points).map((mainkey) => {
  403. if (isMulti) {
  404. return (
  405. <div>
  406. {` ${mainkey}:\n mask:\n`}
  407. {points[mainkey].map((item, subkey) => (
  408. <Item
  409. mainkey={mainkey}
  410. subkey={subkey}
  411. editing={editing}
  412. handleEdit={handleEdit}
  413. points={item}
  414. showButtons={showButtons}
  415. handleRemove={handleRemove}
  416. yamlKeyPrefix={yamlKeyPrefix}
  417. />
  418. ))}
  419. </div>
  420. );
  421. } else {
  422. return (
  423. <Item
  424. mainkey={mainkey}
  425. editing={editing}
  426. handleEdit={handleEdit}
  427. points={points[mainkey]}
  428. showButtons={showButtons}
  429. handleRemove={handleRemove}
  430. yamlKeyPrefix={yamlKeyPrefix}
  431. />
  432. );
  433. }
  434. })}
  435. </pre>
  436. </Box>
  437. );
  438. }
  439. function Item({ mainkey, subkey, editing, handleEdit, points, showButtons, handleRemove, yamlKeyPrefix }) {
  440. return (
  441. <span
  442. data-key={mainkey}
  443. data-subkey={subkey}
  444. className={`block hover:text-blue-400 cursor-pointer relative ${
  445. editing.key === mainkey && editing.subkey === subkey ? 'text-blue-800 dark:text-blue-600' : ''
  446. }`}
  447. onClick={handleEdit}
  448. title="Click to edit"
  449. >
  450. {`${yamlKeyPrefix(points, mainkey, subkey)}${polylinePointsToPolyline(points)}`}
  451. {showButtons ? (
  452. <Button
  453. className="absolute top-0 right-0"
  454. color="red"
  455. data-key={mainkey}
  456. data-subkey={subkey}
  457. onClick={handleRemove}
  458. >
  459. Remove
  460. </Button>
  461. ) : null}
  462. </span>
  463. );
  464. }
  465. function getPolylinePoints(polyline) {
  466. if (!polyline) {
  467. return;
  468. }
  469. return polyline.split(',').reduce((memo, point, i) => {
  470. if (i % 2) {
  471. memo[memo.length - 1].push(parseInt(point, 10));
  472. } else {
  473. memo.push([parseInt(point, 10)]);
  474. }
  475. return memo;
  476. }, []);
  477. }
  478. function scalePolylinePoints(polylinePoints, scale) {
  479. if (!polylinePoints) {
  480. return;
  481. }
  482. return polylinePoints.map(([x, y]) => [Math.round(x * scale), Math.round(y * scale)]);
  483. }
  484. function polylinePointsToPolyline(polylinePoints) {
  485. if (!polylinePoints) {
  486. return;
  487. }
  488. return polylinePoints.reduce((memo, [x, y]) => `${memo}${x},${y},`, '').replace(/,$/, '');
  489. }
  490. const PolyPointRadius = 10;
  491. function PolyPoint({ boundingRef, index, x, y, onMove, onRemove }) {
  492. const [hidden, setHidden] = useState(false);
  493. const handleDragOver = useCallback(
  494. (event) => {
  495. if (
  496. !boundingRef.current ||
  497. (event.target !== boundingRef.current && !boundingRef.current.contains(event.target))
  498. ) {
  499. return;
  500. }
  501. onMove(index, event.layerX - PolyPointRadius * 2, event.layerY - PolyPointRadius * 2);
  502. },
  503. [onMove, index, boundingRef.current]
  504. );
  505. const handleDragStart = useCallback(() => {
  506. boundingRef.current && boundingRef.current.addEventListener('dragover', handleDragOver, false);
  507. setHidden(true);
  508. }, [setHidden, boundingRef.current, handleDragOver]);
  509. const handleDragEnd = useCallback(() => {
  510. boundingRef.current && boundingRef.current.removeEventListener('dragover', handleDragOver);
  511. setHidden(false);
  512. }, [setHidden, boundingRef.current, handleDragOver]);
  513. const handleRightClick = useCallback(
  514. (event) => {
  515. event.preventDefault();
  516. onRemove(index);
  517. },
  518. [onRemove, index]
  519. );
  520. const handleClick = useCallback((event) => {
  521. event.stopPropagation();
  522. event.preventDefault();
  523. }, []);
  524. return (
  525. <div
  526. className={`${hidden ? 'opacity-0' : ''} bg-gray-900 rounded-full absolute z-20`}
  527. style={`top: ${y - PolyPointRadius}px; left: ${x - PolyPointRadius}px; width: 20px; height: 20px;`}
  528. draggable
  529. onclick={handleClick}
  530. oncontextmenu={handleRightClick}
  531. ondragstart={handleDragStart}
  532. ondragend={handleDragEnd}
  533. />
  534. );
  535. }