4 ตรวจสอบชิ้นงาน OK/NG

หาตำแหน่งถาดด้วย ArUco ตรวจว่าชิ้นงานครบและไม่มีคราบ แล้ววัดว่าเครื่องตรวจตัดสินผิดบ่อยแค่ไหน

Author

ผศ.ดร.ศุภชัย วรพจน์พิศุทธิ์

Published

15 ตุลาคม 2569

Scene + CameraPixelsFeaturesMeasurandCalibrationUncertaintyDecision

โจทย์

สายบรรจุต้องตรวจว่าถาดแต่ละใบมี ชิ้นงานครบ 20 ช่อง และ ไม่มีคราบบนถาด ก่อนปิดกล่อง ปัญหาแรกคือถาดไม่เคยวางตรงที่เดิม ถ้ากำหนดตำแหน่งช่องเป็น pixel ตายตัว เมื่อถาดเลื่อนหรือกล้องเอียง ช่องจะไม่ตรงทันที (ปัญหาเดียวกับตารางสุดท้ายของใบงาน 2)

4 ArUco markers -> homography -> top view -> fixed ROI     (ส่วนที่ 1)
ROI -> dark ratio / difference from golden -> OK/NG       (ส่วนที่ 2)
30 test images -> confusion matrix -> FRR, FAR             (ส่วนที่ 3)

เมื่อจบใบงาน นักศึกษาควรสามารถ

  • หาตำแหน่งชิ้นงานจาก marker และแปลงภาพเป็นมุมมองด้านบนด้วย perspective transform
  • ตรวจการมี/ไม่มีของชิ้นงานใน ROI และหาตำหนิด้วยผลต่างจากภาพต้นแบบ (golden image)
  • ประเมินเครื่องตรวจด้วย confusion matrix, FRR และ FAR และเลือกเกณฑ์ตัดสิน

อุปกรณ์: กล้องพร้อมขาตั้ง, แผ่นถาด data/tray_sheet.pdf (พิมพ์ขนาดจริง 100%), ชิ้นงานกลมสีเข้มเส้นผ่านศูนย์กลางประมาณ 20 mm จำนวน 20 ชิ้น (เหรียญ 1 บาท หรือกระดุม), ดินสอหรือกระดาษสีเข้มเล็ก ๆ ทำ “คราบ”

Tipไม่มีกล้อง?

ใช้ภาพใน data/04_tray/ok (ถาดดี 15 ภาพ) และ data/04_tray/ng (ถาดเสีย 15 ภาพ) ได้ทั้งใบงาน

ใบบันทึกผล: ดาวน์โหลด PDF เปิดบน tablet แล้วเขียนผลลงในตารางและกรอบคำตอบ ส่งเป็น PDF เมื่อจบคาบ

โค้ดตั้งต้น

รันโค้ดนี้ให้ได้ผลก่อนเริ่มส่วนที่ 1 ดาวน์โหลดได้ที่ code/04_start.py โค้ดนี้รันได้ด้วยตัวเอง ไม่ต้องใช้โค้ดจากใบงานก่อนหน้า แก้ SOURCE ให้ตรงกับกล้องหรือไฟล์ที่ใช้ (ใบงาน 0)

"""Worksheet 4 starter: find the tray by its 4 ArUco markers and show the top view.

Run: python 04_start.py      (s = save top view, q = quit)
"""
import cv2
import numpy as np

SOURCE = "data/04_tray/ok/ok_00.jpg"     # an image file, or 0 for a webcam

S = 2                                    # pixel per mm in the top view
MARKER_MM = {0: (30, 30), 1: (267, 30), 2: (267, 180), 3: (30, 180)}   # marker centers (mm)
W, H = (267 - 30) * S, (180 - 30) * S    # top view size (pixel)
CELLS_MM = [(148.5 + (c - 2) * 40, 105 + (r - 1.5) * 40) for r in range(4) for c in range(5)]
CELLS = [(int((x - 30) * S), int((y - 30) * S)) for x, y in CELLS_MM]

detector = cv2.aruco.ArucoDetector(cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50))


def top_view(img):
    """Warp the tray to a top view, or return None if a marker is missing."""
    corners, ids, _ = detector.detectMarkers(img)
    if ids is None:
        return None
    found = {int(i): c[0].mean(axis=0) for i, c in zip(ids.ravel(), corners)}
    if not all(k in found for k in MARKER_MM):
        return None
    src = np.float32([found[k] for k in range(4)])
    dst = np.float32([((x - 30) * S, (y - 30) * S) for x, y in (MARKER_MM[k] for k in range(4))])
    M = cv2.getPerspectiveTransform(src, dst)
    return cv2.warpPerspective(img, M, (W, H))


is_image = isinstance(SOURCE, str) and SOURCE.lower().endswith((".jpg", ".png"))
cap = None if is_image else cv2.VideoCapture(SOURCE)

while True:
    if is_image:
        frame = cv2.imread(SOURCE)
    else:
        ret, frame = cap.read()
        if not ret:
            print("Failed to grab frame: check SOURCE")
            break

    top = top_view(frame)
    if top is None:
        view = frame.copy()
        cv2.putText(view, "need all 4 markers", (10, 30),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
    else:
        view = top.copy()
        for i, (cx, cy) in enumerate(CELLS):
            cv2.circle(view, (cx, cy), 9 * S, (0, 0, 255), 2)
            cv2.putText(view, str(i), (cx - 8, cy + 6), cv2.FONT_HERSHEY_SIMPLEX,
                        0.6, (0, 0, 255), 2)

    cv2.imshow("Worksheet 4", view)
    key = cv2.waitKey(30) & 0xFF
    if key == ord('s') and top is not None:
        cv2.imwrite("top_view.png", top)
        print("saved top_view.png")
    elif key == ord('q'):
        break

if cap is not None:
    cap.release()
cv2.destroyAllWindows()
ปุ่ม ทำอะไร
s บันทึกภาพ top view
q หยุดโปรแกรม

ตรวจว่าพร้อม: เห็นภาพถาดมุมบน พร้อมวงกลมสีแดงตรงช่องทั้ง 20 ช่อง

ส่วนที่ 1: หาตำแหน่งถาด

ส่วนนี้อธิบายฟังก์ชัน top_view() ในโค้ดตั้งต้นทีละขั้น

ArUco marker

ArUco คือสี่เหลี่ยมขาวดำที่มีรหัสอยู่ข้างใน OpenCV หาตำแหน่งมุมทั้ง 4 ของ marker และอ่านหมายเลขได้แม่นยำ โรงงานใช้ marker หรือจุดอ้างอิง (fiducial) แบบนี้บนถาด แผ่น PCB และโต๊ะหุ่นยนต์ แผ่นถาดของใบงานนี้มี marker หมายเลข 0–3 ที่มุมซ้ายบน ขวาบน ขวาล่าง และซ้ายล่าง

import cv2
import numpy as np

detector = cv2.aruco.ArucoDetector(cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50))

img = cv2.imread("data/04_tray/ok/ok_00.jpg")       # or a frame from the camera
corners, ids, _ = detector.detectMarkers(img)
print("found ids:", ids.ravel())

view = img.copy()                                   # draw on a copy, keep img clean
cv2.aruco.drawDetectedMarkers(view, corners, ids)
cv2.imshow("Markers", view)
cv2.waitKey(0)

แปลงเป็นมุมมองด้านบน

เรารู้ตำแหน่งจริงของจุดกึ่งกลาง marker บนแผ่นพิมพ์ (หน่วย mm) และรู้ตำแหน่งในภาพ (หน่วย pixel) สี่คู่จุดนี้พอสำหรับหา homography ซึ่งเป็นเมทริกซ์ 3 x 3 ที่แปลงระนาบเอียงในภาพให้เป็นภาพมองตรงจากด้านบน

S = 2                                                   # pixel per mm in the top view
MARKER_MM = {0: (30, 30), 1: (267, 30), 2: (267, 180), 3: (30, 180)}   # centers on the sheet
W, H = (267 - 30) * S, (180 - 30) * S                   # top view size (pixel)

def top_view(img):
    corners, ids, _ = detector.detectMarkers(img)
    if ids is None:
        return None
    found = {int(i): c[0].mean(axis=0) for i, c in zip(ids.ravel(), corners)}   # id -> center
    if not all(k in found for k in MARKER_MM):
        return None                                     # need all 4 markers
    src = np.float32([found[k] for k in range(4)])
    dst = np.float32([((x - 30) * S, (y - 30) * S) for x, y in (MARKER_MM[k] for k in range(4))])
    M = cv2.getPerspectiveTransform(src, dst)
    return cv2.warpPerspective(img, M, (W, H))

top = top_view(img)
cv2.imshow("Top view", top)
cv2.waitKey(0)

ในภาพ top view ทุก 1 pixel เท่ากับ 0.5 mm ทั่วทั้งภาพ ไม่ว่าถาดจะวางเอียงแค่ไหน ตำแหน่งของช่องจึงคำนวณจากแบบของแผ่นได้โดยตรง

CELLS_MM = [(148.5 + (c - 2) * 40, 105 + (r - 1.5) * 40) for r in range(4) for c in range(5)]
CELLS = [(int((x - 30) * S), int((y - 30) * S)) for x, y in CELLS_MM]     # in the top view

for i, (cx, cy) in enumerate(CELLS):
    cv2.circle(top, (cx, cy), 9 * S, (0, 0, 255), 2)
    cv2.putText(top, str(i), (cx - 8, cy + 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)

ทดลองเลื่อนและหมุนถาด (หรือเปิดภาพหลายภาพ) แล้วดูว่าวงกลมสีแดงยังตรงช่องหรือไม่

ทดลอง พบ marker ครบ 4 ตัว วงกลมตรงช่องทุกช่อง
ถาดตรง กลางภาพ
หมุนถาด 20 องศา
เอียงกล้อง 20 องศา
เอานิ้วบัง marker 1 ตัว

ส่วนที่ 2: ตรวจถาด

มีชิ้นงานหรือไม่

ในแต่ละช่อง นับสัดส่วน pixel ที่มืดกว่า threshold ภายในวงกลม ถ้ามีชิ้นงานสีเข้ม สัดส่วนจะสูง

def dark_ratio(gray, cx, cy, r=9 * S, t=128):
    m = np.zeros_like(gray)
    cv2.circle(m, (cx, cy), r, 255, -1)
    return np.mean(gray[m == 255] < t)          # 0..1

gray = cv2.cvtColor(top, cv2.COLOR_BGR2GRAY)
ratios = [dark_ratio(gray, cx, cy) for cx, cy in CELLS]
present = [______________ for r in ratios]      # True when a part is in the cell
print("parts:", sum(present), "/", len(CELLS))
ภาพ ช่องว่างจริง ช่องว่างที่ตรวจพบ dark ratio ต่ำสุดของช่องที่มีของ dark ratio สูงสุดของช่องว่าง
ok_00 ไม่มี
ng_00
ng_01

มีคราบหรือไม่: เทียบกับภาพต้นแบบ

golden image คือภาพ top view ของถาดที่ดี ภาพที่ตรวจจะถูกลบกับภาพนี้ทีละ pixel (cv2.absdiff) บริเวณที่ต่างกันมากคือสิ่งที่ไม่ควรมี เพราะทั้งสองภาพถูกจัดตำแหน่งด้วย marker แล้ว ลวดลายที่พิมพ์ไว้จะซ้อนกันพอดีและหักล้างกัน

def prep(img):
    g = cv2.cvtColor(top_view(img), cv2.COLOR_BGR2GRAY)
    return cv2.GaussianBlur(g, (5, 5), 0)               # reduce pixel noise

golden = prep(cv2.imread("data/04_tray/ok/ok_00.jpg"))

# inspect only the tray surface: skip the cells (parts move a little) and the markers
roi = np.full((H, W), 255, np.uint8)
for cx, cy in CELLS:
    cv2.circle(roi, (cx, cy), 14 * S, 0, -1)
for x, y in [(0, 0), (W, 0), (W, H), (0, H)]:
    cv2.rectangle(roi, (x - 18 * S, y - 18 * S), (x + 18 * S, y + 18 * S), 0, -1)

def stain_area(img, t_diff=30):
    g = prep(img)
    g = cv2.convertScaleAbs(g, alpha=np.median(golden) / np.median(g))   # match brightness
    diff = cv2.absdiff(g, golden)
    diff[roi == 0] = 0
    _, bw = cv2.threshold(diff, t_diff, 255, cv2.THRESH_BINARY)
    contours, _ = cv2.findContours(bw, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    return max([cv2.contourArea(c) for c in contours], default=0)        # largest blob (pixel^2)

ทดลองเอาบรรทัดที่ลบช่องและ marker ออกจาก roi หรือเอาบรรทัดปรับความสว่างออก แล้วดูว่า stain_area ของถาดดีเปลี่ยนไปเท่าไร

ตัดสิน OK/NG

MIN_STAIN = 20                                  # pixel^2 in the top view (1 pixel^2 = 0.25 mm^2)

def inspect(img):
    gray = cv2.cvtColor(top_view(img), cv2.COLOR_BGR2GRAY)
    n_parts = sum(dark_ratio(gray, cx, cy) > 0.5 for cx, cy in CELLS)
    area = stain_area(img)
    ok = ______________________________          # all 20 parts and no stain
    return ok, n_parts, area

ส่วนที่ 3: เครื่องตรวจตัดสินผิดบ่อยแค่ไหน

เครื่องตรวจผิดได้ 2 แบบ ซึ่งมีราคาต่างกัน

เครื่องตัดสิน OK เครื่องตัดสิน NG
ถาดดีจริง ถูก false reject ของดีถูกคัดทิ้ง เสียเงินและเวลา
ถาดเสียจริง false accept ของเสียหลุดถึงลูกค้า เสียชื่อเสียง ถูก

\mathrm{FRR} = \frac{\text{ถาดดีที่ถูกตัดสินเป็น NG}}{\text{ถาดดีทั้งหมด}} \qquad \mathrm{FAR} = \frac{\text{ถาดเสียที่ถูกตัดสินเป็น OK}}{\text{ถาดเสียทั้งหมด}}

import glob

def evaluate(min_stain):
    global MIN_STAIN
    MIN_STAIN = min_stain
    fr = sum(not inspect(cv2.imread(f))[0] for f in glob.glob("data/04_tray/ok/*.jpg"))
    fa = sum(inspect(cv2.imread(f))[0] for f in glob.glob("data/04_tray/ng/*.jpg"))
    return fr / 15, fa / 15

for t in [5, 10, 15, 20, 25, 30, 40]:
    frr, far = evaluate(t)
    print(f"MIN_STAIN = {t:3d}   FRR = {100 * frr:5.1f} %   FAR = {100 * far:5.1f} %")
MIN_STAIN 5 10 15 20 25 30 40
FRR (%)
FAR (%)

เขียน confusion matrix ของเกณฑ์ที่เลือก

ตัดสิน OK ตัดสิน NG
ถาดดีจริง (15)
ถาดเสียจริง (15)
Noteคำถาม
  1. เมื่อเพิ่ม MIN_STAIN ค่า FRR และ FAR เปลี่ยนไปอย่างไร ทำไมจึงลดทั้งสองค่าพร้อมกันไม่ได้
  2. ถ้าเป็นถาดยา (ของเสียหลุดไม่ได้) กับถาดขนม (ของดีทิ้งแพง) ควรเลือกเกณฑ์ต่างกันอย่างไร
  3. ถาดเสียที่เครื่องตรวจไม่พบมีลักษณะอย่างไร ควรแก้ที่แสง กล้อง หรือโค้ด
  4. ถ้าคราบมีได้หลายแบบมาก (สี รูปทรง ขนาดต่างกัน) จนเขียนกฎไม่ครบ ควรเปลี่ยนไปใช้ AI แบบใบงาน 1 หรือไม่ ต้องเตรียมอะไรบ้าง

ทบทวน

  • marker หรือจุดอ้างอิงทำให้หาตำแหน่งชิ้นงานได้แม่นยำ และ homography แปลงภาพเอียงเป็นภาพด้านบนที่มีสเกล mm เท่ากันทั้งภาพ
  • เมื่อภาพถูกจัดตำแหน่งแล้ว ROI ที่กำหนดจากแบบของชิ้นงานจะใช้ได้ทุกภาพ
  • presence/absence ใช้สัดส่วน pixel ใน ROI ส่วนตำหนิหาได้จากผลต่างกับ golden image
  • เครื่องตรวจทุกเครื่องผิดได้ ต้องรายงาน FRR และ FAR และเลือกเกณฑ์ตามราคาของความผิดแต่ละแบบ