Search code examples
c#imagewinformsalphaerase

Erase Part of Bitmap with another Bitmap


Let me preface this with a real life product; You may remember in Elementary school, they had scratch paper which basically consisted of a rainbow-colored sheet of paper with a black film on top. You would take a sharp object and peel away the black film to expose the colored paper.

I am attempting to do the same thing using images in a picture box.

My idea consists of these things:

  • A textured image.
  • A black rectangle the size of the picture box.
  • A circle image.

What I am trying to achieve is to open a program, have an image drawn to a picture box with the black rectangle on top of it. Upon clicking the picture box it uses the circle to invert the alpha of the rectangle where I click using the circle as a reference.

  • My Problem- I cannot figure out any way to erase (set the transparency of) a part of the black rectangle where I click.

For the life of me, I do not know of any method to cut a window in an image. It is almost like a reverse crop, where I keep the exterior elements rather than the interior, exposing the textured image below.

Can WinForms not do this? Am I crazy? Should I just give up?

I should mention that I prefer not to have to change alpha on a pixel per pixel basis. It would slow the program down far too much to be used as a pseudo-painter. If that is the only way, however, feel free to show.

Here is an image of what I'm trying to achieve:

enter image description here


Solution

  • This is not really hard:

    • Set the colored image as a PictureBox's BackgroundImage.
    • Set a black image as its Image.
    • And draw into the image using the normal mouse events and a transparent Pen..

    enter image description here

    We need a point list to use DrawCurve:

    List<Point> currentLine = new List<Point>();
    

    We need to prepare and clear the the black layer:

    private void ClearSheet()
    {
        if (pictureBox1.Image != null) pictureBox1.Image.Dispose();
        Bitmap bmp = new Bitmap(pictureBox1.ClientSize.Width, pictureBox1.ClientSize.Height);
        using (Graphics G = Graphics.FromImage(bmp)) G.Clear(Color.Black);
        pictureBox1.Image = bmp;
        currentLine.Clear();
    }
    
    private void cb_clear_Click(object sender, EventArgs e)
    {
        ClearSheet();
    }
    

    To draw into the Image we need to use an associated Graphics object..:

    void drawIntoImage()
    {
        using (Graphics G = Graphics.FromImage(pictureBox1.Image))
        {
            // we want the tranparency to copy over the black pixels
            G.CompositingMode = System.Drawing.Drawing2D.CompositingMode.SourceCopy;
            G.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
            G.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality;
    
            using (Pen somePen = new Pen(Color.Transparent, penWidth))
            {
                somePen.MiterLimit = penWidth / 2;
                somePen.EndCap = System.Drawing.Drawing2D.LineCap.Round;
                somePen.LineJoin = System.Drawing.Drawing2D.LineJoin.Round;
                somePen.StartCap = System.Drawing.Drawing2D.LineCap.Round;
                if (currentLine.Count > 1)
                    G.DrawCurve(somePen, currentLine.ToArray());
            }
    
        }
        // enforce the display:
        pictureBox1.Image = pictureBox1.Image;
    }
    

    The usual mouse events:

    private void pictureBox1_MouseDown(object sender, MouseEventArgs e)
    {
        currentLine.Add(e.Location);
    }
    
    private void pictureBox1_MouseMove(object sender, MouseEventArgs e)
    {
        if (e.Button == System.Windows.Forms.MouseButtons.Left)
        {
            currentLine.Add(e.Location);
            drawIntoImage();
        }  
    }
    
    private void pictureBox1_MouseUp(object sender, MouseEventArgs e)
    {
        currentLine.Clear();
    }
    

    That's all that's needed. Make sure to keep the PB's SizeMode = Normal or else the pixels won't match..!

    Note that there are a few challenges when you want to get soft edges, more painting tools, letting a simple click paint a dot or an undo or other finer details to work. But the basics are not hard at all..

    Btw, changing Alpha is not any different from changing the color channels.

    As an alternative you may want to play with a TextureBrush:

    TextureBrush brush = new TextureBrush(pictureBox1.BackgroundImage);
    
    using (Pen somePen = new Pen(brush) )
    {
      // basically 
      // the same drawing code.. 
    }
    

    But I found this to be rather slow.

    Update:

    Using a png-file as a custom tip is a little harder; the main reason is that the drawing is reversed: We don't want to draw the pixels, we want to clear them. GDI+ doesn't support any such composition modes, so we need to do it in code.

    To be fast we use two tricks: LockBits will be as fast as it gets and restricting the area to our custom brush tip will prevent wasting time.

    Let's assume you have a file to use and load it into a bitmap:

    string stampFile = @"yourStampFile.png";
    Bitmap stamp = null;
    
    private void Form1_Load(object sender, EventArgs e)
    {
        stamp = (Bitmap) Bitmap.FromFile(stampFile);
    }
    

    Now we need a new function to draw it into our Image; instead of DrawCurve we need to use DrawImage:

    void stampIntoImage(Point pt)
    {
        Point point =  new Point(pt.X - stamp.Width / 2, pt.Y - stamp.Height / 2);
        using (Bitmap stamped = new Bitmap(stamp.Width, stamp.Height) )
        {
            using (Graphics G = Graphics.FromImage(stamped))
            {
                stamp.SetResolution(stamped.HorizontalResolution, stamped.VerticalResolution);
                G.CompositingMode = System.Drawing.Drawing2D.CompositingMode.SourceOver;
                G.DrawImage(pictureBox1.Image, 0, 0, 
                            new Rectangle(point, stamped.Size), GraphicsUnit.Pixel);
                writeAlpha(stamped, stamp);
            }
            using (Graphics G = Graphics.FromImage(pictureBox1.Image))
            {
                G.CompositingMode = System.Drawing.Drawing2D.CompositingMode.SourceCopy;
                G.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
                G.CompositingQuality = 
                   System.Drawing.Drawing2D.CompositingQuality.HighQuality;
                G.DrawImage(stamped, point);
            }
        }
        pictureBox1.Image = pictureBox1.Image;
    }
    

    A few notes: I found that I hat to do an explicit SetResolution since the stamp file I photoshopped was 72dpi and the default Bitmaps in my program were 120dpi. Watch out for these differences!

    I start the Bitmap to be drawn by copying the right part of the current Image.

    Then I call a fast routine that applies the alpha of the stamp to it:

    void writeAlpha(Bitmap target, Bitmap source)
    {
       // this method assumes the bitmaps both are 32bpp and have the same size
        int Bpp = 4;  
        var bmpData0 = target.LockBits(
                        new Rectangle(0, 0, target.Width, target.Height),
                        ImageLockMode.ReadWrite, target.PixelFormat);
        var bmpData1 = source.LockBits(
                        new Rectangle(0, 0, source.Width, source.Height),
                        ImageLockMode.ReadOnly, source.PixelFormat);
    
        int len = bmpData0.Height * bmpData0.Stride;
        byte[] data0 = new byte[len];
        byte[] data1 = new byte[len];
        Marshal.Copy(bmpData0.Scan0, data0, 0, len);
        Marshal.Copy(bmpData1.Scan0, data1, 0, len);
    
        for (int i = 0; i < len; i += Bpp)
        {
            int tgtA = data0[i+3];        // opacity
            int srcA = 255 - data1[i+3];  // transparency
            if (srcA > 0) data0[i + 3] = (byte)(tgtA < srcA ? 0 : tgtA - srcA);
        }
        Marshal.Copy(data0, 0, bmpData0.Scan0, len);
        target.UnlockBits(bmpData0);
        source.UnlockBits(bmpData1);
    }
    

    I use a simple rule: Reduce target opacity by the source transparency and make sure we don't get negative.. You may want to play around with it.

    Now all we need is to adapt the MouseMove; for my tests I have added two RadioButtons to switch between the original round pen and the custom stamp tip:

    private void pictureBox1_MouseMove(object sender, MouseEventArgs e)
    {
        if (e.Button == System.Windows.Forms.MouseButtons.Left)
        {
            if (rb_pen.Checked)
            {
                currentLine.Add(e.Location);
                drawIntoImage();
            }
            else if (rb_stamp.Checked) { stampIntoImage(e.Location); };
        }
    }
    

    I didn't use a fish but you can see the soft edges:

    enter image description here

    Update 2: Here is a MouseDown that allows for simple clicks:

    private void pictureBox1_MouseDown(object sender, MouseEventArgs e)
    {
       if (rb_pen.Checked) currentLine.Add(e.Location);
       else if (rb_stamp.Checked)
       {
           { stampIntoImage(e.Location); };
       }
    }