361 lines
14 KiB
PHP
361 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Pacs\Sync;
|
|
|
|
use App\Domain\Study\Priority;
|
|
use App\Domain\Study\WorkflowLevel;
|
|
use App\Models\DicomServer;
|
|
use App\Services\Pacs\DicomUtils;
|
|
use App\Services\Pacs\OrthancRestClient;
|
|
use App\Services\StudyRouter\DicomStudyRouter;
|
|
use App\Services\StudyRouter\RawDicomTags;
|
|
use Carbon\Carbon;
|
|
use Exception;
|
|
use Illuminate\Pipeline\Pipeline;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
final class StudiesSync
|
|
{
|
|
public const SYNC_AGENT = '$$_pacs_agent_$$';
|
|
private Collection $study_ids;
|
|
|
|
private Collection $insert_queue;
|
|
|
|
private Collection $update_queue;
|
|
|
|
private Collection $archive_queue;
|
|
|
|
private OrthancRestClient $client;
|
|
|
|
public function __construct(private readonly DicomServer $dicomServer, ?OrthancRestClient $client = null, private readonly int $maxInstances = 3)
|
|
{
|
|
$this->study_ids = collect();
|
|
$this->client = $client ?? new OrthancRestClient($dicomServer);
|
|
$this->resetQueues();
|
|
}
|
|
|
|
public function getDicomServer(): DicomServer
|
|
{
|
|
return $this->dicomServer;
|
|
}
|
|
|
|
public function execute(): void
|
|
{
|
|
app(Pipeline::class)
|
|
->send($this)
|
|
->through([
|
|
Pipes\ScanStudies::class,
|
|
Pipes\FilterStudies::class,
|
|
Pipes\InsertStudies::class,
|
|
Pipes\UpdateStudies::class,
|
|
Pipes\ArchiveStudies::class,
|
|
])
|
|
->thenReturn();
|
|
}
|
|
|
|
public function getClient(): OrthancRestClient
|
|
{
|
|
return $this->client;
|
|
}
|
|
|
|
public function getStudyIds(): Collection
|
|
{
|
|
return $this->study_ids;
|
|
}
|
|
|
|
public function setStudyIds(array $study_ids): void
|
|
{
|
|
$this->study_ids = collect($study_ids);
|
|
}
|
|
|
|
public function resetQueues(): void
|
|
{
|
|
$this->insert_queue = collect();
|
|
$this->update_queue = collect();
|
|
$this->archive_queue = collect();
|
|
}
|
|
|
|
public function getInsertQueue(): Collection
|
|
{
|
|
return $this->insert_queue;
|
|
}
|
|
|
|
public function getUpdateQueue(): Collection
|
|
{
|
|
return $this->update_queue;
|
|
}
|
|
|
|
public function getArchiveQueue(): Collection
|
|
{
|
|
return $this->archive_queue;
|
|
}
|
|
|
|
public function fetchStudyDetails(string $orthanc_uuid): ?array
|
|
{
|
|
$study = $this->client->getStudyDetails($orthanc_uuid);
|
|
if ($study == null) {
|
|
return null;
|
|
}
|
|
|
|
$stats = $this->client->getStudyStatistics($orthanc_uuid);
|
|
$study['Statistics'] = $stats;
|
|
|
|
$series = $this->client->getStudySeries($orthanc_uuid);
|
|
$study['Series'] = $series;
|
|
|
|
return $study;
|
|
}
|
|
|
|
public function fetchInstancesList(string $orthanc_uuid): ?array
|
|
{
|
|
return $this->client->getStudyInstancesList($orthanc_uuid);
|
|
}
|
|
|
|
public function fetchInstancesTags(string $orthanc_uuid): ?array
|
|
{
|
|
return $this->client->getInstanceDetails($orthanc_uuid, true);
|
|
}
|
|
|
|
public function getStudyDescription(mixed $orthanc_src): ?string
|
|
{
|
|
$result = data_get($orthanc_src, 'MainDicomTags.StudyDescription');
|
|
|
|
if (blank($result)) {
|
|
$result = data_get($orthanc_src, 'RequestedTags.AcquisitionDeviceProcessingDescription');
|
|
}
|
|
|
|
if (blank($result)) {
|
|
$result = data_get($orthanc_src, 'MainDicomTags.AcquisitionDeviceProcessingDescription');
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function transformData(mixed $orthanc_src): array
|
|
{
|
|
$stable_study = (bool) data_get($orthanc_src, 'IsStable', false);
|
|
if (! $stable_study) {
|
|
// do not process unstable studies.
|
|
// defer till next sync when hopefully the study becomes stable
|
|
return [];
|
|
}
|
|
|
|
$orthanc_uuid = strtolower($orthanc_src['ID']);
|
|
$dicomHeaders = $this->getStudyDicomTags($orthanc_uuid);
|
|
$routing = DicomStudyRouter::matchStudy($dicomHeaders);
|
|
|
|
$inst_name = data_get($orthanc_src, 'MainDicomTags.InstitutionName');
|
|
$patient_name = data_get($orthanc_src, 'PatientMainDicomTags.PatientName');
|
|
$name_parts = tokenizeString($patient_name);
|
|
$patient_age = data_get($orthanc_src, 'RequestedTags.PatientAge');
|
|
if (blank($patient_age) && ! empty($name_parts)) {
|
|
// try to get age from last part of patient name
|
|
$last = end($name_parts);
|
|
if (preg_match('/^\d+[YMD]$/i', $last)) {
|
|
$patient_age = $last;
|
|
/*
|
|
// sanitize patient name
|
|
array_pop($name_parts);
|
|
$patient_name = implode(' ', $name_parts);
|
|
*/
|
|
}
|
|
}
|
|
|
|
if ($patient_age !== null) {
|
|
$age = strtoupper(ltrim($patient_age, '0'));
|
|
if (strlen($age) > 1) {
|
|
$patient_age = $age;
|
|
}
|
|
}
|
|
|
|
// $patient_name = trim($patient_name, '.^ ');
|
|
|
|
$descr = $this->getStudyDescription($orthanc_src);
|
|
$study = [
|
|
'dicom_server_id' => $this->dicomServer->id,
|
|
'orthanc_uuid' => $orthanc_uuid,
|
|
'institution_name' => $inst_name,
|
|
'organization_id' => $routing['organization_id'],
|
|
'department_id' => $routing['department_id'],
|
|
|
|
'patient_uuid' => strtolower($orthanc_src['ParentPatient']),
|
|
'patient_id' => data_get($orthanc_src, 'PatientMainDicomTags.PatientID'),
|
|
'patient_name' => $patient_name,
|
|
'patient_sex' => data_get($orthanc_src, 'PatientMainDicomTags.PatientSex'),
|
|
'patient_age' => $patient_age,
|
|
|
|
'accession_number' => data_get($orthanc_src, 'MainDicomTags.AccessionNumber'),
|
|
'referring_physician_name' => data_get($orthanc_src, 'MainDicomTags.ReferringPhysicianName'),
|
|
'study_id' => data_get($orthanc_src, 'MainDicomTags.StudyID'),
|
|
'study_instance_uid' => data_get($orthanc_src, 'MainDicomTags.StudyInstanceUID'),
|
|
'modality' => data_get($orthanc_src, 'RequestedTags.Modality'),
|
|
'body_part_examined' => data_get($orthanc_src, 'RequestedTags.BodyPartExamined'),
|
|
|
|
'study_date' => DicomUtils::dateTimeToCarbon($orthanc_src['MainDicomTags']['StudyDate'], $orthanc_src['MainDicomTags']['StudyTime']) ?? Carbon::createFromTimestamp(0),
|
|
'received_at' => Carbon::parse($orthanc_src['LastUpdate'], 'UTC'),
|
|
|
|
'image_count' => data_get($orthanc_src, 'Statistics.CountInstances'),
|
|
'series_count' => data_get($orthanc_src, 'Statistics.CountSeries'),
|
|
'disk_size' => data_get($orthanc_src, 'Statistics.DiskSize'),
|
|
|
|
'study_description' => $descr,
|
|
];
|
|
|
|
$study['workflow_level'] = $stable_study
|
|
? WorkflowLevel::Unassigned->value
|
|
: WorkflowLevel::Received->value;
|
|
|
|
$patient_birthdate = null;
|
|
$dob = data_get($orthanc_src, 'PatientMainDicomTags.PatientBirthDate');
|
|
if (filled($dob)) {
|
|
try {
|
|
$patient_birthdate = Carbon::parse($dob);
|
|
} catch (Exception $e) {
|
|
Log::error('Failed to parse PatientMainDicomTags.PatientBirthDate: {dob}', ['dob' => $dob, 'exception' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
if ($patient_birthdate == null && $patient_age !== null) {
|
|
try {
|
|
// $age = (int) preg_replace('/[^0-9]/', '', $patient_age);
|
|
$age_num = (int) filter_var($patient_age, FILTER_SANITIZE_NUMBER_INT);
|
|
$now = now();
|
|
switch (strtoupper(substr($patient_age, -1))) {
|
|
case 'Y':
|
|
$patient_birthdate = $now->subYears($age_num);
|
|
break;
|
|
case 'M':
|
|
$patient_birthdate = $now->subMonths($age_num);
|
|
break;
|
|
case 'D':
|
|
$patient_birthdate = $now->subDays($age_num);
|
|
break;
|
|
}
|
|
} catch (Exception) {
|
|
Log::error('Failed to parse patient_age: {age}', ['age' => $patient_age]);
|
|
}
|
|
}
|
|
|
|
$study['patient_birthdate'] = $patient_birthdate;
|
|
|
|
// check for priority in patient name or description
|
|
if (preg_match('/\b(urgent|stat)\b/i', implode(' ', [$descr, $patient_name]))) {
|
|
$this->setValue($study, 'priority', Priority::Stat->value);
|
|
}
|
|
|
|
$properties = [
|
|
'other_patient_names' => data_get($orthanc_src, 'RequestedTags.OtherPatientNames'),
|
|
'other_patient_ids' => data_get($orthanc_src, 'RequestedTags.OtherPatientIDs'),
|
|
'software_versions' => data_get($orthanc_src, 'RequestedTags.SoftwareVersions'),
|
|
'station_name' => data_get($orthanc_src, 'RequestedTags.StationName'),
|
|
'operators_name' => data_get($orthanc_src, 'RequestedTags.OperatorsName'),
|
|
'manufacturer' => data_get($orthanc_src, 'RequestedTags.Manufacturer'),
|
|
'manufacturer_model_name' => data_get($orthanc_src, 'RequestedTags.ManufacturerModelName'),
|
|
'acquisition_date' => DicomUtils::dateTimeToCarbon(data_get($orthanc_src, 'RequestedTags.AcquisitionDate'), data_get($orthanc_src, 'RequestedTags.AcquisitionTime')),
|
|
];
|
|
$properties = array_purge($properties);
|
|
if (empty($properties)) {
|
|
$properties = [
|
|
'other_patient_names' => data_get($orthanc_src, 'MainDicomTags.OtherPatientNames'),
|
|
'other_patient_ids' => data_get($orthanc_src, 'MainDicomTags.OtherPatientIDs'),
|
|
'software_versions' => data_get($orthanc_src, 'MainDicomTags.SoftwareVersions'),
|
|
'station_name' => data_get($orthanc_src, 'MainDicomTags.StationName'),
|
|
'operators_name' => data_get($orthanc_src, 'MainDicomTags.OperatorsName'),
|
|
'manufacturer' => data_get($orthanc_src, 'MainDicomTags.Manufacturer'),
|
|
'manufacturer_model_name' => data_get($orthanc_src, 'MainDicomTags.ManufacturerModelName'),
|
|
'acquisition_date' => DicomUtils::dateTimeToCarbon(data_get($orthanc_src, 'MainDicomTags.AcquisitionDate'), data_get($orthanc_src, 'MainDicomTags.AcquisitionTime')),
|
|
];
|
|
$properties = array_purge($properties);
|
|
}
|
|
|
|
$series = [];
|
|
foreach (data_get($orthanc_src, 'Series', []) as $ser) {
|
|
$params = [
|
|
'orthanc_uuid' => strtolower($ser['ID']),
|
|
'series_instance_uid' => data_get($ser, 'MainDicomTags.SeriesInstanceUID'),
|
|
'series_date' => DicomUtils::dateTimeToCarbon(data_get($ser, 'MainDicomTags.SeriesDate'), data_get($ser, 'MainDicomTags.SeriesTime')),
|
|
'series_number' => data_get($ser, 'MainDicomTags.SeriesNumber'),
|
|
'series_description' => data_get($ser, 'MainDicomTags.SeriesDescription'),
|
|
'protocol_name' => data_get($ser, 'MainDicomTags.ProtocolName'),
|
|
'modality' => data_get($ser, 'MainDicomTags.Modality'),
|
|
'body_part_examined' => data_get($ser, 'MainDicomTags.BodyPartExamined'),
|
|
'performed_procedure_step_description' => data_get($ser, 'MainDicomTags.PerformedProcedureStepDescription'),
|
|
'sequence_name' => data_get($ser, 'MainDicomTags.SequenceName'),
|
|
];
|
|
$params['num_instances'] = count(data_get($ser, 'Instances', []));
|
|
$params = array_purge($params);
|
|
if (! empty($params)) {
|
|
$series[] = $params;
|
|
}
|
|
}
|
|
|
|
if (empty($series)) {
|
|
$series = null;
|
|
} else {
|
|
// $series = array_multisort(array_column($series, 'series_number'), SORT_ASC, $series);
|
|
usort($series, fn ($a, $b): int => (int) $a['series_number'] <=> (int) $b['series_number']);
|
|
}
|
|
if (empty($properties)) {
|
|
$properties = null;
|
|
}
|
|
|
|
$dicom_properties = array_purge([
|
|
'patient_id' => $study['patient_id'],
|
|
'patient_name' => $study['patient_name'],
|
|
'patient_birthdate' => $study['patient_birthdate'],
|
|
'patient_age' => $study['patient_age'],
|
|
'patient_sex' => $study['patient_sex'],
|
|
'accession_number' => $study['accession_number'],
|
|
'referring_physician_name' => $study['referring_physician_name'],
|
|
'study_id' => $study['study_id'],
|
|
'body_part_examined' => $study['body_part_examined'],
|
|
'study_date' => $study['study_date'],
|
|
'study_description' => $study['study_description'],
|
|
]);
|
|
|
|
$details = array_purge(compact('properties', 'series', 'dicom_properties'));
|
|
$study = array_purge($study);
|
|
|
|
// todo: handle $routing['radiologists']
|
|
|
|
return compact('study', 'details');
|
|
}
|
|
|
|
private function getStudyDicomTags(string $study_uuid): array
|
|
{
|
|
$instances = $this->fetchInstancesList($study_uuid);
|
|
if (empty($instances)) {
|
|
return [];
|
|
}
|
|
|
|
// randomly sample few instances for tags collection
|
|
$selectedInstances = count($instances) <= $this->maxInstances
|
|
? $instances
|
|
: array_intersect_key($instances, array_flip(array_rand($instances, $this->maxInstances)));
|
|
|
|
$tags = collect();
|
|
foreach ($selectedInstances as $instance) {
|
|
foreach ($this->fetchInstancesTags($instance) as $key => $value) {
|
|
if ($key == 'MainDicomTags' || $key == 'RequestedTags') {
|
|
foreach ($value as $tag => $val) {
|
|
$dcmTag = RawDicomTags::tryFrom($tag);
|
|
if ($dcmTag != null && (! $tags->has($dcmTag->name) || $tags->get($dcmTag->name) !== $val)) {
|
|
$tags->put($dcmTag->name, $val);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $tags->toArray();
|
|
}
|
|
|
|
private function setValue(array &$array, string $key, mixed $value): void
|
|
{
|
|
if (filled($value)) {
|
|
$array[$key] = $value;
|
|
}
|
|
}
|
|
}
|