개발현황

크레플의 R&D를 소개합니다.

R&D

개발현황

라인스캔카메라 프레임 깨짐 문제 해결기

  • 관리자
  • 2025.04.16

라인스캔카메라 프레임 깨짐 문제 해결기

라인스캔카메라 프레임 깨짐 문제 해결기

aka 메모리 덮어쓰기와 얕은 복사의 함정

들어가며

얼마전 저희 팀은 파스 제작에 사용되는 플라스타 원단 검사 장비를 개발하여 고객사에 납품하였습니다.
이 장비는 고속으로 흘러가는 원단라인스캔 방식으로 촬영하고, 실시간 검사를 통해 불량을 검출하여 커팅 직후 불량 패치에 대해 배출 신호를 발생시키는 역할을 합니다.

장비 개발이 완료된 이후, 촬영된 이미지 중 프레임이 깨진 듯한 현상이 발견되었습니다.
증상은 간헐적으로 발생했으며(약 1000m 진행 중 1~2회 수준), 한 번 발생하면 연속적으로 반복되는 경향이 있어 근본적인 원인 분석 및 해결이 필요했습니다.


프레임 깨짐 문제, 픽셀 소실 인가?

실제 이미지를 사용할 수 없어 문제 증상을 설명하기 위한 예시 이미지를 제작하였습니다.

정상적으로 촬영된 경우에 대한 예시 이미지
정상 촬영 예시 이미지

프레임 깨짐 문제가 발생한 경우에 대한 예시 이미지
비정상 촬영 예시 이미지

라인스캔카메라에서 한 프레임을 획득하는 방식은 다음과 같습니다.

1 line 촬영 (4096 x 1 pixel) -> 버퍼에 저장 -> 다음 line 촬영 -> 버퍼에 저장 -> ... -> 1000 line 완성 -> 1 frame 반환

미리 확보해놓은 문제 이미지 데이터를 분석해보았을 때 마치 프레임의 일부 영역이 소실되어 있는 것처럼 보입니다.

만약 진짜 픽셀 소실이라면 원단의 진행방향에 따라 상단 -> 하단 연속성이 존재해야하는데

실제로는 상단에 더 나중에 촬영된 영역이 나타나는 경우도 발생하였기 때문에

프레임 깨짐 현상은 픽셀 소실이 아닌 덮어쓰기 일 가능성에 조금 더 가깝다고 판단하였습니다.


문제 재현을 위한 테스트 환경 구축

문제 증상을 재현하고 원인을 분석하기 위하여 최소한의 구성만으로 테스트할 수 있는 간이 환경을 사무실 내에 별도로 구성하였습니다.
실제 장비에서는 엔코더 기반 트리거로 카메라 촬영이 이루어지지만, 테스트 환경에서는 트리거 없이 Continuous 모드로 설정하고 Line Rate를 조정하여 촬영 속도를 조절하였습니다.

본 장비 개발에 사용된 부품 구성
실제 장비 구성

테스트 장비에 사용된 부품 구성
테스트 장비 구성

이처럼 단순화된 구성에서도 문제 현상이 재현 되었으며, 덕분에 본격적인 원인 분석을 진행할 수 있었습니다.


의심 가득했던 하드웨어, 결백 판정

처음에는 엔코더 신호의 불안정성, 조명 컨트롤러 싱크 문제, 카메라 고장 등 다양한 가능성을 고민했습니다.

당연히 디버깅을 어디서부터 어떻게 시작해야할지 막막했습니다.

하지만 고맙게도? 테스트 장비를 구동하여 동일한 증상을 재현해 내는데 성공 했기 때문에 테스트 환경 구성에 포함되지 않은 항목들은 모두 의심 대상에서 제외시킬 수 있었습니다.

또한 카메라 제조사에서 제공한 공식 프로그램(이하 MVS)으로 촬영 했을 때는 문제가 발생하지 않았지만,

자체 개발한 비전 프로그램으로 실행했을 때만 프레임 깨짐 현상이 발생하였기 때문에

문제 원인을 자체 개발 검사 프로그램으로 좁혀갈 수 있는 중요한 단서가 되었습니다.


검사 지연과 Queue 의 병목

클래스 다이어그램 일부 발췌
관계도.png

검사 프로그램에서 핵심 로직이 두 가지 존재합니다.

하나는 카메라에서 이미지를 가져오는 부분이고 다른 하나는 이미지를 검사하는 부분입니다.

두 가지 모두 멀티스레드로 구현되어 있으며 이미지는 Queue 자료구조를 통해 주고 받습니다.

검사 코드 부분 발췌
검사 코드

먼저 검사를 수행하는 코드(=find_defects 함수 내부 로직)를 모두 주석 처리하고 카메라에서 이미지를 가져오는 부분만 독립적으로 테스트를 해보았더니 프레임 깨짐 현상이 사라졌습니다.

다시 검사 관련 코드를 살리고 Queue에 이미지를 넣고 꺼내는 put/get 카운트를 확인해보니 검사 속도가 느려서 이미지가 Queue에 계속 쌓이고 있는 상황이었습니다.

Queue에서 꺼내는 속도보다 쌓이는 속도가 빠르면 당연히 병목은 생길 수 있지만, 이론적으로는 병목이 있어도 이미지가 ‘깨질’ 이유는 없었습니다.

Queue에 빠른 속도로 이미지를 입력하고 병목을 유발한 후 꺼내는 테스트 코드를 작성해 보았지만, 이 경우에는 프레임 깨짐 현상을 재현할 수 없었습니다.

그래서 두 가지 가정을 세우고 추가적인 테스트를 진행해보았습니다.

가정 1.

추론 코드 대신 time.sleep 만으로 지연을 발생시켰을 때도 동일한 증상이 발생하는가?
그렇다면 순수하게 Queue 의 문제라고 볼 수 있을 것이다.

→ inference code 주석처리하고 time.sleep(0.1) 으로 지연만 줬는데도 Queue 에 적재되고 이미지가 깨지는 증상이 재현되었음

가정 2.

이런 증상이 발생했을 때 촬영을 멈추고 기다리면 Queue input 카운트와 Queue output 카운트가 동일해지는가?
그렇다면 라인 픽셀이 소실된 것이 아니라 이미지가 덮어쓰여진 것이라고 볼 수 있을 것이다.

→ put 450 / get 375 상태에서 camera 연결은 유지하되 put 은 더이상 안하고 기다렸더니 get 450 까지 왔음

그래서 다시 한 번 의심의 방향을 바꾸어, 이번에는 Queue 에 담기는 이미지 객체 자체 에 주목했습니다.


이미지 객체의 복사 문제 발견, 깊은 복사로 해결

이미지를 데이터클래스로 감싸서 Queue 에 넣고 있기 때문에 만약 Queue에 넣는 데이터가 깨졌다면 분명히 에러가 발생해야 합니다.

하지만 에러는 발생하지 않았고, 이미지가 깨진 것처럼 보인다면 이미지 데이터 자체에 문제가 있을 것이라고 생각했습니다.

copy 메소드 적용 부분
copy 적용 코드

on_request 함수는 ImageData 객체를 전달 받아서 InspectionImageData로 변환한 후 Queue에 넣는 역할을 합니다.

혹시나 싶어 이미지 객체에 .copy() 메소드를 사용하여 깊은 복사를 시도해보았더니, 프레임 깨짐 현상이 사라졌습니다.

이미지 객체를 Queue에 넣기 전에 얕은 복사를 했기 때문에 이미지 객체가 계속해서 덮어쓰여진 것이었습니다.


심층 분석, 도대체 어디서 부터?

get_frame 코드 예시

코드를 살펴본 결과, HikRobot 에서 제공한 SDK 라이브러리를 통해 카메라 프레임을 가져오는 부분에서부터 문제가 시작되었음을 확인할 수 있었습니다.

카메라는 정해진 메모리 주소에 프레임 버퍼를 계속 작성하고 있고, 이렇게 만들어진 image_array 객체는 Queue 에 들어갈 때까지도 얕은 복사만 진행되고 있었습니다.

즉, 카메라 get_frame() 함수에서 반환된 NumPy 객체는 실제 버퍼 메모리를 참조하고 있었고,

이후로 Queue 에 이미지가 들어갈 때까지 계속 얕은 복사만 진행되었기 때문에

이 이미지를 Queue에 넣어도 고정된 이미지가 아니라 계속 갱신되는 버퍼를 가리키고 있었던 것입니다.

이로 인해 Queue에서 이미지를 꺼낼 때, 해당 이미지가 버퍼에 아직 쓰이고 있는 상태라면 프레임이 깨진 상태로 보일 수밖에 없었던 것 입니다.

정확한 검증을 위해 간단한 테스트 코드를 작성하여 문제를 재현해보았습니다.

예제 코드



import numpy as np

import queue

from ctypes import cast, POINTER, c_ubyte





# 예시 버퍼 (C 스타일 버퍼처럼 사용할 수 있는 메모리 공간)

class FrameInfo:

    def __init__(self):

        # 예시로 10개의 값을 가진 버퍼

        self.pBuffer = (c_ubyte * 10)(*range(10))  # 0부터 9까지의 값

        self.nFilledSize = 10  # 실제 데이터 크기





if __name__ == "__main__":

    # 객체 생성

    frame_info = FrameInfo()



    # 포인터를 사용하여 메모리 참조

    image_array = np.ctypeslib.as_array(cast(frame_info.pBuffer, POINTER(c_ubyte)), shape=(frame_info.nFilledSize,))



    # 원본 배열 출력

    print("Original buffer:", list(frame_info.pBuffer))



    # 참조된 NumPy 배열 출력

    print("NumPy array (referring to original buffer):", image_array) # [0 1 2 3 4 5 6 7 8 9]



    q = queue.Queue()

    q.put(image_array)

    q.put(image_array)

    q.put(image_array)



    # NumPy 배열 수정 (메모리 주소 참조)

    image_array[0] = 100  # NumPy 배열에서 첫 번째 값 변경



    # 원본 배열과 NumPy 배열 수정 후 출력

    print("Modified buffer (after changing NumPy array):", list(frame_info.pBuffer))

    print("Modified NumPy array:", q.get())  # [100   1   2   3   4   5   6   7   8   9]

        

FrameInfo 데이터 클래스는 C 스타일 버퍼처럼 사용할 수 있는 메모리 공간을 가지고 있습니다.

numpy 객체로 변환하고 이를 Queue에 넣은 후, numpy 객체를 수정하면 이미 넣은 Queue 객체도 함께 수정되는 것을 확인할 수 있습니다.


여기서 잠깐, 복사는 왜 중요할까?

Python에서는 객체를 복사할 때 얕은 복사(shallow copy) 와 깊은 복사(deep copy) 의 차이를 명확히 이해하는 것이 중요합니다.

특히, 이미지나 배열처럼 데이터 용량이 크고 변경 가능(mutable)한 객체를 다룰 때는 더욱 그렇습니다.

얕은 복사란?

얕은 복사란, 객체 자체는 새로 복사하지만, 그 안에 포함된 내부 요소(하위 객체)는 원본과 같은 객체를 참조하는 복사 방식입니다.

깊은 복사란?

깊은 복사란, 객체를 복사할 때 최상위 객체는 물론, 그 내부에 포함된 모든 하위 객체까지 재귀적으로 복사하는 방식입니다.
원본과 복사본이 완전히 독립적인 상태가 됩니다.

예제 코드



import copy



original = [1, [2, 3]]



shallow = copy.copy(original)

deep = copy.deepcopy(original)



original[1][0] = 999



print(shallow)  # [1, [999, 3]] ← 내부 리스트는 원본과 연결됨

print(deep)     # [1, [2, 3]]   ← 완전히 독립된 복사본

        

위 예제 코드에서 볼 수 있듯 얕은 복사는 내부 리스트가 원본과 연결되어 있어 원본을 변경하면 복사본도 영향을 받지만, 깊은 복사는 완전히 독립된 복사본을 생성합니다.

다음 코드에서는 어떤 결과가 나올까요?



import copy



original = [1, [2, 3]]



shallow = copy.copy(original)

deep = copy.deepcopy(original)



original[0] = 999



print(shallow)  #               ← 얕은 복사 결과는?

print(deep)     # [1, [2, 3]]   ← 완전히 독립된 복사본

        

정답은 [1, [2, 3]]가 출력됩니다.

얕은 복사는 원본이 변경되면 복사본도 영향을 받는다고 설명했는데 왜 그럴까요?

original[0] 은 immutable한 정수값이기 때문입니다.

파이썬에서 mutable한 객체(리스트, 딕셔너리 등)를 변수 간 대입하면 실제로는 값이 할당되지 않고 같은 메모리 주소를 바라봅니다.

반대로 immutable한 객체(숫자, 문자열, 튜플 등)는 값을 복사하여 새로운 객체를 생성합니다.

위와 같은 경우 original[0] 의 값은 immutable 하기 때문에 값을 변경할 경우 새로운 메모리 공간에 값을 할당하게 되어 얕은 복사 결과에 영향을 주지 않습니다.

파이썬에서 얕은 복사를 수행할 때는 copy 라이브러리 외에도 리스트 슬라이싱 [:] 이나 list() 생성자 등을 다양한 방법을 사용할 수 있습니다.

파이썬에서 제공되는 얕은 복사 방법들

방법 설명 예시
copy.copy() 표준 라이브러리 copy 모듈의 얕은 복사 함수 copy.copy(obj)
슬라이싱 리스트, 문자열, 튜플 등에 사용할 수 있는 얕은 복사 a[:]
생성자 사용 같은 타입의 생성자에 객체를 넣어서 복사 list(a), dict(b)
set() / tuple() 집합, 튜플도 생성자로 얕은 복사가 가능 set(s), tuple(t)
.copy() 메서드 일부 built-in 타입이 제공하는 복사 메서드 a.copy()

파이썬 자료형과 mutable/immutable 여부

class 설명 구분
list mutable한 순서가 있는 객체 집합 mutable
set mutable한 순서가 없는 고유한 객체 집합 mutable
dict key와 value가 맵핑된 객체, 순서 없음 mutable
bool 참, 거짓 immutable
int 정수 immutable
float 실수 immutable
tuple immutable한 순서가 있는 객체 집합 immutable
str 문자열 immutable
frozenset immutable한 set immutable

오늘의 교훈: “얕게 복사하면, 깊게 후회할 수 있다”

이번 문제는 메모리 참조와 관련된 아주 기본적인 개념을 간과함으로써 발생한 사례였습니다.

작은 메모리 관리 실수 하나가 실제 장비에서는 심각한 버그로 이어질 수 있다는 사실을 몸소 체감할 수 있었습니다.
그 덕분에 앞으로는 단순히 작동하니까 괜찮겠지라는 안일한 태도보다는,
코드 내부에서 무엇이 실제로 일어나고 있는지 항상 점검하고 검증하는 습관이 얼마나 중요한지 다시 한번 되새기게 되었습니다.

얕은 복사는 메모리 사용과 속도 면에서는 장점이 있지만,
구조가 복잡하거나 상태가 공유되는 환경에서는 예상치 못한 문제를 일으킬 수 있습니다.

특히 멀티스레드 환경이나 Queue를 통한 데이터 전달처럼 생명 주기가 길어지는 구조에서는
깊은 복사를 기본값으로 고려하는 것이 훨씬 안전한 선택이라는 점을 다시금 확인할 수 있었습니다.