diff --git a/build.gradle b/build.gradle index 0de045e5e7..a9af6fe607 100644 --- a/build.gradle +++ b/build.gradle @@ -50,11 +50,8 @@ allprojects { // Turn on test results test { testLogging { - afterSuite { desc, result -> - if (!desc.parent && project.hasProperty('printTestResults')) { - println "Test results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)" - } - } + events "failed" + exceptionFormat "full" } } } diff --git a/core/src/main/java/edu/wpi/grip/core/PreviousNext.java b/core/src/main/java/edu/wpi/grip/core/PreviousNext.java new file mode 100644 index 0000000000..78ed817f51 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/PreviousNext.java @@ -0,0 +1,19 @@ +package edu.wpi.grip.core; + + +/** + * An Object that can switch its value. + */ +public interface PreviousNext { + + /** + * Perform the next action on this object. + */ + void next(); + + /** + * Perform the previous action on this object. + */ + void previous(); + +} diff --git a/core/src/main/java/edu/wpi/grip/core/StartStoppable.java b/core/src/main/java/edu/wpi/grip/core/StartStoppable.java new file mode 100644 index 0000000000..c3eaa40f2c --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/StartStoppable.java @@ -0,0 +1,49 @@ +package edu.wpi.grip.core; + + +import com.google.common.eventbus.EventBus; + +import java.io.IOException; +import java.util.concurrent.TimeoutException; + +/** + * An Object that can be stopped and started multiple times. + */ +public interface StartStoppable { + + /** + * Starts this StartStoppable + * + * @return Itself + * @throws IOException If cleaning up some system resource fails + */ + default T start(EventBus eventBus) throws IOException { + start(); + eventBus.register(this); + return (T) this; + } + + /** + * Any method that overrides this method should post a {@link edu.wpi.grip.core.events.StartedStoppedEvent} + * to the {@link EventBus} if is successfully starts. + * + * @throws IOException If cleaning up some system resource fails + */ + void start() throws IOException; + + /** + * Any method that overrides this method should post a {@link edu.wpi.grip.core.events.StartedStoppedEvent} + * to the {@link EventBus} if is successfully stops. + * + * @throws TimeoutException If the thread fails to stop in a timely manner + * @throws IOException If cleaning up some system resource fails. + */ + void stop() throws TimeoutException, IOException; + + /** + * Used to indicate if the source is running or stopped + * + * @return true if this source is running + */ + boolean isStarted(); +} diff --git a/core/src/main/java/edu/wpi/grip/core/events/StartedStoppedEvent.java b/core/src/main/java/edu/wpi/grip/core/events/StartedStoppedEvent.java new file mode 100644 index 0000000000..575902f3e3 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/events/StartedStoppedEvent.java @@ -0,0 +1,19 @@ +package edu.wpi.grip.core.events; + + +import edu.wpi.grip.core.StartStoppable; + +/** + * An event that occurs when a {@link StartStoppable StartStoppable's} state changes. + */ +public class StartedStoppedEvent { + private final StartStoppable startStoppable; + + public StartedStoppedEvent(StartStoppable startStoppable) { + this.startStoppable = startStoppable; + } + + public StartStoppable getStartStoppable() { + return this.startStoppable; + } +} diff --git a/core/src/main/java/edu/wpi/grip/core/sources/CameraSource.java b/core/src/main/java/edu/wpi/grip/core/sources/CameraSource.java index 05046e4408..a3fb30c9ba 100644 --- a/core/src/main/java/edu/wpi/grip/core/sources/CameraSource.java +++ b/core/src/main/java/edu/wpi/grip/core/sources/CameraSource.java @@ -5,12 +5,10 @@ import com.google.common.eventbus.EventBus; import com.google.common.eventbus.Subscribe; import com.thoughtworks.xstream.annotations.XStreamAlias; -import edu.wpi.grip.core.OutputSocket; -import edu.wpi.grip.core.SocketHint; -import edu.wpi.grip.core.SocketHints; -import edu.wpi.grip.core.Source; -import edu.wpi.grip.core.events.UnexpectedThrowableEvent; +import edu.wpi.grip.core.*; import edu.wpi.grip.core.events.SourceRemovedEvent; +import edu.wpi.grip.core.events.StartedStoppedEvent; +import edu.wpi.grip.core.events.UnexpectedThrowableEvent; import org.bytedeco.javacpp.opencv_core.Mat; import org.bytedeco.javacv.*; @@ -27,7 +25,7 @@ * Provides a way to generate a constantly updated {@link Mat} from a camera */ @XStreamAlias(value = "grip:Camera") -public class CameraSource extends Source { +public final class CameraSource extends Source implements StartStoppable { private final static String DEVICE_NUMBER_PROPERTY = "deviceNumber"; private final static String ADDRESS_PROPERTY = "address"; @@ -42,7 +40,7 @@ public class CameraSource extends Source { private OutputSocket frameOutputSocket; private OutputSocket frameRateOutputSocket; private Optional frameThread; - private Optional grabber; + private FrameGrabber grabber; /** * Creates a camera source that can be used as an input to a pipeline @@ -72,7 +70,6 @@ public CameraSource(EventBus eventBus, String address) throws IOException { * Used for serialization */ public CameraSource() { - this.grabber = Optional.empty(); this.frameThread = Optional.empty(); } @@ -81,9 +78,7 @@ private void initialize(EventBus eventBus, FrameGrabber frameGrabber, String nam this.name = name; this.frameOutputSocket = new OutputSocket<>(eventBus, imageOutputHint); this.frameRateOutputSocket = new OutputSocket<>(eventBus, frameRateOutputHint); - - this.eventBus.register(this); - this.startVideo(frameGrabber); + this.grabber = frameGrabber; } @Override @@ -127,109 +122,119 @@ public void createFromProperties(EventBus eventBus, Properties properties) throw } /** - * Starts the video capture from the - * - * @param grabber A JavaCV {@link FrameGrabber} instance to capture from + * Starts the video capture from this frame grabber. */ - private synchronized void startVideo(FrameGrabber grabber) throws IOException { + public void start() throws IOException, IllegalStateException { final OpenCVFrameConverter.ToMat convertToMat = new OpenCVFrameConverter.ToMat(); - if (this.frameThread.isPresent()) { - throw new IllegalStateException("The video retrieval thread has already been started."); - } - if (this.grabber.isPresent()) { - throw new IllegalStateException("The Frame Grabber has already been started."); - } - try { - grabber.start(); - } catch (FrameGrabber.Exception e) { - throw new IOException("A problem occurred trying to start the frame grabber for " + this.name, e); - } + synchronized (this) { + if (this.frameThread.isPresent()) { + throw new IllegalStateException("The video retrieval thread has already been started."); + } + try { + grabber.start(); + } catch (FrameGrabber.Exception e) { + throw new IOException("A problem occurred trying to start the frame grabber for " + this.name, e); + } - // Store the grabber only once it has been started in the case that there is an exception. - this.grabber = Optional.of(grabber); + final Thread frameExecutor = new Thread(() -> { + long lastFrame = System.currentTimeMillis(); + while (!Thread.interrupted()) { + final Frame videoFrame; + try { + videoFrame = grabber.grab(); + } catch (FrameGrabber.Exception e) { + throw new IllegalStateException("Failed to grab image", e); + } - final Thread frameExecutor = new Thread(() -> { - long lastFrame = System.currentTimeMillis(); - while (!Thread.interrupted()) { - final Frame videoFrame; - try { - videoFrame = grabber.grab(); - } catch (FrameGrabber.Exception e) { - throw new IllegalStateException("Failed to grab image", e); - } - final Mat frameMat = convertToMat.convert(videoFrame); + final Mat frameMat = convertToMat.convert(videoFrame); - if (frameMat == null || frameMat.isNull()) { - throw new IllegalStateException("The camera returned a null frame Mat"); + if (frameMat == null || frameMat.isNull()) { + throw new IllegalStateException("The camera returned a null frame Mat"); + } + + frameMat.copyTo(frameOutputSocket.getValue().get()); + frameOutputSocket.setValue(frameOutputSocket.getValue().get()); + long thisMoment = System.currentTimeMillis(); + frameRateOutputSocket.setValue(1000 / (thisMoment - lastFrame)); + lastFrame = thisMoment; } + }, "Camera"); - frameMat.copyTo(frameOutputSocket.getValue().get()); - frameOutputSocket.setValue(frameOutputSocket.getValue().get()); - long thisMoment = System.currentTimeMillis(); - frameRateOutputSocket.setValue(1000 / (thisMoment - lastFrame)); - lastFrame = thisMoment; - } - }, "Camera"); - frameExecutor.setUncaughtExceptionHandler( - (thread, exception) -> { - eventBus.post(new UnexpectedThrowableEvent(exception, "Webcam Frame Grabber Thread crashed with uncaught exception")); - try { - stopVideo(); - } catch (TimeoutException e) { - eventBus.post(new UnexpectedThrowableEvent(e, "Webcam Frame Grabber could not be stopped!")); + frameExecutor.setUncaughtExceptionHandler( + (thread, exception) -> { + // TODO: This should use the ExceptionWitness once that has a UI component added for it + eventBus.post(new UnexpectedThrowableEvent(exception, "Camera Frame Grabber Thread crashed with uncaught exception")); + try { + stop(); + } catch (TimeoutException e) { + // TODO: This should use the ExceptionWitness once that has a UI component added for it + eventBus.post(new UnexpectedThrowableEvent(e, "Camera Frame Grabber could not be stopped!")); + } } - } - ); - frameExecutor.setDaemon(true); - frameExecutor.start(); - frameThread = Optional.of(frameExecutor); + ); + frameExecutor.setDaemon(true); + frameExecutor.start(); + this.frameThread = Optional.of(frameExecutor); + // This should only be posted now that it is running + eventBus.post(new StartedStoppedEvent(this)); + } } /** - * Stops the video feed from updating the output socket. + * Stops this source. + * This will stop the source publishing new socket values after this method returns. * - * @throws TimeoutException If the thread running the Webcam fails to join this one after a timeout. + * @return The source that was stopped + * @throws TimeoutException if the thread running the source fails to stop. + * @throws IOException If there is a problem stopping the Source */ - private void stopVideo() throws TimeoutException { - if (frameThread.isPresent()) { - final Thread ex = frameThread.get(); - ex.interrupt(); - try { - ex.join(TimeUnit.SECONDS.toMillis(2)); - if (ex.isAlive()) { - throw new TimeoutException("Unable to terminate video feed from Web Camera"); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - //TODO: Move this into a logging framework - System.out.println("Caught Exception:"); - e.printStackTrace(); - } finally { - frameThread = Optional.empty(); - // This will always run even if a timeout exception occurs + public final void stop() throws TimeoutException, IllegalStateException { + synchronized (this) { + if (frameThread.isPresent()) { + final Thread ex = frameThread.get(); + ex.interrupt(); try { - grabber.ifPresent(grabber -> { - try { - grabber.stop(); - } catch (FrameGrabber.Exception e) { - throw new IllegalStateException("A problem occurred trying to stop the frame grabber", e); - } - }); + ex.join(TimeUnit.SECONDS.toMillis(500)); + if (ex.isAlive()) { + throw new TimeoutException("Unable to terminate video feed from Web Camera"); + } + // This should only be removed if the thread is successfully killed off + frameThread = Optional.empty(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + //TODO: Move this into a logging framework + System.err.println("Caught Exception:"); + e.printStackTrace(); } finally { - // This will always run even if we fail to stop the grabber - grabber = Optional.empty(); + // This will always run even if a timeout exception occurs + try { + // Calling this multiple times will have no effect + grabber.stop(); + } catch (FrameGrabber.Exception e) { + throw new IllegalStateException("A problem occurred trying to stop the frame grabber", e); + } } + } else { + throw new IllegalStateException("Tried to stop a Webcam that is already stopped."); } - } else { - throw new IllegalStateException("Tried to stop a Webcam that is already stopped."); } + eventBus.post(new StartedStoppedEvent(this)); + frameRateOutputSocket.setValue(0); + } + + @Override + public synchronized boolean isStarted() { + return this.frameThread.isPresent() && this.frameThread.get().isAlive(); } @Subscribe public void onSourceRemovedEvent(SourceRemovedEvent event) throws TimeoutException { if (event.getSource() == this) { - this.stopVideo(); - this.eventBus.unregister(this); + try { + if (this.isStarted()) this.stop(); + } finally { + this.eventBus.unregister(this); + } } } diff --git a/core/src/main/java/edu/wpi/grip/core/sources/ImageFileSource.java b/core/src/main/java/edu/wpi/grip/core/sources/ImageFileSource.java index 74c8e0d047..c802ee7e9b 100644 --- a/core/src/main/java/edu/wpi/grip/core/sources/ImageFileSource.java +++ b/core/src/main/java/edu/wpi/grip/core/sources/ImageFileSource.java @@ -7,6 +7,7 @@ import edu.wpi.grip.core.SocketHint; import edu.wpi.grip.core.SocketHints; import edu.wpi.grip.core.Source; +import edu.wpi.grip.core.util.ImageLoadingUtility; import org.bytedeco.javacpp.opencv_core.Mat; import org.bytedeco.javacpp.opencv_imgcodecs; @@ -23,7 +24,7 @@ * Provides a way to generate a {@link Mat} from an image on the filesystem. */ @XStreamAlias(value = "grip:ImageFile") -public class ImageFileSource extends Source { +public final class ImageFileSource extends Source { private final String PATH_PROPERTY = "path"; @@ -31,6 +32,7 @@ public class ImageFileSource extends Source { private String path; private final SocketHint imageOutputHint = SocketHints.Inputs.createMatSocketHint("Image", true); private OutputSocket outputSocket; + private EventBus eventBus; /** * @param eventBus The event bus for the pipeline. @@ -47,10 +49,8 @@ public ImageFileSource() { } private void initialize(EventBus eventBus, String path) throws IOException { - checkNotNull(eventBus, "Event Bus was null."); - checkNotNull(path, "Path was null"); - - this.path = path; + this.eventBus = checkNotNull(eventBus, "Event Bus was null."); + this.path = checkNotNull(path, "Path was null"); this.name = Files.getNameWithoutExtension(this.path); this.outputSocket = new OutputSocket<>(eventBus, imageOutputHint); @@ -80,10 +80,10 @@ public void createFromProperties(EventBus eventBus, Properties properties) throw if (path == null) { throw new IllegalArgumentException("Cannot create ImageFileSource without a path."); } - this.initialize(eventBus, path); } + /** * Loads the image and posts an update to the {@link EventBus} * @@ -93,20 +93,9 @@ private void loadImage(String path) throws IOException { this.loadImage(path, opencv_imgcodecs.IMREAD_COLOR); } - /** - * Loads the image and posts an update to the {@link EventBus} - * - * @param path The location on the file system where the image exists. - * @param flags Flags to pass to imread {@link opencv_imgcodecs#imread(String, int)} - */ + private void loadImage(String path, final int flags) throws IOException { - Mat mat = opencv_imgcodecs.imread(path, flags); - if (!mat.empty()) { - mat.copyTo(this.outputSocket.getValue().get()); - this.outputSocket.setValue(this.outputSocket.getValue().get()); - } else { - // TODO Output Error to GUI about invalid url - throw new IOException("Error loading image " + path); - } + ImageLoadingUtility.loadImage(path, flags, this.outputSocket.getValue().get()); + this.outputSocket.setValue(this.outputSocket.getValue().get()); } } diff --git a/core/src/main/java/edu/wpi/grip/core/sources/MultiImageFileSource.java b/core/src/main/java/edu/wpi/grip/core/sources/MultiImageFileSource.java new file mode 100644 index 0000000000..2862f77b91 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/sources/MultiImageFileSource.java @@ -0,0 +1,167 @@ +package edu.wpi.grip.core.sources; + + +import com.google.common.eventbus.EventBus; +import com.google.common.math.IntMath; +import com.thoughtworks.xstream.annotations.XStreamAlias; +import edu.wpi.grip.core.*; +import edu.wpi.grip.core.util.ImageLoadingUtility; +import org.bytedeco.javacpp.opencv_core.Mat; + +import java.io.File; +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import static com.google.common.base.Preconditions.checkElementIndex; +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * A Source that supports multiple images. They can be toggled using {@link MultiImageFileSource#next()} and + * {@link MultiImageFileSource#previous()} + */ +@XStreamAlias(value = "grip:MultiImageFile") +public final class MultiImageFileSource extends Source implements PreviousNext { + private static final String INDEX_PROPERTY = "index"; + private static final String SIZE_PROPERTY = "numImages"; + + private final SocketHint imageOutputHint = SocketHints.Inputs.createMatSocketHint("Image", true); + private OutputSocket outputSocket; + + private EventBus eventBus; + private List paths; + private Mat[] images; + private AtomicInteger index; + + /** + * @param eventBus The event bus. + * @param files A list of files to be loaded. + * @param index The index to use as the first file that is in the socket. + * @throws IOException If the source fails to load any of the images + */ + public MultiImageFileSource(final EventBus eventBus, final List files, int index) throws IOException { + super(); + this.initialize(eventBus, files.stream() + .map(file -> URLDecoder.decode(Paths.get(file.toURI()).toString())) + .collect(Collectors.toList()), index); + } + + public MultiImageFileSource(final EventBus eventBus, final List files) throws IOException { + this(eventBus, files, 0); + } + + /** + * Used only for serialization + */ + public MultiImageFileSource() { + super(); + } + + + @SuppressWarnings("unchecked") + private void initialize(final EventBus eventBus, final List paths, int index) throws IOException { + this.eventBus = checkNotNull(eventBus, "Event bus can not be null"); + this.paths = checkNotNull(paths, "The paths can not be null"); + this.outputSocket = new OutputSocket(eventBus, imageOutputHint); + this.index = new AtomicInteger(checkElementIndex(index, paths.size(), "File List Index")); + this.images = createImagesArray(paths); + this.outputSocket.setValue(addIndexAndGetImageByOffset(0)); + } + + @Override + public String getName() { + return "Multi-Image"; + } + + @Override + protected OutputSocket[] createOutputSockets() { + return new OutputSocket[]{ + outputSocket + }; + } + + @Override + public Properties getProperties() { + final Properties properties = new Properties(); + properties.setProperty(SIZE_PROPERTY, Integer.toString(paths.size())); + properties.setProperty(INDEX_PROPERTY, Integer.toString(index.get())); + for (int i = 0; i < paths.size(); i++) { + properties.setProperty(getPathProperty(i), paths.get(i)); + } + return properties; + } + + @Override + public void createFromProperties(EventBus eventBus, Properties properties) throws IOException { + final int index = Integer.valueOf(properties.getProperty(INDEX_PROPERTY)); + final int size = Integer.valueOf(properties.getProperty(SIZE_PROPERTY)); + final List paths = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + paths.add(properties.getProperty(getPathProperty(i))); + } + this.initialize(eventBus, paths, index); + } + + /** + * Adds the delta to the index value and returns the matrix at that index (Circular). + * If the delta moves the index pointer outside of the bounds of the image array the number will + * 'overflow' to remain within the bounds of the image array. + * + * @param delta the value to add to the index when getting the image + * @return The matrix at the given index in the array. + */ + private Mat addIndexAndGetImageByOffset(final int delta) { + final int listSize = images.length; + final int newMatIndex = index.updateAndGet(currentIndex -> { + assert currentIndex >= 0 : "The current index should never be less than zero"; + assert currentIndex < listSize : "The current index should always be less than the size of the list"; + // No need to do any more calculations because there is no change. + if (delta == 0) return currentIndex; + return IntMath.mod(currentIndex + delta, listSize); + }); + return images[newMatIndex]; + } + + /** + * Assigns the output socket to the next image. (Wraps around) + */ + @Override + public final void next() { + outputSocket.setValue(addIndexAndGetImageByOffset(+1)); + } + + /** + * Assigns the output socket to the previous image. (Wraps around) + */ + @Override + public final void previous() { + outputSocket.setValue(addIndexAndGetImageByOffset(-1)); + } + + private static String getPathProperty(int index) { + return "path[" + index + "]"; + } + + /** + * Creates an array of mats from the paths on the filesystem. + * This pre-loads them into memory so that they can be accessed quickly. + * + * @param paths The paths of all of the images. + * @return The list of Mats loaded from the file system. This array will have the same number of elements as paths. + * @throws IOException if one of the images fails to load + */ + private static Mat[] createImagesArray(List paths) throws IOException { + final Mat[] images = new Mat[paths.size()]; + for (int i = 0; i < paths.size(); i++) { + final Mat image = new Mat(); + ImageLoadingUtility.loadImage(paths.get(i), image); + images[i] = image; + } + return images; + } +} diff --git a/core/src/main/java/edu/wpi/grip/core/util/ImageLoadingUtility.java b/core/src/main/java/edu/wpi/grip/core/util/ImageLoadingUtility.java new file mode 100644 index 0000000000..6a7304e1ca --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/util/ImageLoadingUtility.java @@ -0,0 +1,39 @@ +package edu.wpi.grip.core.util; + + +import org.bytedeco.javacpp.opencv_core.Mat; +import org.bytedeco.javacpp.opencv_imgcodecs; + +import java.io.IOException; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * A utility wrapper for loading images from the file system. + */ +public final class ImageLoadingUtility { + private ImageLoadingUtility() { /* no op */ } + + public static void loadImage(String path, Mat dst) throws IOException { + loadImage(path, opencv_imgcodecs.IMREAD_COLOR, dst); + } + + /** + * Loads the image into the destination Mat + * + * @param path The location on the file system where the image exists. + * @param flags Flags to pass to imread {@link opencv_imgcodecs#imread(String, int)} + * @param dst The matrix to load the image into. + */ + public static void loadImage(String path, final int flags, Mat dst) throws IOException { + checkNotNull(path, "The path can not be null"); + checkNotNull(dst, "The destination Mat can not be null"); + final Mat img = opencv_imgcodecs.imread(path, flags); + if (img != null && !img.empty() && !img.isNull()) { + img.copyTo(dst); + } else { + throw new IOException("Error loading image " + path); + } + } + +} diff --git a/core/src/test/java/edu/wpi/grip/core/sources/ImageFileSourceTest.java b/core/src/test/java/edu/wpi/grip/core/sources/ImageFileSourceTest.java index 3c6f767f20..5c2d113b58 100644 --- a/core/src/test/java/edu/wpi/grip/core/sources/ImageFileSourceTest.java +++ b/core/src/test/java/edu/wpi/grip/core/sources/ImageFileSourceTest.java @@ -2,6 +2,8 @@ import com.google.common.eventbus.EventBus; import edu.wpi.grip.core.OutputSocket; +import edu.wpi.grip.util.Files; +import edu.wpi.grip.util.ImageWithData; import org.bytedeco.javacpp.opencv_core.Mat; import org.junit.Before; import org.junit.Test; @@ -17,30 +19,26 @@ * */ public class ImageFileSourceTest { - private File imageFile; - private File textFile; + private final ImageWithData imageFile = Files.imageFile; + private final File textFile = Files.textFile; private static EventBus eventBus; @Before public void setUp() throws URISyntaxException { this.eventBus = new EventBus(); - textFile = new File(ImageFileSourceTest.class.getResource("/edu/wpi/grip/images/NotAnImage.txt").toURI()); - imageFile = new File(ImageFileSourceTest.class.getResource("/edu/wpi/grip/images/GRIP_Logo.png").toURI()); } @Test public void testLoadImageToMat() throws IOException { // Given above setup // When - final ImageFileSource fileSource = new ImageFileSource(eventBus, this.imageFile); + final ImageFileSource fileSource = new ImageFileSource(eventBus, this.imageFile.file); OutputSocket outputSocket = fileSource.getOutputSockets()[0]; // Then - assertNotNull("The output socket's value was null.", outputSocket.getValue()); + assertTrue("The output socket's value was empty.", outputSocket.getValue().isPresent()); - // Check that the image that is read in is 2 dimentional - assertEquals("Matrix from loaded image did not have expected number of rows.", 183, outputSocket.getValue().get().rows()); - assertEquals("Matrix from loaded image did not have expected number of cols.", 480, outputSocket.getValue().get().cols()); + imageFile.assertSameImage(outputSocket.getValue().get()); } @Test(expected = IOException.class) diff --git a/core/src/test/java/edu/wpi/grip/core/sources/MultiImageFileSourceTest.java b/core/src/test/java/edu/wpi/grip/core/sources/MultiImageFileSourceTest.java new file mode 100644 index 0000000000..df4e6f988d --- /dev/null +++ b/core/src/test/java/edu/wpi/grip/core/sources/MultiImageFileSourceTest.java @@ -0,0 +1,66 @@ +package edu.wpi.grip.core.sources; + +import com.google.common.eventbus.EventBus; +import edu.wpi.grip.core.OutputSocket; +import edu.wpi.grip.util.Files; +import edu.wpi.grip.util.ImageWithData; +import org.bytedeco.javacpp.opencv_core.Mat; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.Properties; + + +public class MultiImageFileSourceTest { + private static final ImageWithData imageFile = Files.imageFile, gompeiJpegFile = Files.gompeiJpegFile; + private static final File textFile = Files.textFile; + private MultiImageFileSource source; + private MultiImageFileSource sourceWithIndexSet; + + @Before + public void setUp() throws IOException { + source = new MultiImageFileSource( + new EventBus(), + Arrays.asList(imageFile.file, gompeiJpegFile.file)); + sourceWithIndexSet = new MultiImageFileSource( + new EventBus(), + Arrays.asList(imageFile.file, gompeiJpegFile.file), 1); + } + + @Test(expected = IOException.class) + public void createMultiImageFileSourceWithTextFile() throws IOException { + new MultiImageFileSource(new EventBus(), Arrays.asList(imageFile.file, gompeiJpegFile.file, textFile)); + } + + @Test + public void testNextValue() throws Exception { + source.next(); + OutputSocket outputSocket = source.getOutputSockets()[0]; + gompeiJpegFile.assertSameImage(outputSocket.getValue().get()); + } + + @Test + public void testPreviousValue() throws Exception { + source.previous(); + OutputSocket outputSocket = source.getOutputSockets()[0]; + gompeiJpegFile.assertSameImage(outputSocket.getValue().get()); + } + + @Test + public void testConstructedWithIndex() { + OutputSocket outputSocket = sourceWithIndexSet.getOutputSockets()[0]; + gompeiJpegFile.assertSameImage(outputSocket.getValue().get()); + } + + @Test + public void testLoadFromProperties() throws Exception { + final Properties properties = sourceWithIndexSet.getProperties(); + final MultiImageFileSource newSource = new MultiImageFileSource(); + newSource.createFromProperties(new EventBus(), properties); + OutputSocket outputSocket = newSource.getOutputSockets()[0]; + gompeiJpegFile.assertSameImage(outputSocket.getValue().get()); + } +} \ No newline at end of file diff --git a/core/src/test/java/edu/wpi/grip/util/Files.java b/core/src/test/java/edu/wpi/grip/util/Files.java new file mode 100644 index 0000000000..45bf92cf4b --- /dev/null +++ b/core/src/test/java/edu/wpi/grip/util/Files.java @@ -0,0 +1,23 @@ +package edu.wpi.grip.util; + +import java.io.File; +import java.net.URISyntaxException; + +/** + * Utility class for files that may be used in tests + */ +public class Files { + public static final ImageWithData imageFile, gompeiJpegFile; + public static final File textFile; + static { + try { + textFile = new File(Files.class.getResource("/edu/wpi/grip/images/NotAnImage.txt").toURI()); + imageFile = new ImageWithData(new File( + Files.class.getResource("/edu/wpi/grip/images/GRIP_Logo.png").toURI()), 183, 480); + gompeiJpegFile = new ImageWithData(new File( + Files.class.getResource("/edu/wpi/grip/images/gompei.jpeg").toURI()), 220, 225); + } catch (URISyntaxException e) { + throw new IllegalStateException("Could not load file from system", e); + } + } +} diff --git a/core/src/test/java/edu/wpi/grip/util/ImageWithData.java b/core/src/test/java/edu/wpi/grip/util/ImageWithData.java new file mode 100644 index 0000000000..b3e24a6bf3 --- /dev/null +++ b/core/src/test/java/edu/wpi/grip/util/ImageWithData.java @@ -0,0 +1,27 @@ +package edu.wpi.grip.util; + + +import org.bytedeco.javacpp.opencv_core.Mat; + +import java.io.File; + +import static org.junit.Assert.assertEquals; + +public class ImageWithData { + private final int rows; + private final int cols; + public final File file; + + protected ImageWithData(File file, int rows, int cols) { + this.file = file; + this.rows = rows; + this.cols = cols; + } + + public void assertSameImage(final Mat image) { + // Check that the image that is read in is 2 dimensional + assertEquals("Matrix from loaded image did not have expected number of rows.", this.rows , image.rows()); + assertEquals("Matrix from loaded image did not have expected number of cols.", this.cols, image.cols()); + } + +} diff --git a/core/src/test/resources/edu/wpi/grip/images/gompei.jpeg b/core/src/test/resources/edu/wpi/grip/images/gompei.jpeg new file mode 100644 index 0000000000..99ae71249c Binary files /dev/null and b/core/src/test/resources/edu/wpi/grip/images/gompei.jpeg differ diff --git a/ui/src/main/java/edu/wpi/grip/ui/components/PreviousNextButtons.java b/ui/src/main/java/edu/wpi/grip/ui/components/PreviousNextButtons.java new file mode 100644 index 0000000000..d1577274ef --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/components/PreviousNextButtons.java @@ -0,0 +1,86 @@ +package edu.wpi.grip.ui.components; + +import edu.wpi.grip.core.PreviousNext; +import edu.wpi.grip.ui.pipeline.source.CameraSourceView; +import edu.wpi.grip.ui.util.DPIUtility; +import javafx.scene.Node; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.Tooltip; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import org.controlsfx.control.SegmentedButton; + +import java.util.function.Consumer; + +/** + * A button that can be used to control anything that is {@link PreviousNext}. + */ +public final class PreviousNextButtons extends SegmentedButton { + protected static final String + NEXT_BUTTON_STYLE_CLASS = "next-button", + PREVIOUS_BUTTON_STYLE_CLASS = "previous-button"; + + private static final Image + nextImage = new Image(CameraSourceView.class.getResourceAsStream("/edu/wpi/grip/ui/icons/next.png")), + previousImage = new Image(CameraSourceView.class.getResourceAsStream("/edu/wpi/grip/ui/icons/previous.png")); + + private final ToggleButton previousButton; + private final ToggleButton nextButton; + + public PreviousNextButtons(PreviousNext switchable) { + super(); + + /** + * A ToggleButton that will only say selected long enough for the consumer action to be performed. + */ + class NonTogglingToggleButton extends ToggleButton { + private NonTogglingToggleButton(Node graphic, String message, Consumer switchAction, String styleClass) { + super(null, graphic); + + this.selectedProperty().addListener((observable, oldV, newV) -> { + // Only run when the button is selected. + if (!newV) return; + switchAction.accept(switchable); + // Now that the action has run we can deselect this button. + this.setSelected(false); + }); + + this.setTooltip(new Tooltip(message)); + this.setAccessibleText(message); + this.getStyleClass().add(styleClass); + } + } + + this.previousButton = new NonTogglingToggleButton( + createButtonGraphic(previousImage), "Previous", PreviousNext::previous, PREVIOUS_BUTTON_STYLE_CLASS); + this.nextButton = new NonTogglingToggleButton( + createButtonGraphic(nextImage), "Next", PreviousNext::next, NEXT_BUTTON_STYLE_CLASS); + + getButtons().addAll(previousButton, nextButton); + } + + // Intentionally left package private for testing + ToggleButton getPreviousButton() { + return previousButton; + } + + // Intentionally left package private for testing + ToggleButton getNextButton() { + return nextButton; + } + + + /** + * Creates the buttons Graphic at the right resolution for the control. + * + * @param image The image to use as the graphic + * @return The graphic for the button. + */ + private Node createButtonGraphic(Image image) { + final ImageView icon = new ImageView(image); + icon.setFitHeight(DPIUtility.MINI_ICON_SIZE); + icon.setFitWidth(DPIUtility.MINI_ICON_SIZE); + return icon; + } + +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/components/StartStoppableButton.java b/ui/src/main/java/edu/wpi/grip/ui/components/StartStoppableButton.java new file mode 100644 index 0000000000..4c8089d419 --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/components/StartStoppableButton.java @@ -0,0 +1,120 @@ +package edu.wpi.grip.ui.components; + + +import com.google.common.eventbus.EventBus; +import com.google.common.eventbus.Subscribe; +import edu.wpi.grip.core.StartStoppable; +import edu.wpi.grip.core.events.StartedStoppedEvent; +import edu.wpi.grip.core.events.UnexpectedThrowableEvent; +import edu.wpi.grip.ui.util.DPIUtility; +import javafx.application.Platform; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.Tooltip; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeoutException; + +import static com.google.common.base.Preconditions.checkNotNull; + +public final class StartStoppableButton extends ToggleButton { + protected static final String BASE_STYLE_CLASS = "start-stoppable-button"; + protected static final String STARTED_STYLE_CLASS = "started"; + protected static final String STOPPED_STYLE_CLASS = "stopped"; + + private static final Image startImage = new Image(StartStoppableButton.class.getResourceAsStream("/edu/wpi/grip/ui/icons/start.png")); + private static final Image stopImage = new Image(StartStoppableButton.class.getResourceAsStream("/edu/wpi/grip/ui/icons/stop.png")); + + private final Tooltip startStopTooltip; + + private final StartStoppable startStoppable; + + public StartStoppableButton(final EventBus eventBus, final StartStoppable startStoppable) { + super(null, pickGraphic(startStoppable)); + this.startStoppable = checkNotNull(startStoppable, "StartStoppable can not be null"); + this.startStopTooltip = new Tooltip(getButtonActionString()); + setContentDisplay(ContentDisplay.RIGHT); + assignState(); + setAllFromState(); + + addEventFilter(MouseEvent.MOUSE_RELEASED, (event) -> { + event.consume(); + if (!isSelected()) try { + startStoppable.start(eventBus); + // If this fails then an StartedStoppedEvent will not be posted + } catch (IOException e) { + eventBus.post(new UnexpectedThrowableEvent(e, "Failed to start")); + } + else try { + startStoppable.stop(); + // If this fails then an StartedStoppedEvent will not be posted + } catch (TimeoutException | IOException e) { + eventBus.post(new UnexpectedThrowableEvent(e, "Failed to stop")); + } + }); + + selectedProperty().addListener((o, oldV, newV) -> { + setAllFromState(); + }); + HBox.setHgrow(this, Priority.NEVER); + + eventBus.register(this); + } + + private void setAllFromState() { + assert Platform.isFxApplicationThread() : "This must be called from the FX Thread"; + startStopTooltip.setText(getButtonActionString()); + setAccessibleText(getButtonActionString()); + setGraphic(pickGraphic(startStoppable)); + getStyleClass().addAll(getCurrentStyleClasses()); + } + + /** + * @return The description of what action clicking the button will have. + */ + private String getButtonActionString() { + return (startStoppable.isStarted() ? "Stop" : "Start"); + } + + /** + * @return The style classes that should be assigned to the button + */ + private List getCurrentStyleClasses() { + return Arrays.asList(BASE_STYLE_CLASS, startStoppable.isStarted() ? STARTED_STYLE_CLASS : STOPPED_STYLE_CLASS); + } + + /** + * Assigns the state of the button from the {@link StartStoppable} + */ + private void assignState() { + setSelected(startStoppable.isStarted()); + } + + + @Subscribe + public void onStartedStopped(StartedStoppedEvent event) { + if (startStoppable.equals(event.getStartStoppable())) { + Platform.runLater(this::assignState); + } + } + + /** + * Gets the graphic that should be used for the button given the current source's state + * + * @return The graphic to show on the button. + */ + private static ImageView pickGraphic(StartStoppable startStoppable) { + final ImageView icon = startStoppable.isStarted() ? new ImageView(stopImage) : new ImageView(startImage); + icon.setFitHeight(DPIUtility.MINI_ICON_SIZE); + icon.setFitWidth(DPIUtility.MINI_ICON_SIZE); + return icon; + } + +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/pipeline/AddSourceView.java b/ui/src/main/java/edu/wpi/grip/ui/pipeline/AddSourceView.java index bdb045d154..5a039c77d1 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/pipeline/AddSourceView.java +++ b/ui/src/main/java/edu/wpi/grip/ui/pipeline/AddSourceView.java @@ -1,17 +1,22 @@ package edu.wpi.grip.ui.pipeline; import com.google.common.eventbus.EventBus; -import edu.wpi.grip.core.events.UnexpectedThrowableEvent; import edu.wpi.grip.core.events.SourceAddedEvent; +import edu.wpi.grip.core.events.UnexpectedThrowableEvent; import edu.wpi.grip.core.sources.CameraSource; import edu.wpi.grip.core.sources.ImageFileSource; +import edu.wpi.grip.core.sources.MultiImageFileSource; import edu.wpi.grip.ui.util.DPIUtility; +import javafx.application.Platform; import javafx.event.EventHandler; import javafx.scene.Parent; import javafx.scene.control.*; import javafx.scene.image.ImageView; import javafx.scene.input.MouseEvent; +import javafx.scene.layout.GridPane; import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.text.Text; import javafx.scene.text.TextAlignment; import javafx.stage.FileChooser; @@ -21,8 +26,11 @@ import java.net.URISyntaxException; import java.net.URL; import java.util.List; +import java.util.function.Consumer; import java.util.function.Predicate; +import static com.google.common.base.Preconditions.checkNotNull; + /** * A box of buttons that let the user add different kinds of {@link edu.wpi.grip.core.Source Source}s. Depending on which button is pressed, * a different dialog is presented for the user to construct that source. As an example, the image file source results @@ -32,12 +40,37 @@ public class AddSourceView extends HBox { private final EventBus eventBus; + @FunctionalInterface + private interface SupplierWithIO { + T getWithIO() throws IOException; + } + + private class SourceDialog extends Dialog { + private final Text errorText = new Text(); + + private SourceDialog(final Parent root, Control inputField) { + super(); + final GridPane gridContent = new GridPane(); + gridContent.setMaxWidth(Double.MAX_VALUE); + GridPane.setHgrow(inputField, Priority.ALWAYS); + GridPane.setHgrow(errorText, Priority.NEVER); + errorText.wrappingWidthProperty().bind(inputField.widthProperty()); + gridContent.add(errorText, 0, 0); + gridContent.add(inputField, 0, 1); + + getDialogPane().setContent(gridContent); + getDialogPane().setStyle(root.getStyle()); + getDialogPane().getStylesheets().addAll(root.getStylesheets()); + getDialogPane().getButtonTypes().addAll(ButtonType.CANCEL, ButtonType.OK); + } + } + public AddSourceView(EventBus eventBus) { - this.eventBus = eventBus; + this.eventBus = checkNotNull(eventBus, "Event Bus can not be null"); this.setFillHeight(true); - addButton("Add\nImage", getClass().getResource("/edu/wpi/grip/ui/icons/add-image.png"), mouseEvent -> { + addButton("Add\nImage(s)", getClass().getResource("/edu/wpi/grip/ui/icons/add-image.png"), mouseEvent -> { // Show a file picker so the user can open one or more images from disk final FileChooser fileChooser = new FileChooser(); fileChooser.setTitle("Open an image"); @@ -47,48 +80,47 @@ public AddSourceView(EventBus eventBus) { if (imageFiles == null) return; // Add a new source for each image . - imageFiles.forEach(file -> { + if (imageFiles.size() == 1) { try { - eventBus.post(new SourceAddedEvent(new ImageFileSource(eventBus, file))); + eventBus.post(new SourceAddedEvent(new ImageFileSource(eventBus, imageFiles.get(0)))); } catch (IOException e) { - eventBus.post(new UnexpectedThrowableEvent(e, "Tried to create an invalid source")); + eventBus.post(new UnexpectedThrowableEvent(e, "The image selected was invalid")); } - }); + } else { + try { + eventBus.post(new SourceAddedEvent(new MultiImageFileSource(eventBus, imageFiles))); + } catch (IOException e) { + eventBus.post(new UnexpectedThrowableEvent(e, "One of the images selected was invalid")); + } + } }); addButton("Add\nWebcam", getClass().getResource("/edu/wpi/grip/ui/icons/add-webcam.png"), mouseEvent -> { final Parent root = this.getScene().getRoot(); // Show a dialog for the user to pick a camera index - final Dialog dialog = new Dialog<>(); final Spinner cameraIndex = new Spinner(0, Integer.MAX_VALUE, 0); + final SourceDialog dialog = new SourceDialog(root, cameraIndex); dialog.setTitle("Add Webcam"); dialog.setHeaderText("Choose a camera"); dialog.setContentText("index"); - dialog.getDialogPane().setContent(cameraIndex); - dialog.getDialogPane().getButtonTypes().addAll(ButtonType.CANCEL, ButtonType.OK); - dialog.getDialogPane().setStyle(root.getStyle()); - dialog.getDialogPane().getStylesheets().addAll(root.getStylesheets()); // If the user clicks OK, add a new camera source - dialog.showAndWait().filter(Predicate.isEqual(ButtonType.OK)).ifPresent(result -> { - try { - final CameraSource source = new CameraSource(eventBus, cameraIndex.getValue()); - eventBus.post(new SourceAddedEvent(source)); - } catch (IOException e) { - eventBus.post(new UnexpectedThrowableEvent(e, "Tried to create an invalid source")); - } - }); + loadCamera(dialog, + () -> new CameraSource(eventBus, cameraIndex.getValue()).start(eventBus), + e -> { + dialog.errorText.setText(e.getMessage()); + }); }); addButton("Add IP\nCamera", getClass().getResource("/edu/wpi/grip/ui/icons/add-webcam.png"), mouseEvent -> { final Parent root = this.getScene().getRoot(); // Show a dialog for the user to pick a camera URL - final Dialog dialog = new Dialog<>(); final TextField cameraAddress = new TextField(); + final SourceDialog dialog = new SourceDialog(root, cameraAddress); cameraAddress.setPromptText("Ex: http://10.1.90.11/mjpg/video.mjpg"); cameraAddress.textProperty().addListener(observable -> { boolean validURL = true; @@ -106,24 +138,38 @@ public AddSourceView(EventBus eventBus) { dialog.setTitle("Add IP Camera"); dialog.setHeaderText("Enter the IP camera URL"); dialog.setContentText("URL"); - dialog.getDialogPane().setContent(cameraAddress); - dialog.getDialogPane().getButtonTypes().addAll(ButtonType.CANCEL, ButtonType.OK); dialog.getDialogPane().lookupButton(ButtonType.OK).setDisable(true); - dialog.getDialogPane().setStyle(root.getStyle()); - dialog.getDialogPane().getStylesheets().addAll(root.getStylesheets()); // If the user clicks OK, add a new camera source - dialog.showAndWait().filter(Predicate.isEqual(ButtonType.OK)).ifPresent(result -> { - try { - final CameraSource source = new CameraSource(eventBus, cameraAddress.getText()); - eventBus.post(new SourceAddedEvent(source)); - } catch (IOException e) { - eventBus.post(new UnexpectedThrowableEvent(e, "Tried to create an invalid source")); - } - }); + loadCamera(dialog, + () -> new CameraSource(eventBus, cameraAddress.getText()).start(eventBus), + e -> { + dialog.errorText.setText(e.getMessage()); + }); }); } + /** + * @param dialog The dialog to load the camera with + * @param cameraSourceSupplier The supplier that will create the camera + * @param failureCallback The handler for when the camera source supplier throws an IO Exception + */ + private void loadCamera(Dialog dialog, SupplierWithIO cameraSourceSupplier, Consumer failureCallback) { + assert Platform.isFxApplicationThread() : "Should only run in FX thread"; + dialog.showAndWait().filter(Predicate.isEqual(ButtonType.OK)).ifPresent(result -> { + try { + // Will try to create the camera with the values from the supplier + final CameraSource source = cameraSourceSupplier.getWithIO(); + eventBus.post(new SourceAddedEvent(source)); + } catch (IOException e) { + // This will run it again with the new values retrieved by the supplier + failureCallback.accept(e); + Platform.runLater(() -> loadCamera(dialog, cameraSourceSupplier, failureCallback)); + } + }); + } + + /** * Add a new button for adding a source. This method takes care of setting the event handler. */ diff --git a/ui/src/main/java/edu/wpi/grip/ui/pipeline/PipelineView.java b/ui/src/main/java/edu/wpi/grip/ui/pipeline/PipelineView.java index 25c2150e5e..497304a4f2 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/pipeline/PipelineView.java +++ b/ui/src/main/java/edu/wpi/grip/ui/pipeline/PipelineView.java @@ -5,6 +5,8 @@ import edu.wpi.grip.core.*; import edu.wpi.grip.core.events.*; import edu.wpi.grip.ui.pipeline.input.InputSocketView; +import edu.wpi.grip.ui.pipeline.source.SourceView; +import edu.wpi.grip.ui.pipeline.source.SourceViewFactory; import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.property.ReadOnlyObjectProperty; @@ -140,7 +142,7 @@ private OutputSocketView findOutputSocketView(OutputSocket socket) { } } - for (SourceView sourceView : this.getSources()) { + for (SourceView sourceView : this.getSources()) { for (OutputSocketView socketView : sourceView.getOutputSockets()) { if (socketView.getSocket() == socket) { return socketView; @@ -235,7 +237,9 @@ private void addConnectionView(Connection connection) { @Subscribe public void onSourceAdded(SourceAddedEvent event) { - Platform.runLater(() -> this.sources.getChildren().add(new SourceView(this.eventBus, event.getSource()))); + Platform.runLater(() -> + this.sources.getChildren().add( + SourceViewFactory.createSourceControlsView(eventBus, event.getSource()))); } @Subscribe diff --git a/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/CameraSourceView.java b/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/CameraSourceView.java new file mode 100644 index 0000000000..b95b39907b --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/CameraSourceView.java @@ -0,0 +1,17 @@ +package edu.wpi.grip.ui.pipeline.source; + +import com.google.common.eventbus.EventBus; +import edu.wpi.grip.core.sources.CameraSource; +import edu.wpi.grip.ui.components.StartStoppableButton; + +/** + * Provides controls for a {@link CameraSource} + */ +public final class CameraSourceView extends SourceView { + + public CameraSourceView(EventBus eventBus, CameraSource cameraSource) { + super(eventBus, cameraSource); + addControls(new StartStoppableButton(eventBus, cameraSource)); + } + +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/MultiImageFileSourceView.java b/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/MultiImageFileSourceView.java new file mode 100644 index 0000000000..6cd9f695ee --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/MultiImageFileSourceView.java @@ -0,0 +1,17 @@ +package edu.wpi.grip.ui.pipeline.source; + + +import com.google.common.eventbus.EventBus; +import edu.wpi.grip.core.sources.MultiImageFileSource; +import edu.wpi.grip.ui.components.PreviousNextButtons; + +/** + * Provides controls for a {@link MultiImageFileSource} + */ +public final class MultiImageFileSourceView extends SourceView { + + public MultiImageFileSourceView(final EventBus eventBus, final MultiImageFileSource source) { + super(eventBus, source); + addControls(new PreviousNextButtons(source)); + } +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/pipeline/SourceView.java b/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/SourceView.java similarity index 66% rename from ui/src/main/java/edu/wpi/grip/ui/pipeline/SourceView.java rename to ui/src/main/java/edu/wpi/grip/ui/pipeline/source/SourceView.java index 775e0df9a5..2441b73aec 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/pipeline/SourceView.java +++ b/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/SourceView.java @@ -1,23 +1,31 @@ -package edu.wpi.grip.ui.pipeline; +package edu.wpi.grip.ui.pipeline.source; import com.google.common.eventbus.EventBus; import edu.wpi.grip.core.OutputSocket; import edu.wpi.grip.core.Source; import edu.wpi.grip.core.events.SourceRemovedEvent; +import edu.wpi.grip.ui.pipeline.OutputSocketView; +import edu.wpi.grip.ui.pipeline.StepView; import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; +import javafx.scene.Node; import javafx.scene.control.Label; +import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import java.io.IOException; +import static com.google.common.base.Preconditions.checkNotNull; + /** * A JavaFX control that represents a {@link Source}. SourceViews are somewhat analogous to * {@link StepView}s in thatthe pipeline contrains them and they contain some sockets, but SourceViews * only have output sockets, and they show up in a different place. + * + * @param The type of Source this view is for. */ -public class SourceView extends VBox { +public abstract class SourceView extends VBox { @FXML private Label name; @@ -25,12 +33,15 @@ public class SourceView extends VBox { @FXML private VBox sockets; + @FXML + private HBox controls; + private final EventBus eventBus; - private final Source source; + private final S source; - public SourceView(EventBus eventBus, Source source) { - this.eventBus = eventBus; - this.source = source; + public SourceView(EventBus eventBus, S source) { + this.eventBus = checkNotNull(eventBus, "The EventBus can not be null"); + this.source = checkNotNull(source, "The Source can not be null"); try { FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("Source.fxml")); @@ -48,10 +59,17 @@ public SourceView(EventBus eventBus, Source source) { } } - public Source getSource() { + public S getSource() { return this.source; } + /** + * Adds the given Nodes to the node that should hold the controls. + */ + protected void addControls(Node... control) { + controls.getChildren().addAll(control); + } + /** * @return An unmodifiable list of {@link OutputSocketView}s corresponding to the sockets that this source produces */ @@ -64,4 +82,5 @@ public ObservableList getOutputSockets() { public void delete() { this.eventBus.post(new SourceRemovedEvent(this.getSource())); } + } diff --git a/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/SourceViewFactory.java b/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/SourceViewFactory.java new file mode 100644 index 0000000000..c70a42389c --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/SourceViewFactory.java @@ -0,0 +1,35 @@ +package edu.wpi.grip.ui.pipeline.source; + +import com.google.common.eventbus.EventBus; +import edu.wpi.grip.core.Source; +import edu.wpi.grip.core.sources.CameraSource; +import edu.wpi.grip.core.sources.MultiImageFileSource; + +/** + * Factory for creating views to control sources. + */ +public final class SourceViewFactory { + private SourceViewFactory() { /* no op */ } + + /** + * Create an instance of {@link SourceView} appropriate for the given socket. + * + * @param eventBus The EventBus + * @param source The source to create the view for + * @param The type of the source + * @return The appropriate SourceView. + */ + public static SourceView createSourceControlsView(EventBus eventBus, S source) { + final SourceView sourceView; + if (source instanceof CameraSource) { + sourceView = (SourceView) new CameraSourceView(eventBus, (CameraSource) source); + } else if (source instanceof MultiImageFileSource) { + sourceView = (SourceView) new MultiImageFileSourceView(eventBus, (MultiImageFileSource) source); + } else { + sourceView = new SourceView(eventBus, source) { + }; + } + eventBus.register(sourceView); + return sourceView; + } +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/util/DPIUtility.java b/ui/src/main/java/edu/wpi/grip/ui/util/DPIUtility.java index 4e94e0da52..b8517f32df 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/util/DPIUtility.java +++ b/ui/src/main/java/edu/wpi/grip/ui/util/DPIUtility.java @@ -11,6 +11,7 @@ public class DPIUtility { private final static double HIDPI_SCALE = 2.0; public final static double FONT_SIZE = 11.0 * (isManualHiDPI() ? HIDPI_SCALE : 1.0); + public final static double MINI_ICON_SIZE = 8.0 * (isManualHiDPI() ? HIDPI_SCALE : 1.0); public final static double SMALL_ICON_SIZE = 16.0 * (isManualHiDPI() ? HIDPI_SCALE : 1.0); public final static double LARGE_ICON_SIZE = 48.0 * (isManualHiDPI() ? HIDPI_SCALE : 1.0); public final static double STROKE_WIDTH = 2.0 * (isManualHiDPI() ? HIDPI_SCALE : 1.0); diff --git a/ui/src/main/resources/edu/wpi/grip/ui/GRIP.css b/ui/src/main/resources/edu/wpi/grip/ui/GRIP.css index ac0d55ae16..1bbd2b2c07 100644 --- a/ui/src/main/resources/edu/wpi/grip/ui/GRIP.css +++ b/ui/src/main/resources/edu/wpi/grip/ui/GRIP.css @@ -123,7 +123,7 @@ Button.add-source { -fx-spacing: 0.5em; } -Button.delete, Button.move-left, Button.move-right { +Button.delete, Button.move-left, Button.move-right, start-stoppable-button { -fx-padding: 0.2em 0.8em; -fx-font-weight: bold; -fx-font-color: -fx-dark-color; @@ -181,6 +181,10 @@ VBox.sockets { -fx-padding: 0.5em; } +.source-header-box { + -fx-padding: 0 0 0.5em 0; +} + .preview-box { -fx-spacing: 0.5em; } diff --git a/ui/src/main/resources/edu/wpi/grip/ui/icons/next.png b/ui/src/main/resources/edu/wpi/grip/ui/icons/next.png new file mode 100644 index 0000000000..d8de5e04e7 Binary files /dev/null and b/ui/src/main/resources/edu/wpi/grip/ui/icons/next.png differ diff --git a/ui/src/main/resources/edu/wpi/grip/ui/icons/previous.png b/ui/src/main/resources/edu/wpi/grip/ui/icons/previous.png new file mode 100644 index 0000000000..bfa54255d9 Binary files /dev/null and b/ui/src/main/resources/edu/wpi/grip/ui/icons/previous.png differ diff --git a/ui/src/main/resources/edu/wpi/grip/ui/icons/start.png b/ui/src/main/resources/edu/wpi/grip/ui/icons/start.png new file mode 100644 index 0000000000..d8de5e04e7 Binary files /dev/null and b/ui/src/main/resources/edu/wpi/grip/ui/icons/start.png differ diff --git a/ui/src/main/resources/edu/wpi/grip/ui/icons/stop.png b/ui/src/main/resources/edu/wpi/grip/ui/icons/stop.png new file mode 100644 index 0000000000..b113484d8e Binary files /dev/null and b/ui/src/main/resources/edu/wpi/grip/ui/icons/stop.png differ diff --git a/ui/src/main/resources/edu/wpi/grip/ui/pipeline/Source.fxml b/ui/src/main/resources/edu/wpi/grip/ui/pipeline/Source.fxml deleted file mode 100644 index 9e218253db..0000000000 --- a/ui/src/main/resources/edu/wpi/grip/ui/pipeline/Source.fxml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - -