diff --git a/web/libs/editor/src/assets/icons/merge.svg b/web/libs/editor/src/assets/icons/merge.svg
new file mode 100644
index 000000000000..9b2865b7c3a2
--- /dev/null
+++ b/web/libs/editor/src/assets/icons/merge.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/libs/editor/src/components/SidePanels/OutlinerPanel/MergeIcon.tsx b/web/libs/editor/src/components/SidePanels/OutlinerPanel/MergeIcon.tsx
new file mode 100644
index 000000000000..a3e0f383be9f
--- /dev/null
+++ b/web/libs/editor/src/components/SidePanels/OutlinerPanel/MergeIcon.tsx
@@ -0,0 +1,17 @@
+import React from "react";
+
+const MergeIcon: React.FC> = (props) => (
+
+);
+
+export default MergeIcon;
diff --git a/web/libs/editor/src/components/SidePanels/OutlinerPanel/ViewControls.tsx b/web/libs/editor/src/components/SidePanels/OutlinerPanel/ViewControls.tsx
index c994165dcab7..a5e443aa8311 100644
--- a/web/libs/editor/src/components/SidePanels/OutlinerPanel/ViewControls.tsx
+++ b/web/libs/editor/src/components/SidePanels/OutlinerPanel/ViewControls.tsx
@@ -16,6 +16,8 @@ import { Dropdown } from "@humansignal/ui";
// eslint-disable-next-line
// @ts-ignore
import { Menu } from "../../../common/Menu/Menu";
+// local merge icon
+import MergeIcon from "./MergeIcon";
import { cn } from "../../../utils/bem";
import { SidePanelsContext } from "../SidePanelsContext";
import "./ViewControls.prefix.css";
@@ -163,6 +165,7 @@ export const ViewControls: FC = observer(
/>
)}
+
);
@@ -317,3 +320,30 @@ const ToggleRegionsVisibilityButton = observer
);
});
+
+const MergeRegionsButton = observer>(({ regions }) => {
+ const merge = useCallback(
+ (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ regions.annotation.mergeSelectedRegions?.();
+ },
+ [regions],
+ );
+
+ const isDisabled = !regions?.selection || regions.selection.size < 2;
+
+ return (
+
+ );
+});
diff --git a/web/libs/editor/src/stores/Annotation/Annotation.js b/web/libs/editor/src/stores/Annotation/Annotation.js
index 2a08ad9b892a..1e35e8503d58 100644
--- a/web/libs/editor/src/stores/Annotation/Annotation.js
+++ b/web/libs/editor/src/stores/Annotation/Annotation.js
@@ -1,5 +1,7 @@
import { throttle } from "@humansignal/core/lib/utils/lodash-replacements";
import { destroy, detach, flow, getEnv, getParent, getRoot, isAlive, onSnapshot, types } from "mobx-state-tree";
+import Canvas from "../../utils/canvas";
+import { decode, encode } from "@thi.ng/rle-pack";
import { ff } from "@humansignal/core";
import { errorBuilder } from "../../core/DataValidator/ConfigValidator";
import { guidGenerator } from "../../core/Helpers";
@@ -530,6 +532,161 @@ const _Annotation = types
}
},
+ async mergeSelectedRegions() { // Merge selected annotations with RLE masks
+ const selected = (self.selectedRegions || []).filter((r) => !!r);
+ if (!selected || selected.length < 2) return null;
+
+ // Collect only regions that actually provide RLE (either stored or
+ // generated). Ignore all other selected labels (rectangles, etc.).
+ const contributors = [];
+ for (const region of selected) {
+ let sourceRLE = null;
+ if (region.rle && region.rle.length) {
+ sourceRLE = region.rle;
+ } else {
+ try {
+ sourceRLE = Canvas.Region2RLE?.(region) || null;
+ } catch (e) {
+ sourceRLE = null;
+ }
+ }
+
+ if (sourceRLE && sourceRLE.length) {
+ contributors.push({ region, sourceRLE });
+ }
+ }
+
+ // If nothing to merge (no RLE contributors), do nothing and keep
+ // all selected regions intact.
+ if (!contributors.length) return null;
+
+ // Determine image dimensions and class from the first contributor.
+ const first = contributors[0].region;
+ const ent = first.currentImageEntity;
+ const nw = ent?.naturalWidth ?? 0;
+ const nh = ent?.naturalHeight ?? 0;
+ if (!nw || !nh) return null;
+
+ // Ensure all contributors belong to the same image entity
+ for (const c of contributors) {
+ if (c.region.currentImageEntity !== ent) {
+ console.error("merge: selected regions (contributors) belong to different image entities");
+ return null;
+ }
+ }
+
+ // Build a union that preserves original RGBA per-pixel
+ const unionAlpha = new Uint8ClampedArray(nw * nh);
+ const unionRGBA = new Uint8ClampedArray(nw * nh * 4);
+ const mergedRegionIds = new Set();
+ let foundAnyRLE = false;
+
+ for (const region of selected) {
+ try {
+ const sourceRLE = region.rle && region.rle.length
+ ? region.rle
+ : (() => {
+ try { return Canvas.Region2RLE?.(region) || null; }
+ catch (e) { return null; }
+ })();
+ if (!sourceRLE || !sourceRLE.length) continue;
+
+ const decoded = decode(sourceRLE); // RGBA bytes
+ if (!decoded) continue;
+
+ foundAnyRLE = true;
+ mergedRegionIds.add(region.id);
+
+ // For each pixel, if this region's alpha is greater than the current
+ // union alpha, copy the full RGBA into the union buffer.
+ for (let p = 0, pix = nw * nh; p < pix; p++) {
+ const off = p * 4;
+ const alpha = decoded[off + 3] || 0;
+ if (alpha > unionAlpha[p]) {
+ unionAlpha[p] = alpha;
+ unionRGBA[off] = decoded[off] || 0;
+ unionRGBA[off + 1] = decoded[off + 1] || 0;
+ unionRGBA[off + 2] = decoded[off + 2] || 0;
+ unionRGBA[off + 3] = alpha;
+ }
+ }
+ } catch (err) {
+ console.debug("merge: region decode failed", err);
+ }
+ }
+
+ if (!foundAnyRLE) return null;
+
+ // Build mergedDataURL from the merged RGBA buffer
+ const canvas = document.createElement("canvas");
+ canvas.width = nw;
+ canvas.height = nh;
+ const ctx = canvas.getContext("2d");
+ const imageData = ctx.createImageData(nw, nh);
+ imageData.data.set(unionRGBA);
+ ctx.putImageData(imageData, 0, 0);
+ const mergedDataURL = canvas.toDataURL();
+ const mergedBase64 = typeof mergedDataURL === "string" ? mergedDataURL.split(",")[1] : null;
+
+ // Use first selected region's result as label/class
+ const firstResult = first.results?.[0];
+ const resultValue = firstResult?.value?.toJSON
+ ? firstResult.value.toJSON()
+ : structuredClone(firstResult?.value ?? {});
+ const control = firstResult?.from_name ?? first.results?.[0]?.from_name;
+ const object = firstResult?.to_name ?? first.results?.[0]?.to_name;
+
+ // Encode RLE from the merged RGBA buffer (preserving original RGB and
+ // using max-alpha per overlapping pixel).
+ let rleEncoded = null;
+ try {
+ const encoded = encode(unionRGBA);
+ if (encoded && encoded.length) {
+ rleEncoded = Array.isArray(encoded) ? encoded : Array.from(encoded);
+ }
+ } catch (err) {
+ rleEncoded = null;
+ }
+
+ const areaValue = {
+ ...(rleEncoded ? { rle: rleEncoded } : {}),
+ maskDataURL: mergedDataURL,
+ base64: mergedBase64,
+ original_width: nw,
+ original_height: nh,
+ };
+
+ const newArea = self.createResult(areaValue, resultValue, control, object);
+
+ try { newArea?.updateMaskImage?.(); } catch (e) {}
+
+ try {
+ const valueForSerialization = {
+ format: "rle",
+ rle: rleEncoded ? Array.from(rleEncoded) : [],
+ ...(resultValue || {}),
+ };
+ const serialized = {
+ original_width: nw,
+ original_height: nh,
+ image_rotation: ent?.rotation ?? 0,
+ value: valueForSerialization,
+ };
+ if (first.results?.[0]?.item_index !== undefined) serialized.item_index = first.results[0].item_index;
+ Object.defineProperty(newArea, "_rawResult", { value: Object.freeze(structuredClone(serialized)) });
+ } catch (e) {}
+
+ try {
+ for (const r of selected) {
+ // Only delete regions that actually contributed RLE data to the merge.
+ if (!mergedRegionIds.has(r.id)) continue;
+ try { r.deleteRegion(); } catch (err) { console.debug("merge: failed to delete region", r?.id, err); }
+ }
+ } catch (err) { console.debug("merge: error while deleting selected regions", err); }
+
+ return newArea;
+ },
+
deleteSelectedRegions() {
for (const region of self.selectedRegions) {
region.deleteRegion();
@@ -670,10 +827,6 @@ const _Annotation = types
const points = currentRegion?.points?.length ?? 0;
stopDrawingAfterNextUndo = points <= 1;
- } else if (currentRegion?.type === "vectorregion") {
- const vertices = currentRegion?.vertices?.length ?? 0;
-
- stopDrawingAfterNextUndo = vertices <= 1;
}
history.undo();
@@ -811,6 +964,9 @@ const _Annotation = types
if (!self.editable) return;
const result = self.serializeAnnotation({ fast: true });
+ // if this is new annotation and no regions added yet
+
+ if (!self.pk && !result.length) return;
self.setDraftSelected();
self.versions.draft = result;
@@ -976,8 +1132,7 @@ const _Annotation = types
// }
// };
- const { enableHotkeys } = self.store.settings;
- Hotkey.setScope(enableHotkeys ? Hotkey.DEFAULT_SCOPE : "__none__");
+ Hotkey.setScope(Hotkey.DEFAULT_SCOPE);
},
createResult(areaValue, resultValue, control, object, skipAfrerCreate = false, additionalStates = []) {