전체 소스 코드

 

GitHub - 21june/FaceMaskDetection: C# WPF - Face Mask Detection

C# WPF - Face Mask Detection. Contribute to 21june/FaceMaskDetection development by creating an account on GitHub.

github.com

 

Nuget 패키지

 다음과 같이 Nuget 패키지를 등록합니다.

  <ItemGroup>
    <PackageReference Include="OpenCvSharp4" Version="4.10.0.20241108" />
    <PackageReference Include="OpenCvSharp4.Extensions" Version="4.10.0.20241108" />
    <PackageReference Include="OpenCvSharp4.runtime.win" Version="4.10.0.20241108" />
    <PackageReference Include="OpenCvSharp4.Windows" Version="4.10.0.20241108" />
    <PackageReference Include="OpenCvSharp4.WpfExtensions" Version="4.10.0.20241108" />
  </ItemGroup>

 

UI XAML 파일 (MainWindow.xaml)

 UI는 다음과 같은 소스 코드를 따릅니다.

<Window x:Class="MaskDetection.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:MaskDetection"
        mc:Ignorable="d"
        Title="MainWindow" Height="640" Width="1024">	
	<Grid>
		<Grid.ColumnDefinitions>
			<ColumnDefinition Width="0.6*" />
			<ColumnDefinition Width="0.4*" />
		</Grid.ColumnDefinitions>
        
		<!-- Left (Picture) --> 
		<Grid Grid.Column="0">
			<Image x:Name="image_cam" Stretch="Uniform"/>
		</Grid>
        
		<!-- Right (Menu) -->
		<Grid Grid.Column="1">
			<Grid.RowDefinitions>
				<!-- 1. Input-->
				<RowDefinition Height="0.3*" />
				<!-- 2. Model -->
				<RowDefinition Height="0.2*" />
				<!-- 3. Face Detection -->
				<RowDefinition Height="0.2*" />
				<RowDefinition Height="0.3*" />
			</Grid.RowDefinitions>

			
			<!-- 1. Input -->
			<GroupBox Grid.Row="0" Header="Input" Margin="5" FontSize="15">
				<Grid>
					<Grid.RowDefinitions>
						<!-- Camera -->
						<RowDefinition Height="0.5*" />
						<!-- Image -->
						<RowDefinition Height="1.0*" />
					</Grid.RowDefinitions>
					<!-- Camera -->
					<Grid Grid.Row="0">
						<Grid.ColumnDefinitions>
							<ColumnDefinition Width="0.3*" />
							<ColumnDefinition Width="0.7*" />
						</Grid.ColumnDefinitions>
						<TextBlock Grid.Column="0" Text="Camera" VerticalAlignment="Center" FontSize="20" />
						<Button Grid.Column="1" x:Name="button_cam" Content="Open" Click="ClickEvent" FontSize="20" Margin="10"/>
					</Grid>
                    
					<!-- Image -->
					<Grid Grid.Row="1">
						<Grid.RowDefinitions>
							<RowDefinition Height="0.5*" />
							<RowDefinition Height="0.5*" />
						</Grid.RowDefinitions>
						<!-- Load Button... -->
						<Grid Grid.Row="0">
							<Grid.ColumnDefinitions>
								<ColumnDefinition Width="0.3*" />
								<ColumnDefinition Width="0.7*" />
							</Grid.ColumnDefinitions>
							<TextBlock Grid.Column="0" Text="Image" VerticalAlignment="Center" FontSize="20"/>
							<Button x:Name="button_image" Grid.Column="1" Content="Load" Click="ClickEvent" FontSize="20" Margin="10"/>
						</Grid>
						<!-- Path Text... -->
						<TextBlock x:Name="text_image" Grid.Row="1" TextWrapping="Wrap" VerticalAlignment="Center" Text="No File" FontSize="15"/>
					</Grid>
				</Grid>
			</GroupBox>


			<!-- 2. Model -->
			<GroupBox Grid.Row="1" Header="Mask Detection" Margin="5" FontSize="15">
				<Grid>
					<Grid.RowDefinitions>
						<RowDefinition Height="0.5*" />
						<RowDefinition Height="0.5*" />
					</Grid.RowDefinitions>
					<!-- Load -->
					<Grid Grid.Row="0">
						<Grid.ColumnDefinitions>
							<ColumnDefinition Width="0.3*" />
							<ColumnDefinition Width="0.7*" />
						</Grid.ColumnDefinitions>
						<TextBlock Grid.Column="0" Text="File" VerticalAlignment="Center" FontSize="20" />
						<Button x:Name="button_model" Grid.Column="1" Content="Load" Click="ClickEvent" FontSize="15" Margin="5"/>
					</Grid>
					<!-- Path -->
					<TextBlock x:Name="text_model" Grid.Row="3" TextWrapping="Wrap" VerticalAlignment="Center" Text="No File" FontSize="15"/>
				</Grid>
			</GroupBox>


			<!-- 3. Face Detection -->
			<GroupBox Grid.Row="2" Header="Face Detection" Margin="5" FontSize="15">
				<Grid>
					<Grid.RowDefinitions>
						<RowDefinition Height="0.5*" />
						<RowDefinition Height="0.5*" />
					</Grid.RowDefinitions>
					<!-- Enable -->
					<Grid Grid.Row="0">
						<Grid.ColumnDefinitions>
							<ColumnDefinition Width="0.3*" />
							<ColumnDefinition Width="0.7*" />
						</Grid.ColumnDefinitions>
						<TextBlock Grid.Column="0" Text="Enable" VerticalAlignment="Center" FontSize="20" />
						<CheckBox Grid.Column="1" x:Name="check_facedet" Content="Auto Face-Cognition" VerticalAlignment="Center" Margin="10" Click="ClickEvent"/>
					</Grid>
					<!-- Confidence -->
					<Grid Grid.Row="1">
						<Grid.ColumnDefinitions>
							<ColumnDefinition Width="0.3*" />
							<ColumnDefinition Width="0.7*" />
						</Grid.ColumnDefinitions>
						<TextBlock Grid.Column="0" Text="Confidence" VerticalAlignment="Center" FontSize="20" />
						<Slider x:Name="slider_face_conf" Grid.Column="1" Minimum="0" Maximum="100" VerticalAlignment="Center" ValueChanged="SliderEvent" />

					</Grid>
				</Grid>
			</GroupBox>

		</Grid>

	</Grid>
	
</Window>

 

 

MainWindow.xaml.cs

 다음과 같이 변수들을 초기화했으며, OpenCvSharp.Dnn.CvDnn.ReadNetFromOnnx 함수를 통해 이전 포스팅에서 생성한 onnx 파일을 로드해줍니다. modelPath 값의 경우 onnx 파일 경로에 맞게 수정해주셔야 합니다.

public partial class MainWindow : Window
{
    VideoCapture m_capture;
    Thread t_cap;
    bool m_isRunning = false;
    bool b_facecog = true;

    OpenCvSharp.Dnn.Net net;
    OpenCvSharp.Size resz = new OpenCvSharp.Size(224, 224);

    // Mean & Standard Deviation
    bool b_meanstd = false;
    static float[] mean = new float[3] { 0.5703f, 0.4665f, 0.4177f };
    static float[] std = new float[3] { 0.2429f, 0.2231f, 0.2191f };

    float face_confidence = 0.5f;

    public MainWindow()
    {
        InitializeComponent();
        m_capture = new VideoCapture();

        // 모델 로드
        string mask_model = "resnet18_Mask_12K_None_EPOCH200_LR0.0001.onnx";
        net = OpenCvSharp.Dnn.CvDnn.ReadNetFromOnnx(mask_model);

        // UI 처리
        if (!net.Empty())	text_model.Text = "Model: " + mask_model;
        else				text_model.Text = "Model: " + "No Model";

        slider_face_conf.Value = (int)(face_confidence * 100);
        check_facedet.IsChecked = b_facecog = true;
    }

    ...
}

 

 ClickEvent 함수는 각 UI의 클릭 이벤트 시 호출되는 함수입니다.

1. button_cam의 경우 카메라를 연결하고 영상을 가져와 추론 후 결과를 출력하는 과정을 스레드 해제까지 수행합니다.

2. button_image은 파일 열기 다이얼로그를 실행하여 이미지 파일을 선택하고 해당 이미지를 불러와 추론 후 결과를 나타냅니다.

3. button_model은 파일 열기 다이얼로그를 실행하여 모델 파일(*.onnx)을 선택하고 해당 모델을 불러와 추론 시 사용합니다.

4. check_facedet은 체크하면 사람의 얼굴을 찾고 각 얼굴 별 마스크를 착용했는지 확인합니다. 체크 해제 시에는 전체 이미지를 검사합니다.

 SliderEvent 함수는 슬라이더의 값 변경 시 호출됩니다. 해당 값을 계산하여 confidence 값에 사용합니다. 

// 버튼 클릭 이벤트
private void ClickEvent(object sender, RoutedEventArgs e)
{
    if (sender.Equals(button_cam)) // 카메라 연결/해제 버튼
    {
        if (!m_isRunning)
        {
            if (t_cap != null && t_cap.IsAlive)
            {
                MessageBox.Show("Camera is closing... Wait..", "Error");
                return;
            }
            t_cap = new Thread(new ThreadStart(ThreadFunc));
            t_cap.IsBackground = true; // 프로그램 꺼질 때 쓰레드도 같이 꺼짐
            m_isRunning = true;
            t_cap.Start();
            button_cam.Content = "Close";
        }
        else
        {
            m_isRunning = false;
            image_cam.Source = null;
            button_cam.Content = "Open";
        }
    }

    else if (sender.Equals(button_image)) // 이미지 로드 버튼
    {

        OpenFileDialog openFileDialog = new OpenFileDialog();
        openFileDialog.Filter = "PNG files (*.png)|*.png|BMP files (*.bmp)|*.bmp|JPG files (*.jpg)|*.jpg|JPEG files (*.jpeg)|*.jpeg|All files (*.*)|*.*";
        if (openFileDialog.ShowDialog() == true)
        {
            Mat image = Cv2.ImRead(openFileDialog.FileName);
            if (!image.Empty()) text_image.Text = "Image: " + openFileDialog.SafeFileName;
            else text_image.Text = "Image: " + "No Model";
            Run(image);
        }
    }
    else if (sender.Equals(button_model)) // 모델 로드 버튼
    {

        OpenFileDialog openFileDialog = new OpenFileDialog();
        openFileDialog.Filter = "ONNX Weight files (*.onnx)|*.onnx|All files (*.*)|*.*"; // ONNX 파일만
        if (openFileDialog.ShowDialog() == true)
        {
            net = OpenCvSharp.Dnn.CvDnn.ReadNetFromOnnx(openFileDialog.FileName);
            if (!net.Empty())	text_model.Text = "Model: " + openFileDialog.SafeFileName;
            else				text_model.Text = "Model: " + "No Model";
        }
    }
    else if (sender.Equals(check_facedet)) // face detection 체크 박스
    {
        if (check_facedet.IsChecked == true)	b_facedet = true;
        else									b_facedet = false;
    }
}
// Slider 값 변경 이벤트
private void SliderEvent(object sender, RoutedPropertyChangedEventArgs<double> e)
{
    if (sender.Equals(slider_face_conf))
    {
        face_confidence = (float)slider_face_conf.Value / 100.0f;
    }
}

 

 ThreadFunc 함수입니다. 카메라를 Open 시킨 후 플래그 값(m_isRunning)이 바뀔 때까지 스레드 풀링(Pooling)합니다. 루프 안에서 카메라로부터 영상을 받아와 추론을 하고 결과를 출력합니다.

private void ThreadFunc() // 카메라를 연결하고 프레임을 읽고 추론까지 진행함
{
    m_capture.Open(0, VideoCaptureAPIs.DSHOW);

    Mat frame = new Mat();
    while (m_isRunning)
    {
        if (m_capture.IsOpened() == true)
        {
            m_capture.Read(frame);
            if (!frame.Empty())
                Run(frame);
            Thread.Sleep(10); // prevent for lag
        }
        else
        {
            m_isRunning = false;
            image_cam.Dispatcher.Invoke(() => { image_cam.Source = null; });
            button_cam.Dispatcher.Invoke(() => { button_cam.Content = "Open"; });
        }
    }
    if (m_capture.IsOpened())
        m_capture.Release();
}

 

 ImageROI 함수는 face detection을 수행하는지 안하는지 체크하고 해당하는 이미지를 리스트에 담아 리턴해주는 역할을 수행합니다.

private void ImageROI(Mat image,  out List<OpenCvSharp.Rect> faces)
{
    faces = new List<OpenCvSharp.Rect>();

    if (b_facedet == true)
    {
        FaceCrop(image, out faces);
    }
    else
    {
        OpenCvSharp.Rect rt = new OpenCvSharp.Rect(0, 0, image.Width, image.Height);
        faces.Add(rt);
    }

}

 

 

 ImageROI 내부에서 사용된 FaceCrop 함수입니다. Face Detection 기법들이 포함되어있습니다. 기본으로 얼굴 인식이 훈련된 SSD Model을 불러오게 합니다. HaarCascade 기법도 코드에 포함되어있지만 실사용하기에 매우 부족한 성능이라 코드 포함만 해뒀고 실제로 사용하진 않았습니다. 

// face detection for using ssd (or haarcascade)
private void FaceCrop(Mat image, out List<OpenCvSharp.Rect> list)
{
    list = new List<OpenCvSharp.Rect>();

    if (true) // SSD Model : Useful
    {
        OpenCvSharp.Dnn.Net facenet;
        // Download: https://github.com/spmallick/learnopencv/blob/master/FaceDetectionComparison/models/deploy.prototxt
        var prototext = "deploy.prototxt";
        // Download: https://github.com/spmallick/learnopencv/blob/master/FaceDetectionComparison/models/res10_300x300_ssd_iter_140000_fp16.caffemodel
        var modelPath = "res10_300x300_ssd_iter_140000_fp16.caffemodel";
        facenet = Net.ReadNetFromCaffe(prototext, modelPath);
        Mat inputBlob = CvDnn.BlobFromImage(
            image, 1, new OpenCvSharp.Size(300, 300), new OpenCvSharp.Scalar(104, 177, 123),
            false, false
        );
        facenet.SetInput(inputBlob, "data");
        string[] outputs = facenet.GetUnconnectedOutLayersNames();
        Mat outputBlobs = facenet.Forward("detection_out");
        Mat ch1Blobs = outputBlobs.Reshape(1, 1);

        int rows = outputBlobs.Size(2);
        int cols = outputBlobs.Size(3);
        long total = outputBlobs.Total();
        ch1Blobs.GetArray(out float[] data);
        if (data.Length == 1) return;

        for (int i = 0; i < rows; i++)
        {
            float confidence = data[i * cols + 2]; // Access confidence score

            // 설정된 confidence 값보다 클 경우만
            if (confidence > face_confidence)
            {
                int x1 = (int)(data[i * cols + 3] * image.Width);
                int y1 = (int)(data[i * cols + 4] * image.Height);
                int x2 = (int)(data[i * cols + 5] * image.Width);
                int y2 = (int)(data[i * cols + 6] * image.Height);

                OpenCvSharp.Rect rt = new OpenCvSharp.Rect(x1, y1, x2, y2);

                int centerX = (rt.Left + rt.Right) / 2;
                int centerY = (rt.Top + rt.Bottom) / 2;

                int width = x2 - x1;
                int height = y2 - y1;

                // 그냥 face recognition 하면, 얼굴이 너무 빡세게 잡혀서.. 가로세로 10% 정도씩 늘려줌.
                float face_scale_X = 0.1f;
                float face_scale_Y = 0.1f;
                if (x1 - (width * face_scale_X) < 0) x1 = 0;
                else x1 = x1 - (int)(width * face_scale_X);

                if (x2 + (width * face_scale_X) > image.Width) x2 = image.Width;
                else x2 = x2 + (int)(width * face_scale_X);

                if (y1 - (height * face_scale_Y) < 0) y1 = 0;
                else y1 = y1 - (int)(height * face_scale_Y);

                if (y2 + (height * face_scale_Y) > image.Height) y2 = image.Height;
                else y2 = y2 + (int)(height * face_scale_Y);

                OpenCvSharp.Rect item = new OpenCvSharp.Rect(x1, y1, x2 - x1, y2 - y1);
                list.Add(item);
            }
        }
    }

    if (false) // Cascade Classifier : Useless
    {
        // Download: https://github.com/mitre/biqt-face/tree/master/config/haarcascades
        string filenameFaceCascade = "haarcascade_frontalface_alt2.xml";
        CascadeClassifier faceCascade = new CascadeClassifier();
        if (!faceCascade.Load(filenameFaceCascade))
        {
            Console.WriteLine("error");
            return;
        }

        // detect 
        OpenCvSharp.Rect[] faces = faceCascade.DetectMultiScale(image);
        foreach (var item in faces)
        {
            list.Add(item);
            Cv2.Rectangle(image, item, Scalar.Red); // add rectangle to the image
            Console.WriteLine("faces : " + item);
        }
    }
}

 

 NormalizeImage는 이미지 정규화를 수행합니다. 기본적으로 255를 나눠서 0~1의 값으로 Normalize 시킨 다음에, Mean 값 만큼 빼주고 Std 값 만큼 나눠주는 식으로 수행합니다. 허나 이것도 구현만 해두었고 실제로 사용하진 않습니다. ImageNet의 mean std 값으로도 해봤고 제가 직접 구한 트레이닝 데이터셋의 mean std 값으로도 넣어봤지만 안한 상태가 결과로써 가장 유의미했기 때문입니다.

// 정규화 과정
private void NormalizeImage(ref Mat img)
{
    img.ConvertTo(img, MatType.CV_32FC3);
    Mat[] rgb = img.Split();

    // 0.0f~1.0f
    rgb[0] = rgb[0].Divide(255.0f); // B
    rgb[1] = rgb[1].Divide(255.0f); // G
    rgb[2] = rgb[2].Divide(255.0f); // R
    if (b_meanstd)
    {
        // mean
        rgb[2] = rgb[2].Subtract(new Scalar(mean[0])); // B
        rgb[1] = rgb[1].Subtract(new Scalar(mean[1])); // G
        rgb[0] = rgb[0].Subtract(new Scalar(mean[2])); // R

        // std
        rgb[2] = rgb[2].Divide(std[0]); // B
        rgb[1] = rgb[1].Divide(std[1]); // G
        rgb[0] = rgb[0].Divide(std[2]); // R
    }
    Cv2.Merge(rgb, img);
    Cv2.Resize(img, img, resz);
}

 

 

 마지막으로 추론하는 Inference 함수입니다.

// 추론 과정
private void Inference(Mat image, out int label, out double prob)
{
    if (net.Empty())
    {
        MessageBox.Show("No Found Model!");
        label = -1; prob = 0;
        return;
    }
    if (image.Empty()) {
        label = -1; prob = 0; return;
    }
    Mat resizedImage = image.Clone();
    Mat blob = new Mat();
    NormalizeImage(ref resizedImage); 
    blob = CvDnn.BlobFromImage(resizedImage, 1.0f,
        new OpenCvSharp.Size(224, 224), swapRB:true, crop:false);

    net.SetInput(blob);
    string[] outBlobNames = net.GetUnconnectedOutLayersNames();
    Mat[] outputBlobs = outBlobNames.Select(toMat => new Mat()).ToArray();
    Mat matprob = net.Forward("output");

    // 최대 값의 구하기
    double maxVal, minVal;
    OpenCvSharp.Point minLoc, maxLoc;
    Cv2.MinMaxLoc(matprob, out minVal, out maxVal, out minLoc, out maxLoc);
    label = maxLoc.X;
    prob = maxVal * 100;
}

 

 이제 하이라이트인 Inference(추론) 함수에 대해서는 자세히 살펴보겠습니다.

1. 이미지를 Normalize 한 후에 BlobFromImage 함수를 통해 적절히 파라미터를 입력하고 OpenCV의 DNN에 사용되는 데이터 형식인 Blob 형태로 만들어줍니다. 

(참고1) Blob 형태는 NCHW 포맷으로, N:데이터 개수, C:채널 개수, H:Height, W:Width입니다.

 

2. 생성된 Blob은 SetInput 함수를 통해 입력으로 넣어줍니다.

 

3. 다음에 Forward(OutputName)으로 추론을 진행합니다. 여기서 OutputName은 이전 포스팅의 모델 코드에서 SaveToONNX 함수에 output_names으로 설정한 값을 넣으시면 됩니다. 제 포스팅대로 진행했다면 "output"을 넣으시면 됩니다.

# pytorch의 모델을 onnx 포맷으로 저장
def SaveToONNX(model, onnx_save_path):
	...
	output_names = ['output']
	...
    # 모델 변환
    torch.onnx.export(model,                     # 실행될 모델
    				...
                    output_names = output_names  # 모델의 출력값을 가리키는 이름
    )
...

 

4. Forward의 결과로 나온 값을 MinMaxLoc 함수를 이용해 최소, 최대 값을 구하고 위치(여기서는 라벨의 번호)와 값을 Inference 함수의 리턴 값으로 넘겨줍니다.

 

 

 짜잔! 프로그램 완성입니다! Face Detection으로 얼굴의 ROI를 잡아주고 해당 얼굴 위치에 마스크 착용 유무를 체크합니다!

전체 소스 코드

 

PyroNote/MaskDetection at main · 21june/PyroNote

My DeepLearning Test Repository. Contribute to 21june/PyroNote development by creating an account on GitHub.

github.com

(ResNet 모델 코드에 대한 설명은 나중에 따로 논문 요약하면서 코드에 대해 포스팅을 올려볼 생각이 있어서 이번 프로젝트에서는 패스합니다. 모델쪽은 주석으로 대략 정리는 해두었으니 참고하시면 될 것 같습니다. 해당 모델 코드는 혁펜하임님의 Legend 13 강의의 소스 코드를 참고했습니다.)

 

임포트 및 디바이스 설정

 다음과 같이 임포트를 진행했습니다.

# pytorch
import torch 
from torch import nn, optim 
from torchvision import datasets, transforms 
from torch.utils.data import Dataset, DataLoader, random_split 
from torch.optim.lr_scheduler import _LRScheduler
import torchvision.models as models

# matplotlib
import matplotlib.pyplot as plt

# etc
import os
import math
import random
from tqdm import tqdm  # 진행도 측정용
import onnx
import onnxruntime
import numpy as np

# GPU가 인식되면 GPU 사용, 아니면 CPU 사용
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
print(DEVICE)
print(torch.__version__)

 

 

상수 값

 학습에 사용될 파라미터 값들을 입력합니다. 적절하게 파라미터를 조절해서 학습을 진행하시면 됩니다. 특별하게 해당 상수 값을 사용한 이유는 없고 주로 초기값으로 많이들 사용해서 저도 동일하게 사용해봤습니다.

 

BATCH_SIZE = 64 # batch size
LR = 1e-4 # learning rate
EPOCH = 200 # input num of epoch
num_classes = 2 # input num of classes
criterion = nn.CrossEntropyLoss() # for Loss
model_type = "resnet18" # select model
dataset = "Mask_12K" # using dataset
dataset_path = f"/mnt/e/Base_Dataset" # dataset path
save_model_path = f"/mnt/e/Results/{model_type}_{dataset}_EPOCH{EPOCH}_LR{LR}.pt" # model path to save
save_onnx_path = f"/mnt/e/Results/{model_type}_{dataset}_EPOCH{EPOCH}_LR{LR}.onnx" # onnx path to save
save_lastepoch_model_path = f"/mnt/e/Results/{model_type}_{dataset}_{mean_std_type}_EPOCH{EPOCH}_LR{LR}_LASTEPOCH.pt" # model path to save
save_lastepoch_onnx_path = f"/mnt/e/Results/{model_type}_{dataset}_{mean_std_type}_EPOCH{EPOCH}_LR{LR}_LASTEPOCH.onnx" # onnx path to save

train_share = 0.8 # percentage of train dataset 
valid_share = 0.1 # percentage of val dataset
test_share = 0.1 # # percentage of test dataset

(path 관련 값들은 안바꾸시면 오류나니까 꼭 수정하시고 사용해주세요.)

 

데이터 셋

 데이터 셋을 적당히 세팅해줍니다. 먼저 transform을 통해 간단히 전처리를 해줍니다. Resize를 224, 224로 맞춰준 것은 ResNet이 224, 224 사이즈의 입력 이미지를 기준으로 나온 논문이기 때문입니다. 당연히 다른 사이즈를 넣어도 되지만 최적의 학습을 위해서는 가능하면 저 값을 맞춰주는게 좋습니다.

 참고로, ToTensor()를 하면 픽셀 별 RGB 값들이 0~1의 값으로 변경됩니다. 따라서 ToTensor() 이후 Normalize()를 해줘야하며 반대로 할 경우 제대로 동작하지 않을 수 있습니다.

 Normalize의 값은 학습 데이터(여기서는 이미지 데이터)들의 Mean, Std를 구해서 평균 값을 R,G,B 각각 넣어주시면 됩니다. ImageNet은 ImageNet 데이터셋의 값이며, Mask12K는 제가 직접 구한 Mean, Std 값입니다.

(+추가) 실제로 학습하고 실제 영상으로 테스트해보니 Normalize로 mean, std를 안넣은게 가장 결과가 좋았습니다. 

if dataset == "Mask_12K":
    transform = transforms.Compose(
        [
            transforms.Resize((224, 224)),
            transforms.ToTensor(),
        ]
    )

    if mean_std_type == "Mask12K":
        transform = transforms.Compose(
            transform.transforms + [transforms.Normalize(mean=[0.5703, 0.4665, 0.4177], std=[0.2429, 0.2231, 0.2191])]
        )
    elif mean_std_type == "ImageNet":
        transform = transforms.Compose(
            transform.transforms + [transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])]
        )

 

 이어서, dataset을 적절한 위치를 지정해 로드한 후 앞서 입력한 share 비율만큼 나눠줍니다. 이후 사용자 정의 함수인 show_random_images를 사용해 랜덤하게 이미지를 로드한 후 라벨 값을 확인합니다. 데이터 라벨링이 적절하게 되어있는지 확인하는 과정이며 각 라벨링이 어떤 번호로 되어있는지 확인하기 위해 사용합니다.

    dataset_path = f"/mnt/e/Mask_12K" # 데이터셋 경로    
    train_DS = datasets.ImageFolder(root=dataset_path, transform=transform) 
    # Default: train 70%, valid 15%, test 15%
    train_size = int(train_share * len(train_DS))
    valid_size = int(valid_share * len(train_DS))
    test_size = len(train_DS) - train_size - valid_size
    train_DS, val_DS, test_DS = random_split(train_DS, [train_size, valid_size, test_size])

    train_DL = torch.utils.data.DataLoader(train_DS, batch_size=BATCH_SIZE, shuffle=True)
    val_DL = torch.utils.data.DataLoader(val_DS, batch_size=BATCH_SIZE, shuffle=True)
    test_DL = torch.utils.data.DataLoader(test_DS, batch_size=BATCH_SIZE, shuffle=True)

    train_loader = DataLoader(train_DS, batch_size=BATCH_SIZE, shuffle=True)
    val_loader = DataLoader(val_DS, batch_size=BATCH_SIZE, shuffle=True)
    test_loader = DataLoader(test_DS, batch_size=BATCH_SIZE, shuffle=False)
    
    # 적합하게 라벨링 되어있는지 랜덤으로 가져와서 테스트하는 용도
    dataloaders = { 'Train': train_DL, 'Valid':val_DL, 'Test': test_DL }
    show_random_images(dataloaders, n=10)

 라벨 0번이 마스크 착용, 1번이 마스크 미착용이네요. 라벨 값들과 이미지를 비교해보니 적절하게 잘 들어가 있는 모양입니다. 잘 기억해뒀다가 추론 할 때도 사용해봅시다.

 

모델 설정

 이후 모델을 선택합니다. ResNet은 레이어 깊이에 따라 여러 모델이 존재합니다. resnet 뒤에 붙은 숫자는 레이어의 수를 의미하니 높을수록 깊어져 학습이 더욱 정확해지지만 학습이 상당히 느려집니다. 이 프로젝트에선 깊은 단계까진 필요없고 resnet18정도로 진행해보겠습니다. 이후 모델의 형태를 텍스트로 확인합니다. 

# 사용할 모델 선택
if model_type == "resnet18":
    model = resnet18(num_classes=num_classes).to(DEVICE)
elif model_type == "resnet34":
    model = resnet34(num_classes=num_classes).to(DEVICE)
elif model_type == "resnet50":
    model = resnet50(num_classes=num_classes).to(DEVICE)
elif model_type == "resnet101":
    model = resnet101(num_classes=num_classes).to(DEVICE)
elif model_type == "resnet152":
    model = resnet152(num_classes=num_classes).to(DEVICE)
elif model_type == "resnet18_pretrained": # pytorch 모델 사용 + pretrained 사용
    model = models.resnet18(pretrained=True).to(DEVICE)
    num_features = model.fc.in_features
    model.fc = torch.nn.Linear(num_features, num_classes).to(DEVICE)
elif model_type == "resnet50_pretrained": # pytorch 모델 사용 + pretrained 사용
    model = models.resnet50(pretrained=True).to(DEVICE)
    num_features = model.fc.in_features
    model.fc = torch.nn.Linear(num_features, num_classes).to(DEVICE)

print(model) # 모델 구성 확인
x_batch, _ = next(iter(train_DL)) 
print(model(x_batch.to(DEVICE)).shape)
(Output)

ResNet(
  (conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)
  (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (relu): ReLU(inplace=True)
  (maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1, ceil_mode=False)
  (stage1): Sequential(
    (0): BasicBlock(
      (residual): Sequential(
        (0): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
        (1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
        (2): ReLU(inplace=True)
        (3): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
        (4): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
      (relu): ReLU(inplace=True)
    )
    (1): BasicBlock(
      (residual): Sequential(
        (0): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
        (1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
        (2): ReLU(inplace=True)
        (3): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
        (4): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
      (relu): ReLU(inplace=True)
...
  (avgpool): AdaptiveAvgPool2d(output_size=(1, 1))
  (fc): Linear(in_features=512, out_features=2, bias=True)
)
torch.Size([64, 2])

 마지막 줄의 torch.Size([64, 2])의 경우, train dataloader에서 한 번의 iterator인 이미지 64(=BATCH_SIZE)개 만큼 가져와서 model에 추론한것의 shape를 확인한 것입니다. 64개 이미지 각각의 라벨별(마스크O, 마스크X 2가지 라벨) 확률이 들어가 있습니다.

 

(예시)

model(x_batch.to(DEVICE))의 [0,0] 값 = 첫 번째 이미지에서 마스크 쓰고 있을 확률

model(x_batch.to(DEVICE))의 [0,1] 값 = 첫 번째 이미지에서 마스크 안 쓰고 있을 확률

 

 

학습 및 검증

 학습에 앞서 몇 가지 변수를 추가했습니다.

optimizer = optim.Adam(model.parameters(), lr=LR)

# ReduceLROnPlateau # https://pytorch.org/docs/stable/generated/torch.optim.lr_scheduler.ReduceLROnPlateau.html
# 후에 step에서 넣는 값(여기서는 val_loss)의 변화를 추적하여 LR 값을 조정하는 방식.
# 여기서는 val_loss가 연속적인 EPOCH 10(patience)회 동안 낮아지지(min) 않을 경우, 
# 새로운 LR 값을 LR * 0.1(factor)을 계산하여 생성한다. 단, 새로운 LR 값은 0(min_lr)을 넘어야한다.
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=10, threshold=1e-4, threshold_mode='rel', min_lr=0, verbose=False)

train_losses = []
val_losses = []

best_val_loss = float('inf')
optimizer 옵티마이저를 설정합니다. 여기서는 Adam을 사용합니다.
scheduler learing rate 값을 조절할 스케쥴러를 설정합니다. 상황에 따라 LR Scheduler를 안쓰는 경우도 있고 다른 알고리즘을 사용해도 되지만 여러 프로젝트를 봤을 때 ReduceLROnPlateau랑 CosineAnnealing 기법이 자주 사용되는 것 같습니다.
train_losses, val_losses plot에 각 EPOCH 별로 loss 값을 출력하기 위해 사용됩니다. 매 EPOCH마다 값이 저장됩니다.
best_val_loss val_loss가 낮아질 때마다 모델을 저장하기 위해서는 해당 값이 필요합니다. 

 

 

이제 본격적으로 학습에 진행합니다.

for ep in range(EPOCH):
    model.train() # train mode로 전환
    train_loss = 0.0

    with tqdm(train_DL) as tepoch: # 진행도 체크용
        # Training Loop
        for x_batch, y_batch in train_DL:
            tepoch.set_description(f"Epoch {ep+1} / {EPOCH}")
            x_batch = x_batch.to(DEVICE)
            y_batch = y_batch.to(DEVICE)

            optimizer.zero_grad() # gradient 누적을 막기 위한 초기화
            y_hat = model(x_batch) # inference
            loss = criterion(y_hat, y_batch)
            loss.backward() # backpropagation
            optimizer.step() # weight update

            train_loss += loss.item()

        train_loss /= len(train_DL)
        train_losses.append(train_loss)
        
        # eval mode로 전환
        model.eval()
        val_loss = 0.0
        with torch.no_grad(): # gradient 자동 계산 X / val, test땐 gradient 계산이 필요없으니
            for x_batch, y_batch in val_DL:
                x_batch = x_batch.to(DEVICE)
                y_batch = y_batch.to(DEVICE)
                y_hat = model(x_batch) # inference 결과
                loss = criterion(y_hat, y_batch) # loss check
                val_loss += loss.item()
        
        val_loss /= len(val_DL)
        val_losses.append(val_loss)        
        print(f"Epoch [{ep+1}/{EPOCH}], Train Loss: {train_loss:.4f}, Val Loss: {val_loss:.4f}")
        
        # LR Scheduler
        if scheduler is not None:
            # 앞서 확인했듯이 ReduceLROnPlateau는 매 사이클마다 확인하는 메트릭이 필요함. 여기서는 val_loss의 값을 기준으로 한다.
            if isinstance(scheduler, torch.optim.lr_scheduler.ReduceLROnPlateau): 
                scheduler.step(val_loss)
            else:
                scheduler.step()

        # 베스트 모델만 저장함. 여기서 베스트 모델은 val_loss 값이 낮은 것.
        if val_loss < best_val_loss:
            best_val_loss = val_loss
            torch.save(model, save_model_path)
            SaveToONNX(model, save_onnx_path)
            
# 마지막 EPOCH Weight 저장
torch.save(model, save_lastepoch_model_path)
SaveToONNX(model, save_lastepoch_onnx_path, x_batch)

 코드의 큰 흐름은 다음과 같습니다.

(1) train - mode 변경 (train)

(2) train - dataloader를 통해 이미지 및 라벨 한 묶음(BATCH_SIZE=64) 가져와서 x_batch, y_batch에 넣기

(3) train - x_batch, y_batch로 순전파, 역전파 진행 후 train_loss 체크하고 weight 값 업데이트

(4) val - mode 변경 (eval)

(5) val - dataloader를 통해 이미지 및 라벨 한 묶음(BATCH_SIZE=64) 가져와서 x_batch, y_batch에 넣기

(6) val - x_batch, y_batch로 순전파, 역전파 진행 후 val_loss 체크하기 (train이 아니라서 weight 값 업데이트는 안함)

(7) schedular를 통해 lr(learning rate)값 업데이트 (선택)

(8) ONNX 데이터 저장

(9) 1~8번의 과정을 dataloader 마지막 iteration까지 반복한다.

 

학습 결과 그래프

 다음과 같이 EPOCH에 따른 train_loss, val_loss 값 그래프를 그려보겠습니다. 

# EPOCH 별 val, train loss 체크
plt.plot(range(1, EPOCH+1), train_losses, 'b')
plt.plot(range(1, EPOCH+1), val_losses, 'r-')
plt.xlabel('EPOCH')
plt.ylabel('Loss')
plt.title('Train vs. Val Loss')
plt.grid()

 위와 같은 그래프가 나옵니다. 처음에는 많이 요동치다가 EPOCH 80쯤부터 잠잠해집니다. 이 프로젝트는 EPOCH를 100번정도로 해도 충분한 것 같네요.

 

테스트

 테스트는 학습, 검증 과정과 상당히 유사합니다. 다만 테스트는 EPOCH가 1번 뿐이며, weight를 업데이트 해주는 등의 과정을 안해도 된다고 보시면 됩니다. 테스트는 학습한 값을 가지고 추론해서 나온 값을 확인하는 단순한 과정이므로 당연한거겠죠?

model.eval()

test_loss = 0.0
correct = 0
total = 0

# Test Loop
with torch.no_grad():
    for x_batch, y_batch in test_loader:
        x_batch = x_batch.to(DEVICE)
        y_batch = y_batch.to(DEVICE)
        y_hat = model(x_batch) # inference
        loss = criterion(y_hat, y_batch)
        test_loss += loss.item()
        
        _, predicted = torch.max(y_hat, 1)
        correct += (predicted == y_batch).sum().item()
        total += y_batch.size(0)

accuracy = 100.0 * correct / total
test_loss /= len(test_loader)

print(f"Test Loss: {test_loss:.4f}, Accuracy: {accuracy:.2f}%")

 

(Output)
Test Loss: 0.3155, Accuracy: 99.77%

 똑같이 loss와 accuracy를 출력하고 테스트가 끝납니다. 정확도 99.77%로 엄청 잘 나왔네요! 결과물 파일도 다음과 같이 잘 나온걸 확인할 수 있습니다. Resnet18은 43MB, Resnet50은 92MB정도 나오네요.

 

 다음 포스팅은 이번 학습의 결과물로 나온 파일(onnx)를 가지고 실제 어플리케이션(C#, WPF)에서 로드 후 추론까지 진행하는 코드를 살펴보겠습니다.

 딥러닝 공부하면서 개인 프로젝트 주제를 생각해봤습니다. 딥러닝 공부 후 첫 프로젝트인 만큼 단순하면서도 현실에서 쓰일만한게 뭐가 있을까 고민을 했습니다.

 요즘 다시 독감, 코로나 환자가 많이 발생하고 미세먼지도 심해서 마스크 착용 유무를 판단하는 모델을 만든다면 현실에서 유용하지 않을까 생각이 들어 이 주제로 선택했습니다. 

 

1. 추론 프로그램 환경

 추론용 프로그램은 C#의 WPF 프레임워크를 통해 개발합니다. 컴퓨터 사양은 크게 필요하지 않으며 웹캠이 하나 필요합니다. 노트북의 경우 내장된 웹캠을 이용하시면 됩니다. 혹은 이미지 파일을 불러와서 결과를 확인 할 수도 있습니다.

 

 C#의 NuGet 패키지로 아래와 같이 OpenCV 관련 패키지들을 추가해주었습니다. OpenCvSharp4 하위의 패키지들은 추가를 안하면 오류가 발생하더라구요. 오류가 발생하지 않는다면 굳이 추가해주지 않아도 좋습니다. 

    <PackageReference Include="OpenCvSharp4" Version="4.10.0.20241108" />
    <PackageReference Include="OpenCvSharp4.Extensions" Version="4.10.0.20241108" />
    <PackageReference Include="OpenCvSharp4.runtime.win" Version="4.10.0.20241108" />
    <PackageReference Include="OpenCvSharp4.Windows" Version="4.10.0.20241108" />
    <PackageReference Include="OpenCvSharp4.WpfExtensions" Version="4.10.0.20241108" />

 MFC나 자바 등 다른 언어 및 프레임 워크에서 개발하시더라도 값을 UI로 표현하는 부분만 각기 다를 뿐, 딥러닝 모델을 불러오고 추론하는 주요 부분들은 OpenCV의 함수를 사용하므로 약간의 수정을 거친 후 사용하시면 됩니다.

 

2. 학습 프로그램 환경

 학습 PC 환경은 CPU i7-12700, GPU RTX 3080, RAM 64GB가 달린 윈도우 데스크탑을 사용합니다. WSL(Window Subsystem Linux)를 통해 pytorch 1.13, pytorch-cuda 11.6 버전을 설치하여 GPU로 학습했습니다. 학습 프로그램에서 최종 결과물을 onnx 포맷의 파일로 변환하는 것까지 수행합니다. 이후 추론 프로그램에서 앞서 생성한 onnx 파일을 읽어와서 추론을 수행합니다. 다음과 같은 패키지를 사전에 받아주면 좋습니다.

패키지 명 버전
python 3.10.14
pytorch 1.13.0
pytorch-cuda 11.6
matplotlib 3.8.4
numpy 1.24.3
onnx 1.14.0
onnxruntime 1.17.1
tqdm 4.66.4

 

 모델은 ResNet만 사용했습니다. ResNet 중에서도 레이어가 가장 얕은, ResNet-18을 중점으로 테스트했습니다. 데이터셋이 그렇게 크지도 않고 레이어도 얕은데 생각보다 학습이 생각보다 오래 걸리더군요.. 사장님 4090 사주세요...

3. 데이터 셋 준비하기

 이제 데이터셋을 준비해봅시다. Kaggle에서 mask를 검색했더니 우리가 흔히 아는 마스크가 아닌, 마스킹과 관련있는 데이터들이 나오더군요. 그래서 'face mask'를 키워드로 검색해보니 이제서야 제대로 된 데이터셋들이 나옵니다. 다음 링크에 있는 데이터셋으로 선택했습니다.

 

Face Mask Detection ~12K Images Dataset

12K Images divided in training testing and validation directories.

www.kaggle.com

 아래와 같이 Download 버튼을 눌러 받을 수 있습니다만 로그인을 해야 다운로드 가능합니다. Kaggle에 데이터셋이 다양하고 여러 대회들도 있으며 남들이 짠 코드도 볼 수 있어서 편하므로 가입을 권장드립니다.

 이 데이터셋으로 선정한 이유는.. 여러 마스크 데이터셋을 봤지만 다른 데이터셋은 합성을 한 경우가 많았고, 영상 해상도가 너무 높거나 데이터수가 적은 케이스도 많았습니다. 이 데이터셋은 사이즈도 작고 파일도 1.2만개나 있고 대부분 합성이 아닌 실제 마스크를 쓴 사람의 사진 위주라 선택하게 되었습니다.

 

 이 데이터셋을 제 프로젝트에서 사용하려면 압축해제 후에 약간 수정이 필요합니다. 업로더가 친절하게 Train, Valid, Test마다 폴더를 구분해 데이터를 나눠주었습니다. 하지만 여기서는 마스크 낀 사람/마스크 안 낀 사람으로만 구분된 데이터셋을 만들고 매 학습마다 랜덤하게 Train, Valid, Test를 나누도록 하겠습니다. 

 아래 사진과 같이, Validation\WithMask, Train\WithMask, Test\WithMask의 사진들을 WithMask 폴더 한 곳에, Validation\WithoutMask, Train\WithoutMask, Test\WithoutMask의 사진들을 WithoutMask 폴더 한 곳에 모아두겠습니다. 

(왼쪽) 원본 데이터셋, (오른쪽) 합친 데이터셋

 

 간단한 프로젝트이므로 영상에 사람 얼굴이 하나인 케이스만을 고려하고자 합니다. 여유가 된다면 사람이 여러명인 영상을 처리하는 것도 해보려고 합니다. OpenCV의 Cascade Classifier나 DeepFace 라이브러리 등을 이용해서 얼굴만 따로 떼내면 각 얼굴마다 마스크 착용 유무를 판단할 수 있을 것 같습니다.

 

 

4. 결과물 (초안)

 현재까지 완성된 아웃풋입니다. 

 마스크를 쓰면 Great! You're wearing a mask! (확률(%))가 출력되고, 마스크를 안쓰면 Put on your mask quickly! (확률(%))가 출력됩니다. 고작 18개의 레이어로 구성된 모델일 뿐인데도 생각보다 꽤 정확했습니다. 거기다 손으로 가려도, 턱스크를 써도 마스크 착용이 아닌걸 잘 인식하네요. 시중에서 사용하기에는 더 다듬을 필요가 있겠지만 간단히 재미 용도로는 충분히 쓸만해 보였습니다.

 

 만들고보니 아무리 첫 프로젝트라 해도 너무 허접하고 별게 없어서 WPF 공부 할 겸 UI 기능이라도 더 추가할까 고민 중 입니다;;

 

5. 포스팅 안내

 포스팅 순서는 다음과 같이 진행됩니다.

[1] 소개 및 환경 세팅 (현재 포스팅)

[2] 학습 코드

[3] GUI 코드

[4] 결과 및 끄적끄적

 

 다음엔 pytorch 모델 코드에 대해 포스팅하겠습니다.

이전 포스팅에서 WSL2 환경을 통해 Tensorflow GPU를 구축했습니다. 

이번 포스팅은 WSL2를 원격 서버(SSH 통신)으로 삼아 외부에서도 내부에 있는 데스크탑에서 코딩하듯이 사용하는 방법에 대해서 서술하겠습니다. 글을 읽어보면 다음과 같은 의문이 남을 수 있습니다.

 

"밖에서 코딩할 일 있으면 노트북에서 직접 하면 되지 뭐하러 원격 코딩을 해?"

👉 노트북의 성능은 한계가 있습니다. 원격 코딩의 목적은 사람에 따라 다르겠지만 이 포스팅의 경우 데스크탑의 성능을 끌어오기 위함입니다. 또한 딥러닝같이 오랜 시간 풀로드로 돌리는 경우 노트북 수명에도 문제가 발생할 수 있습니다.

 

"팀뷰어, 크롬 원격 등 편한 화면 원격 제어 프로그램이 있는데 왜 굳이 그렇게 귀찮게 해?"

👉 물론 화면 원격 제어 프로그램을 통해서 해결할 수도 있습니다. 제일 편리하니까요. 다만 화면 원격 제어 프로그램은 기본적으로 유선 랜을 쓰거나 무선을 쓴다면 네트워크 퀄리티가 상당히 좋은 환경이어야 딜레이, 화질 문제에 벗어나 쾌적하게 사용할 수 있습니다. 또한 제 아무리 네트워크 환경이 좋다해도 네이티브로 코딩하는 것만큼 스무스하지는 않습니다. 

 

 

0. 시작하기 전에...

진행에 앞서, 본 포스팅에서는 외부 접속까지 고려하고 있기에 웹에서 공유기 설정을 건드려야하는 과정이 있습니다. 제조사마다 관리자 웹 페이지가 다르므로 이 부분은 숙지하고 계시고 본인이 갖고 계신 제조사의 관리자 페이지 설명이 적힌 글을 참고하시길 바랍니다. 본 포스팅에서는 iptime 제조사의 관리자 웹 페이지를 다룹니다.

 

 

1. 공유기 설정 및 포트포워딩 설정

외부 접속이 필요없으시면 2장으로 넘어가셔도 됩니다. 또한 회사나 학교 등은 일반적으로 외부 접속이 막혀있는 구조가 많으므로 제대로 작동되지 않을 수 있습니다.

이 포스팅을 검색해서 들어오시는 분들이라면 어느정도 네트워크 지식이 있을 것으로 예상되지만 모르시거나 까먹으신 분들도 계실거라 생각되어 이 장과 관련된 네트워크 지식에 대해서 얘기해보도록 하겠습니다. * 제가 네트워크 전문가 아니기에 전문적인 설명을 하지는 못합니다. 따라서 외부접속 설정에 필요한 매우 기본적인 네트워크 지식만 작성했습니다.

 

네트워크 구성이 다음과 같이 되어있다고 가정하겠습니다. 통신사에서 제공한 모뎀이 있고 이 모뎀을 연결한 공유기가 있고, A 공유기에 물린 수많은 전자기기들이 있습니다. 외부에서 제 데스크탑으로 접속하려면 어떻게 해야할까요?외부 IP가 111.222.333.444니까, 단순히 111.222.333.444의 원격 서버로 보내줘! 라고 요청하면 되는걸까요? 아닙니다. 111.222.333.444 IP 안에는 무수히 많은 전자기기들이 있습니다. 원격 서버가 여러 개가 존재할 수도 있죠. 따라서 이를 구분해주거나 우선순위를 매겨야 할 필요성이 있습니다.

구분해주는 방법은 다양하게 있지만 기본적으로 포트 번호로 할 수 있습니다. 포트번호는 'IP주소:포트번호'로로 주소를 구성합니다. 가령 http의 포트번호는 일반적으로 80을 사용합니다.

예를 들어봅시다. 네이버의 ip주소는 223.130.195.200 입니다.(명령 프롬프트에서 ping www.naver.com을 치시면 IP 주소를 확인 하실 수 있습니다.) 223.130.195.200을 주소창에 입력하면 네이버로 들어갑니다. 또한 223.130.195.200:80을 주소창에 입력하면 마찬가지로 네이버로 들어갑니다. 포트번호 80 대신 다른 숫자를 넣으면 들어가지지 않습니다. 

 

다음으로 모뎀, 공유기에서 데이터를 포워딩(전달)할 때 포트별로 우선순위를 매기는 방법이 있습니다. 만약 111.222.333.444:22 주소에 접근하면 데스크탑으로 포워딩 해달라고 모뎀, 공유기에게 요청하고 싶습니다. 이럴때 사용하는 방법은 DMZ, 포트포워딩이 있습니다. DMZ는 모든 포트번호에 대해서 입력한 IP 주소로 전달하라는 의미입니다. 반면 포트포워딩은 특정 포트번호에 대해서만 입력한 IP 주소로 전달하라는 의미입니다. 참고로, DMZ와 포트포워딩이 모두 입력된 포트의 경우 포트포워딩의 우선순위가 높습니다. 가령 100번 포트에 대해서 DMZ는 노트북에, 포트포워딩은 데스크탑으로 등록해두었다면 포트포워딩으로 설정된 데스크탑이 우선순위를 갖게 됩니다. 그리고 DMZ는 모든 포트가 외부에 노출되므로 보안에 매우 취약하여 추천하지 않습니다.

하지만 여전히 해결해야 할 문제가 있습니다. 위에서 살펴본 네트워크 구성도를 보시면 통신사 모뎀과 개인 공유기, 두 단계를 거쳐 데스크탑에 도달하는 것을 확인할 수 있습니다. 개인 공유기 설정만해서는 안된다는 것이죠. 먼저 통신사 모뎀에서 개인 공유기 IP(192.128.0.5)를 2222번 포트에 대해서 포트포워딩 설정해주고, 개인 공유기에서 데스크탑 IP(192.168.0.2)를 이용해 2222번 포트에 대해서 포트포워딩을 설정해야합니다. 모뎀에서 설정하나 공유기에서 설정하나 기본적인 방식은 같으므로 공유기에서 설정하는 방식만 살펴보겠습니다.

 

  • 192.168.0.1 주소로 들어갑니다. (주소는 네트워크 환경에 따라 다를 수 있습니다.)
  • 로그인을 해야하는데 iptime의 경우 따로 설정을 안하셨다면 아이디 admin, 비밀번호 admin 입니다.
  • 로그인하고 관리도구로 들어갑니다.

  • 현재 기기의 내부 IP 주소를 확인해야합니다.
  • 고급 설정 - 네트워크 관리 - 내부 네트워크 설정으로 들어갑니다.
  • 아래 연결된 기기들이 나열되어있는데 포트포워딩으로 설정할 기기의 내부 IP를 확인할 수 있습니다.
  • 아래 예시의 경우, 192.168.0.2가 데스크탑 내부 IP주소입니다.

  • 고급 설정 - NAT/라우터 관리-포트포워드 설정으로 들어갑니다.
  • 규칙 이름은 적당히 지어주고 앞서 찾은 내부 IP 주소를 입력합니다.
    프로토콜은 TCP로 설정하고 외부 포트 2222~2222, 내부 포트 2222~2222로 값을 넣고 적용 버튼을 누른 뒤 저장합니다.
  • 기본 SSH 포트번호는 22지만 필자는 NAS 서버도 외부 접속을 허용하고 있어서 유니크한 포트번호인 2222를 선택했습니다. 다른 번호로 설정하셔도 상관없습니다.

  • 마찬가지로 모뎀에서도 적용해줍니다.
  • 모뎀의 경우에는 포트포워딩 내부 주소로 공유기의 IP 내부 주소를 입력해야합니다. 포트는 동일하게 2222로 두시면 됩니다.
  • 이러면 외부 접속을 위한 준비는 완료되었습니다.

 

 

2. WSL2에 SSH 설치 및 세팅

WSL2에서 SSH 설치하는 과정에 대해서 알아보겠습니다. WSL2 설치 방법은 이전 포스팅을 확인해주세요. Ubuntu 20.24 기준으로 작성되었습니다.

 

  • SSH 서버를 설치합니다.
sudo apt-get install openssh-server
  • 아래 명령어를 입력합니다.
sudo nano /etc/ssh/sshd_config
  • Before: 아래 사진과 같습니다.
#Port 22
...
#PasswordAuthentication no
  • After: 아래 사진과 같습니다.
  • '#'이 주석처리고 우리는 Port 번호를 바꿔야하니 주석을 지워주시고 포트 번호를 22 대신 2222로 수정합니다.
  • PasswordAuthentication도 주석을 지우고 no를 yes로 수정합니다. 이 기능을 키면 로그인 시 암호를 물어봅니다.
Port 2222
...
PasswordAuthentication yes
  • ssh 서버를 킵니다. 끄는 방법은 start 대신 stop을 입력하시면 됩니다.
sudo service ssh start
  • 혹시 오류가 난다면 아래 커맨드를 입력해 키를 생성합니다. 보안, 인증을 위해 키를 생성합니다.
sudo ssh-keygen -t rsa
sudo ssh-keygen -t dsa
  • 테스트 해봅시다. 외부 네트워크에 접속한 기기가 있다면 가장 좋습니다. 우리는 이미 하나씩은 갖고 있습니다. 바로 스마트폰입니다.
  • Wi-Fi 말고 외부 망을 사용하는 LTE, 3G로 사용하여 SSH 서버에 접속합니다. 스마트폰에서 SSH Client 앱(Terminus, xTerminal, WebSSH 등)을 설치합니다.
  • 외부 주소를 알아야하는데 공유기 관리자 웹 사이트에서도 확인이 가능하지만 더 간단한 방법은 네이버로 들어가 'ip 주소 확인'이라고 검색하면 외부 주소를 알려줍니다. 이 주소를 기억해둡시다.

  • 앱에서 SSH 서버 정보를 입력하고 접속해서 확인해봅시다. 정상적으로 접속이 된 것을 확인할 수 있습니다.

 

 

3. Windows에서 방화벽 설정 및 WSL2 포트 허용

Windows는 방화벽이란 보안 시스템이 존재하여 외부 접근을 의도적으로 막습니다. 예외를 추가하여 특정 포트의 외부 접근을 허용하게 만들수도 있습니다.

  • 검색 - Windows Defender 방화벽 - 고급 설정을 들어갑니다.
  • 인바운드 규칙을 누르고 우측에 [새 규칙...]을 누릅니다. 아래와 같이 선택하여 추가합니다.

  • 위 과정을 아웃바운드 규칙에서도 동일하게 적용하여 추가합니다. 


방화벽을 통해 외부 접근을 허용하게 만들었어도 Windows가 WSL2로 직접 포워딩해주진 않습니다. WSL은 이름 그대로 Windows Sub systems for Linux, 윈도우의 리눅스 서브 시스템이기 때문입니다. 따라서 특정 포트에 대해서 이 포트는 Windows가 아닌 Subsystem인 WSL로 전달하라고 요청을 해야합니다.

 

  • 아래와 같은 소스를 가진 *.ps1 확장자 파일을 작성합니다.
  • $ports=@(포트번호);에 앞서 설정한 포트번호로 수정해야합니다. 경로는 아무데나 두셔도 상관없습니다.
If (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator"))

{   
$arguments = "& '" + $myinvocation.mycommand.definition + "'"
Start-Process powershell -Verb runAs -ArgumentList $arguments
Break
}

$remoteport = bash.exe -c "ifconfig eth0 | grep 'inet '"
$found = $remoteport -match '\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}';

if( $found ){
  $remoteport = $matches[0];
} else{
  echo "The Script Exited, the ip address of WSL 2 cannot be found";
  exit;
}

$ports=@(2222);

iex "netsh interface portproxy reset";
for( $i = 0; $i -lt $ports.length; $i++ ){
  $port = $ports[$i];
  iex "netsh interface portproxy add v4tov4 listenport=$port connectport=$port connectaddress=$remoteport";
}
iex "netsh interface portproxy show v4tov4";
  • Windows PowerShell을 관리자 권한으로 실행시킵니다.
  • 소스를 저장한 폴더로 들어가 아래 커맨드를 입력합니다.
PowerShell.exe -ExecutionPolicy Bypass -File .\wsl2_port.ps1

 

 

4. VSCode로 SSH 원격 코딩 테스트

  • VSCode 좌측 메뉴에 Extensions에서 ssh를 검색해 Remote - SSH를 설치합니다.

 

  • Ctrl+Shift+P를 입력하고 SSH를 검색합니다.
  • [Remote-SSH:Connect to Host...]를 선택합니다.
  • [Configure SSH Hosts...] 를 선택합니다.
  • C:\Users\[사용자 이름]\.ssh\config를 선택합니다.

  • 아래와 같이 수정합니다. IP 주소와 포트 번호는 자신이 앞서 설정한대로 입력해줍니다. User는 WSL2 Linux에서 사용하는 사용자 이름을 입력해야합니다.
Host june@115.XXX.XXX.XXX:2222
    HostName 115.XXX.XXX.XXX
    User june
    Port 2222
  • Ctrl+Shift+P를 눌러 [Remote-SSH:Connect to Host...]에 다시 들어가면 사용자 이름@IP주소:포트번호 형태로 선택지가 생깁니다. 더블클릭하여 들어갑니다.

  • 원격 접속된 것을 확인할 수 있습니다.
  • Linux를 선택하고, Handprint? Handfinger? 어쩌고 나오는데 실수로 바로 넘겨버려서 사진이 없습니다. 아무튼 대충 엔터 눌러서 넘어가시고 비밀번호를 입력합니다.

  • 그럼 서버로 접속이 완료됩니다. 좌측에 보면 폴더에 진입하지 않아 소스를 쓸수가 없습니다.
  • Open Folder 버튼을 눌러 서버 폴더로 진입할 수 있습니다. 적당한 폴더로 들어가 소스를 작성해봅시다.
  • 이전 포스팅에서 작성한 Projects 폴더의 소스를 테스트해보겠습니다.

  • 아래와 같이 정상적으로 잘 동작함을 확인할 수 있습니다.
  • 이렇게 원격 코딩 환경이 완료되었습니다. 이제 어디서든 데스크탑의 리소스를 사용해 코딩을 할 수 있습니다.

 

 

작성일자: 2023-06-25

최근에 딥러닝을 공부하고 있습니다. 어느 정도 공부가 되어 실전에서 사용하려고 하니 GPU 세팅이 안되있어서 성능이 좋지 않았습니다.

포스팅된 내용들로 하는데 잘 안되더군요. 살펴보니 Tensorflow 2.10.0 부터 Window에서 GPU 지원하지 않으니 Linux에서 사용하거나 Window에서 쓸거면 WSL2을 이용하라고 합니다. (CPU만 사용하실거면 상관없습니다.)

2.0.9 버전을 사용하면 WSL2 같은거 설치 안해도 VSCode에서 바로 쓸 수 있겠지만 언젠간 WSL2를 이용해 Tensorflow 환경을 구축하게 될 것같아서 해봤습니다.

다운 시간과 삽질하는 시간까지 2~3시간 정도는 잡아야하므로 급하게 윈도우에서 사용하셔야 하는 분들은 Tensorflow 2.0.9 이전 버전에 CUDA, cuDNN을 설치하는 방법이 더 빠를수도 있습니다.

혹여나 잘 안되거나 오탈자, 오류가 있다면 말씀주세요. 최대한 피드백 드리도록 하겠습니다.

 

 

0. 설치하기 전에...

이번 포스팅 내용은 다음과 같습니다.

  • 가장 먼저 WSL2 Ubuntu 20.04 버전을 설치합니다.
    • WSL이란, 윈도우 하위 시스템으로 리눅스를 사용한다는 의미로 받아들이면 될 것 같습니다. 실제 영문명도 Windows Sub-system for Linux 입니다.
  • 그 후 WSL2에서 NVIDIA의 CUDA와 cuDNN을 설치하고 정상적으로 설치되었는지 체크합니다.
    • CUDA는 NVIDIA에서 개발한 GPU 개발 툴 입니다. GPU 가속화에 사용되며 많은 양의 연산 처리에 이점이 있습니다.
    • cuDNN은 CUDA 기반 딥러닝 라이브러리입니다.
  • 다음에는 Anaconda를 설치하여 Python 가상환경을 만들고 Tensorflow를 다운받습니다.
    • Anaconda는 데이터 분석이나 수학, 과학 관련 패키지를 포함한 Python 배포판 입니다. 
    • 가상 환경을 구축할 수 있어 관리 및 유지보수에 용이합니다.
  • 마지막으로 윈도우 VSCode 상에서 WSL2의  Python Interpreter로 설정하는 것 까지가 오늘의 포스팅 내용입니다.
    • VSCode는 Visual Studio Code로 Microsoft에서 개발한 오픈소스 코드 에디터입니다. 관점에 따라 다르게 생각하실수 있겠지만 필자는 디버깅, 빌드, 배포 등이 가능하므로 통합 개발환경(IDE)이라고 봐도 무방하다고 생각합니다.
    • 일반 코드 에디터와 다르게 수많은 플러그인과 기능들이 있고 속도가 빠르고 프로그램이 가벼우며 주요 플랫폼(맥, 윈도우, 리눅스 등)에서 모두 이용가능하다는 장점을 가지고 있습니다. 

 

 

1.  WSL2 - Ubuntu 20.04 설치하기

  • WSL2를 사용하기 전에 시스템 설정을 변경해야합니다.
    • 윈도우 검색 - Windows 기능 켜기/끄기 실행
  • 아래 항목을 체크해주시고 재부팅 해주시면 됩니다.
    • Hyper-V / Linux용 Windows 하위 시스템 / Windows 하이퍼바이저 플랫폼 / 가상 머신 플랫폼

 

  • Windows PowerShell을 실행시켜줍니다.
    • 윈도우 검색 - Windows PowerShell - 관리자 권한으로 실행
  • 아래 명령어를 입력해 Ubuntu를 설치할 수 있습니다.
    • wsl --install -d ubuntu-20.04
  • Ubuntu 대신 다른 리눅스 배포판을 받고 싶으시면 아래 명령어로 확인 가능합니다.
    • wsl --list --online

 

  • [2차 수정  추가] 아래와 같은 오류가 발생하면서 깨진 문자가 나타나면서 설치가 안된다면...
    WslRegisterDistribution failed with error: 0x800701bc
    아래 사이트에서 커널 업데이트 프로그램 설치 후 하시면 됩니다. ("x64 머신용 최신 WSL2 Linux 커널 업데이트 패키지" 라고 써있는 링크) 
 

이전 버전 WSL의 수동 설치 단계

wsl install 명령을 사용하지 않고 이전 버전의 Windows에 WSL을 수동으로 설치하는 방법에 대한 단계별 지침입니다.

learn.microsoft.com

 

 

 

 

  • 설치를 진행하다보면 아래와 같은 텍스트가 발생합니다.
  • 혹여나 아래와 같은 텍스트가 나타지 않으면 wsl --install -d ubuntu-20.04를 한번 더 수행해주세요. 우분투 파일 다운만 받고 설치가 안됐을 수 있습니다.
  • WSL 로그인 시 사용할 유저명과 비밀번호를 입력하시면 됩니다.
  • 입력하면 자동으로 WSL로 들어가집니다.

 

  • WSL 실행 방법은 검색창에 wsl을 입력해서 wsl 앱을 통해 들어갈 수 있으며, Windows PowerShell에서 wsl 입력하면 들어갈 수 있습니다.
  • 반대로 wsl을 끄고 싶다면 PowerShell에서 wsl --shutdown을 입력하시면 됩니다.

 

  • 간단하게 wsl 설치를 완료했습니다.

 

 

2.  WSL2 - CUDA, cuDNN 설치하기

  • 설치 하기 전에 Tensorflow 버전에 따른 CUDA, cuDNN 호환 버전을 확인해야합니다. 아래 사이트를 들어가면 자세히 나와있습니다.
  • [1차 수정 추가] 그래픽카드 종류에 따라 호환되는 CUDA 버전도 나뉜다고 합니다. 아래 사이트에서 확인하시고 그래픽 카드에 맞는 적절한 버전으로 설치해주세요. (댓글에 정보 주신 IIIIIIlllI님 감사드립니다.)

 

 

 

  • 다음 사진과 같이 클릭합니다. 각 요소에 대한 설명은 다음과 같습니다.
    • Operating System: 설치할 OS를 선택해주세요. 우리는 WSL2를 통해 Ubuntu 20.04(Linux)에 설치할 예정이므로 Linux를 선택합니다.
    • Architecture: 자신의 CPU에 맞는 아키텍처를 선택해주시면 됩니다. 대부분 라이젠, 인텔 쓰실테니 잘 모른다면 x86_64 선택해주시면 됩니다.
    • Distribution: 원하시는 리눅스 운영체제를 선택하시면 됩니다. 저는 Ubuntu를 선택했습니다.
    • Version: 리눅스 버전을 선택해주세요. 저는 Ubuntu 20.04 버전이므로 20.04를 선택했습니다.
    • Installer Type: 설치 방식을 선택해주세요. 가장 기본인 deb (local)을 선택했습니다.
  • 클릭하면 아래 파란 박스와 같이 명령어 문장이 나타납니다. 저 문장 그대로 복사해서 WSL 프로그램에 입력하시면 설치됩니다.

  • 위 명령어를 입력하기 전에 아래 경로에 폴더 하나를 생성해줍시다. 혼란을 막기 위해 앞으로 이 폴더에 CUDA, cuDNN, Anaconda 등 파일을 다운받겠습니다. (다른 폴더로 설정하셔도 됩니다.)
    • 눈치채셨겠지만 WSL에서 /mnt/c/ 경로는 윈도우 상에서  C:\와 동일합니다. 
    • 앞으로 C:\Downloads에 프로그램을 넣으면 WSL에서도 쉽게 접근이 가능합니다.
cd /mnt/c/
mkdir Downloads
cd Downloads
  • 이제 명령어를 입력하여 CUDA 설치를 마칩니다.
wget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2004/x86_64/cuda-ubuntu2004.pin
sudo mv cuda-ubuntu2004.pin /etc/apt/preferences.d/cuda-repository-pin-600
wget https://developer.download.nvidia.com/compute/cuda/11.8.0/local_installers/cuda-repo-ubuntu2004-11-8-local_11.8.0-520.61.05-1_amd64.deb
sudo dpkg -i cuda-repo-ubuntu2004-11-8-local_11.8.0-520.61.05-1_amd64.deb
sudo cp /var/cuda-repo-ubuntu2004-11-8-local/cuda-*-keyring.gpg /usr/share/keyrings/
sudo apt-get update
sudo apt-get -y install cuda

 

 

  • 다음엔 cuDNN을 설치합니다. 
    • https://developer.nvidia.com/cudnn
    • 먼저, NVIDIA 회원가입을 합니다. 이후 아래 그림과 같이 진행하여 버젼에 따라 cuDNN 파일을 다운로드 받습니다.
    • 다운로드 받은 파일을 C:\Downloads에 넣으시면 됩니다.

  • 아래 명령어를 입력하여 cuDNN을 설치합니다.
tar xvf cudnn-linux-x86_64-8.6.0.163_cuda11-archive.tar.xz
sudo cp cudnn-*-archive/include/cudnn*.h /usr/local/cuda/include 
sudo cp -P cudnn-*-archive/lib/libcudnn* /usr/local/cuda/lib64 
sudo chmod a+r /usr/local/cuda/include/cudnn*.h /usr/local/cuda/lib64/libcudnn*
  • cuDNN의 라이브러리 경로를 추가하기 위해 다음 명령어를 입력합니다.
    • nano는 텍스트 편집기입니다.
sudo nano ~/.bashrc
  • ~/.bashrc 마지막에 아래 문장을 추가해줍니다.
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/cuda-11.8/lib64/
  • 마지막으로 ~/.bashrc 파일을 동기화 시켜줍니다.
source ~/.bashrc

 

  • 제대로 설치되었다면 아래 파일을 실행시켰을 때 Result = PASS가 출력됩니다.
/usr/local/cuda-11.8/extras/demo_suite/deviceQuery

 

 

3.  WSL2 - Anaconda 및 가상환경 설정과 Tensorflow 패키지 설치

  •  Anaconda 설치를 위해 아래 사이트로 들어갑니다.
    • https://www.anaconda.com/
    • 이후 아래 사진과 같이 진행하여 설치합니다.
    • 다운로드 링크를 복사하여 WSL에서 wget 커맨드 이용해서 다운 받으셔도 되고, 윈도우에서 받아 C:\Downloads 폴더로 옮기셔도 됩니다. 
wget https://repo.anaconda.com/archive/Anaconda3-2023.03-1-Linux-x86_64.sh

  • 다음 명령어를 입력하여 설치합니다.
bash Anaconda3-2023.03-1-Linux-x86_64.sh
  • 설치 시작하면 계속 엔터를 연타합니다.
  • Please answer 'yes' or 'no': 이 텍스트가 나오면 yes를 입력합니다.
  • [/home/[UserName]/anaconda3] >>> : 이 텍스트가 나오면 엔터 누르면 됩니다.
  • by running conda init [yes|no] [no] >>> : 이 텍스트가 나오면 yes를 입력합니다.

 

 

  • 이제 커맨드 상에서 conda를 사용할 수 있게 등록시켜주도록 합니다.
nano ~/.bashrc
  • ~/.bashrc 파일 맨 마지막에 아래 코드를 입력해주세요. 이미 export PATH 파일이 있다면 앞에 "/home/june/anaconda3/bin:" 까지만 입력해주세요. 
  • /home/june/anaconda3/은 앞에서 anaconda 설치 시 입력한 PREFIX 값입니다. 
export PATH=/home/june/anaconda3/bin:$PATH
  • bashrc 파일을 동기화해주면 끝입니다.
source ~/.bashrc

 

  • 아래 명령어를 입력해 가상환경을 만듭니다. [NAME]에는 가상환경으로 쓸 이름을 적습니다. 저의 경우 가상환경 이름을 tensorflow로 설정했고 Python 버전은 3.10으로 두었습니다.
conda create -n [NAME] python=3.10
  • conda activate는 가상환경에 들어가는 명령어입니다. 앞서 작성한 가상환경에 들어간 다음 tensorflow를 설치합니다.
conda activate [NAME]
pip install tensorflow==2.12.0

 

 

4.  Window - VSCode에서 WSL 연결

  • VSCode에서 좌측에 Market을 들어가 wsl과 python을 설치합니다.

  • Ctrl+Shift+P를 누르고 WSL을 입력합니다. 이후 WSL: Connect to WSL을 누르면 자동으로 WSL으로 이동합니다.

  • 마찬가지로 Ctrl+Shift+P를 누르고 WSL을 입력합니다. 이후 Python: Select Interpreter를 선택하고 Anaconda에서 생성한 가상환경을 Interpreter로 설정합니다.

 

  • 마지막으로 Python 파일 하나를 생성하고 아래 소스를 입력합니다.
from tensorflow.python.client import device_lib
device_lib.list_local_devices()
  • 아래 사진과 같이, Output에서 빨간 밑줄친 것과 같이 GPU 정보가 나오면 끝입니다.

 

 

 

작성일자: 2023-06-24

1차 수정: 2023-07-06

2차 수정: 2024-01-08

 

+ Recent posts