Henara.Imaging.Client 1.0.1
Henara.Imaging.Client
Client for the Henara imaging pipeline. Send scanned pages, get back one result per page: which Muster it was and the OCR reading, or why it was declined.
using var gateway = new HenaraImagingClient(new HenaraImagingOptions
{
Address = "http://10.110.0.178:5080",
});
await foreach (var page in gateway.ReadStreamAsync(pages))
{
if (page.IsRead) Save(page.PageClass!, page.ResultJson!);
else if (page.IsDeclined) Review(page.FileName, page.Declined!.Status);
}
No protobuf types, no gRPC types, no channel management. The generated client is internal to the package.
Targets net8.0, net9.0 and net10.0. Deliberately not net10-only — the gateway itself is pinned there by a dependency, and that pin should not reach callers.
Install
dotnet add package Henara.Imaging.Client
Set it up
Either construct one directly and keep it:
using var gateway = new HenaraImagingClient(new HenaraImagingOptions
{
Address = "http://10.110.0.178:5080",
});
Or register it for DI:
builder.Services.AddHenaraImaging(builder.Configuration);
// appsettings.json
"HenaraImaging": {
"Address": "http://10.110.0.178:5080"
}
Address resolution
An explicit address always wins. Otherwise HENARA_IMAGING_ADDRESS is used.
Address |
HENARA_IMAGING_ADDRESS |
result |
|---|---|---|
| set | unset | the option |
| unset | set | the variable |
| set | set | the option |
| unset | unset | throws, naming both |
So the same build can be pointed at a different gateway per environment with no config file, which is what containers and CI usually want — while an explicit value stays authoritative.
// takes HENARA_IMAGING_ADDRESS
using var gateway = new HenaraImagingClient(new HenaraImagingOptions());
// a different variable
new HenaraImagingOptions { AddressVariable = "MY_GATEWAY" };
// no fallback: require an explicit address
new HenaraImagingOptions { AddressVariable = null };
HenaraImagingClient.Address reports what was actually resolved — worth logging
at start-up. Under DI the check runs at boot (ValidateOnStart), so a missing
address fails there rather than on the first scan.
Registered as a singleton, because the client owns one HTTP/2 connection and is built to be reused. A client per request leaks handles and pays connection setup every time. It is thread-safe.
Plain http:// is normal: the gateway speaks h2c behind a VPN. https:// works
unchanged.
Send pages
| method | |
|---|---|
ReadStreamAsync(pages) |
the default. Results as each page finishes |
ReadAsync(pages) |
waits for all of it; results in input order |
ReadOneAsync(page) |
one page |
ReadFileAsync(path) |
one page straight off disk |
ListTemplatesAsync() |
what this gateway can recognise |
IsReadyAsync() |
is it up and its classifier ready |
var pages = new List<DocumentPage>
{
await DocumentPage.FromFileAsync(@"C:\scans\page1.jpg"),
new DocumentPage("page2.png", bytes),
};
Prefer streaming
A page costs 13–24 seconds, nearly all of it OCR. Measured on a three-page batch:
ReadStreamAsync first result after 13.5 s, whole batch 24.0 s
ReadAsync nothing until 26.2 s
For twenty pages that is the difference between working on page one after a few seconds and staring at nothing for five minutes.
Streamed results are not ordered — that is the point of them. Correlate on
SourceIndex, which is the index of the page in the list you passed.
ReadAsync does return input order.
Read the result
PageResult.Status is Read, Declined or Failed, and exactly one of
ResultJson, Declined and Error is set to match.
switch (page.Status)
{
case PageStatus.Read:
// page.PageClass, page.TemplateId, page.Side, page.Target
Save(page.ResultJson!);
break;
case PageStatus.Declined:
// Not recognised, and NEVER sent to OCR.
Queue(page.FileName, page.Declined!.Status, page.Declined.Reasons);
break;
case PageStatus.Failed:
// Recognised, but the OCR call did not return a reading.
Retry(page.FileName, page.Error!.Code, page.Error.Target);
break;
}
PageClass is the routing answer:
| class | form |
|---|---|
13 |
Heilmittelverordnung 13 |
56 |
pre-printed Muster 56 |
56_1e |
Muster 56.1/E + 56.2/E |
reha_teilnahme |
Teilnahmebestätigung Rehabilitationssport |
ft_teilnahme |
Teilnahmebestätigung Funktionstraining |
A declined page is never a misroute. The recognizer declines rather than guess, which is the property the whole design rests on — so a declined page is a page for a human, not a failure to retry.
Side is Front, Back or Unknown. Unknown is a real answer, not a
missing one: a declined page has no side, and defaulting it to Front would be a
guess that reaches the reader.
Ask for more than fields
await gateway.ReadAsync(pages, new ReadOptions
{
IncludeFieldImages = true, // stamps and signatures, as cropped images
IncludePdf = true,
Autocrop = true, // default; turn off for already-cropped images
});
IncludeFieldImages is the expensive one. Each attendance-sheet signature
comes back as a ~42 KB base64 crop, so a filled sheet with 30 rows is roughly
900 KB instead of 2 KB. Off by default.
Timeouts
The per-call budget scales with the batch, because a constant one either strangles a large batch or lets a hung single page sit for minutes:
BaseTimeoutSeconds (30) + TimeoutSecondsPerPage (60) × pages
Both are on HenaraImagingOptions. MaxReceiveMessageBytes defaults to 64 MB,
matching the gateway; the gRPC default of 4 MB is far too small for these
responses and fails mid-batch rather than up front.
Failure modes
| what you see | what it means |
|---|---|
IsReadyAsync() false |
gateway down, or its classifier still building its registry (~8 s after start) |
Status == Declined |
not recognised. Do not retry; route to a human |
Status == Failed, code Unavailable |
the OCR service could not be reached |
Status == Failed, code NO_READER_FOR_CLASS |
recognised as something no OCR call handles |
RpcException from the call itself |
the gateway could not reach its classifier at all — nothing could be decided about any page, so the whole call fails rather than returning invented per-page errors |
Layout
henara-imaging-client/
src/Henara.Imaging.Client/
HenaraImagingClient.cs the client
HenaraImagingOptions.cs address, limits, timeouts
DocumentPage.cs input page + ReadOptions
PageResult.cs result types
ServiceCollectionExtensions.cs AddHenaraImaging
gateway.proto is not copied here, and this project no longer compiles it.
The contract lives in henara-imaging-contracts and arrives as the
Henara.Imaging.Contracts package, which the gateway references too — so the
two sides of the connection cannot drift, and this repository builds from a
standalone clone.
Consumers never deal with protobuf: everything on this package's public surface
is a plain record. They do pick up Henara.Imaging.Contracts transitively,
alongside Google.Protobuf and Grpc.Net.ClientFactory, which is ordinary for
a gRPC client package.
Build and pack
dotnet build src/Henara.Imaging.Client
dotnet pack src/Henara.Imaging.Client -c Release -o ./nupkg
Produces .nupkg and .snupkg with lib/net8.0, lib/net9.0, lib/net10.0
and XML docs for each.
To publish:
dotnet nuget push nupkg/Henara.Imaging.Client.0.2.0.nupkg \
--source nuget.praxxme --api-key <key>
Version is set in the csproj. It is 0.3.0 and nothing has consumed it yet, so
the shape is still free to change.
Verified
Built as a package, then consumed from a net8.0 project referencing the
.nupkg — not the source — against the live gateway:
IsReadyAsync -> True
ListTemplatesAsync -> 8 templates, contract 1.0, digest 88bc0597ffebd2f5
6 validated, 8 active
ReadStreamAsync first result after 13.5 s, whole batch 24.0 s
[0] 2241845_….Jpeg: 13 (Front) via ReadMuster13, 1233 chars
[1] 2257197_….Jpeg: reha_teilnahme (Front) via ReadAttendanceSheet, 731 chars
[2] junk.bin: declined (INVALID_IMAGE)
ReadAsync 2 read, 1 declined, 0 failed, in 26.2 s
order preserved on ReadAsync: True
declined page carries no reading: True
Address resolution, all nine cases:
1. options only, no env var -> http://10.110.0.178:5080
2. env var only, Address unset -> http://10.110.0.178:5080
3. BOTH set: options must win -> (options won)
4. neither set -> InvalidOperationException, names both
5. fallback disabled (AddressVariable = null) -> InvalidOperationException
6. custom variable name -> http://10.110.0.178:5080
7. DI, empty config, env var supplies it -> http://10.110.0.178:5080
8. DI, config wins over env var -> (config won)
9. DI, neither: fails validation -> OptionsValidationException at boot
No packages depend on Henara.Imaging.Client.
.NET 10.0
- Google.Protobuf (>= 3.35.1)
- Grpc.Net.ClientFactory (>= 2.71.0)
- Henara.Imaging.Contracts (>= 1.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
- Microsoft.Extensions.Options.DataAnnotations (>= 8.0.0)
.NET 8.0
- Google.Protobuf (>= 3.35.1)
- Grpc.Net.ClientFactory (>= 2.71.0)
- Henara.Imaging.Contracts (>= 1.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
- Microsoft.Extensions.Options.DataAnnotations (>= 8.0.0)
.NET 9.0
- Google.Protobuf (>= 3.35.1)
- Grpc.Net.ClientFactory (>= 2.71.0)
- Henara.Imaging.Contracts (>= 1.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
- Microsoft.Extensions.Options.DataAnnotations (>= 8.0.0)