CameraMap.jsx 19 KB

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