lare 0.1.0__py3-none-any.whl

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.
lare/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = '0.1.0'
lare/cli.py ADDED
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import webbrowser
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from lare.console import console, log_error, log_step, log_success
9
+
10
+
11
+ @click.group()
12
+ @click.version_option(package_name='lare')
13
+ def main():
14
+ '''LARE — Label And Review Engine'''
15
+ pass
16
+
17
+
18
+ @main.command()
19
+ @click.argument('csv_file', required=False)
20
+ @click.option('--config', 'config_path', type=click.Path(exists=True), help='Path to lare_config.toml')
21
+ @click.option('--no-project', is_flag=True, help='Author config only, skip database creation')
22
+ def create(csv_file: str | None, config_path: str | None, no_project: bool):
23
+ '''Initialize a new LARE project from a CSV dataset.'''
24
+ import questionary
25
+ from rich.progress import Progress
26
+
27
+ from lare.config import LareConfig, load_config, config_to_dict
28
+ from lare.console import print_config_table, print_creation_summary
29
+ from lare.database import create_database
30
+ from lare.interactive import run_wizard
31
+ from lare.pipeline import load_and_process
32
+
33
+ config: LareConfig | None = None
34
+
35
+ try:
36
+ if config_path:
37
+ log_step(f'Loading config from {config_path}')
38
+ config = load_config(config_path)
39
+ elif Path('lare_config.toml').exists():
40
+ use_existing = questionary.confirm(
41
+ 'Found existing lare_config.toml. Use this to initialize the project?',
42
+ default=True,
43
+ ).ask()
44
+ if use_existing is None:
45
+ console.print('\n[yellow]Wizard cancelled.[/yellow]')
46
+ raise SystemExit(1)
47
+ if use_existing:
48
+ config = load_config('lare_config.toml')
49
+ else:
50
+ config = run_wizard()
51
+ else:
52
+ config = run_wizard()
53
+ except KeyboardInterrupt:
54
+ console.print('\n[yellow]Wizard cancelled.[/yellow]')
55
+ raise SystemExit(1)
56
+
57
+ if config is None:
58
+ log_error('No configuration provided')
59
+
60
+ print_config_table(config_to_dict(config))
61
+
62
+ if no_project:
63
+ log_success('Configuration authored — skipping database creation (--no-project)')
64
+ return
65
+
66
+ project_path = config.paths.project
67
+ if Path(project_path).exists():
68
+ log_error(
69
+ f'File risk: {project_path} already exists. '
70
+ 'Overwriting is blocked.'
71
+ )
72
+
73
+ source_csv = csv_file or config.paths.source_csv
74
+
75
+ if not source_csv or not Path(source_csv).exists():
76
+ log_error(f'Source CSV not found: {source_csv}')
77
+
78
+ with Progress(console=console) as progress:
79
+ task = progress.add_task('Processing pipeline...', total=None)
80
+ df, rows_before = load_and_process(config, source_csv)
81
+ progress.update(task, completed=True)
82
+
83
+ log_step(f'Creating database: {project_path}')
84
+
85
+ with Progress(console=console) as progress:
86
+ task = progress.add_task('Writing database...', total=None)
87
+ create_database(project_path, config, df)
88
+ progress.update(task, completed=True)
89
+
90
+ print_creation_summary(
91
+ rows_before=rows_before,
92
+ rows_after=df.height,
93
+ label_count=len(config.labels),
94
+ project_path=project_path,
95
+ )
96
+
97
+
98
+ @main.command()
99
+ @click.argument('project', type=click.Path(exists=True))
100
+ @click.option('--port', default=8741, type=int, help='Server port (default: 8741)')
101
+ def run(project: str, port: int):
102
+ '''Launch the LARE review interface.'''
103
+ import uvicorn
104
+ from lare.server import create_app
105
+
106
+ log_step(f'Opening project: {project}')
107
+
108
+ app = create_app(project)
109
+
110
+ url = f'http://127.0.0.1:{port}'
111
+ log_step(f'Starting server at {url}')
112
+
113
+ webbrowser.open(url)
114
+
115
+ uvicorn.run(app, host='127.0.0.1', port=port, log_level='warning')
116
+
117
+
118
+ @main.command()
119
+ @click.argument('project', type=click.Path(exists=True))
120
+ @click.option('--strategy', type=click.Choice(['csv', 'copy']), required=True, help='Export strategy')
121
+ @click.option('--output', required=True, type=click.Path(), help='Output path')
122
+ def export(project: str, strategy: str, output: str):
123
+ '''Export classified entries from a LARE project.'''
124
+ from lare.export import export_copy, export_csv
125
+
126
+ log_step(f'Exporting from {project} using strategy: {strategy}')
127
+
128
+ if strategy == 'csv':
129
+ export_csv(project, output)
130
+ elif strategy == 'copy':
131
+ export_copy(project, output)
132
+
133
+
134
+ if __name__ == '__main__':
135
+ main()
lare/config.py ADDED
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, field_validator, model_validator
7
+
8
+ if sys.version_info >= (3, 11):
9
+ import tomllib
10
+ else:
11
+ import tomli as tomllib
12
+
13
+ import tomli_w
14
+
15
+
16
+ class PathsConfig(BaseModel):
17
+ project: str = 'project.lare'
18
+ source_csv: str
19
+ image_directory: str | None = None
20
+
21
+
22
+ class RulesConfig(BaseModel):
23
+ filter_method: str | None = None
24
+ ranking_score: str
25
+ id_column: str
26
+ id_display_method: Literal['raw', 'basename', 'stem'] = 'raw'
27
+
28
+
29
+ class ImageConfig(BaseModel):
30
+ column: str
31
+ title: str
32
+ format: Literal['fits', 'png', 'jpg', 'jpeg', 'tiff']
33
+ stretching: str | None = None
34
+ min_threshold: float = 0.5
35
+ max_threshold: float = 99.5
36
+ subdirectory: str | None = None
37
+
38
+ @field_validator('stretching')
39
+ @classmethod
40
+ def stretching_requires_fits(cls, v: str | None, info) -> str | None:
41
+ if v is not None and info.data.get('format') != 'fits':
42
+ raise ValueError('stretching is only valid for FITS format images')
43
+ return v
44
+
45
+ @field_validator('subdirectory')
46
+ @classmethod
47
+ def subdirectory_needs_image_directory(cls, v: str | None) -> str | None:
48
+ return v
49
+
50
+
51
+ class LabelConfig(BaseModel):
52
+ label: str
53
+ title: str
54
+ shortcut: str
55
+ is_emergent: bool = False
56
+
57
+ @field_validator('shortcut')
58
+ @classmethod
59
+ def validate_shortcut(cls, v: str) -> str:
60
+ if len(v) != 1 or not v.isalpha():
61
+ raise ValueError('shortcut must be a single alphabetic character')
62
+ return v.lower()
63
+
64
+
65
+ class LareConfig(BaseModel):
66
+ paths: PathsConfig
67
+ rules: RulesConfig
68
+ images: list[ImageConfig]
69
+ labels: list[LabelConfig]
70
+
71
+ @model_validator(mode='after')
72
+ def validate_shortcuts_unique(self) -> LareConfig:
73
+ shortcuts = [lb.shortcut for lb in self.labels]
74
+ if len(shortcuts) != len(set(shortcuts)):
75
+ dupes = [s for s in shortcuts if shortcuts.count(s) > 1]
76
+ raise ValueError(
77
+ f'duplicate keyboard shortcuts detected: {set(dupes)}'
78
+ )
79
+ return self
80
+
81
+ @model_validator(mode='after')
82
+ def validate_subdirectory_requires_image_dir(self) -> LareConfig:
83
+ if self.paths.image_directory is None:
84
+ for img in self.images:
85
+ if img.subdirectory is not None:
86
+ raise ValueError(
87
+ f'image "{img.title}" has subdirectory set but '
88
+ f'paths.image_directory is not configured'
89
+ )
90
+ return self
91
+
92
+ @model_validator(mode='after')
93
+ def validate_fits_extras(self) -> LareConfig:
94
+ has_fits = any(img.format == 'fits' for img in self.images)
95
+ if has_fits:
96
+ try:
97
+ import astropy # noqa: F401
98
+ except ImportError:
99
+ raise ValueError(
100
+ 'FITS image support requires extra dependencies. '
101
+ 'Install with: pip install lare[fits]'
102
+ )
103
+ return self
104
+
105
+
106
+ def load_config(path: str) -> LareConfig:
107
+ with open(path, 'rb') as f:
108
+ raw = tomllib.load(f)
109
+ return parse_config_dict(raw)
110
+
111
+
112
+ def parse_config_dict(raw: dict) -> LareConfig:
113
+ return LareConfig(
114
+ paths=PathsConfig(**raw.get('paths', {})),
115
+ rules=RulesConfig(**raw.get('rules', {})),
116
+ images=[ImageConfig(**img) for img in raw.get('images', [])],
117
+ labels=[LabelConfig(**lb) for lb in raw.get('labels', [])],
118
+ )
119
+
120
+
121
+ def config_to_dict(config: LareConfig) -> dict:
122
+ return config.model_dump(mode='python')
123
+
124
+
125
+ def write_config(config: LareConfig, path: str) -> None:
126
+ d = config.model_dump(mode='python', exclude_none=True)
127
+ toml_dict = {
128
+ 'paths': d['paths'],
129
+ 'rules': d['rules'],
130
+ 'images': d['images'],
131
+ 'labels': d['labels'],
132
+ }
133
+ with open(path, 'wb') as f:
134
+ tomli_w.dump(toml_dict, f)
lare/console.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+ from rich.table import Table
7
+
8
+ console = Console()
9
+
10
+
11
+ def log_step(msg: str) -> None:
12
+ console.print(f'\n[bold cyan]ℹ[/] {msg}')
13
+
14
+
15
+ def log_success(msg: str) -> None:
16
+ console.print(f'\n[bold green]✔[/] {msg}')
17
+
18
+
19
+ def log_warning(msg: str) -> None:
20
+ console.print(f'\n[bold yellow]⚠[/] {msg}')
21
+
22
+
23
+ def log_error(msg: str) -> None:
24
+ console.print(f'\n[bold red]✖[/] {msg}')
25
+ raise click.Abort()
26
+
27
+
28
+ def print_config_table(config_dict: dict) -> None:
29
+ from rich import box
30
+
31
+ console.print()
32
+ table = Table(title='Project Configuration', show_lines=False, box=box.SIMPLE, show_edge=False)
33
+ table.add_column('Parameter', style='cyan')
34
+ table.add_column('Value', style='white')
35
+
36
+ def _flatten(d: dict, prefix: str = '') -> None:
37
+ for key, val in d.items():
38
+ full_key = f'{prefix}.{key}' if prefix else key
39
+ if isinstance(val, dict):
40
+ _flatten(val, full_key)
41
+ elif isinstance(val, list):
42
+ for i, item in enumerate(val):
43
+ if isinstance(item, dict):
44
+ _flatten(item, f'{full_key}[{i}]')
45
+ else:
46
+ table.add_row(f'{full_key}[{i}]', str(item))
47
+ else:
48
+ table.add_row(full_key, str(val))
49
+
50
+ _flatten(config_dict)
51
+ console.print(table)
52
+
53
+
54
+ def print_audit_banner(labeled: int, total: int) -> None:
55
+ from rich import box
56
+
57
+ console.print()
58
+ if labeled < total:
59
+ console.print(Panel(
60
+ f'Exported {labeled} entries.\n'
61
+ f'[yellow]Alert:[/] You have only audited '
62
+ f'{labeled}/{total} filtered entries in this project database.',
63
+ title='[bold yellow]⚠ Audit Incomplete[/]',
64
+ border_style='yellow',
65
+ box=box.ROUNDED,
66
+ padding=(1, 2)
67
+ ))
68
+ else:
69
+ console.print(Panel(
70
+ f'Exported {labeled}/{total} entries — full audit complete.',
71
+ title='[bold green]✔ Audit Complete[/]',
72
+ border_style='green',
73
+ box=box.ROUNDED,
74
+ padding=(1, 2)
75
+ ))
76
+ console.print()
77
+
78
+
79
+ def print_creation_summary(
80
+ rows_before: int,
81
+ rows_after: int,
82
+ label_count: int,
83
+ project_path: str,
84
+ ) -> None:
85
+ from rich import box
86
+
87
+ console.print()
88
+ table = Table(title='Project Created', show_lines=False, box=box.SIMPLE, show_edge=False)
89
+ table.add_column('Metric', style='cyan')
90
+ table.add_column('Value', style='white')
91
+ table.add_row('Source rows', str(rows_before))
92
+ table.add_row('Queued rows (after filter)', str(rows_after))
93
+ table.add_row('Labels registered', str(label_count))
94
+ table.add_row('Database path', project_path)
95
+ console.print(table)
96
+ console.print()
lare/database.py ADDED
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import threading
6
+ import uuid
7
+ from pathlib import Path
8
+
9
+ import polars as pl
10
+
11
+ from lare.config import LareConfig
12
+
13
+
14
+ POLARS_TO_SQLITE = {
15
+ pl.Float64: 'REAL',
16
+ pl.Float32: 'REAL',
17
+ pl.Int64: 'INTEGER',
18
+ pl.Int32: 'INTEGER',
19
+ pl.Int16: 'INTEGER',
20
+ pl.Int8: 'INTEGER',
21
+ pl.UInt64: 'INTEGER',
22
+ pl.UInt32: 'INTEGER',
23
+ pl.UInt16: 'INTEGER',
24
+ pl.UInt8: 'INTEGER',
25
+ pl.Boolean: 'INTEGER',
26
+ pl.Utf8: 'TEXT',
27
+ pl.String: 'TEXT',
28
+ }
29
+
30
+
31
+ class LareDatabase:
32
+ def __init__(self, path: str | Path):
33
+ self.path = str(path)
34
+ self._lock = threading.Lock()
35
+ self._conn: sqlite3.Connection | None = None
36
+
37
+ def connect(self) -> LareDatabase:
38
+ self._conn = sqlite3.connect(self.path, check_same_thread=False)
39
+ self._conn.row_factory = sqlite3.Row
40
+ self._conn.execute('PRAGMA journal_mode=WAL')
41
+ self._conn.execute('PRAGMA busy_timeout=5000')
42
+ return self
43
+
44
+ def close(self) -> None:
45
+ if self._conn:
46
+ self._conn.close()
47
+ self._conn = None
48
+
49
+ def __enter__(self) -> LareDatabase:
50
+ return self.connect()
51
+
52
+ def __exit__(self, *exc) -> None:
53
+ self.close()
54
+
55
+ @property
56
+ def conn(self) -> sqlite3.Connection:
57
+ if self._conn is None:
58
+ raise RuntimeError('Database not connected')
59
+ return self._conn
60
+
61
+ def _write(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
62
+ with self._lock:
63
+ cursor = self._conn.execute(sql, params)
64
+ self._conn.commit()
65
+ return cursor
66
+
67
+ def _write_many(self, sql: str, rows: list[tuple]) -> None:
68
+ with self._lock:
69
+ self._conn.executemany(sql, rows)
70
+ self._conn.commit()
71
+
72
+ def get_entry(self, queue_order: int) -> dict | None:
73
+ row = self.conn.execute(
74
+ 'SELECT * FROM labeling_queue WHERE queue_order = ?',
75
+ (queue_order,)
76
+ ).fetchone()
77
+ return dict(row) if row else None
78
+
79
+ def get_next_unlabeled(self, after: int = -1) -> dict | None:
80
+ row = self.conn.execute(
81
+ 'SELECT * FROM labeling_queue '
82
+ 'WHERE (final_label IS NULL OR TRIM(final_label) = \'\') AND queue_order > ? '
83
+ 'ORDER BY queue_order ASC LIMIT 1',
84
+ (after,)
85
+ ).fetchone()
86
+ return dict(row) if row else None
87
+
88
+ def set_label(self, entry_id: str, label: str) -> None:
89
+ self._write(
90
+ 'UPDATE labeling_queue SET final_label = ? WHERE entry_id = ?',
91
+ (label.strip(), entry_id),
92
+ )
93
+
94
+ def clear_label(self, entry_id: str) -> None:
95
+ self._write(
96
+ 'UPDATE labeling_queue SET final_label = NULL WHERE entry_id = ?',
97
+ (entry_id,),
98
+ )
99
+
100
+ def search_display_id(self, query: str) -> list[dict]:
101
+ rows = self.conn.execute(
102
+ 'SELECT queue_order, entry_id, display_id, final_label '
103
+ 'FROM labeling_queue WHERE display_id LIKE ? ORDER BY queue_order',
104
+ (f'%{query}%',)
105
+ ).fetchall()
106
+ return [dict(r) for r in rows]
107
+
108
+ def get_entry_by_display_id(self, display_id: str) -> list[dict]:
109
+ rows = self.conn.execute(
110
+ 'SELECT * FROM labeling_queue WHERE display_id = ? '
111
+ 'ORDER BY queue_order',
112
+ (display_id,)
113
+ ).fetchall()
114
+ return [dict(r) for r in rows]
115
+
116
+ def get_audit_stats(self) -> tuple[int, int]:
117
+ row = self.conn.execute(
118
+ 'SELECT '
119
+ 'COUNT(CASE WHEN final_label IS NOT NULL AND TRIM(final_label) != \'\' THEN 1 END) AS labeled, '
120
+ 'COUNT(*) AS total '
121
+ 'FROM labeling_queue'
122
+ ).fetchone()
123
+ return row['labeled'], row['total']
124
+
125
+ def get_config(self) -> dict:
126
+ row = self.conn.execute(
127
+ 'SELECT config_json FROM project_metadata LIMIT 1'
128
+ ).fetchone()
129
+ if row is None:
130
+ raise RuntimeError('No project metadata found in database')
131
+ return json.loads(row['config_json'])
132
+
133
+ def get_all_labeled(self) -> list[dict]:
134
+ rows = self.conn.execute(
135
+ 'SELECT * FROM labeling_queue '
136
+ 'WHERE final_label IS NOT NULL AND TRIM(final_label) != \'\' '
137
+ 'ORDER BY queue_order'
138
+ ).fetchall()
139
+ return [dict(r) for r in rows]
140
+
141
+ def get_total_count(self) -> int:
142
+ row = self.conn.execute(
143
+ 'SELECT COUNT(*) AS cnt FROM labeling_queue'
144
+ ).fetchone()
145
+ return row['cnt']
146
+
147
+
148
+ def create_database(
149
+ path: str | Path,
150
+ config: LareConfig,
151
+ df: pl.DataFrame,
152
+ ) -> None:
153
+ path = str(path)
154
+ conn = sqlite3.connect(path)
155
+ conn.execute('PRAGMA journal_mode=WAL')
156
+
157
+ conn.execute(
158
+ 'CREATE TABLE project_metadata ('
159
+ ' project_id TEXT PRIMARY KEY,'
160
+ ' created_at TEXT DEFAULT CURRENT_TIMESTAMP,'
161
+ ' config_json TEXT NOT NULL'
162
+ ')'
163
+ )
164
+
165
+ fixed_cols = [
166
+ 'queue_order INTEGER PRIMARY KEY',
167
+ 'entry_id TEXT UNIQUE NOT NULL',
168
+ 'display_id TEXT NOT NULL',
169
+ 'final_label TEXT DEFAULT NULL',
170
+ ]
171
+
172
+ skip_cols = {'queue_order', 'entry_id', 'display_id', 'final_label'}
173
+ dynamic_cols = []
174
+ for name, dtype in zip(df.columns, df.dtypes):
175
+ if name in skip_cols:
176
+ continue
177
+ sqlite_type = POLARS_TO_SQLITE.get(dtype, 'TEXT')
178
+ dynamic_cols.append(f'"{name}" {sqlite_type}')
179
+
180
+ all_cols = fixed_cols + dynamic_cols
181
+ col_defs = ', '.join(all_cols)
182
+
183
+ conn.execute(f'CREATE TABLE labeling_queue ({col_defs})')
184
+ conn.execute('CREATE INDEX idx_display_id ON labeling_queue(display_id)')
185
+ conn.execute('CREATE INDEX idx_final_label ON labeling_queue(final_label)')
186
+
187
+ ordered_columns = ['queue_order', 'entry_id', 'display_id']
188
+ ordered_columns.append('final_label')
189
+ for name in df.columns:
190
+ if name not in skip_cols:
191
+ ordered_columns.append(name)
192
+
193
+ select_cols = []
194
+ for col in ordered_columns:
195
+ if col == 'final_label':
196
+ select_cols.append(col)
197
+ elif col in df.columns:
198
+ select_cols.append(col)
199
+
200
+ insert_df = df.select([c for c in ordered_columns if c in df.columns])
201
+
202
+ if 'final_label' not in insert_df.columns:
203
+ insert_df = insert_df.with_columns(
204
+ pl.lit(None).cast(pl.Utf8).alias('final_label')
205
+ )
206
+
207
+ final_order = ['queue_order', 'entry_id', 'display_id', 'final_label']
208
+ for c in insert_df.columns:
209
+ if c not in final_order:
210
+ final_order.append(c)
211
+ insert_df = insert_df.select(final_order)
212
+
213
+ placeholders = ', '.join(['?'] * len(insert_df.columns))
214
+ insert_sql = f'INSERT INTO labeling_queue VALUES ({placeholders})'
215
+
216
+ rows = insert_df.rows()
217
+ conn.executemany(insert_sql, rows)
218
+
219
+ project_id = str(uuid.uuid4())
220
+ config_json = json.dumps(config.model_dump(mode='python'), default=str)
221
+ conn.execute(
222
+ 'INSERT INTO project_metadata (project_id, config_json) VALUES (?, ?)',
223
+ (project_id, config_json),
224
+ )
225
+
226
+ conn.commit()
227
+ conn.close()
228
+
229
+
230
+ def open_database(path: str | Path) -> LareDatabase:
231
+ return LareDatabase(path)
lare/export.py ADDED
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import csv as csv_module
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ from lare.config import LareConfig, parse_config_dict
8
+ from lare.console import console, log_step, log_success, print_audit_banner
9
+ from lare.database import open_database
10
+
11
+
12
+ def export_csv(db_path: str, output_path: str) -> None:
13
+ db = open_database(db_path)
14
+ with db:
15
+ labeled = db.get_all_labeled()
16
+ labeled_count = len(labeled)
17
+ total = db.get_total_count()
18
+
19
+ if not labeled:
20
+ log_step('No labeled entries to export')
21
+ print_audit_banner(0, total)
22
+ return
23
+
24
+ log_step(f'Exporting {labeled_count} labeled entries to CSV')
25
+
26
+ fieldnames = list(labeled[0].keys())
27
+ with open(output_path, 'w', newline='') as f:
28
+ writer = csv_module.DictWriter(f, fieldnames=fieldnames)
29
+ writer.writeheader()
30
+ writer.writerows(labeled)
31
+
32
+ log_success(f'CSV written to {output_path}')
33
+ print_audit_banner(labeled_count, total)
34
+
35
+
36
+ def export_copy(db_path: str, output_path: str) -> None:
37
+ from rich.progress import Progress
38
+
39
+ db = open_database(db_path)
40
+ with db:
41
+ config_dict = db.get_config()
42
+ config = parse_config_dict(config_dict)
43
+
44
+ labeled = db.get_all_labeled()
45
+ labeled_count = len(labeled)
46
+ total = db.get_total_count()
47
+
48
+ if not labeled:
49
+ log_step('No labeled entries to export')
50
+ print_audit_banner(0, total)
51
+ return
52
+
53
+ log_step(f'Copying {labeled_count} labeled entries')
54
+ output_root = Path(output_path)
55
+
56
+ with Progress(console=console) as progress:
57
+ task = progress.add_task('Copying files...', total=labeled_count)
58
+ copied = 0
59
+
60
+ for entry in labeled:
61
+ label = entry['final_label']
62
+ for img_config in config.images:
63
+ col_value = entry.get(img_config.column)
64
+ if not col_value:
65
+ continue
66
+
67
+ if config.paths.image_directory:
68
+ base = Path(config.paths.image_directory)
69
+ basename = Path(col_value).name
70
+ if img_config.subdirectory:
71
+ source = base / img_config.subdirectory / basename
72
+ else:
73
+ source = base / basename
74
+ else:
75
+ source = Path(col_value)
76
+
77
+ if not source.exists():
78
+ continue
79
+
80
+ target_dir = output_root / label
81
+ target_dir.mkdir(parents=True, exist_ok=True)
82
+ target = target_dir / source.name
83
+
84
+ if target.exists():
85
+ stem = target.stem
86
+ suffix = target.suffix
87
+ counter = 1
88
+ while target.exists():
89
+ target = target_dir / f'{stem}_{counter}{suffix}'
90
+ counter += 1
91
+
92
+ shutil.copy2(str(source), str(target))
93
+ copied += 1
94
+
95
+ progress.advance(task)
96
+
97
+ log_success(f'Copied {copied} files to {output_path}')
98
+ print_audit_banner(labeled_count, total)