/*
* Copyright (C) 2015 Patryk Strach
*
* This file is part of Virtual Slide Viewer.
*
* Virtual Slide Viewer is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software Foundation,
* either version 3 of the License, or (at your option) any later version.
*
* Virtual Slide Viewer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Virtual Slide Viewer.
* If not, see <http://www.gnu.org/licenses/>.
*/
package virtualslideviewer.ui.imageviewing;
import java.awt.Dimension;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.geom.Point2D;
import java.util.ArrayList;
import java.util.List;
import virtualslideviewer.core.BufferedVirtualSlideImage;
import virtualslideviewer.core.ImageIndex;
import virtualslideviewer.imageviewing.VisibleImageLoader;
import virtualslideviewer.util.ImageUtil;
import virtualslideviewer.util.ParameterValidator;
/**
* A presentation model containing state and behavior for an image rendering view.
*/
public class ImagePresentationModel
{
public interface Listener
{
/**
* Called when content of visible part of an image has changed either due to camera movement or new data availability.
*/
public void onVisibleImageContentUpdate();
/**
* Called when the image to render has been changed.
*/
public void onImageChange();
}
private static final long MAX_THUMBNAIL_SIZE = 1024 * 1024 * 3;
private static final double MAX_ZOOM = 4.0;
private BufferedVirtualSlideImage mImage;
private final Camera mCamera;
private final VisibleImageLoader mImageLoader;
private double mZoomIncrement = 1.1;
private double mResolutionTransitionThreshold = 0.5;
private int mCurrentChannel;
private int mCurrentZ;
private int mCurrentTimePoint;
private final List<Listener> mListeners = new ArrayList<>();
/**
* @param camera Camera used to control the visible region of an image.
* @param imageLoader Loader used to load visible part of an image.
*/
public ImagePresentationModel(Camera camera, VisibleImageLoader imageLoader)
{
ParameterValidator.throwIfNull(camera, "camera");
ParameterValidator.throwIfNull(imageLoader, "imageLoader");
mCamera = camera;
mImageLoader = imageLoader;
mCamera.addChangeListener(() ->
{
if(mImage == null)
return;
mListeners.forEach(l -> l.onVisibleImageContentUpdate());
});
}
/**
* Adds a listener.
*/
public void addListener(Listener listener)
{
if(listener == null)
throw new IllegalArgumentException("newListener cannot be null.");
mListeners.add(listener);
}
/**
* Sets an image which will be displayed in the panel.
*/
public void setImage(BufferedVirtualSlideImage image)
{
if(image == null)
throw new IllegalArgumentException("image cannot be null.");
mCamera.setImageSize(image.getImageSize(image.getResolutionCount() - 1));
if(mImage != null)
mCamera.zoomToFit();
mImage = image;
mCurrentChannel = 0;
mCurrentZ = 0;
mCurrentTimePoint = 0;
mListeners.forEach(l -> l.onImageChange());
}
public boolean isImageLoaded()
{
return mImage != null;
}
/**
* @see Camera#pan(Point)
*/
public void pan(int x, int y)
{
mCamera.pan(new Point(x, y));
}
/**
* Zooms the image at specified point.
*
* @param increment Zoom increment to use. Positive values zooms in, while negative zooms the view out.
* @param zoomAt The point in <b>pixels</b> <b>relative</b> to the upper left corner of visible region
* at which the image will be zoomed. Has to be positive.
*/
public void zoomAt(int increment, Point zoomAtPoint)
{
double realIncrement = mZoomIncrement * increment;
double newZoom = (realIncrement >= 0.0f) ? (mCamera.getZoom() * realIncrement) : (mCamera.getZoom() / -realIncrement);
newZoom = Math.min(Math.max(newZoom, getMinZoom()), getMaxZoom());
mCamera.setZoom(newZoom, zoomAtPoint);
}
public double getMinZoom()
{
Dimension halfViewportSize = new Dimension(mCamera.getViewportSize().width / 2, mCamera.getViewportSize().height / 2);
double zoomToFitHalfScreen = ImageUtil.getScaleToFit(mImage.getImageSize(mImage.getResolutionCount() - 1), halfViewportSize);
return Math.min(zoomToFitHalfScreen, 1.0);
}
public double getMaxZoom()
{
return MAX_ZOOM;
}
/**
* @see Camera#setZoom(double)
*/
public void setZoom(double zoom)
{
mCamera.setZoom(zoom);
}
/**
* Sets the size of a viewport into which the visible part of image will be rendered.
*/
public void setViewportSize(Dimension size)
{
boolean firstTimeInitialization = mCamera.getViewportSize().equals(new Dimension(0, 0));
mCamera.setViewportSize(size);
if(firstTimeInitialization)
{
// zoomToFit() doesn't work in setImage() on first time because the viewport size is unknown yet.
mCamera.zoomToFit();
}
}
/**
* @see Camera#getZoom()
*/
public double getZoom()
{
return mCamera.getZoom();
}
/**
* Gets the size of visible part of the image which should be used during the rendering.
*
* Note that this size can be smaller than the size of the viewport when the image at current zoom is smaller.
*/
public Dimension getVisibleImageRegionSize()
{
return mCamera.getAbsoluteVisibleRegionBounds().getSize();
}
/**
* Gets the size of image data returned by a call to {@link #loadImageDataInto(byte[])}.
*
* Because the returned image is NOT scaled to a destination size the result from a call to this function will most of the time differ
* from the result of a {@link #getVisibleImageRegionSize()} call.
*/
public Dimension getImageDataSize()
{
Dimension imageSize = mImage.getImageSize(getResolutionToUseDuringLoading());
return Camera.relativeToAbsoluteBounds(mCamera.getVisibleRegionBounds(), imageSize).getSize();
}
/**
* Loads the data of currently visible part of the image.
*
* The image is <b>NOT</b> scaled to destination size due to performance reasons, because the only purpose of returned image
* is to be rendered, it is more efficient to do the scaling on the fly during rendering.
*
* @param dst The buffer to load data into.
*
* @see #getImageDataSize()
*/
public void loadImageDataInto(byte[] dst)
{
int bestResIndex = getResolutionToUseDuringLoading();
Rectangle visibleImageBounds = Camera.relativeToAbsoluteBounds(mCamera.getVisibleRegionBounds(), mImage.getImageSize(bestResIndex));
mImageLoader.getVisibleImageData(mImage, dst, visibleImageBounds, getCurrentImageIndexForResolution(bestResIndex),
() -> mListeners.forEach(l -> l.onVisibleImageContentUpdate()));
}
private int getResolutionToUseDuringLoading()
{
List<Dimension> imageResolutions = new ArrayList<>(mImage.getResolutionCount());
for(int i = 0; i < mImage.getResolutionCount(); i++)
{
imageResolutions.add(mImage.getImageSize(i));
}
return mCamera.getBestResolutionForCurrentZoom(imageResolutions, mResolutionTransitionThreshold);
}
public int getCurrentChannel()
{
return mCurrentChannel;
}
public int getChannelCount()
{
return mImage.getChannelCount();
}
public void setCurrentChannel(int channel)
{
if(channel >= getChannelCount())
throw new IllegalArgumentException("The index of channel has to be lower than the number of channels in the image.");
mCurrentChannel = channel;
mListeners.forEach(l -> l.onImageChange());
}
public int getCurrentZPlane()
{
return mCurrentZ;
}
public int getZPlanesCount()
{
return mImage.getZPlaneCount();
}
public void setCurrentZPlane(int z)
{
if(z >= getZPlanesCount())
throw new IllegalArgumentException("The index of z plane has to be lower than the number of z planes in the image.");
mCurrentZ = z;
mListeners.forEach(l -> l.onImageChange());
}
public int getCurrentTimePoint()
{
return mCurrentTimePoint;
}
public int getTimePointCount()
{
return mImage.getTimePointCount();
}
public void setCurrentTimePoint(int timePoint)
{
if(timePoint >= getTimePointCount())
throw new IllegalArgumentException("The index of time point has to be lower than the number of time points in the image.");
mCurrentTimePoint = timePoint;
mListeners.forEach(l -> l.onImageChange());
}
private ImageIndex getCurrentImageIndexForResolution(int resIndex)
{
return new ImageIndex(resIndex, mCurrentChannel, mCurrentZ, mCurrentTimePoint);
}
/**
* Gets the size of image's thumbnail.
*/
public Dimension getThumbnailSize()
{
return mImage.getImageSize(getThumbnailResIndex());
}
/**
* Loads the data of image's thumbnail.
*
* @param dst The buffer to load data into.
*
* @see #getThumbnailSize()
*/
public void getThumbnailData(byte[] dst)
{
mImage.getPixels(dst, new Rectangle(new Point(0, 0), getThumbnailSize()), getCurrentImageIndexForResolution(getThumbnailResIndex()));
}
private int getThumbnailResIndex()
{
return ImageUtil.getResolutionIndexWithSizeNotBiggerThan(mImage, MAX_THUMBNAIL_SIZE);
}
/**
* Checks whether the image is in RGB color space.
*/
public boolean isImageRGB()
{
return mImage.isRGB();
}
/**
* Handle the mouse click on an preview image.
*
* @param clickPointInImageSpace The position at which the click was made.
* @param previewImageSize The size of preview image.
*/
public void handleImagePreviewClick(Point clickPointInImageSpace, Dimension previewImageSize)
{
mCamera.setPosition(absolutePositionToRelative(clickPointInImageSpace, previewImageSize));
}
/**
* Converts given position in absolute coordinates into relative ones (that is in the range 0.0 - 1.0).
*
* @param absolutePos The absolute position to convert.
* @param areaSize The maximum value of an absolute position.
*
* @return The position in relative coordinates.
*/
private Point2D absolutePositionToRelative(Point absolutePos, Dimension areaSize)
{
double x = absolutePos.getX() / areaSize.getWidth();
double y = absolutePos.getY() / areaSize.getHeight();
return new Point2D.Double(x, y);
}
/**
* Gets the bounds of visible region marker as visible on image preview.
*
* @param imagePreviewBounds The bounds of image preview.
*/
public Rectangle getVisibleRegionMarkerBounds(Rectangle imagePreviewBounds)
{
int x = (int)(mCamera.getVisibleRegionBounds().getX() * imagePreviewBounds.getWidth());
int y = (int)(mCamera.getVisibleRegionBounds().getY() * imagePreviewBounds.getHeight());
int width = (int)(mCamera.getVisibleRegionBounds().getWidth() * imagePreviewBounds.getWidth());
int height = (int)(mCamera.getVisibleRegionBounds().getHeight() * imagePreviewBounds.getHeight());
return new Rectangle(x + imagePreviewBounds.x, y + imagePreviewBounds.y, width, height);
}
/**
* Gets the zoom increment.
*
* @see #setZoomIncrement(double)
*/
public double getZoomIncrement()
{
return mZoomIncrement;
}
/**
* Sets a zoom increment to be used when a user wants to zoom the image.
*
* @param zoomIncrement New zoom increment. Has to be larger than 1.0
*/
public void setZoomIncrement(double zoomIncrement)
{
if(zoomIncrement <= 1.0f)
throw new IllegalArgumentException("Zoom increment has to be larger than 1.0");
mZoomIncrement = zoomIncrement;
}
/**
* Gets the threshold of transition beetwen image resolutions.
* @see #setResolutionTransitionThreshold(double)
*/
public double getResolutionTransitionThreshold()
{
return mResolutionTransitionThreshold;
}
/**
* Sets the threshold of a transition between image resolutions.
*
* This threshold is used to determine from which resolution level to get pixels at zoom level in between 2 resolutions.
* After the percentage of distance from lower to higher resolution level is higher than specified threshold, the function
* {@link #loadImageDataInto(byte[])} will use pixels from higher resolution.
*
* The lower the threshold the sooner the controller will retrieve pixels from higher resolution of image resulting in better
* quality and less visible transition, but beware that fetching pixels from higher resolution means that a lot more pixels
* needs to be retrieved causing severe perfomance loss.
*
* @param newValue New threshold, its value has to be in [0.0, 1.0] range.
*/
public void setResolutionTransitionThreshold(double newValue)
{
if(newValue < 0.0 || newValue > 1.0)
throw new IllegalArgumentException("Invalid value. The valid values are in range [0.0, 1.0]");
mResolutionTransitionThreshold = newValue;
}
}