import { ff } from "@humansignal/core"; import { inject } from "mobx-react"; import { destroy, getRoot, getType, types } from "mobx-state-tree"; import ImageView from "../../../components/ImageView/ImageView"; import { customTypes } from "../../../core/CustomTypes"; import Registry from "../../../core/Registry"; import { AnnotationMixin } from "../../../mixins/AnnotationMixin"; import { IsReadyWithDepsMixin } from "../../../mixins/IsReadyMixin"; import { BrushRegionModel } from "../../../regions/BrushRegion"; import { EllipseRegionModel } from "../../../regions/EllipseRegion"; import { KeyPointRegionModel } from "../../../regions/KeyPointRegion"; import { PolygonRegionModel } from "../../../regions/PolygonRegion"; import { VectorRegionModel } from "../../../regions/VectorRegion"; import { RectRegionModel } from "../../../regions/RectRegion"; import * as Tools from "../../../tools"; import ToolsManager from "../../../tools/Manager"; import { parseValue } from "../../../utils/data"; import { FF_DEV_3377, FF_DEV_3391, FF_DEV_3793, FF_LSDV_4583, FF_LSDV_4583_6, FF_ZOOM_OPTIM, isFF, } from "../../../utils/feature-flags"; import { guidGenerator } from "../../../utils/unique"; import { clamp, isDefined } from "../../../utils/utilities"; import ObjectBase from "../Base"; import { DrawingRegion } from "./DrawingRegion"; import { ImageEntityMixin } from "./ImageEntityMixin"; import { ImageSelection } from "./ImageSelection"; import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH, SNAP_TO_PIXEL_MODE } from "../../../components/ImageView/Image"; import MultiItemObjectBase from "../MultiItemObjectBase"; const IMAGE_PRELOAD_COUNT = 3; const ZOOM_INTENSITY = 0.009; const MIN_ZOOM = 0.1; const MAX_ZOOM = 100; const MAX_ZOOM_CHANGE_PER_EVENT = 0.3; // Maximum zoom change per wheel event (30%) /** * The `Image` tag shows an image on the page. Use for all image annotation tasks to display an image on the labeling interface. * * Use with the following data types: images. * * When you annotate image regions with this tag, the annotations are saved as percentages of the original size of the image, from 0-100. * * @example * * * * * * * @example * * * * * * * * @name Image * @meta_title Image Tags for Images * @meta_description Customize Label Studio with the Image tag to annotate images for computer vision machine learning and data science projects. * @param {string} name - Name of the element * @param {string} value - Data field containing a path or URL to the image * @param {string} [valueList] - References a variable that holds a list of image URLs. For an example, see the [Multi-Page Document Annotation](/templates/multi-page-document-annotation) template. * @param {boolean} [smoothing] - Enable smoothing, by default it uses user settings * @param {string=} [width=100%] - Image width * @param {string=} [maxWidth=750px] - Maximum image width * @param {boolean=} [zoom=false] - Enable zooming an image with the mouse wheel * @param {boolean=} [negativeZoom=false] - Enable zooming out an image * @param {float=} [zoomBy=1.1] - Scale factor * @param {boolean=} [grid=false] - Whether to show a grid * @param {number=} [gridSize=30] - Specify size of the grid * @param {string=} [gridColor=#EEEEF4] - Color of the grid in hex, opacity is 0.15 * @param {boolean} [zoomControl=false] - Show zoom controls in toolbar * @param {boolean} [brightnessControl=false] - Show brightness control in toolbar * @param {boolean} [contrastControl=false] - Show contrast control in toolbar * @param {boolean} [rotateControl=false] - Show rotate control in toolbar * @param {boolean} [crosshair=false] - Show crosshair cursor * @param {left|center|right} [horizontalAlignment=left] - Where to align image horizontally. Can be one of "left", "center", or "right" * @param {top|center|bottom} [verticalAlignment=top] - Where to align image vertically. Can be one of "top", "center", or "bottom" * @param {auto|original|fit} [defaultZoom=fit] - Specify the initial zoom of the image within the viewport while preserving its ratio. Can be one of "auto", "original", or "fit" * @param {none|anonymous|use-credentials} [crossOrigin=none] - Configures CORS cross domain behavior for this image, either "none", "anonymous", or "use-credentials", similar to [DOM `img` crossOrigin property](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/crossOrigin). */ const TagAttrs = types.model({ value: types.maybeNull(types.string), valuelist: types.maybeNull(types.string), resize: types.maybeNull(types.number), width: types.optional(types.string, "100%"), height: types.maybeNull(types.string), maxwidth: types.optional(types.string, "100%"), maxheight: types.optional(types.string, "calc(100vh - 194px)"), smoothing: types.maybeNull(types.boolean), // rulers: types.optional(types.boolean, true), grid: types.optional(types.boolean, false), gridsize: types.optional(types.string, "30"), gridcolor: types.optional(customTypes.color, "#EEEEF4"), zoom: types.optional(types.boolean, true), negativezoom: types.optional(types.boolean, false), zoomby: types.optional(types.string, "1.1"), showlabels: types.optional(types.boolean, false), zoomcontrol: types.optional(types.boolean, true), brightnesscontrol: types.optional(types.boolean, false), contrastcontrol: types.optional(types.boolean, false), rotatecontrol: types.optional(types.boolean, false), crosshair: types.optional(types.boolean, false), selectioncontrol: types.optional(types.boolean, true), // this property is just to turn lazyload off to e2e tests lazyoff: types.optional(types.boolean, false), horizontalalignment: types.optional(types.enumeration(["left", "center", "right"]), "left"), verticalalignment: types.optional(types.enumeration(["top", "center", "bottom"]), "top"), defaultzoom: types.optional(types.enumeration(["auto", "original", "fit"]), "fit"), crossorigin: types.optional(types.enumeration(["none", "anonymous", "use-credentials"]), "none"), }); const IMAGE_CONSTANTS = { rectangleModel: "RectangleModel", rectangleLabelsModel: "RectangleLabelsModel", ellipseModel: "EllipseModel", ellipseLabelsModel: "EllipseLabelsModel", brushLabelsModel: "BrushLabelsModel", rectanglelabels: "rectanglelabels", keypointlabels: "keypointlabels", polygonlabels: "polygonlabels", vectorlabels: "vectorlabels", brushlabels: "brushlabels", bitmaskModel: "BitmaskModel", bitmasklabels: "bitmasklabels", brushModel: "BrushModel", ellipselabels: "ellipselabels", }; const Model = types .model({ type: "image", // tools: types.array(BaseTool), sizeUpdated: types.optional(types.boolean, false), /** * Cursor coordinates */ cursorPositionX: types.optional(types.number, 0), cursorPositionY: types.optional(types.number, 0), brushControl: types.optional(types.string, "brush"), brushStrokeWidth: types.optional(types.number, 15), /** * Mode * brush for Image Segmentation * eraser for Image Segmentation */ mode: types.optional(types.enumeration(["drawing", "viewing", "brush", "eraser"]), "viewing"), regions: types.array( types.union( BrushRegionModel, RectRegionModel, EllipseRegionModel, PolygonRegionModel, VectorRegionModel, KeyPointRegionModel, ), [], ), drawingRegion: types.optional(DrawingRegion, null), selectionArea: types.optional(ImageSelection, { start: null, end: null }), }) .volatile(() => ({ currentImage: undefined, supportSuggestions: true, })) .views((self) => ({ get store() { return getRoot(self); }, get multiImage() { return !!self.isMultiItem; }, // an alias of currentImage to make an interface reusable get currentItemIndex() { return self.currentImage; }, get parsedValue() { return parseValue(self.value, self.store.task.dataObj); }, get parsedValueList() { return parseValue(self.valuelist, self.store.task.dataObj); }, get currentSrc() { return self.currentImageEntity.src; }, get usedValue() { return self.multiImage ? self.valuelist : self.value; }, get images() { const value = self.parsedValue; if (!value) return []; if (Array.isArray(value)) return value; return [value]; }, /** * @return {boolean} */ get hasStates() { const states = self.states(); return states && states.length > 0; }, get selectedRegions() { return self.regs.filter((region) => region.inSelection); }, get selectedRegionsBBox() { let bboxCoords; self.selectedRegions.forEach((region) => { const regionBBox = region.bboxCoords; if (!regionBBox) return; if (bboxCoords) { bboxCoords = { left: Math.min(regionBBox?.left, bboxCoords.left), top: Math.min(regionBBox?.top, bboxCoords.top), right: Math.max(regionBBox?.right, bboxCoords.right), bottom: Math.max(regionBBox?.bottom, bboxCoords.bottom), }; } else { bboxCoords = regionBBox; } }); return bboxCoords; }, get regionsInSelectionArea() { return self.regs.filter((region) => region.isInSelectionArea); }, get selectedShape() { return self.regs.find((r) => r.selected); }, get suggestions() { return self.annotation?.regionStore.suggestions.filter((r) => r.object === self) || []; }, get useTransformer() { return self.getToolsManager().findSelectedTool()?.useTransformer === true; }, get stageTranslate() { const { stageWidth: width, stageHeight: height } = self; return { 0: { x: 0, y: 0 }, 90: { x: 0, y: height }, 180: { x: width, y: height }, 270: { x: width, y: 0 }, }[self.rotation]; }, get stageScale() { return self.zoomScale; }, get layerZoomScalePosition() { return { scaleX: self.zoomScale, scaleY: self.zoomScale, x: self.zoomingPositionX + self.alignmentOffset.x, y: self.zoomingPositionY + self.alignmentOffset.y, }; }, get hasTools() { return !!self.getToolsManager().allTools()?.length; }, get imageCrossOrigin() { const value = self.crossorigin.toLowerCase(); if (!value || value === "none") return "anonymous"; return value; }, get fillerHeight() { const { naturalWidth, naturalHeight } = self; return self.isSideways ? `${(naturalWidth / naturalHeight) * 100}%` : `${(naturalHeight / naturalWidth) * 100}%`; }, get zoomedPixelSize() { const { naturalWidth, naturalHeight } = self; if (isFF(FF_DEV_3793)) { return { x: 100 / naturalWidth, y: 100 / naturalHeight, }; } return { x: self.stageWidth / naturalWidth, y: self.stageHeight / naturalHeight, }; }, isSamePixel({ x: x1, y: y1 }, { x: x2, y: y2 }) { const zoomedPixelSizeX = self.zoomedPixelSize.x; const zoomedPixelSizeY = self.zoomedPixelSize.y; return Math.abs(x1 - x2) < zoomedPixelSizeX / 2 && Math.abs(y1 - y2) < zoomedPixelSizeY / 2; }, snapPointToPixel({ x, y }, snapMode = SNAP_TO_PIXEL_MODE.EDGE) { const zoomedPixelSizeX = self.zoomedPixelSize.x; const zoomedPixelSizeY = self.zoomedPixelSize.y; switch (snapMode) { case SNAP_TO_PIXEL_MODE.EDGE: { return { x: Math.round(x / zoomedPixelSizeX) * zoomedPixelSizeX, y: Math.round(y / zoomedPixelSizeY) * zoomedPixelSizeY, }; } case SNAP_TO_PIXEL_MODE.CENTER: { return { x: Math.floor(x / zoomedPixelSizeX) * zoomedPixelSizeX + zoomedPixelSizeX / 2, y: Math.floor(y / zoomedPixelSizeY) * zoomedPixelSizeY + zoomedPixelSizeY / 2, }; } } }, createSerializedResult(region, value) { const index = region.item_index ?? 0; const currentImageEntity = self.findImageEntity(index); const imageDimension = { original_width: currentImageEntity.naturalWidth, original_height: currentImageEntity.naturalHeight, image_rotation: currentImageEntity.rotation, }; if (self.multiImage && isDefined(index)) { imageDimension.item_index = index; } // We're using raw region result instead of calulated one when // the image data is not available (image is not yet loaded) // As the serialization also happens during region creation, // we have to forsee this scenario and avoid using raw result // as it can only be present for already created (submitter) regions const useRawResult = !currentImageEntity.imageLoaded && isDefined(region._rawResult); return useRawResult ? structuredClone(region._rawResult) : { ...imageDimension, value, }; }, /** * @return {object} */ states() { return self.annotation.toNames.get(self.name); }, activeStates() { const states = self.states(); return states && states.filter((s) => s.isSelected && s.type.includes("labels")); }, controlButton() { const names = self.states(); if (!names || names.length === 0) return; let returnedControl = names[0]; names.forEach((item) => { if ( item.type === IMAGE_CONSTANTS.rectanglelabels || item.type === IMAGE_CONSTANTS.brushlabels || item.type === IMAGE_CONSTANTS.bitmasklabels || item.type === IMAGE_CONSTANTS.ellipselabels ) { returnedControl = item; } }); return returnedControl; }, get controlButtonType() { const name = self.controlButton(); return getType(name).name; }, get isSideways() { return (self.rotation + 360) % 180 === 90; }, get stageComponentSize() { if (self.isSideways) { return { width: self.stageHeight, height: self.stageWidth, }; } return { width: self.stageWidth, height: self.stageHeight, }; }, get canvasSize() { if (self.isSideways) { return { width: isFF(FF_DEV_3377) ? self.naturalHeight * self.stageZoomX : Math.round(self.naturalHeight * self.stageZoomX), height: isFF(FF_DEV_3377) ? self.naturalWidth * self.stageZoomY : Math.round(self.naturalWidth * self.stageZoomY), }; } return { width: isFF(FF_DEV_3377) ? self.naturalWidth * self.stageZoomX : Math.round(self.naturalWidth * self.stageZoomX), height: isFF(FF_DEV_3377) ? self.naturalHeight * self.stageZoomY : Math.round(self.naturalHeight * self.stageZoomY), }; }, get alignmentOffset() { const offset = { x: 0, y: 0 }; if (isFF(FF_ZOOM_OPTIM)) { switch (self.horizontalalignment) { case "center": { offset.x = (self.containerWidth - self.canvasSize.width) / 2; break; } case "right": { offset.x = self.containerWidth - self.canvasSize.width; break; } } switch (self.verticalalignment) { case "center": { offset.y = (self.containerHeight - self.canvasSize.height) / 2; break; } case "bottom": { offset.y = self.containerHeight - self.canvasSize.height; break; } } } return offset; }, get zoomBy() { return Number.parseFloat(self.zoomby); }, get isDrawing() { return !!self.drawingRegion; }, get imageTransform() { const imgStyle = { // scale transform leaves gaps on image border, so much better to change image sizes width: `${self.stageWidth * self.zoomScale}px`, height: `${self.stageHeight * self.zoomScale}px`, transformOrigin: "left top", // We should always set some transform to make the image rendering in the same way all the time transform: "translate3d(0,0,0)", filter: `brightness(${self.brightnessGrade}%) contrast(${self.contrastGrade}%)`, }; const imgTransform = []; if (self.zoomScale !== 1) { const { zoomingPositionX = 0, zoomingPositionY = 0 } = self; imgTransform.push(`translate3d(${zoomingPositionX}px,${zoomingPositionY}px, 0)`); } if (self.rotation) { const translate = { 90: "0, -100%", 180: "-100%, -100%", 270: "-100%, 0", }; // there is a top left origin already set for zoom; so translate+rotate imgTransform.push(`rotate(${self.rotation}deg)`); imgTransform.push(`translate(${translate[self.rotation] || "0, 0"})`); } if (imgTransform?.length > 0) { imgStyle.transform = imgTransform.join(" "); } return imgStyle; }, get maxScale() { return self.isSideways ? Math.min(self.containerWidth / self.naturalHeight, self.containerHeight / self.naturalWidth) : Math.min(self.containerWidth / self.naturalWidth, self.containerHeight / self.naturalHeight); }, get coverScale() { return self.isSideways ? Math.max(self.containerWidth / self.naturalHeight, self.containerHeight / self.naturalWidth) : Math.max(self.containerWidth / self.naturalWidth, self.containerHeight / self.naturalHeight); }, get viewPortBBoxCoords() { let width = self.canvasSize.width / self.zoomScale; let height = self.canvasSize.height / self.zoomScale; const leftOffset = -self.zoomingPositionX / self.zoomScale; const topOffset = -self.zoomingPositionY / self.zoomScale; const rightOffset = self.stageComponentSize.width - (leftOffset + width); const bottomOffset = self.stageComponentSize.height - (topOffset + height); const offsets = [leftOffset, topOffset, rightOffset, bottomOffset]; if (self.isSideways) { [width, height] = [height, width]; } if (self.rotation) { const rotateCount = (self.rotation / 90) % 4; for (let k = 0; k < rotateCount; k++) { offsets.push(offsets.shift()); } } const left = offsets[0]; const top = offsets[1]; return { left, top, right: left + width, bottom: top + height, width, height, }; }, })) .volatile((self) => ({ manager: null, })) // actions for the tools .actions((self) => { const manager = ToolsManager.getInstance({ name: self.name }); const env = { manager, control: self, object: self }; function createImageEntities() { if (!self.store.task) return; // Clear existing entities to prevent duplicates from React StrictMode double mounting self.imageEntities.clear(); const parsedValue = self.multiImage ? self.parsedValueList : self.parsedValue; const idPostfix = self.annotation ? `@${self.annotation.id}` : ""; if (Array.isArray(parsedValue)) { parsedValue.forEach((src, index) => { self.imageEntities.push({ id: `${self.name}#${index}${idPostfix}`, src, index, }); }); } else { self.imageEntities.push({ id: `${self.name}#0${idPostfix}`, src: parsedValue, index: 0, }); } self.setCurrentImage(0); } function afterAttach() { if (ff.isActive(FF_DEV_3391) && !self.annotation) { return; } if (self.selectioncontrol) manager.addTool("MoveTool", Tools.Selection.create({}, env), "MoveTool"); if (self.zoomcontrol) manager.addTool("ZoomPanTool", Tools.Zoom.create({}, env), "ZoomPanTool"); if (self.brightnesscontrol) manager.addTool("BrightnessTool", Tools.Brightness.create({}, env), "BrightnessTool"); if (self.contrastcontrol) manager.addTool("ContrastTool", Tools.Contrast.create({}, env), "ContrastTool"); if (self.rotatecontrol) manager.addTool("RotateTool", Tools.Rotate.create({}, env), "RotateTool"); createImageEntities(); } function afterResultCreated(region) { if (!region) return; if (region.classification) return; if (!self.multiImage) return; region.setItemIndex?.(self.currentImage); } function getToolsManager() { return manager; } return { afterAttach, getToolsManager, afterResultCreated, }; }) .extend((self) => { let skipInteractions = false; return { views: { getSkipInteractions() { if (isFF(FF_ZOOM_OPTIM)) { if (skipInteractions) return true; const isLinkingMode = self.annotation.isLinkingMode; if (isLinkingMode) return false; const manager = self.getToolsManager(); const tool = manager.findSelectedTool(); const canInteractWithRegions = tool?.canInteractWithRegions; return !canInteractWithRegions; } const manager = self.getToolsManager(); const isPanning = manager.findSelectedTool()?.toolName === "ZoomPanTool"; return skipInteractions || isPanning; }, get smoothingEnabled() { const names = self.annotation?.names; if (!names) return self.smoothing; const hasBitmask = Array.from(names.values()).some(({ type }) => { return type.includes("bitmask"); }); if (hasBitmask) return false; return self.smoothing; }, }, actions: { setSkipInteractions(value) { skipInteractions = value; }, updateSkipInteractions(e) { const currentTool = self.getToolsManager().findSelectedTool(); if (currentTool?.shouldSkipInteractions) { return self.setSkipInteractions(currentTool.shouldSkipInteractions(e)); } self.setSkipInteractions(e.evt && (e.evt.metaKey || e.evt.ctrlKey)); }, }, }; }) .actions((self) => ({ freezeHistory() { //self.annotation.history.freeze(); }, afterRegionSelected(region) { if (self.multiImage) { self.setCurrentImage(region.item_index); } }, createDrawingRegion(areaValue, resultValue, control, dynamic) { const controlTag = self.annotation.names.get(control.name); const result = { from_name: controlTag, to_name: self, type: control.resultType, value: resultValue, }; const areaRaw = { id: guidGenerator(), object: self, ...areaValue, results: [result], dynamic, item_index: self.currentImage, }; self.drawingRegion = areaRaw; return self.drawingRegion; }, deleteDrawingRegion() { const { drawingRegion } = self; if (!drawingRegion) return; self.drawingRegion = null; destroy(drawingRegion); }, setSelectionStart(point) { self.selectionArea.setStart(point); }, setSelectionEnd(point) { self.selectionArea.setEnd(point); }, resetSelection() { self.selectionArea.setStart(null); self.selectionArea.setEnd(null); }, updateBrushControl(arg) { self.brushControl = arg; }, updateBrushStrokeWidth(arg) { self.brushStrokeWidth = arg; }, /** * Update brightnessGrade of Image * @param {number} value */ setBrightnessGrade(value) { self.brightnessGrade = value; }, setContrastGrade(value) { self.contrastGrade = value; }, setGridSize(value) { self.gridsize = String(value); }, // an alias of setCurrentImage for making an interface reusable setCurrentItem(index = 0) { self.setCurrentImage(index); }, setCurrentImage(index = 0) { index = index ?? 0; if (index === self.currentImage) return; self.currentImage = index; self.currentImageEntity = self.findImageEntity(index); if (isFF(FF_LSDV_4583_6)) self.preloadImages(); }, preloadImages() { self.currentImageEntity.setImageLoaded(false); self.currentImageEntity.preload(); if (self.multiImage) { const [currentIndex, length] = [self.currentImage, self.imageEntities.length]; const prevSliceIndex = clamp(currentIndex - IMAGE_PRELOAD_COUNT, 0, currentIndex); const nextSliceIndex = clamp(currentIndex + 1 + IMAGE_PRELOAD_COUNT, currentIndex, length - 1); const images = [ ...self.imageEntities.slice(prevSliceIndex, currentIndex), ...self.imageEntities.slice(currentIndex + 1, nextSliceIndex), ]; images.forEach((imageEntity) => { imageEntity.preload(); }); } }, /** * Set pointer of X and Y */ setPointerPosition({ x, y }) { self.freezeHistory(); self.cursorPositionX = x; self.cursorPositionY = y; }, /** * Set zoom */ setZoom(scale) { scale = clamp(scale, 1, Number.POSITIVE_INFINITY); self.currentZoom = scale; // cool comment about all this stuff const maxScale = self.maxScale; const coverScale = self.coverScale; if (maxScale > 1) { // image < container if (scale < maxScale) { // scale = 1 or before stage size is max self.stageZoom = scale; // scale stage self.zoomScale = 1; // don't scale image } else { self.stageZoom = maxScale; // scale stage to max self.zoomScale = scale / maxScale; // scale image for the rest scale } } else { // image > container if (scale > maxScale) { // scale = 1 or any other zoom bigger then viewport self.stageZoom = maxScale; // stage squizzed self.zoomScale = scale; // scale image for the rest scale : scale image usually } else { // negative zoom bigger than image negative scale self.stageZoom = scale; // squize stage more self.zoomScale = 1; // don't scale image } } if (self.zoomScale > 1) { // zoomScale scales image above maxScale, so scale the rest of stage the same way const z = Math.min(maxScale * self.zoomScale, coverScale); if (self.containerWidth / self.naturalWidth > self.containerHeight / self.naturalHeight) { self.stageZoomX = z; self.stageZoomY = self.stageZoom; } else { self.stageZoomX = self.stageZoom; self.stageZoomY = z; } } else { self.stageZoomX = self.stageZoom; self.stageZoomY = self.stageZoom; } }, updateImageAfterZoom() { const { stageWidth, stageHeight } = self; self._recalculateImageParams(); if (stageWidth !== self.stageWidth || stageHeight !== self.stageHeight) { self._updateRegionsSizes({ width: self.stageWidth, height: self.stageHeight, naturalWidth: self.naturalWidth, naturalHeight: self.naturalHeight, }); } }, setZoomPosition(x, y) { const [width, height] = isFF(FF_DEV_3377) ? [self.canvasSize.width, self.canvasSize.height] : [self.containerWidth, self.containerHeight]; const [minX, minY] = [ width - self.stageComponentSize.width * self.zoomScale, height - self.stageComponentSize.height * self.zoomScale, ]; self.zoomingPositionX = clamp(x, minX, 0); self.zoomingPositionY = clamp(y, minY, 0); }, resetZoomPositionToCenter() { const { stageComponentSize, zoomScale } = self; const { width, height } = stageComponentSize; const [containerWidth, containerHeight] = isFF(FF_DEV_3377) ? [self.canvasSize.width, self.canvasSize.height] : [self.containerWidth, self.containerHeight]; self.setZoomPosition((containerWidth - width * zoomScale) / 2, (containerHeight - height * zoomScale) / 2); }, sizeToFit() { const { maxScale } = self; self.defaultzoom = "fit"; self.setZoom(maxScale); self.updateImageAfterZoom(); self.resetZoomPositionToCenter(); }, sizeToOriginal() { const { maxScale } = self; self.defaultzoom = "original"; self.setZoom(maxScale > 1 ? 1 : 1 / maxScale); self.updateImageAfterZoom(); self.resetZoomPositionToCenter(); }, sizeToAuto() { self.defaultzoom = "auto"; self.setZoom(1); self.updateImageAfterZoom(); self.resetZoomPositionToCenter(); }, getInertialZoom(val) { const invert = getRoot(self).settings.invertedZoom ? 1 : -1; // Invert the delta value so that: // - Pinch out (positive deltaY) zooms in // - Pinch in (negative deltaY) zooms out // - Scroll up (positive deltaY) zooms in // - Scroll down (negative deltaY) zooms out const invertedVal = val * invert; // Calculate the zoom change using exponential formula // This provides smooth zooming for both mouse wheel and trackpad pinch const zoomChange = Math.exp(invertedVal * ZOOM_INTENSITY); // Limit the maximum zoom change per event to prevent aggressive zooming // This prevents users from accidentally zooming too far with a single wheel event const limitedZoomChange = Math.max( 1 - MAX_ZOOM_CHANGE_PER_EVENT, Math.min(1 + MAX_ZOOM_CHANGE_PER_EVENT, zoomChange), ); return clamp(self.currentZoom * limitedZoomChange, MIN_ZOOM, MAX_ZOOM); }, /** * Handle zoom events from mouse wheel or trackpad pinch * Unified smooth zoom behavior that works well for both input methods * @param {number} val - The delta value from the wheel event * @param {Object} mouseRelativePos - The mouse position relative to the canvas */ handleZoom( val, mouseRelativePos = { x: self.canvasSize.width / 2, y: self.canvasSize.height / 2 }, isEvent = false, ) { if (val) { const zoomScale = isEvent ? self.getInertialZoom(val) : val > 0 ? self.currentZoom * self.zoomBy : self.currentZoom / self.zoomBy; // Handle negative zoom restrictions if (self.negativezoom !== true && zoomScale <= 1) { self.setZoom(1); self.setZoomPosition(0, 0); self.updateImageAfterZoom(); return; } // Handle zoom out to fit or smaller if (zoomScale <= 1) { self.setZoom(zoomScale); self.setZoomPosition(0, 0); self.updateImageAfterZoom(); return; } // Zoom to point (mouse position) - keeps the point under the cursor in the same position let stageScale = self.zoomScale; const mouseAbsolutePos = { x: (mouseRelativePos.x - self.zoomingPositionX) / stageScale, y: (mouseRelativePos.y - self.zoomingPositionY) / stageScale, }; self.setZoom(zoomScale); stageScale = self.zoomScale; const zoomingPosition = { x: -(mouseAbsolutePos.x - mouseRelativePos.x / stageScale) * stageScale, y: -(mouseAbsolutePos.y - mouseRelativePos.y / stageScale) * stageScale, }; self.setZoomPosition(zoomingPosition.x, zoomingPosition.y); self.updateImageAfterZoom(); } }, /** * Set mode of Image (drawing and viewing) * @param {string} mode */ setMode(mode) { self.mode = mode; }, setImageRef(ref) { self.imageRef = ref; }, setContainerRef(ref) { self.containerRef = ref; }, setStageRef(ref) { self.stageRef = ref; const currentTool = self.getToolsManager().findSelectedTool(); currentTool?.updateCursor?.(); }, setOverlayRef(ref) { self.overlayRef = ref; }, // @todo remove setSelected() { // self.selectedShape = shape; }, rotate(degree = -90) { self.rotation = (self.rotation + degree + 360) % 360; let ratioK = 1 / self.stageRatio; if (self.isSideways) { self.stageRatio = self.naturalWidth / self.naturalHeight; } else { self.stageRatio = 1; } ratioK = ratioK * self.stageRatio; self.setZoom(self.currentZoom); if (degree === -90) { this.setZoomPosition( self.zoomingPositionY * ratioK, self.stageComponentSize.height - self.zoomingPositionX * ratioK - self.stageComponentSize.height * self.zoomScale, ); } if (degree === 90) { this.setZoomPosition( self.stageComponentSize.width - self.zoomingPositionY * ratioK - self.stageComponentSize.width * self.zoomScale, self.zoomingPositionX * ratioK, ); } self.updateImageAfterZoom(); }, _recalculateImageParams() { self.stageWidth = isFF(FF_DEV_3377) ? self.naturalWidth * self.stageZoom : Math.round(self.naturalWidth * self.stageZoom); self.stageHeight = isFF(FF_DEV_3377) ? self.naturalHeight * self.stageZoom : Math.round(self.naturalHeight * self.stageZoom); }, _updateImageSize({ width, height, userResize }) { if (self.naturalWidth === undefined) { return; } if (width > 1 && height > 1) { const prevWidth = self.canvasSize.width; const prevHeight = self.canvasSize.height; const prevStageZoom = self.stageZoom; const prevZoomScale = self.zoomScale; self.containerWidth = width; self.containerHeight = height; // reinit zoom to calc stageW/H self.setZoom(self.currentZoom); self._recalculateImageParams(); const zoomChangeRatio = self.stageZoom / prevStageZoom; const scaleChangeRatio = self.zoomScale / prevZoomScale; const changeRatio = zoomChangeRatio * scaleChangeRatio; self.setZoomPosition( self.zoomingPositionX * changeRatio + (self.canvasSize.width / 2 - (prevWidth / 2) * changeRatio), self.zoomingPositionY * changeRatio + (self.canvasSize.height / 2 - (prevHeight / 2) * changeRatio), ); } self.sizeUpdated = true; self._updateRegionsSizes({ width: self.stageWidth, height: self.stageHeight, naturalWidth: self.naturalWidth, naturalHeight: self.naturalHeight, userResize, }); }, _updateRegionsSizes({ width, height, naturalWidth, naturalHeight, userResize }) { const _historyLength = self.annotation?.history?.history?.length; self.annotation.history.freeze(); self.regions.forEach((shape) => { shape.updateImageSize?.(width / naturalWidth, height / naturalHeight, width, height, userResize); }); self.regs.forEach((shape) => { shape.updateImageSize?.(width / naturalWidth, height / naturalHeight, width, height, userResize); }); self.drawingRegion?.updateImageSize(width / naturalWidth, height / naturalHeight, width, height, userResize); setTimeout(self.annotation.history.unfreeze, 0); //sometimes when user zoomed in, annotation was creating a new history. This fix that in case the user has nothing in the history yet if (_historyLength <= 1) { // Don't force unselection of regions during the updateObjects callback from history reinit setTimeout(() => self.annotation?.reinitHistory(false), 0); } }, updateImageSize(ev) { const { naturalWidth, naturalHeight } = self.imageRef ?? ev.target; const { offsetWidth, offsetHeight } = self.containerRef; self.naturalWidth = naturalWidth; self.naturalHeight = naturalHeight; self._updateImageSize({ width: offsetWidth, height: offsetHeight }); // after regions' sizes adjustment we have to reset all saved history changes // mobx do some batch update here, so we have to reset it asynchronously // this happens only after initial load, so it's safe self.setReady(true); if (self.defaultzoom === "fit") { self.sizeToFit(); } else { self.sizeToAuto(); } // Don't force unselection of regions during the updateObjects callback from history reinit setTimeout(() => self.annotation?.reinitHistory(false), 0); }, checkLabels() { // there should be at least one available label or none of them should be selected const labelStates = self.activeStates() || []; const selectedStates = self.getAvailableStates(); return selectedStates.length !== 0 || labelStates.length === 0; }, addShape(shape) { self.regions.push(shape); self.annotation.addRegion(shape); self.setSelected(shape.id); shape.selectRegion(); }, /** * Resize of image canvas * @param {*} width * @param {*} height */ onResize(width, height, userResize) { self._updateImageSize({ width, height, userResize }); }, event(name, ev, screenX, screenY) { const [canvasX, canvasY] = self.fixZoomedCoords([screenX, screenY]); const x = self.canvasToInternalX(canvasX); const y = self.canvasToInternalY(canvasY); self.getToolsManager().event(name, ev.evt || ev, x, y, canvasX, canvasY); }, })); const CoordsCalculations = types .model() .actions((self) => ({ // convert screen coords to image coords considering zoom fixZoomedCoords([x, y]) { if (!self.stageRef) { return [x, y]; } // good official way, but maybe a bit slower and with repeating cloning const p = self.stageRef.getAbsoluteTransform().copy().invert().point({ x, y }); return [p.x, p.y]; }, // convert image coords to screen coords considering zoom zoomOriginalCoords([x, y]) { const p = self.stageRef.getAbsoluteTransform().point({ x, y }); return [p.x, p.y]; }, /** * @typedef {number[]|{ x: number, y: number }} Point */ /** * @callback PointFn * @param {Point} point * @returns Point */ /** * Wrap point operations to convert zoomed coords from screen to image and back * Good for event handlers, receiving screen coords, but working with image coords * Accepts both [x, y] and {x, y} points; preserves this format * @param {PointFn} fn wrapped function do some math with image coords * @return {PointFn} outer function do some math with screen coords */ fixForZoom(fn) { return (p) => this.fixForZoomWrapper(p, fn); }, fixForZoomWrapper(p, fn) { const asArray = p.x === undefined; const [x, y] = self.fixZoomedCoords(asArray ? p : [p.x, p.y]); const modified = fn(asArray ? [x, y] : { x, y }); const zoomed = self.zoomOriginalCoords(asArray ? modified : [modified.x, modified.y]); return asArray ? zoomed : { x: zoomed[0], y: zoomed[1] }; }, })) // putting this transforms to views forces other getters to be recalculated on resize .views((self) => ({ // helps to calculate rotation because internal coords are square and real one usually aren't get whRatio() { // don't need this for absolute coords if (!isFF(FF_DEV_3793)) return 1; return self.stageWidth / self.stageHeight; }, // @todo scale? canvasToInternalX(n) { return (n / self.stageWidth) * RELATIVE_STAGE_WIDTH; }, canvasToInternalY(n) { return (n / self.stageHeight) * RELATIVE_STAGE_HEIGHT; }, internalToCanvasX(n) { return (n / RELATIVE_STAGE_WIDTH) * self.stageWidth; }, internalToCanvasY(n) { return (n / RELATIVE_STAGE_HEIGHT) * self.stageHeight; }, internalToImageX(n) { const { naturalWidth } = self.currentImageEntity; return (n / RELATIVE_STAGE_WIDTH) * naturalWidth; }, internalToImageY(n) { const { naturalHeight } = self.currentImageEntity; return (n / RELATIVE_STAGE_HEIGHT) * naturalHeight; }, imageToInternalX(n) { const { naturalWidth } = self.currentImageEntity; return (n / naturalWidth) * RELATIVE_STAGE_WIDTH; }, imageToInternalY(n) { const { naturalHeight } = self.currentImageEntity; return (n / naturalHeight) * RELATIVE_STAGE_HEIGHT; }, })); // mock coords calculations to transparently pass coords with FF 3793 off const AbsoluteCoordsCalculations = CoordsCalculations.views(() => ({ canvasToInternalX(n) { return n; }, canvasToInternalY(n) { return n; }, internalToCanvasX(n) { return n; }, internalToCanvasY(n) { return n; }, })); const ImageModel = types.compose( "ImageModel", TagAttrs, ObjectBase, ...(isFF(FF_LSDV_4583) ? [MultiItemObjectBase] : []), AnnotationMixin, IsReadyWithDepsMixin, ImageEntityMixin, Model, isFF(FF_DEV_3793) ? CoordsCalculations : AbsoluteCoordsCalculations, ); const HtxImage = inject("store")(ImageView); Registry.addTag("image", ImageModel, HtxImage); Registry.addObjectType(ImageModel); export { ImageModel, HtxImage };