/* * ShootOFF - Software for Laser Dry Fire Training * Copyright (C) 2016 phrack * * This program 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. * * This program 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 this program. If not, see <http://www.gnu.org/licenses/>. */ package com.shootoff.camera.perspective; import java.util.ArrayList; import java.util.List; import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javafx.geometry.Bounds; import javafx.geometry.Dimension2D; /** * This class is used to resize targets on the projector arena to real world * dimensions. This allows users and training exercises to take virtual targets * and set them to the sizes they are in real life are specific distances. For * example, ISSF targets are 10 cm x 10 cm at 25 m for most handgun * competitions. This class lets you resize an ISSF target on a projector arena * to 10 cm by 10 cm as if it were at 25 m. Given defaults like the ISSF case, * this class can resize a target to appear as if it is at any distance. * * All units for this class are in millimeters because those units make the * calculations nicer. In particular, we use the following formula to calculate * unknown camera and distance parameters and target dimensions: * * distance to object (mm) = * * focal length (mm) * real height of the object (mm) * image height (pixels) * --------------------------------------------------------------------------- * object height (pixels) * sensor height (mm) * * @author cbdmaul */ public class PerspectiveManager { private static final Logger logger = LoggerFactory.getLogger(PerspectiveManager.class); private final static int US_LETTER_WIDTH_MM = 279; private final static int US_LETTER_HEIGHT_MM = 216; private final static int DEFAULT_SHOOTER_DISTANCE = 3000; private String calibratedCameraName; // Key = camera name private static final List<CameraParameters> cameraParameters = new ArrayList<>(); // All in millimeters private double focalLength = -1; private double sensorHeight = -1; private double sensorWidth = -1; private int cameraDistance = -1; private int shooterDistance = -1; private double pxPerMMhigh = -1; private double pxPerMMwide = -1; // All in pixels private int projectionHeight = -1; private int projectionWidth = -1; private int cameraHeight = -1; private int cameraWidth = -1; private int patternHeight = -1; private int patternWidth = -1; private int projectorResHeight = -1; private int projectorResWidth = -1; private static class CameraParameters { private final String cameraName; private final double focalLength; private final double sensorWidth; private final double sensorHeight; private final Dimension2D validDims; public CameraParameters(String cameraName, double focalLength, double sensorWidth, double sensorHeight, Dimension2D validDims) { this.focalLength = focalLength; this.sensorWidth = sensorWidth; this.sensorHeight = sensorHeight; this.validDims = validDims; this.cameraName = cameraName; } public double getFocalLength() { return focalLength; } public double getSensorWidth() { return sensorWidth; } public double getSensorHeight() { return sensorHeight; } public String getName() { return cameraName; } public Dimension2D getValidDimensions() { return validDims; } } // TODO: Implement a way to load these values from a file // so that they can be easily tweaked/added to static { cameraParameters.add(new CameraParameters("C270", 4.0, 3.58, 2.02, new Dimension2D(1280, 720))); cameraParameters.add(new CameraParameters("C270", 4.0, 3.60, 2.712, new Dimension2D(800, 600))); cameraParameters.add(new CameraParameters("C270", 4.0, 3.145, 2.343, new Dimension2D(640, 480))); cameraParameters.add(new CameraParameters("C920", 3.67, 4.80, 2.70, new Dimension2D(1280, 720))); cameraParameters.add(new CameraParameters("HD-3000", 4, 3.787, 2.864, new Dimension2D(640, 480))); } // For testing protected PerspectiveManager(Bounds arenaBounds) { if (logger.isTraceEnabled()) logger.trace("pattern res w {} h {}", arenaBounds.getWidth(), arenaBounds.getHeight()); patternWidth = (int) arenaBounds.getWidth(); patternHeight = (int) arenaBounds.getHeight(); } public PerspectiveManager(Bounds arenaBounds, Dimension2D feedDims, Dimension2D paperBounds, Dimension2D projectorRes) { this(arenaBounds); setCameraFeedSize((int) feedDims.getWidth(), (int) feedDims.getHeight()); this.setProjectorResolution(projectorRes); setProjectionSizeFromLetterPaperPixels(paperBounds); if (cameraDistance == -1) cameraDistance = DEFAULT_SHOOTER_DISTANCE; if (shooterDistance == -1) shooterDistance = DEFAULT_SHOOTER_DISTANCE; calculateRealWorldSize(); } public PerspectiveManager(String cameraName, Bounds arenaBounds, Dimension2D feedDims, Dimension2D projectorRes) { this(cameraName, feedDims, arenaBounds); this.setProjectorResolution(projectorRes); if (cameraDistance == -1) cameraDistance = DEFAULT_SHOOTER_DISTANCE; if (shooterDistance == -1) shooterDistance = DEFAULT_SHOOTER_DISTANCE; calculateUnknown(); } public PerspectiveManager(String cameraName, Dimension2D resolution, Bounds arenaBounds) { this(arenaBounds); calibratedCameraName = cameraName; if (!setCameraParameters(cameraName, resolution)) { throw new UnsupportedCameraException(cameraName + " does not support target perspectives because" + " its focal length and sensor parameters are unknown."); } setCameraFeedSize(resolution); } public PerspectiveManager(String cameraName, Bounds arenaBounds, Dimension2D feedDims, Dimension2D paperBounds, Dimension2D projectorRes) { this(cameraName, feedDims, arenaBounds); setProjectionSizeFromLetterPaperPixels(paperBounds); this.setProjectorResolution(projectorRes); // Camera distance is unknown calculateUnknown(); if (cameraDistance == -1) cameraDistance = DEFAULT_SHOOTER_DISTANCE; if (shooterDistance == -1) shooterDistance = DEFAULT_SHOOTER_DISTANCE; calculateRealWorldSize(); } public static boolean isCameraSupported(final String cameraName, Dimension2D desiredResolution) { for (final CameraParameters cam : cameraParameters) { if (cameraName.contains(cam.getName()) && Math.abs(cam.getValidDimensions().getWidth() - desiredResolution.getWidth()) < .001 && Math.abs(cam.getValidDimensions().getHeight() - desiredResolution.getHeight()) < .001) { return true; } } return false; } private boolean setCameraParameters(final String cameraName, Dimension2D desiredResolution) { for (final CameraParameters cam : cameraParameters) { if (cameraName.contains(cam.getName()) && Math.abs(cam.getValidDimensions().getWidth() - desiredResolution.getWidth()) < .001 && Math.abs(cam.getValidDimensions().getHeight() - desiredResolution.getHeight()) < .001) { focalLength = cam.getFocalLength(); sensorWidth = cam.getSensorWidth(); sensorHeight = cam.getSensorHeight(); return true; } } return false; } // Used for testing protected void setCameraParameters(double focalLength, double sensorWidth, double sensorHeight) { if (logger.isTraceEnabled()) logger.trace("camera params fl {} sw {} sh {}", focalLength, sensorWidth, sensorHeight); this.focalLength = focalLength; this.sensorHeight = sensorHeight; this.sensorWidth = sensorWidth; } /* * The real world width and height of the projector arena in the camera feed * (in mm) -- currently only used for testing. Could ask the user to set * this manually, but this is enough of a pain that for now we just ask them * to calibrate with paper */ protected void setProjectionSize(int width, int height) { if (logger.isTraceEnabled()) logger.trace("projection w {} h {}", width, height); projectionHeight = height; projectionWidth = width; } /* * Specify (or find with OpenCV) the number of camera pixels that are * represent a U.S. standard letter, which is 8.5 x 11 inches or 216 x 279mm * We assume that the paper is placed sideways! We could probably adjust for * this though */ private void setProjectionSizeFromLetterPaperPixels(Dimension2D letterDims) { if (logger.isTraceEnabled()) logger.trace("letter w {} h {}", letterDims.getWidth(), letterDims.getHeight()); if (cameraWidth == -1 || patternWidth == -1) { logger.error("Missing cameraWidth or patternWidth for US Letter calculation"); return; } // Calculate the size of the whole camera feed using the size of the // letter final double cameraFeedWidthMM = (cameraWidth / letterDims.getWidth()) * US_LETTER_WIDTH_MM; final double cameraFeedHeightMM = (cameraHeight / letterDims.getHeight()) * US_LETTER_HEIGHT_MM; if (logger.isTraceEnabled()) { logger.trace("{} = ({} / {}) * {}", cameraFeedWidthMM, cameraWidth, letterDims.getWidth(), US_LETTER_WIDTH_MM); logger.trace("{} = ({} / {}) * {}", cameraFeedHeightMM, cameraHeight, letterDims.getHeight(), US_LETTER_HEIGHT_MM); } // Set the projection width/height in mm projectionWidth = (int) (cameraFeedWidthMM * ((double) patternWidth / (double) cameraWidth)); projectionHeight = (int) (cameraFeedHeightMM * ((double) patternHeight / (double) cameraHeight)); if (logger.isTraceEnabled()) { logger.trace("{} = ({} / {}) * {}", projectionWidth, cameraFeedWidthMM, patternWidth, cameraWidth); logger.trace("{} = ({} / {}) * {}", projectionHeight, cameraFeedHeightMM, patternHeight, cameraHeight); } } /* The camera feed width and height (in px) */ public void setCameraFeedSize(int width, int height) { if (logger.isTraceEnabled()) logger.trace("camera feed w {} h {}", width, height); cameraHeight = height; cameraWidth = width; } private void setCameraFeedSize(Dimension2D resolution) { setCameraFeedSize((int) resolution.getWidth(), (int) resolution.getHeight()); } /* Distance (in mm) camera to screen */ public void setCameraDistance(int cameraDistance) { if (logger.isTraceEnabled()) logger.trace("cameraDistance {}", cameraDistance); this.cameraDistance = cameraDistance; // TODO: Add logic to recalculate camera parameters if they were set via // calibration and not stored parameters if (!isInitialized()) { calculateUnknown(); // This makes things work easier if the user doesn't really know to // set shooter distance // or the user starts an exercise that uses perspective but didn't // set shooter distance if (shooterDistance == -1) shooterDistance = cameraDistance; } } /* Distance (in mm) camera to shooter */ public void setShooterDistance(int shooterDistance) { if (logger.isTraceEnabled()) logger.trace("shooterDistance {}", shooterDistance); this.shooterDistance = shooterDistance; } public String getCalibratedCameraName() { return calibratedCameraName; } public int getCameraDistance() { return cameraDistance; } public int getShooterDistance() { return shooterDistance; } /* * The resolution of the screen the arena is projected on Due to DPI scaling * this might not correspond to the projector's resolution, but it is easier * to think of that way */ public void setProjectorResolution(int width, int height) { if (logger.isTraceEnabled()) logger.trace("projector res w {} h {}", width, height); projectorResWidth = width; projectorResHeight = height; } public void setProjectorResolution(Dimension2D dims) { setProjectorResolution((int) dims.getWidth(), (int) dims.getHeight()); } public int getProjectionWidth() { return projectionWidth; } public int getProjectionHeight() { return projectionHeight; } protected double getFocalLength() { return focalLength; } protected double getSensorWidth() { return sensorWidth; } protected double getSensorHeight() { return sensorHeight; } protected int getUnknownCount() { int unknownCount = 0; final double wValues[] = { focalLength, patternWidth, cameraWidth, projectionWidth, sensorWidth, cameraDistance, projectorResWidth }; for (int i = 0; i < wValues.length; i++) { if (wValues[i] == -1) { if (logger.isTraceEnabled()) logger.trace("Unknown: {}", i); unknownCount++; } } return unknownCount; } protected void calculateUnknown() { final int unknownCount = getUnknownCount(); if (unknownCount > 1) { // We're okay with two unknowns if they're these two. if (!(focalLength == -1 && sensorWidth == -1 && unknownCount == 2)) { logger.error("More than one unknown"); return; } } else if (unknownCount == 0) { projectionWidth = -1; projectionHeight = -1; } if (projectionWidth == -1) { projectionWidth = (int) (((double) cameraDistance * (double) patternWidth * sensorWidth) / (focalLength * cameraWidth)); projectionHeight = (int) (((double) cameraDistance * (double) patternHeight * sensorHeight) / (focalLength * cameraHeight)); if (logger.isTraceEnabled()) logger.trace("({} * {} * {}) / ({} * {})", cameraDistance, patternWidth, sensorWidth, focalLength, cameraWidth); } else if (sensorWidth == -1) { // Fix focalLength at 4 since we do not know it and we can only // calculate 1 unknown // 4 is an arbitrary selection if (focalLength == -1) focalLength = 4; sensorWidth = ((projectionWidth * focalLength * cameraWidth) / ((double) cameraDistance * (double) patternWidth)); sensorHeight = ((projectionHeight * focalLength * cameraHeight) / ((double) cameraDistance * (double) patternHeight)); if (logger.isTraceEnabled()) { logger.trace("({} * {} * {}) / ({} * {})", projectionWidth, focalLength, cameraWidth, cameraDistance, patternWidth); } logger.info("New camera params: focalLength {} sensorWidth {} sensorHeight {} width {} height {}", focalLength, sensorWidth, sensorHeight, cameraWidth, cameraHeight); } else if (cameraDistance == -1) { final int cameraDistanceH = (int) ((projectionHeight * focalLength * cameraHeight) / (patternHeight * sensorHeight)); final int cameraDistanceW = (int) ((projectionWidth * focalLength * cameraWidth) / (patternWidth * sensorWidth)); if (logger.isTraceEnabled()) { logger.trace("{} = ({} * {} * {}) / ({} * {})", cameraDistanceH, projectionHeight, focalLength, cameraHeight, patternHeight, sensorHeight); logger.trace("{} = ({} * {} * {}) / ({} * {})", cameraDistanceW, projectionWidth, focalLength, cameraWidth, patternWidth, sensorWidth); } cameraDistance = (cameraDistanceH + cameraDistanceW) / 2; } else { logger.error("Unknown not supported"); } calculateRealWorldSize(); if (logger.isTraceEnabled()) logger.trace("pW {} pH {} - pxW {} pxH {}", projectionWidth, projectionHeight, pxPerMMwide, pxPerMMhigh); } void calculateRealWorldSize() { logger.debug("calculateRealWorldSize"); if (projectorResWidth > -1 && projectionWidth > -1) { pxPerMMwide = ((double) projectorResWidth / (double) projectionWidth); pxPerMMhigh = ((double) projectorResHeight / (double) projectionHeight); } } /** * Starting with a target's real world width and height in mm, as it appears * on a projection, calculate a new width and height in pixels to resize the * target to such that it appears to be a distance of * <code>desiredDistance</code> in mm away. This calculation assumes that * the current real world dimensions are the result of the target being * <code>realDistance</code> away in mm. * * To set the initial real word size of a target, call this method with the * desired <code>realWidth</code> and <code>realHeight</code> with * <code>realDistance</code> and <code>desiredDistance</code> equal to the * target's initial real world distance. * * @param realWidth * the current width of the target on the projection in mm * @param realHeight * the current height of the target on the projection in mm * @param realDistance * the current distance of the target in mm * @param desiredDistance * the desired new distance of the target used to derive the new * target dimensions. Value must be > 0 * @return the new targets dimensions in pixels necessary to make it appear * <code>desiredDistance</code> away given its current real world * dimensions and distance */ public Optional<Dimension2D> calculateObjectSize(double realWidth, double realHeight, double desiredDistance) { if (!isInitialized()) { logger.error("projection manager has unknowns projectionWidth = {}, projectionHeight = {}, " + "shooterDistance = {}, pxPerMMhigh = {}", projectionWidth, projectionHeight, shooterDistance, pxPerMMhigh); return Optional.empty(); } if (desiredDistance == 0) { throw new IllegalArgumentException("desiredDistance cannot be 0"); } // Make it appropriate size for the desired distance // Should just cap the result dimensions at the size of the projector final double distRatio = shooterDistance / desiredDistance; final double adjWidthmm = realWidth * distRatio; final double adjHeightmm = realHeight * distRatio; final double adjWidthpx = adjWidthmm * pxPerMMwide; final double adjHeightpx = adjHeightmm * pxPerMMhigh; if (logger.isTraceEnabled()) { logger.trace("real w {} h {} d {}", realWidth, realHeight, desiredDistance); logger.trace("sD {} dR {} - adjmm {} {} adjpx {} {}", shooterDistance, distRatio, adjWidthmm, adjHeightmm, adjWidthpx, adjHeightpx); } return Optional.of(new Dimension2D(adjWidthpx, adjHeightpx)); } public boolean isInitialized() { return projectionWidth > -1 && projectionHeight > -1 && shooterDistance > -1 && pxPerMMhigh > -1; } public boolean isCameraParamsKnown() { return !(sensorWidth == -1 && focalLength == -1); } }