From 92fca05b2233b026980072fdf3b0833b9fdde482 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Fri, 2 Nov 2018 10:40:09 +0100 Subject: [PATCH] fixing exif based orientation error --- README.md | 4 + backend/Logger.ts | 7 -- backend/model/DiskManger.ts | 1 - backend/model/exif.d.ts | 32 -------- backend/model/sql/enitites/PhotoEntity.ts | 5 +- backend/model/threading/DiskMangerWorker.ts | 8 +- backend/model/threading/Worker.ts | 2 +- common/entities/PhotoDTO.ts | 24 ++++++ frontend/app/app.module.ts | 5 +- frontend/app/gallery/FixOrientationPipe.ts | 82 +++++++++++++++++++ .../directory/directory.gallery.component.css | 25 ++++++ .../directory.gallery.component.html | 3 +- .../directory/directory.gallery.component.ts | 11 ++- frontend/app/gallery/grid/GridRowBuilder.ts | 9 +- .../gallery/grid/grid.gallery.component.ts | 2 +- .../photo/photo.grid.gallery.component.html | 5 +- .../photo.lightbox.gallery.component.html | 6 +- .../photo/photo.lightbox.gallery.component.ts | 37 +++++++-- .../lightbox.map.gallery.component.html | 4 +- .../lightbox.map.gallery.component.ts | 3 + .../gallery/share/share.gallery.component.css | 3 + .../app/gallery/thumnailManager.service.ts | 2 +- package.json | 42 +++++----- 23 files changed, 233 insertions(+), 89 deletions(-) delete mode 100644 backend/model/exif.d.ts create mode 100644 frontend/app/gallery/FixOrientationPipe.ts diff --git a/README.md b/README.md index edbe59f7..b819de56 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,10 @@ apt-get install build-essential libkrb5-dev gcc g++ * you can write some note in the blog.md for every directory * bug free :) - `In progress` +## Known errors + +There is no nice way to handle EXIF orientation tag properly. +The page handles these photos, but might cause same error in the user experience (e.g.: the pages loads those photos slower. See issue #11) ## Credits Crossbrowser testing sponsored by [Browser Stack](https://www.browserstack.com) diff --git a/backend/Logger.ts b/backend/Logger.ts index bbb0c4b1..441d1eb1 100644 --- a/backend/Logger.ts +++ b/backend/Logger.ts @@ -1,12 +1,5 @@ import * as winston from 'winston'; -declare module 'winston' { - interface LoggerInstance { - logFileName: string; - logFilePath: string; - } -} - export const winstonSettings = { transports: [ new winston.transports.Console({ diff --git a/backend/model/DiskManger.ts b/backend/model/DiskManger.ts index 22abb409..a753291b 100644 --- a/backend/model/DiskManger.ts +++ b/backend/model/DiskManger.ts @@ -1,4 +1,3 @@ -/// import {DirectoryDTO} from '../../common/entities/DirectoryDTO'; import {Logger} from '../Logger'; import {Config} from '../../common/config/private/Config'; diff --git a/backend/model/exif.d.ts b/backend/model/exif.d.ts deleted file mode 100644 index dada00e9..00000000 --- a/backend/model/exif.d.ts +++ /dev/null @@ -1,32 +0,0 @@ -declare module 'node-iptc' { - - function e(data): any; - - module e { - } - - export = e; -} - - -declare module 'exif-parser' { - export interface ExifData { - tags: any; - imageSize: any; - } - - export interface ExifObject { - enableTagNames(value: boolean); - - enableImageSize(value: boolean); - - enableReturnTags(value: boolean); - - parse(): ExifData; - - } - - export function create(data: any): ExifObject; - -} - diff --git a/backend/model/sql/enitites/PhotoEntity.ts b/backend/model/sql/enitites/PhotoEntity.ts index 2f8f3883..24d3f490 100644 --- a/backend/model/sql/enitites/PhotoEntity.ts +++ b/backend/model/sql/enitites/PhotoEntity.ts @@ -1,7 +1,7 @@ import {Column, Entity, ManyToOne, PrimaryGeneratedColumn} from 'typeorm'; import {CameraMetadata, GPSMetadata, ImageSize, PhotoDTO, PhotoMetadata, PositionMetaData} from '../../../../common/entities/PhotoDTO'; import {DirectoryEntity} from './DirectoryEntity'; - +import {OrientationTypes} from 'ts-exif-parser'; @Entity() export class CameraMetadataEntity implements CameraMetadata { @@ -80,6 +80,9 @@ export class PhotoMetadataEntity implements PhotoMetadata { @Column(type => PositionMetaDataEntity) positionData: PositionMetaDataEntity; + @Column('tinyint') + orientation: OrientationTypes; + @Column(type => ImageSizeEntity) size: ImageSizeEntity; diff --git a/backend/model/threading/DiskMangerWorker.ts b/backend/model/threading/DiskMangerWorker.ts index 45b7e988..2b930020 100644 --- a/backend/model/threading/DiskMangerWorker.ts +++ b/backend/model/threading/DiskMangerWorker.ts @@ -4,7 +4,7 @@ import {DirectoryDTO} from '../../../common/entities/DirectoryDTO'; import {CameraMetadata, GPSMetadata, ImageSize, PhotoDTO, PhotoMetadata} from '../../../common/entities/PhotoDTO'; import {Logger} from '../../Logger'; import {IptcParser} from 'ts-node-iptc'; -import {ExifParserFactory} from 'ts-exif-parser'; +import {ExifParserFactory, OrientationTypes} from 'ts-exif-parser'; import {ProjectPath} from '../../ProjectPath'; import {Config} from '../../../common/config/private/Config'; @@ -29,7 +29,6 @@ export class DiskMangerWorker { public static scanDirectory(relativeDirectoryName: string, maxPhotos: number = null, photosOnly: boolean = false): Promise { return new Promise((resolve, reject) => { - const directoryName = path.basename(relativeDirectoryName); const directoryParent = path.join(path.dirname(relativeDirectoryName), path.sep); const absoluteDirectoryName = path.join(ProjectPath.ImageFolder, relativeDirectoryName); @@ -94,10 +93,10 @@ export class DiskMangerWorker { cameraData: {}, positionData: null, size: {}, + orientation: OrientationTypes.TOP_RIGHT, creationDate: 0, fileSize: 0 }; - try { try { @@ -130,6 +129,9 @@ export class DiskMangerWorker { metadata.creationDate = exif.tags.CreateDate || exif.tags.DateTimeOriginal || exif.tags.ModifyDate; } + if (exif.tags.Orientation) { + metadata.orientation = exif.tags.Orientation; + } if (exif.imageSize) { metadata.size = {width: exif.imageSize.width, height: exif.imageSize.height}; diff --git a/backend/model/threading/Worker.ts b/backend/model/threading/Worker.ts index 1d3c80a1..c49f81e6 100644 --- a/backend/model/threading/Worker.ts +++ b/backend/model/threading/Worker.ts @@ -8,7 +8,7 @@ export class Worker { public static process() { Logger.debug('Worker is waiting for tasks'); - process.on('message', async (task: WorkerTask)=> { + process.on('message', async (task: WorkerTask) => { try { let result = null; switch (task.type) { diff --git a/common/entities/PhotoDTO.ts b/common/entities/PhotoDTO.ts index 93f5db9c..8cd73b06 100644 --- a/common/entities/PhotoDTO.ts +++ b/common/entities/PhotoDTO.ts @@ -1,4 +1,6 @@ import {DirectoryDTO} from './DirectoryDTO'; +import {ImageSize} from './PhotoDTO'; +import {OrientationTypes} from 'ts-exif-parser'; export interface PhotoDTO { id: number; @@ -13,6 +15,7 @@ export interface PhotoMetadata { keywords: Array; cameraData: CameraMetadata; positionData: PositionMetaData; + orientation: OrientationTypes; size: ImageSize; creationDate: number; fileSize: number; @@ -57,4 +60,25 @@ export module PhotoDTO { photo.metadata.positionData.GPSData.latitude && photo.metadata.positionData.GPSData.longitude)); }; + + export const isSideWay = (photo: PhotoDTO): boolean => { + return photo.metadata.orientation === OrientationTypes.LEFT_TOP || + photo.metadata.orientation === OrientationTypes.RIGHT_TOP || + photo.metadata.orientation === OrientationTypes.LEFT_BOTTOM || + photo.metadata.orientation === OrientationTypes.RIGHT_BOTTOM; + + }; + + export const getRotatedSize = (photo: PhotoDTO): ImageSize => { + if (isSideWay(photo)) { + // noinspection JSSuspiciousNameCombination + return {width: photo.metadata.size.height, height: photo.metadata.size.width}; + } + return photo.metadata.size; + }; + + export const calcRotatedAspectRatio = (photo: PhotoDTO): number => { + const size = getRotatedSize(photo); + return size.width / size.height; + }; } diff --git a/frontend/app/app.module.ts b/frontend/app/app.module.ts index c20979cc..35156764 100644 --- a/frontend/app/app.module.ts +++ b/frontend/app/app.module.ts @@ -72,6 +72,7 @@ import {IconizeSortingMethod} from './pipes/IconizeSortingMethod'; import {StringifySortingMethod} from './pipes/StringifySortingMethod'; import {RandomQueryBuilderGalleryComponent} from './gallery/random-query-builder/random-query-builder.gallery.component'; import {RandomPhotoSettingsComponent} from './settings/random-photo/random-photo.settings.component'; +import {FixOrientationPipe} from './gallery/FixOrientationPipe'; @Injectable() export class GoogleMapsConfig { @@ -166,7 +167,9 @@ export function translationsFactory(locale: string) { IndexingSettingsComponent, StringifyRole, IconizeSortingMethod, - StringifySortingMethod], + StringifySortingMethod, + FixOrientationPipe + ], providers: [ {provide: UrlSerializer, useClass: CustomUrlSerializer}, {provide: LAZY_MAPS_API_CONFIG, useClass: GoogleMapsConfig}, diff --git a/frontend/app/gallery/FixOrientationPipe.ts b/frontend/app/gallery/FixOrientationPipe.ts new file mode 100644 index 00000000..b32fd77e --- /dev/null +++ b/frontend/app/gallery/FixOrientationPipe.ts @@ -0,0 +1,82 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {OrientationTypes} from 'ts-exif-parser'; + +/** + * This pipe is used to fix thumbnail and photo orientation based on their exif orientation tag + */ + +@Pipe({name: 'fixOrientation'}) +export class FixOrientationPipe implements PipeTransform { + + public static transform(imageSrc: string, orientation: OrientationTypes): Promise { + if (orientation === OrientationTypes.TOP_LEFT) { + return Promise.resolve(imageSrc); + } + return new Promise((resolve) => { + const img = new Image(); + + // noinspection SpellCheckingInspection + img.onload = () => { + const width = img.width, + height = img.height, + canvas = document.createElement('canvas'), + ctx = canvas.getContext('2d'); + + // set proper canvas dimensions before transform & export + if (OrientationTypes.BOTTOM_LEFT < orientation && + orientation < OrientationTypes.LEFT_BOTTOM) { + // noinspection JSSuspiciousNameCombination + canvas.width = height; + // noinspection JSSuspiciousNameCombination + canvas.height = width; + } else { + canvas.width = width; + canvas.height = height; + } + + // transform context before drawing image + switch (orientation) { + case OrientationTypes.TOP_RIGHT: // 2 + ctx.transform(-1, 0, 0, 1, width, 0); + break; + case OrientationTypes.BOTTOM_RIGHT: // 3 + ctx.transform(-1, 0, 0, -1, width, height); + break; + case OrientationTypes.BOTTOM_LEFT: // 4 + ctx.transform(1, 0, 0, -1, 0, height); + break; + case OrientationTypes.LEFT_TOP: // 5 + ctx.transform(0, 1, 1, 0, 0, 0); + break; + case OrientationTypes.RIGHT_TOP: // 6 + ctx.transform(0, 1, -1, 0, height, 0); + break; + case OrientationTypes.RIGHT_BOTTOM: // 7 + ctx.transform(0, -1, -1, 0, height, width); + break; + case OrientationTypes.LEFT_BOTTOM: // 8 + ctx.transform(0, -1, 1, 0, 0, width); + break; + default: + break; + } + + // draw image + ctx.drawImage(img, 0, 0); + + // export base64 + resolve(canvas.toDataURL()); + }; + + img.onerror = () => { + resolve(imageSrc); + }; + + img.src = imageSrc; + }); + } + + transform(imageSrc: string, orientation: OrientationTypes): Promise { + return FixOrientationPipe.transform(imageSrc, orientation); + } +} diff --git a/frontend/app/gallery/directory/directory.gallery.component.css b/frontend/app/gallery/directory/directory.gallery.component.css index 5428d229..91e7e9dd 100644 --- a/frontend/app/gallery/directory/directory.gallery.component.css +++ b/frontend/app/gallery/directory/directory.gallery.component.css @@ -54,3 +54,28 @@ a:hover .photo-container { width: 180px; white-space: normal; } + +/* transforming photo, based on exif orientation*/ +.photo-orientation-1 { +} +.photo-orientation-2 { +transform: rotateY(180deg); +} +.photo-orientation-3 { + transform: rotate(180deg); +} +.photo-orientation-4 { + transform: rotate(180deg) rotateY(180deg); +} +.photo-orientation-5 { + transform: rotate(270deg) rotateY(180deg); +} +.photo-orientation-6 { + transform: rotate(90deg); +} +.photo-orientation-7 { + transform: rotate(90deg) rotateY(180deg); +} +.photo-orientation-8 { + transform: rotate(270deg); +} diff --git a/frontend/app/gallery/directory/directory.gallery.component.html b/frontend/app/gallery/directory/directory.gallery.component.html index 376229be..6de81773 100644 --- a/frontend/app/gallery/directory/directory.gallery.component.html +++ b/frontend/app/gallery/directory/directory.gallery.component.html @@ -7,7 +7,8 @@
-
0) { + return this.directory.photos[0]; + } + return null; + } + getSanitizedThUrl() { return this._sanitizer.bypassSecurityTrustStyle('url(' + encodeURI(this.thumbnail.Src).replace(/\(/g, '%28') .replace(/\)/g, '%29') + ')'); @@ -51,7 +58,7 @@ export class GalleryDirectoryComponent implements OnInit, OnDestroy { ngOnInit() { if (this.directory.photos.length > 0) { - this.thumbnail = this.thumbnailService.getThumbnail(new Photo(this.directory.photos[0], this.calcSize(), this.calcSize())); + this.thumbnail = this.thumbnailService.getThumbnail(new Photo(this.SamplePhoto, this.calcSize(), this.calcSize())); } } diff --git a/frontend/app/gallery/grid/GridRowBuilder.ts b/frontend/app/gallery/grid/GridRowBuilder.ts index 805f90ba..2968ce9e 100644 --- a/frontend/app/gallery/grid/GridRowBuilder.ts +++ b/frontend/app/gallery/grid/GridRowBuilder.ts @@ -2,12 +2,12 @@ import {PhotoDTO} from '../../../../common/entities/PhotoDTO'; export class GridRowBuilder { - private photoRow: Array = []; + private photoRow: PhotoDTO[] = []; private photoIndex = 0; // index of the last pushed photo to the photoRow - constructor(private photos: Array, + constructor(private photos: PhotoDTO[], private startIndex: number, private photoMargin: number, private containerWidth: number) { @@ -41,7 +41,7 @@ export class GridRowBuilder { return true; } - public getPhotoRow(): Array { + public getPhotoRow(): PhotoDTO[] { return this.photoRow; } @@ -61,7 +61,8 @@ export class GridRowBuilder { public calcRowHeight(): number { let width = 0; for (let i = 0; i < this.photoRow.length; i++) { - width += ((this.photoRow[i].metadata.size.width) / (this.photoRow[i].metadata.size.height)); // summing up aspect ratios + const size = PhotoDTO.getRotatedSize(this.photoRow[i]); + width += (size.width / size.height); // summing up aspect ratios } const height = (this.containerWidth - this.photoRow.length * (this.photoMargin * 2) - 1) / width; // cant be equal -> width-1 diff --git a/frontend/app/gallery/grid/grid.gallery.component.ts b/frontend/app/gallery/grid/grid.gallery.component.ts index 27a2cf52..e8bdda97 100644 --- a/frontend/app/gallery/grid/grid.gallery.component.ts +++ b/frontend/app/gallery/grid/grid.gallery.component.ts @@ -189,7 +189,7 @@ export class GalleryGridComponent implements OnChanges, OnInit, AfterViewInit, O const imageHeight = rowHeight - (this.IMAGE_MARGIN * 2); photoRowBuilder.getPhotoRow().forEach((photo) => { - const imageWidth = imageHeight * (photo.metadata.size.width / photo.metadata.size.height); + const imageWidth = imageHeight * PhotoDTO.calcRotatedAspectRatio(photo); this.photosToRender.push(new GridPhoto(photo, imageWidth, imageHeight, this.renderedPhotoIndex)); }); diff --git a/frontend/app/gallery/grid/photo/photo.grid.gallery.component.html b/frontend/app/gallery/grid/photo/photo.grid.gallery.component.html index 49d6cf4f..1b62cc00 100644 --- a/frontend/app/gallery/grid/photo/photo.grid.gallery.component.html +++ b/frontend/app/gallery/grid/photo/photo.grid.gallery.component.html @@ -1,5 +1,6 @@
- + #{{keyword}} #{{keyword}} - , + ,
diff --git a/frontend/app/gallery/lightbox/photo/photo.lightbox.gallery.component.html b/frontend/app/gallery/lightbox/photo/photo.lightbox.gallery.component.html index 91afe10c..752245f4 100644 --- a/frontend/app/gallery/lightbox/photo/photo.lightbox.gallery.component.html +++ b/frontend/app/gallery/lightbox/photo/photo.lightbox.gallery.component.html @@ -2,12 +2,12 @@ + [src]="thumbnailSrc"/> - diff --git a/frontend/app/gallery/lightbox/photo/photo.lightbox.gallery.component.ts b/frontend/app/gallery/lightbox/photo/photo.lightbox.gallery.component.ts index 640da36f..f9693e19 100644 --- a/frontend/app/gallery/lightbox/photo/photo.lightbox.gallery.component.ts +++ b/frontend/app/gallery/lightbox/photo/photo.lightbox.gallery.component.ts @@ -1,5 +1,7 @@ import {Component, ElementRef, Input, OnChanges} from '@angular/core'; import {GridPhoto} from '../../grid/GridPhoto'; +import {PhotoDTO} from '../../../../../common/entities/PhotoDTO'; +import {FixOrientationPipe} from '../../FixOrientationPipe'; @Component({ selector: 'app-gallery-lightbox-photo', @@ -11,26 +13,43 @@ export class GalleryLightboxPhotoComponent implements OnChanges { @Input() gridPhoto: GridPhoto; @Input() loadImage = false; @Input() windowAspect = 1; + prevGirdPhoto = null; public imageSize = {width: 'auto', height: '100'}; - imageLoaded = false; + private imageLoaded = false; public imageLoadFinished = false; + thumbnailSrc: string = null; + photoSrc: string = null; + constructor(public elementRef: ElementRef) { } ngOnChanges() { - this.imageLoaded = false; this.imageLoadFinished = false; this.setImageSize(); + if (this.prevGirdPhoto !== this.gridPhoto) { + this.prevGirdPhoto = this.gridPhoto; + this.thumbnailSrc = null; + this.photoSrc = null; + } + if (this.thumbnailSrc == null && this.gridPhoto && this.ThumbnailUrl !== null) { + FixOrientationPipe.transform(this.ThumbnailUrl, this.gridPhoto.photo.metadata.orientation) + .then((src) => this.thumbnailSrc = src); + } + + if (this.photoSrc == null && this.gridPhoto && this.loadImage) { + FixOrientationPipe.transform(this.gridPhoto.getPhotoPath(), this.gridPhoto.photo.metadata.orientation) + .then((src) => this.thumbnailSrc = src); + } } onImageError() { // TODO:handle error this.imageLoadFinished = true; - console.error('cant load image'); + console.error('Error: cannot load image for lightbox url: ' + this.gridPhoto.getPhotoPath()); } @@ -39,7 +58,7 @@ export class GalleryLightboxPhotoComponent implements OnChanges { this.imageLoaded = true; } - public thumbnailPath(): string { + private get ThumbnailUrl(): string { if (this.gridPhoto.isThumbnailAvailable() === true) { return this.gridPhoto.getThumbnailPath(); } @@ -50,8 +69,14 @@ export class GalleryLightboxPhotoComponent implements OnChanges { return null; } + public get PhotoSrc(): string { + return this.gridPhoto.getPhotoPath(); + } + public showThumbnail(): boolean { - return this.gridPhoto && !this.imageLoaded && + return this.gridPhoto && + !this.imageLoaded && + this.thumbnailSrc !== null && (this.gridPhoto.isThumbnailAvailable() || this.gridPhoto.isReplacementThumbnailAvailable()); } @@ -61,7 +86,7 @@ export class GalleryLightboxPhotoComponent implements OnChanges { } - const photoAspect = this.gridPhoto.photo.metadata.size.width / this.gridPhoto.photo.metadata.size.height; + const photoAspect = PhotoDTO.calcRotatedAspectRatio(this.gridPhoto.photo); if (photoAspect < this.windowAspect) { this.imageSize.height = '100'; diff --git a/frontend/app/gallery/map/lightbox/lightbox.map.gallery.component.html b/frontend/app/gallery/map/lightbox/lightbox.map.gallery.component.html index a01338eb..a8e55de9 100644 --- a/frontend/app/gallery/map/lightbox/lightbox.map.gallery.component.html +++ b/frontend/app/gallery/map/lightbox/lightbox.map.gallery.component.html @@ -14,14 +14,14 @@ *ngFor="let photo of mapPhotos" [latitude]="photo.latitude" [longitude]="photo.longitude" - [iconUrl]="photo.iconUrl" + [iconUrl]="photo.iconUrl | fixOrientation:photo.orientation | async" (markerClick)="loadPreview(photo)" [agmFitBounds]="true"> + [src]="photo.preview.thumbnail.Src | fixOrientation:photo.orientation | async">
= 6.9 <=10.0" + "node": ">= 6.9 <11.0" } }