/* * Copyright (c) 2019. Kin-Hong Wong. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * ============================================================================== */ package com.easymobo.openlabeler; import com.easymobo.openlabeler.model.Annotation; import com.easymobo.openlabeler.model.ObjectModel; import com.easymobo.openlabeler.preference.PreferencePane; import com.easymobo.openlabeler.preference.Settings; import com.easymobo.openlabeler.support.SupportInfoPane; import com.easymobo.openlabeler.tag.ObjectTag; import com.easymobo.openlabeler.tag.ShapeItem; import com.easymobo.openlabeler.tag.TagBoard; import com.easymobo.openlabeler.tensorflow.TFTrainer; import com.easymobo.openlabeler.tool.ExportCOCOController; import com.easymobo.openlabeler.ui.MediaPane; import com.easymobo.openlabeler.ui.MediaTableView.MediaFile; import com.easymobo.openlabeler.ui.ObjectTableView; import com.easymobo.openlabeler.undo.ChangeBase; import com.easymobo.openlabeler.undo.ListChange; import com.easymobo.openlabeler.undo.NameChange; import com.easymobo.openlabeler.undo.ShapeChange; import com.easymobo.openlabeler.util.AppUtils; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; import javafx.beans.binding.LongBinding; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener.Change; import javafx.collections.ObservableSet; import javafx.event.ActionEvent; import javafx.event.Event; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.fxml.Initializable; import javafx.geometry.Bounds; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.control.ButtonBar.ButtonData; import javafx.scene.image.Image; import javafx.scene.input.Clipboard; import javafx.scene.input.DataFormat; import javafx.stage.DirectoryChooser; import javafx.stage.FileChooser; import javafx.stage.FileChooser.ExtensionFilter; import javafx.stage.Stage; import javafx.stage.WindowEvent; import org.apache.commons.lang3.SystemUtils; import org.fxmisc.easybind.EasyBind; import org.fxmisc.easybind.monadic.MonadicBinding; import org.fxmisc.undo.UndoManager; import org.fxmisc.undo.UndoManagerFactory; import org.reactfx.EventStream; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; import javax.xml.bind.Marshaller; import javax.xml.bind.Unmarshaller; import java.io.File; import java.io.IOException; import java.io.StringReader; import java.io.StringWriter; import java.lang.invoke.MethodHandles; import java.net.URL; import java.text.MessageFormat; import java.util.*; import java.util.logging.Level; import java.util.logging.Logger; import static com.easymobo.openlabeler.tag.ShapeItem.Type.POLYGON; import static com.easymobo.openlabeler.tag.ShapeItem.Type.RECTANGLE; import static javafx.scene.control.Alert.AlertType.CONFIRMATION; import static org.reactfx.EventStreams.changesOf; import static org.reactfx.EventStreams.merge; public class OpenLabelerController implements Initializable, AutoCloseable { @FXML private ScrollPane scrollPane; @FXML private Label status, coords; @FXML private TagBoard tagBoard; @FXML private MenuBar menuBar; @FXML private Menu menuOpenRecent; @FXML private MenuItem miClose, miSave, msPreference, miPreference, msExit, miExit, miUndo, miRedo, miCut, miCopy, miPaste, miDelete, miPrevMediaFile, miNextMediaFile, miGoToUnlabeledMediaFile, miZoomIn, miZoomOut, miZoomFit, miRotateLeft, miRotateRight, miShowHint, miClearHint, miInspectLabels, miExportCOCO, msAbout, miAbout; @FXML private RadioMenuItem miShapeRectangle, miShapePolygon; @FXML private ToolBar toolBar; @FXML private Button btnUndo, btnRedo; @FXML private MediaPane mediaPane; @FXML private ObjectTableView objectTable; private static final Logger LOG = Logger.getLogger(MethodHandles.lookup().lookupClass().getCanonicalName()); private static final DataFormat DATA_FORMAT_JAXB = new DataFormat("application/openlabeler-jaxb"); private ResourceBundle bundle; // For PASCAL VOC xml persistence private JAXBContext jaxbContext; // undo/redo private ObservableSet<EventStream<ChangeBase<?>>> changes = FXCollections.observableSet(); private UndoManager<ChangeBase<?>> undoManager; // TensorFlow training TFTrainer trainer; @Override public void initialize(URL location, ResourceBundle resources) { // Platform dependent menu adjustments menuBar.setUseSystemMenuBar(true); if (SystemUtils.IS_OS_MAC) { msPreference.setVisible(false); miPreference.setVisible(false); msExit.setVisible(false); miExit.setVisible(false); msAbout.setVisible(false); miAbout.setVisible(false); } try { bundle = resources; tagBoard.statusProperty().set(bundle.getString("msg.openMedia")); jaxbContext = JAXBContext.newInstance(Annotation.class); } catch (JAXBException ex) { LOG.log(Level.SEVERE, "Unable to create JAXBContext", ex); } // ScrollPane steals focus, so it is always the focus owner scrollPane.setOnKeyPressed(event -> tagBoard.onKeyPressed(event)); // Paste menu item miPaste.getParentMenu().setOnShowing(event -> { Clipboard clipboard = Clipboard.getSystemClipboard(); miPaste.disableProperty().set(!clipboard.getContentTypes().contains(DATA_FORMAT_JAXB)); }); // Remove mnemonics from toolbar button tooltips fixTooltipMnemonics(toolBar); trainer = new TFTrainer(); trainer.init(); bindProperties(); } public void handleWindowEvent(WindowEvent event) { if (event.getEventType() == WindowEvent.WINDOW_SHOWN) { // Initial focus tagBoard.requestFocus(); // Open last media file/folder if (Settings.isOpenLastMedia() && Settings.recentFilesProperty.size() > 0) { File fileOrDir = new File(Settings.recentFilesProperty.get(0)); openFileOrDir(fileOrDir); } } } @FXML private void onFileOpenFile(ActionEvent actionEvent) { ExtensionFilter imageFilter = new ExtensionFilter("Image Files (*.jpg, *.png, *.gif)", "*.JPG", "*.jpg", "*.JPEG", "*.jpeg", "*.PNG", "*.png", "*.GIF", ".gif"); FileChooser fileChooser = new FileChooser(); fileChooser.setTitle(bundle.getString("menu.openMediaFile").replaceAll("_", "")); fileChooser.getExtensionFilters().add(imageFilter); File file = fileChooser.showOpenDialog(tagBoard.getScene().getWindow()); openFileOrDir(file); } @FXML private void onFileOpenDir(ActionEvent actionEvent) { DirectoryChooser dirChooser = new DirectoryChooser(); dirChooser.setTitle(bundle.getString("menu.openMediaDir").replaceAll("_", "")); File dir = dirChooser.showDialog(tagBoard.getScene().getWindow()); openFileOrDir(dir); } private void openFileOrDir(File file) { if (file == null || !file.exists()) { return; } if (tagBoard.getModel() != null && tagBoard.getModel().getFile().equals(file) || !canClose()) { return; } mediaPane.openFileOrDir(file); } @FXML private void onFileMenu(Event event) { menuOpenRecent.getItems().clear(); for (Iterator<String> it = Settings.recentFilesProperty.iterator(); it.hasNext(); ) { File fileOrDir = new File(it.next()); MenuItem item = new MenuItem(fileOrDir.getAbsolutePath()); item.setOnAction(value -> openFileOrDir(fileOrDir)); menuOpenRecent.getItems().add(item); } // Clear menu item if (menuOpenRecent.getItems().size() > 0) { menuOpenRecent.getItems().add(new SeparatorMenuItem()); MenuItem clear = new MenuItem(bundle.getString("menu.clear")); clear.setOnAction(value -> Settings.recentFilesProperty.clear()); menuOpenRecent.getItems().add(clear); } menuOpenRecent.setDisable(menuOpenRecent.getItems().size() <= 0); } @FXML private void onClose(ActionEvent actionEvent) { if (canClose()) { tagBoard.setModel(null); undoManager.forgetHistory(); mediaPane.clear(); } } @FXML private void onExit(ActionEvent actionEvent) { if (canClose()) { close(); Platform.exit(); } } @FXML private void onSave(ActionEvent actionEvent) { save(true); } @FXML private void onUndo(ActionEvent event) { undoManager.undo(); } @FXML private void onRedo(ActionEvent event) { undoManager.redo(); } @FXML private void onShapeMenu() { miShapeRectangle.setSelected(Settings.getEditShape() == RECTANGLE); miShapePolygon.setSelected(Settings.getEditShape() == POLYGON); } @FXML private void onShapeRectangle(ActionEvent event) { Settings.setEditShape(RECTANGLE); } @FXML private void onShapePolygon(ActionEvent event) { Settings.setEditShape(ShapeItem.Type.POLYGON); } @FXML private void onCut(ActionEvent event) { ObjectTag tag = tagBoard.selectedObjectProperty().get(); toClipboard(tag.getModel()); tagBoard.deleteSelected(bundle.getString("menu.cut")); } @FXML private void onCopy(ActionEvent event) { ObjectTag tag = tagBoard.selectedObjectProperty().get(); toClipboard((ObjectModel)tag.getModel().clone()); } @FXML private void onPaste(ActionEvent event) { ObjectModel model = fromClipboard(); if (model != null) { tagBoard.addObjectTag((ObjectModel)model.clone(), bundle.getString("menu.paste")); } } @FXML private void onDelete(ActionEvent event) { tagBoard.deleteSelected(bundle.getString("menu.delete")); } @FXML private void onPrevMedia(ActionEvent actionEvent) { mediaPane.onPrevMediaFile(actionEvent); } @FXML private void onNextMediaFile(ActionEvent actionEvent) { mediaPane.onNextMediaFile(actionEvent); } @FXML private void onGoToUnlabeledMediaFile(ActionEvent actionEvent) { mediaPane.onGoToUnlabeledMediaFile(actionEvent); } @FXML private void onZoomIn(ActionEvent actionEvent) { tagBoard.getScale().setX(tagBoard.getScale().getX() * 1.1); tagBoard.getScale().setY(tagBoard.getScale().getY() * 1.1); } @FXML private void onZoomOut(ActionEvent actionEvent) { tagBoard.getScale().setX(tagBoard.getScale().getX() * 0.9); tagBoard.getScale().setY(tagBoard.getScale().getY() * 0.9); } @FXML private void onZoomFit(ActionEvent actionEvent) { zoomFit(); } /** * Sets the scale transform to roughly fit the scroll pane view port */ private void zoomFit() { if (tagBoard.getImageView().getImage() == null) { return; } double imgWidth = tagBoard.getImageView().getImage().getWidth(); double imgHeight = tagBoard.getImageView().getImage().getHeight(); Bounds bounds = scrollPane.getLayoutBounds(); // Take into account scrollbar space so that scroll bars will not be shown after fitting Set<Node> nodes = scrollPane.lookupAll(".scroll-bar"); Optional<ScrollBar> sbar = nodes.stream().filter(n -> n instanceof ScrollBar).map(n -> (ScrollBar) n).findFirst(); double sbSpace = Math.max(sbar.isPresent() ? sbar.get().getWidth() : 30, 30); double factor = Math.min( Math.round((bounds.getWidth() - sbSpace) * 10.0 / imgWidth) / 10.0, Math.round((bounds.getHeight() - sbSpace) * 10.0 / imgHeight) / 10.0); tagBoard.getScale().setX(factor); tagBoard.getScale().setY(factor); } @FXML private void onPreference(ActionEvent actionEvent) { new PreferencePane().showAndWait(); } @FXML private void onRotateLeft(ActionEvent event) { rotate(-90); } @FXML private void onRotateRight(ActionEvent event) { rotate(90); } @FXML private void rotate(int angle) { tagBoard.rotate(angle); } @FXML private void onShowHint(ActionEvent event) { tagBoard.showHints(); } @FXML private void onClearHint(ActionEvent event) { tagBoard.clearHints(); } @FXML private void onInspectLabels(ActionEvent event) { } @FXML private void onExportCOCO(ActionEvent event) { try { FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("/fxml/tool/ExportCOCO.fxml"), bundle); Node content = fxmlLoader.load(); ExportCOCOController controller = fxmlLoader.getController(); controller.showDialog(content, tagBoard.getModel()); } catch (IOException ex) { LOG.log(Level.SEVERE, "Unable to load FXML: ", ex); } } @FXML private void onAbout(ActionEvent actionEvent) { Stage aboutDialog = OpenLabeler.createAboutStage(bundle); aboutDialog.initOwner(tagBoard.getScene().getWindow()); aboutDialog.showAndWait(); } @FXML private void onSupportInfo(ActionEvent actionEvent) { new SupportInfoPane().showAndWait(); } private void save(boolean force) { if (!force && !Settings.isSaveEveryChange()) { return; } try { Annotation model = tagBoard.getModel(); if (model == null) { return; } Marshaller marshaller = jaxbContext.createMarshaller(); // output pretty printed marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); marshaller.setProperty(Marshaller.JAXB_FRAGMENT, Boolean.TRUE); File xmlFile = AppUtils.getAnnotationFile(model.getFile()); if (xmlFile.getParentFile() != null) { xmlFile.getParentFile().mkdirs(); } xmlFile.createNewFile(); marshaller.marshal(model, xmlFile); mediaPane.updateFileStats(); tagBoard.statusProperty().set(bundle.getString("msg.saved")); if (!Settings.isSaveEveryChange()) { undoManager.forgetHistory(); } } catch (Exception ex) { LOG.log(Level.SEVERE, "Unable to save", ex); } } public boolean canClose() { if (miSave.isDisable()) { return true; } ButtonType saveAndClose = new ButtonType(bundle.getString("label.saveAndClose"), ButtonData.OTHER); Alert alert = AppUtils.createAlert(CONFIRMATION, bundle.getString("label.alert"), bundle.getString("msg.confirmClose")); alert.getButtonTypes().clear(); alert.getButtonTypes().addAll(saveAndClose, ButtonType.YES, ButtonType.NO); Optional<ButtonType> result = alert.showAndWait(); if (result.get() == saveAndClose) { save(true); } return result.get() != ButtonType.NO; } private void bindProperties() { // Files boolean loading[] = { false }; mediaPane.getSelectionModel().selectedItemProperty().addListener((observable, oldFile, newFile) -> { if (newFile == null) { updateAppTitle(newFile); return; } if (oldFile != null && oldFile.equals(tagBoard.getModel().getFile()) && !canClose()) { // Switch back to the previous media selection Platform.runLater(() -> mediaPane.getSelectionModel().select(new MediaFile(oldFile))); return; } if (tagBoard.getModel() != null && tagBoard.getModel().getFile().equals(newFile)) { return; } File xmlFile = AppUtils.getAnnotationFile(newFile); Annotation annotation = null; try { Image image = new Image(newFile.toURI().toURL().toExternalForm()); if (xmlFile.exists()) { Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); annotation = (Annotation) unmarshaller.unmarshal(xmlFile); annotation.setFile(newFile); annotation.getSize().setImage(image); } if (annotation == null) { annotation = new Annotation(newFile, image); } loading[0] = true; tagBoard.setModel(annotation); zoomFit(); undoManager.forgetHistory(); updateAppTitle(newFile); } catch (Exception ex) { LOG.log(Level.SEVERE, "Unable to load", ex); } finally { loading[0] = false; } }); BooleanBinding hasPrev = mediaPane.sizeProperty().greaterThan(1).and(mediaPane.getSelectionModel().selectedIndexProperty().greaterThan(0)); miPrevMediaFile.disableProperty().bind(hasPrev.not()); BooleanBinding hasNext = mediaPane.sizeProperty().greaterThan(1).and(mediaPane.getSelectionModel().selectedIndexProperty().lessThan(mediaPane.sizeProperty().subtract(1))); miNextMediaFile.disableProperty().bind(hasNext.not()); // File -> Close miClose.disableProperty().bind(tagBoard.modelProperty().isNull()); // File -> Save miSave.disableProperty().bind(Settings.saveEveryChangeProperty.or(miUndo.disableProperty())); // Edit -> Undo/Redo undoManager = UndoManagerFactory.unlimitedHistorySingleChangeUM( merge(changes), // stream of changes to observe c -> c.invert(), // function to invert a change c -> c.redo(), // function to undo a change (c1, c2) -> c1.mergeWith(c2)); // function to merge two changes changes.add(changesOf(tagBoard.objectsProperty().get()).map(c -> new ListChange(tagBoard.objectsProperty().get(), c))); tagBoard.objectsProperty().addListener((Change<? extends ObjectTag> change) -> { if (!change.next()) { return; } if (change.wasAdded()) { change.getAddedSubList().forEach(target -> { EventStream<ChangeBase<?>> es = changesOf(target.nameProperty()).map(c -> new NameChange(bundle.getString("menu.editName"), target.nameProperty(), c)); target.getProperties().put("EventStreamName", es); target.nameProperty().addListener((observable, oldValue, newValue) -> save(false)); changes.add(es); es = changesOf(target.shapeItemProperty()).map(c -> new ShapeChange(bundle.getString("menu.changeShape"), target.shapeItemProperty(), c)); target.getProperties().put("EventStreamBounds", es); target.shapeItemProperty().addListener((observable, oldValue, newValue) -> save(false)); changes.add(es); }); } else if (change.wasRemoved()) { change.getRemoved().forEach(target -> { changes.remove(target.getProperties().get("EventStreamName")); changes.remove(target.getProperties().get("EventStreamBounds")); }); } if (!loading[0]) { save(false); } }); miUndo.disableProperty().bind(undoManager.undoAvailableProperty().map(x -> !x)); miRedo.disableProperty().bind(undoManager.redoAvailableProperty().map(x -> !x)); // update Undo/Redo menu item and tooltip text undoManager.nextUndoProperty().addListener((observable, oldValue, newValue) -> { String name = newValue == null ? "" : newValue.getName(); String msg = MessageFormat.format(bundle.getString("menu.undoAction"), name); miUndo.setText(msg); btnUndo.getTooltip().setText(msg); }); undoManager.nextRedoProperty().addListener((observable, oldValue, newValue) -> { String name = newValue == null ? "" : newValue.getName(); String msg = MessageFormat.format(bundle.getString("menu.redoAction"), name); miRedo.setText(msg); btnRedo.getTooltip().setText(msg); }); // Edit -> Cut miCut.disableProperty().bind(tagBoard.selectedObjectProperty().isNull()); // Edit -> Copy miCopy.disableProperty().bind(tagBoard.selectedObjectProperty().isNull()); // Edit -> Delete miDelete.disableProperty().bind(tagBoard.selectedObjectProperty().isNull()); miGoToUnlabeledMediaFile.disableProperty().bind(mediaPane.nextUnlabeledMediaProperty().isNull()); // Link object table and object group objectTable.setItems(tagBoard.objectsProperty()); tagBoard.selectedObjectProperty().addListener((observable, oldValue, newValue) -> { objectTable.getSelectionModel().select(newValue); }); objectTable.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> { if (newValue != null) { newValue.setSelected(true); } }); // View -> Zoom miZoomIn.disableProperty().bind(tagBoard.modelProperty().isNull()); miZoomOut.disableProperty().bind(tagBoard.modelProperty().isNull()); miZoomFit.disableProperty().bind(tagBoard.modelProperty().isNull()); // View -> Rotate miRotateLeft.disableProperty().bind(tagBoard.modelProperty().isNull()); miRotateRight.disableProperty().bind(tagBoard.modelProperty().isNull()); // View -> Show/Clear Hints ObservableValue<Long> visibleHints = EasyBind.combine( EasyBind.map(tagBoard.hintsProperty().get(), t -> t.visibleProperty()), stream -> stream.filter(visible -> visible).count()); LongBinding vhBinding = Bindings.createLongBinding(() -> ((MonadicBinding<Long>)visibleHints).get(), visibleHints); BooleanBinding canShowHint = tagBoard.hintsProperty().sizeProperty().greaterThan(0).and(vhBinding.lessThan(tagBoard.hintsProperty().sizeProperty())); miShowHint.disableProperty().bind(canShowHint.not()); BooleanBinding canClearHint = vhBinding.greaterThan(0); miClearHint.disableProperty().bind(canClearHint.not()); // Status bar tagBoard.statusProperty().addListener((observable, oldValue, newValue) -> status.setText(newValue)); trainer.checkpointProperty().addListener((observable, oldValue, newValue) -> { status.setText(MessageFormat.format(bundle.getString("msg.ckptCreated"), newValue)); }); coords.textProperty().bind(tagBoard.tagCoordsProperty()); } private void toClipboard(ObjectModel model) { Clipboard clipboard = Clipboard.getSystemClipboard(); Map<DataFormat, Object> content = new HashMap(); try { Marshaller marshaller = jaxbContext.createMarshaller(); StringWriter writer = new StringWriter(); // output pretty printed marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); marshaller.setProperty(Marshaller.JAXB_FRAGMENT, Boolean.TRUE); marshaller.marshal(model, writer); content.put(DATA_FORMAT_JAXB, writer.toString()); content.put(DataFormat.PLAIN_TEXT, writer.toString()); clipboard.setContent(content); } catch (Exception ex) { LOG.log(Level.WARNING, "Unable to put content to clipboard", ex); } } private ObjectModel fromClipboard() { Clipboard clipboard = Clipboard.getSystemClipboard(); if (clipboard.getContentTypes().contains(DATA_FORMAT_JAXB)) { try { String content = (String)clipboard.getContent(DATA_FORMAT_JAXB); Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); return (ObjectModel) unmarshaller.unmarshal(new StringReader(content)); } catch (Exception ex) { LOG.log(Level.SEVERE, "Unable to get content from clipboard", ex); } } return null; } private void updateAppTitle(File file) { String title = bundle.getString("app.name"); if (file != null && file.exists()) { title += " - " + file.getAbsolutePath(); } ((Stage) tagBoard.getScene().getWindow()).setTitle(title); } private void fixTooltipMnemonics(ToolBar toolBar) { toolBar.getItems().stream().filter(Button.class::isInstance).forEach(node -> { Tooltip tooltip = ((Button) node).getTooltip(); if (tooltip != null) { tooltip.setText(tooltip.getText().replaceAll("_", "")); } }); } /** * Clean up */ public void close() { trainer.close(); tagBoard.close(); mediaPane.close(); } }