Skip to content

Commit 60c70d8

Browse files
authored
Add handling of EXIF oriented jpeg images (#43)
* Add correct handling of EXIF oriented jpeg images. * Fix git json annotation file line-endings. * Load reference EXIF annotation data for consistent tests. * Fix code smells.
1 parent 47284b6 commit 60c70d8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1550
-569
lines changed

.gitattributes

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
# Force json test annotation files to always have LF line endings.
2-
/src/test/resources/testannotations/json/**/*.json text eol=lf
2+
/src/test/resources/testannotations/**/*.json text eol=lf

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ dependencies {
100100

101101
// https://mvnrepository.com/artifact/org.locationtech.jts/jts-core
102102
implementation 'org.locationtech.jts:jts-core:1.19.0'
103+
104+
// https://mvnrepository.com/artifact/com.drewnoakes/metadata-extractor
105+
implementation 'com.drewnoakes:metadata-extractor:2.18.0'
103106
}
104107

105108
javafx {

src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java

Lines changed: 139 additions & 129 deletions
Large diffs are not rendered by default.

src/main/java/com/github/mfl28/boundingboxeditor/model/Model.java

Lines changed: 38 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,9 @@ public void updateBoundingShapeDataAtFileIndex(int fileIndex, List<BoundingShape
157157
String fileName = imageFileNameToFile.get(fileIndex);
158158

159159
ImageAnnotation imageAnnotation = imageFileNameToAnnotation.getOrDefault(fileName,
160-
new ImageAnnotation(
161-
imageFileNameToMetaData
162-
.get(fileName)));
160+
new ImageAnnotation(
161+
imageFileNameToMetaData
162+
.get(fileName)));
163163

164164
if(!boundingShapeData.isEmpty()) {
165165
if(!imageAnnotation.getBoundingShapeData().equals(boundingShapeData)) {
@@ -191,7 +191,7 @@ public void updateImageAnnotations(Collection<ImageAnnotation> imageAnnotations,
191191
IOResult.OperationType operationType) {
192192
boolean noCurrentAnnotations =
193193
imageFileNameToAnnotation.values().stream()
194-
.allMatch(imageAnnotation -> imageAnnotation.getBoundingShapeData().isEmpty());
194+
.allMatch(imageAnnotation -> imageAnnotation.getBoundingShapeData().isEmpty());
195195
boolean boundingShapesAdded = false;
196196

197197
for(final ImageAnnotation annotation : imageAnnotations) {
@@ -219,27 +219,27 @@ public void updateImageAnnotations(Collection<ImageAnnotation> imageAnnotations,
219219

220220
public ImageMetaData getCurrentImageMetaData() {
221221
return imageFileNameToMetaData.computeIfAbsent(getCurrentImageFileName(),
222-
key -> {
223-
ImageMetaData newMetaData;
222+
key -> {
223+
ImageMetaData newMetaData;
224224

225-
try {
226-
newMetaData =
227-
ImageMetaData.fromFile(getCurrentImageFile());
228-
} catch(IOException e) {
229-
throw new UncheckedIOException(e);
230-
}
225+
try {
226+
newMetaData =
227+
ImageMetaData.fromFile(getCurrentImageFile());
228+
} catch(IOException e) {
229+
throw new UncheckedIOException(e);
230+
}
231231

232-
ImageAnnotation currentImageAnnotation =
233-
getCurrentImageAnnotation();
232+
ImageAnnotation currentImageAnnotation =
233+
getCurrentImageAnnotation();
234234

235-
if(currentImageAnnotation != null &&
236-
!currentImageAnnotation.getImageMetaData()
237-
.hasDetails()) {
238-
currentImageAnnotation.setImageMetaData(newMetaData);
239-
}
235+
if(currentImageAnnotation != null &&
236+
!currentImageAnnotation.getImageMetaData()
237+
.hasDetails()) {
238+
currentImageAnnotation.setImageMetaData(newMetaData);
239+
}
240240

241-
return newMetaData;
242-
});
241+
return newMetaData;
242+
});
243243
}
244244

245245
/**
@@ -303,7 +303,7 @@ public BoundingBoxPredictorConfig getBoundingBoxPredictorConfig() {
303303
*/
304304
public ImageAnnotationData createImageAnnotationData() {
305305
return new ImageAnnotationData(imageFileNameToAnnotation.values(), categoryToAssignedBoundingShapesCount,
306-
getCategoryNameToCategoryMap());
306+
getCategoryNameToCategoryMap());
307307
}
308308

309309
/**
@@ -451,10 +451,14 @@ public List<File> getImageFiles() {
451451
return Collections.unmodifiableList(imageFileNameToFile.valueList());
452452
}
453453

454+
public List<ImageMetaData> getImageMetaDataList() {
455+
return imageFileNameToFile.valueList().stream().map(file -> imageFileNameToMetaData.get(file.getName())).toList();
456+
}
457+
454458
public void setImageFiles(Collection<File> imageFiles) {
455459
imageFileNameToFile = ListOrderedMap.listOrderedMap(imageFiles.parallelStream()
456-
.collect(LinkedHashMap::new, (map, item) -> map
457-
.put(item.getName(), item), Map::putAll));
460+
.collect(LinkedHashMap::new, (map, item) -> map
461+
.put(item.getName(), item), Map::putAll));
458462

459463
nrImageFiles.set(imageFileNameToFile.size());
460464
fileIndex.set(0);
@@ -491,20 +495,20 @@ public void clearAnnotationData(boolean keepCategories) {
491495

492496
public Map<String, ObjectCategory> getCategoryNameToCategoryMap() {
493497
return objectCategories.stream()
494-
.collect(Collectors.toMap(ObjectCategory::getName, Function.identity()));
498+
.collect(Collectors.toMap(ObjectCategory::getName, Function.identity()));
495499
}
496500

497501
private Map<String, Integer> createMergedCategoryToBoundingShapeCountMap(Map<String, Integer> toMerge) {
498502
return Stream.of(categoryToAssignedBoundingShapesCount, toMerge)
499-
.map(Map::entrySet)
500-
.flatMap(Collection::stream)
501-
.collect(
502-
Collectors.toMap(
503-
Map.Entry::getKey,
504-
Map.Entry::getValue,
505-
Integer::sum
506-
)
507-
);
503+
.map(Map::entrySet)
504+
.flatMap(Collection::stream)
505+
.collect(
506+
Collectors.toMap(
507+
Map.Entry::getKey,
508+
Map.Entry::getValue,
509+
Integer::sum
510+
)
511+
);
508512
}
509513

510514
private void updateObjectCategoriesFromData(Map<String, ObjectCategory> categoryNameToCategoryMap) {

src/main/java/com/github/mfl28/boundingboxeditor/model/data/ImageAnnotation.java

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -94,21 +94,12 @@ public String getImageFileName() {
9494
}
9595

9696
/**
97-
* Returns the width of the annotated image.
98-
*
99-
* @return the width of the image
100-
*/
101-
public double getImageWidth() {
102-
return imageMetaData.getImageWidth();
103-
}
104-
105-
/**
106-
* Returns the height of the annotated image.
97+
* Returns the height of the oriented annotated image.
10798
*
10899
* @return the height of the image
109100
*/
110-
public double getImageHeight() {
111-
return imageMetaData.getImageHeight();
101+
public double getOrientedImageHeight() {
102+
return imageMetaData.getOrientedHeight();
112103
}
113104

114105
/**
@@ -120,6 +111,15 @@ public int getImageDepth() {
120111
return imageMetaData.getImageDepth();
121112
}
122113

114+
/**
115+
* Returns the width of the oriented annotated image.
116+
*
117+
* @return the width of the image
118+
*/
119+
public double getOrientedImageWidth() {
120+
return imageMetaData.getOrientedWidth();
121+
}
122+
123123
/**
124124
* Returns the name of the annotated image's containing folder.
125125
*

src/main/java/com/github/mfl28/boundingboxeditor/model/data/ImageMetaData.java

Lines changed: 63 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,19 @@
1818
*/
1919
package com.github.mfl28.boundingboxeditor.model.data;
2020

21+
import com.drew.imaging.jpeg.JpegMetadataReader;
22+
import com.drew.imaging.jpeg.JpegProcessingException;
23+
import com.drew.metadata.MetadataException;
24+
import com.drew.metadata.exif.ExifDirectoryBase;
25+
import com.drew.metadata.exif.ExifIFD0Directory;
2126
import com.google.gson.annotations.SerializedName;
2227

2328
import javax.imageio.ImageIO;
2429
import javax.imageio.ImageReader;
2530
import javax.imageio.stream.ImageInputStream;
2631
import java.io.File;
2732
import java.io.IOException;
33+
import java.io.Serial;
2834
import java.util.Iterator;
2935
import java.util.List;
3036
import java.util.Objects;
@@ -37,6 +43,7 @@ public final class ImageMetaData {
3743
private static final String NOT_AN_IMAGE_FILE_ERROR_MESSAGE = "Not an image file.";
3844
private static final String UNSUPPORTED_IMAGE_FORMAT_ERROR_MESSAGE = "Unsupported image file format.";
3945
private static final String INVALID_IMAGE_FILE_ERROR_MESSAGE = "Invalid image file.";
46+
private static final String JPEG_READ_ERROR_MESSAGE = "Cannot read JPEG metadata.";
4047
private final String fileName;
4148
private ImageMetaDataDetails details;
4249

@@ -49,9 +56,14 @@ public final class ImageMetaData {
4956
* @param imageHeight the height of the image
5057
* @param imageDepth the depth (= number of channels) of the image
5158
*/
52-
public ImageMetaData(String fileName, String folderName, double imageWidth, double imageHeight, int imageDepth) {
59+
public ImageMetaData(String fileName, String folderName, String url, double imageWidth, double imageHeight, int imageDepth) {
5360
this.fileName = fileName;
54-
this.details = new ImageMetaDataDetails(folderName, imageWidth, imageHeight, imageDepth);
61+
this.details = new ImageMetaDataDetails(folderName, url, imageWidth, imageHeight, imageDepth, 1);
62+
}
63+
64+
public ImageMetaData(String fileName, String folderName, String url, double imageWidth, double imageHeight, int imageDepth, int orientation) {
65+
this.fileName = fileName;
66+
this.details = new ImageMetaDataDetails(folderName, url, imageWidth, imageHeight, imageDepth, orientation);
5567
}
5668

5769
public ImageMetaData(String fileName) {
@@ -67,7 +79,8 @@ public ImageMetaData(String fileName) {
6779
public static ImageMetaData fromFile(File imageFile) throws IOException {
6880
ImageDimensions imageDimensions = readImageDimensionsFromFile(imageFile);
6981
return new ImageMetaData(imageFile.getName(), imageFile.toPath().getParent().toFile().getName(),
70-
imageDimensions.width(), imageDimensions.height(), imageDimensions.depth());
82+
imageFile.toURI().toString(),
83+
imageDimensions.width(), imageDimensions.height(), imageDimensions.depth(), imageDimensions.orientation());
7184
}
7285

7386
/**
@@ -79,6 +92,14 @@ public double getImageWidth() {
7992
return details.imageWidth;
8093
}
8194

95+
public double getOrientedWidth() {
96+
if(details.orientation >= 5) {
97+
return getImageHeight();
98+
}
99+
100+
return getImageWidth();
101+
}
102+
82103
/**
83104
* Returns the height of the image.
84105
*
@@ -88,6 +109,14 @@ public double getImageHeight() {
88109
return details.imageHeight;
89110
}
90111

112+
public double getOrientedHeight() {
113+
if(details.orientation >= 5) {
114+
return getImageWidth();
115+
}
116+
117+
return getImageHeight();
118+
}
119+
91120
/**
92121
* Returns the depth (= number of channels) of the image.
93122
*
@@ -97,6 +126,10 @@ public int getImageDepth() {
97126
return details.imageDepth;
98127
}
99128

129+
public int getOrientation() {
130+
return details.orientation;
131+
}
132+
100133
/**
101134
* Returns the filename of the image.
102135
*
@@ -115,6 +148,10 @@ public String getFolderName() {
115148
return details.folderName;
116149
}
117150

151+
public String getFileUrl() {
152+
return details.url;
153+
}
154+
118155
public boolean hasDetails() {
119156
return details != null;
120157
}
@@ -137,14 +174,15 @@ public String getDimensionsString() {
137174
return "[]";
138175
}
139176

140-
return "[" + (int) getImageWidth() + " x " + (int) getImageHeight() + "]";
177+
return "[" + (int) getOrientedWidth() + " x " + (int) getOrientedHeight() + "]";
141178
}
142179

143180
private static ImageDimensions readImageDimensionsFromFile(File imageFile) throws IOException {
144181
double width;
145182
double height;
146183
int numComponents;
147-
// Source: https://stackoverflow.com/a/1560052
184+
int orientation = 1;
185+
148186
try(ImageInputStream imageStream = ImageIO.createImageInputStream(imageFile)) {
149187
if(imageStream == null) {
150188
throw new NotAnImageFileException(NOT_AN_IMAGE_FILE_ERROR_MESSAGE);
@@ -166,6 +204,18 @@ private static ImageDimensions readImageDimensionsFromFile(File imageFile) throw
166204
width = reader.getWidth(0);
167205
height = reader.getHeight(0);
168206
numComponents = reader.getRawImageType(0).getNumComponents();
207+
208+
if(imageFormatName.equals("jpeg")) {
209+
try {
210+
final ExifIFD0Directory exifDirectory = JpegMetadataReader.readMetadata(imageFile).getFirstDirectoryOfType(ExifIFD0Directory.class);
211+
212+
if(exifDirectory != null && exifDirectory.containsTag(ExifDirectoryBase.TAG_ORIENTATION)) {
213+
orientation = exifDirectory.getInt(ExifDirectoryBase.TAG_ORIENTATION);
214+
}
215+
} catch(JpegProcessingException | MetadataException | IOException exception) {
216+
throw new UnsupportedImageFileException(JPEG_READ_ERROR_MESSAGE);
217+
}
218+
}
169219
} finally {
170220
reader.dispose();
171221
}
@@ -174,35 +224,17 @@ private static ImageDimensions readImageDimensionsFromFile(File imageFile) throw
174224
}
175225
}
176226

177-
return new ImageDimensions(width, height, numComponents);
227+
return new ImageDimensions(width, height, numComponents, orientation);
178228
}
179229

180-
private record ImageMetaDataDetails(String folderName, @SerializedName("width") double imageWidth,
230+
private record ImageMetaDataDetails(String folderName, String url, @SerializedName("width") double imageWidth,
181231
@SerializedName("height") double imageHeight,
182-
@SerializedName("depth") int imageDepth) {
183-
184-
@Override
185-
public int hashCode() {
186-
return Objects.hash(imageWidth, imageHeight, imageDepth);
187-
}
188-
189-
@Override
190-
public boolean equals(Object o) {
191-
if(this == o) {
192-
return true;
193-
}
194-
195-
if(!(o instanceof ImageMetaDataDetails that)) {
196-
return false;
197-
}
198-
199-
return Double.compare(that.imageWidth, imageWidth) == 0 &&
200-
Double.compare(that.imageHeight, imageHeight) == 0 &&
201-
imageDepth == that.imageDepth;
202-
}
203-
}
232+
@SerializedName("depth") int imageDepth,
233+
int orientation) {
234+
}
204235

205236
public static class NotAnImageFileException extends RuntimeException {
237+
@Serial
206238
private static final long serialVersionUID = 5256590447321177896L;
207239

208240
public NotAnImageFileException(String errorMessage) {
@@ -211,13 +243,14 @@ public NotAnImageFileException(String errorMessage) {
211243
}
212244

213245
public static class UnsupportedImageFileException extends RuntimeException {
246+
@Serial
214247
private static final long serialVersionUID = -4143199502921469708L;
215248

216249
public UnsupportedImageFileException(String errorMessage) {
217250
super(errorMessage);
218251
}
219252
}
220253

221-
private record ImageDimensions(double width, double height, int depth) {
254+
private record ImageDimensions(double width, double height, int depth, int orientation) {
222255
}
223256
}

0 commit comments

Comments
 (0)