1인 콘텐츠 개발실

[c#] 간단한 유틸을 만들어보자! - [DIY 그림판] 편 본문

개발&제작/프로그램

[c#] 간단한 유틸을 만들어보자! - [DIY 그림판] 편

표준라이브러리 2017. 4. 14. 16:22

해당 포스팅은 C#을 이용하여 간단한 유틸리티 프로그램을 개발하면서, 그 안에 쓰이는 라이브러리나 알고리즘(로직) 등을 다뤄보고 학습하는 포스팅입니다. 이 글을 통해 기존에 있던 프로그램과 비교하면서 해당 프로그램의 쓰임새, 동작 방식 등을 분석하거나 조금은 다른 관점으로 접근하여 생각해 보는것이 이 글과 앞으로 있을 포스팅의 목표입니다.





# 개요


이번에 다룰 유틸리티 프로그램은 [메모장]만큼이나 사용자들이 간편히 애용하는 [그림판]입니다. 사용자가 [그림판]을 이용하여 직접 그림을 그리거나, 이미 있는 사진, 그림, 이미지 등을 열고 편집하는 용도로 사용합니다. [메모장]이 텍스트 파일을 열고 편집할 수 있다면 [그림판]은 이미지 파일을 열고 편집할 수 있는 프로그램입니다. 이미지 파일의 종류는 비트맵 방식(.bmp, .jpg, .png......)과 벡터 방식(.svg......)으로 나뉘는데, 이 중에서 그림판은 비트맵 방식의 이미지 파일을 다룰 수 있습니다.



윈도우에서 제공하는 그림판 프로그램



그림판에서 발전된 형태의 프로그램들이 많이 존재하지만 그 중 하나를 꼽으라면 역시 Adobe사의 PhotoShop(이하, 포토샵)이 대표적인 프로그램이지 않을까 생각합니다. 포토샵은 그림을 직접 그리거나, 있는 기존 이미지의 수정/합성 작업 기능을 그림판 보다 더 강력하고 다양하게 제공하기 때문에, 그 기능들을 능숙하게 다룰 수 있는 사용자라면 복잡한 작업들을 손쉽게 수행할 수 있도록 도와줍니다. 따라서 포토샵은 디자이너, 사진 편집자 등등의 전문적인 직종에서 널리 사용하고 있고, 일반 사람들도 포토샵의 기능을 배우고 사용하고 있습니다.


그림판도 Windows 7으로 넘어오면서 UI가 바뀌고 브러쉬의 종류가 많아지는 등 변화가 이뤄지긴 했지만, 아쉽게도 전문적으로 사용하기에는 기능이 빈약합니다. 그래도 기본적인 그리기와 편집 기능이 충실히 구현되어 있고, 프로그램 동작이 매우 간편한 장점이 있어 드문드문 사용하기도 합니다.



그림판을 이용하여 실사를 표현하는 모습 (https://youtu.be/v2g5qbvb7F4)



포토샵을 비롯해 그림판의 대표적인 기능을 나열하면 다음과 같습니다.


- 마우스 혹은 태블릿을 이용하여 직접 그림을 그릴 수 있음.

- 이미지를 기하학적 변환/자르기/붙이기 등등의 방식으로 편집 할 수 있음.



이 중 [DIY 그림판]은 첫번째 기능인 마우스로 그림 그리는 기능을 집중적으로 다루고 구현할 것입니다.





# [DIY 그림판] 기능


[DIY 그림판]의 기능은 세부적으로 펜 모드로 그리기/지우기, 도형 그리기, 영역 색 채우기 등이 있고 이 때, 펜 크기(도형의 윤곽선도 같이 조절)와 색상을 선택할 수 있도록 하였습니다. 마지막으로 그린 그림을 저장할 수 있게 구현하였습니다.


기능에 대한 언급을 하기 전에, 기존의 그림판을 보면 기능 버튼들을 이미지로 표현하고 배열하여 그림판만의 UI를 구축한 모습을 볼 수 있습니다. 오늘날 사용자 인터페이스(UI)는 사용자가 프로그램을 사용하는데 있어서 중요한 요소로 자리매김 하였기 때문에 프로그램 개발 단계에서 UI를 적용하고 다룰 수 있어야 합니다. 


그래서 이번 [DIY 그림판]에서는 UI에 기본이 되는 아이콘 이미지 적용 방법에 대해 알려드리겠습니다.


일단 아이콘 이미지는 직접 그려서 사용해도 좋지만, 아이콘 파인더(https://www.iconfinder.com/) 사이트를 이용하면 원하는 형태의 아이콘 이미지를 간편하게 얻을 수 있습니다. 


아이콘 파인더 사이트에서 아이콘 이미지를 검색. 검색된 이미지 중에서 적합한 이미지를 다운 받아서 사용



아이콘 파인더에 대한 사용방법에 대해서는 이미 많은 수의 내용들이 검색되니 이를 참고하시면 좋을듯 하고, 어쨌든 다양하고 좋은 품질의 아이콘 이미지가 많다는 점에서 자주 애용하는 사이트입니다.


이미지를 선별한 후에는 프로젝트에 이미지들을 리소스로 등록합니다. [솔루션 탐색기]에서 [Properties]안에 [Resources.resx]를 더블클릭합니다. 이 후 [리소스 추가(R)] 버튼을 눌러 [기존 파일 추가(E)...] 버튼을 클릭하여 이미지 파일을 추가합니다. Resources.resx 영역에 파일을 직접 드래그 앤 드랍해도 추가 가능합니다.



Resource.resx에 이미지 리소스를 추가하는 모습



추가한 이미지들은 자동으로 Resources 폴더에 추가되므로, Resources 폴더를 직접 건드리지 않아도 됩니다. 리소스가 추가되었으면, 이제 버튼 아이콘을 적용할 수 있습니다. Form 디자인 창으로 가서, 아이콘을 적용할 버튼을 선택하고 BackgroundImage 속성값을 클릭하여 리소스 선택창을 통해 원하는 리소스를 선택하여 아이콘을 적용합니다.



이미지 리소스를 버튼 아이콘으로 적용하는 모습



추가한 이미지들은 자동으로 Resources 폴더에 추가되므로, Resources 폴더를 직접 건드리지 않아도 됩니다. 리소스가 추가되었으면, 이제 버튼 아이콘을 적용할 수 있습니다. Form.cs[디자인] 창으로 가서, 아이콘을 적용할 버튼을 선택하고 BackgroundImage 속성값에서 리소스 선택창을 통해 원하는 리소스를 선택하여 아이콘을 적용하면 됩니다.


Windows Form에 속한 거의 모든 도구 클래스들은 BackgroundImage 속성값을 통해 사용자가 원하는 이미지를 적용할 수 있으니 위의 과정처럼 진행하면 아이콘을 적용할 수 있습니다.


이제 본격적으로 [DIY 그림판]의 동작 방식에 대해 설명하도록 하겠습니다. 전체적인 흐름은 그리기 도구를 선택할 때마다 그에 따른 모드가 선택되고 모드에 따라 PictureBox 내에서 마우스 이벤트를 달리 사용합니다. 예를 들어 도형 모드가 선택되었을 때의 마우스 좌클릭(Mouse Down)과 색 채우기 모드일 때의 마우스 좌클릭이 다른 작업을 수행하게 됩니다. 즉, 모드에 따라 같은 이벤트 내에서 다른 로직을 수행 하는 방식입니다.

 


각 마우스 이벤트에 따라 수행하는 그리기 모드 작업



[그리기 모드 상수 선언]

enum DRAW_MODE : int
{
        PENMODE = 0,        // 펜 모드
        SHAPEMODE = 1,      // 도형 모드
        PAINTMODE = 2,      // 색 채우기 모드
        ERASERMODE = 3,     // 지우개 모드
        EDITMODE = 4        // 그 외 편집 모드
}


[그리기 모드에 따른 마우스 커서 변경]

private void SetDrawMode(int mode)
{
    switch (mode)
    {
        case (int)DRAW_MODE.PENMODE:
            curMode = (int)DRAW_MODE.PENMODE;
            this.Cursor = LoadCursor(Properties.Resources.PenCursor_small);
            break;
        case (int)DRAW_MODE.SHAPEMODE:
            curMode = (int)DRAW_MODE.SHAPEMODE;
            this.Cursor = LoadCursor(Properties.Resources.ShapesCursor);
            break;
        case (int)DRAW_MODE.PAINTMODE:
            curMode = (int)DRAW_MODE.PAINTMODE;
            this.Cursor = LoadCursor(Properties.Resources.PaintCursor);
            break;
        case (int)DRAW_MODE.ERASERMODE:
            curMode = (int)DRAW_MODE.ERASERMODE;
            this.Cursor = LoadCursor(Properties.Resources.EraserCursor);
            break;
        case (int)DRAW_MODE.EDITMODE:
            this.Cursor = Cursors.Default;
            break;
        default:
            this.Cursor = Cursors.Default;
            break;
    }
}

private Cursor LoadCursor(byte[] cursorFile)
{
    MemoryStream cursorMemoryStream = new MemoryStream(cursorFile);
    Cursor hand = new Cursor(cursorMemoryStream);

    return hand;
}


그리기 모드를 상수화하였고, 도구를 선택할 때 마다 PictureBox 내에서 마우스 포인터를 다르게 표시하도록 처리하였습니다. (ex: 색 채우기 모드일 때는 페인트 모양의 커서, 지우기 모드일 때는 지우개 모양의 커서)



[PictureBox에서 Mouse Move 이벤트]

private void pictureBox1_MouseMove(object sender, MouseEventArgs e)
{
    if ((curMode == (int)DRAW_MODE.PENMODE || curMode == (int)DRAW_MODE.ERASERMODE) && e.Button == MouseButtons.Left)
    {
        Point curPoint = pictureBox1.PointToClient(new Point(Control.MousePosition.X, Control.MousePosition.Y));

        Pen p;
        if (curMode == (int)DRAW_MODE.ERASERMODE)
            p = new Pen(Color.White);
        else
            p = new Pen(curColor);

        p.Width = curLineSize;

        Graphics g = Graphics.FromImage(pictureBoxBmp);
        g.DrawEllipse(p, curPoint.X, curPoint.Y, p.Width, p.Width);
        pictureBox1.Image = pictureBoxBmp;

        p.Dispose();
        g.Dispose();
    }
}


e.Button == MouseButtons.Left 조건식을 통해 마우스를 좌클릭한 상태로 이동 중일 때만 해당 작업을 수행하도록 하였고, 펜 모드(PENMODE)와 지우개 모드(ERASERMODE)를 구분하여 펜 모드일 때는 색상을 현재 선택된 색상(curColor)을, 지우개 모드일 때는 흰 색으로 설정하였습니다.


펜 모드와 지우개 모드는 MouseMove 이벤트가 주기적으로 호출되는 점을 이용하여 호출될 때마다 해당 지점에 지정한 사이즈만한 원을 그리도록 처리하였습니다. MouseMove 이벤트가 호출되는 주기보다 마우스를 빠르게 움직이면 경로 사이사이 비게 되는 현상이 존재합니다. 그림판에서도 이를 방지하기 위해 움직였던 경로를 계산하여 선을 한번에 그려주고 있었습니다만, [DIY 그림판]에서는 해당 현상을 일단 놔두기로 하였습니다.



마우스를 빠르게 이동한 탓에 경로 사이사이에 검은색 원이 비어있는 모습




Mouse Down과 Mouse Up 이벤트에서는 도형 모드와 색 채우기 모드에 대한 처리를 수행합니다. 



[PictureBox에서 Mouse Down, Mouse Up 이벤트]

private void pictureBox1_MouseDown(object sender, MouseEventArgs e)
{
    if (curMode == (int)DRAW_MODE.SHAPEMODE && e.Button == MouseButtons.Left)
    {
        mouseDownPoint = new Point(e.X, e.Y);
    }
    else if (curMode == (int)DRAW_MODE.PAINTMODE && e.Button == MouseButtons.Left)
    {
        Point startPoint = new Point(e.X, e.Y);
        Color preColor = pictureBoxBmp.GetPixel(startPoint.X, startPoint.Y);
        doFloodFill(startPoint, preColor);
        pictureBox1.Image = pictureBoxBmp;
    }
}

private void pictureBox1_MouseUp(object sender, MouseEventArgs e)
{
    if(curMode == (int)DRAW_MODE.SHAPEMODE && e.Button == MouseButtons.Left)
    {
        Pen p = new Pen(curColor);
        p.Width = curLineSize;

        Point mouseUpPoint = new Point(e.X, e.Y);
                    
        Graphics g = Graphics.FromImage(pictureBoxBmp);
        g.DrawRectangle(p, new Rectangle(mouseDownPoint.X, mouseDownPoint.Y, Math.Abs(mouseUpPoint.X - mouseDownPoint.X), Math.Abs(mouseUpPoint.Y - mouseDownPoint.Y)));
        pictureBox1.Image = pictureBoxBmp;

        p.Dispose();
        g.Dispose();
    }


도형 모드일 때 Mouse Down에서 현재 마우스 좌표를 mouseDownPoint 변수를 통해 기억해뒀다가, Mouse Up에서의 좌표와 계산하여 그 사이즈 만큼 도형을 그리게 됩니다. (26번째 줄) 도형은 현재 사각형 모양만 생성하도록 했지만, DrawRectangle함수를 다른것으로 대체하면 다른 다각형 모양도 쉽게 생성 가능합니다.


색 채우기 모드에서는 doFloodFill 함수를 실행하고 그 결과를 PictureBox Image로 저장하도록 처리하였습니다.



[Stack 클래스를 이용한 영역 색 채우기 알고리즘]

private void doFloodFill(Point startPoint, Color preColor)
{
    try
    {
        Stack<point> pixels = new Stack<point>();
        preColor = pictureBoxBmp.GetPixel(startPoint.X, startPoint.Y);
        pixels.Push(startPoint);

        while (pixels.Count > 0)
        {
            Point i = pixels.Pop();
            if (i.X < pictureBoxBmp.Width && i.X > 0 && i.Y < pictureBoxBmp.Height && i.Y > 0)
            {
                if (pictureBoxBmp.GetPixel(i.X, i.Y) == preColor)
                {
                    pictureBoxBmp.SetPixel(i.X, i.Y, curColor);
                    pixels.Push(new Point(i.X - 1, i.Y));
                    pixels.Push(new Point(i.X + 1, i.Y));
                    pixels.Push(new Point(i.X, i.Y - 1));
                    pixels.Push(new Point(i.X, i.Y + 1));
                }
            }
        }
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message);
    }
}


영역 채우기에 대한 알고리즘을 찾아보니 재귀 호출을 이용해서 시작 지점 (x, y)로 부터 -1, +1 하면서 한단계씩 채워가는 알고리즘이 있었는데, 픽셀 수가 아무래도 1만개, 10만개 넘어가다 보니 복잡도가 천문학적인 숫자가 나와서인지 StackOverFlow 오류가 발생하였습니다. 이를 해결하기 위한 방법으로 Stack 클래스를 사용하면, StackOverFlow가 발생하지 않으면서 좀 더 빠르게 처리할 수 있다고 하여 이를 적용하였습니다.



[PictureBox 내용 저장]

private void button6_Click(object sender, EventArgs e)
{
    SaveFileDialog saveFileDialog = new SaveFileDialog();
    saveFileDialog.FileName = "paint1.png";
    saveFileDialog.Filter = "PNG File|*.png|Bitmap File|*.bmp|JPEG File|*.jpg";

    if (pictureBox1.Image == null)
    {
        return;
    }

    if (saveFileDialog.ShowDialog() == DialogResult.OK)
    {
        FileStream fs = (FileStream)saveFileDialog.OpenFile();
        switch (saveFileDialog.FilterIndex)
        {
            case 1:
                this.pictureBox1.Image.Save(fs, ImageFormat.Png);
                break;

            case 2:
                this.pictureBox1.Image.Save(fs, ImageFormat.Bmp);
                break;

            case 3:
                this.pictureBox1.Image.Save(fs, ImageFormat.Jpeg);
                break;
        }

        fs.Dispose();
        fs.Close();
    }  
}


이미지 파일 저장은 Image 클래스에 Save 메서드가 따로 있어서 비교적 쉽게 처리할 수 있습니다. 기본적으로 .png 파일 형식으로 저장하게 설정하였고, 다른 이미지 파일 형식도 고를 수 있도록 하였습니다.






# 완성 및 마무리


- [DIY 그림판] 소스 파일 : https://github.com/hyunil-stdlib/SimplePaint

(소스코드와 실행 파일을 받으실 수 있습니다.)



[DIY 그림판] 프로그램 구동 모습


위의 그림을 저장한 이미지



이번 [DIY 그림판] 포스팅은 이것으로 마치고, 다음에 올릴 포스팅도 기대해주시면 감사하겠습니다.



# 참조

- 아이콘 파인더 : https://www.iconfinder.com/

- 마이크로소프트 Developer Network (msdn)  : https://msdn.microsoft.com/ko-kr/library/618ayhy6.aspx

Flood Fill 알고리즘 : https://simpledevcode.wordpress.com/2015/12/29/flood-fill-algorithm-using-c-net/

- Unbelievably Realistic Microsoft Paint Art : Santa Claus Speed Painting Time Lapse : https://youtu.be/v2g5qbvb7F4


Comments