seedbuddy 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
seedbuddy/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Prepare culture-dish photographs for a SeedBuddy workbench."""
seedbuddy/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from seedbuddy.cli import main
2
+
3
+ raise SystemExit(main())
seedbuddy/cli.py ADDED
@@ -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
+ )
@@ -0,0 +1,177 @@
1
+ """The record of a sorted folder: each dish's photographs, and what a person confirmed.
2
+
3
+ Sorting suggests the first photograph of a dish and says whether its seeds can
4
+ be counted. A person's confirmation is kept beside that suggestion as the
5
+ dish's ``decision``, which names the photograph to use and whether it can be
6
+ counted.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ from seedbuddy.photos.photographs import PHOTO_SUFFIXES
17
+ from seedbuddy.photos.sorting import Sorted
18
+
19
+
20
+ def record_of(result: Sorted) -> dict:
21
+ return {
22
+ "dishes": [
23
+ {
24
+ "shots": [
25
+ {
26
+ "path": str(shot.path),
27
+ "sharpness": None
28
+ if shot.sharpness is None
29
+ else round(shot.sharpness, 3),
30
+ "suitable": shot.suitable,
31
+ "reason": shot.reason,
32
+ "alignment": [
33
+ [round(value, 5) for value in row] for row in shot.alignment
34
+ ],
35
+ }
36
+ for shot in dish.shots
37
+ ],
38
+ }
39
+ for dish in result.dishes
40
+ ],
41
+ "skipped": [
42
+ {"path": str(item.path), "reason": item.reason} for item in result.skipped
43
+ ],
44
+ }
45
+
46
+
47
+ def load_record(path: Path) -> dict:
48
+ try:
49
+ record = json.loads(path.read_text(encoding="utf-8"))
50
+ except json.JSONDecodeError as error:
51
+ raise ValueError(f"{path} is not a record of sorted photographs") from error
52
+ if not _is_record(record):
53
+ raise ValueError(f"{path} is not a record of sorted photographs")
54
+ return record
55
+
56
+
57
+ def save_record(path: Path, record: dict) -> None:
58
+ descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}-")
59
+ try:
60
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
61
+ handle.write(json.dumps(record, indent=2, ensure_ascii=False) + "\n")
62
+ handle.flush()
63
+ os.fsync(handle.fileno())
64
+ os.replace(temporary, path)
65
+ finally:
66
+ Path(temporary).unlink(missing_ok=True)
67
+
68
+
69
+ def has_confirmations(record: dict) -> bool:
70
+ return any(dish.get("decision") is not None for dish in record["dishes"])
71
+
72
+
73
+ def confirm(
74
+ record: dict, dish: int, shot: int, suitable: bool | None, note: str
75
+ ) -> None:
76
+ """Use one photograph of a dish, and say whether its seeds can be counted."""
77
+ entry = record["dishes"][dish]
78
+ entry["decision"] = _decision(entry["shots"][shot], suitable, note)
79
+
80
+
81
+ def clear_confirmation(record: dict, dish: int) -> None:
82
+ record["dishes"][dish].pop("decision", None)
83
+
84
+
85
+ def accept_remaining(record: dict) -> None:
86
+ """Confirm the suggestion for every dish that has no confirmation yet."""
87
+ for dish in record["dishes"]:
88
+ if dish.get("decision") is not None:
89
+ continue
90
+ suggested = dish["shots"][0]
91
+ dish["decision"] = _decision(suggested, suggested["suitable"], "")
92
+
93
+
94
+ def is_verdict(value: object) -> bool:
95
+ """Whether a value says the seeds can be counted, cannot, or left unsure."""
96
+ return value is True or value is False or value is None
97
+
98
+
99
+ def _decision(shot: dict, suitable: bool | None, note: str) -> dict:
100
+ return {"chosen": shot["path"], "suitable": suitable, "note": note}
101
+
102
+
103
+ def _is_record(record: object) -> bool:
104
+ if not isinstance(record, dict):
105
+ return False
106
+ dishes, skipped = record.get("dishes"), record.get("skipped")
107
+ return (
108
+ isinstance(dishes, list)
109
+ and all(_is_dish(dish) for dish in dishes)
110
+ and isinstance(skipped, list)
111
+ and all(_is_skipped(item) for item in skipped)
112
+ )
113
+
114
+
115
+ def _is_dish(dish: object) -> bool:
116
+ if not isinstance(dish, dict):
117
+ return False
118
+ shots = dish.get("shots")
119
+ if not isinstance(shots, list) or not shots:
120
+ return False
121
+ if not all(_is_shot(shot) for shot in shots):
122
+ return False
123
+ return _is_decision(dish.get("decision"), {shot["path"] for shot in shots})
124
+
125
+
126
+ def _is_shot(shot: object) -> bool:
127
+ if not isinstance(shot, dict):
128
+ return False
129
+ sharpness, reason = shot.get("sharpness"), shot.get("reason")
130
+ return (
131
+ _is_photograph(shot.get("path"))
132
+ and (sharpness is None or _is_number(sharpness))
133
+ and isinstance(shot.get("suitable"), bool)
134
+ and (reason is None or isinstance(reason, str))
135
+ and _is_alignment(shot.get("alignment"))
136
+ )
137
+
138
+
139
+ def _is_photograph(path: object) -> bool:
140
+ return isinstance(path, str) and Path(path).suffix.lower() in PHOTO_SUFFIXES
141
+
142
+
143
+ def _is_alignment(alignment: object) -> bool:
144
+ return (
145
+ isinstance(alignment, list)
146
+ and len(alignment) == 2
147
+ and all(
148
+ isinstance(row, list)
149
+ and len(row) == 3
150
+ and all(_is_number(value) for value in row)
151
+ for row in alignment
152
+ )
153
+ )
154
+
155
+
156
+ def _is_number(value: object) -> bool:
157
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
158
+
159
+
160
+ def _is_decision(decision: object, paths: set[str]) -> bool:
161
+ if decision is None:
162
+ return True
163
+ if not isinstance(decision, dict):
164
+ return False
165
+ return (
166
+ decision.get("chosen") in paths
167
+ and is_verdict(decision.get("suitable"))
168
+ and isinstance(decision.get("note"), str)
169
+ )
170
+
171
+
172
+ def _is_skipped(item: object) -> bool:
173
+ return (
174
+ isinstance(item, dict)
175
+ and isinstance(item.get("path"), str)
176
+ and isinstance(item.get("reason"), str)
177
+ )