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,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module FilenameParsers
|
|
5
|
+
# Parser for Canon CR2 (Canon Raw 2) format files from ASIAir Plus.
|
|
6
|
+
#
|
|
7
|
+
# CR2 files are Canon RAW image files that can be captured by cameras like the Canon T7.
|
|
8
|
+
# The filename structure is similar to FITS files when captured via ASIAir Plus.
|
|
9
|
+
#
|
|
10
|
+
# Expected filename format:
|
|
11
|
+
# Type_[Target]_[Mosaic]_Exposure_BinBinning_Camera_ISO/Gain_DateTime_CCDTemp_ImageIndex.cr2
|
|
12
|
+
#
|
|
13
|
+
# Example:
|
|
14
|
+
# Light_M42_1.0s_Bin1_T7_ISO100_20220508-120000_-10.0C_0001.cr2
|
|
15
|
+
#
|
|
16
|
+
# Returns a hash with keys identical to FitsFilenameParser, except:
|
|
17
|
+
# - :file_format (:cr2 instead of :fits)
|
|
18
|
+
#
|
|
19
|
+
# The CR2 format uses CCD-TEMP instead of ISO for temperature tracking since RAW files
|
|
20
|
+
# preserve more metadata from the camera hardware.
|
|
21
|
+
class CR2FilenameParser < FilenameParser
|
|
22
|
+
include Logging
|
|
23
|
+
|
|
24
|
+
# @return [Hash] Parsed metadata from CR2 filename
|
|
25
|
+
def parse # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
|
|
26
|
+
base_name = extract_base_name
|
|
27
|
+
parts = parse_parts(base_name)
|
|
28
|
+
result = {}
|
|
29
|
+
|
|
30
|
+
if @filename.start_with? 'IMG_'
|
|
31
|
+
logger.error(
|
|
32
|
+
'Raw images must be renamed before organizing. Run `astro-subframe-organizer raw rename`, ' \
|
|
33
|
+
'then try again.',
|
|
34
|
+
)
|
|
35
|
+
exit(1)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
result[:file_format] = :cr2
|
|
40
|
+
result[:path] = @path
|
|
41
|
+
result[:filename] = @filename
|
|
42
|
+
|
|
43
|
+
# If the file is already organized somewhere, get the information from its path.
|
|
44
|
+
result.merge!(extract_metadata_from_path)
|
|
45
|
+
|
|
46
|
+
result[:type] = parts.shift
|
|
47
|
+
result[:target] = parts.shift if result[:type] == 'Light'
|
|
48
|
+
result[:mosaic_pane] = parts.shift if parts.first&.match?(/\A\d+-\d+\z/)
|
|
49
|
+
result[:exposure] = parts.shift
|
|
50
|
+
result[:bin] = parts.shift.gsub('Bin', '') if parts.first&.start_with?('Bin')
|
|
51
|
+
result[:camera] = parts.shift if Equipment::Camera.all.include?(parts.first)
|
|
52
|
+
result[:iso] = parts.shift.gsub('ISO', '') if parts.first&.start_with?('ISO')
|
|
53
|
+
result[:gain] = parts.shift.gsub('gain', '') if parts.first&.start_with?('gain')
|
|
54
|
+
result[:created_at] = DateTime.strptime(parts.shift, FILENAME_DT_FORMAT)
|
|
55
|
+
result[:ccd_temp] = parts.shift
|
|
56
|
+
result[:image_index] = parts.shift
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
logger.error "Failed to parse #{@path}: #{e}"
|
|
59
|
+
ensure
|
|
60
|
+
logger.debug result
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
FileMetadata.from_parsed_data(result)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module FilenameParsers
|
|
5
|
+
# Parser for FITS format files from ASIAir Plus.
|
|
6
|
+
#
|
|
7
|
+
# Expected filename format:
|
|
8
|
+
# Type_[Target]_[Mosaic]_Exposure_BinBinning_Camera_ISO/Gain_DateTime_CCDTemp_ImageIndex
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# Light_M42_1.0s_Bin1_T7_ISO100_20220508-120000_-10.0C_0001.fit
|
|
12
|
+
# Dark_30.0s_Bin1_T7_ISO100_20220508-120000_-10.0C_0001.fit
|
|
13
|
+
#
|
|
14
|
+
# Returns a hash with keys:
|
|
15
|
+
# - :type (Light, Dark, Flat, Bias)
|
|
16
|
+
# - :target (for Light files only)
|
|
17
|
+
# - :mosaic_pane (optional, if present)
|
|
18
|
+
# - :exposure (with units, e.g., "1.0s")
|
|
19
|
+
# - :bin (numeric string)
|
|
20
|
+
# - :camera (model name)
|
|
21
|
+
# - :iso (numeric string, if present)
|
|
22
|
+
# - :gain (numeric string, if ISO not present)
|
|
23
|
+
# - :created_at (DateTime object)
|
|
24
|
+
# - :ccd_temp (with units, e.g., "-10.0C")
|
|
25
|
+
# - :image_index (numeric string)
|
|
26
|
+
# - :file_format (:fits)
|
|
27
|
+
# - :path (full file path)
|
|
28
|
+
# - :filename (just the filename)
|
|
29
|
+
class FitsFilenameParser < FilenameParser
|
|
30
|
+
include Logging
|
|
31
|
+
|
|
32
|
+
# @return [Hash] Parsed metadata from FITS filename
|
|
33
|
+
def parse # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
34
|
+
base_name = extract_base_name
|
|
35
|
+
parts = parse_parts(base_name)
|
|
36
|
+
result = {}
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
result[:file_format] = :fits
|
|
40
|
+
result[:path] = @path
|
|
41
|
+
result[:filename] = @filename
|
|
42
|
+
|
|
43
|
+
# If the file is already organized somewhere, get the information from its path.
|
|
44
|
+
result.merge!(extract_metadata_from_path)
|
|
45
|
+
|
|
46
|
+
result[:type] = parts.shift
|
|
47
|
+
result[:target] = parts.shift if result[:type] == 'Light'
|
|
48
|
+
result[:mosaic_pane] = parts.shift if parts.first&.match?(/\A\d+-\d+\z/)
|
|
49
|
+
result[:exposure] = parts.shift
|
|
50
|
+
result[:bin] = parts.shift.gsub('Bin', '') if parts.first&.start_with?('Bin')
|
|
51
|
+
result[:camera] = parts.shift if Equipment::Camera.all.include?(parts.first)
|
|
52
|
+
result[:iso] = parts.shift.gsub('ISO', '') if parts.first&.start_with?('ISO')
|
|
53
|
+
result[:gain] = parts.shift.gsub('gain', '') if parts.first&.start_with?('gain')
|
|
54
|
+
result[:created_at] = DateTime.strptime(parts.shift, FILENAME_DT_FORMAT)
|
|
55
|
+
result[:ccd_temp] = parts.shift
|
|
56
|
+
result[:image_index] = parts.shift
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
logger.error e
|
|
59
|
+
ensure
|
|
60
|
+
logger.debug result
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
FileMetadata.from_parsed_data(result)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fits_parser'
|
|
4
|
+
|
|
5
|
+
module AstroSubframeOrganizer
|
|
6
|
+
module FilenameParsers
|
|
7
|
+
# Parser to generate FileMetadata based on FITS headers and filename patterns.
|
|
8
|
+
class FitsHeaderParser < FilenameParser
|
|
9
|
+
include Logging
|
|
10
|
+
include AstroSubframeOrganizer::Utils::ExposureFormat
|
|
11
|
+
|
|
12
|
+
def headers
|
|
13
|
+
@headers ||= load_headers(path)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def [](key)
|
|
17
|
+
headers[key]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def parse # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
21
|
+
result = {
|
|
22
|
+
file_format: :fits,
|
|
23
|
+
path: @path,
|
|
24
|
+
filename: @filename,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# If the file is already organized somewhere, get the information from its path.
|
|
28
|
+
metadata_from_path = extract_metadata_from_path
|
|
29
|
+
result[:telescope] = metadata_from_path[:telescope] || header(:telescope)
|
|
30
|
+
result[:filter] = metadata_from_path[:filter] || header(:filter)
|
|
31
|
+
result[:dark_flat] = metadata_from_path[:dark_flat]
|
|
32
|
+
|
|
33
|
+
result[:type] = image_type
|
|
34
|
+
result[:target] = target if light_frame?
|
|
35
|
+
result[:exposure] = format_exposure(header(:exposure))
|
|
36
|
+
result[:bin] = header(:binning)
|
|
37
|
+
result[:camera] = header(:camera)
|
|
38
|
+
result[:gain] = header(:gain)
|
|
39
|
+
result[:iso] = header(:iso)
|
|
40
|
+
result[:created_at] = parse_date(header(:date_obs))
|
|
41
|
+
result[:ccd_temp] = format_temp(ccd_temperature)
|
|
42
|
+
result[:image_index] = parse_parts(extract_base_name).last
|
|
43
|
+
result[:rotation] = rotation_angle
|
|
44
|
+
result[:mosaic_pane] = mosaic_pane
|
|
45
|
+
|
|
46
|
+
FileMetadata.from_parsed_data(result)
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
logger.error "Failed to parse FITS headers for #{@filename}: #{e.message}"
|
|
49
|
+
FileMetadata.from_parsed_data(file_format: :fits, path: @path, filename: @filename)
|
|
50
|
+
ensure
|
|
51
|
+
logger.debug result
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def target
|
|
55
|
+
header(:target) || parsed_from_filename[:target]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def mosaic_pane
|
|
59
|
+
# Not available in FITS headers for ASIAIR mosaic frames.
|
|
60
|
+
# Must be parsed from filename pattern: TARGET_ROW-COL_
|
|
61
|
+
parsed_from_filename[:mosaic_pane]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def image_type
|
|
65
|
+
header(:type)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def light_frame?
|
|
69
|
+
image_type == Astrophoto::LIGHT
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def header(key)
|
|
73
|
+
keys = Config.fits_header_mappings[key.to_s] || []
|
|
74
|
+
keys.lazy.filter_map { |k| headers[k] }.first
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def rotation_angle
|
|
78
|
+
header(:rotation)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ccd_temperature
|
|
82
|
+
header(:temperature)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def load_headers(path)
|
|
88
|
+
hdu = FitsParser.open(path) do |parser|
|
|
89
|
+
parser.parse_hdus.find { |h| h[:header] }
|
|
90
|
+
end
|
|
91
|
+
raise "No HDU with headers found in #{path}" unless hdu
|
|
92
|
+
|
|
93
|
+
hdu[:header]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_date(value)
|
|
97
|
+
return nil if value.nil?
|
|
98
|
+
|
|
99
|
+
DateTime.strptime(value, '%Y-%m-%dT%H:%M:%S.%6N')
|
|
100
|
+
rescue ArgumentError
|
|
101
|
+
DateTime.parse(value)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parsed_from_filename
|
|
105
|
+
@parsed_from_filename ||= begin
|
|
106
|
+
parts = File.basename(@path, '.*').split('_')
|
|
107
|
+
pane_index = parts.index { |p| p.match?(/\A\d{1,2}-\d{1,2}\z/) }
|
|
108
|
+
{
|
|
109
|
+
target: pane_index ? parts[1...pane_index].join('_') : nil,
|
|
110
|
+
mosaic_pane: pane_index ? parts[pane_index] : nil,
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def format_temp(temp)
|
|
116
|
+
format '%0.1fC', temp
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
# Main class responsible for interactively organizing FITS files based on their headers and user input.
|
|
5
|
+
class FitsOrganizer
|
|
6
|
+
include Logging
|
|
7
|
+
|
|
8
|
+
private attr_accessor :prompt, :path, :dry_run
|
|
9
|
+
|
|
10
|
+
def initialize(path = Dir.pwd, dry_run: nil)
|
|
11
|
+
self.prompt = AstroSubframeOrganizer.prompt
|
|
12
|
+
self.path = path
|
|
13
|
+
self.dry_run = dry_run
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Organizes dark files by ISO, BIN, CCD-TEMP, EXPOSURE, and MONTH to facilitate the creation of
|
|
17
|
+
# master darks that may have varying temperatures. This organization can be changed by updating
|
|
18
|
+
# Astrophoto#target_dir for the DARK type.
|
|
19
|
+
#
|
|
20
|
+
# If the file has an exposure of less than 10 seconds, you will be asked if it is a flat dark.
|
|
21
|
+
# If so, it will be organized into a folder that will match your corresponding flat files so that
|
|
22
|
+
# you can run WBPP with just your biases, flat darks, and flats using the grouping keywords
|
|
23
|
+
# FLATSET, BIN, EXP, and ISO. CCD-TEMP will be ignored for the purposes of these files, as it is
|
|
24
|
+
# assumed they will be taken under roughly the same conditions as the flats are taken.
|
|
25
|
+
#
|
|
26
|
+
# If the files are normal dark files, they will be organized by ISO, EXPOSURE, BIN, CCD-TEMP, and MONTH.
|
|
27
|
+
# With this, you can run WBPP with just bias and darks using the grouping keywords CCD-TEMP, ISO, EXP,
|
|
28
|
+
# and MONTH (optional).
|
|
29
|
+
def organize_darks
|
|
30
|
+
Organizer.new(
|
|
31
|
+
path: path,
|
|
32
|
+
type: Astrophoto::DARK,
|
|
33
|
+
prompt: prompt,
|
|
34
|
+
).organize(dry_run: is_dry_run?)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def organize_biases
|
|
38
|
+
Organizer.new(
|
|
39
|
+
path: path,
|
|
40
|
+
type: Astrophoto::BIAS,
|
|
41
|
+
prompt: prompt,
|
|
42
|
+
).organize(dry_run: is_dry_run?)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Organizes flat files by FLATSET, ISO, BIN, EXP (EXPOSURE), TELESCOPE, and FILTER. To change these
|
|
46
|
+
# properties, update Astrophoto#target_dir for the FLAT type. The TELESCOPE and FILTER keywords are
|
|
47
|
+
# for matching LIGHTS which will have the same keywords set when organized using this script.
|
|
48
|
+
#
|
|
49
|
+
# You can run WBPP with just your biases, flat darks, and flats using the grouping keywords
|
|
50
|
+
# FLATSET, BIN, EXP, and ISO. CCD-TEMP will be ignored for the purposes of these files, as it is
|
|
51
|
+
# assumed they will be taken under roughly the same conditions as the flat darks are taken.
|
|
52
|
+
#
|
|
53
|
+
# After running WBPP, you should delete the `EXP` keyword from the master flat file name (if present)
|
|
54
|
+
# before using that master flat in a WBPP integration run, since exposure time should not be considered
|
|
55
|
+
# when grouping flats to lights.
|
|
56
|
+
def organize_flats
|
|
57
|
+
Organizer.new(
|
|
58
|
+
path: path,
|
|
59
|
+
type: Astrophoto::FLAT,
|
|
60
|
+
prompt: prompt,
|
|
61
|
+
).organize(dry_run: is_dry_run?)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Organizes light files by FLATSET, ISO, BIN, EXP (EXPOSURE), TELESCOPE, and FILTER. To change these
|
|
65
|
+
# properties, update Astrophoto#target_dir for the LIGHT type. The TELESCOPE and FILTER keywords are
|
|
66
|
+
# for matching LIGHTS which will have the same keywords set when organized using this script.
|
|
67
|
+
#
|
|
68
|
+
# CCD-TEMP is included in the group naming using a rounded value to facilitate consistent keyword matching
|
|
69
|
+
# in PixInsight WBPP, ensuring frames captured within a temperature range are grouped together.
|
|
70
|
+
#
|
|
71
|
+
# You can run WBPP with just your master biases, master darks, and master flats using the grouping
|
|
72
|
+
# keywords FLATSET, BIN, EXP, CCD-TEMP, and ISO.
|
|
73
|
+
#
|
|
74
|
+
# If you are running WBPP on multiple targets using this data, e.g. for a mosaic, you should make sure
|
|
75
|
+
# to use LIGHT as a post-processing keyword and register files using `auto by LIGHT`.
|
|
76
|
+
def organize_lights
|
|
77
|
+
Organizer.new(
|
|
78
|
+
path: path,
|
|
79
|
+
type: Astrophoto::LIGHT,
|
|
80
|
+
prompt: prompt,
|
|
81
|
+
).organize(dry_run: is_dry_run?)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Checks for empty directories. Run this option after performing a move of previously
|
|
85
|
+
# organized data.
|
|
86
|
+
def remove_empty_directories
|
|
87
|
+
Utils::EmptyDirectoryCleaner.new(path).cleanup(dry_run: is_dry_run?)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Removes all the jpg thumbnails under this directory.
|
|
91
|
+
def remove_jpg_thumbnails
|
|
92
|
+
Utils::ThumbnailCleaner.new(path).cleanup(dry_run: is_dry_run?)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Renames CR2 Raw files to match the same name pattern as ASIAir does based on EXIF data.
|
|
96
|
+
def rename_from_exif
|
|
97
|
+
renamer = Utils::ExifRenamer.new(path)
|
|
98
|
+
type = prompt.enum_select('What is the file type?', Astrophoto::TYPES)
|
|
99
|
+
target = prompt.ask('What is the target name?') if type == Astrophoto::LIGHT
|
|
100
|
+
|
|
101
|
+
renamer.rename(type: type, target: target, dry_run: is_dry_run?)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def is_dry_run? # rubocop:disable Naming/PredicatePrefix
|
|
105
|
+
dry_run.nil? ? prompt.yes?('Is this a dry run?', default: 'y') : dry_run
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def rename_to_img
|
|
109
|
+
renamer = Utils::ExifRenamer.new(path)
|
|
110
|
+
renamer.revert(dry_run: is_dry_run?)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Prompts the user to choose which organizing task to run. This is the main entry point of
|
|
114
|
+
# this script.
|
|
115
|
+
def organize
|
|
116
|
+
loop do
|
|
117
|
+
message = 'What are we organizing?'
|
|
118
|
+
message += ' (Dry Run)' if dry_run
|
|
119
|
+
|
|
120
|
+
choice = prompt.enum_select message, per_page: 8 do |menu|
|
|
121
|
+
menu.choice 'Darks', :darks
|
|
122
|
+
menu.choice 'Flats', :flats
|
|
123
|
+
menu.choice 'Lights', :lights
|
|
124
|
+
menu.choice 'Biases', :biases
|
|
125
|
+
menu.choice 'Remove empty directories', :empty_dirs
|
|
126
|
+
menu.choice 'Remove jpg thumbnails', :thumbnails
|
|
127
|
+
menu.choice 'Rename files from EXIF data', :rename
|
|
128
|
+
menu.choice 'Quit', :quit
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
case choice
|
|
132
|
+
when :darks then organize_darks
|
|
133
|
+
when :flats then organize_flats
|
|
134
|
+
when :lights then organize_lights
|
|
135
|
+
when :biases then organize_biases
|
|
136
|
+
when :empty_dirs then remove_empty_directories
|
|
137
|
+
when :thumbnails then remove_jpg_thumbnails
|
|
138
|
+
when :rename then rename_from_exif
|
|
139
|
+
when :quit then break
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.run
|
|
145
|
+
organizer = FitsOrganizer.new
|
|
146
|
+
organizer.organize
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# TODO: Add menu to select for barlow/flatteners
|
|
152
|
+
def select_accessories; end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
# Main class responsible for organizing astrophotography subframes based on metadata and user input.
|
|
5
|
+
class Organizer
|
|
6
|
+
include Logging
|
|
7
|
+
|
|
8
|
+
attr_reader :file_sets, :type, :path, :equipment_selector
|
|
9
|
+
protected attr_accessor :prompt
|
|
10
|
+
|
|
11
|
+
def initialize(type:, path: Dir.pwd, prompt: AstroSubframeOrganizer.prompt, equipment_selector: nil)
|
|
12
|
+
@prompt = prompt
|
|
13
|
+
@path = path
|
|
14
|
+
@type = type
|
|
15
|
+
@file_sets = file_sets_for(type)
|
|
16
|
+
@equipment_selector = equipment_selector || EquipmentSelector.new(prompt)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fits_files # rubocop:disable Metrics/AbcSize
|
|
20
|
+
exts = (Config.fits_extensions + Config.raw_extensions).flat_map { |e| [e.downcase, e.upcase] }
|
|
21
|
+
all = Dir.glob(exts.map { |e| "**/*#{e}" }, base: path)
|
|
22
|
+
processable = all.filter { |f| !File.basename(f).match?(Utils::ExifRenamer::RAW_NAME_PATTERN) }
|
|
23
|
+
.uniq
|
|
24
|
+
.map { |relative| File.join(path, relative) }
|
|
25
|
+
|
|
26
|
+
if processable.size != all.size
|
|
27
|
+
unprocessable_raw_files_warning = 'Unprocessed raw images detected, but will be ignored. " +
|
|
28
|
+
"Raw images must be renamed before organizing. Run `astro-subframe-organizer raw rename_from_exif`, " +
|
|
29
|
+
"then try again.'
|
|
30
|
+
logger.warn(unprocessable_raw_files_warning)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
processable.map { |f| Astrophoto.new(f) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def organize(dry_run: false) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
37
|
+
logger.info "Preparing to move #{file_sets.sum { |set| set.files.size }} #{type} files " \
|
|
38
|
+
"from #{file_sets.size} groups..."
|
|
39
|
+
@file_sets.each do |fileset|
|
|
40
|
+
next if fileset.already_moved?
|
|
41
|
+
|
|
42
|
+
if fileset.all_unmoved?
|
|
43
|
+
move = ENV['ASTRO_SUBFRAME_SKIP_CONFIRM'] == 'true' ||
|
|
44
|
+
prompt.yes?(
|
|
45
|
+
<<~MSG,
|
|
46
|
+
Preparing to move #{fileset.size} #{fileset.type} file(s)
|
|
47
|
+
FROM #{relative_to_pwd(fileset.current_dir)}
|
|
48
|
+
TO #{relative_to_pwd(fileset.files.first.target_dir)}
|
|
49
|
+
Continue?
|
|
50
|
+
MSG
|
|
51
|
+
default: 'y',
|
|
52
|
+
)
|
|
53
|
+
next unless move
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
logger.info "For #{type} set #{fileset.files.first.filename}..#{fileset.files.last.filename}:"
|
|
57
|
+
|
|
58
|
+
check_telescope(fileset) if [Astrophoto::FLAT, Astrophoto::LIGHT].include?(type)
|
|
59
|
+
check_filter(fileset) if [Astrophoto::FLAT, Astrophoto::LIGHT].include?(type)
|
|
60
|
+
check_camera(fileset)
|
|
61
|
+
|
|
62
|
+
require 'tty-progressbar'
|
|
63
|
+
|
|
64
|
+
# Initialize the bar with the size of the fileset
|
|
65
|
+
bar = TTY::ProgressBar.new('Moving files [:bar] :current/:total (:percent) :eta', total: fileset.size)
|
|
66
|
+
|
|
67
|
+
fileset.each do |file|
|
|
68
|
+
file.move(dry_run, bar)
|
|
69
|
+
bar.advance(1) # Move the bar forward by 1 for each file
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
logger.info 'Done'
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def file_sets_for(type)
|
|
79
|
+
FileSet.from_files(fits_files, type: type)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def check_camera(fileset)
|
|
83
|
+
candidates = fileset.camera_candidates
|
|
84
|
+
camera = if candidates.size > 1
|
|
85
|
+
logger.warn "Multiple cameras detected: #{candidates}"
|
|
86
|
+
equipment_selector.choose_camera
|
|
87
|
+
else
|
|
88
|
+
equipment_selector.choose_camera_or_confirm(detected: candidates.first)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
fileset.apply_camera!(camera)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def check_telescope(fileset)
|
|
95
|
+
candidates = fileset.telescope_candidates
|
|
96
|
+
telescope = if candidates.size > 1
|
|
97
|
+
logger.warn "Multiple telescopes detected: #{candidates}"
|
|
98
|
+
equipment_selector.choose_telescope
|
|
99
|
+
else
|
|
100
|
+
equipment_selector.choose_telescope_or_confirm(detected: candidates.first)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
fileset.apply_telescope!(telescope)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def check_filter(fileset)
|
|
107
|
+
candidates = fileset.filter_candidates
|
|
108
|
+
filter = if candidates.size > 1
|
|
109
|
+
logger.warn "Multiple filters detected: #{candidates}"
|
|
110
|
+
equipment_selector.choose_filter
|
|
111
|
+
else
|
|
112
|
+
equipment_selector.choose_filter_or_confirm(detected: candidates.first)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
fileset.apply_filter!(filter)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def relative_to_pwd(path)
|
|
119
|
+
relative = Pathname.new(path).relative_path_from(Dir.pwd).to_s
|
|
120
|
+
relative.start_with?('..') ? path : relative
|
|
121
|
+
rescue ArgumentError
|
|
122
|
+
path # fallback to absolute path on different drives (Windows)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
# Factory for creating and using path builders.
|
|
5
|
+
#
|
|
6
|
+
# This class serves as the entry point for building folder paths for astrophotography
|
|
7
|
+
# image files. It uses the Strategy pattern to delegate path construction to specialized
|
|
8
|
+
# builder classes based on the image file type (Dark, Flat, Light, or Bias).
|
|
9
|
+
#
|
|
10
|
+
# The resulting folder paths contain keywords that facilitate automatic file matching in
|
|
11
|
+
# PixInsight's WeightedBatchPreProcessing (WBPP) script during calibration and integration.
|
|
12
|
+
#
|
|
13
|
+
# **Usage:**
|
|
14
|
+
# path = PathBuilder.build_for(photo_metadata) # => "Dark_ISO_100_EXP_30.0s_..."
|
|
15
|
+
# full_path = PathBuilder.target_path_for(metadata) # => "Dark_ISO_100_.../filename.fit"
|
|
16
|
+
#
|
|
17
|
+
# See the individual builder classes for details on keyword structure for each file type:
|
|
18
|
+
# - DarkPathBuilder for dark calibration frames
|
|
19
|
+
# - FlatPathBuilder for flat field frames
|
|
20
|
+
# - LightPathBuilder for science (light) frames
|
|
21
|
+
# - BiasPathBuilder for bias (zero-exposure) frames
|
|
22
|
+
#
|
|
23
|
+
# See README.md for details on the WBPP calibration workflow and keyword matching.
|
|
24
|
+
class PathBuilder
|
|
25
|
+
# Builds the folder path for an image based on its type and metadata.
|
|
26
|
+
#
|
|
27
|
+
# @param metadata [Object] Image metadata object (typically an Astrophoto instance)
|
|
28
|
+
# Must respond to: type, dark_flat?, file_format
|
|
29
|
+
# @return [String] The folder path with WBPP-compatible keywords
|
|
30
|
+
# @raise [ArgumentError] If the image type is not supported
|
|
31
|
+
def self.build_for(metadata)
|
|
32
|
+
builder = case metadata.type
|
|
33
|
+
when 'Dark'
|
|
34
|
+
PathBuilders::DarkPathBuilder.new(metadata)
|
|
35
|
+
when 'Flat'
|
|
36
|
+
PathBuilders::FlatPathBuilder.new(metadata)
|
|
37
|
+
when 'Light'
|
|
38
|
+
PathBuilders::LightPathBuilder.new(metadata)
|
|
39
|
+
when 'Bias'
|
|
40
|
+
PathBuilders::BiasPathBuilder.new(metadata)
|
|
41
|
+
else
|
|
42
|
+
raise ArgumentError, "Unsupported type: #{metadata.type}"
|
|
43
|
+
end
|
|
44
|
+
builder.build
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Builds the full target path (folder + filename) for an image.
|
|
48
|
+
#
|
|
49
|
+
# @param metadata [Object] Image metadata object with filename and type
|
|
50
|
+
# @return [String] The full path where the file will be moved
|
|
51
|
+
def self.target_path_for(metadata)
|
|
52
|
+
File.join(build_for(metadata), metadata.filename)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module PathBuilders
|
|
5
|
+
# Base class for all path builders.
|
|
6
|
+
#
|
|
7
|
+
# Path builders construct folder paths based on image metadata, using keywords that
|
|
8
|
+
# facilitate organization for PixInsight's WeightedBatchPreProcessing (WBPP) script.
|
|
9
|
+
# WBPP uses these keywords to automatically match and group images for calibration and
|
|
10
|
+
# integration tasks.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses should implement a `build` method that returns the folder path as a string
|
|
13
|
+
# with keyword-value pairs separated by underscores (e.g., "Dark_ISO_100_EXP_30.0s_...").
|
|
14
|
+
#
|
|
15
|
+
# See README.md for details on how WBPP uses these keywords in the calibration workflow.
|
|
16
|
+
class BasePathBuilder
|
|
17
|
+
def initialize(metadata)
|
|
18
|
+
@metadata = metadata
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
protected
|
|
22
|
+
|
|
23
|
+
# @return [String, nil] "ISO_<value>" if iso is present, "GAIN_<value>" if gain is present
|
|
24
|
+
def iso_or_gain
|
|
25
|
+
if @metadata.iso
|
|
26
|
+
"ISO_#{@metadata.iso}"
|
|
27
|
+
elsif @metadata.gain
|
|
28
|
+
"GAIN_#{@metadata.gain}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AstroSubframeOrganizer
|
|
4
|
+
module PathBuilders
|
|
5
|
+
# Builds folder paths for bias (zero-exposure) calibration frames.
|
|
6
|
+
#
|
|
7
|
+
# Bias frames are organized by the following keywords, which allow WBPP to
|
|
8
|
+
# automatically match biases during calibration of darks, flats, and lights:
|
|
9
|
+
#
|
|
10
|
+
# Bias_ISO_<value>_EXP_<value>_Bin_<value>_CAMERA_<model>_MONTH_<YYYY-MM>
|
|
11
|
+
#
|
|
12
|
+
# **Keyword meanings:**
|
|
13
|
+
# - ISO: Camera ISO setting
|
|
14
|
+
# - EXP: Exposure time (typically 0.0s for bias frames)
|
|
15
|
+
# - Bin: Binning mode
|
|
16
|
+
# - CAMERA: Camera model
|
|
17
|
+
# - MONTH: Year-Month in YYYY-MM format (used for seasonal grouping)
|
|
18
|
+
#
|
|
19
|
+
# Bias frames are primarily used as the baseline calibration for darks, flats, and lights
|
|
20
|
+
# to remove electronic noise. WBPP uses these keywords to match an appropriate master bias
|
|
21
|
+
# based on the ISO, exposure, binning, and season of acquisition.
|
|
22
|
+
#
|
|
23
|
+
# See README.md for details on the WBPP calibration workflow.
|
|
24
|
+
class BiasPathBuilder < BasePathBuilder
|
|
25
|
+
# @return [String] The folder path for this bias frame
|
|
26
|
+
def build
|
|
27
|
+
[
|
|
28
|
+
'Bias',
|
|
29
|
+
iso_or_gain,
|
|
30
|
+
"EXP_#{@metadata.exposure}",
|
|
31
|
+
"Bin_#{@metadata.bin}",
|
|
32
|
+
"CAMERA_#{@metadata.camera || '????'}",
|
|
33
|
+
"MONTH_#{@metadata.month}",
|
|
34
|
+
].compact.join('_')
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|