diff --git a/server/ERNI.PhotoDatabase.Annotator/.gitattributes b/server/ERNI.PhotoDatabase.Annotator/.gitattributes new file mode 100644 index 0000000..9f2abe9 --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/.gitattributes @@ -0,0 +1 @@ +assets/Model/yolov4.onnx filter=lfs diff=lfs merge=lfs -text diff --git a/server/ERNI.PhotoDatabase.Annotator/AnnotationPredictor.cs b/server/ERNI.PhotoDatabase.Annotator/AnnotationPredictor.cs new file mode 100644 index 0000000..e6a1000 --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/AnnotationPredictor.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Linq; +using Microsoft.ML; +using ERNI.PhotoDatabase.Annotator.YoloParser; +using ERNI.PhotoDatabase.Annotator.Utils; +using System.Drawing.Imaging; + +namespace ERNI.PhotoDatabase.Annotator +{ + public class AnnotationPredictor + { + public (string[], byte[]) MakePrediction(Bitmap bmp) + { + MLContext mlContext = new MLContext(); + List tags = new List(); + var bmpWithBoxes = bmp.Clone(new RectangleF(0, 0, bmp.Width, bmp.Height), bmp.PixelFormat); + try + { + var modelScorer = new OnnxModelScorer(FileUtils.ModelFilePath, mlContext); + var prediction = modelScorer.Score(bmp); + + YoloOutputParser parser = new YoloOutputParser(); + var boxes = parser.ParseOutputs(prediction); + + IList detectedObjects = parser.NonMaxSuppression(boxes, 10, .5F); ; + DrawBoundingBox(ref bmpWithBoxes, detectedObjects); + + tags = detectedObjects.Select(_ => _.Label).Distinct().ToList(); + } + catch (Exception ex) + { + Console.WriteLine(ex.ToString()); + } + + return (tags.ToArray(), bmpWithBoxes.ToByteArray(ImageFormat.Jpeg)); + } + + private static void DrawBoundingBox(ref Bitmap image, + IList filteredBoundingBoxes) + { + var originalImageHeight = image.Height; + var originalImageWidth = image.Width; + + foreach (var box in filteredBoundingBoxes) + { + var x = (uint)Math.Max(box.Dimensions.X1, 0); + var y = (uint)Math.Max(box.Dimensions.Y1, 0); + var width = (uint)Math.Min(originalImageWidth - x, box.Dimensions.Width); + var height = (uint)Math.Min(originalImageHeight - y, box.Dimensions.Height); + + x = (uint)originalImageWidth * x / Yolov4ModelSettings.ImageSettings.imageWidth; + y = (uint)originalImageHeight * y / Yolov4ModelSettings.ImageSettings.imageHeight; + width = (uint)originalImageWidth * width / Yolov4ModelSettings.ImageSettings.imageWidth; + height = (uint)originalImageHeight * height / Yolov4ModelSettings.ImageSettings.imageHeight; + + string text = $"{box.Label} ({box.Confidence * 100:0}%)"; + + using (Graphics thumbnailGraphic = Graphics.FromImage(image)) + { + thumbnailGraphic.CompositingQuality = CompositingQuality.HighQuality; + thumbnailGraphic.SmoothingMode = SmoothingMode.HighQuality; + thumbnailGraphic.InterpolationMode = InterpolationMode.HighQualityBicubic; + + // Define Text Options + Font drawFont = new Font("Arial", 12, FontStyle.Bold); + SizeF size = thumbnailGraphic.MeasureString(text, drawFont); + SolidBrush fontBrush = new SolidBrush(Color.Black); + Point atPoint = new Point((int)x, (int)y - (int)size.Height - 1); + + // Define BoundingBox options + Pen pen = new Pen(box.BoxColor, 3.2f); + SolidBrush colorBrush = new SolidBrush(box.BoxColor); + + thumbnailGraphic.FillRectangle(colorBrush, (int)x, (int)(y - size.Height - 1), (int)size.Width, (int)size.Height); + + thumbnailGraphic.DrawString(text, drawFont, fontBrush, atPoint); + + // Draw bounding box on image + thumbnailGraphic.DrawRectangle(pen, x, y, width, height); + } + } + } + } +} diff --git a/server/ERNI.PhotoDatabase.Annotator/DataStructures/ImageNetPrediction.cs b/server/ERNI.PhotoDatabase.Annotator/DataStructures/ImageNetPrediction.cs new file mode 100644 index 0000000..d4776ae --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/DataStructures/ImageNetPrediction.cs @@ -0,0 +1,25 @@ +using Microsoft.ML.Data; + +namespace ERNI.PhotoDatabase.Annotator.DataStructures +{ + public class ImageNetPrediction + { + [VectorType(1, 52, 52, 3, 85)] + [ColumnName(Yolov4ModelSettings.Output_1)] + public float[] Output_1; + + [VectorType(1, 26, 26, 3, 85)] + [ColumnName(Yolov4ModelSettings.Output_2)] + public float[] Output_2; + + [VectorType(1, 52, 52, 3, 85)] + [ColumnName(Yolov4ModelSettings.Output_3)] + public float[] Output_3; + + [ColumnName("width")] + public float ImageWidth { get; set; } + + [ColumnName("height")] + public float ImageHeight { get; set; } + } +} diff --git a/server/ERNI.PhotoDatabase.Annotator/DataStructures/InputPicture.cs b/server/ERNI.PhotoDatabase.Annotator/DataStructures/InputPicture.cs new file mode 100644 index 0000000..5ce6aca --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/DataStructures/InputPicture.cs @@ -0,0 +1,19 @@ +using Microsoft.ML.Data; +using Microsoft.ML.Transforms.Image; +using System.Drawing; + +namespace ERNI.PhotoDatabase.Annotator.DataStructures +{ + public class InputPicture + { + [ColumnName("bitmap")] + [ImageType(Yolov4ModelSettings.ImageSettings.imageHeight, Yolov4ModelSettings.ImageSettings.imageWidth)] + public Bitmap Image { get; set; } + + [ColumnName("width")] + public float ImageWidth => Image.Width; + + [ColumnName("height")] + public float ImageHeight => Image.Height; + } +} diff --git a/server/ERNI.PhotoDatabase.Annotator/ERNI.PhotoDatabase.Annotator.csproj b/server/ERNI.PhotoDatabase.Annotator/ERNI.PhotoDatabase.Annotator.csproj new file mode 100644 index 0000000..0749a2b --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/ERNI.PhotoDatabase.Annotator.csproj @@ -0,0 +1,24 @@ + + + + netcoreapp2.2 + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/server/ERNI.PhotoDatabase.Annotator/OnnxModelScorer.cs b/server/ERNI.PhotoDatabase.Annotator/OnnxModelScorer.cs new file mode 100644 index 0000000..1e8b268 --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/OnnxModelScorer.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.ML; + +using ERNI.PhotoDatabase.Annotator.DataStructures; +using System.Drawing; + +namespace ERNI.PhotoDatabase.Annotator +{ + public class OnnxModelScorer + { + private readonly string modelLocation; + private readonly MLContext mlContext; + + public OnnxModelScorer(string modelLocation, MLContext mlContext) + { + this.modelLocation = modelLocation; + this.mlContext = mlContext; + } + + private ITransformer LoadModel(string modelLocation) + { + var data = mlContext.Data.LoadFromEnumerable(new List()); + + var pipeline = mlContext.Transforms.ResizeImages(outputColumnName: Yolov4ModelSettings.Input, + imageWidth: Yolov4ModelSettings.ImageSettings.imageWidth, + imageHeight: Yolov4ModelSettings.ImageSettings.imageHeight, + inputColumnName: "bitmap", + resizing: Microsoft.ML.Transforms.Image.ImageResizingEstimator.ResizingKind.IsoPad) + .Append(mlContext.Transforms.ExtractPixels(outputColumnName: Yolov4ModelSettings.Input, + scaleImage: 1f / 255f, interleavePixelColors: true)) + .Append(mlContext.Transforms.ApplyOnnxModel(modelFile: modelLocation, + outputColumnNames: Yolov4ModelSettings.ModelOutputNames, + inputColumnNames: Yolov4ModelSettings.ModelInputName, + shapeDictionary: Yolov4ModelSettings.Shape)); + + var model = pipeline.Fit(data); + + return model; + } + + private ImageNetPrediction PredictDataUsingModel(Bitmap picture, ITransformer model) + { + var predictionEngine = mlContext.Model.CreatePredictionEngine(model); + var prediction = predictionEngine.Predict(new InputPicture { Image = picture }); + + return prediction; + } + + public ImageNetPrediction Score(Bitmap picture) + { + var model = LoadModel(modelLocation); + + return PredictDataUsingModel(picture, model); + } + } +} diff --git a/server/ERNI.PhotoDatabase.Annotator/PhotoAnnotator.cs b/server/ERNI.PhotoDatabase.Annotator/PhotoAnnotator.cs new file mode 100644 index 0000000..f65b92d --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/PhotoAnnotator.cs @@ -0,0 +1,23 @@ +using System.Drawing; +using System.IO; + +namespace ERNI.PhotoDatabase.Annotator +{ + public class PhotoAnnotator + { + public (string[], byte[]) AnnotatePhoto(byte[] photoData) + { + Bitmap bmp; + string[] tags; + byte[] pictureData; + + using (var ms = new MemoryStream(photoData)) + { + bmp = new Bitmap(ms); + var predictor = new AnnotationPredictor(); + (tags, pictureData) = predictor.MakePrediction(bmp); + } + return (tags, pictureData); + } + } +} diff --git a/server/ERNI.PhotoDatabase.Annotator/Utils/Extensions.cs b/server/ERNI.PhotoDatabase.Annotator/Utils/Extensions.cs new file mode 100644 index 0000000..01fb722 --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/Utils/Extensions.cs @@ -0,0 +1,18 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; + +namespace ERNI.PhotoDatabase.Annotator.Utils +{ + public static class Extensions + { + public static byte[] ToByteArray(this Image image, ImageFormat format) + { + using (MemoryStream ms = new MemoryStream()) + { + image.Save(ms, format); + return ms.ToArray(); + } + } + } +} diff --git a/server/ERNI.PhotoDatabase.Annotator/Utils/FileUtils.cs b/server/ERNI.PhotoDatabase.Annotator/Utils/FileUtils.cs new file mode 100644 index 0000000..56f7edb --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/Utils/FileUtils.cs @@ -0,0 +1,21 @@ +using System.IO; + +namespace ERNI.PhotoDatabase.Annotator.Utils +{ + public class FileUtils + { + public static string AssetsRelativePath = @"../../../../ERNI.PhotoDatabase.Annotator/assets"; + public static string AssetsPath = GetAbsolutePath(AssetsRelativePath); + public static string ModelFilePath = Path.Combine(AssetsPath, "Model", "yolov4.onnx"); + + public static string GetAbsolutePath(string relativePath) + { + FileInfo _dataRoot = new FileInfo(typeof(AnnotationPredictor).Assembly.Location); + string assemblyFolderPath = _dataRoot.Directory.FullName; + + string fullPath = Path.Combine(assemblyFolderPath, relativePath); + + return fullPath; + } + } +} diff --git a/server/ERNI.PhotoDatabase.Annotator/YoloParser/DimensionsBase.cs b/server/ERNI.PhotoDatabase.Annotator/YoloParser/DimensionsBase.cs new file mode 100644 index 0000000..7683dc4 --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/YoloParser/DimensionsBase.cs @@ -0,0 +1,20 @@ +namespace ERNI.PhotoDatabase.Annotator.YoloParser +{ + public class Dimensions + { + public float X1 { get; set; } + public float Y1 { get; set; } + public float X2 { get; set; } + public float Y2 { get; set; } + + public float Width + { + get => X2 - X1; + } + + public float Height + { + get => Y2 - Y1; + } + } +} diff --git a/server/ERNI.PhotoDatabase.Annotator/YoloParser/YoloBoundingBox.cs b/server/ERNI.PhotoDatabase.Annotator/YoloParser/YoloBoundingBox.cs new file mode 100644 index 0000000..074bbc2 --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/YoloParser/YoloBoundingBox.cs @@ -0,0 +1,20 @@ +using System.Drawing; + +namespace ERNI.PhotoDatabase.Annotator.YoloParser +{ + public class YoloBoundingBox + { + public Dimensions Dimensions { get; set; } + + public string Label { get; set; } + + public float Confidence { get; set; } + + public RectangleF Rect + { + get { return new RectangleF(Dimensions.X1, Dimensions.Y1, Dimensions.Width, Dimensions.Height); } + } + + public Color BoxColor { get; set; } + } +} diff --git a/server/ERNI.PhotoDatabase.Annotator/YoloParser/YoloOutputParser.cs b/server/ERNI.PhotoDatabase.Annotator/YoloParser/YoloOutputParser.cs new file mode 100644 index 0000000..6d453e9 --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/YoloParser/YoloOutputParser.cs @@ -0,0 +1,239 @@ +using ERNI.PhotoDatabase.Annotator.DataStructures; +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; + +namespace ERNI.PhotoDatabase.Annotator.YoloParser +{ + public class YoloOutputParser + { + // https://github.com/hunglc007/tensorflow-yolov4-tflite/blob/master/data/anchors/yolov4_anchors.txt + static readonly float[][][] ANCHORS = new float[][][] + { + new float[][] { new float[] { 12, 16 }, new float[] { 19, 36 }, new float[] { 40, 28 } }, + new float[][] { new float[] { 36, 75 }, new float[] { 76, 55 }, new float[] { 72, 146 } }, + new float[][] { new float[] { 142, 110 }, new float[] { 192, 243 }, new float[] { 459, 401 } } + }; + static readonly float[] STRIDES = new float[] { 8, 16, 32 }; + static readonly float[] XYSCALE = new float[] { 1.2f, 1.1f, 1.05f }; + static readonly int[] shapes = new int[] { 52, 26, 13 }; + const int anchorsCount = 3; + + private string[] labels = new string[] { + "person", "bicycle", "car", "motorbike", "aeroplane", "bus", "train", + "truck", "boat", "traffic light", "fire hydrant", "stop sign", + "parking meter", "bench", "bird", "cat", "dog", "horse", "sheep", + "cow", "elephant", "bear", "zebra", "giraffe", "backpack", "umbrella", + "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard", + "sports ball", "kite", "baseball bat", "baseball glove", "skateboard", + "surfboard", "tennis racket", "bottle", "wine glass", "cup", "fork", + "knife", "spoon", "bowl", "banana", "apple", "sandwich", "orange", + "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair", + "sofa", "pottedplant", "bed", "diningtable", "toilet", "tvmonitor", + "laptop", "mouse", "remote", "keyboard", "cell phone", "microwave", + "oven", "toaster", "sink", "refrigerator", "book", "clock", "vase", + "scissors", "teddy bear", "hair drier", "toothbrush" }; + + private static Color[] classColors = new Color[] + { + Color.Khaki, + Color.Fuchsia, + Color.Silver, + Color.RoyalBlue, + Color.Green, + Color.DarkOrange, + Color.Purple, + Color.Gold, + Color.Red, + Color.Aquamarine, + Color.Lime, + Color.AliceBlue, + Color.Sienna, + Color.Orchid, + Color.Tan, + Color.LightPink, + Color.Yellow, + Color.HotPink, + Color.OliveDrab, + Color.SandyBrown, + Color.DarkTurquoise + }; + + private Dimensions ExtractBoundingBoxDimensions(float[] offsetModelOutput, int row, int column, float xyScale, float stride, float[] anchor) + { + var rawDx = offsetModelOutput[0]; + var rawDy = offsetModelOutput[1]; + var rawDw = offsetModelOutput[2]; + var rawDh = offsetModelOutput[3]; + + float predX = ((Sigmoid(rawDx) * xyScale) - 0.5f * (xyScale - 1) + row) * stride; + float predY = ((Sigmoid(rawDy) * xyScale) - 0.5f * (xyScale - 1) + column) * stride; + float predW = (float)Math.Exp(rawDw) * anchor[0]; + float predH = (float)Math.Exp(rawDh) * anchor[1]; + + // postprocess_boxes + // (1) (x, y, w, h) --> (xmin, ymin, xmax, ymax) + float predX1 = predX - predW * 0.5f; + float predY1 = predY - predH * 0.5f; + float predX2 = predX + predW * 0.5f; + float predY2 = predY + predH * 0.5f; + + // (2) (xmin, ymin, xmax, ymax) -> (xmin_org, ymin_org, xmax_org, ymax_org) + float org_h = Yolov4ModelSettings.ImageSettings.imageHeight; + float org_w = Yolov4ModelSettings.ImageSettings.imageWidth; + + float inputSize = 416f; + float resizeRatio = Math.Min(inputSize / org_w, inputSize / org_h); + float dw = (inputSize - resizeRatio * org_w) / 2f; + float dh = (inputSize - resizeRatio * org_h) / 2f; + + var orgX1 = 1f * (predX1 - dw) / resizeRatio; // left + var orgX2 = 1f * (predX2 - dw) / resizeRatio; // right + var orgY1 = 1f * (predY1 - dh) / resizeRatio; // top + var orgY2 = 1f * (predY2 - dh) / resizeRatio; // bottom + + // (3) clip some boxes that are out of range + orgX1 = Math.Max(orgX1, 0); + orgY1 = Math.Max(orgY1, 0); + orgX2 = Math.Min(orgX2, org_w - 1); + orgY2 = Math.Min(orgY2, org_h - 1); + + return new Dimensions + { + X1 = orgX1, + X2 = orgX2, + Y1 = orgY1, + Y2 = orgY2 + }; + } + + private float IntersectionOverUnion(RectangleF boundingBoxA, RectangleF boundingBoxB) + { + var areaA = boundingBoxA.Width * boundingBoxA.Height; + var areaB = boundingBoxB.Width * boundingBoxB.Height; + + if (areaA <= 0 || areaB <= 0) + return 0; + + var minX = Math.Max(boundingBoxA.Left, boundingBoxB.Left); + var minY = Math.Max(boundingBoxA.Top, boundingBoxB.Top); + var maxX = Math.Min(boundingBoxA.Right, boundingBoxB.Right); + var maxY = Math.Min(boundingBoxA.Bottom, boundingBoxB.Bottom); + + var intersectionArea = Math.Max(maxY - minY, 0) * Math.Max(maxX - minX, 0); + + return intersectionArea / (areaA + areaB - intersectionArea); + } + + public IList ParseOutputs(ImageNetPrediction prediction, float threshold = .3F) + { + // ported from https://github.com/onnx/models/tree/master/vision/object_detection_segmentation/yolov4#postprocessing-steps + List postProcesssedResults = new List(); + int classesCount = labels.Length; + // YOLOv4 outputs from 3 different levels (different object scales) + var results = new[] { prediction.Output_1, prediction.Output_2, prediction.Output_3 }; + + var boxes = new List(); + for (int i = 0; i < results.Length; i++) + { + var result = results[i]; + var outputSize = shapes[i]; + + for (int boxY = 0; boxY < outputSize; boxY++) + { + for (int boxX = 0; boxX < outputSize; boxX++) + { + for (int a = 0; a < anchorsCount; a++) + { + var offset = (boxY * outputSize * (classesCount + 5) * anchorsCount) + + (boxX * (classesCount + 5) * anchorsCount) + + a * (classesCount + 5); + var offsetPredictions = result.Skip(offset).Take(classesCount + 5).ToArray(); + + var confidence = offsetPredictions[4]; + var predClasses = offsetPredictions.Skip(5).ToArray(); + var boundingBox = ExtractBoundingBoxDimensions(offsetPredictions, boxX, boxY, + XYSCALE[i], STRIDES[i], + ANCHORS[i][a]); + if (boundingBox.X1 > boundingBox.X2 + || boundingBox.Y1 > boundingBox.Y2) + { + continue; + } + + var scores = predClasses.Select(p => p * confidence).ToList(); + float topScore = scores.Max(); + if (topScore < threshold) + continue; + + boxes.Add(new YoloBoundingBox() + { + Dimensions = boundingBox, + Confidence = topScore, + Label = labels[scores.IndexOf(topScore)], + BoxColor = classColors[scores.IndexOf(topScore) % (classColors.Length - 1)] + }); + } + } + } + } + + return boxes; + } + + private float Sigmoid(float value) + { + var k = (float)Math.Exp(value); + return k / (1.0f + k); + } + + public IList NonMaxSuppression(IList boxes, int limit, float threshold) + { + var activeCount = boxes.Count; + var isActiveBoxes = new bool[boxes.Count]; + + for (int i = 0; i < isActiveBoxes.Length; i++) + isActiveBoxes[i] = true; + + var sortedBoxes = boxes.Select((b, i) => new { Box = b, Index = i }) + .OrderByDescending(b => b.Box.Confidence) + .ToList(); + + var results = new List(); + + for (int i = 0; i < boxes.Count; i++) + { + if (isActiveBoxes[i]) + { + var boxA = sortedBoxes[i].Box; + results.Add(boxA); + + if (results.Count >= limit) + break; + + for (var j = i + 1; j < boxes.Count; j++) + { + if (isActiveBoxes[j]) + { + var boxB = sortedBoxes[j].Box; + + if (IntersectionOverUnion(boxA.Rect, boxB.Rect) > threshold) + { + isActiveBoxes[j] = false; + activeCount--; + + if (activeCount <= 0) + break; + } + } + } + if (activeCount <= 0) + break; + } + } + + return results; + } + } +} diff --git a/server/ERNI.PhotoDatabase.Annotator/Yolov4ModelSettings.cs b/server/ERNI.PhotoDatabase.Annotator/Yolov4ModelSettings.cs new file mode 100644 index 0000000..1b59a92 --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/Yolov4ModelSettings.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace ERNI.PhotoDatabase.Annotator +{ + public class Yolov4ModelSettings + { + public const string Input = "input_1:0"; + + public const string Output_1 = "Identity:0"; + public const string Output_2 = "Identity_1:0"; + public const string Output_3 = "Identity_2:0"; + + // input tensor name + public static readonly string[] ModelInputName = new[] { Input }; + + // output tensors name + public static readonly string[] ModelOutputNames = new[] + { + Output_1, + Output_2, + Output_3 + }; + + public static readonly Dictionary Shape = new Dictionary() + { + { Input, new[] { 1, 416, 416, 3 } }, + { Output_1, new[] { 1, 52, 52, 3, 85 } }, + { Output_2, new[] { 1, 26, 26, 3, 85 } }, + { Output_3, new[] { 1, 13, 13, 3, 85 } }, + }; + + public struct ImageSettings + { + public const int imageHeight = 416; + public const int imageWidth = 416; + } + } +} diff --git a/server/ERNI.PhotoDatabase.Annotator/assets/Model/yolov4.onnx b/server/ERNI.PhotoDatabase.Annotator/assets/Model/yolov4.onnx new file mode 100644 index 0000000..ed9bf99 --- /dev/null +++ b/server/ERNI.PhotoDatabase.Annotator/assets/Model/yolov4.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1881fe9c506c970d7866cb4bfc33bda791ce46951a3c39c45ace2ff2b9daf369 +size 257470589 diff --git a/server/ERNI.PhotoDatabase.DataAccess/DomainModel/Photo.cs b/server/ERNI.PhotoDatabase.DataAccess/DomainModel/Photo.cs index 7f23a21..4b8cd76 100644 --- a/server/ERNI.PhotoDatabase.DataAccess/DomainModel/Photo.cs +++ b/server/ERNI.PhotoDatabase.DataAccess/DomainModel/Photo.cs @@ -12,6 +12,8 @@ public class Photo public Guid FullSizeImageId { get; set; } public Guid ThumbnailImageId { get; set; } + + public Guid TaggedThumbnailImageId { get; set; } public List PhotoTags { get; set; } diff --git a/server/ERNI.PhotoDatabase.DataAccess/EntityConfiguration/PhotoConfiguration.cs b/server/ERNI.PhotoDatabase.DataAccess/EntityConfiguration/PhotoConfiguration.cs index 10cbcfb..256b578 100644 --- a/server/ERNI.PhotoDatabase.DataAccess/EntityConfiguration/PhotoConfiguration.cs +++ b/server/ERNI.PhotoDatabase.DataAccess/EntityConfiguration/PhotoConfiguration.cs @@ -11,6 +11,7 @@ public override void Configure(EntityTypeBuilder builder) builder.Property(p => p.Name).HasMaxLength(60).IsRequired(); builder.Property(p => p.FullSizeImageId).IsRequired(); builder.Property(p => p.ThumbnailImageId).IsRequired(); + builder.Property(p => p.TaggedThumbnailImageId).IsRequired(); builder.HasIndex(p => p.FullSizeImageId).IsUnique(); builder.HasIndex(p => p.ThumbnailImageId).IsUnique(); diff --git a/server/ERNI.PhotoDatabase.DataAccess/Migrations/20201202184054_AddTaggedThumbnail.Designer.cs b/server/ERNI.PhotoDatabase.DataAccess/Migrations/20201202184054_AddTaggedThumbnail.Designer.cs new file mode 100644 index 0000000..f9149e1 --- /dev/null +++ b/server/ERNI.PhotoDatabase.DataAccess/Migrations/20201202184054_AddTaggedThumbnail.Designer.cs @@ -0,0 +1,129 @@ +// +using System; +using ERNI.PhotoDatabase.DataAccess; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ERNI.PhotoDatabase.DataAccess.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20201202184054_AddTaggedThumbnail")] + partial class AddTaggedThumbnail + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("ERNI.PhotoDatabase.DataAccess.DomainModel.Photo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("FullSizeImageId"); + + b.Property("Height"); + + b.Property("Mime"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60); + + b.Property("TaggedThumbnailImageId"); + + b.Property("ThumbnailImageId"); + + b.Property("Width"); + + b.HasKey("Id"); + + b.HasIndex("FullSizeImageId") + .IsUnique(); + + b.HasIndex("ThumbnailImageId") + .IsUnique(); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("ERNI.PhotoDatabase.DataAccess.DomainModel.PhotoTag", b => + { + b.Property("PhotoId"); + + b.Property("TagId"); + + b.HasKey("PhotoId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("PhotoTag"); + }); + + modelBuilder.Entity("ERNI.PhotoDatabase.DataAccess.DomainModel.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Text") + .IsRequired(); + + b.HasKey("Id"); + + b.HasIndex("Text") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("ERNI.PhotoDatabase.DataAccess.DomainModel.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("CanUpload"); + + b.Property("FirstName"); + + b.Property("IsAdmin"); + + b.Property("LastName"); + + b.Property("UniqueIdentifier"); + + b.Property("Username"); + + b.HasKey("Id"); + + b.HasIndex("UniqueIdentifier") + .IsUnique() + .HasFilter("[UniqueIdentifier] IS NOT NULL"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("ERNI.PhotoDatabase.DataAccess.DomainModel.PhotoTag", b => + { + b.HasOne("ERNI.PhotoDatabase.DataAccess.DomainModel.Photo", "Photo") + .WithMany("PhotoTags") + .HasForeignKey("PhotoId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ERNI.PhotoDatabase.DataAccess.DomainModel.Tag", "Tag") + .WithMany("PhotoTags") + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/ERNI.PhotoDatabase.DataAccess/Migrations/20201202184054_AddTaggedThumbnail.cs b/server/ERNI.PhotoDatabase.DataAccess/Migrations/20201202184054_AddTaggedThumbnail.cs new file mode 100644 index 0000000..6046e10 --- /dev/null +++ b/server/ERNI.PhotoDatabase.DataAccess/Migrations/20201202184054_AddTaggedThumbnail.cs @@ -0,0 +1,24 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ERNI.PhotoDatabase.DataAccess.Migrations +{ + public partial class AddTaggedThumbnail : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TaggedThumbnailImageId", + table: "Photos", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TaggedThumbnailImageId", + table: "Photos"); + } + } +} diff --git a/server/ERNI.PhotoDatabase.DataAccess/Migrations/DatabaseContextModelSnapshot.cs b/server/ERNI.PhotoDatabase.DataAccess/Migrations/DatabaseContextModelSnapshot.cs index c855f1b..4b26fdb 100644 --- a/server/ERNI.PhotoDatabase.DataAccess/Migrations/DatabaseContextModelSnapshot.cs +++ b/server/ERNI.PhotoDatabase.DataAccess/Migrations/DatabaseContextModelSnapshot.cs @@ -1,12 +1,10 @@ // +using System; using ERNI.PhotoDatabase.DataAccess; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.EntityFrameworkCore.Storage.Internal; -using System; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace ERNI.PhotoDatabase.DataAccess.Migrations { @@ -17,13 +15,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "2.0.1-rtm-125") + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079") + .HasAnnotation("Relational:MaxIdentifierLength", 128) .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); modelBuilder.Entity("ERNI.PhotoDatabase.DataAccess.DomainModel.Photo", b => { b.Property("Id") - .ValueGeneratedOnAdd(); + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); b.Property("FullSizeImageId"); @@ -35,6 +35,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasMaxLength(60); + b.Property("TaggedThumbnailImageId"); + b.Property("ThumbnailImageId"); b.Property("Width"); @@ -66,7 +68,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("ERNI.PhotoDatabase.DataAccess.DomainModel.Tag", b => { b.Property("Id") - .ValueGeneratedOnAdd(); + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); b.Property("Text") .IsRequired(); @@ -82,7 +85,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("ERNI.PhotoDatabase.DataAccess.DomainModel.User", b => { b.Property("Id") - .ValueGeneratedOnAdd(); + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); b.Property("CanUpload"); diff --git a/server/ERNI.PhotoDatabase.DataAccess/Repository/IPhotoRepository.cs b/server/ERNI.PhotoDatabase.DataAccess/Repository/IPhotoRepository.cs index 82dcf4e..1e7acf1 100644 --- a/server/ERNI.PhotoDatabase.DataAccess/Repository/IPhotoRepository.cs +++ b/server/ERNI.PhotoDatabase.DataAccess/Repository/IPhotoRepository.cs @@ -18,7 +18,7 @@ public interface IPhotoRepository Task GetPhoto(int id, CancellationToken cancellationToken); - Photo StorePhoto(string fileName, Guid fullSizeBlobId, Guid thumbnailBlobId, string mime, int width, int height); + Photo StorePhoto(string fileName, Guid fullSizeBlobId, Guid thumbnailBlobId, Guid taggedThumbnailBlobId, string mime, int width, int height); void DeletePhoto(Photo photo); diff --git a/server/ERNI.PhotoDatabase.DataAccess/Repository/PhotoRepository.cs b/server/ERNI.PhotoDatabase.DataAccess/Repository/PhotoRepository.cs index 7b34932..ac28788 100644 --- a/server/ERNI.PhotoDatabase.DataAccess/Repository/PhotoRepository.cs +++ b/server/ERNI.PhotoDatabase.DataAccess/Repository/PhotoRepository.cs @@ -51,13 +51,14 @@ public Task GetPhoto(int id, CancellationToken cancellationToken) .SingleOrDefaultAsync(_ => _.Id == id, cancellationToken); } - public Photo StorePhoto(string fileName, Guid fullSizeBlobId, Guid thumbnailBlobId, string mime, int width, int height) + public Photo StorePhoto(string fileName, Guid fullSizeBlobId, Guid thumbnailBlobId, Guid taggedThumbnailBlobId, string mime, int width, int height) { var photo = new Photo { Name = fileName, FullSizeImageId = fullSizeBlobId, ThumbnailImageId = thumbnailBlobId, + TaggedThumbnailImageId = taggedThumbnailBlobId, Mime = mime, Width = width, Height = height diff --git a/server/ERNI.PhotoDatabase.Server.sln b/server/ERNI.PhotoDatabase.Server.sln index 9f0b1f7..1c07535 100644 --- a/server/ERNI.PhotoDatabase.Server.sln +++ b/server/ERNI.PhotoDatabase.Server.sln @@ -1,13 +1,15 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27004.2005 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30523.141 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ERNI.PhotoDatabase.Server", "ERNI.PhotoDatabase.Server\ERNI.PhotoDatabase.Server.csproj", "{7E3C185E-D3B4-43FD-ABFD-ACD247FBE304}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ERNI.PhotoDatabase.DataAccess", "ERNI.PhotoDatabase.DataAccess\ERNI.PhotoDatabase.DataAccess.csproj", "{126A9387-0968-49D3-B16F-0EB050138806}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERNI.PhotoDatabase.Server.Test", "ERNI.PhotoDatabase.Server.Test\ERNI.PhotoDatabase.Server.Test.csproj", "{3C416E86-C888-4CF3-B569-3B22779881B5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ERNI.PhotoDatabase.Server.Test", "ERNI.PhotoDatabase.Server.Test\ERNI.PhotoDatabase.Server.Test.csproj", "{3C416E86-C888-4CF3-B569-3B22779881B5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERNI.PhotoDatabase.Annotator", "ERNI.PhotoDatabase.Annotator\ERNI.PhotoDatabase.Annotator.csproj", "{BF259CF3-4007-4847-9EEE-E8C90283EA86}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -27,6 +29,10 @@ Global {3C416E86-C888-4CF3-B569-3B22779881B5}.Debug|Any CPU.Build.0 = Debug|Any CPU {3C416E86-C888-4CF3-B569-3B22779881B5}.Release|Any CPU.ActiveCfg = Release|Any CPU {3C416E86-C888-4CF3-B569-3B22779881B5}.Release|Any CPU.Build.0 = Release|Any CPU + {BF259CF3-4007-4847-9EEE-E8C90283EA86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF259CF3-4007-4847-9EEE-E8C90283EA86}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF259CF3-4007-4847-9EEE-E8C90283EA86}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF259CF3-4007-4847-9EEE-E8C90283EA86}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/server/ERNI.PhotoDatabase.Server/Controllers/DetailController.cs b/server/ERNI.PhotoDatabase.Server/Controllers/DetailController.cs index 816482c..30b264e 100644 --- a/server/ERNI.PhotoDatabase.Server/Controllers/DetailController.cs +++ b/server/ERNI.PhotoDatabase.Server/Controllers/DetailController.cs @@ -4,6 +4,7 @@ using ERNI.PhotoDatabase.DataAccess.Repository; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; +using System; namespace ERNI.PhotoDatabase.Server.Controllers { @@ -34,6 +35,8 @@ public async Task Index(int id, CancellationToken cancellationTok Width = photo.Width, Height = photo.Height, ThumbnailUrl = Url.Action("Thumbnail", "Photo", new { id = photo.Id }), + HasAnnotationLayer = photo.TaggedThumbnailImageId != Guid.Empty, + TaggedThumbnailUrl = Url.Action("Thumbnail", "Photo", new { id = photo.Id, withOverlay = true }), DetailUrl = Url.Action("Index", "Detail", new { id = photo.Id }) }); } diff --git a/server/ERNI.PhotoDatabase.Server/Controllers/PhotoController.cs b/server/ERNI.PhotoDatabase.Server/Controllers/PhotoController.cs index a78905b..70c196e 100644 --- a/server/ERNI.PhotoDatabase.Server/Controllers/PhotoController.cs +++ b/server/ERNI.PhotoDatabase.Server/Controllers/PhotoController.cs @@ -10,6 +10,7 @@ using ERNI.PhotoDatabase.DataAccess.UnitOfWork; using ERNI.PhotoDatabase.Server.Configuration; using ERNI.PhotoDatabase.Server.Utils.Image; +using ERNI.PhotoDatabase.Annotator; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using Microsoft.AspNetCore.Authorization; @@ -22,28 +23,42 @@ namespace ERNI.PhotoDatabase.Server.Controllers public class PhotoController : Controller { private readonly Lazy repository; + private readonly Lazy tagRepository; private readonly Lazy imageStore; private readonly Lazy unitOfWork; private readonly Lazy imageTools; + private readonly Lazy photoAnnotator; private readonly IOptions settings; - public PhotoController(Lazy repository, Lazy imageStore, Lazy unitOfWork, Lazy imageTools, IOptions settings) + public PhotoController(Lazy repository, + Lazy tagRepository, + Lazy imageStore, + Lazy unitOfWork, + Lazy imageTools, + IOptions settings, + Lazy photoAnnotator) { this.repository = repository; + this.tagRepository = tagRepository; this.imageStore = imageStore; this.unitOfWork = unitOfWork; this.imageTools = imageTools; this.settings = settings; + this.photoAnnotator = photoAnnotator; } private IPhotoRepository Repository => repository.Value; + private ITagRepository TagRepository => tagRepository.Value; + private ImageStore ImageStore => imageStore.Value; private IUnitOfWork UnitOfWork => unitOfWork.Value; private IImageManipulation ImageTools => imageTools.Value; + private PhotoAnnotator PhotoAnnotator => photoAnnotator.Value; + [HttpGet] public async Task Get(CancellationToken cancellationToken) { @@ -82,7 +97,7 @@ public async Task Download(int id, int? size, CancellationToken c } [HttpGet("{id}/thumbnail")] - public async Task Thumbnail(int id, CancellationToken cancellationToken) + public async Task Thumbnail(int id, CancellationToken cancellationToken, bool withOverlay = false) { var photo = await this.Repository.GetPhoto(id, cancellationToken); @@ -90,9 +105,9 @@ public async Task Thumbnail(int id, CancellationToken cancellatio { return NotFound(); } - - var image = await this.ImageStore.GetImageBlobAsync(photo.ThumbnailImageId, cancellationToken); - + var image = withOverlay + ? await ImageStore.GetImageBlobAsync(photo.TaggedThumbnailImageId, cancellationToken) + : await ImageStore.GetImageBlobAsync(photo.ThumbnailImageId, cancellationToken); return File(image.Content, "image/jpeg"); } @@ -107,6 +122,7 @@ public async Task GetImagesByTag(string tag, CancellationToken ca // GET api/values /// /// Uploads the specified files. + /// Runs automatic tagging for photo. /// /// The files. /// @@ -114,7 +130,7 @@ public async Task GetImagesByTag(string tag, CancellationToken ca [Authorize(Roles = "uploader")] public async Task Upload(List files, CancellationToken cancellationToken) { - var photos = new List(); + Dictionary taggedPhotos = new Dictionary(); foreach (var formFile in files) { @@ -126,20 +142,38 @@ public async Task Upload(List files, CancellationToken var thumbnailData = ImageTools.ResizeTo(data, this.settings.Value.Thumbnail); var (width, height) = ImageTools.GetSize(data); + (string[] tags, byte[] taggedPhotoData) = PhotoAnnotator.AnnotatePhoto(thumbnailData); + var fullSizeBlob = new ImageBlob {Content = data, Id = Guid.NewGuid()}; var thumbnailBlob = new ImageBlob {Content = thumbnailData, Id = Guid.NewGuid()}; + var taggedThumbnailBlob = new ImageBlob { Content = taggedPhotoData, Id = Guid.NewGuid() }; await ImageStore.SaveImageBlobAsync(fullSizeBlob, cancellationToken); await ImageStore.SaveImageBlobAsync(thumbnailBlob, cancellationToken); + await ImageStore.SaveImageBlobAsync(taggedThumbnailBlob, cancellationToken); - var photo = Repository.StorePhoto(formFile.FileName, fullSizeBlob.Id, thumbnailBlob.Id, formFile.ContentType, width, height); + var photo = Repository.StorePhoto(formFile.FileName, fullSizeBlob.Id, thumbnailBlob.Id, taggedThumbnailBlob.Id, formFile.ContentType, width, height); - photos.Add(photo); + taggedPhotos.Add(photo, tags); } } - await this.UnitOfWork.SaveChanges(cancellationToken); + await UnitOfWork.SaveChanges(cancellationToken); + await SaveTags(taggedPhotos, cancellationToken); + + return RedirectToAction("Index", "Tag", new {fileIds = taggedPhotos.Select(_ => _.Key.Id).ToList()}); + } - return RedirectToAction("Index", "Tag", new {fileIds = photos.Select(_ => _.Id).ToList()}); + private async Task SaveTags(Dictionary taggedPhotos, CancellationToken cancellationToken) + { + using (var t = await UnitOfWork.BeginTransaction(cancellationToken)) + { + foreach (var tag in taggedPhotos) + { + await TagRepository.SetTagsForImage(tag.Key.Id, tag.Value, cancellationToken); + await UnitOfWork.SaveChanges(cancellationToken); + } + t.Commit(); + } } // DELETE api/values @@ -161,6 +195,7 @@ public async Task Delete(int id, CancellationToken cancellationTo await ImageStore.DeleteImageBlobAsync(photo.FullSizeImageId, cancellationToken); await ImageStore.DeleteImageBlobAsync(photo.ThumbnailImageId, cancellationToken); + await ImageStore.DeleteImageBlobAsync(photo.TaggedThumbnailImageId, cancellationToken); Repository.DeletePhoto(photo); diff --git a/server/ERNI.PhotoDatabase.Server/ERNI.PhotoDatabase.Server.csproj b/server/ERNI.PhotoDatabase.Server/ERNI.PhotoDatabase.Server.csproj index df3f5a9..6cfb083 100644 --- a/server/ERNI.PhotoDatabase.Server/ERNI.PhotoDatabase.Server.csproj +++ b/server/ERNI.PhotoDatabase.Server/ERNI.PhotoDatabase.Server.csproj @@ -40,6 +40,7 @@ + diff --git a/server/ERNI.PhotoDatabase.Server/MainModule.cs b/server/ERNI.PhotoDatabase.Server/MainModule.cs index d13ce40..e65644e 100644 --- a/server/ERNI.PhotoDatabase.Server/MainModule.cs +++ b/server/ERNI.PhotoDatabase.Server/MainModule.cs @@ -1,4 +1,5 @@ using Autofac; +using ERNI.PhotoDatabase.Annotator; using ERNI.PhotoDatabase.DataAccess; using ERNI.PhotoDatabase.Server.Utils.Image; @@ -11,6 +12,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterModule(); builder.RegisterType().AsImplementedInterfaces(); + + builder.RegisterType().InstancePerLifetimeScope(); } } } diff --git a/server/ERNI.PhotoDatabase.Server/Model/PhotoModel.cs b/server/ERNI.PhotoDatabase.Server/Model/PhotoModel.cs index adeedd6..5b10698 100644 --- a/server/ERNI.PhotoDatabase.Server/Model/PhotoModel.cs +++ b/server/ERNI.PhotoDatabase.Server/Model/PhotoModel.cs @@ -15,5 +15,9 @@ public class PhotoModel public string DetailUrl { get; set; } public string ThumbnailUrl { get; set; } + + public bool HasAnnotationLayer { get; set; } + + public string TaggedThumbnailUrl { get; set; } } } \ No newline at end of file diff --git a/server/ERNI.PhotoDatabase.Server/Properties/launchSettings.json b/server/ERNI.PhotoDatabase.Server/Properties/launchSettings.json index a0b1fcb..136bb0a 100644 --- a/server/ERNI.PhotoDatabase.Server/Properties/launchSettings.json +++ b/server/ERNI.PhotoDatabase.Server/Properties/launchSettings.json @@ -26,4 +26,4 @@ "applicationUrl": "http://localhost:60585/" } } -} +} \ No newline at end of file diff --git a/server/ERNI.PhotoDatabase.Server/Views/Detail/Index.cshtml b/server/ERNI.PhotoDatabase.Server/Views/Detail/Index.cshtml index 8e8c4be..03d9db9 100644 --- a/server/ERNI.PhotoDatabase.Server/Views/Detail/Index.cshtml +++ b/server/ERNI.PhotoDatabase.Server/Views/Detail/Index.cshtml @@ -3,16 +3,17 @@ @using ERNI.PhotoDatabase.Server.Utils @model ERNI.PhotoDatabase.Server.Controllers.PhotoModel -@{ +@{ ViewData["Title"] = $"Photo - {Model.Name}"; - Layout = "~/Views/Shared/_Layout.cshtml"; + Layout = "~/Views/Shared/_Layout.cshtml"; } @inject IOptions ImageSettings
- @Model.Name + @Model.Name +

@Model.Name

@@ -31,9 +32,20 @@ Edit tags } + + + @if (Model.HasAnnotationLayer) + { +
+ +
+ }
+
Downloads
+@section Scripts +{ + +} + diff --git a/server/ERNI.PhotoDatabase.Server/Views/Shared/EditorTemplates/ImageDescription.cshtml b/server/ERNI.PhotoDatabase.Server/Views/Shared/EditorTemplates/ImageDescription.cshtml index ed2a867..71ff7f1 100644 --- a/server/ERNI.PhotoDatabase.Server/Views/Shared/EditorTemplates/ImageDescription.cshtml +++ b/server/ERNI.PhotoDatabase.Server/Views/Shared/EditorTemplates/ImageDescription.cshtml @@ -1,10 +1,12 @@ @model ERNI.PhotoDatabase.Server.Model.ImageDescription +
- +
- @Model.Name + @Model.Name +
@@ -13,7 +15,7 @@
- +
\ No newline at end of file diff --git a/server/ERNI.PhotoDatabase.Server/Views/Tag/Index.cshtml b/server/ERNI.PhotoDatabase.Server/Views/Tag/Index.cshtml index 0603627..bd93633 100644 --- a/server/ERNI.PhotoDatabase.Server/Views/Tag/Index.cshtml +++ b/server/ERNI.PhotoDatabase.Server/Views/Tag/Index.cshtml @@ -1,11 +1,9 @@ @using ERNI.PhotoDatabase.Server.Controllers @model ERNI.PhotoDatabase.Server.Model.UploadResult -@{ - Layout = "_Layout"; +@{ Layout = "_Layout"; - var returnAction = Model.Images.Length == 1 ? nameof(TagController.SaveOne) : nameof(TagController.Save); -} + var returnAction = Model.Images.Length == 1 ? nameof(TagController.SaveOne) : nameof(TagController.Save); }
@@ -14,6 +12,10 @@
+
+ Toggle annotation +
+ @for (var i = 0; i < Model.Images.Length; ++i) { @@ -25,6 +27,10 @@
+
+ Toggle annotation +
+ @@ -41,4 +47,5 @@ initializeTagging('@Url.Action("GetTags")'); + } \ No newline at end of file diff --git a/server/ERNI.PhotoDatabase.Server/wwwroot/js/toggleOverlay.js b/server/ERNI.PhotoDatabase.Server/wwwroot/js/toggleOverlay.js new file mode 100644 index 0000000..6c21d4f --- /dev/null +++ b/server/ERNI.PhotoDatabase.Server/wwwroot/js/toggleOverlay.js @@ -0,0 +1,9 @@ +$('.image-toggler').click(function () { + if ($(".image").is(":visible")) { + $(".image").hide(); + $(".image-tag").show(); + } else { + $(".image").show(); + $(".image-tag").hide(); + } +});