《深度学习入门 基于Python的理论与实现》-斋藤康毅（笔记）
步骤/目录： 无本文首发于个人博客https://lisper517.top/index.php/archives/4260/，转载请注明出处。本文的原文写作日期为2020年11月17日，主要目的是总结《深度学习入门 基于Python的理论与实现》。笔者的毕业论文有很多东西来自这本书。下面的内容有些缩进或者图片缺失，可以参考 原word文档 。本书主要用numpy来处理运算，用matplotlib将结果可视化。对于没有基础的人十分友好，只需要高中的线代知识就可以看懂。 1.x=x.flatten() 将矩阵及张量转换为一维数组。x>15，结果得到一个由True和False组成的一维列表，x[x>15]即可输出所有大于15的成员。 矩阵与一维数组的*乘，（数组长度与矩阵列数相同）将一维数组扩张为矩阵形状后简单相乘，符合交换率，符号为*； 矩阵与数组dot乘，（数组长度与矩阵列数相同）将一维数组与每行对应索引相乘求和，得到一维数组，长度为矩阵的行数：如[[1, 2], [3, 4], [7, 8]] dot [5,6]，则得[1*5+2*6, 3*5+4*6,7*5+8*6]，不符合交换律，用np.dot计算。 一次绘图流程：p42 2.感知机：接受一个或多个输入，输出一个信号。有多个输入时，可以给各个输入配置权重。机器学习就是人给机器一些数据，机器根据这些数据选出权重的过程。 P56从图像上，感知机是一条直线，有局限性。叠加多个感知机可以构造类似曲线。 3.对于单层感知机，实际上有两个函数，比如首先有y=b+x1*w1+x2*w2，其中b为偏置，w1、w2为权重；然后通过激活函数，比如h(x)=1或0（当y<=0时为0，否则为1。这种函数的输出是跳跃的，可称为阶跃函数）。若把激活函数换成其他形式，就接近神经网络的概念了。常用的一个激活函数（sigmoid函数）：1/(1+exp(-x))，其中exp(-x)为e的-x次方。 接受numpy数组为参数的阶跃函数见p69。 激活函数也可以使用ReLU函数，其在x<=0时输出0，在x>0时则为线性关系。P74 对于输出层的激活函数，如果是分类问题（类似阶跃函数）用softmax函数，回归问题（连续输出数值）用恒等函数。P89 softmax函数的输出是连续的，总和为1，可以看作概率。为了节省算力，对未知模型进行预测时通常也可以省略输出层的softmax激活函数（接受数据进行学习时可以不省略）。 在分类问题中，输出层的神经元数量通常等于类别的数量。哪个神经元的输出最大，就会归类为该类别。 4.正规化：将数据进行处理，使其限制在某个范围内。正规化是一种预处理。 批处理：相同的神经网络，多个输出，多个结果。批处理可以大大加快运算速度。P101 5.损失函数：描述当前神经网络与测试数据（即监督数据）在多大程度上不拟合。一般可用均方误差函数和交叉熵。均方考虑了非正确解、越接近0（越小）越好，交叉熵只包含正确解、越接近0（越小）越好。 机器学习就是要使得损失函数值最小。如果训练数据太多，可以从中随机选择部分数据来学习，称为mini batch。 不用精确度作为评价标准，因为要设定参数的导数（变化梯度），如果用精确度为指标，容易导致导数为0，参数无法更新（精确度常常是跳跃的，微调无法产生影响，而损失函数是连续变化的）。这也是sigmoid函数优于阶跃函数的地方（激活函数）。 对于矩阵，y[0,2]的写法等同于y[0][2] 梯度指示的方向是损失函数减少量最多的地方，不一定指向损失函数值最小的地方。所以，机器学习可能会陷入局部最小值、鞍点或者学习平原。 鞍点处的梯度也为0，比如一个马鞍形曲面，(0,0,0)处的形状就很像马鞍。 梯度，就是一个一维数组。一个函数有多个输入，那么梯度就是这个多维函数（在某一点处）的导数。梯度数组的大小和输入多少相等，意义是对每一个输入，在保持其他输入不变时求该输入的导数，最终组成一个导数数组。为了使损失函数减小，需要沿负梯度方向下降。 6.反向传播法算梯度的速度比微积分法快许多，主要应用了复合函数的求导（相乘）。 7.有时SGD（随机梯度下降），即沿着梯度最大的地方下山的方法，不一定能最快下山。还有其他一些方法。 Momentum（动量法），如果某一方向上的梯度较小，但是持续很久，就适合用这种方法。模拟静止小球在山中的运动，某一方向累计的梯度会给小球积累该方向的速度，于是在该方向上移动越来越快。 AdaGrad（Adaptive适应梯度法），对梯度中的每一个导数都有一个学习率，该学习率还会不断衰减。AdaGrad是通过记录过去所有梯度的平方和实现的，最终学习率会衰减到0。不想让学习率衰减太快可以用RMSProp法，其会更多地考虑最近的梯度、逐渐减小久远梯度的权重。 Adam（直观上讲，类似于融合了AdaGrad与Momentum法，2015年提出），甚至可对一些超参数调校。 一些技巧： （1）权重的初始值：对于sigmoid或tanh等激活函数，使用Xavier方法，上一层的节点个数为n，本层的初始值大概是标准差1/sqrt(n)的高斯分布； 对relu激活函数，可以使用He初始值。 在手头数据集少时，可以“学习迁移”，即训练完一个数据集，将权重的最终值作为下一次学习的初始值。 （2）调整激活值的分布：使用batch normalization，优点是增大学习率（学的快）、对初始值不会过于敏感、抑制过拟合。 （3）抑制过拟合：过拟合主要是由于训练数据少、网络参数太多（层数多、节点多等）。过拟合的表现常常是训练数据准确度高，测试数据准确度不佳。对简单网络，常用权值衰减来抑制过拟合。对复杂网络，可用Dropout方法，随机删除一些节点。另外，可以用已有的数据生成新的数据，比如对手写数字图片，可对图片进行平移、旋转等操作。 （4）高效寻找超参数：对于网络层数与节点数、batch大小、学习率、权值衰减等超参数，需要高效寻找之。可在训练数据和测试数据之外再分一个验证数据，用来对探讨超参数取什么值，然后让超参数不断*10，在对数尺度上变化，并观测其对学习的影响。一般来说，测试数据应该只在最后用一次。 （5）GPU运算：GPU擅长大量的并行运算，比如矩阵的乘法；而CPU擅长连续的复杂运算。使用GPU，可以将训练和测试时间大大缩短。据书中称，NVIDIA的显卡比AMD的更合适。 （6）分布式学习：和用GPU一样，可缩短学习时间。Google的TensorFlow和微软的CNTK都很重视分布式学习。 8.CNN（Convolutional Neural Network），书中之前实现的网络，隐藏层的结构是Affine+Relu（或者其他激活函数），最后一层是Affine+Softmax，而CNN的隐藏层是Conv（卷积层）+Relu+Pooling（池化层），最后一层不变。普通Affine的问题是没有考虑图片的形状，比如矩阵中相距较远的两个点关联度较小，近大。从原理上，卷积层使用了类似滤波器的方法，用了矩阵的*乘，并且偏置只有一个。*乘，即相同大小矩阵的对应位置乘积之和。 池化层则是将层中的数据减少形成特征化的数据，比如只取最大值、只取平均值。 CNN是图像识别中常用的操作。通过观察，CNN的不同Conv层，大概在进行从基础到抽象的观察。 在各种CNN中，书中介绍了CNN元组LeNet及2012年提出的AlexNet。据称，AlexNet是深度学习热潮的导火索。 Google旗下的Deep Mind公司，开发出了AlphaGo和DQN。其中DQN可以接受游戏的图像输入（比如每秒4帧），学习如何玩游戏，甚至可以做出超越人类的手柄操作。
步骤/目录： 无本文首发于个人博客https://lisper517.top/index.php/archives/4259/，转载请注明出处。本文的原文写作日期为2020年10月22日，主要目的是用Python重构按键精灵。主体程序，主要是一些功能的实现，具体应用需要自己写。写的比较乱：import win32gui, win32api, win32con, win32ui import time from time import sleep import random from random import uniform import logging from logging import info, warning, error from config import VK_CODE import copy import cv2 as cv import traceback # mode为test意为调试模式，real则为实际应用 # In 'test' mode, it will print some hidden info for Debug mode = 'test' # 模式图的存储路径 # The save path of pattern pics pattern_path = 'yourpath\\' # 设置logging的书写格式 # Setting logging configs logging.basicConfig( level=logging.DEBUG, format= '%(lineno)d : %(asctime)s : %(levelname)s : %(funcName)s : %(message)s', filename='yourpath\\log{}.txt'.format( time.strftime('-%Y-%m-%d')), filemode='w') # 用当前时间初始化随机数 # Set random seed random.seed(time.time()) def Get_PosAndHwnd(hwnd=0, ClassName=None, TitleName=None): '''接受句柄、类名、标题名，返回第一个匹配的窗口位置和句柄。 Accepts Handle, ClassName or TitleName of a window, returns the rect-pos of the first window that matches. Args: hwnd: 目标句柄 target handle. ClassName: 目标窗口类名 target ClassName. TitleName: 目标窗口标题 target TitleName. Returns: x1, y1, x2, y2, hwnd 前四个参数描述窗口在显示器中的位置，hwnd为句柄。 x1-y2 describes the pos of the window in screen, last arg is the handle. ''' try: if hwnd == 0: hwnd = win32gui.FindWindow(ClassName, TitleName) x1, y1, x2, y2 = win32gui.GetWindowRect(hwnd) info('找到窗口，句柄：{}，标题名：{}，类名：{}，坐标：{}'.format( hwnd, win32gui.GetWindowText(hwnd), win32gui.GetClassName(hwnd), (x1, y1, x2, y2))) return x1, y1, x2, y2, hwnd except: global mode if mode == 'test': traceback.print_exc() elif mode == 'real': error('Get_PosAndHwnd 出现错误') def Activate_Hwnd(hwnd): '''激活指定句柄的窗口。每次转换时使用。 Activates the window with the matching hwnd. Args: hwnd: 目标句柄 target handle. Returns: No return. ''' try: win32gui.SendMessage(hwnd, win32con.WM_ACTIVATE, win32con.WA_ACTIVE, 0) # WA_CLICKACTIVE为通过鼠标激活，WA_ACTIVE为鼠标以外的东西（如键盘）激活，WA_INACTIVE为取消激活 # WA_CLICKACTIVE means activate by mouse, WA_ACTIVE means other equipments( like keyboard).WA_INACTIVE means inactivate it. info('激活句柄为 {} 标题为 {} 的窗口'.format(hwnd, win32gui.GetWindowText(hwnd))) except: global mode if mode == 'test': traceback.print_exc() elif mode == 'real': error('激活窗口 出现错误') def LeftClick(hwnd, x1, y1, times='first', platform='PC'): '''左键单击（自带延时） Left Click the window (located by hwnd) once (with human-like delay). Args: hwnd: 目标窗口的句柄 target hwnd. x1, y1: 需要点击的坐标 target pos. times: 'first'表示从其他窗口转过来点击，用于调用Activate_Hwnd(hwnd)。 If 'first', means target window is inactive/unfocused, thus call Activate_Hwnd(hwnd). platform: 目前无用。'PC'表示当前操作的是桌面版，不是安卓模拟器('AM')。 Useless now. 'PC' means target window is PC, not Android manipulator ('AM'). Returns: No return. ''' try: if platform == 'PC': # 对于桌面版 # for PC tmp_pos = win32api.MAKELONG(x1, y1) #elif flag == 'AM': # 对于模拟器 # for Android manipulator #click_pos = win32gui.ScreenToClient(hwnd, (x1, y1)) # 注意坐标是整个窗口还是客户区的，用ScreenToClient转换 # switch between screen/client pos #tmp_pos = win32api.MAKELONG(click_pos[0], click_pos[1]) if times == 'first': Activate_Hwnd(hwnd) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, tmp_pos) # 鼠标点击格式为SendMessage(hWnd,WM_LBUTTONDOWN,fwKeys,MAKELONG(xPos,yPos)); # fwKeys可以取：MK_CONTROL、MK_LBUTTON、MK_MBUTTON、MK_RBUTTON、MK_SHIFT等，多个值用|隔开 sleep(uniform(0.05, 0.08)) win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0000, tmp_pos) # 抬起时有些电脑上fwKeys显示为0000，有些可能为MK_LBUTTON # Use spy++ for details sleep(uniform(0.07, 0.095)) info('对句柄 {} 标题 {} 的坐标 {} 进行 左键单击 动作'.format( hwnd, win32gui.GetWindowText(hwnd), (x1, y1))) except: global mode if mode == 'test': traceback.print_exc() elif mode == 'real': error('左键单击 出现错误') def LeftClick_sequence(hwnd, points, times='second', platform='PC'): '''左键依序单击（依次点击多个点） Click a sequence of points in order. Args: hwnd: 目标窗口的句柄 target hwnd. points: 需要点击的坐标 target points in the form like ((x1,y1),(x2,y2)...). times: 'first'表示从其他窗口转过来点击，用于调用Activate_Hwnd(hwnd)。 If 'first', means target window is inactive/unfocused, thus call Activate_Hwnd(hwnd). platform: 目前无用。'PC'表示当前操作的是桌面版，不是安卓模拟器('AM')。 Useless now. 'PC' means target window is PC, not Android manipulator ('AM'). Returns: No return. ''' try: if times == 'first': Activate_Hwnd(hwnd) for point in points: LeftClick(hwnd, point[0], point[1], 'second', platform) info('对句柄 {} 标题 {} 的坐标 {} 进行 左键依序单击 动作'.format( hwnd, win32gui.GetWindowText(hwnd), points)) except: global mode if mode == 'test': traceback.print_exc() elif mode == 'real': error('左键单击 出现错误') def LeftDoubleClick(hwnd, x1, y1, times='first', platform='PC'): '''左键同一位置双击 Click the same pos twice. Args: hwnd: 目标窗口的句柄 target hwnd. x1, y1: 需要点击的坐标 target pos. times: 'first'表示从其他窗口转过来点击，用于调用Activate_Hwnd(hwnd)。 If 'first', means target window is inactive/unfocused, thus call Activate_Hwnd(hwnd). platform: 目前无用。'PC'表示当前操作的是桌面版，不是安卓模拟器('AM')。 Useless now. 'PC' means target window is PC, not Android manipulator ('AM'). Returns: No return. ''' try: if times == 'first': Activate_Hwnd(hwnd) info('左键同一位置双击--第一次点击') LeftClick(hwnd, x1, y1, 'second', platform) info('左键同一位置双击--第二次点击') LeftClick(hwnd, x1, y1, 'second', platform) except: global mode if mode == 'test': traceback.print_exc() elif mode == 'real': error('左键同一位置双击 出现错误') def LeftDifDoubleClick_Rect(hwnd, x1, y1, times='first', platform='PC', delta=(0, 4)): '''左键不同位置双击，范围为双正方形 Left click 2 pos, in a double-square-like area. _________ | ___ | | | | | | |___| | |_______| (x1, y1) is at the center, (x2, y2) between the squares (borders included). Args: hwnd: 目标窗口的句柄 target hwnd. x1, y1: 第一次点击的坐标 first pos to click. times: 'first'表示从其他窗口转过来点击，用于调用Activate_Hwnd(hwnd)。 If 'first', means target window is inactive/unfocused, thus call Activate_Hwnd(hwnd). platform: 目前无用。'PC'表示当前操作的是桌面版，不是安卓模拟器('AM')。 Useless now. 'PC' means target window is PC, not Android manipulator ('AM'). delta: 第二个点的横坐标在x1+delta[0] 到 x1+delta[1]间（包括端点），纵坐标同样。 x2 varies between x1+delta[0] to x1+delta[1], y2 the same (both ends included). Returns: No return. ''' try: if times == 'first': Activate_Hwnd(hwnd) info('左键正方形双击--第一次点击') LeftClick(hwnd, x1, y1, 'second', platform) point_list = [] for delta_x in range(delta[0], delta[1] + 1): for delta_y in range(delta[0], delta[1] + 1): point_list.append((x1 + delta_x, y1 + delta_y)) point_list.append((x1 + delta_x, y1 - delta_y)) point_list.append((x1 - delta_x, y1 + delta_y)) point_list.append((x1 - delta_x, y1 - delta_y)) point_list = list(set(point_list)) point_list.remove((x1, y1)) x1, y1 = random.sample(point_list, 1)[0] info('左键正方形双击--第二次点击') LeftClick(hwnd, x1, y1, 'second', platform) except: global mode if mode == 'test': traceback.print_exc() elif mode == 'real': error('左键正方形双击 出现错误') def LeftDifDoubleClick_Cir(hwnd, x1, y1, times='first', platform='PC', r=4): '''左键不同位置双击，范围为圆形。 Left Click 2 pos in a circle. (x1, y1)为圆心，r为半径，(x2, y2)在圆内及圆上。 (x1, y1) is the center, r the radius. Generates (x2, y2) in the circle (or on the border). Args: hwnd: 目标窗口的句柄 target hwnd. x1, y1: 第一次点击的坐标 first pos to click. times: 'first'表示从其他窗口转过来点击，用于调用Activate_Hwnd(hwnd)。 If 'first', means target window is inactive/unfocused, thus call Activate_Hwnd(hwnd). platform: 目前无用。'PC'表示当前操作的是桌面版，不是安卓模拟器('AM')。 Useless now. 'PC' means target window is PC, not Android manipulator ('AM'). r: 圆的半径。 radius. Returns: 返回两次点击的坐标(x1,y1,x2,y2)。 Returns 2 clicked pos in (x1,y1,x2,y2). ''' try: if times == 'first': Activate_Hwnd(hwnd) info('左键圆形双击--第一次点击') LeftClick(hwnd, x1, y1, 'second', platform) m, n = x1, y1 point_list = [] final_point_list = [] for delta_x in range(r + 1): for delta_y in range(r + 1): point_list.append((delta_x, delta_y)) point_list.remove((0, 0)) for i in range(len(point_list)): x = point_list[i][0] y = point_list[i][1] if x * x + y * y <= r * r: final_point_list.append((x1 + x, y1 + y)) final_point_list.append((x1 + x, y1 - y)) final_point_list.append((x1 - x, y1 + y)) final_point_list.append((x1 - x, y1 - y)) final_point_list = list(set(final_point_list)) x1, y1 = random.sample(final_point_list, 1)[0] info('左键圆形双击--第二次点击') LeftClick(hwnd, x1, y1, 'second', platform) return (m, n, x1, y1) except: global mode if mode == 'test': traceback.print_exc() elif mode == 'real': error('左键圆形双击 出现错误') def Random_Areas_LeftClicks(hwnd, click_areas, times='first', platform='PC', r=4, click_strategy=1): '''从几种点击策略中选一种执行，在一定范围对目标窗口进行点击。 Select a click strategy/combination, click the window in some given areas. --------- 单个点击区域如右。A single click_area looks like left. | ---- | 如果只点击一次或者点击两次中的第一个坐标，该坐标位于小矩形中（包括边界）。 | | | | If only click once, or the first click in 2 clicks, it locates in the minor rect (borders included). | | | | 小矩形的每条边距大矩形距离为r个像素点。 | | | | Each border of the minor rect is r-pixel far from the outside rect. | ---- | --------- 第二次点击的区域不会超出大矩形的范围（包括边界)）。对于一个点击区域[x1,y1,x2,y2]，其中的小矩形为[x1+r,y1+r,x2-r,y2-r]。 The second is in the big rect (borders included). For a click_area like (x1,y1,x2,y2), the minor rect is [x1+r,y1+r,x2-r,y2-r]. Args: hwnd: 目标窗口的句柄 target hwnd. click_areas: 可选的点击范围（矩形）。格式为[[x1,y1,x2,y2],[x3,y3,x4,y4]...]，只有一个区域则可以写成[x1,y1,x2,y2]。 Possible rect-areas to click, given in the form of [[x1,y1,x2,y2],[x3,y3,x4,y4]...]. If only one area, [x1,y1,x2,y2] is also acceptable. times: 'first'表示从其他窗口转过来点击，用于调用Activate_Hwnd(hwnd)。 If 'first', means target window is inactive/unfocused, thus call Activate_Hwnd(hwnd). platform: 目前无用。'PC'表示当前操作的是桌面版，不是安卓模拟器('AM')。 Useless now. 'PC' means target window is PC, not Android manipulator ('AM'). r: 圆的半径。 radius. click_strategy: 选择的点击策略。看看源码，很容易写自己的策略。主要包括点击方式和概率。 A strategy for clicking, includes combination of clicking functions and its weights. Look at the src code, easy to construct your own click_strategy. Returns: 返回两次点击的坐标(x1,y1,x2,y2)。如果只点击一次，返回(x1, y1, -1, -1) Returns 2 clicked pos in (x1,y1,x2,y2). If only clicked once, return (x1, y1, -1, -1). ''' try: # 缩小边界，并加上一个值，表示区域点的坐标个数（包含边界） total = 0 areas = copy.deepcopy( click_areas ) # python中传列表参是浅拷贝，为了防止改变原click_areas需要写这一句。传字符串、数字是深拷贝 if isinstance(areas[0], int): areas = [areas] for i in range(len(areas)): areas[i][0] = areas[i][0] + r areas[i][1] = areas[i][1] + r areas[i][2] = areas[i][2] - r areas[i][3] = areas[i][3] - r areas[i].append((areas[i][2] - areas[i][0] + 1) * (areas[i][3] - areas[i][1] + 1)) total = total + areas[i][4] rand = random.randint(0, total - 1) # 找出第一次点击位置落在哪个区域，并找出具体坐标 # find out the first click is in which area, and the exact pos. x = 0 y = 0 for i in range(len(areas)): rand = rand - areas[i][4] if rand < 0: rand = rand + areas[i][4] w = areas[i][2] - areas[i][0] + 1 x = rand % w + areas[i][0] y = rand // w + areas[i][1] # 现在python中整型之间用/也会得到float break if click_strategy == 1: # 1号策略，包括左键单击、左键同一位置双击、左键圆形双击三种 #总结果数为173，左键单击:左键同一位置双击:左键圆形双击 的比例为 131:29:13 rand = random.randint(1, 173) if times == 'first': Activate_Hwnd(hwnd) if rand <= 131: # weighs 131 info('随机区域左键 选择 左键单击，坐标{}'.format((x, y))) LeftClick(hwnd, x, y, 'second', platform) return (x, y, -1, -1) elif rand >= 161: # weighs 13 info('随机区域左键 选择 左键圆形双击，第一次点击坐标{}'.format((x, y))) return LeftDifDoubleClick_Cir(hwnd, x, y, 'second', platform, r) else: # weighs 29 info('随机区域左键 选择 左键同位置双击，坐标{}'.format((x, y))) LeftDoubleClick(hwnd, x, y, 'second', platform) return (x, y, x, y) elif click_strategy == 2: # 2号策略，仅包括左键单击，用于只能单击的情况 if times == 'first': Activate_Hwnd(hwnd) info('随机区域左键 选择 左键单击，坐标{}'.format((x, y))) LeftClick(hwnd, x, y, 'second', platform) return (x, y, -1, -1) except: global mode if mode == 'test': traceback.print_exc() elif mode == 'real': error('随机区域左键 出现错误') def press_key(hwnd, key='esc', times='second'): '''单次按键（自带延时） Press a key once (with human-like delay). Args: hwnd: 目标窗口的句柄 target hwnd. key: 需要按的键，详见config.VK_CODE。 The key to press, see also in config.VK_CODE. times: 'first'表示从其他窗口转过来点击，用于调用Activate_Hwnd(hwnd)。 If 'first', means target window is inactive/unfocused, thus call Activate_Hwnd(hwnd). Returns: No return. ''' try: if times == 'first': Activate_Hwnd(hwnd) win32api.SendMessage(hwnd, win32con.WM_KEYDOWN, VK_CODE[key], 0) # 暂时将lParam写为0 # 这里不知道是否要暂停 # I don't konw whether to pause here. time.sleep(0.01) win32api.SendMessage(hwnd, win32con.WM_CHAR, VK_CODE[key], 0) sleep(uniform(0.05, 0.08)) win32api.SendMessage(hwnd, win32con.WM_KEYUP, VK_CODE[key], 0) sleep(uniform(0.07, 0.095)) info('对句柄 {} 标题 {} 的窗口 按 {} 键一次'.format(hwnd, win32gui.GetWindowText(hwnd), key)) except: global mode if mode == 'test': traceback.print_exc() elif mode == 'real': error('单次按键 出现错误') def press_keys(hwnd, keys, times='second'): '''依序按键 Press a sequence of keys in order. Args: hwnd: 目标窗口的句柄 target hwnd. keys: 需要按的键，详见config.VK_CODE。 Keys to press, see also in config.VK_CODE. times: 'first'表示从其他窗口转过来点击，用于调用Activate_Hwnd(hwnd)。 If 'first', means target window is inactive/unfocused, thus call Activate_Hwnd(hwnd). Returns: No return. ''' try: if times == 'first': Activate_Hwnd(hwnd) for key in keys: press_key(hwnd, key, 'second') info('对句柄 {} 标题 {} 的窗口 依序按 {} 键'.format(hwnd, win32gui.GetWindowText(hwnd), keys)) except: global mode if mode == 'test': traceback.print_exc() elif mode == 'real': error('依序按键 出现错误') def ClientRect_PrtSc(hwnd, area=None, filename=''): '''根据句柄、截图位置和图片路径，对窗口的客户区截图并存到指定位置。 Press PrtSc for a window's client area, save the pic in the indicated path. 从https://blog.csdn.net/zhuisui_woxin/article/details/84345036和https://www.cnblogs.com/Evan-fanfan/p/11097850.html改编 Made some changes and corrected some errors from 2 links above (took me some time to figure the errors). Args: hwnd: 目标窗口的句柄 target hwnd. area: 需要的区域。为None时表示整个客户区。 Needed area. If None, means get the whole client area. filename: 指定路径。为''时表示文件名根据hwnd生成。 Indicate the path. If '', means 'path\{}.bmp'.format. Returns: No return. ''' try: hwnd = hwnd if filename == '': filename = 'yourpath\\{}.bmp'.format( hwnd) hwndDC = win32gui.GetDC( hwnd) # 获取窗口的设备上下文Device Context。GetWindowDC包括了非客户区，而GetDC仅为客户区 # GetWindowDC also gets the non-client area, while GetDC only gets the client area. mfcDC = win32ui.CreateDCFromHandle(hwndDC) # 获取mfcDC saveDC = mfcDC.CreateCompatibleDC() # 创建可兼容DC saveBitMap = win32ui.CreateBitmap() # 创建bitmap以保存图片 '''MonitorDev = win32api.EnumDisplayMonitors( None, None) # 获取显示器信息，枚举显示器，笔记本据说可能有问题''' x1, y1, x2, y2 = win32gui.GetClientRect( hwnd) #GetClientRect获取客户区窗口位置，GetWindowRect获取整个窗口的位置信息 x, y, w, h = (0, 0, 0, 0) if area == None: x = 0 y = 0 w = x2 - x1 h = y2 - y1 else: x, y, m, n = area w = m - x h = n - y saveBitMap.CreateCompatibleBitmap(mfcDC, w, h) # 为bitmap开辟空间 # 对saveBitMap.CreateCompatibleBitmap(mfcDC, w, h)的理解： # 1.mfc相当于一个虚拟屏幕。这里的参数w和h决定了这个屏幕的大小。 # 2.屏幕的初始状态是黑色，每个坐标都是#000000 # 3.之前有mfcDC = win32ui.CreateDCFromHandle(hwndDC)，又有hwndDC = win32gui.GetDC(hwnd) # mfcDC和hwnd窗口之间建立了某种关联，可以将hwnd窗口中的图像放到虚拟屏幕上 saveDC.SelectObject(saveBitMap) # 将截图保存到saveBitMap中 saveDC.BitBlt((0, 0), (w, h), mfcDC, (x, y), win32con.SRCCOPY) # 对saveDC.BitBlt(坐标1, (w, h), mfcDC, 坐标2, win32con.SRCCOPY)的理解： # BitBlt的功能大概是把从hwnd窗口截到的图放到虚拟屏幕上，信息转入saveDC。 # 1.坐标1是针对窗口截图的，指定截图放在黑色背景上的位置（指定左上角） # 2.w和h窗口截图的长宽，而坐标2指定了开始截图的位置 # 这两个参数决定了从hwnd窗口的哪里截图、截多大的图 # 3.mfcDC已经和hwnd窗口建立了关联，所以不需要指定虚拟屏幕从哪个窗口获得截图 # 4.SRCCOPY意为将截图直接拷贝到虚拟屏幕中 # 接下来的saveBitMap.SaveBitmapFile(saveDC, filename)则是对虚拟屏幕截图并保存到指定位置 # Google for more info. saveBitMap.SaveBitmapFile(saveDC, filename) # 清除数据 # GetDC一类的需要用ReleaseDC释放，CreateDC一类的用DeleteDC释放，DeleteObject则删除一个逻辑笔、画笔、字体、位图、区域或者调色板 win32gui.DeleteObject(saveBitMap.GetHandle()) saveDC.DeleteDC() mfcDC.DeleteDC() win32gui.ReleaseDC(hwnd, hwndDC) info('对句柄 {} 标题 {} 的窗口截图并保存'.format(hwnd, win32gui.GetWindowText(hwnd))) except: global mode if mode == 'test': traceback.print_exc() elif mode == 'real': error('客户区截图 出现错误') def G(pattern_filename): '''将pattern的文件名加上路径前缀与类型后缀并返回 Add path prefix and '.bmp' for a pattern pic file and return it. Args: pattern_filename: 模式图片名。Pattern pic file name. Returns: opencv2 可用的路径。The usable path for opencv2. ''' try: global pattern_path # 在全局变量中 # a global variable. return pattern_path + pattern_filename + '.bmp' except: global mode if mode == 'test': traceback.print_exc() elif mode == 'real': error('加模式图路径 出现错误') def Find_Pic(hwnd, pattern, return_pos='l', find_pattern_in_area=None, filename=''): '''寻找图片，找到则返回小图在大图中的位置坐标及模板图大小，否则返回(-1,-1,tw,th)。 Find the pattern pic in the target pic. Return the location and pattern's size if found, or (-1,-1,pattern-weight,pattern-height) if not. https://blog.csdn.net/wz2671/article/details/102751549和https://www.cnblogs.com/ssyfj/p/9271883.html很有帮助。 Above 2 links help. Args: hwnd: 目标窗口的句柄 target hwnd. pattern: 模式图，小图。 The pic to be found, pattern pic, smaller pic. return_pos: 为'l'时返回找到的左上角坐标，'c'则返回中心（偏左上）。 If 'l', return the left-top corner, 'c' the centor pos (if odd, lefter and topper). find_pattern_in_area: 指定在大图的哪个区域查找。None表示在全部区域中找。 In which area of the bigger pic to find. None means the whole area. filename: 指定路径。为''时表示文件名根据hwnd生成。 Indicate the bigger-pic's path. If '', means 'path\{}.bmp'.format. Returns: 如果找到，以(x, y, pat_w, pat_h)形式返回小图在大图中的位置；没找到则返回(-1, -1, pat_w, pat_h)。 The location and pattern's size in the form of (x, y, pat_w, pat_h) if found, or (x, y, pat_w, pat_h) if not. ''' try: hwnd = hwnd ClientRect_PrtSc(hwnd, find_pattern_in_area, filename) if filename == '': # 没有提供文件名则需要改成默认文件名 filename = 'youpath\\{}.bmp'.format( hwnd) pat = cv.imread(pattern) # pattern src = cv.imread(filename) # source # imread的文件名必须带上格式后缀 theight, twidth = pat.shape[:2] # 获取pattern的高和宽 result = cv.matchTemplate(src, pat, cv.TM_SQDIFF_NORMED) # 执行模板匹配，采用的匹配方式cv2.TM_SQDIFF_NORMED # matchTemplate(大图像, 小图像(子图像、模式图像)m, 匹配方式, result 和 mask 可选) # 返回一个矩阵result，大小为(W-w+1)*(H-h+1)，比大图像少了右、下一圈，是子图像左上顶点在大图像中可能出现的位置 # result矩阵中的每个位置，其值表示子图像左上顶点放在该位置时，子图像与大图像上相应区域的匹配度 # result中的点位置和大图像保持一致。 # 关于匹配方式：（） # 1.TM_SQDIFF是平方差匹配；TM_SQDIFF_NORMED是标准平方差匹配。利用平方差来进行匹配,最好匹配为0；匹配越差,匹配值越大。 # 2.TM_CCORR是相关性匹配；TM_CCORR_NORMED是标准相关性匹配。采用模板和图像间的乘法操作,数越大表示匹配程度较高, 0表示最坏的匹配效果。 # 3.TM_CCOEFF是相关性系数匹配；TM_CCOEFF_NORMED是标准相关性系数匹配。将模版对其均值的相对值与图像对其均值的相关值进行匹配,1表示完美匹配,-1表示糟糕的匹配,0表示没有任何相关性(随机序列)。 # 总结：随着从简单的测量(平方差)到更复杂的测量(相关系数),我们可获得越来越准确的匹配(同时也意味着越来越大的计算代价)。 min_val, max_val, min_loc, max_loc = cv.minMaxLoc(result) # minMaxLoc(矩阵Mat)，在一个矩阵中找出最大值、最小值、对应序号 # 对于TM_SQDIFF_NORMED模式下最匹配的位置，如果不匹配值大于1%，就认为没有找到。 if min_val > 0.01: return (-1, -1, twidth, theight) global mode if mode == 'test': # 以下代码绘制矩形边框，并将匹配区域标注出来，用于调试 # codes after are used for debug, draw the big/small pic and the location. cv.rectangle(src, min_loc, (min_loc[0] + twidth, min_loc[1] + theight), (0, 0, 225), 1) # 在src图像矩阵中加上矩形，范围是模板图的范围 # rectangle(目标图像, 矩形的一个顶点, 矩形对角线上的另一个顶点, 矩形线条颜色, 线条粗细)返回空值 strmin_val = str(min_val) print('\n匹配度' + strmin_val) cv.imshow("MatchResult----MatchingValue=" + strmin_val, src) # 将画上了矩形的图像在窗口中显示出来 cv.imshow('pattern pic', pat) # imshow(窗口名称, 图像矩阵)，在窗口中显示图像，窗口自动调整到图像大小 # cv.imwrite('1.png', template, [int(cv.IMWRITE_PNG_COMPRESSION), 9]) # imwrite(文件名, 图像矩阵, 格式编码) 将图像矩阵以特定编码保存，暂时用不到 # IMWRITE_PNG_COMPRESSION为png格式，0-9为压缩级别，9为不压缩，消耗时间最少 cv.waitKey(0) # 等待键盘输入任意值，参数表示等待时间（ms），0表示一直等 cv.destroyAllWindows() # 关闭所有窗口 if return_pos == 'c': x = min_loc[0] + twidth // 2 y = min_loc[1] + theight // 2 elif return_pos == 'l': x = min_loc[0] y = min_loc[1] info('在句柄 {} 标题 {} 的窗口找到模式{}'.format(hwnd, win32gui.GetWindowText(hwnd), pattern)) return (x, y, twidth, theight) except: if mode == 'test': traceback.print_exc() elif mode == 'real': error('找图 出现错误') def print_hitted_points(hitted_points, hwnd, deltas): '''测试用函数，根据hwnd找到截图文件（不要重新截图）并标注出点击过的点。 Used for debug, find the big pic by hwnd (No PrtSc again) and mark the clicked pos. Args: hitted_points: 点击过的点。clicked points. hwnd: 用于找到大图。Used to find the big pic. deltas: 如果大图是客户区的一部分截图，该变量可描述大图左上角顶点在整个客户区中的位置。 If the big pic is only part of the window, deltas would help describe big-pic's location (deltas is the left-top corner pos). Returns: No return. ''' try: x1, y1, x2, y2 = hitted_points x1, x2 = x1 - deltas[0], x2 - deltas[0] y1, y2 = y1 - deltas[1], y2 - deltas[1] print('deltas = {}, '.format(deltas)) print('hitted_points = ({},{}) ({},{})'.format(x1, y1, x2, y2)) # 这样就得到了应该标注的坐标 filename = 'yourpath\\{}.bmp'.format( hwnd) src = cv.imread(filename) if x2 < 0 and y2 < 0: cv.circle(src, (x1, y1), 1, (0, 0, 225), 4) # cv.circle(img, point, point_size, point_color, thickness)本来用来画圆的 # 参数分别是图像矩阵、圆心、半径、线的颜色、稠度 # 稠度可以取0、4、8，画出来点的大小不同 cv.imshow('single one----hitted points', src) elif x1 == x2 and y1 == y2: cv.circle(src, (x1, y1), 1, (0, 0, 225), 4) cv.imshow('double same----hitted points', src) else: cv.circle(src, (x1, y1), 1, (0, 0, 225), 4) cv.circle(src, (x2, y2), 1, (0, 0, 225), 4) cv.imshow('double diff----hitted points', src) cv.waitKey(0) cv.destroyAllWindows() except: traceback.print_exc() def FindPic_RandomLeftClick(hwnd, pattern, max_try=20, time_interval=0.5, click_strategy=1, times='first', click_areas=None, find_pattern_in_area=None, r=4, platform='PC', click_pos='l', filename=''): '''从指定的句柄窗口寻找图片，若最大次数内找到则执行随机区域左键点击，否则返回False。返回点击的坐标(x1,y1,x2,y2)，若只点击了一次则返回(x1,y1,-1,-1)。 这是一个整合程度高函数，可能要看一下其中用到的其他函数。 Find a pattern pic in the hwnd window, if not found after max_try, return False; if found, operate a click_strategy, and return clicked pos. If clicked only once, return (x1,y1,-1,-1). This func is highly intergrated. Turn to some called sub-funcs if confused. Args in detail: hwnd: 要截图并点击的窗口句柄。The window to PrtSc and click. pattern: 要寻找的模式小图。The pattern-pic's path to be found. max_try: 指定最多寻找的次数。If not found, after max_try, it will let go. time_interval指定每次寻找的间隔时间（实际查找间隔比该时间多一点）。 Interval between each try (a little longer than given, in fact). click_strategy表示点击策略，详见Random_Areas_LeftClicks函数。 Indicate the click strategy, see also in Random_Areas_LeftClicks(). times: 'first'表示从其他窗口转过来点击，用于调用Activate_Hwnd(hwnd)。 If 'first', means target window is inactive/unfocused, thus call Activate_Hwnd(hwnd). click_areas: 可选的点击范围（矩形）。格式为[[x1,y1,x2,y2],[x3,y3,x4,y4]...]，只有一个区域则可以写成[x1,y1,x2,y2]。为None时表示就点模式小图所在的区域。 Possible rect-areas to click, given in the form of [[x1,y1,x2,y2],[x3,y3,x4,y4]...]. If only one area, [x1,y1,x2,y2] is also acceptable. If None, means to click the area where pattern pic is found. find_pattern_in_area: 指定在窗口客户区的哪个区域寻找模式图，为None时表示在整个客户区寻找。 Which rect-area of the client area of the window to find the pattern pic. If None, means find in the whole area of the client area. find_pattern_in_area从技术上是只截取客户区该区域的图实现的。 If not None, technically, it works by only PrtSc the partly area of the window's client area. r: 指示对指定的点击区域，应该内收多少个像素点。对于高或宽<4的模式图，可能需要自己把r设置成更小的值。 Shrink r pixels for each click_area. For pat-pic smaller than 4 pixels, adjust r to a smaller value. platform: 目前无用。'PC'表示当前操作的是桌面版，不是安卓模拟器('AM')。 Useless now. 'PC' means target window is PC, not Android manipulator ('AM'). click_pos: 可取值'l'或'c'，l表示left_top，后者表示center。详见Find_Pic()。 Possible values are 'l' and 'c'. See also in Find_Pic(). 用于指示在窗口中找到模式图后，是返回其左上坐标还是中心点坐标（模式图长宽为奇数时返回值偏左上）。 'l' means left-top corner pos, 'c' means center pos, used by Find_Pic(). 现在暂时只用'l'模式，'c'模式可能用于以后功能拓展。 For now 'l' is enough, 'c' may help expand furthur functions. filename: 手动指定大图文件，从该文件中查找模式图。一般用不到。 Indicate the big-pic's path manually, not used in ordinary applications. Returns: 若最大次数后仍未找到模式图则返回False。 若找到模式图，返回点击的坐标(x1,y1,x2,y2)，若只点击了一次则返回(x1,y1,-1,-1)。 If pattern pic not found after max_try, return False; if found, return clicked pos in (x1,y1,x2,y2). Return (x1,y1,-1,-1) if only clicked once. ''' try: global mode x, y = -1, -1 deltas = ( -1, -1 ) # 这是截图区域与整个客户区的左上顶点的相对位置。用于测试 # Used for debug, in print_hitted_points(). for i in range(max_try): x, y, twidth, theight = Find_Pic(hwnd, pattern, 'l', find_pattern_in_area, filename) if x == -1 and y == -1: if mode == 'test': print('\r第{}次没找到'.format(i + 1), end='') sleep(time_interval) continue else: if find_pattern_in_area == None: # 若使用默认的全客户区查找，返回的坐标是相对于整个客户区的 delta_x, delta_y = 0, 0 else: # 指定了查找区域，则返回的坐标是相对于查找区域的，需要转换一下 delta_x, delta_y = find_pattern_in_area[ 0], find_pattern_in_area[1] if click_areas == None: # 不给出点击区域，则默认点击区域是窗口找到模式图片的区域 click_areas = x + delta_x, y + delta_y, x + twidth + delta_x, y + theight + delta_y if mode == 'test': deltas = (delta_x, delta_y) break if x == -1 and y == -1: info('未能找到并随机点击：对句柄 {} 标题 {} 的窗口未找到模式{}，等待了{}次*{}秒'.format( hwnd, win32gui.GetWindowText(hwnd), pattern, max_try, time_interval)) return False else: if mode == 'test': # 在测试模式下，画出点击了窗口上的哪些点 hitted_points = Random_Areas_LeftClicks( hwnd, click_areas, times, platform, r, click_strategy) print_hitted_points(hitted_points, hwnd, deltas) return hitted_points elif mode == 'real': return Random_Areas_LeftClicks(hwnd, click_areas, times, platform, r, click_strategy) except: if mode == 'test': traceback.print_exc() elif mode == 'real': error('找图并随机左键点击 出现错误') if __name__ == '__main__': try: click_areas = [292, 175, 314, 192] find_pattern_in_area = [44, 82, 245, 313] # [44, 82, 100, 150] print(FindPic_RandomLeftClick(3212840, G('pattern1'), click_strategy=2)) except: traceback.print_exc() 配置文件：# 键盘码 VK_CODE = { 'backspace': 0x08, 'tab': 0x09, 'clear': 0x0C, 'enter': 0x0D, 'shift': 0x10, 'ctrl': 0x11, 'alt': 0x12, 'pause': 0x13, 'caps_lock': 0x14, 'esc': 0x1B, 'spacebar': 0x20, 'page_up': 0x21, 'page_down': 0x22, 'end': 0x23, 'home': 0x24, 'left_arrow': 0x25, 'up_arrow': 0x26, 'right_arrow': 0x27, 'down_arrow': 0x28, 'select': 0x29, 'print': 0x2A, 'execute': 0x2B, 'print_screen': 0x2C, 'ins': 0x2D, 'del': 0x2E, 'help': 0x2F, '0': 0x30, '1': 0x31, '2': 0x32, '3': 0x33, '4': 0x34, '5': 0x35, '6': 0x36, '7': 0x37, '8': 0x38, '9': 0x39, 'a': 0x41, 'b': 0x42, 'c': 0x43, 'd': 0x44, 'e': 0x45, 'f': 0x46, 'g': 0x47, 'h': 0x48, 'i': 0x49, 'j': 0x4A, 'k': 0x4B, 'l': 0x4C, 'm': 0x4D, 'n': 0x4E, 'o': 0x4F, 'p': 0x50, 'q': 0x51, 'r': 0x52, 's': 0x53, 't': 0x54, 'u': 0x55, 'v': 0x56, 'w': 0x57, 'x': 0x58, 'y': 0x59, 'z': 0x5A, 'numpad_0': 0x60, 'numpad_1': 0x61, 'numpad_2': 0x62, 'numpad_3': 0x63, 'numpad_4': 0x64, 'numpad_5': 0x65, 'numpad_6': 0x66, 'numpad_7': 0x67, 'numpad_8': 0x68, 'numpad_9': 0x69, 'multiply_key': 0x6A, 'add_key': 0x6B, 'separator_key': 0x6C, 'subtract_key': 0x6D, 'decimal_key': 0x6E, 'divide_key': 0x6F, 'F1': 0x70, 'F2': 0x71, 'F3': 0x72, 'F4': 0x73, 'F5': 0x74, 'F6': 0x75, 'F7': 0x76, 'F8': 0x77, 'F9': 0x78, 'F10': 0x79, 'F11': 0x7A, 'F12': 0x7B, 'F13': 0x7C, 'F14': 0x7D, 'F15': 0x7E, 'F16': 0x7F, 'F17': 0x80, 'F18': 0x81, 'F19': 0x82, 'F20': 0x83, 'F21': 0x84, 'F22': 0x85, 'F23': 0x86, 'F24': 0x87, 'num_lock': 0x90, 'scroll_lock': 0x91, 'left_shift': 0xA0, 'right_shift ': 0xA1, 'left_control': 0xA2, 'right_control': 0xA3, 'left_menu': 0xA4, 'right_menu': 0xA5, 'browser_back': 0xA6, 'browser_forward': 0xA7, 'browser_refresh': 0xA8, 'browser_stop': 0xA9, 'browser_search': 0xAA, 'browser_favorites': 0xAB, 'browser_start_and_home': 0xAC, 'volume_mute': 0xAD, 'volume_Down': 0xAE, 'volume_up': 0xAF, 'next_track': 0xB0, 'previous_track': 0xB1, 'stop_media': 0xB2, 'play/pause_media': 0xB3, 'start_mail': 0xB4, 'select_media': 0xB5, 'start_application_1': 0xB6, 'start_application_2': 0xB7, 'attn_key': 0xF6, 'crsel_key': 0xF7, 'exsel_key': 0xF8, 'play_key': 0xFA, 'zoom_key': 0xFB, 'clear_key': 0xFE, '+': 0xBB, ',': 0xBC, '-': 0xBD, '.': 0xBE, '/': 0xBF, '`': 0xC0, ';': 0xBA, '[': 0xDB, '\\': 0xDC, ']': 0xDD, "'": 0xDE, '`': 0xC0 }
步骤/目录： 无本文首发于个人博客https://lisper517.top/index.php/archives/4257/，转载请注明出处。本文的原文写作日期为2020年10月21日，主要目的是用Python截取屏幕。最近需要对窗口截图，看到了以下文章：https://www.cnblogs.com/Evan-fanfan/p/11097850.html其中有些错误（乱写文章害人不浅啊）。经过我2天实验，大概搞清楚了3个关键api的用处。注释和代码如下：# 根据句柄、截图位置和图片路径，对窗口的客户区截图并存到指定位置 # GetDC一类的需要用ReleaseDC释放，CreateDC一类的用DeleteDC释放，DeleteObject则删除一个逻辑笔、画笔、字体、位图、区域或者调色板， def ClientRect_PrtSc(hwnd, area=None, filename=''): try: hwnd = hwnd if filename == '': filename = 'C:\\Users\\tadio\\Desktop\\helper\\pics\\targets\\{}.bmp'.format( hwnd) hwndDC = win32gui.GetDC( hwnd) # 获取窗口的设备上下文Device Context。GetWindowDC包括了非客户区，而GetDC仅为客户区 mfcDC = win32ui.CreateDCFromHandle(hwndDC) # 获取mfcDC saveDC = mfcDC.CreateCompatibleDC() # 创建可兼容DC saveBitMap = win32ui.CreateBitmap() # 创建bitmap以保存图片 '''MonitorDev = win32api.EnumDisplayMonitors( None, None) # 获取显示器信息，枚举显示器，笔记本据说可能有问题''' x1, y1, x2, y2 = win32gui.GetClientRect( hwnd) #GetClientRect获取客户区窗口位置，GetWindowRect获取整个窗口的位置信息 x, y, w, h = (0, 0, 0, 0) if area == None: x = 0 y = 0 w = x2 - x1 h = y2 - y1 else: x, y, m, n = area w = m - x h = n - y saveBitMap.CreateCompatibleBitmap(mfcDC, w, h) # 为bitmap开辟空间 # 对saveBitMap.CreateCompatibleBitmap(mfcDC, w, h)的理解： # 1.mfc相当于一个虚拟屏幕。这里的参数w和h决定了这个屏幕的大小。 # 2.屏幕的初始状态是黑色，每个坐标都是#000000 # 3.之前有mfcDC = win32ui.CreateDCFromHandle(hwndDC)，又有hwndDC = win32gui.GetDC(hwnd) # mfcDC和hwnd窗口之间建立了某种关联，可以将hwnd窗口中的图像放到虚拟屏幕上 saveDC.SelectObject(saveBitMap) # 将截图保存到saveBitMap中 saveDC.BitBlt((0, 0), (w, h), mfcDC, (x, y), win32con.SRCCOPY) # 对saveDC.BitBlt(坐标1, (w, h), mfcDC, 坐标2, win32con.SRCCOPY)的理解： # BitBlt的功能大概是把从hwnd窗口截到的图放到虚拟屏幕上，信息转入saveDC。 # 1.坐标1是针对窗口截图的，指定截图放在黑色背景上的位置（指定左上角） # 2.w和h窗口截图的长宽，而坐标2指定了开始截图的位置 # 这两个参数决定了从hwnd窗口的哪里截图、截多大的图 # 3.mfcDC已经和hwnd窗口建立了关联，所以不需要指定虚拟屏幕从哪个窗口获得截图 # 4.SRCCOPY意为将截图直接拷贝到虚拟屏幕中 # 接下来的saveBitMap.SaveBitmapFile(saveDC, filename)则是对虚拟屏幕截图并保存到指定位置 saveBitMap.SaveBitmapFile(saveDC, filename) # 清除数据 win32gui.DeleteObject(saveBitMap.GetHandle()) saveDC.DeleteDC() mfcDC.DeleteDC() win32gui.ReleaseDC(hwnd, hwndDC) info('对句柄 {} 标题 {} 的窗口截图并保存'.format(hwnd, win32gui.GetWindowText(hwnd))) except: error('客户区截图 出现错误')
步骤/目录： 无本文首发于个人博客https://lisper517.top/index.php/archives/4247/，转载请注明出处。本文的原文写作日期为2020年9月17日，主要目的是总结《Python网络数据采集》。下面的内容有些缩进或者图片缺失，可以参考 原word文档 。1.本书使用标准urllib模块和非标准模块bs4模块： from urllib.request import urlopen from bs4 import BeautifulSoup html = urlopen("http://www.pythonscraping.com/pages/page3.html") bsObj = BeautifulSoup(html) 2.find_all()方法相关： find_all(name=None, attrs={}, recursive=True, text=None, limit=None, **kwargs) method of bs4.BeautifulSoup instance 第一个参数name为标签名，为None时可匹配所有标签名； 第二个为标签属性，常以字典形式给出； 第三个为是否递归查找，除非十分了解网页格式且有时间要求，一般情况下都取True； 第四个为匹配标签内容； limit为查找返回个数限制，为1时该方法等同find方法； 最后是一些其他参数。 3.html格式相关： 子节点（children）和后代节点（descendant），子节点仅为下一级的标签，后代节点包含所有下级标签： for child in bsObj.find("table",{"id":"giftList"}).children: print(child) 这些相关节点都是包含空格、换行的，处理时务必去掉 兄弟标签：next_siblings为后续的所有同级标签，previous_siblings相反。这两个都有去掉s的形式。 父标签与祖先标签：parent和parents 4.常用re符号： 5.用attrs方法返回一个标签的所有属性（字典形式） 6.scrapy应用：自学 7.API，应用编程接口，有严格的语法，用XML或json表示数据。 HTTP常用方法：get，请求网页信息；post，用户提交信息；put，请求更新某个数据；delete，请求删除某个数据。 8.使用twitter库获取推文、更新推文等；利用google、baidu提供的API。 9.用urllib.request.urlretrieve下载文件 10.学习mysql mysql基础命令： net start mysql 启动sql网络服务；CREATE DATABASE scraping 创建数据库；USE scraping 使用数据库； >CREATE TABLE pages (id BIGINT(7) NOT NULL AUTO_INCREMENT, title VARCHAR(200), content VARCHAR(10000), created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id)); 用以上头创建数据库中的表； > INSERT INTO pages (title, content) VALUES ("Test page title", "This is some te st page content. It can be up to 10,000 characters long."); 向表中插入数据； >SELECT * FROM pages WHERE title LIKE "%test%"; 查找数据； 删除只需要把对应的SELECT换成DELETE即可。记得先确认，加上WHERE。 >UPDATE pages SET title="A new title", content="Some new content" WHERE id=2; 更新数据。 ALTER DATABASE scraping CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; 将scraping数据库的字符集从utf8mb4改为utf8mb4_unicode_ci。 用pymysql在python中使用mysql： import pymysql conn = pymysql.connect(host='127.0.0.1', user='root', passwd='aboutime', db='mysql')# 创建连接对象 cur = conn.cursor()# 创建游标对象 cur.execute('USE scraping')# 用execute方法操作数据库 cur.execute('SELECT * FROM pages WHERE id = 3') print(cur.fetchone())# 查询后用fetchone输出结果 cur.execute("INSERT INTO pages (title, content) VALUES (\"%s\",\"%s\")", (title, content))# 用格式化字符串插入新条目 cur.close() conn.close()# 一定记得关闭游标对象和连接对象 另外，作者推荐用自己创建的id（而不是具有唯一性的字段）作为key（字典键值对查询可增快速度） 11.配置SMTP客户端后，用smtplib或email模块发送邮件。发送邮件可以随时了解爬虫的工作情况。 12.读取txt：r=urlopen(‘http://xxxxxxxx/test.txt’);chars=r.read();content=chars.decode(encoding=’utf-8’) utf-8编码：真实情况是，UTF-8 的每个字符开头有一个标记表示“这个字符只用一个字节”或“那个字符需要用两个字节”，一个字符最多可以是四个字节。由于这四个字节里还包含一部分设置信息，用来决定多少字节用做字符编码，所以全部的 32 位（32 位 =4 字节 ×8 位 / 字节）并不会都用，其实最多使用 21 位，也就是总共 2 097 152 种可能里面可以有 1 114 112个字符。 一般网站的meta名标签中可能有charset=’’的属性，可以找到网站编码。 13.读取csv： 将csv文件保存在内存中，将其转换成一个文件对象后读取： from urllib.request import urlopen from io import StringIO import csv data = urlopen('http://.../test.csv').read().decode('ascii', 'ignore') datafile = StringIO(data) csvReader = csv.reader(datafile) for row in csvReader: print(row) csvReader是可迭代的，且由列表构成。reader也可以用DictReader，详见p86 14.用pdfminer3k处理pdf文件：见p88 15.由于没有好的处理docx库，需要手动处理：见p89 python-docx模块 16.文本内容、分析短语；用openrefine清洗数据：p98 进一步分析文本：p105，去掉常见无意义词 Markov模型：从一个状态转移到另一个状态。从一个状态转出的可能性和为1；下一个状态仅由当前状态决定。可以作为网页的热门度排名，计数一个网站的被引次数。或者用于生成随机文本（胡言乱语模拟器）p108 16.用mysql数据库中爬取的链接数据与有向图，用广度优先搜索解决六度分隔问题：p110 17.用nltk模块分析文本：p114 18.用requests模块执行http操作： 提交表单：表单所在的页面一般是html，提交的标签一般有method=’post’和action=’xxx’，其中xxx才是爬虫提交表单需要接入的接口。子标签中一般有type=’text’之类的东西，尤其注意变量名，比如网页需要提交姓名，子标签中有name=’lastname’，那么就需要用requests的post方法，其中参数data需要一个字典，字典中应该有’lastname’:’xxx’一项。 例：网站http://pythonscraping.com/pages/files/form.html的格式为html，源码为： Tell me your name! First name: Last name: 那么爬虫需要接入：http://pythonscraping.com/pages/files/processing.php，如下： import requests params = {'firstname': 'T', 'lastname': 'Bag'} r = requests.post('http://pythonscraping.com/pages/files/processing.php', data=params) print(r.text) r.text就是网页接收表单后返回的数据。 提交文件的方法类似：网页源码 Submit a jpg, png, or gif: 程序代码： import requests files = {'uploadFile': open('../files/Python-logo.png', 'rb')} r = requests.post("http://pythonscraping.com/pages/processing2.php", files=files) print(r.text) 登录见p125：用户输入用户名和密码，网站返回cookie，接下来都用该cookie访问: import requests params = {'username': 'Ryan', 'password': 'password'} 穿越网页表单与登录窗口进行采集 ｜ 125 r = requests.post("http://pythonscraping.com/pages/cookies/welcome.php", params) print("Cookie is set to:") print(r.cookies.get_dict()) print("-----------") print("Going to profile page...") r = requests.get("http://pythonscraping.com/pages/cookies/profile.php", cookies=r.cookies) print(r.text) 对简单的访问这样处理没有问题，但是如果你面对的网站比较复杂，它经常暗自调整 cookie，或者如果你从一开始就完全不想要用 cookie，该怎么处理呢？ Requests 库的 session 函数可以完美地解决这些问题： import requests session = requests.Session() params = {'username': 'username', 'password': 'password'} s = session.post("http://pythonscraping.com/pages/cookies/welcome.php", params) print("Cookie is set to:") print(s.cookies.get_dict()) print("-----------") print("Going to profile page...") s = session.get("http://pythonscraping.com/pages/cookies/profile.php") print(s.text) 在这个例子中，会话（session）对象（调用 requests.Session() 获取）会持续跟踪会话信 息，像 cookie、header，甚至包括运行 HTTP 协议的信息，比如 HTTPAdapter（为 HTTP 和 HTTPS 的链接会话提供统一接口）。 在一些安全性较高的网站可能遇到HTTP基本接入认证，可用requests库的auth模块处理： import requests from requests.auth import AuthBase from requests.auth import HTTPBasicAuth auth = HTTPBasicAuth('ryan', 'password') r = requests.post(url="http://pythonscraping.com/pages/auth/login.php", auth= auth) print(r.text) 19.js简介：javascript是一种弱类型语言。所有变量和函数都用var显式声明（对比python）。Python运行js比较慢，尽量绕开js直接获取数据。 网页常用js库： Jquery，特征是源码包含jquery入口： </ script> Google Analytics：特征是页面下方有： 如果一个网站使用了 Google Analytics 或其他类似的网络分析系统，而你不想让网站知道你在采集数据，就要确保把那些分析工具的 cookie 或者所有 cookie 都关掉。 Google地图：类似google.maps.LatLng() Ajax和DHTML可以使浏览器在同一个url上获得变化的数据，一般都需要浏览器执行js。那些使用了 Ajax 或 DHTML 技术改变 / 加载内容的页面，可能有一些采集手段，但是用Python 解决这个问题只有两种途径：直接从 JavaScript 代码里采集内容，或者用 Python 的第三方库运行 JavaScript，直接采集你在浏览器里看到的页面。 Selenium库可以执行js，需要和一个浏览器一起运行，比如phantomjs无头浏览器（不是python库，类似chrome浏览器，只是没有界面）。如果用chrome或者firefox等，需要执行相应的driver。 from selenium import webdriver import time driver = webdriver.PhantomJS(executable_path='相应的driver路径') driver.get("http://pythonscraping.com/pages/javascript/ajaxDemo.html") time.sleep(3) print(driver.find_element_by_id('content').text) driver.close() 由于phantomjs已经停止维护，可以使用chrome浏览器的无头模式（v60以上支持）： from selenium.webdriver.chrome.options import Options chrome_options = Options() chrome_options.add_argument('--headless') chrome_options.add_argument('--disable-gpu') driver = webdriver.Chrome(chrome_options=chrome_options) Selenium库的作用和bs4类似。查找单个元素时，find_element_by_tag_name方法返回匹配的标签名，需要返回列表时写elements即可。不习惯的话可以用driver.page_source返回页面源码，用bs解析该源码。 上面的代码中显式等待了3s，还可以通过让selenium检查页面变化来判断，称为隐式等待： from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC driver = webdriver.PhantomJS(executable_path='') driver.get("http://pythonscraping.com/pages/javascript/ajaxDemo.html") try: element = WebDriverWait(driver, 10).until(#等待上限为10s EC.presence_of_element_located((By.ID, "loadedButton"))) finally: print(driver.find_element_by_id("content").text) driver.close() 其中EC是expected condition的缩写，常用简称。 (By.ID, "loadedButton")是一种定位器。定位器的使用见p135 用selenium执行js实现客户端重定向：不断检测页面上一个元素，直到抛出StaleElementReferenceException异常，说明页面刷新了： from selenium import webdriver import time from selenium.webdriver.remote.webelement import WebElement from selenium.common.exceptions import StaleElementReferenceException def waitForLoad(driver): elem = driver.find_element_by_tag_name("html") count = 0 while True: count += 1 if count > 20: print("Timing out after 10 seconds and returning") return time.sleep(.5) try: elem == driver.find_element_by_tag_name("html") except StaleElementReferenceException: return driver = webdriver.PhantomJS(executable_path='<Path to Phantom Js>') driver.get("http://pythonscraping.com/pages/javascript/redirectDemo1.html") waitForLoad(driver) print(driver.page_source) 每半s检查一次，时限为10s。 20.使用pillow库进行图像处理、tesseract进行文字识别（tesseract可以直接在cmd中运行，或者用pytesseract在python中使用）。另外，numpy含有大量科学计算方法，比如可以将图片表示为像素数组，可以配合tesseract。 P143例子是将图片颜色过滤，然后用tesseract识别。 有兴趣的可以试试训练tesseract识别验证码。 21.用requests的session还可以更改请求头等。可以伪装手机用户访问，比如user-agent改成：User-Agent:Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X)AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257Safari/9537.53 P158介绍了保存cookie等方法。 通过一些用户看不到却在网页源码里的字段，服务器可能轻易识别爬虫并封ip。用selenium中的is_displayed()方法可以判断标签是否隐藏，见p161 22.出现问题怎么办？ 首先，如果你从网络服务器收到的页面是空白的，缺少信息，或其遇到他不符合你预期 的情况（或者不是你在浏览器上看到的内容），有可能是因为网站创建页面的 JavaScript 执行有问题。可以看看第 10 章内容。 • 如果你准备向网站提交表单或发出 POST 请求，记得检查一下页面的内容，看看你想提 交的每个字段是不是都已经填好，而且格式也正确。用 Chrome 浏览器的网络面板（快 捷键 F12 打开开发者控制台，然后点击“Network”即可看到）查看发送到网站的 POST 命令，确认你的每个参数都是正确的。 • 如果你已经登录网站却不能保持登录状态，或者网站上出现了其他的“登录状态”异常， 请检查你的 cookie。确认在加载每个页面时 cookie 都被正确调用，而且你的 cookie 在 每次发起请求时都发送到了网站上。 • 如果你在客户端遇到了 HTTP 错误，尤其是 403 禁止访问错误，这可能说明网站已经把 你的 IP 当作机器人了，不再接受你的任何请求。你要么等待你的 IP 地址从网站黑名单 里移除，要么就换个 IP 地址（可以去星巴克上网，或者看看第 14 章的内容）。如果你 确定自己并没有被封杀，那么再检查下面的内容。 ♦ 确认你的爬虫在网站上的速度不是特别快。快速采集是一种恶习，会对网管的服务 器造成沉重的负担，还会让你陷入违法境地，也是 IP 被网站列入黑名单的首要原因。 给你的爬虫增加延迟，让它们在夜深人静的时候运行。切记：匆匆忙忙写程序或收 集数据都是拙劣项目管理的表现；应该提前做好计划，避免临阵慌乱。 ♦ 还有一件必须做的事情：修改你的请求头！有些网站会封杀任何声称自己是爬虫的 访问者。如果你不确定请求头的值怎样才算合适，就用你自己浏览器的请求头吧。 ♦ 确认你没有点击或访问任何人类用户通常不能点击或接入的信息（更多信息请查阅 12.3.2 节）。 ♦ 如果你用了一大堆复杂的手段才接入网站，考虑联系一下网管吧，告诉他们你的目的。 试试发邮件到 webmaster@< 域名 > 或 admin@< 域名 >，请求网管允许你使用爬虫采 集数据。管理员也是人嘛！ 23.如何用爬虫+unittest测试网页前端，见p166。Selenium本意是测试网站，可以用其对网页元素进行点击、点击并按住、双击等操作，还可以生成动作链，也可以将一个元素拖放一定距离/到其他元素上，或者将网页截屏。 24.如何改变ip：用the onion router TOR。TOR也是有局限的，务必遵守道德底线。 Pysocks（一个简单的python代理服务器通信模块）可以和TOR搭配使用 25.使用云服务部署爬虫、搭建服务器等。
《Learn Python the Hard Way/笨办法学python》（第三版）-Zed Shaw（笔记）
步骤/目录： 无本文首发于个人博客https://lisper517.top/index.php/archives/4242/，转载请注明出处。本文的原文写作日期为2020年9月17日，主要目的是总结《Learn Python the Hard Way/笨办法学python》（第三版）。下面的内容有些缩进或者图片缺失，可以参考 原word文档 。总结：这本书适合自学能力较强的，或者有一定基础的人自学。 1.格式化输出：%s用于string（基于str()），%r用于debug（基于repr()，原封不动显示输出，双引号转换为单引号），%d用于整数。format更先进。 2.sys.argv：脚本接受的参数列表。[0]为脚本路径。 3.文件操作：f=open(文件名,’w’)。常用方法：f.close()、read()、readline()、truncate()、write(字符串)等。注意方法的参数。 4.f.seek(整数)将读/写指针放到指定位置。 5.str.split(‘ ’)以空格为标志分割str，返回列表。 列表.pop(x)弹出（即删除）列表[x]元素，返回这个元素。 sorted()整理一个列表或元组等。 6.一些关键字： global：在函数中定义的变量默认是局部变量，局部变量覆盖同名全局变量。引入全局变量需要用global。 assert（断言）：判断后面跟着的表达式，若值为False则立刻终止程序并触发一个assert错误。 pass：用来占位。 yield：在函数中遇到yield后，会将所有局部变量保存，返回yield后面的对象，然后继续执行函数。yield主要是为了节省空间。 exec：将字符串当作python语句执行。总是返回None。 7.使用 字典名.items()来遍历字典。字典的get方法：get(key,default)用键找到对应的值，若没有则返回default，默认为None。 8.super方法：为了使子类调用父类方法可使用super方法。在python3中，格式为super().父类方法。在复杂的继承中，super调用的哪个方法可能比较复杂。 为了以一定的顺序快速寻找到指定方法，python使用了MRO（Method Resolution Order）方法解析顺序，涉及到C3算法。应该尽量避免多重继承。 9.str.strip([chars])方法：去除str两端的chars字符串，默认为去除空白符。 10. random库： >>> import random >>> random.choice(['apple', 'pear', 'banana'])'apple' >>> random.sample(range(100), 10) # sampling without replacement [30, 83, 16, 4, 8, 81, 41, 50, 18, 33] >>> random.random() # random float 0.17970987693706186 >>> random.randrange(6) # random integer chosen from range(6)4 11.后面可以不用看了
步骤/目录： 无本文首发于个人博客https://lisper517.top/index.php/archives/4241/，转载请注明出处。本文的原文写作日期为2020年9月6日。本文的目的如标题所示。阅读《python基础教程第三版》第六章提到了函数及其作用域的嵌套。nonlocal为内部函数指定外部函数中的变量时使用。如果函数是三层嵌套会如何呢？我写了如下代码用于验证：def out(): x = 100 def middle(): x = 10 def mini(): nonlocal x print(x) return mini return middle out()()()结果输出了10。可见nonlocal有就近的原则。为了在mini中能够调用out中的x，我试着在mini中使用out.x或者声明global x，但都出现了错误。问题：如果出现了多层函数嵌套，在内层函数中应该如何指定使用哪一个作用域中的变量？
letterGirls.setdefault(girl[0], []).append(girl)：setdefault返回对象与append应用
步骤/目录： 无本文首发于个人博客https://lisper517.top/index.php/archives/4239/，转载请注明出处。本文的原文写作日期为2020年9月6日。本文的目的如标题所示。《python基础教程第三版》第五章有一例：girls = ['alice', 'bernice', 'clarice'] boys = ['chris', 'arnold', 'bob'] letterGirls = {} for girl in girls: letterGirls.setdefault(girl[0], []).append(girl) print([b+'+'+g for b in boys for g in letterGirls[b[0]]])目的是配对首字母相同的boy与girl并据此创建列表。关键在于理解letterGirls.setdefault(girl[0], []).append(girl) 。书上说append方法不能用于字典，看上去好像这一句错了。其实可以从左向右看：letterGirls是一个字典，对字典使用setdefault方法，参数为(girl[0], [])，即对girls列表中的每一个字符串元素，以其首字母为键查找字典中对应值，该键不存在则创建 首字母: [] 的项。setdefault返回的是值，也就是[]空列表，然后对空列表使用append方法，原位将空列表修改为[girl]。这一步后输出字典，得到：{'a': ['alice'], 'b': ['bernice'], 'c': ['clarice']}最后一行，关键在于for g in letterGirls[b[0]]。以b的首字母为键得到值g，然后打印b+g。这里的问题是值g好像是一个一元字符串列表，列表和字符串是不能用+操作并输出的。但如果写print([type(g) for b in boys for g in letterGirls[b[0]]])就会发现，g的类型是str类。这是因为in操作，letterGirls[b[0]]才是一个一元字符串列表，for g in 字符串列表，那么就会得到这个字符串了。上面的：letterGirls.setdefault(girl[0], []).append(girl) 也可以替换成：letterGirls.setdefault(girl[0], [girl])注意不能写成：letterGirls.setdefault(girl[0], girl)（这时for g in 字符串，则会输出一个一个的字符）原因已在上面说明了。
步骤/目录： 无本文首发于个人博客https://lisper517.top/index.php/archives/4232/，转载请注明出处。本文的原文写作日期为2020年9月5日。本文的目的如标题所示。看《笨方法学python第三版》的时候习题46，想写一个python脚本自动在当前目录下创建项目骨架。其中需要将一些内容写入一个文件，需要用到format方法，但出现了一个问题。简化版的问题代码如下：project_name = 'test' setup_config = ''' config = { 'description': '{0}', 'author': 'Author Name', } '''.format(project_name) print(setup_config)运行时出现异常：KeyError: "\n 'description'"KeyError是查找字典中的键时键不存在返回的错误。作为替代方法，我暂时先用了%格式化输出：project_name = 'test' setup_config = ''' config = { 'description': '%s', 'author': 'Author Name', } ''' % project_name print(setup_config)可以正常运行 。我想到了在format中用{{}}来输出{}的问题，多试几次，发现：project_name = 'test' setup_config = ''' config = {{ 'description': '{0}', 'author': 'Author Name', }} '''.format(project_name) print(setup_config)就可以正常运行，但第一段的代码中{0}是高亮显示的。我猜测可能是format方法中，误以为：{ 'description': '{0}{到:这之间的内容是键，: 后面的是指定格式，但我没有给出键，因为想使用0作为键。由于我使用的vs code高亮了{0}，可能解释器和编辑器的理解不一样。总结：要用format方法输出{}，不论出现在字符串的哪里，都应该使用双括号来替代{}，才可以正确识别。
步骤/目录： 无本文首发于个人博客https://lisper517.top/index.php/archives/4230/，转载请注明出处。本文的原文写作日期为2020年9月4日，主要目的是总结《A Byte Of Python3》。下面的内容有些缩进或者图片缺失，可以参考 原word文档 。1.python中有整数、浮点数、复数。整数受限于内存大小。双引号等同于单引号，用三引号来指示多行字符串。表示’，可以\’，或者在头尾使用双引号，或者使用三引号。行末的\表示下一行与本行之间没有换行符。类似C++中的raw string，前面加r或R可指定自然字符串。相邻的字符串会被自动连接。 输出中的format方法，类似printf+setf，可以调整格式。 标识符不能以数字开头，大小写敏感，只能用数字、字母、下划线。可以使用中文。 python中的任何东西都是对象。 变量不需要声明类型，可以直接初始化。 python默认一行一句，若一行多句可用;间隔。使用缩进来决定语句的层次， 2.定义了字符串与整数的*操作；x**y为x的y次方；定义了浮点数的%求余；//为取整除 - 返回商的整数部分（向下取整）；&、|、^为按位与、或、异或；~x为-(x+1)；比较操作符可连接；not为非操作，类似!；and、or为和、或操作；in和not in为从属关系，is和not is为是否关系。优先级见p25。 3.有if、while、for三种控制流语句，没有switch，某些场合下用字典代替。条件后跟:，while后可选else，for后也可选else。break用于跳出，注意，对应的else部分不会执行；continue。 4.range函数，提供第一个参数到第二个参数的序列（不含第二个参数），步长为1，可用第三个参数调整步长。len函数返回字符串的长度。 5.函数：用关键字def定义，如 def 函数名(参数表): 函数体 使用global语句来调用block外的变量。可以同时声明多个变量。 非局部：用于定义函数中的函数，介于局部和全局之间，nonlocal 函数的参数默认值也需要从右到左，但调用时可以只给出其中几个值而忽略其他值来使用默认参数，这时参数的顺序可以乱序。 参数个数不确定，可以使用*。单个*可以将参数收集为一个列表，**则可以将参数收集为字典。*后面要输入非列表中的参数，需要用keyword-only参数，如果仅想使用keyword-only参数，可以使用*后面不带名字，然后接一个k-o参数。 return用于退出函数，单独的return等同return None。pass代表空语句块。 Docstring，在模块、类或者函数中，用三引号括起来的部分，是该部分的说明。使用print(对象名.__doc__)可以打印其说明文档。其首行以大写字母开始、句号结束，第二行为空行，第三行开始说明。help就是抓取docstring，按q可退出help。 6.模块：类似C中多个源文件。要使用某个模块，可import之，如果不是内置的模块，解释器会在sys.path中列出的路径中查找（其第一个元素就是当前目录），找到后运行这个模块，这个初始化过程仅在第一次导入时运行。 在执行python XXX.py 参数表 时，会将参数表传递给XXX.py模块，用sys.argv列表可查看参数列表，其中sys.argv[0]元素是脚本名称。查看os.getcwd()可获取当前目录。 为了使导入模块变快，会自动创建按字节编译的pyc文件，在当前目录，若没有写权限则不会创建。pyc文件与平台无关。 如果想导入sys中的argv、使其可以直接使用而不加sys.，可from sys import argv，或者导入sys中全部：from sys import *（但不会导入双下划线开头的对象），一般应避免这样使用。 每个模块都有__name__，若其为’__main__’说明这个模块类似含有main函数。此可用来避免import模块时其自动运行一次。 dir()函数可以列出模块中的标识符（类，函数，变量），给其空参数则会列出本模块的标识符。del语句可以删除变量或名字。 包是一个文件夹，含有多个模块，其中有一个__init__.py文件来指明该文件夹包含python模块。包可嵌套。 7.数据结构：列表、元组、字典、集合。 列表list用[]包括，元素间用逗号分隔，有序。可以用append方法来在列表尾部添加元素。对列表使用len会计数其元素个数。列表还可以使用sort方法，或者用del删除某个元素。列表中的元素类型可以不同，比如一个字符串和整数在同一个列表中。列表可嵌套。 元组和列表类似，但像字符串一样不可变，用()包括（也可以不带括号）。含一个元素的元组需要在该元素后加逗号，来区别括号表达式。 字典：键值对，键要求唯一且不可变，比如字符串做键，值可变。字典用{}包括，每个对之间用逗号分隔，键与值间用:分隔，为 键:值 。字典无序，可以自己排序。del 字典名[键] 可以删除该键值对，使用 字典名[键]=值 来添加键值对。使用 for x,y in 字典名.items(): 可遍历字典（items返回元组的列表）。 元组、字符串和列表都是序列，有切片操作。[x:y:z]，从x开始到y之前（不包括y）的元素，步长为z（省略时为1），x省略默认为从开头开始，y省略默认到结尾（包括结尾）。[::-1]表示倒序。 集合：无序。用set([])来表示，用in来查看有无元素，copy方法来复制集合，add方法增加元素，issuperset方法来判断子集，remove方法删除元素，&或intersection方法求交集。 8.变量赋值导致引用、alias，slicing可以创建拷贝。 字符串都是str类，方法众多。startswith(pattern)，检验是否以pattern开头；find(p)，返回位置，找不到返回-1；p.join(str类序列)，在str类序列中加入p作为分隔符，并返回这个大字符串。 9.路径中有空格时需要再加双引号。cmd只能识别双引号。 time库中有一些时间的输出格式。 os提供与系统的交互。os.system()向shell输出字符串命令，os.path.exists()判断路径是否存在，os.mkdir()创建路径。 10.类使用class定义，类的方法也有隐藏的this指针，即self参数。self参数在写方法时必须要加上。类的方法也可以在不实例化时使用。 构造函数，可以自己定义，方法名为__init__。 在类方法前面写的变量是类变量，由所有对象共享，与c++区别。双下划线开头的数据成员则是每个对象私有。一些不涉及具体对象的方法可定义为静态方法，即在函数结束后写 函数名=staticmethod(函数名) ，对齐def。 继承：将父类的名称作为一个元组，写成class 子类(父类): 子类的构造函数不会自动调用父类的构造函数，需要显式调用。 11.一些str处理：字符串.lower()返回字符串的小写形式；字符串.replace(p1,p2)将其中的p1以p2替换； string.punctuation为所有标点符号的序列。 12.读写文件用file类的对象。 如f = open(‘test.txt’, ‘w’)，以写模式打开test.txt。还有r、a（附加）、b（二进制）等模式，不加参数时默认以r模式打开text文件。f.write(字符串)为向文件写入字符串，f.readline()为读取并返回文件中的一行字符串，可搭配循环使用，遇到EOF时返回空字符。 用pickle库读写对象：pickle.dump(待写入对象,f)，f是一个file对象，待写入对象可以是任意对象。pickle.load(f)为读取文件。 13.try、except异常处理：后面不带圆括号的except可处理所有异常。一个try应该跟着一个except，如果没有则用默认的except：打印错误信息并退出。如果except块内写continue，就可以不管异常、继续运行。 可以编写自己的异常类，一个异常类应该直接或间接继承Error或Exception类，然后用raise 异常类来触发这个异常类： class ShortInputException(Exception): def __init__(self, length, atleast): Exception.__init__(self) self.length = length self.atleast = atleast try: text = input('Enter something:') if len(text) <3: raise ShortInputException(len(text), 3) except EOFError:#ctrl + D引发，未输入任何东西 print('please enter something') except ShortInputException as ex:#ex为自己写的异常类的对象 print('ShortInputException: The input was {0} long, expected at least {1}'\ .format(ex.length, ex.atleast)) else: print('No exception triggered') finally：不管怎样都会执行的块。即使except而退出，也会先执行这里。 try: a = int(input("请输入 a 的值:")) print(20/a) except: print("发生异常！") else: print("执行 else 块中的代码") finally : print("执行 finally 块中的代码") 上例中的else必须搭配except，且在except的后面。 with也能起类似的作用。具体看https://developer.ibm.com/zh/articles/os-cn-pythonwith/ 浅谈：起到类似 try: except: finally:的作用；代码更少，还有其他好处。 只能用于有上下文管理器，即有__enter__() 和__exit__()方法的对象。__enter__()方法会在with的代码块执行之前执行，__exit__（）会在代码块执行结束后执行。__exit__()方法内会自带当前对象的清理方法。 14.标准库： sys包含系统指定的函数功能；warnings包含一些警告模块；logging得到调试信息： #!/usr/bin/python # Filename: use_logging.py import os, platform, logging if platform.platform().startswith('Windows'): logging_file = os.path.join(os.getenv('HOMEDRIVE'), 'test.log') else: logging_file = os.path.join(os.getenv('HOME'),'test.log') logging.basicConfig( level = logging.DEBUG, format = '%(asctime)s : %(levelname )s : %(message)s', filename = logging_file, filemode = 'w', ) logging.debug("Start of the program") logging.info("Doing something") logging.warning("Dying now") 其中platform模块可以得到平台信息 15.其他： a,b = (1,2)会使得a为1、b为2，函数可以返回元组。a ,*b = [1,2,3]使得a为1，b为[2,3]。可以用a,b=b,a交换两个变量。 类的常用方法： Lambda表达式：一种函数 列表综合：根据已有列表生成新列表 listone = [2,3,4] listtwo = [2*i for i in listone if i > 2] #得到[6,8] exec(字符串)可将字符串作为python语句执行；eval(字符串)则可将字符串作为python表达式求值。 assert：声明某个条件必须满足，不满足则会引发错误并退出程序。 repr函数用来取得对象的规范字符串表示。大多数时候都有eval(repr(object)) == object。需要类有__repr__方法。 isatty()方法是否连接到终端设备。如sys.stdout.isatty()检测stdout是否连接显示器。
步骤/目录： 1.需求介绍 2.ffmpeg 3.tinypng 4.squoosh （1）Node.js （2）克隆仓库并安装运行 a.本地运行网页服务器 b.运行桌面版 c.squoosh-cli （3）squoosh的使用 5.修改vimg_processor_multi本文首发于个人博客https://lisper517.top/index.php/archives/60/，转载请注明出处。本文的目的是介绍一些图片压缩的工具和使用方法。本文写作日期为2022年10月19日。运行的平台为win10，编辑器为VS Code。1.需求介绍在处理前端时，常常需要放一些图片在网页中，但是一般素材图片占用空间都很大，直接放到网页上不太合适，如果有方法把图片压缩一下就very的nice。本文就介绍一些图片压缩的工具和方法。2.ffmpeg在之前的文章 Python，爬虫与深度学习（17）——电影的信息爬取、下载与处理（二） 中，笔者用python写了一个vimg_processor类，主要是用ffmpeg来获取图片/视频信息、压缩图片/视频。其中使用ffmpeg压缩图片的命令参考了 这篇文章 ，如下：'ffmpeg -i "{}" -vf palettegen=max_colors=256:stats_mode=single -y "{}"'.format(self.origin_abs_path, self.palette) 'ffmpeg -i "{}" -i "{}" -lavfi "[0][1:v] paletteuse" -pix_fmt "{}" -y "{}"'.format(self.origin_abs_path, self.palette, self.pix_fmt, self.temp_img) 'ffmpeg -i "{}" -vf scale={} -y "{}"'.format(self.temp_img, self.definition, self.compressed_abs_path)总的来说是使用调色板将原图使用的256色减少，然后根据调色板输出图片，最后再压缩图片尺寸。笔者其实不太了解ffmpeg，使用上面的命令，虽然图片占用空间能压缩到很小，但色彩减少导致肉眼能看出很多差距，比较明显的是在人像中，如果涂了口红，压缩后的图片会呈现一种灰红色的质感，这是无法接受的。经过一番寻找，笔者发现了其他压缩图片的方法，它们压缩图片的算法比较复杂，效果也远非简单的3句ffmpeg命令可比。3.tinypngTinyPNG 据说是一个久负盛名的图片压缩网站，可惜它的压缩算法没有开源。如果你只是想压缩几张图片，可以到它的网址 https://tinypng.com/ 手动上传图片，据说每天只能上传20张不超过5MB的图片；如果需要批量压缩图片，你可以到 https://tinypng.com/developers 填写姓名、邮箱申请一个API key（限制是每月只能压缩500张），然后使用python或者其他语言写一个客户端实现压缩脚本，可以参考 这个项目 。使用TinyPNG确实压缩效果不错，可惜源码不开源，而且能压缩的图片张数有限制（据说有方法开源绕开限制，参考 这篇文章 ，原理是tinypng服务器限制上传次数时用到了ip进行判断，所以在构造请求头时修改了ip）。综合考虑下来，笔者放弃了TinyPNG，毕竟万一哪天这类闭源工具开始收费就不太合适了。像TinyPNG这样的在线图片压缩网站还有很多，比如：https://tinyjpg.com/ （TinyPNG的系列网站）https://imagestool.com/zh_CN/compress-images.htmlhttps://squoosh.app/本文将重点介绍squoosh。4.squooshsquoosh 是google chrome labs推出的一款开源图片压缩工具，源码放到了github上： https://github.com/GoogleChromeLabs/squoosh ，这些源码主要是TypeScript、JavaScript写的，但是笔者希望用python调用这个图像压缩的工具，这需要一些技巧，下面以Win10为例讲解。（1）Node.js因为要用JavaScript，需要安装Node.js、npm。按照 runoob教程 ，进行如下操作：到 https://nodejs.org/zh-cn/download/ 下载对应的安装包，笔者选择的是14.15.1的64位Windows安装包（下载地址为 https://nodejs.org/dist/v14.15.1/node-v14.15.1-x64.msi ，因为在 这个问题 中提到开发时用的就是14.15.1版本），一路确认，唯一需要注意的是中间有一步是勾选 Automatically install the neccessary tools ，把这个勾选去掉，因为它会安装其他版本的python3、污染环境。安装完后，在cmd中实验一下，输入 node --version 、 npm --version ，可输出版本号。其他安装方法包括安装二进制文件、docker安装、linux上编译安装，这里就不介绍了。（2）克隆仓库并安装运行a.本地运行网页服务器第一种方法是在本地运行网页服务器，这种方法并不是很推荐。到 项目的github地址 下载zip包（或者你也可用pull到本地），解压到合适的位置，在cmd中进入该目录，进行如下操作：npm install npm run build npm run dev最后一行命令会开启一个本地的服务器，好比你把 https://squoosh.app/ 搬到了本地的服务器运行。笔者在实验时出现了一些错误，具体信息如下：>npm install npm WARN read-shrinkwrap This version of npm is compatible with lockfileVersion@1, but package-lock.json was generated for lockfileVersion@2. I'll try to do my best with it! > squoosh@2.0.0 prepare D:\squoosh-dev > husky install npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.1.3 (node_modules\fsevents): npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.1.3: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"}) added 816 packages from 299 contributors and audited 819 packages in 34.939s 43 packages are looking for funding run `npm fund` for details found 106 vulnerabilities (89 moderate, 12 high, 5 critical) run `npm audit fix` to fix them, or `npm audit` for details >npm run build > squoosh@2.0.0 build D:\squoosh-dev > rollup -c && node lib/move-output.js src/static-build/index.tsx → .tmp/build... Browserslist: caniuse-lite is outdated. Please run: npx browserslist@latest --update-db Unable to determine site origin, defaulting to https://squoosh.app created .tmp/build in 26.7s>npm run dev > squoosh@2.0.0 dev D:\squoosh-dev > DEV_PORT="${DEV_PORT:=5000}" run-p watch serve 'DEV_PORT' 不是内部或外部命令，也不是可运行的程序 或批处理文件。 npm ERR! code ELIFECYCLE npm ERR! errno 1 npm ERR! squoosh@2.0.0 dev: `DEV_PORT="${DEV_PORT:=5000}" run-p watch serve` npm ERR! Exit status 1 npm ERR! npm ERR! Failed at the squoosh@2.0.0 dev script. npm ERR! This is probably not a problem with npm. There is likely additional logging output above. npm ERR! A complete log of this run can be found in: npm ERR! C:\Users\xxx\AppData\Roaming\npm-cache\_logs\xxxx-debug.log不知道这是为什么。即使运行 npm audit fix 、 npx browserslist@latest --update-db 也无济于事；安装16版本的Node.js，又报错 Could not load /squoosh-dev/src/shared/prerendered-app/colors.css ，最后只好放弃。根据报错信息，如果成功安装，大概访问本机的5000端口就能看到squoosh网页。实际上，就算通过这种方式成功安装了squoosh，也和浏览器访问 https://squoosh.app/ 几乎一样，唯一的区别是不需要联网。如果有需要，笔者更推荐下一种方法。b.运行桌面版根据 https://github.com/matiasbenedetto/squoosh-desktop-app 这个项目的介绍，到 https://squoosh-desktop.vercel.app/ 网页去下载 Windows版的Portable文件 即可，是一个exe文件（只有45MB左右大小），打开来是一个隐藏了一些UI的浏览器，这种方式下都不需要安装Node.js了。c.squoosh-cli上面两种方法提供的主要是网页UI，只适合少量图片压缩。如果要大量压缩图片，最好还是能使用命令行、脚本。squoosh确实提供了这样的API。同样安装Node.js-v14.15.1，下载squoosh项目的zip包，解压到合适的位置，在cmd中进入该目录，但是这次执行以下命令：npm i -g @squoosh/cli这样的话会安装squoosh-cli，以后在cmd中用 squoosh-cli <options...> 就可以调用squoosh进行图片压缩。或者你也可以不运行 npm i -g @squoosh/cli 安装，而是直接用 npx @squoosh/cli <options...> 的格式来调用squoosh（推荐还是用 npm i -g @squoosh/cli 安装一下）。最后记得把squoosh-dev文件夹加到PATH中，就可以随时调用squoosh-cli。（3）squoosh的使用前两种方法安装squoosh或者网页访问 https://squoosh.app/ 时使用都差不多，这里主要介绍一下命令行使用 squoosh-cli 的参数。cmd输入 squoosh-cli -h 可显示以下信息：Usage: squoosh-cli [options] <files...> Options: -d, --output-dir Output directory (default: ".") -s, --suffix <suffix> Append suffix to output files (default: "") --max-optimizer-rounds <rounds> Maximum number of compressions to use for auto optimizations (default: "6") --optimizer-butteraugli-target <butteraugli distance> Target Butteraugli distance for auto optimizer (default: "1.4") --resize [config] Resize the image before compressing --quant [config] Reduce the number of colors used (aka. paletting) --rotate [config] Rotate image --mozjpeg [config] Use MozJPEG to generate a .jpg file with the given configuration --webp [config] Use WebP to generate a .webp file with the given configuration --avif [config] Use AVIF to generate a .avif file with the given configuration --jxl [config] Use JPEG-XL to generate a .jxl file with the given configuration --wp2 [config] Use WebP2 to generate a .wp2 file with the given configuration --oxipng [config] Use OxiPNG to generate a .png file with the given configuration -h, --help display help for command参考 这篇文章 ，使用以下参数：squoosh-cli --mozjpeg {quality:75,baseline:false,arithmetic:false,progressive:true,optimize_coding:true,smoothing:0,color_space:3,quant_table:3,trellis_multipass:false,trellis_opt_zero:false,trellis_opt_table:false,trellis_loops:1,auto_subsample:true,chroma_subsample:2,separate_chroma_quality:false,chroma_quality:75} "输入路径" -d "输出目录" -s "#test#"就能达到非常不错的压缩效果。你可以自己在squoosh网页里实验不同的参数，笔者只实验调整了一下quality。5.修改vimg_processor_multi修改了之前的vimg_processor_multi.py，调整了vimg_processor的compress_img方法，稍微修改了compress_imgs函数，最终成品如下（所有标注 #需要更改 的上一行是可能需要更改的地方）：#!/usr/bin/python3 # -*- coding: UTF-8 -*- import os, time, json, subprocess, re, traceback, redis, platform, sys from MyPythonLib import log as mylog from MyPythonLib import redis as myredis #CPU_THREADS = (12, 8) CPU_THREADS = (15, 10) # ffmpeg软编解码时线程数，根据CPU配置调整。分别为解码用的线程、编码用的线程，在压缩时，前者大于后者 #需要更改 # 可以用一个文件实验最佳的CPU_THREADS。软编解码的快慢看ffmpeg最右边的 speed=数字x NAS_mounted_path = 'D:\\' # NAS挂载路径。由于函数get_redis_rel_path_from_abs_path，最后一个分隔符必须带上 #需要更改 CWD = os.getcwd() r_pool = myredis.get_pool(host='192.168.1.1', port=56379) #需要更改 r_cur = redis.Redis(connection_pool=r_pool) log_path = os.path.join(CWD, 'logs') #需要更改 if not os.path.isdir(s=log_path): os.mkdir(path=log_path) log = mylog.get_logger(abs_path=log_path, log_file_name='vimg_processor_multi.log') TEMP_DIR = os.path.join(CWD, 'temp') # temp这个文件夹主要是在压缩图片时存放临时的调色板 #需要更改 if not os.path.isdir(s=TEMP_DIR): os.mkdir(path=TEMP_DIR) redis_key_set_todo = 'vimg_processor:set:todo' # 这个redis集合存放需要压缩的文件的相对路径（仅包含压缩前的文件名），路径分隔符为linux的/（下同） redis_key_set_wrong = 'vimg_processor:set:wrong' #集合，存放压缩时出现错误的文件的相对路径（仅包含压缩前的文件名） redis_key_str_ongoing = 'vimg_processor:str:ongoing:' # 存放正在压缩的文件的相对路径，键名包含压缩前的文件相对路径，值为压缩后的文件相对路径 redis_key_str_done = 'vimg_processor:str:done:' # 存放已压缩完的文件的相对路径，键名包含压缩前的文件相对路径，值为压缩后的文件相对路径 start_time = time.time() fail_msg_list = [] # 每次有错误信息时都放到这个列表中 sys_kind = platform.system() # 字符串，表示系统类型 def get_suffix(filename: str) -> str: '''返回文件后缀名。若文件无后缀名（以 . 结尾，或者不含 . ）则返回空字符串。 :param filename: 文件名（可带路径前缀） :return str，文件后缀名（不含 . ）''' suffix = os.path.splitext(filename)[-1] return (suffix[1:], '')[suffix == ''] def is_video(filename: str) -> bool: '''根据后缀名判断文件是否视频。 :param filename: 文件名（可带路径前缀） :return bool，True表示文件是视频，False表示非视频''' video_suffix_set = { "WMV", "ASF", "ASX", "RM", "RMVB", "MP4", "TS", "MPG", "3GP", "MOV", "M4V", "AVI", "MKV", "FIV", "VOB", "FLV", "MPEG", "ISO" } #需要更改 #iso文件不一定能用ffmpeg打开，可以自行取舍 return (False, True)[get_suffix(filename).upper() in video_suffix_set] def is_img(filename: str) -> bool: '''根据后缀名判断文件是否图片。 :param filename: 文件名（可带路径前缀） :return bool，True表示文件是图片，False表示非图片''' img_suffix_set = {"JPG", "JPEG", "BMP", "PNG", "WEBP"} #需要更改 return (False, True)[get_suffix(filename).upper() in img_suffix_set] def replace_path_seperator(path: str, sys_kind: str) -> str: '''根据操作系统，替换路径中的分隔符。 :param path: 需要替换的路径 :param sys_kind: 目标操作系统 :return str，替换好的路径''' if sys_kind == 'Windows': return path.replace('/', '\\') elif sys_kind == 'Linux': return path.replace('\\', '/') def is_origin(filename: str) -> bool: '''接受文件名，判断这个文件是源文件还是压缩后的文件。 :param filename: 文件名（可带路径前缀） :return bool，True表示该文件为源文件''' prefix = os.path.splitext(filename)[0] return (True, False)[re.search(r'#test#$', prefix) != None] def get_redis_rel_path_from_abs_path(abs_path: str) -> str: '''根据本机上文件的绝对路径，返回存储到redis中的相对路径。 :由于redis中存储的是使用linux分隔符的相对路径，所以需要这个函数转换： :将abs_path首先去掉NAS_mounted_path，然后分隔符换成linux的。由于这个函数，NAS_mounted_path最后需要带上一个分隔符 :param abs_path: 文件在本机的绝对路径 :return str，redis中存储的相对路径，即 XX/XX/XX 这样的形式。注意最前面没有分隔符''' global NAS_mounted_path redis_rel_path = abs_path[len(NAS_mounted_path):] return replace_path_seperator(redis_rel_path, sys_kind='Linux') def get_abs_path_from_redis_rel_path(redis_rel_path: str) -> str: '''根据redis中存储的相对路径，返回本机上该文件的绝对路径。 :将redis_rel_path连上NAS_mounted_path，然后替换分隔符为本机的 :param redis_rel_path: redis中存储的相对路径 :return str，文件在本机中的绝对路径''' global NAS_mounted_path, sys_kind abs_path = os.path.join(NAS_mounted_path, redis_rel_path) return replace_path_seperator(abs_path, sys_kind=sys_kind) def get_compressed_abs_path(abs_path: str, fmt: str = '') -> str: '''根据源文件的绝对路径，返回压缩后文件的绝对路径。这里的绝对路径都是本机上的。 :压缩后的图像或者视频将放在同路径下，所以需要给转换后的文件名中加上字符串#test#（在后缀名前）：1.mp4 -> 1#test#.mp4 :有些特殊情况下压缩后文件会重名，比如一个文件夹中同时存在1.mp4、1.rmvb时，又用fmt指定压缩后格式。此时会对压缩后的文件加上序号重命名（1#test#.mp4，1#test#(1).mp4，1#test#(2).mp4） :param abs_path: 需要压缩的文件的绝对路径 :param fmt: 指定转换后文件的格式，为空字符串时不改变格式 :return str，压缩后文件的绝对路径，根据操作系统调整''' prefix, suffix = os.path.splitext(abs_path) suffix = (fmt, suffix.lstrip('.'))[fmt == ''] compressed_abs_path = prefix + '#test#.' + suffix i = 1 while (os.path.exists(compressed_abs_path)): compressed_abs_path = prefix + '#test#(' + str(i) + ').' + suffix i += 1 return compressed_abs_path def get_origin_abs_path(abs_path: str) -> str: '''根据压缩后文件的绝对路径到redis中查询，返回源文件的绝对路径。这里的绝对路径都是本机上的。 :这个函数只应该用于已经压缩完的文件，函数将到redis_key_str_done的所有字符串中进行查询 :param abs_path: 压缩后文件的绝对路径 :return str，源文件的绝对路径，根据操作系统调整；若无法查询到，返回空字符串''' global r_cur, sys_kind, NAS_mounted_path, redis_key_str_done compressed_redis_rel_path = get_redis_rel_path_from_abs_path(abs_path) redis_rel_paths_keys = r_cur.keys(redis_key_str_done + '*') for redis_rel_path_key in redis_rel_paths_keys: if r_cur.get(redis_rel_path_key) == compressed_redis_rel_path: origin_abs_path = redis_rel_path_key[len(redis_key_str_done):] origin_abs_path = replace_path_seperator(origin_abs_path, sys_kind=sys_kind) origin_abs_path = os.path.join(NAS_mounted_path, origin_abs_path) return origin_abs_path return '' class vimg_processor(object): '''用于压缩的图像视频类。初始化时会读取图像视频的一些参数。''' def __init__(self, origin_abs_path: str, compressed_abs_path: str = "", video_size: str = '', img_max_pix: int = 0, fmt: str = ''): '''用文件的绝对路径等参数初始化对象。 :param origin_abs_path: 源文件的绝对路径 :param compressed_abs_path: 输出文件的绝对路径。默认为同目录下文件名末加#test#保存（1.mp4 -> 1#test#.mp4），有重名时会自动按 1#test#.mp4 、1#test#(1).mp4 、1#test#(2).mp4 的顺序重命名 :param video_size: 输出视频文件时，适配的屏幕大小。有 phone 、 pad 、 pc 、 空字符串 四种选择，空字符串表示保留原宽高 :param img_max_pix: 输出图片文件时，若源图片的宽高同时超过该值，宽高中较大的一个数值会被缩小到该值，另一个数值也等比例缩小；为0时，表示图片的宽高不缩小（宽高的单位为像素点） :param fmt: 输出文件的格式，即后缀名。为空字符串时，表示按原格式输出。其他可取值如 mp4 、 jpg 等''' self.origin_abs_path = origin_abs_path self.compressed_abs_path = (compressed_abs_path, get_compressed_abs_path( origin_abs_path, fmt=fmt))[compressed_abs_path == ""] self.width, self.height = 0, 1 if is_video(origin_abs_path): self.get_video_info() w, h = self.width, self.height ratio = w / h self.ratio = ratio definition = '' if video_size == 'phone': if ratio <= 0.6: definition = (f'{w}:{h}', '360:640')[w >= 360] elif ratio <= 1.0: definition = (f'{w}:{h}', '360:480')[w >= 360] elif ratio <= 1.66: definition = (f'{w}:{h}', '480:360')[w >= 480] elif ratio > 1.66: definition = (f'{w}:{h}', '640:360')[w >= 640] elif video_size == 'pad': if ratio <= 0.6: definition = (f'{w}:{h}', '720:1280')[w >= 720] elif ratio <= 1.0: definition = (f'{w}:{h}', '720:960')[w >= 720] elif ratio <= 1.66: definition = (f'{w}:{h}', '960:720')[w >= 960] elif ratio > 1.66: definition = (f'{w}:{h}', '1280:720')[w >= 1280] elif video_size == 'pc': if ratio <= 0.6: definition = (f'{w}:{h}', '1080:1920')[w >= 1080] elif ratio <= 1.0: definition = (f'{w}:{h}', '1080:1440')[w >= 1080] elif ratio <= 1.66: definition = (f'{w}:{h}', '1440:1080')[w >= 1440] elif ratio > 1.66: definition = (f'{w}:{h}', '1920:1080')[w >= 1920] elif video_size == '': definition = f'{w}:{h}' self.definition = definition elif is_img(origin_abs_path): self.pix_fmt = '' # pix_fmt即像素格式 fmt = (fmt, get_suffix(origin_abs_path))[fmt == ''] self.palette = os.path.join(TEMP_DIR, 'palette.' + fmt) self.temp_img = os.path.join(TEMP_DIR, 'temp_img.' + fmt) self.get_img_info() w, h = self.width, self.height img_max_pix = abs(img_max_pix) if img_max_pix == 0: pass elif w > img_max_pix and h > img_max_pix: ratio = max(w, h) / img_max_pix w, h = int(w / ratio), int(h / ratio) self.definition = f'{w}:{h}' def get_video_info(self): '''使用ffprobe获取视频的宽高（width、height）。''' global sys_kind command_probe = f'ffprobe -select_streams v -show_entries format=bit_rate -show_streams -v quiet -of csv="p=0" -of json -i "{self.origin_abs_path}"' if sys_kind == 'Windows': result = subprocess.Popen(command_probe, shell=False, stdout=subprocess.PIPE).stdout elif sys_kind == 'Linux': result = subprocess.Popen(command_probe, shell=True, stdout=subprocess.PIPE).stdout list_std = result.readlines() str_tmp = '' for item in list_std: str_tmp += bytes.decode(item.strip()) json_data = json.loads(str_tmp) self.width = int(json_data['streams'][0]['width']) self.height = int(json_data['streams'][0]['height']) # self.bitrate = int(json_data['format']['bit_rate']) / 1024 # 以后可能用到视频的比特率 # 视频的比特率有时不能用ffprobe读取到，建议自己根据视频大小和时长计算 def get_img_info(self): '''获取图片的width、height、pix_fmt。''' global sys_kind command_probe = f'ffprobe -hide_banner -v quiet -print_format json -show_format -show_streams "{self.origin_abs_path}"' if sys_kind == 'Windows': result = subprocess.Popen(command_probe, shell=False, stdout=subprocess.PIPE).stdout elif sys_kind == 'Linux': result = subprocess.Popen(command_probe, shell=True, stdout=subprocess.PIPE).stdout list_std = result.readlines() str_tmp = '' for item in list_std: str_tmp += bytes.decode(item.strip()) json_data = json.loads(str_tmp) self.pix_fmt = str(json_data['streams'][0]['pix_fmt']) self.width = int(json_data['streams'][0]['width']) self.height = int(json_data['streams'][0]['height']) def compress_video(self): '''通过ffmpeg压缩视频文件（输出的像素格式指定为yuv420p）。''' global CPU_THREADS # AMD显卡加速，硬编码 '''command_compress = 'ffmpeg -hwaccel dxva2 -i "{}" -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264_amf -b:a 64k -ar 44100 "{}"'.format( self.origin_abs_path, self.definition, self.compressed_abs_path)''' # NVIDIA显卡加速，硬编解码 '''command_compress = 'ffmpeg -hwaccel cuvid -c:v h264_cuvid -i "{}" -threads 8 -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264_nvenc -b:a 64k -ar 44100 "{}"'.format( self.origin_abs_path, self.definition, self.compressed_abs_path)''' # CPU软编解码。-threads 参数可限制线程数，控制CPU占用。 command_compress = 'ffmpeg -threads {} -i "{}" -threads {} -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264 -b:a 64k -ar 44100 "{}"'.format( CPU_THREADS[0], self.origin_abs_path, CPU_THREADS[1], self.definition, self.compressed_abs_path) result = os.system(command_compress) if result != 0: raise Exception( f'vimg_processor.compress_video failed，视频压缩失败：{command_compress}' ) def compress_img_ffmpeg(self): '''通过ffmpeg压缩图像文件。''' command_palette = 'ffmpeg -i "{}" -vf palettegen=max_colors=256:stats_mode=single -y "{}"'.format( self.origin_abs_path, self.palette) command_compress = 'ffmpeg -i "{}" -i "{}" -lavfi "[0][1:v] paletteuse" -pix_fmt "{}" -y "{}"'.format( self.origin_abs_path, self.palette, self.pix_fmt, self.temp_img) result = os.system(command_palette) if result != 0: raise Exception( f'vimg_processor.compress_img_ffmpeg failed，无法获得调色板：{command_palette}' ) result = os.system(command_compress) if result != 0: raise Exception( f'vimg_processor.compress_img_ffmpeg failed，生成中间图片失败：{command_compress}' ) command_compress = 'ffmpeg -i "{}" -vf scale={} -y "{}"'.format( self.temp_img, self.definition, self.compressed_abs_path) result = os.system(command_compress) if result != 0: raise Exception( f'vimg_processor.compress_img_ffmpeg failed，生成最终图片失败：{command_compress}' ) def compress_img_squoosh(self, quality: int = 75): '''通过squoosh-cli压缩图像文件。\n安装squoosh-cli见https://lisper517.top/index.php/archives/60/ :param quality: 压缩图片的品质，取值范围为0-100，详见squoosh的说明''' basename_origin = os.path.basename(self.origin_abs_path) abs_dir = self.origin_abs_path[:-len(basename_origin)] basename_compressed = os.path.basename(self.compressed_abs_path) prefix_origin = os.path.splitext(basename_origin)[0] prefix_compressed = os.path.splitext(basename_compressed)[0] suffix = prefix_compressed[len(prefix_origin):] fmt = get_suffix(basename_compressed).upper() if fmt == 'JPG': fmt = 'mozjpeg' elif fmt == 'PNG': fmt = 'oxipng' elif fmt == 'WEBP': fmt = 'webp' command_compress = '''squoosh-cli --{} {{quality:{},baseline:false,arithmetic:false,progressive:true,optimize_coding:true,smoothing:0,color_space:3,quant_table:3,trellis_multipass:false,trellis_opt_zero:false,trellis_opt_table:false,trellis_loops:1,auto_subsample:true,chroma_subsample:2,separate_chroma_quality:false,chroma_quality:75}} "{}" -d "{}" -s "{}"'''.format( fmt, quality, self.origin_abs_path, abs_dir, suffix) result = os.system(command_compress) if result != 0: raise Exception( f'vimg_processor.compress_img_squoosh failed，生成最终图片失败：{command_compress}' ) '''------------------------------------------------------------主要功能区------------------------------------------------------------''' '''------------------------------------------------------------主要功能区------------------------------------------------------------''' '''------------------------------------------------------------主要功能区------------------------------------------------------------''' def get_definitions_video(target_dir: str): '''获取一个文件夹下所有视频文件的分辨率，按宽高比进行分类。 :param target_dir: 目标文件夹的绝对路径 :结果会输出到当前目录下的 definitions.txt 文件中''' global CWD ratio_dict = dict() # 字典，键为宽高的比值，值为列表，包括该比值下的具体宽高数值 # 例：1.7777778:['640x360', '1280x720', '1920x1080'] for dirpath, dirnames, filenames in os.walk(target_dir): for filename in filenames: abs_path = os.path.join(dirpath, filename) if is_video(abs_path): print('\r视频文件：{:200}'.format(abs_path), end='') vimg = vimg_processor(abs_path) ratio = vimg.ratio definition = "{}x{}".format(vimg.width, vimg.height) if ratio_dict.get(ratio) == None: ratio_dict[ratio] = [definition] else: if definition not in ratio_dict[ratio]: ratio_dict[ratio].append(definition) # 按宽高比值升序输出 ratio_list = [] for key, value in ratio_dict.items(): ratio_list.append(key) ratio_list.sort() with open(os.path.join(CWD, 'definitions.txt'), 'w', encoding='utf8') as f: for ratio in ratio_list: ratio_dict[ratio].sort() f.write('宽高比：{:.6}，成员：'.format(ratio)) for definition in ratio_dict[ratio][:-1]: f.write(definition + ' , ') f.write(ratio_dict[ratio][-1] + '\n') print('') def compress_videos(video_size: str = '', fmt: str = ''): '''从redis中得到需要压缩的视频文件，进行压缩，并完成压缩；直到redis中没有剩余任务。 :对应地，该项目会在redis的key中移动，从todo到ongoing，最后到done；若出错，则放到wrong。 :输出文件在源文件名末尾加 #test# 放在同目录下（1.mp4 -> 1#test#.mp4）；输出文件有重名时会自动按 1#test#.mp4 、1#test#(1).mp4 、1#test#(2).mp4 的顺序重命名。 :param video_size: 视频适配的屏幕大小。 phone、pad、pc、空字符串 分别表示适配手机、平板、电脑、不改变原大小 :param fmt: 指定输出的文件格式，空字符串表示原格式。建议用mp4 ''' global NAS_mounted_path, r_cur, log, start_time, fail_msg_list, sys_kind global redis_key_set_todo, redis_key_set_wrong, redis_key_str_ongoing, redis_key_str_done msg = '开始时间：{}'.format(time.strftime('%Y-%m-%d %H:%M:%S')) mylog.log_print(log, 'info', msg) while (True): if r_cur.scard(redis_key_set_todo) == 0: break try: redis_rel_path = r_cur.spop(redis_key_set_todo, count=1)[0] rel_path = replace_path_seperator(redis_rel_path, sys_kind=sys_kind) abs_path = os.path.join(NAS_mounted_path, rel_path) if is_video(abs_path) and os.path.isfile(abs_path): vimg = vimg_processor(origin_abs_path=abs_path, video_size=video_size, fmt=fmt) compressed_abs_path = vimg.compressed_abs_path if os.path.exists(compressed_abs_path): # 输出文件已存在时跳过 continue start_time = time.time() log.info('{}压缩中...'.format(abs_path)) compressed_redis_rel_path = get_redis_rel_path_from_abs_path( compressed_abs_path) r_cur.set(redis_key_str_ongoing + redis_rel_path, compressed_redis_rel_path) vimg.compress_video() r_cur.delete(redis_key_str_ongoing + redis_rel_path) r_cur.set(redis_key_str_done + redis_rel_path, compressed_redis_rel_path) log.info('视频压缩成功，耗时{:.2f}min'.format( (time.time() - start_time) / 60)) except: except_info = traceback.format_exc() fail_msg_list.append(except_info) mylog.log_print(log, 'error', except_info) if os.path.exists(compressed_abs_path): os.remove(compressed_abs_path) r_cur.srem(redis_key_set_todo, redis_rel_path) r_cur.delete(redis_key_str_ongoing + redis_rel_path) r_cur.delete(redis_key_str_done + redis_rel_path) exc_type, exc_value, exc_traceback = sys.exc_info() # 获得异常名 exc_type = exc_type.__name__ if exc_type == 'KeyboardInterrupt': # 在linux上时，无法得到KeyboardInterrupt r_cur.sadd(redis_key_set_todo, redis_rel_path) else: mylog.log_print(log, 'error', f'错误类型为{exc_type}') r_cur.sadd(redis_key_set_wrong, redis_rel_path) break def compress_imgs(compressor: str = 'squoosh', fmt: str = '', img_max_pix: int = 0, quality: int = 75): '''从redis中得到需要压缩的图片文件，进行压缩，并完成压缩；直到redis中没有剩余任务。 :对应地，该项目会在redis的key中移动，从todo到ongoing，最后到done；若出错，则放到wrong。 :输出文件在源文件名末尾加 #test# 放在同目录下（1.jpg -> 1#test#.jpg）；输出文件有重名时会自动按 1#test#.jpg 、1#test#(1).jpg 、1#test#(2).jpg 的顺序重命名。 :param compressor: 指定压缩的方式。可选择 ffmpeg 或 squoosh ，推荐后者 :param fmt: 指定输出的文件格式，空字符串表示原格式。建议用jpg，目前squoosh只支持jpg、png、webp :param img_max_pix: （仅在ffmpeg时可用）输出图片文件时，若源图片的宽高同时超过该值，宽高中较大的一个数值会被缩小到该值，另一个数值也等比例缩小；为0时，表示图片的宽高不缩小（宽高的单位为像素点） :使用squoosh时，图片会保留原有尺寸 :param quality: （仅在squoosh时可用）压缩图片的品质，取值范围为0-100，详见squoosh的说明 ''' global NAS_mounted_path, TEMP_DIR, r_cur, log, start_time, fail_msg_list, sys_kind global redis_key_set_todo, redis_key_set_wrong, redis_key_str_ongoing, redis_key_str_done msg = '开始时间：{}'.format(time.strftime('%Y-%m-%d %H:%M:%S')) mylog.log_print(log, 'info', msg) while (True): if r_cur.scard(redis_key_set_todo) == 0: break try: redis_rel_path = r_cur.spop(redis_key_set_todo, count=1)[0] rel_path = replace_path_seperator(redis_rel_path, sys_kind=sys_kind) abs_path = os.path.join(NAS_mounted_path, rel_path) if is_img(abs_path) and os.path.isfile(abs_path): vimg = vimg_processor(origin_abs_path=abs_path, img_max_pix=img_max_pix, fmt=fmt) compressed_abs_path = vimg.compressed_abs_path if os.path.exists(compressed_abs_path): # 输出文件已存在时跳过 continue log.info('{}压缩中...'.format(abs_path)) compressed_redis_rel_path = get_redis_rel_path_from_abs_path( compressed_abs_path) r_cur.set(redis_key_str_ongoing + redis_rel_path, compressed_redis_rel_path) if compressor == 'ffmpeg': vimg.compress_img_ffmpeg() elif compressor == 'squoosh': vimg.compress_img_squoosh(quality=quality) r_cur.delete(redis_key_str_ongoing + redis_rel_path) r_cur.set(redis_key_str_done + redis_rel_path, compressed_redis_rel_path) except: except_info = traceback.format_exc() fail_msg_list.append(except_info) mylog.log_print(log, 'error', except_info) if os.path.exists(compressed_abs_path): os.remove(compressed_abs_path) r_cur.srem(redis_key_set_todo, redis_rel_path) r_cur.delete(redis_key_str_ongoing + redis_rel_path) r_cur.delete(redis_key_str_done + redis_rel_path) r_cur.sadd(redis_key_set_wrong, redis_rel_path) exc_type, exc_value, exc_traceback = sys.exc_info() # 获得异常名 exc_type = exc_type.__name__ if exc_type == 'KeyboardInterrupt': # 在linux上时，无法得到KeyboardInterrupt r_cur.sadd(redis_key_set_todo, redis_rel_path) else: mylog.log_print(log, 'error', f'错误类型为{exc_type}') r_cur.sadd(redis_key_set_wrong, redis_rel_path) break # 最后清理一下TEMP_DIR for dirpath, dirnames, filenames in os.walk(TEMP_DIR): for filename in filenames: os.remove(os.path.join(dirpath, filename)) def delete_test(): '''视频或图片压缩完毕后，比较原文件与压缩后的文件，留下较小且大小非0的文件。 :由于ffmpeg处理效果不确定，建议检查一下处理后的文件，确认无误后再执行，因为源文件会被彻底删除。具体见 下载-压缩-入库流程 :将到redis的redis_key_str_done中读取压缩前后的文件相对路径。删除#test#文件后，删除redis_key_str_done对应的键 ''' global r_cur, log, fail_msg_list global redis_key_str_done redis_rel_path_done_keys = r_cur.keys(redis_key_str_done + '*') for redis_rel_path_done_key in redis_rel_path_done_keys: origin_redis_rel_path = redis_rel_path_done_key[len(redis_key_str_done ):] compressed_redis_rel_path = r_cur.get(redis_rel_path_done_key) origin_abs_path = get_abs_path_from_redis_rel_path( origin_redis_rel_path) compressed_abs_path = get_abs_path_from_redis_rel_path( compressed_redis_rel_path) # redis中存储的相对路径都是 压缩中 ，但是现在这些文件已经手动放到了 检查中 里 origin_abs_path = origin_abs_path.replace('压缩中', '检查中', 1) #需要更改 compressed_abs_path = compressed_abs_path.replace('压缩中', '检查中', 1) #需要更改 if os.path.isfile(origin_abs_path) and os.path.isfile( compressed_abs_path): before_size = os.path.getsize(origin_abs_path) after_size = os.path.getsize(compressed_abs_path) if after_size < before_size and after_size != 0: try: os.remove(origin_abs_path) # 注意这里的压缩后文件名改回原名时是把最右边的第一个#test#去掉，而不是直接改成源文件名 # 因为如果压缩时用fmt指定输出格式，则可能当前目录下有重名文件 # 1.avi、1.wmv -> 1#test#.mp4、1#test#(1).mp4 -> 1.mp4、1(1).mp4 prefix, suffix = os.path.splitext(compressed_abs_path) reversed_prefix = prefix[::-1] reversed_prefix = reversed_prefix.replace('#tset#', '', 1) prefix = reversed_prefix[::-1] temp = prefix + suffix os.rename(compressed_abs_path, temp) r_cur.delete(redis_rel_path_done_key) log.info(f'将{origin_abs_path}替换为小文件') except PermissionError: r_cur.set(redis_rel_path_done_key, compressed_redis_rel_path) msg = f'delete_test failed，删除失败：{origin_abs_path}' mylog.log_print(log, 'warning', msg) fail_msg_list.append(msg) continue else: os.remove(compressed_abs_path) msg = f'{origin_abs_path}压缩后的文件比之前大（或大小为0），删掉了处理后的文件' log.info(msg) # 最后检查一下有没有剩余的处理后文件 dir_checking = replace_path_seperator('xxx/xxx/检查中', sys_kind=sys_kind) #需要更改 dir_checking = os.path.join(NAS_mounted_path, dir_checking) for dirpath, dirnames, filenames in os.walk(dir_checking): for filename in filenames: if '#test#' in filename: abs_path = os.path.join(dirpath, filename) msg = f'疑似还有#test#文件：{abs_path}' mylog.log_print(log, 'warning', msg) fail_msg_list.append(msg) def main(): '''执行入口。''' target_dir = r'D:\xxx\xxx\压缩中' #需要更改 global sys_kind try: #get_definitions_video(target_dir) #compress_videos(video_size='pad', fmt='mp4') compress_imgs(compressor='squoosh', fmt='jpg', quality=75) #delete_test() print('已完成') if len(fail_msg_list) != 0: print('本次压缩时出现了以下异常：') for i in fail_msg_list: print(i) if sys_kind == 'Windows': input() except: except_info = traceback.format_exc() mylog.log_print(log, 'error', except_info) if sys_kind == 'Windows': input() if __name__ == '__main__': main() '''下载-压缩-入库流程： 1.将需要压缩的文件先暂时放到 待压缩 中，进行人工筛选 2.筛选完后，将 待压缩 中的文件手动剪切到 压缩中 文件夹里 3.直连NAS的机器运行python脚本，更新redis库、移动文件 4.其他连接到NAS的机器从redis中获取任务、进行压缩 5.从 压缩完 中将文件剪切到 检查中 里，进行人工检查 6.检查完后，手动运行vimg_processor_multi里的delete_test，保留源文件或压缩后文件中的一个 7.手动将 检查中 里的文件放到其他目录，比如 待迁移 或 入库中 里 8.运行file_to_sql，将本地文件入库 '''如前所述，这是一个NAS、多电脑协作压缩的版本，如果只是在本地压缩图片的话可以自行修改。