diff --git a/core/src/main/java/edu/wpi/grip/core/FileManager.java b/core/src/main/java/edu/wpi/grip/core/FileManager.java new file mode 100644 index 0000000000..930e0ce890 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/FileManager.java @@ -0,0 +1,15 @@ +package edu.wpi.grip.core; + +/** + * A FileManager saves images to disk. + */ +public interface FileManager { + + /** + * Saves an array of bytes to a file. + * + * @param image The image to save + * @param fileName The file name to save + */ + void saveImage(byte[] image, String fileName); +} diff --git a/core/src/main/java/edu/wpi/grip/core/GripCoreModule.java b/core/src/main/java/edu/wpi/grip/core/GripCoreModule.java index f9f874cebb..5e7c5ce8b6 100644 --- a/core/src/main/java/edu/wpi/grip/core/GripCoreModule.java +++ b/core/src/main/java/edu/wpi/grip/core/GripCoreModule.java @@ -56,7 +56,9 @@ public class GripCoreModule extends AbstractModule { globalLogger.removeHandler(handler); } - final Handler fileHandler = new FileHandler("%h/GRIP.log"); //Log to the file "GRIPlogger.log" + GripFileManager.GRIP_DIRECTORY.mkdirs(); + final Handler fileHandler + = new FileHandler(GripFileManager.GRIP_DIRECTORY.getPath() + "/GRIP.log"); //Set level to handler and logger fileHandler.setLevel(Level.FINE); diff --git a/core/src/main/java/edu/wpi/grip/core/GripFileManager.java b/core/src/main/java/edu/wpi/grip/core/GripFileManager.java new file mode 100644 index 0000000000..2a5083b49a --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/GripFileManager.java @@ -0,0 +1,43 @@ +package edu.wpi.grip.core; + +import com.google.common.io.Files; +import com.google.inject.Singleton; + +import java.io.File; +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Implementation of {@code FileManager}. Saves files into a directory named GRIP in the user's + * home folder. + */ +@Singleton +public class GripFileManager implements FileManager { + + private static final Logger logger = Logger.getLogger(GripFileManager.class.getName()); + + public static final File GRIP_DIRECTORY + = new File(System.getProperty("user.home") + File.separator + "GRIP"); + public static final File IMAGE_DIRECTORY = new File(GRIP_DIRECTORY, "images"); + + @Override + public void saveImage(byte[] image, String fileName) { + checkNotNull(image, "An image was not provided to the FileManager"); + checkNotNull(fileName, "A filename was not provided to the FileManager"); + + File file = new File(IMAGE_DIRECTORY, fileName); + Thread thread = new Thread(() -> { + try { + IMAGE_DIRECTORY.mkdirs(); // If the user deletes the directory + Files.write(image, file); + } catch (IOException ex) { + logger.log(Level.WARNING, ex.getMessage(), ex); + } + }); + thread.setDaemon(true); + thread.start(); + } +} diff --git a/core/src/main/java/edu/wpi/grip/core/GripFileModule.java b/core/src/main/java/edu/wpi/grip/core/GripFileModule.java new file mode 100644 index 0000000000..25072a8c77 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/GripFileModule.java @@ -0,0 +1,12 @@ +package edu.wpi.grip.core; + +import com.google.inject.AbstractModule; + +public class GripFileModule extends AbstractModule { + + @Override + protected void configure() { + bind(FileManager.class).to(GripFileManager.class); + } + +} diff --git a/core/src/main/java/edu/wpi/grip/core/operations/Operations.java b/core/src/main/java/edu/wpi/grip/core/operations/Operations.java index 7e15c29709..7056f7e5fe 100644 --- a/core/src/main/java/edu/wpi/grip/core/operations/Operations.java +++ b/core/src/main/java/edu/wpi/grip/core/operations/Operations.java @@ -1,5 +1,6 @@ package edu.wpi.grip.core.operations; +import edu.wpi.grip.core.FileManager; import edu.wpi.grip.core.OperationMetaData; import edu.wpi.grip.core.events.OperationAddedEvent; import edu.wpi.grip.core.operations.composite.BlobsReport; @@ -21,6 +22,7 @@ import edu.wpi.grip.core.operations.composite.PublishVideoOperation; import edu.wpi.grip.core.operations.composite.RGBThresholdOperation; import edu.wpi.grip.core.operations.composite.ResizeOperation; +import edu.wpi.grip.core.operations.composite.SaveImageOperation; import edu.wpi.grip.core.operations.composite.SwitchOperation; import edu.wpi.grip.core.operations.composite.ThresholdMoving; import edu.wpi.grip.core.operations.composite.ValveOperation; @@ -65,12 +67,14 @@ public class Operations { @Named("ntManager") MapNetworkPublisherFactory ntPublisherFactory, @Named("httpManager") MapNetworkPublisherFactory httpPublishFactory, @Named("rosManager") ROSNetworkPublisherFactory rosPublishFactory, + FileManager fileManager, InputSocket.Factory isf, OutputSocket.Factory osf) { this.eventBus = checkNotNull(eventBus, "EventBus cannot be null"); checkNotNull(ntPublisherFactory, "ntPublisherFactory cannot be null"); checkNotNull(httpPublishFactory, "httpPublisherFactory cannot be null"); checkNotNull(rosPublishFactory, "rosPublishFactory cannot be null"); + checkNotNull(fileManager, "fileManager cannot be null"); this.operations = ImmutableList.of( // Composite operations new OperationMetaData(BlurOperation.DESCRIPTION, @@ -105,6 +109,8 @@ public class Operations { () -> new ResizeOperation(isf, osf)), new OperationMetaData(RGBThresholdOperation.DESCRIPTION, () -> new RGBThresholdOperation(isf, osf)), + new OperationMetaData(SaveImageOperation.DESCRIPTION, + () -> new SaveImageOperation(isf, osf, fileManager)), new OperationMetaData(SwitchOperation.DESCRIPTION, () -> new SwitchOperation(isf, osf)), new OperationMetaData(ValveOperation.DESCRIPTION, diff --git a/core/src/main/java/edu/wpi/grip/core/operations/composite/SaveImageOperation.java b/core/src/main/java/edu/wpi/grip/core/operations/composite/SaveImageOperation.java new file mode 100644 index 0000000000..37b179481a --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/operations/composite/SaveImageOperation.java @@ -0,0 +1,135 @@ +package edu.wpi.grip.core.operations.composite; + +import edu.wpi.grip.core.FileManager; +import edu.wpi.grip.core.Operation; +import edu.wpi.grip.core.OperationDescription; +import edu.wpi.grip.core.sockets.InputSocket; +import edu.wpi.grip.core.sockets.OutputSocket; +import edu.wpi.grip.core.sockets.SocketHint; +import edu.wpi.grip.core.sockets.SocketHints; +import edu.wpi.grip.core.util.Icon; + +import com.google.common.base.Stopwatch; +import com.google.common.collect.ImmutableList; + +import org.bytedeco.javacpp.BytePointer; +import org.bytedeco.javacpp.IntPointer; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.bytedeco.javacpp.opencv_core.Mat; +import static org.bytedeco.javacpp.opencv_imgcodecs.CV_IMWRITE_JPEG_QUALITY; +import static org.bytedeco.javacpp.opencv_imgcodecs.imencode; + +/** + * Save JPEG files periodically to the local disk. + */ +public class SaveImageOperation implements Operation { + + public static final OperationDescription DESCRIPTION = + OperationDescription.builder() + .name("Save Images to Disk") + .summary("Save image periodically to local disk") + .category(OperationDescription.Category.MISCELLANEOUS) + .icon(Icon.iconStream("publish-video")) + .build(); + + private final SocketHint inputHint + = SocketHints.Inputs.createMatSocketHint("Input", false); + private final SocketHint fileTypeHint + = SocketHints.createEnumSocketHint("File type", FileTypes.JPEG); + private final SocketHint qualityHint + = SocketHints.Inputs.createNumberSliderSocketHint("Quality", 90, 0, 100); + private final SocketHint periodHint + = SocketHints.Inputs.createNumberSpinnerSocketHint("Period", 0.1); + private final SocketHint activeHint + = SocketHints.Inputs.createCheckboxSocketHint("Active", false); + + private final SocketHint outputHint = SocketHints.Outputs.createMatSocketHint("Output"); + + private final InputSocket inputSocket; + private final InputSocket fileTypesSocket; + private final InputSocket qualitySocket; + private final InputSocket periodSocket; + private final InputSocket activeSocket; + + private final OutputSocket outputSocket; + + private final FileManager fileManager; + private final BytePointer imagePointer = new BytePointer(); + private final Stopwatch stopwatch = Stopwatch.createStarted(); + private final DateTimeFormatter formatter + = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss-SSS"); + + private enum FileTypes { + JPEG, PNG; + + @Override + public String toString() { + return super.toString().toLowerCase(); + } + } + + @SuppressWarnings("JavadocMethod") + public SaveImageOperation(InputSocket.Factory inputSocketFactory, + OutputSocket.Factory outputSocketFactory, + FileManager fileManager) { + this.fileManager = fileManager; + + inputSocket = inputSocketFactory.create(inputHint); + fileTypesSocket = inputSocketFactory.create(fileTypeHint); + qualitySocket = inputSocketFactory.create(qualityHint); + periodSocket = inputSocketFactory.create(periodHint); + activeSocket = inputSocketFactory.create(activeHint); + + outputSocket = outputSocketFactory.create(outputHint); + } + + @Override + public List getInputSockets() { + return ImmutableList.of( + inputSocket, + fileTypesSocket, + qualitySocket, + periodSocket, + activeSocket + ); + } + + @Override + public List getOutputSockets() { + return ImmutableList.of( + outputSocket + ); + } + + @Override + public void perform() { + if (!activeSocket.getValue().orElse(false)) { + return; + } + + // don't save new image until period expires + if (stopwatch.elapsed(TimeUnit.MILLISECONDS) + < periodSocket.getValue().get().doubleValue() * 1000L) { + return; + } + stopwatch.reset(); + stopwatch.start(); + + imencode("." + fileTypesSocket.getValue().get(), inputSocket.getValue().get(), imagePointer, + new IntPointer(CV_IMWRITE_JPEG_QUALITY, qualitySocket.getValue().get().intValue())); + byte[] buffer = new byte[128 * 1024]; + int bufferSize = imagePointer.limit(); + if (bufferSize > buffer.length) { + buffer = new byte[imagePointer.limit()]; + } + imagePointer.get(buffer, 0, bufferSize); + + fileManager.saveImage(buffer, LocalDateTime.now().format(formatter) + + "." + fileTypesSocket.getValue().get()); + } +} diff --git a/core/src/test/java/edu/wpi/grip/core/composite/SaveImageOperationTest.java b/core/src/test/java/edu/wpi/grip/core/composite/SaveImageOperationTest.java new file mode 100644 index 0000000000..aad53b2a46 --- /dev/null +++ b/core/src/test/java/edu/wpi/grip/core/composite/SaveImageOperationTest.java @@ -0,0 +1,35 @@ +package edu.wpi.grip.core.composite; + + +import edu.wpi.grip.core.operations.composite.SaveImageOperation; +import edu.wpi.grip.core.sockets.InputSocket; +import edu.wpi.grip.core.sockets.MockInputSocketFactory; +import edu.wpi.grip.core.sockets.MockOutputSocketFactory; +import edu.wpi.grip.core.util.MockFileManager; + +import com.google.common.eventbus.EventBus; + +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; + +public class SaveImageOperationTest { + + private InputSocket activeSocket; + + @Before + public void setUp() throws Exception { + EventBus eventBus = new EventBus(); + SaveImageOperation operation = new SaveImageOperation(new MockInputSocketFactory(eventBus), + new MockOutputSocketFactory(eventBus), new MockFileManager()); + activeSocket = operation.getInputSockets().stream().filter( + o -> o.getSocketHint().getIdentifier().equals("Active") + && o.getSocketHint().getType().equals(Boolean.class)).findFirst().get(); + } + + @Test + public void testActiveButtonDefaultsDisabled() { + assertFalse("The active socket was not false (disabled).", activeSocket.getValue().get()); + } +} diff --git a/core/src/test/java/edu/wpi/grip/core/operations/OperationsFactory.java b/core/src/test/java/edu/wpi/grip/core/operations/OperationsFactory.java index 10cddd575d..c82fe7cbc8 100644 --- a/core/src/test/java/edu/wpi/grip/core/operations/OperationsFactory.java +++ b/core/src/test/java/edu/wpi/grip/core/operations/OperationsFactory.java @@ -1,6 +1,7 @@ package edu.wpi.grip.core.operations; +import edu.wpi.grip.core.FileManager; import edu.wpi.grip.core.operations.network.MapNetworkPublisherFactory; import edu.wpi.grip.core.operations.network.MockMapNetworkPublisher; import edu.wpi.grip.core.operations.network.ros.JavaToMessageConverter; @@ -10,6 +11,7 @@ import edu.wpi.grip.core.sockets.MockInputSocketFactory; import edu.wpi.grip.core.sockets.MockOutputSocketFactory; import edu.wpi.grip.core.sockets.OutputSocket; +import edu.wpi.grip.core.util.MockFileManager; import com.google.common.eventbus.EventBus; @@ -22,6 +24,7 @@ public static Operations create(EventBus eventBus) { MockMapNetworkPublisher::new, MockMapNetworkPublisher::new, MockROSMessagePublisher::new, + new MockFileManager(), new MockInputSocketFactory(eventBus), new MockOutputSocketFactory(eventBus)); } @@ -30,9 +33,10 @@ public static Operations create(EventBus eventBus, MapNetworkPublisherFactory mapFactory, MapNetworkPublisherFactory httpFactory, ROSNetworkPublisherFactory rosFactory, + FileManager fileManager, InputSocket.Factory isf, OutputSocket.Factory osf) { - return new Operations(eventBus, mapFactory, httpFactory, rosFactory, isf, osf); + return new Operations(eventBus, mapFactory, httpFactory, rosFactory, fileManager, isf, osf); } public static CVOperations createCV(EventBus eventBus) { diff --git a/core/src/test/java/edu/wpi/grip/core/util/MockFileManager.java b/core/src/test/java/edu/wpi/grip/core/util/MockFileManager.java new file mode 100644 index 0000000000..10397bcc94 --- /dev/null +++ b/core/src/test/java/edu/wpi/grip/core/util/MockFileManager.java @@ -0,0 +1,12 @@ +package edu.wpi.grip.core.util; + +import edu.wpi.grip.core.FileManager; + +public class MockFileManager implements FileManager { + + @Override + public void saveImage(byte[] image, String fileName) { + // No body here because this is for testing only. + } + +} diff --git a/core/src/test/java/edu/wpi/grip/util/GripCoreTestModule.java b/core/src/test/java/edu/wpi/grip/util/GripCoreTestModule.java index a563c4fbe3..47eff2b0cf 100644 --- a/core/src/test/java/edu/wpi/grip/util/GripCoreTestModule.java +++ b/core/src/test/java/edu/wpi/grip/util/GripCoreTestModule.java @@ -1,11 +1,13 @@ package edu.wpi.grip.util; +import edu.wpi.grip.core.FileManager; import edu.wpi.grip.core.GripCoreModule; import edu.wpi.grip.core.http.GripServer; import edu.wpi.grip.core.http.GripServerTest; import edu.wpi.grip.core.sources.CameraSource; import edu.wpi.grip.core.sources.MockFrameGrabberFactory; +import edu.wpi.grip.core.util.MockFileManager; import com.google.common.eventbus.SubscriberExceptionContext; @@ -75,6 +77,7 @@ protected void configure() { assert setUp : "The GripCoreTestModule handler was not set up. Call 'setUp' before passing " + "the injector"; bind(CameraSource.FrameGrabberFactory.class).to(MockFrameGrabberFactory.class); + bind(FileManager.class).to(MockFileManager.class); super.configure(); // HTTP server injection bindings bind(GripServer.JettyServerFactory.class).to(GripServerTest.TestServerFactory.class); diff --git a/ui/src/main/java/edu/wpi/grip/ui/Main.java b/ui/src/main/java/edu/wpi/grip/ui/Main.java index 9164077674..21f442c4e7 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/Main.java +++ b/ui/src/main/java/edu/wpi/grip/ui/Main.java @@ -1,6 +1,7 @@ package edu.wpi.grip.ui; import edu.wpi.grip.core.GripCoreModule; +import edu.wpi.grip.core.GripFileModule; import edu.wpi.grip.core.PipelineRunner; import edu.wpi.grip.core.events.UnexpectedThrowableEvent; import edu.wpi.grip.core.http.GripServer; @@ -71,15 +72,16 @@ public void start(Stage stage) throws Exception { if (parameters.contains("--headless")) { // If --headless was specified on the command line, run in headless mode (only use the core // module) - injector = Guice.createInjector(Modules.override(new GripCoreModule(), + injector = Guice.createInjector(Modules.override(new GripCoreModule(), new GripFileModule(), new GripSourcesHardwareModule()).with(new GripNetworkModule())); injector.injectMembers(this); parameters.remove("--headless"); } else { // Otherwise, run with both the core and UI modules, and show the JavaFX stage - injector = Guice.createInjector(Modules.override(new GripCoreModule(), + injector = Guice.createInjector(Modules.override(new GripCoreModule(), new GripFileModule(), new GripSourcesHardwareModule()).with(new GripNetworkModule(), new GripUiModule())); + injector.injectMembers(this); System.setProperty("prism.lcdtext", "false");