5.9 [C#]CAPTCHAを機械学習の力で突破したい


Home -> 雑用 -> 雑用メモ -> [5.9 [C#]CAPTCHAを機械学習の力で突破したい]

2015/05/06 作成
一切推敲していない糞文章故、大変読み難い代物となっております。

要約

頑張ればできなくもないけど実用的かというと人によって判断が分かれるところ。

概要

機械の力でCAPTCHAを突破しようという試みは昔から世界中で行われているが、Googleのアレみたいなのは結構難しいらしい。
ところがそれ以外の種類ならもう少し簡単に解析が可能なものもあるはずなので、そいつらを被験体にして解析してみたい。
どっかにライブラリなり何なりが転がってないかなーと思って適当に始めたら一筋縄ではいかなかった。

一覧

  1. CAPTCHA突破ライブラリ
  2. CAPTCHA Breaking Scripting Language
  3. 訓練させたいけど
  4. CAPTCHA Breaking Library
  5. サンプルプログラムの流れを追う
  6. 画像の前処理を自前で実装する
  7. 画像解析を実装する
  8. 訓練用データの用意
  9. 訓練を実装する
  10. 訓練の成果を確認する
  11. まとめ

CAPTCHA突破ライブラリ

CAPTCHA突破ライブラリというものを探してみたが、思ったより種類が少ない。

てなわけで「CAPTCHA Breaking Library」と「CAPTCHA Breaking Scripting Language」を用いて頑張ってみた。

CAPTCHA Breaking Scripting Language

「CAPTCHA Breaking Library」はC#のライブラリ。といってもNugetとかからすくに使える訳じゃないから少々面倒。
「CAPTCHA Breaking Scripting Language」は上のライブラリを用いた付属ソフトウェアで用いる独自スクリプト言語。
このソフトにはいくつかのサンプルデータについて学習済みデータベースが入っており、正しく画像が解析できるか確認できる。

付属ソフトウェアによるサンプル解析結果 画像00: 付属ソフトウェアによるサンプル解析結果

学習済みなのでいい感じに解析してくれる。ここで用いられたスクリプトは以下の通り。

**************************************************
*          CryptograPHP CAPTCHA Breaker          *
**************************************************
* Estimated CAPTCHA Solving Accuracy:     90.17% *
* Average attempts to solve a CAPTCHA: 0.298 ~ 1 *
**************************************************
*               Scott Clayton 2012               *
**************************************************

SetupSegmenter, BLOB, 8, 8, 4
SetupSolver,    BVS, "234569ABCDEFGHKLMNPRTWXYZ", 32, 32
Load,           "cryptographp.db"

DefinePreconditions
   Binarize, 230
EndPreconditions

Solve, %IMAGE%

ちなみにGoogle Project Hostingの方には例としてこんなスクリプトが書いてあった。

**********************************************************
* Scott Clayton                           April 14, 2012 *
**********************************************************
* This script is part of the CBL interpreter:            *
* http://code.google.com/p/captcha-breaking-library/     *
**********************************************************
* The CAPTCHA that this script breaks came from:         *
* http://www.codeproject.com/Articles/5947/CAPTCHA-Image *
**********************************************************

SetMode,        all
SetupSegmenter, BLOB, 4, 14, 8
SetupSolver,    MNN, "0123456789", 20, 20, 8, 150, 0.95
Load,           "mnn.solver.db"

DefinePreconditions
   Resize,           400, 100
   Subtract,         "merge3.bmp"
   Invert
   Median,           1
   MeanShift,        1, 2, 5
   Binarize,         150
   ColorFillBlobs,   80, 52
   RemoveSmallBlobs, 90, 4, 14
   HistogramRotate
   Binarize,         200
   ColorFillBlobs
EndPreconditions

Solve, %IMAGE%

どうやら前処理が肝らしい。ここさえ上手く出来ればそれなりの精度で解析してくれそうだ。

それでは自前のCAPTCHA画像ならどうか。まずはいくつかのサンプルを用意する。

CAPTCHA画像サンプル 画像01: CAPTCHA画像サンプル

どうやって学習させたらいいのかイマイチわからないので、ひとまず先程の学習データを流用して適当にスクリプトを書いてみた。

SetMode,        all
SetupSegmenter, BLOB, 10, 25, 5
SetupSolver,    MNN, "0123456789", 20, 30, 8, 150, 0.95
Load,           "mnn.solver.db"

DefinePreconditions
   Binarize, 9
EndPreconditions

Solve, %IMAGE%

これでどこまで解析してくれるか?画像を突っ込んでみた結果が以下。

自前の画像を突っ込んだ結果 画像02: 自前の画像を突っ込んだ結果

駄目じゃん。
まあこんなもんだろうとは思っていたけれども。

訓練させたいけど

この画像用に改めて訓練させれば多分きちんと解析できるようになる、はず。
じゃあどうやって訓練させればいいのか。以下のページで言語構文が解説されている。

どうやら、このアプリケーションはセグメンタとソルバが別々になっていてソルバのみが機械学習の対象のようだ。
つまり、文字を正しく読み取れるかどうかとは関係なく、画像を文字ごとに正しく分割できるかを訓練無しで確認する必要がある。

そこで、先程の画像を正しく分割できるようにスクリプトの調整を試みたが中々上手くいかない。
結局、画像を事前にトリミングしておいたら正しく分割できた。

事前にトリミングしておいたら正しく分割できた 画像03: 事前にトリミングしておいたら正しく分割できた

じゃあスクリプトでトリミングはできないか、というと実はできない。
仕方がないのでC#でガッツリとコードを書くしかなさそうだ。

CAPTCHA Breaking Library

ソースはSubversionで取ってくる。

どうやらこのライブラリは内部ではEmguCV経由でOpenCVを使っているらしい。
それにしても色々入っていてどれが何だかよく分からない。
ソリューションの中にいくつものプロジェクトが内包された感じになっているが慣れていないので良く分からん。

複数のプロジェクトから成るソリューション 画像04: 複数のプロジェクトから成るソリューション

そもそもソリューションとプロジェクトの違いとは何ぞやという所から既に分からない。
ちょっとググったら割と分かりやすい記事があった。なるほどなるほど。

という訳で以下にプロジェクト一覧を書いておく。

当面は以下の2段階の目標をそれぞれ達成することを目指す。

  1. 画像を文字ごとに正しく分割するために画像を適当に調整できるようにする
  2. 正しく分割された画像から文字を正しく推測できるような学習データを用意する

サンプルプログラムの流れを追う

サンプルプログラム「CAPTCHA Breaker GUI」は大まかに以下のような流れでCAPTCHA画像を解析する。

  1. スクリプトファイルをstringとして読み込む
  2. 画像ファイルをBitmapとして読み込む
  3. backgroundWorkerに上の2つをStartArgsオブジェクトとして渡す
  4. さっきの引数を使ってCaptchaInterpreterオブジェクトを作成する
  5. Execute()すると中でスクリプト文字列を1行ずつ読み込みつつ画像を解析する
    1. スクリプト文字列を1行ずつ読み込んで内容を解析する
      1. コメントなら無視する
      2. 行内に記述された引数をリストに読み込む
      3. 前処理に関する記述ならList<string> PreconditionCodeに文字列として格納する
      4. 設定または解析(分割・解読)に関する記述なら読込ごとに実行する
      5. 画像の分割を実行するときは直前にOnBeforeSegmentationイベントハンドラにより前処理が行われる
        • void captcha_OnBeforeSegmentation(Segmenter s)
        • このイベントが未設定の場合、無条件で例外が返される
    2. 読み取り結果を返り値とする

これを初めの方から自分で追っていけば良さそう。
前処理は自分で書けば何とかなりそう。EmguCVさえ扱えればいい感じに処理できそう。
じゃあ機械学習はどんな感じでさせればよいのか。CAPTCHABreaker.cs 内を見ると色々書いてある。
訓練データの作成は以下のコードによって行われる。適当にコメントを改変・追加したので見てもらえれば分かるはず。

        private const string trainersFolder = "trainers";

        /// <summary>
        /// Create a Solution Set from a folder of .BMP images where the name of the image is the solution to the CAPTCHA it contains.
        /// 指定フォルダ内に含まれる、ファイル名が画像内のCAPTCHAの解答になっている.BMPから正答の集合を作成する。
        /// E.G., image "d829f4.bmp" would be a CAPTCHA with the solution "d829f4".
        /// 例. 画像「d829f4.bmp」は「d829f4」が答えになるようなCAPTCHA画像。
        /// </summary>
        /// <param name="folder">The folder containing the images to test on. The directory search for images is NOT recursive.
        /// 訓練対象画像を含むフォルダ。画像のディレクトリ検索は再帰的には実行されない。 </param>
        /// <returns></returns>
        public SolutionSet CreateSolutionSet(string folder)
        {
            try
            {
                List<Pattern> patterns = new List<Pattern>();

                string savedir = folder + "\\" + trainersFolder + "\\";
                string[] allFiles = Directory.GetFiles(folder, "*.bmp", SearchOption.TopDirectoryOnly);
                int completed = 0;

                // We can safely load each image in parallel
                Parallel.ForEach(allFiles, file =>
                {
                    // Assume the filename is the solution to the CAPTCHA
                    // ファイル名がCAPTCHAの解答であると推定する
                    FileInfo info = new FileInfo(file);
                    string filename = info.Name.Substring(0, info.Name.IndexOf("."));

                    // Get list of solutions
                    // 解答の各文字をリストとして取得する
                    List<string> solutions = filename.ToCharStringList();

                    // Segment the image and create patterns
                    // 画像を分割して訓練用の一時リストを作成する
                    List<Bitmap> segments = Segment((Bitmap)System.Drawing.Image.FromFile(file));
                    List<Pattern> temp = GetPatternList(segments, solutions);

                    // Save each segmented image to a folder for faster loading later
                    // 分割済みの画像をフォルダに保存して後で速く読み込めるようにする
                    if (!Directory.Exists(savedir))
                    {
                        Directory.CreateDirectory(savedir);
                    }
                    for (int i = 0; i < Math.Min(segments.Count, solutions.Count); i++)
                    {
                        if (!Directory.Exists(savedir + solutions[i]))
                        {
                            Directory.CreateDirectory(savedir + solutions[i]);
                        }

                        segments[i].Save(
                            savedir + solutions[i] + "\\" + filename + "_" + i + "_" + segments[i].GetHashCode().ToString("X") + ".bmp"
                        );
                    }

                    // Add them to the list of patterns
                    // 一時リストを訓練用リストに追加する
                    lock (patterns)
                    {
                        patterns.AddRange(temp);
                    }

                    // Report progress if we are running asynchronously
                    // 非同期実行なら進捗報告
                    if (asyncSetCreater.IsBusy)
                    {
                        completed++;
                        double percent = ((double)completed / (double)allFiles.Length) * 100.0;
                        asyncSetCreater.ReportProgress((int)percent);
                    }
                });

                // This is the only way (aside from ugly reflection techniques) to create an instance of the solution set.
                // これが正答の集合を作成する唯一の方法(糞みたいなループ処理を用いない限りにおいて)
                return new SolutionSetCreator(patterns) as SolutionSet;
            }
            catch (Exception ex)
            {
                throw new SolutionSetException(
                    "Error trying create a solution set. Does the folder you specified exist and contain the expected images?",
                     ex);
            }
        }

上のコードはSegment(Bitmap)が正しく画像を分割してくれることを前提とした設計となっている。
訓練データを作成する時点で正しく分割できることが必須なのである。

画像の前処理を自前で実装する

画像が適切に処理されていれば高い確率で正しく分割できるようになる。それならその処理とやらを書くほかない。
このライブラリで用いられているEmguCVはバージョン2.2.0であり、そのドキュメンテーションは以下のページにある。

画像の前処理はプロジェクト ScottClayton.Interpreter 内の CaptchaInterpreter.cs で定義されているcaptcha_OnBeforeSegmentationに記述されている。
実際のピクセル処理の多くはプロジェクト ScottClayton.CAPTCHA 内の Segmenter.cs などに関数として記述されている。
可能な限りOpenCVに頼らずポインタを使用したコードを自前で書いているのが印象的なコードとなっている。
EmguCVでサラサラっと書いてあるのを想像していたので、こういったものを見せつけられて弄るのが億劫になってしまった。

とりあえず、今回は読み込んだBitmapのトリミングと閾値処理だけを自前で実装するという線で妥協。
CAPTCHA画像はサイズが小さいのでSetPixelを2重ループでぶん回しても速度はさほど問題にはならない。
閾値判定にはHSV色空間の明度を算出して用いることにした。

Bitmap bmpBase = new Bitmap(@"D:\test\CAPTCHAs\59308.png");
Rectangle rect = new Rectangle(5, 5, 140, 40);
Bitmap bmpNew = bmpBase.Clone(rect, bmpBase.PixelFormat);

for (int i = 0; i < bmpNew.Width; i++)
{
    for (int j = 0; j < bmpNew.Height; j++)
    {
        Color cColor = bmpNew.GetPixel(i, j);//1px分の色取得
        int value255 = Math.Max(Math.Max(cColor.R, cColor.G), cColor.B);//明度[0-255]
        if (value255 > 90)//閾値90
        {
            bmpNew.SetPixel(i, j, Color.White);
        }
        else
        {
            bmpNew.SetPixel(i, j, Color.Black);
        }
    }
}

元々はvoid captcha_OnBeforeSegmentation(Segmenter s)内で画像の前処理を行うようになっているが引数のSegmenterてのがどんな構造になっているのか調べるのが面倒。
ひとまずはこれで勘弁を。

画像解析を実装する

サンプルプログラムを書き換えてもいいが、コードの整理も兼ねてUIを最初から作ることに。

CAPTCHA Breaking Library 2012 のRelease版をコンパイルするとexeと一緒にdll類も出てくる。
そいつらは別のプロジェクトに「追加」することで、そこでもコードが使えるようになる。
OpenCV関連は既存の項目として、それ以外は参照として追加する。
てかよく考えたらサンプルプログラムにdll全部入ってるやんけ。

既存の学習データにより解析を実行するなら以下のようなコードでおk。なんでbutton2なのかという話は只のこちらの都合。

private void button2_Click(object sender, EventArgs e)
{
    CAPTCHABreaker captcha = new CAPTCHABreaker();

    //"SETUPSOLVER"
    captcha.SetSolverMethod(new MultiNeuralNetSolver("0123456789", 20, 30));
    //"SETUPSEGMENTER"
    captcha.SetSegmentationMethod(new BlobSegmentMethod(12, 24, 5));
    //"LOAD"
    captcha.LoadFromFile("mnn.solver.db");
    captcha.OnBeforeSegmentation += new CAPTCHABreaker.BeforeSegmentHandler(captcha_OnBeforeSegmentation);

    string solution = captcha.Solve(bmpNew);
    textBox1.Text = solution;
}
void captcha_OnBeforeSegmentation(Segmenter s)
{
}

さっき作ったbmpNewをそのまま渡すつもりで書いたのでBeforeSegmentHandlerの中身は空。
学習データは適当に引っ張ってきたので読み取りが正しく行われる見込みはほとんどないが、とにかく読み取ってもらう。
結果はフォーム上のtextBox1に書き出す。

前処理を行った画像と読み取り結果(仮) 画像05: 前処理を行った画像と読み取り結果(仮)

まだ訓練していないので仕方ないが最初の「5」以外は読み取れていない。やっぱりきちんと訓練しないとダメっぽい。

そもそも画像の分割は正しく行われているのだろうか。
サンプルプログラムでは分割結果も表示していたので([画像03]参照)、それを取得する方法があるはず。
コードを見てみたらOnGlobalBitmapMessageというイベントのハンドラにBitmapが渡されていた。
これを使いたい場合はScottClayton.Interpreter.dllを参照に追加する必要がある。

しかし実際のところ、動作を行っているのはOnGlobalBitmapMessageイベントのハンドラである。
そこでは、分割後の画像リストList<Bitmap>を水平方向に結合してOnGlobalBitmapMessageイベントハンドラに渡している。
この機能は本来デバッグ用なのでusing ScottClayton.CAPTCHA.Utility;する必要がある。
そして最大の注意点は、このハンドラはUIスレッドとは別のスレッドで動作しているという点である。

delegate void BmpRefresh2Delegate(Bitmap b);
private void button2_Click(object sender, EventArgs e)
{
    CAPTCHABreaker captcha = new CAPTCHABreaker();

    GlobalMessage.ALLOW_MESSAGES = true;
    GlobalMessage.OnGlobalBitmapMessage += new GlobalMessage.BitmapMessageHandler(GlobalMessage_OnGlobalBitmapMessage);
    
    //以下はcaptchaの処理を同様に記述する
    
}
//このハンドラは別スレッド上で動作する
void GlobalMessage_OnGlobalBitmapMessage(List<Bitmap> images, string tag)
{
    Bitmap b = images.MergeHorizontal();
    Invoke(new BmpRefresh2Delegate(BmpRefresh2), new object[] { b });
}
void BmpRefresh2(Bitmap b)
{
    pictureBox2.Image = b;
    this.toolStripStatusLabel1.Text = "あ";
}

これを実行すると以下のように表示される。

画像の分割結果の表示 画像06: 画像の分割結果の表示

分割は正しく行われているようだ。やはり学習データを用意したい。

訓練用データの用意

後でコードを読めばわかることだが、訓練用の画像データは正しく分割できるものでなければならない。
これは、訓練の流れが「画像の分割→各分割画像と答えを組み合わせて比較・訓練」という風になっているからである。
よって、正しく分割できない画像は訓練前に除外しておきたい。

今、訓練用データを110枚用意したので、このうちいくつが正しく分割できるか試した。分割の妥当性は1つずつ目で確かめる。
2値化の閾値はいろいろ見て80に設定。これぐらい低くしてやらないとゴミが残ってしまう。
結局、正しく分割できていると言える精度の画像が72個だけ用意できた。全体の65%にあたる。微妙。
ここでは後の便宜のために正しく分割できた処理後画像を*.bmpとして適当なフォルダに分けて別に保存し直す。

訓練を実装する

訓練のコードは CaptchaInterpreter.cs の531行目あたりから呼び出されるcaptcha.TrainOnSet()内に書かれている。
既存の正答リストが存在する場合はそれを読み込み、存在しない場合はCreateSolutionSet()により作成する。
この関数は CAPTCHABreaker.cs 内で定義されていて、指定されたフォルダ内の*.bmp全てを対象に訓練を行う。
この際、拡張子を除いたファイル名をCAPTCHAの解答として用い、画像の分割結果と解答を組み合われて正答リストを作成する。
作成されたリストのめいめいは、指定フォルダ内のサブフォルダ「trainers」内に保存される。

コードは以下のような感じで書けばおk。
訓練を開始すると、デバッグ用の出力につらつらと表示が出てくる。本当は別スレッドでやったほうがいいけど面倒だからUIスレッドで。

private void button4_Click(object sender, EventArgs e)
{
    CAPTCHABreaker captcha = new CAPTCHABreaker();
    //"SETUPSOLVER"
    captcha.SetSolverMethod(new MultiNeuralNetSolver("0123456789", 20, 30));
    //"SETUPSEGMENTER"
    captcha.SetSegmentationMethod(new BlobSegmentMethod(12, 24, 5));

    //前処理用のイベント(エラー回避)
    captcha.OnBeforeSegmentation += new CAPTCHABreaker.BeforeSegmentHandler(captcha_OnBeforeSegmentation);

    ScottClayton.Neural.PatternResult tresult1 = captcha.TrainOnSet(@"D:\test\CAPTCHAs\t", 1);
    captcha.SaveToFile("captcha.db");

    toolStripStatusLabel1.Text = "訓練終了 [Error: " + tresult1.Error + ", PercentageCorrect: " + tresult1.PercentageCorrect + "]";
}

数分間の訓練が終わると17.6MBの"captcha.db"ができていた。

訓練の成果を確認する

きちんと学習してくれたのかを確かめるために、学習に用いていない別のCAPTCHA画像をいくつか用意する。
これらについて正しく読み取りを行うことができれば、訓練は成功ということになる。
まずは1枚だけ用意して突っ込んでみた。

訓練後のデータベースを用いた読み取り 画像07: 訓練後のデータベースを用いた読み取り

いけるやん!
さっきまで試しに用いていたデータベースはアルファベットも候補に含んだCAPTCHAのものだったので、精度が上がるのもわかる。

それでは、あらゆる画像について、正答率はいくら程度なのだろうか。
実は、それを確かめるための関数もこのライブラリには最初から付いている。割と親切設計である。
今回は64枚の画像を新たに用意した。訓練に用いた画像を流用するとバイアスがかかってしまうので注意。

ところで、今更ではあるが複数の画像を解析させるときは事前に処理したBitmapを渡すといったことが難しい。
やろうと思ったら一時フォルダに吐き出してそのフォルダを指定するしかないが、やっぱり面倒。
仕方がないので、画像の前処理もやっぱりイベントハンドラ内に突っ込みたい。
そのためには、CaptchaInterpreter.cs内のvoid captcha_OnBeforeSegmentation(Segmenter s)の中身に当たる部分を自分で書かなければならない。

じゃあSegmenterって何ぞやという話になる訳だが、Segmenter.cs内でどのような処理を行っているのか見てみればわかる。
結局、Bitmap Segmenter.Imageを弄ればそれでおk。プロパティとして宣言されているので変え放題。
コードは以下の通り。

void captcha_OnBeforeSegmentation(Segmenter s)
{
    Rectangle rect = new Rectangle(5, 5, 140, 40);
    Bitmap bmpNew = s.Image.Clone(rect, s.Image.PixelFormat);

    for (int i = 0; i < bmpNew.Width; i++)
    {
        for (int j = 0; j < bmpNew.Height; j++)
        {
            Color cColor = bmpNew.GetPixel(i, j);
            int value255 = Math.Max(Math.Max(cColor.R, cColor.G), cColor.B);//明度[0-255]
            bmpNew.SetPixel(i, j, (value255 >= 80) ? Color.White : Color.Black);//閾値80
        }
    }
    s.Image = bmpNew;
}

また、精度の試験は以下のようにして実行できる。
一番の注意事項は、画像を一気に読み込ませるときは拡張子がbmpのやつしか読み込んでくれないこと。
もしPNGなりJPGなりを読み込ませたい場合は予め拡張子を書き換えておかなければならない。
ファイル名と拡張子の矛盾はブラックボックス部分が適当に闇に葬ってくれるので気にしなくてもよい。

private void button5_Click(object sender, EventArgs e)
{
    CAPTCHABreaker captcha = new CAPTCHABreaker();

    GlobalMessage.ALLOW_MESSAGES = true;
    GlobalMessage.OnGlobalBitmapMessage += new GlobalMessage.BitmapMessageHandler(GlobalMessage_OnGlobalBitmapMessage);

    //"SETUPSOLVER"
    captcha.SetSolverMethod(new MultiNeuralNetSolver("0123456789", 20, 30));
    //"SETUPSEGMENTER"
    captcha.SetSegmentationMethod(new BlobSegmentMethod(12, 24, 5));
    //"LOAD"
    captcha.LoadFromFile("captcha.db");
    captcha.OnBeforeSegmentation += new CAPTCHABreaker.BeforeSegmentHandler(captcha_OnBeforeSegmentation);

    string testfolder = @"D:\test\CAPTCHAs\new";
    ScottClayton.Neural.PatternResult tresult3 = captcha.TestOnSet(testfolder);
    if (tresult3 == null)
    {
        System.Diagnostics.Debug.WriteLine("*.bmpが見つかりませんでした");
        return;
    }
    System.Diagnostics.Debug.WriteLine("Testing Complete. Percent Correct: " + tresult3.PercentageCorrect.ToString("0.00") + "%");
}

これで数十秒間待つとコンソールにログが吐き出される。

精度試験の結果 画像08: 精度試験の結果

正答率は"77.14%"だそうで、まあまあといったところではないだろうか。
実用に耐えうるかどうかは別として、4回中3回は正しい答えを返してくれるなら機械にしては上出来だろう。
多分この数字は各文字が正しく読み取れる確率なのでCAPTCHA全体が正しく読み取れる確率は (0.7714)^5 = 0.273 程度と推定される。
4回中1回となると実用には向きそうにない。
ただし、答えが同じで別なCAPTCHA画像が複数用意できる場合は正答率を向上させることができる。
例えば5枚用意した場合は 1-(1-(0.7714)^5)^5 = 0.797 程度まで正答率が上がる。

まとめ

実際にCAPTCHAを突破したければ95%くらいの精度は欲しいところ。
今回は文字が0から9までの10種類だったので、こんなコードでもそこそこの正答率を叩き出した。
しかし、GoogleのreCAPTCHAみたいな種類のものは解析がとても難しい。
そもそもアレは人間にすら読めない画像を提示することがよくあるので機械にやらせようというのも無理があるが。

何はともあれ、そこそこの正確さで読み取ることができるようになったので今回は満足。


管理人Twitter: @su_te_ak/◆mmft4k9vgtL6
要望等はTwitterへ

Home -> 雑用 -> 雑用メモ -> [5.9 [C#]CAPTCHAを機械学習の力で突破したい]

ここ以降は鯖が勝手に付加するやつです