DEV Community

Cover image for Display DICOM metadata on the terminal
Hasan Aga
Hasan Aga

Posted on

Display DICOM metadata on the terminal

Here is a quick guide on how to view the metadata of DICOM files without leaving the terminal.

What is DICOM

DICOM is a file format used in the medical field. The file is similar to "PNG" but it usually has more metadata associated with it.

What is a terminal file-manager?

A terminal file-manager is an app that makes navigating the terminal easier. Instead of writing multiple cd commands we can use the arrow keys to move around. I found two file-managers, "Ranger" and "nnn" and in this post we will cover setting up Ranger to preview DICOM file metadata on the fly.

Configuring Ranger

After downloading the app, head over to ~/.config/ranger then open rc.conf and paste these lines:

set use_preview_script true
set preview_script ~/.config/ranger/scope.sh
Enter fullscreen mode Exit fullscreen mode

and create a scope.sh file then copy this sample scope.sh into it:

#!/usr/bin/env bash

set -o noclobber -o noglob -o nounset -o pipefail
IFS=$'\n'

## If the option `use_preview_script` is set to `true`,

## then this script will be called and its output will be displayed in ranger.

## ANSI color codes are supported.

## STDIN is disabled, so interactive scripts won't work properly

## This script is considered a configuration file and must be updated manually.

## It will be left untouched if you upgrade ranger.

## Because of some automated testing we do on the script #'s for comments need

## to be doubled up. Code that is commented out, because it's an alternative for

## example, gets only one #.

## Meanings of exit codes:

## code | meaning | action of ranger

## -----+------------+-------------------------------------------

## 0 | success | Display stdout as preview

## 1 | no preview | Display no preview at all

## 2 | plain text | Display the plain content of the file

## 3 | fix width | Don't reload when width changes

## 4 | fix height | Don't reload when height changes

## 5 | fix both | Don't ever reload

## 6 | image | Display the image `$IMAGE_CACHE_PATH` points to as an image preview

## 7 | image | Display the file directly as an image

## Script arguments

FILE_PATH="${1}"         # Full path of the highlighted file
PV_WIDTH="${2}" # Width of the preview pane (number of fitting characters)

## shellcheck disable=SC2034 # PV_HEIGHT is provided for convenience and unused

PV_HEIGHT="${3}"         # Height of the preview pane (number of fitting characters)
IMAGE_CACHE_PATH="${4}" # Full path that should be used to cache image preview
PV_IMAGE_ENABLED="${5}" # 'True' if image previews are enabled, 'False' otherwise.

FILE_EXTENSION="${FILE_PATH##*.}"
FILE_EXTENSION_LOWER="$(printf "%s" "${FILE_EXTENSION}" | tr '[:upper:]' '[:lower:]')"

## Settings

HIGHLIGHT_SIZE_MAX=262143 # 256KiB
HIGHLIGHT_TABWIDTH="${HIGHLIGHT_TABWIDTH:-8}"
HIGHLIGHT_STYLE="${HIGHLIGHT_STYLE:-pablo}"
HIGHLIGHT_OPTIONS="--replace-tabs=${HIGHLIGHT_TABWIDTH} --style=${HIGHLIGHT_STYLE} ${HIGHLIGHT_OPTIONS:-}"
PYGMENTIZE_STYLE="${PYGMENTIZE_STYLE:-autumn}"
BAT_STYLE="${BAT_STYLE:-plain}"
OPENSCAD_IMGSIZE="${RNGR_OPENSCAD_IMGSIZE:-1000,1000}"
OPENSCAD_COLORSCHEME="${RNGR_OPENSCAD_COLORSCHEME:-Tomorrow Night}"
SQLITE_TABLE_LIMIT=20 # Display only the top <limit> tables in database, set to 0 for no exhaustive preview (only the sqlite_master table is displayed).
SQLITE_ROW_LIMIT=5 # Display only the first and the last (<limit> - 1) records in each table, set to 0 for no limits.

handle_dicom() {
local filepath="$1"
python3 - <<EOF
import sys
import pydicom

filepath = "$filepath"
dataset = pydicom.dcmread(filepath)
print(dataset)
EOF
}

handle_extension() {
case "${FILE_EXTENSION_LOWER}" in
        ## Archive
        a|ace|alz|arc|arj|bz|bz2|cab|cpio|deb|gz|jar|lha|lz|lzh|lzma|lzo|\
        rpm|rz|t7z|tar|tbz|tbz2|tgz|tlz|txz|tZ|tzo|war|xpi|xz|Z|zip)
            atool --list -- "${FILE_PATH}" && exit 5
bsdtar --list --file "${FILE_PATH}" && exit 5
            exit 1;;
        rar)
            ## Avoid password prompt by providing empty password
            unrar lt -p- -- "${FILE_PATH}" && exit 5
exit 1;;
7z) ## Avoid password prompt by providing empty password
7z l -p -- "${FILE_PATH}" && exit 5
exit 1;;

        ## PDF
        pdf)
            ## Preview as text conversion
            pdftotext -l 10 -nopgbrk -q -- "${FILE_PATH}" - | \
              fmt -w "${PV_WIDTH}" && exit 5
            mutool draw -F txt -i -- "${FILE_PATH}" 1-10 | \
              fmt -w "${PV_WIDTH}" && exit 5
            exiftool "${FILE_PATH}" && exit 5
            exit 1;;

        ## BitTorrent
        torrent)
            transmission-show -- "${FILE_PATH}" && exit 5
            exit 1;;

        ## OpenDocument
        odt|sxw)
            ## Preview as text conversion
            odt2txt "${FILE_PATH}" && exit 5
            ## Preview as markdown conversion
            pandoc -s -t markdown -- "${FILE_PATH}" && exit 5
            exit 1;;
        ods|odp)
            ## Preview as text conversion (unsupported by pandoc for markdown)
            odt2txt "${FILE_PATH}" && exit 5
            exit 1;;

        ## XLSX
        xlsx)
            ## Preview as csv conversion
            ## Uses: https://github.com/dilshod/xlsx2csv
            xlsx2csv -- "${FILE_PATH}" && exit 5
            exit 1;;

        ## HTML
        htm|html|xhtml)
            ## Preview as text conversion
            w3m -dump "${FILE_PATH}" && exit 5
            lynx -dump -- "${FILE_PATH}" && exit 5
            elinks -dump "${FILE_PATH}" && exit 5
            pandoc -s -t markdown -- "${FILE_PATH}" && exit 5
            ;;

        ## JSON
        json)
            jq --color-output . "${FILE_PATH}" && exit 5
            python -m json.tool -- "${FILE_PATH}" && exit 5
            ;;

        ## Jupyter Notebooks
        ipynb)
            jupyter nbconvert --to markdown "${FILE_PATH}" --stdout | env COLORTERM=8bit bat --color=always --style=plain --language=markdown && exit 5
            jupyter nbconvert --to markdown "${FILE_PATH}" --stdout && exit 5
            jq --color-output . "${FILE_PATH}" && exit 5
            python -m json.tool -- "${FILE_PATH}" && exit 5
            ;;

        ## Direct Stream Digital/Transfer (DSDIFF) and wavpack aren't detected
        ## by file(1).
        dff|dsf|wv|wvc)
            mediainfo "${FILE_PATH}" && exit 5
            exiftool "${FILE_PATH}" && exit 5
            ;; # Continue with next handler on failure

        ## for dcm files
        dcm)
            handle_dicom "${FILE_PATH}" && exit 5
            ;;
    esac

}

handle_image() { ## Size of the preview if there are multiple options or it has to be ## rendered from vector graphics. If the conversion program allows ## specifying only one dimension while keeping the aspect ratio, the width ## will be used.
local DEFAULT_SIZE="1920x1080"

    local mimetype="${1}"
    case "${mimetype}" in
        ## SVG
        image/svg+xml|image/svg)
            rsvg-convert --keep-aspect-ratio --width "${DEFAULT_SIZE%x*}" "${FILE_PATH}" -o "${IMAGE_CACHE_PATH}.png" \
                && mv "${IMAGE_CACHE_PATH}.png" "${IMAGE_CACHE_PATH}" \
                && exit 6
            exit 1;;

        ## DjVu
        image/vnd.djvu)
            ddjvu -format=tiff -quality=90 -page=1 -size="${DEFAULT_SIZE}" \
                  - "${IMAGE_CACHE_PATH}" < "${FILE_PATH}" \
                  && exit 6 || exit 1;;

        ## Image
        image/*)
            local orientation
            orientation="$( identify -format '%[EXIF:Orientation]\n' -- "${FILE_PATH}" )"
            ## If orientation data is present and the image actually
            ## needs rotating ("1" means no rotation)...
            if [[ -n "$orientation" && "$orientation" != 1 ]]; then
                ## ...auto-rotate the image according to the EXIF data.
                convert -- "${FILE_PATH}" -auto-orient "${IMAGE_CACHE_PATH}" && exit 6
            fi

            ## `w3mimgdisplay` will be called for all images (unless overridden
            ## as above), but might fail for unsupported types.
            exit 7;;

        ## Video
        # video/*)
        #     # Get embedded thumbnail
        #     ffmpeg -i "${FILE_PATH}" -map 0:v -map -0:V -c copy "${IMAGE_CACHE_PATH}" && exit 6
        #     # Get frame 10% into video
        #     ffmpegthumbnailer -i "${FILE_PATH}" -o "${IMAGE_CACHE_PATH}" -s 0 && exit 6
        #     exit 1;;

        ## Audio
        # audio/*)
        #     # Get embedded thumbnail
        #     ffmpeg -i "${FILE_PATH}" -map 0:v -map -0:V -c copy \
        #       "${IMAGE_CACHE_PATH}" && exit 6;;

        ## PDF
        # application/pdf)
        #     pdftoppm -f 1 -l 1 \
        #              -scale-to-x "${DEFAULT_SIZE%x*}" \
        #              -scale-to-y -1 \
        #              -singlefile \
        #              -jpeg -tiffcompression jpeg \
        #              -- "${FILE_PATH}" "${IMAGE_CACHE_PATH%.*}" \
        #         && exit 6 || exit 1;;


        ## ePub, MOBI, FB2 (using Calibre)
        # application/epub+zip|application/x-mobipocket-ebook|\
        # application/x-fictionbook+xml)
        #     # ePub (using https://github.com/marianosimone/epub-thumbnailer)
        #     epub-thumbnailer "${FILE_PATH}" "${IMAGE_CACHE_PATH}" \
        #         "${DEFAULT_SIZE%x*}" && exit 6
        #     ebook-meta --get-cover="${IMAGE_CACHE_PATH}" -- "${FILE_PATH}" \
        #         >/dev/null && exit 6
        #     exit 1;;

        ## Font
        application/font*|application/*opentype)
            preview_png="/tmp/$(basename "${IMAGE_CACHE_PATH%.*}").png"
            if fontimage -o "${preview_png}" \
                         --pixelsize "120" \
                         --fontname \
                         --pixelsize "80" \
                         --text "  ABCDEFGHIJKLMNOPQRSTUVWXYZ  " \
                         --text "  abcdefghijklmnopqrstuvwxyz  " \
                         --text "  0123456789.:,;(*!?') ff fl fi ffi ffl  " \
                         --text "  The quick brown fox jumps over the lazy dog.  " \
                         "${FILE_PATH}";
            then
                convert -- "${preview_png}" "${IMAGE_CACHE_PATH}" \
                    && rm "${preview_png}" \
                    && exit 6
            else
                exit 1
            fi
            ;;

        ## Preview archives using the first image inside.
        ## (Very useful for comic book collections for example.)
        # application/zip|application/x-rar|application/x-7z-compressed|\
        #     application/x-xz|application/x-bzip2|application/x-gzip|application/x-tar)
        #     local fn=""; local fe=""
        #     local zip=""; local rar=""; local tar=""; local bsd=""
        #     case "${mimetype}" in
        #         application/zip) zip=1 ;;
        #         application/x-rar) rar=1 ;;
        #         application/x-7z-compressed) ;;
        #         *) tar=1 ;;
        #     esac
        #     { [ "$tar" ] && fn=$(tar --list --file "${FILE_PATH}"); } || \
        #     { fn=$(bsdtar --list --file "${FILE_PATH}") && bsd=1 && tar=""; } || \
        #     { [ "$rar" ] && fn=$(unrar lb -p- -- "${FILE_PATH}"); } || \
        #     { [ "$zip" ] && fn=$(zipinfo -1 -- "${FILE_PATH}"); } || return
        #
        #     fn=$(echo "$fn" | python -c "from __future__ import print_function; \
        #             import sys; import mimetypes as m; \
        #             [ print(l, end='') for l in sys.stdin if \
        #               (m.guess_type(l[:-1])[0] or '').startswith('image/') ]" |\
        #         sort -V | head -n 1)
        #     [ "$fn" = "" ] && return
        #     [ "$bsd" ] && fn=$(printf '%b' "$fn")
        #
        #     [ "$tar" ] && tar --extract --to-stdout \
        #         --file "${FILE_PATH}" -- "$fn" > "${IMAGE_CACHE_PATH}" && exit 6
        #     fe=$(echo -n "$fn" | sed 's/[][*?\]/\\\0/g')
        #     [ "$bsd" ] && bsdtar --extract --to-stdout \
        #         --file "${FILE_PATH}" -- "$fe" > "${IMAGE_CACHE_PATH}" && exit 6
        #     [ "$bsd" ] || [ "$tar" ] && rm -- "${IMAGE_CACHE_PATH}"
        #     [ "$rar" ] && unrar p -p- -inul -- "${FILE_PATH}" "$fn" > \
        #         "${IMAGE_CACHE_PATH}" && exit 6
        #     [ "$zip" ] && unzip -pP "" -- "${FILE_PATH}" "$fe" > \
        #         "${IMAGE_CACHE_PATH}" && exit 6
        #     [ "$rar" ] || [ "$zip" ] && rm -- "${IMAGE_CACHE_PATH}"
        #     ;;
    esac

    # openscad_image() {
    #     TMPPNG="$(mktemp -t XXXXXX.png)"
    #     openscad --colorscheme="${OPENSCAD_COLORSCHEME}" \
    #         --imgsize="${OPENSCAD_IMGSIZE/x/,}" \
    #         -o "${TMPPNG}" "${1}"
    #     mv "${TMPPNG}" "${IMAGE_CACHE_PATH}"
    # }

    case "${FILE_EXTENSION_LOWER}" in
       ## 3D models
       ## OpenSCAD only supports png image output, and ${IMAGE_CACHE_PATH}
       ## is hardcoded as jpeg. So we make a tempfile.png and just
       ## move/rename it to jpg. This works because image libraries are
       ## smart enough to handle it.
       # csg|scad)
       #     openscad_image "${FILE_PATH}" && exit 6
       #     ;;
       # 3mf|amf|dxf|off|stl)
       #     openscad_image <(echo "import(\"${FILE_PATH}\");") && exit 6
       #     ;;
       drawio)
           draw.io -x "${FILE_PATH}" -o "${IMAGE_CACHE_PATH}" \
               --width "${DEFAULT_SIZE%x*}" && exit 6
           exit 1;;
    esac

}

handle_mime() {
local mimetype="${1}"
    case "${mimetype}" in ## RTF and DOC
text/rtf|\*msword) ## Preview as text conversion ## note: catdoc does not always work for .doc files ## catdoc: http://www.wagner.pp.ru/~vitus/software/catdoc/
catdoc -- "${FILE_PATH}" && exit 5
exit 1;;

        ## DOCX, ePub, FB2 (using markdown)
        ## You might want to remove "|epub" and/or "|fb2" below if you have
        ## uncommented other methods to preview those formats
        *wordprocessingml.document|*/epub+zip|*/x-fictionbook+xml)
            ## Preview as markdown conversion
            pandoc -s -t markdown -- "${FILE_PATH}" && exit 5
            exit 1;;

        ## E-mails
        message/rfc822)
            ## Parsing performed by mu: https://github.com/djcb/mu
            mu view -- "${FILE_PATH}" && exit 5
            exit 1;;

        ## XLS
        *ms-excel)
            ## Preview as csv conversion
            ## xls2csv comes with catdoc:
            ##   http://www.wagner.pp.ru/~vitus/software/catdoc/
            xls2csv -- "${FILE_PATH}" && exit 5
            exit 1;;

        ## SQLite
        *sqlite3)
            ## Preview as text conversion
            sqlite_tables="$( sqlite3 "file:${FILE_PATH}?mode=ro" '.tables' )" \
                || exit 1
            [ -z "${sqlite_tables}" ] &&
                { echo "Empty SQLite database." && exit 5; }
            sqlite_show_query() {
                sqlite-utils query "${FILE_PATH}" "${1}" --table --fmt fancy_grid \
                || sqlite3 "file:${FILE_PATH}?mode=ro" "${1}" -header -column
            }
            ## Display basic table information
            sqlite_rowcount_query="$(
                sqlite3 "file:${FILE_PATH}?mode=ro" -noheader \
                    'SELECT group_concat(
                        "SELECT """ || name || """ AS tblname,
                                          count(*) AS rowcount
                         FROM " || name,
                        " UNION ALL "
                    )
                    FROM sqlite_master
                    WHERE type="table" AND name NOT LIKE "sqlite_%";'
            )"
            sqlite_show_query \
                "SELECT tblname AS 'table', rowcount AS 'count',
                (
                    SELECT '(' || group_concat(name, ', ') || ')'
                    FROM pragma_table_info(tblname)
                ) AS 'columns',
                (
                    SELECT '(' || group_concat(
                        upper(type) || (
                            CASE WHEN pk > 0 THEN ' PRIMARY KEY' ELSE '' END
                        ),
                        ', '
                    ) || ')'
                    FROM pragma_table_info(tblname)
                ) AS 'types'
                FROM (${sqlite_rowcount_query});"
            if [ "${SQLITE_TABLE_LIMIT}" -gt 0 ] &&
               [ "${SQLITE_ROW_LIMIT}" -ge 0 ]; then
                ## Do exhaustive preview
                echo && printf '>%.0s' $( seq "${PV_WIDTH}" ) && echo
                sqlite3 "file:${FILE_PATH}?mode=ro" -noheader \
                    "SELECT name FROM sqlite_master
                    WHERE type='table' AND name NOT LIKE 'sqlite_%'
                    LIMIT ${SQLITE_TABLE_LIMIT};" |
                    while read -r sqlite_table; do
                        sqlite_rowcount="$(
                            sqlite3 "file:${FILE_PATH}?mode=ro" -noheader \
                                "SELECT count(*) FROM ${sqlite_table}"
                        )"
                        echo
                        if [ "${SQLITE_ROW_LIMIT}" -gt 0 ] &&
                           [ "${SQLITE_ROW_LIMIT}" \
                             -lt "${sqlite_rowcount}" ]; then
                            echo "${sqlite_table} [${SQLITE_ROW_LIMIT} of ${sqlite_rowcount}]:"
                            sqlite_ellipsis_query="$(
                                sqlite3 "file:${FILE_PATH}?mode=ro" -noheader \
                                    "SELECT 'SELECT ' || group_concat(
                                        '''...''', ', '
                                    )
                                    FROM pragma_table_info(
                                        '${sqlite_table}'
                                    );"
                            )"
                            sqlite_show_query \
                                "SELECT * FROM (
                                    SELECT * FROM ${sqlite_table} LIMIT 1
                                )
                                UNION ALL ${sqlite_ellipsis_query} UNION ALL
                                SELECT * FROM (
                                    SELECT * FROM ${sqlite_table}
                                    LIMIT (${SQLITE_ROW_LIMIT} - 1)
                                    OFFSET (
                                        ${sqlite_rowcount}
                                        - (${SQLITE_ROW_LIMIT} - 1)
                                    )
                                );"
                        else
                            echo "${sqlite_table} [${sqlite_rowcount}]:"
                            sqlite_show_query "SELECT * FROM ${sqlite_table};"
                        fi
                    done
            fi
            exit 5;;

        ## Text
        text/* | */xml)
            ## Syntax highlight
            if [[ "$( stat --printf='%s' -- "${FILE_PATH}" )" -gt "${HIGHLIGHT_SIZE_MAX}" ]]; then
                exit 2
            fi
            if [[ "$( tput colors )" -ge 256 ]]; then
                local pygmentize_format='terminal256'
                local highlight_format='xterm256'
            else
                local pygmentize_format='terminal'
                local highlight_format='ansi'
            fi
            env HIGHLIGHT_OPTIONS="${HIGHLIGHT_OPTIONS}" highlight \
                --out-format="${highlight_format}" \
                --force -- "${FILE_PATH}" && exit 5
            env COLORTERM=8bit bat --color=always --style="${BAT_STYLE}" \
                -- "${FILE_PATH}" && exit 5
            pygmentize -f "${pygmentize_format}" -O "style=${PYGMENTIZE_STYLE}"\
                -- "${FILE_PATH}" && exit 5
            exit 2;;

        ## DjVu
        image/vnd.djvu)
            ## Preview as text conversion (requires djvulibre)
            djvutxt "${FILE_PATH}" | fmt -w "${PV_WIDTH}" && exit 5
            exiftool "${FILE_PATH}" && exit 5
            exit 1;;

        ## Image
        image/*)
            ## Preview as text conversion
            # img2txt --gamma=0.6 --width="${PV_WIDTH}" -- "${FILE_PATH}" && exit 4
            exiftool "${FILE_PATH}" && exit 5
            exit 1;;

        ## Video and audio
        video/* | audio/*)
            mediainfo "${FILE_PATH}" && exit 5
            exiftool "${FILE_PATH}" && exit 5
            exit 1;;

        ## ELF files (executables and shared objects)
        application/x-executable | application/x-pie-executable | application/x-sharedlib)
            readelf -WCa "${FILE_PATH}" && exit 5
            exit 1;;
    esac

}

handle_fallback() {
echo '----- File Type Classification -----' && file --dereference --brief -- "${FILE_PATH}" && exit 5
}

MIMETYPE="$( file --dereference --brief --mime-type -- "${FILE_PATH}" )"
if [["${PV_IMAGE_ENABLED}" == 'True']]; then
handle_image "${MIMETYPE}"
fi
handle_extension
handle_mime "${MIMETYPE}"
handle_fallback

exit 1

Enter fullscreen mode Exit fullscreen mode

The above file is the default scope.sh with the following parts added to enable previewing DICOM metadata:

  1. The handle_dicom function which uses the Pydicom Python library to open the file and read its metadata.
  2. The dcm case inside handle_extension function

Needless to say that you will need to install Pydicom on your system for this to work, you can do that using PIP or Conda.


Enter fullscreen mode Exit fullscreen mode

Top comments (3)

Collapse
 
gokayburuc profile image
gokayburuc.dev

Thanks for your valuable article.

Just an idea , maybe next time you can improve this script with fzf & fd .

  • fzf : with code preview options fuzzy file finder
  • fd : To improve fzf performance you can use this file finder
  • ripgrep : improved word grabbing app written in rust for terminal just like sed, awk

Altogether can be awesome nuclear fusion power for your code.

Collapse
 
hasanaga profile image
Hasan Aga

Thanks for the suggestions. Actually I need to distribute this feature in a less intrusive way. Copying files manually is not ideal IMO. Any idea on how to do that?

Collapse
 
gokayburuc profile image
gokayburuc.dev

my bash file mover. Tab selects files. Fuzzy finder also works. you can add your own preview options also.


## INFO: FZF MULTI MOVER

fmovep(){
# you can change the --preview part according to your functions 
  fd --full-path | fzf --multi --reverse --preview="cat {}" | xargs -I {} mv -t "$1" "{}"  
}

## INFO: FZF MULTI MOVER NO PREVIEW 

fmove(){
    fd --full-path | fzf --multi --reverse | xargs -I {} mv -t "$1" "{}"  
}
Enter fullscreen mode Exit fullscreen mode

and also you can create a pattern for your file types for example if you know the EXIF info you can create a mask or a filter to handle files.

for example :

## shows newer than 01-07-2024 ( DD-MM-YYYY) 
fd --newer "2024-07-01" --extension=DICOM 
Enter fullscreen mode Exit fullscreen mode

If you describe me your specific issue about this project i can try to help you as much as I know.

Piping the fd fzf and mv makes the big difference.

I think fd & fzf chapter of this video is the answer to your question:

Some comments may only be visible to logged-in visitors. Sign in to view all comments.