Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -91,17 +91,17 @@ 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)

**Tip:** by hovering on a photo in the photo viewer, you can view its name, seconds between the last photo, and similarity % to the last photo. Use this information to help you calibrate the grouper.

![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)

Expand All @@ -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"
}
```

Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = "★";
}
48 changes: 42 additions & 6 deletions src/main/java/com/penguinpush/cullergrader/logic/FileUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -25,19 +28,52 @@ public static void exportBestTakes(List<PhotoGroup> photoGroups, File targetFold
targetFolder.mkdirs();
}

Map<String, Integer> 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<Photo> 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) : "";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public List<PhotoGroup> generateGroups(List<Photo> photoList, float timestampThr
currentGroup.addPhoto(current);
} else {
currentGroup.setIndex(groups.size());
currentGroup.applyDefaultSelectionStrategy();
groups.add(currentGroup);
currentGroup = new PhotoGroup();

Expand All @@ -72,6 +73,7 @@ public List<PhotoGroup> generateGroups(List<Photo> photoList, float timestampThr
// add the last group too
if (currentGroup.getSize() > 0) {
currentGroup.setIndex(groups.size());
currentGroup.applyDefaultSelectionStrategy();
groups.add(currentGroup);
}

Expand Down
4 changes: 2 additions & 2 deletions src/main/java/com/penguinpush/cullergrader/media/Photo.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
103 changes: 85 additions & 18 deletions src/main/java/com/penguinpush/cullergrader/media/PhotoGroup.java
Original file line number Diff line number Diff line change
@@ -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<Photo> photos = new ArrayList<>();
private Photo bestTake;
private final LinkedHashSet<Photo> 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
Expand All @@ -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<Photo> photos) {
Expand All @@ -46,29 +51,91 @@ public void addPhotos(List<Photo> 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<Photo> getPhotos() {
return Collections.unmodifiableList(photos); // read-only list
}

public void setBestTake(Photo photo) {

// New selection methods
public Set<Photo> 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'");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<Property name="accelerator" type="javax.swing.KeyStroke" editor="org.netbeans.modules.form.editors.KeyStrokeEditor">
<KeyStroke key="Ctrl+S"/>
</Property>
<Property name="text" type="java.lang.String" value="Export Best Takes"/>
<Property name="text" type="java.lang.String" value="Export Selected Takes"/>
</Properties>
<Events>
<EventHandler event="actionPerformed" listener="java.awt.event.ActionListener" parameters="java.awt.event.ActionEvent" handler="jMenuItemExportActionPerformed"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
<Property name="accelerator" type="javax.swing.KeyStroke" editor="org.netbeans.modules.form.editors.KeyStrokeEditor">
<KeyStroke key="SPACE"/>
</Property>
<Property name="text" type="java.lang.String" value="Set Best Take"/>
<Property name="text" type="java.lang.String" value="Set as Selected Take"/>
</Properties>
<Events>
<EventHandler event="actionPerformed" listener="java.awt.event.ActionListener" parameters="java.awt.event.ActionEvent" handler="jMenuSetBestTakeActionPerformed"/>
Expand Down
Loading