import { destroy, detach, getEnv, getParent, onPatch, types } from "mobx-state-tree";
|
|
import { Hotkey } from "../core/Hotkey";
|
import { isDefined } from "../utils/utilities";
|
import { AllRegionsType } from "../regions";
|
import { debounce } from "../utils/debounce";
|
import Tree, { TRAVERSE_STOP } from "../core/Tree";
|
import { FF_DEV_2755, isFF } from "../utils/feature-flags";
|
|
const hotkeys = Hotkey("RegionStore");
|
|
const localStorageKeys = {
|
sort: "outliner:sort",
|
sortDirection: "outliner:sort-direction",
|
group: "outliner:group",
|
view: "regionstore:view",
|
};
|
|
const SelectionMap = types
|
.model({
|
selected: types.optional(types.map(types.safeReference(AllRegionsType)), {}),
|
drawingSelected: types.optional(types.map(types.safeReference(AllRegionsType)), {}),
|
})
|
.views((self) => {
|
return {
|
get keys() {
|
return Array.from(self.selected.keys());
|
},
|
get annotation() {
|
return getParent(self).annotation;
|
},
|
get highlighted() {
|
return self.selected.size === 1 ? self.selected.values().next().value : null;
|
},
|
get size() {
|
return self.selected.size;
|
},
|
get list() {
|
return Array.from(self.selected.values());
|
},
|
isSelected(region) {
|
return self.selected.has(region.id);
|
},
|
};
|
})
|
.actions((self) => {
|
const updateResultsFromSelection = debounce(() => {
|
self._updateResultsFromSelection();
|
}, 0);
|
|
return {
|
beforeUnselect(region) {
|
region.perRegionTags.forEach((tag) => tag.submitChanges?.());
|
},
|
afterUnselect(region) {
|
region.afterUnselectRegion?.();
|
},
|
drawingSelect(region) {
|
self.drawingSelected.put(region);
|
},
|
drawingUnselect() {
|
Array.from(self.drawingSelected.values()).forEach((region) => {
|
self.drawingSelected.delete(region.id);
|
});
|
},
|
select(region) {
|
self.selected.put(region);
|
region.selectRegion && region.selectRegion();
|
|
if (self.highlighted) {
|
// @todo some backward compatibility, should be rewritten to state handling
|
// @todo but there are some actions should be performed like scroll to region
|
self.highlighted.perRegionTags.forEach((tag) => tag.updateFromResult?.(undefined));
|
// special case for Taxonomy as labeling tool
|
self.highlighted.labelingTags.forEach((tag) => tag.updateFromResult?.(undefined));
|
updateResultsFromSelection();
|
} else {
|
updateResultsFromSelection();
|
}
|
|
// hook for side effects after region selected
|
region.object?.afterRegionSelected?.(region);
|
},
|
_updateResultsFromSelection() {
|
self._updateResultsFromRegions(self.selected.values());
|
},
|
_updateResultsFromRegions(regions) {
|
const valuesFromControls = {};
|
const controlsByName = {};
|
|
Array.from(regions).map((region) => {
|
region.results.forEach((result) => {
|
const controlName = result.from_name.name;
|
const currentValue = valuesFromControls[controlName];
|
|
if (currentValue !== undefined) {
|
valuesFromControls[controlName] = result.mergeMainValue(currentValue);
|
} else {
|
controlsByName[controlName] = result.from_name;
|
valuesFromControls[controlName] = result.mainValue;
|
}
|
});
|
});
|
self.annotation.unselectStates();
|
for (const [controlName, value] of Object.entries(valuesFromControls)) {
|
const control = controlsByName[controlName];
|
|
control.updateFromResult?.(value);
|
}
|
},
|
unselect(region) {
|
self.beforeUnselect(region);
|
self.selected.delete(region.id);
|
self.afterUnselect(region);
|
},
|
clear() {
|
// clear() in the middle empties selected regions, so store them in separate array
|
const regionEntries = [...self.selected.values()];
|
|
for (const region of regionEntries) {
|
self.beforeUnselect(region);
|
}
|
self.selected.clear();
|
for (const region of regionEntries) {
|
self.afterUnselect(region);
|
}
|
},
|
highlight(region) {
|
self.clear();
|
self.select(region);
|
},
|
};
|
});
|
|
export default types
|
.model("RegionStore", {
|
sort: types.optional(
|
types.enumeration(["date", "score", "mediaStartTime"]),
|
() => window.localStorage.getItem(localStorageKeys.sort) ?? "date",
|
),
|
|
sortOrder: types.optional(
|
types.enumeration(["asc", "desc"]),
|
() => window.localStorage.getItem(localStorageKeys.sortDirection) ?? "asc",
|
),
|
|
group: types.optional(
|
types.enumeration(["type", "label", "manual"]),
|
() => window.localStorage.getItem(localStorageKeys.group) ?? "manual",
|
),
|
|
filter: types.maybeNull(types.array(types.safeReference(AllRegionsType)), null),
|
|
view: types.optional(
|
types.enumeration(["regions", "labels"]),
|
window.localStorage.getItem(localStorageKeys.view) ?? "regions",
|
),
|
selection: types.optional(SelectionMap, {}),
|
})
|
.views((self) => {
|
let lastClickedItem;
|
const getShiftClickSelectedRange = (item, tree) => {
|
const regions = [];
|
let clickedRegionsFound = 0;
|
|
Tree.traverseTree({ children: tree }, (node) => {
|
if (!node.isArea) return;
|
if (node.item === lastClickedItem || node.item === item || clickedRegionsFound === 1) {
|
if (node.item) regions.push(node.item);
|
if (node.item === lastClickedItem) ++clickedRegionsFound;
|
if (node.item === item) ++clickedRegionsFound;
|
}
|
if (clickedRegionsFound >= 2) {
|
return TRAVERSE_STOP;
|
}
|
});
|
|
return regions;
|
};
|
const createClickRegionInTreeHandler = (tree) => {
|
return (ev, item) => {
|
if (ev.shiftKey) {
|
const regions = getShiftClickSelectedRange(item, tree);
|
|
regions.forEach((region) => {
|
self.selection.select(region);
|
});
|
|
lastClickedItem = null;
|
return;
|
}
|
lastClickedItem = item;
|
if (ev.metaKey || ev.ctrlKey) {
|
self.toggleSelection(item);
|
return;
|
}
|
if (self.selection.highlighted === item) {
|
self.clearSelection();
|
return;
|
}
|
self.highlight(item);
|
};
|
};
|
|
return {
|
get annotation() {
|
return getParent(self);
|
},
|
|
get classifications() {
|
const textAreas = Array.from(self.annotation.names.values())
|
.filter((t) => isDefined(t))
|
.filter((t) => t.type === "textarea" && !t.perregion)
|
.map((t) => t.regions);
|
|
return [].concat(...textAreas);
|
},
|
|
get regions() {
|
return Array.from(self.annotation.areas.values()).filter((area) => !area.classification);
|
},
|
|
get filteredRegions() {
|
return self.filter || self.regions;
|
},
|
|
get suggestions() {
|
return Array.from(self.annotation.suggestions.values()).filter((area) => !area.classification);
|
},
|
|
get isAllHidden() {
|
return !self.regions.find((area) => !area.hidden);
|
},
|
|
get sortedRegions() {
|
const sorts = {
|
date: (isDesc) =>
|
[...self.filteredRegions].sort(isDesc ? (a, b) => b.ouid - a.ouid : (a, b) => a.ouid - b.ouid),
|
score: (isDesc) =>
|
[...self.filteredRegions].sort(isDesc ? (a, b) => b.score - a.score : (a, b) => a.score - b.score),
|
mediaStartTime: (isDesc) =>
|
[...self.filteredRegions].sort((a, b) => {
|
const aTime = self.getRegionMediaTime(a);
|
const bTime = self.getRegionMediaTime(b);
|
|
// Handle regions without media time (put them at the end)
|
if (aTime === null && bTime === null) return 0;
|
if (aTime === null) return 1;
|
if (bTime === null) return -1;
|
|
return isDesc ? bTime - aTime : aTime - bTime;
|
}),
|
};
|
|
const sorted = sorts[self.sort](self.sortOrder === "desc");
|
|
return sorted;
|
},
|
|
get regionIndexMap() {
|
const map = {};
|
self.sortedRegions.forEach((region, idx) => {
|
map[region.id] = idx + 1;
|
});
|
return map;
|
},
|
|
getRegionMediaTime(region) {
|
// Handle audio regions - they have start time in seconds
|
if ((region.type === "audioregion" || region.type === "timeseriesregion") && typeof region.start === "number") {
|
return region.start;
|
}
|
|
// Handle timeline regions (video) - they have ranges with start frame
|
if (region.type === "timelineregion" && region.ranges?.[0]) {
|
return region.ranges[0].start;
|
}
|
|
// Handle video rectangle regions - they have sequence with frames
|
if (region.type === "videorectangleregion" && region.sequence?.[0]) {
|
return region.sequence[0].frame;
|
}
|
|
// Return null for regions without media time information
|
return null;
|
},
|
|
getRegionsTree(enrich) {
|
if (self.group === null || self.group === "manual") {
|
return self.asTree(enrich);
|
}
|
if (self.group === "label") {
|
return self.asLabelsTree(enrich);
|
}
|
if (self.group === "type") {
|
return self.asTypeTree(enrich);
|
}
|
console.error(`Grouping by ${self.group} is not implemented`);
|
},
|
|
asTree(enrich) {
|
const regions = self.sortedRegions;
|
const tree = [];
|
const lookup = new Map();
|
const onClick = createClickRegionInTreeHandler(tree);
|
|
// every region has a parentID
|
// parentID is an empty string - "" if it's top level
|
// or it can contain a string key to the parent region
|
// [ { id: "1", parentID: "" }, { id: "2", parentID: "1" } ]
|
// would create a tree of two elements
|
|
regions.forEach((el, idx) => {
|
const result = enrich(el, idx, onClick);
|
|
Object.assign(result, {
|
item: el,
|
children: [],
|
isArea: true,
|
});
|
|
lookup.set(el.cleanId, result);
|
});
|
|
lookup.forEach((el) => {
|
const pid = el.item.parentID;
|
const parent = pid ? (lookup.get(pid) ?? lookup.get(pid.replace(/#(.+)/i, ""))) : null;
|
|
if (parent) return parent.children.push(el);
|
|
tree.push(el);
|
});
|
|
return tree;
|
},
|
|
asLabelsTree(enrich) {
|
// collect all label states into two maps
|
const groups = {};
|
const result = [];
|
const onClick = createClickRegionInTreeHandler(result);
|
let index = 0;
|
const getLabelGroup = (label, key) => {
|
const labelGroup = groups[key];
|
|
if (labelGroup) return labelGroup;
|
|
return (groups[key] = {
|
...enrich(label, index, true),
|
id: key,
|
isGroup: true,
|
isNotLabel: true,
|
children: [],
|
});
|
};
|
const getRegionLabel = (region) =>
|
region.labeling?.selectedLabels || (region.emptyLabel && [region.emptyLabel]);
|
const addToLabelGroup = (key, label, region) => {
|
const group = getLabelGroup(label, key);
|
const groupId = group.id;
|
const labelHotKey = getRegionLabel(region)?.[0]?.hotkey;
|
|
if (isFF(FF_DEV_2755)) {
|
group.hotkey = labelHotKey;
|
group.pos = groupId.slice(0, groupId.indexOf("#"));
|
}
|
group.children.push({
|
...enrich(region, index, false, null, onClick, groupId),
|
item: region,
|
isArea: true,
|
});
|
};
|
const addRegionsToLabelGroup = (labels, region) => {
|
if (labels) {
|
for (const label of labels) {
|
addToLabelGroup(`${label.value}#${label.id}`, label, region);
|
}
|
} else {
|
addToLabelGroup("no-label", undefined, region);
|
}
|
};
|
|
for (const region of self.regions) {
|
addRegionsToLabelGroup(region.labeling?.selectedLabels, region);
|
|
index++;
|
}
|
|
const groupsArray = Object.values(groups);
|
|
if (isFF(FF_DEV_2755)) {
|
groupsArray.sort((a, b) => (a.hotkey > b.hotkey ? 1 : a.hotkey < b.hotkey ? -1 : 0));
|
}
|
result.push(...groupsArray);
|
|
return result;
|
},
|
|
asTypeTree(enrich) {
|
// collect all label states into two maps
|
const groups = {};
|
const result = [];
|
const onClick = createClickRegionInTreeHandler(result);
|
|
let index = 0;
|
|
const getTypeGroup = (region, key) => {
|
const group = groups[key];
|
|
if (group) return group;
|
|
const groupingEntity = {
|
type: "tool",
|
value: key.replace("region", ""),
|
background: "#000",
|
};
|
|
return (groups[key] = {
|
...enrich(groupingEntity, index, true),
|
id: key,
|
key,
|
isArea: false,
|
children: [],
|
isGroup: true,
|
entity: region,
|
});
|
};
|
|
const addToLabelGroup = (region) => {
|
const key = region.type;
|
const group = getTypeGroup(region, key);
|
|
group.children.push({
|
...enrich(region, index, false, null, onClick),
|
item: region,
|
isArea: true,
|
});
|
};
|
|
for (const region of self.regions) {
|
addToLabelGroup(region);
|
|
index++;
|
}
|
|
result.push(...Object.values(groups));
|
|
return result;
|
},
|
|
get hasSelection() {
|
return !!self.selection.size;
|
},
|
isSelected(region) {
|
return self.selection.isSelected(region);
|
},
|
|
get selectedIds() {
|
return Array.from(self.selection.selected.values()).map((reg) => reg.id);
|
},
|
|
get persistantView() {
|
return window.localStorage.getItem(localStorageKeys.view) ?? self.view;
|
},
|
};
|
})
|
.actions((self) => ({
|
addRegion(region) {
|
self.regions.push(region);
|
getEnv(self).events.invoke("entityCreate", region);
|
},
|
|
toggleSortOrder() {
|
if (self.sortOrder === "asc") self.sortOrder = "desc";
|
else self.sortOrder = "asc";
|
},
|
|
setView(view) {
|
if (isFF(FF_DEV_2755)) {
|
window.localStorage.setItem(localStorageKeys.view, view);
|
}
|
self.view = view;
|
},
|
|
setSort(sort) {
|
if (self.sort === sort) {
|
self.toggleSortOrder();
|
} else {
|
self.sortOrder = "asc";
|
self.sort = sort;
|
}
|
|
window.localStorage.setItem(localStorageKeys.sort, self.sort);
|
window.localStorage.setItem(localStorageKeys.sortDirection, self.sortOrder);
|
|
self.initHotkeys();
|
self.annotation.updateAppearenceFromState();
|
},
|
|
setGrouping(group) {
|
self.group = group;
|
window.localStorage.setItem(localStorageKeys.group, self.group);
|
},
|
|
setFilteredRegions(filter) {
|
if (self.regions.length === filter.length) {
|
self.filter = null;
|
self.regions.forEach((region) => region.filtered && region.toggleFiltered());
|
} else {
|
const filteredIds = filter.map((filter) => filter.id);
|
|
self.filter = filter;
|
|
self.regions.forEach((region) => {
|
if (!region.hideable || (region.hidden && !region.filtered)) return;
|
if (filteredIds.includes(region.id)) region.hidden && region.toggleFiltered();
|
else if (!region.hidden) region.toggleFiltered();
|
});
|
}
|
self.annotation.updateAppearenceFromState();
|
},
|
|
/**
|
* Delete region
|
* @param {obj} region
|
*/
|
deleteRegion(region) {
|
detach(region);
|
|
// find regions that have that region as a parent
|
const children = self.filterByParentID(region.id);
|
|
children && children.forEach((r) => r.setParentID(region.parentID));
|
|
getEnv(self).events.invoke("entityDelete", region);
|
|
destroy(region);
|
self.initHotkeys();
|
},
|
|
findRegionID(id) {
|
if (!id) return null;
|
return self.regions.find((r) => r.id === id);
|
},
|
|
findRegion(id) {
|
return self.findRegionID(id);
|
},
|
|
filterByParentID(id) {
|
return self.regions.filter((r) => r.parentID === id);
|
},
|
|
normalizeRegionID(regionId) {
|
if (!regionId) return "";
|
if (!regionId.includes("#")) {
|
regionId = `${regionId}#${self.annotation.id}`;
|
}
|
return regionId;
|
},
|
|
afterCreate() {
|
onPatch(self, (patch) => {
|
if ((patch.op === "add" || patch.op === "delete") && patch.path.indexOf("/regions/") !== -1) {
|
self.initHotkeys();
|
}
|
});
|
self.view =
|
window.localStorage.getItem(localStorageKeys.view) ??
|
(self.annotation.store.settings.displayLabelsByDefault ? "labels" : "regions");
|
},
|
|
// init Alt hotkeys for regions selection
|
initHotkeys() {
|
const PREFIX = "alt+shift+";
|
|
hotkeys.unbindAll();
|
|
self.sortedRegions.forEach((r, n) => {
|
hotkeys.addKey(PREFIX + (n + 1), () => {
|
self.unselectAll();
|
r.selectRegion();
|
});
|
});
|
|
// this is added just for the reference to show up in the
|
// settings page
|
hotkeys.addKey("alt+shift+$n", () => {}, "Select a region");
|
},
|
|
/**
|
* @param {boolean} tryToKeepStates try to keep states selected if such settings enabled
|
*/
|
unselectAll() {
|
self.annotation.unselectAll();
|
},
|
|
unhighlightAll() {
|
self.regions.forEach((r) => r.setHighlight(false));
|
},
|
|
selectNext() {
|
const { regions } = self;
|
const idx = self.regions.findIndex((r) => r.selected);
|
|
if (idx < 0) {
|
const region = regions[0];
|
|
region && self.annotation.selectArea(region);
|
} else {
|
const next = isDefined(regions[idx + 1]) ? regions[idx + 1] : regions[0];
|
|
next && self.annotation.selectArea(next);
|
}
|
},
|
|
toggleVisibility() {
|
const shouldBeHidden = !self.isAllHidden;
|
|
self.regions.forEach((area) => {
|
if (area.hidden !== shouldBeHidden) {
|
area.toggleHidden();
|
}
|
});
|
},
|
|
selectRegionByID(regionId) {
|
const normalizedRegionId = self.normalizeRegionID(regionId);
|
const targetRegion = self.findRegionID(normalizedRegionId);
|
if (!targetRegion) return;
|
self.toggleSelection(targetRegion, true);
|
},
|
|
setRegionVisible(regionId) {
|
const normalizedRegionId = self.normalizeRegionID(regionId);
|
const targetRegion = self.findRegionID(normalizedRegionId);
|
if (!targetRegion) return;
|
|
self.regions.forEach((area) => {
|
if (!area.hidden) {
|
area.toggleHidden();
|
}
|
});
|
|
targetRegion.toggleHidden();
|
},
|
|
setHiddenByTool(shouldBeHidden, label) {
|
self.regions.forEach((area) => {
|
if (area.hidden !== shouldBeHidden && area.type === label.type) {
|
area.toggleHidden();
|
}
|
});
|
},
|
|
setHiddenByLabel(shouldBeHidden, label) {
|
self.regions.forEach((area) => {
|
if (area.hidden !== shouldBeHidden) {
|
const l = area.labeling;
|
|
if (l) {
|
const selected = l.selectedLabels;
|
|
if (selected.includes(label)) {
|
area.toggleHidden();
|
}
|
}
|
}
|
});
|
},
|
|
highlight(area) {
|
self.selection.highlight(area);
|
},
|
|
clearSelection() {
|
self.selection.clear();
|
},
|
|
selectRegionsByIds(ids) {
|
self.regions.map((region) => {
|
if (ids.indexOf(region.id) === -1) return;
|
self.toggleSelection(region, true);
|
});
|
},
|
|
toggleSelection(region, isSelected) {
|
if (!isDefined(isSelected)) isSelected = !self.selection.isSelected(region);
|
if (isSelected) {
|
self.selection.select(region);
|
} else {
|
self.selection.unselect(region);
|
}
|
},
|
}));
|