UX Overhaul Part 1 (#3047)
Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>
This commit is contained in:
parent
5934d516f3
commit
ff79710ac6
324 changed files with 11589 additions and 4598 deletions
|
|
@ -1,10 +1,14 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.DTOs;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Extensions;
|
||||
using EasyCaching.Core;
|
||||
using Flurl;
|
||||
|
|
@ -13,6 +17,9 @@ using HtmlAgilityPack;
|
|||
using Kavita.Common;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetVips;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using SixLabors.ImageSharp.Processing.Processors.Quantization;
|
||||
using Image = NetVips.Image;
|
||||
|
||||
namespace API.Services;
|
||||
|
|
@ -60,6 +67,7 @@ public interface IImageService
|
|||
Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
|
||||
Task<bool> IsImage(string filePath);
|
||||
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
|
||||
void UpdateColorScape(IHasCoverImage entity);
|
||||
}
|
||||
|
||||
public class ImageService : IImageService
|
||||
|
|
@ -73,6 +81,9 @@ public class ImageService : IImageService
|
|||
public const string CollectionTagCoverImageRegex = @"tag\d+";
|
||||
public const string ReadingListCoverImageRegex = @"readinglist\d+";
|
||||
|
||||
private const double WhiteThreshold = 0.90; // Colors with lightness above this are considered too close to white
|
||||
private const double BlackThreshold = 0.25; // Colors with lightness below this are considered too close to black
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Width of the Thumbnail generation
|
||||
|
|
@ -415,13 +426,266 @@ public class ImageService : IImageService
|
|||
|
||||
_logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain);
|
||||
return filename;
|
||||
}catch (Exception ex)
|
||||
} catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading favicon.png for {Domain}", domain);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static (Vector3?, Vector3?) GetPrimarySecondaryColors(string imagePath)
|
||||
{
|
||||
using var image = Image.NewFromFile(imagePath);
|
||||
// Resize the image to speed up processing
|
||||
var resizedImage = image.Resize(0.1);
|
||||
|
||||
|
||||
// Convert image to RGB array
|
||||
var pixels = resizedImage.WriteToMemory().ToArray();
|
||||
|
||||
// Convert to list of Vector3 (RGB)
|
||||
var rgbPixels = new List<Vector3>();
|
||||
for (var i = 0; i < pixels.Length - 2; i += 3)
|
||||
{
|
||||
rgbPixels.Add(new Vector3(pixels[i], pixels[i + 1], pixels[i + 2]));
|
||||
}
|
||||
|
||||
// Perform k-means clustering
|
||||
var clusters = KMeansClustering(rgbPixels, 4);
|
||||
|
||||
var sorted = SortByVibrancy(clusters);
|
||||
|
||||
if (sorted.Count >= 2)
|
||||
{
|
||||
return (sorted[0], sorted[1]);
|
||||
}
|
||||
if (sorted.Count == 1)
|
||||
{
|
||||
return (sorted[0], null);
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
private static (Vector3?, Vector3?) GetPrimaryColorSharp(string imagePath)
|
||||
{
|
||||
using var image = SixLabors.ImageSharp.Image.Load<Rgb24>(imagePath);
|
||||
|
||||
image.Mutate(
|
||||
x => x
|
||||
// Scale the image down preserving the aspect ratio. This will speed up quantization.
|
||||
// We use nearest neighbor as it will be the fastest approach.
|
||||
.Resize(new ResizeOptions() { Sampler = KnownResamplers.NearestNeighbor, Size = new SixLabors.ImageSharp.Size(100, 0) })
|
||||
|
||||
// Reduce the color palette to 1 color without dithering.
|
||||
.Quantize(new OctreeQuantizer(new QuantizerOptions { MaxColors = 4 })));
|
||||
|
||||
Rgb24 dominantColor = image[0, 0];
|
||||
|
||||
// This will give you a dominant color in HEX format i.e #5E35B1FF
|
||||
return (new Vector3(dominantColor.R, dominantColor.G, dominantColor.B), new Vector3(dominantColor.R, dominantColor.G, dominantColor.B));
|
||||
}
|
||||
|
||||
private static Image PreProcessImage(Image image)
|
||||
{
|
||||
// Create a mask for white and black pixels
|
||||
var whiteMask = image.Colourspace(Enums.Interpretation.Lab)[0] > (WhiteThreshold * 100);
|
||||
var blackMask = image.Colourspace(Enums.Interpretation.Lab)[0] < (BlackThreshold * 100);
|
||||
|
||||
// Create a replacement color (e.g., medium gray)
|
||||
var replacementColor = new[] { 128.0, 128.0, 128.0 };
|
||||
|
||||
// Apply the masks to replace white and black pixels
|
||||
var processedImage = image.Copy();
|
||||
processedImage = processedImage.Ifthenelse(whiteMask, replacementColor);
|
||||
processedImage = processedImage.Ifthenelse(blackMask, replacementColor);
|
||||
|
||||
return processedImage;
|
||||
}
|
||||
|
||||
private static Dictionary<Vector3, int> GenerateColorHistogram(Image image)
|
||||
{
|
||||
var pixels = image.WriteToMemory().ToArray();
|
||||
var histogram = new Dictionary<Vector3, int>();
|
||||
|
||||
for (var i = 0; i < pixels.Length; i += 3)
|
||||
{
|
||||
var color = new Vector3(pixels[i], pixels[i + 1], pixels[i + 2]);
|
||||
if (!histogram.TryAdd(color, 1))
|
||||
{
|
||||
histogram[color]++;
|
||||
}
|
||||
}
|
||||
|
||||
return histogram;
|
||||
}
|
||||
|
||||
private static bool IsColorCloseToWhiteOrBlack(Vector3 color)
|
||||
{
|
||||
var (_, _, lightness) = RgbToHsl(color);
|
||||
return lightness is > WhiteThreshold or < BlackThreshold;
|
||||
}
|
||||
|
||||
private static List<Vector3> KMeansClustering(List<Vector3> points, int k, int maxIterations = 100)
|
||||
{
|
||||
var random = new Random();
|
||||
var centroids = points.OrderBy(x => random.Next()).Take(k).ToList();
|
||||
|
||||
for (var i = 0; i < maxIterations; i++)
|
||||
{
|
||||
var clusters = new List<Vector3>[k];
|
||||
for (var j = 0; j < k; j++)
|
||||
{
|
||||
clusters[j] = [];
|
||||
}
|
||||
|
||||
foreach (var point in points)
|
||||
{
|
||||
var nearestCentroidIndex = centroids
|
||||
.Select((centroid, index) => new { Index = index, Distance = Vector3.DistanceSquared(centroid, point) })
|
||||
.OrderBy(x => x.Distance)
|
||||
.First().Index;
|
||||
clusters[nearestCentroidIndex].Add(point);
|
||||
}
|
||||
|
||||
var newCentroids = clusters.Select(cluster =>
|
||||
cluster.Count != 0 ? new Vector3(
|
||||
cluster.Average(p => p.X),
|
||||
cluster.Average(p => p.Y),
|
||||
cluster.Average(p => p.Z)
|
||||
) : Vector3.Zero
|
||||
).ToList();
|
||||
|
||||
if (centroids.SequenceEqual(newCentroids))
|
||||
break;
|
||||
|
||||
centroids = newCentroids;
|
||||
}
|
||||
|
||||
return centroids;
|
||||
}
|
||||
|
||||
// public static Vector3 GetComplementaryColor(Vector3 color)
|
||||
// {
|
||||
// // Simple complementary color calculation
|
||||
// return new Vector3(255 - color.X, 255 - color.Y, 255 - color.Z);
|
||||
// }
|
||||
|
||||
public static List<Vector3> SortByBrightness(List<Vector3> colors)
|
||||
{
|
||||
return colors.OrderBy(c => 0.299 * c.X + 0.587 * c.Y + 0.114 * c.Z).ToList();
|
||||
}
|
||||
|
||||
public static List<Vector3> SortByVibrancy(List<Vector3> colors)
|
||||
{
|
||||
return colors.OrderByDescending(c =>
|
||||
{
|
||||
float max = Math.Max(c.X, Math.Max(c.Y, c.Z));
|
||||
float min = Math.Min(c.X, Math.Min(c.Y, c.Z));
|
||||
return (max - min) / max;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static string RgbToHex(Vector3 color)
|
||||
{
|
||||
return $"#{(int)color.X:X2}{(int)color.Y:X2}{(int)color.Z:X2}";
|
||||
}
|
||||
|
||||
private static Vector3 GetComplementaryColor(Vector3 color)
|
||||
{
|
||||
// Convert RGB to HSL
|
||||
var (h, s, l) = RgbToHsl(color);
|
||||
|
||||
// Rotate hue by 180 degrees
|
||||
h = (h + 180) % 360;
|
||||
|
||||
// Convert back to RGB
|
||||
return HslToRgb(h, s, l);
|
||||
}
|
||||
|
||||
private static (double H, double S, double L) RgbToHsl(Vector3 rgb)
|
||||
{
|
||||
double r = rgb.X / 255;
|
||||
double g = rgb.Y / 255;
|
||||
double b = rgb.Z / 255;
|
||||
|
||||
var max = Math.Max(r, Math.Max(g, b));
|
||||
var min = Math.Min(r, Math.Min(g, b));
|
||||
var diff = max - min;
|
||||
|
||||
double h = 0;
|
||||
double s = 0;
|
||||
var l = (max + min) / 2;
|
||||
|
||||
if (Math.Abs(diff) > 0.00001)
|
||||
{
|
||||
s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min);
|
||||
|
||||
if (max == r)
|
||||
h = (g - b) / diff + (g < b ? 6 : 0);
|
||||
else if (max == g)
|
||||
h = (b - r) / diff + 2;
|
||||
else if (max == b)
|
||||
h = (r - g) / diff + 4;
|
||||
|
||||
h *= 60;
|
||||
}
|
||||
|
||||
return (h, s, l);
|
||||
}
|
||||
|
||||
private static Vector3 HslToRgb(double h, double s, double l)
|
||||
{
|
||||
double r, g, b;
|
||||
|
||||
if (Math.Abs(s) < 0.00001)
|
||||
{
|
||||
r = g = b = l;
|
||||
}
|
||||
else
|
||||
{
|
||||
var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
var p = 2 * l - q;
|
||||
r = HueToRgb(p, q, h + 120);
|
||||
g = HueToRgb(p, q, h);
|
||||
b = HueToRgb(p, q, h - 120);
|
||||
}
|
||||
|
||||
return new Vector3((float)(r * 255), (float)(g * 255), (float)(b * 255));
|
||||
}
|
||||
|
||||
private static double HueToRgb(double p, double q, double t)
|
||||
{
|
||||
if (t < 0) t += 360;
|
||||
if (t > 360) t -= 360;
|
||||
return t switch
|
||||
{
|
||||
< 60 => p + (q - p) * t / 60,
|
||||
< 180 => q,
|
||||
< 240 => p + (q - p) * (240 - t) / 60,
|
||||
_ => p
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the Primary and Secondary colors from a file
|
||||
/// </summary>
|
||||
/// <remarks>This may use a second most common color or a complementary color. It's up to implemenation to choose what's best</remarks>
|
||||
/// <param name="sourceFile"></param>
|
||||
/// <returns></returns>
|
||||
public static ColorScape CalculateColorScape(string sourceFile)
|
||||
{
|
||||
if (!File.Exists(sourceFile)) return new ColorScape() {Primary = null, Secondary = null};
|
||||
|
||||
var colors = GetPrimarySecondaryColors(sourceFile);
|
||||
|
||||
return new ColorScape()
|
||||
{
|
||||
Primary = colors.Item1 == null ? null : RgbToHex(colors.Item1.Value),
|
||||
Secondary = colors.Item2 == null ? null : RgbToHex(colors.Item2.Value)
|
||||
};
|
||||
}
|
||||
|
||||
private static string FallbackToKavitaReaderFavicon(string baseUrl)
|
||||
{
|
||||
var correctSizeLink = string.Empty;
|
||||
|
|
@ -582,4 +846,39 @@ public class ImageService : IImageService
|
|||
|
||||
image.WriteToFile(dest);
|
||||
}
|
||||
|
||||
public void UpdateColorScape(IHasCoverImage entity)
|
||||
{
|
||||
var colors = CalculateColorScape(
|
||||
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, entity.CoverImage));
|
||||
entity.PrimaryColor = colors.Primary;
|
||||
entity.SecondaryColor = colors.Secondary;
|
||||
}
|
||||
|
||||
public static Color HexToRgb(string? hex)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null");
|
||||
|
||||
// Remove the leading '#' if present
|
||||
hex = hex.TrimStart('#');
|
||||
|
||||
// Ensure the hex string is valid
|
||||
if (hex.Length != 6 && hex.Length != 3)
|
||||
{
|
||||
throw new ArgumentException("Hex string should be 6 or 3 characters long.");
|
||||
}
|
||||
|
||||
if (hex.Length == 3)
|
||||
{
|
||||
// Expand shorthand notation to full form (e.g., "abc" -> "aabbcc")
|
||||
hex = string.Concat(hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]);
|
||||
}
|
||||
|
||||
// Parse the hex string into RGB components
|
||||
var r = Convert.ToInt32(hex.Substring(0, 2), 16);
|
||||
var g = Convert.ToInt32(hex.Substring(2, 2), 16);
|
||||
var b = Convert.ToInt32(hex.Substring(4, 2), 16);
|
||||
|
||||
return Color.FromArgb(r, g, b);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue