naiso 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ad34abb90d874020e78ab7414a1e5265986a529622fdbe0d15e857313382c9e7
4
+ data.tar.gz: 8da518fa8a7c56f351519937f04c562ae7c1e76fcb462261fa08deb3d2a5c26d
5
+ SHA512:
6
+ metadata.gz: 63757c465a29723ecdeff3f75d3d3a9cfc5ed5435a49a1660110688d3fbb6eea782e7f777735af19360712bd6e355a7ed6ce11e3838237981eafb3bff8dbdebd
7
+ data.tar.gz: 70b28db5d09ab2ddeaccf799c84e0ca40551317f2f47ed6aa2f811018594b9402cc29f5717c7b0950f87ed3e256580e33fd6fc62a57aec8175ec0d7dc014b045
data/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # Naiso
2
+
3
+ 상품 상세 이미지 섹션 분할 도구
4
+
5
+ 긴 세로형 상품 상세 이미지를 섹션별로 자동 분할하고, 텍스트 유무를 분석하는 Ruby gem입니다.
6
+
7
+ ## 설치
8
+
9
+ ### 시스템 요구사항
10
+
11
+ ```bash
12
+ # macOS
13
+ brew install vips
14
+ brew install tesseract tesseract-lang
15
+
16
+ # Ubuntu/Debian
17
+ sudo apt-get install libvips-dev tesseract-ocr tesseract-ocr-kor
18
+ ```
19
+
20
+ ### Gem 설치
21
+
22
+ ```bash
23
+ gem install naiso
24
+ ```
25
+
26
+ 또는 Gemfile에 추가:
27
+
28
+ ```ruby
29
+ gem 'naiso'
30
+ ```
31
+
32
+ ### 버전 정보
33
+ - Ruby 2.7+
34
+ - libvips 8.10+
35
+ - Tesseract 4.x / 5.x
36
+
37
+ ## 기능
38
+
39
+ ### 1. 이미지 분할
40
+
41
+ 긴 상세 이미지를 다음 기준으로 자동 분할합니다:
42
+
43
+ | 감지 유형 | 설명 |
44
+ |----------|------|
45
+ | 단색 영역 | 연속된 solid color 배경 (variance < threshold) |
46
+ | 구분선 | 가로 방향 구분선 (위아래 여백이 단색) |
47
+ | 배경색 전환 | 흰색→회색 등 배경색이 바뀌는 지점 |
48
+ | 복잡도 기반 | 최대 높이 초과 시 엣지 밀도가 낮은 지점 |
49
+
50
+ ### 2. 텍스트 분석 (OCR)
51
+
52
+ 분할된 섹션에서 텍스트 유무와 크기 정보를 분석합니다.
53
+
54
+ **분석 정보:**
55
+ - 텍스트 유무 (has_text)
56
+ - 글자 수 (text_length)
57
+ - 단어별 위치/크기 (x, y, width, height)
58
+ - 통계 (min/max/avg 높이, 단어 수)
59
+
60
+ ### 3. 이미지 병합
61
+
62
+ 분할된 섹션들을 다시 하나로 합칩니다.
63
+
64
+ ## CLI 사용법
65
+
66
+ ```bash
67
+ # 기본 분할
68
+ naiso detail.jpg
69
+
70
+ # 옵션 지정
71
+ naiso detail.jpg -t 5 -g 100 -m 400
72
+
73
+ # 텍스트 분석 포함
74
+ naiso detail.jpg -c
75
+
76
+ # JSON 결과 저장
77
+ naiso detail.jpg -c -j result.json
78
+
79
+ # 분할 후 병합
80
+ naiso detail.jpg --merge
81
+
82
+ # 기존 섹션만 병합
83
+ naiso --merge-only sections/
84
+ ```
85
+
86
+ ### CLI 옵션
87
+
88
+ | 옵션 | 설명 | 기본값 |
89
+ |------|------|--------|
90
+ | `-t, --threshold FLOAT` | 단색 판정 임계값 | 10.0 |
91
+ | `-g, --gap INT` | 최소 단색 영역 높이 | 50px |
92
+ | `-m, --min-height INT` | 최소 섹션 높이 | 너비 × 2/3 |
93
+ | `-M, --max-height INT` | 최대 섹션 높이 | 너비 × 1.5 |
94
+ | `-o, --output DIR` | 출력 디렉토리 | sections/ |
95
+ | `-c, --check-text` | 텍스트 분석 수행 | - |
96
+ | `-j, --json FILE` | JSON 결과 저장 경로 | 자동 생성 |
97
+ | `--merge` | 분할 후 병합 | - |
98
+ | `--merge-only DIR` | 섹션 병합만 수행 | - |
99
+ | `-v, --version` | 버전 표시 | - |
100
+ | `-h, --help` | 도움말 표시 | - |
101
+
102
+ ## Ruby API
103
+
104
+ ```ruby
105
+ require 'naiso'
106
+
107
+ # 이미지 분할
108
+ config = Naiso::SplitConfig.new(
109
+ variance_threshold: 5.0,
110
+ min_gap_height: 100,
111
+ min_section_height: 400
112
+ )
113
+ splitter = Naiso::ImageSplitter.new(config)
114
+ result = splitter.split('detail.jpg')
115
+
116
+ puts result.output_files # 생성된 파일 목록
117
+ puts result.split_points # 분할 위치
118
+ puts result.uniform_regions # 감지된 단색 영역
119
+
120
+ # 텍스트 분석
121
+ detector = Naiso::TextDetector.new
122
+ analysis = detector.detect_with_size('section_01.jpg')
123
+
124
+ puts analysis[:has_text] # true/false
125
+ puts analysis[:text] # 검출된 텍스트
126
+ puts analysis[:stats] # 통계 정보
127
+
128
+ # 여러 이미지 분석
129
+ detector.analyze_images(result.output_files, json_path: 'result.json')
130
+
131
+ # 이미지 병합
132
+ Naiso::ImageMerger.merge_sections('sections/')
133
+
134
+ # 개별 이미지 병합
135
+ Naiso::ImageMerger.merge(['img1.jpg', 'img2.jpg'], 'output.jpg')
136
+ ```
137
+
138
+ ## 출력 파일
139
+
140
+ ```
141
+ sections/
142
+ ├── detail_section_01.jpg
143
+ ├── detail_section_02.jpg
144
+ ├── ...
145
+ ├── detail_text_analysis.json # -c 옵션 시
146
+ └── detail_merged.jpg # --merge 옵션 시
147
+ ```
148
+
149
+ ## JSON 출력 형식
150
+
151
+ ```json
152
+ {
153
+ "generated_at": "2025-12-10T18:00:00+09:00",
154
+ "total_images": 11,
155
+ "images_with_text": 10,
156
+ "images_without_text": 1,
157
+ "sections": [
158
+ {
159
+ "filename": "detail_section_01.jpg",
160
+ "has_text": true,
161
+ "text_length": 22,
162
+ "text": "검출된 텍스트...",
163
+ "stats": {
164
+ "min_height": 15,
165
+ "max_height": 48,
166
+ "avg_height": 30.6,
167
+ "word_count": 18,
168
+ "filtered_count": 5
169
+ },
170
+ "words": [
171
+ {
172
+ "text": "단어",
173
+ "x": 100,
174
+ "y": 50,
175
+ "width": 40,
176
+ "height": 30,
177
+ "conf": 92.5
178
+ }
179
+ ]
180
+ }
181
+ ]
182
+ }
183
+ ```
184
+
185
+ ## 의존성
186
+
187
+ - [ruby-vips](https://github.com/libvips/ruby-vips) - 이미지 처리
188
+ - [numo-narray](https://github.com/ruby-numo/numo-narray) - 수치 배열 연산
189
+ - [rtesseract](https://github.com/dannnylo/rtesseract) - OCR (Tesseract 래퍼)
190
+
191
+ ## 라이선스
192
+
193
+ MIT License
data/exe/naiso ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'naiso'
5
+
6
+ Naiso::CLI.new.run
data/lib/naiso/cli.rb ADDED
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Naiso
6
+ # CLI 인터페이스
7
+ class CLI
8
+ def initialize
9
+ @options = {
10
+ threshold: 10.0,
11
+ gap: 50,
12
+ min_height: nil,
13
+ max_height: nil,
14
+ output: nil,
15
+ check_text: false,
16
+ json_output: nil,
17
+ merge: false,
18
+ merge_only: false
19
+ }
20
+ end
21
+
22
+ def run(args = ARGV)
23
+ parse_args(args)
24
+
25
+ # 병합만 수행하는 경우
26
+ if @options[:merge_only]
27
+ ImageMerger.merge_sections(@options[:merge_only])
28
+ return
29
+ end
30
+
31
+ config = SplitConfig.new(
32
+ variance_threshold: @options[:threshold],
33
+ min_gap_height: @options[:gap],
34
+ min_section_height: @options[:min_height],
35
+ max_section_height: @options[:max_height]
36
+ )
37
+
38
+ splitter = ImageSplitter.new(config)
39
+ result = splitter.split(@options[:image], output_dir: @options[:output])
40
+
41
+ # 텍스트 검출 옵션이 활성화된 경우
42
+ if @options[:check_text] && result.output_files.any?
43
+ detector = TextDetector.new
44
+
45
+ # JSON 경로 결정 (지정하지 않으면 출력 디렉토리에 자동 생성)
46
+ json_path = @options[:json_output]
47
+ if json_path.nil? && @options[:check_text]
48
+ output_dir = @options[:output] || File.join(File.dirname(@options[:image]), 'sections')
49
+ base_name = File.basename(@options[:image], '.*')
50
+ json_path = File.join(output_dir, "#{base_name}_text_analysis.json")
51
+ end
52
+
53
+ detector.analyze_images(result.output_files, json_path: json_path)
54
+ end
55
+
56
+ # 병합 옵션이 활성화된 경우
57
+ if @options[:merge] && result.output_files.any?
58
+ output_dir = @options[:output] || File.join(File.dirname(@options[:image]), 'sections')
59
+ ImageMerger.merge_sections(output_dir)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def parse_args(args)
66
+ parser = OptionParser.new do |opts|
67
+ opts.banner = "사용법: naiso [옵션] <이미지>"
68
+ opts.separator ''
69
+ opts.separator '상품 상세 이미지를 섹션별로 분할합니다.'
70
+ opts.separator ''
71
+ opts.separator '옵션:'
72
+
73
+ opts.on('-t', '--threshold FLOAT', Float, '단색 판정 임계값 (기본: 10.0)') do |v|
74
+ @options[:threshold] = v
75
+ end
76
+
77
+ opts.on('-g', '--gap INT', Integer, '최소 단색 영역 높이 (기본: 50px)') do |v|
78
+ @options[:gap] = v
79
+ end
80
+
81
+ opts.on('-m', '--min-height INT', Integer, '최소 섹션 높이 (기본: 이미지 너비의 2/3)') do |v|
82
+ @options[:min_height] = v
83
+ end
84
+
85
+ opts.on('-M', '--max-height INT', Integer, '최대 섹션 높이 (기본: 이미지 너비의 1.5배)') do |v|
86
+ @options[:max_height] = v
87
+ end
88
+
89
+ opts.on('-o', '--output DIR', '출력 디렉토리') do |v|
90
+ @options[:output] = v
91
+ end
92
+
93
+ opts.on('-c', '--check-text', '분할 후 텍스트 분석 (크기 정보 포함)') do
94
+ @options[:check_text] = true
95
+ end
96
+
97
+ opts.on('-j', '--json FILE', 'JSON 결과 저장 경로 (-c 옵션 필요)') do |v|
98
+ @options[:json_output] = v
99
+ end
100
+
101
+ opts.on('--merge', '분할된 이미지를 다시 하나로 병합') do
102
+ @options[:merge] = true
103
+ end
104
+
105
+ opts.on('--merge-only DIR', '기존 섹션 이미지들을 병합만 수행') do |v|
106
+ @options[:merge_only] = v
107
+ end
108
+
109
+ opts.on('-v', '--version', '버전 표시') do
110
+ puts "naiso #{Naiso::VERSION}"
111
+ exit
112
+ end
113
+
114
+ opts.on('-h', '--help', '도움말 표시') do
115
+ puts opts
116
+ exit
117
+ end
118
+
119
+ opts.separator ''
120
+ opts.separator '예시:'
121
+ opts.separator ' naiso detail.jpg'
122
+ opts.separator ' naiso detail.jpg -t 5 -g 100 -m 400'
123
+ opts.separator ' naiso detail.jpg -M 1200'
124
+ opts.separator ' naiso detail.jpg -c # 텍스트 분석 포함'
125
+ opts.separator ' naiso detail.jpg -c -j result.json # JSON 저장'
126
+ opts.separator ' naiso detail.jpg --merge # 분할 후 병합'
127
+ opts.separator ' naiso --merge-only sections/ # 기존 섹션 병합'
128
+ end
129
+
130
+ parser.parse!(args)
131
+
132
+ @options[:image] = args[0] || 'detail.jpg'
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'vips'
4
+
5
+ module Naiso
6
+ # 이미지 병합기
7
+ class ImageMerger
8
+ # 여러 이미지를 세로로 합치기
9
+ # @param image_paths [Array<String>] 이미지 파일 경로 배열 (순서대로 합쳐짐)
10
+ # @param output_path [String] 출력 파일 경로
11
+ # @param verbose [Boolean] 상세 출력 여부
12
+ # @return [String] 출력 파일 경로
13
+ def self.merge(image_paths, output_path, verbose: true)
14
+ raise ArgumentError, '이미지가 없습니다' if image_paths.empty?
15
+
16
+ puts "이미지 병합 중... (#{image_paths.size}개)" if verbose
17
+
18
+ # 첫 번째 이미지 로드
19
+ images = image_paths.map { |path| Vips::Image.new_from_file(path) }
20
+
21
+ # 너비 확인 (모두 같아야 함)
22
+ widths = images.map(&:width).uniq
23
+ if widths.size > 1
24
+ puts "경고: 이미지 너비가 다릅니다 (#{widths.join(', ')}px). 첫 번째 이미지 너비로 맞춥니다." if verbose
25
+ target_width = images.first.width
26
+ images = images.map do |img|
27
+ img.width == target_width ? img : img.resize(target_width.to_f / img.width)
28
+ end
29
+ end
30
+
31
+ # 세로로 합치기
32
+ merged = images.first
33
+ images[1..].each do |img|
34
+ merged = merged.join(img, :vertical)
35
+ end
36
+
37
+ # 저장
38
+ merged.write_to_file(output_path, Q: 95)
39
+
40
+ if verbose
41
+ total_height = images.sum(&:height)
42
+ puts " 입력: #{image_paths.size}개 이미지"
43
+ puts " 출력: #{output_path}"
44
+ puts " 크기: #{merged.width} x #{merged.height}px"
45
+ end
46
+
47
+ output_path
48
+ end
49
+
50
+ # 디렉토리 내 섹션 이미지들을 합치기
51
+ # @param input_dir [String] 섹션 이미지가 있는 디렉토리
52
+ # @param output_path [String] 출력 파일 경로 (nil이면 자동 생성)
53
+ # @param pattern [String] 파일 패턴 (glob)
54
+ # @param verbose [Boolean] 상세 출력 여부
55
+ # @return [String] 출력 파일 경로
56
+ def self.merge_sections(input_dir, output_path: nil, pattern: '*_section_*.jpg', verbose: true)
57
+ # 섹션 파일 찾기 (정렬)
58
+ section_files = Dir.glob(File.join(input_dir, pattern)).sort
59
+
60
+ raise ArgumentError, "섹션 파일을 찾을 수 없습니다: #{input_dir}/#{pattern}" if section_files.empty?
61
+
62
+ # 출력 경로 자동 생성
63
+ if output_path.nil?
64
+ # 첫 번째 파일에서 기본 이름 추출: "vitac_section_01.jpg" -> "vitac"
65
+ base_name = File.basename(section_files.first).sub(/_section_\d+\.jpg$/, '')
66
+ output_path = File.join(input_dir, "#{base_name}_merged.jpg")
67
+ end
68
+
69
+ merge(section_files, output_path, verbose: verbose)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'vips'
4
+ require 'fileutils'
5
+
6
+ module Naiso
7
+ # 이미지 분할기
8
+ class ImageSplitter
9
+ def initialize(config = nil)
10
+ @config = config || SplitConfig.new
11
+ end
12
+
13
+ def split(image_path, output_dir: nil, verbose: true)
14
+ result = SplitResult.new
15
+
16
+ # 이미지 로드
17
+ image = Vips::Image.new_from_file(image_path)
18
+
19
+ # 설정값 계산
20
+ min_height = @config.min_section_height || (image.width * 2 / 3)
21
+ max_height = @config.max_section_height || (image.width * 1.5).to_i
22
+
23
+ if verbose
24
+ puts "이미지 크기: #{image.width} x #{image.height}"
25
+ puts "최소 섹션 높이: #{min_height}px"
26
+ puts "최대 섹션 높이: #{max_height}px"
27
+ end
28
+
29
+ # 분석기 및 감지기 초기화
30
+ analyzer = RowAnalyzer.new(image)
31
+ detector = SplitPointDetector.new(analyzer, @config)
32
+
33
+ # 분할점 수집
34
+ result.uniform_regions = detector.find_uniform_regions
35
+ result.divider_lines = detector.find_divider_lines
36
+ result.background_transitions = detector.find_background_transitions
37
+
38
+ print_detection_results(result) if verbose
39
+
40
+ # 분할점 병합
41
+ split_points = merge_split_points(result, image.height, min_height)
42
+
43
+ # 최대 높이 초과 섹션 분할
44
+ if max_height > 0
45
+ split_points, complexity_splits = apply_max_height_splits(
46
+ split_points, max_height, min_height, detector, verbose
47
+ )
48
+ result.complexity_splits = complexity_splits
49
+ end
50
+
51
+ result.split_points = split_points
52
+
53
+ if verbose
54
+ puts "\n분할 위치: #{split_points}"
55
+ puts "생성될 섹션 수: #{split_points.size - 1}개"
56
+ end
57
+
58
+ # 이미지 분할 및 저장
59
+ if split_points.nil? || split_points.size < 2
60
+ puts '분할할 영역을 찾지 못했습니다.' if verbose
61
+ return result
62
+ end
63
+
64
+ output_dir = prepare_output_dir(image_path, output_dir)
65
+ result.output_files = save_sections(image, split_points, output_dir, image_path, verbose)
66
+
67
+ result
68
+ end
69
+
70
+ private
71
+
72
+ def merge_split_points(result, image_height, min_height)
73
+ split_y = [0]
74
+
75
+ # 단색 영역 중앙점 추가
76
+ result.uniform_regions.each do |start_pos, end_pos|
77
+ split_y << (start_pos + end_pos) / 2
78
+ end
79
+
80
+ # 구분선 추가
81
+ split_y.concat(result.divider_lines)
82
+
83
+ # 배경색 전환점 추가
84
+ split_y.concat(result.background_transitions)
85
+
86
+ split_y << image_height
87
+
88
+ # 정렬 및 중복 제거
89
+ split_y = split_y.uniq.sort
90
+
91
+ # 너무 작은 섹션 병합 (단, 시작점 0은 항상 유지)
92
+ filtered = [0]
93
+ split_y[1..].each do |y|
94
+ gap = y - filtered.last
95
+ if gap >= min_height
96
+ filtered << y
97
+ elsif filtered.size >= 2
98
+ new_prev_gap = y - filtered[-2]
99
+ filtered[-1] = y if new_prev_gap >= min_height
100
+ elsif filtered.last != 0
101
+ # 시작점이 0이면 유지, 아니면 대체
102
+ filtered[-1] = y
103
+ end
104
+ # filtered.last가 0이고 gap < min_height면, 다음 분할점을 기다림
105
+ end
106
+
107
+ filtered << image_height if filtered.last != image_height
108
+
109
+ filtered
110
+ end
111
+
112
+ def apply_max_height_splits(split_points, max_height, min_height, detector, verbose)
113
+ needs_split = (0...(split_points.size - 1)).any? do |i|
114
+ split_points[i + 1] - split_points[i] > max_height
115
+ end
116
+
117
+ return [split_points, []] unless needs_split
118
+
119
+ puts "\n최대 높이 초과 섹션 감지, 복잡도 기반 분할 수행..." if verbose
120
+
121
+ complexity_splits = []
122
+ final_splits = [split_points.first]
123
+
124
+ (0...(split_points.size - 1)).each do |i|
125
+ section_start = split_points[i]
126
+ section_end = split_points[i + 1]
127
+ section_height = section_end - section_start
128
+
129
+ if section_height > max_height
130
+ current_start = section_start
131
+
132
+ while current_start < section_end
133
+ remaining = section_end - current_start
134
+ break if remaining <= max_height
135
+
136
+ search_start = current_start + min_height
137
+ search_end = [current_start + max_height, section_end - min_height].min
138
+
139
+ best_split = if search_start >= search_end
140
+ (current_start + [current_start + max_height, section_end].min) / 2
141
+ else
142
+ margin = [50, (search_end - search_start) / 4].min
143
+ detector.find_best_split_in_range(search_start, search_end, margin: margin)
144
+ end
145
+
146
+ final_splits << best_split
147
+ complexity_splits << best_split
148
+
149
+ puts " 복잡도 기반 분할: 행 #{best_split}" if verbose
150
+
151
+ current_start = best_split
152
+ end
153
+ end
154
+
155
+ final_splits << section_end
156
+ end
157
+
158
+ [final_splits.uniq.sort, complexity_splits]
159
+ end
160
+
161
+ def prepare_output_dir(image_path, output_dir)
162
+ output_dir ||= File.join(File.dirname(image_path), 'sections')
163
+ FileUtils.mkdir_p(output_dir)
164
+ output_dir
165
+ end
166
+
167
+ def save_sections(image, split_points, output_dir, image_path, verbose)
168
+ output_files = []
169
+ base_name = File.basename(image_path, '.*')
170
+
171
+ (0...(split_points.size - 1)).each do |i|
172
+ y_start = split_points[i]
173
+ y_end = split_points[i + 1]
174
+ height = y_end - y_start
175
+
176
+ # 섹션 추출
177
+ section = image.crop(0, y_start, image.width, height)
178
+
179
+ # 저장
180
+ output_path = File.join(output_dir, "#{base_name}_section_#{format('%02d', i + 1)}.jpg")
181
+ section.write_to_file(output_path, Q: 95)
182
+ output_files << output_path
183
+
184
+ puts " 저장: #{File.basename(output_path)} (높이: #{height}px)" if verbose
185
+ end
186
+
187
+ output_files
188
+ end
189
+
190
+ def print_detection_results(result)
191
+ puts "\n발견된 단색 영역: #{result.uniform_regions.size}개"
192
+ result.uniform_regions.each_with_index do |(start_pos, end_pos), i|
193
+ puts " #{i + 1}. 행 #{start_pos} ~ #{end_pos} (높이: #{end_pos - start_pos}px)"
194
+ end
195
+
196
+ puts "\n발견된 구분선: #{result.divider_lines.size}개"
197
+ result.divider_lines.each_with_index do |y, i|
198
+ puts " #{i + 1}. 행 #{y}"
199
+ end
200
+
201
+ puts "\n발견된 배경색 전환: #{result.background_transitions.size}개"
202
+ result.background_transitions.each_with_index do |y, i|
203
+ puts " #{i + 1}. 행 #{y}"
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'vips'
4
+ require 'numo/narray'
5
+
6
+ module Naiso
7
+ # 이미지 행 분석기
8
+ class RowAnalyzer
9
+ attr_reader :height, :width
10
+
11
+ def initialize(image)
12
+ @image = image
13
+ @width = image.width
14
+ @height = image.height
15
+ @variance = nil
16
+ @complexity = nil
17
+ @img_array = nil
18
+ end
19
+
20
+ # 이미지를 Numo::NArray로 변환 (지연 로딩)
21
+ def img_array
22
+ @img_array ||= begin
23
+ # Vips 이미지를 메모리 배열로 변환
24
+ bands = @image.bands
25
+ data = @image.write_to_memory
26
+
27
+ # 바이트 배열을 NArray로 변환
28
+ arr = Numo::UInt8.from_binary(data)
29
+ arr.reshape(@height, @width, bands)
30
+ end
31
+ end
32
+
33
+ # 각 행의 색상 분산 (지연 계산)
34
+ def variance
35
+ @variance ||= calculate_variance
36
+ end
37
+
38
+ # 각 행의 콘텐츠 복잡도 (지연 계산)
39
+ def complexity
40
+ @complexity ||= calculate_complexity
41
+ end
42
+
43
+ private
44
+
45
+ def calculate_variance
46
+ arr = img_array
47
+ result = Numo::DFloat.zeros(@height)
48
+
49
+ @height.times do |y|
50
+ row = arr[y, true, true].cast_to(Numo::DFloat)
51
+ # 각 채널별 표준편차 계산 후 평균
52
+ channel_stds = (0...arr.shape[2]).map do |c|
53
+ channel_data = row[true, c]
54
+ std_dev(channel_data)
55
+ end
56
+ result[y] = channel_stds.sum / channel_stds.size
57
+ end
58
+
59
+ result
60
+ end
61
+
62
+ def calculate_complexity
63
+ # Sobel 엣지 감지
64
+ gray = @image.colourspace(:b_w)
65
+ edges = gray.sobel
66
+
67
+ # 엣지 이미지를 배열로 변환
68
+ edge_data = edges.write_to_memory
69
+ edge_arr = Numo::UInt8.from_binary(edge_data).reshape(@height, @width)
70
+
71
+ # 각 행의 엣지 밀도
72
+ edge_density = Numo::DFloat.zeros(@height)
73
+ @height.times do |y|
74
+ edge_density[y] = edge_arr[y, true].cast_to(Numo::DFloat).mean
75
+ end
76
+
77
+ # 색상 분산
78
+ color_variance = variance
79
+
80
+ # 정규화
81
+ edge_max = edge_density.max
82
+ color_max = color_variance.max
83
+
84
+ edge_norm = edge_max > 0 ? edge_density / edge_max : edge_density
85
+ color_norm = color_max > 0 ? color_variance / color_max : color_variance
86
+
87
+ # 가중 합산
88
+ edge_norm * 0.7 + color_norm * 0.3
89
+ end
90
+
91
+ def std_dev(arr)
92
+ mean = arr.mean
93
+ variance = ((arr - mean) ** 2).mean
94
+ Math.sqrt(variance)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Naiso
4
+ # 분할 설정
5
+ class SplitConfig
6
+ attr_accessor :variance_threshold, :min_gap_height, :min_section_height, :max_section_height
7
+
8
+ def initialize(
9
+ variance_threshold: 10.0,
10
+ min_gap_height: 50,
11
+ min_section_height: nil,
12
+ max_section_height: nil
13
+ )
14
+ @variance_threshold = variance_threshold
15
+ @min_gap_height = min_gap_height
16
+ @min_section_height = min_section_height
17
+ @max_section_height = max_section_height
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'numo/narray'
4
+
5
+ module Naiso
6
+ # 분할점 감지기
7
+ class SplitPointDetector
8
+ def initialize(analyzer, config)
9
+ @analyzer = analyzer
10
+ @config = config
11
+ end
12
+
13
+ # 연속된 단색 영역 찾기
14
+ def find_uniform_regions
15
+ variance = @analyzer.variance
16
+ threshold = @config.variance_threshold
17
+
18
+ regions = []
19
+ in_region = false
20
+ region_start = 0
21
+
22
+ @analyzer.height.times do |i|
23
+ uniform = variance[i] < threshold
24
+
25
+ if uniform && !in_region
26
+ in_region = true
27
+ region_start = i
28
+ elsif !uniform && in_region
29
+ in_region = false
30
+ if i - region_start >= @config.min_gap_height
31
+ regions << [region_start, i]
32
+ end
33
+ end
34
+ end
35
+
36
+ # 마지막까지 단색이면
37
+ if in_region
38
+ region_end = @analyzer.height
39
+ if region_end - region_start >= @config.min_gap_height
40
+ regions << [region_start, region_end]
41
+ end
42
+ end
43
+
44
+ regions
45
+ end
46
+
47
+ # 가로 구분선 감지
48
+ def find_divider_lines(
49
+ line_variance_threshold: 3.0,
50
+ margin_check: 30,
51
+ margin_variance_threshold: 5.0
52
+ )
53
+ img_array = @analyzer.img_array
54
+ variance = @analyzer.variance
55
+ height = @analyzer.height
56
+
57
+ dividers = []
58
+
59
+ (margin_check...(height - margin_check)).each do |y|
60
+ next if variance[y] > line_variance_threshold
61
+
62
+ margin_above = img_array[(y - margin_check)...y, true, true]
63
+ margin_below = img_array[(y + 1)...(y + 1 + margin_check), true, true]
64
+
65
+ above_variance = calculate_region_variance(margin_above)
66
+ below_variance = calculate_region_variance(margin_below)
67
+
68
+ next if above_variance > margin_variance_threshold
69
+ next if below_variance > margin_variance_threshold
70
+
71
+ above_mean = margin_above.cast_to(Numo::DFloat).mean
72
+ below_mean = margin_below.cast_to(Numo::DFloat).mean
73
+ line_mean = img_array[y, true, true].cast_to(Numo::DFloat).mean
74
+
75
+ color_diff = (line_mean - (above_mean + below_mean) / 2.0).abs
76
+ dividers << y if color_diff > 10
77
+ end
78
+
79
+ merge_nearby_points(dividers)
80
+ end
81
+
82
+ # 배경색 전환 지점 감지
83
+ def find_background_transitions(
84
+ variance_threshold: 5.0,
85
+ min_uniform_height: 20,
86
+ color_diff_threshold: 15.0
87
+ )
88
+ img_array = @analyzer.img_array
89
+ variance = @analyzer.variance
90
+ height = @analyzer.height
91
+
92
+ transitions = []
93
+
94
+ (min_uniform_height...(height - min_uniform_height)).each do |y|
95
+ # 위아래가 모두 단색인지 확인
96
+ above_uniform = variance[(y - min_uniform_height)...y].to_a.all? { |v| v < variance_threshold }
97
+ below_uniform = variance[y...(y + min_uniform_height)].to_a.all? { |v| v < variance_threshold }
98
+
99
+ next unless above_uniform && below_uniform
100
+
101
+ above_region = img_array[(y - min_uniform_height)...y, true, true]
102
+ below_region = img_array[y...(y + min_uniform_height), true, true]
103
+
104
+ above_color = calculate_mean_color(above_region)
105
+ below_color = calculate_mean_color(below_region)
106
+
107
+ # RGB 유클리드 거리
108
+ color_diff = Math.sqrt(
109
+ above_color.zip(below_color).map { |a, b| (a - b) ** 2 }.sum
110
+ )
111
+
112
+ transitions << y if color_diff > color_diff_threshold
113
+ end
114
+
115
+ merge_nearby_points(transitions)
116
+ end
117
+
118
+ # 주어진 범위 내에서 복잡도가 가장 낮은 분할점 찾기
119
+ def find_best_split_in_range(start_pos, end_pos, margin: 50)
120
+ search_start = start_pos + margin
121
+ search_end = end_pos - margin
122
+
123
+ return (start_pos + end_pos) / 2 if search_start >= search_end
124
+
125
+ window_size = 20
126
+ complexity = @analyzer.complexity
127
+
128
+ region = complexity[search_start...search_end]
129
+ return search_start + region.min_index if region.size < window_size
130
+
131
+ # 이동 평균으로 smoothing
132
+ smoothed = []
133
+ (0...(region.size - window_size)).each do |i|
134
+ smoothed << region[i...(i + window_size)].mean
135
+ end
136
+
137
+ best_idx = smoothed.each_with_index.min_by { |v, _| v }[1] + window_size / 2
138
+ search_start + best_idx
139
+ end
140
+
141
+ private
142
+
143
+ def calculate_region_variance(region)
144
+ # 각 행의 표준편차 평균
145
+ variances = []
146
+ region.shape[0].times do |y|
147
+ row = region[y, true, true].cast_to(Numo::DFloat)
148
+ channel_stds = (0...region.shape[2]).map do |c|
149
+ channel_data = row[true, c]
150
+ mean = channel_data.mean
151
+ Math.sqrt(((channel_data - mean) ** 2).mean)
152
+ end
153
+ variances << channel_stds.sum / channel_stds.size
154
+ end
155
+ variances.sum / variances.size
156
+ end
157
+
158
+ def calculate_mean_color(region)
159
+ channels = region.shape[2]
160
+ (0...channels).map do |c|
161
+ region[true, true, c].cast_to(Numo::DFloat).mean
162
+ end
163
+ end
164
+
165
+ def merge_nearby_points(points, threshold: 5)
166
+ return [] if points.empty?
167
+
168
+ merged = []
169
+ group_start = points.first
170
+ group_end = points.first
171
+
172
+ points[1..].each do |y|
173
+ if y <= group_end + threshold
174
+ group_end = y
175
+ else
176
+ merged << (group_start + group_end) / 2
177
+ group_start = y
178
+ group_end = y
179
+ end
180
+ end
181
+
182
+ merged << (group_start + group_end) / 2
183
+ merged
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Naiso
4
+ # 분할 결과
5
+ class SplitResult
6
+ attr_accessor :output_files, :split_points, :uniform_regions,
7
+ :divider_lines, :background_transitions, :complexity_splits
8
+
9
+ def initialize
10
+ @output_files = []
11
+ @split_points = []
12
+ @uniform_regions = []
13
+ @divider_lines = []
14
+ @background_transitions = []
15
+ @complexity_splits = []
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'vips'
4
+ require 'rtesseract'
5
+ require 'json'
6
+
7
+ module Naiso
8
+ # 텍스트 검출기
9
+ class TextDetector
10
+ # 최소 텍스트 길이 (공백 제외)
11
+ MIN_TEXT_LENGTH = 3
12
+ # 최소 신뢰도 (0-100, 이 값 미만은 무시)
13
+ MIN_CONFIDENCE = 60.0
14
+ # 최소 단어 크기 (픽셀, 이 값 미만은 노이즈로 간주)
15
+ MIN_WORD_SIZE = 10
16
+
17
+ def initialize(languages: %w[kor eng], min_confidence: MIN_CONFIDENCE, min_word_size: MIN_WORD_SIZE)
18
+ @languages = languages.join('+')
19
+ @min_confidence = min_confidence
20
+ @min_word_size = min_word_size
21
+ end
22
+
23
+ # 이미지에 텍스트가 있는지 검사
24
+ # 원본과 반전 이미지 모두에서 OCR 시도 (흰색 텍스트 대응)
25
+ # @param image_path [String] 이미지 파일 경로
26
+ # @return [Hash] { has_text: Boolean, text: String, text_length: Integer }
27
+ def detect(image_path)
28
+ # 원본 이미지에서 OCR
29
+ original_result = ocr_image(image_path)
30
+
31
+ # 원본에서 텍스트를 찾았으면 반환
32
+ return original_result if original_result[:has_text]
33
+
34
+ # 반전 이미지에서 OCR 시도 (흰색 텍스트 + 어두운 배경 대응)
35
+ inverted_result = ocr_inverted_image(image_path)
36
+
37
+ # 더 많은 텍스트를 찾은 결과 반환
38
+ if inverted_result[:text_length] > original_result[:text_length]
39
+ inverted_result
40
+ else
41
+ original_result
42
+ end
43
+ rescue StandardError => e
44
+ {
45
+ has_text: false,
46
+ text: '',
47
+ text_length: 0,
48
+ error: e.message
49
+ }
50
+ end
51
+
52
+ # 텍스트 크기 정보를 포함한 상세 검출
53
+ # @param image_path [String] 이미지 파일 경로
54
+ # @return [Hash] { has_text:, text:, text_length:, words: [{text:, x:, y:, width:, height:, conf:}], stats: {min_height:, max_height:, avg_height:} }
55
+ def detect_with_size(image_path)
56
+ result = detect_tsv(image_path)
57
+
58
+ # 원본에서 못 찾으면 반전 이미지 시도
59
+ unless result[:has_text]
60
+ inverted_result = detect_tsv_inverted(image_path)
61
+ result = inverted_result if inverted_result[:text_length] > result[:text_length]
62
+ end
63
+
64
+ result
65
+ rescue StandardError => e
66
+ {
67
+ has_text: false,
68
+ text: '',
69
+ text_length: 0,
70
+ words: [],
71
+ stats: nil,
72
+ error: e.message
73
+ }
74
+ end
75
+
76
+ # 여러 이미지에서 텍스트 분석 (크기 정보 포함)
77
+ # @param image_paths [Array<String>] 이미지 파일 경로 배열
78
+ # @param verbose [Boolean] 상세 출력 여부
79
+ # @param json_path [String, nil] JSON 저장 경로 (nil이면 저장 안함)
80
+ # @return [Array<Hash>] 분석 결과 배열
81
+ def analyze_images(image_paths, verbose: true, json_path: nil)
82
+ puts "\n텍스트 검출 중..." if verbose
83
+
84
+ results = []
85
+
86
+ image_paths.each_with_index do |path, i|
87
+ result = detect_with_size(path)
88
+ filename = File.basename(path)
89
+
90
+ analysis = {
91
+ filename: filename,
92
+ path: path,
93
+ has_text: result[:has_text],
94
+ text_length: result[:text_length],
95
+ text: result[:text],
96
+ stats: result[:stats],
97
+ words: result[:words]
98
+ }
99
+ results << analysis
100
+
101
+ if verbose
102
+ if result[:has_text] && result[:stats]
103
+ stats = result[:stats]
104
+ puts format(' %2d. %-30s 텍스트 있음 (%d자, %d단어) | 높이: %d~%dpx (평균 %.1fpx)',
105
+ i + 1, filename, result[:text_length], stats[:word_count],
106
+ stats[:min_height], stats[:max_height], stats[:avg_height])
107
+ else
108
+ puts format(' %2d. %-30s 텍스트 없음', i + 1, filename)
109
+ end
110
+ end
111
+ end
112
+
113
+ # JSON 저장
114
+ if json_path
115
+ save_json(results, json_path)
116
+ puts "\nJSON 저장: #{json_path}" if verbose
117
+ end
118
+
119
+ # 텍스트 없는 이미지 요약
120
+ no_text_images = results.reject { |r| r[:has_text] }
121
+ if verbose
122
+ puts "\n텍스트 없는 이미지: #{no_text_images.size}개"
123
+ no_text_images.each do |r|
124
+ puts " - #{r[:filename]}"
125
+ end
126
+ end
127
+
128
+ results
129
+ end
130
+
131
+ # 여러 이미지에서 텍스트 없는 이미지 찾기 (하위 호환성)
132
+ # @param image_paths [Array<String>] 이미지 파일 경로 배열
133
+ # @param verbose [Boolean] 상세 출력 여부
134
+ # @return [Array<String>] 텍스트가 없는 이미지 경로 배열
135
+ def find_images_without_text(image_paths, verbose: true)
136
+ results = analyze_images(image_paths, verbose: verbose)
137
+ results.reject { |r| r[:has_text] }.map { |r| r[:path] }
138
+ end
139
+
140
+ private
141
+
142
+ def ocr_image(image_path)
143
+ # PSM 3 (기본값: 자동 페이지 세분화)로 시도
144
+ result = ocr_with_psm(image_path, 3)
145
+ return result if result[:has_text]
146
+
147
+ # PSM 6 (단일 텍스트 블록 가정)으로 재시도
148
+ ocr_with_psm(image_path, 6)
149
+ end
150
+
151
+ def ocr_with_psm(image_path, psm)
152
+ ocr = RTesseract.new(image_path, lang: @languages, psm: psm)
153
+ text = ocr.to_s.strip
154
+ clean_text = text.gsub(/[\s\p{P}\p{S}]/, '')
155
+
156
+ {
157
+ has_text: clean_text.length >= MIN_TEXT_LENGTH,
158
+ text: text,
159
+ text_length: clean_text.length
160
+ }
161
+ end
162
+
163
+ def ocr_inverted_image(image_path)
164
+ # libvips로 이미지 반전
165
+ image = Vips::Image.new_from_file(image_path)
166
+ inverted = image.invert
167
+
168
+ # 임시 파일로 저장
169
+ temp_path = "/tmp/inverted_#{File.basename(image_path)}"
170
+ inverted.write_to_file(temp_path)
171
+
172
+ result = ocr_image(temp_path)
173
+
174
+ # 임시 파일 삭제
175
+ File.delete(temp_path) if File.exist?(temp_path)
176
+
177
+ result
178
+ end
179
+
180
+ # TSV 출력으로 텍스트 크기 정보 추출
181
+ def detect_tsv(image_path)
182
+ parse_tsv_output(image_path, image_path)
183
+ end
184
+
185
+ def detect_tsv_inverted(image_path)
186
+ image = Vips::Image.new_from_file(image_path)
187
+ inverted = image.invert
188
+
189
+ temp_path = "/tmp/inverted_#{File.basename(image_path)}"
190
+ inverted.write_to_file(temp_path)
191
+
192
+ result = parse_tsv_output(temp_path, image_path)
193
+
194
+ File.delete(temp_path) if File.exist?(temp_path)
195
+
196
+ result
197
+ end
198
+
199
+ def parse_tsv_output(ocr_path, original_path)
200
+ # PSM 6으로 TSV 출력
201
+ tsv_output = `tesseract "#{ocr_path}" stdout -l #{@languages} --psm 6 tsv 2>/dev/null`
202
+
203
+ all_words = []
204
+ lines = tsv_output.split("\n")
205
+
206
+ # 헤더 스킵
207
+ lines[1..].each do |line|
208
+ cols = line.split("\t")
209
+ next if cols.size < 12
210
+
211
+ level = cols[0].to_i
212
+ next unless level == 5 # word level
213
+
214
+ text = cols[11].to_s.strip
215
+ next if text.empty?
216
+
217
+ conf = cols[10].to_f
218
+ next if conf < 0 # 빈 결과 제외
219
+
220
+ all_words << {
221
+ text: text,
222
+ x: cols[6].to_i,
223
+ y: cols[7].to_i,
224
+ width: cols[8].to_i,
225
+ height: cols[9].to_i,
226
+ conf: conf.round(1)
227
+ }
228
+ end
229
+
230
+ # 신뢰도 및 크기 필터링
231
+ confident_words = all_words.select do |w|
232
+ w[:conf] >= @min_confidence &&
233
+ w[:width] >= @min_word_size &&
234
+ w[:height] >= @min_word_size
235
+ end
236
+
237
+ # 신뢰도 높은 단어들로 텍스트 합치기
238
+ full_text = confident_words.map { |w| w[:text] }.join(' ')
239
+ clean_text = full_text.gsub(/[\s\p{P}\p{S}]/, '')
240
+
241
+ # 통계 계산 (신뢰도 높은 단어 기준)
242
+ stats = nil
243
+ if confident_words.any?
244
+ heights = confident_words.map { |w| w[:height] }
245
+ stats = {
246
+ min_height: heights.min,
247
+ max_height: heights.max,
248
+ avg_height: (heights.sum.to_f / heights.size).round(1),
249
+ word_count: confident_words.size,
250
+ filtered_count: all_words.size - confident_words.size
251
+ }
252
+ end
253
+
254
+ {
255
+ has_text: clean_text.length >= MIN_TEXT_LENGTH,
256
+ text: full_text,
257
+ text_length: clean_text.length,
258
+ words: confident_words,
259
+ stats: stats
260
+ }
261
+ end
262
+
263
+ def save_json(results, json_path)
264
+ # words 배열은 너무 길 수 있으므로 요약 버전도 생성
265
+ output = {
266
+ generated_at: Time.now.iso8601,
267
+ total_images: results.size,
268
+ images_with_text: results.count { |r| r[:has_text] },
269
+ images_without_text: results.count { |r| !r[:has_text] },
270
+ sections: results.map do |r|
271
+ {
272
+ filename: r[:filename],
273
+ has_text: r[:has_text],
274
+ text_length: r[:text_length],
275
+ text: r[:text],
276
+ stats: r[:stats],
277
+ words: r[:words]
278
+ }
279
+ end
280
+ }
281
+
282
+ File.write(json_path, JSON.pretty_generate(output))
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Naiso
4
+ VERSION = '0.1.0'
5
+ end
data/lib/naiso.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'naiso/version'
4
+ require_relative 'naiso/split_config'
5
+ require_relative 'naiso/split_result'
6
+ require_relative 'naiso/row_analyzer'
7
+ require_relative 'naiso/split_point_detector'
8
+ require_relative 'naiso/image_splitter'
9
+ require_relative 'naiso/image_merger'
10
+ require_relative 'naiso/text_detector'
11
+ require_relative 'naiso/cli'
12
+
13
+ module Naiso
14
+ class Error < StandardError; end
15
+ end
data/naiso.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/naiso/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'naiso'
7
+ spec.version = Naiso::VERSION
8
+ spec.authors = ['Wonsup Yoon']
9
+ spec.email = ['wonsup@example.com']
10
+
11
+ spec.summary = '상품 상세 이미지 섹션 분할 도구'
12
+ spec.description = '긴 상세 이미지를 단색/그라데이션 배경 영역을 기준으로 자동 분할합니다.'
13
+ spec.homepage = 'https://github.com/TeamMilestone/naiso'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 2.7.0'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = spec.homepage
19
+
20
+ spec.files = Dir.chdir(__dir__) do
21
+ `git ls-files -z`.split("\x0").reject do |f|
22
+ (File.expand_path(f) == __FILE__) ||
23
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
24
+ end
25
+ end
26
+ spec.bindir = 'exe'
27
+ spec.executables = ['naiso']
28
+ spec.require_paths = ['lib']
29
+
30
+ spec.add_dependency 'numo-narray', '~> 0.9'
31
+ spec.add_dependency 'rtesseract', '~> 3.1'
32
+ spec.add_dependency 'ruby-vips', '~> 2.1'
33
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: naiso
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Wonsup Yoon
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: numo-narray
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.9'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.9'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rtesseract
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: ruby-vips
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.1'
54
+ description: 긴 상세 이미지를 단색/그라데이션 배경 영역을 기준으로 자동 분할합니다.
55
+ email:
56
+ - wonsup@example.com
57
+ executables:
58
+ - naiso
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - README.md
63
+ - exe/naiso
64
+ - lib/naiso.rb
65
+ - lib/naiso/cli.rb
66
+ - lib/naiso/image_merger.rb
67
+ - lib/naiso/image_splitter.rb
68
+ - lib/naiso/row_analyzer.rb
69
+ - lib/naiso/split_config.rb
70
+ - lib/naiso/split_point_detector.rb
71
+ - lib/naiso/split_result.rb
72
+ - lib/naiso/text_detector.rb
73
+ - lib/naiso/version.rb
74
+ - naiso.gemspec
75
+ homepage: https://github.com/TeamMilestone/naiso
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ homepage_uri: https://github.com/TeamMilestone/naiso
80
+ source_code_uri: https://github.com/TeamMilestone/naiso
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 2.7.0
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubygems_version: 3.6.9
96
+ specification_version: 4
97
+ summary: 상품 상세 이미지 섹션 분할 도구
98
+ test_files: []