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  }