using SkiaSharp;
using System.Text.Json;

const int ImageSize = 32;
const int ClassCount = 26;

if (args.Length == 0)
{
    PrintUsage();
    return;
}

var command = args[0].ToLowerInvariant();

try
{
    switch (command)
    {
        case "generate":
            GenerateSyntheticData(args);
            break;
        case "train-simple":
            TrainSimple(args);
            break;
        case "predict-simple":
            PredictSimple(args);
            break;
        default:
            Console.WriteLine($"Unknown command: {args[0]}");
            PrintUsage();
            break;
    }
}
catch (Exception ex)
{
    Console.Error.WriteLine(ex);
}

static void GenerateSyntheticData(string[] args)
{
    var outputRoot = args.Length > 1 ? args[1] : Path.Combine("data", "letters");
    var imagesPerLetter = args.Length > 2 && int.TryParse(args[2], out var parsedCount)
        ? parsedCount
        : 80;

    if (imagesPerLetter <= 0)
    {
        throw new InvalidOperationException("Image count must be greater than zero.");
    }

    var fullOutputRoot = Path.GetFullPath(outputRoot);
    Directory.CreateDirectory(fullOutputRoot);

    var random = new Random(42);
    var letters = Enumerable.Range('A', ClassCount).Select(c => ((char)c).ToString()).ToArray();
    var fontFamilies = new[]
    {
        "Segoe Print",
        "Comic Sans MS",
        "MV Boli",
        "Kristen ITC",
        "Bradley Hand ITC",
        "Segoe Script",
        "Arial"
    };

    foreach (var letter in letters)
    {
        var letterDir = Path.Combine(fullOutputRoot, letter);
        Directory.CreateDirectory(letterDir);

        for (var i = 0; i < imagesPerLetter; i++)
        {
            var filePath = Path.Combine(letterDir, $"{letter}_{i + 1:0000}.png");
            using var bitmap = new SKBitmap(96, 96, SKColorType.Bgra8888, SKAlphaType.Premul);
            using var canvas = new SKCanvas(bitmap);

            var background = RandomGray(random, 238, 255);
            canvas.Clear(background);
            DrawPaperNoise(canvas, random, 96, 96);

            var family = fontFamilies[random.Next(fontFamilies.Length)];
            using var typeface = SKTypeface.FromFamilyName(
                family,
                random.NextDouble() < 0.18 ? SKFontStyle.Bold : SKFontStyle.Normal);
            using var paint = new SKPaint
            {
                Color = RandomInk(random),
                IsAntialias = true,
                Typeface = typeface,
                TextSize = random.Next(58, 78)
            };

            var bounds = new SKRect();
            paint.MeasureText(letter, ref bounds);
            var x = 48 - bounds.MidX + random.Next(-8, 9);
            var y = 52 - bounds.MidY + random.Next(-7, 8);

            canvas.Save();
            canvas.Translate(48, 48);
            canvas.RotateDegrees((float)(random.NextDouble() * 32 - 16));
            canvas.Skew((float)(random.NextDouble() * 0.18 - 0.09), (float)(random.NextDouble() * 0.08 - 0.04));
            canvas.Translate(-48, -48);

            canvas.DrawText(letter, x, y, paint);

            if (random.NextDouble() < 0.35)
            {
                using var secondPaint = new SKPaint
                {
                    Color = paint.Color.WithAlpha((byte)random.Next(70, 130)),
                    IsAntialias = true,
                    Typeface = typeface,
                    TextSize = paint.TextSize
                };
                canvas.DrawText(letter, x + random.Next(-1, 2), y + random.Next(-1, 2), secondPaint);
            }

            canvas.Restore();
            DrawStrayStroke(canvas, random);

            using var image = SKImage.FromBitmap(bitmap);
            using var data = image.Encode(SKEncodedImageFormat.Png, 95);
            using var stream = File.Open(filePath, FileMode.Create, FileAccess.Write);
            data.SaveTo(stream);
        }
    }

    Console.WriteLine($"Generated {letters.Length * imagesPerLetter} images in {fullOutputRoot}");
}

static void TrainSimple(string[] args)
{
    var dataRoot = args.Length > 1 ? args[1] : Path.Combine("data", "letters");
    var epochs = args.Length > 2 && int.TryParse(args[2], out var parsedEpochs) ? parsedEpochs : 5;
    var learningRate = args.Length > 3 && double.TryParse(args[3], out var parsedLearningRate) ? parsedLearningRate : 0.05;
    var maxSamples = args.Length > 4 && int.TryParse(args[4], out var parsedMaxSamples) ? parsedMaxSamples : 260;
    var modelPath = args.Length > 5 ? args[5] : Path.Combine("models", "simple-letter-model.json");
    var fullDataRoot = Path.GetFullPath(dataRoot);
    var fullModelPath = Path.GetFullPath(modelPath);

    var allSamples = LoadSamples(fullDataRoot).ToList();
    if (allSamples.Count == 0)
    {
        throw new InvalidOperationException($"No training images found in {fullDataRoot}.");
    }

    var labels = allSamples.Select(s => s.Label).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToArray();
    var labelToIndex = labels.Select((label, index) => new { label, index })
        .ToDictionary(x => x.label, x => x.index, StringComparer.OrdinalIgnoreCase);
    var random = new Random(42);
    var samples = allSamples
        .GroupBy(s => s.Label, StringComparer.OrdinalIgnoreCase)
        .SelectMany(group => group.OrderBy(_ => random.Next()).Take(Math.Max(1, maxSamples / labels.Length)))
        .OrderBy(_ => random.Next())
        .ToList();

    var model = new SimpleSoftmaxModel(ImageSize * ImageSize, labels.Length, random);

    Console.WriteLine("Simple model: logits = W*x + b");
    Console.WriteLine("Loss: cross entropy");
    Console.WriteLine("Update: manual gradient descent");
    Console.WriteLine($"Loaded {samples.Count} learning samples from {labels.Length} labels.");
    Console.WriteLine($"Epochs: {epochs}, learning rate: {learningRate}");

    for (var epoch = 1; epoch <= epochs; epoch++)
    {
        var totalLoss = 0.0;
        var correct = 0;
        var seen = 0;

        foreach (var sample in samples.OrderBy(_ => random.Next()))
        {
            var x = ImageToArray(sample.ImagePath);
            var y = labelToIndex[sample.Label];
            var result = model.TrainOne(x, y, learningRate);

            totalLoss += result.Loss;
            correct += result.PredictedIndex == y ? 1 : 0;
            seen++;

            if (epoch == 1 && seen <= 5)
            {
                Console.WriteLine(
                    $"  sample {seen}: answer={sample.Label}, predicted={labels[result.PredictedIndex]}, loss={result.Loss:F4}");
                Console.WriteLine(
                    $"    probability before update for correct answer: {result.CorrectProbability:P2}");
                Console.WriteLine(
                    $"    weight change example: W[{sample.Label}, firstPixel] += {result.FirstWeightDelta:E4}");
            }
        }

        Console.WriteLine(
            $"Epoch {epoch}/{epochs} - avg loss: {totalLoss / Math.Max(1, seen):F4}, accuracy: {(double)correct / Math.Max(1, seen):P2}");
    }

    Console.WriteLine();
    Console.WriteLine("What happened:");
    Console.WriteLine("1. Each image became 1024 numbers, one number per pixel.");
    Console.WriteLine("2. The model multiplied pixels by weights to get one score per letter.");
    Console.WriteLine("3. Softmax converted scores into probabilities.");
    Console.WriteLine("4. Cross entropy measured how wrong the correct letter probability was.");
    Console.WriteLine("5. Gradient descent nudged W and b so the correct letter gets a higher score next time.");

    model.Save(fullModelPath, labels);
    Console.WriteLine();
    Console.WriteLine($"Saved model to {fullModelPath}");
}

static void PredictSimple(string[] args)
{
    if (args.Length < 2)
    {
        throw new InvalidOperationException("Image path is required.");
    }

    var imagePath = Path.GetFullPath(args[1]);
    var modelPath = args.Length > 2 ? args[2] : Path.Combine("models", "simple-letter-model.json");
    var fullModelPath = Path.GetFullPath(modelPath);

    if (!File.Exists(imagePath))
    {
        throw new FileNotFoundException("Image file not found.", imagePath);
    }

    if (!File.Exists(fullModelPath))
    {
        throw new FileNotFoundException("Model file not found. Train first with train-simple.", fullModelPath);
    }

    var (model, labels) = SimpleSoftmaxModel.Load(fullModelPath);
    var input = ImageToArray(imagePath);
    var probabilities = model.Predict(input);
    var ranked = probabilities
        .Select((probability, index) => new { Label = labels[index], Probability = probability })
        .OrderByDescending(x => x.Probability)
        .Take(Math.Min(5, labels.Length))
        .ToList();

    Console.WriteLine($"Image: {imagePath}");
    Console.WriteLine($"Model: {fullModelPath}");
    Console.WriteLine($"Prediction: {ranked[0].Label} ({ranked[0].Probability:P2})");
    Console.WriteLine("Top guesses:");

    foreach (var item in ranked)
    {
        Console.WriteLine($"  {item.Label}: {item.Probability:P2}");
    }
}

static double[] ImageToArray(string imagePath)
{
    using var source = SKBitmap.Decode(imagePath)
        ?? throw new InvalidOperationException($"Unable to decode image: {imagePath}");
    using var normalized = NormalizeImage(source);

    var values = new double[ImageSize * ImageSize];
    var index = 0;
    for (var y = 0; y < ImageSize; y++)
    {
        for (var x = 0; x < ImageSize; x++)
        {
            var pixel = normalized.GetPixel(x, y);
            var gray = (pixel.Red + pixel.Green + pixel.Blue) / 3.0;
            values[index++] = (255.0 - gray) / 255.0;
        }
    }

    return values;
}

static SKBitmap NormalizeImage(SKBitmap source)
{
    const int targetInkSize = 24;
    const int inkThreshold = 180;

    var left = source.Width;
    var top = source.Height;
    var right = -1;
    var bottom = -1;

    for (var y = 0; y < source.Height; y++)
    {
        for (var x = 0; x < source.Width; x++)
        {
            var pixel = source.GetPixel(x, y);
            var gray = (pixel.Red + pixel.Green + pixel.Blue) / 3;
            if (gray >= inkThreshold)
            {
                continue;
            }

            left = Math.Min(left, x);
            top = Math.Min(top, y);
            right = Math.Max(right, x);
            bottom = Math.Max(bottom, y);
        }
    }

    var normalized = new SKBitmap(ImageSize, ImageSize, SKColorType.Bgra8888, SKAlphaType.Premul);
    using var canvas = new SKCanvas(normalized);
    canvas.Clear(SKColors.White);

    if (right < left || bottom < top)
    {
        return normalized;
    }

    var cropWidth = right - left + 1;
    var cropHeight = bottom - top + 1;
    var scale = targetInkSize / (float)Math.Max(cropWidth, cropHeight);
    var drawWidth = cropWidth * scale;
    var drawHeight = cropHeight * scale;
    var dest = new SKRect(
        (ImageSize - drawWidth) / 2f,
        (ImageSize - drawHeight) / 2f,
        (ImageSize + drawWidth) / 2f,
        (ImageSize + drawHeight) / 2f);
    var src = new SKRect(left, top, right + 1, bottom + 1);

    using var paint = new SKPaint
    {
        FilterQuality = SKFilterQuality.High,
        IsAntialias = true
    };
    canvas.DrawBitmap(source, src, dest, paint);

    return normalized;
}

static IEnumerable<LetterSample> LoadSamples(string root)
{
    if (!Directory.Exists(root))
    {
        yield break;
    }

    var extensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
    {
        ".png", ".jpg", ".jpeg", ".bmp"
    };

    foreach (var file in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
    {
        if (!extensions.Contains(Path.GetExtension(file)))
        {
            continue;
        }

        var label = new DirectoryInfo(Path.GetDirectoryName(file)!).Name;
        yield return new LetterSample(Path.GetFullPath(file), label);
    }
}

static void PrintUsage()
{
    Console.WriteLine("""
    LetterRecognizer

    Generate synthetic handwritten-style training images:
      dotnet run --project LetterRecognizer -- generate LetterRecognizer/data/letters 80

    Learn with a pure C# minimal model:
      dotnet run --project LetterRecognizer -- train-simple LetterRecognizer/data/letters 5 0.05 260

    Predict one image with the saved pure C# model:
      dotnet run --project LetterRecognizer -- predict-simple LetterRecognizer/data/letters/A/A_0001.png LetterRecognizer/models/simple-letter-model.json

    Training data layout:
      data/letters/A/a001.png
      data/letters/A/a002.png
      data/letters/B/b001.png
      data/letters/C/c001.png
    """);
}

static SKColor RandomGray(Random random, byte min, byte max)
{
    var value = (byte)random.Next(min, max + 1);
    return new SKColor(value, value, value);
}

static SKColor RandomInk(Random random)
{
    var value = (byte)random.Next(8, 50);
    return new SKColor(value, value, value, (byte)random.Next(215, 256));
}

static void DrawPaperNoise(SKCanvas canvas, Random random, int width, int height)
{
    using var paint = new SKPaint
    {
        IsAntialias = false
    };

    for (var i = 0; i < 90; i++)
    {
        var shade = (byte)random.Next(180, 245);
        paint.Color = new SKColor(shade, shade, shade, (byte)random.Next(8, 24));
        canvas.DrawPoint(random.Next(width), random.Next(height), paint);
    }
}

static void DrawStrayStroke(SKCanvas canvas, Random random)
{
    if (random.NextDouble() > 0.22)
    {
        return;
    }

    using var paint = new SKPaint
    {
        Color = new SKColor(20, 20, 20, (byte)random.Next(20, 45)),
        StrokeWidth = (float)(random.NextDouble() * 1.2 + 0.4),
        IsAntialias = true
    };

    var x = random.Next(16, 80);
    var y = random.Next(16, 80);
    canvas.DrawLine(x, y, x + random.Next(-5, 6), y + random.Next(-5, 6), paint);
}

public sealed record LetterSample(string ImagePath, string Label);

public sealed class SimpleSoftmaxModel
{
    private const int ModelImageSize = 32;

    private readonly int _inputSize;
    private readonly int _classCount;
    private readonly double[,] _weights;
    private readonly double[] _biases;

    public SimpleSoftmaxModel(int inputSize, int classCount, Random random)
    {
        _inputSize = inputSize;
        _classCount = classCount;
        _weights = new double[classCount, inputSize];
        _biases = new double[classCount];

        for (var c = 0; c < classCount; c++)
        {
            for (var i = 0; i < inputSize; i++)
            {
                _weights[c, i] = (random.NextDouble() - 0.5) * 0.01;
            }
        }
    }

    public SimpleTrainResult TrainOne(double[] input, int correctIndex, double learningRate)
    {
        var logits = Forward(input);
        var probabilities = Softmax(logits);
        var predictedIndex = ArgMax(probabilities);
        var loss = -Math.Log(Math.Max(probabilities[correctIndex], 1e-12));
        var firstWeightBefore = _weights[correctIndex, 0];

        for (var c = 0; c < _classCount; c++)
        {
            var gradientForLogit = probabilities[c] - (c == correctIndex ? 1.0 : 0.0);

            for (var i = 0; i < _inputSize; i++)
            {
                var gradientForWeight = gradientForLogit * input[i];
                _weights[c, i] -= learningRate * gradientForWeight;
            }

            _biases[c] -= learningRate * gradientForLogit;
        }

        return new SimpleTrainResult(
            predictedIndex,
            loss,
            probabilities[correctIndex],
            _weights[correctIndex, 0] - firstWeightBefore);
    }

    public double[] Predict(double[] input)
    {
        return Softmax(Forward(input));
    }

    public void Save(string modelPath, string[] labels)
    {
        Directory.CreateDirectory(Path.GetDirectoryName(modelPath) ?? ".");

        var weights = new double[_classCount][];
        for (var c = 0; c < _classCount; c++)
        {
            weights[c] = new double[_inputSize];
            for (var i = 0; i < _inputSize; i++)
            {
                weights[c][i] = _weights[c, i];
            }
        }

        var modelFile = new SimpleModelFile(
            ModelImageSize,
            labels,
            weights,
            _biases.ToArray());

        var options = new JsonSerializerOptions { WriteIndented = true };
        File.WriteAllText(modelPath, JsonSerializer.Serialize(modelFile, options));
    }

    public static (SimpleSoftmaxModel Model, string[] Labels) Load(string modelPath)
    {
        var json = File.ReadAllText(modelPath);
        var modelFile = JsonSerializer.Deserialize<SimpleModelFile>(json)
            ?? throw new InvalidOperationException($"Unable to load model: {modelPath}");

        if (modelFile.ImageSize != ModelImageSize)
        {
            throw new InvalidOperationException($"Model image size {modelFile.ImageSize} does not match expected size {ModelImageSize}.");
        }

        if (modelFile.Labels.Length == 0)
        {
            throw new InvalidOperationException("Model does not contain labels.");
        }

        if (modelFile.Weights.Length != modelFile.Labels.Length || modelFile.Biases.Length != modelFile.Labels.Length)
        {
            throw new InvalidOperationException("Model weight or bias count does not match labels.");
        }

        var model = new SimpleSoftmaxModel(ModelImageSize * ModelImageSize, modelFile.Labels.Length, new Random(0));

        for (var c = 0; c < modelFile.Labels.Length; c++)
        {
            if (modelFile.Weights[c].Length != ModelImageSize * ModelImageSize)
            {
                throw new InvalidOperationException($"Weight row {c} has {modelFile.Weights[c].Length} values.");
            }

            for (var i = 0; i < ModelImageSize * ModelImageSize; i++)
            {
                model._weights[c, i] = modelFile.Weights[c][i];
            }

            model._biases[c] = modelFile.Biases[c];
        }

        return (model, modelFile.Labels);
    }

    private double[] Forward(double[] input)
    {
        var logits = new double[_classCount];

        for (var c = 0; c < _classCount; c++)
        {
            var score = _biases[c];
            for (var i = 0; i < _inputSize; i++)
            {
                score += _weights[c, i] * input[i];
            }

            logits[c] = score;
        }

        return logits;
    }

    private static double[] Softmax(double[] logits)
    {
        var max = logits.Max();
        var exp = new double[logits.Length];
        var sum = 0.0;

        for (var i = 0; i < logits.Length; i++)
        {
            exp[i] = Math.Exp(logits[i] - max);
            sum += exp[i];
        }

        for (var i = 0; i < exp.Length; i++)
        {
            exp[i] /= sum;
        }

        return exp;
    }

    private static int ArgMax(double[] values)
    {
        var bestIndex = 0;
        var bestValue = values[0];

        for (var i = 1; i < values.Length; i++)
        {
            if (values[i] > bestValue)
            {
                bestIndex = i;
                bestValue = values[i];
            }
        }

        return bestIndex;
    }
}

public sealed record SimpleTrainResult(
    int PredictedIndex,
    double Loss,
    double CorrectProbability,
    double FirstWeightDelta);

public sealed record SimpleModelFile(
    int ImageSize,
    string[] Labels,
    double[][] Weights,
    double[] Biases);
 

// 将训练用的图片放到指定目录,例如A目录下放入A字符的绘图,B目录下放所有的B字符的绘图...

训练方法: 将图片放入指定字母的目录,例如:A目录下放入所有手写为A的图,之后调用: ./LetterRecognizer train-simple ../../../data/letters 10 0.03 2080 ../../../mode

训练之后回在mode目录生成一个模型文件simple-letter-model.json,之后再用这个模型文件去识别图片: 
 ./LetterRecognizer predict-simple ../../../data/test/003.png ../../../models/simple-letter-model.json

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐