본문 바로가기
iOS/CoreML

CoreML 배워보자 (2) - Object Detection

by HaningYa 2020. 4. 24.
728x90

 

실전! Core ML을 활용한 머신러닝 iOS 앱 개발: 인공지능을 활용한 객체 인식, 감정 탐지, 스타일 전이, 스케치 인식 구현

애플 Core ML을 활용한 스마트한 iOS 앱 만들기! Core ML은 다양한 머신러닝 작업을 지원하기 위해 설계된 API를 제공하는 애플의 유명한 프레임워크다. 이 프레임워크를 활용하면 머신러닝 모델을 훈련시킨 다음 그 모델을 iOS 앱에 통합시킬 수 있다. 이 책은 Core ML을 이해하기 쉽게 설명할 뿐 아니라 머신러닝을 명확하게 설명해 준다. 모바일 플랫폼(특히 iOS)에서 현실적이면서 흥미로운 머신러닝 예제를 통해 배우며, 시각 기반의 애플리케

wikibook.co.kr

 

 

CoreML 배워보자 (1)

책을 한권 샀다. 실전! Core ML을 활용한 머신러닝 iOS 앱 개발: 인공지능을 활용한 객체 인식, 감정 탐지, 스타일 전이, 스케치 인식 구현 애플 Core ML을 활용한 스마트한 iOS 앱 만들기! Core ML은 다양한 머..

haningya.tistory.com

 

지난번 CoreML과 ML에 대한 전반적인걸 공부해 보았다.

 

이번엔 객체인식 예제를 통해 앱을 만들려고 한다.


실세계에서 객체 인식

  • 만들 앱 : 사용자가 아이폰의 카메라로 사진을 찍으면 이미지 분류 모델을 사용해서 그 장면에서 지배적인 객체를 분류해 주는 앱
  • 사용할 알고리즘 : CNN(Convolutional neural network)

CNN이란

[참고]

 

[번역] 딥러닝 (CNN) 직관적 이해 - (1)

평소 무엇인가를 쉽게 설명하는 능력이 있다고 생각해서 , CNN (convolutional neural network) 도 그렇게 해볼까 했는데 역시 무리. 쉽게 설명한다는것은 그것에 대해 확실한 이해를 가지고 있다고 생각될때 가능..

hamait.tistory.com


프로젝트를 만든다.

필요한 Framework를 추가한다.

  • CoreML : prediction을 위해서
  • AVFoundation : 카메라에 접근해서 프레임을 캡쳐하기 위해 사용, iOS와 다른 플랫폼에서 시청각 미디어를 캡처, 처리, 합성, 제어, 가져오기, 내보내기를 처리하는 클래스들이다. 
  • CoreVideo : 카메라에 접근해서 프레임을 캡쳐하기 위해 사용, 디지털 동영상을 처리하기 위해 파이프라인 기반의 API 를 제공하여 Metal 과 OpenGL 에서 지원하는 기능을 사용해 처리속도를 높여준다.

TARGETS --> Build Phases --> Link Binary with Libraries --> AVFoundation, CoreVideo, CoreML 추가


Storayboard 작업을 해준다.

Preview를 위한 UIView 와 텍스트 표시를 위한 UILabel 로 간단하다.


CaptureViewPreviewView.swift 파일을 만든다.

차후에 코드를 더 작성할 것이다.

CapturePreviewView 클래스를 포함해 캡처된 프레임을 표시한다.

import AVFoundation
import UIKit

class CapturePreviewView : UIView{
    
}

 

그리고 아까 만든 Preview용 UIView의 class를 CapturePreviewVIew로 바꿔준다.


VideoCaputre.swift 파일을 만든다.

동영상 프레임 캡처를 포함하여 카메라를 관리하고 다루는 작업을 한다.

캡처된 프래임을 등록된 델리게이트에 전달 함으로써 VideoCapture 클래스가 온전히 프레임을 캡쳐하는 작업에만 집중 할 수 있게 해준다.

import UIKit
import AVFoundation

public protocol VideoCaptureDelegate: class {
    func onFrameCaptured(videoCapture: VideoCapture, pixelBuffer:CVPixelBuffer?, timestamp:CMTime)
}

/**
 Class used to faciliate accessing each frame of the camera using the AVFoundation framework (and presenting
 the frames on a preview view)
 https://developer.apple.com/documentation/avfoundation/avcapturevideodataoutput
 */
public class VideoCapture : NSObject{
    
    public weak var delegate: VideoCaptureDelegate?
    
    /**
     Frames Per Second; used to throttle capture rate
     */
    public var fps = 15    
    
    var lastTimestamp = CMTime()
    
    override init() {
        super.init()
        
    }
    
    func initCamera() -> Bool{
        return true
    }
    
    /**
     Start capturing frames
     This is a blocking call which can take some time, therefore you should perform session setup off
     the main queue to avoid blocking it.
     */
    public func asyncStartCapturing(completion: (() -> Void)? = nil){

    }
    
    /**
     Stop capturing frames
     */
    public func asyncStopCapturing(completion: (() -> Void)? = nil){

    }
}

// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate

extension VideoCapture : AVCaptureVideoDataOutputSampleBufferDelegate{
    
    /**
     Called when a new video frame was written
     */
    public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
    }
}

 


 

  • VideoCaptureDelegate : CVPixelBuffer 형식의 이미지 데이터(캡처된 프레임)와 CMTime형식의 타임 스탬프의 참조를 전달한다.
  • CVPixelBuffer : 픽셀 데이터르 담는데 특화된 CoreVideo 데이터 구조 (Core ML 요구 데이터 구조)
  • CMTIME : 동영상 프레임에서 바로 얻는 타임 스탬프 캡슐화를 위한 데이터 구조이다.

CIImage.swift 파일을 만든다.

CIImage 클래스에 편의 확장 기능을 제공하여 Core ML 모델에서 사용할 수 있도록 프레임을 준비할 때 쓰인다.

import UIKit

extension CIImage{
    
    /**
     Return a resized version of this instance (centered)
     */
    func resize(size: CGSize) -> CIImage {
        fatalError("Not implemented")
    }
    
    /**
     Property that returns a Core Video pixel buffer (CVPixelBuffer) of the image.
     CVPixelBuffer is a Core Video pixel buffer (or just image buffer) that holds pixels in main memory. Applications generating frames, compressing or decompressing video, or using Core Image can all make use of Core Video pixel buffers.
     https://developer.apple.com/documentation/corevideo/cvpixelbuffer
     */
    func toPixelBuffer(context:CIContext,
                       size insize:CGSize? = nil,
                       gray:Bool=true) -> CVPixelBuffer?{
        fatalError("Not implemented")
    }
}

ViewController 를 세팅한다.

import된 Core ML 모델과의 인터페이스를 담당한다.

import UIKit
import CoreVideo
import AVFoundation

class ViewController: UIViewController {

    @IBOutlet weak var previewView: CapturePreviewView!
    @IBOutlet weak var classifiedLabel: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        
    }
}

// MARK: - VideoCaptureDelegate

extension ViewController : VideoCaptureDelegate{
    
    func onFrameCaptured(videoCapture: VideoCapture,
                         pixelBuffer:CVPixelBuffer?,
                         timestamp:CMTime){
        
    }
}


그럼 기본적인 프로젝트 세팅은 완료되었다.

하나씩 기능별로 구현해 보자.


카메라에 접근해 프레임 캡쳐

VideoCapture.swift 에서 initCamera()와 captureOutput() 메소드를 완성한다.

 func initCamera() -> Bool{
        // 여러 구성 처리를 일괄 작업한다는 신호를 보낸다. 변경사항은 commitConfiguration 메서드르 호출해야 반영된다.
        captureSession.beginConfiguration()
        //원하는 품질을 고른다.
        captureSession.sessionPreset = AVCaptureSession.Preset.medium
        guard let captureDevice = AVCaptureDevice.default(for: AVMediaType.video) else{
            print("ERROR : no video device available")
            return false
        }
        guard let videoInput = try? AVCaptureDeviceInput(device: captureDevice) else{
            print("ERROR : could not create AVCaptureDeviceInput")
            return false
        }
        if captureSession.canAddInput(videoInput){
            captureSession.addInput(videoInput)
        }
        
        //프레임의 도착지
        let videoOutput = AVCaptureVideoDataOutput()
        let settings:[String:Any] = [
            kCVPixelBufferPixelFormatTypeKey as String : NSNumber(value: kCVPixelFormatType_32BGRA) //풀컬러
        ]
        videoOutput.videoSettings = settings
        videoOutput.alwaysDiscardsLateVideoFrames = true // 디스패치 큐가 사용중일 때 도착한 프레임은 모두 폐기된다.
        videoOutput.setSampleBufferDelegate(self, queue: sessionQueue) // 디스패치 큐로 유입된 프레임을 전달하는 델리게이트를 구현한다.
        if captureSession.canAddOutput(videoOutput){
            captureSession.addOutput(videoOutput) // 출력의 구성요청의 일부로 세션에 추가한다.
        }
        videoOutput.connection(with: AVMediaType.video)?.videoOrientation = .portrait // 이미지가 회전하는것을 방지하기위해 세로방향으로 요청한다.
        captureSession.commitConfiguration() // 구성을 커밋하여 변경사항을 반영한다.
        
        return true
    }
  public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        //output은 AVCaptureVideoDataOutput 형식이고 프레임 관련 출력이다.
        //sample buffer는 CMSampleBuffer 형식이고 프레임의 데이터 접근을 위해 사용된다
        guard let delegate = self.delegate else { return } //delegate가 할당되지 않았을 경우를 방지한다.
        let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)//최신 프레임과 관련된 시간을 얻는다.
        
        let elapsedTime = timestamp - lastTimestamp
        if elapsedTime >= CMTimeMake(value: 1,timescale: Int32(fps)){
            lastTimestamp = timestamp
            let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)//충분한 시간이 지난 뒤 샘플의 이미지 버퍼에 대한 참조를 얻어 델리게이트에 전달한다.
            
            delegate.onFrameCaptured(videoCapture: self, pixelBuffer: imageBuffer, timestamp: timestamp)
        }
    }

뷰에 연결

CapturePreviewView 클래스 내부에 추가한다.

 //뷰를 생성하는 초기에 호출되어 뷰를 인스턴스화 하고 그에 연결할 CALayer를 결정한다.
    override class var layerClass: AnyClass{
        return AVCaptureVideoPreviewLayer.self //동영상 프레임을 처리하기 위한 것이다
    }

ViewController 에서 VideoCapture를 인스턴스화 하고 ViewDidLoad에서 카메라를 초기화 한 후 세션에 할당한다.

 let videoCapture : VideoCapture = VideoCapture()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        if self.videoCapture.initCamera(){
            (self.previewView.layer as! AVCaptureVideoPreviewLayer).session =
                self.videoCapture.captureSession
            
            (self.previewView.layer as! AVCaptureVideoPreviewLayer).videoGravity =
                AVLayerVideoGravity.resizeAspectFill
            
            self.videoCapture.asyncStartCapturing()
        }else{
            fatalError("Fail to init Video Capture")
        }
        
        
    }

 


중간점검으로 폰화면에 렌더링 된 동영상 프레임을 확인해 본다.

*실제 디바이스로 테스트 해야한다.

 

에러

혹시 이런 에러가 뜨면 아이폰 설정-->일반-->기기관리-->개발자앱-->신뢰 하면 해결된다.

 

개인정보 plist

info.plist를 작성안해줘서 뜨는 오류이다.

 

camera usage 설명을 작성해준다.


실행되는 것을 볼 수 있다.


데이터 전처리

ViewController 를 작업한다.

func onFrameCaptured(videoCapture: VideoCapture,pixelBuffer:CVPixelBuffer?,timestamp:CMTime){
        videoCapture.delegate = self
    }

videoCapture 델리게이트를 viewcontroller에 할당한다.

이제 onFrameCaputered 콜백을 통해 프레임을 받게 된다.


프로젝트에 훈련된 모델 import

[여기서 받을 수 있다]

 

Machine Learning - Models - Apple Developer

FCRN-DepthPrediction Depth Estimation Predict the depth from a single image. View models MNIST Drawing Classification Classify a single handwritten digit (supports digits 0-9). View models UpdatableDrawingClassifier Drawing Classification Drawing classifie

developer.apple.com

Inception v3를 사용할 것이기 때문에 다운로드 받는다. (참조만 업데이트 한다면 모델을 바꾸는건 어렵지 않다.)

 

어라 근데 없다.

 

그래서 여기서 다운받는다.

 

yulingtianxia/Core-ML-Sample

A Demo using Vision Framework building on Core ML Framework - yulingtianxia/Core-ML-Sample

github.com

 

프로젝트에 추가시킨 모습

Prediction 탭을 보면 이 모델은 입력으로 크기가 299회색소299인 컬러 이미지를 받아 문자열로 단일 클래스 레이블과 문자열-double 형의 딕셔너리 형태로 전체 범주에 대한 확률을 반환단다.


모델 코드 한번 뜯어보자

더보기
/// Model Prediction Input Type
@available(macOS 10.13, iOS 11.0, tvOS 11.0, watchOS 4.0, *)
class Inceptionv3Input : MLFeatureProvider {

    /// Input image to be classified as color (kCVPixelFormatType_32BGRA) image buffer, 299 pixels wide by 299 pixels high
    var image: CVPixelBuffer

    var featureNames: Set<String> {
        get {
            return ["image"]
        }
    }
    
    func featureValue(for featureName: String) -> MLFeatureValue? {
        if (featureName == "image") {
            return MLFeatureValue(pixelBuffer: image)
        }
        return nil
    }
    
    init(image: CVPixelBuffer) {
        self.image = image
    }
}

 

첫번째 부분은 모델의 입력 부분이다.

모델의 특징값 컬렉션, 이미지 특징을 표현하는 프로토콜인 MLFeatureProvider 프로토콜을 구현한다.

예상 데이터 구조인 CVPixelBuffer와 주석에 선언된 명세를 볼 수 있다.

 


더보기
/// Model Prediction Output Type
@available(macOS 10.13, iOS 11.0, tvOS 11.0, watchOS 4.0, *)
class Inceptionv3Output : MLFeatureProvider {

    /// Source provided by CoreML

    private let provider : MLFeatureProvider


    /// Probability of each category as dictionary of strings to doubles
    lazy var classLabelProbs: [String : Double] = {
        [unowned self] in return self.provider.featureValue(for: "classLabelProbs")!.dictionaryValue as! [String : Double]
    }()

    /// Most likely image category as string value
    lazy var classLabel: String = {
        [unowned self] in return self.provider.featureValue(for: "classLabel")!.stringValue
    }()

    var featureNames: Set<String> {
        return self.provider.featureNames
    }
    
    func featureValue(for featureName: String) -> MLFeatureValue? {
        return self.provider.featureValue(for: featureName)
    }

    init(classLabelProbs: [String : Double], classLabel: String) {
        self.provider = try! MLDictionaryFeatureProvider(dictionary: ["classLabelProbs" : MLFeatureValue(dictionary: classLabelProbs as [AnyHashable : NSNumber]), "classLabel" : MLFeatureValue(string: classLabel)])
    }

    init(features: MLFeatureProvider) {
        self.provider = features
    }
}

두번째 부분은 출력부분이다. 확률과 카테고리 범주를 나타내는 문자열로 표현되며 featureValue(for featureName : String)을 사용해 접근할 수 있다.


 

더보기
/// Class for model loading and prediction
@available(macOS 10.13, iOS 11.0, tvOS 11.0, watchOS 4.0, *)
class Inceptionv3 {
    var model: MLModel

/// URL of model assuming it was installed in the same bundle as this class
    class var urlOfModelInThisBundle : URL {
        let bundle = Bundle(for: Inceptionv3.self)
        return bundle.url(forResource: "Inceptionv3", withExtension:"mlmodelc")!
    }

    /**
        Construct a model with explicit path to mlmodelc file
        - parameters:
           - url: the file url of the model
           - throws: an NSError object that describes the problem
    */
    init(contentsOf url: URL) throws {
        self.model = try MLModel(contentsOf: url)
    }

    /// Construct a model that automatically loads the model from the app's bundle
    convenience init() {
        try! self.init(contentsOf: type(of:self).urlOfModelInThisBundle)
    }

    /**
        Construct a model with configuration
        - parameters:
           - configuration: the desired model configuration
           - throws: an NSError object that describes the problem
    */
    @available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 5.0, *)
    convenience init(configuration: MLModelConfiguration) throws {
        try self.init(contentsOf: type(of:self).urlOfModelInThisBundle, configuration: configuration)
    }

    /**
        Construct a model with explicit path to mlmodelc file and configuration
        - parameters:
           - url: the file url of the model
           - configuration: the desired model configuration
           - throws: an NSError object that describes the problem
    */
    @available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 5.0, *)
    init(contentsOf url: URL, configuration: MLModelConfiguration) throws {
        self.model = try MLModel(contentsOf: url, configuration: configuration)
    }

    /**
        Make a prediction using the structured interface
        - parameters:
           - input: the input to the prediction as Inceptionv3Input
        - throws: an NSError object that describes the problem
        - returns: the result of the prediction as Inceptionv3Output
    */
    func prediction(input: Inceptionv3Input) throws -> Inceptionv3Output {
        return try self.prediction(input: input, options: MLPredictionOptions())
    }

    /**
        Make a prediction using the structured interface
        - parameters:
           - input: the input to the prediction as Inceptionv3Input
           - options: prediction options 
        - throws: an NSError object that describes the problem
        - returns: the result of the prediction as Inceptionv3Output
    */
    func prediction(input: Inceptionv3Input, options: MLPredictionOptions) throws -> Inceptionv3Output {
        let outFeatures = try model.prediction(from: input, options:options)
        return Inceptionv3Output(features: outFeatures)
    }

    /**
        Make a prediction using the convenience interface
        - parameters:
            - image: Input image to be classified as color (kCVPixelFormatType_32BGRA) image buffer, 299 pixels wide by 299 pixels high
        - throws: an NSError object that describes the problem
        - returns: the result of the prediction as Inceptionv3Output
    */
    func prediction(image: CVPixelBuffer) throws -> Inceptionv3Output {
        let input_ = Inceptionv3Input(image: image)
        return try self.prediction(input: input_)
    }

    /**
        Make a batch prediction using the structured interface
        - parameters:
           - inputs: the inputs to the prediction as [Inceptionv3Input]
           - options: prediction options 
        - throws: an NSError object that describes the problem
        - returns: the result of the prediction as [Inceptionv3Output]
    */
    @available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 5.0, *)
    func predictions(inputs: [Inceptionv3Input], options: MLPredictionOptions = MLPredictionOptions()) throws -> [Inceptionv3Output] {
        let batchIn = MLArrayBatchProvider(array: inputs)
        let batchOut = try model.predictions(from: batchIn, options: options)
        var results : [Inceptionv3Output] = []
        results.reserveCapacity(inputs.count)
        for i in 0..<batchOut.count {
            let outProvider = batchOut.features(at: i)
            let result =  Inceptionv3Output(features: outProvider)
            results.append(result)
        }
        return results
    }
}

 

세번째 부분은 모델부분 이다.

자세한 설명은 주석참고


이미지 크기 Resizing

맞는 데이터 형식과 이미지포멧으로 데이터를 받게 되지만 299x299보다 훨씬 큰 사이즈의 이미지를 받게 된다. 

 

CIImage를 작성한다.

 

CIIMage 는 원시 픽셀 데이터를 추출하기 위해 이미지를 조작하는 데 편리한 메서드를 제공한다.

 

resize 함수를 구현한다.

func resize(size: CGSize) -> CIImage {
        let scale = min(size.width, size.height)/min(self.extent.size.width, self.extent.size.height)
        let resizedImage = self.transformed(by: CGAffineTransform(
        scaleX: scale, y: scale))
        
        //이미지가 정사각형 형태가 아닐경우 초과하는 부분을 잘라내기 위한 코드
        let width = resizedImage.extent.width
        let height = resizedImage.extent.height
        let xOffset = ((CGFloat(width) - size.width)/2.0)
        let yOffset = ((CGFloat(height) - size.height)/2.0)
        let rect = CGRect(x: xOffset, y: yOffset, width: size.width, height: size.height)
        return resizedImage.clamped(to: rect).cropped(to:
            CGRect(x: 0, y: 0, width: size.width, height: size.height))
    }

 

CIImage에서 CVPixelBuffer를 얻는 기능을 만들 차례이다;

func toPixelBuffer(context:CIContext,size insize:CGSize? = nil,gray:Bool=true) -> CVPixelBuffer?
    {
        //이미지를 렌더링 할 픽셀 버퍼를 만든다.
        //픽셀 버퍼의 호환성 요건을 정의하는 속성을 담은 배열
        let attributes = [
            kCVPixelBufferCGImageCompatibilityKey : kCFBooleanTrue, //CGImage형식과 호환
            kCVPixelBufferCGBitmapContextCompatibilityKey : kCFBooleanTrue //CoreGraphics 비트맵과 호환
        ] as CFDictionary
        
        var nullablePixelBuffer : CVPixelBuffer? = nil
        let status = CVPixelBufferCreate(kCFAllocatorDefault,
                                         Int(self.extent.size.width),
                                         Int(self.extent.size.height),
                                         gray ? kCVPixelFormatType_OneComponent8 : kCVPixelFormatType_32ARGB,
                                         attributes,
                                         &nullablePixelBuffer)
        guard status == kCVReturnSuccess, let pixelBuffer = nullablePixelBuffer
            else{
                return nil
        }
        
        //픽셀 버퍼 잠금
        CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
        context.render(self,
                       to: pixelBuffer,
                       bounds: CGRect(x: 0,
                                      y: 0,
                                      width: self.extent.size.width,
                                      height: self.extent.size.height),
                       colorSpace: gray ? CGColorSpaceCreateDeviceGray() : self.colorSpace)
        
        //픽셀 버퍼 잠금 해제
        CVPixelBufferUnlockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
        return pixelBuffer
    }

ViewController 의 onFrameCapture에서 toPixelBuffer를 사용한다.

    let context = CIContext()

 

viewcontroller에 위 코드를 추가한다.

 

func onFrameCaptured(videoCapture: VideoCapture,pixelBuffer:CVPixelBuffer?,timestamp:CMTime){
        videoCapture.delegate = self
        
        
        guard let pixelBuffer = pixelBuffer else{ return }
        
        //모델에 쓰일 이미지 준비
        guard let scaledPixelBuffer = CIImage(cvImageBuffer: pixelBuffer).resize(size: CGSize(width: 299, height: 299)).toPixelBuffer(context: context)else { return }
        
    }

Prediction

import 한 모델을 인스턴스화 시킨다.

    let model = Inceptionv3()

 

그리고 onFrameCaptured에서 prediction 하는 코드를 추가한다.

func onFrameCaptured(videoCapture: VideoCapture,pixelBuffer:CVPixelBuffer?,timestamp:CMTime){
        videoCapture.delegate = self
        
        guard let pixelBuffer = pixelBuffer else{ return }
        
        //모델에 쓰일 이미지 준비
        guard let scaledPixelBuffer = CIImage(cvImageBuffer: pixelBuffer).resize(size: CGSize(width: 299, height: 299)).toPixelBuffer(context: context)else { return }
        
        let prediction = try? self.model.prediction(image: scaledPixelBuffer)
        
        //레이블 업데이트
        DispatchQueue.main.async {
            classifiedLabel.text = prediction?.classLabel ?? "이건모르겠음"
        }
        
    }

이제 앱을 실행해볼까

나름 맞다


[프로젝트 코드전체]

 

KimTaeHyeong17/CoreML_Object_Detection

CoreML을 사용한 Object Detection . Contribute to KimTaeHyeong17/CoreML_Object_Detection development by creating an account on GitHub.

github.com

 

728x90

'iOS > CoreML' 카테고리의 다른 글

CoreML 배워보자 (1)  (0) 2020.04.13

댓글