Skip to content

Commit d6e62e5

Browse files
Add dumptruck image command
1 parent 9b08095 commit d6e62e5

File tree

11 files changed

+259
-48
lines changed

11 files changed

+259
-48
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using System;
2+
using System.IO;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using DSharpPlus.CommandsNext;
6+
using DSharpPlus.Entities;
7+
using Kattbot.Common.Utils;
8+
using Kattbot.Helpers;
9+
using Kattbot.Services.Images;
10+
using MediatR;
11+
using SixLabors.ImageSharp;
12+
using SixLabors.ImageSharp.PixelFormats;
13+
14+
namespace Kattbot.CommandHandlers.Images;
15+
16+
#pragma warning disable SA1402 // File may only contain a single type
17+
public class DumpTruckEmoteRequest : CommandRequest
18+
{
19+
public DumpTruckEmoteRequest(CommandContext ctx, DiscordEmoji emoji)
20+
: base(ctx)
21+
{
22+
Emoji = emoji;
23+
}
24+
25+
public DiscordEmoji Emoji { get; set; }
26+
27+
public string? Speed { get; set; }
28+
}
29+
30+
public class DumpTruckImageHandlers : IRequestHandler<DumpTruckEmoteRequest>
31+
{
32+
private readonly DiscordResolver _discordResolver;
33+
private readonly ImageService _imageService;
34+
35+
public DumpTruckImageHandlers(ImageService imageService, DiscordResolver discordResolver)
36+
{
37+
_imageService = imageService;
38+
_discordResolver = discordResolver;
39+
}
40+
41+
public async Task Handle(DumpTruckEmoteRequest request, CancellationToken cancellationToken)
42+
{
43+
CommandContext ctx = request.Ctx;
44+
DiscordEmoji emoji = request.Emoji;
45+
46+
string imageUrl = emoji.GetEmojiImageUrl();
47+
48+
ImageStreamResult imageStreamResult = await DumpTruckImage(imageUrl);
49+
50+
using MemoryStream imageStream = imageStreamResult.MemoryStream;
51+
string fileExtension = imageStreamResult.FileExtension;
52+
53+
string imageName = emoji.Id != 0 ? emoji.Id.ToString() : emoji.Name;
54+
55+
var fileName = $"{imageName}.{fileExtension}";
56+
57+
var responseBuilder = new DiscordMessageBuilder();
58+
59+
responseBuilder.AddFile(fileName, imageStreamResult.MemoryStream);
60+
61+
await ctx.RespondAsync(responseBuilder);
62+
}
63+
64+
private async Task<ImageStreamResult> DumpTruckImage(
65+
string imageUrl,
66+
ImageTransformDelegate<Rgba32>? preTransform = null)
67+
{
68+
Image<Rgba32> inputImage = await _imageService.DownloadImage<Rgba32>(imageUrl);
69+
70+
if (preTransform != null)
71+
{
72+
inputImage = preTransform(inputImage);
73+
}
74+
75+
string dumpTruckFile = Path.Combine("Resources", "dumptruck_v1.png");
76+
using Image<Rgba32> dumpTruckImage = Image.Load<Rgba32>(dumpTruckFile);
77+
string dumpTruckMaskFile = Path.Combine("Resources", "dumptruck_v1_mask.png");
78+
using Image<Rgba32> dumpTruckMaskImage = Image.Load<Rgba32>(dumpTruckMaskFile);
79+
80+
Image petImage = ImageEffects.FillMaskWithTiledImage(dumpTruckImage, dumpTruckMaskImage, inputImage);
81+
82+
ImageStreamResult outputImageStream = await ImageService.GetImageStream(petImage);
83+
84+
return outputImageStream;
85+
}
86+
}

src/Kattbot/CommandModules/ImageModule.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,15 @@ public Task Pet(CommandContext ctx, string? speed = null)
126126
return _commandParallelQueue.Writer.WriteAsync(request).AsTask();
127127
}
128128

129+
[Command("dumptruck")]
130+
[Cooldown(maxUses: 5, resetAfter: 10, CooldownBucketType.Global)]
131+
public Task DumpTruck(CommandContext ctx, DiscordEmoji emoji)
132+
{
133+
var request = new DumpTruckEmoteRequest(ctx, emoji);
134+
135+
return _commandParallelQueue.Writer.WriteAsync(request).AsTask();
136+
}
137+
129138
[Command("gpt-image")]
130139
[Cooldown(maxUses: 5, resetAfter: 30, CooldownBucketType.Global)]
131140
public Task GptImage(CommandContext ctx, [RemainingText] string prompt)

src/Kattbot/Kattbot.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,11 @@
4545
<None Update="Resources\pet_sprite_sheet.png">
4646
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
4747
</None>
48+
<None Update="Resources\dumptruck_v1.png">
49+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
50+
</None>
51+
<None Update="Resources\dumptruck_v1_mask.png">
52+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
53+
</None>
4854
</ItemGroup>
4955
</Project>
817 KB
Loading
33.2 KB
Loading

src/Kattbot/Services/Images/ImageEffects.cs

Lines changed: 122 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.IO;
34
using SixLabors.ImageSharp;
45
using SixLabors.ImageSharp.Drawing;
@@ -25,14 +26,13 @@ public static Image ScaleImage(Image image, double scaleFactor)
2526

2627
public static Image DeepFryImage(Image image)
2728
{
28-
image.Mutate(
29-
i =>
30-
{
31-
i.Contrast(5f);
32-
i.Brightness(1.5f);
33-
i.GaussianSharpen(5f);
34-
i.Saturate(5f);
35-
});
29+
image.Mutate(i =>
30+
{
31+
i.Contrast(5f);
32+
i.Brightness(1.5f);
33+
i.GaussianSharpen(5f);
34+
i.Saturate(5f);
35+
});
3636

3737
return image;
3838
}
@@ -119,20 +119,19 @@ public static Image<TPixel> CropToCircle<TPixel>(Image<TPixel> image)
119119
imageAsPngWithTransparency = image;
120120
}
121121

122-
Image<TPixel> cloned = imageAsPngWithTransparency.Clone(
123-
i =>
122+
Image<TPixel> cloned = imageAsPngWithTransparency.Clone(i =>
123+
{
124+
var opts = new DrawingOptions
124125
{
125-
var opts = new DrawingOptions
126+
GraphicsOptions = new GraphicsOptions
126127
{
127-
GraphicsOptions = new GraphicsOptions
128-
{
129-
Antialias = true,
130-
AlphaCompositionMode = PixelAlphaCompositionMode.DestIn,
131-
},
132-
};
128+
Antialias = true,
129+
AlphaCompositionMode = PixelAlphaCompositionMode.DestIn,
130+
},
131+
};
133132

134-
i.Fill(opts, Color.Black, ellipsePath);
135-
});
133+
i.Fill(opts, Color.Black, ellipsePath);
134+
});
136135

137136
return cloned;
138137
}
@@ -185,32 +184,31 @@ public static Image PetPet(Image<Rgba32> inputImage, int fps = default)
185184
float[] squishFactors = [0.9f, 0.8f, 0.75f, 0.8f, 0.85f];
186185

187186
// Resize the input image to match the frame size
188-
Image<Rgba32> resizedImage = inputImage.Clone(
189-
ctx =>
190-
{
191-
// Crop the image to a square
192-
ctx.Crop(
193-
Math.Min(inputImage.Width, inputImage.Height),
194-
Math.Min(inputImage.Width, inputImage.Height));
187+
Image<Rgba32> resizedImage = inputImage.Clone(ctx =>
188+
{
189+
// Crop the image to a square
190+
ctx.Crop(
191+
Math.Min(inputImage.Width, inputImage.Height),
192+
Math.Min(inputImage.Width, inputImage.Height));
195193

196-
// Downscale the image to the frame size and then some
197-
const int newWidth = (int)(frameSize * scale);
198-
const int newHeight = (int)(frameSize * scale);
194+
// Downscale the image to the frame size and then some
195+
const int newWidth = (int)(frameSize * scale);
196+
const int newHeight = (int)(frameSize * scale);
199197

200-
ctx.Resize(
201-
new ResizeOptions
202-
{
203-
Size = new Size(newWidth, newHeight),
204-
Sampler = KnownResamplers.Hermite,
205-
});
206-
});
198+
ctx.Resize(
199+
new ResizeOptions
200+
{
201+
Size = new Size(newWidth, newHeight),
202+
Sampler = KnownResamplers.Hermite,
203+
});
204+
});
207205

208206
// Optimize transparency by clipping pixels with low alpha values in order to reduce dithering artifacts
209207
resizedImage.ProcessPixelRows(a => ImageProcessors.ClipTransparencyProcessor(a, alphaThreshold));
210208

211209
for (var i = 0; i < frameCount; i++)
212210
{
213-
var overlayFrameRectangle = new Rectangle(i * overlayWidth, 0, overlayWidth, overlayHeight);
211+
var overlayFrameRectangle = new Rectangle(i * overlayWidth, y: 0, overlayWidth, overlayHeight);
214212

215213
Image<Rgba32> overlayFrame = overlaySpriteSheet.Clone(ctx => ctx.Crop(overlayFrameRectangle));
216214

@@ -220,23 +218,24 @@ public static Image PetPet(Image<Rgba32> inputImage, int fps = default)
220218
Image<Rgba32> squishedFrame =
221219
resizedImage.Clone(x => x.Resize(resizedImage.Width, (int)(resizedImage.Height * frameSquishFactor)));
222220
#if DEBUG
221+
223222
// temporarily save the frame to disk
224223
squishedFrame.SaveAsPng(Path.Combine(Path.GetTempPath(), "kattbot", $"a_squished_frame_{i}.png"));
225224
#endif
226225
var outputFrame = new Image<Rgba32>(frameSize, frameSize);
227226

228227
// Draw the resized input frame in the bottom right corner
229-
outputFrame.Mutate(
230-
x =>
231-
{
232-
int newFrameOffsetX = frameSize - squishedFrame.Width;
233-
int newFrameOffsetY = frameSize - squishedFrame.Height;
234-
x.DrawImage(squishedFrame, new Point(newFrameOffsetX, newFrameOffsetY), 1f);
235-
});
228+
outputFrame.Mutate(x =>
229+
{
230+
int newFrameOffsetX = frameSize - squishedFrame.Width;
231+
int newFrameOffsetY = frameSize - squishedFrame.Height;
232+
x.DrawImage(squishedFrame, new Point(newFrameOffsetX, newFrameOffsetY), opacity: 1f);
233+
});
236234

237235
// Draw the overlay frame
238-
outputFrame.Mutate(x => x.DrawImage(overlayFrame, new Point(0, overlayY), 1f));
236+
outputFrame.Mutate(x => x.DrawImage(overlayFrame, new Point(x: 0, overlayY), opacity: 1f));
239237
#if DEBUG
238+
240239
// temporarily save the frame to disk
241240
outputFrame.SaveAsPng(Path.Combine(Path.GetTempPath(), "kattbot", $"b_frame_{i}.png"));
242241
#endif
@@ -267,4 +266,82 @@ public static Image PetPet(Image<Rgba32> inputImage, int fps = default)
267266

268267
return outputGif;
269268
}
269+
270+
public static Image FillMaskWithTiledImage(
271+
Image<Rgba32> targetImage,
272+
Image<Rgba32> maskImage,
273+
Image<Rgba32> tileImage)
274+
{
275+
Image<Rgba32> result = targetImage.Clone();
276+
var random = new Random();
277+
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);
281+
282+
// Resize the tile image to maintain square aspect ratio
283+
Image<Rgba32> resizedTileImage = tileImage.Clone(ctx =>
284+
ctx.Resize(desiredTileSize, desiredTileSize, KnownResamplers.Hermite));
285+
286+
// Calculate tile placement parameters
287+
int tileWidth = resizedTileImage.Width;
288+
int tileHeight = resizedTileImage.Height;
289+
int maxJitterX = tileWidth / 4;
290+
int maxJitterY = tileHeight / 4;
291+
292+
// Create a list of mask regions to fill
293+
var maskRegions = new List<Point>();
294+
295+
// Scan for white pixels in the mask (sampling at intervals)
296+
for (var y = 0; y < maskImage.Height; y += tileHeight / 2)
297+
{
298+
for (var x = 0; x < maskImage.Width; x += tileWidth / 2)
299+
{
300+
if (x < maskImage.Width && y < maskImage.Height)
301+
{
302+
Rgba32 pixel = maskImage[x, y];
303+
304+
// Check if pixel is white (high RGB values)
305+
if (pixel.R > 200 && pixel.G > 200 && pixel.B > 200)
306+
{
307+
maskRegions.Add(new Point(x, y));
308+
}
309+
}
310+
}
311+
}
312+
313+
// Place tiles randomly over mask regions
314+
foreach (Point region in maskRegions)
315+
{
316+
// Add random jitter to tile placement
317+
int jitterX = random.Next(-maxJitterX, maxJitterX);
318+
int jitterY = random.Next(-maxJitterY, maxJitterY);
319+
320+
// Center the tile on the detected mask pixel
321+
int tileX = (region.X + jitterX) - (tileWidth / 2);
322+
int tileY = (region.Y + jitterY) - (tileHeight / 2);
323+
324+
// Only draw if the tile center would be within the mask area
325+
int centerX = tileX + (tileWidth / 2);
326+
int centerY = tileY + (tileHeight / 2);
327+
328+
if (centerX >= 0 && centerX < maskImage.Width &&
329+
centerY >= 0 && centerY < maskImage.Height)
330+
{
331+
Rgba32 centerPixel = maskImage[centerX, centerY];
332+
if (centerPixel.R > 200 && centerPixel.G > 200 && centerPixel.B > 200)
333+
{
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
339+
result.Mutate(ctx => { ctx.DrawImage(resizedTileImage, new Point(tileX, tileY), opacity: 1f); });
340+
}
341+
}
342+
}
343+
344+
resizedTileImage.Dispose();
345+
return result;
346+
}
270347
}

tests/Kattbot.Tests/ImageTests.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace Kattbot.Tests;
1111
[Ignore] // Can't save to /tmp on GitHub Actions. TODO: fix
1212
public class ImageTests
1313
{
14-
[DataTestMethod]
14+
[TestMethod]
1515
[DataRow("froge.png")]
1616
public async Task PetPetTest(string inputFilename)
1717
{
@@ -28,7 +28,7 @@ public async Task PetPetTest(string inputFilename)
2828
Assert.IsTrue(File.Exists(outputFile));
2929
}
3030

31-
[DataTestMethod]
31+
[TestMethod]
3232
[DataRow("froge.png")]
3333
public async Task CropToCircle(string inputFilename)
3434
{
@@ -44,7 +44,7 @@ public async Task CropToCircle(string inputFilename)
4444
Assert.IsTrue(File.Exists(outputFile));
4545
}
4646

47-
[DataTestMethod]
47+
[TestMethod]
4848
[DataRow("froge.png")]
4949
public async Task Twirl(string inputFilename)
5050
{
@@ -59,4 +59,28 @@ public async Task Twirl(string inputFilename)
5959

6060
Assert.IsTrue(File.Exists(outputFile));
6161
}
62+
63+
[TestMethod]
64+
[DataRow("froge.png")]
65+
[DataRow("madjoy.png")]
66+
public async Task FillMaskWithTiledImage(string tileImageFilename)
67+
{
68+
string targetImageFile = Path.Combine("Resources", "dumptruck_v1.png");
69+
string maskImageFile = Path.Combine("Resources", "dumptruck_v1_mask.png");
70+
string tileImageFile = Path.Combine("Resources", tileImageFilename);
71+
string outputDir = Path.Combine(Path.GetTempPath(), "kattbot");
72+
string outputFile = Path.Combine(outputDir, $"mask_filled_{tileImageFilename}");
73+
74+
Directory.CreateDirectory(outputDir);
75+
76+
using Image<Rgba32> targetImage = Image.Load<Rgba32>(targetImageFile);
77+
using Image<Rgba32> maskImage = Image.Load<Rgba32>(maskImageFile);
78+
using Image<Rgba32> tileImage = Image.Load<Rgba32>(tileImageFile);
79+
80+
Image filledImage = ImageEffects.FillMaskWithTiledImage(targetImage, maskImage, tileImage);
81+
82+
await filledImage.SaveAsPngAsync(outputFile);
83+
84+
Assert.IsTrue(File.Exists(outputFile));
85+
}
6286
}

0 commit comments

Comments
 (0)