diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..db7e1d2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.cmd text eol=crlf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98aed7f..5755e81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,14 +48,30 @@ jobs: runs-on: windows-2025 needs: build-test if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write steps: - uses: actions/checkout@v5 - uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x - - run: dotnet publish BiStudent/BiStudent.csproj -c Release -r win-x64 --self-contained true -o out/BiStudent-Client - - run: dotnet publish BiStudent.Server/BiStudent.Server.csproj -c Release -r win-x64 --self-contained true -o out/BiStudent-Server + - run: dotnet publish BiStudent/BiStudent.csproj -c Release -r win-x64 --self-contained true -o out/Client + - run: dotnet publish BiStudent.Server/BiStudent.Server.csproj -c Release -r win-x64 --self-contained true -o out/Server + - name: Build installer + shell: pwsh + run: | + choco install innosetup -y --no-progress + Copy-Item installer/BiStudent.cmd out/ + & "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" /Q "/DSrc=$PWD\out" "/DOutDir=$PWD\dist" "/DAppVersion=1.0.${{ github.run_number }}" installer/BiStudent.iss + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - uses: actions/upload-artifact@v5 with: - name: BiStudent-win-x64 - path: out/ + name: BiStudent-Setup + path: dist/BiStudent-Setup.exe + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: > + gh release create "v1.0.${{ github.run_number }}" dist/BiStudent-Setup.exe + --target "${{ github.sha }}" --title "BiStudent 1.0.${{ github.run_number }}" + --notes "Установщик для Windows 10/11 x64: скачайте BiStudent-Setup.exe и запустите. Вход в демо-режиме: teacher / teacher123." diff --git a/.gitignore b/.gitignore index a740d8a..630cdb2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ BiStudent.Server/App_Data*/ BiStudent.Core/Models/face_recognition_sface_2021dec.onnx BiStudent.Core/Models/*.download TestResults/ +/out/ diff --git a/BiStudent.Core/FaceEngine.cs b/BiStudent.Core/FaceEngine.cs index 9c6a99b..f855ee1 100644 --- a/BiStudent.Core/FaceEngine.cs +++ b/BiStudent.Core/FaceEngine.cs @@ -20,6 +20,7 @@ private static readonly (double X, double Y)[] AlignedLandmarks = private readonly Net recognitionNet; private FaceDetectorYN? detector; private OpenCvSharp.Size detectorSize; + private readonly Dictionary scaledDetectors = new(); private readonly List<(int StudentId, float[] Vector)> enrolled = new(); // OpenCV Zoo uses 0.363 for cosine matching. A slightly higher threshold @@ -188,8 +189,49 @@ private Mat FindFaces(Mat frame) detectorSize = size; } var faces = new Mat(); - detector.Detect(frame, faces); - return faces; + try + { + detector.Detect(frame, faces); + if (faces.Rows > 0 || Math.Max(frame.Width, frame.Height) <= 480) return faces; + var longest = Math.Max(frame.Width, frame.Height); + foreach (var longSide in new[] { 640, 480, 320 }) + { + if (longSide >= longest) continue; + var scale = (double)longSide / longest; + var reducedSize = new OpenCvSharp.Size( + Math.Max(1, (int)Math.Round(frame.Width * scale)), + Math.Max(1, (int)Math.Round(frame.Height * scale))); + if (!scaledDetectors.TryGetValue(reducedSize, out var scaledDetector)) + { + if (scaledDetectors.Count == 8) + { + foreach (var cached in scaledDetectors.Values) + cached.Dispose(); + scaledDetectors.Clear(); + } + scaledDetector = FaceDetectorYN.Create(detectionModel, "", reducedSize, 0.85f); + scaledDetectors.Add(reducedSize, scaledDetector); + } + using var reduced = new Mat(); + Cv2.Resize(frame, reduced, reducedSize, 0, 0, InterpolationFlags.Area); + using var found = new Mat(); + scaledDetector.Detect(reduced, found); + var foundRows = found.Rows; + if (foundRows == 0) continue; + for (var row = 0; row < foundRows; row++) + for (var col = 0; col < 14; col++) + found.Set(row, col, found.At(row, col) * + (col % 2 == 0 ? (float)frame.Width / reduced.Width : (float)frame.Height / reduced.Height)); + faces.Dispose(); + return found.Clone(); + } + return faces; + } + catch + { + faces.Dispose(); + throw; + } } private static OpenCvSharp.Rect Bounds(Mat faces, int row, int width, int height) @@ -256,6 +298,8 @@ private static double Dot(float[] a, float[] b) public void Dispose() { detector?.Dispose(); + foreach (var cached in scaledDetectors.Values) + cached.Dispose(); recognitionNet.Dispose(); } } diff --git a/BiStudent.Tests/AuditFixesTests.cs b/BiStudent.Tests/AuditFixesTests.cs index ff96dbb..7ba1837 100644 --- a/BiStudent.Tests/AuditFixesTests.cs +++ b/BiStudent.Tests/AuditFixesTests.cs @@ -139,7 +139,7 @@ public async Task OneOffSlotBecomesRegular() var group = await PostAsync("/api/groups", new CreateGroupRequest("Регулярная группа")); var date = DateOnly.FromDateTime(DateTime.Today).AddDays(-7); var request = new SaveSlotRequest(group.Id, null, "Физика", null, date.DayOfWeek, - new TimeOnly(8, 0), new TimeOnly(9, 20), WeekParity.Every, date); + new TimeOnly(19, 0), new TimeOnly(20, 20), WeekParity.Every, date); var slot = await PostAsync("/api/schedule/slots", request); Assert.Equal(HttpStatusCode.OK, (await client.PutAsJsonAsync($"/api/schedule/slots/{slot.Id}", @@ -156,7 +156,7 @@ public async Task JournalWithAncientStartReturnsQuickly() var group = await PostAsync("/api/groups", new CreateGroupRequest("Большой период")); var today = DateOnly.FromDateTime(DateTime.Today); await PostAsync("/api/schedule/slots", new SaveSlotRequest(group.Id, null, "История", null, - today.DayOfWeek, new TimeOnly(8, 0), new TimeOnly(9, 20), WeekParity.Every, null)); + today.DayOfWeek, new TimeOnly(19, 0), new TimeOnly(20, 20), WeekParity.Every, null)); var timer = Stopwatch.StartNew(); using var response = await client.GetAsync($"/api/journal/{group.Id}?from=0001-01-01&to={today:yyyy-MM-dd}"); diff --git a/BiStudent.Tests/FaceEngineLargeFaceTests.cs b/BiStudent.Tests/FaceEngineLargeFaceTests.cs new file mode 100644 index 0000000..49325de --- /dev/null +++ b/BiStudent.Tests/FaceEngineLargeFaceTests.cs @@ -0,0 +1,31 @@ +using BiStudent; +using OpenCvSharp; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class FaceEngineLargeFaceTests +{ + [Theory] + [InlineData("armstrong")] + [InlineData("aldrin")] + [InlineData("ride")] + public void CaptureChecked_FindsFaceFillingFrame(string name) + { + using var engine = new FaceEngine(); + using var frame = FaceFixtures.Load(name); + var face = Assert.Single(engine.Detect(frame)); + var margin = (int)Math.Round(face.Width * 0.05); + var left = Math.Max(0, face.X - margin); + var top = Math.Max(0, face.Y - margin); + var right = Math.Min(frame.Width, face.Right + margin); + var bottom = Math.Min(frame.Height, face.Bottom + margin); + using var cropped = new Mat(frame, new Rect(left, top, right - left, bottom - top)); + var scale = 1280d / Math.Max(cropped.Width, cropped.Height); + using var big = new Mat(); + Cv2.Resize(cropped, big, new Size((int)Math.Round(cropped.Width * scale), + (int)Math.Round(cropped.Height * scale)), 0, 0, InterpolationFlags.Cubic); + Assert.NotEqual("В кадре нет лица", engine.AssessFrame(big).Problem); + engine.CaptureChecked(big); + } +} diff --git a/README.md b/README.md index 5b3cd46..fc918b8 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,15 @@ **Учётные записи и копии.** Администратор заводит преподавателей и выдаёт временные пароли. Сервер раз в сутки делает резервную копию, вручную её можно скачать и восстановить в разделе «Администрирование». -## Запуск +## Скачать + +**[BiStudent-Setup.exe](https://github.com/Felorez/BiStudent/releases/latest/download/BiStudent-Setup.exe)** для Windows 10/11 x64. Установка без прав администратора, .NET ставить не нужно. После установки на рабочем столе появляется ярлык BiStudent: он запускает сервер и открывает клиент. На новой базе создаются демо-данные, вход **teacher / teacher123**. + +Установщик не подписан. Если Windows покажет «Windows защитила ваш компьютер», нажмите «Подробнее» → «Выполнить в любом случае». Брандмауэр спросит про BiStudent.Server: разрешите доступ, иначе студенты не откроют сайт регистрации с телефона. + +Новая версия собирается автоматически после каждого слияния в `main`, все версии лежат в [Releases](https://github.com/Felorez/BiStudent/releases). + +## Запуск из исходников Нужны Windows x64 и .NET 10 SDK. @@ -53,6 +61,7 @@ cd BiStudent | `docker compose up -d --build` | сервер в Docker на `http://localhost:5080`, база в томе `bistudent-data` | | `dotnet test --solution BiStudent.slnx` | тесты сервера (`BiStudent.Tests`) и клиента (`BiStudent.Client.Tests`) | | `node --test tests/site/register.test.cjs` | тесты сайта регистрации лица | +| `ISCC installer\BiStudent.iss` | собрать установщик в `dist/` из `out/Client` и `out/Server` (порядок сборки в начале файла) | | `BiStudent.exe --self-test` | без сервера проверяет модели, камеры и окно входа, результат в `diagnostics/result.txt` | | `BiStudent.exe --screenshots <адрес> <логин> <пароль>` | входит на сервер и сохраняет скриншоты всех разделов в `diagnostics/` | diff --git a/installer/BiStudent.cmd b/installer/BiStudent.cmd new file mode 100644 index 0000000..57ee192 --- /dev/null +++ b/installer/BiStudent.cmd @@ -0,0 +1,24 @@ +@echo off +chcp 65001 >nul +title BiStudent +cd /d "%~dp0" + +tasklist /fi "imagename eq BiStudent.Server.exe" | find /i "BiStudent.Server.exe" >nul +if errorlevel 1 ( + rem На пустой базе создаются демо-данные: вход teacher / teacher123. + set Demo__Enabled=true + start "BiStudent — сервер (не закрывайте это окно)" /min /d "%~dp0Server" "%~dp0Server\BiStudent.Server.exe" --urls http://0.0.0.0:5080 +) + +echo Запускаю BiStudent, подождите... +for /l %%i in (1,1,60) do ( + curl.exe -s -f -m 2 http://localhost:5080/health >nul 2>&1 && goto ready + timeout /t 1 /nobreak >nul +) +echo. +echo Сервер не ответил за минуту. Откройте свёрнутое окно «BiStudent — сервер» и пришлите текст ошибки. +pause +exit /b 1 + +:ready +start "" /d "%~dp0Client" "%~dp0Client\BiStudent.exe" diff --git a/installer/BiStudent.iss b/installer/BiStudent.iss new file mode 100644 index 0000000..460371a --- /dev/null +++ b/installer/BiStudent.iss @@ -0,0 +1,52 @@ +; Сборка: dotnet publish клиента в out\Client и сервера в out\Server, installer\BiStudent.cmd в out\, +; затем ISCC /DSrc=<путь к out> /DOutDir=<куда положить exe> /DAppVersion=<версия> installer\BiStudent.iss +#ifndef Src + #define Src AddBackslash(SourcePath) + "..\out" +#endif +#ifndef OutDir + #define OutDir AddBackslash(SourcePath) + "..\dist" +#endif +#ifndef AppVersion + #define AppVersion "1.0" +#endif + +[Setup] +AppId={{6B1F2C3A-8E44-4F0B-9D1E-B15DE7E1A001} +AppName=BiStudent +AppVersion={#AppVersion} +AppPublisher=BiStudent +DefaultDirName={localappdata}\Programs\BiStudent +DefaultGroupName=BiStudent +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +OutputDir={#OutDir} +OutputBaseFilename=BiStudent-Setup +Compression=lzma2/max +SolidCompression=yes +LZMANumBlockThreads=4 +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +UninstallDisplayIcon={app}\Client\BiStudent.exe +WizardStyle=modern +CloseApplications=force + +[Languages] +Name: "ru"; MessagesFile: "compiler:Languages\Russian.isl" + +[Tasks] +Name: "desktopicon"; Description: "Ярлык на рабочем столе"; GroupDescription: "Дополнительно:" + +[Files] +Source: "{#Src}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\BiStudent"; Filename: "{app}\BiStudent.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\Client\BiStudent.exe"; Flags: runminimized +Name: "{group}\Удалить BiStudent"; Filename: "{uninstallexe}" +Name: "{autodesktop}\BiStudent"; Filename: "{app}\BiStudent.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\Client\BiStudent.exe"; Flags: runminimized; Tasks: desktopicon + +[Run] +Filename: "{app}\BiStudent.cmd"; WorkingDir: "{app}"; Description: "Запустить BiStudent"; Flags: postinstall nowait skipifsilent runminimized shellexec + +[UninstallRun] +Filename: "{sys}\taskkill.exe"; Parameters: "/f /im BiStudent.Server.exe"; Flags: runhidden; RunOnceId: "StopServer" +Filename: "{sys}\taskkill.exe"; Parameters: "/f /im BiStudent.exe"; Flags: runhidden; RunOnceId: "StopClient"