File size: 3,106 Bytes
acd0294
 
 
 
 
 
 
960f984
acd0294
 
 
 
 
 
 
 
960f984
acd0294
 
 
 
960f984
acd0294
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
"""Run RootScope's CPU classification stage in a throwaway subprocess.

Why this file exists
--------------------
Unpickling the XGBoost classifier builds a Booster, and building a Booster
probes for CUDA devices. On ZeroGPU that registers device state the
snapshot/restore cycle cannot reproduce, so *every* subsequent ``@spaces.GPU``
task in that process aborts, including a bare
``torch.zeros(2048, 2048, device="cuda")``. Confirmed on the live Space: the
diagnostics' trivial-allocation step returned ``GPU task aborted`` while the
environment itself was healthy (torch 2.11.0+cu130, ``cuda.is_available True``).

Merely having the model resident is enough to break the GPU stages, even though
classification happens long after them. So the main process must never load a
classifier at all. This worker loads all three from disk, does the whole
iterative classify + post-process + overlay pass, writes its results, and
exits, taking the poisoned CUDA state with it. The parent is left holding
torch state only, the same shape as the Cellpose-SAM Space, which snapshots
cleanly.

The cost is reloading ~460 MB of classifiers per run instead of once at
startup: about 2 s against a 2-4 minute pipeline.

Invoked by ``app.py`` as::

    python classify_worker.py <job.pkl> <result.pkl>

Both paths live in the run's own temp directory. The result pickle always gets
written, success or failure, so the parent can tell a crash apart from a
process that died without saying why.
"""

import pickle
import sys
import traceback


def run(job):
    """Load the classifiers and run the CPU stage. Returns the per-cell table."""
    # Imported here, not at module scope, so that even this process does no
    # model work until it has a job to do.
    from rootscope import predict as rs

    models, scalers, feature_cols, le = rs.load_models(job["model_dir"])
    if not models:
        raise RuntimeError(f"No usable models found in {job['model_dir']}")

    df = rs.stage_classify(
        job["df_base"], job["masks"], job["img_rgb"],
        job["layer_lookup"], job["adjacency"],
        models, scalers, feature_cols, le,
        out_dir=job["out_dir"],
        stem=job["stem"],
        source_name=job["source_name"],
        um_per_px=job["um_per_px"],
        max_rounds=job["max_rounds"],
        label_cells=job["label_cells"],
    )
    return {"ok": True, "df": df, "classes": [str(c) for c in le.classes_],
            "models": list(models.keys())}


def main():
    job_path, result_path = sys.argv[1], sys.argv[2]
    try:
        with open(job_path, "rb") as f:
            job = pickle.load(f)
        result = run(job)
    except Exception:
        # Never let the parent guess. A traceback here is far more use than
        # "the subprocess exited 1".
        result = {"ok": False, "traceback": traceback.format_exc()}
        print("[classify-worker] FAILED\n" + result["traceback"], flush=True)

    with open(result_path, "wb") as f:
        pickle.dump(result, f, protocol=pickle.HIGHEST_PROTOCOL)

    sys.exit(0 if result.get("ok") else 1)


if __name__ == "__main__":
    main()