Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/libs/editor/src/assets/icons/merge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from "react";

const MergeIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg
{...props}
viewBox="0 0 122.88 122.51"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
role="img"
>
<g>
<path fill="currentColor" d="M45.76,0c12.64,0,24.08,5.12,32.36,13.4c5.31,5.31,9.33,11.93,11.52,19.32c7.61,2.16,14.4,6.23,19.84,11.67 c8.28,8.28,13.4,19.72,13.4,32.36c0,12.64-5.12,24.08-13.4,32.36c-8.28,8.28-19.72,13.4-32.36,13.4c-12.64,0-24.08-5.12-32.36-13.4 c-5.31-5.31-9.33-11.93-11.52-19.32c-7.61-2.16-14.4-6.23-19.84-11.67C5.12,69.83,0,58.39,0,45.76C0,33.12,5.12,21.68,13.4,13.4 C21.68,5.12,33.12,0,45.76,0L45.76,0z M91.36,41.98c0.08,1.03,0.13,2.07,0.15,3.12l0,0.01l0,0.06v0.01l0,0.05l0,0.04l0,0.02l0,0.12 v0.02l0,0.03l0,0.05l0,0.01l0,0.06v0.01v0.05l0,0.04v0.02v0.06c0,12.64-5.12,24.08-13.4,32.36c-8.28,8.28-19.72,13.4-32.36,13.4 h-0.05h-0.03h-0.02l-0.05,0h0l-0.05,0h-0.03l-0.02,0l-0.05,0h-0.01l-0.04,0l-0.03,0h-0.02l-0.05,0l-0.01,0 c-0.93-0.01-1.86-0.05-2.77-0.11c1.9,4.48,4.65,8.52,8.04,11.91c6.8,6.8,16.19,11,26.57,11c10.37,0,19.77-4.21,26.56-11 c6.8-6.8,11-16.19,11-26.56c0-10.37-4.2-19.77-11-26.57C100.19,46.69,96.01,43.88,91.36,41.98L91.36,41.98z M77.12,30.99h0.05h0.03 h0.02l0.05,0h0l0.05,0h0.03l0.02,0l0.05,0h0.01l0.04,0l0.03,0h0.02l0.05,0l0.01,0c0.93,0.01,1.85,0.05,2.77,0.11 c-1.9-4.48-4.65-8.52-8.04-11.91c-6.8-6.8-16.19-11-26.57-11c-10.37,0-19.77,4.21-26.56,11c-6.8,6.8-11,16.19-11,26.56 c0,10.37,4.21,19.77,11,26.57c3.49,3.49,7.67,6.3,12.32,8.21c-0.08-1.03-0.13-2.07-0.15-3.12l0-0.01l0-0.06v-0.01l0-0.05l0-0.04 l0-0.02l0-0.12v-0.02l0-0.03l0-0.05l0-0.01l0-0.06v-0.01v-0.05l0-0.04v-0.02v-0.06c0-12.64,5.12-24.08,13.4-32.36 C53.05,36.11,64.49,30.99,77.12,30.99L77.12,30.99z"/>
</g>
</svg>
);

export default MergeIcon;
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -163,6 +165,7 @@ export const ViewControls: FC<ViewControlsProps> = observer(
/>
</div>
)}
<MergeRegionsButton regions={regions} />
<ToggleRegionsVisibilityButton regions={regions} />
</div>
);
Expand Down Expand Up @@ -317,3 +320,30 @@ const ToggleRegionsVisibilityButton = observer<FC<ToggleRegionsVisibilityButton>
</Button>
);
});

const MergeRegionsButton = observer<FC<ToggleRegionsVisibilityButton>>(({ regions }) => {
const merge = useCallback(
(e) => {
e.preventDefault();
e.stopPropagation();
regions.annotation.mergeSelectedRegions?.();
},
[regions],
);

const isDisabled = !regions?.selection || regions.selection.size < 2;

return (
<Button
variant="neutral"
size="smaller"
look="string"
disabled={isDisabled}
onClick={merge}
aria-label={"Merge selected regions"}
tooltip={"Merge selected regions"}
>
<MergeIcon width={16} height={16} />
</Button>
);
});
167 changes: 161 additions & 6 deletions web/libs/editor/src/stores/Annotation/Annotation.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = []) {
Expand Down
Loading