diff --git a/README.md b/README.md index 2f67e8e..7d6e502 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ Cullergrader is named for being a tool that culls and grades* photos. Please not 3. [How to Use](#how-to-use) 1. [Open a Folder of Images](#1-open-a-folder-of-images) 2. [Calibrate Grouping Settings](#2-calibrate-grouping-settings) - 3. [View Photos and Select Best Takes](#3-view-photos-and-select-best-takes) - 4. [Export Your Best Takes](#4-export-your-best-takes) + 3. [View Photos and Select Takes](#3-view-photos-and-select-takes) + 4. [Export Selected Takes](#4-export-selected-takes) 4. [Config](#config) 1. [Default Config](#default-config) 2. [Config Settings Explained](#config-settings-explained) @@ -91,8 +91,8 @@ Although the default settings are designed to work fine out of the box, dependin ![images/grouping_settings.png](images/grouping_settings.png) -### 3. View Photos and Select Best Takes -By clicking on a photo, users can access the `Photo Viewer`, bringing up all individual photos in a group, with the best take marked by a star (which by default is the first image in group). By navigating using either mouse or `arrow keys` (left and right to move between photos, up and down to move between groups) to a photo, they can use the `spacebar` or `Controls > Set Best Take` to change a photo to the best take. +### 3. View Photos and Select Takes +By clicking on a photo, users can access the `Photo Viewer`, bringing up all individual photos in a group, with selected takes marked by a star. By navigating using either mouse or `arrow keys` (left and right to move between photos, up and down to move between groups), users can press `spacebar` or use `Controls > Set as Selected Take` to toggle photo selection. Multiple photos can be selected per group, and groups with 0 selections will not be exported. ![images/photo_viewer.png](images/photo_viewer.png) @@ -100,8 +100,8 @@ By clicking on a photo, users can access the `Photo Viewer`, bringing up all ind ![images/photo_info.png](images/photo_info.png) -### 4. Export Your Best Takes -Best takes can be exported to a folder using `File > Export Best Takes` or with `Ctrl + S`. After choosing an export folder, the selected best takes will begin copying to that folder! +### 4. Export Selected Takes +Selected takes can be exported to a folder using `File > Export Selected Takes` or with `Ctrl + S`. After choosing an export folder, all selected takes from each group will be copied to that folder. Groups with no selections will be skipped. ![images/export_to.png](images/export_to.png) @@ -119,7 +119,8 @@ Best takes can be exported to a folder using `File > Export Best Takes` or with "HASHED_WIDTH": 8, "HASHED_HEIGHT": 8, "TIME_THRESHOLD_SECONDS": 15, - "SIMILARITY_THRESHOLD_PERCENT": 45 + "SIMILARITY_THRESHOLD_PERCENT": 45, + "DEFAULT_SELECTION_STRATEGY": "first" } ``` @@ -137,8 +138,9 @@ Best takes can be exported to a folder using `File > Export Best Takes` or with | `HASHED_HEIGHT` | The height that images are computed at before hashing, higher values mean more accurate similarity checks at the cost of performance | `int` | | `TIME_THRESHOLD_SECONDS` | The default amount of seconds between photos (from the timestamp) before they're counted as a new group. Editable in-app, but will not change the default stored here | `float` | | `SIMILARITY_THRESHOLD_PERCENT` | The default similarity between two photo hashes before they're counted as a new group. Higher values means more lenience in image similarity (larger groups, less in number). Editable in-app, but will not change the default stored here | `float` | +| `DEFAULT_SELECTION_STRATEGY` | The automatic selection strategy when creating groups. Options: `"first"` (select first photo), `"last"` (select last photo), `"first_and_last"` (select both), `"all"` (select all), `"none"` (no automatic selection) | `String` | -Note: More config options are technically functional, such as `PLACEHOLDER_THUMBNAIL_PATH`, `KEYBIND_SET_BESTTAKE`, or `GRIDMEDIA_LABEL_TEXT_COLOR`, but are not documented here and aren't editable by default due to their configurability not significantly impacting program function. Users are free to explore the source code and add these into `config.json` themselves, and they should work as intended. +Note: More config options are technically functional, such as `PLACEHOLDER_THUMBNAIL_PATH`, `KEYBIND_TOGGLE_SELECTION`, or `GRIDMEDIA_LABEL_TEXT_COLOR`, but are not documented here and aren't editable by default due to their configurability not significantly impacting program function. Users are free to explore the source code and add these into `config.json` themselves, and they should work as intended. ## Contributing Contributions to Cullergrader are **greatly appreciated**, as a tool made from one photographer to another, the best way Cullergrader can improve is through continued feedback and contributions. diff --git a/src/main/java/com/penguinpush/cullergrader/config/AppConstants.java b/src/main/java/com/penguinpush/cullergrader/config/AppConstants.java index b6c8d5b..003c044 100644 --- a/src/main/java/com/penguinpush/cullergrader/config/AppConstants.java +++ b/src/main/java/com/penguinpush/cullergrader/config/AppConstants.java @@ -35,6 +35,7 @@ public class AppConstants { public static final int HASHED_HEIGHT = config.HASHED_HEIGHT; public static final float TIME_THRESHOLD_SECONDS = config.TIME_THRESHOLD_SECONDS; public static final float SIMILARITY_THRESHOLD_PERCENT = config.SIMILARITY_THRESHOLD_PERCENT; + public static final String DEFAULT_SELECTION_STRATEGY = config.DEFAULT_SELECTION_STRATEGY; public static final int MAX_PRIORITY = config.MAX_PRIORITY; public static final int IMAGE_PRIORITY = config.IMAGE_PRIORITY; @@ -66,7 +67,7 @@ public class AppConstants { public static final String KEYBIND_PHOTO_NEXT = config.KEYBIND_PHOTO_NEXT; public static final String KEYBIND_GROUP_PREVIOUS = config.KEYBIND_GROUP_PREVIOUS; public static final String KEYBIND_GROUP_NEXT = config.KEYBIND_GROUP_NEXT; - public static final String KEYBIND_SET_BESTTAKE = config.KEYBIND_SET_BESTTAKE; + public static final String KEYBIND_TOGGLE_SELECTION = config.KEYBIND_TOGGLE_SELECTION; - public static final String BESTTAKE_LABEL_TEXT = config.BESTTAKE_LABEL_TEXT; + public static final String SELECTED_LABEL_TEXT = config.SELECTED_LABEL_TEXT; } \ No newline at end of file diff --git a/src/main/java/com/penguinpush/cullergrader/config/DefaultAppConstants.java b/src/main/java/com/penguinpush/cullergrader/config/DefaultAppConstants.java index 366c76e..9d3bc64 100644 --- a/src/main/java/com/penguinpush/cullergrader/config/DefaultAppConstants.java +++ b/src/main/java/com/penguinpush/cullergrader/config/DefaultAppConstants.java @@ -13,6 +13,7 @@ public class DefaultAppConstants { public int HASHED_HEIGHT = 8; public float TIME_THRESHOLD_SECONDS = 15; public float SIMILARITY_THRESHOLD_PERCENT = 45; + public String DEFAULT_SELECTION_STRATEGY = "first"; public int MAX_PRIORITY = 0; public int IMAGE_PRIORITY = 1; @@ -44,7 +45,7 @@ public class DefaultAppConstants { public String KEYBIND_PHOTO_NEXT = "RIGHT"; public String KEYBIND_GROUP_PREVIOUS = "UP"; public String KEYBIND_GROUP_NEXT = "DOWN"; - public String KEYBIND_SET_BESTTAKE = "SPACE"; + public String KEYBIND_TOGGLE_SELECTION = "SPACE"; - public String BESTTAKE_LABEL_TEXT = "★"; + public String SELECTED_LABEL_TEXT = "★"; } diff --git a/src/main/java/com/penguinpush/cullergrader/logic/FileUtils.java b/src/main/java/com/penguinpush/cullergrader/logic/FileUtils.java index f3cfc2a..a37394b 100644 --- a/src/main/java/com/penguinpush/cullergrader/logic/FileUtils.java +++ b/src/main/java/com/penguinpush/cullergrader/logic/FileUtils.java @@ -10,7 +10,10 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.StandardCopyOption; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Set; public class FileUtils { @@ -25,19 +28,52 @@ public static void exportBestTakes(List photoGroups, File targetFold targetFolder.mkdirs(); } + Map filenameCounts = new HashMap<>(); + for (PhotoGroup group : photoGroups) { - Photo bestTake = group.getBestTake(); - if (bestTake != null) { - File sourceFile = bestTake.getFile(); - File destinationFile = new File(targetFolder, sourceFile.getName()); + Set selectedPhotos = group.getSelectedTakes(); + + // Skip groups with 0 selections + if (selectedPhotos.isEmpty()) { + continue; + } + + for (Photo photo : selectedPhotos) { + File sourceFile = photo.getFile(); + String originalName = sourceFile.getName(); + String baseName = getBaseName(originalName); + String extension = getExtension(originalName); + + // Handle filename collisions + String finalName = originalName; + if (filenameCounts.containsKey(originalName)) { + int count = filenameCounts.get(originalName); + finalName = baseName + "_" + count + extension; + filenameCounts.put(originalName, count + 1); + } else { + filenameCounts.put(originalName, 1); + } + + File destinationFile = new File(targetFolder, finalName); try { - Files.copy(sourceFile.toPath(), destinationFile.toPath(), StandardCopyOption.REPLACE_EXISTING); - logMessage("copied file: " + sourceFile.getAbsolutePath()); + Files.copy(sourceFile.toPath(), destinationFile.toPath(), + StandardCopyOption.REPLACE_EXISTING); + logMessage("copied file: " + sourceFile.getAbsolutePath() + " → " + finalName); } catch (IOException e) { logMessage("couldn't copy file: " + sourceFile.getAbsolutePath()); } } } } + + private static String getBaseName(String filename) { + int lastDot = filename.lastIndexOf('.'); + return lastDot > 0 ? filename.substring(0, lastDot) : filename; + } + + private static String getExtension(String filename) { + int lastDot = filename.lastIndexOf('.'); + return lastDot > 0 ? filename.substring(lastDot) : ""; + } } diff --git a/src/main/java/com/penguinpush/cullergrader/logic/GroupingEngine.java b/src/main/java/com/penguinpush/cullergrader/logic/GroupingEngine.java index 5932871..4840341 100644 --- a/src/main/java/com/penguinpush/cullergrader/logic/GroupingEngine.java +++ b/src/main/java/com/penguinpush/cullergrader/logic/GroupingEngine.java @@ -57,6 +57,7 @@ public List generateGroups(List photoList, float timestampThr currentGroup.addPhoto(current); } else { currentGroup.setIndex(groups.size()); + currentGroup.applyDefaultSelectionStrategy(); groups.add(currentGroup); currentGroup = new PhotoGroup(); @@ -72,6 +73,7 @@ public List generateGroups(List photoList, float timestampThr // add the last group too if (currentGroup.getSize() > 0) { currentGroup.setIndex(groups.size()); + currentGroup.applyDefaultSelectionStrategy(); groups.add(currentGroup); } diff --git a/src/main/java/com/penguinpush/cullergrader/media/Photo.java b/src/main/java/com/penguinpush/cullergrader/media/Photo.java index 1187575..c81700c 100644 --- a/src/main/java/com/penguinpush/cullergrader/media/Photo.java +++ b/src/main/java/com/penguinpush/cullergrader/media/Photo.java @@ -109,8 +109,8 @@ public void setGroup(PhotoGroup group) { this.group = group; } - public boolean isBestTake() { - return group != null && group.getBestTake() == this; + public boolean isSelected() { + return group != null && group.isSelected(this); } public void setMetrics(float deltaTimeRatio, float hammingDistanceRatio) { diff --git a/src/main/java/com/penguinpush/cullergrader/media/PhotoGroup.java b/src/main/java/com/penguinpush/cullergrader/media/PhotoGroup.java index caf88c8..4779aa4 100644 --- a/src/main/java/com/penguinpush/cullergrader/media/PhotoGroup.java +++ b/src/main/java/com/penguinpush/cullergrader/media/PhotoGroup.java @@ -1,23 +1,30 @@ package com.penguinpush.cullergrader.media; +import com.penguinpush.cullergrader.config.AppConstants; +import static com.penguinpush.cullergrader.utils.Logger.logMessage; + import java.awt.image.BufferedImage; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; public class PhotoGroup extends GridMedia { private final List photos = new ArrayList<>(); - private Photo bestTake; - + private final LinkedHashSet selectedTakes = new LinkedHashSet<>(); + @Override public BufferedImage getThumbnail() { - return bestTake.getThumbnail(); + // Always use first photo in group for thumbnail + return photos.isEmpty() ? null : photos.get(0).getThumbnail(); } - + @Override public String getName() { - return bestTake.getFile().getName() + " (group)"; + // Always use first photo in group for name + return photos.isEmpty() ? "(empty group)" : photos.get(0).getFile().getName() + " (group)"; } @Override @@ -33,8 +40,6 @@ public String getTooltip() { public void addPhoto(Photo photo) { photos.add(photo); photo.setGroup(this); - if (bestTake == null) - bestTake = photo; } public void addPhotos(List photos) { @@ -46,29 +51,91 @@ public void addPhotos(List photos) { public boolean removePhoto(Photo photo) { boolean removed = photos.remove(photo); - + if (removed) { photo.setGroup(null); - - if (photo.equals(bestTake)) { - bestTake = photos.isEmpty() ? null : photos.get(0); // fallback to first photo, or null if empty - } + // Remove from selected takes (allow 0 selections) + selectedTakes.remove(photo); } - + return removed; } public List getPhotos() { return Collections.unmodifiableList(photos); // read-only list } - - public void setBestTake(Photo photo) { + + // New selection methods + public Set getSelectedTakes() { + return Collections.unmodifiableSet(selectedTakes); + } + + public boolean isSelected(Photo photo) { + return selectedTakes.contains(photo); + } + + public boolean addSelectedTake(Photo photo) { if (photos.contains(photo)) { - bestTake = photo; + return selectedTakes.add(photo); } + return false; } - public Photo getBestTake() { - return bestTake; + public boolean removeSelectedTake(Photo photo) { + return selectedTakes.remove(photo); + } + + public void toggleSelection(Photo photo) { + if (isSelected(photo)) { + removeSelectedTake(photo); + } else { + addSelectedTake(photo); + } + } + + public void clearSelections() { + selectedTakes.clear(); + } + + public void applyDefaultSelectionStrategy() { + String strategy = AppConstants.DEFAULT_SELECTION_STRATEGY; + + switch (strategy.toLowerCase()) { + case "first": + if (!photos.isEmpty()) { + selectedTakes.add(photos.get(0)); + } + break; + + case "last": + if (!photos.isEmpty()) { + selectedTakes.add(photos.get(photos.size() - 1)); + } + break; + + case "first_and_last": + if (!photos.isEmpty()) { + selectedTakes.add(photos.get(0)); + if (photos.size() > 1) { + selectedTakes.add(photos.get(photos.size() - 1)); + } + } + break; + + case "all": + selectedTakes.addAll(photos); + break; + + case "none": + // Don't select anything + break; + + default: + // Invalid strategy, fallback to "first" + if (!photos.isEmpty()) { + selectedTakes.add(photos.get(0)); + } + logMessage("Invalid selection strategy: " + strategy + ", using 'first'"); + } } } diff --git a/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.form b/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.form index 0c6e245..d280d6e 100644 --- a/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.form +++ b/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.form @@ -25,7 +25,7 @@ - + diff --git a/src/main/java/com/penguinpush/cullergrader/ui/PhotoGridFrame.form b/src/main/java/com/penguinpush/cullergrader/ui/PhotoGridFrame.form index 4444dbf..67c0ea3 100644 --- a/src/main/java/com/penguinpush/cullergrader/ui/PhotoGridFrame.form +++ b/src/main/java/com/penguinpush/cullergrader/ui/PhotoGridFrame.form @@ -63,7 +63,7 @@ - + diff --git a/src/main/java/com/penguinpush/cullergrader/ui/PhotoGridFrame.java b/src/main/java/com/penguinpush/cullergrader/ui/PhotoGridFrame.java index 04c2c5b..650c462 100644 --- a/src/main/java/com/penguinpush/cullergrader/ui/PhotoGridFrame.java +++ b/src/main/java/com/penguinpush/cullergrader/ui/PhotoGridFrame.java @@ -36,7 +36,14 @@ public void updateGrid(PhotoGroup photoGroup) { jGridPanel.populateGrid((List) (List) photoGroup.getPhotos(), width, height, AppConstants.PHOTO_OFFSCREEN_PRIORITY); jGridPanel.updatePriorities(AppConstants.PHOTO_ONSCREEN_PRIORITY, AppConstants.PHOTO_OFFSCREEN_PRIORITY); - setImagePanelPhoto(photoGroup.getBestTake()); + // Get first selected photo (or first photo if none selected) + Photo photoToShow = photoGroup.getSelectedTakes().isEmpty() + ? (photoGroup.getPhotos().isEmpty() ? null : photoGroup.getPhotos().get(0)) + : photoGroup.getSelectedTakes().iterator().next(); + + if (photoToShow != null) { + setImagePanelPhoto(photoToShow); + } setVisible(true); } @@ -86,23 +93,32 @@ private void nextGroup() { } } - private void setBestTake() { + private void toggleSelection() { Photo photo = jImagePanel.getPhoto(); if (photoGroup.getIndex() <= photoGroups.size() - 1) { - photoGroup.setBestTake(photo); + photoGroup.toggleSelection(photo); groupGridFrame.setNeedsRefresh(); + jGridPanel.repaint(); // Repaint photo grid thumbnails + groupGridFrame.repaint(); // Repaint group grid to update opacity repaint(); } } + // Keep for compatibility with generated menu code + private void setBestTake() { + toggleSelection(); + } + public void setImagePanelPhoto(Photo photo) { if (thumbnailCache.containsKey(photo)) { jImagePanel.setImage(thumbnailCache.get(photo)); - return; + } else { + SwingUtilities.invokeLater(() -> jImagePanel.setPhoto(photo)); } - SwingUtilities.invokeLater(() -> jImagePanel.setPhoto(photo)); + // Update border highlighting for currently viewed photo + jGridPanel.setCurrentlyViewedPhoto(photo); } @Override diff --git a/src/main/java/com/penguinpush/cullergrader/ui/components/JGridMedia.java b/src/main/java/com/penguinpush/cullergrader/ui/components/JGridMedia.java index 24c10a3..ee115cf 100644 --- a/src/main/java/com/penguinpush/cullergrader/ui/components/JGridMedia.java +++ b/src/main/java/com/penguinpush/cullergrader/ui/components/JGridMedia.java @@ -18,6 +18,7 @@ public class JGridMedia extends JLabel { private PhotoGridFrame photoGridFrame; private Photo thumbnailPhoto = new Photo(new File("placeholder.jpg"), 0, ""); private ImageLoader imageLoader; + private boolean isCurrentlyViewed = false; public JGridMedia(GridMedia gridMedia, ImageIcon placeholder, Dimension dimensions, ImageLoader imageLoader) { @@ -58,7 +59,9 @@ public void loadThumbnail(int priority) { if (gridMedia instanceof Photo) { thumbnailPhoto = (Photo) gridMedia; } else if (gridMedia instanceof PhotoGroup) { - thumbnailPhoto = ((PhotoGroup) gridMedia).getBestTake(); + PhotoGroup photoGroup = (PhotoGroup) gridMedia; + // Always use first photo in group for thumbnail + thumbnailPhoto = photoGroup.getPhotos().isEmpty() ? null : photoGroup.getPhotos().get(0); } if (thumbnailPhoto != null) { @@ -84,8 +87,8 @@ public void updateBestTake() { if (gridMedia instanceof Photo) { Photo photo = (Photo) gridMedia; - if (photo.isBestTake()) { - setLabelText(AppConstants.BESTTAKE_LABEL_TEXT); + if (photo.isSelected()) { + setLabelText(AppConstants.SELECTED_LABEL_TEXT); } else { setLabelText(null); } @@ -96,11 +99,43 @@ public Photo getThumbnailPhoto() { return thumbnailPhoto; } + public void setCurrentlyViewed(boolean currentlyViewed) { + this.isCurrentlyViewed = currentlyViewed; + repaint(); + } + + public boolean isCurrentlyViewed() { + return isCurrentlyViewed; + } + @Override protected void paintComponent(Graphics g) { - super.paintComponent(g); + // Apply 50% opacity to groups with 0 selections + if (gridMedia instanceof PhotoGroup) { + PhotoGroup photoGroup = (PhotoGroup) gridMedia; + if (photoGroup.getSelectedTakes().isEmpty()) { + Graphics2D g2d = (Graphics2D) g.create(); + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f)); + super.paintComponent(g2d); + g2d.dispose(); + } else { + super.paintComponent(g); + } + } else { + super.paintComponent(g); + } + updateBestTake(); + // Draw 3px red border if this is the currently viewed photo + if (isCurrentlyViewed && gridMedia instanceof Photo) { + Graphics2D g2d = (Graphics2D) g.create(); + g2d.setColor(Color.RED); + g2d.setStroke(new BasicStroke(3)); + g2d.drawRect(1, 1, getWidth() - 3, getHeight() - 3); + g2d.dispose(); + } + if (labelText != null) { Graphics2D graphics2d = (Graphics2D) g.create(); drawLabelText(graphics2d, labelText); diff --git a/src/main/java/com/penguinpush/cullergrader/ui/components/JGridPanel.java b/src/main/java/com/penguinpush/cullergrader/ui/components/JGridPanel.java index b0bf004..a9d442d 100644 --- a/src/main/java/com/penguinpush/cullergrader/ui/components/JGridPanel.java +++ b/src/main/java/com/penguinpush/cullergrader/ui/components/JGridPanel.java @@ -89,12 +89,28 @@ public void updatePriorities(int ONSCREEN_PRIORITY, int OFFSCREEN_PRIORITY) { imageLoader.updatePriority(photo, priority); } else if (jGridMedia.gridMedia instanceof PhotoGroup) { PhotoGroup photoGroup = (PhotoGroup) jGridMedia.gridMedia; - imageLoader.updatePriority(photoGroup.getBestTake(), priority); + // Update priority for first photo in group (thumbnail) + if (!photoGroup.getPhotos().isEmpty()) { + Photo firstPhoto = photoGroup.getPhotos().get(0); + imageLoader.updatePriority(firstPhoto, priority); + } } } } } + public void setCurrentlyViewedPhoto(Photo currentPhoto) { + for (Component component : gridPanel.getComponents()) { + if (component instanceof JGridMedia) { + JGridMedia jGridMedia = (JGridMedia) component; + // Set border on the matching photo thumbnail + boolean isCurrentlyViewed = jGridMedia.gridMedia instanceof Photo + && jGridMedia.gridMedia == currentPhoto; + jGridMedia.setCurrentlyViewed(isCurrentlyViewed); + } + } + } + private void initComponentProperties() { gridPanel.setLayout(new WrapLayout(FlowLayout.LEFT, singleRow)); diff --git a/src/main/resources/config.json b/src/main/resources/config.json index 99f0e21..d449230 100644 --- a/src/main/resources/config.json +++ b/src/main/resources/config.json @@ -9,5 +9,6 @@ "HASHED_WIDTH": 8, "HASHED_HEIGHT": 8, "TIME_THRESHOLD_SECONDS": 15, - "SIMILARITY_THRESHOLD_PERCENT": 45 + "SIMILARITY_THRESHOLD_PERCENT": 45, + "DEFAULT_SELECTION_STRATEGY": "first" } \ No newline at end of file