이번에는 ROI(관심 영역) 자르기와 저장 기능을 WPF OpenCV 프로젝트에 구현해 보죠.
지난 포스팅에서 OverlayCanvas를 이용해 ROI를 그릴 UI 뼈대를 만들었습니다. 이제 그 뼈대에 살을 붙여 실제로 작동하는 코드를 만들 차례입니다.
오늘은 작업량이 좀 있습니다.
- Model: OpenCV로 이미지를 자르고(
Crop) 저장하는 기능 구현 - ViewModel: UI와 Model을 연결
- View (Code Behind): 마우스로 사각형을 그리고 좌표를 계산하는 로직 구현
특히 마지막 좌표 계산 부분은 중요하니 끝까지 잘 따라와 주세요!
Model: OpenCVService에 자르기 기능 추가
영상 처리에서 관심 영역을 잘라내는 것을 **크롭(Crop)**이라고 하죠? OpenCVSharp을 이용해 원본 이미지(_srcImage)에서 특정 영역(Rect)을 잘라내고, 저장하는 함수를 OpenCVService.cs에 추가하겠습니다.
// OpenCVService.cs
public void CropImage(int x, int y, int w, int h)
{
if (_srcImage == null) return;
// ROI 영역 지정
OpenCvSharp.Rect roi = new OpenCvSharp.Rect(x, y, w, h);
// 이미지 자르기 (Clone으로 깊은 복사)
Mat newSrc = new Mat(_srcImage, roi).Clone();
// 기존 이미지 메모리 해제 및 교체
_srcImage.Dispose();
_srcImage = newSrc;
// 결과 이미지도 업데이트
if (_destImage != null) _destImage.Dispose();
_destImage = _srcImage.Clone();
// 화면 갱신을 위한 비트맵 변환
_cachedOriginal = _srcImage.ToWriteableBitmap();
_cachedProcessed = _destImage.ToWriteableBitmap();
}
public void SaveRoiImage(string filePath, int x, int y, int w, int h)
{
if (_srcImage == null) return;
OpenCvSharp.Rect roi = new OpenCvSharp.Rect(x, y, w, h);
// 이미지 범위 체크 (예외 방지)
if (roi.X < 0) roi.X = 0;
if (roi.Y < 0) roi.Y = 0;
if (roi.X + roi.Width > _srcImage.Width) roi.Width = _srcImage.Width - roi.X;
if (roi.Y + roi.Height > _srcImage.Height) roi.Height = _srcImage.Height - roi.Y;
using (Mat roiMat = new Mat(_srcImage, roi))
{
roiMat.SaveImage(filePath);
}
}
특별히 어려운 내용은 없습니다. OpenCvSharp.Rect로 범위를 지정해서 잘라내는 것이 핵심입니다.
ViewModel: 연결 다리 놓기
이제 MainViewModel.cs에서 방금 만든 함수들을 호출할 수 있도록 연결해 줍니다. UI에서 마우스 좌표(x, y, w, h)를 던져주면, 여기서 받아서 서비스로 넘기는 역할입니다.
// MainViewModel.cs
public void CropImage(int x, int y, int w, int h)
{
try
{
_cvServices.CropImage(x, y, w, h);
ShowOriginal = true; // 잘린 이미지가 원본이 되므로 원본 보기로 전환
UpdateDisplay();
AnalysisResult = $"이미지 자르기 완료 (크기:{w} x {h})";
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
}
public void SaveRoiImage(string path, int x, int y, int w, int h)
{
try
{
_cvServices.SaveRoiImage(path, x, y, w, h);
AnalysisResult = $"ROI 저장 완료: {path}";
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
}
View (Code Behind): 마우스 이벤트의 향연
자, 이제 가장 중요한 MainWindow.xaml.cs입니다. 여기서는 **”사용자가 마우스로 사각형을 그리는 동작“**을 구현해야 합니다.
3-1. 변수 및 열거형(Enum) 선언
먼저 클래스 내부에 필요한 변수들을 선언합니다. 나중에 도형 그리기 기능이 추가될 것을 대비해 Enum으로 모드를 관리하겠습니다.
public enum DrawingMode
{
None,
Roi
}
public partial class MainWindow : Window
{
// ... 기존 변수들 ...
// ROI 관련 변수
private bool _isRoiDrawing = false; // 현재 그리고 있는가?
private Point _roiStartPoint; // 시작점 (클릭한 곳)
private Rect _currentRoiRect; // 최종 계산된 ROI (이미지 기준)
// 그리기 모드
private DrawingMode _currentDrawMode = DrawingMode.None;
3-2. Context Menu (우-클릭) 처리
사용자가 우클릭을 했을 때, “배경에서 클릭했는지” vs **”이미 그려진 ROI 사각형 위에서 클릭했는지“**를 구분해야 합니다. ROI 위에서 클릭했다면 ‘자르기/저장’ 메뉴가 떠야 하고, 배경이라면 ‘ROI 그리기 시작’ 메뉴가 떠야 하니까요.
private void ZoomBorder_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
if (ImgView.Source == null) return;
bool isClickedOnRoi = false;
// ROI 사각형이 켜져 있고, 그 위를 클릭했는지 확인
if (RoiRect.Visibility == Visibility.Visible && _currentRoiRect.Width > 0)
{
Point mousePt = e.GetPosition(ImgView);
if (_currentRoiRect.Contains(mousePt)) isClickedOnRoi = true;
}
if (isClickedOnRoi) return; // ROI 위라면 XAML에 정의된 ROI 메뉴가 뜸
// 아니라면 배경 메뉴(그리기 시작 등)를 띄움
ContextMenu? menu = this.FindResource("DrawingContextMenu") as ContextMenu;
if (menu != null)
{
menu.PlacementTarget = sender as UIElement;
menu.IsOpen = true;
}
e.Handled = true;
}

3-3. MouseDown (그리기 시작)
메뉴에서 Draw Roi를 선택하면 커서(Cursor)가 십자가로 바뀌고 그리기 모드가 됩니다. 이제 왼쪽 마우스를 눌렀을 때 ROI 그리기를 시작하는 코드를 ZoomBorder_MouseDown에 추가합니다.
private void ZoomBorder_MouseDown(object sender, MouseButtonEventArgs e)
{
// ... 기존 휠 버튼(Pan) 로직 ...
// [추가] 왼쪽 버튼 클릭 & 이미지 로드 상태
else if (e.ChangedButton == MouseButton.Left && ImgView.Source != null)
{
Point mousePos = e.GetPosition(ImgView);
var bitmap = ImgView.Source as BitmapSource;
if (_currentDrawMode == DrawingMode.Roi)
{
// 마우스가 이미지 범위 안에 있을 때만 시작
if (mousePos.X >= 0 && mousePos.X < bitmap.PixelWidth &&
mousePos.Y >= 0 && mousePos.Y < bitmap.PixelHeight)
{
_isRoiDrawing = true;
_roiStartPoint = mousePos; // 시작점 저장
// 사각형 초기화 및 보이기
RoiRect.Visibility = Visibility.Visible;
RoiRect.Width = 0;
RoiRect.Height = 0;
UpdateRoiVisual(mousePos, mousePos); // 화면 갱신
ImgCanvas.CaptureMouse(); // 마우스 포커스 잡기
}
}
}
}
3-4. MouseMove (사각형 그리기)
마우스를 드래그(Mouse Drag)하는 동안 실시간으로 사각형이 커졌다 작아졌다 해야겠죠? ZoomBorder_MouseMove에 로직을 추가합니다.
private void ZoomBorder_MouseMove(object sender, MouseEventArgs e)
{
// ... 기존 Pan 로직 ...
// [추가] ROI 그리기 중이라면
else if (_isRoiDrawing)
{
Point currentPos = e.GetPosition(ImgView);
var bitmap = ImgView.Source as BitmapSource;
// 마우스가 이미지 밖으로 나가지 않게 제한 (Clamp)
if (currentPos.X < 0) currentPos.X = 0;
if (currentPos.Y < 0) currentPos.Y = 0;
if (currentPos.X > bitmap.PixelWidth) currentPos.X = bitmap.PixelWidth;
if (currentPos.Y > bitmap.PixelHeight) currentPos.Y = bitmap.PixelHeight;
// 시각적 업데이트 함수 호출
UpdateRoiVisual(_roiStartPoint, currentPos);
}
// ... 기존 좌표 표시 로직 ...
}
정상적으로 빌드가 되었다면 아래와 같이 ROI 영역 잡기가 동작할 겁니다.

3-5. 그리기 완료 (MouseUp)
마우스 버튼을 떼면 그리기를 멈추고 마우스 캡처(ReleaseMouseCapture)를 해제합니다. 아래 코드를 추가하여 작성해 주세요.
private void ZoomBorder_MouseUp(object sender, MouseButtonEventArgs e)
{
// ... 기존 Pan 종료 로직 ...
else if (_isRoiDrawing)
{
_isRoiDrawing = false;
ImgCanvas.ReleaseMouseCapture();
}
}
핵심: UpdateRoiVisual (좌표 변환의 마법)
이번 포스팅의 하이라이트입니다.
“이미지가 확대/이동 된 상태에서, 어떻게 사각형을 정확한 위치에 그릴 것인가?”
단순히 Canvas에 그리면 이미지가 확대될 때 사각형은 제자리에 있거나 엉뚱한 크기가 됩니다. 그래서 우리는 **”이미지 기준 좌표“**를 계산하고, 그것을 다시 **”현재 화면 기준 좌표“**로 변환해서 그려줘야 합니다.
private void UpdateRoiVisual(Point start, Point end)
{
// 1. 논리적 계산: 두 점을 이용해 X, Y, W, H 구하기 (이미지 기준)
double x = Math.Min(start.X, end.X);
double y = Math.Min(start.Y, end.Y);
double w = Math.Abs(end.X - start.X);
double h = Math.Abs(end.Y - start.Y);
// 나중에 자를 때 쓸 실제 데이터 저장
_currentRoiRect = new Rect(x, y, w, h);
// 2. 시각적 변환: 현재 줌 배율(Scale)과 이동 거리(Translate) 반영
double screenX = x * imgScale.ScaleX + imgTranslate.X;
double screenY = y * imgScale.ScaleY + imgTranslate.Y;
double screenW = w * imgScale.ScaleX;
double screenH = h * imgScale.ScaleY;
// 중요: RoiRect가 부모의 Transform을 따라가지 않도록 초기화 (충돌 방지)
RoiRect.RenderTransform = null;
// 화면에 그리기
RoiRect.Width = screenW;
RoiRect.Height = screenH;
Canvas.SetLeft(RoiRect, screenX);
Canvas.SetTop(RoiRect, screenY);
}
Tip!
RoiRect.RenderTransform = null; 이 코드가 없으면 사각형이 이중으로 확대되거나 이상한 곳으로 튈 수 있습니다. 정신 건강을 위해 꼭 넣어두시길 권장합니다.
Canvas.SetLeft: 사각형의 위치를 부모 캔버스(ImgCanvas) 내의 절대 좌표로 강제 지정하는 함수입니다.
(링크된 사이트를 참조하면, 왜 Canvas.SetLeft 를 써야 하는지, 좌표가 어떻게 계산되는지 이해하는데 많은 도움이 될 겁니다.)
실행 결과
이제 실행해 볼까요? 이미지를 불러오고, 우-클릭으로 Draw ROI를 선택한 뒤 드래그(Drag)하면 빨간색 사각형이 예쁘게 그려집니다.
그리고 그려진 사각형 위에서 다시 우-클릭하면 Crop Image 메뉴가 뜹니다.


아직 Crop Image 메뉴를 눌렀을 때 실제로 자르는 이벤트 연결(MenuItem_Crop_Click)은 안 했지만, UI와 좌표 계산 로직은 완벽하게 돌아갑니다.
다음 포스팅에서는 드디어 “자르기(Crop)” 버튼을 눌렀을 때의 동작과, 잘라낸 이미지를 저장하는 마무리를 하도록 하죠. ^^
참고자료
OpenCvSharp Rect:
https://shimat.github.io/opencvsharp_docs/html/T_OpenCvSharp_Rect.htm
WPF Canvas.SetLeft:
https://learn.microsoft.com/ko-kr/dotnet/api/system.windows.controls.canvas.setleft
Math.Min/Max:
https://learn.microsoft.com/ko-kr/dotnet/api/system.math.min
좌표 공간 변환:
https://learn.microsoft.com/ko-kr/dotnet/api/system.windows.uielement.translatepoint