astro-subframe-organizer 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +737 -0
- data/exe/astro-subframe-organizer +8 -0
- data/lib/astro_subframe_organizer/astrophoto.rb +122 -0
- data/lib/astro_subframe_organizer/commands/cleanup/empty_directories.rb +27 -0
- data/lib/astro_subframe_organizer/commands/cleanup/thumbnails.rb +38 -0
- data/lib/astro_subframe_organizer/commands/cleanup/unorganize.rb +25 -0
- data/lib/astro_subframe_organizer/commands/equipment_options.rb +30 -0
- data/lib/astro_subframe_organizer/commands/init.rb +54 -0
- data/lib/astro_subframe_organizer/commands/inspect.rb +101 -0
- data/lib/astro_subframe_organizer/commands/organize/base.rb +43 -0
- data/lib/astro_subframe_organizer/commands/organize/bias.rb +13 -0
- data/lib/astro_subframe_organizer/commands/organize/darks.rb +12 -0
- data/lib/astro_subframe_organizer/commands/organize/flats.rb +14 -0
- data/lib/astro_subframe_organizer/commands/organize/lights.rb +14 -0
- data/lib/astro_subframe_organizer/commands/raw/rename_from_exif.rb +45 -0
- data/lib/astro_subframe_organizer/commands/raw/revert_name.rb +28 -0
- data/lib/astro_subframe_organizer/commands/run.rb +28 -0
- data/lib/astro_subframe_organizer/commands/shared_options.rb +29 -0
- data/lib/astro_subframe_organizer/commands/version.rb +15 -0
- data/lib/astro_subframe_organizer/commands.rb +64 -0
- data/lib/astro_subframe_organizer/config.rb +140 -0
- data/lib/astro_subframe_organizer/equipment/camera.rb +14 -0
- data/lib/astro_subframe_organizer/equipment/filter.rb +14 -0
- data/lib/astro_subframe_organizer/equipment/telescope.rb +14 -0
- data/lib/astro_subframe_organizer/equipment_selector.rb +103 -0
- data/lib/astro_subframe_organizer/file_metadata.rb +135 -0
- data/lib/astro_subframe_organizer/file_set.rb +107 -0
- data/lib/astro_subframe_organizer/filename_parser.rb +106 -0
- data/lib/astro_subframe_organizer/filename_parsers/cr2_filename_parser.rb +67 -0
- data/lib/astro_subframe_organizer/filename_parsers/fits_filename_parser.rb +67 -0
- data/lib/astro_subframe_organizer/filename_parsers/fits_header_parser.rb +120 -0
- data/lib/astro_subframe_organizer/fits_organizer.rb +154 -0
- data/lib/astro_subframe_organizer/logging.rb +10 -0
- data/lib/astro_subframe_organizer/organizer.rb +125 -0
- data/lib/astro_subframe_organizer/path_builder.rb +55 -0
- data/lib/astro_subframe_organizer/path_builders/base_path_builder.rb +33 -0
- data/lib/astro_subframe_organizer/path_builders/bias_path_builder.rb +38 -0
- data/lib/astro_subframe_organizer/path_builders/dark_path_builder.rb +61 -0
- data/lib/astro_subframe_organizer/path_builders/flat_path_builder.rb +48 -0
- data/lib/astro_subframe_organizer/path_builders/light_path_builder.rb +53 -0
- data/lib/astro_subframe_organizer/utils/empty_directory_cleaner.rb +36 -0
- data/lib/astro_subframe_organizer/utils/exif_renamer.rb +132 -0
- data/lib/astro_subframe_organizer/utils/exposure_format.rb +25 -0
- data/lib/astro_subframe_organizer/utils/file_utils.rb +10 -0
- data/lib/astro_subframe_organizer/utils/fits_stripper.rb +115 -0
- data/lib/astro_subframe_organizer/utils/raw_stripper.rb +47 -0
- data/lib/astro_subframe_organizer/utils/thumbnail_cleaner.rb +26 -0
- data/lib/astro_subframe_organizer/utils/unorganizer.rb +54 -0
- data/lib/astro_subframe_organizer/version.rb +5 -0
- data/lib/astro_subframe_organizer.rb +103 -0
- metadata +182 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module PathBuilders
|
|
5
|
+
# Builds folder paths for dark calibration frames.
|
|
6
|
+
#
|
|
7
|
+
# Dark frames are organized into folders by the following keywords, which allow WBPP
|
|
8
|
+
# to automatically match darks to lights during calibration:
|
|
9
|
+
#
|
|
10
|
+
# **Normal Darks:**
|
|
11
|
+
# - Dark_ISO_<value>_EXP_<value>_CCD-TEMP_<value>_CAMERA_<model>_MONTH_<YYYY-MM>
|
|
12
|
+
#
|
|
13
|
+
# These keywords enable matching darks to lights based on ISO, exposure time, CCD
|
|
14
|
+
# temperature, and the season (month) when captured. Temperature matching in WBPP
|
|
15
|
+
# allows for +/- 1°C variation to accommodate uncooled cameras.
|
|
16
|
+
#
|
|
17
|
+
# **Flat Darks (short exposure darks < 10 seconds):**
|
|
18
|
+
# - DarkFlat_FLATSET_<date>_ISO_<value>_EXP_<value>_Bin_<value>_CAMERA_<model>
|
|
19
|
+
#
|
|
20
|
+
# Flat darks are grouped with their corresponding flat set using the FLATSET keyword.
|
|
21
|
+
# Temperature is not included since flats and flat darks are captured at roughly the
|
|
22
|
+
# same time under similar conditions. These are organized together so WBPP can load
|
|
23
|
+
# them as a unit during the flat field calibration step.
|
|
24
|
+
#
|
|
25
|
+
# See README.md for details on WBPP_Darks and WBPP_Flats process icons.
|
|
26
|
+
class DarkPathBuilder < BasePathBuilder
|
|
27
|
+
# @return [String] The folder path for this dark frame
|
|
28
|
+
def build
|
|
29
|
+
if @metadata.dark_flat?
|
|
30
|
+
build_flat_dark_path
|
|
31
|
+
else
|
|
32
|
+
build_normal_dark_path
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def build_normal_dark_path
|
|
39
|
+
[
|
|
40
|
+
'Dark',
|
|
41
|
+
iso_or_gain,
|
|
42
|
+
"EXP_#{@metadata.exposure}",
|
|
43
|
+
"CCD-TEMP_#{@metadata.rounded_ccd_temp}",
|
|
44
|
+
"CAMERA_#{@metadata.camera || '????'}",
|
|
45
|
+
"MONTH_#{@metadata.month}",
|
|
46
|
+
].compact.join('_')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_flat_dark_path
|
|
50
|
+
[
|
|
51
|
+
'DarkFlat',
|
|
52
|
+
"FLATSET_#{@metadata.flatset_id}",
|
|
53
|
+
iso_or_gain,
|
|
54
|
+
"EXP_#{@metadata.exposure}",
|
|
55
|
+
"Bin_#{@metadata.bin}",
|
|
56
|
+
"CAMERA_#{@metadata.camera || '????'}",
|
|
57
|
+
].compact.join('_')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module PathBuilders
|
|
5
|
+
# Builds folder paths for flat field frames.
|
|
6
|
+
#
|
|
7
|
+
# Flat frames are organized by the following keywords, which allow WBPP to
|
|
8
|
+
# automatically match flats to lights during the flat field calibration step:
|
|
9
|
+
#
|
|
10
|
+
# Flat_FLATSET_<date>_ISO_<value>_EXP_<value>_Bin_<value>_TELESCOPE_<name>_FILTER_<name>_CAMERA_<model>
|
|
11
|
+
#
|
|
12
|
+
# **Keyword meanings:**
|
|
13
|
+
# - FLATSET: Date-based grouping (typically the morning after lights were captured)
|
|
14
|
+
# - ROTATION: Normalized rotation angle (module 180 to ignore meridian flip, only present if rotation is in FITS)
|
|
15
|
+
# - ISO: Camera ISO setting (if ISO is recorded, mutually exclusive with GAIN)
|
|
16
|
+
# - GAIN: Camera gain setting (if gain is recorded, mutually exclusive with ISO)
|
|
17
|
+
# - EXP: Exposure time (note: WBPP requires matching EXP on flats to lights in WBPP_Integration)
|
|
18
|
+
# - Bin: Binning mode
|
|
19
|
+
# - TELESCOPE: Optical equipment used (refractor, Newtonian, etc.)
|
|
20
|
+
# - FILTER: Filter used (luminance, Ha, OIII, etc.)
|
|
21
|
+
# - CAMERA: Camera model
|
|
22
|
+
#
|
|
23
|
+
# These flats are typically grouped together with their corresponding flat darks in the
|
|
24
|
+
# same FLATSET directory so WBPP can load both in one step during the WBPP_Flats process.
|
|
25
|
+
#
|
|
26
|
+
# Important: After generating master flats in WBPP, remove the "EXP_*" segment from the
|
|
27
|
+
# filename so that WBPP_Integration can automatically match them to lights. Keywords must
|
|
28
|
+
# match exactly between files for WBPP to match them.
|
|
29
|
+
#
|
|
30
|
+
# See README.md for details on WBPP_Flats process icon and keyword matching.
|
|
31
|
+
class FlatPathBuilder < BasePathBuilder
|
|
32
|
+
# @return [String] The folder path for this flat frame
|
|
33
|
+
def build
|
|
34
|
+
[
|
|
35
|
+
'Flat',
|
|
36
|
+
"FLATSET_#{@metadata.flatset_id}",
|
|
37
|
+
@metadata.normalized_rotation&.then { |r| "ROTATION_#{r}deg" },
|
|
38
|
+
iso_or_gain,
|
|
39
|
+
"EXP_#{@metadata.exposure}",
|
|
40
|
+
"Bin_#{@metadata.bin}",
|
|
41
|
+
"TELESCOPE_#{@metadata.telescope || '????'}",
|
|
42
|
+
"FILTER_#{@metadata.filter || '????'}",
|
|
43
|
+
"CAMERA_#{@metadata.camera || '????'}",
|
|
44
|
+
].compact.join('_')
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module PathBuilders
|
|
5
|
+
# rubocop:disable Layout/LineLength
|
|
6
|
+
# Builds folder paths for light (science) frames.
|
|
7
|
+
#
|
|
8
|
+
# Light frames are organized by the following keywords, which allow WBPP to
|
|
9
|
+
# automatically match lights to calibration frames (darks and flats) during integration:
|
|
10
|
+
#
|
|
11
|
+
# **Pattern:**
|
|
12
|
+
# Light_<target>_PANE_<pane>_FLATSET_<date>_ROTATION_<angle>deg_ISO_<value>_EXP_<value>_Bin_<value>_CCD-TEMP_<value>_TELESCOPE_<name>_FILTER_<name>_CAMERA_<model>
|
|
13
|
+
#
|
|
14
|
+
# **Keyword meanings:**
|
|
15
|
+
# - Target: Object being imaged (e.g., M42, NGC1977)
|
|
16
|
+
# - PANE: Mosaic pane identifier (e.g., 1-1, 1-2) - optional
|
|
17
|
+
# - FLATSET: Date-based grouping matching the flats/darks captured for this light set
|
|
18
|
+
# - ROTATION: Normalized rotation angle (module 180 to ignore meridian flip, only present if rotation is in FITS)
|
|
19
|
+
# - ISO/GAIN: Camera ISO or GAIN setting (must match darks and flats)
|
|
20
|
+
# - EXP: Exposure time (must match darks and flats)
|
|
21
|
+
# - Bin: Binning mode (must match darks and flats)
|
|
22
|
+
# - CCD-TEMP: Rounded CCD temperature (used to group frames for calibration consistency)
|
|
23
|
+
# - TELESCOPE: Optical equipment used
|
|
24
|
+
# - FILTER: Filter used
|
|
25
|
+
# - CAMERA: Camera model
|
|
26
|
+
#
|
|
27
|
+
# The keywords are used by WBPP_Integration to automatically select matching darks,
|
|
28
|
+
# flats, and biases for each light frame during the calibration and integration process.
|
|
29
|
+
#
|
|
30
|
+
# See README.md for details on WBPP_Integration process icon and keyword matching.
|
|
31
|
+
# rubocop:enable Layout/LineLength
|
|
32
|
+
class LightPathBuilder < BasePathBuilder
|
|
33
|
+
# @return [String] The folder path for this light frame
|
|
34
|
+
def build
|
|
35
|
+
pane_segment = @metadata.mosaic_pane ? "_PANE_#{@metadata.mosaic_pane}" : ''
|
|
36
|
+
prefix = "Light_#{@metadata.target}#{pane_segment}"
|
|
37
|
+
|
|
38
|
+
[
|
|
39
|
+
prefix,
|
|
40
|
+
"FLATSET_#{@metadata.flatset_id}",
|
|
41
|
+
@metadata.normalized_rotation&.then { |r| "ROTATION_#{r}deg" },
|
|
42
|
+
iso_or_gain,
|
|
43
|
+
"EXP_#{@metadata.exposure}",
|
|
44
|
+
"Bin_#{@metadata.bin}",
|
|
45
|
+
"CCD-TEMP_#{@metadata.rounded_ccd_temp}",
|
|
46
|
+
"TELESCOPE_#{@metadata.telescope || '????'}",
|
|
47
|
+
"FILTER_#{@metadata.filter || '????'}",
|
|
48
|
+
"CAMERA_#{@metadata.camera || '????'}",
|
|
49
|
+
].compact.join('_')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module Utils
|
|
5
|
+
# A cleanup utility that removes empty directories from `path` and all
|
|
6
|
+
# its subdirectories. Useful after organizing and moving subframes.
|
|
7
|
+
class EmptyDirectoryCleaner
|
|
8
|
+
include Logging
|
|
9
|
+
|
|
10
|
+
attr_reader :path
|
|
11
|
+
|
|
12
|
+
def initialize(path = Dir.pwd)
|
|
13
|
+
@path = path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Removes empty directories under the given directory.
|
|
17
|
+
def cleanup(dry_run: false, verbose: false)
|
|
18
|
+
logger.info 'Cleaning up empty directories...'
|
|
19
|
+
|
|
20
|
+
Dir.glob('**//*/', base: path).reverse_each do |dir|
|
|
21
|
+
full_path = File.join(path, dir)
|
|
22
|
+
entries = Dir.entries(full_path) - ['.', '..', '.DS_Store']
|
|
23
|
+
|
|
24
|
+
next unless entries.empty?
|
|
25
|
+
|
|
26
|
+
unless dry_run
|
|
27
|
+
ds_store = File.join(full_path, '.DS_Store')
|
|
28
|
+
FileUtils.rm_f(ds_store, verbose: verbose)
|
|
29
|
+
FileUtils.rmdir(full_path, verbose: verbose)
|
|
30
|
+
end
|
|
31
|
+
logger.debug "rmdir #{full_path}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'exiftool_vendored'
|
|
4
|
+
|
|
5
|
+
module AstroSubframeOrganizer
|
|
6
|
+
module Utils
|
|
7
|
+
# Renames CR2 files based on EXIF data to match the standard subframe naming
|
|
8
|
+
# convention: TYPE_TARGET_EXPTIME_BIN_CAMERA_ISO_DATETIME_TEMP_SEQ.CR2
|
|
9
|
+
class ExifRenamer
|
|
10
|
+
include Logging
|
|
11
|
+
include ExposureFormat
|
|
12
|
+
|
|
13
|
+
RAW_NAME_PATTERN = /^(IMG_|DSC_|_DSC|DSCN)/
|
|
14
|
+
|
|
15
|
+
EXIF_DT_FORMAT = '%Y:%m:%d %H:%M:%S%z'
|
|
16
|
+
|
|
17
|
+
attr_reader :path
|
|
18
|
+
|
|
19
|
+
def initialize(path = Dir.pwd)
|
|
20
|
+
@path = path
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def rename(type:, target: nil, dry_run: false)
|
|
24
|
+
Exiftool.command = 'exiftool.exe' if Gem.win_platform?
|
|
25
|
+
|
|
26
|
+
cr2_files = find_cr2_files
|
|
27
|
+
|
|
28
|
+
if cr2_files.empty?
|
|
29
|
+
logger.warn 'No CR2 files found.'
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
e = Exiftool.new(cr2_files)
|
|
34
|
+
bar = TTY::ProgressBar.new(
|
|
35
|
+
'Renameing files from EXIF data [:bar] :current/:total (:percent) :eta',
|
|
36
|
+
total: e.files_with_results.size,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
e.files_with_results.each do |cr2|
|
|
40
|
+
exif = e.result_for(cr2)
|
|
41
|
+
rename_file(cr2, exif, type: type, target: target, dry_run: dry_run, bar: bar)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def already_named?(files)
|
|
46
|
+
files.none? { |file| File.basename(file).match?(RAW_NAME_PATTERN) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def find_cr2_files
|
|
50
|
+
exts = Config.raw_extensions.flat_map { |ext| [ext.downcase, ext.upcase] }
|
|
51
|
+
Dir.glob(exts.map { |e| "**/*#{e}" }, base: path)
|
|
52
|
+
.map { |f| File.expand_path(File.join(path, f)) }
|
|
53
|
+
.uniq
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def revert(dry_run: false)
|
|
57
|
+
cr2_files = find_cr2_files # recursive search
|
|
58
|
+
|
|
59
|
+
if cr2_files.empty?
|
|
60
|
+
logger.warn 'No CR2 files found.'
|
|
61
|
+
return
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
cr2_files.each_with_index do |file, index|
|
|
65
|
+
idx = derive_sequence_number_from_filename(file) || (index + 1)
|
|
66
|
+
filename = "IMG_#{idx.to_s.rjust(4, '0')}.CR2"
|
|
67
|
+
target_file = File.join(File.dirname(file), filename)
|
|
68
|
+
|
|
69
|
+
logger.info "Renaming #{File.basename(file)} to #{filename}"
|
|
70
|
+
FileUtils.move(file, target_file, verbose: dry_run, noop: dry_run) unless File.exist?(target_file)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def rename_file(cr2, exif, type:, target:, dry_run:, bar: nil)
|
|
77
|
+
filename = build_filename(exif, type: type, target: target)
|
|
78
|
+
target_file = File.join(File.dirname(cr2), filename)
|
|
79
|
+
|
|
80
|
+
if File.exist?(target_file)
|
|
81
|
+
msg = "Skipping #{cr2}, target #{target_file} already exists."
|
|
82
|
+
bar ? bar.log(msg) : logger.warn(msg)
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
FileUtils.mv(cr2, target_file, verbose: dry_run, noop: dry_run)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def get_exif_value(exif, mapping_key)
|
|
90
|
+
tags = Config.exif_tag_mappings[mapping_key] || []
|
|
91
|
+
tags.each do |tag|
|
|
92
|
+
return exif[tag] if exif[tag]
|
|
93
|
+
end
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def derive_sequence_number_from_filename(path)
|
|
98
|
+
File.basename(path, '.*').split(/[_-]/).last.to_i
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def build_filename(exif, type:, target:)
|
|
102
|
+
exp_str = format_exposure(get_exif_value(exif, 'exposure'))
|
|
103
|
+
created_at = resolve_time(exif).strftime(FILENAME_DT_FORMAT)
|
|
104
|
+
ccd_temp = format('%.1fC', get_exif_value(exif, 'temperature').to_f)
|
|
105
|
+
seq_num = derive_sequence_number_from_filename(exif.source_file).to_s.rjust(4, '0')
|
|
106
|
+
camera = resolve_camera(get_exif_value(exif, 'model'))
|
|
107
|
+
|
|
108
|
+
[type, target, exp_str, 'Bin1', camera, "ISO#{get_exif_value(exif, 'iso')}", created_at, ccd_temp, seq_num] # rubocop:disable Style/StringConcatenation
|
|
109
|
+
.compact
|
|
110
|
+
.join('_') + '.CR2'
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def resolve_camera(cam_model)
|
|
114
|
+
camera = Equipment::Camera.all.find { |c| cam_model.include?(c) }
|
|
115
|
+
if camera.nil?
|
|
116
|
+
logger.warn "Camera #{cam_model} did not match any expected models."
|
|
117
|
+
cam_model
|
|
118
|
+
else
|
|
119
|
+
camera
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def resolve_time(exif)
|
|
124
|
+
dt = get_exif_value(exif, 'timestamp')
|
|
125
|
+
tz = exif[:time_zone] || '+00:00'
|
|
126
|
+
Time.strptime("#{dt}#{tz}", EXIF_DT_FORMAT)
|
|
127
|
+
rescue ArgumentError
|
|
128
|
+
Time.parse(dt)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module Utils
|
|
5
|
+
# Utility module to format exposure times in a human-readable way for path building and filename generation.
|
|
6
|
+
module ExposureFormat
|
|
7
|
+
# Formats an exposure time in seconds into a string with appropriate units (s, ms, or us).
|
|
8
|
+
# @param exp_time [Numeric] The exposure time in seconds
|
|
9
|
+
# @return [String] The formatted exposure time with units (e.g., "30.0s", "500.0ms", "250.0us")
|
|
10
|
+
def format_exposure(exp_time)
|
|
11
|
+
exp_time = exp_time.to_f
|
|
12
|
+
unit = 's'
|
|
13
|
+
if exp_time < 1.0
|
|
14
|
+
exp_time *= 1000
|
|
15
|
+
unit = 'ms'
|
|
16
|
+
end
|
|
17
|
+
if exp_time < 1.0
|
|
18
|
+
exp_time *= 1000
|
|
19
|
+
unit = 'us'
|
|
20
|
+
end
|
|
21
|
+
format('%<time>.1f%<unit>s', time: exp_time, unit: unit)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module FileUtils # rubocop:disable Style/Documentation
|
|
6
|
+
# We redefine the internal output method used by FileUtils
|
|
7
|
+
def self.fu_output_message(msg)
|
|
8
|
+
AstroSubframeOrganizer.logger.info(msg)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module Utils
|
|
5
|
+
# Strips image data and identifying location information from FITS files.
|
|
6
|
+
# The resulting file is valid FITS with NAXIS set to 0, meaning no
|
|
7
|
+
# data array follows the header block.
|
|
8
|
+
#
|
|
9
|
+
# Location headers stripped: SITELAT, SITELONG, SITEELEV, RA, DEC,
|
|
10
|
+
# OBJCTRA, OBJCTDEC, CRVAL1, CRVAL2, CRPIX1, CRPIX2, and WCS keywords.
|
|
11
|
+
class FitsStripper
|
|
12
|
+
BLOCK_SIZE = 2880
|
|
13
|
+
CARD_SIZE = 80
|
|
14
|
+
CARDS_PER_BLOCK = BLOCK_SIZE / CARD_SIZE
|
|
15
|
+
|
|
16
|
+
LOCATION_HEADERS = %w[
|
|
17
|
+
SITELAT
|
|
18
|
+
SITELONG
|
|
19
|
+
SITEELEV
|
|
20
|
+
RA
|
|
21
|
+
DEC
|
|
22
|
+
OBJCTRA
|
|
23
|
+
OBJCTDEC
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# WCS (World Coordinate System) headers that encode pointing information
|
|
27
|
+
WCS_PREFIXES = %w[CRVAL CRPIX CD1_ CD2_ A_ B_ AP_ BP_ CTYPE CUNIT].freeze
|
|
28
|
+
|
|
29
|
+
def self.strip(input_path, output_path = nil)
|
|
30
|
+
new(input_path, output_path).strip
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(input_path, output_path = nil)
|
|
34
|
+
@input_path = input_path
|
|
35
|
+
@output_path = output_path || input_path # default to in-place
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def already_stripped?
|
|
39
|
+
File.open(@input_path, 'rb') do |f|
|
|
40
|
+
loop do
|
|
41
|
+
block = f.read(BLOCK_SIZE)
|
|
42
|
+
return false unless block&.length == BLOCK_SIZE
|
|
43
|
+
|
|
44
|
+
cards = block.scan(/.{#{CARD_SIZE}}/mo)
|
|
45
|
+
naxis_card = cards.find { |c| c.start_with?('NAXIS =') }
|
|
46
|
+
return naxis_card.match?(/=\s+0\b/) if naxis_card
|
|
47
|
+
|
|
48
|
+
return false if header_end?(block)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def strip
|
|
55
|
+
header_blocks = read_header_blocks
|
|
56
|
+
patched = patch_naxis(header_blocks)
|
|
57
|
+
patched = strip_location_headers(patched)
|
|
58
|
+
File.binwrite(@output_path, patched)
|
|
59
|
+
@output_path
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def read_header_blocks
|
|
65
|
+
blocks = []
|
|
66
|
+
File.open(@input_path, 'rb') do |f|
|
|
67
|
+
loop do
|
|
68
|
+
block = f.read(BLOCK_SIZE)
|
|
69
|
+
break unless block&.length == BLOCK_SIZE
|
|
70
|
+
|
|
71
|
+
blocks << block
|
|
72
|
+
break if header_end?(block)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
blocks.join
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def patch_naxis(header_data)
|
|
79
|
+
header_data.gsub(/NAXIS =\s+\d+/) { |m| 'NAXIS = 0'.ljust(m.length) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def strip_location_headers(header_data)
|
|
83
|
+
cards = header_data.scan(/.{#{CARD_SIZE}}/mo)
|
|
84
|
+
cards.map do |card|
|
|
85
|
+
if location_header?(card)
|
|
86
|
+
blank_card
|
|
87
|
+
else
|
|
88
|
+
card
|
|
89
|
+
end
|
|
90
|
+
end.join
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def location_header?(card)
|
|
94
|
+
key = card[0, 8].strip
|
|
95
|
+
LOCATION_HEADERS.include?(key) ||
|
|
96
|
+
WCS_PREFIXES.any? { |prefix| key.start_with?(prefix) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def blank_card
|
|
100
|
+
' ' * CARD_SIZE
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def header_end?(block)
|
|
104
|
+
block.scan(/.{#{CARD_SIZE}}/mo).any? { |card| card.start_with?('END ') }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def stripped_path(input_path)
|
|
108
|
+
dir = File.dirname(input_path)
|
|
109
|
+
basename = File.basename(input_path, '.*')
|
|
110
|
+
ext = File.extname(input_path)
|
|
111
|
+
File.join(dir, "#{basename}_stripped#{ext}")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'exiftool_vendored'
|
|
5
|
+
|
|
6
|
+
module AstroSubframeOrganizer
|
|
7
|
+
module Utils
|
|
8
|
+
# Strips RAW files down to their metadata by creating a MIE (Metadata Information Extraction) file using exiftool.
|
|
9
|
+
# This is useful for users who want to keep metadata but remove large image data from their RAW files, for example
|
|
10
|
+
# to generate test fixtures where metadata is important, but image data is not needed.
|
|
11
|
+
class RawStripper
|
|
12
|
+
def initialize(input_path, output_path)
|
|
13
|
+
@input = input_path
|
|
14
|
+
@output = output_path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def already_stripped?
|
|
18
|
+
# A stripped RAW (MIE file) is typically under 1MB,
|
|
19
|
+
# whereas a real RAW is 20MB+.
|
|
20
|
+
return false unless File.exist?(@output)
|
|
21
|
+
|
|
22
|
+
File.size(@output) < 1_048_576
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def strip # rubocop:disable Naming/PredicateMethod
|
|
26
|
+
# We use exiftool to create a Metadata Information Extraction (MIE) file.
|
|
27
|
+
# This contains all metadata but zero image data.
|
|
28
|
+
# We then save it with the original extension so the parsers recognize it.
|
|
29
|
+
tmp_mie = "#{@output}.mie"
|
|
30
|
+
Exiftool.command = 'exiftool.exe' if Gem.win_platform?
|
|
31
|
+
|
|
32
|
+
# -o specifies the output file. exiftool creates a MIE file if the extension is .mie
|
|
33
|
+
# -all:all ensures we copy all metadata blocks (EXIF, MakerNotes, etc.)
|
|
34
|
+
success = system("#{Exiftool.command} -o \"#{tmp_mie}\" -all:all \"#{@input}\" > /dev/null 2>&1")
|
|
35
|
+
|
|
36
|
+
if success && File.exist?(tmp_mie)
|
|
37
|
+
FileUtils.mkdir_p(File.dirname(@output))
|
|
38
|
+
FileUtils.mv(tmp_mie, @output, force: true)
|
|
39
|
+
true
|
|
40
|
+
else
|
|
41
|
+
FileUtils.rm_f(tmp_mie)
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module Utils
|
|
5
|
+
# A cleanup utility that removes thumbnail images with filenames mattching `pattern`
|
|
6
|
+
# from `path` and all its subdirectories.
|
|
7
|
+
class ThumbnailCleaner
|
|
8
|
+
include Logging
|
|
9
|
+
|
|
10
|
+
ASIAIR_THUMBNAIL_PATTERN = '**/**_thn.jpg'
|
|
11
|
+
|
|
12
|
+
attr_reader :path
|
|
13
|
+
|
|
14
|
+
def initialize(path = Dir.pwd)
|
|
15
|
+
@path = path
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Removes all the jpg thumbnails under the given directory.
|
|
19
|
+
def cleanup(pattern: ASIAIR_THUMBNAIL_PATTERN, dry_run: false, verbose: false)
|
|
20
|
+
logger.info 'Removing jpg thumbnails...'
|
|
21
|
+
Dir.glob([pattern], base: path)
|
|
22
|
+
.each { |thumbnail| FileUtils.rm File.join(path, thumbnail), noop: dry_run, verbose: verbose }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module Utils
|
|
5
|
+
# Moves all FITS and CR2 files from subdirectories back into the target directory.
|
|
6
|
+
# Useful for undoing an organize run so files can be re-organized with different settings.
|
|
7
|
+
class Unorganizer
|
|
8
|
+
include Logging
|
|
9
|
+
|
|
10
|
+
attr_reader :path
|
|
11
|
+
|
|
12
|
+
def initialize(path = Dir.pwd)
|
|
13
|
+
@path = path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def unorganize(dry_run: false, verbose: false)
|
|
17
|
+
files = find_organized_files
|
|
18
|
+
|
|
19
|
+
if files.empty?
|
|
20
|
+
logger.info 'No organized files found.'
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
logger.info "Preparing to move #{files.size} files to #{path}..."
|
|
25
|
+
|
|
26
|
+
bar = TTY::ProgressBar.new('Moving files [:bar] :current/:total (:percent) :eta', total: files.size)
|
|
27
|
+
|
|
28
|
+
files.each do |file|
|
|
29
|
+
dest = File.join(path, File.basename(file))
|
|
30
|
+
if File.exist?(dest)
|
|
31
|
+
bar.log "Skipping #{File.basename(file)}, already exists in #{path}."
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
FileUtils.mv(file, dest, verbose: verbose || dry_run, noop: dry_run)
|
|
35
|
+
bar.advance(1)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
cleanup_empty_dirs(dry_run: dry_run) unless dry_run
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def find_organized_files
|
|
44
|
+
Dir.glob(['**/*.fit', '**/*.FIT', '**/*.cr2', '**/*.CR2'], base: path)
|
|
45
|
+
.map { |f| File.join(path, f) }
|
|
46
|
+
.reject { |f| File.dirname(f) == path }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def cleanup_empty_dirs(dry_run: false, verbose: false)
|
|
50
|
+
EmptyDirectoryCleaner.new(path).cleanup(dry_run: dry_run, verbose: verbose)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|