seedbuddy 0.2.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.
- seedbuddy-0.2.0/.gitignore +23 -0
- seedbuddy-0.2.0/PKG-INFO +116 -0
- seedbuddy-0.2.0/README.md +94 -0
- seedbuddy-0.2.0/pyproject.toml +37 -0
- seedbuddy-0.2.0/src/seedbuddy/__init__.py +1 -0
- seedbuddy-0.2.0/src/seedbuddy/__main__.py +3 -0
- seedbuddy-0.2.0/src/seedbuddy/cli.py +102 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/__init__.py +1 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/dish-recipe.json +12 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/dish.py +58 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/features.py +57 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/fingerprint.py +74 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/photographs.py +27 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/record.py +177 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/review.css +420 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/review.js +382 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/review.py +260 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/review_page.html +54 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/sharpness.py +117 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/sorting.py +228 -0
- seedbuddy-0.2.0/src/seedbuddy/photos/suitability.py +178 -0
- seedbuddy-0.2.0/tests/test_photo_review.py +213 -0
- seedbuddy-0.2.0/tests/test_photo_sorting.py +216 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
!web/.env.example
|
|
6
|
+
.cursor/
|
|
7
|
+
.claude/settings.local.json
|
|
8
|
+
.codex/
|
|
9
|
+
.mcp.json
|
|
10
|
+
.agents/
|
|
11
|
+
.lody/
|
|
12
|
+
skills-lock.json
|
|
13
|
+
.venv/
|
|
14
|
+
__pycache__/
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.ruff_cache/
|
|
17
|
+
*.py[cod]
|
|
18
|
+
/build/
|
|
19
|
+
/dist/
|
|
20
|
+
*.egg-info/
|
|
21
|
+
/output/
|
|
22
|
+
/data/
|
|
23
|
+
/tests/fixtures/images/
|
seedbuddy-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: seedbuddy
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Prepare culture-dish photographs for a SeedBuddy workbench
|
|
5
|
+
Project-URL: Repository, https://github.com/i-richardwang/SeedBuddy
|
|
6
|
+
Project-URL: Issues, https://github.com/i-richardwang/SeedBuddy/issues
|
|
7
|
+
Author: Richard Wang
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: culture-dish,focus,photography,plant-tissue-culture
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: numpy>=2.0
|
|
20
|
+
Requires-Dist: opencv-python-headless>=5.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# seedbuddy
|
|
24
|
+
|
|
25
|
+
Prepare culture-dish photographs for a [SeedBuddy](https://github.com/i-richardwang/SeedBuddy) workbench.
|
|
26
|
+
|
|
27
|
+
A dish is often photographed several times in a row. `seedbuddy photos sort` sorts a folder of photographs into dishes, puts the photograph whose seeds are sharpest first for each dish, and says whether the seeds in each photograph can be counted. `seedbuddy photos review` then serves a page on which a person confirms, dish by dish, which photograph to use.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uvx seedbuddy photos sort ~/plates -o ~/plates/plates.json
|
|
31
|
+
uvx seedbuddy photos review ~/plates/plates.json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`python -m seedbuddy` is the module entry point and accepts the same commands.
|
|
35
|
+
|
|
36
|
+
`sort` reads the folder's JPEG, PNG and TIFF files, prints JSON, writes the same JSON to the file given with `-o`, and leaves every photograph where it is:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"dishes": [
|
|
41
|
+
{
|
|
42
|
+
"shots": [
|
|
43
|
+
{
|
|
44
|
+
"path": "/home/lab/plates/plate-02.jpg",
|
|
45
|
+
"sharpness": 0.0,
|
|
46
|
+
"suitable": true,
|
|
47
|
+
"reason": null,
|
|
48
|
+
"alignment": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "/home/lab/plates/plate-01.jpg",
|
|
52
|
+
"sharpness": -0.4,
|
|
53
|
+
"suitable": false,
|
|
54
|
+
"reason": "seeds are out of focus",
|
|
55
|
+
"alignment": [[0.998, 0.052, -41.3], [-0.052, 0.998, 96.8]]
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"skipped": [
|
|
61
|
+
{ "path": "/home/lab/plates/notes.png", "reason": "no dish found" }
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Each entry of `dishes` is one dish, and `shots` lists its photographs sharpest first. The first shot is the suggestion.
|
|
67
|
+
|
|
68
|
+
- `sharpness` is the natural log of how sharply a photograph renders the seeds relative to the first one, so `-0.4` is about a third less fine detail. A dish photographed once has a `sharpness` of `null`.
|
|
69
|
+
- `suitable` says whether the seeds can be counted, and `reason` says why not.
|
|
70
|
+
- `alignment` is the camera motion that carries the photograph onto the first one of its dish, as the two rows of an affine matrix in pixels.
|
|
71
|
+
|
|
72
|
+
`skipped` lists files that could not be read or that show no dish.
|
|
73
|
+
|
|
74
|
+
## How photographs are matched
|
|
75
|
+
|
|
76
|
+
Photographs show the same dish when one movement of the camera carries the layout of the whole dish, seeds, substrate outline and lid labels alike, from one photograph onto the other. Agreement in one small region, such as a date label that every dish of a batch carries, is not enough. The decision depends on the share of features that agree and the area they cover, not on how many seeds a dish holds, and the handwriting on labels is never read.
|
|
77
|
+
|
|
78
|
+
## How focus is judged
|
|
79
|
+
|
|
80
|
+
Two photographs of one dish are compared at the same physical points within the central 60% of the dish's radius, where the seeds lie and the lid labels do not reach. At each point the finest detail is weighed against the seed's outline, which cancels exposure, so a photograph focused on the labels rather than the seeds ranks low.
|
|
81
|
+
|
|
82
|
+
## Whether a photograph can be counted
|
|
83
|
+
|
|
84
|
+
Each photograph is judged on its own, so the sharpest photograph of a dish can still be one that cannot be counted. The seeds lie on the filter paper, inside the rim, where the lid labels do not reach. They can be counted when their own mark is crisp against the paper. `suitable` is false when that mark has gone soft (`seeds are out of focus`), or when tiny gray droplets are spread across the paper and the seeds themselves are not a crisp colored mark (`paper covered by droplets`). A small seed that is still a separate mark can be counted, and so can a germinated seed.
|
|
85
|
+
|
|
86
|
+
## Confirming the suggestions
|
|
87
|
+
|
|
88
|
+
The first shot and its `suitable` are a suggestion, and the person who will count the dish has the last word. `review` prints the address of a page on the local computer. The page lists the dishes and shows every photograph of the one in view. Changing between the photographs of a dish keeps each seed in place, so focus can be compared at one spot.
|
|
89
|
+
|
|
90
|
+
For each dish, accept the suggestion, or bring up the photograph to use and mark its seeds as countable, not countable, or not sure. A decision moves on to the next dish that still has none. A note can be kept with the confirmation. Accept remaining suggestions confirms the suggestion for every dish that still has none, and leaves a correction that is already there as it is. Worth a look shows the dishes whose suggested photograph cannot be counted, or that have another photograph about as sharp.
|
|
91
|
+
|
|
92
|
+
The confirmation is stored in the same file, beside the dish's shots:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"shots": [...],
|
|
97
|
+
"decision": {
|
|
98
|
+
"chosen": "/home/lab/plates/plate-01.jpg",
|
|
99
|
+
"suitable": true,
|
|
100
|
+
"note": "thin, but each seed is separate"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`decision.chosen` is the photograph to use. `decision.suitable` is `true` when its seeds can be counted, `false` when they cannot, and `null` when the person was not sure. Sorting into the same file again is refused once any confirmation is present.
|
|
106
|
+
|
|
107
|
+
## Publishing
|
|
108
|
+
|
|
109
|
+
The package version is in `packages/seedbuddy/pyproject.toml`. From the repository root, build into an empty `dist/` and publish:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
uv build --package seedbuddy
|
|
113
|
+
uv publish
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The publishing credential is described in the repository environment example. The release tag matches the package version.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# seedbuddy
|
|
2
|
+
|
|
3
|
+
Prepare culture-dish photographs for a [SeedBuddy](https://github.com/i-richardwang/SeedBuddy) workbench.
|
|
4
|
+
|
|
5
|
+
A dish is often photographed several times in a row. `seedbuddy photos sort` sorts a folder of photographs into dishes, puts the photograph whose seeds are sharpest first for each dish, and says whether the seeds in each photograph can be counted. `seedbuddy photos review` then serves a page on which a person confirms, dish by dish, which photograph to use.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uvx seedbuddy photos sort ~/plates -o ~/plates/plates.json
|
|
9
|
+
uvx seedbuddy photos review ~/plates/plates.json
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`python -m seedbuddy` is the module entry point and accepts the same commands.
|
|
13
|
+
|
|
14
|
+
`sort` reads the folder's JPEG, PNG and TIFF files, prints JSON, writes the same JSON to the file given with `-o`, and leaves every photograph where it is:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"dishes": [
|
|
19
|
+
{
|
|
20
|
+
"shots": [
|
|
21
|
+
{
|
|
22
|
+
"path": "/home/lab/plates/plate-02.jpg",
|
|
23
|
+
"sharpness": 0.0,
|
|
24
|
+
"suitable": true,
|
|
25
|
+
"reason": null,
|
|
26
|
+
"alignment": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"path": "/home/lab/plates/plate-01.jpg",
|
|
30
|
+
"sharpness": -0.4,
|
|
31
|
+
"suitable": false,
|
|
32
|
+
"reason": "seeds are out of focus",
|
|
33
|
+
"alignment": [[0.998, 0.052, -41.3], [-0.052, 0.998, 96.8]]
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"skipped": [
|
|
39
|
+
{ "path": "/home/lab/plates/notes.png", "reason": "no dish found" }
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Each entry of `dishes` is one dish, and `shots` lists its photographs sharpest first. The first shot is the suggestion.
|
|
45
|
+
|
|
46
|
+
- `sharpness` is the natural log of how sharply a photograph renders the seeds relative to the first one, so `-0.4` is about a third less fine detail. A dish photographed once has a `sharpness` of `null`.
|
|
47
|
+
- `suitable` says whether the seeds can be counted, and `reason` says why not.
|
|
48
|
+
- `alignment` is the camera motion that carries the photograph onto the first one of its dish, as the two rows of an affine matrix in pixels.
|
|
49
|
+
|
|
50
|
+
`skipped` lists files that could not be read or that show no dish.
|
|
51
|
+
|
|
52
|
+
## How photographs are matched
|
|
53
|
+
|
|
54
|
+
Photographs show the same dish when one movement of the camera carries the layout of the whole dish, seeds, substrate outline and lid labels alike, from one photograph onto the other. Agreement in one small region, such as a date label that every dish of a batch carries, is not enough. The decision depends on the share of features that agree and the area they cover, not on how many seeds a dish holds, and the handwriting on labels is never read.
|
|
55
|
+
|
|
56
|
+
## How focus is judged
|
|
57
|
+
|
|
58
|
+
Two photographs of one dish are compared at the same physical points within the central 60% of the dish's radius, where the seeds lie and the lid labels do not reach. At each point the finest detail is weighed against the seed's outline, which cancels exposure, so a photograph focused on the labels rather than the seeds ranks low.
|
|
59
|
+
|
|
60
|
+
## Whether a photograph can be counted
|
|
61
|
+
|
|
62
|
+
Each photograph is judged on its own, so the sharpest photograph of a dish can still be one that cannot be counted. The seeds lie on the filter paper, inside the rim, where the lid labels do not reach. They can be counted when their own mark is crisp against the paper. `suitable` is false when that mark has gone soft (`seeds are out of focus`), or when tiny gray droplets are spread across the paper and the seeds themselves are not a crisp colored mark (`paper covered by droplets`). A small seed that is still a separate mark can be counted, and so can a germinated seed.
|
|
63
|
+
|
|
64
|
+
## Confirming the suggestions
|
|
65
|
+
|
|
66
|
+
The first shot and its `suitable` are a suggestion, and the person who will count the dish has the last word. `review` prints the address of a page on the local computer. The page lists the dishes and shows every photograph of the one in view. Changing between the photographs of a dish keeps each seed in place, so focus can be compared at one spot.
|
|
67
|
+
|
|
68
|
+
For each dish, accept the suggestion, or bring up the photograph to use and mark its seeds as countable, not countable, or not sure. A decision moves on to the next dish that still has none. A note can be kept with the confirmation. Accept remaining suggestions confirms the suggestion for every dish that still has none, and leaves a correction that is already there as it is. Worth a look shows the dishes whose suggested photograph cannot be counted, or that have another photograph about as sharp.
|
|
69
|
+
|
|
70
|
+
The confirmation is stored in the same file, beside the dish's shots:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"shots": [...],
|
|
75
|
+
"decision": {
|
|
76
|
+
"chosen": "/home/lab/plates/plate-01.jpg",
|
|
77
|
+
"suitable": true,
|
|
78
|
+
"note": "thin, but each seed is separate"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`decision.chosen` is the photograph to use. `decision.suitable` is `true` when its seeds can be counted, `false` when they cannot, and `null` when the person was not sure. Sorting into the same file again is refused once any confirmation is present.
|
|
84
|
+
|
|
85
|
+
## Publishing
|
|
86
|
+
|
|
87
|
+
The package version is in `packages/seedbuddy/pyproject.toml`. From the repository root, build into an empty `dist/` and publish:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
uv build --package seedbuddy
|
|
91
|
+
uv publish
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The publishing credential is described in the repository environment example. The release tag matches the package version.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "seedbuddy"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Prepare culture-dish photographs for a SeedBuddy workbench"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Richard Wang" }]
|
|
8
|
+
keywords = ["plant-tissue-culture", "culture-dish", "photography", "focus"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Environment :: Console",
|
|
12
|
+
"Intended Audience :: Science/Research",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Image Recognition",
|
|
18
|
+
]
|
|
19
|
+
requires-python = ">=3.11"
|
|
20
|
+
dependencies = [
|
|
21
|
+
"numpy>=2.0",
|
|
22
|
+
"opencv-python-headless>=5.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
seedbuddy = "seedbuddy.cli:main"
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Repository = "https://github.com/i-richardwang/SeedBuddy"
|
|
30
|
+
Issues = "https://github.com/i-richardwang/SeedBuddy/issues"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/seedbuddy"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Prepare culture-dish photographs for a SeedBuddy workbench."""
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from seedbuddy.photos.record import (
|
|
9
|
+
has_confirmations,
|
|
10
|
+
load_record,
|
|
11
|
+
record_of,
|
|
12
|
+
save_record,
|
|
13
|
+
)
|
|
14
|
+
from seedbuddy.photos.review import serve_review
|
|
15
|
+
from seedbuddy.photos.sorting import sort_photographs
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _sort(args: argparse.Namespace) -> int:
|
|
19
|
+
folder = Path(args.folder).expanduser().resolve()
|
|
20
|
+
if not folder.is_dir():
|
|
21
|
+
raise ValueError(f"{folder} is not a folder")
|
|
22
|
+
output = _unconfirmed(args.output)
|
|
23
|
+
record = record_of(sort_photographs(folder, progress=_progress))
|
|
24
|
+
if output is not None:
|
|
25
|
+
save_record(output, record)
|
|
26
|
+
json.dump(record, sys.stdout, indent=2, ensure_ascii=False)
|
|
27
|
+
sys.stdout.write("\n")
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _review(args: argparse.Namespace) -> int:
|
|
32
|
+
path = Path(args.file).expanduser()
|
|
33
|
+
if not path.is_file():
|
|
34
|
+
raise ValueError(f"{path} is not a file")
|
|
35
|
+
serve_review(path)
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _unconfirmed(output: str | None) -> Path | None:
|
|
40
|
+
"""Where the record may be written: a file that holds no confirmation."""
|
|
41
|
+
if output is None:
|
|
42
|
+
return None
|
|
43
|
+
path = Path(output).expanduser()
|
|
44
|
+
if path.is_file() and has_confirmations(load_record(path)):
|
|
45
|
+
raise ValueError(f"{path} already holds confirmations")
|
|
46
|
+
return path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _progress(line: str) -> None:
|
|
50
|
+
print(line, file=sys.stderr)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parser() -> argparse.ArgumentParser:
|
|
54
|
+
parser = argparse.ArgumentParser(
|
|
55
|
+
prog="seedbuddy",
|
|
56
|
+
description="Prepare culture-dish photographs for a SeedBuddy workbench.",
|
|
57
|
+
)
|
|
58
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
59
|
+
|
|
60
|
+
photos = commands.add_parser("photos", help="Work with a folder of photographs")
|
|
61
|
+
photo_commands = photos.add_subparsers(dest="photos_command", required=True)
|
|
62
|
+
sorting = photo_commands.add_parser(
|
|
63
|
+
"sort",
|
|
64
|
+
help="Sort the photographs into dishes and suggest one to use for each",
|
|
65
|
+
description=(
|
|
66
|
+
"Sort the photographs in FOLDER by the dish they show, the one whose "
|
|
67
|
+
"seeds are sharpest first, and say whether the seeds in each can be "
|
|
68
|
+
"told apart well enough to count. Prints JSON; files are left "
|
|
69
|
+
"untouched."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
sorting.add_argument("folder", metavar="FOLDER")
|
|
73
|
+
sorting.add_argument(
|
|
74
|
+
"-o",
|
|
75
|
+
"--output",
|
|
76
|
+
metavar="FILE",
|
|
77
|
+
help=(
|
|
78
|
+
"also write the JSON to FILE; refused when FILE already holds confirmations"
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
sorting.set_defaults(handler=_sort)
|
|
82
|
+
reviewing = photo_commands.add_parser(
|
|
83
|
+
"review",
|
|
84
|
+
help="Confirm which photograph of each dish to use",
|
|
85
|
+
description=(
|
|
86
|
+
"Serve a page to confirm or correct the suggestions in FILE. "
|
|
87
|
+
"Confirmations are written back into FILE. The photographs stay "
|
|
88
|
+
"where they are."
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
reviewing.add_argument("file", metavar="FILE")
|
|
92
|
+
reviewing.set_defaults(handler=_review)
|
|
93
|
+
return parser
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: list[str] | None = None) -> int:
|
|
97
|
+
args = _parser().parse_args(argv)
|
|
98
|
+
try:
|
|
99
|
+
return args.handler(args)
|
|
100
|
+
except (OSError, ValueError) as error:
|
|
101
|
+
print(f"error: {error}", file=sys.stderr)
|
|
102
|
+
return 2
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Sort a folder of dish photographs and confirm which of each dish to use."""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Locate the dish circle in a grayscale photograph.
|
|
2
|
+
|
|
3
|
+
The dish recipe is byte-identical to the workbench recipe.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from importlib.resources import files
|
|
12
|
+
|
|
13
|
+
import cv2
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
RECIPE = json.loads(files("seedbuddy.photos").joinpath("dish-recipe.json").read_text())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class DishCircle:
|
|
21
|
+
x: float
|
|
22
|
+
y: float
|
|
23
|
+
radius: float
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def locate_dish(gray: np.ndarray) -> DishCircle | None:
|
|
27
|
+
height, width = gray.shape
|
|
28
|
+
scale = min(1.0, RECIPE["thumbnailLongEdge"] / max(width, height))
|
|
29
|
+
size = (
|
|
30
|
+
max(1, math.floor(width * scale + 0.5)),
|
|
31
|
+
max(1, math.floor(height * scale + 0.5)),
|
|
32
|
+
)
|
|
33
|
+
small = cv2.resize(gray, size, interpolation=cv2.INTER_AREA)
|
|
34
|
+
small = cv2.GaussianBlur(small, (0, 0), RECIPE["sigma"])
|
|
35
|
+
side = min(small.shape)
|
|
36
|
+
circles = cv2.HoughCircles(
|
|
37
|
+
small,
|
|
38
|
+
cv2.HOUGH_GRADIENT,
|
|
39
|
+
dp=RECIPE["dp"],
|
|
40
|
+
minDist=side // 2,
|
|
41
|
+
param1=RECIPE["edgeThreshold"],
|
|
42
|
+
param2=RECIPE["centerThreshold"],
|
|
43
|
+
minRadius=int(side * RECIPE["minRadiusFraction"]),
|
|
44
|
+
maxRadius=int(side * RECIPE["maxRadiusFraction"]),
|
|
45
|
+
)
|
|
46
|
+
if circles is None:
|
|
47
|
+
return None
|
|
48
|
+
# The dish is the large circle near the middle of the frame.
|
|
49
|
+
centre = np.array([small.shape[1] / 2.0, small.shape[0] / 2.0])
|
|
50
|
+
x, y, radius = min(
|
|
51
|
+
circles[0],
|
|
52
|
+
key=lambda c: (
|
|
53
|
+
np.linalg.norm(c[:2] - centre) - RECIPE["radiusPreference"] * c[2]
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
return DishCircle(
|
|
57
|
+
x=float(x / scale), y=float(y / scale), radius=float(radius / scale)
|
|
58
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Distinctive points of a dish photograph and their counterparts in another."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from seedbuddy.photos.dish import DishCircle
|
|
9
|
+
|
|
10
|
+
# Features are found at a resolution where the dish has this radius, so every
|
|
11
|
+
# threshold expressed in dish radii holds for any camera.
|
|
12
|
+
WORKING_RADIUS = 700.0
|
|
13
|
+
# Removes fine periodic texture, such as a woven filter paper, whose repeats
|
|
14
|
+
# match anywhere; seeds are several times larger and survive.
|
|
15
|
+
TEXTURE_SIGMA = 1.0
|
|
16
|
+
CONTRAST_THRESHOLD = 0.02
|
|
17
|
+
RATIO_TEST = 0.75
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def features(
|
|
21
|
+
gray: np.ndarray, dish: DishCircle, radius: float, limit: int = 0
|
|
22
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
23
|
+
"""Points within ``radius`` dish radii of the centre, in full-resolution pixels.
|
|
24
|
+
|
|
25
|
+
``limit`` keeps only the strongest features; 0 keeps them all.
|
|
26
|
+
"""
|
|
27
|
+
scale = min(1.0, WORKING_RADIUS / dish.radius)
|
|
28
|
+
small = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
|
|
29
|
+
small = cv2.GaussianBlur(small, (0, 0), TEXTURE_SIGMA)
|
|
30
|
+
mask = np.zeros_like(small)
|
|
31
|
+
cv2.circle(
|
|
32
|
+
mask,
|
|
33
|
+
(round(dish.x * scale), round(dish.y * scale)),
|
|
34
|
+
round(dish.radius * scale * radius),
|
|
35
|
+
255,
|
|
36
|
+
-1,
|
|
37
|
+
)
|
|
38
|
+
sift = cv2.SIFT.create(nfeatures=limit, contrastThreshold=CONTRAST_THRESHOLD)
|
|
39
|
+
keypoints, descriptors = sift.detectAndCompute(small, mask)
|
|
40
|
+
points = np.array([keypoint.pt for keypoint in keypoints], np.float32)
|
|
41
|
+
if descriptors is None:
|
|
42
|
+
descriptors = np.empty((0, 128), np.float32)
|
|
43
|
+
return points.reshape(-1, 2) / scale, descriptors
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def matches(
|
|
47
|
+
descriptors_a: np.ndarray, descriptors_b: np.ndarray
|
|
48
|
+
) -> list[tuple[int, int]]:
|
|
49
|
+
"""Index pairs of features that resemble each other and nothing else."""
|
|
50
|
+
if len(descriptors_a) < 2 or len(descriptors_b) < 2:
|
|
51
|
+
return []
|
|
52
|
+
pairs = cv2.BFMatcher().knnMatch(descriptors_a, descriptors_b, k=2)
|
|
53
|
+
return [
|
|
54
|
+
(pair[0].queryIdx, pair[0].trainIdx)
|
|
55
|
+
for pair in pairs
|
|
56
|
+
if len(pair) == 2 and pair[0].distance < RATIO_TEST * pair[1].distance
|
|
57
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Recognize two photographs of one dish by the layout of everything in it.
|
|
2
|
+
|
|
3
|
+
Seeds, the substrate's outline and the lid's labels together form a layout no
|
|
4
|
+
other dish shares. Two photographs show the same dish when one rigid motion of
|
|
5
|
+
the camera carries that layout from one onto the other across the whole dish;
|
|
6
|
+
a patch that agrees alone, such as a date label every dish of a batch carries,
|
|
7
|
+
does not.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import cv2
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from seedbuddy.photos.dish import DishCircle
|
|
19
|
+
from seedbuddy.photos.features import features, matches
|
|
20
|
+
|
|
21
|
+
FEATURE_RADIUS = 0.95
|
|
22
|
+
MAX_FEATURES = 4000
|
|
23
|
+
# The lid sits above the seeds, so a change of camera height moves the two by
|
|
24
|
+
# slightly different amounts; the tolerance absorbs that parallax.
|
|
25
|
+
REPROJECTION_TOLERANCE = 0.014
|
|
26
|
+
MIN_INLIERS = 8
|
|
27
|
+
MIN_INLIER_SHARE = 0.5
|
|
28
|
+
MAX_SCALE_ERROR = 0.1
|
|
29
|
+
MIN_COVERAGE = 0.05
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Survey:
|
|
34
|
+
path: Path
|
|
35
|
+
dish: DishCircle
|
|
36
|
+
points: np.ndarray
|
|
37
|
+
"""Feature locations in full-resolution pixels."""
|
|
38
|
+
descriptors: np.ndarray
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def survey(path: Path, gray: np.ndarray, dish: DishCircle) -> Survey:
|
|
42
|
+
points, descriptors = features(gray, dish, FEATURE_RADIUS, MAX_FEATURES)
|
|
43
|
+
return Survey(path, dish, points, descriptors)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def dish_motion(a: Survey, b: Survey) -> np.ndarray | None:
|
|
47
|
+
"""The camera motion carrying a onto b, when they show the same dish."""
|
|
48
|
+
paired = matches(a.descriptors, b.descriptors)
|
|
49
|
+
if len(paired) < MIN_INLIERS:
|
|
50
|
+
return None
|
|
51
|
+
points_a = a.points[[i for i, _ in paired]]
|
|
52
|
+
points_b = b.points[[j for _, j in paired]]
|
|
53
|
+
motion, inliers = cv2.estimateAffinePartial2D(
|
|
54
|
+
points_a,
|
|
55
|
+
points_b,
|
|
56
|
+
method=cv2.RANSAC,
|
|
57
|
+
ransacReprojThreshold=REPROJECTION_TOLERANCE * b.dish.radius,
|
|
58
|
+
)
|
|
59
|
+
if motion is None or inliers is None:
|
|
60
|
+
return None
|
|
61
|
+
agreed = inliers.ravel().astype(bool)
|
|
62
|
+
count = int(agreed.sum())
|
|
63
|
+
if count < MIN_INLIERS or count / len(paired) < MIN_INLIER_SHARE:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
scale = float(np.hypot(motion[0, 0], motion[1, 0]))
|
|
67
|
+
expected = b.dish.radius / a.dish.radius
|
|
68
|
+
if scale <= 0 or abs(np.log(scale / expected)) > MAX_SCALE_ERROR:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
spread = cv2.contourArea(cv2.convexHull(points_b[agreed]))
|
|
72
|
+
if spread / (np.pi * b.dish.radius**2) < MIN_COVERAGE:
|
|
73
|
+
return None
|
|
74
|
+
return motion
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""The photograph files a command is asked to read."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
PHOTO_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
|
|
10
|
+
|
|
11
|
+
Progress = Callable[[str], None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Skipped:
|
|
16
|
+
path: Path
|
|
17
|
+
reason: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def photographs_in(folder: Path) -> list[Path]:
|
|
21
|
+
return sorted(
|
|
22
|
+
path
|
|
23
|
+
for path in folder.iterdir()
|
|
24
|
+
if path.is_file()
|
|
25
|
+
and not path.name.startswith(".")
|
|
26
|
+
and path.suffix.lower() in PHOTO_SUFFIXES
|
|
27
|
+
)
|