Henara.Imaging.Client 1.0.2

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, Failed or Skipped. At most one of ResultJson, Declined and Error is set to match — a skipped page has none of them.

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;

    case PageStatus.Skipped:
        // Recognised, and YOU asked for no reading of this class.
        HandleYourself(page.FileName, page.PageClass!);
        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.

Ask for less: classify without reading

For forms you already handle yourself, name the class and the gateway will identify the page and stop there:

await gateway.ReadAsync(pages, new ReadOptions
{
    SkipReadForClasses = ["56"],   // recognise Muster 56, do not read it
});

Those pages come back as PageStatus.Skipped with ResultJson null — not empty. The page is still fully identified: PageClass, TemplateId, Side and the Target that would have been called are all populated, so you can route it onward yourself without a second pass.

Classes are matched exactly. "56" does not cover "56_1e" — list both if you mean both. They share a reader (ReadMuster56) but they are different forms, and a list that quietly widened itself would stop returning readings for a form you never named. A class nothing emits is ignored rather than rejected, so a caller built against an older registry keeps working.

It is also much cheaper. Skipping is classification only — about 2 s/page against ~15 s for a read — so listing every class is a fast way to sort a mixed bundle before deciding what to read:

var sort = new ReadOptions
{
    SkipReadForClasses = ["13", "56", "56_1e", "reha_teilnahme", "ft_teilnahme"],
};

Declines are unaffected: a page the recognizer will not name is still Declined, whatever is in the list.

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 == Skipped recognised, but its class is in your SkipReadForClasses. Not an error
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.1.1.0.nupkg \
  --source nuget.praxxme --api-key <key>

Version is set in the csproj.

Testing an unpublished contract

Henara.Imaging.Contracts comes from the private feed, and nuget.config pins it there — so by default a contract change has to be published before this package can build against it. That is right for CI and wrong for the ten minutes in which you are changing both.

local-packages/ is a local NuGet source for exactly that window. Pack the contract straight into it:

cd ../henara-imaging-contracts
dotnet pack src/Henara.Imaging.Contracts/Henara.Imaging.Contracts.csproj \
  -c Release -o ../henara-imaging-client/local-packages

cd ../henara-imaging-client
dotnet build src/Henara.Imaging.Client -c Release

The local copy wins while it is there. Delete it and the next restore goes back to the feed — worth doing before you trust a green build, since a version that only exists on your disk will fail in CI.

Two details that are easy to get wrong if you change this setup:

  • The directory must exist even when empty. A local source that is missing is NU1301, a hard restore error, which is why .gitkeep is committed. CI clones it empty and never notices.
  • PraxxMe repeats the exact package id in packageSourceMapping. NuGet matches the longest pattern and then considers only the sources declaring that pattern. With ContractsLocal claiming Henara.Imaging.Contracts and PraxxMe claiming only Henara.*, the specific pattern would win outright and CI would stop finding the contract on the feed.

.nupkg is gitignored, so nothing you drop in local-packages/ is committed.

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.

Version Downloads Last updated
2.1.0 1 11/06/2026
2.0.0 2 11/05/2026
1.8.1 1 10/19/2026
1.8.0 1 10/19/2026
1.7.0 2 10/14/2026
1.6.0 1 10/14/2026
1.5.0 2 10/14/2026
1.4.0 1 10/14/2026
1.3.0 0 10/14/2026
1.2.0 13 10/05/2026
1.0.2 1 10/05/2026
1.0.1 0 10/01/2026