JLiteBox: An Image Catalog Software
JLiteBox is a JavaFx application that catalogs RAW and JPG images and allows the user to display them in a GUI. It is essentially a clone of the popular Adobe Lightroom or PhotoMechanic by Camera Bits.
- JavaFX
- Maven
- Google Guice
- SQLite
- Metadata Extractor
- vavr.io
- LibRawFx
- Typesafe Config
- JUnit
- SLF4J
- Logback
- JaCoCo
- Jackson
The Maven configuration creates a jar file jlitebox-1.0-SNAPSHOT.jar
in the /target
directory. Note that there is also another smaller jlitebox-1.0-SNAPSHOT.jar
that's available but that will not have all the app dependencies and will require you to set up the classpath for the app's dependencies.
$ mvn package
Running mvn test
should run all the unit tests and create a Test Coverage report in target/site/jacoco
. You should be able to see the results of the test and the test coverage with JaCoCo's comprehensive report.
The project runs on language level 11 but requires Java 17 to run to be able to leverage the latest features of JavaFx.
$ java --enable-native-access ALL-UNNAMED \
--add-modules jdk.incubator.foreign \
-jar jlitebox-1.0-SNAPSHOT.jar.jar
The standard way of running the app with all its features. For this iteration, it now displays all images in the catalog:
$ java --enable-native-access ALL-UNNAMED \
--add-modules jdk.incubator.foreign \
-jar jlitebox-1.0-SNAPSHOT.jar.jar -d {import-dir}
Imports the images in specified directory import-dir
to store them in the configured catalog and logs the activity/status on the standard output (usually the screen).
There is a sample folder that's available under sample/images
so you can test the app with:
$ cd target/
$ java --enable-native-access ALL-UNNAMED \
--add-modules jdk.incubator.foreign \
-jar jlitebox-1.0-SNAPSHOT.jar.jar -d ../sample/images/
The file is a simple text file with a list of URLs where the import can download from; it is assumed that the images hosted in the URL does not require any authentication to be able to create a copy of the said image locally.
There is a sample file that's available under sample/import.txt
so you can test the app with:
$ cd target/
$ java --enable-native-access ALL-UNNAMED \
--add-modules jdk.incubator.foreign \
-jar jlitebox-1.0-SNAPSHOT.jar.jar -i ../sample/import.txt
The application is configured using the HOCON format which can be found in resources/application.conf
file. Below are the configuration parameters and what they mean:
Parameter | Type | Default | Remarks |
---|---|---|---|
import.overwrite |
boolean | true |
To denote if file import overwrites by default |
import.temp-dir |
string | System.getProperty("java.io.tmpdir") |
The temp directory for downloading and images being processed |
storage.root-dir |
string | ~/tmp |
The root directory of the catalog where files are stored during import and read from when rendering on GUI |
metadata.db-url |
string | jdbc:sqlite:db/jlitebox.sqlite |
The SQLite3 database where image metadata is stored |
images.supported-exts |
string array | [ JPG, DNG, NEF ] |
File extensions of supported files |
preview.width |
int | 640 | Pixel width for previews for RAW files |
preview.height |
int | 480 | Pixel height for previews for RAW files |
Logging is done using LogBack (in conjunction with SLF4J) and follows the same standards for logging configuration located in resources/logback.xml
. Right now, the logging is on console only in INFO
level.
The code are sub-divided into packages according to their logical grouping:
Name | Remarks |
---|---|
config |
Application and catalog configuration |
controller |
Application controllers (MVC) |
equipment |
Image equipment classes (e.g., camera, lens) |
exceptions |
Exception custom classes |
filter |
Filtering of Images for GUI display |
image |
The basic models for representing the images, mainly the Image class and sub-classes, along with the catalog and downloader |
image.metadata |
Handling of image metadata |
image.preview |
Handling of image previews |
storage |
Storage of file and metadata database (SQLite3 JDBC) |
utils |
General/common utility classes and/or static functions that is shared across the application |
view |
Application views (MVC) |
The edu.bu.cs622.jlitebox.App
is the main class, but the edu.bu.cs622.jlitebox.AppFx
is where the heart of the application starts. This is done because of the strict Java 9 modularity that JavaFx follows and since JavaFx is not distributed with the JDK since Java 11, this pattern is adopted to get around it. More explanation can be read here.
This is a relatively basic implementation of Model-View-Controller design pattern. The controllers are in the edu.bu.cs622.jlitebox.controller
package and the views are in edu.bu.cs622.jlitebox.view
package. As noted in the previous section, the application can be run in GUI or console mode depending on the parameters, and the console mode not only has the ability to emit messages (leveraging the logging framework) but also accept inputs from the user via the keyboard. The GUI based view/controller are within the JavaFx framework which has its own MVC implementation shown in this article and the application abides by the best practices as dicated by the article.
We are leveraging Dependency Injection technique through Google Guice. You can see this used throughout the app and the modules are defined in JLiteBoxModule
:
public class JLiteBoxModule extends AbstractModule {
@Override
protected void configure() {
bind(ImageCatalogConfiguration.class).to(BasicImageCatalogConfiguration.class);
bind(ImageImportController.class).to(ConsoleFileImportController.class);
bind(ImageImporterView.class).to(ImageImporterConsoleView.class);
bind(ImageCatalog.class).to(BasicImageCatalog.class);
bind(ImageMetadataStorage.class).to(DatabaseImageMetadataStorage.class);
bind(ImageStorage.class).to(FileImageStorage.class);
bind(ImageMetadataStorage.class).to(DatabaseImageMetadataStorage.class);
bind(UserInputSource.class).to(UserKeyboardInputSource.class);
bind(ImageDownloader.class).to(BasicImageDownloader.class);
bind(ImageMetadataExtractor.class).to(LibRawMetadataExtractor.class);
bind(ImagePreviewGenerator.class).to(LibRawImagePreviewGenerator.class);
}
}
And these are used accordingly with the injector like shown in the code below:
// create the Guice injector
var injector = Guice.createInjector(new JLiteBoxModule());
// create the class you want using dependency injection
var previewGenerator = injector.getInstance(ImagePreviewGenerator.class);
// profit!
var preview = previewGenerator.generatePreview((RawImage) img);
In addition, where are also using Factory pattern when the dependency-injection using Guice is not necessary or deemed overkill. One example of this is the ImageFactory
used to create instances of Image
by specifying a file location:
public final class ImageFactory {
public static Image fromFile(String path) {
return ImageUtils.isJpegImage(path) ? new JpegImage(path) : new RawImage(path);
}
}
And this is used like this:
// creates a RawImage instance
var image = ImageFactory.fromFile("./hello.dng");
The advent of Java 8's "functions as first-class citizens" allows us to write with Functional Programming principles in mind, and this has been enhanced using Vavr. The features of the library are used sparingly so as not to deviate/clash with the OOP constructs being taught in this class, but we do apply them where it would make the code more concise and robust, and thus easier to reason about. Consider this simple example:
shutterSpeed = Try.of(() -> Float.parseFloat(s)).getOrElse((float) 0));
The use of monadic construct Try<T>
allows us to write a shorter, more concise code that achieves the same effect: here instead of having a try/catch block to assign 0
to shutterSpeed
we are using Try::of
instead which "maps" the result if successful and "maps left" to assign 0 if fails.
The application leverages the use of custom exceptions for having the option of "recovering" from errors that are not critical like out-of-memory errors. The base class JLiteBoxException
has a requiresInteraction()
method to denote if an error is recoverable or not.
public class JLiteBoxException extends Exception {
boolean interact = false;
public boolean requiresInteraction() {
return this.interact;
}
}
And with that you have further convenience subclasses NonRecoverableException
and RecoverableException
which sets the interact
false automatically in the constructors and all other exceptions can either derive directly from JLiteBoxException
or either of the intermediate mentioned subclasses. The Controller and/or View can leverage the JLiteBoxException::requiresInteraction
method to see if a failure in an operation can be recovered and ask the user to continue or not.
We are using the JSON format to serialize/deserialize objects to make it more portable and avoid the general issues that's tied to the default binary serialization to persist objects for long-term storage. We are using Jackson for JSON serialization/deserialization.
We are using SQLite3 to persist our image metadata and JPEG preview blobs. Below is the fields for the database. The artifacts are in the /db
sub-folder including the schema
Name | Type | Nullable? | Remarks |
---|---|---|---|
name | text | no | PK, Name derived from src file name |
image_type | text | yes | JPG for JPEG (default), RAW based on the src file name extension |
src_path | text | no | The location in the file storage |
camera_id | text | yes | Camera ID (FK to Camera table) |
lens_id | text | yes | Lens ID (FK to Lens table) |
lens_focal_length | real | yes | Lens focal length (extracted from EXIF metadata) |
shutter_speed | real | yes | Image shutter speed (extracted from EXIF metadata) |
capture_date | integer | yes | Image capture date epoch format (extracted from EXIF metadata) |
iso | integer | yes | Image ISO (extracted from EXIF metadata) |
raw_metadata | text | yes | The raw image metadata in JSON format from ImageMetadata java class |
image_preview | blob | yes | JPEG preview binary blob |
Name | Type | Nullable? | Remarks |
---|---|---|---|
id | text | no | primary key |
brand | text | no | Camera brand (extracted from EXIF metadata) |
model | text | yes | Camera model (extracted from EXIF metadata) |
is_autofocus | numeric | yes | Is camera auto-focus? (inferred from brand/model) |
Name | Type | Nullable? | Remarks |
---|---|---|---|
id | text | no | primary key |
brand | text | no | Lens brand (extracted from EXIF metadata) |
model | text | yes | Lens model (extracted from EXIF metadata) |
For this application, the use case for object persistence is for saving/reading image metadata to/from a SQLite3 database (in raw_metadata
field) as reading the metadata from the RAW file itself is very expensive and poses a lot overhead (like having to use JavaFx classes which are relatively heavyweight). It is much more efficient to read this from a database and then deserialize. This is the strategy that we employ here: the metadata is serialized into JSON like this before written into database. Here is an example of the ImageMetadata
instance when serialized to JSON (prior to persisting to database):
{
"rawData": {
"CameraModel": "Z 6",
"MaxAp @MaxFocal": "f/2.8",
"ExposureProgram": "-1",
"MedteringMode": "-1",
"Focal length": "105.0 mm",
"Shutter": "200.0",
"MaxFocal": "105.0 mm",
// ... other fields
"XMP": "<?xpacket> .... <?xpacket end=\"w\"?>",
"Lens": "NIKKOR Z MC 105mm f/2.8 VR S",
"FocusMode": "-1",
"MinFocal": "105.0 mm"
},
"aperture": 2.8,
"shutterSpeed": 105.0,
"captureDate": 1641764652000,
"iso": 100
}
And with Jackson we leverage the library's annotation so that the library can instantiate the proper subclass if using abstract and/or interfaces. In our case, ImageMetadata
has Camera
and Lens
fields that are instances of the abstract class ImagingEquipment
so we need to provide hints on what subclass to use to instantiate, and these what the annotations are used for. So we have these annotations in ImagingEquipment
declaration:
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY)
@JsonSubTypes({ @JsonSubTypes.Type(value = Camera.class, name = "Camera"),
@JsonSubTypes.Type(value = Lens.class, name = "Lens") })
public abstract class ImagingEquipment {
// ...
}
We are fully leveraging JavaFx to generate the GUI and one of the core features implemented here is the creation of JPEG preview for RAW files that would have not been possible without the LibRawFx library. There are limitations to this library, however as it can only support cameras like Nikon (newer cameras only), Canon and Fuji.
We are also using JavaFXML for UI markup language to be able to see what the GUI will look like while building it.
The application centers on 2 use cases for the use of threads:
- Make importing of images faster by using multiple threads of execution. Importing has 2 expensive operations: generating a preview and reading the EXIF metadata but both of these are I/O heavy and allows better scheduling. This also allows us to use a better thread pool other than ForkJoinPool which is actually not optimized for heavy I/O threads.
- Make the GUI responsive. JavaFx GUI update is single-threaded, and what makes it even more complicated is that it allows update of GUI in its own thread. Fortunately, it doesn't prevent us from using threads altogether but the thread would not be able to update GUI from its own execution path; to make this possible we are using the convenience function
Platform::runLater
. In our own specific use, when you import from the GUI, the import is done in the background as soon as you hit OK so the GUI remains responsive while the GUI's status bar is being updated with the status of the import operation. Consider the code snippet fromMainController
class:
As for the implementation, we mainly leveraged the newer CompletableFuture<T>
construct that allows the use of parallelism and asynchronous operations that are relatively easy to manage and reason about. We are using thread-safe collection and object types and use immutability whenever possible to totally avoid the use of synchronized
keyword and explicit locking of resources that are potentially not thread-safe.
private void updateStatusBarText(String text) {
Platform.runLater(() -> {
logger.info("Refreshing view...");
initializeImageCollectionView();
statusBar.setText(text);
});
}
// updateStatusBarText is used here
protected void handleFileImportAction(ActionEvent event) {
if (selectedDirectory != null) {
// ...
CompletableFuture.runAsync(() -> Try.of(() -> catalog.addImagesFromDirectory(
selectedDirectory.getAbsolutePath(),
ImageFactory.withDefaultImportOptions(),
(currentImage, currentIndex, all) -> {
logger.info("Image {} of {} imported.", currentIndex, all);
updateStatusBarText(String.format("Image %d of %d imported.", currentIndex, all));
return null;
}))).thenRun(() -> Platform.runLater(() -> {
// once everything is finished, refresh the main view
logger.info("Refreshing view...");
initializeImageCollectionView();
statusBar.setText(statusBarDefaultText);
}));
}
}