From b657bd29976b387eec47dc8d38c5d086a3c57488 Mon Sep 17 00:00:00 2001 From: Cyprian Zdebski Date: Sat, 13 Dec 2025 23:42:56 +0100 Subject: [PATCH 1/6] add CLI mode --- .../com/penguinpush/cullergrader/CLI.java | 281 ++++++++++++++++++ .../com/penguinpush/cullergrader/Main.java | 43 ++- 2 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/penguinpush/cullergrader/CLI.java diff --git a/src/main/java/com/penguinpush/cullergrader/CLI.java b/src/main/java/com/penguinpush/cullergrader/CLI.java new file mode 100644 index 0000000..20895c7 --- /dev/null +++ b/src/main/java/com/penguinpush/cullergrader/CLI.java @@ -0,0 +1,281 @@ +package com.penguinpush.cullergrader; + +import com.penguinpush.cullergrader.logic.*; +import com.penguinpush.cullergrader.media.*; +import com.penguinpush.cullergrader.config.AppConstants; + +import java.io.File; +import java.util.List; + +/** + * Command-line interface for Cullergrader. + * Provides photo grouping and export functionality without launching the GUI. + */ +public class CLI { + + // Exit codes + private static final int EXIT_SUCCESS = 0; + private static final int EXIT_FAILURE = 1; + + // Parsed arguments with defaults from AppConstants + private String inputPath = null; + private String outputPath = null; + private float timeThreshold = AppConstants.TIME_THRESHOLD_SECONDS; + private float similarityThreshold = AppConstants.SIMILARITY_THRESHOLD_PERCENT; + + /** + * Main entry point for CLI mode. + * + * @param args Command-line arguments + * @return Exit code (0 = success, 1 = failure) + */ + public int run(String[] args) { + // Handle --help first + if (hasArgument(args, "--help") || hasArgument(args, "-h")) { + printHelp(); + return EXIT_SUCCESS; + } + + // Parse arguments + if (!parseArguments(args)) { + System.err.println("Error: Invalid arguments. Use --help for usage information."); + return EXIT_FAILURE; + } + + // Validate required arguments + if (inputPath == null) { + System.err.println("Error: --input is required."); + printHelp(); + return EXIT_FAILURE; + } + + // Validate input directory + File inputFolder = new File(inputPath); + if (!inputFolder.exists() || !inputFolder.isDirectory()) { + System.err.println("Error: Input directory does not exist: " + inputPath); + return EXIT_FAILURE; + } + + if (!inputFolder.canRead()) { + System.err.println("Error: Cannot read input directory: " + inputPath); + return EXIT_FAILURE; + } + + // Validate output directory if provided + File outputFolder = null; + if (outputPath != null) { + outputFolder = new File(outputPath); + if (outputFolder.exists() && !outputFolder.isDirectory()) { + System.err.println("Error: Output path exists but is not a directory: " + outputPath); + return EXIT_FAILURE; + } + + if (outputFolder.exists() && !outputFolder.canWrite()) { + System.err.println("Error: Cannot write to output directory: " + outputPath); + return EXIT_FAILURE; + } + } + + // Execute workflow + try { + executeWorkflow(inputFolder, outputFolder); + return EXIT_SUCCESS; + } catch (Exception e) { + System.err.println("Error: Processing failed - " + e.getMessage()); + e.printStackTrace(); + return EXIT_FAILURE; + } + } + + /** + * Parses command-line arguments. + * + * @param args Command-line arguments + * @return true if parsing succeeded, false on error + */ + private boolean parseArguments(String[] args) { + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + + // Input path + if (arg.equals("--input") || arg.equals("-i")) { + if (i + 1 >= args.length) { + System.err.println("Error: --input requires a value"); + return false; + } + inputPath = args[++i]; + } + // Output path + else if (arg.equals("--output") || arg.equals("-o")) { + if (i + 1 >= args.length) { + System.err.println("Error: --output requires a value"); + return false; + } + outputPath = args[++i]; + } + // Time threshold + else if (arg.equals("--time") || arg.equals("-t")) { + if (i + 1 >= args.length) { + System.err.println("Error: --time requires a value"); + return false; + } + try { + timeThreshold = Float.parseFloat(args[++i]); + if (timeThreshold <= 0) { + System.err.println("Error: Time threshold must be positive"); + return false; + } + } catch (NumberFormatException e) { + System.err.println("Error: Invalid time threshold value: " + args[i]); + return false; + } + } + // Similarity threshold + else if (arg.equals("--similarity") || arg.equals("-s")) { + if (i + 1 >= args.length) { + System.err.println("Error: --similarity requires a value"); + return false; + } + try { + similarityThreshold = Float.parseFloat(args[++i]); + if (similarityThreshold < 0 || similarityThreshold > 100) { + System.err.println("Error: Similarity threshold must be 0-100"); + return false; + } + } catch (NumberFormatException e) { + System.err.println("Error: Invalid similarity threshold value: " + args[i]); + return false; + } + } + // Skip --help and -h (handled in run method) + else if (arg.equals("--help") || arg.equals("-h")) { + // Already handled in run(), just skip + } + // Unknown argument + else if (arg.startsWith("-")) { + System.err.println("Error: Unknown argument: " + arg); + return false; + } + } + + return true; + } + + /** + * Executes the main CLI workflow: load photos, generate groups, and export. + * + * @param inputFolder Input directory containing photos + * @param outputFolder Output directory for best takes + */ + private void executeWorkflow(File inputFolder, File outputFolder) { + long startTime = System.currentTimeMillis(); + boolean previewMode = (outputFolder == null); + + // Print configuration header + System.out.println("Cullergrader CLI"); + System.out.println("================"); + System.out.println("Input: " + inputFolder.getAbsolutePath()); + if (!previewMode) { + System.out.println("Output: " + outputFolder.getAbsolutePath()); + } else { + System.out.println("Mode: Preview (no files will be exported)"); + } + System.out.println("Time threshold: " + timeThreshold + " seconds"); + System.out.println("Similarity threshold: " + similarityThreshold + "%"); + System.out.println(); + + // Load and hash photos + System.out.println("Loading and hashing photos from: " + inputFolder.getAbsolutePath()); + GroupingEngine engine = new GroupingEngine(); + List photos = engine.photoListFromFolder(inputFolder); + + if (photos.isEmpty()) { + System.out.println("No photos found in input directory."); + return; + } + + System.out.println("Found " + photos.size() + " photos"); + System.out.println(); + + // Generate groups + System.out.println("Generating groups with thresholds: " + timeThreshold + "s time, " + similarityThreshold + "% similarity"); + List groups = engine.generateGroups(photos, timeThreshold, similarityThreshold); + + System.out.println("Created " + groups.size() + " groups from " + photos.size() + " photos"); + System.out.println(); + + // Export or preview + if (previewMode) { + System.out.println("Preview - Best takes that would be exported:"); + System.out.println("--------------------------------------------"); + for (int i = 0; i < groups.size(); i++) { + PhotoGroup group = groups.get(i); + Photo bestTake = group.getBestTake(); + if (bestTake != null) { + System.out.println("[Group " + i + "] " + bestTake.getFile().getName()); + } + } + System.out.println(); + System.out.println("To export these " + groups.size() + " files, run again with --output "); + } else { + System.out.println("Exporting best takes to: " + outputFolder.getAbsolutePath()); + FileUtils.exportBestTakes(groups, outputFolder); + System.out.println(); + System.out.println("Successfully exported " + groups.size() + " files"); + } + + // Summary + long endTime = System.currentTimeMillis(); + long durationMs = endTime - startTime; + double durationSec = durationMs / 1000.0; + System.out.println(); + System.out.println("Processing completed in " + String.format("%.2f", durationSec) + " seconds"); + } + + /** + * Prints help message showing usage and available options. + */ + private void printHelp() { + System.out.println("Cullergrader CLI - Photo grouping and export tool"); + System.out.println(); + System.out.println("USAGE:"); + System.out.println(" java -jar cullergrader.jar [OPTIONS]"); + System.out.println(); + System.out.println(" No arguments launches GUI mode"); + System.out.println(); + System.out.println("OPTIONS:"); + System.out.println(" -i, --input Input folder containing photos (required)"); + System.out.println(" -o, --output Output folder for best takes (optional, preview mode if omitted)"); + System.out.println(" -t, --time Time threshold in seconds (default: " + AppConstants.TIME_THRESHOLD_SECONDS + ")"); + System.out.println(" -s, --similarity Similarity threshold 0-100 (default: " + AppConstants.SIMILARITY_THRESHOLD_PERCENT + ")"); + System.out.println(" -h, --help Show this help message"); + System.out.println(); + System.out.println("EXAMPLES:"); + System.out.println(" # Preview mode (no export)"); + System.out.println(" java -jar cullergrader.jar --input /photos"); + System.out.println(); + System.out.println(" # Export mode"); + System.out.println(" java -jar cullergrader.jar --input /photos --output /export"); + System.out.println(); + System.out.println(" # Custom thresholds with export"); + System.out.println(" java -jar cullergrader.jar -i /photos -o /export -t 10 -s 40"); + System.out.println(); + } + + /** + * Helper method to check if a specific flag is present in arguments. + * Used by Main.java to detect CLI mode. + * + * @param args Command-line arguments + * @param flag Flag to search for + * @return true if flag is present, false otherwise + */ + public static boolean hasArgument(String[] args, String flag) { + for (String arg : args) { + if (arg.equals(flag)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/penguinpush/cullergrader/Main.java b/src/main/java/com/penguinpush/cullergrader/Main.java index bae4efa..f5eb719 100644 --- a/src/main/java/com/penguinpush/cullergrader/Main.java +++ b/src/main/java/com/penguinpush/cullergrader/Main.java @@ -10,17 +10,44 @@ public class Main { public static void main(String[] args) { - // load theme - if (AppConstants.DARK_THEME) { - FlatDarculaLaf.setup(); + // Detect CLI mode by checking for CLI-specific arguments + if (isCLIMode(args)) { + // CLI mode - skip GUI initialization + CLI cli = new CLI(); + int exitCode = cli.run(args); + System.exit(exitCode); } else { - FlatIntelliJLaf.setup(); + // GUI mode + // load theme + if (AppConstants.DARK_THEME) { + FlatDarculaLaf.setup(); + } else { + FlatIntelliJLaf.setup(); + } + + GroupingEngine groupingEngine = new GroupingEngine(); + ImageLoader imageLoader = new ImageLoader(); + + SwingUtilities.invokeLater(() -> new GroupGridFrame(imageLoader, groupingEngine)); + GroupGridFrame.initializeLoggerCallback(); } + } - GroupingEngine groupingEngine = new GroupingEngine(); - ImageLoader imageLoader = new ImageLoader(); + /** + * Determines if the application should run in CLI mode based on command-line arguments. + * + * @param args Command-line arguments + * @return true if CLI mode should be used, false for GUI mode + */ + private static boolean isCLIMode(String[] args) { + if (args.length == 0) { + return false; + } - SwingUtilities.invokeLater(() -> new GroupGridFrame(imageLoader, groupingEngine)); - GroupGridFrame.initializeLoggerCallback(); + // Check for CLI-specific flags + return CLI.hasArgument(args, "--input") || + CLI.hasArgument(args, "-i") || + CLI.hasArgument(args, "--help") || + CLI.hasArgument(args, "-h"); } } From 9518e5c34fb39482d5ca7edd17efa7b0caac6771 Mon Sep 17 00:00:00 2001 From: Cyprian Zdebski Date: Sat, 13 Dec 2025 23:43:02 +0100 Subject: [PATCH 2/6] update README --- README.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/README.md b/README.md index 2f67e8e..12d08c9 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,47 @@ Best takes can be exported to a folder using `File > Export Best Takes` or with ![images/export_to.png](images/export_to.png) +## CLI Usage + +Cullergrader can be run in command-line mode for automated workflows and scripting. + +### Basic Usage + +```bash +# Launch GUI (no arguments) +java -jar cullergrader.jar + +# Run CLI mode +java -jar cullergrader.jar --input /path/to/photos --output /path/to/export +``` + +### CLI Options + +| Option | Short | Description | Required | +|--------|-------|-------------|----------| +| `--input` | `-i` | Input folder containing photos | Yes | +| `--output` | `-o` | Output folder for best takes (preview mode if omitted) | No | +| `--time` | `-t` | Time threshold in seconds (default: 15) | No | +| `--similarity` | `-s` | Similarity threshold 0-100 (default: 45) | No | +| `--help` | `-h` | Show help message | No | + +### Examples + +**Preview mode (no export)**: +```bash +java -jar cullergrader.jar --input ~/photos/vacation +``` + +**Export to folder**: +```bash +java -jar cullergrader.jar --input ~/photos/vacation --output ~/photos/best +``` + +**Custom thresholds**: +```bash +java -jar cullergrader.jar -i ~/photos/vacation -o ~/photos/best -t 10 -s 40 +``` + ## Config ### Default Config ```json From b846054ee4831f61de37afc5ae676c1deb8a5c12 Mon Sep 17 00:00:00 2001 From: Cyprian Zdebski Date: Sun, 14 Dec 2025 01:25:44 +0100 Subject: [PATCH 3/6] Add 'export JSON information' options to both GUI and CLI --- README.md | 11 +++ .../com/penguinpush/cullergrader/CLI.java | 24 +++++++ .../cullergrader/logic/FileUtils.java | 70 +++++++++++++++++++ .../cullergrader/ui/GroupGridFrame.java | 66 +++++++++++++++++ 4 files changed, 171 insertions(+) diff --git a/README.md b/README.md index 12d08c9..778ae74 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ java -jar cullergrader.jar --input /path/to/photos --output /path/to/export |--------|-------|-------------|----------| | `--input` | `-i` | Input folder containing photos | Yes | | `--output` | `-o` | Output folder for best takes (preview mode if omitted) | No | +| `--json` | `-j` | Export group information to JSON file | No | | `--time` | `-t` | Time threshold in seconds (default: 15) | No | | `--similarity` | `-s` | Similarity threshold 0-100 (default: 45) | No | | `--help` | `-h` | Show help message | No | @@ -146,6 +147,16 @@ java -jar cullergrader.jar --input ~/photos/vacation --output ~/photos/best java -jar cullergrader.jar -i ~/photos/vacation -o ~/photos/best -t 10 -s 40 ``` +**Export JSON metadata only**: +```bash +java -jar cullergrader.jar --input ~/photos/vacation --json groups.json +``` + +**Export both files and JSON**: +```bash +java -jar cullergrader.jar -i ~/photos/vacation -o ~/photos/best --json ~/photos/best/groups.json +``` + ## Config ### Default Config ```json diff --git a/src/main/java/com/penguinpush/cullergrader/CLI.java b/src/main/java/com/penguinpush/cullergrader/CLI.java index 20895c7..8fe020b 100644 --- a/src/main/java/com/penguinpush/cullergrader/CLI.java +++ b/src/main/java/com/penguinpush/cullergrader/CLI.java @@ -20,6 +20,7 @@ public class CLI { // Parsed arguments with defaults from AppConstants private String inputPath = null; private String outputPath = null; + private String jsonPath = null; private float timeThreshold = AppConstants.TIME_THRESHOLD_SECONDS; private float similarityThreshold = AppConstants.SIMILARITY_THRESHOLD_PERCENT; @@ -113,6 +114,14 @@ else if (arg.equals("--output") || arg.equals("-o")) { } outputPath = args[++i]; } + // JSON export path + else if (arg.equals("--json") || arg.equals("-j")) { + if (i + 1 >= args.length) { + System.err.println("Error: --json requires a value"); + return false; + } + jsonPath = args[++i]; + } // Time threshold else if (arg.equals("--time") || arg.equals("-t")) { if (i + 1 >= args.length) { @@ -204,6 +213,14 @@ private void executeWorkflow(File inputFolder, File outputFolder) { System.out.println("Created " + groups.size() + " groups from " + photos.size() + " photos"); System.out.println(); + // Export JSON if requested + if (jsonPath != null) { + File jsonFile = new File(jsonPath); + System.out.println("Exporting group information to: " + jsonFile.getAbsolutePath()); + FileUtils.exportGroupsJson(groups, jsonFile, timeThreshold, similarityThreshold); + System.out.println(); + } + // Export or preview if (previewMode) { System.out.println("Preview - Best takes that would be exported:"); @@ -246,6 +263,7 @@ private void printHelp() { System.out.println("OPTIONS:"); System.out.println(" -i, --input Input folder containing photos (required)"); System.out.println(" -o, --output Output folder for best takes (optional, preview mode if omitted)"); + System.out.println(" -j, --json Export group information to JSON file (optional)"); System.out.println(" -t, --time Time threshold in seconds (default: " + AppConstants.TIME_THRESHOLD_SECONDS + ")"); System.out.println(" -s, --similarity Similarity threshold 0-100 (default: " + AppConstants.SIMILARITY_THRESHOLD_PERCENT + ")"); System.out.println(" -h, --help Show this help message"); @@ -257,6 +275,12 @@ private void printHelp() { System.out.println(" # Export mode"); System.out.println(" java -jar cullergrader.jar --input /photos --output /export"); System.out.println(); + System.out.println(" # Export JSON metadata only"); + System.out.println(" java -jar cullergrader.jar --input /photos --json groups.json"); + System.out.println(); + System.out.println(" # Export both files and JSON"); + System.out.println(" java -jar cullergrader.jar -i /photos -o /export --json /export/groups.json"); + System.out.println(); System.out.println(" # Custom thresholds with export"); System.out.println(" java -jar cullergrader.jar -i /photos -o /export -t 10 -s 40"); System.out.println(); diff --git a/src/main/java/com/penguinpush/cullergrader/logic/FileUtils.java b/src/main/java/com/penguinpush/cullergrader/logic/FileUtils.java index f3cfc2a..6896691 100644 --- a/src/main/java/com/penguinpush/cullergrader/logic/FileUtils.java +++ b/src/main/java/com/penguinpush/cullergrader/logic/FileUtils.java @@ -4,13 +4,20 @@ import com.penguinpush.cullergrader.media.PhotoGroup; import static com.penguinpush.cullergrader.utils.Logger.logMessage; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + import javax.swing.*; import java.io.File; +import java.io.FileWriter; import java.io.IOException; import java.nio.file.Files; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class FileUtils { @@ -40,4 +47,67 @@ public static void exportBestTakes(List photoGroups, File targetFold } } } + + public static void exportGroupsJson(List photoGroups, File jsonFile, + float timeThreshold, float similarityThreshold) { + // Create parent directories if needed + File parentDir = jsonFile.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs(); + } + + // Build JSON structure + Map root = new HashMap<>(); + root.put("totalGroups", photoGroups.size()); + root.put("totalPhotos", photoGroups.stream().mapToInt(PhotoGroup::getSize).sum()); + root.put("exportTimestamp", System.currentTimeMillis()); + + Map thresholds = new HashMap<>(); + thresholds.put("timeThresholdSeconds", timeThreshold); + thresholds.put("similarityThresholdPercent", similarityThreshold); + root.put("thresholds", thresholds); + + List> groupsList = new ArrayList<>(); + for (PhotoGroup group : photoGroups) { + Map groupMap = new HashMap<>(); + groupMap.put("groupIndex", group.getIndex()); + groupMap.put("photoCount", group.getSize()); + + Photo bestTake = group.getBestTake(); + if (bestTake != null) { + groupMap.put("bestTakeFilename", bestTake.getFile().getName()); + } + + List> photosList = new ArrayList<>(); + for (Photo photo : group.getPhotos()) { + Map photoMap = new HashMap<>(); + photoMap.put("filename", photo.getFile().getName()); + photoMap.put("path", photo.getPath()); + photoMap.put("timestamp", photo.getTimestamp()); + photoMap.put("hash", photo.getHash()); + photoMap.put("isBestTake", photo.isBestTake()); + + List metrics = photo.getMetrics(); + if (metrics.size() >= 2) { + photoMap.put("deltaTimeSeconds", metrics.get(0)); + photoMap.put("similarityPercent", metrics.get(1)); + } + + photosList.add(photoMap); + } + groupMap.put("photos", photosList); + groupsList.add(groupMap); + } + root.put("groups", groupsList); + + // Write JSON file with pretty printing + try (FileWriter writer = new FileWriter(jsonFile)) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + gson.toJson(root, writer); + logMessage("Exported group information to: " + jsonFile.getAbsolutePath()); + } catch (IOException e) { + logMessage("Failed to export JSON: " + e.getMessage()); + throw new RuntimeException("Failed to export JSON", e); + } + } } diff --git a/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java b/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java index f702a40..859fbeb 100644 --- a/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java +++ b/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java @@ -103,6 +103,7 @@ private void initComponents() { jMenu = new javax.swing.JMenu(); jMenuItemOpen = new javax.swing.JMenuItem(); jMenuItemExport = new javax.swing.JMenuItem(); + jMenuItemExportJson = new javax.swing.JMenuItem(); setDefaultCloseOperation(javax.swing.WindowConstants.EXIT_ON_CLOSE); setTitle("Cullergrader"); @@ -149,6 +150,14 @@ public void actionPerformed(java.awt.event.ActionEvent evt) { }); jMenu.add(jMenuItemExport); + jMenuItemExportJson.setText("Export Group Information (JSON)"); + jMenuItemExportJson.addActionListener(new java.awt.event.ActionListener() { + public void actionPerformed(java.awt.event.ActionEvent evt) { + jMenuItemExportJsonActionPerformed(evt); + } + }); + jMenu.add(jMenuItemExportJson); + jMenuBar.add(jMenu); setJMenuBar(jMenuBar); @@ -214,6 +223,62 @@ private void jMenuItemExportActionPerformed(java.awt.event.ActionEvent evt) {//G } }//GEN-LAST:event_jMenuItemExportActionPerformed + private void jMenuItemExportJsonActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_jMenuItemExportJsonActionPerformed + if (photoGroups == null || photoGroups.isEmpty()) { + JOptionPane.showMessageDialog( + null, + "No groups to export. Please open a folder first.", + "No Groups", + JOptionPane.WARNING_MESSAGE + ); + return; + } + + JFileChooser chooser = new JFileChooser(importDirectory != null ? importDirectory : new File(AppConstants.DEFAULT_FOLDER_PATH)); + chooser.setDialogTitle("Export Group Information (JSON)"); + chooser.setFileSelectionMode(JFileChooser.FILES_ONLY); + chooser.setSelectedFile(new File("groups.json")); + chooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("JSON files", "json")); + + int result = chooser.showSaveDialog(null); + if (result == JFileChooser.APPROVE_OPTION) { + File selectedFile = chooser.getSelectedFile(); + + // Ensure .json extension + final File jsonFile; + if (!selectedFile.getName().toLowerCase().endsWith(".json")) { + jsonFile = new File(selectedFile.getPath() + ".json"); + } else { + jsonFile = selectedFile; + } + + try { + float timeThreshold = (float) jTimestampSpinner.getValue(); + float similarityThreshold = (float) jSimilaritySpinner.getValue(); + + FileUtils.exportGroupsJson(photoGroups, jsonFile, timeThreshold, similarityThreshold); + + SwingUtilities.invokeLater(() -> { + JOptionPane.showMessageDialog( + null, + "Group information exported to:\n" + jsonFile.getAbsolutePath(), + "Export Successful!", + JOptionPane.INFORMATION_MESSAGE + ); + }); + } catch (Exception e) { + SwingUtilities.invokeLater(() -> { + JOptionPane.showMessageDialog( + null, + "Failed to export JSON: " + e.getMessage(), + "Export Failed", + JOptionPane.ERROR_MESSAGE + ); + }); + } + } + }//GEN-LAST:event_jMenuItemExportJsonActionPerformed + private void jMenuItemOpenActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_jMenuItemOpenActionPerformed JFileChooser chooser = new JFileChooser(importDirectory != null ? importDirectory : new File(AppConstants.DEFAULT_FOLDER_PATH)); chooser.setDialogTitle("Open Folder..."); @@ -261,6 +326,7 @@ private void jReloadButtonActionPerformed(java.awt.event.ActionEvent evt) {//GEN private javax.swing.JMenu jMenu; private javax.swing.JMenuBar jMenuBar; private javax.swing.JMenuItem jMenuItemExport; + private javax.swing.JMenuItem jMenuItemExportJson; private javax.swing.JMenuItem jMenuItemOpen; private javax.swing.JButton jReloadButton; private javax.swing.JLabel jSimilarityLabel; From 693452023efae025672bd718961dfbbe58b755ea Mon Sep 17 00:00:00 2001 From: Cyprian Zdebski Date: Sun, 14 Dec 2025 06:08:28 +0100 Subject: [PATCH 4/6] Feature/add raw support (#1) * Add preview image RAM caching + RAW files support * simplify caching logic * update readme --- README.md | 44 ++- pom.xml | 2 +- .../com/penguinpush/cullergrader/Main.java | 1 + .../cullergrader/config/AppConstants.java | 1 + .../config/DefaultAppConstants.java | 1 + .../cullergrader/logic/GroupingEngine.java | 16 +- .../cullergrader/media/PhotoUtils.java | 251 +++++++++++++++++- .../cullergrader/ui/GroupGridFrame.java | 4 + .../cullergrader/utils/Logger.java | 19 ++ src/main/resources/config.json | 3 +- 10 files changed, 319 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 2f67e8e..b4a8a7e 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,13 @@ Cullergrader is named for being a tool that culls and grades* photos. Please not 4. [Config](#config) 1. [Default Config](#default-config) 2. [Config Settings Explained](#config-settings-explained) -5. [Contributing](#contributing) -6. [License](#license) +5. [Performance Tuning](#performance-tuning) +6. [Contributing](#contributing) +7. [License](#license) ## Features - 100% free and open-source! +- Experimental RAW image format support - works with most camera RAW files that contain an embedded JPEG preview - Configurable options for calibrating your perceptual hash - Hash similarity - Timestamp difference @@ -48,7 +50,7 @@ Cullergrader is named for being a tool that culls and grades* photos. Please not - Cache options - Grouping settings - Dark theme - + ![images/group_viewer.png](images/group_viewer.png) @@ -106,6 +108,9 @@ Best takes can be exported to a folder using `File > Export Best Takes` or with ![images/export_to.png](images/export_to.png) ## Config + +Configuration is **optional**. Cullergrader includes sensible defaults for all settings. To customize, create a `config.json` file in the same directory as the executable. + ### Default Config ```json { @@ -119,7 +124,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, + "IMAGE_PREVIEW_CACHE_SIZE_MB": 1024 } ``` @@ -137,8 +143,36 @@ 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` | +| `IMAGE_PREVIEW_CACHE_SIZE_MB` | Maximum memory (in megabytes) to use for caching image previews. Default 1024 MB (1 GB). Increase for large photo shoots (see Performance Tuning section) | `int` | + +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. + +## Performance Tuning + +For large photo shoots, you can increase the image preview cache size in `config.json`: + +```json +{ + "IMAGE_PREVIEW_CACHE_SIZE_MB": 2048 +} +``` + +**Default:** 2048 MB (2 GB) + +The preview cache stores scaled thumbnails (240×160) for all image files in memory to avoid re-reading from disk. The cache fills until reaching the configured limit, then stops caching new entries (no eviction). + +**Configuration examples:** +- 512 MB = Smaller cache for limited memory systems +- 1024 MB = Moderate cache for typical photo shoots +- 2048 MB = Default (recommended for most users) +- 4096 MB = Very large photo shoots (4000+ files) + +The cache applies to all image formats (JPEG, PNG, RAW, etc.) and significantly improves performance when: +- Changing grouping thresholds +- Reloading groups +- Scrolling through large photo sets -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. +The cache clears automatically when loading a new directory. ## 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/pom.xml b/pom.xml index b59ec67..3291a0e 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ com.drewnoakes metadata-extractor - 2.18.0 + 2.19.0 com.google.code.gson diff --git a/src/main/java/com/penguinpush/cullergrader/Main.java b/src/main/java/com/penguinpush/cullergrader/Main.java index bae4efa..15f5236 100644 --- a/src/main/java/com/penguinpush/cullergrader/Main.java +++ b/src/main/java/com/penguinpush/cullergrader/Main.java @@ -3,6 +3,7 @@ import com.penguinpush.cullergrader.logic.*; import com.penguinpush.cullergrader.ui.GroupGridFrame; import com.penguinpush.cullergrader.config.AppConstants; +import com.penguinpush.cullergrader.media.PhotoUtils; import javax.swing.SwingUtilities; import com.formdev.flatlaf.FlatIntelliJLaf; import com.formdev.flatlaf.FlatDarculaLaf; diff --git a/src/main/java/com/penguinpush/cullergrader/config/AppConstants.java b/src/main/java/com/penguinpush/cullergrader/config/AppConstants.java index b6c8d5b..21a9f75 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 int IMAGE_PREVIEW_CACHE_SIZE_MB = config.IMAGE_PREVIEW_CACHE_SIZE_MB; public static final int MAX_PRIORITY = config.MAX_PRIORITY; public static final int IMAGE_PRIORITY = config.IMAGE_PRIORITY; diff --git a/src/main/java/com/penguinpush/cullergrader/config/DefaultAppConstants.java b/src/main/java/com/penguinpush/cullergrader/config/DefaultAppConstants.java index 366c76e..797293c 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 int IMAGE_PREVIEW_CACHE_SIZE_MB = 2048; // Default 2048 MB (2 GB) public int MAX_PRIORITY = 0; public int IMAGE_PRIORITY = 1; diff --git a/src/main/java/com/penguinpush/cullergrader/logic/GroupingEngine.java b/src/main/java/com/penguinpush/cullergrader/logic/GroupingEngine.java index 5932871..fe23ed4 100644 --- a/src/main/java/com/penguinpush/cullergrader/logic/GroupingEngine.java +++ b/src/main/java/com/penguinpush/cullergrader/logic/GroupingEngine.java @@ -32,11 +32,21 @@ public List photoListFromFolder(File folder) { } public List generateGroups(List photoList, float timestampThreshold, float similarityThreshold) { + // Filter out photos with null hashes (RAW files without previews, corrupted files, etc.) + List validPhotos = new ArrayList<>(); + for (Photo photo : photoList) { + if (photo.getHash() != null) { + validPhotos.add(photo); + } else { + logMessage("Skipping photo with null hash: " + photo.getFile().getName()); + } + } + List groups = new ArrayList<>(); PhotoGroup currentGroup = new PhotoGroup(); - for (int i = 0; i < photoList.size(); i++) { - Photo current = photoList.get(i); + for (int i = 0; i < validPhotos.size(); i++) { + Photo current = validPhotos.get(i); if (currentGroup.getSize() == 0) { current.setIndex(0); @@ -69,6 +79,8 @@ public List generateGroups(List photoList, float timestampThr logMessage("added " + current.getFile().getName() + " " + deltaTimeSeconds + " " + hammingDistancePercent + " to group " + groups.size()); } + logMessage("Filtered " + (photoList.size() - validPhotos.size()) + " photos with null hashes"); + // add the last group too if (currentGroup.getSize() > 0) { currentGroup.setIndex(groups.size()); diff --git a/src/main/java/com/penguinpush/cullergrader/media/PhotoUtils.java b/src/main/java/com/penguinpush/cullergrader/media/PhotoUtils.java index 349cc3f..bbe2084 100644 --- a/src/main/java/com/penguinpush/cullergrader/media/PhotoUtils.java +++ b/src/main/java/com/penguinpush/cullergrader/media/PhotoUtils.java @@ -1,48 +1,237 @@ package com.penguinpush.cullergrader.media; import static com.penguinpush.cullergrader.utils.Logger.logMessage; +import static com.penguinpush.cullergrader.utils.Logger.logToConsoleOnly; +import com.penguinpush.cullergrader.config.AppConstants; import com.drew.imaging.ImageMetadataReader; import com.drew.metadata.Metadata; import com.drew.metadata.exif.ExifSubIFDDirectory; +import com.drew.metadata.exif.ExifThumbnailDirectory; import java.awt.Image; import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; import java.io.File; +import java.io.RandomAccessFile; import java.util.*; +import java.util.concurrent.atomic.AtomicLong; import javax.imageio.*; import javax.imageio.stream.ImageInputStream; import javax.swing.*; public class PhotoUtils { + // Simple fill-until-full cache for all image previews (strong references to avoid GC under memory pressure) + // Caches SCALED previews at display resolution (240×160 default) + // Cache size configured via IMAGE_PREVIEW_CACHE_SIZE_MB in config.json (default 1024 MB) + private static final long MAX_CACHE_SIZE_BYTES = AppConstants.IMAGE_PREVIEW_CACHE_SIZE_MB * 1024L * 1024L; + private static long currentCacheSizeBytes = 0; + + // Hit/miss tracking for monitoring cache effectiveness + private static final AtomicLong cacheHits = new AtomicLong(0); + private static final AtomicLong cacheMisses = new AtomicLong(0); + + // Simple fill-until-full cache with no eviction + private static final Map imagePreviewCache = + Collections.synchronizedMap(new LinkedHashMap(16, 0.75f, false)); + + private static class ImagePreviewEntry { + final long lastModified; + final BufferedImage preview; // Strong reference (no SoftReference) + final long sizeBytes; + + ImagePreviewEntry(long lastModified, BufferedImage preview) { + this.lastModified = lastModified; + this.preview = preview; + // Calculate actual byte size: width × height × bytes per pixel + // TYPE_INT_RGB = 4 bytes per pixel, TYPE_3BYTE_BGR = 3 bytes per pixel, etc. + int bytesPerPixel; + switch (preview.getType()) { + case BufferedImage.TYPE_INT_RGB: + case BufferedImage.TYPE_INT_ARGB: + case BufferedImage.TYPE_INT_ARGB_PRE: + case BufferedImage.TYPE_INT_BGR: + bytesPerPixel = 4; + break; + case BufferedImage.TYPE_3BYTE_BGR: + bytesPerPixel = 3; + break; + case BufferedImage.TYPE_USHORT_565_RGB: + case BufferedImage.TYPE_USHORT_555_RGB: + case BufferedImage.TYPE_USHORT_GRAY: + bytesPerPixel = 2; + break; + case BufferedImage.TYPE_BYTE_GRAY: + case BufferedImage.TYPE_BYTE_BINARY: + case BufferedImage.TYPE_BYTE_INDEXED: + bytesPerPixel = 1; + break; + default: + // For custom or unknown types, estimate 4 bytes per pixel + bytesPerPixel = 4; + } + this.sizeBytes = preview.getWidth() * preview.getHeight() * bytesPerPixel; + } + } + public static BufferedImage readLowResImage(File file, int targetWidth, int targetHeight) throws Exception { - try (ImageInputStream iis = ImageIO.createImageInputStream(file)) { - Iterator readers = ImageIO.getImageReaders(iis); - if (!readers.hasNext()) { + String path = file.getAbsolutePath(); + long lastModified = file.lastModified(); + + // Check cache first (for ALL file types - RAW, JPEG, PNG, etc.) + ImagePreviewEntry entry = imagePreviewCache.get(path); + if (entry != null && entry.lastModified == lastModified) { + cacheHits.incrementAndGet(); // Track cache hit + logToConsoleOnly("Retrieved cached preview: " + file.getName()); + return scalePreviewIfNeeded(entry.preview, targetWidth, targetHeight); + } + + // Cache miss - extract/read image + BufferedImage fullImage; + if (isRawFile(file)) { + // RAW files: Extract embedded JPEG preview + fullImage = extractRawPreview(file); + if (fullImage == null) { + logToConsoleOnly("Skipping RAW file without embedded preview: " + file.getName()); return null; } + } else { + // Regular JPEG/PNG files: Read via ImageIO + fullImage = ImageIO.read(file); + if (fullImage == null) { + return null; + } + } - ImageReader reader = readers.next(); - reader.setInput(iis, true); + // Scale to display resolution and cache (NOT arbitrary size!) + // Cache at THUMBNAIL_ICON_WIDTH × THUMBNAIL_ICON_HEIGHT (240×160 default) + // This is the display resolution, so thumbnails can use it directly + int cacheWidth = AppConstants.THUMBNAIL_ICON_WIDTH; + int cacheHeight = AppConstants.THUMBNAIL_ICON_HEIGHT; + BufferedImage cachedPreview = scalePreviewIfNeeded(fullImage, cacheWidth, cacheHeight); - int fullWidth = reader.getWidth(0); - int fullHeight = reader.getHeight(0); + // Store at display resolution (only if space available) + ImagePreviewEntry newEntry = new ImagePreviewEntry(lastModified, cachedPreview); - int xStep = Math.max(1, fullWidth / targetWidth); - int yStep = Math.max(1, fullHeight / targetHeight); + // Simple fill-until-full: only cache if we have space + if (currentCacheSizeBytes + newEntry.sizeBytes <= MAX_CACHE_SIZE_BYTES) { + imagePreviewCache.put(path, newEntry); + currentCacheSizeBytes += newEntry.sizeBytes; + logToConsoleOnly("Cached preview for: " + file.getName()); + } else { + logToConsoleOnly("Cache full, not caching: " + file.getName()); + } - ImageReadParam parameters = reader.getDefaultReadParam(); - parameters.setSourceSubsampling(xStep, yStep, xStep / 2, yStep / 2); // read every n-th pixel + cacheMisses.incrementAndGet(); // Track cache miss - return reader.read(0, parameters); - } + // Return scaled to requested size (e.g., 8×8 for hashing, 240×160 for display) + return scalePreviewIfNeeded(cachedPreview, targetWidth, targetHeight); } public static boolean isImageFile(File file) { String name = file.getName().toLowerCase(); return name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png") || name.endsWith(".bmp") - || name.endsWith(".gif") || name.endsWith(".webp"); + || name.endsWith(".gif") || name.endsWith(".webp") + // RAW formats + || name.endsWith(".arw") || name.endsWith(".sr2") // Sony + || name.endsWith(".cr2") || name.endsWith(".cr3") || name.endsWith(".crw") // Canon + || name.endsWith(".nef") || name.endsWith(".nrw") // Nikon + || name.endsWith(".dng") // Adobe/Universal + || name.endsWith(".orf") || name.endsWith(".ori") // Olympus + || name.endsWith(".raf") // Fujifilm + || name.endsWith(".pef") || name.endsWith(".ptx") // Pentax + || name.endsWith(".rw2") // Panasonic + || name.endsWith(".3fr") // Hasselblad + || name.endsWith(".raw"); // Generic + } + + public static boolean isRawFile(File file) { + String name = file.getName().toLowerCase(); + return name.endsWith(".arw") || name.endsWith(".sr2") + || name.endsWith(".cr2") || name.endsWith(".cr3") || name.endsWith(".crw") + || name.endsWith(".nef") || name.endsWith(".nrw") + || name.endsWith(".dng") + || name.endsWith(".orf") || name.endsWith(".ori") + || name.endsWith(".raf") + || name.endsWith(".pef") || name.endsWith(".ptx") + || name.endsWith(".rw2") + || name.endsWith(".3fr") + || name.endsWith(".raw"); + } + + /** + * Extracts embedded JPEG preview from a RAW file. + * + * Uses metadata-extractor to locate the thumbnail, then reads the bytes directly. + * Caching is handled by readLowResImage(), not here. + * + * @param file The RAW file to extract preview from + * @return BufferedImage of the embedded JPEG preview, or null if no preview found + */ + private static BufferedImage extractRawPreview(File file) { + try { + Metadata metadata = ImageMetadataReader.readMetadata(file); + ExifThumbnailDirectory thumbnailDir = metadata.getFirstDirectoryOfType(ExifThumbnailDirectory.class); + + if (thumbnailDir == null) { + return null; + } + + // Get thumbnail length from metadata-extractor + // The library handles all RAW format parsing for us + Integer length = thumbnailDir.getInteger(ExifThumbnailDirectory.TAG_THUMBNAIL_LENGTH); + + if (length == null) { + return null; + } + + // Use getAdjustedThumbnailOffset() for the correct absolute offset + // This method properly calculates the offset relative to the file start + int adjustedOffset = thumbnailDir.getAdjustedThumbnailOffset(); + + // Read the thumbnail bytes from the file at the adjusted offset + byte[] thumbnailBytes = new byte[length]; + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + raf.seek(adjustedOffset); + raf.readFully(thumbnailBytes); + } + + // Decode the JPEG thumbnail + ByteArrayInputStream bais = new ByteArrayInputStream(thumbnailBytes); + return ImageIO.read(bais); + + } catch (Exception e) { + logToConsoleOnly("Failed to extract preview from RAW file: " + file.getName() + " - " + e.getMessage()); + return null; + } + } + + private static BufferedImage scalePreviewIfNeeded(BufferedImage preview, int targetWidth, int targetHeight) { + int previewWidth = preview.getWidth(); + int previewHeight = preview.getHeight(); + + // If preview is significantly larger than target, scale it down + if (previewWidth > targetWidth * 2 || previewHeight > targetHeight * 2) { + int xStep = Math.max(1, previewWidth / targetWidth); + int yStep = Math.max(1, previewHeight / targetHeight); + + BufferedImage scaledPreview = new BufferedImage( + previewWidth / xStep, + previewHeight / yStep, + BufferedImage.TYPE_INT_RGB + ); + + java.awt.Graphics2D g = scaledPreview.createGraphics(); + g.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION, + java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(preview, 0, 0, scaledPreview.getWidth(), scaledPreview.getHeight(), null); + g.dispose(); + + return scaledPreview; + } + + return preview; } public static long extractTimestamp(File file) { @@ -128,4 +317,38 @@ public static float[] rgbToHsv(int r, int g, int b) { hsv[2] = v; return hsv; } + + /** + * Returns the number of image previews currently cached in memory. + * Useful for debugging and monitoring cache effectiveness. + */ + public static int getImagePreviewCacheSize() { + return imagePreviewCache.size(); + } + + /** + * Returns the current cache size in bytes. + */ + public static long getCurrentCacheSizeBytes() { + return currentCacheSizeBytes; + } + + /** + * Returns the maximum image preview cache size in bytes. + */ + public static long getImagePreviewCacheMaxSizeBytes() { + return MAX_CACHE_SIZE_BYTES; + } + + /** + * Clears all cached image previews. + * Useful for testing or freeing memory. + */ + public static void clearImagePreviewCache() { + int size = imagePreviewCache.size(); + imagePreviewCache.clear(); + currentCacheSizeBytes = 0; + logMessage("Cleared image preview cache (" + size + " entries)"); + } + } diff --git a/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java b/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java index f702a40..485d54f 100644 --- a/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java +++ b/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java @@ -225,6 +225,10 @@ private void jMenuItemOpenActionPerformed(java.awt.event.ActionEvent evt) {//GEN long startTime = System.currentTimeMillis(); importDirectory = chooser.getSelectedFile(); + + // Clear image preview cache when loading new directory (fresh start) + PhotoUtils.clearImagePreviewCache(); + List groups = FileUtils.loadFolder(importDirectory, groupingEngine, (float) jTimestampSpinner.getValue(), (float) jSimilaritySpinner.getValue()); loadFrame(groups); diff --git a/src/main/java/com/penguinpush/cullergrader/utils/Logger.java b/src/main/java/com/penguinpush/cullergrader/utils/Logger.java index f6dd7ef..d9602a2 100644 --- a/src/main/java/com/penguinpush/cullergrader/utils/Logger.java +++ b/src/main/java/com/penguinpush/cullergrader/utils/Logger.java @@ -57,4 +57,23 @@ public static synchronized void logMessage(String message) { public static void registerLogCallback(Consumer callback) { logCallback = callback; } + + /** + * Logs a message to console and log file only, without triggering the GUI callback. + * Useful for verbose/cache operations that shouldn't clutter the GUI info panel. + */ + public static synchronized void logToConsoleOnly(String message) { + try (BufferedWriter bufferedWriter = new BufferedWriter(new FileWriter(logFile, true))) { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss.SSS")); + + bufferedWriter.write(timestamp + ": " + message); + bufferedWriter.newLine(); + + // Do NOT call logCallback - this prevents GUI updates + System.out.println(message); + + } catch (IOException e) { + System.err.println("an error occurred... ironically, there's nowhere to log this: " + e.getMessage()); + } + } } \ No newline at end of file diff --git a/src/main/resources/config.json b/src/main/resources/config.json index 99f0e21..2f6b652 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, + "IMAGE_PREVIEW_CACHE_SIZE_MB": 2048 } \ No newline at end of file From 8b425051c6d80e778b73535b8b67e126d8bfa9aa Mon Sep 17 00:00:00 2001 From: Cyprian Zdebski Date: Sun, 14 Dec 2025 06:09:13 +0100 Subject: [PATCH 5/6] Feature/add cli support (#2) * add CLI mode * update README --- README.md | 41 +++ .../com/penguinpush/cullergrader/CLI.java | 281 ++++++++++++++++++ .../com/penguinpush/cullergrader/Main.java | 43 ++- 3 files changed, 357 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/penguinpush/cullergrader/CLI.java diff --git a/README.md b/README.md index b4a8a7e..69b62e9 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,47 @@ Best takes can be exported to a folder using `File > Export Best Takes` or with ![images/export_to.png](images/export_to.png) +## CLI Usage + +Cullergrader can be run in command-line mode for automated workflows and scripting. + +### Basic Usage + +```bash +# Launch GUI (no arguments) +java -jar cullergrader.jar + +# Run CLI mode +java -jar cullergrader.jar --input /path/to/photos --output /path/to/export +``` + +### CLI Options + +| Option | Short | Description | Required | +|--------|-------|-------------|----------| +| `--input` | `-i` | Input folder containing photos | Yes | +| `--output` | `-o` | Output folder for best takes (preview mode if omitted) | No | +| `--time` | `-t` | Time threshold in seconds (default: 15) | No | +| `--similarity` | `-s` | Similarity threshold 0-100 (default: 45) | No | +| `--help` | `-h` | Show help message | No | + +### Examples + +**Preview mode (no export)**: +```bash +java -jar cullergrader.jar --input ~/photos/vacation +``` + +**Export to folder**: +```bash +java -jar cullergrader.jar --input ~/photos/vacation --output ~/photos/best +``` + +**Custom thresholds**: +```bash +java -jar cullergrader.jar -i ~/photos/vacation -o ~/photos/best -t 10 -s 40 +``` + ## Config Configuration is **optional**. Cullergrader includes sensible defaults for all settings. To customize, create a `config.json` file in the same directory as the executable. diff --git a/src/main/java/com/penguinpush/cullergrader/CLI.java b/src/main/java/com/penguinpush/cullergrader/CLI.java new file mode 100644 index 0000000..20895c7 --- /dev/null +++ b/src/main/java/com/penguinpush/cullergrader/CLI.java @@ -0,0 +1,281 @@ +package com.penguinpush.cullergrader; + +import com.penguinpush.cullergrader.logic.*; +import com.penguinpush.cullergrader.media.*; +import com.penguinpush.cullergrader.config.AppConstants; + +import java.io.File; +import java.util.List; + +/** + * Command-line interface for Cullergrader. + * Provides photo grouping and export functionality without launching the GUI. + */ +public class CLI { + + // Exit codes + private static final int EXIT_SUCCESS = 0; + private static final int EXIT_FAILURE = 1; + + // Parsed arguments with defaults from AppConstants + private String inputPath = null; + private String outputPath = null; + private float timeThreshold = AppConstants.TIME_THRESHOLD_SECONDS; + private float similarityThreshold = AppConstants.SIMILARITY_THRESHOLD_PERCENT; + + /** + * Main entry point for CLI mode. + * + * @param args Command-line arguments + * @return Exit code (0 = success, 1 = failure) + */ + public int run(String[] args) { + // Handle --help first + if (hasArgument(args, "--help") || hasArgument(args, "-h")) { + printHelp(); + return EXIT_SUCCESS; + } + + // Parse arguments + if (!parseArguments(args)) { + System.err.println("Error: Invalid arguments. Use --help for usage information."); + return EXIT_FAILURE; + } + + // Validate required arguments + if (inputPath == null) { + System.err.println("Error: --input is required."); + printHelp(); + return EXIT_FAILURE; + } + + // Validate input directory + File inputFolder = new File(inputPath); + if (!inputFolder.exists() || !inputFolder.isDirectory()) { + System.err.println("Error: Input directory does not exist: " + inputPath); + return EXIT_FAILURE; + } + + if (!inputFolder.canRead()) { + System.err.println("Error: Cannot read input directory: " + inputPath); + return EXIT_FAILURE; + } + + // Validate output directory if provided + File outputFolder = null; + if (outputPath != null) { + outputFolder = new File(outputPath); + if (outputFolder.exists() && !outputFolder.isDirectory()) { + System.err.println("Error: Output path exists but is not a directory: " + outputPath); + return EXIT_FAILURE; + } + + if (outputFolder.exists() && !outputFolder.canWrite()) { + System.err.println("Error: Cannot write to output directory: " + outputPath); + return EXIT_FAILURE; + } + } + + // Execute workflow + try { + executeWorkflow(inputFolder, outputFolder); + return EXIT_SUCCESS; + } catch (Exception e) { + System.err.println("Error: Processing failed - " + e.getMessage()); + e.printStackTrace(); + return EXIT_FAILURE; + } + } + + /** + * Parses command-line arguments. + * + * @param args Command-line arguments + * @return true if parsing succeeded, false on error + */ + private boolean parseArguments(String[] args) { + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + + // Input path + if (arg.equals("--input") || arg.equals("-i")) { + if (i + 1 >= args.length) { + System.err.println("Error: --input requires a value"); + return false; + } + inputPath = args[++i]; + } + // Output path + else if (arg.equals("--output") || arg.equals("-o")) { + if (i + 1 >= args.length) { + System.err.println("Error: --output requires a value"); + return false; + } + outputPath = args[++i]; + } + // Time threshold + else if (arg.equals("--time") || arg.equals("-t")) { + if (i + 1 >= args.length) { + System.err.println("Error: --time requires a value"); + return false; + } + try { + timeThreshold = Float.parseFloat(args[++i]); + if (timeThreshold <= 0) { + System.err.println("Error: Time threshold must be positive"); + return false; + } + } catch (NumberFormatException e) { + System.err.println("Error: Invalid time threshold value: " + args[i]); + return false; + } + } + // Similarity threshold + else if (arg.equals("--similarity") || arg.equals("-s")) { + if (i + 1 >= args.length) { + System.err.println("Error: --similarity requires a value"); + return false; + } + try { + similarityThreshold = Float.parseFloat(args[++i]); + if (similarityThreshold < 0 || similarityThreshold > 100) { + System.err.println("Error: Similarity threshold must be 0-100"); + return false; + } + } catch (NumberFormatException e) { + System.err.println("Error: Invalid similarity threshold value: " + args[i]); + return false; + } + } + // Skip --help and -h (handled in run method) + else if (arg.equals("--help") || arg.equals("-h")) { + // Already handled in run(), just skip + } + // Unknown argument + else if (arg.startsWith("-")) { + System.err.println("Error: Unknown argument: " + arg); + return false; + } + } + + return true; + } + + /** + * Executes the main CLI workflow: load photos, generate groups, and export. + * + * @param inputFolder Input directory containing photos + * @param outputFolder Output directory for best takes + */ + private void executeWorkflow(File inputFolder, File outputFolder) { + long startTime = System.currentTimeMillis(); + boolean previewMode = (outputFolder == null); + + // Print configuration header + System.out.println("Cullergrader CLI"); + System.out.println("================"); + System.out.println("Input: " + inputFolder.getAbsolutePath()); + if (!previewMode) { + System.out.println("Output: " + outputFolder.getAbsolutePath()); + } else { + System.out.println("Mode: Preview (no files will be exported)"); + } + System.out.println("Time threshold: " + timeThreshold + " seconds"); + System.out.println("Similarity threshold: " + similarityThreshold + "%"); + System.out.println(); + + // Load and hash photos + System.out.println("Loading and hashing photos from: " + inputFolder.getAbsolutePath()); + GroupingEngine engine = new GroupingEngine(); + List photos = engine.photoListFromFolder(inputFolder); + + if (photos.isEmpty()) { + System.out.println("No photos found in input directory."); + return; + } + + System.out.println("Found " + photos.size() + " photos"); + System.out.println(); + + // Generate groups + System.out.println("Generating groups with thresholds: " + timeThreshold + "s time, " + similarityThreshold + "% similarity"); + List groups = engine.generateGroups(photos, timeThreshold, similarityThreshold); + + System.out.println("Created " + groups.size() + " groups from " + photos.size() + " photos"); + System.out.println(); + + // Export or preview + if (previewMode) { + System.out.println("Preview - Best takes that would be exported:"); + System.out.println("--------------------------------------------"); + for (int i = 0; i < groups.size(); i++) { + PhotoGroup group = groups.get(i); + Photo bestTake = group.getBestTake(); + if (bestTake != null) { + System.out.println("[Group " + i + "] " + bestTake.getFile().getName()); + } + } + System.out.println(); + System.out.println("To export these " + groups.size() + " files, run again with --output "); + } else { + System.out.println("Exporting best takes to: " + outputFolder.getAbsolutePath()); + FileUtils.exportBestTakes(groups, outputFolder); + System.out.println(); + System.out.println("Successfully exported " + groups.size() + " files"); + } + + // Summary + long endTime = System.currentTimeMillis(); + long durationMs = endTime - startTime; + double durationSec = durationMs / 1000.0; + System.out.println(); + System.out.println("Processing completed in " + String.format("%.2f", durationSec) + " seconds"); + } + + /** + * Prints help message showing usage and available options. + */ + private void printHelp() { + System.out.println("Cullergrader CLI - Photo grouping and export tool"); + System.out.println(); + System.out.println("USAGE:"); + System.out.println(" java -jar cullergrader.jar [OPTIONS]"); + System.out.println(); + System.out.println(" No arguments launches GUI mode"); + System.out.println(); + System.out.println("OPTIONS:"); + System.out.println(" -i, --input Input folder containing photos (required)"); + System.out.println(" -o, --output Output folder for best takes (optional, preview mode if omitted)"); + System.out.println(" -t, --time Time threshold in seconds (default: " + AppConstants.TIME_THRESHOLD_SECONDS + ")"); + System.out.println(" -s, --similarity Similarity threshold 0-100 (default: " + AppConstants.SIMILARITY_THRESHOLD_PERCENT + ")"); + System.out.println(" -h, --help Show this help message"); + System.out.println(); + System.out.println("EXAMPLES:"); + System.out.println(" # Preview mode (no export)"); + System.out.println(" java -jar cullergrader.jar --input /photos"); + System.out.println(); + System.out.println(" # Export mode"); + System.out.println(" java -jar cullergrader.jar --input /photos --output /export"); + System.out.println(); + System.out.println(" # Custom thresholds with export"); + System.out.println(" java -jar cullergrader.jar -i /photos -o /export -t 10 -s 40"); + System.out.println(); + } + + /** + * Helper method to check if a specific flag is present in arguments. + * Used by Main.java to detect CLI mode. + * + * @param args Command-line arguments + * @param flag Flag to search for + * @return true if flag is present, false otherwise + */ + public static boolean hasArgument(String[] args, String flag) { + for (String arg : args) { + if (arg.equals(flag)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/penguinpush/cullergrader/Main.java b/src/main/java/com/penguinpush/cullergrader/Main.java index 15f5236..716f142 100644 --- a/src/main/java/com/penguinpush/cullergrader/Main.java +++ b/src/main/java/com/penguinpush/cullergrader/Main.java @@ -11,17 +11,44 @@ public class Main { public static void main(String[] args) { - // load theme - if (AppConstants.DARK_THEME) { - FlatDarculaLaf.setup(); + // Detect CLI mode by checking for CLI-specific arguments + if (isCLIMode(args)) { + // CLI mode - skip GUI initialization + CLI cli = new CLI(); + int exitCode = cli.run(args); + System.exit(exitCode); } else { - FlatIntelliJLaf.setup(); + // GUI mode + // load theme + if (AppConstants.DARK_THEME) { + FlatDarculaLaf.setup(); + } else { + FlatIntelliJLaf.setup(); + } + + GroupingEngine groupingEngine = new GroupingEngine(); + ImageLoader imageLoader = new ImageLoader(); + + SwingUtilities.invokeLater(() -> new GroupGridFrame(imageLoader, groupingEngine)); + GroupGridFrame.initializeLoggerCallback(); } + } - GroupingEngine groupingEngine = new GroupingEngine(); - ImageLoader imageLoader = new ImageLoader(); + /** + * Determines if the application should run in CLI mode based on command-line arguments. + * + * @param args Command-line arguments + * @return true if CLI mode should be used, false for GUI mode + */ + private static boolean isCLIMode(String[] args) { + if (args.length == 0) { + return false; + } - SwingUtilities.invokeLater(() -> new GroupGridFrame(imageLoader, groupingEngine)); - GroupGridFrame.initializeLoggerCallback(); + // Check for CLI-specific flags + return CLI.hasArgument(args, "--input") || + CLI.hasArgument(args, "-i") || + CLI.hasArgument(args, "--help") || + CLI.hasArgument(args, "-h"); } } From 6b6624e18c673cfa2dda5d203c0d830a31bc2203 Mon Sep 17 00:00:00 2001 From: Cyprian Zdebski Date: Sun, 14 Dec 2025 06:20:42 +0100 Subject: [PATCH 6/6] Revert "Merge branch 'main' into feature/add-info-export" This reverts commit d968d025635e42d7c2548bfa0321e9711ec98c73, reversing changes made to b846054ee4831f61de37afc5ae676c1deb8a5c12. --- README.md | 44 +-- pom.xml | 2 +- .../com/penguinpush/cullergrader/CLI.java | 3 - .../com/penguinpush/cullergrader/Main.java | 1 - .../cullergrader/config/AppConstants.java | 1 - .../config/DefaultAppConstants.java | 1 - .../cullergrader/logic/GroupingEngine.java | 16 +- .../cullergrader/media/PhotoUtils.java | 251 +----------------- .../cullergrader/ui/GroupGridFrame.java | 4 - .../cullergrader/utils/Logger.java | 19 -- src/main/resources/config.json | 3 +- 11 files changed, 23 insertions(+), 322 deletions(-) diff --git a/README.md b/README.md index 5aa5e1d..778ae74 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,11 @@ Cullergrader is named for being a tool that culls and grades* photos. Please not 4. [Config](#config) 1. [Default Config](#default-config) 2. [Config Settings Explained](#config-settings-explained) -5. [Performance Tuning](#performance-tuning) -6. [Contributing](#contributing) -7. [License](#license) +5. [Contributing](#contributing) +6. [License](#license) ## Features - 100% free and open-source! -- Experimental RAW image format support - works with most camera RAW files that contain an embedded JPEG preview - Configurable options for calibrating your perceptual hash - Hash similarity - Timestamp difference @@ -50,7 +48,7 @@ Cullergrader is named for being a tool that culls and grades* photos. Please not - Cache options - Grouping settings - Dark theme - + ![images/group_viewer.png](images/group_viewer.png) @@ -160,9 +158,6 @@ java -jar cullergrader.jar -i ~/photos/vacation -o ~/photos/best --json ~/photos ``` ## Config - -Configuration is **optional**. Cullergrader includes sensible defaults for all settings. To customize, create a `config.json` file in the same directory as the executable. - ### Default Config ```json { @@ -176,8 +171,7 @@ Configuration is **optional**. Cullergrader includes sensible defaults for all s "HASHED_WIDTH": 8, "HASHED_HEIGHT": 8, "TIME_THRESHOLD_SECONDS": 15, - "SIMILARITY_THRESHOLD_PERCENT": 45, - "IMAGE_PREVIEW_CACHE_SIZE_MB": 1024 + "SIMILARITY_THRESHOLD_PERCENT": 45 } ``` @@ -195,36 +189,8 @@ Configuration is **optional**. Cullergrader includes sensible defaults for all s | `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` | -| `IMAGE_PREVIEW_CACHE_SIZE_MB` | Maximum memory (in megabytes) to use for caching image previews. Default 1024 MB (1 GB). Increase for large photo shoots (see Performance Tuning section) | `int` | - -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. - -## Performance Tuning - -For large photo shoots, you can increase the image preview cache size in `config.json`: - -```json -{ - "IMAGE_PREVIEW_CACHE_SIZE_MB": 2048 -} -``` - -**Default:** 2048 MB (2 GB) - -The preview cache stores scaled thumbnails (240×160) for all image files in memory to avoid re-reading from disk. The cache fills until reaching the configured limit, then stops caching new entries (no eviction). - -**Configuration examples:** -- 512 MB = Smaller cache for limited memory systems -- 1024 MB = Moderate cache for typical photo shoots -- 2048 MB = Default (recommended for most users) -- 4096 MB = Very large photo shoots (4000+ files) - -The cache applies to all image formats (JPEG, PNG, RAW, etc.) and significantly improves performance when: -- Changing grouping thresholds -- Reloading groups -- Scrolling through large photo sets -The cache clears automatically when loading a new directory. +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. ## 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/pom.xml b/pom.xml index 3291a0e..b59ec67 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ com.drewnoakes metadata-extractor - 2.19.0 + 2.18.0 com.google.code.gson diff --git a/src/main/java/com/penguinpush/cullergrader/CLI.java b/src/main/java/com/penguinpush/cullergrader/CLI.java index d61f7f8..8fe020b 100644 --- a/src/main/java/com/penguinpush/cullergrader/CLI.java +++ b/src/main/java/com/penguinpush/cullergrader/CLI.java @@ -114,7 +114,6 @@ else if (arg.equals("--output") || arg.equals("-o")) { } outputPath = args[++i]; } - // JSON export path else if (arg.equals("--json") || arg.equals("-j")) { if (i + 1 >= args.length) { @@ -123,7 +122,6 @@ else if (arg.equals("--json") || arg.equals("-j")) { } jsonPath = args[++i]; } - // Time threshold else if (arg.equals("--time") || arg.equals("-t")) { if (i + 1 >= args.length) { @@ -223,7 +221,6 @@ private void executeWorkflow(File inputFolder, File outputFolder) { System.out.println(); } - // Export or preview if (previewMode) { System.out.println("Preview - Best takes that would be exported:"); diff --git a/src/main/java/com/penguinpush/cullergrader/Main.java b/src/main/java/com/penguinpush/cullergrader/Main.java index 716f142..f5eb719 100644 --- a/src/main/java/com/penguinpush/cullergrader/Main.java +++ b/src/main/java/com/penguinpush/cullergrader/Main.java @@ -3,7 +3,6 @@ import com.penguinpush.cullergrader.logic.*; import com.penguinpush.cullergrader.ui.GroupGridFrame; import com.penguinpush.cullergrader.config.AppConstants; -import com.penguinpush.cullergrader.media.PhotoUtils; import javax.swing.SwingUtilities; import com.formdev.flatlaf.FlatIntelliJLaf; import com.formdev.flatlaf.FlatDarculaLaf; diff --git a/src/main/java/com/penguinpush/cullergrader/config/AppConstants.java b/src/main/java/com/penguinpush/cullergrader/config/AppConstants.java index 21a9f75..b6c8d5b 100644 --- a/src/main/java/com/penguinpush/cullergrader/config/AppConstants.java +++ b/src/main/java/com/penguinpush/cullergrader/config/AppConstants.java @@ -35,7 +35,6 @@ 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 int IMAGE_PREVIEW_CACHE_SIZE_MB = config.IMAGE_PREVIEW_CACHE_SIZE_MB; public static final int MAX_PRIORITY = config.MAX_PRIORITY; public static final int IMAGE_PRIORITY = config.IMAGE_PRIORITY; diff --git a/src/main/java/com/penguinpush/cullergrader/config/DefaultAppConstants.java b/src/main/java/com/penguinpush/cullergrader/config/DefaultAppConstants.java index 797293c..366c76e 100644 --- a/src/main/java/com/penguinpush/cullergrader/config/DefaultAppConstants.java +++ b/src/main/java/com/penguinpush/cullergrader/config/DefaultAppConstants.java @@ -13,7 +13,6 @@ public class DefaultAppConstants { public int HASHED_HEIGHT = 8; public float TIME_THRESHOLD_SECONDS = 15; public float SIMILARITY_THRESHOLD_PERCENT = 45; - public int IMAGE_PREVIEW_CACHE_SIZE_MB = 2048; // Default 2048 MB (2 GB) public int MAX_PRIORITY = 0; public int IMAGE_PRIORITY = 1; diff --git a/src/main/java/com/penguinpush/cullergrader/logic/GroupingEngine.java b/src/main/java/com/penguinpush/cullergrader/logic/GroupingEngine.java index fe23ed4..5932871 100644 --- a/src/main/java/com/penguinpush/cullergrader/logic/GroupingEngine.java +++ b/src/main/java/com/penguinpush/cullergrader/logic/GroupingEngine.java @@ -32,21 +32,11 @@ public List photoListFromFolder(File folder) { } public List generateGroups(List photoList, float timestampThreshold, float similarityThreshold) { - // Filter out photos with null hashes (RAW files without previews, corrupted files, etc.) - List validPhotos = new ArrayList<>(); - for (Photo photo : photoList) { - if (photo.getHash() != null) { - validPhotos.add(photo); - } else { - logMessage("Skipping photo with null hash: " + photo.getFile().getName()); - } - } - List groups = new ArrayList<>(); PhotoGroup currentGroup = new PhotoGroup(); - for (int i = 0; i < validPhotos.size(); i++) { - Photo current = validPhotos.get(i); + for (int i = 0; i < photoList.size(); i++) { + Photo current = photoList.get(i); if (currentGroup.getSize() == 0) { current.setIndex(0); @@ -79,8 +69,6 @@ public List generateGroups(List photoList, float timestampThr logMessage("added " + current.getFile().getName() + " " + deltaTimeSeconds + " " + hammingDistancePercent + " to group " + groups.size()); } - logMessage("Filtered " + (photoList.size() - validPhotos.size()) + " photos with null hashes"); - // add the last group too if (currentGroup.getSize() > 0) { currentGroup.setIndex(groups.size()); diff --git a/src/main/java/com/penguinpush/cullergrader/media/PhotoUtils.java b/src/main/java/com/penguinpush/cullergrader/media/PhotoUtils.java index bbe2084..349cc3f 100644 --- a/src/main/java/com/penguinpush/cullergrader/media/PhotoUtils.java +++ b/src/main/java/com/penguinpush/cullergrader/media/PhotoUtils.java @@ -1,237 +1,48 @@ package com.penguinpush.cullergrader.media; import static com.penguinpush.cullergrader.utils.Logger.logMessage; -import static com.penguinpush.cullergrader.utils.Logger.logToConsoleOnly; -import com.penguinpush.cullergrader.config.AppConstants; import com.drew.imaging.ImageMetadataReader; import com.drew.metadata.Metadata; import com.drew.metadata.exif.ExifSubIFDDirectory; -import com.drew.metadata.exif.ExifThumbnailDirectory; import java.awt.Image; import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; import java.io.File; -import java.io.RandomAccessFile; import java.util.*; -import java.util.concurrent.atomic.AtomicLong; import javax.imageio.*; import javax.imageio.stream.ImageInputStream; import javax.swing.*; public class PhotoUtils { - // Simple fill-until-full cache for all image previews (strong references to avoid GC under memory pressure) - // Caches SCALED previews at display resolution (240×160 default) - // Cache size configured via IMAGE_PREVIEW_CACHE_SIZE_MB in config.json (default 1024 MB) - private static final long MAX_CACHE_SIZE_BYTES = AppConstants.IMAGE_PREVIEW_CACHE_SIZE_MB * 1024L * 1024L; - private static long currentCacheSizeBytes = 0; - - // Hit/miss tracking for monitoring cache effectiveness - private static final AtomicLong cacheHits = new AtomicLong(0); - private static final AtomicLong cacheMisses = new AtomicLong(0); - - // Simple fill-until-full cache with no eviction - private static final Map imagePreviewCache = - Collections.synchronizedMap(new LinkedHashMap(16, 0.75f, false)); - - private static class ImagePreviewEntry { - final long lastModified; - final BufferedImage preview; // Strong reference (no SoftReference) - final long sizeBytes; - - ImagePreviewEntry(long lastModified, BufferedImage preview) { - this.lastModified = lastModified; - this.preview = preview; - // Calculate actual byte size: width × height × bytes per pixel - // TYPE_INT_RGB = 4 bytes per pixel, TYPE_3BYTE_BGR = 3 bytes per pixel, etc. - int bytesPerPixel; - switch (preview.getType()) { - case BufferedImage.TYPE_INT_RGB: - case BufferedImage.TYPE_INT_ARGB: - case BufferedImage.TYPE_INT_ARGB_PRE: - case BufferedImage.TYPE_INT_BGR: - bytesPerPixel = 4; - break; - case BufferedImage.TYPE_3BYTE_BGR: - bytesPerPixel = 3; - break; - case BufferedImage.TYPE_USHORT_565_RGB: - case BufferedImage.TYPE_USHORT_555_RGB: - case BufferedImage.TYPE_USHORT_GRAY: - bytesPerPixel = 2; - break; - case BufferedImage.TYPE_BYTE_GRAY: - case BufferedImage.TYPE_BYTE_BINARY: - case BufferedImage.TYPE_BYTE_INDEXED: - bytesPerPixel = 1; - break; - default: - // For custom or unknown types, estimate 4 bytes per pixel - bytesPerPixel = 4; - } - this.sizeBytes = preview.getWidth() * preview.getHeight() * bytesPerPixel; - } - } - public static BufferedImage readLowResImage(File file, int targetWidth, int targetHeight) throws Exception { - String path = file.getAbsolutePath(); - long lastModified = file.lastModified(); - - // Check cache first (for ALL file types - RAW, JPEG, PNG, etc.) - ImagePreviewEntry entry = imagePreviewCache.get(path); - if (entry != null && entry.lastModified == lastModified) { - cacheHits.incrementAndGet(); // Track cache hit - logToConsoleOnly("Retrieved cached preview: " + file.getName()); - return scalePreviewIfNeeded(entry.preview, targetWidth, targetHeight); - } - - // Cache miss - extract/read image - BufferedImage fullImage; - if (isRawFile(file)) { - // RAW files: Extract embedded JPEG preview - fullImage = extractRawPreview(file); - if (fullImage == null) { - logToConsoleOnly("Skipping RAW file without embedded preview: " + file.getName()); + try (ImageInputStream iis = ImageIO.createImageInputStream(file)) { + Iterator readers = ImageIO.getImageReaders(iis); + if (!readers.hasNext()) { return null; } - } else { - // Regular JPEG/PNG files: Read via ImageIO - fullImage = ImageIO.read(file); - if (fullImage == null) { - return null; - } - } - // Scale to display resolution and cache (NOT arbitrary size!) - // Cache at THUMBNAIL_ICON_WIDTH × THUMBNAIL_ICON_HEIGHT (240×160 default) - // This is the display resolution, so thumbnails can use it directly - int cacheWidth = AppConstants.THUMBNAIL_ICON_WIDTH; - int cacheHeight = AppConstants.THUMBNAIL_ICON_HEIGHT; - BufferedImage cachedPreview = scalePreviewIfNeeded(fullImage, cacheWidth, cacheHeight); + ImageReader reader = readers.next(); + reader.setInput(iis, true); - // Store at display resolution (only if space available) - ImagePreviewEntry newEntry = new ImagePreviewEntry(lastModified, cachedPreview); + int fullWidth = reader.getWidth(0); + int fullHeight = reader.getHeight(0); - // Simple fill-until-full: only cache if we have space - if (currentCacheSizeBytes + newEntry.sizeBytes <= MAX_CACHE_SIZE_BYTES) { - imagePreviewCache.put(path, newEntry); - currentCacheSizeBytes += newEntry.sizeBytes; - logToConsoleOnly("Cached preview for: " + file.getName()); - } else { - logToConsoleOnly("Cache full, not caching: " + file.getName()); - } + int xStep = Math.max(1, fullWidth / targetWidth); + int yStep = Math.max(1, fullHeight / targetHeight); - cacheMisses.incrementAndGet(); // Track cache miss + ImageReadParam parameters = reader.getDefaultReadParam(); + parameters.setSourceSubsampling(xStep, yStep, xStep / 2, yStep / 2); // read every n-th pixel - // Return scaled to requested size (e.g., 8×8 for hashing, 240×160 for display) - return scalePreviewIfNeeded(cachedPreview, targetWidth, targetHeight); + return reader.read(0, parameters); + } } public static boolean isImageFile(File file) { String name = file.getName().toLowerCase(); return name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png") || name.endsWith(".bmp") - || name.endsWith(".gif") || name.endsWith(".webp") - // RAW formats - || name.endsWith(".arw") || name.endsWith(".sr2") // Sony - || name.endsWith(".cr2") || name.endsWith(".cr3") || name.endsWith(".crw") // Canon - || name.endsWith(".nef") || name.endsWith(".nrw") // Nikon - || name.endsWith(".dng") // Adobe/Universal - || name.endsWith(".orf") || name.endsWith(".ori") // Olympus - || name.endsWith(".raf") // Fujifilm - || name.endsWith(".pef") || name.endsWith(".ptx") // Pentax - || name.endsWith(".rw2") // Panasonic - || name.endsWith(".3fr") // Hasselblad - || name.endsWith(".raw"); // Generic - } - - public static boolean isRawFile(File file) { - String name = file.getName().toLowerCase(); - return name.endsWith(".arw") || name.endsWith(".sr2") - || name.endsWith(".cr2") || name.endsWith(".cr3") || name.endsWith(".crw") - || name.endsWith(".nef") || name.endsWith(".nrw") - || name.endsWith(".dng") - || name.endsWith(".orf") || name.endsWith(".ori") - || name.endsWith(".raf") - || name.endsWith(".pef") || name.endsWith(".ptx") - || name.endsWith(".rw2") - || name.endsWith(".3fr") - || name.endsWith(".raw"); - } - - /** - * Extracts embedded JPEG preview from a RAW file. - * - * Uses metadata-extractor to locate the thumbnail, then reads the bytes directly. - * Caching is handled by readLowResImage(), not here. - * - * @param file The RAW file to extract preview from - * @return BufferedImage of the embedded JPEG preview, or null if no preview found - */ - private static BufferedImage extractRawPreview(File file) { - try { - Metadata metadata = ImageMetadataReader.readMetadata(file); - ExifThumbnailDirectory thumbnailDir = metadata.getFirstDirectoryOfType(ExifThumbnailDirectory.class); - - if (thumbnailDir == null) { - return null; - } - - // Get thumbnail length from metadata-extractor - // The library handles all RAW format parsing for us - Integer length = thumbnailDir.getInteger(ExifThumbnailDirectory.TAG_THUMBNAIL_LENGTH); - - if (length == null) { - return null; - } - - // Use getAdjustedThumbnailOffset() for the correct absolute offset - // This method properly calculates the offset relative to the file start - int adjustedOffset = thumbnailDir.getAdjustedThumbnailOffset(); - - // Read the thumbnail bytes from the file at the adjusted offset - byte[] thumbnailBytes = new byte[length]; - try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { - raf.seek(adjustedOffset); - raf.readFully(thumbnailBytes); - } - - // Decode the JPEG thumbnail - ByteArrayInputStream bais = new ByteArrayInputStream(thumbnailBytes); - return ImageIO.read(bais); - - } catch (Exception e) { - logToConsoleOnly("Failed to extract preview from RAW file: " + file.getName() + " - " + e.getMessage()); - return null; - } - } - - private static BufferedImage scalePreviewIfNeeded(BufferedImage preview, int targetWidth, int targetHeight) { - int previewWidth = preview.getWidth(); - int previewHeight = preview.getHeight(); - - // If preview is significantly larger than target, scale it down - if (previewWidth > targetWidth * 2 || previewHeight > targetHeight * 2) { - int xStep = Math.max(1, previewWidth / targetWidth); - int yStep = Math.max(1, previewHeight / targetHeight); - - BufferedImage scaledPreview = new BufferedImage( - previewWidth / xStep, - previewHeight / yStep, - BufferedImage.TYPE_INT_RGB - ); - - java.awt.Graphics2D g = scaledPreview.createGraphics(); - g.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION, - java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR); - g.drawImage(preview, 0, 0, scaledPreview.getWidth(), scaledPreview.getHeight(), null); - g.dispose(); - - return scaledPreview; - } - - return preview; + || name.endsWith(".gif") || name.endsWith(".webp"); } public static long extractTimestamp(File file) { @@ -317,38 +128,4 @@ public static float[] rgbToHsv(int r, int g, int b) { hsv[2] = v; return hsv; } - - /** - * Returns the number of image previews currently cached in memory. - * Useful for debugging and monitoring cache effectiveness. - */ - public static int getImagePreviewCacheSize() { - return imagePreviewCache.size(); - } - - /** - * Returns the current cache size in bytes. - */ - public static long getCurrentCacheSizeBytes() { - return currentCacheSizeBytes; - } - - /** - * Returns the maximum image preview cache size in bytes. - */ - public static long getImagePreviewCacheMaxSizeBytes() { - return MAX_CACHE_SIZE_BYTES; - } - - /** - * Clears all cached image previews. - * Useful for testing or freeing memory. - */ - public static void clearImagePreviewCache() { - int size = imagePreviewCache.size(); - imagePreviewCache.clear(); - currentCacheSizeBytes = 0; - logMessage("Cleared image preview cache (" + size + " entries)"); - } - } diff --git a/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java b/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java index 59d2d42..859fbeb 100644 --- a/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java +++ b/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java @@ -290,10 +290,6 @@ private void jMenuItemOpenActionPerformed(java.awt.event.ActionEvent evt) {//GEN long startTime = System.currentTimeMillis(); importDirectory = chooser.getSelectedFile(); - - // Clear image preview cache when loading new directory (fresh start) - PhotoUtils.clearImagePreviewCache(); - List groups = FileUtils.loadFolder(importDirectory, groupingEngine, (float) jTimestampSpinner.getValue(), (float) jSimilaritySpinner.getValue()); loadFrame(groups); diff --git a/src/main/java/com/penguinpush/cullergrader/utils/Logger.java b/src/main/java/com/penguinpush/cullergrader/utils/Logger.java index d9602a2..f6dd7ef 100644 --- a/src/main/java/com/penguinpush/cullergrader/utils/Logger.java +++ b/src/main/java/com/penguinpush/cullergrader/utils/Logger.java @@ -57,23 +57,4 @@ public static synchronized void logMessage(String message) { public static void registerLogCallback(Consumer callback) { logCallback = callback; } - - /** - * Logs a message to console and log file only, without triggering the GUI callback. - * Useful for verbose/cache operations that shouldn't clutter the GUI info panel. - */ - public static synchronized void logToConsoleOnly(String message) { - try (BufferedWriter bufferedWriter = new BufferedWriter(new FileWriter(logFile, true))) { - String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss.SSS")); - - bufferedWriter.write(timestamp + ": " + message); - bufferedWriter.newLine(); - - // Do NOT call logCallback - this prevents GUI updates - System.out.println(message); - - } catch (IOException e) { - System.err.println("an error occurred... ironically, there's nowhere to log this: " + e.getMessage()); - } - } } \ No newline at end of file diff --git a/src/main/resources/config.json b/src/main/resources/config.json index 2f6b652..99f0e21 100644 --- a/src/main/resources/config.json +++ b/src/main/resources/config.json @@ -9,6 +9,5 @@ "HASHED_WIDTH": 8, "HASHED_HEIGHT": 8, "TIME_THRESHOLD_SECONDS": 15, - "SIMILARITY_THRESHOLD_PERCENT": 45, - "IMAGE_PREVIEW_CACHE_SIZE_MB": 2048 + "SIMILARITY_THRESHOLD_PERCENT": 45 } \ No newline at end of file