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 +1 -0
- lare/cli.py +135 -0
- lare/config.py +134 -0
- lare/console.py +96 -0
- lare/database.py +231 -0
- lare/export.py +98 -0
- lare/imaging.py +163 -0
- lare/interactive.py +287 -0
- lare/pipeline.py +92 -0
- lare/server.py +207 -0
- lare/static/assets/EBGaramond-jRkwfIhC.ttf +0 -0
- lare/static/assets/Inter-c8O0ljhh.ttf +0 -0
- lare/static/assets/JetBrainsMono-BrlcHZ7m.ttf +0 -0
- lare/static/assets/index-D4HzesMy.js +71 -0
- lare/static/assets/index-oCWIDgZQ.css +1 -0
- lare/static/index.html +17 -0
- lare-0.1.0.dist-info/METADATA +189 -0
- lare-0.1.0.dist-info/RECORD +22 -0
- lare-0.1.0.dist-info/WHEEL +5 -0
- lare-0.1.0.dist-info/entry_points.txt +2 -0
- lare-0.1.0.dist-info/licenses/LICENSE +21 -0
- lare-0.1.0.dist-info/top_level.txt +1 -0
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)
|