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);