inou/main.go

800 lines
21 KiB
Go
Executable File

package main
import (
"database/sql"
"encoding/binary"
"fmt"
"image"
"image/color"
"image/png"
"math"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/google/uuid"
_ "github.com/mattn/go-sqlite3"
)
// Paths - configurable via environment or defaults
var (
RawDir = getEnv("INOU_RAW", "/tank/inou/uploads")
ObjectDir = getEnv("INOU_OBJECTS", "/tank/inou/objects")
DBPath = getEnv("INOU_DB", "/tank/inou/data/inou.db")
)
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// extractPatientGUIDFromPath extracts patient GUID from paths like /uploads/{guid}/...
func extractPatientGUIDFromPath(path string) string {
parts := strings.Split(filepath.ToSlash(path), "/")
for i, part := range parts {
if part == "uploads" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
// objectPath returns the storage path for an image GUID
// e.g., "abc12345-6789-..." -> "/tank/inou/objects/ab/c1/abc12345-6789-..."
func objectPath(guid string) string {
clean := strings.ReplaceAll(guid, "-", "")
if len(clean) < 4 {
return filepath.Join(ObjectDir, guid)
}
return filepath.Join(ObjectDir, clean[0:2], clean[2:4], guid)
}
var db *sql.DB
func initDB() error {
var err error
db, err = sql.Open("sqlite3", DBPath)
if err != nil {
return err
}
// Drop old tables
// db.Exec("DROP TABLE IF EXISTS slices")
// db.Exec("DROP TABLE IF EXISTS series")
// db.Exec("DROP TABLE IF EXISTS studies")
schema := `
CREATE TABLE studies (
guid TEXT PRIMARY KEY,
study_instance_uid TEXT UNIQUE,
patient_guid TEXT,
patient_name TEXT,
patient_dob TEXT,
patient_sex TEXT,
patient_age TEXT,
study_date TEXT,
study_time TEXT,
study_desc TEXT,
institution TEXT,
accession_number TEXT,
referring_physician TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE series (
guid TEXT PRIMARY KEY,
study_guid TEXT REFERENCES studies(guid),
series_instance_uid TEXT,
series_number INTEGER,
series_desc TEXT,
modality TEXT,
body_part TEXT,
protocol_name TEXT,
manufacturer TEXT,
model TEXT,
magnetic_field_strength TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(series_instance_uid, series_desc)
);
CREATE TABLE slices (
guid TEXT PRIMARY KEY,
series_guid TEXT REFERENCES series(guid),
sop_instance_uid TEXT UNIQUE,
instance_number INTEGER,
slice_location REAL,
slice_thickness REAL,
rows INTEGER,
cols INTEGER,
pixel_spacing_row REAL,
pixel_spacing_col REAL,
bits_allocated INTEGER,
bits_stored INTEGER,
high_bit INTEGER,
pixel_representation INTEGER,
window_center REAL,
window_width REAL,
rescale_intercept REAL,
rescale_slope REAL,
image_position_x REAL,
image_position_y REAL,
image_position_z REAL,
image_orientation TEXT,
orientation_type TEXT,
tr REAL,
te REAL,
ti REAL,
flip_angle REAL,
echo_number INTEGER,
spacing_between_slices REAL,
acquisition_time TEXT,
content_time TEXT,
photometric_interpretation TEXT,
samples_per_pixel INTEGER,
pixel_min INTEGER,
pixel_max INTEGER,
raw_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_slices_series ON slices(series_guid);
CREATE INDEX idx_series_study ON series(study_guid);
CREATE INDEX idx_studies_patient ON studies(patient_guid);
`
_, err = db.Exec(schema)
return err
}
// DICOM tag parsing functions
func findTag(data []byte, group, elem uint16) int {
target := make([]byte, 4)
binary.LittleEndian.PutUint16(target[0:2], group)
binary.LittleEndian.PutUint16(target[2:4], elem)
for i := 0; i < len(data)-4; i++ {
if data[i] == target[0] && data[i+1] == target[1] &&
data[i+2] == target[2] && data[i+3] == target[3] {
return i
}
}
return -1
}
func findLastTag(data []byte, group, elem uint16) int {
target := make([]byte, 4)
binary.LittleEndian.PutUint16(target[0:2], group)
binary.LittleEndian.PutUint16(target[2:4], elem)
lastPos := -1
for i := 0; i < len(data)-4; i++ {
if data[i] == target[0] && data[i+1] == target[1] &&
data[i+2] == target[2] && data[i+3] == target[3] {
lastPos = i
}
}
return lastPos
}
func readUint16At(data []byte, pos int) uint16 {
return binary.LittleEndian.Uint16(data[pos : pos+2])
}
func readStringTag(data []byte, group, elem uint16) string {
pos := findTag(data, group, elem)
if pos < 0 {
return ""
}
vr := string(data[pos+4 : pos+6])
var length uint16
var valPos int
if vr == "OB" || vr == "OW" || vr == "SQ" || vr == "UN" {
length = uint16(binary.LittleEndian.Uint32(data[pos+8 : pos+12]))
valPos = pos + 12
} else {
length = readUint16At(data, pos+6)
valPos = pos + 8
}
if valPos+int(length) > len(data) {
return ""
}
s := string(data[valPos : valPos+int(length)])
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == 0) {
s = s[:len(s)-1]
}
return s
}
func readIntTag(data []byte, group, elem uint16) int {
s := readStringTag(data, group, elem)
s = strings.TrimSpace(s)
n, _ := strconv.Atoi(s)
return n
}
func readIntTagSmart(data []byte, group, elem uint16) int {
pos := findTag(data, group, elem)
if pos < 0 {
return 0
}
vr := string(data[pos+4 : pos+6])
if vr == "US" || vr == "SS" {
valPos := pos + 8
if valPos+2 <= len(data) {
return int(binary.LittleEndian.Uint16(data[valPos : valPos+2]))
}
}
s := readStringTag(data, group, elem)
s = strings.TrimSpace(s)
n, _ := strconv.Atoi(s)
return n
}
func readFloatTag(data []byte, group, elem uint16) float64 {
s := readStringTag(data, group, elem)
s = strings.TrimSpace(s)
f, _ := strconv.ParseFloat(s, 64)
return f
}
// FramePosition holds per-frame position data from enhanced multi-frame DICOM
type FramePosition struct {
X, Y, Z float64
}
func parsePerFramePositions(data []byte, numFrames int) []FramePosition {
positions := make([]FramePosition, numFrames)
perFrameTag := []byte{0x00, 0x52, 0x30, 0x92}
perFramePos := -1
for i := 0; i < len(data)-4; i++ {
if data[i] == perFrameTag[0] && data[i+1] == perFrameTag[1] &&
data[i+2] == perFrameTag[2] && data[i+3] == perFrameTag[3] {
perFramePos = i
break
}
}
if perFramePos < 0 {
return positions
}
planeTag := []byte{0x20, 0x00, 0x13, 0x91}
imgPosTag := []byte{0x20, 0x00, 0x32, 0x00}
frameIdx := 0
searchStart := perFramePos
for frameIdx < numFrames {
planePos := -1
for i := searchStart; i < len(data)-4; i++ {
if data[i] == planeTag[0] && data[i+1] == planeTag[1] &&
data[i+2] == planeTag[2] && data[i+3] == planeTag[3] {
planePos = i
break
}
}
if planePos < 0 {
break
}
for i := planePos; i < planePos+100 && i < len(data)-8; i++ {
if data[i] == imgPosTag[0] && data[i+1] == imgPosTag[1] &&
data[i+2] == imgPosTag[2] && data[i+3] == imgPosTag[3] {
if data[i+4] == 'D' && data[i+5] == 'S' {
length := int(binary.LittleEndian.Uint16(data[i+6 : i+8]))
if i+8+length <= len(data) {
value := strings.TrimSpace(string(data[i+8 : i+8+length]))
parts := strings.Split(value, "\\")
if len(parts) >= 3 {
positions[frameIdx].X, _ = strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
positions[frameIdx].Y, _ = strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
positions[frameIdx].Z, _ = strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
}
}
}
break
}
}
searchStart = planePos + 1
frameIdx++
}
return positions
}
func findPixelData(data []byte, expectedBytes int) int {
target := []byte{0xE0, 0x7F, 0x10, 0x00}
pos := 0
for {
idx := -1
for i := pos; i < len(data)-12; i++ {
if data[i] == target[0] && data[i+1] == target[1] &&
data[i+2] == target[2] && data[i+3] == target[3] {
idx = i
break
}
}
if idx < 0 {
return -1
}
vr := string(data[idx+4 : idx+6])
var length int
if vr == "OW" || vr == "OB" {
length = int(binary.LittleEndian.Uint32(data[idx+8 : idx+12]))
} else {
length = int(binary.LittleEndian.Uint16(data[idx+6 : idx+8]))
}
if length > 0 && (length == expectedBytes || (length > expectedBytes*9/10 && length < expectedBytes*11/10)) {
return idx
}
pos = idx + 1
}
}
func isLocalizer(data []byte) bool {
imageType := readStringTag(data, 0x0008, 0x0008)
imageType = strings.ToUpper(imageType)
return strings.Contains(imageType, "LOCALIZER") || strings.Contains(imageType, "SCOUT")
}
func getOrientationType(data []byte) string {
orientStr := readStringTag(data, 0x0020, 0x0037)
if orientStr == "" {
return ""
}
parts := strings.Split(orientStr, "\\")
if len(parts) < 6 {
return ""
}
vals := make([]float64, 6)
for i := 0; i < 6; i++ {
vals[i], _ = strconv.ParseFloat(strings.TrimSpace(parts[i]), 64)
}
rowX, rowY, rowZ := vals[0], vals[1], vals[2]
colX, colY, colZ := vals[3], vals[4], vals[5]
normX := rowY*colZ - rowZ*colY
normY := rowZ*colX - rowX*colZ
normZ := rowX*colY - rowY*colX
absX := math.Abs(normX)
absY := math.Abs(normY)
absZ := math.Abs(normZ)
if absZ >= absX && absZ >= absY {
return "AX"
} else if absX >= absY && absX >= absZ {
return "SAG"
}
return "COR"
}
func getOrCreateStudy(data []byte, patientGUID string) (string, error) {
studyUID := readStringTag(data, 0x0020, 0x000D)
var guid string
err := db.QueryRow("SELECT guid FROM studies WHERE study_instance_uid = ?", studyUID).Scan(&guid)
if err == nil {
return guid, nil
}
guid = uuid.New().String()
_, err = db.Exec(`INSERT INTO studies (
guid, study_instance_uid, patient_guid, patient_name, patient_dob, patient_sex, patient_age,
study_date, study_time, study_desc, institution, accession_number, referring_physician
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
guid,
studyUID,
patientGUID,
readStringTag(data, 0x0010, 0x0010),
readStringTag(data, 0x0010, 0x0030),
readStringTag(data, 0x0010, 0x0040),
readStringTag(data, 0x0010, 0x1010),
readStringTag(data, 0x0008, 0x0020),
readStringTag(data, 0x0008, 0x0030),
readStringTag(data, 0x0008, 0x1030),
readStringTag(data, 0x0008, 0x0080),
readStringTag(data, 0x0008, 0x0050),
readStringTag(data, 0x0008, 0x0090),
)
if err != nil {
return "", err
}
return guid, nil
}
func getOrCreateSeries(data []byte, studyGUID string) (string, error) {
seriesUID := readStringTag(data, 0x0020, 0x000E)
seriesDesc := readStringTag(data, 0x0008, 0x103E)
var guid string
err := db.QueryRow("SELECT guid FROM series WHERE series_instance_uid = ? AND series_desc = ?", seriesUID, seriesDesc).Scan(&guid)
if err == nil {
return guid, nil
}
guid = uuid.New().String()
_, err = db.Exec(`INSERT INTO series (
guid, study_guid, series_instance_uid, series_number, series_desc, modality, body_part,
protocol_name, manufacturer, model, magnetic_field_strength
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
guid,
studyGUID,
seriesUID,
readIntTag(data, 0x0020, 0x0011),
seriesDesc,
readStringTag(data, 0x0008, 0x0060),
readStringTag(data, 0x0018, 0x0015),
readStringTag(data, 0x0018, 0x1030),
readStringTag(data, 0x0008, 0x0070),
readStringTag(data, 0x0008, 0x1090),
readStringTag(data, 0x0018, 0x0087),
)
if err != nil {
return "", err
}
return guid, nil
}
func insertSlice(data []byte, sliceGUID, seriesGUID, rawPath string, pixelMin, pixelMax int, orientationType string, frameIndex int, framePos *FramePosition) error {
sopUID := readStringTag(data, 0x0008, 0x0018)
if frameIndex > 0 {
sopUID = fmt.Sprintf("%s.%d", sopUID, frameIndex)
}
// Parse pixel spacing
psStr := readStringTag(data, 0x0028, 0x0030)
var psRow, psCol float64
if psStr != "" {
parts := strings.Split(psStr, "\\")
if len(parts) >= 2 {
psRow, _ = strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
psCol, _ = strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
}
}
// Parse image position
var posX, posY, posZ float64
if framePos != nil && (framePos.X != 0 || framePos.Y != 0 || framePos.Z != 0) {
posX, posY, posZ = framePos.X, framePos.Y, framePos.Z
} else {
posStr := readStringTag(data, 0x0020, 0x0032)
if posStr != "" {
parts := strings.Split(posStr, "\\")
if len(parts) >= 3 {
posX, _ = strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
posY, _ = strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
posZ, _ = strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
}
}
}
// Compute instance_number and slice_location
var instanceNumber int
var sliceLocation float64
if framePos != nil {
instanceNumber = frameIndex + 1
switch orientationType {
case "SAG":
sliceLocation = posX
case "COR":
sliceLocation = posY
default:
sliceLocation = posZ
}
} else {
instanceNumber = readIntTagSmart(data, 0x0020, 0x0013)
sliceLocation = readFloatTag(data, 0x0020, 0x1041)
}
_, err := db.Exec(`INSERT OR REPLACE INTO slices (
guid, series_guid, sop_instance_uid, instance_number, slice_location, slice_thickness,
rows, cols, pixel_spacing_row, pixel_spacing_col,
bits_allocated, bits_stored, high_bit, pixel_representation,
window_center, window_width, rescale_intercept, rescale_slope,
image_position_x, image_position_y, image_position_z, image_orientation, orientation_type,
tr, te, ti, flip_angle, echo_number, spacing_between_slices,
acquisition_time, content_time, photometric_interpretation, samples_per_pixel,
pixel_min, pixel_max, raw_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
sliceGUID,
seriesGUID,
sopUID,
instanceNumber,
sliceLocation,
readFloatTag(data, 0x0018, 0x0050),
readIntTagSmart(data, 0x0028, 0x0010),
readIntTagSmart(data, 0x0028, 0x0011),
psRow, psCol,
readIntTagSmart(data, 0x0028, 0x0100),
readIntTagSmart(data, 0x0028, 0x0101),
readIntTagSmart(data, 0x0028, 0x0102),
readIntTagSmart(data, 0x0028, 0x0103),
readFloatTag(data, 0x0028, 0x1050),
readFloatTag(data, 0x0028, 0x1051),
readFloatTag(data, 0x0028, 0x1052),
readFloatTag(data, 0x0028, 0x1053),
posX, posY, posZ,
readStringTag(data, 0x0020, 0x0037),
orientationType,
readFloatTag(data, 0x0018, 0x0080),
readFloatTag(data, 0x0018, 0x0081),
readFloatTag(data, 0x0018, 0x0082),
readFloatTag(data, 0x0018, 0x1314),
readIntTag(data, 0x0018, 0x0086),
readFloatTag(data, 0x0018, 0x0088),
readStringTag(data, 0x0008, 0x0032),
readStringTag(data, 0x0008, 0x0033),
readStringTag(data, 0x0028, 0x0004),
readIntTagSmart(data, 0x0028, 0x0002),
pixelMin, pixelMax,
rawPath,
)
return err
}
// Gray16Image implements image.Image for 16-bit grayscale
type Gray16Image struct {
Pixels []uint16
Width int
Height int
}
func (g *Gray16Image) ColorModel() color.Model { return color.Gray16Model }
func (g *Gray16Image) Bounds() image.Rectangle { return image.Rect(0, 0, g.Width, g.Height) }
func (g *Gray16Image) At(x, y int) color.Color {
if x < 0 || x >= g.Width || y < 0 || y >= g.Height {
return color.Gray16{0}
}
return color.Gray16{g.Pixels[y*g.Width+x]}
}
func save16BitPNG(pixels []uint16, width, height int, path string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
img := &Gray16Image{Pixels: pixels, Width: width, Height: height}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return png.Encode(f, img)
}
type Slice struct {
Path string
InstanceNum int
}
func findDICOMFiles(root string) ([]string, error) {
var files []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
name := info.Name()
if name == "DICOMDIR" || name == "LOCKFILE" || name == "VERSION" ||
strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".png") ||
strings.HasSuffix(name, ".exe") || strings.HasSuffix(name, ".dll") {
return nil
}
data, err := os.ReadFile(path)
if err != nil || len(data) < 132 {
return nil
}
if string(data[128:132]) == "DICM" {
files = append(files, path)
}
return nil
})
return files, err
}
func convertFromPath(inputPath string) error {
fmt.Printf("Scanning %s for DICOM files...\n", inputPath)
files, err := findDICOMFiles(inputPath)
if err != nil {
return err
}
fmt.Printf("Found %d DICOM files\n", len(files))
if len(files) == 0 {
return fmt.Errorf("no DICOM files found in %s", inputPath)
}
patientGUID := extractPatientGUIDFromPath(inputPath)
if patientGUID == "" {
fmt.Printf("Warning: No patient GUID in path, using empty\n")
} else {
fmt.Printf("Patient GUID: %s\n", patientGUID)
}
// Group files by series
seriesFiles := make(map[string][]Slice)
seriesData := make(map[string][]byte)
for _, path := range files {
data, err := os.ReadFile(path)
if err != nil {
continue
}
seriesUID := readStringTag(data, 0x0020, 0x000E)
seriesDesc := readStringTag(data, 0x0008, 0x103E)
instanceNum := readIntTagSmart(data, 0x0020, 0x0013)
seriesKey := seriesUID + "|" + seriesDesc
seriesFiles[seriesKey] = append(seriesFiles[seriesKey], Slice{Path: path, InstanceNum: instanceNum})
if _, exists := seriesData[seriesKey]; !exists {
seriesData[seriesKey] = data
}
}
fmt.Printf("Found %d series\n", len(seriesFiles))
for seriesKey, slices := range seriesFiles {
sort.Slice(slices, func(i, j int) bool {
return slices[i].InstanceNum < slices[j].InstanceNum
})
data := seriesData[seriesKey]
seriesDesc := readStringTag(data, 0x0008, 0x103E)
if seriesDesc == "" {
seriesDesc = "UNKNOWN"
}
studyGUID, err := getOrCreateStudy(data, patientGUID)
if err != nil {
fmt.Printf("Error creating study: %v\n", err)
continue
}
seriesGUID, err := getOrCreateSeries(data, studyGUID)
if err != nil {
fmt.Printf("Error creating series: %v\n", err)
continue
}
fmt.Printf("\nProcessing series: %s (%d files)\n", seriesDesc, len(slices))
for _, slice := range slices {
data, err := os.ReadFile(slice.Path)
if err != nil {
continue
}
if isLocalizer(data) {
fmt.Printf(" Skipping localizer: %s\n", slice.Path)
continue
}
if err := processFile(data, slice.Path, seriesGUID, inputPath); err != nil {
fmt.Printf(" Error: %v (%s)\n", err, filepath.Base(slice.Path))
}
}
}
return nil
}
func processFile(data []byte, filePath string, seriesGUID string, inputPath string) error {
rowsPos := findTag(data, 0x0028, 0x0010)
colsPos := findTag(data, 0x0028, 0x0011)
if rowsPos < 0 || colsPos < 0 {
return fmt.Errorf("missing rows/cols tags")
}
rows := int(readUint16At(data, rowsPos+8))
cols := int(readUint16At(data, colsPos+8))
numFrames := readIntTagSmart(data, 0x0028, 0x0008)
if numFrames == 0 {
numFrames = 1
}
framePositions := parsePerFramePositions(data, numFrames)
frameSize := rows * cols * 2
expectedBytes := frameSize * numFrames
pixelPos := findPixelData(data, expectedBytes)
if pixelPos < 0 {
pixelPos = findLastTag(data, 0x7FE0, 0x0010)
}
if pixelPos < 0 {
return fmt.Errorf("pixel data not found")
}
vr := string(data[pixelPos+4 : pixelPos+6])
var pixelDataStart int
if vr == "OW" || vr == "OB" {
pixelDataStart = pixelPos + 12
} else {
pixelDataStart = pixelPos + 8
}
orientationType := getOrientationType(data)
relRawPath, _ := filepath.Rel(RawDir, filePath)
for frameIdx := 0; frameIdx < numFrames; frameIdx++ {
frameOffset := pixelDataStart + (frameIdx * frameSize)
var framePos *FramePosition
if frameIdx < len(framePositions) {
framePos = &framePositions[frameIdx]
}
numPixels := rows * cols
pixels := make([]uint16, numPixels)
var minVal, maxVal uint16 = 65535, 0
for i := 0; i < numPixels; i++ {
offset := frameOffset + i*2
if offset+2 > len(data) {
break
}
v := binary.LittleEndian.Uint16(data[offset : offset+2])
pixels[i] = v
if v < minVal {
minVal = v
}
if v > maxVal {
maxVal = v
}
}
// Generate UUID - this is both the PK and the image filename
sliceGUID := uuid.New().String()
// Save 16-bit PNG to object storage
outPath := objectPath(sliceGUID)
if err := save16BitPNG(pixels, cols, rows, outPath); err != nil {
return fmt.Errorf("save png: %w", err)
}
// Build raw_path - append :frameIdx for multi-frame
rawPath := relRawPath
if numFrames > 1 {
rawPath = fmt.Sprintf("%s:%d", relRawPath, frameIdx)
}
// Insert to DB
if err := insertSlice(data, sliceGUID, seriesGUID, rawPath, int(minVal), int(maxVal), orientationType, frameIdx, framePos); err != nil {
return fmt.Errorf("insert slice: %w", err)
}
fmt.Printf(" %s -> %s\n", filepath.Base(filePath), sliceGUID[:8])
}
return nil
}
func main() {
if err := initDB(); err != nil {
fmt.Printf("Error initializing database: %v\n", err)
os.Exit(1)
}
defer db.Close()
fmt.Println("Database initialized")
if len(os.Args) < 2 {
fmt.Println("Usage: convert <path>")
fmt.Println(" Path should be /tank/inou/uploads/{patient-guid}/...")
os.Exit(1)
}
inputPath := os.Args[1]
if strings.HasPrefix(inputPath, "~/") {
home, _ := os.UserHomeDir()
inputPath = filepath.Join(home, inputPath[2:])
}
if err := convertFromPath(inputPath); err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
fmt.Println("\nDone!")
}