字母识别--机器学习
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
更多推荐




所有评论(0)