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.
@@ -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/
@@ -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,3 @@
1
+ from seedbuddy.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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,12 @@
1
+ {
2
+ "thumbnailLongEdge": 1200,
3
+ "sigma": 3,
4
+ "dp": 1.2,
5
+ "edgeThreshold": 80,
6
+ "centerThreshold": 45,
7
+ "minRadiusFraction": 0.32,
8
+ "maxRadiusFraction": 0.49,
9
+ "radiusPreference": 0.15,
10
+ "coverageMargin": 0.15,
11
+ "radiusTolerancePixels": 1
12
+ }
@@ -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
+ )