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 ") 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!") }