Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.cmd text eol=crlf
24 changes: 20 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ BiStudent.Server/App_Data*/
BiStudent.Core/Models/face_recognition_sface_2021dec.onnx
BiStudent.Core/Models/*.download
TestResults/
/out/
48 changes: 46 additions & 2 deletions BiStudent.Core/FaceEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenCvSharp.Size, FaceDetectorYN> scaledDetectors = new();
private readonly List<(int StudentId, float[] Vector)> enrolled = new();

// OpenCV Zoo uses 0.363 for cosine matching. A slightly higher threshold
Expand Down Expand Up @@ -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<float>(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)
Expand Down Expand Up @@ -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();
}
}
4 changes: 2 additions & 2 deletions BiStudent.Tests/AuditFixesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ public async Task OneOffSlotBecomesRegular()
var group = await PostAsync<GroupDto>("/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<SlotDto>("/api/schedule/slots", request);

Assert.Equal(HttpStatusCode.OK, (await client.PutAsJsonAsync($"/api/schedule/slots/{slot.Id}",
Expand All @@ -156,7 +156,7 @@ public async Task JournalWithAncientStartReturnsQuickly()
var group = await PostAsync<GroupDto>("/api/groups", new CreateGroupRequest("Большой период"));
var today = DateOnly.FromDateTime(DateTime.Today);
await PostAsync<SlotDto>("/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}");
Expand Down
31 changes: 31 additions & 0 deletions BiStudent.Tests/FaceEngineLargeFaceTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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/` |

Expand Down
24 changes: 24 additions & 0 deletions installer/BiStudent.cmd
Original file line number Diff line number Diff line change
@@ -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"
52 changes: 52 additions & 0 deletions installer/BiStudent.iss
Original file line number Diff line number Diff line change
@@ -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"