import { getParent, getRoot, getSnapshot, types } from "mobx-state-tree";
|
import { ff } from "@humansignal/core";
|
import { guidGenerator } from "../core/Helpers";
|
import Registry from "../core/Registry";
|
import Tree from "../core/Tree";
|
import { AnnotationMixin } from "../mixins/AnnotationMixin";
|
import { isDefined } from "../utils/utilities";
|
import { FF_LSDV_4583, isFF } from "../utils/feature-flags";
|
|
const resultTypes = [
|
"labels",
|
"hypertextlabels",
|
"paragraphlabels",
|
"rectangle",
|
"keypoint",
|
"polygon",
|
"brush",
|
"bitmask",
|
"ellipse",
|
"magicwand",
|
"rectanglelabels",
|
"keypointlabels",
|
"polygonlabels",
|
"vector",
|
"vectorlabels",
|
"brushlabels",
|
"bitmasklabels",
|
"ellipselabels",
|
"timeserieslabels",
|
"timelinelabels",
|
"choices",
|
"datetime",
|
"number",
|
"taxonomy",
|
"textarea",
|
"rating",
|
"pairwise",
|
"videorectangle",
|
"ranker",
|
"custominterface",
|
];
|
|
const resultValues = {
|
ranker: types.union(types.array(types.string), types.frozen(), types.null),
|
datetime: types.maybe(types.string),
|
number: types.maybe(types.number),
|
rating: types.maybe(types.number),
|
item_index: types.maybeNull(types.number),
|
text: types.maybe(types.union(types.string, types.array(types.string))),
|
choices: types.maybe(types.array(types.union(types.string, types.array(types.string)))),
|
// pairwise
|
selected: types.maybe(types.enumeration(["left", "right"])),
|
// @todo all other *labels
|
labels: types.maybe(types.array(types.string)),
|
htmllabels: types.maybe(types.array(types.string)),
|
hypertextlabels: types.maybe(types.array(types.string)),
|
paragraphlabels: types.maybe(types.array(types.string)),
|
rectanglelabels: types.maybe(types.array(types.string)),
|
keypointlabels: types.maybe(types.array(types.string)),
|
polygonlabels: types.maybe(types.array(types.string)),
|
vectorlabels: types.maybe(types.array(types.string)),
|
ellipselabels: types.maybe(types.array(types.string)),
|
brushlabels: types.maybe(types.array(types.string)),
|
timeserieslabels: types.maybe(types.array(types.string)),
|
timelinelabels: types.maybe(types.array(types.string)), // new one
|
bitmasklabels: types.maybe(types.array(types.string)),
|
taxonomy: types.frozen(), // array of arrays of strings
|
sequence: types.frozen(),
|
custom: types.maybe(types.frozen()), // for CustomInterface regions
|
};
|
|
const Result = types
|
.model("Result", {
|
id: types.optional(types.identifier, guidGenerator),
|
// pid: types.optional(types.string, guidGenerator),
|
|
score: types.maybeNull(types.number),
|
// @todo to readonly mixin
|
readonly: types.optional(types.boolean, false),
|
|
// @why?
|
// hidden: types.optional(types.boolean, false),
|
|
// @todo to mixins
|
// selected: types.optional(types.boolean, false),
|
// highlighted: types.optional(types.boolean, false),
|
|
// @todo pid?
|
// parentID: types.optional(types.string, ""),
|
|
// KonvaRegion, TextRegion, HyperTextRegion, AudioRegion)),
|
// optional for classifications
|
// labeling/control tag
|
from_name: types.late(() => types.reference(types.union(...Registry.modelsArr()))),
|
// object tag
|
to_name: types.late(() => types.reference(types.union(...Registry.objectTypes()))),
|
// @todo some general type, maybe just a `string`
|
type: ff.isActive(ff.FF_CUSTOM_TAGS)
|
? types.late(() =>
|
types.enumeration([
|
...resultTypes,
|
...Registry.customTags.filter((t) => t.resultName).map((t) => t.resultName),
|
]),
|
)
|
: types.enumeration([...resultTypes]),
|
// @todo much better to have just a value, not a hash with empty fields
|
value: ff.isActive(ff.FF_CUSTOM_TAGS)
|
? types.late(() =>
|
types.model({
|
...resultValues,
|
...Object.fromEntries(
|
Registry.customTags.filter((t) => t.resultName).map((t) => [t.resultName, types.maybe(t.result)]),
|
),
|
}),
|
)
|
: types.model({
|
...resultValues,
|
}),
|
// info about object and region
|
meta: types.frozen(),
|
})
|
.views((self) => ({
|
get perRegionStates() {
|
const states = self.states;
|
|
return states && states.filter((s) => s.perregion === true);
|
},
|
|
get store() {
|
return getRoot(self);
|
},
|
|
get area() {
|
return getParent(self, 2);
|
},
|
|
get mainValue() {
|
return self.value[self.from_name.valueType];
|
},
|
|
mergeMainValue(value) {
|
value = value?.toJSON ? value.toJSON() : value;
|
const mainValue = self.mainValue?.toJSON?.() ? self.mainValue?.toJSON?.() : self.mainValue;
|
|
if (typeof value !== typeof mainValue) return null;
|
if (self.type.endsWith("labels")) {
|
return value.filter((x) => mainValue.includes(x));
|
}
|
return value === mainValue ? value : null;
|
},
|
|
get hasValue() {
|
const value = self.mainValue;
|
|
if (!isDefined(value)) return false;
|
if (Array.isArray(value)) return value.length > 0;
|
return true;
|
},
|
|
get editable() {
|
throw new Error("Not implemented");
|
},
|
|
isReadOnly() {
|
return self.readonly || self.area.isReadOnly();
|
},
|
|
isSelfReadOnly() {
|
return self.readonly;
|
},
|
|
getSelectedString(joinstr = " ") {
|
return self.mainValue?.join(joinstr) || "";
|
},
|
|
// @todo check all usages of selectedLabels:
|
// — check usages of non-array values (like `if selectedValues ...`)
|
// - check empty labels, they should be returned as an array
|
get selectedLabels() {
|
if (self.mainValue?.length === 0 && self.from_name.allowempty) {
|
return self.from_name.findLabel(null);
|
}
|
return self.mainValue?.map((value) => self.from_name.findLabel(value)).filter(Boolean) ?? [];
|
},
|
|
/**
|
* Checks perRegion and Visibility params
|
*/
|
get canBeSubmitted() {
|
const control = self.from_name;
|
|
// Find the first node moving up in the tree with the given visibleWhen value
|
function findParentWithVisibleWhen(control, visibleWhen) {
|
let currentControl = control;
|
|
while (currentControl) {
|
if (currentControl.visiblewhen === visibleWhen) return currentControl;
|
|
try {
|
currentControl = getParent(currentControl);
|
if (!currentControl) break;
|
} catch {
|
break;
|
}
|
}
|
|
return null;
|
}
|
|
if (control.perregion) {
|
const label = control.whenlabelvalue;
|
|
if (label && !self.area.hasLabel(label)) return false;
|
}
|
|
// picks leaf's (last item in a path) value for Taxonomy or usual Choice value for Choices
|
const innerResults = (r) => r.map((s) => (Array.isArray(s) ? s.at(-1) : s));
|
|
const isChoiceSelected = () => {
|
const tagName = control.whentagname;
|
const choiceValues = control.whenchoicevalue?.split(",") ?? null;
|
const results = self.annotation.results.filter((r) => ["choices", "taxonomy"].includes(r.type) && r !== self);
|
|
if (tagName) {
|
const result = results.find((r) => {
|
if (r.from_name.name !== tagName) return false;
|
// for perRegion choices we should check that they are in the same area
|
return !r.from_name.perregion || r.area === self.area;
|
});
|
|
if (!result) return false;
|
if (
|
choiceValues &&
|
!choiceValues.some((v) =>
|
innerResults(result.mainValue).some((vv) => result.from_name.selectedChoicesMatch(v, vv)),
|
)
|
)
|
return false;
|
} else {
|
if (!results.length) return false;
|
// if no given choice value is selected in any choice result
|
if (
|
choiceValues &&
|
!results.some((r) =>
|
choiceValues.some((v) => innerResults(r.mainValue).some((vv) => r.from_name.selectedChoicesMatch(v, vv))),
|
)
|
)
|
return false;
|
}
|
return true;
|
};
|
|
// When perregion is used, we must ignore the visibility of the components and focus only on the selection
|
if (control.perregion && control.visiblewhen === "choice-selected") {
|
return isChoiceSelected();
|
}
|
|
if (control.visiblewhen === "choice-unselected") {
|
return !isChoiceSelected();
|
}
|
|
// We need to check if there is any node up in the tree with visibility restrictions so we can determine
|
// if the element is selected considering its own visibility
|
if (!control.perregion && findParentWithVisibleWhen(control, "choice-selected")) {
|
return control.isVisible === false ? false : isChoiceSelected();
|
}
|
|
return true;
|
},
|
|
get tag() {
|
const value = self.mainValue;
|
|
if (!value || !value.length) return null;
|
if (!self.from_name.findLabel) return null;
|
return self.from_name.findLabel(value[0]);
|
},
|
|
get style() {
|
if (!self.tag) return null;
|
const fillcolor = self.tag.background || self.tag.parent?.fillcolor;
|
|
if (!fillcolor) return null;
|
const strokecolor = self.tag.background || self.tag.parent.strokecolor;
|
const { strokewidth, fillopacity, opacity } = self.tag.parent;
|
|
return { strokecolor, strokewidth, fillcolor, fillopacity, opacity };
|
},
|
|
get emptyStyle() {
|
const emptyLabel = self.from_name.emptyLabel;
|
|
if (!emptyLabel) return null;
|
const fillcolor = emptyLabel.background || emptyLabel.parent.fillcolor;
|
|
if (!fillcolor) return null;
|
const strokecolor = emptyLabel.background || emptyLabel.parent.strokecolor;
|
const { strokewidth, fillopacity, opacity } = emptyLabel.parent;
|
|
return { strokecolor, strokewidth, fillcolor, fillopacity, opacity };
|
},
|
|
get controlStyle() {
|
if (!self.from_name) return null;
|
|
const { fillcolor, strokecolor, strokewidth, fillopacity, opacity } = self.from_name;
|
|
return { strokecolor, strokewidth, fillcolor, fillopacity, opacity };
|
},
|
|
/**
|
* This name historically is used for the region elements for getting their bboxes.
|
* Now we need it for a result also.
|
* Let's say "Region" here means just an area on the screen.
|
* So that it's an element through which we can get the bbox for an area where classification takes place.
|
*/
|
getRegionElement() {
|
return self.from_name?.getRegionElement?.();
|
},
|
}))
|
.volatile(() => ({
|
pid: "",
|
selected: false,
|
// highlighted: types.optional(types.boolean, false),
|
}))
|
.actions((self) => ({
|
setValue(value) {
|
self.value[self.from_name.valueType] = value;
|
},
|
|
afterCreate() {
|
self.pid = self.id;
|
},
|
|
afterAttach() {
|
// const tag = self.from_name;
|
// update state of classification tags
|
// @todo unify this with `selectArea`
|
},
|
|
setParentID(id) {
|
self.parentID = id;
|
},
|
|
setMetaValue(key, value) {
|
self.meta = { ...self.meta, [key]: value };
|
},
|
|
// update region appearence based on it's current states, for
|
// example bbox needs to update its colors when you change the
|
// label, becuase it takes color from the label
|
updateAppearenceFromState() {},
|
|
serialize(options) {
|
const sn = getSnapshot(self);
|
const { type, score, value, meta } = sn;
|
const { valueType } = self.from_name;
|
const data = self.area ? self.area.serialize(options) : {};
|
// cut off annotation id
|
const id = self.area?.cleanId;
|
const from_name = Tree.cleanUpId(sn.from_name);
|
const to_name = Tree.cleanUpId(sn.to_name);
|
|
if (!data) return null;
|
if (!self.canBeSubmitted) return null;
|
|
if (!isDefined(data.value)) data.value = {};
|
// with `mergeLabelsAndResults` control uses only one result even with external `Labels`
|
if (self.to_name.mergeLabelsAndResults) {
|
// we are in labeling result, so skipping it, labels will be added to the main result
|
if (type === "labels") return null;
|
// add labels to the main result, not nested ones
|
// if this is specialized labels, then labels will be already part of it, so skipping it
|
if (!type.endsWith("labels") && self.area?.labels?.length && !self.from_name.perregion) {
|
data.value.labels = self.area.labels;
|
}
|
}
|
|
if (meta || (self.area.meta && Object.keys(self.area.meta).length)) {
|
// `meta` is used for lead_time which is stored in one result, while area's `meta` is used for meta text,
|
// and this text is duplicated in every connected result, so we should prefer area's `meta` for actual value.
|
data.meta = { ...meta, ...self.area.meta };
|
}
|
|
if (self.area.parentID) {
|
data.parentID = self.area.parentID.replace(/#.*/, "");
|
}
|
|
Object.assign(data, { id, from_name, to_name, type, origin: self.area.origin });
|
|
if (isDefined(value[valueType])) {
|
Object.assign(data.value, { [valueType]: value[valueType] });
|
}
|
|
if (typeof score === "number") data.score = score;
|
|
if (self.isSelfReadOnly()) data.readonly = true;
|
|
if (isFF(FF_LSDV_4583) && isDefined(self.area.item_index)) {
|
data.item_index = self.area.item_index;
|
}
|
|
return data;
|
},
|
|
setHighlight(val) {
|
self._highlighted = val;
|
},
|
|
toggleHighlight() {
|
self.setHighlight(!self._highlighted);
|
},
|
|
toggleHidden() {
|
self.hidden = !self.hidden;
|
},
|
}));
|
|
export default types.compose("Result", Result, AnnotationMixin);
|