MetroPilot 케이스 스터디 데모 체험  |  로그인

케이스 스터디 — 사진밖에 없는 계측 데이터를 믿을 수 있게 만들기

README가 "무엇을 만들었나"라면, 이 문서는 "무엇을 결정했고 왜 그렇게 결정했나"입니다. 막힌 지점과 그때 고른 선택지를 순서대로 적었습니다.

0. 한 줄로

반도체 계측 장비가 측정 결과를 사진으로만 저장하고 데이터 파일을 남기지 않아서, 그 사진을 읽어 측정 데이터·공정능력 통계·보고서를 만드는 시스템을 만들었습니다.

기술적으로 어려웠던 건 "사진에서 숫자를 읽는 것"이 아니었습니다. 읽어낸 숫자를 믿어도 되는지 판단하는 것이었습니다.

계측 데이터에서 가장 위험한 실패는 프로그램이 멈추는 게 아니라, 틀린 값을 자신 있게 내놓는 것입니다. 아래 결정들은 전부 이 한 문장에서 나왔습니다.

1. 문제 — 장비가 데이터를 안 준다

wafer 위 여러 Point의 선폭(CD)과 정렬 오차(Overlay)를 계측 장비로 측정합니다. 장비는 결과를 화면에 띄우고, 그 화면을 사진(.bmp)으로 저장합니다. 그게 전부입니다. CSV도, 로그도 없습니다.

그래서 실제 업무는 이랬습니다.

  1. 사진을 한 장씩 연다
  2. 사진에 찍힌 숫자를 눈으로 읽는다
  3. 엑셀에 손으로 옮겨 적는다
  4. 그 값으로 Cp/Cpk를 계산한다

Point 하나에 사진 6장, wafer 하나에 Point 10개 이상. 한 번 측정할 때마다 수십~수백 장입니다.

시간도 문제였지만 진짜 문제는 따로 있었습니다. 눈으로 읽고 손으로 옮기는 과정에서 오타가 나도 아무도 모릅니다. 원본이 사진이라 나중에 대조할 수도 없습니다.

사진에 뭐가 찍혀 있나

측정값 사진에는 흰 글자로 이렇게 찍힙니다.

2=6.8,XY=(0.0,6.8)um

번호=값,XY=(X좌표,Y좌표) 형식입니다. 그리고 몇 가지 성가신 성질이 있습니다.

2. 첫 번째 결정 — 범용 OCR을 버리다

처음에는 당연히 Tesseract를 썼습니다. 그리고 값이 널뛰었습니다.

관점을 바꾼 지점

Tesseract는 "어떤 폰트로 쓰였는지 모르는 글자"를 읽는 도구입니다. 그래서 확률적으로 추측합니다.

그런데 이 사진은 그런 상황이 아니었습니다. 계측기 화면이라 폰트도 글자 색도 항상 똑같습니다. 모르는 걸 추측할 이유가 없었습니다.

그래서 범용 OCR을 버리고 템플릿 매칭으로 갔습니다.

판독 파이프라인 6단계

  1. 정답을 눈으로 확인한 사진에서 0~9와 기호(= , . ( ) X Y) 모양을 글자 단위로 잘라 저장 (build_templates.py → char_templates.pkl)
  2. 새 사진에서는 글자를 하나씩 잘라 템플릿과 픽셀 일치율로 비교해서 가장 비슷한 것을 고름 (ocr_core.py)
  3. 일치율이 기준(CONFIDENCE_THRESHOLD = 0.85)에 못 미치면 값을 내놓되 확인필요로 표시

결과

Sample 폴더 사진 72장(측정값이 찍힌 39장) 전체를 다시 돌렸습니다.

항목 Tesseract 템플릿 매칭
CD (Target 14.5) 12.8 ~ 18.3 으로 널뜀 14.6 ~ 15.3
L/S (Target 9.5 / 5.5) 8.7 같은 값이 튀어나옴 9.2~9.6 / 5.2~5.7
Overlay 좌표 오추출 발생 0건
신뢰도 미달 — 0건 (39장 전부 0.85 이상)

배운 것: 범용 도구가 안 되면 더 좋은 범용 도구를 찾는 게 아니라, 내 문제에만 있는 제약(폰트가 고정이다)을 무기로 쓸 수 있는지 먼저 보는 게 빨랐습니다.

3. 두 번째 결정 — 순서를 믿지 않는다

사진은 시간순으로 저장되지만, 그 순서가 무엇을 뜻하는지는 보장되지 않습니다. Overlay가 먼저 찍힐 수도, Line/Space가 먼저 찍힐 수도 있고, 측정 전/측정값 순서도 항목마다 다릅니다.

"n번째 사진은 CD"처럼 순서를 가정하는 코드는 당장은 돌아가고 언젠가 반드시 조용히 틀립니다. 그리고 틀렸을 때 아무 티가 안 납니다.

그래서 순서를 아예 쓰지 않기로 했습니다.

마지막 항목이 특히 그랬습니다. 처음엔 "번호 0,1,2,3이 곧 방향이겠지" 했는데, 실제 사진에서는 번호 순서와 화면 위치가 사진마다 뒤바뀌었습니다. 번호를 믿었으면 상/하가 뒤집힌 데이터를 자신 있게 내놨을 겁니다.

4. 실제 사진이 가르쳐준 것들

여기서부터는 책상에서 나온 설계가 아니라, 실제 문제 사진을 폴더째 확보해서 재현하며 나온 수정들입니다.

4-1. 겹쳐 그려진 측정줄

계측기가 측정줄 두 개를 화면에 겹쳐 그리는 경우가 있습니다. 글자가 물리적으로 뭉개져서 그 줄은 못 읽습니다.

겹쳐 찍힌 라벨을 재현한 이미지

※ 실제 계측 사진은 공정 데이터라 공개할 수 없어, 같은 현상을 코드로 재현한 합성 이미지입니다(scripts/make_demo_images.py --overlap). 라벨 두 개가 겹쳐 2=1.3,XY=(1.3,0.0)과 3=1.0,XY=(1.0,0.0)이 한 줄로 섞였습니다. 아래는 같은 사진의 정상적인 줄입니다.

정상적으로 찍힌 라벨

재현한 이미지를 실제 판독기에 넣어보면 이렇게 나옵니다 — 줄 4개가 3개로 잡히고, 값은 2개만 읽힙니다. 글로 쓴 현상이 실제로 그렇게 동작한다는 걸 이미지 자체가 증명합니다.

여기까지는 "한 줄 못 읽음"이면 끝날 문제인데, 실제로는 훨씬 나쁜 일이 벌어졌습니다. 종류를 "읽은 값 개수"로 판별하고 있었기 때문에, 4줄짜리 Overlay 사진이 2줄만 읽히면 Line/Space로 둔갑해서 엉뚱한 Target에 매칭됐습니다. 한 줄을 못 읽은 게 사진 한 장 전체를 오염시킨 겁니다.

→ 종류 판별의 근거를 "읽은 개수"에서 "글자 박스의 폭으로 역산한 실제 줄 개수"로 바꿨습니다 (count_lines_in_boxes). 값을 못 읽어도 "여기 줄이 몇 개였다"는 사실은 살아남습니다.

그리고 그런 사진에서 읽힌 나머지 값도 전부 확인필요로 표시합니다. 옆줄이 뭉개질 정도의 사진이면 읽힌 값도 의심스럽기 때문입니다.

곁가지로 두 가지를 더 고쳤습니다.

4-2. X와 Y가 붙어버리는 문제

X와 Y가 화면에서 맞닿게 찍히면 글자 하나로 뭉개져 엉뚱한 글자(주로 7)로 읽힙니다.

예전 코드는 이때 "형식이 안 맞는다"며 줄 전체를 버렸습니다. Sample3 폴더에서는 14장 중 12장을 통째로 놓치고 있었습니다. 값 자체는 멀쩡히 읽혔는데 구분자가 뭉개졌다는 이유로요.

→ 값과 좌표 사이의 ,XY= 부분은 글자를 그대로 요구하지 않고 "아무 글자 3개까지"로 완화했습니다.

LINE_RE = re.compile(r'(\d)=(\d+\.\d).{0,3}=\((\d+\.\d)[,.](\d+\.\d)\)')
#                                    ^^^^^^ 여기가 ,XY= 자리

느슨하게 풀면 위험해 보이지만, 뒤에 나올 값-좌표 교차검증이 그 위험을 잡아줍니다. 4개 폴더 전수 비교로 안전한지 확인하고 풀었습니다.

같은 맥락에서 신뢰도 점수도 고쳤습니다. ,XY= 부분 글자는 점수 계산에서 빼고 실제로 값에 쓰는 글자(번호·값·X·Y)만 봅니다. 안 그러면 구분자가 뭉개졌다는 이유로 멀쩡한 줄이 무더기로 확인필요에 걸려서, 진짜 이상값이 그 속에 묻힙니다.

4-3. 밝은 패드를 글자로 착각

밝은 원형 패드 무늬가 글자 마스크로 잡히면서 세로 180px짜리 큰 덩어리가 생겨 글자줄로 오인됐습니다.

추측으로 임계값을 정하는 대신 실측했습니다. 4개 폴더 187줄을 전수 확인한 결과, 측정줄 한 줄의 높이는 항상 19px이었고 두 줄이 겹쳐도 28px을 넘지 않았습니다.

→ MAX_LINE_HEIGHT = 40 — 세로 40px을 넘는 덩어리는 글자줄에서 제외합니다. 실측 최대치 28px에 여유를 두되, 오인 사례인 180px과는 확실히 갈리는 값입니다.

배운 것: 임계값은 감으로 정하면 나중에 왜 그 숫자인지 아무도 모릅니다. 세어보고 정하면 근거가 코드에 남습니다.

5. "읽었다"와 "맞다"는 다르다

여기가 이 프로젝트에서 가장 마음에 드는 부분입니다.

측정줄 한 줄에는 숫자가 셋 있습니다 — 값, X좌표, Y좌표. 실제 사진 113줄을 놓고 이 셋의 관계를 살펴보다가 규칙을 찾았습니다.

값 = √(X² + Y²)

값과 좌표의 직각삼각형 관계

※ 실제 판독 데이터입니다. 왼쪽은 Sample 폴더의 통과 사례, 오른쪽은 LS Sample 폴더에서 유일하게 걸린 줄입니다.

값은 좌표 벡터의 길이였습니다. 그런데 중요한 건 이겁니다 — 값과 좌표는 줄 안에서 따로 찍혀 있고, OCR도 따로 읽습니다. 즉 서로 독립적으로 읽힌 숫자끼리 검산이 됩니다. 한쪽을 잘못 읽으면 이 등식이 깨집니다.

→ 이 관계가 허용 오차(REDUNDANCY_TOLERANCE = 0.05)보다 크게 어긋나면 확인필요로 표시합니다 (read_value_line).

그리고 예상 못한 결과

113줄 중 112줄은 오차 0.012 이하로 통과했고, 딱 1줄이 걸렸습니다. 오탐 0건입니다.

걸린 줄(8 (45).bmp의 번호 1)은 값이 1.8인데 좌표 길이는 1.71, 오차 0.088이었습니다. 사진 실물을 잘라서 확인했습니다.

OCR은 정상이었습니다. 사진에 1=1.8,XY=(0.2,1.7) 이라고 그대로 찍혀 있었습니다. 계측기가 값과 좌표를 서로 안 맞게 출력한 것이었습니다.

그래서 이 장치의 이름을 바꿨습니다. "OCR 오독 검출기"가 아니라 "값-좌표 정합성 검증기"입니다. OCR 오독과 원본 데이터 불일치를 둘 다 잡습니다.

배운 것: 데이터 안에 이미 들어 있는 중복(redundancy)을 찾으면, 추가 정보 없이 검산할 수 있습니다. 그리고 검증기를 만들면 내가 의심하던 것 말고 다른 것이 잡힐 수 있습니다 — 그때 검증기의 정의를 고쳐야지, 결과를 무시하면 안 됩니다.

6. 답을 모를 때는 지어내지 않는다

Line/Space 측정은 한 사진에 값이 두 개 찍히는데, 어느 쪽이 Line이고 어느 쪽이 Space인지 표시가 없습니다.

보통은 Target이 다르니 가까운 쪽으로 배정하면 됩니다. 그런데 Line Target과 Space Target이 같은 경우(둘 다 2um)가 있었습니다. 오차가 정확히 같아져서(0 = 0) 배정이 사실상 동전 던지기가 됩니다.

사진 속 번호 순서나 화면 위치는 앞서 봤듯 사진마다 뒤바뀌어서 못 씁니다.

물리에서 답을 찾다

이 사진들은 도금 전 사진입니다. Line은 PR(감광액)이 없어 바닥면이 보여 어둡게, Space는 PR이 덮여 있어 밝게 찍힙니다.

크로스헤어(측정 십자선) 주변 픽셀 밝기를 비교해봤더니, 테스트 폴더 6장 전부 "큰 값 = 어두움 = Line" 패턴이 일관됐습니다.

마커가 줄무늬 한가운데 놓인 경우

※ 코드로 그린 사진입니다. 마커 주변 밝기를 프로그램이 실제로 재보면 Line 69.2 / Space 98.0 — 차이 28.8로 확실히 갈립니다. 번호 순서가 뒤집힌 문제 사진에서도 밝기 패턴만은 안 뒤집혔습니다.

그런데 100%는 아니었다

전체 11장으로 넓히니 9장만 일치했습니다. 크로스헤어가 밝기 경계에 걸치는 사진이 있었습니다.

마커가 밝기 경계에 걸친 경우

※ 같은 자리를 같은 축척으로 잘랐습니다. 마커만 경계로 옮겼을 뿐인데 Line 69.2 / Space 79.0 — 차이가 28.8에서 9.8로 줄어듭니다. 조금 더 어두운 띠 쪽으로 밀면 차이는 0.1까지 떨어집니다.

여기서 알게 된 것이 하나 더 있습니다. 이 검증은 틀린 답을 내놓는 방식으로 무너지지 않습니다. Line 마커도 같은 어두운 띠 위에 있어서 Space가 그보다 더 어두워질 수는 없기 때문에, 판정이 뒤집히는 게 아니라 두 밝기의 차이가 0에 수렴해 판정이 동전 던지기가 됩니다. 그래서 "밝기가 반대로 나왔다"가 아니라 "밝기로는 아무 말도 할 수 없다"가 정확한 표현입니다.

여기서 선택지가 둘이었습니다.

선택 결과
밝기 검증 결과로 배정을 뒤집는다 82% 확률로 맞고 18% 확률로 조용히 틀림
배정은 그대로 두고 불일치를 표시한다 사람이 그 2장을 확인하게 됨

두 번째를 골랐습니다. 배정은 1차 원칙(Line이 Space보다 물리적으로 큼)대로 두고, 밝기 검증이 어긋나면 로그를 남기고 해당 행을 확인필요로 표시합니다 (verify_ls_brightness, match_ls_targets).

82%짜리 근거로 값을 뒤집는 건, 정확도를 조금 올리는 대신 "이 값이 왜 이렇게 됐는지 아무도 모르는" 경우를 만드는 일입니다. 계측 데이터에서는 남는 장사가 아닙니다.

같은 철학이 프로그램 전체에 일관됩니다 — 값-좌표 검증도, 밝기 검증도, 신뢰도 점수도 값을 바꾸지 않습니다. 표시만 합니다.

디버깅 노트: 첫 구현에서 verify_ls_brightness가 NumPy의 np.False_를 반환해서 is False 비교가 통과하지 않았습니다. 파이썬 False와 값은 같지만 같은 객체가 아니라서요. bool()로 감싸 해결했습니다.

7. 조용히 틀리지 않게 — 회귀 테스트

측정 도구를 고칠 때 제일 무서운 건 판독 결과가 달라졌는데 모르고 지나가는 것입니다.

처음의 기준값은 이랬습니다.

5.5 Space Avg=5.3923 / 9.5 Line Avg=9.4154 / 14.5 Pad CD Avg=14.9538, 측정결과 92행

평균 3개와 행 수. 그리고 이게 새는 그물이라는 걸 실제로 겪었습니다. 사진 한 장(9-1_22.png)이 통째로 안 읽혔는데 평균은 멀쩡해서 안 걸렸습니다.

→ 기준을 사진 한 장 한 장의 판독 결과 전체로 내렸습니다 (scripts/regression_check.py).

기준 데이터셋도 그냥 모은 게 아닙니다. LS Sample은 Line Target = Space Target 상황을 재현하려고 직접 촬영해서 추가한 것이고, Sample2/Sample3은 겹친 줄·XY 붙음 버그를 재현하는 폴더입니다. 버그를 고칠 때마다 그 버그를 재현하는 사진이 기준값에 남았습니다.

한 가지가 더 붙었습니다. 실측 사진은 사내 데이터라 공개 저장소에 넣을 수 없는데, 기준값이 실측 사진에만 묶여 있으면 공개본에서는 회귀 검증을 아예 돌릴 수 없습니다. 그래서 합성 사진(10절)을 6번째 기준값으로 추가했습니다 — regression_check.py --demo는 사진이 없으면 그 자리에서 만들어(난수 seed 고정) 기준값과 대조합니다. 저장소를 clone 받은 사람도 같은 검증을 돌릴 수 있습니다.

8. 결과물 — 무엇이 나오는가

측정 결과 리포트

※ 예시 데이터로 생성한 화면입니다. 실제 공정 측정값이 아닙니다.

엑셀에는 측정값·Target·규격 판정이 행 단위로 남고, HTML 리포트에는 항목별 Cpk와 추세가 함께 나옵니다. 값 하나하나에 확인필요·입력방법이 붙어 있어서, 나중에 "이 숫자는 기계가 읽은 것인가 사람이 넣은 것인가"를 되짚을 수 있습니다.

9. 두 얼굴, 한 몸

데스크탑 프로그램(tkinter)으로 시작했는데, 사내 여러 명이 쓰게 되면서 웹 버전이 필요해졌습니다.

측정 로직을 복사하지 않았습니다. 웹은 기존 코어를 그대로 import 합니다. 판독 규칙이 두 벌 존재하면 언젠가 갈라지고, 그러면 "데스크탑 결과와 웹 결과가 다른" 최악의 상황이 옵니다.

다만 한 곳에서 구조가 갈렸습니다. 못 읽은 값을 사람이 입력하는 단계입니다.

손으로 넣은 값도 OCR 값과 완전히 같은 경로(Target 매칭·이상치 검사)를 지나갑니다. 그리고 엑셀의 입력방법 열에 자동/수동이 남습니다. 계측 데이터니까 사람이 넣은 값이 기계가 읽은 값과 안 섞이고 추적돼야 합니다.

운영은 Oracle Cloud ARM + Docker Compose + Caddy(Let's Encrypt 자동 갱신)이고, 배포는 스크립트 한 줄입니다.

10. 보여줄 수 있게 만들기 — 계측기 사진을 코드로 그리다

도구를 다 만들고 나서 막힌 곳은 엉뚱한 데였습니다. 보여줄 수가 없었습니다. 실제 계측 사진은 사내 데이터라 밖에 못 내놓고, 로그인 없이는 화면 하나 열리지 않으니 "이런 걸 만들었습니다"라고 말할 방법이 없었습니다.

그래서 계측기 사진을 코드로 그렸습니다(scripts/make_demo_images.py).

실제 사진을 픽셀 단위로 관찰해서 다시 만들었습니다. 처음엔 SEM으로 짐작하고 회색 고대비 배경을 그렸는데 실물과 전혀 달랐습니다 — 광학 현미경 사진이라 전체적으로 흐릿하고 대비가 아주 낮습니다(전 채널 80~150). 색 상수는 전부 실측값에서 가져왔습니다.

제일 중요한 발견은 측정 표시였습니다. 십자 마커가 아니라 선분 + 양 끝 X이고, 전부 초록으로 이어 그려야 합니다. ls_brightness가 초록 덩어리의 중심 주변 밝기를 재기 때문입니다 — X만 따로 찍으면 중심이 경계에 놓여 밝은 쪽과 어두운 쪽이 반반 섞이고 판정이 무너집니다.

제약도 하나 있었습니다. 배경은 어느 채널도 170을 넘으면 안 됩니다. 판독기가 R>170 & G>170 & B>170을 글자로 보기 때문에, 넘으면 배경 무늬가 글자 덩어리로 잡혀 줄 개수 판별이 틀어집니다. 다행히 실제 사진도 최대 150 언저리라 이 제약이 사실성을 해치지 않았습니다.

코드로 그린 계측기 사진

※ 위 사진은 전부 코드가 그린 것입니다. 4-1절의 겹친 라벨도 이 생성기로 재현했습니다 — 화면 가운데 왼쪽에 두 라벨이 겹쳐 있습니다.

검증은 자기참조로 했습니다. 만든 사진을 진짜 판독기로 다시 읽어 줄 수별 장수가 {0:9, 1:9, 2:9, 4:9}로 나오고 확인필요가 0건인지 확인합니다. 생성기만 통과하고 실제 프로그램에서 안 읽히면 아무 소용이 없으니까요.

결과만 보여주는 데모는 절반짜리였다

사진 36장으로 무로그인 데모를 열었더니 곧 문제가 드러났습니다. 방문자가 무슨 사진이 처리되는지 모른 채 숫자 표만 보게 된 것입니다. 이 도구의 값어치는 "사진 → 숫자"인데 정작 그 변환이 화면에 없었습니다.

그래서 판독 과정을 눈에 보이게 만들었습니다.

여기서 한 가지를 의식적으로 지켰습니다. 사진이 고정이니 값도 고정이라 미리 계산해 넣고 싶어지지만, 그러면 판독이 깨져도 화면은 멀쩡해 보입니다. 데모가 거짓말을 하게 되는 것입니다. 그래서 그림(미리보기·좌표)은 한 번 만들어 공유하고, 값은 그 실행이 실제로 읽은 결과에서 가져옵니다.

11. 돌아보며

관통하는 원칙 하나를 꼽으면 이겁니다.

모르면 모른다고 말한다.

전부 "정확도를 조금 포기하고 설명 가능성을 얻는" 교환입니다. 일반적인 소프트웨어라면 과할 수 있지만, 계측 데이터는 틀린 값 하나가 공정 판단을 바꿉니다. 값이 틀렸다는 걸 알 수 있는 상태가, 틀렸는지 모르는 상태보다 낫습니다.

그리고 이 원칙은 도메인을 알아야 세울 수 있었습니다. Line이 Space보다 크다는 것, PR이 없으면 어둡게 찍힌다는 것, Cpk 계산에서 표본표준편차를 써야 한다는 것 — 코드가 아니라 공정 지식에서 나온 판단들입니다.

부록 — 숫자로 보는 프로젝트

Python 7,893줄
커밋 242개
회귀 테스트 데이터셋 실측 5종 + 합성 1종
외부 OCR 엔진 0개 (자체 템플릿 매칭)
리포트 차트 라이브러리 0개 (SVG 직접 생성)
배포 Oracle ARM · Docker · Caddy · 스크립트 1줄

의존성을 얇게 유지한 건 취향이 아니라 제약이었습니다. 사내망에서 설치 승인 없이 돌아가야 했습니다.