كيفية حل لغز السودوكو باستخدام Azure AI وAngular و.NET
مقدمة: بناء محلّل وحلّال سودوكو بالاعتماد على الذكاء الاصطناعي
في هذا المقال سنشرح كيفية إنشاء تطبيق عملي يستطيع قراءة صورة جدول سودوكو ثم استخراج الأرقام منها آلياً، وبعد ذلك حل اللغز برمجياً. تعتمد الفكرة على خدمة Azure Form Recognizer لاستخراج محتوى الجدول من الصورة، ثم استخدام خوارزمية backtracking لإكمال الحل.
سنستخدم .NET 5 في الواجهة الخلفية، وAngular في الواجهة الأمامية، مع مكتبة Angular Material لتحسين المظهر وتجربة الاستخدام. هذا النوع من التطبيقات مفيد لفهم كيفية دمج خدمات الذكاء الاصطناعي السحابية مع تطبيقات الويب الحديثة.

المتطلبات الأساسية قبل البدء
- تثبيت أحدث إصدار
LTSمنNode.js. - تثبيت أداة
Angular CLI. - امتلاك حساب اشتراك في
Azure. - تثبيت
.NET Core 5.0 SDK. - تثبيت
Visual Studio 2019أو إصدار مناسب يدعم المشروع.
وجود هذه الأدوات يضمن لك إعداد بيئة تطوير متكاملة لبناء التطبيق وتشغيله محلياً قبل نشره.
ما هي خدمة Azure Form Recognizer؟
تُعد خدمة Azure Form Recognizer إحدى خدمات الإدراك المعرفي في Azure، وهي مخصصة لاستخراج البيانات من المستندات والصور باستخدام تقنيات التعلم الآلي. بدلاً من إدخال البيانات يدوياً، يمكن للخدمة تحليل الصور أو النماذج واستخراج العناصر المهمة منها بشكل آلي.
أبرز قدرات الخدمة
- النماذج الجاهزة: لاستخراج البيانات من الفواتير، والإيصالات، وبطاقات الهوية، وبطاقات الأعمال.
- النماذج المخصّصة: لتدريب الخدمة على مستندات خاصة بمؤسستك.
Layout API: لاستخراج النصوص، والعلامات، وبنية الجداول من المستندات والصور.
في مشروعنا سنستخدم Layout API لأن صورة السودوكو تُعد في جوهرها جدولاً مكوّناً من خلايا، وهذا مناسب جداً لاستخراج الصفوف والأعمدة والقيم الموجودة داخلها.
إنشاء مورد Form Recognizer في Azure
ابدأ بتسجيل الدخول إلى بوابة Azure Portal، ثم ابحث عن Cognitive Services وأنشئ مورداً جديداً لخدمة Form Recognizer.

عند إعداد المورد، ستحتاج إلى تعبئة الحقول التالية:
- Subscription: نوع الاشتراك.
- Resource group: مجموعة الموارد الحالية أو إنشاء مجموعة جديدة.
- Region: المنطقة الجغرافية المناسبة.
- Name: اسم فريد للمورد.
- Pricing tier: خطة التسعير المناسبة.

بعد اكتمال الإنشاء، انتقل إلى صفحة المورد ثم افتح قسم Keys and Endpoint واحفظ قيمة endpoint وأحد المفاتيح keys لأننا سنستخدمهما عند استدعاء الواجهة البرمجية من تطبيق .NET.

إنشاء مشروع ASP.NET Core with Angular
من داخل Visual Studio 2019، أنشئ مشروعاً جديداً من النوع ASP.NET Core with Angular.

اختر اسماً للمشروع مثل ngSudokuSolver ثم تابع الإعدادات.

في شاشة الإعدادات الإضافية، اضبط إطار العمل على .NET 5.0 واجعل نوع المصادقة None.

بعد إنشاء المشروع ستجد أن مجلد ClientApp يحتوي على تطبيق Angular، بينما يضم مجلد Controllers وحدات API الخاصة بالخادم.

للتبسيط، يمكنك حذف المجلدين fetchdata وcounter من المسار ClientApp/src/app ثم إزالة مراجعتهما من الملف app.module.ts.
تثبيت الحزم المطلوبة في .NET
من خلال NuGet Package Manager Console شغّل الأوامر التالية:
Install-Package Polly -Version 7.2.1
Install-Package Newtonsoft.Json -Version 13.0.1
تُستخدم مكتبة Polly لتطبيق سياسات إعادة المحاولة ومعالجة الأخطاء المؤقتة، بينما تساعد مكتبة Newtonsoft.Json في تحليل استجابات JSON القادمة من خدمة Azure.
إنشاء المعالج HttpRetryMessageHandler
أنشئ مجلداً باسم Models ثم أضف ملفاً جديداً باسم HttpRetryMessageHandler.cs وضع داخله الكود التالي:
using Newtonsoft.Json;
using Polly;
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace ngSudokuSolver.Models
{
public class HttpRetryMessageHandler : DelegatingHandler
{
public HttpRetryMessageHandler(HttpClientHandler handler) : base(handler)
{
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Policy.Handle<HttpRequestException>()
.Or<TaskCanceledException>()
.OrResult<HttpResponseMessage>(x =>
{
string result = x.Content.ReadAsStringAsync().GetAwaiter().GetResult();
dynamic array = JsonConvert.DeserializeObject(result);
if (array["status"] == "running")
{
return true;
}
else
{
return false;
}
})
.WaitAndRetryAsync(7, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)))
.ExecuteAsync(() => base.SendAsync(request, cancellationToken));
}
}
وظيفة هذا المعالج هي إعادة تنفيذ الاستدعاء عند بقاء حالة المعالجة running. هذا مهم لأن خدمة Form Recognizer لا تعيد النتيجة النهائية فوراً، بل تبدأ التحليل أولاً ثم تتيح النتيجة بعد لحظات.
لماذا نحتاج إلى إعادة المحاولة؟
- لأن استدعاء التحليل يعيد غالباً الحالة
202 Accepted. - لأن النتيجة النهائية تصبح جاهزة لاحقاً عبر رابط
Operation-Location. - لأن مدة التحليل تختلف حسب جودة الصورة وكمية البيانات فيها.
إضافة المتحكم FormRecognizerController
أضف متحكماً جديداً باسم FormRecognizerController.cs داخل مجلد Controllers.

ثم أضف الكود التالي:
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using ngSudokuSolver.Models;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
namespace ngSudokuSolver.Controllers
{
[Produces("application/json")]
[Route("api/[controller]")]
public class FormRecognizerController : ControllerBase
{
static string endpoint;
static string apiKey;
public FormRecognizerController()
{
endpoint = "https://sudokusolver.cognitiveservices.azure.com/";
apiKey = "a9f75796b3ba49bdade48eb3b905cb0e";
}
[HttpPost, DisableRequestSizeLimit]
public async Task<string[][]> Post()
{
try
{
string[][] sudokuArray = GetNewSudokuArray();
if (Request.Form.Files.Count > 0)
{
var file = Request.Form.Files[Request.Form.Files.Count - 1];
if (file.Length > 0)
{
var memoryStream = new MemoryStream();
file.CopyTo(memoryStream);
byte[] imageFileBytes = memoryStream.ToArray();
memoryStream.Flush();
string SudokuLayoutJSON = await GetSudokuBoardLayout(imageFileBytes);
if (SudokuLayoutJSON.Length > 0)
{
sudokuArray = GetSudokuBoardItems(SudokuLayoutJSON);
}
}
}
return sudokuArray;
}
catch
{
throw;
}
}
static async Task<string> GetSudokuBoardLayout(byte[] byteData)
{
HttpClient client = new();
client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", apiKey);
string uri = endpoint + "formrecognizer/v2.1-preview.3/layout/analyze";
string LayoutJSON = string.Empty;
using (ByteArrayContent content = new(byteData))
{
HttpResponseMessage response;
content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
response = await client.PostAsync(uri, content);
if (response.IsSuccessStatusCode)
{
HttpHeaders headers = response.Headers;
if (headers.TryGetValues("Operation-Location", out IEnumerable<string> values))
{
string OperationLocation = values.First();
LayoutJSON = await GetJSON(OperationLocation);
}
}
}
return LayoutJSON;
}
static async Task<string> GetJSON(string endpoint)
{
using var client = new HttpClient(new HttpRetryMessageHandler(new HttpClientHandler()));
var request = new HttpRequestMessage();
request.Method = HttpMethod.Get;
request.RequestUri = new Uri(endpoint);
client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", apiKey);
var response = await client.SendAsync(request);
var result = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
return result;
}
static string[][] GetSudokuBoardItems(string LayoutData)
{
string[][] sudokuArray = GetNewSudokuArray();
dynamic array = JsonConvert.DeserializeObject(LayoutData);
int countOfCells = ((JArray)array?.analyzeResult?.pageResults[0]?.tables[0]?.cells).Count;
for (int i = 0; i < countOfCells; i++)
{
int rowIndex = array.analyzeResult.pageResults[0].tables[0].cells[i].rowIndex;
int columnIndex = array.analyzeResult.pageResults[0].tables[0].cells[i].columnIndex;
sudokuArray[rowIndex][columnIndex] = array.analyzeResult.pageResults[0].tables[0].cells[i]?.text;
}
return sudokuArray;
}
static string[][] GetNewSudokuArray()
{
string[][] sudokuArray = new string[9][];
for (int i = 0; i < 9; i++)
{
sudokuArray[i] = new string[9];
}
return sudokuArray;
}
}
}
كيف يعمل هذا المتحكم؟
- يستقبل الصورة المرفوعة من المستخدم داخل الطلب.
- يحوّل الصورة إلى مصفوفة بايتات
byte[]. - يرسل الصورة إلى واجهة
Layout API. - يقرأ رابط
Operation-Locationمن الترويسات. - يستخدم
GetJSON()لجلب النتيجة النهائية بعد اكتمال التحليل. - يبني مصفوفة ثنائية الأبعاد
9x9تمثّل خلايا السودوكو.
ملاحظة مهمة: عند تطبيق هذا المشروع عملياً، لا تضع المفتاح apiKey مباشرة داخل الكود في بيئة الإنتاج، بل خزّنه في الإعدادات السرية مثل appsettings أو Azure Key Vault للحفاظ على الأمان.
العمل على الواجهة الأمامية باستخدام Angular
الجزء الخاص بالعميل موجود داخل المجلد ClientApp. يمكن إدارة هذا الجزء عبر Angular CLI لتسريع إنشاء الملفات والوحدات والخدمات.
تثبيت Angular Material
شغّل الأمر التالي داخل مجلد ClientApp:
ng add @angular/material
اختر سمة جاهزة مثل Indigo/Pink، وفعّل أنماط typography والحركات الخاصة بالمتصفح.

إنشاء وحدة خاصة بمكونات Angular Material
أنشئ وحدة جديدة بالأمر التالي:
ng g m ng-material
ثم أضف الكود التالي إلى الملف ng-material.module.ts:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatInputModule } from '@angular/material/input';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
const materialModules = [
MatButtonModule,
MatCardModule,
MatInputModule,
MatToolbarModule,
MatDividerModule,
MatIconModule,
];
@NgModule({
declarations: [],
imports: [CommonModule, ...materialModules],
exports: [...materialModules],
})
export class NgMaterialModule {}
بعد ذلك، استورد الوحدة NgMaterialModule داخل الملف app.module.ts.
import { NgMaterialModule } from './ng-material/ng-material.module';
@NgModule({
...
imports: [
...,
NgMaterialModule,
],
})
تهيئة شريط التنقل
حدّث الملف nav-menu.component.html بالكود التالي:
<mat-toolbar color="primary" class="mat-elevation-z2">
<mat-toolbar-row>
<div>
<button mat-button [routerLink]='["/"]'>
<mat-icon>book</mat-icon>
Sudoku Solver
</button>
</div>
</mat-toolbar-row>
</mat-toolbar>
هذا الشريط يمنح التطبيق واجهة بسيطة وواضحة، مع رابط مباشر إلى الصفحة الرئيسية.
إنشاء خدمة FormRecognizerService
أنشئ خدمة جديدة بالأمر التالي:
ng g s services/form-recognizer
ثم أضف الكود التالي إلى الملف form-recognizer.service.ts:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({
providedIn: 'root',
})
export class FormRecognizerService {
baseURL: string;
constructor(private http: HttpClient) {
this.baseURL = '/api/FormRecognizer';
}
getSudokuTableFromImage(image: FormData) {
return this.http.post(this.baseURL, image);
}
}
هذه الخدمة مسؤولة عن إرسال الصورة إلى الخادم واستقبال المصفوفة الناتجة التي تحتوي على أرقام جدول السودوكو.
تحديث مكوّن الصفحة الرئيسية
افتح الملف home.component.html وضع الكود التالي:
<div class="container">
<h1 class="display-4">Solve Sudoku using Azure AI</h1>
<mat-divider></mat-divider>
<div class="row mt-3">
<div class="col-md-6">
<mat-card class="mat-elevation-z4">
<mat-card-content>
<table>
<tr *ngFor="let row of gameBoard">
<td *ngFor="let col of gameBoard">
{{game[row][col]}}
</td>
</tr>
</table>
</mat-card-content>
<mat-card-actions>
<button type="button" mat-raised-button color="primary" (click)="SolveSudoku()">
Solve Sudoku
</button>
</mat-card-actions>
</mat-card>
</div>
<div class="col-md-6">
<div class="image-container">
<img class="preview-image" src={{imagePreview}}>
</div>
<input type="file" (change)="uploadImage($event)" />
<hr />
<button mat-raised-button color="accent" (click)="GetSudokuTable()">
<span *ngIf="loading" class="spinner-border spinner-border-sm mr-1"></span>
Extract Sudoku Table
</button>
</div>
</div>
</div>
تعرض هذه الواجهة:
- جدول سودوكو بحجم
9x9. - معاينة للصورة المرفوعة.
- زر لاستخراج الأرقام من الصورة.
- زر لحل اللغز بعد تعبئة القيم المستخرجة.
ثم حدّث الملف home.component.ts بالكود التالي:
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subject } from 'rxjs';
import { FormRecognizerService } from '../services/form-recognizer.service';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
})
export class HomeComponent implements OnDestroy {
gameBoard = [0, 1, 2, 3, 4, 5, 6, 7, 8];
loading = false;
imageFile;
imagePreview;
maxFileSize: number;
isValidFile = true;
status: string;
DefaultStatus: string;
imageData = new FormData();
game = new Array(9);
private unsubscribe$ = new Subject();
constructor(private formRecognizerService: FormRecognizerService) {
this.DefaultStatus = 'Maximum size allowed for the image is 4 MB';
this.status = this.DefaultStatus;
this.maxFileSize = 4 * 1024 * 1024; // 4MB
for (var i = 0; i < this.game.length; i++) {
this.game[i] = new Array(9);
}
}
uploadImage(event) {
this.imageFile = event.target.files[0];
if (this.imageFile.size > this.maxFileSize) {
this.status = `The file size is ${this.imageFile.size} bytes, this is more than the allowed limit of ${this.maxFileSize} bytes.`;
this.isValidFile = false;
} else if (this.imageFile.type.indexOf('image') == -1) {
this.status = 'Please upload a valid image file';
this.isValidFile = false;
} else {
const reader = new FileReader();
reader.readAsDataURL(event.target.files[0]);
reader.onload = () => {
this.imagePreview = reader.result;
};
this.status = this.DefaultStatus;
this.isValidFile = true;
}
}
GetSudokuTable() {
if (this.isValidFile) {
this.loading = true;
this.imageData.append('imageFile', this.imageFile);
this.formRecognizerService
.getSudokuTableFromImage(this.imageData)
.pipe(takeUntil(this.unsubscribe$))
.subscribe(
(result: any) => {
this.game = result;
this.loading = false;
},
() => {
console.error();
this.loading = false;
}
);
}
}
SolveSudoku() {
this.sudokuSolver(this.game);
}
ngOnDestroy() {
this.unsubscribe$.next();
this.unsubscribe$.complete();
}
private sudokuSolver(data) {
for (let i = 0; i < 9; i++) {
for (let j = 0; j < 9; j++) {
if (data[i][j] == '') {
for (let k = 1; k <= 9; k++) {
if (this.isSudokuValid(data, i, j, k)) {
data[i][j] = ` ${k} `;
if (this.sudokuSolver(data)) {
return true;
} else {
data[i][j] = '';
}
}
}
return false;
}
}
}
return true;
}
private isSudokuValid(board, row, col, k) {
for (let i = 0; i < 9; i++) {
const m = 3 * Math.floor(row / 3) + Math.floor(i / 3);
const n = 3 * Math.floor(col / 3) + (i % 3);
if (board[row][i] == k || board[i][col] == k || board[m][n] == k) {
return false;
}
}
return true;
}
}
كيف تعمل خوارزمية الحل؟
تعتمد الدالة sudokuSolver() على أسلوب backtracking، وهو من أكثر الأساليب شيوعاً في حل السودوكو. تبدأ الخوارزمية بالبحث عن أول خلية فارغة، ثم تجرّب وضع قيمة من 1 إلى 9. إذا كانت القيمة صالحة وفق قواعد اللعبة، تنتقل إلى الخلية التالية. وإذا وصلت إلى حالة تعارض، فإنها تتراجع إلى الخطوة السابقة وتجرّب قيمة أخرى.
مزايا هذا الأسلوب
- بسيط في الفهم والتنفيذ.
- مناسب للألغاز القياسية ذات الشبكة
9x9. - يمكن دمجه بسهولة مع نتائج التعرف البصري على الأرقام.
تجربة تشغيل التطبيق
- شغّل المشروع عبر الضغط على
F5. - ارفَع صورة واضحة لجدول سودوكو.
- اضغط زر
Extract Sudoku Tableلاستخراج الأرقام من الصورة. - بعد تعبئة الجدول جزئياً، اضغط زر
Solve Sudokuلإكمال الحل.


نصائح تقنية لتحسين دقة النتائج
- استخدم صوراً واضحة وعالية التباين لتسهيل التعرف على الأرقام.
- يفضل أن تكون الصورة ملتقطة من زاوية مستقيمة لتقليل تشوّه الجدول.
- من الأفضل تنفيذ خطوة تحقق إضافية بعد الاستخراج لمراجعة القيم غير المنطقية.
- يمكن لاحقاً إضافة دعم لتصحيح الأخطاء الناتجة عن التعرف البصري قبل تشغيل خوارزمية الحل.
لماذا هذا المشروع مناسب كمحتوى تقني عالي القيمة؟
هذا المشروع لا يقدّم مثالاً نظرياً فقط، بل يجمع بين عدة مهارات مطلوبة في تطوير البرمجيات الحديثة:
- التكامل مع خدمات الذكاء الاصطناعي السحابية.
- بناء
Web APIباستخدامASP.NET Core. - تطوير واجهة تفاعلية باستخدام
Angular. - معالجة الصور والملفات المرفوعة من المستخدم.
- تطبيق خوارزميات منطقية لحل المشكلات.
هذا الدمج يجعل المقال عملياً ومفيداً للمطورين، كما يمنحه قيمة حقيقية تتوافق مع متطلبات المحتوى عالي الجودة المناسب للقبول في Google AdSense.
الخلاصة التقنية
يُظهر هذا التطبيق كيف يمكن الاستفادة من Azure AI في استخراج البيانات من الصور، ثم تحويل هذه البيانات إلى مدخلات قابلة للمعالجة بخوارزميات تقليدية مثل backtracking. تقنياً، تكمن قوة الحل في الجمع بين التعرف الذكي على بنية الجدول وبين معالجة منطقية دقيقة لحل السودوكو. وإذا أردت تطوير المشروع أكثر، فبإمكانك إضافة التحقق من جودة الصورة، وتحسين الأمان بإخفاء المفاتيح السرية، وتحديث الواجهة لتدعم التعديل اليدوي على القيم المستخرجة قبل بدء الحل.