github.com/jjjabc/fitsio@v0.0.0-20161215022839-d1807e9e818e/cmd/view-fits/main.go (about) 1 // Copyright 2016 The astrogo Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 // view-fits is a simple program to display images in a FITS file. 6 // 7 // Usage of view-fits: 8 // $ view-fits [file1 [file2 [...]]] 9 // 10 // Examples: 11 // $ view-fits astrogo/fitsio/testdata/file-img2-bitpix+08.fits 12 // $ view-fits astrogo/fitsio/testdata/file-img2-bitpix*.fits 13 // $ view-fits http://data.astropy.org/tutorials/FITS-images/HorseHead.fits 14 // $ view-fits file:///some/file.fits 15 // 16 // Controls: 17 // - left/right arrows: switch to previous/next file 18 // - up/down arrows: switch to previous/next image in the current file 19 // - r: reload/redisplay current image 20 // - z: resize window to fit current image 21 // - p: print current image to 'output.png' 22 // - ?: show help 23 // - q/ESC: quit 24 // 25 package main 26 27 import ( 28 "flag" 29 "fmt" 30 "image" 31 "image/color" 32 "image/draw" 33 "image/png" 34 "io" 35 "io/ioutil" 36 "log" 37 "net/http" 38 "os" 39 "strings" 40 41 "github.com/astrogo/fitsio" 42 43 "golang.org/x/exp/shiny/driver" 44 "golang.org/x/exp/shiny/screen" 45 "golang.org/x/mobile/event/key" 46 "golang.org/x/mobile/event/lifecycle" 47 "golang.org/x/mobile/event/mouse" 48 "golang.org/x/mobile/event/paint" 49 "golang.org/x/mobile/event/size" 50 ) 51 52 type fileInfo struct { 53 Name string 54 Images []imageInfo 55 } 56 57 type imageInfo struct { 58 image.Image 59 scale int // image scale in percents (default: 100%) 60 orig image.Point 61 } 62 63 func main() { 64 65 help := flag.Bool("help", false, "show help") 66 67 flag.Usage = func() { 68 fmt.Fprintf(os.Stderr, `view-fits - a FITS image viewer. 69 70 Usage of view-fits: 71 $ view-fits [file1 [file2 [...]]] 72 73 Examples: 74 $ view-fits astrogo/fitsio/testdata/file-img2-bitpix+08.fits 75 $ view-fits astrogo/fitsio/testdata/file-img2-bitpix*.fits 76 $ view-fits http://data.astropy.org/tutorials/FITS-images/HorseHead.fits 77 $ view-fits file:///some/file.fits 78 79 Controls: 80 - left/right arrows: switch to previous/next file 81 - up/down arrows: switch to previous/next image in the current file 82 - r: reload/redisplay current image 83 - z: resize window to fit current image 84 - p: print current image to 'output.png' 85 - +: increase zoom-level by 20%% 86 - -: decrease zoom-level by 20%% 87 - ?: show help 88 - q/ESC: quit 89 90 Mouse controls: 91 - Left button: pan image 92 `) 93 } 94 95 flag.Parse() 96 97 if *help || len(os.Args) < 2 { 98 flag.Usage() 99 os.Exit(0) 100 } 101 102 log.SetFlags(0) 103 log.SetPrefix("[view-fits] ") 104 105 infos := processFiles() 106 if len(infos) == 0 { 107 log.Fatal("No image among given FITS files.") 108 } 109 110 type cursor struct { 111 file int 112 img int 113 } 114 115 driver.Main(func(s screen.Screen) { 116 117 // Number of files. 118 nbFiles := len(infos) 119 120 // Current displayed file and image in file. 121 cur := cursor{file: 0, img: 0} 122 123 // Building the main window. 124 w, err := s.NewWindow(&screen.NewWindowOptions{ 125 Width: 500, 126 Height: 500, 127 }) 128 if err != nil { 129 log.Fatal(err) 130 } 131 defer w.Release() 132 133 // Building the screen buffer. 134 b, err := s.NewBuffer(image.Point{500, 500}) 135 if err != nil { 136 log.Fatal(err) 137 } 138 defer release(b) 139 140 w.Fill(b.Bounds(), color.Black, draw.Src) 141 w.Publish() 142 143 var ( 144 sz size.Event 145 //bkg = color.Black 146 bkg = color.RGBA{0xe0, 0xe0, 0xe0, 0xff} // Material Design "Grey 300" 147 148 mbl image.Rectangle // mouse button-left position 149 150 repaint = true 151 panning = false 152 ) 153 154 for { 155 switch e := w.NextEvent().(type) { 156 default: 157 // ignore 158 159 case lifecycle.Event: 160 switch { 161 case e.From == lifecycle.StageVisible && e.To == lifecycle.StageFocused: 162 repaint = true 163 default: 164 repaint = false 165 } 166 167 case mouse.Event: 168 ix := int(e.X) 169 iy := int(e.Y) 170 switch e.Button { 171 case mouse.ButtonLeft: 172 switch e.Direction { 173 case mouse.DirPress: 174 panning = true 175 mbl = image.Rect(ix, iy, ix, iy) 176 177 case mouse.DirRelease: 178 panning = false 179 mbl.Max = image.Point{ix, iy} 180 181 switch { 182 case e.Modifiers&key.ModShift != 0: 183 // zoom-in 184 default: 185 // pan 186 repaint = true 187 img := &infos[cur.file].Images[cur.img] 188 dx := mbl.Dx() 189 dy := mbl.Dy() 190 img.orig = originTrans(img.orig.Sub(image.Point{dx, dy}), sz.Bounds(), img) 191 } 192 } 193 194 case mouse.ButtonRight: 195 case mouse.ButtonWheelDown: 196 if e.Direction == mouse.DirPress { 197 ctrlZoomOut(&infos[cur.file].Images[cur.img], &repaint) 198 } 199 case mouse.ButtonWheelUp: 200 if e.Direction == mouse.DirPress { 201 ctrlZoomIn(&infos[cur.file].Images[cur.img], &repaint) 202 } 203 } 204 205 if panning { 206 repaint = true 207 img := &infos[cur.file].Images[cur.img] 208 mbl.Max = image.Point{ix, iy} 209 dx := mbl.Dx() 210 dy := mbl.Dy() 211 img.orig = originTrans(img.orig.Sub(image.Point{dx, dy}), sz.Bounds(), img) 212 mbl.Min = mbl.Max 213 } 214 215 case key.Event: 216 switch e.Code { 217 case key.CodeEscape, key.CodeQ: 218 return 219 220 case key.CodeSlash: 221 if e.Direction == key.DirPress && e.Modifiers&key.ModShift != 0 { 222 flag.Usage() 223 continue 224 } 225 226 case key.CodeKeypadPlusSign: 227 if e.Direction == key.DirPress { 228 ctrlZoomIn(&infos[cur.file].Images[cur.img], &repaint) 229 } 230 231 case key.CodeEqualSign: 232 if e.Direction == key.DirPress && e.Modifiers&key.ModShift != 0 { 233 ctrlZoomIn(&infos[cur.file].Images[cur.img], &repaint) 234 } 235 236 case key.CodeHyphenMinus: 237 if e.Direction == key.DirPress { 238 ctrlZoomOut(&infos[cur.file].Images[cur.img], &repaint) 239 } 240 241 case key.CodeRightArrow: 242 if e.Direction == key.DirPress { 243 repaint = true 244 if cur.file < nbFiles-1 { 245 cur.file++ 246 } else { 247 cur.file = 0 248 } 249 cur.img = 0 250 log.Printf("file: %v\n", infos[cur.file].Name) 251 log.Printf("images: %d\n", len(infos[cur.file].Images)) 252 } 253 254 case key.CodeLeftArrow: 255 if e.Direction == key.DirPress { 256 repaint = true 257 if cur.file == 0 { 258 cur.file = nbFiles - 1 259 } else { 260 cur.file-- 261 } 262 cur.img = 0 263 log.Printf("file: %v\n", infos[cur.file].Name) 264 log.Printf("images: %d\n", len(infos[cur.file].Images)) 265 } 266 267 case key.CodeDownArrow: 268 if e.Direction == key.DirPress { 269 repaint = true 270 nbImg := len(infos[cur.file].Images) 271 if cur.img < nbImg-1 { 272 cur.img++ 273 } else { 274 cur.img = 0 275 } 276 } 277 278 case key.CodeUpArrow: 279 if e.Direction == key.DirPress { 280 repaint = true 281 nbImg := len(infos[cur.file].Images) 282 if cur.img == 0 { 283 cur.img = nbImg - 1 284 } else { 285 cur.img-- 286 } 287 } 288 289 case key.CodeR: 290 if e.Direction == key.DirPress { 291 repaint = true 292 } 293 294 case key.CodeZ: 295 if e.Direction == key.DirPress { 296 // resize to current image 297 // TODO(sbinet) 298 repaint = true 299 } 300 301 case key.CodeP: 302 if e.Direction != key.DirPress { 303 continue 304 } 305 out, err := os.Create("output.png") 306 if err != nil { 307 log.Fatalf("error printing image: %v\n", err) 308 } 309 defer out.Close() 310 err = png.Encode(out, infos[cur.file].Images[cur.img]) 311 if err != nil { 312 log.Fatalf("error printing image: %v\n", err) 313 } 314 err = out.Close() 315 if err != nil { 316 log.Fatalf("error printing image: %v\n", err) 317 } 318 log.Printf("printed current image to [%s]\n", out.Name()) 319 } 320 321 case size.Event: 322 sz = e 323 324 case paint.Event: 325 if !repaint { 326 continue 327 } 328 repaint = false 329 img := infos[cur.file].Images[cur.img] 330 331 release(b) 332 b, err = s.NewBuffer(img.Bounds().Size()) 333 if err != nil { 334 log.Fatal(err) 335 } 336 defer release(b) 337 338 draw.Draw(b.RGBA(), b.Bounds(), img, img.orig, draw.Src) 339 340 w.Fill(sz.Bounds(), bkg, draw.Src) 341 w.Upload(image.Point{}, b, img.Bounds()) 342 w.Publish() 343 } 344 345 if repaint { 346 w.Send(paint.Event{}) 347 } 348 349 } 350 351 }) 352 } 353 354 func processFiles() []fileInfo { 355 infos := make([]fileInfo, 0, len(flag.Args())) 356 // Parsing input files. 357 for _, fname := range flag.Args() { 358 359 finfo := fileInfo{Name: fname} 360 361 r, err := openStream(fname) 362 if err != nil { 363 log.Fatalf("Can not open the input file: %v", err) 364 } 365 defer r.Close() 366 367 // Opening the FITS file. 368 f, err := fitsio.Open(r) 369 if err != nil { 370 log.Fatalf("Can not open the FITS input file: %v", err) 371 } 372 defer f.Close() 373 374 // Getting the file HDUs. 375 hdus := f.HDUs() 376 for _, hdu := range hdus { 377 // Getting the header informations. 378 header := hdu.Header() 379 axes := header.Axes() 380 381 // Discarding HDU with no axes. 382 if len(axes) != 0 { 383 if hdu, ok := hdu.(fitsio.Image); ok { 384 img := hdu.Image() 385 if img != nil { 386 finfo.Images = append(finfo.Images, imageInfo{ 387 Image: img, 388 scale: 100, 389 orig: image.Point{}, 390 }) 391 } 392 } 393 } 394 } 395 396 if len(finfo.Images) > 0 { 397 infos = append(infos, finfo) 398 } 399 } 400 401 return infos 402 } 403 404 func openStream(name string) (io.ReadCloser, error) { 405 switch { 406 case strings.HasPrefix(name, "http://") || strings.HasPrefix(name, "https://"): 407 resp, err := http.Get(name) 408 if err != nil { 409 return nil, err 410 } 411 defer resp.Body.Close() 412 413 f, err := ioutil.TempFile("", "view-fits-") 414 if err != nil { 415 return nil, err 416 } 417 418 _, err = io.Copy(f, resp.Body) 419 if err != nil { 420 f.Close() 421 return nil, err 422 } 423 424 // make sure we have at least a full FITS block 425 f.Seek(0, 2880) 426 f.Seek(0, 0) 427 428 return f, nil 429 430 case strings.HasPrefix(name, "file://"): 431 name = name[len("file://"):] 432 return os.Open(name) 433 default: 434 return os.Open(name) 435 } 436 } 437 438 type releaser interface { 439 Release() 440 } 441 442 func release(r releaser) { 443 if r != nil { 444 r.Release() 445 } 446 } 447 448 func ctrlZoomOut(img *imageInfo, repaint *bool) { 449 *repaint = true 450 if img.scale <= 20 { 451 *repaint = false 452 return 453 } 454 455 img.scale -= 20 456 457 if img.scale <= 0 { 458 img.scale = 20 459 } 460 } 461 462 func ctrlZoomIn(img *imageInfo, repaint *bool) { 463 *repaint = true 464 img.scale += 20 465 } 466 467 func min(i, j int) int { 468 if i < j { 469 return i 470 } 471 return j 472 } 473 474 func max(a, b int) int { 475 if a > b { 476 return a 477 } 478 return b 479 } 480 481 // originTrans translates the origin with respect to the current image and the 482 // current canvas size. This makes sure we never incorrect position the image. 483 // (i.e., panning never goes too far, and whenever the canvas is bigger than 484 // the image, the origin is *always* (0, 0). 485 func originTrans(pt image.Point, win image.Rectangle, img *imageInfo) image.Point { 486 // If there's no valid image, then always return (0, 0). 487 if img == nil { 488 return image.Point{0, 0} 489 } 490 491 // Quick aliases. 492 ww := win.Dx() 493 wh := win.Dy() 494 dw := img.Bounds().Dx() - ww 495 dh := img.Bounds().Dy() - wh 496 497 // Set the allowable range of the origin point of the image. 498 // i.e., never less than (0, 0) and never greater than the width/height 499 // of the image that isn't viewable at any given point (which is determined 500 // by the canvas size). 501 pt.X = min(img.Bounds().Min.X+dw, max(pt.X, 0)) 502 pt.Y = min(img.Bounds().Min.Y+dh, max(pt.Y, 0)) 503 504 // Validate origin point. If the width/height of an image is smaller than 505 // the canvas width/height, then the image origin cannot change in x/y 506 // direction. 507 if img.Bounds().Dx() < ww { 508 pt.X = 0 509 } 510 if img.Bounds().Dy() < wh { 511 pt.Y = 0 512 } 513 514 return pt 515 }