PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
thumbnail_service.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
15
21
22#include <algorithm>
23#include <cmath>
24#include <cstring>
25#include <filesystem>
26
27#ifdef PACS_JPEG_FOUND
28#include <jpeglib.h>
29#endif
30
31#ifdef PACS_PNG_FOUND
32#include <png.h>
33#endif
34
35namespace kcenon::pacs::web {
36
37// =============================================================================
38// Construction / Destruction
39// =============================================================================
40
42 std::shared_ptr<storage::index_database> database)
43 : database_(std::move(database)) {}
44
46
47// =============================================================================
48// Thumbnail Generation
49// =============================================================================
50
52 std::string_view sop_instance_uid, const thumbnail_params& params) {
53 // Validate parameters
54 if (params.size != 64 && params.size != 128 && params.size != 256 &&
55 params.size != 512) {
56 return thumbnail_result::error("Invalid size: must be 64, 128, 256, or 512");
57 }
58
59 if (params.format != "jpeg" && params.format != "png") {
60 return thumbnail_result::error("Invalid format: must be jpeg or png");
61 }
62
63 if (params.quality < 1 || params.quality > 100) {
64 return thumbnail_result::error("Invalid quality: must be 1-100");
65 }
66
67 // Check database is available
68 if (database_ == nullptr) {
69 return thumbnail_result::error("Database not configured");
70 }
71
72 // Build cache key
73 cache_key key{
74 std::string(sop_instance_uid), params.size, params.format,
75 params.quality, params.frame};
76
77 // Check cache first (read lock)
78 {
79 std::shared_lock lock(cache_mutex_);
80 auto it = cache_.find(key);
81 if (it != cache_.end()) {
82 // Update last accessed time
83 const_cast<thumbnail_cache_entry&>(it->second).last_accessed =
84 std::chrono::system_clock::now();
85 return thumbnail_result::ok(it->second);
86 }
87 }
88
89 // Find instance in database
90 auto instance = database_->find_instance(sop_instance_uid);
91 if (!instance) {
92 return thumbnail_result::error("Instance not found");
93 }
94
95 // Check if file exists
96 if (!std::filesystem::exists(instance->file_path)) {
97 return thumbnail_result::error("DICOM file not found");
98 }
99
100 // Generate thumbnail
101 auto thumbnail_data = generate_thumbnail(instance->file_path, params);
102 if (thumbnail_data.empty()) {
103 return thumbnail_result::error("Failed to generate thumbnail");
104 }
105
106 // Create cache entry
108 entry.data = std::move(thumbnail_data);
109 entry.content_type = get_content_type(params.format);
110 entry.created_at = std::chrono::system_clock::now();
111 entry.last_accessed = entry.created_at;
112
113 // Store in cache (write lock)
114 {
115 std::unique_lock lock(cache_mutex_);
116
117 // Check if we need to evict entries
118 while (current_cache_size_ + entry.data.size() > max_cache_size_ &&
119 !cache_.empty()) {
120 evict_lru();
121 }
122
123 current_cache_size_ += entry.data.size();
124 cache_[key] = entry;
125 }
126
127 return thumbnail_result::ok(std::move(entry));
128}
129
131 std::string_view series_uid, const thumbnail_params& params) {
132 auto sop_uid = select_representative_instance(series_uid);
133 if (!sop_uid) {
134 return thumbnail_result::error("No instances found in series");
135 }
136
137 return get_thumbnail(*sop_uid, params);
138}
139
141 std::string_view study_uid, const thumbnail_params& params) {
142 auto series_uid = select_representative_series(study_uid);
143 if (!series_uid) {
144 return thumbnail_result::error("No series found in study");
145 }
146
147 return get_series_thumbnail(*series_uid, params);
148}
149
150// =============================================================================
151// Cache Management
152// =============================================================================
153
155 std::unique_lock lock(cache_mutex_);
156 cache_.clear();
158}
159
160void thumbnail_service::clear_cache(std::string_view sop_instance_uid) {
161 std::unique_lock lock(cache_mutex_);
162
163 // Remove all entries for this instance (any size/format/quality)
164 for (auto it = cache_.begin(); it != cache_.end();) {
165 if (it->first.uid == sop_instance_uid) {
166 current_cache_size_ -= it->second.data.size();
167 it = cache_.erase(it);
168 } else {
169 ++it;
170 }
171 }
172}
173
175 std::shared_lock lock(cache_mutex_);
176 return current_cache_size_;
177}
178
180 std::shared_lock lock(cache_mutex_);
181 return cache_.size();
182}
183
185 std::unique_lock lock(cache_mutex_);
186 max_cache_size_ = max_bytes;
187
188 // Evict if over limit
189 while (current_cache_size_ > max_cache_size_ && !cache_.empty()) {
190 evict_lru();
191 }
192}
193
195 std::shared_lock lock(cache_mutex_);
196 return max_cache_size_;
197}
198
199// =============================================================================
200// Internal Methods
201// =============================================================================
202
204 std::string_view file_path, const thumbnail_params& params) {
205 using namespace kcenon::pacs::core;
206
207 // Open DICOM file
208 auto result = dicom_file::open(std::filesystem::path(file_path));
209 if (result.is_err()) {
210 return {};
211 }
212
213 auto& file = result.value();
214 const auto& dataset = file.dataset();
215
216 // Get image dimensions
217 auto rows = dataset.get_numeric<uint16_t>(tags::rows);
218 auto columns = dataset.get_numeric<uint16_t>(tags::columns);
219
220 if (!rows || !columns || *rows == 0 || *columns == 0) {
221 return {};
222 }
223
224 // Get bits information
225 auto bits_allocated =
226 dataset.get_numeric<uint16_t>(dicom_tag{0x0028, 0x0100});
227 auto bits_stored = dataset.get_numeric<uint16_t>(dicom_tag{0x0028, 0x0101});
228 auto samples_per_pixel = dataset.get_numeric<uint16_t>(tags::samples_per_pixel);
229 auto pixel_representation =
230 dataset.get_numeric<uint16_t>(dicom_tag{0x0028, 0x0103});
231
232 if (!bits_allocated || !bits_stored) {
233 return {};
234 }
235
236 uint16_t spp = samples_per_pixel.value_or(1);
237 uint16_t repr = pixel_representation.value_or(0);
238
239 // Get photometric interpretation
240 auto photometric = dataset.get_string(tags::photometric_interpretation);
241
242 // Get pixel data
243 auto* pixel_element = dataset.get(tags::pixel_data);
244 if (pixel_element == nullptr) {
245 return {};
246 }
247
248 auto raw_data = pixel_element->raw_data();
249 if (raw_data.empty()) {
250 return {};
251 }
252
253 // Calculate frame offset for multi-frame images
254 size_t frame_size = static_cast<size_t>(*rows) * (*columns) * spp *
255 ((*bits_allocated + 7) / 8);
256
257 uint32_t frame_index = params.frame > 0 ? params.frame - 1 : 0;
258 size_t frame_offset = frame_index * frame_size;
259
260 if (frame_offset + frame_size > raw_data.size()) {
261 // Fall back to first frame
262 frame_offset = 0;
263 }
264
265 // Convert to 8-bit grayscale or RGB
266 std::vector<uint8_t> pixels;
267 size_t num_pixels = static_cast<size_t>(*rows) * (*columns) * spp;
268
269 if (*bits_allocated == 16) {
270 // 16-bit to 8-bit conversion with auto window/level
271 int min_val = 65535;
272 int max_val = -65536;
273
274 // Find min/max for windowing
275 for (size_t i = 0; i < num_pixels; ++i) {
276 size_t idx = frame_offset + i * 2;
277 if (idx + 1 >= raw_data.size()) break;
278
279 int16_t pixel;
280 if (repr == 0) {
281 pixel = static_cast<int16_t>(raw_data[idx] |
282 (raw_data[idx + 1] << 8));
283 } else {
284 pixel = static_cast<int16_t>(raw_data[idx] |
285 (raw_data[idx + 1] << 8));
286 }
287 min_val = std::min(min_val, static_cast<int>(pixel));
288 max_val = std::max(max_val, static_cast<int>(pixel));
289 }
290
291 double window_width = static_cast<double>(max_val - min_val);
292 double window_center = static_cast<double>(min_val + max_val) / 2.0;
293 if (window_width < 1) window_width = 1;
294
295 pixels.resize(num_pixels);
296 for (size_t i = 0; i < num_pixels; ++i) {
297 size_t idx = frame_offset + i * 2;
298 if (idx + 1 >= raw_data.size()) break;
299
300 int16_t pixel = static_cast<int16_t>(raw_data[idx] |
301 (raw_data[idx + 1] << 8));
302
303 double lower = window_center - window_width / 2.0;
304 double upper = window_center + window_width / 2.0;
305
306 if (pixel <= lower) {
307 pixels[i] = 0;
308 } else if (pixel >= upper) {
309 pixels[i] = 255;
310 } else {
311 pixels[i] = static_cast<uint8_t>(
312 ((pixel - lower) / window_width) * 255.0);
313 }
314 }
315 } else {
316 // 8-bit: direct copy
317 pixels.resize(num_pixels);
318 for (size_t i = 0; i < num_pixels; ++i) {
319 size_t idx = frame_offset + i;
320 if (idx < raw_data.size()) {
321 pixels[i] = raw_data[idx];
322 }
323 }
324 }
325
326 // Handle MONOCHROME1 inversion
327 if (photometric == "MONOCHROME1") {
328 for (auto& p : pixels) {
329 p = static_cast<uint8_t>(255 - p);
330 }
331 }
332
333 // Resize to target size
334 uint16_t src_width = *columns;
335 uint16_t src_height = *rows;
336 uint16_t dst_size = params.size;
337
338 // Calculate destination dimensions maintaining aspect ratio
339 uint16_t dst_width, dst_height;
340 if (src_width > src_height) {
341 dst_width = dst_size;
342 dst_height =
343 static_cast<uint16_t>(static_cast<float>(src_height) / src_width * dst_size);
344 } else {
345 dst_height = dst_size;
346 dst_width =
347 static_cast<uint16_t>(static_cast<float>(src_width) / src_height * dst_size);
348 }
349
350 if (dst_width == 0) dst_width = 1;
351 if (dst_height == 0) dst_height = 1;
352
353 // Simple bilinear resize
354 std::vector<uint8_t> resized(static_cast<size_t>(dst_width) * dst_height * spp);
355
356 float x_ratio = static_cast<float>(src_width) / dst_width;
357 float y_ratio = static_cast<float>(src_height) / dst_height;
358
359 for (uint16_t y = 0; y < dst_height; ++y) {
360 for (uint16_t x = 0; x < dst_width; ++x) {
361 float src_x = x * x_ratio;
362 float src_y = y * y_ratio;
363
364 int x0 = static_cast<int>(src_x);
365 int y0 = static_cast<int>(src_y);
366 int x1 = std::min(x0 + 1, static_cast<int>(src_width - 1));
367 int y1 = std::min(y0 + 1, static_cast<int>(src_height - 1));
368
369 float x_diff = src_x - x0;
370 float y_diff = src_y - y0;
371
372 for (uint16_t c = 0; c < spp; ++c) {
373 size_t idx00 = (static_cast<size_t>(y0) * src_width + x0) * spp + c;
374 size_t idx01 = (static_cast<size_t>(y0) * src_width + x1) * spp + c;
375 size_t idx10 = (static_cast<size_t>(y1) * src_width + x0) * spp + c;
376 size_t idx11 = (static_cast<size_t>(y1) * src_width + x1) * spp + c;
377
378 float v00 = idx00 < pixels.size() ? pixels[idx00] : 0;
379 float v01 = idx01 < pixels.size() ? pixels[idx01] : 0;
380 float v10 = idx10 < pixels.size() ? pixels[idx10] : 0;
381 float v11 = idx11 < pixels.size() ? pixels[idx11] : 0;
382
383 float value = v00 * (1 - x_diff) * (1 - y_diff) +
384 v01 * x_diff * (1 - y_diff) +
385 v10 * (1 - x_diff) * y_diff +
386 v11 * x_diff * y_diff;
387
388 size_t dst_idx =
389 (static_cast<size_t>(y) * dst_width + x) * spp + c;
390 resized[dst_idx] = static_cast<uint8_t>(std::clamp(value, 0.0f, 255.0f));
391 }
392 }
393 }
394
395 // Encode to output format
396 std::vector<uint8_t> output;
397
398 if (params.format == "jpeg") {
399#ifdef PACS_JPEG_FOUND
400 // JPEG encoding
401 struct jpeg_compress_struct cinfo {};
402 struct jpeg_error_mgr jerr {};
403
404 cinfo.err = jpeg_std_error(&jerr);
405 jpeg_create_compress(&cinfo);
406
407 // Memory destination
408 unsigned char* outbuffer = nullptr;
409 unsigned long outsize = 0;
410 jpeg_mem_dest(&cinfo, &outbuffer, &outsize);
411
412 cinfo.image_width = dst_width;
413 cinfo.image_height = dst_height;
414 cinfo.input_components = static_cast<int>(spp);
415 cinfo.in_color_space = (spp == 1) ? JCS_GRAYSCALE : JCS_RGB;
416
417 jpeg_set_defaults(&cinfo);
418 jpeg_set_quality(&cinfo, params.quality, TRUE);
419 jpeg_start_compress(&cinfo, TRUE);
420
421 JSAMPROW row_pointer[1];
422 int row_stride = dst_width * spp;
423
424 while (cinfo.next_scanline < cinfo.image_height) {
425 row_pointer[0] = &resized[cinfo.next_scanline * static_cast<size_t>(row_stride)];
426 jpeg_write_scanlines(&cinfo, row_pointer, 1);
427 }
428
429 jpeg_finish_compress(&cinfo);
430 jpeg_destroy_compress(&cinfo);
431
432 if (outbuffer != nullptr && outsize > 0) {
433 output.assign(outbuffer, outbuffer + outsize);
434 free(outbuffer);
435 }
436#else
437 // Fallback: return empty if JPEG not available
438 return {};
439#endif
440 } else if (params.format == "png") {
441#ifdef PACS_PNG_FOUND
442 // PNG encoding using libpng memory I/O
443 struct png_mem_buffer {
444 std::vector<uint8_t> data;
445 };
446
447 auto write_callback = [](png_structp png_ptr, png_bytep data,
448 png_size_t length) {
449 auto* buffer =
450 static_cast<png_mem_buffer*>(png_get_io_ptr(png_ptr));
451 buffer->data.insert(buffer->data.end(), data, data + length);
452 };
453
454 auto flush_callback = [](png_structp /*png_ptr*/) {
455 // No-op for memory buffer
456 };
457
458 png_mem_buffer buffer;
459
460 png_structp png_ptr = png_create_write_struct(
461 PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
462 if (png_ptr == nullptr) {
463 return {};
464 }
465
466 png_infop info_ptr = png_create_info_struct(png_ptr);
467 if (info_ptr == nullptr) {
468 png_destroy_write_struct(&png_ptr, nullptr);
469 return {};
470 }
471
472 if (setjmp(png_jmpbuf(png_ptr))) {
473 png_destroy_write_struct(&png_ptr, &info_ptr);
474 return {};
475 }
476
477 png_set_write_fn(png_ptr, &buffer, write_callback, flush_callback);
478
479 int color_type = (spp == 1) ? PNG_COLOR_TYPE_GRAY : PNG_COLOR_TYPE_RGB;
480 png_set_IHDR(png_ptr, info_ptr, dst_width, dst_height, 8, color_type,
481 PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT,
482 PNG_FILTER_TYPE_DEFAULT);
483
484 png_write_info(png_ptr, info_ptr);
485
486 int row_stride = dst_width * spp;
487 for (uint16_t y = 0; y < dst_height; ++y) {
488 png_bytep row = &resized[static_cast<size_t>(y) * row_stride];
489 png_write_row(png_ptr, row);
490 }
491
492 png_write_end(png_ptr, nullptr);
493 png_destroy_write_struct(&png_ptr, &info_ptr);
494
495 output = std::move(buffer.data);
496#else
497 // Fallback: return empty if PNG not available
498 return {};
499#endif
500 }
501
502 return output;
503}
504
506 std::string_view series_uid) {
507 auto instances_result = database_->list_instances(series_uid);
508 if (instances_result.is_err() || instances_result.value().empty()) {
509 return std::nullopt;
510 }
511
512 const auto& instances = instances_result.value();
513
514 // Sort by instance number and select middle instance
515 std::vector<std::pair<int, std::string>> sorted;
516 for (const auto& inst : instances) {
517 int num = inst.instance_number.value_or(1);
518 sorted.emplace_back(num, inst.sop_uid);
519 }
520
521 std::sort(sorted.begin(), sorted.end(),
522 [](const auto& a, const auto& b) { return a.first < b.first; });
523
524 // Select middle instance
525 size_t middle = sorted.size() / 2;
526 return sorted[middle].second;
527}
528
530 std::string_view study_uid) {
531 auto series_result = database_->list_series(study_uid);
532 if (series_result.is_err() || series_result.value().empty()) {
533 return std::nullopt;
534 }
535
536 const auto& series_list = series_result.value();
537
538 // Prefer series with images (CT, MR, CR, DX, etc.)
539 // Use first series with most instances as representative
540 const kcenon::pacs::storage::series_record* best = nullptr;
541 for (const auto& s : series_list) {
542 // Skip structured reports, KOS, etc.
543 if (s.modality == "SR" || s.modality == "KO" || s.modality == "PR") {
544 continue;
545 }
546
547 if (best == nullptr || s.num_instances > best->num_instances) {
548 best = &s;
549 }
550 }
551
552 if (best == nullptr && !series_list.empty()) {
553 // Fall back to first series
554 best = &series_list[0];
555 }
556
557 return best ? std::make_optional(best->series_uid) : std::nullopt;
558}
559
561 // Find least recently used entry
562 auto lru_it = cache_.end();
563 auto oldest_time = std::chrono::system_clock::time_point::max();
564
565 for (auto it = cache_.begin(); it != cache_.end(); ++it) {
566 if (it->second.last_accessed < oldest_time) {
567 oldest_time = it->second.last_accessed;
568 lru_it = it;
569 }
570 }
571
572 if (lru_it != cache_.end()) {
573 current_cache_size_ -= lru_it->second.data.size();
574 cache_.erase(lru_it);
575 }
576}
577
578std::string thumbnail_service::get_content_type(std::string_view format) {
579 if (format == "jpeg") {
580 return "image/jpeg";
581 } else if (format == "png") {
582 return "image/png";
583 }
584 return "application/octet-stream";
585}
586
587} // namespace kcenon::pacs::web
std::shared_ptr< storage::index_database > database_
Database for instance lookups.
thumbnail_result get_thumbnail(std::string_view sop_instance_uid, const thumbnail_params &params)
Get or generate thumbnail for a specific instance.
void evict_lru()
Evict least recently used entries to make room.
thumbnail_service(std::shared_ptr< storage::index_database > database)
Construct thumbnail service with database.
std::vector< uint8_t > generate_thumbnail(std::string_view file_path, const thumbnail_params &params)
Generate thumbnail from DICOM file.
thumbnail_result get_series_thumbnail(std::string_view series_uid, const thumbnail_params &params)
Get thumbnail for a series (representative image)
void set_max_cache_size(size_t max_bytes)
Set maximum cache size.
size_t cache_entry_count() const
Get number of cached entries.
static std::string get_content_type(std::string_view format)
Get MIME type for format.
size_t current_cache_size_
Current cache size in bytes.
size_t max_cache_size_
Maximum cache size (default: 64MB)
size_t cache_size() const
Get current cache size in bytes.
thumbnail_result get_study_thumbnail(std::string_view study_uid, const thumbnail_params &params)
Get thumbnail for a study (representative image)
size_t max_cache_size() const
Get maximum cache size.
std::optional< std::string > select_representative_series(std::string_view study_uid)
Select representative series from study.
std::optional< std::string > select_representative_instance(std::string_view series_uid)
Select representative instance from series.
std::unordered_map< cache_key, thumbnail_cache_entry, cache_key_hash > cache_
Thumbnail cache.
void clear_cache()
Clear all cached thumbnails.
std::shared_mutex cache_mutex_
Cache mutex.
DICOM Dataset - ordered collection of Data Elements.
DICOM Data Element representation (Tag, VR, Value)
DICOM Part 10 file handling for reading/writing DICOM files.
Compile-time constants for commonly used DICOM tags.
PACS index database for metadata storage and retrieval.
constexpr dicom_tag rows
Rows.
constexpr dicom_tag columns
Columns.
constexpr dicom_tag pixel_data
Pixel Data.
constexpr dicom_tag samples_per_pixel
Samples per Pixel.
constexpr dicom_tag photometric_interpretation
Photometric Interpretation.
Series record from the database.
int num_instances
Number of instances in this series (denormalized)
std::string series_uid
Series Instance UID - DICOM tag (0020,000E)
Cached thumbnail entry.
std::vector< uint8_t > data
Compressed image data.
std::chrono::system_clock::time_point created_at
When the entry was created.
std::chrono::system_clock::time_point last_accessed
When the entry was last accessed.
std::string content_type
MIME content type.
Parameters for thumbnail generation.
std::string format
Output format ("jpeg", "png")
uint16_t size
Output size in pixels (64, 128, 256, 512)
uint32_t frame
Frame number for multi-frame images (1-indexed)
int quality
Quality for lossy compression (1-100)
Result type for thumbnail operations.
static thumbnail_result ok(thumbnail_cache_entry entry)
Create a success result.
static thumbnail_result error(std::string message)
Create an error result.
Thumbnail generation service for DICOM images.