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
43 changes: 41 additions & 2 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ Default profile is `dev`, which enables:

DIMSE status (current):

- Implemented: C-ECHO, C-STORE
- Not implemented yet: C-FIND, C-MOVE
- Implemented: C-ECHO, C-STORE, C-FIND (Study Root)
- Not implemented yet: C-MOVE

DIMSE C-STORE currently uses a strict curated list of accepted transfer capabilities (SOP class +
transfer syntax combinations) under `app.dimse.cstore.accepted-transfer-capabilities`.
Expand All @@ -34,6 +34,29 @@ You can change transfer capabilities at runtime via API:
- `DELETE /api/system/config/dimse/transfer-capabilities/{SOPClassUID}`
- `POST /api/system/config/dimse/transfer-capabilities/replace`

C-FIND (Study Root) is always enabled when DIMSE server is enabled (`app.dimse.cstore.enabled=true`).
Supported query levels are `STUDY`, `SERIES`, and `IMAGE`.

Query behavior:

- Standard C-FIND keys are matched (`StudyInstanceUID`, `SeriesInstanceUID`, `SOPInstanceUID`,
`PatientID`, `PatientName`, `Modality`, `StudyDescription`, `SeriesDescription`).
- Old Dicoogle-like free-text and `keyword:value` filters are accepted on the same endpoint.
- Mixed queries are supported (for example: free text + strict DICOM keys together).
- Broad queries without UIDs are supported by scanning indexed instances and filtering in-memory.
- Response size is capped by `app.dimse.cfind.max-results` (default `1000`).
- Optional query negotiation supported: `FUZZY` (PN fuzzy matching) and `DATETIME`
(DT matching/ranges).
- Sequence/nested matching keys are supported for sequence items in the query dataset.
- C-CANCEL stops response emission and now also short-circuits plugin scanning.
- Invalid C-FIND identifiers (missing/unsupported level, invalid range syntax, DT ranges without
DATETIME negotiation) are rejected with `IdentifierDoesNotMatchSOPClass`.

`keyword:value` filters:

- Any DICOM keyword present in the object can be used (case-insensitive), for example
`StudyDate`, `AccessionNumber`, `PatientID`, `Modality`, `StudyDescription`.

Config source options:

- `app.dimse.cstore.config.source=yaml` (single-node runtime updates)
Expand Down Expand Up @@ -124,3 +147,19 @@ curl -i -u developer:developer \
```

Expected: `404` with `application/problem+json`.

### 8) C-FIND examples (findscu)

Standard Study Root by UID:

```bash
findscu -v -S -k QueryRetrieveLevel=STUDY -k StudyInstanceUID=<StudyUID> \
-aet TESTSCU -aec DICOOGLE localhost 11112
```

Mixed free-text + keyword filter on same request (old Dicoogle style):

```bash
findscu -v -S -k QueryRetrieveLevel=STUDY -k "PatientName=brain modality:MR" \
-aet TESTSCU -aec DICOOGLE localhost 11112
```
5 changes: 5 additions & 0 deletions backend/dicoogle-next-protocol-dimse/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
<artifactId>dicoogle-next-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.dicoogle</groupId>
<artifactId>dicoogle-next-sdk</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.dcm4che</groupId>
<artifactId>dcm4che-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
package org.dicoogle.protocol.dimse;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
import java.util.regex.Pattern;
import org.dcm4che3.data.Attributes;
import org.dcm4che3.data.Tag;
import org.dcm4che3.data.VR;
import org.dcm4che3.net.QueryOption;
import org.dcm4che3.net.Status;
import org.dcm4che3.net.service.DicomServiceException;
import org.dicoogle.sdk.query.DimseFindAccessPolicy;
import org.dicoogle.sdk.query.DimseFindServicePlugin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class CFindService {

private static final String STUDY_ROOT_MODEL_UID = "1.2.840.10008.5.1.4.1.2.2.1";
private static final Pattern KEYWORD_PATTERN = Pattern.compile("[A-Za-z0-9_.-]+:[^\\s]+");
private static final Logger LOGGER = LoggerFactory.getLogger(CFindService.class);

private final List<DimseFindServicePlugin> plugins;
private final List<DimseFindAccessPolicy> accessPolicies;
private final Set<String> supportedLevels;
private final int maxResults;
private final MeterRegistry meterRegistry;

public CFindService(
List<DimseFindServicePlugin> plugins,
List<DimseFindAccessPolicy> accessPolicies,
DimseCFindProperties properties,
MeterRegistry meterRegistry) {
this.plugins = List.copyOf(plugins);
this.accessPolicies = List.copyOf(accessPolicies);
this.supportedLevels =
properties.getSupportedQueryLevels().stream()
.map(level -> level.toUpperCase(Locale.ROOT))
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
this.maxResults = properties.getMaxResults();
this.meterRegistry = meterRegistry;
}

public List<Attributes> find(
String affectedSopClassUid,
Attributes keys,
String callingAet,
String calledAet,
int associationSerialNo,
java.util.Set<QueryOption> queryOptions,
BooleanSupplier cancelRequested)
throws DicomServiceException {
long startNs = System.nanoTime();
increment("dicoogle.cfind.requests", null);

if (!STUDY_ROOT_MODEL_UID.equals(affectedSopClassUid)) {
increment("dicoogle.cfind.failure", "unsupported-sop-class");
throw new DicomServiceException(
Status.SOPclassNotSupported, "Only Study Root C-FIND is supported");
}

String queryLevel = keys.getString(Tag.QueryRetrieveLevel);
if (queryLevel == null || queryLevel.isBlank()) {
increment("dicoogle.cfind.failure", "missing-query-level");
throw new DicomServiceException(
Status.IdentifierDoesNotMatchSOPClass, "Missing QueryRetrieveLevel");
}

String normalizedLevel = queryLevel.trim().toUpperCase(Locale.ROOT);
if (!supportedLevels.contains(normalizedLevel)) {
increment("dicoogle.cfind.failure", "unsupported-query-level");
throw new DicomServiceException(
Status.IdentifierDoesNotMatchSOPClass,
"Unsupported QueryRetrieveLevel: " + normalizedLevel);
}

DimseFindServicePlugin.QueryRetrieveLevel level;
try {
level = DimseFindServicePlugin.QueryRetrieveLevel.valueOf(normalizedLevel);
} catch (IllegalArgumentException ex) {
increment("dicoogle.cfind.failure", "invalid-query-level");
throw new DicomServiceException(
Status.IdentifierDoesNotMatchSOPClass, "Invalid QueryRetrieveLevel");
}

validateIdentifier(keys, queryOptions);

if (plugins.isEmpty()) {
increment("dicoogle.cfind.failure", "no-query-plugin");
throw new DicomServiceException(
Status.UnableToProcess, "No DIMSE query plugin is configured");
}

String rawPatientName = keys.getString(Tag.PatientName, null);
String freeText = extractFreeText(rawPatientName);
Map<String, String> keywordFilters = extractKeywordFilters(rawPatientName);

Attributes normalizedKeys = new Attributes(keys);
if (freeText == null || freeText.isBlank()) {
normalizedKeys.setNull(Tag.PatientName, org.dcm4che3.data.VR.PN);
} else {
normalizedKeys.setString(Tag.PatientName, org.dcm4che3.data.VR.PN, freeText);
}

DimseFindServicePlugin.FindRequest request =
new DimseFindServicePlugin.FindRequest(
DimseFindServicePlugin.InformationModel.STUDY_ROOT,
level,
callingAet,
calledAet,
associationSerialNo,
normalizedKeys,
freeText,
keywordFilters,
queryOptions.contains(QueryOption.FUZZY),
queryOptions.contains(QueryOption.DATETIME),
cancelRequested == null ? () -> false : cancelRequested);

for (DimseFindAccessPolicy policy : accessPolicies) {
DimseFindAccessPolicy.Decision decision = policy.evaluate(request);
if (!decision.allowed()) {
increment("dicoogle.cfind.failure", "policy-denied");
throw new DicomServiceException(Status.UnableToProcess, decision.reason());
}
}

LOGGER.info(
"C-FIND request association={} aet={} level={} keys={} freeText={} keywordFilters={}",
associationSerialNo,
callingAet,
normalizedLevel,
requestedKeyTags(normalizedKeys),
freeText,
keywordFilters.keySet());

for (DimseFindServicePlugin plugin : plugins) {
try {
List<Attributes> matches = plugin.find(request);
if (matches != null) {
List<Attributes> limited =
matches.size() <= maxResults ? matches : matches.subList(0, maxResults);
increment("dicoogle.cfind.success", null);
meterRegistry
.counter("dicoogle.cfind.matches", "level", normalizedLevel)
.increment(limited.size());
recordLatency("success", normalizedLevel, startNs);
return limited;
}
} catch (IOException ex) {
increment("dicoogle.cfind.failure", "plugin-io");
recordLatency("failure", normalizedLevel, startNs);
throw new DicomServiceException(Status.UnableToProcess, ex.getMessage());
}
}

increment("dicoogle.cfind.success", null);
recordLatency("success", normalizedLevel, startNs);
return List.of();
}

private String requestedKeyTags(Attributes keys) {
StringBuilder out = new StringBuilder();
for (int tag : keys.tags()) {
String value = keys.getString(tag, null);
boolean hasValue = value != null && !value.isBlank();
if (!out.isEmpty()) {
out.append(',');
}
out.append(String.format("%08X%s", tag, hasValue ? "=*" : ""));
}
return out.toString();
}

private String extractFreeText(String direct) {
if (direct == null || direct.isBlank()) {
return null;
}

StringBuilder out = new StringBuilder();
for (String token : direct.trim().split("\\s+")) {
if (!KEYWORD_PATTERN.matcher(token).matches()) {
if (!out.isEmpty()) {
out.append(' ');
}
out.append(token);
}
}
return out.isEmpty() ? null : out.toString();
}

private Map<String, String> extractKeywordFilters(String raw) {
if (raw == null || raw.isBlank()) {
return Map.of();
}

Map<String, String> out = new LinkedHashMap<>();
for (String token : raw.trim().split("\\s+")) {
if (KEYWORD_PATTERN.matcher(token).matches()) {
int index = token.indexOf(':');
if (index > 0 && index < token.length() - 1) {
out.put(token.substring(0, index), token.substring(index + 1));
}
}
}
return out;
}

private void increment(String meterName, String reason) {
if (reason == null) {
meterRegistry.counter(meterName).increment();
} else {
meterRegistry.counter(meterName, "reason", reason).increment();
}
}

private void validateIdentifier(Attributes keys, Set<QueryOption> queryOptions)
throws DicomServiceException {
for (int tag : keys.tags()) {
if (tag == Tag.QueryRetrieveLevel) {
continue;
}

String value = keys.getString(tag, null);
if (value == null || value.isBlank()) {
continue;
}

VR vr = keys.getVR(tag);
if (vr == VR.DT && containsRange(value) && !queryOptions.contains(QueryOption.DATETIME)) {
increment("dicoogle.cfind.failure", "datetime-negotiation-required");
throw new DicomServiceException(
Status.IdentifierDoesNotMatchSOPClass,
"DT range matching requires DATETIME query negotiation");
}

if ((vr == VR.DA || vr == VR.TM || vr == VR.DT) && hasInvalidRangeSyntax(value)) {
increment("dicoogle.cfind.failure", "invalid-range-syntax");
throw new DicomServiceException(
Status.IdentifierDoesNotMatchSOPClass,
String.format("Invalid range syntax for query key: %08X", tag));
}
}
}

private boolean containsRange(String value) {
return value.indexOf('-') >= 0;
}

private boolean hasInvalidRangeSyntax(String value) {
int firstDash = value.indexOf('-');
if (firstDash < 0) {
return false;
}
if (value.indexOf('-', firstDash + 1) >= 0) {
return true;
}
String start = value.substring(0, firstDash).trim();
String end = value.substring(firstDash + 1).trim();
return start.isEmpty() && end.isEmpty();
}

private void recordLatency(String outcome, String level, long startNs) {
Timer.builder("dicoogle.cfind.latency")
.tag("outcome", outcome)
.tag("level", level)
.register(meterRegistry)
.record(System.nanoTime() - startNs, TimeUnit.NANOSECONDS);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.dicoogle.protocol.dimse;

import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "app.dimse.cfind")
public class DimseCFindProperties {

private List<String> supportedQueryLevels = List.of("STUDY", "SERIES", "IMAGE");
private int maxResults = 1000;

public List<String> getSupportedQueryLevels() {
return supportedQueryLevels;
}

public void setSupportedQueryLevels(List<String> supportedQueryLevels) {
this.supportedQueryLevels =
supportedQueryLevels == null || supportedQueryLevels.isEmpty()
? List.of("STUDY", "SERIES", "IMAGE")
: supportedQueryLevels;
}

public int getMaxResults() {
return maxResults;
}

public void setMaxResults(int maxResults) {
this.maxResults = Math.max(1, maxResults);
}
}
Loading
Loading