代码解析深度学习系统编程模型:TensorFlow vs. CNTK
from: http://geek.csdn.net/news/detail/62429
CNTK是微軟用于搭建深度神經網絡的計算網絡工具包,此項目已在Github上開源。因為我最近寫了關于TensorFlow的文章,所以想比較一下這兩個系統的相似和差異之處。畢竟,CNTK也是許多圖像識別挑戰賽的衛冕冠軍。為了內容的完整性,我應該也對比一下Theano、Torch和Caffe。后三者也是現在非常流行的框架。但是本文僅限于討論CNTK和TensorFlow,其余的框架將在今后討論。Kenneth Tran對這五個深度學習工具包做過一次高水平(以他個人觀點)的分析。本文并不是一個CNTK或者TensorFlow的使用教程。我的目的在于從程序員的角度對它們做高層次的對比。本文也不屬于性能分析,而是編程模型分析。文中會夾雜著大量的代碼,如果你討厭閱讀代碼,請直接跳到結論部分。
CNTK有一套極度優化的運行系統來訓練和測試神經網絡,它是以抽象的計算圖形式構建。如此看來,CNTK和TensorFlow長得非常相似。但是,它們有一些本質上的區別。為了演示這些特性和區別,我會用到兩個標準示例,它們分別包括了兩個系統及調用各自系統完成的任務。第一個例子是用較淺的卷積神經網絡來解決標準的MNIST手寫數字集的識別任務。我會針對它們兩種遞歸神經網絡方法的差異性做一些點評總結。
TensorFlow和CNTK都屬于腳本驅動型的。我的意思是說神經網絡構建的流程圖都是在一個腳本里完成,并調用一些智能的自動化步驟完成訓練。TensorFlow的腳本是與Python語言捆綁的,Python操作符能夠用來控制計算圖的執行過程。CNTK目前還沒有和Python或是C++綁定(盡管已經承諾過),所以它目前訓練和測試的流程控制還是需要精心編制設計的。等會我將展示,這個過程并不能算是一種限制。CNTK網絡需要用到兩個腳本:一個控制訓練和測試參數的配置文件和一個用于構建網絡的網絡定義語言(Network Definition Language, NDL)文件。
我會首先描述神經網絡的流程圖,因為這是與TensorFlow最相似之處。CNTK支持兩種方式來定義網絡。一種是使用“Simple Network Builder”,只需設置幾個參數就能生成一個簡單的標準神經網絡。另一種是使用網絡定義語言(NDL)。此處例子(直接從Github下載的)使用的是NDL。下面就是Convolution.ndl文件的縮略版本。(為了節省頁面空間,我把多行文件合并到同一行,并用逗號分隔)
CNTK網絡圖有一些特殊的節點。它們是描述輸入數據和訓練標簽的FeatureNodes和LabelNodes,用來評估訓練結果的CriterionNodes和EvalNodes,和表示輸出的OutputNodes。當我們在下文中遇到它們的時候我再具體解釋。在文件頂部還有一些用來加載數據(特征)和標簽的宏定義。如下所示,我們將MNIST數據集的圖像作為特征讀入,經過歸一化之后轉化為若干浮點數組。得到的數組“featScaled”將作為神經網絡的輸入值。
load = ndlMnistMacros# the actual NDL that defines the network run = DNNndlMnistMacros = [imageW = 28, imageH = 28labelDim = 10features = ImageInput(imageW, imageH, 1)featScale = Const(0.00390625)featScaled = Scale(featScale, features)labels = Input(labelDim) ]DNN=[# conv1kW1 = 5, kH1 = 5cMap1 = 16hStride1 = 1, vStride1 = 1conv1_act = ConvReLULayer(featScaled,cMap1,25,kW1,kH1,hStride1,vStride1,10, 1)# pool1pool1W = 2, pool1H = 2pool1hStride = 2, pool1vStride = 2pool1 = MaxPooling(conv1_act, pool1W, pool1H, pool1hStride, pool1vStride)# conv2kW2 = 5, kH2 = 5cMap2 = 32hStride2 = 1, vStride2 = 1conv2_act = ConvReLULayer(pool1,cMap2,400,kW2, kH2, hStride2, vStride2,10, 1)# pool2pool2W = 2, pool2H = 2pool2hStride = 2, pool2vStride = 2pool2 = MaxPooling(conv2_act, pool2W, pool2H, pool2hStride, pool2vStride)h1Dim = 128h1 = DNNSigmoidLayer(512, h1Dim, pool2, 1)ol = DNNLayer(h1Dim, labelDim, h1, 1)ce = CrossEntropyWithSoftmax(labels, ol)err = ErrorPrediction(labels, ol)# Special NodesFeatureNodes = (features)LabelNodes = (labels)CriterionNodes = (ce)EvalNodes = (err)OutputNodes = (ol) ]DNN小節定義了網絡的結構。此神經網絡包括了兩個卷積-最大池化層,接著是有一個128節點隱藏層的全連接標準網絡。
在卷積層I 我們使用5x5的卷積核函數,并且在參數空間定義了16個(cMap1)。操作符ConvReLULayer實際上是在宏文件中定義的另一個子網絡的縮寫。
在計算時,我們想把卷積的參數用矩陣W和向量B來表示,那么如果輸入的是X,網絡的輸出將是f(op(W, X) + B)的形式。在這里操作符op就是卷積運算,f是標準規則化函數relu(x)=max(x,0)。
ConvReLULayer的NDL代碼如下圖所示:
ConvReLULayer(inp, outMap, inWCount, kW, kH, hStride, vStride, wScale, bValue) = [convW = Parameter(outMap, inWCount, init="uniform", initValueScale=wScale)convB = Parameter(outMap, 1, init="fixedValue", value=bValue)conv = Convolution(convW, inp, kW, kH, outMap, hStride,vStride,zeroPadding=false)convPlusB = Plus(conv, convB);act = RectifiedLinear(convPlusB); ]矩陣W和向量B是模型的參數,它們會被賦予一個初始值,并在訓練的過程中不斷更新直到生成最終模型。這里,convW是一個16行25列的矩陣,B是長度為16的向量。Convolution是內置的卷積函數,默認不使用補零的方法。也就是說對28x28的圖像做卷積運算,實際上只是對24x24的中心區域操作,得到的結果是16個24x24的sudo-image。
接著我們用2x2的區域應用最大池化操作,最后得到的結果是16個12x12的矩陣。
對于第二個卷積層,我們把卷積濾波器的個數由16個提升到32個。這一次我們有16通道的輸入數據,因此W矩陣的尺寸為32行25×16 = 400列,向量B的長度為32。這次的卷積運算針對12x12圖像幀的中心區域,所以得到的結果是32個8x8的矩陣。第二次池化操作的結果是32個4x4的幀,或者32x16=512。
最后兩層,是由512個池化輸出結果經過128個節點的隱藏層連接到10個輸出節點,經歷了兩次運算操作。
DNNSigmoidLayer(inDim, outDim, x, parmScale) = [W = Parameter(outDim, inDim, init="uniform", initValueScale=parmScale)b = Parameter(outDim, 1, init="uniform", initValueScale=parmScale)t = Times(W, x)z = Plus(t, b)y = Sigmoid(z) ]DNNLayer(inDim, outDim, x, parmScale) = [W = Parameter(outDim, inDim, init="uniform", initValueScale=parmScale)b = Parameter(outDim, 1, init="uniform", initValueScale=parmScale)t = Times(W, x)z = Plus(t, b) ]如你所見,這些運算步驟都是標準的線性代數運算形式W*x+b。
圖定義的最后部分是交叉熵和誤差節點,以及將它們綁定到特殊的節點名稱。
我們接著要來定義訓練的過程,但是先把它與用TensorFlow構建相似的網絡模型做個比較。我們在之前的文章里討論過這部分內容,這里再討論一次。你是否注意到我們使用了與CNTK相同的一組變量,只不過這里我們把它稱作變量,而在CNTK稱作參數。維度也略有不同。盡管卷積濾波器都是5x5,在CNTK我們前后兩級分別使用了16個和32個濾波器,但是在TensorFlow的例子里我們用的是32個和64個。
def weight_variable(shape, names):initial = tf.truncated_normal(shape, stddev=0.1)return tf.Variable(initial, name=names)def bias_variable(shape, names):initial = tf.constant(0.1, shape=shape)return tf.Variable(initial, name=names)x = tf.placeholder(tf.float32, [None, 784], name="x")sess = tf.InteractiveSession()W_conv1 = weight_variable([5, 5, 1, 32], "wconv") b_conv1 = bias_variable([32], "bconv") W_conv2 = weight_variable([5, 5, 32, 64], "wconv2") b_conv2 = bias_variable([64], "bconv2") W_fc1 = weight_variable([7 * 7 * 64, 1024], "wfc1") b_fc1 = bias_variable([1024], "bfcl") W_fc2 = weight_variable([1024, 10], "wfc2") b_fc2 = bias_variable([10], "bfc2")網絡的構建過程也大同小異。
def conv2d(x, W):return tf.nn.conv2d(x, W, strides=[1, 1, 1, 1], padding='SAME')def max_pool_2x2(x):return tf.nn.max_pool(x, ksize=[1, 2, 2, 1],strides=[1, 2, 2, 1], padding='SAME')#first convolutional layer x_image = tf.reshape(x, [-1,28,28,1]) h_conv1 = tf.nn.relu(conv2d(x_image, W_conv1) + b_conv1) h_pool1 = max_pool_2x2(h_conv1) #second convolutional layer h_conv2 = tf.nn.relu(conv2d(h_pool1, W_conv2) + b_conv2) h_pool2 = max_pool_2x2(h_conv2) #final layer h_pool2_flat = tf.reshape(h_pool2, [-1, 7*7*64]) h_fc1 = tf.nn.relu(tf.matmul(h_pool2_flat, W_fc1) + b_fc1) h_fc1_drop = tf.nn.dropout(h_fc1, keep_prob) y_conv=tf.nn.softmax(tf.matmul(h_fc1_drop, W_fc2) + b_fc2)卷積運算的唯一不同之處是這里定義了補零,因此第一次卷積運算的輸出是28x28,經過池化后,降為14x14。第二次卷積運算和池化之后的結果降為了7x7,所以最后一層的輸入是7x7x64 = 3136維,有1024個隱藏節點(使用relu而不是sigmoid函數)。(在訓練時,最后一步用到了dropout函數將模型數值隨機地置零。如果keep_prob=1則忽略這步操作。)
網絡訓練
CNTK中設置網絡模型訓練的方式與TensorFlow差別巨大。訓練和測試步驟是在一個convolution.config的文件內設置。CNTK和TensorFlow都是通過符號化分析流程圖來計算梯度下降訓練算法中所用到的梯度值。CNTK組給出了一本非常贊的“書”來闡述梯度是如何計算的。現階段CNTK只支持一種學習方法:Mini-batch隨機梯度下降法,但他們承諾今后加入更多的算法。He, Zhang, Ren 和 Sun發表了一篇優秀的論文介紹他們是如何用嵌套殘留還原法(nested residual reduction)來訓練極度深層(深達1000層)的網絡模型,所以讓我們拭目以待這個方法被融入到CNTK中。配置文件的縮略版如下所示。
command = train:test modelPath = "$ModelDir$/02_Convolution" ndlMacros = "$ConfigDir$/Macros.ndl"train = [action = "train"NDLNetworkBuilder = [networkDescription = "$ConfigDir$/02_Convolution.ndl"]SGD = [epochSize = 60000minibatchSize = 32learningRatesPerMB = 0.5momentumPerMB = 0*10:0.7maxEpochs = 15]reader = [readerType = "UCIFastReader"file = "$DataDir$/Train-28x28.txt"features = [dim = 784start = 1]labels = [# details deleted]] ] test = […. ]命令行顯示了執行的順序:先訓練后測試。先聲明了各種文件的路徑,然后訓練模塊設置了待訓練的網絡模型以及隨機梯度下降(SGD)的參數。讀取模塊根據NDL文件中的設置讀取了“特征”和“標簽”數據。測試模塊設置了用于測試的參數。
16核(沒有GPU)的Linux VM需要消耗62.95分鐘來執行訓練和測試過程,999.01分鐘的用戶時間和4分鐘的系統時間。用戶時間指的是所有16個核都在滿負荷運轉(999/63 = 15.85)。但這并不算什么,因為CNTK是為并行計算而設計的,大規模GPU支持才是真正的設計點。
TensorFlow的訓練步驟在Python控制流程中設置得更清晰。而使用的算法Adam也是基于梯度計算的,由Kingma和Ba發明。TensorFlow的函數庫里有大量基于梯度的優化方法,但我沒有嘗試其它的方法。
如下所以,cross_entropy是按照標準形式定義的,然后傳入優化器生成一個 “train_step”對象。
y_ = tf.placeholder(tf.float32, [None, 10]) cross_entropy = -tf.reduce_sum(y_*tf.log(y_conv)) train_step = tf.train.AdamOptimizer(1e-4).minimize(cross_entropy) correct_prediction = tf.equal(tf.argmax(y_conv,1), tf.argmax(y_,1)) accuracy = tf.reduce_mean(tf.cast(correct_prediction, "float")) sess.run(tf.initialize_all_variables()) for i in range(20000):batch = mnist.train.next_batch(50)train_step.run(feed_dict={x: batch[0], y_: batch[1], keep_prob: 0.5})print("test accuracy %g"%accuracy.eval(feed_dict={ x: mnist.test.images, y_: mnist.test.labels, keep_prob: 1.0}))隨后Python腳本每批處理50條數據,以50%的舍棄率執行train_step,迭代20000次。測試步驟在整個測試集上評估準確率。
除了巧妙的自動求積分和Adam優化器的構建,一切都是直截了當的。我在16核的服務器上用CNTK例子中相同的數據集又跑了一遍。出乎我意料的是所需的時間與CNTK幾乎一模一樣。實際運行時間是62.02分鐘,用戶時間為160.45分鐘,所以幾乎沒用利用并行運算。我覺得這些數字不能說明什么。CNTK和TensorFlow都是為大規模GPU運算而設計的,它們運行的訓練算法并不完全一致。
遞歸神經網絡在CNTK和TensorFlow的實現
遞歸神經網絡(RNNs)在語言建模方面用途廣泛,例如打字時預測下一個輸入單詞,或是用于自動翻譯系統。(更多例子請參見Andrej Karpathy的博客)真是個有趣的想法。系統的輸入是一個單詞(或者一組單詞)以及系統基于目前所出現單詞而更新的狀態,輸出的是一個預測單詞列表和系統的新狀態,如圖1所示。
當然,RNN還有許多變種形式。其中一種常見的形式是長短期記憶模型(LSTM),其定義公式如下:
此處 \sigma 表示sigmoid函數。
如果你有興趣讀一篇關于LSTM及其工作原理的博文,我推薦Christopher Olah所寫的這篇。他繪制了一張示意圖,使得上面的等式更容易理解。我把它稍作修改,使它符合CNTK版本的方程式,結果如下圖所示。
圖中使用了sigmoid和tanh函數,并且級聯變量得到了下面的表達式:
其中W和b是學習得到的權重。
CNTK版本
下面是為LSTM模型設置的NDL。有兩件事需要注意。一個是網絡模型中使用了一個稱作“PastValue”的延遲操作符直接處理了遞歸的邏輯,它用到了維度和延遲時間兩個變量,返回該值的一個副本。第二件事情是注意W矩陣的處理方式,它與上面以及圖3中所示的級聯操作有何區別。在這里,它們把屬于x和h的所有W壓入堆棧,把所有b值也存入堆棧。然后計算一個W*x和一個W*h并求和,再加上b的值。然后再使用一個行切分操作符,分別用獨立的sigmoid函數處理它們。還需關注的是針對c的W矩陣都是對角陣。
LSTMPComponent(inputDim, outputDim, cellDim, inputx, cellDimX2, cellDimX3, cellDimX4) = [wx = Parameter(cellDimX4, inputDim, init="uniform", initValueScale=1);b = Parameter(cellDimX4, 1, init="fixedValue", value=0.0);Wh = Parameter(cellDimX4, outputDim, init="uniform", initValueScale=1);Wci = Parameter(cellDim, init="uniform", initValueScale=1);Wcf = Parameter(cellDim, init="uniform", initValueScale=1);Wco = Parameter(cellDim, init="uniform", initValueScale=1);dh = PastValue(outputDim, output, timeStep=1);dc = PastValue(cellDim, ct, timeStep=1);wxx = Times(wx, inputx);wxxpb = Plus(wxx, b);whh = Times(wh, dh);wxxpbpwhh = Plus(wxxpb,whh)G1 = RowSlice(0, cellDim, wxxpbpwhh)G2 = RowSlice(cellDim, cellDim, wxxpbpwhh)G3 = RowSlice(cellDimX2, cellDim, wxxpbpwhh);G4 = RowSlice(cellDimX3, cellDim, wxxpbpwhh);Wcidc = DiagTimes(Wci, dc);it = Sigmoid (Plus ( G1, Wcidc));bit = ElementTimes(it, Tanh( G2 ));Wcfdc = DiagTimes(Wcf, dc);ft = Sigmoid( Plus (G3, Wcfdc));bft = ElementTimes(ft, dc);ct = Plus(bft, bit);Wcoct = DiagTimes(Wco, ct);ot = Sigmoid( Plus( G4, Wcoct));mt = ElementTimes(ot, Tanh(ct));Wmr = Parameter(outputDim, cellDim, init="uniform", initValueScale=1);output = Times(Wmr, mt); ]TensorFlow版本
TensorFlow版本的LSTM遞歸神經網絡模型與CNTK版本完全不同。盡管它們所執行的操作符都一樣,但TensorFlow的表示方式充分發揮了Python控制流的作用。這個概念模型非常簡單。我們創建了一個LSTM單元,并且定義一個“狀態”作為此單元的輸入,同時也是此單元的輸出。偽代碼如下所示:
cell = rnn_cell.BasicLSTMCell(lstm_size) # Initial state of the LSTM memory. state = tf.zeros([batch_size, lstm.state_size])for current_batch_of_words in words_in_dataset:# The value of state is updated after processing each batch of words. output, state = cell(current_batch_of_words, state)這段摘自教程的偽代碼版本很好地反映了圖1的內容。折磨人的地方在于微妙細節的處理。記住大部分時間TensorFlow的python代碼是在搭建流程圖,所以我們需要下一點功夫來繪制用于訓練和執行的循環流程圖。
這里最大的挑戰在于如何在一個循環內創建并重復使用權重矩陣和偏置向量。CNTK使用了“PastValue”操作符來創建所需的循環。TensorFlow則使用了上面提到的所謂遞歸機制,和一個非常聰明的變量保存和調用機制來完成同樣的任務。“PastValue”在TensorFlow中對應的是一個函數, tf.get_variable( “name”, size, initializer = None) ,它的行為取決于當前變量域中的“reuse”這個標志位。如果reuse==False而且在當時不存在其它的同名變量,那么get_variable 用那個變量名返回一個新的變量,并用初始化器對其初始化。否則將會返回錯誤。如果reuse == True,那么get_variable返回之前已經存在的那個變量。如果不存在這樣的變量,則返回一個錯誤。
為了演示這種用法,以下是TensorFlow的一個函數的簡化版本,用來創建上面等式一的sigmoid函數。它只是W*x+b 的一個演化版本,其中x是一個list[a,b,c,…]
def linear(args, output_size, scope=None):#Linear map: sum_i(args[i] * W[i]), where W[i] is a variable.with vs.variable_scope(scope):matrix = vs.get_variable("Matrix", [total_arg_size, output_size])res = math_ops.matmul(array_ops.concat(1, args), matrix)bias_term = vs.get_variable("Bias", [output_size],initializer=init_ops.constant_initializer(1.))return res + bias_term接下來定義BasicLSTMCell,大致的寫法如下所示。(想要查看這些函數的完整版本,請前往TensorFlow Github代碼庫里的rnn_cell.py腳本。)
class BasicLSTMCell(RNNCell):def __call__(self, inputs, state, scope=None):with vs.variable_scope(scope): c, h = array_ops.split(1, 2, state)concat = linear([inputs, h], 4 * self._num_units)i, j, f, o = array_ops.split(1, 4, concat)new_c = c * sigmoid(f) + sigmoid(i) * tanh(j)new_h = tanh(new_c) * sigmoid(o)return new_h, array_ops.concat(1, [new_c, new_h])你可以看到,這里相當準確地再現圖3中的圖示。你會注意到上面的split操作符正是對應于CNTK的row slice操作符。
現在我們可以創建一個可以用于訓練的遞歸神經網絡模型,在同樣的變量域我們能用共享的W和b變量創建另一個網絡模型用于測試。具體的做法在TensorFlow的遞歸神經網絡教程ptb_word_lm.py腳本中有介紹。還有兩點值得留意。(應該說它們對于我理解這個例子,有著至關重要的作用)他們創建一個lstmModel類來訓練和測試網絡模型。
class lstmModel:def __init__(self, is_training, num_steps):self._input_data = tf.placeholder(tf.int32, [batch_size, num_steps])self._targets = tf.placeholder(tf.int32, [batch_size, num_steps])cell = rnn_cell.BasicLSTMCell(size, forget_bias=0.0)outputs = []states = []state = self._initial_statewith tf.variable_scope("RNN"):for time_step in range(num_steps):if time_step > 0: tf.get_variable_scope().reuse_variables()(cell_output, state) = cell(inputs[:, time_step, :], state)outputs.append(cell_output)states.append(state)… many details omitted …我們在主程序中創建一個訓練實例和一個測試實例,并調用它們(事實上還要創建一個實例,為了簡化過程我暫時先把它忽略)。
with tf.variable_scope("model", reuse=None, initializer=initializer):m = PTBModel(is_training=True, 20) with tf.variable_scope("model", reuse=True, initializer=initializer):mtest = PTBModel(is_training=False, 1)在上述代碼中,創建了實例m,初始化設置20步且不用reuse。從初始化這一步你能觀察到,在計算流程圖中該單元被展開成20個副本,并且在首次迭代后reuse標志置為True,此時所有的實例都將共享同一組W和b。訓練過程在這個展開的版本上完成。第二個版本mtest設置reuse=True,且在圖中只有該單元的一個實例。但是變量域和m相同,因此它與m共享同一組訓練得到的變量。
一旦訓練完成,我們可以用一個內核來調用這個網絡模型。
cost, state = sess.run([mtest.cost, mtest.final_state],{mtest.input_data: x,mtest.targets: y,mtest.initial_state: state})x和y是輸入變量。這和教程示例中的完整過程相去甚遠。舉個例子,我完全沒有深入到訓練過程的細節中去,完整的示例使用了stacked LSTM并設置了dropout的比例。我的希望是,我在此羅列的細節將有助于讀者了解代碼的最基本結構。
總結
我對兩個系統的編程模型做了比較。這里是一些頂層的想法。
TensorFlow和CNTK在卷積神經網絡那個簡單例子中的做法非常相似。然而,我發現tensorflow版本更容易進行實驗,因為它是由Python驅動的。我能用IPython notebook加載它并做一些其它嘗試。而CNTK則需要用戶完全理解如何用配置文件表達想法。我覺得這很困難。我用TensorFlow能很容易寫一個簡單的k-means聚類算法(詳見我之前關于TensorFlow的文章)。我卻無法用CNTK來實現,不過這可能是由于我的無知,而不是CNTK的局限性。如果有人能提示我該怎么做,我會很感激的)。
在LSTM遞歸神經網絡的例子里,我發現CNTK的版本相當的透明。我發現TensorFlow版本的頂層想法非常優雅,但我也發現想了解它的所有細節卻非常困難,因為涉及到了變量作用域和變量共享的巧妙用法。我不得不深入地了解它的工作原理。但到現在我也不是十分清楚!我在TensorFlow版本里確實發現了一個微小但很容易修復的bug,而且我不相信變量作用域和reuse標志是解決封裝問題的最好方法。但是TensorFlow的好處在于我能很容易地修改實驗。
我也必須說CNTK書和TensorFlow教程都是優秀入門級讀物。我相信有更多詳細的、深入的書馬上就會面世。
我也相信,隨著兩個系統的不斷成熟,它們都會有改進,并且能更容易地使用。我在此不討論性能問題,但CNTK目前在解決某些挑戰難題的速度方面略勝一籌。但隨著這些系統的快速發展,我希望看到競爭也隨之升溫。
原文鏈接:TensorFlow Meets Microsoft’s CNTK?
作者:Dennis Gannon(MSR退休數據科學家,印第安納大學計算機科學榮譽退休教授)?
譯者:趙屹華 審校:劉翔宇?
責編:周建丁(投稿請聯系zhoujd@csdn.net,優稿優酬)
延伸閱讀:MXNet設計筆記之:深度學習的編程模式比較
總結
以上是生活随笔為你收集整理的代码解析深度学习系统编程模型:TensorFlow vs. CNTK的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 滤镜URL
- 下一篇: 图像亮度、对比度调节(伽马校正)