astrooracle 0.1.0__tar.gz
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.
- astrooracle-0.1.0/LICENSE +21 -0
- astrooracle-0.1.0/PKG-INFO +116 -0
- astrooracle-0.1.0/README.md +65 -0
- astrooracle-0.1.0/pyproject.toml +114 -0
- astrooracle-0.1.0/setup.cfg +4 -0
- astrooracle-0.1.0/src/astrooracle/__init__.py +1 -0
- astrooracle-0.1.0/src/astrooracle/annotations.py +61 -0
- astrooracle-0.1.0/src/astrooracle/batch_html.py +67 -0
- astrooracle-0.1.0/src/astrooracle/cli.py +219 -0
- astrooracle-0.1.0/src/astrooracle/config.py +41 -0
- astrooracle-0.1.0/src/astrooracle/core.py +70 -0
- astrooracle-0.1.0/src/astrooracle/logging_utils.py +35 -0
- astrooracle-0.1.0/src/astrooracle/py.typed +0 -0
- astrooracle-0.1.0/src/astrooracle/ui_notebook.py +132 -0
- astrooracle-0.1.0/src/astrooracle/viz_matplotlib.py +44 -0
- astrooracle-0.1.0/src/astrooracle/viz_plotly.py +20 -0
- astrooracle-0.1.0/src/astrooracle/watch.py +51 -0
- astrooracle-0.1.0/src/astrooracle.egg-info/PKG-INFO +116 -0
- astrooracle-0.1.0/src/astrooracle.egg-info/SOURCES.txt +22 -0
- astrooracle-0.1.0/src/astrooracle.egg-info/dependency_links.txt +1 -0
- astrooracle-0.1.0/src/astrooracle.egg-info/entry_points.txt +2 -0
- astrooracle-0.1.0/src/astrooracle.egg-info/requires.txt +32 -0
- astrooracle-0.1.0/src/astrooracle.egg-info/top_level.txt +1 -0
- astrooracle-0.1.0/tests/test_select.py +21 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AstroOracle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: astrooracle
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AstroOracle: active learning oracle for astronomical anomaly triage (SkyView cutouts + annotations + retrain hooks).
|
|
5
|
+
Author: AstroOracle Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/<OWNER>/AstroOracle
|
|
8
|
+
Project-URL: Issues, https://github.com/<OWNER>/AstroOracle/issues
|
|
9
|
+
Keywords: astronomy,anomaly-detection,active-learning,skyview,astropy
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy>=1.23
|
|
23
|
+
Requires-Dist: pandas>=2.0
|
|
24
|
+
Requires-Dist: matplotlib>=3.7
|
|
25
|
+
Requires-Dist: astropy>=5.3
|
|
26
|
+
Requires-Dist: astroquery>=0.4.6
|
|
27
|
+
Requires-Dist: scikit-learn>=1.3
|
|
28
|
+
Requires-Dist: pyarrow>=12.0
|
|
29
|
+
Requires-Dist: pillow>=10.0
|
|
30
|
+
Provides-Extra: notebook
|
|
31
|
+
Requires-Dist: ipywidgets>=8.1; extra == "notebook"
|
|
32
|
+
Requires-Dist: jupyterlab>=4.0; extra == "notebook"
|
|
33
|
+
Requires-Dist: plotly>=5.18; extra == "notebook"
|
|
34
|
+
Provides-Extra: plotly
|
|
35
|
+
Requires-Dist: plotly>=5.18; extra == "plotly"
|
|
36
|
+
Provides-Extra: watch
|
|
37
|
+
Requires-Dist: watchdog>=3.0; extra == "watch"
|
|
38
|
+
Provides-Extra: dev
|
|
39
|
+
Requires-Dist: pytest<10,>=8; extra == "dev"
|
|
40
|
+
Requires-Dist: pytest-cov<8,>=5; extra == "dev"
|
|
41
|
+
Requires-Dist: coverage[toml]<8,>=7; extra == "dev"
|
|
42
|
+
Requires-Dist: ruff<1.0,>=0.6; extra == "dev"
|
|
43
|
+
Requires-Dist: black<27,>=24; extra == "dev"
|
|
44
|
+
Requires-Dist: pre-commit<5,>=3.7; extra == "dev"
|
|
45
|
+
Requires-Dist: mypy<2,>=1.10; extra == "dev"
|
|
46
|
+
Requires-Dist: types-requests>=2.32.0.20240914; extra == "dev"
|
|
47
|
+
Requires-Dist: build<2,>=1.2; extra == "dev"
|
|
48
|
+
Requires-Dist: twine<7,>=5; extra == "dev"
|
|
49
|
+
Requires-Dist: nox>=2024.4.15; extra == "dev"
|
|
50
|
+
Dynamic: license-file
|
|
51
|
+
|
|
52
|
+
# AstroOracle
|
|
53
|
+
|
|
54
|
+
AstroOracle is an active learning “oracle” for astronomical anomaly triage.
|
|
55
|
+
|
|
56
|
+
- Reads candidate detections from a Parquet file.
|
|
57
|
+
- Ranks them by uncertainty + optional embedding diversity.
|
|
58
|
+
- Pulls multi-survey cutouts from SkyView.
|
|
59
|
+
- Annotates via CLI (matplotlib) or Jupyter (ipywidgets + Plotly).
|
|
60
|
+
- Logs events in append-only JSONL.
|
|
61
|
+
- Optionally triggers retraining.
|
|
62
|
+
|
|
63
|
+
## Install (editable)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
python -m venv .venv
|
|
67
|
+
source .venv/bin/activate
|
|
68
|
+
pip install -U pip
|
|
69
|
+
pip install -e ".[plotly,watch,notebook]"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Quickstart (CLI)
|
|
73
|
+
|
|
74
|
+
Generate sample candidates:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
python examples/sample_candidates.py
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Run oracle:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
astrooracle run --candidates candidates.parquet --interval 300 --n-query 6
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Headless + PNG export:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
astrooracle run --no-gui --save-cutouts cutouts/ --candidates candidates.parquet
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Jupyter UI
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
jupyter lab
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Open `examples/oracle_notebook.ipynb`.
|
|
99
|
+
|
|
100
|
+
## Watch mode (watchdog)
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
astrooracle watch --candidates candidates.parquet
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Batch HTML export
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
astrooracle batch-html --candidates candidates.parquet --out-dir batch_out/ --n-query 60
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Open `batch_out/index.html` and export annotations from the page.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# AstroOracle
|
|
2
|
+
|
|
3
|
+
AstroOracle is an active learning “oracle” for astronomical anomaly triage.
|
|
4
|
+
|
|
5
|
+
- Reads candidate detections from a Parquet file.
|
|
6
|
+
- Ranks them by uncertainty + optional embedding diversity.
|
|
7
|
+
- Pulls multi-survey cutouts from SkyView.
|
|
8
|
+
- Annotates via CLI (matplotlib) or Jupyter (ipywidgets + Plotly).
|
|
9
|
+
- Logs events in append-only JSONL.
|
|
10
|
+
- Optionally triggers retraining.
|
|
11
|
+
|
|
12
|
+
## Install (editable)
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
python -m venv .venv
|
|
16
|
+
source .venv/bin/activate
|
|
17
|
+
pip install -U pip
|
|
18
|
+
pip install -e ".[plotly,watch,notebook]"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quickstart (CLI)
|
|
22
|
+
|
|
23
|
+
Generate sample candidates:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python examples/sample_candidates.py
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Run oracle:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
astrooracle run --candidates candidates.parquet --interval 300 --n-query 6
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Headless + PNG export:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
astrooracle run --no-gui --save-cutouts cutouts/ --candidates candidates.parquet
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Jupyter UI
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
jupyter lab
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Open `examples/oracle_notebook.ipynb`.
|
|
48
|
+
|
|
49
|
+
## Watch mode (watchdog)
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
astrooracle watch --candidates candidates.parquet
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Batch HTML export
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
astrooracle batch-html --candidates candidates.parquet --out-dir batch_out/ --n-query 60
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Open `batch_out/index.html` and export annotations from the page.
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "astrooracle"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "AstroOracle: active learning oracle for astronomical anomaly triage (SkyView cutouts + annotations + retrain hooks)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "AstroOracle Contributors" }]
|
|
13
|
+
keywords = ["astronomy", "anomaly-detection", "active-learning", "skyview", "astropy"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Astronomy",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"numpy>=1.23",
|
|
27
|
+
"pandas>=2.0",
|
|
28
|
+
"matplotlib>=3.7",
|
|
29
|
+
"astropy>=5.3",
|
|
30
|
+
"astroquery>=0.4.6",
|
|
31
|
+
"scikit-learn>=1.3",
|
|
32
|
+
"pyarrow>=12.0",
|
|
33
|
+
"pillow>=10.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/<OWNER>/AstroOracle"
|
|
38
|
+
Issues = "https://github.com/<OWNER>/AstroOracle/issues"
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
notebook = [
|
|
42
|
+
"ipywidgets>=8.1",
|
|
43
|
+
"jupyterlab>=4.0",
|
|
44
|
+
"plotly>=5.18",
|
|
45
|
+
]
|
|
46
|
+
plotly = ["plotly>=5.18"]
|
|
47
|
+
watch = ["watchdog>=3.0"]
|
|
48
|
+
|
|
49
|
+
# A single extra that makes CI/dev reproducible.
|
|
50
|
+
# Pins are intentionally bounded to avoid surprise breakage in CI.
|
|
51
|
+
dev = [
|
|
52
|
+
"pytest>=8,<10",
|
|
53
|
+
"pytest-cov>=5,<8",
|
|
54
|
+
"coverage[toml]>=7,<8",
|
|
55
|
+
"ruff>=0.6,<1.0",
|
|
56
|
+
"black>=24,<27",
|
|
57
|
+
"pre-commit>=3.7,<5",
|
|
58
|
+
"mypy>=1.10,<2",
|
|
59
|
+
"types-requests>=2.32.0.20240914",
|
|
60
|
+
"build>=1.2,<2",
|
|
61
|
+
"twine>=5,<7",
|
|
62
|
+
"nox>=2024.4.15",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
[project.scripts]
|
|
67
|
+
astrooracle = "astrooracle.cli:main"
|
|
68
|
+
|
|
69
|
+
[tool.setuptools]
|
|
70
|
+
package-dir = { "" = "src" }
|
|
71
|
+
|
|
72
|
+
[tool.setuptools.packages.find]
|
|
73
|
+
where = ["src"]
|
|
74
|
+
|
|
75
|
+
[tool.setuptools.package-data]
|
|
76
|
+
astrooracle = ["py.typed"]
|
|
77
|
+
|
|
78
|
+
[tool.pytest.ini_options]
|
|
79
|
+
minversion = "8.0"
|
|
80
|
+
addopts = "-q --disable-warnings --maxfail=1 --cov=astrooracle --cov-report=term-missing"
|
|
81
|
+
testpaths = ["tests"]
|
|
82
|
+
|
|
83
|
+
[tool.coverage.run]
|
|
84
|
+
branch = true
|
|
85
|
+
source = ["astrooracle"]
|
|
86
|
+
|
|
87
|
+
[tool.coverage.report]
|
|
88
|
+
fail_under = 0
|
|
89
|
+
show_missing = true
|
|
90
|
+
skip_covered = false
|
|
91
|
+
|
|
92
|
+
[tool.ruff]
|
|
93
|
+
line-length = 100
|
|
94
|
+
target-version = "py310"
|
|
95
|
+
src = ["src"]
|
|
96
|
+
|
|
97
|
+
[tool.ruff.lint]
|
|
98
|
+
select = ["E", "F", "I", "B", "UP", "SIM", "RUF"]
|
|
99
|
+
ignore = ["E501"] # black handles line-length
|
|
100
|
+
|
|
101
|
+
[tool.black]
|
|
102
|
+
line-length = 100
|
|
103
|
+
target-version = ["py310"]
|
|
104
|
+
|
|
105
|
+
[tool.mypy]
|
|
106
|
+
python_version = "3.10"
|
|
107
|
+
warn_return_any = true
|
|
108
|
+
warn_unused_configs = true
|
|
109
|
+
disallow_untyped_defs = false
|
|
110
|
+
no_implicit_optional = true
|
|
111
|
+
check_untyped_defs = true
|
|
112
|
+
strict_equality = true
|
|
113
|
+
ignore_missing_imports = true
|
|
114
|
+
mypy_path = "src"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from .config import OracleConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def append_annotations(cfg: OracleConfig, rows: list[dict[str, Any]]) -> None:
|
|
13
|
+
if not rows:
|
|
14
|
+
return
|
|
15
|
+
df_new = pd.DataFrame(rows)
|
|
16
|
+
cfg.annot_path.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
write_header = not cfg.annot_path.exists()
|
|
18
|
+
df_new.to_csv(cfg.annot_path, mode="a", header=write_header, index=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_annotations(cfg: OracleConfig) -> pd.DataFrame:
|
|
22
|
+
if not cfg.annot_path.exists():
|
|
23
|
+
return pd.DataFrame()
|
|
24
|
+
df = pd.read_csv(cfg.annot_path)
|
|
25
|
+
if "embedding" in df.columns and not df.empty:
|
|
26
|
+
|
|
27
|
+
def _loads(x):
|
|
28
|
+
if pd.isna(x):
|
|
29
|
+
return None
|
|
30
|
+
if isinstance(x, str) and x.strip():
|
|
31
|
+
try:
|
|
32
|
+
return np.array(json.loads(x), dtype=float)
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
df["embedding_vec"] = df["embedding"].map(_loads)
|
|
38
|
+
return df
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def count_labels(cfg: OracleConfig) -> int:
|
|
42
|
+
if not cfg.annot_path.exists():
|
|
43
|
+
return 0
|
|
44
|
+
with cfg.annot_path.open("r", encoding="utf-8") as f:
|
|
45
|
+
n = sum(1 for _ in f)
|
|
46
|
+
return max(n - 1, 0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_retrain_cursor(cfg: OracleConfig) -> int:
|
|
50
|
+
cursor_path = cfg.annot_path.with_suffix(".cursor.json")
|
|
51
|
+
if not cursor_path.exists():
|
|
52
|
+
return 0
|
|
53
|
+
try:
|
|
54
|
+
return int(json.loads(cursor_path.read_text(encoding="utf-8")).get("rows", 0))
|
|
55
|
+
except Exception:
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_retrain_cursor(cfg: OracleConfig, rows: int) -> None:
|
|
60
|
+
cursor_path = cfg.annot_path.with_suffix(".cursor.json")
|
|
61
|
+
cursor_path.write_text(json.dumps({"rows": rows}), encoding="utf-8")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
from .config import OracleConfig
|
|
13
|
+
from .core import fetch_cutouts
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _slug(s: Any) -> str:
|
|
17
|
+
return re.sub(r"[^a-zA-Z0-9_-]+", "_", str(s))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _save_png(array2d: np.ndarray, path: Path) -> None:
|
|
21
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
arr = np.asarray(array2d, dtype=float)
|
|
23
|
+
finite = arr[np.isfinite(arr)]
|
|
24
|
+
if finite.size == 0:
|
|
25
|
+
norm = np.zeros_like(arr, dtype=np.uint8)
|
|
26
|
+
else:
|
|
27
|
+
lo = np.percentile(finite, 1)
|
|
28
|
+
hi = np.percentile(finite, 99)
|
|
29
|
+
if hi <= lo:
|
|
30
|
+
hi = lo + 1.0
|
|
31
|
+
norm = np.clip((arr - lo) / (hi - lo), 0, 1)
|
|
32
|
+
norm = (norm * 255).astype(np.uint8)
|
|
33
|
+
Image.fromarray(norm).save(path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_batch_html(cfg: OracleConfig, candidates_df: pd.DataFrame, out_dir: Path) -> None:
|
|
37
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
cut_dir = out_dir / "cutouts"
|
|
39
|
+
cut_dir.mkdir(exist_ok=True)
|
|
40
|
+
|
|
41
|
+
recs: list[dict[str, Any]] = []
|
|
42
|
+
for _, r in candidates_df.iterrows():
|
|
43
|
+
ra = float(r["ra"])
|
|
44
|
+
dec = float(r["dec"])
|
|
45
|
+
cid = r["id"]
|
|
46
|
+
cutouts = fetch_cutouts(ra, dec, cfg)
|
|
47
|
+
|
|
48
|
+
surveys = []
|
|
49
|
+
for survey, data in cutouts:
|
|
50
|
+
surveys.append(survey)
|
|
51
|
+
png = cut_dir / f"{_slug(cid)}_{_slug(survey)}.png"
|
|
52
|
+
_save_png(data, png)
|
|
53
|
+
|
|
54
|
+
recs.append(
|
|
55
|
+
{
|
|
56
|
+
"id": str(cid),
|
|
57
|
+
"ra": ra,
|
|
58
|
+
"dec": dec,
|
|
59
|
+
"score": float(r["anomaly_score"]),
|
|
60
|
+
"surveys": surveys,
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
(out_dir / "candidates.json").write_text(json.dumps(recs, indent=2), encoding="utf-8")
|
|
65
|
+
|
|
66
|
+
template = Path(__file__).resolve().parents[2] / "templates" / "batch_index.html"
|
|
67
|
+
(out_dir / "index.html").write_text(template.read_text(encoding="utf-8"), encoding="utf-8")
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from .annotations import append_annotations, count_labels, get_retrain_cursor, set_retrain_cursor
|
|
13
|
+
from .batch_html import generate_batch_html
|
|
14
|
+
from .config import OracleConfig
|
|
15
|
+
from .core import fetch_cutouts, load_candidates, select_candidates
|
|
16
|
+
from .logging_utils import log_event
|
|
17
|
+
from .viz_matplotlib import render_cutouts_matplotlib
|
|
18
|
+
from .watch import watch_candidates
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _make_cfg(args: argparse.Namespace) -> OracleConfig:
|
|
22
|
+
cfg0 = OracleConfig.default()
|
|
23
|
+
save_dir = Path(args.save_cutouts) if args.save_cutouts else None
|
|
24
|
+
cutout_radius = (
|
|
25
|
+
cfg0.cutout_radius
|
|
26
|
+
if args.cutout_radius_arcmin is None
|
|
27
|
+
else (float(args.cutout_radius_arcmin) * cfg0.cutout_radius.unit)
|
|
28
|
+
)
|
|
29
|
+
return OracleConfig(
|
|
30
|
+
candidates_path=Path(args.candidates),
|
|
31
|
+
annot_path=Path(args.annotations),
|
|
32
|
+
log_path=Path(args.log),
|
|
33
|
+
retrain_script=Path(args.retrain_script),
|
|
34
|
+
model_path=Path(args.model_path),
|
|
35
|
+
min_new_labels_for_retrain=int(args.min_new_labels),
|
|
36
|
+
check_interval_s=int(args.interval),
|
|
37
|
+
surveys=list(args.survey),
|
|
38
|
+
cutout_radius=cutout_radius,
|
|
39
|
+
pixels=int(args.pixels),
|
|
40
|
+
n_query=int(args.n_query),
|
|
41
|
+
no_gui=bool(args.no_gui),
|
|
42
|
+
save_cutouts_dir=save_dir,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def maybe_retrain(cfg: OracleConfig) -> None:
|
|
47
|
+
total = count_labels(cfg)
|
|
48
|
+
cursor = get_retrain_cursor(cfg)
|
|
49
|
+
new_since = total - cursor
|
|
50
|
+
if new_since >= cfg.min_new_labels_for_retrain:
|
|
51
|
+
print(f"Retrain triggered: {new_since} new labels.")
|
|
52
|
+
log_event(
|
|
53
|
+
cfg, {"event": "retrain_triggered", "new_labels": new_since, "total_labels": total}
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
subprocess.run(["python", str(cfg.retrain_script)], check=True)
|
|
57
|
+
set_retrain_cursor(cfg, total)
|
|
58
|
+
log_event(cfg, {"event": "retrain_success"})
|
|
59
|
+
except Exception as e:
|
|
60
|
+
log_event(cfg, {"event": "retrain_failed", "error": str(e)})
|
|
61
|
+
print(f"Retrain failed: {e}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def annotate_batch(cfg: OracleConfig, top: pd.DataFrame) -> None:
|
|
65
|
+
label_map = {"r": "real_anomaly", "a": "artefact", "c": "known", "j": "new_type"}
|
|
66
|
+
|
|
67
|
+
new_rows = []
|
|
68
|
+
has_emb = "embedding" in top.columns
|
|
69
|
+
|
|
70
|
+
for _, row in top.iterrows():
|
|
71
|
+
title = (
|
|
72
|
+
f"ID: {row['id']} | Score: {float(row['anomaly_score']):.4f} | "
|
|
73
|
+
f"RA: {float(row['ra']):.5f} Dec: {float(row['dec']):.5f}"
|
|
74
|
+
)
|
|
75
|
+
print("\n" + "=" * 60)
|
|
76
|
+
print(title)
|
|
77
|
+
|
|
78
|
+
cutouts = fetch_cutouts(float(row["ra"]), float(row["dec"]), cfg)
|
|
79
|
+
|
|
80
|
+
save_path = None
|
|
81
|
+
if cfg.save_cutouts_dir is not None:
|
|
82
|
+
safe_id = str(row["id"]).replace("/", "_")
|
|
83
|
+
save_path = cfg.save_cutouts_dir / f"{safe_id}.png"
|
|
84
|
+
|
|
85
|
+
render_cutouts_matplotlib(cutouts, title, cfg, save_path=save_path)
|
|
86
|
+
|
|
87
|
+
while True:
|
|
88
|
+
choice = (
|
|
89
|
+
input("Label ? [r]éel [a]rtefact [c]onnu [j]junk/nouveau [s]kip -> ")
|
|
90
|
+
.strip()
|
|
91
|
+
.lower()
|
|
92
|
+
)
|
|
93
|
+
if choice in {"r", "a", "c", "j", "s"}:
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
if choice == "s":
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
comment = input("Commentaire (Enter pour skip): ").strip()
|
|
100
|
+
entry = {
|
|
101
|
+
"id": row["id"],
|
|
102
|
+
"ra": float(row["ra"]),
|
|
103
|
+
"dec": float(row["dec"]),
|
|
104
|
+
"anomaly_score": float(row["anomaly_score"]),
|
|
105
|
+
"label": label_map[choice],
|
|
106
|
+
"comment": comment,
|
|
107
|
+
"annotated_at": pd.Timestamp.utcnow().isoformat(),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if has_emb and row.get("embedding", None) is not None:
|
|
111
|
+
entry["embedding"] = json.dumps(np.asarray(row["embedding"], dtype=float).tolist())
|
|
112
|
+
|
|
113
|
+
new_rows.append(entry)
|
|
114
|
+
|
|
115
|
+
if new_rows:
|
|
116
|
+
append_annotations(cfg, new_rows)
|
|
117
|
+
log_event(cfg, {"event": "new_annotations", "count": len(new_rows)})
|
|
118
|
+
maybe_retrain(cfg)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_run(args: argparse.Namespace) -> None:
|
|
122
|
+
cfg = _make_cfg(args)
|
|
123
|
+
log_event(cfg, {"event": "oracle_started", "mode": "poll"})
|
|
124
|
+
print(f"AstroOracle running. candidates={cfg.candidates_path} interval={cfg.check_interval_s}s")
|
|
125
|
+
|
|
126
|
+
while True:
|
|
127
|
+
try:
|
|
128
|
+
df = load_candidates(cfg)
|
|
129
|
+
if not df.empty:
|
|
130
|
+
top = select_candidates(df, cfg)
|
|
131
|
+
if not top.empty:
|
|
132
|
+
annotate_batch(cfg, top)
|
|
133
|
+
except KeyboardInterrupt:
|
|
134
|
+
log_event(cfg, {"event": "oracle_stopped"})
|
|
135
|
+
print("Stopped.")
|
|
136
|
+
break
|
|
137
|
+
except Exception as e:
|
|
138
|
+
log_event(cfg, {"event": "error", "msg": str(e)})
|
|
139
|
+
print(f"Error: {e}")
|
|
140
|
+
|
|
141
|
+
time.sleep(cfg.check_interval_s)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_watch(args: argparse.Namespace) -> None:
|
|
145
|
+
cfg = _make_cfg(args)
|
|
146
|
+
log_event(cfg, {"event": "oracle_started", "mode": "watch"})
|
|
147
|
+
print(f"AstroOracle watching {cfg.candidates_path}")
|
|
148
|
+
|
|
149
|
+
def _on_change():
|
|
150
|
+
try:
|
|
151
|
+
df = load_candidates(cfg)
|
|
152
|
+
if not df.empty:
|
|
153
|
+
top = select_candidates(df, cfg)
|
|
154
|
+
if not top.empty:
|
|
155
|
+
annotate_batch(cfg, top)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
log_event(cfg, {"event": "error", "msg": str(e)})
|
|
158
|
+
print(f"Error: {e}")
|
|
159
|
+
|
|
160
|
+
watch_candidates(cfg.candidates_path, _on_change)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cmd_batch_html(args: argparse.Namespace) -> None:
|
|
164
|
+
cfg = _make_cfg(args)
|
|
165
|
+
out_dir = Path(args.out_dir)
|
|
166
|
+
df = load_candidates(cfg)
|
|
167
|
+
if df.empty:
|
|
168
|
+
print("No candidates.")
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
cfg2 = OracleConfig(**{**cfg.__dict__, "n_query": int(args.n_query)})
|
|
172
|
+
top = select_candidates(df, cfg2)
|
|
173
|
+
if top.empty:
|
|
174
|
+
print("No selected candidates.")
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
generate_batch_html(cfg2, top, out_dir)
|
|
178
|
+
log_event(cfg2, {"event": "batch_html_generated", "out_dir": str(out_dir), "count": len(top)})
|
|
179
|
+
print(f"Batch HTML written to: {out_dir}/index.html")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main() -> None:
|
|
183
|
+
p = argparse.ArgumentParser(prog="astrooracle")
|
|
184
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
185
|
+
|
|
186
|
+
def add_common(sp):
|
|
187
|
+
sp.add_argument("--candidates", default="candidates.parquet")
|
|
188
|
+
sp.add_argument("--annotations", default="annotations.csv")
|
|
189
|
+
sp.add_argument("--log", default="oracle_log.jsonl")
|
|
190
|
+
sp.add_argument("--retrain-script", default="scripts/retrain_model.py")
|
|
191
|
+
sp.add_argument("--model-path", default="best_model.pkl")
|
|
192
|
+
sp.add_argument("--min-new-labels", type=int, default=10)
|
|
193
|
+
sp.add_argument("--interval", type=int, default=300)
|
|
194
|
+
sp.add_argument("--n-query", type=int, default=6)
|
|
195
|
+
sp.add_argument("--pixels", type=int, default=400)
|
|
196
|
+
sp.add_argument("--cutout-radius-arcmin", type=float, default=None)
|
|
197
|
+
sp.add_argument("--survey", action="append", default=["DSS2 Red", "2MASS J"])
|
|
198
|
+
sp.add_argument("--no-gui", action="store_true")
|
|
199
|
+
sp.add_argument("--save-cutouts", default=None)
|
|
200
|
+
|
|
201
|
+
sp_run = sub.add_parser("run", help="Poll candidates file and annotate in CLI.")
|
|
202
|
+
add_common(sp_run)
|
|
203
|
+
sp_run.set_defaults(fn=cmd_run)
|
|
204
|
+
|
|
205
|
+
sp_watch = sub.add_parser("watch", help="Watch candidates file changes (watchdog).")
|
|
206
|
+
add_common(sp_watch)
|
|
207
|
+
sp_watch.set_defaults(fn=cmd_watch)
|
|
208
|
+
|
|
209
|
+
sp_html = sub.add_parser("batch-html", help="Generate static HTML batch annotator.")
|
|
210
|
+
add_common(sp_html)
|
|
211
|
+
sp_html.add_argument("--out-dir", required=True)
|
|
212
|
+
sp_html.set_defaults(fn=cmd_batch_html)
|
|
213
|
+
|
|
214
|
+
args = p.parse_args()
|
|
215
|
+
args.fn(args)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
main()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from astropy import units as u
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class OracleConfig:
|
|
11
|
+
candidates_path: Path
|
|
12
|
+
annot_path: Path
|
|
13
|
+
log_path: Path
|
|
14
|
+
retrain_script: Path
|
|
15
|
+
model_path: Path
|
|
16
|
+
min_new_labels_for_retrain: int
|
|
17
|
+
check_interval_s: int
|
|
18
|
+
surveys: list[str]
|
|
19
|
+
cutout_radius: u.Quantity
|
|
20
|
+
pixels: int
|
|
21
|
+
n_query: int
|
|
22
|
+
no_gui: bool
|
|
23
|
+
save_cutouts_dir: Path | None
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def default() -> OracleConfig:
|
|
27
|
+
return OracleConfig(
|
|
28
|
+
candidates_path=Path("candidates.parquet"),
|
|
29
|
+
annot_path=Path("annotations.csv"),
|
|
30
|
+
log_path=Path("oracle_log.jsonl"),
|
|
31
|
+
retrain_script=Path("scripts/retrain_model.py"),
|
|
32
|
+
model_path=Path("best_model.pkl"),
|
|
33
|
+
min_new_labels_for_retrain=10,
|
|
34
|
+
check_interval_s=300,
|
|
35
|
+
surveys=["DSS2 Red", "2MASS J"],
|
|
36
|
+
cutout_radius=0.15 * u.arcmin,
|
|
37
|
+
pixels=400,
|
|
38
|
+
n_query=6,
|
|
39
|
+
no_gui=False,
|
|
40
|
+
save_cutouts_dir=None,
|
|
41
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from astropy import units as u
|
|
6
|
+
from astropy.coordinates import SkyCoord
|
|
7
|
+
from astroquery.skyview import SkyView
|
|
8
|
+
from sklearn.metrics import pairwise_distances
|
|
9
|
+
|
|
10
|
+
from .annotations import read_annotations
|
|
11
|
+
from .config import OracleConfig
|
|
12
|
+
|
|
13
|
+
REQUIRED_CAND_COLS = {"id", "ra", "dec", "anomaly_score"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_candidates(cfg: OracleConfig) -> pd.DataFrame:
|
|
17
|
+
if not cfg.candidates_path.exists():
|
|
18
|
+
return pd.DataFrame()
|
|
19
|
+
df = pd.read_parquet(cfg.candidates_path)
|
|
20
|
+
missing = REQUIRED_CAND_COLS - set(df.columns)
|
|
21
|
+
if missing:
|
|
22
|
+
raise ValueError(f"Missing columns in candidates: {missing}")
|
|
23
|
+
return df
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def select_candidates(df: pd.DataFrame, cfg: OracleConfig) -> pd.DataFrame:
|
|
27
|
+
df = df.copy()
|
|
28
|
+
median_score = float(df["anomaly_score"].median())
|
|
29
|
+
df["unc"] = np.abs(df["anomaly_score"] - median_score)
|
|
30
|
+
|
|
31
|
+
diversity = np.ones(len(df), dtype=float)
|
|
32
|
+
if "embedding" in df.columns and cfg.annot_path.exists():
|
|
33
|
+
annot = read_annotations(cfg)
|
|
34
|
+
if not annot.empty and "embedding_vec" in annot.columns:
|
|
35
|
+
ann_vecs = [v for v in annot["embedding_vec"].tolist() if isinstance(v, np.ndarray)]
|
|
36
|
+
if ann_vecs:
|
|
37
|
+
emb_all = np.stack(df["embedding"].values)
|
|
38
|
+
emb_ann = np.stack(ann_vecs)
|
|
39
|
+
dists = pairwise_distances(emb_all, emb_ann, metric="cosine")
|
|
40
|
+
diversity = dists.min(axis=1)
|
|
41
|
+
denom = (diversity.max() - diversity.min()) + 1e-8
|
|
42
|
+
diversity = (diversity - diversity.min()) / denom
|
|
43
|
+
|
|
44
|
+
unc_norm = df["unc"].to_numpy(dtype=float)
|
|
45
|
+
unc_norm = unc_norm / (float(unc_norm.max()) + 1e-8)
|
|
46
|
+
|
|
47
|
+
df["query_score"] = 0.6 * unc_norm + 0.4 * diversity
|
|
48
|
+
return df.nlargest(cfg.n_query, "query_score").reset_index(drop=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fetch_cutouts(ra_deg: float, dec_deg: float, cfg: OracleConfig) -> list[tuple[str, np.ndarray]]:
|
|
52
|
+
coord = SkyCoord(ra=ra_deg * u.deg, dec=dec_deg * u.deg)
|
|
53
|
+
results: list[tuple[str, np.ndarray]] = []
|
|
54
|
+
for survey in cfg.surveys:
|
|
55
|
+
try:
|
|
56
|
+
images = SkyView.get_images(
|
|
57
|
+
position=coord,
|
|
58
|
+
survey=[survey],
|
|
59
|
+
pixels=cfg.pixels,
|
|
60
|
+
radius=cfg.cutout_radius,
|
|
61
|
+
)
|
|
62
|
+
if not images:
|
|
63
|
+
continue
|
|
64
|
+
data = images[0][0].data
|
|
65
|
+
if data is None:
|
|
66
|
+
continue
|
|
67
|
+
results.append((survey, data))
|
|
68
|
+
except Exception:
|
|
69
|
+
continue
|
|
70
|
+
return results
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .config import OracleConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def utcnow_iso() -> str:
|
|
13
|
+
return datetime.utcnow().isoformat()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_file_hash(path: Path) -> str:
|
|
17
|
+
if not path.exists():
|
|
18
|
+
return "no_file"
|
|
19
|
+
h = hashlib.sha256()
|
|
20
|
+
with path.open("rb") as f:
|
|
21
|
+
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
22
|
+
h.update(chunk)
|
|
23
|
+
return h.hexdigest()[:12]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def log_event(cfg: OracleConfig, event: dict[str, Any]) -> None:
|
|
27
|
+
payload = {
|
|
28
|
+
**event,
|
|
29
|
+
"timestamp": utcnow_iso(),
|
|
30
|
+
"model_version": get_file_hash(cfg.model_path),
|
|
31
|
+
"candidates_hash": get_file_hash(cfg.candidates_path),
|
|
32
|
+
}
|
|
33
|
+
cfg.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
with cfg.log_path.open("a", encoding="utf-8") as f:
|
|
35
|
+
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
File without changes
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import ipywidgets as w
|
|
12
|
+
from IPython.display import clear_output, display
|
|
13
|
+
except Exception: # pragma: no cover
|
|
14
|
+
w = None # type: ignore
|
|
15
|
+
display = None # type: ignore
|
|
16
|
+
clear_output = None # type: ignore
|
|
17
|
+
|
|
18
|
+
from .config import OracleConfig
|
|
19
|
+
|
|
20
|
+
LABELS = [
|
|
21
|
+
("Réel", "real_anomaly"),
|
|
22
|
+
("Artefact", "artefact"),
|
|
23
|
+
("Connu", "known"),
|
|
24
|
+
("Nouveau type", "new_type"),
|
|
25
|
+
("Skip", "skip"),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OracleUI:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
cfg: OracleConfig,
|
|
33
|
+
load_candidates_fn: Callable[[OracleConfig], pd.DataFrame],
|
|
34
|
+
select_candidates_fn: Callable[[pd.DataFrame, OracleConfig], pd.DataFrame],
|
|
35
|
+
fetch_cutouts_fn: Callable[[float, float, OracleConfig], Any],
|
|
36
|
+
render_fn: Callable[[Any, str], None],
|
|
37
|
+
append_annotations_fn: Callable[[OracleConfig, list[dict[str, Any]]], None],
|
|
38
|
+
):
|
|
39
|
+
if w is None:
|
|
40
|
+
raise RuntimeError(
|
|
41
|
+
"ipywidgets not installed. Install extras: pip install -e '.[notebook]'"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
self.cfg = cfg
|
|
45
|
+
self.load_candidates = load_candidates_fn
|
|
46
|
+
self.select_candidates = select_candidates_fn
|
|
47
|
+
self.fetch_cutouts = fetch_cutouts_fn
|
|
48
|
+
self.render = render_fn
|
|
49
|
+
self.append_annotations = append_annotations_fn
|
|
50
|
+
|
|
51
|
+
self.batch = pd.DataFrame()
|
|
52
|
+
self.idx = 0
|
|
53
|
+
|
|
54
|
+
self.title = w.HTML("")
|
|
55
|
+
self.comment = w.Textarea(
|
|
56
|
+
description="Commentaire", layout=w.Layout(width="100%", height="80px")
|
|
57
|
+
)
|
|
58
|
+
self.next_btn = w.Button(description="Next")
|
|
59
|
+
self.reload_btn = w.Button(description="Reload candidates", button_style="info")
|
|
60
|
+
self.out = w.Output()
|
|
61
|
+
|
|
62
|
+
self.label_buttons = []
|
|
63
|
+
for txt, lab in LABELS:
|
|
64
|
+
b = w.Button(description=txt)
|
|
65
|
+
b.on_click(lambda _, lab=lab: self.on_label(lab))
|
|
66
|
+
self.label_buttons.append(b)
|
|
67
|
+
|
|
68
|
+
self.next_btn.on_click(lambda _: self.on_next())
|
|
69
|
+
self.reload_btn.on_click(lambda _: self.on_reload())
|
|
70
|
+
|
|
71
|
+
controls = w.HBox([*self.label_buttons, self.next_btn, self.reload_btn])
|
|
72
|
+
display(w.VBox([self.title, self.comment, controls, self.out]))
|
|
73
|
+
|
|
74
|
+
self.on_reload()
|
|
75
|
+
|
|
76
|
+
def on_reload(self):
|
|
77
|
+
df = self.load_candidates(self.cfg)
|
|
78
|
+
if df.empty:
|
|
79
|
+
self.batch = pd.DataFrame()
|
|
80
|
+
self.idx = 0
|
|
81
|
+
with self.out:
|
|
82
|
+
clear_output()
|
|
83
|
+
print("Aucun candidat.")
|
|
84
|
+
return
|
|
85
|
+
self.batch = self.select_candidates(df, self.cfg)
|
|
86
|
+
self.idx = 0
|
|
87
|
+
self.show_current()
|
|
88
|
+
|
|
89
|
+
def show_current(self):
|
|
90
|
+
if self.batch.empty or self.idx >= len(self.batch):
|
|
91
|
+
with self.out:
|
|
92
|
+
clear_output()
|
|
93
|
+
print("Batch terminé.")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
row = self.batch.iloc[self.idx]
|
|
97
|
+
title = (
|
|
98
|
+
f"ID: {row['id']} | score: {float(row['anomaly_score']):.4f} | "
|
|
99
|
+
f"RA: {float(row['ra']):.5f} Dec: {float(row['dec']):.5f}"
|
|
100
|
+
)
|
|
101
|
+
self.title.value = f"<b>{title}</b>"
|
|
102
|
+
|
|
103
|
+
cutouts = self.fetch_cutouts(float(row["ra"]), float(row["dec"]), self.cfg)
|
|
104
|
+
with self.out:
|
|
105
|
+
clear_output()
|
|
106
|
+
self.render(cutouts, title)
|
|
107
|
+
|
|
108
|
+
def on_label(self, label: str):
|
|
109
|
+
if label == "skip":
|
|
110
|
+
self.on_next()
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
row = self.batch.iloc[self.idx]
|
|
114
|
+
entry: dict[str, Any] = {
|
|
115
|
+
"id": row["id"],
|
|
116
|
+
"ra": float(row["ra"]),
|
|
117
|
+
"dec": float(row["dec"]),
|
|
118
|
+
"anomaly_score": float(row["anomaly_score"]),
|
|
119
|
+
"label": label,
|
|
120
|
+
"comment": self.comment.value.strip(),
|
|
121
|
+
"annotated_at": pd.Timestamp.utcnow().isoformat(),
|
|
122
|
+
}
|
|
123
|
+
if "embedding" in self.batch.columns and row.get("embedding", None) is not None:
|
|
124
|
+
entry["embedding"] = json.dumps(np.asarray(row["embedding"], dtype=float).tolist())
|
|
125
|
+
|
|
126
|
+
self.append_annotations(self.cfg, [entry])
|
|
127
|
+
self.comment.value = ""
|
|
128
|
+
self.on_next()
|
|
129
|
+
|
|
130
|
+
def on_next(self):
|
|
131
|
+
self.idx += 1
|
|
132
|
+
self.show_current()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
import numpy as np
|
|
7
|
+
from astropy.visualization import ImageNormalize, ZScaleInterval
|
|
8
|
+
|
|
9
|
+
from .config import OracleConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_cutouts_matplotlib(
|
|
13
|
+
cutouts: list[tuple[str, np.ndarray]],
|
|
14
|
+
title: str,
|
|
15
|
+
cfg: OracleConfig,
|
|
16
|
+
save_path: Path | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
if not cutouts:
|
|
19
|
+
print("No cutouts available.")
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
n = len(cutouts)
|
|
23
|
+
fig, axes = plt.subplots(1, n, figsize=(4 * n, 4))
|
|
24
|
+
if n == 1:
|
|
25
|
+
axes = [axes]
|
|
26
|
+
|
|
27
|
+
for ax, (survey, data) in zip(axes, cutouts, strict=False):
|
|
28
|
+
norm = ImageNormalize(data, interval=ZScaleInterval())
|
|
29
|
+
ax.imshow(data, cmap="gray", norm=norm, origin="lower")
|
|
30
|
+
ax.set_title(survey, fontsize=9)
|
|
31
|
+
ax.axis("off")
|
|
32
|
+
|
|
33
|
+
fig.suptitle(title, fontsize=10)
|
|
34
|
+
plt.tight_layout()
|
|
35
|
+
|
|
36
|
+
if save_path is not None:
|
|
37
|
+
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
fig.savefig(save_path, dpi=150)
|
|
39
|
+
|
|
40
|
+
if not cfg.no_gui:
|
|
41
|
+
plt.show(block=False)
|
|
42
|
+
plt.pause(0.25)
|
|
43
|
+
|
|
44
|
+
plt.close(fig)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import plotly.express as px
|
|
7
|
+
except Exception: # pragma: no cover
|
|
8
|
+
px = None # type: ignore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_cutouts_plotly(cutouts: list[tuple[str, np.ndarray]], title: str) -> None:
|
|
12
|
+
if px is None:
|
|
13
|
+
raise RuntimeError("Plotly not installed. Install extras: pip install -e '.[plotly]'")
|
|
14
|
+
for survey, data in cutouts:
|
|
15
|
+
arr = np.asarray(data, dtype=float)
|
|
16
|
+
fig = px.imshow(arr, origin="lower", aspect="equal", title=f"{title} | {survey}")
|
|
17
|
+
fig.update_layout(margin=dict(l=10, r=10, t=40, b=10))
|
|
18
|
+
fig.update_xaxes(showticklabels=False)
|
|
19
|
+
fig.update_yaxes(showticklabels=False)
|
|
20
|
+
fig.show()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from watchdog.events import FileSystemEventHandler
|
|
9
|
+
from watchdog.observers import Observer
|
|
10
|
+
except Exception: # pragma: no cover
|
|
11
|
+
Observer = None # type: ignore
|
|
12
|
+
FileSystemEventHandler = object # type: ignore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CandidatesHandler(FileSystemEventHandler):
|
|
16
|
+
def __init__(self, candidates_path: Path, on_change: Callable[[], None]):
|
|
17
|
+
self.candidates_path = candidates_path.resolve()
|
|
18
|
+
self.on_change = on_change
|
|
19
|
+
|
|
20
|
+
def on_modified(self, event):
|
|
21
|
+
if getattr(event, "is_directory", False):
|
|
22
|
+
return
|
|
23
|
+
src = Path(getattr(event, "src_path", ""))
|
|
24
|
+
if src and src.resolve() == self.candidates_path:
|
|
25
|
+
self.on_change()
|
|
26
|
+
|
|
27
|
+
def on_created(self, event):
|
|
28
|
+
self.on_modified(event)
|
|
29
|
+
|
|
30
|
+
def on_moved(self, event):
|
|
31
|
+
dst = Path(getattr(event, "dest_path", ""))
|
|
32
|
+
if dst and dst.resolve() == self.candidates_path:
|
|
33
|
+
self.on_change()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def watch_candidates(candidates_path: Path, on_change: Callable[[], None]) -> None:
|
|
37
|
+
if Observer is None:
|
|
38
|
+
raise RuntimeError("watchdog not installed. Install extras: pip install -e '.[watch]'")
|
|
39
|
+
|
|
40
|
+
handler = CandidatesHandler(candidates_path, on_change)
|
|
41
|
+
obs = Observer()
|
|
42
|
+
obs.schedule(handler, str(candidates_path.parent), recursive=False)
|
|
43
|
+
obs.start()
|
|
44
|
+
try:
|
|
45
|
+
while True:
|
|
46
|
+
time.sleep(0.5)
|
|
47
|
+
except KeyboardInterrupt:
|
|
48
|
+
pass
|
|
49
|
+
finally:
|
|
50
|
+
obs.stop()
|
|
51
|
+
obs.join()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: astrooracle
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AstroOracle: active learning oracle for astronomical anomaly triage (SkyView cutouts + annotations + retrain hooks).
|
|
5
|
+
Author: AstroOracle Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/<OWNER>/AstroOracle
|
|
8
|
+
Project-URL: Issues, https://github.com/<OWNER>/AstroOracle/issues
|
|
9
|
+
Keywords: astronomy,anomaly-detection,active-learning,skyview,astropy
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy>=1.23
|
|
23
|
+
Requires-Dist: pandas>=2.0
|
|
24
|
+
Requires-Dist: matplotlib>=3.7
|
|
25
|
+
Requires-Dist: astropy>=5.3
|
|
26
|
+
Requires-Dist: astroquery>=0.4.6
|
|
27
|
+
Requires-Dist: scikit-learn>=1.3
|
|
28
|
+
Requires-Dist: pyarrow>=12.0
|
|
29
|
+
Requires-Dist: pillow>=10.0
|
|
30
|
+
Provides-Extra: notebook
|
|
31
|
+
Requires-Dist: ipywidgets>=8.1; extra == "notebook"
|
|
32
|
+
Requires-Dist: jupyterlab>=4.0; extra == "notebook"
|
|
33
|
+
Requires-Dist: plotly>=5.18; extra == "notebook"
|
|
34
|
+
Provides-Extra: plotly
|
|
35
|
+
Requires-Dist: plotly>=5.18; extra == "plotly"
|
|
36
|
+
Provides-Extra: watch
|
|
37
|
+
Requires-Dist: watchdog>=3.0; extra == "watch"
|
|
38
|
+
Provides-Extra: dev
|
|
39
|
+
Requires-Dist: pytest<10,>=8; extra == "dev"
|
|
40
|
+
Requires-Dist: pytest-cov<8,>=5; extra == "dev"
|
|
41
|
+
Requires-Dist: coverage[toml]<8,>=7; extra == "dev"
|
|
42
|
+
Requires-Dist: ruff<1.0,>=0.6; extra == "dev"
|
|
43
|
+
Requires-Dist: black<27,>=24; extra == "dev"
|
|
44
|
+
Requires-Dist: pre-commit<5,>=3.7; extra == "dev"
|
|
45
|
+
Requires-Dist: mypy<2,>=1.10; extra == "dev"
|
|
46
|
+
Requires-Dist: types-requests>=2.32.0.20240914; extra == "dev"
|
|
47
|
+
Requires-Dist: build<2,>=1.2; extra == "dev"
|
|
48
|
+
Requires-Dist: twine<7,>=5; extra == "dev"
|
|
49
|
+
Requires-Dist: nox>=2024.4.15; extra == "dev"
|
|
50
|
+
Dynamic: license-file
|
|
51
|
+
|
|
52
|
+
# AstroOracle
|
|
53
|
+
|
|
54
|
+
AstroOracle is an active learning “oracle” for astronomical anomaly triage.
|
|
55
|
+
|
|
56
|
+
- Reads candidate detections from a Parquet file.
|
|
57
|
+
- Ranks them by uncertainty + optional embedding diversity.
|
|
58
|
+
- Pulls multi-survey cutouts from SkyView.
|
|
59
|
+
- Annotates via CLI (matplotlib) or Jupyter (ipywidgets + Plotly).
|
|
60
|
+
- Logs events in append-only JSONL.
|
|
61
|
+
- Optionally triggers retraining.
|
|
62
|
+
|
|
63
|
+
## Install (editable)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
python -m venv .venv
|
|
67
|
+
source .venv/bin/activate
|
|
68
|
+
pip install -U pip
|
|
69
|
+
pip install -e ".[plotly,watch,notebook]"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Quickstart (CLI)
|
|
73
|
+
|
|
74
|
+
Generate sample candidates:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
python examples/sample_candidates.py
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Run oracle:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
astrooracle run --candidates candidates.parquet --interval 300 --n-query 6
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Headless + PNG export:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
astrooracle run --no-gui --save-cutouts cutouts/ --candidates candidates.parquet
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Jupyter UI
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
jupyter lab
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Open `examples/oracle_notebook.ipynb`.
|
|
99
|
+
|
|
100
|
+
## Watch mode (watchdog)
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
astrooracle watch --candidates candidates.parquet
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Batch HTML export
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
astrooracle batch-html --candidates candidates.parquet --out-dir batch_out/ --n-query 60
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Open `batch_out/index.html` and export annotations from the page.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/astrooracle/__init__.py
|
|
5
|
+
src/astrooracle/annotations.py
|
|
6
|
+
src/astrooracle/batch_html.py
|
|
7
|
+
src/astrooracle/cli.py
|
|
8
|
+
src/astrooracle/config.py
|
|
9
|
+
src/astrooracle/core.py
|
|
10
|
+
src/astrooracle/logging_utils.py
|
|
11
|
+
src/astrooracle/py.typed
|
|
12
|
+
src/astrooracle/ui_notebook.py
|
|
13
|
+
src/astrooracle/viz_matplotlib.py
|
|
14
|
+
src/astrooracle/viz_plotly.py
|
|
15
|
+
src/astrooracle/watch.py
|
|
16
|
+
src/astrooracle.egg-info/PKG-INFO
|
|
17
|
+
src/astrooracle.egg-info/SOURCES.txt
|
|
18
|
+
src/astrooracle.egg-info/dependency_links.txt
|
|
19
|
+
src/astrooracle.egg-info/entry_points.txt
|
|
20
|
+
src/astrooracle.egg-info/requires.txt
|
|
21
|
+
src/astrooracle.egg-info/top_level.txt
|
|
22
|
+
tests/test_select.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
numpy>=1.23
|
|
2
|
+
pandas>=2.0
|
|
3
|
+
matplotlib>=3.7
|
|
4
|
+
astropy>=5.3
|
|
5
|
+
astroquery>=0.4.6
|
|
6
|
+
scikit-learn>=1.3
|
|
7
|
+
pyarrow>=12.0
|
|
8
|
+
pillow>=10.0
|
|
9
|
+
|
|
10
|
+
[dev]
|
|
11
|
+
pytest<10,>=8
|
|
12
|
+
pytest-cov<8,>=5
|
|
13
|
+
coverage[toml]<8,>=7
|
|
14
|
+
ruff<1.0,>=0.6
|
|
15
|
+
black<27,>=24
|
|
16
|
+
pre-commit<5,>=3.7
|
|
17
|
+
mypy<2,>=1.10
|
|
18
|
+
types-requests>=2.32.0.20240914
|
|
19
|
+
build<2,>=1.2
|
|
20
|
+
twine<7,>=5
|
|
21
|
+
nox>=2024.4.15
|
|
22
|
+
|
|
23
|
+
[notebook]
|
|
24
|
+
ipywidgets>=8.1
|
|
25
|
+
jupyterlab>=4.0
|
|
26
|
+
plotly>=5.18
|
|
27
|
+
|
|
28
|
+
[plotly]
|
|
29
|
+
plotly>=5.18
|
|
30
|
+
|
|
31
|
+
[watch]
|
|
32
|
+
watchdog>=3.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
astrooracle
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
from astrooracle.config import OracleConfig
|
|
5
|
+
from astrooracle.core import select_candidates
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_select_candidates_basic():
|
|
9
|
+
cfg = OracleConfig.default()
|
|
10
|
+
cfg = OracleConfig(**{**cfg.__dict__, "n_query": 5})
|
|
11
|
+
df = pd.DataFrame(
|
|
12
|
+
{
|
|
13
|
+
"id": [f"a{i}" for i in range(10)],
|
|
14
|
+
"ra": np.linspace(0, 1, 10),
|
|
15
|
+
"dec": np.linspace(0, 1, 10),
|
|
16
|
+
"anomaly_score": np.linspace(-2, 2, 10),
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
top = select_candidates(df, cfg)
|
|
20
|
+
assert len(top) == 5
|
|
21
|
+
assert "query_score" in top.columns
|