簡述
所在的組織 華工小燈神 需要為用戶提供方便地獲取成績單的服務,于是需要爬取本科生的教務系統(tǒng),識別驗證碼時使用tesseract正確率太低,于是選擇mnist_cnn里的卷積神經網(wǎng)絡模型進行預測,正確率約98%白群。代碼見github。
基本爬蟲流程
- 本科生教務系統(tǒng)是經典的方正系統(tǒng)硬霍,網(wǎng)上已有不少不錯的爬蟲實現(xiàn)川抡,如python爬蟲實戰(zhàn)之模擬正方教務系統(tǒng)登錄查詢成績、python爬蟲正方教務系統(tǒng)须尚。
- 但是不同學校可能還是會有點區(qū)別侍咱,為了弄清具體的請求過程還是借了個賬號走了下請求流程耐床,即登錄、查看歷史成績楔脯,并用fiddler分析網(wǎng)絡請求的具體細節(jié)撩轰,fiddler在分析這種http請求時非常好用(chrome控制臺還是差點)。所有請求頭昧廷、cookies堪嫂、表單內容等都一覽無余,在使用python模擬請求時也可以用fiddler方便地查看返回頁面內容木柬。
- 可以看到在訪問教務處主頁
http://www.scut.edu.cn/jw2005/
時皆串,實際上訪問的是http://xsweb.scuteo.com/default2.aspx
,而它為我們自動跳轉到另一個路由眉枕,形式是/(20位標識符)/default2.aspx
(即下面代碼中用到的url_login
)恶复,其中20位唯一標識符就是用來標志此次會話怜森,某種程度上它相當于cookies。
- 請求時要注意先get訪問一遍獲取
csrf_token
(即表單中的__VIEWSTATE
參數(shù))谤牡,然后再連同其它參數(shù)一起post
請求副硅,其中中文參數(shù)是以gbk編碼。登錄時的post請求如下:
login_data = {
'__VIEWSTATE': csrf_token,
'txtUserName': student_id,
'TextBox2': password,
'txtSecretCode': veri_code,
'RadioButtonList1': '學生'.encode('gbk'), # 表示學生登錄
'Button1': '',
'lbLanguage': '',
'hidPdrs': '',
'hidsc': '',
}
res = requests.post(url_login, data=login_data)
- 其中veri_code表示驗證碼翅萤,目前來看
/default3.aspx
恐疲、/default5.aspx
等其他版本登錄頁面的可以繞過驗證碼的bug已經被修復,只能訪問http://xsweb.scuteo.com/(20位標識符)/CheckCode.aspx
獲取驗證碼套么,最直接的解決方案就是采用顯示圖片培己,人工輸入的方式:
# 獲取驗證碼
import requests
base_url = 'http://xsweb.scuteo.com/(wmyrw345umteq0e0sh5rayez)/'
res = requests.get(base_url + 'CheckCode.aspx')
from PIL import Image
import io
# 1. 外部工具顯示
image_file = io.BytesIO(res.content) # import io
image = Image.open(image_file) # from PIL import Image
image.show()
veri_code = input('請輸入看到的驗證碼:')
# 2. ipython內嵌顯示
# from IPython.display import Image
# Image(data=res.content)
- 登錄后就能獲取成績了,獲取所有成績的路由為
http://xsweb.scuteo.com/(20位標識符)/xscjcx.aspx?xh=學生賬戶名&xm=學生姓名&gnmkdm=N121605
违诗,學生姓名需要編碼漱凝,post表單內容直接將fiddler中看到的表單數(shù)據(jù)復制過來即可,當然有些參數(shù)也可不要诸迟。此處的請求頭中的referer
參數(shù)必須要有茸炒,我使用的是http://xsweb.scuteo.com/(20位標識符)/xs_main.aspx?xh=學生賬戶名
。 - 在獲取到所有成績頁面后阵苇,查找到成績所在的表格壁公,簡單解析后就可以得到需要的成績信息了。
soup = BeautifulSoup(res.text, "lxml") # res為requests.post返回對象
table = soup.select_one('.datelist')
keys = [i.text for i in table.find('tr').find_all('td')]
scores = [
dict(zip(
keys, [i.text.strip() for i in tr.find_all('td')]))
for tr in table.find_all('tr')[1:]]
print(sorted([i['成績'], i['課程名稱']] for i in scores))
驗證碼處理
- 在識別之前我們可以對驗證碼做一定的處理(可用參考為:蟲數(shù)據(jù) - Lesson 1: 如何做文本行和文字分割)绅项,簡單說來就是灰度紊册、二值、濾波快耿、強化等操作囊陡。(圖片格式)
# 備用嘗試1: 先灰度化,然后利用point函數(shù)對每個像素點操作來二值化掀亥,再中值濾波撞反,最后銳化圖像
from PIL import ImageFilter, ImageEnhance
imageL = image.convert("L").point(
lambda i: i > 25, mode='1').filter(
ImageFilter.MedianFilter(3)).convert('L')
imageS = ImageEnhance.Sharpness(imageL).enhance(2.0)
# 備用嘗試2: 由于驗證碼圖片是palette格式,所以要先轉換成RGB格式再濾波搪花,然后增強遏片,最后灰度、二值
im = image.convert('RGB').filter(ImageFilter.MedianFilter())
enhancer = ImageEnhance.Contrast(im)
im = enhancer.enhance(2)
im = im.convert("L").point(lambda i: i > 25, mode='1')
- 在嘗試獲取一定數(shù)量的驗證碼后撮竿,發(fā)現(xiàn)其固定用RGB(0,0,153)做驗證字符顏色吮便,利用該特征可以很方便地去噪。
# 查看像素分布
import numpy as np
from collections import Counter
a = np.concatenate(np.array(image))
# 將圖片轉為矩陣幢踏,再從二維矩陣降為一維
unique, counts = np.unique(a, return_counts=True)
# 對像素值索引計數(shù)髓需,也可使用Counter(a).most_common()
print(sorted(list(zip(counts, unique)), reverse=True)[:10])
# 對出現(xiàn)次數(shù)排序,并輸出前十個最多出現(xiàn)的
## 可以看到白色像素值索引255的出現(xiàn)最多
## 其次就是驗證碼的深藍色索引值為43
## 因為該驗證碼沒有模糊邊緣房蝉,直接取所有像素值索引為43的點就可拿到字符信息
# 所以可以簡單地去噪
im = image.point(lambda i: i != 43, mode='1')
## 再選擇是否進行其他處理授账,如濾波枯跑、二值
from PIL import ImageFilter
im2 = im.convert('L').filter(ImageFilter.MedianFilter())
im2 = im2.point(lambda i: i > 25, mode='1'); im2
# 其他嘗試:
## 放大后濾波
im = image.resize((image.width*3, image.height*3), Image.ANTIALIAS)
im = im.convert('L').filter(ImageFilter.MedianFilter())
## 獲取輪廓
imC = im.filter(ImageFilter.CONTOUR); imC
## 更多的處理可使用scipy
## 可參考
## https://stackoverflow.com/questions/24687760/numpy-pil-python-crop-image-on-whitespace-or-crop-text-with-histogram-threshol
## http://www.scipy-lectures.org/advanced/image_processing/
## from scipy import ndimage
驗證碼識別
- 在簡單的圖像處理后,就可以拿OCR工具來嘗試效果了白热。先試試開源的字符識別工具tesseract-ocr敛助。(tesseract參數(shù)列表)(注意修改下面的路徑為為自己的tesseract安裝路徑)
# 下載tesseract-ocr-setup.exe 安裝
# pip install pytesseract
import pytesseract
pytesseract.pytesseract.tesseract_cmd = 'D:\\Tesseract-OCR\\tesseract'
tessdata_dir_config = '--tessdata-dir "D:\\Tesseract-OCR\\tessdata"' # or TESSDATA_PREFIX
from functools import partial
convert = partial(pytesseract.image_to_string, lang='eng', config=tessdata_dir_config)
# 可以嘗試使用pip install tesserocr
# 使用下行代碼轉換
veri_code = convert(image) # image 為驗證碼 (PIL.Image)
# 去掉識別出的奇怪的字符
import re
veri_code_ = re.sub('[^0-9a-zA-Z]+', '', veri_code)
print(veri_code, '->', veri_code_)
- 可惜識別率太低,只有8%屋确∧苫鳎看來還是使用卷積神經網(wǎng)絡好了。
- 使用Keras構建神經網(wǎng)絡模型非常簡單攻臀,且在github上可以方便地找到Keras的官方樣例焕数,最經典的就是手寫字符識別了,即mnist_cnn.py刨啸,和此次任務目標非常一致堡赔,直接拿來用,稍作修改即可设联。(另外還有簡單有趣的mnist_transfer_cnn.py)
(一些基本概念可參考:卷積神經網(wǎng)絡CNN基本概念筆記)
- 再簡單搜索下善已,找到這兩篇可以參考的不錯的博文:
TensorFlow練習20: 使用深度學習破解字符驗證碼
使用 Keras 來破解 captcha 驗證碼 - 下面的問題就是構建訓練集了。由于正方教務系統(tǒng)返回的驗證碼格式固定离例,四個字符都是在固定的中心位置上進行了小范圍的旋轉换团,所以很好切割成單個字符,橫坐標的分界點為
[5,17,29,41,53]
(單張驗證碼尺寸為72*27
)宫蛆,至于字符之間有粘連影響的情況暫時不管艘包,故可以考慮簡單切割后識別單個字符。單個字符也便于自己生成訓練集耀盗。
im = image.point(lambda i: i != 43, mode='1')
import numpy as np
import pandas as pd
from matplotlib import pyplot as plt
a = np.array(im)
pd.DataFrame(a.sum(axis=0)).plot.line() # 畫出每列的像素累計值
plt.imshow(a) # 畫出圖像
split_lines = [5,17,29,41,53]
vlines = [plt.axvline(i, color='r') for i in split_lines] # 畫出分割線
plt.show()
- 再去掉下面的一部分空白想虎,將每個字符切割成
12*22
(寬*高)的小塊
im = image.point(lambda i: i != 43, mode='1')
y_min, y_max = 0, 22 # im.height - 1 # 26
split_lines = [5,17,29,41,53]
ims = [im.crop([u, y_min, v, y_max]) for u, v in zip(split_lines[:-1], split_lines[1:])]
for i in range(4):
plt.subplot(1,4,i+1)
plt.imshow(images[i], interpolation='none')
plt.show()
- 下面就是找一些字體,或直接利用windows系統(tǒng)自帶的字體叛拷,利用PIL的draw函數(shù)并隨機旋轉一定角度磷醋,生成一批類似大小的單個字符的訓練集,可惜沒找到一樣的字體效果的胡诗。
from PIL import Image
from PIL import ImageFont
from PIL import ImageDraw
from PIL import ImageFilter, ImageEnhance
import random
import string
CHRS = string.ascii_lowercase + string.digits # 小寫字母+數(shù)字的字符串列表
t_size = (12, 22)
font_size = 20 # 即字體的高(大概值), 寬度約為一半
font_path = [ # 字體路徑
r'E:/python/2017_9/Roboto-Regular.ttf',
r'C:/Windows/Fonts/VERDANA.TTF',
r'C:/Windows/Fonts/SIMKAI.TTF',
r'C:/Windows/Fonts/SIMSUNB.TTF',
r'C:/Windows/Fonts/REFSAN.TTF',
r'C:/Windows/Fonts/MSYH.TTC',
]
fonts = [ImageFont.truetype(fp, font_size) for fp in font_path]
# font = ImageFont.truetype('E:/python/2017_9/simhei.ttf', font_size)
def gen_fake_code(prefix, c, font):
txt = Image.new('L', t_size, color='black')
ImageDraw.Draw(txt).text((0, -2), c, fill='white', font=font)
w = txt.rotate(random.uniform(-20, 20), Image.BILINEAR) # center不指定,默認為中心點
img_ = w.point(lambda i: i < 10, mode='1')
# img_.show()
img_.save(prefix + '_' + c + '.png')
# if __name__ == '__main__':
import os
os.chdir(r'E:\python\2017_9\fakes_single')
for c in CHRS: # 對每一個字符的每一種字體生成200張
for n, font in enumerate(fonts):
for i in range(200):
gen_fake_code(str(n)+'-'+str(i), c, font)
- 對官方mnist-cnn樣例做簡單修改后就可以訓練我們自己的模型了淌友。
import keras
from keras.models import Sequential
from keras.layers import Dense, Dropout, Flatten
from keras.layers import Conv2D, MaxPooling2D
from keras import backend as K
from PIL import Image
import numpy as np
import os
os.chdir(r'E:\python\2017_9\fakes_single') # 跳轉到訓練集目錄
import string
CHRS = string.ascii_lowercase + string.digits # 字符列表
num_classes = 36 # 共要識別36個字符(所有小寫字母+數(shù)字)煌恢,即36類
batch_size = 128
epochs = 12
# 輸入圖片的尺寸
img_rows, img_cols = 12, 22
# 根據(jù)keras的后端是TensorFlow還是Theano轉換輸入形式
if K.image_data_format() == 'channels_first':
input_shape = (1, img_rows, img_cols)
else:
input_shape = (img_rows, img_cols, 1)
import glob
X, Y = [], []
for f in glob.glob('*.png')[:]: # 遍歷當前目錄下所有png后綴的圖片
t = 1.0 * np.array(Image.open(f))
t = t.reshape(*input_shape) # reshape后要賦值
X.append(t) # 驗證碼像素列表
s = f.split('_')[1].split('.')[0] # 獲取文件名中的驗證碼字符
Y.append(CHRS.index(s)) # 將字符轉換為相應的0-35數(shù)值
X = np.stack(X) # 將列表轉換為矩陣
Y = np.stack(Y)
# 此時Y形式為 array([26, 27, 28, ..., 23, 24, 25])
# 對Y值進行one-hot編碼 # 可嘗試 keras.utils.to_categorical(np.array([0,1,1]), 3) 理解
Y = keras.utils.to_categorical(Y, num_classes)
split_point = len(Y) - 720 # 簡單地分割訓練集與測試集
x_train, y_train, x_test, y_test = X[:split_point], Y[:split_point], X[split_point:], Y[split_point:]
# 以下模型和mnist-cnn相同
# 兩層3x3窗口的卷積(卷積核數(shù)為32和64),一層最大池化(MaxPooling2D)
# 再Dropout(隨機屏蔽部分神經元)并一維化(Flatten)到128個單元的全連接層(Dense)震庭,最后Dropout輸出到36個單元的全連接層(全部字符為36個)
model = Sequential()
model.add(Conv2D(32, kernel_size=(3, 3),
activation='relu',
input_shape=input_shape))
model.add(Conv2D(64, (3, 3), activation='relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))
model.add(Flatten())
model.add(Dense(128, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(num_classes, activation='softmax'))
model.compile(loss=keras.losses.categorical_crossentropy,
optimizer=keras.optimizers.Adadelta(),
metrics=['accuracy'])
model.fit(x_train, y_train,
batch_size=batch_size,
epochs=epochs,
verbose=1,
validation_data=(x_test, y_test))
score = model.evaluate(x_test, y_test, verbose=0)
print('Test loss:', score[0])
print('Test accuracy:', score[1])
# model.save(r'E:\python\2017_9\model_test.h5')
- 可以看到很快就收斂了瑰抵,下面接著上面的環(huán)境測試一下,獲取新的驗證碼預測并將其保存
import requests
import io
os.chdir(r'E:\python\2017_9\modeltest')
def get_image():
'''
從教務處網(wǎng)站獲取驗證碼
'''
extra_code = 'azuuwd2ijh40vlnfg1ajdhbn'
url_base = 'http://xsweb.scuteo.com/(%s)/' % extra_code
# url_base = requests.get('http://xsweb.scuteo.com/default2.aspx').url.replace('default2.aspx', '')
url_veri_img = url_base + 'CheckCode.aspx'
headers = {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36',
}
res = requests.get(url_veri_img, headers=headers)
image_file = io.BytesIO(res.content)
image = Image.open(image_file)
return image
def handle_split_image(image):
'''
切割驗證碼器联,返回包含四個字符圖像的列表
'''
im = image.point(lambda i: i != 43, mode='1')
y_min, y_max = 0, 22 # im.height - 1 # 26
split_lines = [5,17,29,41,53]
ims = [im.crop([u, y_min, v, y_max]) for u, v in zip(split_lines[:-1], split_lines[1:])]
# w = w.crop(w.getbbox()) # 切掉白邊 # 暫不需要
return ims
def predict(images):
'''
使用模型對四個字符的列表對應的驗證碼進行預測
'''
Y = []
for i in range(4):
im = images[i]
test_input = np.concatenate(np.array(im))
test_input = test_input.reshape(1, *input_shape)
y_probs = model.predict(test_input)
Y.append(CHRS[y_probs[0].argmax(-1)])
return ''.join(Y)
def multi_process(x):
'''
獲取預測并保存圖片二汛,圖片名為預測值
'''
image = get_image()
images = handle_split_image(image)
image.save( predict(images) + '.png')
from multiprocessing.dummy import Pool
import datetime
now = datetime.datetime.now
start = now()
with Pool(30) as pool:
pool.map(multi_process, [i for i in range(300)])
print('耗時 -> %s' % (now()-start))
- 檢查一下正確率婿崭,約在40%,一些相似字符如
f肴颊、t氓栈、l、1婿着、i
等比較容易混淆授瘦,大概是訓練字體與實際獲取的不太一樣,且驗證碼切割時由于字符歪斜粘連會有某一字符的一部分出現(xiàn)在另一字符區(qū)域導致干擾竟宋。 -
看來要提高正確率提完,還是得人工打碼,繼續(xù)獲取預測1000多個驗證碼并保存丘侠,在此基礎上進行人工打碼徒欣。在windows文件系統(tǒng)下重命名太不方便,圖片看著也太小蜗字,還是用python自帶的GUI庫tkinter做一個簡單的打碼助手打肝,順便統(tǒng)計之前預測的正確率和打碼花費時間,有些小bug秽澳,不過也湊合用闯睹。
# 該文件命名為dama.py 命令行pyinstaller -w -F dama.py 生成exe文件(dist目錄下)放入驗證碼所在文件夾執(zhí)行
import os
import glob
image_files = glob.glob('*.png')
from tkinter import *
root = Tk()
colours = ['green','orange','white']*2
labels = []
entrys = []
strvars = []
for r, c in enumerate(colours):
l = Label(root, text=c, relief=RIDGE, width=34)
l.grid(row=r,column=0)
labels.append(l)
v = StringVar(root, value='predict')
strvars.append(v)
e = Entry(root, textvariable=v, bg=c, relief=SUNKEN, width=10,
font = "Helvetica 44 bold")
e.grid(row=r,column=1)
entrys.append(e)
info_label1 = Label(root, text='當前正確率', relief=RIDGE, width=34)
info_label1.grid(row=7,column=0)
info_label2 = Label(root, text='已用時間', relief=RIDGE, width=34)
info_label2.grid(row=7,column=1)
# ims = []
num = 0
cur_files = None
correct = 0
incorrect = 0
if not os.path.exists('new'):
os.mkdir('new')
import datetime
now = datetime.datetime.now
start = now()
from PIL import Image, ImageTk
def enter_callback(e):
global num, cur_files
global correct, incorrect
if cur_files:
for i in range(6):
name = strvars[i].get()
# print(name)
if cur_files[i].split('.')[0] == name:
correct += 1
else:
incorrect += 1
try:
os.rename(cur_files[i], ''.join(['new/', name, '.png']))
except Exception as e:
print(e)
info1 = '當前正確率: %s' % (correct/(correct+incorrect))
info2 = '已用時間: %s' % (now()-start)
info_label1.config(text=info1)
info_label2.config(text=info2)
else:
for i in range(6):
labels[i].config(width=144)
cur_files = image_files[num: num+6]
for i in range(6):
f = image_files[num+i]
im = Image.open(f).resize((144, 54))
im = ImageTk.PhotoImage(im)
# https://stackoverflow.com/questions/18369936/how-to-open-pil-image-in-tkinter-on-canvas
# im = PhotoImage(file=f)
labels[i].configure(image=im)
labels[i].image = im
strvars[i].set(f.split('.')[0])
num += 6
root.bind("<Return>", enter_callback)
root.mainloop()
- 打碼了一千個后,利用上文模型繼續(xù)進行訓練
# 由人工打碼得到的訓練集進行訓練
os.chdir(r'E:\python\2017_9\modeltest\new')
# 檢查是否有打錯碼如打成3個字符的圖片
file_names = glob.glob('*.png')
error = [i for i in file_names if len(i.split('.')[0])!=4]
if error:
print(error)
raise Exception('打碼出錯担神,請檢查')
# 構造訓練集
X, Y = [], []
for f in file_names:
image = Image.open(f)
ims = handle_split_image(image) # 打開并切割圖片
name = f.split('.')[0]
# 將圖片切割出的四個字符及其準確值依次放入列表
for i, im in enumerate(ims):
# 以下類同上文
t = 1.0 * np.array(im)
t = t.reshape(*input_shape)
X.append(t)
s = name[i]
Y.append(CHRS.index(s)) # 驗證碼字符
X = np.stack(X)
Y = np.stack(Y)
Y = keras.utils.to_categorical(Y, num_classes)
split_point = len(Y) - 1000
x_train, y_train, x_test, y_test = X[:split_point], Y[:split_point], X[split_point:], Y[split_point:]
model.fit(x_train, y_train,
batch_size=batch_size,
epochs=epochs,
verbose=1,
validation_data=(x_test, y_test))
score = model.evaluate(x_test, y_test, verbose=0)
print('Test loss:', score[0])
print('Test accuracy:', score[1])
# model.save('ok.h5') # 保存模型
-
由于訓練集很小楼吃,速度更快,多訓練幾回使得acc在0.99以上妄讯。
- 最后孩锡,再去訪問新的教務處驗證碼進行測試,100張驗證碼大約有98張能完全識別對亥贸,這個正確率暫時足夠使用了躬窜。
- 將以上內容簡單的整合在一起,模型加載采用
keras.models.load_model
方法獲取炕置,再抄點tkinter的UI荣挨,就可得到最終效果,代碼見github:
- 此外朴摊,利用flask和gevent可以構建一個簡單的web服務默垄,詳見README:
其他資料
- 常見驗證碼的弱點與驗證碼識別
- Handwritten Digit Recognition using Convolutional Neural Networks in Python with Keras
- Keras Tutorial: The Ultimate Beginner’s Guide to Deep Learning in Python
- TensorFlow - MNIST For ML Beginners
- TensorFlow - Deep MNIST for Experts
- Keras as a simplified interface to TensorFlow: tutorial
- Neural Net for Handwritten Digit Recognition in JavaScript
- ptigas/simple-captcha-solver
- Multi-Class Classification Tutorial with the Keras Deep Learning Library
- fchollet/classifier_from_little_data_script_3.py
- Youtube - Neural Network Tutorial and Visualization (Python and PyQt - part 1)及其Github - PythonPyQtANN
- PIN-Identify-by-zhengfang與identifyZF - PHP實現(xiàn),使用字典方式解決正方驗證碼-即將驗證碼中單個字符與字典中的字符一一計算距離甚纲,根據(jù)距離判斷其值口锭,類似Kmeans聚類
- 9 Ways to Get Help with Deep Learning in Keras
- issue - Is there a way in Keras to apply different weights to a cost function? #2115
- Keras backends 官方文檔 - 中文
- issue - Custom loss function y_true y_pred shape mismatch #4781
- stackoverflow - Keras binary_crossentropy vs categorical_crossentropy performance?
- wassname/keras_weighted_categorical_crossentropy.py
- fchollet/deep-learning-with-python-notebooks
- Sequence Classification with LSTM Recurrent Neural Networks in Python with Keras
- Jupyter Tricks
- Issues using Keras np_utils.to_categorical
- Keras.losses.categorical_crossentropy
- Keras.optimizers.Adagrad
- Keras.metrics.categorical_accuracy
- Keras.backend.int_shape
- Python/Keras - Saving model weights after every N batches
- Scipy Lecture Notes
- Isolate greatest/smallest labeled patches from numpy array