From 613bcd2850545536791d0292976bcb76e577e504 Mon Sep 17 00:00:00 2001 From: Udhul <126940798+Udhul@users.noreply.github.com> Date: Thu, 26 Jun 2025 20:59:36 +0200 Subject: [PATCH 1/8] fix canvas overlay rendering --- app/frontend/static/js/canvasController.js | 41 ++++++++++------------ app/frontend/static/js/main.js | 4 ++- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/app/frontend/static/js/canvasController.js b/app/frontend/static/js/canvasController.js index 5523727..a87487f 100644 --- a/app/frontend/static/js/canvasController.js +++ b/app/frontend/static/js/canvasController.js @@ -207,13 +207,13 @@ class CanvasManager { applyCanvasTransform() { const t = `translate(${this.transform.panX}px, ${this.transform.panY}px) scale(${this.transform.scale})`; - [this.imageCanvas, this.predictionMaskCanvas, this.userInputCanvas].forEach(c => { - if (c) { - c.style.transformOrigin = 'top left'; - c.style.transform = t; - } - }); + if (this.imageCanvas) { + this.imageCanvas.style.transformOrigin = 'top left'; + this.imageCanvas.style.transform = t; + } this._dispatchEvent('zoom-pan-changed', { scale: this.transform.scale, panX: this.transform.panX, panY: this.transform.panY }); + this.drawUserInputLayer(); + this.drawPredictionMaskLayer(); } // --- Coordinate Transformation --- @@ -222,23 +222,23 @@ class CanvasManager { this.userInputCanvas.width === 0 || this.userInputCanvas.height === 0) return { x: 0, y: 0 }; const rect = this.userInputCanvas.getBoundingClientRect(); - // Normalize click coordinates to be relative to the canvas element const canvasX = (clientX - rect.left) * (this.userInputCanvas.width / rect.width); const canvasY = (clientY - rect.top) * (this.userInputCanvas.height / rect.height); - // Scale canvas coordinates to original image coordinates + const scale = this.displayScale * this.transform.scale; return { - x: canvasX / this.displayScale, - y: canvasY / this.displayScale + x: (canvasX - this.transform.panX) / scale, + y: (canvasY - this.transform.panY) / scale }; } _originalToDisplayCoords(originalX, originalY) { if (!this.originalImageWidth || !this.originalImageHeight || !this.userInputCanvas || this.userInputCanvas.width === 0 || this.userInputCanvas.height === 0) return { x: 0, y: 0 }; + const scale = this.displayScale * this.transform.scale; return { - x: originalX * this.displayScale, - y: originalY * this.displayScale + x: originalX * scale + this.transform.panX, + y: originalY * scale + this.transform.panY }; } @@ -515,13 +515,10 @@ class CanvasManager { if (this.mode === 'edit' && this.selectedLayerIds.length > 0) { op = this.selectedLayerIds.includes(l.layerId) ? 1.0 : FADED_MASK_OPACITY; } - const mask = (this.editingLayerId && l.layerId === this.editingLayerId && this.editingMask) - ? this.editingMask - : l.maskData; - const color = (this.editingLayerId && l.layerId === this.editingLayerId) - ? this.editingColor - : l.color; - if (mask) this._drawBinaryMask(mask, color, op); + const isEditing = this.editingLayerId && l.layerId === this.editingLayerId && this.editingMask; + const mask = isEditing ? this.editingMask : l.maskData; + const color = isEditing ? this.editingColor : l.color; + if (mask) this._drawBinaryMask(mask, color, op, isEditing); }); } @@ -893,7 +890,7 @@ class CanvasManager { } } - _drawBinaryMask(maskData, colorStr, opacity = 1.0) { + _drawBinaryMask(maskData, colorStr, opacity = 1.0, solid = false) { if (!maskData || !maskData.length || !maskData[0].length) return; const maskHeight = maskData.length; const maskWidth = maskData[0].length; @@ -909,7 +906,7 @@ class CanvasManager { const [r, g, b, a_int] = this._parseRgbaFromString(colorStr); const finalAlpha = Math.round(Math.min(1, Math.max(0, opacity)) * a_int); - const spacing = 6; // pixel spacing between hatch lines + const spacing = solid ? 1 : 4; // pixel spacing between hatch lines const lineWidth = 2; // hatch line thickness const isBorder = (mx, my) => { @@ -926,7 +923,7 @@ class CanvasManager { if (!maskData[y][x]) continue; const idx = (y * maskWidth + x) * 4; const border = isBorder(x, y); - const drawPixel = border || ((x + y) % spacing < lineWidth); + const drawPixel = border || solid || ((x + y) % spacing < lineWidth); if (drawPixel) { pixelData[idx] = r; pixelData[idx + 1] = g; diff --git a/app/frontend/static/js/main.js b/app/frontend/static/js/main.js index b26fc44..4cf4c82 100644 --- a/app/frontend/static/js/main.js +++ b/app/frontend/static/js/main.js @@ -613,6 +613,8 @@ document.addEventListener("DOMContentLoaded", () => { canvasManager.loadImageOntoCanvas(imageElement, width, height, filename); const hadState = !!canvasStateCache[imageHash]; restoreCanvasState(imageHash); + canvasManager.setManualPredictions(null); + canvasManager.setAutomaskPredictions(null); layerViewController && layerViewController.setSelectedLayers([]); if (editModeController) editModeController.endEdit(); canvasManager.setMode("creation"); @@ -1125,7 +1127,7 @@ document.addEventListener("DOMContentLoaded", () => { canvasManager.clearAllCanvasInputs(false); canvasManager.setManualPredictions(null); canvasManager.setAutomaskPredictions(null); - canvasManager.setMode("edit"); + canvasManager.setMode("creation"); } if (commitMasksBtn && !commitMasksBtn.dataset.listenerAttached) { From fd02cf9dbfffa6383b4287a2d56b84a02220e654 Mon Sep 17 00:00:00 2001 From: Udhul <126940798+Udhul@users.noreply.github.com> Date: Thu, 26 Jun 2025 21:12:29 +0200 Subject: [PATCH 2/8] fix overlay redraw and remove prediction caching --- app/frontend/static/js/canvasController.js | 39 +++++++++------------- app/frontend/static/js/main.js | 8 ++--- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/app/frontend/static/js/canvasController.js b/app/frontend/static/js/canvasController.js index a87487f..a86d778 100644 --- a/app/frontend/static/js/canvasController.js +++ b/app/frontend/static/js/canvasController.js @@ -211,6 +211,12 @@ class CanvasManager { this.imageCanvas.style.transformOrigin = 'top left'; this.imageCanvas.style.transform = t; } + if (this.predictionMaskCanvas) { + this.predictionMaskCanvas.style.transform = 'none'; + } + if (this.userInputCanvas) { + this.userInputCanvas.style.transform = 'none'; + } this._dispatchEvent('zoom-pan-changed', { scale: this.transform.scale, panX: this.transform.panX, panY: this.transform.panY }); this.drawUserInputLayer(); this.drawPredictionMaskLayer(); @@ -747,8 +753,8 @@ class CanvasManager { if (newScale > maxScale) newScale = maxScale; const scaleRatio = newScale / prevScale; - this.transform.panX += offsetX * (1 - scaleRatio); - this.transform.panY += offsetY * (1 - scaleRatio); + this.transform.panX -= (offsetX - this.transform.panX) * (scaleRatio - 1); + this.transform.panY -= (offsetY - this.transform.panY) * (scaleRatio - 1); this.transform.scale = newScale; this._clampPan(); @@ -1064,37 +1070,24 @@ class CanvasManager { exportState() { return { - points: JSON.parse(JSON.stringify(this.userPoints)), - boxes: JSON.parse(JSON.stringify(this.userBoxes)), - drawnMasks: JSON.parse(JSON.stringify(this.userDrawnMasks)), - maskInput: this.combinedUserMaskInput256 ? JSON.parse(JSON.stringify(this.combinedUserMaskInput256)) : null, - manualPredictions: JSON.parse(JSON.stringify(this.manualPredictions)), - automaskPredictions: JSON.parse(JSON.stringify(this.automaskPredictions)), - selectedManualMaskIndex: this.selectedManualMaskIndex, - currentPredictionMultiBox: this.currentPredictionMultiBox, layers: JSON.parse(JSON.stringify(this.layers)), selectedLayerIds: JSON.parse(JSON.stringify(this.selectedLayerIds)), - mode: this.mode + mode: this.mode, + transform: { ...this.transform } }; } importState(state) { if (!state) return; - this.userPoints = state.points || []; - this.userBoxes = state.boxes || []; - this.currentBox = null; - this.userDrawnMasks = state.drawnMasks || []; - this.combinedUserMaskInput256 = state.maskInput || null; - this.manualPredictions = state.manualPredictions || []; - this.automaskPredictions = state.automaskPredictions || []; - this.selectedManualMaskIndex = state.selectedManualMaskIndex || 0; - this.currentPredictionMultiBox = state.currentPredictionMultiBox || false; this.layers = state.layers || []; this.selectedLayerIds = state.selectedLayerIds || []; this.mode = state.mode || 'edit'; - if (this.userDrawnMasks.length > 0) this._prepareCombinedUserMaskInput(); - this.drawUserInputLayer(); - this.drawPredictionMaskLayer(); + if (state.transform) { + this.transform = { ...this.transform, ...state.transform }; + this.applyCanvasTransform(); + } else { + this.drawPredictionMaskLayer(); + } } startMaskEdit(layerId, maskData, color) { diff --git a/app/frontend/static/js/main.js b/app/frontend/static/js/main.js index 4cf4c82..f40b2f8 100644 --- a/app/frontend/static/js/main.js +++ b/app/frontend/static/js/main.js @@ -39,7 +39,6 @@ document.addEventListener("DOMContentLoaded", () => { const uiManager = new UIManager(); const canvasManager = new CanvasManager(); - const canvasStateCache = {}; let imageLayerCache = {}; let projectTagList = []; let layerTagDebouncers = {}; @@ -367,13 +366,11 @@ document.addEventListener("DOMContentLoaded", () => { } function saveCanvasState(hash) { - if (!hash) return; - canvasStateCache[hash] = canvasManager.exportState(); + // State caching of prediction inputs was removed } function restoreCanvasState(hash) { - const state = canvasStateCache[hash]; - if (state) canvasManager.importState(state); + // Previously saved canvas state is ignored } function syncLayerCache(hash) { @@ -611,7 +608,6 @@ document.addEventListener("DOMContentLoaded", () => { const imageElement = new Image(); imageElement.onload = () => { canvasManager.loadImageOntoCanvas(imageElement, width, height, filename); - const hadState = !!canvasStateCache[imageHash]; restoreCanvasState(imageHash); canvasManager.setManualPredictions(null); canvasManager.setAutomaskPredictions(null); From 7893516f7b9e5a9201f545369afc3c66de44515c Mon Sep 17 00:00:00 2001 From: Udhul <126940798+Udhul@users.noreply.github.com> Date: Thu, 26 Jun 2025 21:46:11 +0200 Subject: [PATCH 3/8] fix overlay mask alignment with zoom --- app/frontend/static/js/canvasController.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/app/frontend/static/js/canvasController.js b/app/frontend/static/js/canvasController.js index a86d778..6048ef1 100644 --- a/app/frontend/static/js/canvasController.js +++ b/app/frontend/static/js/canvasController.js @@ -577,11 +577,12 @@ class CanvasManager { if (pixelCount > 0) { this.tempMaskPixelCtx.putImageData(imageData, 0, 0); - // Draw the processed mask (at original resolution) onto the offscreen canvas, - // scaling it down to the display size. - this.offscreenPredictionCtx.drawImage(this.tempMaskPixelCanvas, 0, 0, - this.offscreenPredictionCanvas.width, - this.offscreenPredictionCanvas.height); + const scale = this.displayScale * this.transform.scale; + this.offscreenPredictionCtx.save(); + this.offscreenPredictionCtx.imageSmoothingEnabled = false; + this.offscreenPredictionCtx.setTransform(scale, 0, 0, scale, this.transform.panX, this.transform.panY); + this.offscreenPredictionCtx.drawImage(this.tempMaskPixelCanvas, 0, 0); + this.offscreenPredictionCtx.restore(); } }); } @@ -940,8 +941,14 @@ class CanvasManager { } this.tempMaskPixelCtx.putImageData(imageData, 0, 0); - this.offscreenPredictionCtx.drawImage(this.tempMaskPixelCanvas, 0, 0, - this.offscreenPredictionCanvas.width, this.offscreenPredictionCanvas.height); + + const scale = this.displayScale * this.transform.scale; + this.offscreenPredictionCtx.save(); + this.offscreenPredictionCtx.imageSmoothingEnabled = false; + this.offscreenPredictionCtx.globalAlpha = 1.0; // opacity already baked into alpha + this.offscreenPredictionCtx.setTransform(scale, 0, 0, scale, this.transform.panX, this.transform.panY); + this.offscreenPredictionCtx.drawImage(this.tempMaskPixelCanvas, 0, 0); + this.offscreenPredictionCtx.restore(); } _dispatchEvent(eventType, data) { From 29ce7c0030733a7fff30e4ca9b8772d679a0f295 Mon Sep 17 00:00:00 2001 From: Udhul <126940798+Udhul@users.noreply.github.com> Date: Thu, 26 Jun 2025 22:19:13 +0200 Subject: [PATCH 4/8] Fix overlay cropping by scaling offscreen canvases --- app/frontend/static/js/canvasController.js | 89 ++++++++++++++++------ 1 file changed, 66 insertions(+), 23 deletions(-) diff --git a/app/frontend/static/js/canvasController.js b/app/frontend/static/js/canvasController.js index 6048ef1..dbdd131 100644 --- a/app/frontend/static/js/canvasController.js +++ b/app/frontend/static/js/canvasController.js @@ -205,6 +205,20 @@ class CanvasManager { // It will be resized in drawPredictionMaskLayer if needed. } + resizeOffscreenForTransform() { + if (!this.imageCanvas) return; + const scaledW = Math.ceil(this.imageCanvas.width * this.transform.scale); + const scaledH = Math.ceil(this.imageCanvas.height * this.transform.scale); + if (this.offscreenPredictionCanvas.width !== scaledW || this.offscreenPredictionCanvas.height !== scaledH) { + this.offscreenPredictionCanvas.width = scaledW; + this.offscreenPredictionCanvas.height = scaledH; + } + if (this.offscreenUserCanvas.width !== scaledW || this.offscreenUserCanvas.height !== scaledH) { + this.offscreenUserCanvas.width = scaledW; + this.offscreenUserCanvas.height = scaledH; + } + } + applyCanvasTransform() { const t = `translate(${this.transform.panX}px, ${this.transform.panY}px) scale(${this.transform.scale})`; if (this.imageCanvas) { @@ -217,6 +231,7 @@ class CanvasManager { if (this.userInputCanvas) { this.userInputCanvas.style.transform = 'none'; } + this.resizeOffscreenForTransform(); this._dispatchEvent('zoom-pan-changed', { scale: this.transform.scale, panX: this.transform.panX, panY: this.transform.panY }); this.drawUserInputLayer(); this.drawPredictionMaskLayer(); @@ -238,14 +253,18 @@ class CanvasManager { }; } - _originalToDisplayCoords(originalX, originalY) { + _originalToViewportCoords(originalX, originalY) { if (!this.originalImageWidth || !this.originalImageHeight || !this.userInputCanvas || this.userInputCanvas.width === 0 || this.userInputCanvas.height === 0) return { x: 0, y: 0 }; const scale = this.displayScale * this.transform.scale; - return { - x: originalX * scale + this.transform.panX, - y: originalY * scale + this.transform.panY - }; + return { x: originalX * scale, y: originalY * scale }; + } + + _originalToDisplayCoords(originalX, originalY) { + if (!this.originalImageWidth || !this.originalImageHeight || !this.userInputCanvas || + this.userInputCanvas.width === 0 || this.userInputCanvas.height === 0) return { x: 0, y: 0 }; + const pv = this._originalToViewportCoords(originalX, originalY); + return { x: pv.x + this.transform.panX, y: pv.y + this.transform.panY }; } getZoomedDisplayScale() { @@ -437,17 +456,17 @@ class CanvasManager { this.offscreenUserCtx.clearRect(0, 0, this.offscreenUserCanvas.width, this.offscreenUserCanvas.height); - const pointDisplayRadius = Math.max(2, 5 * this.displayScale); // Scale point radius slightly - const lineDisplayWidth = Math.max(1, 2 * this.displayScale); // Scale line width + const pointDisplayRadius = Math.max(2, 5 * this.displayScale * this.transform.scale); + const lineDisplayWidth = Math.max(1, 2 * this.displayScale * this.transform.scale); // Draw drawn polygons (lassos) this.userDrawnMasks.forEach(mask => { if (mask.points.length < 3) return; this.offscreenUserCtx.beginPath(); - const firstP_disp = this._originalToDisplayCoords(mask.points[0].x, mask.points[0].y); + const firstP_disp = this._originalToViewportCoords(mask.points[0].x, mask.points[0].y); this.offscreenUserCtx.moveTo(firstP_disp.x, firstP_disp.y); for (let i = 1; i < mask.points.length; i++) { - const p_disp = this._originalToDisplayCoords(mask.points[i].x, mask.points[i].y); + const p_disp = this._originalToViewportCoords(mask.points[i].x, mask.points[i].y); this.offscreenUserCtx.lineTo(p_disp.x, p_disp.y); } this.offscreenUserCtx.closePath(); @@ -461,10 +480,10 @@ class CanvasManager { // Draw current lasso drawing in progress if (this.isDrawingLasso && this.currentLassoPoints.length > 0) { this.offscreenUserCtx.beginPath(); - const firstP_disp = this._originalToDisplayCoords(this.currentLassoPoints[0].x, this.currentLassoPoints[0].y); + const firstP_disp = this._originalToViewportCoords(this.currentLassoPoints[0].x, this.currentLassoPoints[0].y); this.offscreenUserCtx.moveTo(firstP_disp.x, firstP_disp.y); for (let i = 1; i < this.currentLassoPoints.length; i++) { - const p_disp = this._originalToDisplayCoords(this.currentLassoPoints[i].x, this.currentLassoPoints[i].y); + const p_disp = this._originalToViewportCoords(this.currentLassoPoints[i].x, this.currentLassoPoints[i].y); this.offscreenUserCtx.lineTo(p_disp.x, p_disp.y); } this.offscreenUserCtx.strokeStyle = 'rgba(255, 223, 0, 0.95)'; @@ -474,7 +493,7 @@ class CanvasManager { // Draw points this.userPoints.forEach(p_orig => { - const dp = this._originalToDisplayCoords(p_orig.x, p_orig.y); + const dp = this._originalToViewportCoords(p_orig.x, p_orig.y); this.offscreenUserCtx.beginPath(); this.offscreenUserCtx.arc(dp.x, dp.y, pointDisplayRadius, 0, 2 * Math.PI); this.offscreenUserCtx.fillStyle = p_orig.label === 1 ? 'rgba(50, 205, 50, 0.8)' : 'rgba(255, 69, 0, 0.8)'; // LimeGreen/OrangeRed @@ -487,8 +506,8 @@ class CanvasManager { // Draw boxes [...this.userBoxes, this.currentBox].forEach(box => { if (!box) return; - const db1 = this._originalToDisplayCoords(box.x1, box.y1); - const db2 = this._originalToDisplayCoords(box.x2, box.y2); + const db1 = this._originalToViewportCoords(box.x1, box.y1); + const db2 = this._originalToViewportCoords(box.x2, box.y2); this.offscreenUserCtx.strokeStyle = 'rgba(30, 144, 255, 0.85)'; // DodgerBlue this.offscreenUserCtx.lineWidth = lineDisplayWidth; this.offscreenUserCtx.strokeRect(db1.x, db1.y, db2.x - db1.x, db2.y - db1.y); @@ -501,7 +520,18 @@ class CanvasManager { // Composite to visible canvas this.userCtx.clearRect(0, 0, this.userInputCanvas.width, this.userInputCanvas.height); this.userCtx.globalAlpha = this.userInputOpacitySlider ? parseFloat(this.userInputOpacitySlider.value) : 0.8; - this.userCtx.drawImage(this.offscreenUserCanvas, 0, 0); + this.userCtx.imageSmoothingEnabled = false; + this.userCtx.drawImage( + this.offscreenUserCanvas, + -this.transform.panX, + -this.transform.panY, + this.userInputCanvas.width, + this.userInputCanvas.height, + 0, + 0, + this.userInputCanvas.width, + this.userInputCanvas.height + ); this.userCtx.globalAlpha = 1.0; } @@ -578,10 +608,11 @@ class CanvasManager { if (pixelCount > 0) { this.tempMaskPixelCtx.putImageData(imageData, 0, 0); const scale = this.displayScale * this.transform.scale; + const scaledW = maskWidth * scale; + const scaledH = maskHeight * scale; this.offscreenPredictionCtx.save(); this.offscreenPredictionCtx.imageSmoothingEnabled = false; - this.offscreenPredictionCtx.setTransform(scale, 0, 0, scale, this.transform.panX, this.transform.panY); - this.offscreenPredictionCtx.drawImage(this.tempMaskPixelCanvas, 0, 0); + this.offscreenPredictionCtx.drawImage(this.tempMaskPixelCanvas, 0, 0, scaledW, scaledH); this.offscreenPredictionCtx.restore(); } }); @@ -591,7 +622,18 @@ class CanvasManager { this.predictionCtx.clearRect(0, 0, this.predictionMaskCanvas.width, this.predictionMaskCanvas.height); const opacity = this.predictionOpacitySlider ? parseFloat(this.predictionOpacitySlider.value) : 0.7; this.predictionCtx.globalAlpha = opacity; - this.predictionCtx.drawImage(this.offscreenPredictionCanvas, 0, 0); + this.predictionCtx.imageSmoothingEnabled = false; + this.predictionCtx.drawImage( + this.offscreenPredictionCanvas, + -this.transform.panX, + -this.transform.panY, + this.predictionMaskCanvas.width, + this.predictionMaskCanvas.height, + 0, + 0, + this.predictionMaskCanvas.width, + this.predictionMaskCanvas.height + ); this.predictionCtx.globalAlpha = 1.0; } @@ -943,11 +985,12 @@ class CanvasManager { this.tempMaskPixelCtx.putImageData(imageData, 0, 0); const scale = this.displayScale * this.transform.scale; + const scaledW = maskWidth * scale; + const scaledH = maskHeight * scale; this.offscreenPredictionCtx.save(); this.offscreenPredictionCtx.imageSmoothingEnabled = false; - this.offscreenPredictionCtx.globalAlpha = 1.0; // opacity already baked into alpha - this.offscreenPredictionCtx.setTransform(scale, 0, 0, scale, this.transform.panX, this.transform.panY); - this.offscreenPredictionCtx.drawImage(this.tempMaskPixelCanvas, 0, 0); + this.offscreenPredictionCtx.globalAlpha = 1.0; // opacity baked in alpha + this.offscreenPredictionCtx.drawImage(this.tempMaskPixelCanvas, 0, 0, scaledW, scaledH); this.offscreenPredictionCtx.restore(); } @@ -1165,10 +1208,10 @@ class CanvasManager { this.offscreenUserCtx.clearRect(0, 0, this.offscreenUserCanvas.width, this.offscreenUserCanvas.height); if (points && points.length > 0) { this.offscreenUserCtx.beginPath(); - const first = this._originalToDisplayCoords(points[0].x, points[0].y); + const first = this._originalToViewportCoords(points[0].x, points[0].y); this.offscreenUserCtx.moveTo(first.x, first.y); for (let i = 1; i < points.length; i++) { - const p = this._originalToDisplayCoords(points[i].x, points[i].y); + const p = this._originalToViewportCoords(points[i].x, points[i].y); this.offscreenUserCtx.lineTo(p.x, p.y); } this.offscreenUserCtx.strokeStyle = 'rgba(255,223,0,0.95)'; From 55753f61ccd40c6c47bb42bb77f1f67ab0bd5a9e Mon Sep 17 00:00:00 2001 From: Udhul <126940798+Udhul@users.noreply.github.com> Date: Thu, 26 Jun 2025 22:52:09 +0200 Subject: [PATCH 5/8] fix: align overlay cropping with zoom --- app/frontend/static/js/canvasController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend/static/js/canvasController.js b/app/frontend/static/js/canvasController.js index dbdd131..13570c4 100644 --- a/app/frontend/static/js/canvasController.js +++ b/app/frontend/static/js/canvasController.js @@ -220,7 +220,7 @@ class CanvasManager { } applyCanvasTransform() { - const t = `translate(${this.transform.panX}px, ${this.transform.panY}px) scale(${this.transform.scale})`; + const t = `scale(${this.transform.scale}) translate(${this.transform.panX}px, ${this.transform.panY}px)`; if (this.imageCanvas) { this.imageCanvas.style.transformOrigin = 'top left'; this.imageCanvas.style.transform = t; From f48f68eae68c463cc19b0889652b3ae8d8526f87 Mon Sep 17 00:00:00 2001 From: Udhul <126940798+Udhul@users.noreply.github.com> Date: Thu, 26 Jun 2025 23:14:42 +0200 Subject: [PATCH 6/8] fix overlay transform order --- app/frontend/static/js/canvasController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend/static/js/canvasController.js b/app/frontend/static/js/canvasController.js index 13570c4..dbdd131 100644 --- a/app/frontend/static/js/canvasController.js +++ b/app/frontend/static/js/canvasController.js @@ -220,7 +220,7 @@ class CanvasManager { } applyCanvasTransform() { - const t = `scale(${this.transform.scale}) translate(${this.transform.panX}px, ${this.transform.panY}px)`; + const t = `translate(${this.transform.panX}px, ${this.transform.panY}px) scale(${this.transform.scale})`; if (this.imageCanvas) { this.imageCanvas.style.transformOrigin = 'top left'; this.imageCanvas.style.transform = t; From 50233490b287dfa48dddb3e7054f8316d6dc25e7 Mon Sep 17 00:00:00 2001 From: Udhul <126940798+Udhul@users.noreply.github.com> Date: Fri, 27 Jun 2025 06:30:36 +0200 Subject: [PATCH 7/8] fix overlay cropping with correct transform order --- app/frontend/static/js/canvasController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend/static/js/canvasController.js b/app/frontend/static/js/canvasController.js index dbdd131..13570c4 100644 --- a/app/frontend/static/js/canvasController.js +++ b/app/frontend/static/js/canvasController.js @@ -220,7 +220,7 @@ class CanvasManager { } applyCanvasTransform() { - const t = `translate(${this.transform.panX}px, ${this.transform.panY}px) scale(${this.transform.scale})`; + const t = `scale(${this.transform.scale}) translate(${this.transform.panX}px, ${this.transform.panY}px)`; if (this.imageCanvas) { this.imageCanvas.style.transformOrigin = 'top left'; this.imageCanvas.style.transform = t; From 8924399357a5dcf38526dee6fa48302b1e24ec65 Mon Sep 17 00:00:00 2001 From: Udhul <126940798+Udhul@users.noreply.github.com> Date: Fri, 27 Jun 2025 17:48:24 +0200 Subject: [PATCH 8/8] fix overlay transform --- app/frontend/static/js/canvasController.js | 71 +++++++--------------- 1 file changed, 22 insertions(+), 49 deletions(-) diff --git a/app/frontend/static/js/canvasController.js b/app/frontend/static/js/canvasController.js index 13570c4..4f9200c 100644 --- a/app/frontend/static/js/canvasController.js +++ b/app/frontend/static/js/canvasController.js @@ -205,19 +205,9 @@ class CanvasManager { // It will be resized in drawPredictionMaskLayer if needed. } - resizeOffscreenForTransform() { - if (!this.imageCanvas) return; - const scaledW = Math.ceil(this.imageCanvas.width * this.transform.scale); - const scaledH = Math.ceil(this.imageCanvas.height * this.transform.scale); - if (this.offscreenPredictionCanvas.width !== scaledW || this.offscreenPredictionCanvas.height !== scaledH) { - this.offscreenPredictionCanvas.width = scaledW; - this.offscreenPredictionCanvas.height = scaledH; - } - if (this.offscreenUserCanvas.width !== scaledW || this.offscreenUserCanvas.height !== scaledH) { - this.offscreenUserCanvas.width = scaledW; - this.offscreenUserCanvas.height = scaledH; - } - } + // Offscreen canvases match the base display size of the image. They are not + // resized on zoom to avoid cropping issues when panning. They will be sized + // in `drawImageLayer` whenever the image or display area changes. applyCanvasTransform() { const t = `scale(${this.transform.scale}) translate(${this.transform.panX}px, ${this.transform.panY}px)`; @@ -231,7 +221,6 @@ class CanvasManager { if (this.userInputCanvas) { this.userInputCanvas.style.transform = 'none'; } - this.resizeOffscreenForTransform(); this._dispatchEvent('zoom-pan-changed', { scale: this.transform.scale, panX: this.transform.panX, panY: this.transform.panY }); this.drawUserInputLayer(); this.drawPredictionMaskLayer(); @@ -256,7 +245,7 @@ class CanvasManager { _originalToViewportCoords(originalX, originalY) { if (!this.originalImageWidth || !this.originalImageHeight || !this.userInputCanvas || this.userInputCanvas.width === 0 || this.userInputCanvas.height === 0) return { x: 0, y: 0 }; - const scale = this.displayScale * this.transform.scale; + const scale = this.displayScale; return { x: originalX * scale, y: originalY * scale }; } @@ -456,8 +445,8 @@ class CanvasManager { this.offscreenUserCtx.clearRect(0, 0, this.offscreenUserCanvas.width, this.offscreenUserCanvas.height); - const pointDisplayRadius = Math.max(2, 5 * this.displayScale * this.transform.scale); - const lineDisplayWidth = Math.max(1, 2 * this.displayScale * this.transform.scale); + const pointDisplayRadius = Math.max(2, 5 * this.displayScale); + const lineDisplayWidth = Math.max(1, 2 * this.displayScale); // Draw drawn polygons (lassos) this.userDrawnMasks.forEach(mask => { @@ -517,22 +506,15 @@ class CanvasManager { this.offscreenUserCtx.strokeRect(db1.x, db1.y, db2.x - db1.x, db2.y - db1.y); }); - // Composite to visible canvas + // Composite to visible canvas using the current zoom/pan transform + this.userCtx.setTransform(1, 0, 0, 1, 0, 0); this.userCtx.clearRect(0, 0, this.userInputCanvas.width, this.userInputCanvas.height); + this.userCtx.save(); + this.userCtx.setTransform(this.transform.scale, 0, 0, this.transform.scale, this.transform.panX, this.transform.panY); this.userCtx.globalAlpha = this.userInputOpacitySlider ? parseFloat(this.userInputOpacitySlider.value) : 0.8; this.userCtx.imageSmoothingEnabled = false; - this.userCtx.drawImage( - this.offscreenUserCanvas, - -this.transform.panX, - -this.transform.panY, - this.userInputCanvas.width, - this.userInputCanvas.height, - 0, - 0, - this.userInputCanvas.width, - this.userInputCanvas.height - ); - this.userCtx.globalAlpha = 1.0; + this.userCtx.drawImage(this.offscreenUserCanvas, 0, 0); + this.userCtx.restore(); } drawPredictionMaskLayer() { @@ -607,9 +589,8 @@ class CanvasManager { if (pixelCount > 0) { this.tempMaskPixelCtx.putImageData(imageData, 0, 0); - const scale = this.displayScale * this.transform.scale; - const scaledW = maskWidth * scale; - const scaledH = maskHeight * scale; + const scaledW = maskWidth * this.displayScale; + const scaledH = maskHeight * this.displayScale; this.offscreenPredictionCtx.save(); this.offscreenPredictionCtx.imageSmoothingEnabled = false; this.offscreenPredictionCtx.drawImage(this.tempMaskPixelCanvas, 0, 0, scaledW, scaledH); @@ -618,23 +599,16 @@ class CanvasManager { }); } - // Composite to visible prediction canvas + // Composite to visible prediction canvas using the current transform + this.predictionCtx.setTransform(1, 0, 0, 1, 0, 0); this.predictionCtx.clearRect(0, 0, this.predictionMaskCanvas.width, this.predictionMaskCanvas.height); + this.predictionCtx.save(); + this.predictionCtx.setTransform(this.transform.scale, 0, 0, this.transform.scale, this.transform.panX, this.transform.panY); const opacity = this.predictionOpacitySlider ? parseFloat(this.predictionOpacitySlider.value) : 0.7; this.predictionCtx.globalAlpha = opacity; this.predictionCtx.imageSmoothingEnabled = false; - this.predictionCtx.drawImage( - this.offscreenPredictionCanvas, - -this.transform.panX, - -this.transform.panY, - this.predictionMaskCanvas.width, - this.predictionMaskCanvas.height, - 0, - 0, - this.predictionMaskCanvas.width, - this.predictionMaskCanvas.height - ); - this.predictionCtx.globalAlpha = 1.0; + this.predictionCtx.drawImage(this.offscreenPredictionCanvas, 0, 0); + this.predictionCtx.restore(); } // --- User Interaction Handlers --- @@ -984,9 +958,8 @@ class CanvasManager { this.tempMaskPixelCtx.putImageData(imageData, 0, 0); - const scale = this.displayScale * this.transform.scale; - const scaledW = maskWidth * scale; - const scaledH = maskHeight * scale; + const scaledW = maskWidth * this.displayScale; + const scaledH = maskHeight * this.displayScale; this.offscreenPredictionCtx.save(); this.offscreenPredictionCtx.imageSmoothingEnabled = false; this.offscreenPredictionCtx.globalAlpha = 1.0; // opacity baked in alpha