स्कैनर ऐप फ़ोन की फ़ोटो के साथ तीन काम करता है: काग़ज़ को ढूँढता है, उसके चारों कोनों को खींचकर आयत बनाता है, और नतीजे को स्याही और काग़ज़ में बाँट देता है। इनमें से कोई भी काम अनोखा नहीं है, लेकिन हर एक में एक जगह है जहाँ वह चुपचाप ग़लत हो जाता है। osFoundry डेस्कटॉप ऐप का PDF एडिटर यह काम चार कोने वाले हैंडल और एक Black and white बटन से करता है। यह पोस्ट वही पाइपलाइन Python और OpenCV में दोबारा बनाकर उसे नापती है, क्योंकि उसे सटीक तरीक़े से जाँचने का एक रास्ता है: फ़ोटो मैंने ख़ुद बनाई है, इसलिए "छपा हुआ" पन्ना और वे कोने जहाँ उसे रखा गया, दोनों मालूम हैं।
टेस्ट फ़ोटो
make_scan.py Pillow से एक काल्पनिक किराया-अनुबंध का पहला पन्ना बनाता है (Times New Roman, 1275 × 1650, 150 dpi पर Letter), और फिर उसकी आभासी तस्वीर खींचता है: 1500 × 2000 के कैनवास पर पर्सपेक्टिव ट्रांसफ़ॉर्म, लकड़ी की धारियों वाली मेज़, काग़ज़ के नीचे हल्की छाया, नीचे-दाएँ की ओर घटती चमक, थोड़ा शोर और क्वालिटी 82 का JPEG। फ़ोटो में 253 अलग-अलग ग्रे लेवल हैं, और असली कोने (212,236) (1318,184) (1392,1652) (150,1748) हैं।
नीचे सब कुछ इस कमांड से चलता है:
uvx --with opencv-python-headless --with numpy --with pillow --with pymupdf python -I measure.py
OpenCV 5.0.0, NumPy 2.5.4, Python 3.12.15, Apple M1 पर। समय एक-एक बार के रन के हैं, इसलिए उन्हें सिर्फ़ मोटे अंदाज़े की तरह पढ़ें।
चारों कोने ढूँढना
काग़ज़ वह बड़ा चमकीला धब्बा है। फ़ोटो को धुंधला करें ताकि अक्षर मायने न रखें, Otsu से थ्रेशोल्ड चुनवाएँ, ख़ाली जगहें भर दें, और सबसे बड़ा कंटूर रखें:
blur = cv2.GaussianBlur(gray, (0, 0), 6)
t, mask = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # t = 144
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((25, 25), np.uint8))
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
c = max(contours, key=cv2.contourArea)
hull = cv2.convexHull(c)
approxPolyDP से कॉन्वेक्स हल को चार बिंदुओं में समेटना अगला आम क़दम है, और पहला जाल यहीं है। जिस epsilon पर ठीक चार बिंदु मिलते हैं वह फ़ोटो पर निर्भर करता है, इसलिए epsilon की एक रेंज में घूमें और जो पहला चार लौटाए उसे लें। फिर भी कोने सिर्फ़ अंदाज़न सही होते हैं। असली कोनों के मुक़ाबले approxPolyDP के चारों बिंदु 3.0, 5.7, 9.9 और 6.4 px दूर थे (ऊपर-बाएँ, ऊपर-दाएँ, नीचे-दाएँ, नीचे-बाएँ)। 1500 px चौड़ाई में यह ज़्यादा नहीं लगता, लेकिन वार्प के बाद त्रुटि स्केल फ़ैक्टर से बढ़ जाती है, और कुछ पिक्सेल मोटे अक्षर उसके प्रति संवेदनशील होते हैं।
बेहतर कोना वह है जो दो अच्छी तरह फ़िट किए गए किनारों का प्रतिच्छेदन हो। मोटे चतुर्भुज की हर भुजा के पास के कंटूर बिंदु लें, मज़बूत लॉस के साथ उन पर रेखा फ़िट करें ताकि कुछ भटके हुए बिंदु उसे झुका न दें, और पास-पास की रेखाओं को काटें:
def refine(q, pts):
lines = []
for i in range(4):
a, b = q[i], q[(i + 1) % 4]
e = (b - a) / np.linalg.norm(b - a); n = np.array([-e[1], e[0]])
along = (pts - a) @ e; dist = np.abs((pts - a) @ n); L = np.linalg.norm(b - a)
sel = pts[(dist < 12) & (along > 0.08 * L) & (along < 0.92 * L)] # skip the corners themselves
vx, vy, x0, y0 = cv2.fitLine(sel, cv2.DIST_HUBER, 0, 0.01, 0.01).ravel()
lines.append((np.array([x0, y0]), np.array([vx, vy])))
out = []
for i in range(4):
(p1, d1), (p2, d2) = lines[i - 1], lines[i]
t = np.linalg.solve(np.array([d1, -d2]).T, p2 - p1)[0]
out.append(p1 + t * d1)
return np.array(out, np.float32)
असली कोनों के मुक़ाबले त्रुटि घटकर 2.0, 2.2, 6.5 और 2.0 px रह जाती है। पूरी खोज में 12 से 20 ms लगते हैं। जो एक कोना 6.5 px दूर रहता है वह नीचे-दाएँ का है, जो इस फ़ोटो में काग़ज़ के नीचे की छाया की जगह भी है।
आयत में वार्प करना
getPerspectiveTransform चार कोने और आउटपुट के चार कोने लेता है, और बाक़ी warpPerspective करता है:
w = round((np.linalg.norm(q[1] - q[0]) + np.linalg.norm(q[2] - q[3])) / 2)
h = round((np.linalg.norm(q[3] - q[0]) + np.linalg.norm(q[2] - q[1])) / 2)
M = cv2.getPerspectiveTransform(q, np.float32([[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]]))
flat = cv2.warpPerspective(gray, M, (w, h), flags=cv2.INTER_CUBIC)
वार्प में 2 से 6 ms लगते हैं। दूसरा जाल आउटपुट का साइज़ है। आमने-सामने की भुजाओं की लंबाई का औसत w और h चुनने का सबसे सीधा तरीक़ा है, लेकिन पर्सपेक्टिव की वजह से पास की भुजा दूर वाली से लंबी दिखती है और औसत असली आस्पेक्ट रेशियो वापस नहीं लाता। यहाँ उसने 1174 × 1488 दिया, यानी 0.789 का अनुपात, जबकि पन्ना असल में 0.7727 (Letter) है। यह 2% का खिंचाव है। अगर काग़ज़ का साइज़ मालूम है तो वही इस्तेमाल करें; वरना स्कैनर ऐप की तरह थोड़ा खिंचा हुआ पन्ना मान लें।
पहले वार्प करना वैकल्पिक नहीं है। बिना वार्प की फ़ोटो पर, मेज़ समेत, Otsu 42.9% पिक्सेल को स्याही मान लेता है। लकड़ी काग़ज़ से गहरी है और अक्षरों के साथ गिन ली जाती है।
हिस्टोग्राम में एक साइड इफ़ेक्ट दिखता है। फ़ोटो में 253 ग्रे लेवल थे; बाइक्यूबिक वार्प के बाद 255 हैं, क्योंकि इंटरपोलेशन पुराने मानों के बीच नए मान गढ़ देता है। यह याद दिलाता है कि "ग्रे लेवल की गिनती" का मतलब बाइनराइज़ेशन के बाद ही बनता है।
बाइनराइज़ करना
सपाट ग्रेस्केल पन्ने को दो मानों में बदलने के तीन तरीक़े:
def binarize(g, how):
if how == 'global': # one threshold for the whole page
return cv2.threshold(g, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]
if how == 'adaptive': # threshold relative to each pixel's neighbourhood
return cv2.adaptiveThreshold(g, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 41, 12)
if how == 'flatfield': # divide out the paper's own lighting, then one global threshold
bg = cv2.medianBlur(cv2.dilate(g, np.ones((7, 7), np.uint8)), 51)
n = np.clip(g.astype(np.float32) / np.maximum(bg, 1) * 255, 0, 255).astype(np.uint8)
return cv2.threshold(n, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]
इन्हें जाँचने के लिए मैंने हर नतीजे को असली पन्ने के साइज़ पर स्केल किया और स्याही के पिक्सेल की तुलना की। पतले अक्षरों के लिए सटीक पिक्सेल-मिलान (IoU) कठोर है, क्योंकि एक पिक्सेल खिसकी रेखा भी चूक गिनी जाती है, इसलिए तालिका में 3 px सहनशीलता के साथ precision और recall भी हैं: अनुमानित स्याही का वह हिस्सा जिसके 3 px के भीतर असली स्याही है, और असली स्याही का वह हिस्सा जिसके 3 px के भीतर अनुमानित स्याही है। असली पन्ने में 1.85% स्याही है।
| तरीक़ा | स्याही | IoU | Precision (3 px) | Recall (3 px) | समय |
|---|---|---|---|---|---|
| ग्लोबल (Otsu) | 2.64% | 0.602 | 0.944 | 1.000 | 1.0 ms |
| अडैप्टिव गाउसियन | 3.15% | 0.554 | 0.948 | 1.000 | 9.1 ms |
| फ़्लैट-फ़ील्ड + Otsu | 2.81% | 0.591 | 0.941 | 1.000 | 46.4 ms |
| ऐप का सेव किया पन्ना | 2.36% | 0.633 | 1.000 | 1.000 | — |
इस फ़ोटो में इनमें फ़र्क़ नहीं दिखता। वार्प के बाद काग़ज़ हर जगह स्याही से कहीं ज़्यादा चमकीला है, और एक ही थ्रेशोल्ड काफ़ी है। तीनों हर रेखा को बचा लेते हैं (recall 1.000) और उन्हें थोड़ा ज़्यादा मोटा कर देते हैं (1.85% के मुक़ाबले 2.6 से 3.2% स्याही)।
आख़िरी पंक्ति वही पन्ना है जो osFoundry के PDF एडिटर ने उसी फ़ोटो के लिए सेव किया, और उसे उसी तरह जाँचा गया। सेव किए PDF से पन्ने की इमेज मैंने PyMuPDF से वापस पढ़ी: उसमें ठीक 2 ग्रे लेवल और 2.36% स्याही है। एडिटर यह कैसे करता है, यह मैंने नहीं देखा, इसलिए यह पंक्ति सिर्फ़ नतीजों की तुलना है। यह दिखाती है कि चार खींचने वाले हैंडल वाले टूल का नतीजा स्क्रिप्ट वाली पाइपलाइन जैसी ही रेंज में आता है।
जब ग्लोबल थ्रेशोल्ड टूट जाता है
फ़ोटो की रोशनी नरम थी, और यही छिपा देता है कि बाक़ी दो तरीक़े क्यों हैं। इसे देखने के लिए मैंने सपाट पन्ने को एक ढलान से गुणा किया जो नीचे-दाएँ को 40% चमक तक गहरा कर देती है, जैसे किसी फ़ॉर्म पर हाथ की छाया, और तीनों तरीक़े फिर चलाए:
| तरीक़ा | स्याही | Precision (3 px) | Recall (3 px) |
|---|---|---|---|
| ग्लोबल (Otsu) | 49.85% | 0.072 | 1.000 |
| अडैप्टिव गाउसियन | 3.04% | 0.946 | 1.000 |
| फ़्लैट-फ़ील्ड + Otsu | 2.81% | 0.939 | 1.000 |
ग्लोबल Otsu छाया वाले आधे हिस्से को काला कर देता है, इसलिए recall पूरा रहता है जबकि precision गिरकर 0.07 हो जाती है। अडैप्टिव और फ़्लैट-फ़ील्ड के नतीजे लगभग नहीं बदलते। अडैप्टिव को सबसे पहले आज़माना चाहिए: वह ग्लोबल से क़रीब 8 ms ज़्यादा लेता है और उसे कोई अतिरिक्त क़दम नहीं चाहिए। फ़्लैट-फ़ील्ड लगभग वही नतीजा देता है और धीमा है (यहाँ 46 ms), इसलिए इस पन्ने पर उसे चुनने की कोई वजह नहीं।
ऊपर का कोड पूरी पाइपलाइन है; जाँच सिर्फ़ स्याही के पिक्सेल की तुलना उस पन्ने से करती है जिससे फ़ोटो बनी थी। एडिटर मुफ़्त osFoundry डेस्कटॉप ऐप का हिस्सा है, और local-first पेज बताता है कि osFoundry काम को आपकी अपनी मशीन पर कैसे रखता है।

Top comments (0)