Recursive AI

Add a watermark to an image in C#

Add a logo or text watermark to images in C# with pure .NET code: opacity, position, tiled diagonal text, batches, animated GIFs and ASP.NET Core.

To add a watermark to an image in C#, load the photo and the watermark, then lay one over the other with Composite, giving the position and an opacity. For a text watermark, draw the text with DrawText from the OneImage.Text package. Both run in pure .NET code on Windows, Linux, macOS and in containers, with no native libraries. This page covers logo and text watermarks, a tiled diagonal watermark, watermarking a folder and animated GIFs, and adding the watermark on the fly in ASP.NET Core. Every sample was run against OneImage 1.10.2 on .NET 10.

TerminalNuGet
dotnet add package RecursiveAI.OneImage
dotnet add package RecursiveAI.OneImage.Text

You need the second package only for text watermarks. The examples use NASA’s public-domain “Blue Marble” photograph (photo.jpg, 3000×3002) and a 600×300 PNG logo with a transparent background.

Add a logo watermark

Program.cs.NET 10
using OneImage;
 
Image photo = Image.Load("photo.jpg");
 
// Scale the logo to a fifth of the photo's width.
Image logo = Image.Load("logo.png").Resize(width: photo.Width / 5);
 
// Bottom-right corner, with a margin of 3% of the width, at 60% opacity.
int margin = photo.Width * 3 / 100;
photo.Composite(logo,
        x: photo.Width - logo.Width - margin,
        y: photo.Height - logo.Height - margin,
        opacity: 0.6f)
    .Save("photo-logo.jpg");

The logo is scaled to 600 pixels wide, a fifth of the photo, and placed 90 pixels from the bottom-right corner at 60% opacity. Sizing the mark from the photo’s width keeps it in proportion on any image, and the logo’s own transparency is respected, so only its shape shows.

Composite(overlay, x, y, opacity) lays one image over another. The overlay is converted to the photo’s color profile first, and only the pixels under it change. It is lazy like every OneImage operation: the photo is decoded, blended and encoded in one pass when you call Save. For other corners, compute x and y from Width and Height: (margin, margin) is top-left, and ((photo.Width - logo.Width) / 2, (photo.Height - logo.Height) / 2) centers it.

Add a text watermark

Program.cs.NET 10
using OneImage;
using OneImage.Drawing;
using OneImage.Text;
 
Image photo = Image.Load("photo.jpg");
 
var fonts = new FontCollection();
Font font = fonts.Add("NotoSans-Regular.ttf").CreateFont(photo.Width / 30f);
 
// Anchor the text block at the bottom-right corner, inside a margin.
int margin = photo.Width / 40;
var options = new TextOptions(font)
{
    Origin = new(photo.Width - margin, photo.Height - margin),
    HorizontalAlignment = HorizontalAlignment.Right,
    VerticalAlignment = VerticalAlignment.Bottom,
};
 
// Translucent white with a thin dark outline stays readable on light and dark areas.
photo.DrawText("© 2026 Example Photo Co.", options,
        Brush.Solid(new Rgba32(255, 255, 255, 170)),
        new Pen(Brush.Solid(new Rgba32(0, 0, 0, 110)), width: 2))
    .Save("photo-text.jpg");

The text is drawn 100 points high, a thirtieth of the photo’s width, with its block anchored at the bottom-right corner. HorizontalAlignment.Right and VerticalAlignment.Bottom say that the origin is the block’s bottom-right corner, so you don’t have to measure the text to right-align it. The fill is white at an alpha of 170 out of 255, and a thin dark outline from the Pen keeps it readable where the photo is bright.

OneImage.Text ships no fonts, so load one from a file. Here it’s Noto Sans, under the SIL Open Font License. TrueType, OpenType (CFF), WOFF and WOFF2 files load, from a path, a stream or memory, so the font can ship inside your app.

Add a tiled, diagonal watermark

Proof images and document previews usually repeat a translucent word across the whole picture, so that cropping can’t remove it. Turn the text into a path once, then fill it at every point of a grid with a rotation:

Program.cs.NET 10
using System.Numerics;
using OneImage;
using OneImage.Drawing;
using OneImage.Text;
 
Image photo = Image.Load("photo.jpg");
 
var fonts = new FontCollection();
Font font = fonts.Add("NotoSans-Regular.ttf").CreateFont(photo.Width / 16f);
 
// The text as one outline, centered on (0, 0).
var options = new TextOptions(font)
{
    HorizontalAlignment = HorizontalAlignment.Center,
    VerticalAlignment = VerticalAlignment.Center,
};
var text = TextBuilder.GeneratePath("PREVIEW", options);
 
// Repeat it across the image on a grid, turned 30 degrees counterclockwise.
var brush = Brush.Solid(new Rgba32(255, 255, 255, 80));
var turn = Matrix3x2.CreateRotation(-MathF.PI / 6);
int step = photo.Width / 3;
 
Image marked = photo;
for (int y = step / 2; y < photo.Height; y += step)
{
    for (int x = step / 2; x < photo.Width; x += step)
    {
        marked = marked.Fill(brush, text, transform: turn * Matrix3x2.CreateTranslation(x, y));
    }
}
marked.Save("photo-tiled.jpg");

This writes “PREVIEW” on a 3×3 grid, turned 30 degrees, in white at an alpha of 80. TextBuilder.GeneratePath lays out the text once. Each Fill reuses the outline with its own transform, so the text isn’t laid out again for each copy. Change step for a denser or looser grid.

Watermark a folder of images

Program.cs.NET 10
using OneImage;
 
Image logo = Image.Load("logo.png");
Directory.CreateDirectory("marked");
 
foreach (string path in Directory.EnumerateFiles("photos", "*.jpg"))
{
    Image photo = Image.Load(path);
 
    // Size the mark to each photo, so it looks the same on large and small images.
    Image mark = logo.Resize(width: photo.Width / 5);
    int margin = photo.Width * 3 / 100;
 
    photo.Composite(mark, photo.Width - mark.Width - margin, photo.Height - mark.Height - margin, opacity: 0.6f)
         .Save(Path.Combine("marked", Path.GetFileName(path)));
}

The mark is sized from each photo, so a 3000-pixel photo and an 800-pixel one get the same proportions. Phone photos are loaded upright, because Load applies the EXIF orientation first. Without that, a mark placed at the bottom-right of a photo stored sideways would end up along a side once the photo is displayed. Our test phone photo, stored as 1200×800, came out 800×1200 with the mark in the bottom-right corner. The loaded logo is shared by every iteration, because images are immutable.

Watermark an animated GIF

Program.cs.NET 10
using OneImage;
 
Image animation = Image.Load("earth.gif");
Image logo = Image.Load("logo.png").Resize(width: animation.Width / 5);
int margin = 10;
 
// Composite applies to every frame; frame delays and the loop count are kept.
animation.Composite(logo,
        animation.Width - logo.Width - margin,
        animation.Height - logo.Height - margin,
        opacity: 0.6f)
    .Save("earth-marked.gif");

Operations apply to every frame, so the result is still animated. Our 44-frame, 400×400 GIF of the rotating Earth (Wikimedia Commons, CC BY-SA 3.0) came out with 44 frames, each with the mark, and the same 90 ms delays and loop setting. Saved to .webp instead, the same code writes a 44-frame animated WebP. For more on animations, see resizing animated GIFs in C#.

Watermark images on the fly in ASP.NET Core

When the originals must stay unmarked, for example because paying customers download them, add the watermark as images are served. The OneImage.Web middleware has a hook that runs on every processed image before it’s encoded:

Program.csASP.NET Core · .NET 10
using OneImage;
using OneImage.Web;
 
var builder = WebApplication.CreateBuilder(args);
Image logo = Image.Load("logo.png");
 
builder.Services.AddOneImage(o =>
{
    o.CacheSalt = "watermark-1";   // change it whenever the watermark changes
    o.OnBeforeSaveAsync = context =>
    {
        Image image = context.Image;
        Image mark = logo.Resize(width: Math.Max(1, image.Width / 5));
        int margin = image.Width * 3 / 100;
        context.Image = image.Composite(mark,
            image.Width - mark.Width - margin, image.Height - mark.Height - margin, opacity: 0.6f);
        return Task.CompletedTask;
    };
})
// Process every request under /photos, including ones without commands,
// so no unmarked original is ever served.
.ClearProviders()
.AddProvider(_ => new PhysicalFileImageProvider(
    Path.Combine(builder.Environment.ContentRootPath, "photos"), ProcessingBehavior.All, "/photos"));
 
var app = builder.Build();
app.UseOneImage();
app.Run();

Install RecursiveAI.OneImage.Web for this one. Every image under /photos is now served with the mark: /photos/photo.jpg as a 3000×3002 JPEG, and /photos/photo.jpg?width=600&format=webp as a 600×600 WebP, both marked. Processed results are cached on disk, so each size is watermarked once. Two settings matter:

  • ProcessingBehavior.All makes the middleware process requests without resize commands too. With the default, a plain request for the original falls through to the static file middleware unmarked. Here the originals also sit outside wwwroot, so there is no other way to reach them.
  • CacheSalt goes into every cache key. Change it when you change the watermark, so cached copies with the old mark are replaced.

Our guide to resizing images on the fly in ASP.NET Core covers the middleware’s commands, signed URLs and cache settings. To watermark once at upload time instead, apply Composite in your upload endpoint, as in the upload example of our resize guide.

Watermarks with System.Drawing and ImageSharp

Older watermark answers for C#, such as this Stack Overflow question, use System.Drawing’s Graphics.FromImage and DrawImage. Microsoft made System.Drawing.Common Windows-only in .NET 6, so that code fails on Linux servers and in most containers. Our page on that “not supported on this platform” error shows how to port it. If you’re moving from ImageSharp, its DrawImage corresponds to Composite, and the ImageSharp migration guide maps the rest.

A visible watermark discourages reuse but doesn’t prevent it: a corner logo can be cropped off, which is why previews use a tiled mark. For OneImage’s other operations and formats, see the OneImage overview.