Line, Circle, Rectangle 과 같이 이미지 위에 도형 그리기 및 UI 계층 구조를 분석하여 WPF OpenCV 프로젝트에 추가하겠습니다. 지난 포스팅(#12)까지 우리는 ROI(관심 영역)를 사각형으로 그리고, 수정하고, 저장하는 기능까지 모두 구현했습니다. 사실상 ROI 기능은 그것으로 충분해 보입니다.
하지만 영상 처리를 진행하다 보면 단순히 네모난 영역만 자르는 게 아니라, 두 지점 사이의 거리를 측정(Line)하거나, 동그란 제품의 크기를 재거나(Circle), 특정 영역의 균일도(Uniformity)를 봐야 하는 경우가 빈번합니다.
그래서 이번에는 ROI 사각형을 넘어, 선(Line), 원(Circle), 사각형(Rectangle) 등 다양한 도형을 그리는 기능을 추가해 보겠습니다.
UI 계층 구조
본격적인 코딩에 앞서, 우리가 만든 UI 구조를 한번 짚고 넘어갑시다. 이 구조는 제가 “WPF 영상 처리 UI의 정석“이라고 감히 부르고 싶은 패턴입니다. 아래의 UI 이미지에 계층 구조를 표현했는데, 이미지와 아래의 계층 구조를 같이 참고해 주세요.

계층 구조
ZoomBorder (최상위) └── ImgCanvas (무한 영역) ├── ImgView (원본 이미지) └── OverlayCanvas (투명 도화지)
왜 이렇게 복잡하게 만들었을까요? 다 이유가 있습니다.
- ZoomBorder (할아버지): 화면의 틀을 잡고 마우스 입력(휠, 클릭, 드래그)을 총괄합니다. 이미지가 10배 확대되어 화면 밖으로 튀어나가도
ClipToBounds="True"속성 덕분에 UI가 깨지지 않게 막아줍니다. - ImgCanvas (아버지): 자식들이 아무리 커져도(고화질 이미지) 군말 없이 받아주는 완충지대입니다. 이게 없으면
Fit to Screen같은 기능을 구현할 때 좌표계가 꼬여버립니다. - ImgView (형): 실제 이미지를 보여줍니다. 확대/축소(
RenderTransform)의 주인공이죠. - OverlayCanvas (동생): 여기가 핵심입니다! 이미지 위에 그림을 그리는 투명 필름입니다. 형(
ImgView)과 바인딩(Binding ElementName=ImgView)되어 있어서, 형이 커지면 나도 커지고, 형이 움직이면 나도 따라 움직입니다.
덕분에 우리는 “이미지가 확대된 상태인가?”를 고민할 필요 없이, 그냥 이미지 좌표 기준으로 그리기만 하면 됩니다. 나머지는 WPF가 알아서 동기화해주니까요.
XAML :메뉴 추가
먼저 사용자가 어떤 도형을 그릴지 선택할 수 있게 메뉴를 추가합니다. MainWindow.xaml의 리소스(Resource) 영역에 아래 코드를 추가해 주세요.
<ContextMenu x:Key="DrawingContextMenu">
<MenuItem Header="Draw Roi (관심 영역)" Click="Menu_DrawRoi_Click" />
<Separator />
<MenuItem Header="Draw Line (직선 그리기)" Click="Menu_DrawLine_Click" />
<MenuItem Header="Draw Circle (원 그리기)" Click="Menu_DrawCircle_Click" />
<MenuItem Header="Draw Rectangle (사각형 그리기)" Click="Menu_DrawRect_Click" />
<Separator />
<MenuItem Header="Clear All (지우기)" Click="Menu_Clear_Click" />
<Separator />
<MenuItem Header="Fit to Screen (화면 맞춤)" Click="Menu_Fit_Click" />
</ContextMenu>
그리기 로직 구현 (Code Behind)
이제 MainWindow.xaml.cs로 가서 실제 그리기 로직을 작성합니다.
변수 및 모드 설정
먼저 현재 그리기 모드와 임시 도형을 저장할 변수를 선언합니다.
// 그리기 관련 변수
private DrawingMode _currentDrawMode = DrawingMode.None;
private Point _drawStartPoint; // 시작점 (이미지 기준 좌표)
private Shape _tempShape; // 현재 그리고 있는 임시 도형
// 메뉴 클릭 이벤트
private void Menu_DrawLine_Click(object sender, RoutedEventArgs e)
{
_currentDrawMode = DrawingMode.Line;
Cursor = Cursors.Cross;
}
private void Menu_DrawCircle_Click(object sender, RoutedEventArgs e)
{
_currentDrawMode = DrawingMode.Circle;
Cursor = Cursors.Cross;
}
private void Menu_DrawRect_Click(object sender, RoutedEventArgs e)
{
_currentDrawMode = DrawingMode.Rectangle;
Cursor = Cursors.Cross;
}
MouseDown : 그리기 시작
사용자가 마우스를 클릭하면, 선택된 모드에 맞는 도형(Line, Ellipse, Rectangle)을 생성해서 OverlayCanvas에 올립니다. 아직 크기는 0입니다.
private void ZoomBorder_MouseDown(object sender, MouseButtonEventArgs e)
{
// ... (기존 휠 클릭 및 ROI 이동 로직 생략) ...
else if (e.ChangedButton == MouseButton.Left && ImgView.Source != null)
{
Point mousePos = e.GetPosition(ImgView);
// ... (기존 ROI 그리기 로직 생략) ...
// [추가] 일반 도형 그리기 시작
if (_currentDrawMode != DrawingMode.Roi && _currentDrawMode != DrawingMode.None)
{
_drawStartPoint = mousePos;
if (_currentDrawMode == DrawingMode.Line)
{
_tempShape = new Line()
{
Stroke = Brushes.Yellow, StrokeThickness = 2,
X1 = _drawStartPoint.X, Y1 = _drawStartPoint.Y,
X2 = _drawStartPoint.X, Y2 = _drawStartPoint.Y
};
}
else if (_currentDrawMode == DrawingMode.Circle)
{
_tempShape = new Ellipse()
{
Stroke = Brushes.Lime, StrokeThickness = 2,
Width = 0, Height = 0
};
Canvas.SetLeft(_tempShape, _drawStartPoint.X);
Canvas.SetTop(_tempShape, _drawStartPoint.Y);
}
else if (_currentDrawMode == DrawingMode.Rectangle)
{
_tempShape = new Rectangle()
{
Stroke = Brushes.Cyan, StrokeThickness = 2,
Width = 0, Height = 0
};
Canvas.SetLeft(_tempShape, _drawStartPoint.X);
Canvas.SetTop(_tempShape, _drawStartPoint.Y);
}
if (_tempShape != null)
{
OverlayCanvas.Children.Add(_tempShape); // 도화지에 추가
ZoomBorder.CaptureMouse(); // 마우스 권한 획득
}
}
}
}
MouseMove : 그리기
마우스를 드래그할 때 실시간으로 도형의 크기를 업데이트합니다. 사각형이나 원을 그릴 때 마우스를 왼쪽이나 위로 드래그하는 경우(음수 좌표)까지 고려해야 하므로 Math.Min과 Math.Abs를 사용합니다.
private void ZoomBorder_MouseMove(object sender, MouseEventArgs e)
{
// ... (기존 Pan, ROI, Resize 로직 생략) ...
// [추가] 도형 그리기 로직
else if (_currentDrawMode != DrawingMode.None && _tempShape != null && e.LeftButton == MouseButtonState.Pressed)
{
Point currentPos = e.GetPosition(ImgView);
// 1. 직선 그리기: 끝점(X2, Y2)만 업데이트
if (_currentDrawMode == DrawingMode.Line)
{
var line = _tempShape as Line;
line.X2 = currentPos.X;
line.Y2 = currentPos.Y;
}
// 2. 원 & 사각형 그리기: 위치(Left, Top)와 크기(Width, Height) 계산
else if (_currentDrawMode == DrawingMode.Circle || _currentDrawMode == DrawingMode.Rectangle)
{
double x = Math.Min(_drawStartPoint.X, currentPos.X);
double y = Math.Min(_drawStartPoint.Y, currentPos.Y);
double w = Math.Abs(currentPos.X - _drawStartPoint.X);
double h = Math.Abs(currentPos.Y - _drawStartPoint.Y);
_tempShape.Width = w;
_tempShape.Height = h;
Canvas.SetLeft(_tempShape, x);
Canvas.SetTop(_tempShape, y);
}
}
}
MouseUp : 그리기 완료
마우스를 놓으면 그리기를 멈추고 임시 변수를 비웁니다.
private void ZoomBorder_MouseUp(object sender, MouseButtonEventArgs e)
{
// ... (기존 로직들) ...
// [추가] 도형 그리기 종료
else if (_currentDrawMode != DrawingMode.None && _tempShape != null)
{
ZoomBorder.ReleaseMouseCapture();
_currentDrawMode = DrawingMode.None;
_tempShape = null;
Cursor = Cursors.Arrow;
}
}
실행 결과
이제 실행해 볼까요? 이미지를 불러오고 우클릭 메뉴에서 Draw Line, Draw Circle 등을 선택한 뒤 마음껏 드래그해 보세요. 이미지를 확대하거나 이동해도 그려둔 도형들이 이미지에 찰싹 붙어서 잘 따라다니는 것을 확인할 수 있습니다.
이제 UI 기능 구현은 얼추 마무리되었습니다. 다음 포스팅부터는 이렇게 그린 도형들을 활용해서 진짜 OpenCV 영상 처리를 어떻게 하는지 다뤄보도록 하겠습니다.
참고 자료
WPF Shapes: Line, Rectangle, Ellipse 등 WPF 기본 도형 클래스에 대한 설명입니다.
https://learn.microsoft.com/ko-kr/dotnet/desktop/wpf/graphics-multimedia/shapes-and-basic-drawing-in-wpf-overview
OverlayCanvas (Canvas Class): 절대 좌표를 사용하는 Canvas 패널의 특성을 이해하는 데 도움이 됩니다.
https://learn.microsoft.com/ko-kr/dotnet/api/system.windows.controls.canvas
ZIndex (Panel.ZIndex): 여러 도형이 겹칠 때 순서를 정하는 속성입니다. (이번엔 사용 안 했지만 알아두면 좋습니다.)
https://learn.microsoft.com/ko-kr/dotnet/api/system.windows.controls.panel.zindex