diff --git a/README.md b/README.md index 2f67e8e..7fe231c 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": 2048 } ``` @@ -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 2048 MB (2 GB). Increase for very 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