Zoom and Translate an Image from the Mouse Location

Zoom and translate an Image from the mouse location

A few suggestions and a couple of tricks.

Not exactly tricks, just some methods to speed up the calculations when more than one graphic transformation is in place.

  1. Divide and conquer: split the different graphics effects and transformations in different, specialized, methods that do one thing. Then design in a way that makes it possible for these methods to work together when needed.

  2. Keep it simple: when Graphics objects need to accumulate more than a couple of transformations, the order in which Matrices are stacked can cause misunderstandings. It's simpler (and less prone to generate weird outcomes) to calculate some generic transformations (translate and scale, mostly) beforehand, then let GDI+ render already pre-cooked objects and shapes.

    Here, only Matrix.RotateAt and Matrix.Multiply are used.

    Some notes about Matrix transformations here: Flip the GraphicsPath

  3. Use the right tools: for example, a Panel used as canvas is not exactly the best choice. This Control is not double-buffered; this feature can be enabled, but the Panel class is not meant for drawing, while a PictureBox (or a non-System flat Label) supports it on its own.

    Some more notes here: How to apply a fade transition effect to Images

The sample code shows 4 zoom methods, plus generates rotation transformations (which work side-by-side, don't accumulate).

The Zoom modes are selected using an enumerator (private enum ZoomMode):

Zoom modes:

  • ImageLocation: Image scaling is performed in-place, keeping the current Location on the canvas in a fixed position.
  • CenterCanvas: while the Image is scaled, it remains centered on the Canvas.
  • CenterMouse: the Image is scaled and translated to center itself on the current Mouse location on the Canvas.
  • MouseOffset: the Image is scaled and translated to maintain a relative position determined by the initial location of the Mouse pointer on the Image itself.

You can notice that the code simplifies all the calculations, applying translations exclusively relative to the Rectangle that defines the current Image bounds and only in relation to the Location of this shape.

The Rectangle is only scaled when the calculation needs to preemptively determine what the Image size will be after the Mouse Wheel has generated the next Zoom factor.

Visual sample of the implemented functionalities:

GDI+ Zoom and Rotations Samples

Sample code:

  • canvas is the Custom Control, derived from PictureBox (you can find its definition at the bottom). This control is added to the Form in code, here. Modify as needed.
  • trkRotationAngle is the TrackBar used to define the current rotation of the Image. Add this control to the Form in the designer.
  • radZoom_CheckedChanged is the event handler of all the RadioButtons used to set the current Zoom Mode. The value these Controls set is assigned in their Tag property. Add these controls to the Form in the designer.


using System.Drawing;
using System.Drawing.Drawing2D;
using System.IO;
using System.Windows.Forms;

public partial class frmZoomPaint : Form
{
private float rotationAngle = 0.0f;
private float zoomFactor = 1.0f;
private float zoomStep = .05f;

private RectangleF imageRect = RectangleF.Empty;
private PointF imageLocation = PointF.Empty;
private PointF mouseLocation = PointF.Empty;

private Bitmap drawingImage = null;
private PictureBoxEx canvas = null;
private ZoomMode zoomMode = ZoomMode.ImageLocation;

private enum ZoomMode
{
ImageLocation,
CenterCanvas,
CenterMouse,
MouseOffset
}

public frmZoomPaint()
{
InitializeComponent();
string imagePath = [Path of the Image];
drawingImage = (Bitmap)Image.FromStream(new MemoryStream(File.ReadAllBytes(imagePath)));
imageRect = new RectangleF(Point.Empty, drawingImage.Size);

canvas = new PictureBoxEx(new Size(555, 300));
canvas.Location = new Point(10, 10);
canvas.MouseWheel += canvas_MouseWheel;
canvas.MouseMove += canvas_MouseMove;
canvas.MouseDown += canvas_MouseDown;
canvas.MouseUp += canvas_MouseUp;
canvas.Paint += canvas_Paint;
Controls.Add(canvas);
}

private void canvas_MouseWheel(object sender, MouseEventArgs e)
{
mouseLocation = e.Location;
float zoomCurrent = zoomFactor;
zoomFactor += e.Delta > 0 ? zoomStep : -zoomStep;
if (zoomFactor < .10f) zoomStep = .01f;
if (zoomFactor >= .10f) zoomStep = .05f;
if (zoomFactor < .0f) zoomFactor = zoomStep;

switch (zoomMode) {
case ZoomMode.CenterCanvas:
imageRect = CenterScaledRectangleOnCanvas(imageRect, canvas.ClientRectangle);
break;
case ZoomMode.CenterMouse:
imageRect = CenterScaledRectangleOnMousePosition(imageRect, e.Location);
break;
case ZoomMode.MouseOffset:
imageRect = OffsetScaledRectangleOnMousePosition(imageRect, zoomCurrent, e.Location);
break;
default:
break;
}
canvas.Invalidate();
}

private void canvas_MouseDown(object sender, MouseEventArgs e)
{
if (e.Button != MouseButtons.Left) return;
mouseLocation = e.Location;
imageLocation = imageRect.Location;
canvas.Cursor = Cursors.NoMove2D;
}

private void canvas_MouseMove(object sender, MouseEventArgs e)
{
if (e.Button != MouseButtons.Left) return;
imageRect.Location =
new PointF(imageLocation.X + (e.Location.X - mouseLocation.X),
imageLocation.Y + (e.Location.Y - mouseLocation.Y));
canvas.Invalidate();
}

private void canvas_MouseUp(object sender, MouseEventArgs e) =>
canvas.Cursor = Cursors.Default;

private void canvas_Paint(object sender, PaintEventArgs e)
{
var drawingRect = GetDrawingImageRect(imageRect);

using (var mxRotation = new Matrix())
using (var mxTransform = new Matrix()) {

e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
e.Graphics.PixelOffsetMode = PixelOffsetMode.Half;

mxRotation.RotateAt(rotationAngle, GetDrawingImageCenterPoint(drawingRect));
mxTransform.Multiply(mxRotation);

e.Graphics.Transform = mxTransform;
e.Graphics.DrawImage(drawingImage, drawingRect);
}
}

private void trkRotationAngle_ValueChanged(object sender, EventArgs e)
{
rotationAngle = trkAngle.Value;
canvas.Invalidate();
canvas.Focus();
}

private void radZoom_CheckedChanged(object sender, EventArgs e)
{
var rad = sender as RadioButton;
if (rad.Checked) {
zoomMode = (ZoomMode)int.Parse(rad.Tag.ToString());
}
canvas.Focus();
}

#region Drawing Methods

public RectangleF GetScaledRect(RectangleF rect, float scaleFactor) =>
new RectangleF(rect.Location,
new SizeF(rect.Width * scaleFactor, rect.Height * scaleFactor));

public RectangleF GetDrawingImageRect(RectangleF rect) =>
GetScaledRect(rect, zoomFactor);

public PointF GetDrawingImageCenterPoint(RectangleF rect) =>
new PointF(rect.X + rect.Width / 2, rect.Y + rect.Height / 2);

public RectangleF CenterScaledRectangleOnCanvas(RectangleF rect, RectangleF canvas)
{
var scaled = GetScaledRect(rect, zoomFactor);
rect.Location = new PointF((canvas.Width - scaled.Width) / 2,
(canvas.Height - scaled.Height) / 2);
return rect;
}

public RectangleF CenterScaledRectangleOnMousePosition(RectangleF rect, PointF mousePosition)
{
var scaled = GetScaledRect(rect, zoomFactor);
rect.Location = new PointF(mousePosition.X - (scaled.Width / 2),
mousePosition.Y - (scaled.Height / 2));
return rect;
}

public RectangleF OffsetScaledRectangleOnMousePosition(RectangleF rect, float currentZoom, PointF mousePosition)
{
var currentRect = GetScaledRect(imageRect, currentZoom);
if (!currentRect.Contains(mousePosition)) return rect;

float scaleRatio = currentRect.Width / GetScaledRect(rect, zoomFactor).Width;

PointF mouseOffset = new PointF(mousePosition.X - rect.X, mousePosition.Y - rect.Y);
PointF scaledOffset = new PointF(mouseOffset.X / scaleRatio, mouseOffset.Y / scaleRatio);
PointF position = new PointF(rect.X - (scaledOffset.X - mouseOffset.X),
rect.Y - (scaledOffset.Y - mouseOffset.Y));
rect.Location = position;
return rect;
}

#endregion
}

The simple PictureBoxEx custom control (modify and extend as needed):

This PictureBox is selectable, so it can be focused, with a Mouse click

using System.ComponentModel;
using System.Drawing;
using System.Windows.Forms;

[DesignerCategory("Code")]
public class PictureBoxEx : PictureBox
{
public PictureBoxEx() : this (new Size(200, 200)){ }
public PictureBoxEx(Size size) {
SetStyle(ControlStyles.Selectable | ControlStyles.UserMouse, true);
BorderStyle = BorderStyle.FixedSingle;
Size = size;
}
}

Image zoom centered on mouse position

The key to solving this puzzle is to understand how the image gets enlarged. If we're using a zoom factor of 1.2, the image becomes 20% larger. We assign 1.2 to the variable factor and do the following:

image.setScaleX(image.getScaleX() * factor);
image.setScaleY(image.getScaleY() * factor);

The upper left corner of the image stays in the same place while the picture is enlarged. Now consider the point under the mouse cursor. Every pixel above and to the left of the cursor has become 20% larger. This displaces the point under the cursor by 20% downward and to the right. Meanwhile, the cursor is in the same position.

To compensate for the displacement of the point under the cursor, we move the image so that the point gets back under the cursor. The point moved down and right; we move the image up and left by the same distance.

Note that the image might have been moved in the canvas before the zooming operation, so the cursor's horizontal position in the image is currentMouseX - image.getLeft() before zooming, and likewise for the vertical position.

This is how we calculate the displacement after zooming:

var dx = (currentMouseX - image.getLeft()) * (factor - 1),
dy = (currentMouseY - image.getTop()) * (factor - 1);

Finally, we compensate for the displacement by moving the point back under the cursor:

image.setLeft(image.getLeft() - dx);
image.setTop(image.getTop() - dy);

I integrated this calculation into your demo and made the following fiddle:

https://jsfiddle.net/fgLmyxw4/

I also implemented the zoom-out operation.

Zoom image in/out on mouse point using wheel with transform origin center. Need help in calculation

How to scale the image on mouse point if the transform origin is defaulted to 50% 50% by default ?

To shift origin to 50% 50% we need x,y position of mouse, relative to the image i.e. image top left as origin till image bottom right. Then we compensate image position by using the relative coordinates. We need to consider image dimensions as well.

<!DOCTYPE html>
<html lang="en">

<head>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>

<style>
.container {
background-color: lightgrey;
}

.stage {
height: 90vh;
width: 100%;
overflow: hidden;
}

#image {
transform-origin: 50% 50%;
height: auto;
width: 40%;
cursor: grab;
}

.actions {
display: flex;
position: absolute;
bottom: 0;
left: 0;
height: 1.5rem;
width: 100%;
background-color: lightgrey;
}

.action {
margin-right: 1rem;
}
</style>
</head>

<body>
<div class="container">
<div class="stage">
<img id="image" src="https://cdn.pixabay.com/photo/2018/01/14/23/12/nature-3082832__480.jpg" />
</div>
<div class="actions">
<div class="action">
<label for="rotate">Rotate </label>
<input type="range" id="rotate" name="rotate" min="0" max="360">
<button onclick="reset()">Reset All</button>
</div>
</div>
</div>
<script>
const img = document.getElementById('image');
const rotate = document.getElementById('rotate');
let mouseX;
let mouseY;
let mouseTX;
let mouseTY;
let startXOffset = 222.6665;
let startYOffset = 224.713;
let startX = 0;
let startY = 0;
let panning = false;

const ts = {
scale: 1,
rotate: 0,
translate: {
x: 0,
y: 0
}
};

rotate.oninput = function(event) {
event.preventDefault();
ts.rotate = event.target.value;
setTransform();
};

img.onwheel = function(event) {
event.preventDefault();
//need more handling to avoid fast scrolls
var func = img.onwheel;
img.onwheel = null;

let rec = img.getBoundingClientRect();
let x = (event.clientX - rec.x) / ts.scale;
let y = (event.clientY - rec.y) / ts.scale;

let delta = (event.wheelDelta ? event.wheelDelta : -event.deltaY);
ts.scale = (delta > 0) ? (ts.scale + 0.2) : (ts.scale - 0.2);

//let m = (ts.scale - 1) / 2;
let m = (delta > 0) ? 0.1 : -0.1;
ts.translate.x += (-x * m * 2) + (img.offsetWidth * m);
ts.translate.y += (-y * m * 2) + (img.offsetHeight * m);

setTransform();
img.onwheel = func;
};

img.onmousedown = function(event) {
event.preventDefault();
panning = true;
img.style.cursor = 'grabbing';
mouseX = event.clientX;
mouseY = event.clientY;
mouseTX = ts.translate.x;
mouseTY = ts.translate.y;
};

img.onmouseup = function(event) {
panning = false;
img.style.cursor = 'grab';
};

img.onmousemove = function(event) {
event.preventDefault();
let rec = img.getBoundingClientRect();
let xx = event.clientX - rec.x;
let xy = event.clientY - rec.y;

const x = event.clientX;
const y = event.clientY;
pointX = (x - startX);
pointY = (y - startY);
if (!panning) {
return;
}
ts.translate.x =
mouseTX + (x - mouseX);
ts.translate.y =
mouseTY + (y - mouseY);
setTransform();
};

function setTransform() {
const steps = `translate(${ts.translate.x}px,${ts.translate.y}px) scale(${ts.scale}) rotate(${ts.rotate}deg) translate3d(0,0,0)`;
//console.log(steps);
img.style.transform = steps;
}

function reset() {
ts.scale = 1;
ts.rotate = 0;
ts.translate = {
x: 0,
y: 0
};
rotate.value = 180;
img.style.transform = 'none';
}

setTransform();
</script>
</body>

</html>

Zooming into the mouse position with a translation?

It is more safe (and also for code reuse) to un-project the mouse coordinate point (from window coordinates to model coordinates) first even though you know how projection is done.

You can use the following function:

void unProject(int ix, int iy, int &ox, int &oy)
{
// First, ensure that your OpenGL context is the selected one
GLint viewport[4];
GLdouble projection[16];
GLdouble modelview[16];

glGetIntegerv(GL_VIEWPORT, viewport);
glGetDoublev(GL_PROJECTION_MATRIX, projection);
glGetDoublev(GL_MODELVIEW_MATRIX, modelview);

int xx = ix;
int yy = viewport[3] - iy;
GLdouble x, y, z;
gluUnProject(xx, yy, 0 /*check*/, modelview, projection, viewport, &x, &y, &z);

ox = (int) x;
oy = (int) y;
}

The output then is the correct point on the model coordinates for your zooming

Zoom in on a point (using scale and translate)

Finally solved it:

const zoomIntensity = 0.2;

const canvas = document.getElementById("canvas");
let context = canvas.getContext("2d");
const width = 600;
const height = 200;

let scale = 1;
let originx = 0;
let originy = 0;
let visibleWidth = width;
let visibleHeight = height;

function draw(){
// Clear screen to white.
context.fillStyle = "white";
context.fillRect(originx, originy, width/scale, height/scale);
// Draw the black square.
context.fillStyle = "black";
context.fillRect(50, 50, 100, 100);

// Schedule the redraw for the next display refresh.
window.requestAnimationFrame(draw);
}
// Begin the animation loop.
draw();

canvas.onwheel = function (event){
event.preventDefault();
// Get mouse offset.
const mousex = event.clientX - canvas.offsetLeft;
const mousey = event.clientY - canvas.offsetTop;
// Normalize mouse wheel movement to +1 or -1 to avoid unusual jumps.
const wheel = event.deltaY < 0 ? 1 : -1;

// Compute zoom factor.
const zoom = Math.exp(wheel * zoomIntensity);

// Translate so the visible origin is at the context's origin.
context.translate(originx, originy);

// Compute the new visible origin. Originally the mouse is at a
// distance mouse/scale from the corner, we want the point under
// the mouse to remain in the same place after the zoom, but this
// is at mouse/new_scale away from the corner. Therefore we need to
// shift the origin (coordinates of the corner) to account for this.
originx -= mousex/(scale*zoom) - mousex/scale;
originy -= mousey/(scale*zoom) - mousey/scale;

// Scale it (centered around the origin due to the trasnslate above).
context.scale(zoom, zoom);
// Offset the visible origin to it's proper position.
context.translate(-originx, -originy);

// Update scale and others.
scale *= zoom;
visibleWidth = width / scale;
visibleHeight = height / scale;
}
<canvas id="canvas" width="600" height="200"></canvas>

Java Graphics2D - Zoom on mouse location

I solved the problem by implementing what is being described here

Here is the updated code:

public class MyPanel extends JPanel {
...
private double zoom = 1;
private int zoomPointX;
private int zoomPointY;
...

class CustomMouseWheelListener implements MouseWheelListener {
@Override
public void mouseWheelMoved(MouseWheelEvent e) {
zoomPointX = e.getX();
zoomPointY = e.getY();
if (e.getPreciseWheelRotation() < 0) {
zoom -= 0.1;
} else {
zoom += 0.1;
}
if (zoom < 0.01) {
zoom = 0.01;
}
repaint();
}
}
...
protected void paintComponent(Graphics g) {
Graphics2D g2D = (Graphics2D) g;
super.paintComponent(g2D);
AffineTransform at = g2D.getTransform();
at.translate(zoomPointX, zoomPointY);
at.scale(zoom, zoom);
at.translate(-zoomPointX, -zoomPointY);
g2D.setTransform(at);
a_different_class_where_i_do_some_drawing.draw(g2D);
}

}

Sample Image

How to correct the mouse down position after zooming?

Based on the referred problem, to scale and offset the view to the mouse wheel position:

private float zoom = 1f;
private PointF wheelPoint;

private void picturebox_MouseWheel(object sender, MouseEventArgs e)
{
zoom += e.Delta < 0 ? -.1f : .1f;
zoom = Math.Max(.1f, Math.Min(10, zoom));
wheelPoint = new PointF(e.X * (zoom - 1f), e.Y * (zoom - 1f));
picturebox.Invalidate();
}

Offset and scale before drawing your shapes:

private void picturebox_Paint(object sender, PaintEventArgs e)
{
var g = e.Graphics;

g.TranslateTransform(-wheelPoint.X, -wheelPoint.Y);
g.ScaleTransform(zoom, zoom);

// Draw....
}

SO73133298


If you also use the mouse inputs to draw your shapes, then you also need to take the offset into account to get the right point.

Revising the first example to mainly add the ScalePoint method to do the required calculations.

private float _zoom = 1f;
private PointF _wheelPoint;
private readonly SizeF _recSize = new Size(60, 60);
private List<RectangleF> _rects = new List<RectangleF>();

private PointF ScalePoint(PointF p) =>
new PointF(
(p.X + _wheelPoint.X) / _zoom - (_recSize.Width / 2),
(p.Y + _wheelPoint.Y) / _zoom - (_recSize.Height / 2));

private void picturebox_MouseDown(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
_rects.Add(new RectangleF(ScalePoint(e.Location), _recSize));
picturebox.Invalidate();
}
}

private void picturebox_MouseWheel(object sender, MouseEventArgs e)
{
_zoom += e.Delta < 0 ? -.1f : .1f;
_zoom = Math.Max(.1f, Math.Min(10, _zoom));
_wheelPoint = new PointF(e.X * (_zoom - 1f), e.Y * (_zoom - 1f));
picturebox.Invalidate();
}

private void picturebox_Paint(object sender, PaintEventArgs e)
{
var g = e.Graphics;

g.TranslateTransform(-_wheelPoint.X, -_wheelPoint.Y);
g.ScaleTransform(_zoom, _zoom);
g.SmoothingMode = SmoothingMode.AntiAlias;

_rects.ForEach(r => g.FillEllipse(Brushes.Black, r));
}

SO73133298B



Related Topics



Leave a reply



Submit