CameraMap.jsx 18 KB

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