Skip to content

Commit 6fa85ff

Browse files
Improve FillMaskWithTiledImage
1 parent d6e62e5 commit 6fa85ff

File tree

15 files changed

+319
-48
lines changed

15 files changed

+319
-48
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ jobs:
6262
arguments: '/updateprojectfiles'
6363

6464
- name: Test
65-
run: dotnet test
65+
run: dotnet test --filter TestCategory\!=ShittyTests
6666

6767
- name: Log in to the Container registry
6868
uses: docker/login-action@v3

src/Kattbot/Services/Images/ImageEffects.cs

Lines changed: 229 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -275,14 +275,196 @@ public static Image FillMaskWithTiledImage(
275275
Image<Rgba32> result = targetImage.Clone();
276276
var random = new Random();
277277

278-
// Resize tile image based on target image width ratio (1/40 for 32px tiles on 1280px width)
279-
const double tileRatio = 1.0 / 40.0;
280-
var desiredTileSize = (int)(targetImage.Width * tileRatio);
278+
// Calculate average color of tile image for background fill
279+
Rgba32 averageColor = CalculateAverageColor(tileImage);
280+
281+
// Apply brightness reduction to the average color for Layer 1
282+
const float baseLayerBrightness = 0.8f;
283+
var darkenedAverageColor = new Rgba32(
284+
(byte)(averageColor.R * baseLayerBrightness),
285+
(byte)(averageColor.G * baseLayerBrightness),
286+
(byte)(averageColor.B * baseLayerBrightness),
287+
averageColor.A);
288+
289+
// Layer 1: Fill entire masked area with darkened average color using pixel-level processing
290+
for (var y = 0; y < Math.Min(result.Height, maskImage.Height); y++)
291+
{
292+
for (var x = 0; x < Math.Min(result.Width, maskImage.Width); x++)
293+
{
294+
Rgba32 maskPixel = maskImage[x, y];
295+
if (maskPixel is { R: > 200, G: > 200, B: > 200 })
296+
{
297+
result[x, y] = darkenedAverageColor;
298+
}
299+
}
300+
}
301+
302+
// Layer 2: Texture-like coverage with reduced brightness (depth effect)
303+
const double textureLayerTileRatio = 1.0 / 34.0;
304+
const float textureLayerBrightness = 0.9f;
305+
const float textureLayerSpacingMultiplier = 0.3f;
306+
307+
ApplyTextureLayer(
308+
result,
309+
maskImage,
310+
tileImage,
311+
random,
312+
textureLayerTileRatio,
313+
textureLayerBrightness,
314+
textureLayerSpacingMultiplier);
315+
316+
// Layer 3: Higher tile ratio with original brightness and increased spacing
317+
const double tileLayerTileRatio = 1.0 / 30.0;
318+
const float tileLayerBrightness = 1.0f;
319+
const float tileLayerSpacingMultiplier = 0.65f;
320+
321+
ApplyTileLayer(
322+
result,
323+
maskImage,
324+
tileImage,
325+
random,
326+
tileLayerTileRatio,
327+
tileLayerBrightness,
328+
tileLayerSpacingMultiplier);
329+
330+
return result;
331+
}
332+
333+
private static void ApplyTextureLayer(
334+
Image<Rgba32> result,
335+
Image<Rgba32> maskImage,
336+
Image<Rgba32> tileImage,
337+
Random random,
338+
double tileRatio,
339+
float brightness,
340+
float spacingMultiplier)
341+
{
342+
var desiredTileSize = (int)(result.Width * tileRatio);
343+
344+
// Resize the tile image to maintain square aspect ratio
345+
using Image<Rgba32> resizedTileImage = tileImage.Clone(ctx =>
346+
ctx.Resize(desiredTileSize, desiredTileSize, KnownResamplers.Hermite));
347+
348+
int tileWidth = resizedTileImage.Width;
349+
int tileHeight = resizedTileImage.Height;
350+
int maxJitterX = tileWidth / 4; // Reduced jitter for more consistent coverage
351+
int maxJitterY = tileHeight / 4;
352+
353+
// Create a grid that ensures complete coverage with some overlap
354+
var gridSpacingX = (int)(tileWidth * spacingMultiplier);
355+
var gridSpacingY = (int)(tileHeight * spacingMultiplier);
356+
357+
// Start with slight negative offset to ensure edge coverage
358+
int startX = -tileWidth / 2;
359+
int startY = -tileHeight / 2;
360+
361+
for (int gridY = startY; gridY < maskImage.Height + tileHeight; gridY += gridSpacingY)
362+
{
363+
for (int gridX = startX; gridX < maskImage.Width + tileWidth; gridX += gridSpacingX)
364+
{
365+
// Add small random jitter to avoid perfect grid pattern
366+
int jitterX = random.Next(-maxJitterX, maxJitterX);
367+
int jitterY = random.Next(-maxJitterY, maxJitterY);
368+
369+
int tileX = gridX + jitterX;
370+
int tileY = gridY + jitterY;
371+
372+
// Check if this tile position overlaps with the mask
373+
var overlapsMask = false;
374+
int checkRadius = Math.Max(tileWidth, tileHeight) / 2;
375+
376+
for (int checkY = Math.Max(val1: 0, tileY);
377+
checkY < Math.Min(maskImage.Height, tileY + tileHeight) && !overlapsMask;
378+
checkY += checkRadius / 2)
379+
{
380+
for (int checkX = Math.Max(val1: 0, tileX);
381+
checkX < Math.Min(maskImage.Width, tileX + tileWidth) && !overlapsMask;
382+
checkX += checkRadius / 2)
383+
{
384+
if (checkX < maskImage.Width && checkY < maskImage.Height)
385+
{
386+
Rgba32 pixel = maskImage[checkX, checkY];
387+
if (pixel is { R: > 200, G: > 200, B: > 200 })
388+
{
389+
overlapsMask = true;
390+
}
391+
}
392+
}
393+
}
394+
395+
if (overlapsMask)
396+
{
397+
// Draw the tile image cropped to the mask boundaries using direct pixel access
398+
for (var y = 0; y < tileHeight; y++)
399+
{
400+
int resultY = tileY + y;
401+
if (resultY < 0 || resultY >= result.Height) continue;
402+
403+
for (var x = 0; x < tileWidth; x++)
404+
{
405+
int resultX = tileX + x;
406+
if (resultX < 0 || resultX >= result.Width) continue;
407+
408+
// Check if this position is within the mask
409+
if (resultX < maskImage.Width && resultY < maskImage.Height)
410+
{
411+
Rgba32 maskPixel = maskImage[resultX, resultY];
412+
if (maskPixel is { R: > 200, G: > 200, B: > 200 })
413+
{
414+
// Draw tile pixels within the mask, including semi-transparent ones for smooth edges
415+
Rgba32 tilePixel = resizedTileImage[x, y];
416+
if (tilePixel.A > 0) // Include all non-fully-transparent pixels
417+
{
418+
// Apply brightness reduction per-pixel to preserve edge anti-aliasing
419+
var adjustedPixel = new Rgba32(
420+
(byte)(tilePixel.R * brightness),
421+
(byte)(tilePixel.G * brightness),
422+
(byte)(tilePixel.B * brightness),
423+
tilePixel.A); // Keep original alpha
424+
425+
// Blend with existing pixel using alpha blending
426+
Rgba32 existingPixel = result[resultX, resultY];
427+
float alpha = adjustedPixel.A / 255f;
428+
float invAlpha = 1f - alpha;
429+
430+
var blendedPixel = new Rgba32(
431+
(byte)((adjustedPixel.R * alpha) + (existingPixel.R * invAlpha)),
432+
(byte)((adjustedPixel.G * alpha) + (existingPixel.G * invAlpha)),
433+
(byte)((adjustedPixel.B * alpha) + (existingPixel.B * invAlpha)),
434+
Math.Max(adjustedPixel.A, existingPixel.A));
435+
436+
result[resultX, resultY] = blendedPixel;
437+
}
438+
}
439+
}
440+
}
441+
}
442+
}
443+
}
444+
}
445+
}
446+
447+
private static void ApplyTileLayer(
448+
Image<Rgba32> result,
449+
Image<Rgba32> maskImage,
450+
Image<Rgba32> tileImage,
451+
Random random,
452+
double tileRatio,
453+
float brightness,
454+
float spacingMultiplier)
455+
{
456+
var desiredTileSize = (int)(result.Width * tileRatio);
281457

282458
// Resize the tile image to maintain square aspect ratio
283-
Image<Rgba32> resizedTileImage = tileImage.Clone(ctx =>
459+
using Image<Rgba32> resizedTileImage = tileImage.Clone(ctx =>
284460
ctx.Resize(desiredTileSize, desiredTileSize, KnownResamplers.Hermite));
285461

462+
// Adjust brightness if needed
463+
if (Math.Abs(brightness - 1.0f) > 0.001f)
464+
{
465+
resizedTileImage.Mutate(ctx => ctx.Brightness(brightness));
466+
}
467+
286468
// Calculate tile placement parameters
287469
int tileWidth = resizedTileImage.Width;
288470
int tileHeight = resizedTileImage.Height;
@@ -293,16 +475,19 @@ public static Image FillMaskWithTiledImage(
293475
var maskRegions = new List<Point>();
294476

295477
// Scan for white pixels in the mask (sampling at intervals)
296-
for (var y = 0; y < maskImage.Height; y += tileHeight / 2)
478+
var spacingY = (int)(tileHeight * spacingMultiplier);
479+
var spacingX = (int)(tileWidth * spacingMultiplier);
480+
481+
for (var y = 0; y < maskImage.Height; y += spacingY)
297482
{
298-
for (var x = 0; x < maskImage.Width; x += tileWidth / 2)
483+
for (var x = 0; x < maskImage.Width; x += spacingX)
299484
{
300485
if (x < maskImage.Width && y < maskImage.Height)
301486
{
302487
Rgba32 pixel = maskImage[x, y];
303488

304489
// Check if pixel is white (high RGB values)
305-
if (pixel.R > 200 && pixel.G > 200 && pixel.B > 200)
490+
if (pixel is { R: > 200, G: > 200, B: > 200 })
306491
{
307492
maskRegions.Add(new Point(x, y));
308493
}
@@ -329,19 +514,48 @@ public static Image FillMaskWithTiledImage(
329514
centerY >= 0 && centerY < maskImage.Height)
330515
{
331516
Rgba32 centerPixel = maskImage[centerX, centerY];
332-
if (centerPixel.R > 200 && centerPixel.G > 200 && centerPixel.B > 200)
517+
if (centerPixel is { R: > 200, G: > 200, B: > 200 })
333518
{
334-
// Ensure tile placement is within target image bounds
335-
tileX = Math.Max(val1: 0, Math.Min(tileX, targetImage.Width - tileWidth));
336-
tileY = Math.Max(val1: 0, Math.Min(tileY, targetImage.Height - tileHeight));
337-
338-
// Draw the tile image at the calculated position
519+
// Allow tiles to extend beyond image bounds for natural edge effect
520+
// Draw the tile image at the calculated position (can go partially offscreen)
339521
result.Mutate(ctx => { ctx.DrawImage(resizedTileImage, new Point(tileX, tileY), opacity: 1f); });
340522
}
341523
}
342524
}
525+
}
343526

344-
resizedTileImage.Dispose();
345-
return result;
527+
private static Rgba32 CalculateAverageColor(Image<Rgba32> image)
528+
{
529+
long totalR = 0, totalG = 0, totalB = 0, totalA = 0;
530+
var pixelCount = 0;
531+
532+
image.ProcessPixelRows(accessor =>
533+
{
534+
for (var y = 0; y < accessor.Height; y++)
535+
{
536+
Span<Rgba32> pixelRow = accessor.GetRowSpan(y);
537+
for (var x = 0; x < pixelRow.Length; x++)
538+
{
539+
Rgba32 pixel = pixelRow[x];
540+
if (pixel.A > 0) // Only count non-transparent pixels
541+
{
542+
totalR += pixel.R;
543+
totalG += pixel.G;
544+
totalB += pixel.B;
545+
totalA += pixel.A;
546+
pixelCount++;
547+
}
548+
}
549+
}
550+
});
551+
552+
if (pixelCount == 0)
553+
return new Rgba32(r: 0, g: 0, b: 0, a: 0);
554+
555+
return new Rgba32(
556+
(byte)(totalR / pixelCount),
557+
(byte)(totalG / pixelCount),
558+
(byte)(totalB / pixelCount),
559+
(byte)(totalA / pixelCount));
346560
}
347561
}

tests/Kattbot.Tests/EmoteParserTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,21 @@ public void Initialize()
1818
[TestMethod]
1919
public void ParseEmotes_WithMessageContaingEmotes_ReturnEmote()
2020
{
21-
var testMessage = "Some text <:emoji_1:123123123>";
21+
const string testMessage = "Some text <:emoji_1:123123123>";
2222

2323
List<string> emotes = _sut.ExtractEmotesFromMessage(testMessage);
2424

25-
Assert.AreEqual(emotes.Count, 1);
25+
Assert.AreEqual(emotes.Count, actual: 1);
2626
Assert.AreEqual(emotes[0], "<:emoji_1:123123123>");
2727
}
2828

2929
[TestMethod]
3030
public void ParseEmotes_WithMessageContaingThreeEmotes_ReturnThreeEmotes()
3131
{
32-
var testMessage = "Some text <:emoji_1:123123123> other <:emoji_2:123123123><:emoji_3:123123123>";
32+
const string testMessage = "Some text <:emoji_1:123123123> other <:emoji_2:123123123><:emoji_3:123123123>";
3333

3434
List<string> emotes = _sut.ExtractEmotesFromMessage(testMessage);
3535

36-
Assert.AreEqual(emotes.Count, 3);
36+
Assert.AreEqual(emotes.Count, actual: 3);
3737
}
3838
}

tests/Kattbot.Tests/ImageServiceTests.cs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
using System.IO;
22
using System.Linq;
3-
using System.Net.Http;
43
using System.Threading.Tasks;
54
using Kattbot.Services.Images;
65
using Microsoft.VisualStudio.TestTools.UnitTesting;
7-
using NSubstitute;
86
using SixLabors.ImageSharp;
9-
using SixLabors.ImageSharp.Formats;
107

118
namespace Kattbot.Tests;
129

1310
[TestClass]
14-
[Ignore] // Can't save to /tmp on GitHub Actions. TODO: fix
1511
public class ImageServiceTests
1612
{
13+
private readonly TestContext _testContext;
14+
15+
public ImageServiceTests(TestContext testContext)
16+
{
17+
_testContext = testContext;
18+
}
19+
1720
[TestMethod]
1821
[DataRow("cute_cat.jpg")]
1922
[DataRow("froge.png")]
@@ -23,13 +26,13 @@ public async Task EnsureMaxSize_DownscalesImageIfNeeded(string inputFilename)
2326

2427
string inputFile = Path.Combine("Resources", inputFilename);
2528

26-
Image inputImage = await Image.LoadAsync(inputFile);
29+
Image inputImage = await Image.LoadAsync(inputFile, _testContext.CancellationTokenSource.Token);
2730

2831
Image resizedImage = await ImageService.EnsureMaxImageFileSize(inputImage, maxSizeMb);
2932

3033
double resizedImageSize = await ImageService.GetImageSizeInMb(resizedImage);
3134

32-
Assert.IsTrue(resizedImageSize <= maxSizeMb);
35+
Assert.IsLessThanOrEqualTo(maxSizeMb, resizedImageSize);
3336
}
3437

3538
[TestMethod]
@@ -46,7 +49,7 @@ public async Task EnsureSupportedImageFormatOrPng_ConvertsToPngIfFormatIsNotSupp
4649

4750
string inputFile = Path.Combine("Resources", inputFilename);
4851

49-
Image inputImage = await Image.LoadAsync(inputFile);
52+
Image inputImage = await Image.LoadAsync(inputFile, _testContext.CancellationTokenSource.Token);
5053

5154
Image convertedImage = await ImageService.EnsureSupportedImageFormatOrPng(inputImage, supportedFileTypes);
5255

0 commit comments

Comments
 (0)